From 78bd77d64cbbfcf2f46e71b143284092b48c7ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Robles?= Date: Wed, 8 Mar 2023 23:41:02 +0100 Subject: [PATCH 01/10] add API documentation command & description to attributes --- src/Attributes/AppendsQueryParam.php | 2 +- src/Attributes/DocumentedEndpointSection.php | 14 + src/Attributes/FieldsQueryParam.php | 2 +- src/Attributes/FilterQueryParam.php | 61 +- src/Attributes/IncludeQueryParam.php | 2 +- src/Attributes/QueryParam.php | 2 +- src/Attributes/SearchFilterQueryParam.php | 3 +- src/Attributes/SearchQueryParam.php | 2 +- src/Attributes/SortQueryParam.php | 2 +- src/Console/ApiableDocgenCommand.php | 558 +++++++++++++++++++ src/Http/Concerns/AllowsFilters.php | 11 +- src/Http/QueryParamValueType.php | 18 + src/ServiceProvider.php | 3 + 13 files changed, 665 insertions(+), 15 deletions(-) create mode 100644 src/Attributes/DocumentedEndpointSection.php create mode 100644 src/Console/ApiableDocgenCommand.php create mode 100644 src/Http/QueryParamValueType.php diff --git a/src/Attributes/AppendsQueryParam.php b/src/Attributes/AppendsQueryParam.php index f3777b7..24df7fd 100644 --- a/src/Attributes/AppendsQueryParam.php +++ b/src/Attributes/AppendsQueryParam.php @@ -7,7 +7,7 @@ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final class AppendsQueryParam extends QueryParam { - public function __construct(public string $type, public array $attributes) + public function __construct(public string $type, public array $attributes, public string $description = '') { // } diff --git a/src/Attributes/DocumentedEndpointSection.php b/src/Attributes/DocumentedEndpointSection.php new file mode 100644 index 0000000..1428f8a --- /dev/null +++ b/src/Attributes/DocumentedEndpointSection.php @@ -0,0 +1,14 @@ +values instanceof QueryParamValueType) { + return $this->values; + } + + if (is_array($this->values)) { + return array_unique( + array_map( + fn ($value) => $this->assertDataType($value), + $this->values + ) + ); + } + + return $this->assertDataType($this->values); + } + + protected function assertDataType(mixed $value): QueryParamValueType + { + if (is_numeric($value)) { + return QueryParamValueType::Integer; + } + + if ($this->isTimestamp($value)) { + return QueryParamValueType::Timestamp; + } + + if (in_array($value, ['true', 'false'])) { + return QueryParamValueType::Boolean; + } + + if (Str::isJson($value)) { + return QueryParamValueType::Object; + } + + // TODO: Array like "param[0]=foo¶m[1]=bar"... + + return QueryParamValueType::String; + } + + protected function isTimestamp(mixed $value): bool + { + try { + Carbon::parse($value); + + return true; + } catch (\Exception $e) { + return false; + } + } } diff --git a/src/Attributes/IncludeQueryParam.php b/src/Attributes/IncludeQueryParam.php index 74e07e0..fa4f824 100644 --- a/src/Attributes/IncludeQueryParam.php +++ b/src/Attributes/IncludeQueryParam.php @@ -7,7 +7,7 @@ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final class IncludeQueryParam extends QueryParam { - public function __construct(public string|array $relationships) + public function __construct(public string|array $relationships, public string $description = '') { // } diff --git a/src/Attributes/QueryParam.php b/src/Attributes/QueryParam.php index 6c22164..a928903 100644 --- a/src/Attributes/QueryParam.php +++ b/src/Attributes/QueryParam.php @@ -2,7 +2,7 @@ namespace OpenSoutheners\LaravelApiable\Attributes; -class QueryParam +abstract class QueryParam { // } diff --git a/src/Attributes/SearchFilterQueryParam.php b/src/Attributes/SearchFilterQueryParam.php index c29ef76..2e1c61a 100644 --- a/src/Attributes/SearchFilterQueryParam.php +++ b/src/Attributes/SearchFilterQueryParam.php @@ -3,11 +3,12 @@ namespace OpenSoutheners\LaravelApiable\Attributes; use Attribute; +use OpenSoutheners\LaravelApiable\Http\QueryParamValueType; #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] final class SearchFilterQueryParam extends QueryParam { - public function __construct(public string $attribute, public $values = '*') + public function __construct(public string $attribute, public string|array|QueryParamValueType $values = '*', public string $description = '') { // } diff --git a/src/Attributes/SearchQueryParam.php b/src/Attributes/SearchQueryParam.php index e928dde..815edca 100644 --- a/src/Attributes/SearchQueryParam.php +++ b/src/Attributes/SearchQueryParam.php @@ -7,7 +7,7 @@ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] final class SearchQueryParam extends QueryParam { - public function __construct(public bool $allowSearch = true) + public function __construct(public bool $allowSearch = true, public string $description = '') { // } diff --git a/src/Attributes/SortQueryParam.php b/src/Attributes/SortQueryParam.php index 71e658e..d1383a2 100644 --- a/src/Attributes/SortQueryParam.php +++ b/src/Attributes/SortQueryParam.php @@ -8,7 +8,7 @@ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final class SortQueryParam extends QueryParam { - public function __construct(public string $attribute, public ?int $direction = AllowedSort::BOTH) + public function __construct(public string $attribute, public ?int $direction = AllowedSort::BOTH, public string $description = '') { // } diff --git a/src/Console/ApiableDocgenCommand.php b/src/Console/ApiableDocgenCommand.php new file mode 100644 index 0000000..0bb36b8 --- /dev/null +++ b/src/Console/ApiableDocgenCommand.php @@ -0,0 +1,558 @@ +router->getRoutes(); + + $exportFormat = $this->askForExportFormat(); + + $this->getEndpointsToDocument( + $this->filterRoutesToDocument($appRoutes) + ); + + // TODO: Auth event with Sanctum or Passport? + match ($exportFormat) { + 'postman' => $this->exportEndpointsToPostman(), + 'markdown' => $this->exportEndpointsToMarkdown(), + default => null + }; + + $this->info("Export successfully to {$exportFormat}"); + + return 0; + } + + public function exportEndpointsToMarkdown() + { + Collection::make($this->endpoints) + ->groupBy('resource') + ->each(function (Collection $item, string $resource) { + $markdownContent = ''; + $resourceFancyName = $this->resources[$resource]['title'] ?? Str::title($resource); + $markdownContent .= "\n# {$resourceFancyName}\n"; + $resourceDescription = $this->resources[$resource]['description'] ?? "{$resourceFancyName} resource endpoints documentation."; + $markdownContent .= "\n{$resourceDescription}\n"; + + // TODO: Extract model properties into React component + + $item->each(function (array $value) use (&$markdownContent) { + $markdownContent .= "\n---\n"; + $markdownContent .= "\n## {$value['name']} {{ tag: '{$value['method']}', label: '{$value['path']}' }}\n"; + + $queryParamsRequiredProperties = ''; + + if (! empty($value['requiredPayload'])) { + $queryParamsRequiredProperties .= "### Required attributes\n"; + $queryParamsRequiredProperties .= "\n"; + + foreach ($value['requiredPayload'] as $payloadParam => $payloadValueType) { + $queryParamsRequiredProperties .= "\n"; + + // TODO: Query parameter description... + + $queryParamsRequiredProperties .= "\n"; + } + + $queryParamsRequiredProperties .= "\n"; + } + + $queryParamsProperties = "\n"; + + foreach ($value['query'] as $queryParam => $queryParamValue) { + $queryParamsProperties .= "\n"; + + // TODO: Query parameter description... + // $queryParamsProperties .= 'Possible values: '.implode(', ', array_map(fn ($sortValue) => "`{$sortValue}`", array_values($value['query']['sorts'])))."\n"; + $queryParamsProperties .= "{$queryParamValue['description']}\n"; + + $queryParamsProperties .= "\n"; + } + + foreach ($value['payload'] as $payloadParam => $payloadValueType) { + $queryParamsProperties .= "\n"; + + // TODO: Query parameter description... + + $queryParamsProperties .= "\n"; + } + + $queryParamsProperties .= "\n"; + + $markdownContent .= << + + {$value['description']} + + {$queryParamsRequiredProperties} + + ### Optional attributes + {$queryParamsProperties} + + + + + ```bash {{ title: 'cURL' }} + curl -G {$value['fullPath']} \ + -H "Authorization: Bearer {token}" + ``` + + + + ```json {{ title: 'Response' }} + { + "data": [ + { + "id": "1", + "type": "{$value['name']}" + }, + // ... + ], + "meta": {}, + "links": {} + } + ``` + + + EOT; + }); + + Storage::disk('local')->put("exports/markdown/{$resource}.mdx", $markdownContent); + }); + } + + // TODO: Update with new array data structure from fetchRoutes + protected function exportEndpointsToPostman() + { + $postmanCollection = [ + 'info' => [ + 'name' => config('app.name'), + 'schema' => 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json', + ], + 'item' => [], + ]; + + Collection::make($this->endpoints) + ->groupBy('resource') + ->each(function (Collection $item, string $resource) use (&$postmanCollection) { + $postmanItem = [ + 'name' => $resource, + 'description' => $this->resources[$resource]['description'] ?? '', + 'item' => [], + ]; + + $item->each(function (array $value) use (&$postmanItem) { + $postmanItemValue = []; + + $postmanItemValue['name'] = $value['name']; + $postmanItemValue['request']['description'] = $value['description']; + + $host = '{{base_url}}'; + $path = preg_replace('/{(.+)}/m', '{{$1}}', $value['path']); + $postmanItemValue['request']['url']['raw'] = "{$host}{$path}"; + $postmanItemValue['request']['url']['host'] = $host; + $postmanItemValue['request']['url']['path'] = explode('/', $path); + $postmanItemValue['request']['method'] = $value['method']; + $postmanItemValue['request']['header'][] = [ + 'key' => 'Accept', + 'type' => 'text', + 'value' => 'application/json', + ]; + + foreach ($value['query'] as $paramKey => $paramDetails) { + $filterItem = []; + + $filterItem['key'] = $paramKey; + $filterItem['value'] = ''; + $values = implode(', ', (array) $paramDetails['values']); + $filterItem['description'] = $paramDetails['description'] ? "{$paramDetails['description']}. " : ''; + $filterItem['description'] .= "Allowed values: {$values}"; + $filterItem['disabled'] = true; + + $postmanItemValue['request']['url']['query'][] = $filterItem; + } + + if (! empty($value['requiredPayload']) || ! empty($value['payload'])) { + $postmanItemValue['request']['body'] = [ + 'mode' => 'urlencoded', + 'urlencoded' => [], + ]; + } + + foreach ($value['requiredPayload'] as $key => $type) { + $bodyItem = []; + + $bodyItem['key'] = $key; + $bodyItem['value'] = ''; + $bodyItem['description'] = "Required value of type {$type}"; + $bodyItem['disabled'] = false; + + $postmanItemValue['request']['body'][$postmanItemValue['request']['body']['mode']][] = $bodyItem; + } + + foreach ($value['payload'] as $paramKey => $paramDetails) { + $bodyItem = []; + + $bodyItem['key'] = $key; + $bodyItem['value'] = ''; + $bodyItem['description'] = "Optional value of type {$type}"; + $bodyItem['disabled'] = true; + + $postmanItemValue['request']['body'][$postmanItemValue['request']['body']['mode']][] = $bodyItem; + } + + // if ($value['query']['search'] ?? false) { + // $postmanItemValue['request']['url']['query'][] = [ + // 'key' => 'q', + // 'value' => '', + // 'description' => "Search {$postmanItemValue['name']} performing a fulltext search", + // 'disabled' => true, + // ]; + + // foreach ($value['query']['searchFilters'] as $attribute => $filterValues) { + // $searchFilterItem = []; + + // $searchFilterItem['key'] = "search[filter][{$attribute}]"; + // $searchFilterItem['value'] = ''; + // $searchFilterItem['description'] = 'Allowed values: '.implode(',', (array) $filterValues); + // $searchFilterItem['disabled'] = true; + + // $postmanItemValue['request']['url']['query'][] = $searchFilterItem; + // } + // } + + $postmanItem['item'][] = $postmanItemValue; + }); + + $postmanCollection['item'][] = $postmanItem; + }); + + Storage::disk('local')->put( + 'exports/documentation.postman_collection.json', + json_encode($postmanCollection) + ); + } + + protected function filterRoutesToDocument(RouteCollection $routes) + { + $filterOnlyBy = $this->option('only'); + + return array_filter(iterator_to_array($routes), function (Route $route) use ($filterOnlyBy) { + $hasBeenExcluded = Str::is(array_merge(explode(',', $this->option('exclude')), [ + '_debugbar/*', '_ignition/*', 'nova-api/*', 'nova/*', 'nova', + ]), $route->uri()); + + if ($hasBeenExcluded) { + return false; + } + + if ($filterOnlyBy) { + return Str::is($filterOnlyBy, $route->uri()); + } + + return true; + }); + } + + protected function getEndpointsToDocument(array $routes) + { + /** @var \Illuminate\Routing\Route $route */ + foreach ($routes as $route) { + $documentedRoute = []; + + $routeMethods = array_filter($route->methods(), fn ($value) => $value !== 'HEAD'); + + $routeAction = $route->getActionName(); + + if ($routeAction === 'Closure') { + continue; + } + + if (! Str::contains($routeAction, '@')) { + $routeAction = "{$routeAction}@__invoke"; + } + + [$controller, $method] = explode('@', $routeAction); + + if (! class_exists($controller) || ! method_exists($controller, $method)) { + continue; + } + + $classReflector = new ReflectionClass($controller); + $methodReflector = new ReflectionMethod($controller, $method); + + $hasJsonApiResponse = ! empty(array_filter( + $methodReflector->getParameters(), + fn (ReflectionParameter $reflectorParam) => $reflectorParam->getType()?->getName() === JsonApiResponse::class + )); + + $attributes = $hasJsonApiResponse + ? array_filter( + array_merge( + $classReflector->getAttributes(), + $methodReflector->getAttributes() + ), + function (ReflectionAttribute $attribute) { + return is_subclass_of($attribute->getName(), QueryParam::class); + } + ) : []; + + $routeName = $route->getName(); + + if ($routeName && str_contains($routeName, '.')) { + $endpointResource = Str::of($controller)->afterLast('\\')->before('Controller')->lower()->toString(); + $endpointResourcePlural = Str::plural($endpointResource); + + [$endpointAction, $endpointDescription] = match ($method) { + 'index' => ["List {$endpointResourcePlural}", "This endpoint allows you to retrieve a paginated list of all your {$endpointResourcePlural}."], + 'store' => ["Create new {$endpointResource}", "This endpoint allows you to add a new {$endpointResource}."], + 'show' => ["Get one {$endpointResource}", "This endpoint allows you to retrieve a {$endpointResource}."], + 'update' => ["Modify {$endpointResource}", "This endpoint allows you to perform an update on a {$endpointResource}."], + 'destroy' => ["Remove {$endpointResource}", "This endpoint allows you to delete a {$endpointResource}."], + default => [''] + }; + + $documentedRoute['resource'] = $endpointResourcePlural; + $documentedRoute['name'] = $endpointAction; + $documentedRoute['description'] = $endpointDescription; + } + + $documentedEndpointMethodAttribute = array_filter($methodReflector->getAttributes(DocumentedEndpointSection::class)); + + if (! empty($documentedEndpointMethodAttribute)) { + $documentedEndpointMethodAttribute = reset($documentedEndpointMethodAttribute); + $documentedEndpointMethodAttribute = $documentedEndpointMethodAttribute->newInstance(); + + $documentedRoute['name'] = $documentedEndpointMethodAttribute?->title ?? $documentedRoute['name'] ?? ''; + $documentedRoute['description'] = $documentedEndpointMethodAttribute?->description ?? $documentedRoute['description'] ?? ''; + } + + $this->resources[$documentedRoute['resource']] = $this->resources[$documentedRoute['resource']] ?? []; + + if (empty($this->resources[$documentedRoute['resource']])) { + $documentedEndpointClassAttribute = $classReflector->getAttributes(DocumentedEndpointSection::class); + $documentedEndpointClassAttribute = reset($documentedEndpointClassAttribute); + + if ($documentedEndpointClassAttribute) { + $documentedEndpointClassAttribute = $documentedEndpointClassAttribute->newInstance(); + $this->resources[$documentedRoute['resource']]['title'] = $documentedEndpointClassAttribute?->title; + $this->resources[$documentedRoute['resource']]['description'] = $documentedEndpointClassAttribute?->description; + } + } + + $documentedRoute['method'] = head($routeMethods); + $documentedRoute['path'] = Str::start($route->uri(), '/'); + $documentedRoute['fullPath'] = url($route->uri()); + + // + // Get payload params from receiving data routes with validation. + // + + $documentedRoute['requiredPayload'] = []; + $documentedRoute['payload'] = []; + + $formRequestParam = array_filter( + $methodReflector->getParameters(), + fn (ReflectionParameter $reflectorParam) => is_subclass_of($reflectorParam->getType()?->getName(), FormRequest::class) + ); + + if (! empty($formRequestParam)) { + $formRequestParam = reset($formRequestParam)->getType()?->getName(); + + // TODO: Too much guessing... maybe use rules in attributes? + $formRequestParamRules = (new $formRequestParam())->rules(); + + foreach ($formRequestParamRules as $attribute => $values) { + if (is_object($values)) { + // TODO: + $ruleValues = 'string'; + } else { + $ruleValues = is_string($values) ? explode('|', $values) : $values; + } + + $ruleValueType = array_filter( + $ruleValues, + fn ($rule) => in_array($rule, ['numeric', 'string', 'date', 'boolean', 'array']) + ); + + $isRequired = in_array('required', $ruleValues); + + $documentedRoute[$isRequired ? 'requiredPayload' : 'payload'][$attribute] = reset($ruleValueType); + } + } + + // + // Get query params from class or method attributes + // + + $documentedRoute['query'] = []; + + foreach ($attributes as $attribute) { + $attributeInstance = $attribute->newInstance(); + + $matchFn = match ($attribute->getName()) { + DocumentedEndpointSection::class => function () use (&$documentedRoute, $attributeInstance) { + $documentedRoute['name'] = $attributeInstance->title; + $documentedRoute['description'] = $attributeInstance->description; + }, + FilterQueryParam::class => function () use (&$documentedRoute, $attributeInstance) { + $filterTypes = array_map(fn ($operator) => match ($operator) { + AllowedFilter::EXACT => 'equal', + AllowedFilter::SCOPE => 'scope', + AllowedFilter::SIMILAR => 'like', + AllowedFilter::LOWER_THAN => 'lt', + AllowedFilter::GREATER_THAN => 'gt', + AllowedFilter::LOWER_OR_EQUAL_THAN => 'lte', + AllowedFilter::GREATER_OR_EQUAL_THAN => 'gte', + default => 'like', + }, (array) $attributeInstance->type); + $filterParamKey = "filter[{$attributeInstance->attribute}]"; + + foreach ($filterTypes as $type) { + $documentedRoute['query']["{$filterParamKey}[{$type}]"] = [ + 'values' => $attributeInstance->values, + 'description' => $attributeInstance->description, + ]; + } + + if (count($filterTypes) <= 1) { + $documentedRoute['query'][$filterParamKey] = [ + 'values' => $attributeInstance->values, + 'description' => $attributeInstance->description, + ]; + } + }, + FieldsQueryParam::class => function () use (&$documentedRoute, $attributeInstance) { + $documentedRoute['query']["fields[{$attributeInstance->type}]"] = [ + 'values' => $attributeInstance->fields, + 'description' => $attributeInstance->description, + ]; + }, + AppendsQueryParam::class => function () use (&$documentedRoute, $attributeInstance) { + $documentedRoute['query']["appends[{$attributeInstance->type}]"] = [ + 'values' => $attributeInstance->attributes, + 'description' => $attributeInstance->description, + ]; + }, + SortQueryParam::class => function () use (&$documentedRoute, $attributeInstance) { + if (! isset($documentedRoute['query']['sorts'])) { + $documentedRoute['query']['sorts'] = [ + 'values' => [], + 'description' => $attributeInstance->description, + ]; + } + + $documentedRoute['query']['sorts']['values'] = array_merge( + $documentedRoute['query']['sorts']['values'], + match ($attributeInstance->direction) { + AllowedSort::BOTH => [$attributeInstance->attribute, "-$attributeInstance->attribute"], + AllowedSort::DESCENDANT => ["-$attributeInstance->attribute"], + AllowedSort::ASCENDANT => [$attributeInstance->attribute], + }, + ); + }, + SearchFilterQueryParam::class => function () use (&$documentedRoute, $attributeInstance) { + $documentedRoute['query']['searchFilters'][$attributeInstance->attribute] = [ + 'values' => $attributeInstance->values, + 'description' => $attributeInstance->description, + ]; + }, + SearchQueryParam::class => function () use (&$documentedRoute, $attributeInstance) { + $documentedRoute['query']['search'] = [ + 'values' => true, + 'description' => $attributeInstance->description, + ]; + }, + IncludeQueryParam::class => function () use (&$documentedRoute, $attributeInstance) { + $documentedRoute['query']['includes'] = [ + 'values' => $attributeInstance->relationships, + 'description' => $attributeInstance->description, + ]; + }, + }; + + $matchFn(); + } + + $this->endpoints[] = $documentedRoute; + } + } + + protected function askForExportFormat() + { + $postman = $this->option('postman'); + $markdown = $this->option('markdown'); + + $formatOptions = compact('postman', 'markdown'); + + if (empty($formatOptions)) { + $option = $this->askWithCompletion('Export API documentation using format', array_keys($formatOptions)); + } + + return head(array_keys(array_filter($formatOptions))); + } +} diff --git a/src/Http/Concerns/AllowsFilters.php b/src/Http/Concerns/AllowsFilters.php index d33f831..6d68160 100644 --- a/src/Http/Concerns/AllowsFilters.php +++ b/src/Http/Concerns/AllowsFilters.php @@ -28,20 +28,19 @@ trait AllowsFilters */ public function filters(): array { - $queryStringArr = explode('&', $this->request->server('QUERY_STRING', '')); $filters = []; - foreach ($queryStringArr as $param) { + $this->queryParameters()->each(function ($param) use (&$filters) { $filterQueryParam = HeaderUtils::parseQuery($param); if (! is_array(head($filterQueryParam))) { - continue; + return; } $filterQueryParamAttribute = head(array_keys($filterQueryParam)); if ($filterQueryParamAttribute !== 'filter') { - continue; + return; } $filterQueryParam = head($filterQueryParam); @@ -51,11 +50,11 @@ public function filters(): array if (! isset($filters[$filterQueryParamAttribute])) { $filters[$filterQueryParamAttribute] = [$filterQueryParamValue]; - continue; + return; } $filters[$filterQueryParamAttribute][] = $filterQueryParamValue; - } + }); return $filters; } diff --git a/src/Http/QueryParamValueType.php b/src/Http/QueryParamValueType.php new file mode 100644 index 0000000..eddd272 --- /dev/null +++ b/src/Http/QueryParamValueType.php @@ -0,0 +1,18 @@ +registerMacros(); + + $this->commands([ApiableDocgenCommand::class]); } /** From 0523e045c8cd479a11770ce62f968a0efbc84e44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Robles?= Date: Fri, 10 Mar 2023 11:25:56 +0100 Subject: [PATCH 02/10] add forceAppend as attribute --- src/Attributes/ForceAppendAttribute.php | 14 ++++++++++++++ src/Http/Concerns/ResolvesFromRouteAction.php | 2 ++ 2 files changed, 16 insertions(+) create mode 100644 src/Attributes/ForceAppendAttribute.php diff --git a/src/Attributes/ForceAppendAttribute.php b/src/Attributes/ForceAppendAttribute.php new file mode 100644 index 0000000..c130d69 --- /dev/null +++ b/src/Attributes/ForceAppendAttribute.php @@ -0,0 +1,14 @@ +newInstance(); match (true) { + $attributeInstance instanceof ForceAppendAttribute => $this->forceAppend($attributeInstance->type, $attributeInstance->attributes), $attributeInstance instanceof SearchQueryParam => $this->allowSearch($attributeInstance->allowSearch), $attributeInstance instanceof SearchFilterQueryParam => $this->allowSearchFilter($attributeInstance->attribute, $attributeInstance->values), $attributeInstance instanceof FilterQueryParam => $this->allowFilter($attributeInstance->attribute, $attributeInstance->type, $attributeInstance->values), From 36b266a17e71bee0b586e796d4ade2eb489413c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Robles?= Date: Tue, 19 Sep 2023 14:19:41 +0200 Subject: [PATCH 03/10] fix `RequestQueryObject::queryParameters` method --- src/Http/Concerns/AllowsFilters.php | 30 +---------------------------- src/Http/Concerns/AllowsSorts.php | 2 +- src/Http/RequestQueryObject.php | 2 +- 3 files changed, 3 insertions(+), 31 deletions(-) diff --git a/src/Http/Concerns/AllowsFilters.php b/src/Http/Concerns/AllowsFilters.php index 6d68160..1fddb69 100644 --- a/src/Http/Concerns/AllowsFilters.php +++ b/src/Http/Concerns/AllowsFilters.php @@ -28,35 +28,7 @@ trait AllowsFilters */ public function filters(): array { - $filters = []; - - $this->queryParameters()->each(function ($param) use (&$filters) { - $filterQueryParam = HeaderUtils::parseQuery($param); - - if (! is_array(head($filterQueryParam))) { - return; - } - - $filterQueryParamAttribute = head(array_keys($filterQueryParam)); - - if ($filterQueryParamAttribute !== 'filter') { - return; - } - - $filterQueryParam = head($filterQueryParam); - $filterQueryParamAttribute = head(array_keys($filterQueryParam)); - $filterQueryParamValue = head(array_values($filterQueryParam)); - - if (! isset($filters[$filterQueryParamAttribute])) { - $filters[$filterQueryParamAttribute] = [$filterQueryParamValue]; - - return; - } - - $filters[$filterQueryParamAttribute][] = $filterQueryParamValue; - }); - - return $filters; + return $this->queryParameters()->get('filter', []); } /** diff --git a/src/Http/Concerns/AllowsSorts.php b/src/Http/Concerns/AllowsSorts.php index 0c514f6..bb38470 100644 --- a/src/Http/Concerns/AllowsSorts.php +++ b/src/Http/Concerns/AllowsSorts.php @@ -26,7 +26,7 @@ trait AllowsSorts */ public function sorts(): array { - $sortsSourceArr = array_filter(explode(',', $this->request->get('sort', ''))); + $sortsSourceArr = array_filter(explode(',', $this->queryParameters()->get('sort', ''))); $sortsArr = []; while ($sort = array_pop($sortsSourceArr)) { diff --git a/src/Http/RequestQueryObject.php b/src/Http/RequestQueryObject.php index 5fcdf66..3c46cbd 100644 --- a/src/Http/RequestQueryObject.php +++ b/src/Http/RequestQueryObject.php @@ -57,7 +57,7 @@ public function setQuery($query): self public function queryParameters(): Collection { if (! $this->queryParameters) { - $this->queryParameters = Collection::make( + $queryParameters = array_filter( array_map( [HeaderUtils::class, 'parseQuery'], explode('&', $this->request->server('QUERY_STRING', '')) From 604b94d97fb3fcdf5fb51e95d41d75572735aa08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Robles?= Date: Tue, 24 Oct 2023 18:28:48 +0200 Subject: [PATCH 04/10] fix use `parse_http_query` from laravel-helpers --- src/Http/ApplyFiltersToQuery.php | 6 +++--- src/Http/Concerns/AllowsSorts.php | 2 +- src/Http/RequestQueryObject.php | 13 +++++-------- tests/Http/JsonApiResponseTest.php | 1 + 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/Http/ApplyFiltersToQuery.php b/src/Http/ApplyFiltersToQuery.php index ef3d3e2..cb4149f 100644 --- a/src/Http/ApplyFiltersToQuery.php +++ b/src/Http/ApplyFiltersToQuery.php @@ -61,7 +61,7 @@ protected function applyFilters(Builder $query, array $filters): Builder $isScope => $this->applyFilterAsScope($query, $relationship, $scopeName, $operator, $value, $condition), default => null, }; - }, $query, $filterAttribute, $filterValues); + }, $query, $filterAttribute, (array) $filterValues); } return $query; @@ -71,9 +71,9 @@ protected function applyFilters(Builder $query, array $filters): Builder * Wrap query if relationship found applying its operator and conditional to the filtered attribute. * * @param callable(\Illuminate\Database\Eloquent\Builder, string|null, string, string, string, string): mixed $callback - * @param array|string $filterValues + * @param array $filterValues */ - protected function wrapIfRelatedQuery(callable $callback, Builder $query, string $filterAttribute, array|string $filterValues): void + protected function wrapIfRelatedQuery(callable $callback, Builder $query, string $filterAttribute, array $filterValues): void { $systemPreferredOperator = $this->allowed[$filterAttribute]['operator']; diff --git a/src/Http/Concerns/AllowsSorts.php b/src/Http/Concerns/AllowsSorts.php index bb38470..15c034b 100644 --- a/src/Http/Concerns/AllowsSorts.php +++ b/src/Http/Concerns/AllowsSorts.php @@ -26,7 +26,7 @@ trait AllowsSorts */ public function sorts(): array { - $sortsSourceArr = array_filter(explode(',', $this->queryParameters()->get('sort', ''))); + $sortsSourceArr = array_filter(explode(',', $this->queryParameters()->get('sort', [''])[0])); $sortsArr = []; while ($sort = array_pop($sortsSourceArr)) { diff --git a/src/Http/RequestQueryObject.php b/src/Http/RequestQueryObject.php index 3c46cbd..004c9f7 100644 --- a/src/Http/RequestQueryObject.php +++ b/src/Http/RequestQueryObject.php @@ -4,7 +4,8 @@ use Illuminate\Http\Request; use Illuminate\Support\Collection; -use Symfony\Component\HttpFoundation\HeaderUtils; + +use function OpenSoutheners\LaravelHelpers\Utils\parse_http_query; /** * @template T of \Illuminate\Database\Eloquent\Model @@ -57,13 +58,9 @@ public function setQuery($query): self public function queryParameters(): Collection { if (! $this->queryParameters) { - $queryParameters = array_filter( - array_map( - [HeaderUtils::class, 'parseQuery'], - explode('&', $this->request->server('QUERY_STRING', '')) - ) - )->groupBy(fn ($item, $key) => head(array_keys($item)), true) - ->map(fn (Collection $collection) => $collection->flatten(1)->all()); + $this->queryParameters = Collection::make( + parse_http_query($this->request->server('QUERY_STRING')) + ); } return $this->queryParameters; diff --git a/tests/Http/JsonApiResponseTest.php b/tests/Http/JsonApiResponseTest.php index 77b7bff..e10c361 100644 --- a/tests/Http/JsonApiResponseTest.php +++ b/tests/Http/JsonApiResponseTest.php @@ -29,6 +29,7 @@ public function setUp(): void parent::setUp(); $this->generateTestData(); + $this->withoutExceptionHandling(); } public function testFilteringByNonAllowedAttributeWillGetEverything() From 95168f098d3570dc1e752e4535460b33fa67d682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rube=CC=81n=20Robles?= Date: Wed, 17 Jan 2024 02:49:29 +0100 Subject: [PATCH 05/10] wip --- .vscode/settings.json | 5 +- config/apiable.php | 17 + src/Attributes/AppendsQueryParam.php | 10 + src/Attributes/DocumentedEndpointSection.php | 14 - src/Attributes/FieldsQueryParam.php | 10 + src/Attributes/FilterQueryParam.php | 2 + src/Attributes/ResourceResponse.php | 14 + src/Config.php | 0 src/Console/ApiableDocgenCommand.php | 465 +----------------- src/Contracts/ViewQueryable.php | 2 +- src/Contracts/ViewableBuilder.php | 2 +- .../Attributes/DocumentedEndpointSection.php | 14 + .../Attributes/DocumentedResource.php | 17 + src/Documentation/Endpoint.php | 153 ++++++ src/Documentation/Generator.php | 126 +++++ src/Documentation/QueryParam.php | 147 ++++++ src/Documentation/Resource.php | 86 ++++ src/Http/AllowedFilter.php | 2 +- src/Http/AllowedSort.php | 2 +- src/Http/ApplyFiltersToQuery.php | 2 +- src/Http/Concerns/AllowsAppends.php | 8 +- src/Http/Concerns/AllowsFields.php | 16 +- src/Http/Concerns/AllowsFilters.php | 3 +- src/Http/Concerns/AllowsIncludes.php | 18 +- src/Http/Concerns/AllowsSearch.php | 38 +- .../Concerns/IteratesResultsAfterQuery.php | 23 +- src/Http/Concerns/ResolvesFromRouteAction.php | 31 +- src/Http/DefaultFilter.php | 2 +- src/Http/DefaultSort.php | 2 +- src/Http/JsonApiResponse.php | 38 +- src/Http/QueryParamsValidator.php | 9 +- src/Http/RequestQueryObject.php | 17 +- src/JsonApiException.php | 6 +- src/Support/Apiable.php | 4 +- src/Testing/AssertableJsonApi.php | 2 +- src/Testing/TestResponseMacros.php | 2 +- src/VersionedConfig.php | 21 + stubs/markdown.mdx | 53 ++ 38 files changed, 790 insertions(+), 593 deletions(-) delete mode 100644 src/Attributes/DocumentedEndpointSection.php create mode 100644 src/Attributes/ResourceResponse.php create mode 100644 src/Config.php create mode 100644 src/Documentation/Attributes/DocumentedEndpointSection.php create mode 100644 src/Documentation/Attributes/DocumentedResource.php create mode 100644 src/Documentation/Endpoint.php create mode 100644 src/Documentation/Generator.php create mode 100644 src/Documentation/QueryParam.php create mode 100644 src/Documentation/Resource.php create mode 100644 src/VersionedConfig.php create mode 100644 stubs/markdown.mdx diff --git a/.vscode/settings.json b/.vscode/settings.json index e36a17c..b18c2b6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "php-cs-fixer.executablePath": "${workspaceFolder}/vendor/bin/php-cs-fixer" -} + "php-cs-fixer.executablePath": "${workspaceFolder}/vendor/bin/php-cs-fixer", + "php.version": "8.3.1" +} \ No newline at end of file diff --git a/config/apiable.php b/config/apiable.php index d5efd7e..94b84b3 100644 --- a/config/apiable.php +++ b/config/apiable.php @@ -54,4 +54,21 @@ 'include_ids_on_attributes' => false, ], + /** + * Default options for responses like: normalize relations names, include allowed filters and sorts, etc. + * + * @see https://docs.opensoutheners.com/laravel-apiable/guide/documentation.html + */ + 'documentation' => [ + + 'markdown' => [ + 'base_path' => 'storage/exports/markdown', + ], + + 'postman' => [ + 'base_path' => 'storage/exports', + ], + + ], + ]; diff --git a/src/Attributes/AppendsQueryParam.php b/src/Attributes/AppendsQueryParam.php index 24df7fd..f97388c 100644 --- a/src/Attributes/AppendsQueryParam.php +++ b/src/Attributes/AppendsQueryParam.php @@ -3,6 +3,7 @@ namespace OpenSoutheners\LaravelApiable\Attributes; use Attribute; +use OpenSoutheners\LaravelApiable\Support\Facades\Apiable; #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final class AppendsQueryParam extends QueryParam @@ -11,4 +12,13 @@ public function __construct(public string $type, public array $attributes, publi { // } + + public function getTypeAsResource(): string + { + if (! str_contains($this->type, '\\')) { + return $this->type; + } + + return Apiable::getResourceType($this->type); + } } diff --git a/src/Attributes/DocumentedEndpointSection.php b/src/Attributes/DocumentedEndpointSection.php deleted file mode 100644 index 1428f8a..0000000 --- a/src/Attributes/DocumentedEndpointSection.php +++ /dev/null @@ -1,14 +0,0 @@ -type, '\\')) { + return $this->type; + } + + return Apiable::getResourceType($this->type); + } } diff --git a/src/Attributes/FilterQueryParam.php b/src/Attributes/FilterQueryParam.php index b806ce4..e53b178 100644 --- a/src/Attributes/FilterQueryParam.php +++ b/src/Attributes/FilterQueryParam.php @@ -3,6 +3,8 @@ namespace OpenSoutheners\LaravelApiable\Attributes; use Attribute; +use Illuminate\Support\Carbon; +use Illuminate\Support\Str; use OpenSoutheners\LaravelApiable\Http\QueryParamValueType; #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] diff --git a/src/Attributes/ResourceResponse.php b/src/Attributes/ResourceResponse.php new file mode 100644 index 0000000..4b8893e --- /dev/null +++ b/src/Attributes/ResourceResponse.php @@ -0,0 +1,14 @@ + $resource + */ + public function __construct(public string $resource) + { + // + } +} diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..e69de29 diff --git a/src/Console/ApiableDocgenCommand.php b/src/Console/ApiableDocgenCommand.php index 0bb36b8..78eac4a 100644 --- a/src/Console/ApiableDocgenCommand.php +++ b/src/Console/ApiableDocgenCommand.php @@ -3,29 +3,12 @@ namespace OpenSoutheners\LaravelApiable\Console; use Illuminate\Console\Command; -use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Filesystem\Filesystem; use Illuminate\Routing\Route; use Illuminate\Routing\RouteCollection; use Illuminate\Routing\Router; -use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; -use OpenSoutheners\LaravelApiable\Attributes\AppendsQueryParam; -use OpenSoutheners\LaravelApiable\Attributes\DocumentedEndpointSection; -use OpenSoutheners\LaravelApiable\Attributes\FieldsQueryParam; -use OpenSoutheners\LaravelApiable\Attributes\FilterQueryParam; -use OpenSoutheners\LaravelApiable\Attributes\IncludeQueryParam; -use OpenSoutheners\LaravelApiable\Attributes\QueryParam; -use OpenSoutheners\LaravelApiable\Attributes\SearchFilterQueryParam; -use OpenSoutheners\LaravelApiable\Attributes\SearchQueryParam; -use OpenSoutheners\LaravelApiable\Attributes\SortQueryParam; -use OpenSoutheners\LaravelApiable\Http\AllowedFilter; -use OpenSoutheners\LaravelApiable\Http\AllowedSort; -use OpenSoutheners\LaravelApiable\Http\JsonApiResponse; -use ReflectionAttribute; -use ReflectionClass; -use ReflectionMethod; -use ReflectionParameter; +use OpenSoutheners\LaravelApiable\Documentation\Generator; class ApiableDocgenCommand extends Command { @@ -54,6 +37,9 @@ class ApiableDocgenCommand extends Command */ public function __construct( protected Router $router, + protected Generator $generator, + protected Filesystem $filesystem, + protected array $files = [], protected array $resources = [], protected array $endpoints = [] ) { @@ -67,13 +53,9 @@ public function __construct( */ public function handle() { - $appRoutes = $this->router->getRoutes(); - $exportFormat = $this->askForExportFormat(); - $this->getEndpointsToDocument( - $this->filterRoutesToDocument($appRoutes) - ); + $this->generator->generate(); // TODO: Auth event with Sanctum or Passport? match ($exportFormat) { @@ -82,6 +64,12 @@ public function handle() default => null }; + foreach ($this->files as $path => $content) { + $this->filesystem->ensureDirectoryExists(Str::beforeLast($path, '/')); + + $this->filesystem->put($path, $content); + } + $this->info("Export successfully to {$exportFormat}"); return 0; @@ -89,212 +77,13 @@ public function handle() public function exportEndpointsToMarkdown() { - Collection::make($this->endpoints) - ->groupBy('resource') - ->each(function (Collection $item, string $resource) { - $markdownContent = ''; - $resourceFancyName = $this->resources[$resource]['title'] ?? Str::title($resource); - $markdownContent .= "\n# {$resourceFancyName}\n"; - $resourceDescription = $this->resources[$resource]['description'] ?? "{$resourceFancyName} resource endpoints documentation."; - $markdownContent .= "\n{$resourceDescription}\n"; - - // TODO: Extract model properties into React component - - $item->each(function (array $value) use (&$markdownContent) { - $markdownContent .= "\n---\n"; - $markdownContent .= "\n## {$value['name']} {{ tag: '{$value['method']}', label: '{$value['path']}' }}\n"; - - $queryParamsRequiredProperties = ''; - - if (! empty($value['requiredPayload'])) { - $queryParamsRequiredProperties .= "### Required attributes\n"; - $queryParamsRequiredProperties .= "\n"; - - foreach ($value['requiredPayload'] as $payloadParam => $payloadValueType) { - $queryParamsRequiredProperties .= "\n"; - - // TODO: Query parameter description... - - $queryParamsRequiredProperties .= "\n"; - } - - $queryParamsRequiredProperties .= "\n"; - } - - $queryParamsProperties = "\n"; - - foreach ($value['query'] as $queryParam => $queryParamValue) { - $queryParamsProperties .= "\n"; - - // TODO: Query parameter description... - // $queryParamsProperties .= 'Possible values: '.implode(', ', array_map(fn ($sortValue) => "`{$sortValue}`", array_values($value['query']['sorts'])))."\n"; - $queryParamsProperties .= "{$queryParamValue['description']}\n"; - - $queryParamsProperties .= "\n"; - } - - foreach ($value['payload'] as $payloadParam => $payloadValueType) { - $queryParamsProperties .= "\n"; - - // TODO: Query parameter description... - - $queryParamsProperties .= "\n"; - } - - $queryParamsProperties .= "\n"; - - $markdownContent .= << - - {$value['description']} - - {$queryParamsRequiredProperties} - - ### Optional attributes - {$queryParamsProperties} - - - - - ```bash {{ title: 'cURL' }} - curl -G {$value['fullPath']} \ - -H "Authorization: Bearer {token}" - ``` - - - - ```json {{ title: 'Response' }} - { - "data": [ - { - "id": "1", - "type": "{$value['name']}" - }, - // ... - ], - "meta": {}, - "links": {} - } - ``` - - - EOT; - }); - - Storage::disk('local')->put("exports/markdown/{$resource}.mdx", $markdownContent); - }); + $this->files = array_merge($this->files, $this->generator->toMarkdown()); } // TODO: Update with new array data structure from fetchRoutes - protected function exportEndpointsToPostman() + protected function exportEndpointsToPostman(): void { - $postmanCollection = [ - 'info' => [ - 'name' => config('app.name'), - 'schema' => 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json', - ], - 'item' => [], - ]; - - Collection::make($this->endpoints) - ->groupBy('resource') - ->each(function (Collection $item, string $resource) use (&$postmanCollection) { - $postmanItem = [ - 'name' => $resource, - 'description' => $this->resources[$resource]['description'] ?? '', - 'item' => [], - ]; - - $item->each(function (array $value) use (&$postmanItem) { - $postmanItemValue = []; - - $postmanItemValue['name'] = $value['name']; - $postmanItemValue['request']['description'] = $value['description']; - - $host = '{{base_url}}'; - $path = preg_replace('/{(.+)}/m', '{{$1}}', $value['path']); - $postmanItemValue['request']['url']['raw'] = "{$host}{$path}"; - $postmanItemValue['request']['url']['host'] = $host; - $postmanItemValue['request']['url']['path'] = explode('/', $path); - $postmanItemValue['request']['method'] = $value['method']; - $postmanItemValue['request']['header'][] = [ - 'key' => 'Accept', - 'type' => 'text', - 'value' => 'application/json', - ]; - - foreach ($value['query'] as $paramKey => $paramDetails) { - $filterItem = []; - - $filterItem['key'] = $paramKey; - $filterItem['value'] = ''; - $values = implode(', ', (array) $paramDetails['values']); - $filterItem['description'] = $paramDetails['description'] ? "{$paramDetails['description']}. " : ''; - $filterItem['description'] .= "Allowed values: {$values}"; - $filterItem['disabled'] = true; - - $postmanItemValue['request']['url']['query'][] = $filterItem; - } - - if (! empty($value['requiredPayload']) || ! empty($value['payload'])) { - $postmanItemValue['request']['body'] = [ - 'mode' => 'urlencoded', - 'urlencoded' => [], - ]; - } - - foreach ($value['requiredPayload'] as $key => $type) { - $bodyItem = []; - - $bodyItem['key'] = $key; - $bodyItem['value'] = ''; - $bodyItem['description'] = "Required value of type {$type}"; - $bodyItem['disabled'] = false; - - $postmanItemValue['request']['body'][$postmanItemValue['request']['body']['mode']][] = $bodyItem; - } - - foreach ($value['payload'] as $paramKey => $paramDetails) { - $bodyItem = []; - - $bodyItem['key'] = $key; - $bodyItem['value'] = ''; - $bodyItem['description'] = "Optional value of type {$type}"; - $bodyItem['disabled'] = true; - - $postmanItemValue['request']['body'][$postmanItemValue['request']['body']['mode']][] = $bodyItem; - } - - // if ($value['query']['search'] ?? false) { - // $postmanItemValue['request']['url']['query'][] = [ - // 'key' => 'q', - // 'value' => '', - // 'description' => "Search {$postmanItemValue['name']} performing a fulltext search", - // 'disabled' => true, - // ]; - - // foreach ($value['query']['searchFilters'] as $attribute => $filterValues) { - // $searchFilterItem = []; - - // $searchFilterItem['key'] = "search[filter][{$attribute}]"; - // $searchFilterItem['value'] = ''; - // $searchFilterItem['description'] = 'Allowed values: '.implode(',', (array) $filterValues); - // $searchFilterItem['disabled'] = true; - - // $postmanItemValue['request']['url']['query'][] = $searchFilterItem; - // } - // } - - $postmanItem['item'][] = $postmanItemValue; - }); - - $postmanCollection['item'][] = $postmanItem; - }); - - Storage::disk('local')->put( - 'exports/documentation.postman_collection.json', - json_encode($postmanCollection) - ); + $this->files[config('apiable.documentation.postman.base_path').'/documentation.postman_collection.json'] = $this->generator->toPostmanCollection(); } protected function filterRoutesToDocument(RouteCollection $routes) @@ -318,230 +107,6 @@ protected function filterRoutesToDocument(RouteCollection $routes) }); } - protected function getEndpointsToDocument(array $routes) - { - /** @var \Illuminate\Routing\Route $route */ - foreach ($routes as $route) { - $documentedRoute = []; - - $routeMethods = array_filter($route->methods(), fn ($value) => $value !== 'HEAD'); - - $routeAction = $route->getActionName(); - - if ($routeAction === 'Closure') { - continue; - } - - if (! Str::contains($routeAction, '@')) { - $routeAction = "{$routeAction}@__invoke"; - } - - [$controller, $method] = explode('@', $routeAction); - - if (! class_exists($controller) || ! method_exists($controller, $method)) { - continue; - } - - $classReflector = new ReflectionClass($controller); - $methodReflector = new ReflectionMethod($controller, $method); - - $hasJsonApiResponse = ! empty(array_filter( - $methodReflector->getParameters(), - fn (ReflectionParameter $reflectorParam) => $reflectorParam->getType()?->getName() === JsonApiResponse::class - )); - - $attributes = $hasJsonApiResponse - ? array_filter( - array_merge( - $classReflector->getAttributes(), - $methodReflector->getAttributes() - ), - function (ReflectionAttribute $attribute) { - return is_subclass_of($attribute->getName(), QueryParam::class); - } - ) : []; - - $routeName = $route->getName(); - - if ($routeName && str_contains($routeName, '.')) { - $endpointResource = Str::of($controller)->afterLast('\\')->before('Controller')->lower()->toString(); - $endpointResourcePlural = Str::plural($endpointResource); - - [$endpointAction, $endpointDescription] = match ($method) { - 'index' => ["List {$endpointResourcePlural}", "This endpoint allows you to retrieve a paginated list of all your {$endpointResourcePlural}."], - 'store' => ["Create new {$endpointResource}", "This endpoint allows you to add a new {$endpointResource}."], - 'show' => ["Get one {$endpointResource}", "This endpoint allows you to retrieve a {$endpointResource}."], - 'update' => ["Modify {$endpointResource}", "This endpoint allows you to perform an update on a {$endpointResource}."], - 'destroy' => ["Remove {$endpointResource}", "This endpoint allows you to delete a {$endpointResource}."], - default => [''] - }; - - $documentedRoute['resource'] = $endpointResourcePlural; - $documentedRoute['name'] = $endpointAction; - $documentedRoute['description'] = $endpointDescription; - } - - $documentedEndpointMethodAttribute = array_filter($methodReflector->getAttributes(DocumentedEndpointSection::class)); - - if (! empty($documentedEndpointMethodAttribute)) { - $documentedEndpointMethodAttribute = reset($documentedEndpointMethodAttribute); - $documentedEndpointMethodAttribute = $documentedEndpointMethodAttribute->newInstance(); - - $documentedRoute['name'] = $documentedEndpointMethodAttribute?->title ?? $documentedRoute['name'] ?? ''; - $documentedRoute['description'] = $documentedEndpointMethodAttribute?->description ?? $documentedRoute['description'] ?? ''; - } - - $this->resources[$documentedRoute['resource']] = $this->resources[$documentedRoute['resource']] ?? []; - - if (empty($this->resources[$documentedRoute['resource']])) { - $documentedEndpointClassAttribute = $classReflector->getAttributes(DocumentedEndpointSection::class); - $documentedEndpointClassAttribute = reset($documentedEndpointClassAttribute); - - if ($documentedEndpointClassAttribute) { - $documentedEndpointClassAttribute = $documentedEndpointClassAttribute->newInstance(); - $this->resources[$documentedRoute['resource']]['title'] = $documentedEndpointClassAttribute?->title; - $this->resources[$documentedRoute['resource']]['description'] = $documentedEndpointClassAttribute?->description; - } - } - - $documentedRoute['method'] = head($routeMethods); - $documentedRoute['path'] = Str::start($route->uri(), '/'); - $documentedRoute['fullPath'] = url($route->uri()); - - // - // Get payload params from receiving data routes with validation. - // - - $documentedRoute['requiredPayload'] = []; - $documentedRoute['payload'] = []; - - $formRequestParam = array_filter( - $methodReflector->getParameters(), - fn (ReflectionParameter $reflectorParam) => is_subclass_of($reflectorParam->getType()?->getName(), FormRequest::class) - ); - - if (! empty($formRequestParam)) { - $formRequestParam = reset($formRequestParam)->getType()?->getName(); - - // TODO: Too much guessing... maybe use rules in attributes? - $formRequestParamRules = (new $formRequestParam())->rules(); - - foreach ($formRequestParamRules as $attribute => $values) { - if (is_object($values)) { - // TODO: - $ruleValues = 'string'; - } else { - $ruleValues = is_string($values) ? explode('|', $values) : $values; - } - - $ruleValueType = array_filter( - $ruleValues, - fn ($rule) => in_array($rule, ['numeric', 'string', 'date', 'boolean', 'array']) - ); - - $isRequired = in_array('required', $ruleValues); - - $documentedRoute[$isRequired ? 'requiredPayload' : 'payload'][$attribute] = reset($ruleValueType); - } - } - - // - // Get query params from class or method attributes - // - - $documentedRoute['query'] = []; - - foreach ($attributes as $attribute) { - $attributeInstance = $attribute->newInstance(); - - $matchFn = match ($attribute->getName()) { - DocumentedEndpointSection::class => function () use (&$documentedRoute, $attributeInstance) { - $documentedRoute['name'] = $attributeInstance->title; - $documentedRoute['description'] = $attributeInstance->description; - }, - FilterQueryParam::class => function () use (&$documentedRoute, $attributeInstance) { - $filterTypes = array_map(fn ($operator) => match ($operator) { - AllowedFilter::EXACT => 'equal', - AllowedFilter::SCOPE => 'scope', - AllowedFilter::SIMILAR => 'like', - AllowedFilter::LOWER_THAN => 'lt', - AllowedFilter::GREATER_THAN => 'gt', - AllowedFilter::LOWER_OR_EQUAL_THAN => 'lte', - AllowedFilter::GREATER_OR_EQUAL_THAN => 'gte', - default => 'like', - }, (array) $attributeInstance->type); - $filterParamKey = "filter[{$attributeInstance->attribute}]"; - - foreach ($filterTypes as $type) { - $documentedRoute['query']["{$filterParamKey}[{$type}]"] = [ - 'values' => $attributeInstance->values, - 'description' => $attributeInstance->description, - ]; - } - - if (count($filterTypes) <= 1) { - $documentedRoute['query'][$filterParamKey] = [ - 'values' => $attributeInstance->values, - 'description' => $attributeInstance->description, - ]; - } - }, - FieldsQueryParam::class => function () use (&$documentedRoute, $attributeInstance) { - $documentedRoute['query']["fields[{$attributeInstance->type}]"] = [ - 'values' => $attributeInstance->fields, - 'description' => $attributeInstance->description, - ]; - }, - AppendsQueryParam::class => function () use (&$documentedRoute, $attributeInstance) { - $documentedRoute['query']["appends[{$attributeInstance->type}]"] = [ - 'values' => $attributeInstance->attributes, - 'description' => $attributeInstance->description, - ]; - }, - SortQueryParam::class => function () use (&$documentedRoute, $attributeInstance) { - if (! isset($documentedRoute['query']['sorts'])) { - $documentedRoute['query']['sorts'] = [ - 'values' => [], - 'description' => $attributeInstance->description, - ]; - } - - $documentedRoute['query']['sorts']['values'] = array_merge( - $documentedRoute['query']['sorts']['values'], - match ($attributeInstance->direction) { - AllowedSort::BOTH => [$attributeInstance->attribute, "-$attributeInstance->attribute"], - AllowedSort::DESCENDANT => ["-$attributeInstance->attribute"], - AllowedSort::ASCENDANT => [$attributeInstance->attribute], - }, - ); - }, - SearchFilterQueryParam::class => function () use (&$documentedRoute, $attributeInstance) { - $documentedRoute['query']['searchFilters'][$attributeInstance->attribute] = [ - 'values' => $attributeInstance->values, - 'description' => $attributeInstance->description, - ]; - }, - SearchQueryParam::class => function () use (&$documentedRoute, $attributeInstance) { - $documentedRoute['query']['search'] = [ - 'values' => true, - 'description' => $attributeInstance->description, - ]; - }, - IncludeQueryParam::class => function () use (&$documentedRoute, $attributeInstance) { - $documentedRoute['query']['includes'] = [ - 'values' => $attributeInstance->relationships, - 'description' => $attributeInstance->description, - ]; - }, - }; - - $matchFn(); - } - - $this->endpoints[] = $documentedRoute; - } - } - protected function askForExportFormat() { $postman = $this->option('postman'); diff --git a/src/Contracts/ViewQueryable.php b/src/Contracts/ViewQueryable.php index e863482..8ec4603 100644 --- a/src/Contracts/ViewQueryable.php +++ b/src/Contracts/ViewQueryable.php @@ -16,5 +16,5 @@ interface ViewQueryable * @param \Illuminate\Database\Eloquent\Builder $query * @return void */ - public function scopeViewable(Builder $query, Authenticatable $user = null); + public function scopeViewable(Builder $query, ?Authenticatable $user = null); } diff --git a/src/Contracts/ViewableBuilder.php b/src/Contracts/ViewableBuilder.php index e110ad6..72552f5 100644 --- a/src/Contracts/ViewableBuilder.php +++ b/src/Contracts/ViewableBuilder.php @@ -14,5 +14,5 @@ interface ViewableBuilder * * @return \Illuminate\Database\Eloquent\Builder */ - public function viewable(Authenticatable $user = null); + public function viewable(?Authenticatable $user = null); } diff --git a/src/Documentation/Attributes/DocumentedEndpointSection.php b/src/Documentation/Attributes/DocumentedEndpointSection.php new file mode 100644 index 0000000..73f0aaf --- /dev/null +++ b/src/Documentation/Attributes/DocumentedEndpointSection.php @@ -0,0 +1,14 @@ + $query + */ + public function __construct( + protected readonly Resource $resource, + protected readonly Route $route, + protected readonly string $method, + protected readonly string $title = '', + protected readonly string $description = '', + protected readonly string $responseType = 'json:api', + protected array $query = [] + ) { + // + } + + public static function fromMethodAttribute(ReflectionMethod $controllerMethod, Resource $resource, Route $route, string $method): ?self + { + $documentedEndpointAttributeArr = $controllerMethod->getAttributes(Attributes\DocumentedEndpointSection::class); + + $documentedEndpointAttribute = reset($documentedEndpointAttributeArr); + + if (! $documentedEndpointAttribute) { + return null; + } + + $attribute = $documentedEndpointAttribute->newInstance(); + + return new self($resource, $route, $method, $attribute->title, $attribute->description); + } + + public static function fromResourceAction(Resource $resource, Route $route, string $method): ?self + { + $endpointResource = $resource->getName(); + $endpointResourcePlural = Str::plural($endpointResource); + + $action = Str::afterLast($route->getName(), '.'); + + [$title, $description] = match ($action) { + 'index' => ["List {$endpointResourcePlural}", "This endpoint allows you to retrieve a paginated list of all your {$endpointResourcePlural}."], + 'store' => ["Create new {$endpointResource}", "This endpoint allows you to add a new {$endpointResource}."], + 'show' => ["Get one {$endpointResource}", "This endpoint allows you to retrieve a {$endpointResource}."], + 'update' => ["Modify {$endpointResource}", "This endpoint allows you to perform an update on a {$endpointResource}."], + 'destroy' => ["Remove {$endpointResource}", "This endpoint allows you to delete a {$endpointResource}."], + default => ['', ''] + }; + + return new self($resource, $route, $method, $title, $description); + } + + public function getQueryFromAttributes(ReflectionClass $controller, ReflectionMethod $method): self + { + $attributes = $this->hasJsonApiResponse($method) + ? array_filter( + array_merge( + $controller->getAttributes(), + $method->getAttributes() + ), + function (ReflectionAttribute $attribute) { + return is_subclass_of($attribute->getName(), \OpenSoutheners\LaravelApiable\Attributes\QueryParam::class); + } + ) : []; + + foreach ($attributes as $attribute) { + $this->query[] = QueryParam::fromAttribute($attribute->newInstance()); + } + + return $this; + } + + protected function hasJsonApiResponse(ReflectionMethod $method): bool + { + return ! empty(array_filter( + $method->getParameters(), + fn (ReflectionParameter $reflectorParam) => ((string) $reflectorParam->getType()) === JsonApiResponse::class + )); + } + + public function toPostmanItem(): array + { + $postmanItem = [ + 'name' => $this->title, + 'request' => [ + 'method' => $this->route->getActionMethod(), + 'header' => [], + ], + 'url' => [], + 'response' => [], + ]; + + if ($this->responseType === 'json:api') { + $postmanItem['request']['header'][] = [ + 'key' => 'Accept', + 'value' => 'application/vnd.api+json', + 'description' => 'Accept JSON:API as a response content', + 'type' => 'text', + ]; + } + + $routeUriString = Str::of($this->route->uri) + ->explode('/') + ->map(fn (string $pathFragment): string => Str::of($pathFragment) + ->when( + Str::between($pathFragment, '{', '}') !== $pathFragment, + fn (Stringable $string): Stringable => $string->between('{', '}') + ->prepend('{{') + ->append('}}') + )->value() + ); + + $postmanItem['url']['raw'] = "{{base_url}}/{$routeUriString->join('/')}"; + $postmanItem['url']['host'] = ['{{base_url}}']; + $postmanItem['url']['path'] = $routeUriString->toArray(); + + $postmanItem['url']['query'] = array_map( + fn (QueryParam $param): array => $param->toPostman(), + $this->query + ); + + return $postmanItem; + } + + public function fullUrl(): string + { + return url($this->route->uri()); + } + + public function toArray(): array + { + return [ + 'title' => $this->title, + 'action' => $this->route->getActionMethod(), + 'routeMethod' => $this->method, + 'routeUrl' => $this->route->uri, + 'routeFullUrl' => $this->fullUrl(), + 'query' => array_map(fn (QueryParam $param) => $param->toArray(), $this->query), + ]; + } +} diff --git a/src/Documentation/Generator.php b/src/Documentation/Generator.php new file mode 100644 index 0000000..1f83183 --- /dev/null +++ b/src/Documentation/Generator.php @@ -0,0 +1,126 @@ + $resources + */ + public function __construct( + protected readonly Router $router, + protected readonly array $config = [], + protected array $resources = [] + ) { + // + } + + public function generate(): self + { + $appRoutes = $this->router->getRoutes()->get(); + + /** @var \Illuminate\Routing\Route $route */ + foreach ($appRoutes as $route) { + $routeMethods = array_filter($route->methods(), fn ($value) => $value !== 'HEAD'); + + [$controller, $method] = $this->getControllerAndMethod($route); + + if (! $controller || ! $method) { + continue; + } + + $resource = Resource::fromController($controller); + + if (! $resource) { + continue; + } + + $resource = $this->resources[$resource->getName()] ?? $resource; + + foreach ($routeMethods as $routeMethod) { + $endpoint = Endpoint::fromMethodAttribute($method, $resource, $route, $routeMethod) + ?? Endpoint::fromResourceAction($resource, $route, $routeMethod); + + $endpoint->getQueryFromAttributes($controller, $method); + + $resource->addEndpoint($endpoint); + } + + $this->resources[$resource->getName()] = $resource; + } + + return $this; + } + + /** + * @return array{\ReflectionClass, \ReflectionMethod}|null + */ + private function getControllerAndMethod(Route $route): ?array + { + $routeAction = $route->getActionName(); + + // TODO: We still can get something from a closure route... + // Use ReflectionFunction + if ($routeAction === 'Closure') { + return null; + } + + // Invokes are special under the router's hood + if (! Str::contains($routeAction, '@')) { + $routeAction = "{$routeAction}@__invoke"; + } + + [$controller, $method] = explode('@', $routeAction); + + if (! class_exists($controller) || ! method_exists($controller, $method)) { + return null; + } + + $controllerReflection = new \ReflectionClass($controller); + + return [$controllerReflection, $controllerReflection->getMethod($method)]; + } + + public function toPostmanCollection(): string + { + $postmanCollection = [ + 'info' => [ + 'name' => config('app.name'), + 'schema' => 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json', + ], + 'item' => [], + ]; + + foreach ($this->resources as $resource) { + $postmanCollection['item'][] = $resource->toPostmanItem(); + } + + return json_encode($postmanCollection, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + /** + * @return array + */ + public function toMarkdown(): array + { + View::addExtension('mdx', 'blade'); + + $markdownFiles = []; + + foreach ($this->resources as $resource) { + $markdownFilePath = config('apiable.documentation.markdown.base_path')."/{$resource->getName()}.mdx"; + + $markdownFiles[$markdownFilePath] = View::file( + __DIR__.'/../../stubs/markdown.mdx', + $resource->toArray() + )->render(); + } + + return $markdownFiles; + } +} diff --git a/src/Documentation/QueryParam.php b/src/Documentation/QueryParam.php new file mode 100644 index 0000000..8428bbd --- /dev/null +++ b/src/Documentation/QueryParam.php @@ -0,0 +1,147 @@ + $values + */ + public function __construct( + protected readonly string $key, + protected readonly string $description = '', + protected readonly array|string $values = [] + ) { + // + } + + public static function fromAttribute(Attributes\QueryParam $attribute): self + { + return match (get_class($attribute)) { + // DocumentedEndpointSection::class => function () use (&$documentedRoute, $this->attribute) { + // $documentedRoute['name'] = $this->attribute->title; + // $documentedRoute['description'] = $this->attribute->description; + // }, + Attributes\FilterQueryParam::class => static::fromFilterAttribute($attribute), + Attributes\FieldsQueryParam::class => static::fromFieldsAttribute($attribute), + Attributes\AppendsQueryParam::class => static::fromAppendsAttribute($attribute), + Attributes\SortQueryParam::class => static::fromSortsAttribute($attribute), + Attributes\SearchFilterQueryParam::class => static::fromSearchFilterAttribute($attribute), + Attributes\SearchQueryParam::class => static::fromSearchAttribute($attribute), + Attributes\IncludeQueryParam::class => static::fromIncludesAttribute($attribute), + default => static::class, + }; + } + + public static function fromFilterAttribute(Attributes\FilterQueryParam $attribute): self + { + // TODO: Must be always 1 filter type per parameter attribute + $filterOperator = is_array($attribute->type) + ? reset($attribute->type) + : $attribute->type; + + $filterType = match ($filterOperator) { + AllowedFilter::EXACT => 'equal', + AllowedFilter::SCOPE => 'scope', + AllowedFilter::SIMILAR => 'like', + AllowedFilter::LOWER_THAN => 'lt', + AllowedFilter::GREATER_THAN => 'gt', + AllowedFilter::LOWER_OR_EQUAL_THAN => 'lte', + AllowedFilter::GREATER_OR_EQUAL_THAN => 'gte', + default => 'like', + }; + + return new self( + "filter[{$attribute->attribute}][{$filterType}]", + $attribute->description, + $attribute->values, + ); + } + + public static function fromFieldsAttribute(Attributes\FieldsQueryParam $attribute): self + { + return new self( + "fields[{$attribute->getTypeAsResource()}]", + $attribute->description, + $attribute->fields, + ); + } + + public static function fromAppendsAttribute(Attributes\AppendsQueryParam $attribute): self + { + return new self( + "appends[{$attribute->getTypeAsResource()}]", + $attribute->description, + $attribute->attributes, + ); + } + + public static function fromSortsAttribute(Attributes\SortQueryParam $attribute): self + { + // if (! isset($documentedRoute['query']['sorts'])) { + // $documentedRoute['query']['sorts'] = [ + // 'values' => [], + // 'description' => $this->attribute->description, + // ]; + // } + + return new self( + 'sorts', + $attribute->description, + match ($attribute->direction) { + AllowedSort::BOTH => [$attribute->attribute, "-{$attribute->attribute}"], + AllowedSort::DESCENDANT => ["-{$attribute->attribute}"], + AllowedSort::ASCENDANT => [$attribute->attribute], + default => [''], + } + ); + } + + public static function fromSearchFilterAttribute(Attributes\SearchFilterQueryParam $attribute): self + { + return new self( + "search[filter][{$attribute->attribute}]", + $attribute->description, + $attribute->values, + ); + } + + public static function fromSearchAttribute(Attributes\SearchQueryParam $attribute): self + { + return new self( + 'q', + $attribute->description, + ); + } + + public static function fromIncludesAttribute(Attributes\IncludeQueryParam $attribute): self + { + return new self( + 'includes', + $attribute->description, + $attribute->relationships, + ); + } + + public function toPostman(): array + { + return [ + 'key' => $this->key, + 'value' => implode(', ', (array) $this->values), + 'description' => $this->description, + ]; + } + + public function toArray(): array + { + return [ + 'key' => $this->key, + 'description' => $this->description, + 'values' => is_array($this->values) ? implode(', ', $this->values) : $this->values, + ]; + } +} diff --git a/src/Documentation/Resource.php b/src/Documentation/Resource.php new file mode 100644 index 0000000..8893e7a --- /dev/null +++ b/src/Documentation/Resource.php @@ -0,0 +1,86 @@ + $endpoints + */ + public function __construct( + protected readonly string $name, + protected readonly string $title = '', + protected readonly string $description = '', + protected array $endpoints = [], + ) { + // + } + + public function getName(): string + { + return $this->name; + } + + public function getTitle(): string + { + return $this->title ?: Str::title($this->name); + } + + public static function fromController(ReflectionClass $controller): ?self + { + $controllerAttributesArr = $controller->getAttributes(); + + $documentedResourceAttributeArr = array_filter( + $controllerAttributesArr, + fn ($attribute) => $attribute->getName() === DocumentedResource::class + ); + + $documentedResourceAttribute = reset($documentedResourceAttributeArr); + + if (! $documentedResourceAttribute) { + return null; + } + + $documentedResourceAttribute = $documentedResourceAttribute->newInstance(); + + return new self( + $documentedResourceAttribute->name, + $documentedResourceAttribute->title, + $documentedResourceAttribute->description, + ); + } + + public function addEndpoint(Endpoint $endpoint): self + { + $this->endpoints[] = $endpoint; + + return $this; + } + + public function toPostmanItem(): array + { + return [ + 'name' => $this->name, + 'title' => $this->getTitle(), + 'description' => $this->description, + 'item' => array_map( + fn (Endpoint $endpoint): array => $endpoint->toPostmanItem(), + $this->endpoints + ), + ]; + } + + public function toArray(): array + { + return [ + 'name' => $this->name, + 'title' => $this->getTitle(), + 'description' => $this->description, + 'endpoints' => array_map(fn (Endpoint $endpoint) => $endpoint->toArray(), $this->endpoints), + ]; + } +} diff --git a/src/Http/AllowedFilter.php b/src/Http/AllowedFilter.php index 5d4910c..1ecf8c3 100644 --- a/src/Http/AllowedFilter.php +++ b/src/Http/AllowedFilter.php @@ -42,7 +42,7 @@ class AllowedFilter implements Arrayable * @param int|array|null $operator * @return void */ - public function __construct(string $attribute, int|array $operator = null, array|string $values = '*') + public function __construct(string $attribute, int|array|null $operator = null, array|string $values = '*') { if (! is_null($operator) && ! $this->isValidOperator($operator)) { throw new \Exception( diff --git a/src/Http/AllowedSort.php b/src/Http/AllowedSort.php index aec13aa..118e5e9 100644 --- a/src/Http/AllowedSort.php +++ b/src/Http/AllowedSort.php @@ -22,7 +22,7 @@ class AllowedSort implements Arrayable * * @return void */ - public function __construct(string $attribute, int $direction = null) + public function __construct(string $attribute, ?int $direction = null) { $this->attribute = $attribute; $this->direction = (int) ($direction ?? Apiable::config('requests.sorts.default_direction') ?? static::BOTH); diff --git a/src/Http/ApplyFiltersToQuery.php b/src/Http/ApplyFiltersToQuery.php index cb4149f..6f587fb 100644 --- a/src/Http/ApplyFiltersToQuery.php +++ b/src/Http/ApplyFiltersToQuery.php @@ -71,7 +71,7 @@ protected function applyFilters(Builder $query, array $filters): Builder * Wrap query if relationship found applying its operator and conditional to the filtered attribute. * * @param callable(\Illuminate\Database\Eloquent\Builder, string|null, string, string, string, string): mixed $callback - * @param array $filterValues + * @param array $filterValues */ protected function wrapIfRelatedQuery(callable $callback, Builder $query, string $filterAttribute, array $filterValues): void { diff --git a/src/Http/Concerns/AllowsAppends.php b/src/Http/Concerns/AllowsAppends.php index 5108db8..43c14a0 100644 --- a/src/Http/Concerns/AllowsAppends.php +++ b/src/Http/Concerns/AllowsAppends.php @@ -15,10 +15,12 @@ trait AllowsAppends /** * @var array> */ - protected $allowedAppends = []; + protected array $allowedAppends = []; /** * Get user append attributes from request. + * + * @return array */ public function appends(): array { @@ -36,7 +38,7 @@ public function appends(): array * * @param \OpenSoutheners\LaravelApiable\Http\AllowedAppends|class-string<\Illuminate\Database\Eloquent\Model>|string $type */ - public function allowAppends(AllowedAppends|string $type, array $attributes = null): self + public function allowAppends(AllowedAppends|string $type, ?array $attributes = null): self { if ($type instanceof AllowedAppends) { $this->allowedAppends = array_merge($this->allowedAppends, $type->toArray()); @@ -54,7 +56,7 @@ public function allowAppends(AllowedAppends|string $type, array $attributes = nu } /** - * Get appends that passed the validation. + * Get appends filtered by user allowed. */ public function userAllowedAppends(): array { diff --git a/src/Http/Concerns/AllowsFields.php b/src/Http/Concerns/AllowsFields.php index 24dfaab..10b5548 100644 --- a/src/Http/Concerns/AllowsFields.php +++ b/src/Http/Concerns/AllowsFields.php @@ -15,14 +15,14 @@ trait AllowsFields /** * @var array> */ - protected $allowedFields = []; + protected array $allowedFields = []; /** * Get all fields from request. * - * @return array + * @return array */ - public function fields() + public function fields(): array { $fields = $this->request->get('fields', []); @@ -38,9 +38,8 @@ public function fields() * * @param \OpenSoutheners\LaravelApiable\Http\AllowedFields|class-string<\Illuminate\Database\Eloquent\Model>|string $type * @param array|string|null $attributes - * @return $this */ - public function allowFields($type, $attributes = null) + public function allowFields($type, $attributes = null): self { if ($type instanceof AllowedFields) { $this->allowedFields = array_merge($this->allowedFields, $type->toArray()); @@ -57,7 +56,12 @@ public function allowFields($type, $attributes = null) return $this; } - public function userAllowedFields() + /** + * Get fields filtered by user allowed. + * + * @return array + */ + public function userAllowedFields(): array { return $this->validator($this->fields()) ->givingRules($this->allowedFields) diff --git a/src/Http/Concerns/AllowsFilters.php b/src/Http/Concerns/AllowsFilters.php index 1fddb69..1747e52 100644 --- a/src/Http/Concerns/AllowsFilters.php +++ b/src/Http/Concerns/AllowsFilters.php @@ -6,7 +6,6 @@ use OpenSoutheners\LaravelApiable\Http\AllowedFilter; use OpenSoutheners\LaravelApiable\Http\DefaultFilter; use OpenSoutheners\LaravelApiable\Support\Apiable; -use Symfony\Component\HttpFoundation\HeaderUtils; /** * @mixin \OpenSoutheners\LaravelApiable\Http\RequestQueryObject @@ -97,7 +96,7 @@ public function allowScopedFilter(string $attribute, array|string $value = '*'): } /** - * Get user requested filters filtered by allowed ones. + * Get filters filtered by user allowed. */ public function userAllowedFilters(): array { diff --git a/src/Http/Concerns/AllowsIncludes.php b/src/Http/Concerns/AllowsIncludes.php index cdded63..ae26177 100644 --- a/src/Http/Concerns/AllowsIncludes.php +++ b/src/Http/Concerns/AllowsIncludes.php @@ -12,14 +12,12 @@ trait AllowsIncludes /** * @var array */ - protected $allowedIncludes = []; + protected array $allowedIncludes = []; /** * Get user includes relationships from request. - * - * @return array */ - public function includes() + public function includes(): array { return array_filter(explode(',', $this->request->get('include', ''))); } @@ -28,16 +26,20 @@ public function includes() * Allow include relationship to the response. * * @param \OpenSoutheners\LaravelApiable\Http\AllowedInclude|array|string $relationship - * @return $this */ - public function allowInclude($relationship) + public function allowInclude($relationship): self { $this->allowedIncludes = array_merge($this->allowedIncludes, (array) $relationship); return $this; } - public function userAllowedIncludes() + /** + * Get includes filtered by user allowed. + * + * @return array + */ + public function userAllowedIncludes(): array { return $this->validator($this->includes()) ->givingRules(false) @@ -53,7 +55,7 @@ public function userAllowedIncludes() * * @return array */ - public function getAllowedIncludes() + public function getAllowedIncludes(): array { return $this->allowedIncludes; } diff --git a/src/Http/Concerns/AllowsSearch.php b/src/Http/Concerns/AllowsSearch.php index ae7e4ca..c96227d 100644 --- a/src/Http/Concerns/AllowsSearch.php +++ b/src/Http/Concerns/AllowsSearch.php @@ -10,26 +10,21 @@ */ trait AllowsSearch { - /** - * @var bool - */ - protected $allowedSearch = false; + protected bool $allowedSearch = false; /** - * @var array + * @var array>> */ - protected $allowedSearchFilters = []; + protected array $allowedSearchFilters = []; /** * Get user search query from request. - * - * @return string */ - public function searchQuery() + public function searchQuery(): ?string { return head(array_filter( - $this->queryParameters()->get('q', $this->queryParameters()->get('search', [])), - fn ($item) => is_string($item) + $this->queryParameters()->value('q', $this->queryParameters()->value('search', [])), + fn ($item): bool => is_string($item) )); } @@ -38,7 +33,7 @@ public function searchQuery() * * @return string[] */ - public function searchFilters() + public function searchFilters(): array { return array_reduce(array_filter( $this->queryParameters()->get('q', $this->queryParameters()->get('search', [])), @@ -56,10 +51,8 @@ public function searchFilters() /** * Allow fulltext search to be performed. - * - * @return $this */ - public function allowSearch(bool $value = true) + public function allowSearch(bool $value = true): self { $this->allowedSearch = $value; @@ -71,9 +64,8 @@ public function allowSearch(bool $value = true) * * @param \OpenSoutheners\LaravelApiable\Http\AllowedSearchFilter|string $attribute * @param array|string $values - * @return $this */ - public function allowSearchFilter($attribute, $values = ['*']) + public function allowSearchFilter($attribute, $values = ['*']): self { $this->allowedSearchFilters = array_merge_recursive( $this->allowedSearchFilters, @@ -87,20 +79,16 @@ public function allowSearchFilter($attribute, $values = ['*']) /** * Check if fulltext search is allowed. - * - * @return bool */ - public function isSearchAllowed() + public function isSearchAllowed(): bool { return $this->allowedSearch; } /** * Get user requested search filters filtered by allowed ones. - * - * @return array */ - public function userAllowedSearchFilters() + public function userAllowedSearchFilters(): array { $searchFilters = $this->searchFilters(); @@ -117,9 +105,9 @@ public function userAllowedSearchFilters() /** * Get list of allowed search filters. * - * @return array + * @return array>> */ - public function getAllowedSearchFilters() + public function getAllowedSearchFilters(): array { return $this->allowedSearchFilters; } diff --git a/src/Http/Concerns/IteratesResultsAfterQuery.php b/src/Http/Concerns/IteratesResultsAfterQuery.php index f010908..9703d20 100644 --- a/src/Http/Concerns/IteratesResultsAfterQuery.php +++ b/src/Http/Concerns/IteratesResultsAfterQuery.php @@ -15,11 +15,8 @@ trait IteratesResultsAfterQuery { /** * Post-process result from query to apply appended attributes. - * - * @param mixed $result - * @return mixed */ - protected function resultPostProcessing($result) + protected function resultPostProcessing(mixed $result): mixed { if (! $result instanceof JsonApiResource) { return $result; @@ -33,15 +30,15 @@ protected function resultPostProcessing($result) if ($includeAllowed) { $result->additional(['meta' => array_filter([ - 'allowed_filters' => $this->request->getAllowedFilters(), - 'allowed_sorts' => $this->request->getAllowedSorts(), + 'allowed_filters' => $this->getAllowedFilters(), + 'allowed_sorts' => $this->getAllowedSorts(), ])]); } if ($result instanceof JsonApiCollection) { $result->withQuery( array_filter( - $this->getRequest()->query->all(), + $this->request->query->all(), fn ($queryParam) => $queryParam !== 'page', ARRAY_FILTER_USE_KEY ) @@ -55,14 +52,13 @@ protected function resultPostProcessing($result) * Add allowed user appends to result. * * @param \OpenSoutheners\LaravelApiable\Http\Resources\JsonApiCollection|\OpenSoutheners\LaravelApiable\Http\Resources\JsonApiResource $result - * @return void */ - protected function addAppendsToResult($result) + protected function addAppendsToResult($result): void { $filteredUserAppends = (new QueryParamsValidator( - $this->request->appends(), - $this->request->enforcesValidation(), - $this->request->getAllowedAppends() + $this->appends(), + $this->enforcesValidation(), + $this->getAllowedAppends() ))->when( function ($key, $modifiers, $values, $rules, &$valids) { $valids = array_intersect($values, $rules); @@ -91,9 +87,8 @@ function ($key, $modifiers, $values, $rules, &$valids) { * Append array of attributes to the resulted JSON:API resource. * * @param \OpenSoutheners\LaravelApiable\Http\Resources\JsonApiResource|mixed $resource - * @return void */ - protected function appendToApiResource(mixed $resource, array $appends) + protected function appendToApiResource(mixed $resource, array $appends): void { if (! ($resource instanceof JsonApiResource)) { return; diff --git a/src/Http/Concerns/ResolvesFromRouteAction.php b/src/Http/Concerns/ResolvesFromRouteAction.php index 49137d7..793a201 100644 --- a/src/Http/Concerns/ResolvesFromRouteAction.php +++ b/src/Http/Concerns/ResolvesFromRouteAction.php @@ -11,6 +11,7 @@ use OpenSoutheners\LaravelApiable\Attributes\ForceAppendAttribute; use OpenSoutheners\LaravelApiable\Attributes\IncludeQueryParam; use OpenSoutheners\LaravelApiable\Attributes\QueryParam; +use OpenSoutheners\LaravelApiable\Attributes\ResourceResponse; use OpenSoutheners\LaravelApiable\Attributes\SearchFilterQueryParam; use OpenSoutheners\LaravelApiable\Attributes\SearchQueryParam; use OpenSoutheners\LaravelApiable\Attributes\SortQueryParam; @@ -25,10 +26,8 @@ trait ResolvesFromRouteAction { /** * Resolves allowed query parameters from current route if possible. - * - * @return void */ - protected function resolveFromRoute() + protected function resolveFromRoute(): void { $routeAction = Route::currentRouteAction(); @@ -51,9 +50,8 @@ protected function resolveFromRoute() * Get PHP query param attributes from reflected class or method. * * @param \ReflectionClass|\ReflectionMethod $reflected - * @return void */ - protected function resolveAttributesFrom($reflected) + protected function resolveAttributesFrom($reflected): void { $allowedQueryParams = array_filter($reflected->getAttributes(), function (ReflectionAttribute $attribute) { return is_subclass_of($attribute->getName(), QueryParam::class) @@ -63,17 +61,18 @@ protected function resolveAttributesFrom($reflected) foreach ($allowedQueryParams as $allowedQueryParam) { $attributeInstance = $allowedQueryParam->newInstance(); - match (true) { - $attributeInstance instanceof ForceAppendAttribute => $this->forceAppend($attributeInstance->type, $attributeInstance->attributes), - $attributeInstance instanceof SearchQueryParam => $this->allowSearch($attributeInstance->allowSearch), - $attributeInstance instanceof SearchFilterQueryParam => $this->allowSearchFilter($attributeInstance->attribute, $attributeInstance->values), - $attributeInstance instanceof FilterQueryParam => $this->allowFilter($attributeInstance->attribute, $attributeInstance->type, $attributeInstance->values), - $attributeInstance instanceof SortQueryParam => $this->allowSort($attributeInstance->attribute, $attributeInstance->direction), - $attributeInstance instanceof IncludeQueryParam => $this->allowInclude($attributeInstance->relationships), - $attributeInstance instanceof FieldsQueryParam => $this->allowFields($attributeInstance->type, $attributeInstance->fields), - $attributeInstance instanceof AppendsQueryParam => $this->allowAppends($attributeInstance->type, $attributeInstance->attributes), - $attributeInstance instanceof ApplyDefaultSort => $this->applyDefaultSort($attributeInstance->attribute, $attributeInstance->direction), - $attributeInstance instanceof ApplyDefaultFilter => $this->applyDefaultFilter($attributeInstance->attribute, $attributeInstance->operator, $attributeInstance->values), + match (get_class($attributeInstance)) { + ForceAppendAttribute::class => $this->forceAppend($attributeInstance->type, $attributeInstance->attributes), + SearchQueryParam::class => $this->allowSearch($attributeInstance->allowSearch), + SearchFilterQueryParam::class => $this->allowSearchFilter($attributeInstance->attribute, $attributeInstance->values), + FilterQueryParam::class => $this->allowFilter($attributeInstance->attribute, $attributeInstance->type, $attributeInstance->values), + SortQueryParam::class => $this->allowSort($attributeInstance->attribute, $attributeInstance->direction), + IncludeQueryParam::class => $this->allowInclude($attributeInstance->relationships), + FieldsQueryParam::class => $this->allowFields($attributeInstance->type, $attributeInstance->fields), + AppendsQueryParam::class => $this->allowAppends($attributeInstance->type, $attributeInstance->attributes), + ApplyDefaultSort::class => $this->applyDefaultSort($attributeInstance->attribute, $attributeInstance->direction), + ApplyDefaultFilter::class => $this->applyDefaultFilter($attributeInstance->attribute, $attributeInstance->operator, $attributeInstance->values), + ResourceResponse::class => $this->using($attributeInstance->resource), default => null, }; } diff --git a/src/Http/DefaultFilter.php b/src/Http/DefaultFilter.php index b5bd710..db2acd3 100644 --- a/src/Http/DefaultFilter.php +++ b/src/Http/DefaultFilter.php @@ -9,7 +9,7 @@ class DefaultFilter extends AllowedFilter * * @return void */ - public function __construct(string $attribute, int $operator = null, string|array $values = '*') + public function __construct(string $attribute, ?int $operator = null, string|array $values = '*') { if (! is_null($operator) && ! $this->isValidOperator($operator)) { throw new \Exception( diff --git a/src/Http/DefaultSort.php b/src/Http/DefaultSort.php index 68a7015..7d655c9 100644 --- a/src/Http/DefaultSort.php +++ b/src/Http/DefaultSort.php @@ -17,7 +17,7 @@ class DefaultSort implements Arrayable /** * Make an instance of this class. */ - public function __construct(string $attribute, int $direction = null) + public function __construct(string $attribute, ?int $direction = null) { $this->attribute = $attribute; $this->direction = $direction ?? static::ASCENDANT; diff --git a/src/Http/JsonApiResponse.php b/src/Http/JsonApiResponse.php index ce72bdc..121ba45 100644 --- a/src/Http/JsonApiResponse.php +++ b/src/Http/JsonApiResponse.php @@ -3,7 +3,6 @@ namespace OpenSoutheners\LaravelApiable\Http; use Closure; -use Exception; use Illuminate\Contracts\Auth\Guard; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Contracts\Support\Responsable; @@ -22,9 +21,11 @@ /** * @template T of \Illuminate\Database\Eloquent\Model * - * @mixin \OpenSoutheners\LaravelApiable\Http\RequestQueryObject + * @template-extends \OpenSoutheners\LaravelApiable\Http\RequestQueryObject + * + * @mixin \Illuminate\Database\Eloquent\Builder */ -class JsonApiResponse implements Arrayable, Responsable +class JsonApiResponse extends RequestQueryObject implements Arrayable, Responsable { use Concerns\IteratesResultsAfterQuery; use Concerns\ResolvesFromRouteAction; @@ -32,8 +33,6 @@ class JsonApiResponse implements Arrayable, Responsable protected Pipeline $pipeline; - protected ?RequestQueryObject $request; - /** * @var class-string|class-string<\OpenSoutheners\LaravelApiable\Contracts\ViewQueryable> */ @@ -55,9 +54,9 @@ class JsonApiResponse implements Arrayable, Responsable * * @return void */ - public function __construct(Request $request) + public function __construct(public Request $request) { - $this->request = new RequestQueryObject($request); + parent::__construct($request); $this->pipeline = app(Pipeline::class); @@ -86,25 +85,17 @@ public function using($modelOrQuery): self /** @var \Illuminate\Database\Eloquent\Builder|\OpenSoutheners\LaravelApiable\Contracts\ViewableBuilder $query */ $query = is_string($modelOrQuery) ? $modelOrQuery::query() : clone $modelOrQuery; - $this->request->setQuery($query); + $this->setQuery($query); return $this; } /** * Build pipeline and return resulting request query object instance. - * - * @return \OpenSoutheners\LaravelApiable\Http\RequestQueryObject - * - * @throws \Exception */ - protected function buildPipeline() + protected function buildPipeline(): self { - if (! $this->request?->query) { - throw new Exception('RequestQueryObject needs a base query to work, none provided'); - } - - return $this->pipeline->send($this->request) + return $this->pipeline->send($this) ->via('from') ->through([ ApplyFulltextSearchToQuery::class, @@ -181,10 +172,9 @@ protected function serializeResponse(mixed $response): mixed ? call_user_func_array($this->pagination, [$response]) : $response; - $request = $this->request->getRequest(); - $requesterAccepts = $request->header('Accept'); + $requesterAccepts = $this->request->header('Accept'); - if ($this->withinInertia($request) || $requesterAccepts === null || Apiable::config('responses.formatting.force')) { + if ($this->withinInertia($this->request) || $requesterAccepts === null || Apiable::config('responses.formatting.force')) { $requesterAccepts = Apiable::config('responses.formatting.type'); } @@ -303,7 +293,7 @@ public function conditionallyLoadResults(bool $value = true): self /** * Force response serialisation with the specified format otherwise use default. */ - public function forceFormatting(string $format = null): self + public function forceFormatting(?string $format = null): self { Apiable::forceResponseFormatting($format); @@ -311,10 +301,10 @@ public function forceFormatting(string $format = null): self } /** - * Call method of RequestQueryObject if not exists on this. + * Call methods of the underlying query builder if not exists on this. */ public function __call(string $name, array $arguments): mixed { - return $this->forwardDecoratedCallTo($this->request, $name, $arguments); + return $this->forwardDecoratedCallTo($this->query, $name, $arguments); } } diff --git a/src/Http/QueryParamsValidator.php b/src/Http/QueryParamsValidator.php index e2a4341..dd60e22 100644 --- a/src/Http/QueryParamsValidator.php +++ b/src/Http/QueryParamsValidator.php @@ -11,13 +11,16 @@ class QueryParamsValidator /** * @var array{0: callable(string, array, array, array, array): bool, 1: \Throwable|callable}|array */ - protected $validationCallbacks = []; + protected array $validationCallbacks = []; /** * Create new validator instance. */ - public function __construct(protected array $params, protected bool $enforceValidation, protected array|bool $rules = []) - { + public function __construct( + protected array $params, + protected bool $enforceValidation, + protected array|bool $rules = [] + ) { // } diff --git a/src/Http/RequestQueryObject.php b/src/Http/RequestQueryObject.php index 004c9f7..eeb05c5 100644 --- a/src/Http/RequestQueryObject.php +++ b/src/Http/RequestQueryObject.php @@ -2,6 +2,7 @@ namespace OpenSoutheners\LaravelApiable\Http; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -23,7 +24,7 @@ class RequestQueryObject /** * @var \Illuminate\Database\Eloquent\Builder */ - public $query; + public Builder $query; /** * @var \Illuminate\Support\Collection<(int|string), array>|null @@ -33,7 +34,7 @@ class RequestQueryObject /** * Construct the request query object. */ - public function __construct(protected Request $request) + public function __construct(public Request $request) { // } @@ -41,9 +42,9 @@ public function __construct(protected Request $request) /** * Set query for this request query object. * - * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder $query */ - public function setQuery($query): self + public function setQuery(Builder $query): self { $this->query = $query; @@ -66,14 +67,6 @@ public function queryParameters(): Collection return $this->queryParameters; } - /** - * Get the underlying request object. - */ - public function getRequest(): Request - { - return $this->request; - } - /** * Allows the following user operations. */ diff --git a/src/JsonApiException.php b/src/JsonApiException.php index 3b39b76..e36cf4a 100644 --- a/src/JsonApiException.php +++ b/src/JsonApiException.php @@ -13,10 +13,10 @@ class JsonApiException extends Exception */ public function addError( string $title, - string $detail = null, - string $source = null, + ?string $detail = null, + ?string $source = null, ?int $status = 500, - int|string $code = null, + int|string|null $code = null, array $trace = [] ): void { $error = []; diff --git a/src/Support/Apiable.php b/src/Support/Apiable.php index 6119ef8..88fa5ae 100644 --- a/src/Support/Apiable.php +++ b/src/Support/Apiable.php @@ -70,7 +70,7 @@ public static function getResourceType(Model|string $model): string /** * Transforms error rendering to a JSON:API complaint error response. */ - public static function jsonApiRenderable(Throwable $e, bool $withTrace = null): Handler + public static function jsonApiRenderable(Throwable $e, ?bool $withTrace = null): Handler { return new Handler($e, $withTrace); } @@ -144,7 +144,7 @@ public static function scopedFilterSuffix(string $value) * * @return void */ - public static function forceResponseFormatting(string $format = null) + public static function forceResponseFormatting(?string $format = null) { config(['apiable.responses.formatting.force' => true]); diff --git a/src/Testing/AssertableJsonApi.php b/src/Testing/AssertableJsonApi.php index 6c7b319..36591ab 100644 --- a/src/Testing/AssertableJsonApi.php +++ b/src/Testing/AssertableJsonApi.php @@ -99,7 +99,7 @@ public function isResource() * @param mixed $id * @return string */ - protected function getIdentifierMessageFor($id = null, string $type = null) + protected function getIdentifierMessageFor($id = null, ?string $type = null) { $messagePrefix = '{ id: %s, type: "%s" }'; diff --git a/src/Testing/TestResponseMacros.php b/src/Testing/TestResponseMacros.php index c7ba8b7..4f3fd59 100644 --- a/src/Testing/TestResponseMacros.php +++ b/src/Testing/TestResponseMacros.php @@ -8,7 +8,7 @@ class TestResponseMacros { public function assertJsonApi() { - return function (Closure $callback = null) { + return function (?Closure $callback = null) { $assert = AssertableJsonApi::fromTestResponse($this); if ($callback === null) { diff --git a/src/VersionedConfig.php b/src/VersionedConfig.php new file mode 100644 index 0000000..ad3aff4 --- /dev/null +++ b/src/VersionedConfig.php @@ -0,0 +1,21 @@ +getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { + $configValues[$property->getName()] = $property->getValue($this); + } + + return []; + } +} diff --git a/stubs/markdown.mdx b/stubs/markdown.mdx new file mode 100644 index 0000000..9bf56c5 --- /dev/null +++ b/stubs/markdown.mdx @@ -0,0 +1,53 @@ +# {{ $title }} + +{{ $title }} resource endpoints documentation + +{{ $description }} + +@foreach ($endpoints as $endpoint) +## {{ $endpoint['title'] }} @php echo '{{ tag: \''.$endpoint['routeMethod'].'\', label: \''.$endpoint['routeUrl'].'\' }}'; @endphp + + + + {{ $description }} + + ### Optional attributes + + @foreach ($endpoint['query'] as $queryParam) + + {{ $queryParam['description'] }} Possible values are: {{ $queryParam['values'] }} + + @endforeach + + + + + + + ```bash @{{ title: 'cURL' }} + curl -G {{ $endpoint['routeFullUrl'] }} \ + -H "Authorization: Bearer {token}" + ``` + + + + ```json @{{ title: 'Response' }} + { + "data": [ + { + "id": "1", + "type": "{{ $name }}" + }, + // ... + ], + "meta": {}, + "links": {} + } + ``` + + + +@unless ($loop->last) +--- +@endunless +@endforeach From 5d806db0a2a641c0b4300644f0d8d92f4dfa4ebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rube=CC=81n=20Robles?= Date: Mon, 6 May 2024 18:06:14 +0200 Subject: [PATCH 06/10] add raw response format to JsonApiResponse --- src/Http/JsonApiResponse.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Http/JsonApiResponse.php b/src/Http/JsonApiResponse.php index 121ba45..f5b90e1 100644 --- a/src/Http/JsonApiResponse.php +++ b/src/Http/JsonApiResponse.php @@ -181,6 +181,7 @@ protected function serializeResponse(mixed $response): mixed return match ($requesterAccepts) { 'application/json' => $response instanceof Builder ? $response->simplePaginate() : $response, 'application/vnd.api+json' => Apiable::toJsonApi($response), + 'raw' => $response, default => throw new HttpException(406, 'Not acceptable response formatting'), }; } From b983377003b98bf214f3d60bb21097a21194fadd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rube=CC=81n=20Robles?= Date: Fri, 16 Aug 2024 10:25:55 +0200 Subject: [PATCH 07/10] change laravel-helpers with its new package name --- src/Http/ApplyFiltersToQuery.php | 2 +- src/Http/ApplyFulltextSearchToQuery.php | 2 +- src/Http/RequestQueryObject.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Http/ApplyFiltersToQuery.php b/src/Http/ApplyFiltersToQuery.php index 6f587fb..72d5225 100644 --- a/src/Http/ApplyFiltersToQuery.php +++ b/src/Http/ApplyFiltersToQuery.php @@ -11,7 +11,7 @@ use OpenSoutheners\LaravelApiable\Contracts\HandlesRequestQueries; use OpenSoutheners\LaravelApiable\Support\Apiable; -use function OpenSoutheners\LaravelHelpers\Classes\class_namespace; +use function OpenSoutheners\ExtendedPhp\Classes\class_namespace; class ApplyFiltersToQuery implements HandlesRequestQueries { diff --git a/src/Http/ApplyFulltextSearchToQuery.php b/src/Http/ApplyFulltextSearchToQuery.php index 68d7873..8616c58 100644 --- a/src/Http/ApplyFulltextSearchToQuery.php +++ b/src/Http/ApplyFulltextSearchToQuery.php @@ -5,7 +5,7 @@ use Closure; use OpenSoutheners\LaravelApiable\Contracts\HandlesRequestQueries; -use function OpenSoutheners\LaravelHelpers\Classes\class_use; +use function OpenSoutheners\ExtendedPhp\Classes\class_use; class ApplyFulltextSearchToQuery implements HandlesRequestQueries { diff --git a/src/Http/RequestQueryObject.php b/src/Http/RequestQueryObject.php index eeb05c5..69d4299 100644 --- a/src/Http/RequestQueryObject.php +++ b/src/Http/RequestQueryObject.php @@ -6,7 +6,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Collection; -use function OpenSoutheners\LaravelHelpers\Utils\parse_http_query; +use function OpenSoutheners\ExtendedPhp\Utils\parse_http_query; /** * @template T of \Illuminate\Database\Eloquent\Model From f15f142e87559821de4ef20953cd14e537806411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rube=CC=81n=20Robles?= Date: Fri, 16 Aug 2024 10:26:35 +0200 Subject: [PATCH 08/10] fix dependabot --- dependabot.yml => .github/dependabot.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) rename dependabot.yml => .github/dependabot.yml (85%) diff --git a/dependabot.yml b/.github/dependabot.yml similarity index 85% rename from dependabot.yml rename to .github/dependabot.yml index 73f11c1..d202a33 100644 --- a/dependabot.yml +++ b/.github/dependabot.yml @@ -1,8 +1,7 @@ version: 2 updates: - - package-ecosystem: "github-actions" directory: "/" schedule: # Check for updates to GitHub Actions every week - interval: "weekly" \ No newline at end of file + interval: "weekly" From 7840a23fc322e839989f2f2ca05fc1d10bdd26c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rube=CC=81n=20Robles?= Date: Fri, 16 Aug 2024 10:26:50 +0200 Subject: [PATCH 09/10] update publish CI actions --- .github/workflows/publish.yml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3575f0e..8523126 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,19 +13,15 @@ jobs: - name: Get release info id: query-release-info - uses: release-flow/keep-a-changelog-action@v2 + uses: release-flow/keep-a-changelog-action@v3 with: command: query version: latest - name: Publish to Github releases - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: body: ${{ steps.query-release-info.outputs.release-notes }} - # TODO: Check PR https://github.com/softprops/action-gh-release/pull/304 - # make_latest: ${{ $GITHUB_REF_NAME == 'main' && true || false }} - # TODO: Workaround for the above (semi-automatic workflow when non main releases): - # FIXME: See https://github.com/open-southeners/laravel-apiable/actions/runs/4016588356 - # draft: ${{ $GITHUB_REF_NAME != 'main' && true || false }} + make_latest: ${{ $GITHUB_REF_NAME == 'main' && true || false }} # prerelease: true - # files: '*.vsix' \ No newline at end of file + # files: '*.vsix' From 7fbd6c3f5840dd044ee817d77ad3e33a5231e33f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rube=CC=81n=20Robles?= Date: Fri, 16 Aug 2024 10:27:55 +0200 Subject: [PATCH 10/10] wip --- config/apiable.php | 9 +- src/Attributes/AppendsQueryParam.php | 4 +- src/Attributes/FieldsQueryParam.php | 4 +- .../Attributes/EndpointResource.php} | 7 +- src/Documentation/Endpoint.php | 53 +++++- src/Documentation/Generator.php | 2 +- src/Documentation/QueryParam.php | 1 + src/Http/AllowedFields.php | 4 +- src/Http/AllowedFilter.php | 2 +- src/Http/ApplyFieldsToQuery.php | 3 +- src/Http/Concerns/AllowsAppends.php | 4 +- src/Http/Concerns/AllowsFields.php | 4 +- .../Concerns/IteratesResultsAfterQuery.php | 10 +- src/Http/Concerns/ResolvesFromRouteAction.php | 13 +- src/Http/JsonApiResponse.php | 32 ++-- src/Http/Middleware/SerializesResponses.php | 21 +++ src/Http/QueryParamsValidator.php | 5 + src/Http/RequestQueryObject.php | 10 ++ src/Http/Resources/JsonApiResource.php | 3 +- src/ServiceProvider.php | 30 +++- src/Support/Apiable.php | 73 -------- src/Testing/AssertableJsonApi.php | 158 +++++++++++++++--- src/Testing/Concerns/HasCollections.php | 4 +- src/Testing/Concerns/HasIdentifications.php | 10 +- src/Testing/Concerns/HasRelationships.php | 12 +- src/Testing/TestResponseMacros.php | 3 + src/VersionedConfig.php | 21 --- tests/Http/JsonApiResponseTest.php | 16 +- 28 files changed, 319 insertions(+), 199 deletions(-) rename src/{Attributes/ResourceResponse.php => Documentation/Attributes/EndpointResource.php} (56%) create mode 100644 src/Http/Middleware/SerializesResponses.php delete mode 100644 src/VersionedConfig.php diff --git a/config/apiable.php b/config/apiable.php index 94b84b3..72eedf7 100644 --- a/config/apiable.php +++ b/config/apiable.php @@ -5,19 +5,14 @@ return [ - /** - * Resource type model map. - * - * @see https://docs.opensoutheners.com/laravel-apiable/guide/#getting-started - */ - 'resource_type_map' => [], - /** * Default options for request query filters, sorts, etc. * * @see https://docs.opensoutheners.com/laravel-apiable/guide/requests.html */ 'requests' => [ + 'validate' => ! ((bool) env('APIABLE_DEV_MODE', false)), + 'validate_params' => false, 'filters' => [ diff --git a/src/Attributes/AppendsQueryParam.php b/src/Attributes/AppendsQueryParam.php index f97388c..c80abe2 100644 --- a/src/Attributes/AppendsQueryParam.php +++ b/src/Attributes/AppendsQueryParam.php @@ -3,7 +3,7 @@ namespace OpenSoutheners\LaravelApiable\Attributes; use Attribute; -use OpenSoutheners\LaravelApiable\Support\Facades\Apiable; +use OpenSoutheners\LaravelApiable\ServiceProvider; #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final class AppendsQueryParam extends QueryParam @@ -19,6 +19,6 @@ public function getTypeAsResource(): string return $this->type; } - return Apiable::getResourceType($this->type); + return ServiceProvider::getTypeForModel($this->type); } } diff --git a/src/Attributes/FieldsQueryParam.php b/src/Attributes/FieldsQueryParam.php index f18cce6..2903ce4 100644 --- a/src/Attributes/FieldsQueryParam.php +++ b/src/Attributes/FieldsQueryParam.php @@ -3,7 +3,7 @@ namespace OpenSoutheners\LaravelApiable\Attributes; use Attribute; -use OpenSoutheners\LaravelApiable\Support\Facades\Apiable; +use OpenSoutheners\LaravelApiable\ServiceProvider; #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final class FieldsQueryParam extends QueryParam @@ -19,6 +19,6 @@ public function getTypeAsResource(): string return $this->type; } - return Apiable::getResourceType($this->type); + return ServiceProvider::getTypeForModel($this->type); } } diff --git a/src/Attributes/ResourceResponse.php b/src/Documentation/Attributes/EndpointResource.php similarity index 56% rename from src/Attributes/ResourceResponse.php rename to src/Documentation/Attributes/EndpointResource.php index 4b8893e..369a98c 100644 --- a/src/Attributes/ResourceResponse.php +++ b/src/Documentation/Attributes/EndpointResource.php @@ -1,8 +1,11 @@ $resource diff --git a/src/Documentation/Endpoint.php b/src/Documentation/Endpoint.php index ba33f75..91571bf 100644 --- a/src/Documentation/Endpoint.php +++ b/src/Documentation/Endpoint.php @@ -6,6 +6,12 @@ use Illuminate\Support\Str; use Illuminate\Support\Stringable; use OpenSoutheners\LaravelApiable\Http\JsonApiResponse; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode; +use PHPStan\PhpDocParser\Lexer\Lexer; +use PHPStan\PhpDocParser\Parser\ConstExprParser; +use PHPStan\PhpDocParser\Parser\PhpDocParser; +use PHPStan\PhpDocParser\Parser\TokenIterator; +use PHPStan\PhpDocParser\Parser\TypeParser; use ReflectionAttribute; use ReflectionClass; use ReflectionMethod; @@ -40,10 +46,38 @@ public static function fromMethodAttribute(ReflectionMethod $controllerMethod, R $attribute = $documentedEndpointAttribute->newInstance(); - return new self($resource, $route, $method, $attribute->title, $attribute->description); + return new self( + $resource, + $route, + $method, + $attribute->title, + $attribute->description ?: self::getDescriptionFromMethodDoc($controllerMethod->getDocComment()) + ); } - public static function fromResourceAction(Resource $resource, Route $route, string $method): ?self + protected static function getDescriptionFromMethodDoc(string $comment): string + { + $lexer = new Lexer(); + $constExprParser = new ConstExprParser(); + $typeParser = new TypeParser($constExprParser); + $phpDocParser = new PhpDocParser($typeParser, $constExprParser); + + $tokens = new TokenIterator( + $lexer->tokenize($comment) + ); + + $description = ''; + + foreach ($phpDocParser->parse($tokens)->children as $node) { + if ($node instanceof PhpDocTextNode) { + $description .= (string) $node; + } + } + + return $description; + } + + public static function fromResourceAction(ReflectionMethod $controllerMethod, Resource $resource, Route $route, string $method): ?self { $endpointResource = $resource->getName(); $endpointResourcePlural = Str::plural($endpointResource); @@ -59,6 +93,8 @@ public static function fromResourceAction(Resource $resource, Route $route, stri default => ['', ''] }; + $description = self::getDescriptionFromMethodDoc($controllerMethod) ?: $description; + return new self($resource, $route, $method, $title, $description); } @@ -95,10 +131,11 @@ public function toPostmanItem(): array $postmanItem = [ 'name' => $this->title, 'request' => [ - 'method' => $this->route->getActionMethod(), + 'description' => $this->description, + 'method' => $this->method, 'header' => [], + 'url' => [], ], - 'url' => [], 'response' => [], ]; @@ -122,11 +159,11 @@ public function toPostmanItem(): array )->value() ); - $postmanItem['url']['raw'] = "{{base_url}}/{$routeUriString->join('/')}"; - $postmanItem['url']['host'] = ['{{base_url}}']; - $postmanItem['url']['path'] = $routeUriString->toArray(); + $postmanItem['request']['url']['raw'] = "{{base_url}}/{$routeUriString->join('/')}"; + $postmanItem['request']['url']['host'] = ['{{base_url}}']; + $postmanItem['request']['url']['path'] = $routeUriString->toArray(); - $postmanItem['url']['query'] = array_map( + $postmanItem['request']['url']['query'] = array_map( fn (QueryParam $param): array => $param->toPostman(), $this->query ); diff --git a/src/Documentation/Generator.php b/src/Documentation/Generator.php index 1f83183..729ae3a 100644 --- a/src/Documentation/Generator.php +++ b/src/Documentation/Generator.php @@ -44,7 +44,7 @@ public function generate(): self foreach ($routeMethods as $routeMethod) { $endpoint = Endpoint::fromMethodAttribute($method, $resource, $route, $routeMethod) - ?? Endpoint::fromResourceAction($resource, $route, $routeMethod); + ?? Endpoint::fromResourceAction($method, $resource, $route, $routeMethod); $endpoint->getQueryFromAttributes($controller, $method); diff --git a/src/Documentation/QueryParam.php b/src/Documentation/QueryParam.php index 8428bbd..56817df 100644 --- a/src/Documentation/QueryParam.php +++ b/src/Documentation/QueryParam.php @@ -26,6 +26,7 @@ public static function fromAttribute(Attributes\QueryParam $attribute): self // $documentedRoute['name'] = $this->attribute->title; // $documentedRoute['description'] = $this->attribute->description; // }, + Attributes\FilterQueryParam::class => static::fromFilterAttribute($attribute), Attributes\FieldsQueryParam::class => static::fromFieldsAttribute($attribute), Attributes\AppendsQueryParam::class => static::fromAppendsAttribute($attribute), diff --git a/src/Http/AllowedFields.php b/src/Http/AllowedFields.php index bd2452a..b4b62e0 100644 --- a/src/Http/AllowedFields.php +++ b/src/Http/AllowedFields.php @@ -3,7 +3,7 @@ namespace OpenSoutheners\LaravelApiable\Http; use Illuminate\Contracts\Support\Arrayable; -use OpenSoutheners\LaravelApiable\Support\Facades\Apiable; +use OpenSoutheners\LaravelApiable\ServiceProvider; class AllowedFields implements Arrayable { @@ -22,7 +22,7 @@ class AllowedFields implements Arrayable */ public function __construct(string $type, string|array $attributes) { - $this->type = class_exists($type) ? Apiable::getResourceType($type) : $type; + $this->type = class_exists($type) ? ServiceProvider::getTypeForModel($type) : $type; $this->attributes = (array) $attributes; } diff --git a/src/Http/AllowedFilter.php b/src/Http/AllowedFilter.php index 1ecf8c3..6f3f213 100644 --- a/src/Http/AllowedFilter.php +++ b/src/Http/AllowedFilter.php @@ -141,7 +141,7 @@ public static function lowerOrEqualThan($attribute, $values = '*'): self public static function scoped($attribute, $values = '1'): self { return new self( - Apiable::config('requests.filters.enforce_scoped_names') ? Apiable::scopedFilterSuffix($attribute) : $attribute, + Apiable::config('requests.filters.enforce_scoped_names') ? "{$attribute}_scoped" : $attribute, static::SCOPE, $values ); diff --git a/src/Http/ApplyFieldsToQuery.php b/src/Http/ApplyFieldsToQuery.php index 75684d3..ff45778 100644 --- a/src/Http/ApplyFieldsToQuery.php +++ b/src/Http/ApplyFieldsToQuery.php @@ -5,6 +5,7 @@ use Closure; use Illuminate\Database\Eloquent\Builder; use OpenSoutheners\LaravelApiable\Contracts\HandlesRequestQueries; +use OpenSoutheners\LaravelApiable\ServiceProvider; use OpenSoutheners\LaravelApiable\Support\Facades\Apiable; class ApplyFieldsToQuery implements HandlesRequestQueries @@ -39,7 +40,7 @@ protected function applyFields(Builder $query, array $fields) { /** @var \OpenSoutheners\LaravelApiable\Contracts\JsonApiable|\Illuminate\Database\Eloquent\Model $mainQueryModel */ $mainQueryModel = $query->getModel(); - $mainQueryResourceType = Apiable::getResourceType($mainQueryModel); + $mainQueryResourceType = ServiceProvider::getTypeForModel($mainQueryModel); $queryEagerLoaded = $query->getEagerLoads(); // TODO: Move this to some class methods diff --git a/src/Http/Concerns/AllowsAppends.php b/src/Http/Concerns/AllowsAppends.php index 43c14a0..ba52d6e 100644 --- a/src/Http/Concerns/AllowsAppends.php +++ b/src/Http/Concerns/AllowsAppends.php @@ -5,7 +5,7 @@ use Exception; use Illuminate\Database\Eloquent\Model; use OpenSoutheners\LaravelApiable\Http\AllowedAppends; -use OpenSoutheners\LaravelApiable\Support\Facades\Apiable; +use OpenSoutheners\LaravelApiable\ServiceProvider; /** * @mixin \OpenSoutheners\LaravelApiable\Http\RequestQueryObject @@ -47,7 +47,7 @@ public function allowAppends(AllowedAppends|string $type, ?array $attributes = n } if (class_exists($type) && is_subclass_of($type, Model::class)) { - $type = Apiable::getResourceType($type); + $type = ServiceProvider::getTypeForModel($type); } $this->allowedAppends = array_merge($this->allowedAppends, [$type => (array) $attributes]); diff --git a/src/Http/Concerns/AllowsFields.php b/src/Http/Concerns/AllowsFields.php index 10b5548..16cf3ee 100644 --- a/src/Http/Concerns/AllowsFields.php +++ b/src/Http/Concerns/AllowsFields.php @@ -5,7 +5,7 @@ use Exception; use Illuminate\Database\Eloquent\Model; use OpenSoutheners\LaravelApiable\Http\AllowedFields; -use OpenSoutheners\LaravelApiable\Support\Facades\Apiable; +use OpenSoutheners\LaravelApiable\ServiceProvider; /** * @mixin \OpenSoutheners\LaravelApiable\Http\RequestQueryObject @@ -48,7 +48,7 @@ public function allowFields($type, $attributes = null): self } if (class_exists($type) && is_subclass_of($type, Model::class)) { - $type = Apiable::getResourceType($type); + $type = ServiceProvider::getTypeForModel($type); } $this->allowedFields = array_merge($this->allowedFields, [$type => (array) $attributes]); diff --git a/src/Http/Concerns/IteratesResultsAfterQuery.php b/src/Http/Concerns/IteratesResultsAfterQuery.php index 9703d20..3166460 100644 --- a/src/Http/Concerns/IteratesResultsAfterQuery.php +++ b/src/Http/Concerns/IteratesResultsAfterQuery.php @@ -6,6 +6,7 @@ use OpenSoutheners\LaravelApiable\Http\QueryParamsValidator; use OpenSoutheners\LaravelApiable\Http\Resources\JsonApiCollection; use OpenSoutheners\LaravelApiable\Http\Resources\JsonApiResource; +use OpenSoutheners\LaravelApiable\ServiceProvider; use OpenSoutheners\LaravelApiable\Support\Apiable; /** @@ -96,15 +97,20 @@ protected function appendToApiResource(mixed $resource, array $appends): void /** @var array<\OpenSoutheners\LaravelApiable\Http\Resources\JsonApiResource> $resourceIncluded */ $resourceIncluded = $resource->with['included'] ?? []; - $resourceType = Apiable::getResourceType($resource->resource); + $resourceType = ServiceProvider::getTypeForModel( + is_string($resource->resource) ? $resource->resource : get_class($resource->resource) + ); if ($appendsArr = $appends[$resourceType] ?? null) { $resource->resource->makeVisible($appendsArr)->append($appendsArr); } foreach ($resourceIncluded as $included) { - $includedResourceType = Apiable::getResourceType($included->resource); + $includedResourceType = ServiceProvider::getTypeForModel( + is_string($included->resource) ? $included->resource : get_class($included->resource) + ); + // dump($includedResourceType); if ($appendsArr = $appends[$includedResourceType] ?? null) { $included->resource->makeVisible($appendsArr)->append($appendsArr); } diff --git a/src/Http/Concerns/ResolvesFromRouteAction.php b/src/Http/Concerns/ResolvesFromRouteAction.php index 793a201..d4a87ca 100644 --- a/src/Http/Concerns/ResolvesFromRouteAction.php +++ b/src/Http/Concerns/ResolvesFromRouteAction.php @@ -11,7 +11,7 @@ use OpenSoutheners\LaravelApiable\Attributes\ForceAppendAttribute; use OpenSoutheners\LaravelApiable\Attributes\IncludeQueryParam; use OpenSoutheners\LaravelApiable\Attributes\QueryParam; -use OpenSoutheners\LaravelApiable\Attributes\ResourceResponse; +use OpenSoutheners\LaravelApiable\Documentation\Attributes\EndpointResource; use OpenSoutheners\LaravelApiable\Attributes\SearchFilterQueryParam; use OpenSoutheners\LaravelApiable\Attributes\SearchQueryParam; use OpenSoutheners\LaravelApiable\Attributes\SortQueryParam; @@ -53,10 +53,11 @@ protected function resolveFromRoute(): void */ protected function resolveAttributesFrom($reflected): void { - $allowedQueryParams = array_filter($reflected->getAttributes(), function (ReflectionAttribute $attribute) { - return is_subclass_of($attribute->getName(), QueryParam::class) - || in_array($attribute->getName(), [ApplyDefaultFilter::class, ApplyDefaultSort::class]); - }); + $allowedQueryParams = array_filter( + $reflected->getAttributes(), + fn (ReflectionAttribute $attribute): bool => is_subclass_of($attribute->getName(), QueryParam::class) + || in_array($attribute->getName(), [EndpointResource::class, ApplyDefaultFilter::class, ApplyDefaultSort::class]) + ); foreach ($allowedQueryParams as $allowedQueryParam) { $attributeInstance = $allowedQueryParam->newInstance(); @@ -72,7 +73,7 @@ protected function resolveAttributesFrom($reflected): void AppendsQueryParam::class => $this->allowAppends($attributeInstance->type, $attributeInstance->attributes), ApplyDefaultSort::class => $this->applyDefaultSort($attributeInstance->attribute, $attributeInstance->direction), ApplyDefaultFilter::class => $this->applyDefaultFilter($attributeInstance->attribute, $attributeInstance->operator, $attributeInstance->values), - ResourceResponse::class => $this->using($attributeInstance->resource), + EndpointResource::class => $this->using($attributeInstance->resource), default => null, }; } diff --git a/src/Http/JsonApiResponse.php b/src/Http/JsonApiResponse.php index f5b90e1..6f8e545 100644 --- a/src/Http/JsonApiResponse.php +++ b/src/Http/JsonApiResponse.php @@ -11,9 +11,9 @@ use Illuminate\Http\Request; use Illuminate\Pipeline\Pipeline; use Illuminate\Support\Facades\App; -use Illuminate\Support\Traits\ForwardsCalls; use OpenSoutheners\LaravelApiable\Contracts\ViewableBuilder; use OpenSoutheners\LaravelApiable\Contracts\ViewQueryable; +use OpenSoutheners\LaravelApiable\ServiceProvider; use OpenSoutheners\LaravelApiable\Support\Facades\Apiable; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -29,7 +29,6 @@ class JsonApiResponse extends RequestQueryObject implements Arrayable, Responsab { use Concerns\IteratesResultsAfterQuery; use Concerns\ResolvesFromRouteAction; - use ForwardsCalls; protected Pipeline $pipeline; @@ -108,8 +107,18 @@ protected function buildPipeline(): self /** * Get single resource from response. + * + * @deprecated use single() instead */ public function gettingOne(): self + { + return $this->single(); + } + + /** + * Get single resource from response. + */ + public function single(): self { $this->singleResourceResponse = true; @@ -129,17 +138,18 @@ public function includeAllowedToResponse(?bool $value = true): self /** * Get results from processing RequestQueryObject pipeline. */ - public function getResults(Guard $guard): mixed + public function getResults(): mixed { $query = $this->buildPipeline()->query; if ( - Apiable::config('responses.viewable') + app()->bound(Guard::class) + && Apiable::config('responses.viewable') && (is_a($this->model, ViewQueryable::class, true) || is_a($query, ViewableBuilder::class)) ) { /** @var \OpenSoutheners\LaravelApiable\Contracts\ViewableBuilder $query */ - $query->viewable($guard->user()); + $query->viewable(app(Guard::class)->user()); } return $this->resultPostProcessing( @@ -206,7 +216,7 @@ protected function withinInertia($request): bool public function toResponse($request): mixed { /** @var \Illuminate\Contracts\Support\Responsable|mixed $results */ - $results = App::call([$this, 'getResults']); + $results = $this->getResults(); $response = $results instanceof Responsable ? $results->toResponse($request) @@ -246,7 +256,7 @@ public function forceAppend(string|array $type, array $attributes = []): self $type = $this->model; } - $resourceType = class_exists($type) ? Apiable::getResourceType($type) : $type; + $resourceType = class_exists($type) ? ServiceProvider::getTypeForModel($type) : $type; $this->forceAppends = array_merge_recursive($this->forceAppends, [$resourceType => $attributes]); @@ -300,12 +310,4 @@ public function forceFormatting(?string $format = null): self return $this; } - - /** - * Call methods of the underlying query builder if not exists on this. - */ - public function __call(string $name, array $arguments): mixed - { - return $this->forwardDecoratedCallTo($this->query, $name, $arguments); - } } diff --git a/src/Http/Middleware/SerializesResponses.php b/src/Http/Middleware/SerializesResponses.php new file mode 100644 index 0000000..faff26f --- /dev/null +++ b/src/Http/Middleware/SerializesResponses.php @@ -0,0 +1,21 @@ +params; + } + $filteredResults = []; foreach ($this->params as $key => $values) { diff --git a/src/Http/RequestQueryObject.php b/src/Http/RequestQueryObject.php index 69d4299..a292d54 100644 --- a/src/Http/RequestQueryObject.php +++ b/src/Http/RequestQueryObject.php @@ -6,6 +6,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Collection; +use Illuminate\Support\Traits\ForwardsCalls; use function OpenSoutheners\ExtendedPhp\Utils\parse_http_query; /** @@ -20,6 +21,7 @@ class RequestQueryObject use Concerns\AllowsSearch; use Concerns\AllowsSorts; use Concerns\ValidatesParams; + use ForwardsCalls; /** * @var \Illuminate\Database\Eloquent\Builder @@ -117,4 +119,12 @@ public function allowing(array $alloweds): self return $this; } + + /** + * Call methods of the underlying query builder if not exists on this. + */ + public function __call(string $name, array $arguments): mixed + { + return $this->forwardDecoratedCallTo($this->query, $name, $arguments); + } } diff --git a/src/Http/Resources/JsonApiResource.php b/src/Http/Resources/JsonApiResource.php index 881a38e..20753b3 100644 --- a/src/Http/Resources/JsonApiResource.php +++ b/src/Http/Resources/JsonApiResource.php @@ -5,6 +5,7 @@ use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\MissingValue; use OpenSoutheners\LaravelApiable\Http\Request; +use OpenSoutheners\LaravelApiable\ServiceProvider; use OpenSoutheners\LaravelApiable\Support\Facades\Apiable; /** @@ -75,7 +76,7 @@ public function getResourceIdentifier() { return [ $this->resource->getKeyName() => (string) $this->resource->getKey(), - 'type' => Apiable::getResourceType($this->resource), + 'type' => ServiceProvider::getTypeForModel(get_class($this->resource)), ]; } diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 65391ea..bdfe1c2 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -3,11 +3,17 @@ namespace OpenSoutheners\LaravelApiable; use Illuminate\Support\ServiceProvider as BaseServiceProvider; +use Illuminate\Support\Str; use OpenSoutheners\LaravelApiable\Console\ApiableDocgenCommand; use OpenSoutheners\LaravelApiable\Support\Apiable; class ServiceProvider extends BaseServiceProvider { + /** + * @var array, string> + */ + protected static array $customModelTypes = []; + /** * Bootstrap any application services. * @@ -15,10 +21,6 @@ class ServiceProvider extends BaseServiceProvider */ public function boot() { - if (! empty(Apiable::config('resource_type_map'))) { - Apiable::modelResourceTypeMap(Apiable::config('resource_type_map')); - } - if ($this->app->runningInConsole()) { $this->publishes([ __DIR__.'/../config/apiable.php' => config_path('apiable.php'), @@ -54,4 +56,24 @@ public function registerMacros() \Illuminate\Database\Eloquent\Builder::mixin(new \OpenSoutheners\LaravelApiable\Builder()); \Illuminate\Support\Collection::mixin(new \OpenSoutheners\LaravelApiable\Collection()); } + + /** + * Register a custom JSON:API model type. + * + * @param class-string<\Illuminate\Database\Eloquent\Model> $model + */ + public static function registerModelType(string $model, string $type): void + { + self::$customModelTypes[$model] = $type; + } + + /** + * Get JSON:API type for model. + * + * @param class-string<\Illuminate\Database\Eloquent\Model> $model + */ + public static function getTypeForModel(string $model): string + { + return self::$customModelTypes[$model] ?? Str::snake(class_basename($model)); + } } diff --git a/src/Support/Apiable.php b/src/Support/Apiable.php index 88fa5ae..09cd85e 100644 --- a/src/Support/Apiable.php +++ b/src/Support/Apiable.php @@ -6,9 +6,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Resources\MissingValue; use Illuminate\Pagination\AbstractPaginator; -use Illuminate\Support\Arr; use Illuminate\Support\Collection; -use Illuminate\Support\Str; use OpenSoutheners\LaravelApiable\Contracts\JsonApiable; use OpenSoutheners\LaravelApiable\Handler; use OpenSoutheners\LaravelApiable\Http\JsonApiResponse; @@ -18,11 +16,6 @@ class Apiable { - /** - * @var array, string> - */ - protected static $modelResourceTypeMap = []; - /** * Get package prefixed config by key. */ @@ -46,27 +39,6 @@ public static function toJsonApi(mixed $resource): JsonApiResource|JsonApiCollec }; } - /** - * Determine default resource type from giving model. - * - * @param \Illuminate\Database\Eloquent\Model|class-string<\Illuminate\Database\Eloquent\Model> $model - */ - public static function resourceTypeForModel(Model|string $model): string - { - return Str::snake(class_basename(is_string($model) ? $model : get_class($model))); - } - - /** - * Get resource type from a model. - * - * @param \Illuminate\Database\Eloquent\Model|class-string<\Illuminate\Database\Eloquent\Model> $model - */ - public static function getResourceType(Model|string $model): string - { - return static::$modelResourceTypeMap[is_string($model) ? $model : get_class($model)] - ?? static::resourceTypeForModel($model); - } - /** * Transforms error rendering to a JSON:API complaint error response. */ @@ -94,51 +66,6 @@ public static function response($query, array $alloweds = []): JsonApiResponse return $response; } - /** - * Add models to JSON:API types mapping to the application. - * - * @param array>|array, string> $models - * @return void - */ - public static function modelResourceTypeMap(array $models = []) - { - if (! Arr::isAssoc($models)) { - $models = array_map(fn ($model) => static::resourceTypeForModel($model), $models); - } - - static::$modelResourceTypeMap = $models; - } - - /** - * Get models to JSON:API types mapping. - * - * @return array, string> - */ - public static function getModelResourceTypeMap() - { - return static::$modelResourceTypeMap; - } - - /** - * Get model class from given resource type. - * - * @return \Illuminate\Database\Eloquent\Model|false - */ - public static function getModelFromResourceType(string $type) - { - return array_flip(static::$modelResourceTypeMap)[$type] ?? false; - } - - /** - * Add suffix to filter attribute/scope name. - * - * @return string - */ - public static function scopedFilterSuffix(string $value) - { - return "{$value}_scoped"; - } - /** * Force responses to be formatted in a specific format type. * diff --git a/src/Testing/AssertableJsonApi.php b/src/Testing/AssertableJsonApi.php index 36591ab..8ede995 100644 --- a/src/Testing/AssertableJsonApi.php +++ b/src/Testing/AssertableJsonApi.php @@ -2,27 +2,51 @@ namespace OpenSoutheners\LaravelApiable\Testing; +use Closure; +use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Support\Arr; +use Illuminate\Support\Traits\Conditionable; use Illuminate\Support\Traits\Macroable; -use Illuminate\Testing\Fluent\AssertableJson; -use OpenSoutheners\LaravelApiable\Testing\Concerns\HasAttributes; -use OpenSoutheners\LaravelApiable\Testing\Concerns\HasCollections; -use OpenSoutheners\LaravelApiable\Testing\Concerns\HasIdentifications; -use OpenSoutheners\LaravelApiable\Testing\Concerns\HasRelationships; +use Illuminate\Support\Traits\Tappable; +use Illuminate\Testing\Fluent\Concerns\Debugging; +use Illuminate\Testing\Fluent\Concerns\Has; +use Illuminate\Testing\Fluent\Concerns\Interaction; +use Illuminate\Testing\Fluent\Concerns\Matching; use PHPUnit\Framework\Assert as PHPUnit; use PHPUnit\Framework\AssertionFailedError; -class AssertableJsonApi extends AssertableJson +class AssertableJsonApi implements Arrayable { - use HasAttributes; - use HasCollections; - use HasIdentifications; - use HasRelationships; - use Macroable; + use Concerns\HasAttributes, + Concerns\HasCollections, + Concerns\HasIdentifications, + Concerns\HasRelationships, + Conditionable, + Debugging, + Has, + Interaction, + Macroable, + Matching, + Tappable; /** * @var array */ - protected $collection; + private $collection; + + /** + * The properties in the current scope. + * + * @var array + */ + private $props; + + /** + * The "dot" path to the current scope. + * + * @var string|null + */ + private $path; protected function __construct($id = '', $type = '', array $attributes = [], array $relationships = [], array $includeds = [], array $collection = []) { @@ -33,10 +57,15 @@ protected function __construct($id = '', $type = '', array $attributes = [], arr $this->relationships = $relationships; $this->includeds = $includeds; + $this->props = array_merge($attributes, $includeds); + $this->collection = $collection; } - public static function fromTestResponse($response) + /** + * @param \Illuminate\Http\Response $response + */ + public static function fromTestResponse($response): self { try { $content = json_decode($response->getContent(), true); @@ -49,44 +78,122 @@ public static function fromTestResponse($response) $data = head($data); } + PHPUnit::assertTrue($response->headers->get('content-type', '') === 'application/vnd.api+json'); PHPUnit::assertIsArray($data); PHPUnit::assertArrayHasKey('id', $data); PHPUnit::assertArrayHasKey('type', $data); PHPUnit::assertArrayHasKey('attributes', $data); PHPUnit::assertIsArray($data['attributes']); } catch (AssertionFailedError $e) { - PHPUnit::fail('Not a valid JSON:API response or response data is empty.'); + PHPUnit::fail('Not a valid JSON:API response or data is empty.'); } return new self($data['id'], $data['type'], $data['attributes'], $data['relationships'] ?? [], $content['included'] ?? [], $collection); } /** - * Get the instance as an array. + * Compose the absolute "dot" path to the given key. + */ + protected function dotPath(string $key = ''): string + { + if (is_null($this->path)) { + return $key; + } + + return rtrim(implode('.', [$this->path, $key]), '.'); + } + + /** + * Retrieve a prop within the current scope using "dot" notation. * - * @return array + * @return mixed + */ + protected function prop(?string $key = null) + { + return Arr::get($this->props, $key); + } + + /** + * Instantiate a new "scope" at the path of the given key. + */ + protected function scope(string $key, Closure $callback): self + { + $props = $this->prop($key); + $path = $this->dotPath($key); + + PHPUnit::assertIsArray($props, sprintf('Property [%s] is not scopeable.', $path)); + + $scope = new static($props, $path); + $callback($scope); + $scope->interacted(); + + return $this; + } + + /** + * Instantiate a new "scope" on the first child element. + */ + public function first(Closure $callback): self + { + $props = $this->prop(); + + $path = $this->dotPath(); + + PHPUnit::assertNotEmpty($props, $path === '' + ? 'Cannot scope directly onto the first element of the root level because it is empty.' + : sprintf('Cannot scope directly onto the first element of property [%s] because it is empty.', $path) + ); + + $key = array_keys($props)[0]; + + $this->interactsWith($key); + + return $this->scope($key, $callback); + } + + /** + * Instantiate a new "scope" on each child element. + */ + public function each(Closure $callback): self + { + $props = $this->prop(); + + $path = $this->dotPath(); + + PHPUnit::assertNotEmpty($props, $path === '' + ? 'Cannot scope directly onto each element of the root level because it is empty.' + : sprintf('Cannot scope directly onto each element of property [%s] because it is empty.', $path) + ); + + foreach (array_keys($props) as $key) { + $this->interactsWith($key); + + $this->scope($key, $callback); + } + + return $this; + } + + /** + * Get the instance as an array. */ - public function toArray() + public function toArray(): array { return $this->attributes; } /** * Check if data contains a collection of resources. - * - * @return bool */ - public static function responseContainsCollection(array $data = []) + public static function responseContainsCollection(array $data = []): bool { return ! array_key_exists('attributes', $data); } /** * Assert that actual response is a resource - * - * @return $this */ - public function isResource() + public function isResource(): self { PHPUnit::assertEmpty($this->collection, 'Failed asserting that response is a resource'); @@ -95,11 +202,8 @@ public function isResource() /** * Get the identifier in a pretty printable message by id and type. - * - * @param mixed $id - * @return string */ - protected function getIdentifierMessageFor($id = null, ?string $type = null) + protected function getIdentifierMessageFor(mixed $id = null, ?string $type = null): string { $messagePrefix = '{ id: %s, type: "%s" }'; diff --git a/src/Testing/Concerns/HasCollections.php b/src/Testing/Concerns/HasCollections.php index aa92a28..c916cd9 100644 --- a/src/Testing/Concerns/HasCollections.php +++ b/src/Testing/Concerns/HasCollections.php @@ -29,9 +29,9 @@ public function isCollection() /** * Get resource based on its zero-based position in the collection. * - * @return \OpenSoutheners\LaravelApiable\Testing\AssertableJsonApi + * @deprecated Use first() instead */ - public function at(int $position) + public function at(int $position): self { if (! array_key_exists($position, $this->collection)) { PHPUnit::fail(sprintf('There is no item at position "%d" on the collection response.', $position)); diff --git a/src/Testing/Concerns/HasIdentifications.php b/src/Testing/Concerns/HasIdentifications.php index 2a7afe0..b54c28b 100644 --- a/src/Testing/Concerns/HasIdentifications.php +++ b/src/Testing/Concerns/HasIdentifications.php @@ -2,6 +2,8 @@ namespace OpenSoutheners\LaravelApiable\Testing\Concerns; +use Illuminate\Database\Eloquent\Model; +use OpenSoutheners\LaravelApiable\Support\Facades\Apiable; use PHPUnit\Framework\Assert as PHPUnit; /** @@ -37,11 +39,15 @@ public function hasId($value) /** * Check that a resource has the specified type. * - * @param mixed $value + * @param class-string<\Illuminate\Database\Eloquent\Model> $value * @return $this */ - public function hasType($value) + public function hasType(string $value) { + if (class_exists($value) && is_a($value, Model::class, true)) { + $value = Apiable::getResourceType($value); + } + PHPUnit::assertSame($this->type, $value, sprintf('JSON:API response does not have type "%s"', $value)); return $this; diff --git a/src/Testing/Concerns/HasRelationships.php b/src/Testing/Concerns/HasRelationships.php index 869a0cf..95eb1ea 100644 --- a/src/Testing/Concerns/HasRelationships.php +++ b/src/Testing/Concerns/HasRelationships.php @@ -3,7 +3,7 @@ namespace OpenSoutheners\LaravelApiable\Testing\Concerns; use Illuminate\Database\Eloquent\Model; -use OpenSoutheners\LaravelApiable\Support\Facades\Apiable; +use OpenSoutheners\LaravelApiable\ServiceProvider; use PHPUnit\Framework\Assert as PHPUnit; /** @@ -29,7 +29,7 @@ trait HasRelationships public function atRelation(Model $model) { $item = head(array_filter($this->includeds, function ($included) use ($model) { - return $included['type'] === Apiable::getResourceType($model) && $included['id'] == $model->getKey(); + return $included['type'] === ServiceProvider::getTypeForModel($model) && $included['id'] == $model->getKey(); })); return new self($item['id'], $item['type'], $item['attributes'], $item['relationships'] ?? [], $this->includeds); @@ -44,7 +44,7 @@ public function atRelation(Model $model) */ public function hasAnyRelationships($name, $withIncluded = false) { - $type = Apiable::getResourceType($name); + $type = ServiceProvider::getTypeForModel($name); PHPUnit::assertTrue( count($this->filterResources($this->relationships, $type)) > 0, @@ -70,7 +70,7 @@ public function hasAnyRelationships($name, $withIncluded = false) */ public function hasNotAnyRelationships($name, $withIncluded = false) { - $type = Apiable::getResourceType($name); + $type = ServiceProvider::getTypeForModel($name); PHPUnit::assertFalse( count($this->filterResources($this->relationships, $type)) > 0, @@ -95,7 +95,7 @@ public function hasNotAnyRelationships($name, $withIncluded = false) */ public function hasRelationshipWith(Model $model, $withIncluded = false) { - $type = Apiable::getResourceType($model); + $type = ServiceProvider::getTypeForModel($model); PHPUnit::assertTrue( count($this->filterResources($this->relationships, $type, $model->getKey())) > 0, @@ -120,7 +120,7 @@ public function hasRelationshipWith(Model $model, $withIncluded = false) */ public function hasNotRelationshipWith(Model $model, $withIncluded = false) { - $type = Apiable::getResourceType($model); + $type = ServiceProvider::getTypeForModel($model); PHPUnit::assertFalse( count($this->filterResources($this->relationships, $type, $model->getKey())) > 0, diff --git a/src/Testing/TestResponseMacros.php b/src/Testing/TestResponseMacros.php index 4f3fd59..6dbf6b5 100644 --- a/src/Testing/TestResponseMacros.php +++ b/src/Testing/TestResponseMacros.php @@ -4,6 +4,9 @@ use Closure; +/** + * @mixin \Illuminate\Testing\TestResponse + */ class TestResponseMacros { public function assertJsonApi() diff --git a/src/VersionedConfig.php b/src/VersionedConfig.php deleted file mode 100644 index ad3aff4..0000000 --- a/src/VersionedConfig.php +++ /dev/null @@ -1,21 +0,0 @@ -getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { - $configValues[$property->getName()] = $property->getValue($this); - } - - return []; - } -} diff --git a/tests/Http/JsonApiResponseTest.php b/tests/Http/JsonApiResponseTest.php index e10c361..9f09982 100644 --- a/tests/Http/JsonApiResponseTest.php +++ b/tests/Http/JsonApiResponseTest.php @@ -321,8 +321,8 @@ public function testSortingBelongsToRelationshipFieldAsDescendant() $response->assertJsonApi(function (AssertableJsonApi $assert) { $assert->isCollection(); - $assert->at(0)->hasAttribute('title', 'Hello world'); - $assert->at(1)->hasAttribute('title', 'Y esto en español'); + $assert->first(fn (AssertableJsonApi $assert) => $assert->hasAttribute('title', 'Hello world')); + $assert->first(fn (AssertableJsonApi $assert) => $assert->hasAttribute('title', 'Y esto en español')); }); } @@ -380,9 +380,9 @@ public function testResponseAsArrayGetsAllContent() config(['apiable.responses.include_allowed' => true]); Route::get('/', function () { - return response()->json(JsonApiResponse::from(Post::with('tags'))->allowing([ + return JsonApiResponse::from(Post::with('tags'))->allowing([ AllowedFilter::exact('status', ['Active', 'Archived']), - ])); + ]); }); $response = $this->get('/', ['Accept' => 'application/vnd.api+json']); @@ -428,9 +428,7 @@ public function testResponseAsArrayGetsAllContent() public function testResponseWithModifiedQueryWithCountMethodGetsRelationshipsCountsAsAttribute() { Route::get('/', function () { - return response()->json( - JsonApiResponse::from(Post::query()->withCount('tags')) - ); + return JsonApiResponse::from(Post::query()->withCount('tags')); }); $response = $this->get('/', ['Accept' => 'application/vnd.api+json']); @@ -444,9 +442,7 @@ public function testResponseWithModifiedQueryWithCountMethodGetsRelationshipsCou public function testResponseWithAllowedIncludedEndsWithCountGetsRelationshipCountAsAttribute() { Route::get('/', function () { - return response()->json( - JsonApiResponse::from(Post::class)->allowInclude(['tags_count']) - ); + return JsonApiResponse::from(Post::class)->allowInclude(['tags_count']); }); $response = $this->get('/?include=tags_count', ['Accept' => 'application/vnd.api+json']);