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" 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' 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..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' => [ @@ -54,4 +49,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 f3777b7..c80abe2 100644 --- a/src/Attributes/AppendsQueryParam.php +++ b/src/Attributes/AppendsQueryParam.php @@ -3,12 +3,22 @@ namespace OpenSoutheners\LaravelApiable\Attributes; use Attribute; +use OpenSoutheners\LaravelApiable\ServiceProvider; #[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 = '') { // } + + public function getTypeAsResource(): string + { + if (! str_contains($this->type, '\\')) { + return $this->type; + } + + return ServiceProvider::getTypeForModel($this->type); + } } diff --git a/src/Attributes/FieldsQueryParam.php b/src/Attributes/FieldsQueryParam.php index ae7278d..2903ce4 100644 --- a/src/Attributes/FieldsQueryParam.php +++ b/src/Attributes/FieldsQueryParam.php @@ -3,12 +3,22 @@ namespace OpenSoutheners\LaravelApiable\Attributes; use Attribute; +use OpenSoutheners\LaravelApiable\ServiceProvider; #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final class FieldsQueryParam extends QueryParam { - public function __construct(public string $type, public array $fields) + public function __construct(public string $type, public array $fields, public string $description = '') { // } + + public function getTypeAsResource(): string + { + if (! str_contains($this->type, '\\')) { + return $this->type; + } + + return ServiceProvider::getTypeForModel($this->type); + } } diff --git a/src/Attributes/FilterQueryParam.php b/src/Attributes/FilterQueryParam.php index ac03a70..e53b178 100644 --- a/src/Attributes/FilterQueryParam.php +++ b/src/Attributes/FilterQueryParam.php @@ -3,12 +3,71 @@ 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)] final class FilterQueryParam extends QueryParam { - public function __construct(public string $attribute, public int|array|null $type = null, public $values = '*') - { + public function __construct( + public string $attribute, + public int|array|null $type = null, + public string|array|QueryParamValueType $values = '*', + public string $description = '' + ) { // } + + public function getDataType(): QueryParamValueType|array + { + if ($this->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/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 @@ +askForExportFormat(); + + $this->generator->generate(); + + // TODO: Auth event with Sanctum or Passport? + match ($exportFormat) { + 'postman' => $this->exportEndpointsToPostman(), + 'markdown' => $this->exportEndpointsToMarkdown(), + 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; + } + + public function exportEndpointsToMarkdown() + { + $this->files = array_merge($this->files, $this->generator->toMarkdown()); + } + + // TODO: Update with new array data structure from fetchRoutes + protected function exportEndpointsToPostman(): void + { + $this->files[config('apiable.documentation.postman.base_path').'/documentation.postman_collection.json'] = $this->generator->toPostmanCollection(); + } + + 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 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/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 @@ + $resource + */ + public function __construct(public string $resource) + { + // + } +} diff --git a/src/Documentation/Endpoint.php b/src/Documentation/Endpoint.php new file mode 100644 index 0000000..91571bf --- /dev/null +++ b/src/Documentation/Endpoint.php @@ -0,0 +1,190 @@ + $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 ?: self::getDescriptionFromMethodDoc($controllerMethod->getDocComment()) + ); + } + + 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); + + $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 => ['', ''] + }; + + $description = self::getDescriptionFromMethodDoc($controllerMethod) ?: $description; + + 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' => [ + 'description' => $this->description, + 'method' => $this->method, + '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['request']['url']['raw'] = "{{base_url}}/{$routeUriString->join('/')}"; + $postmanItem['request']['url']['host'] = ['{{base_url}}']; + $postmanItem['request']['url']['path'] = $routeUriString->toArray(); + + $postmanItem['request']['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..729ae3a --- /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($method, $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..56817df --- /dev/null +++ b/src/Documentation/QueryParam.php @@ -0,0 +1,148 @@ + $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/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 5d4910c..6f3f213 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( @@ -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/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/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/ApplyFiltersToQuery.php b/src/Http/ApplyFiltersToQuery.php index ef3d3e2..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 { @@ -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/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/Concerns/AllowsAppends.php b/src/Http/Concerns/AllowsAppends.php index 5108db8..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 @@ -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()); @@ -45,7 +47,7 @@ public function allowAppends(AllowedAppends|string $type, array $attributes = nu } 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]); @@ -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..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 @@ -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()); @@ -49,7 +48,7 @@ public function allowFields($type, $attributes = null) } 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]); @@ -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 d33f831..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 @@ -28,36 +27,7 @@ trait AllowsFilters */ public function filters(): array { - $queryStringArr = explode('&', $this->request->server('QUERY_STRING', '')); - $filters = []; - - foreach ($queryStringArr as $param) { - $filterQueryParam = HeaderUtils::parseQuery($param); - - if (! is_array(head($filterQueryParam))) { - continue; - } - - $filterQueryParamAttribute = head(array_keys($filterQueryParam)); - - if ($filterQueryParamAttribute !== 'filter') { - continue; - } - - $filterQueryParam = head($filterQueryParam); - $filterQueryParamAttribute = head(array_keys($filterQueryParam)); - $filterQueryParamValue = head(array_values($filterQueryParam)); - - if (! isset($filters[$filterQueryParamAttribute])) { - $filters[$filterQueryParamAttribute] = [$filterQueryParamValue]; - - continue; - } - - $filters[$filterQueryParamAttribute][] = $filterQueryParamValue; - } - - return $filters; + return $this->queryParameters()->get('filter', []); } /** @@ -126,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/AllowsSorts.php b/src/Http/Concerns/AllowsSorts.php index 0c514f6..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->request->get('sort', ''))); + $sortsSourceArr = array_filter(explode(',', $this->queryParameters()->get('sort', [''])[0])); $sortsArr = []; while ($sort = array_pop($sortsSourceArr)) { diff --git a/src/Http/Concerns/IteratesResultsAfterQuery.php b/src/Http/Concerns/IteratesResultsAfterQuery.php index f010908..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; /** @@ -15,11 +16,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 +31,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 +53,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 +88,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; @@ -101,15 +97,20 @@ protected function appendToApiResource(mixed $resource, array $appends) /** @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 27e7b95..d4a87ca 100644 --- a/src/Http/Concerns/ResolvesFromRouteAction.php +++ b/src/Http/Concerns/ResolvesFromRouteAction.php @@ -8,8 +8,10 @@ use OpenSoutheners\LaravelApiable\Attributes\ApplyDefaultSort; use OpenSoutheners\LaravelApiable\Attributes\FieldsQueryParam; use OpenSoutheners\LaravelApiable\Attributes\FilterQueryParam; +use OpenSoutheners\LaravelApiable\Attributes\ForceAppendAttribute; use OpenSoutheners\LaravelApiable\Attributes\IncludeQueryParam; use OpenSoutheners\LaravelApiable\Attributes\QueryParam; +use OpenSoutheners\LaravelApiable\Documentation\Attributes\EndpointResource; use OpenSoutheners\LaravelApiable\Attributes\SearchFilterQueryParam; use OpenSoutheners\LaravelApiable\Attributes\SearchQueryParam; use OpenSoutheners\LaravelApiable\Attributes\SortQueryParam; @@ -24,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(); @@ -50,28 +50,30 @@ 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) - || 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(); - match (true) { - $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), + EndpointResource::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..6f8e545 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; @@ -12,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; @@ -22,18 +21,17 @@ /** * @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; - use ForwardsCalls; protected Pipeline $pipeline; - protected ?RequestQueryObject $request; - /** * @var class-string|class-string<\OpenSoutheners\LaravelApiable\Contracts\ViewQueryable> */ @@ -55,9 +53,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 +84,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, @@ -117,8 +107,18 @@ protected function buildPipeline() /** * 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; @@ -138,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( @@ -181,16 +182,16 @@ 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'); } 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'), }; } @@ -215,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) @@ -255,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]); @@ -303,18 +304,10 @@ 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); return $this; } - - /** - * Call method of RequestQueryObject if not exists on this. - */ - public function __call(string $name, array $arguments): mixed - { - return $this->forwardDecoratedCallTo($this->request, $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 5fcdf66..a292d54 100644 --- a/src/Http/RequestQueryObject.php +++ b/src/Http/RequestQueryObject.php @@ -2,9 +2,12 @@ namespace OpenSoutheners\LaravelApiable\Http; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Collection; -use Symfony\Component\HttpFoundation\HeaderUtils; + +use Illuminate\Support\Traits\ForwardsCalls; +use function OpenSoutheners\ExtendedPhp\Utils\parse_http_query; /** * @template T of \Illuminate\Database\Eloquent\Model @@ -18,11 +21,12 @@ class RequestQueryObject use Concerns\AllowsSearch; use Concerns\AllowsSorts; use Concerns\ValidatesParams; + use ForwardsCalls; /** * @var \Illuminate\Database\Eloquent\Builder */ - public $query; + public Builder $query; /** * @var \Illuminate\Support\Collection<(int|string), array>|null @@ -32,7 +36,7 @@ class RequestQueryObject /** * Construct the request query object. */ - public function __construct(protected Request $request) + public function __construct(public Request $request) { // } @@ -40,9 +44,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; @@ -58,25 +62,13 @@ public function queryParameters(): Collection { if (! $this->queryParameters) { $this->queryParameters = Collection::make( - 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()); + parse_http_query($this->request->server('QUERY_STRING')) + ); } return $this->queryParameters; } - /** - * Get the underlying request object. - */ - public function getRequest(): Request - { - return $this->request; - } - /** * Allows the following user operations. */ @@ -127,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/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/ServiceProvider.php b/src/ServiceProvider.php index a9374db..bdfe1c2 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -3,10 +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. * @@ -14,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'), @@ -37,6 +40,8 @@ public function register() }); $this->registerMacros(); + + $this->commands([ApiableDocgenCommand::class]); } /** @@ -51,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 6119ef8..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,31 +39,10 @@ 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. */ - 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); } @@ -94,57 +66,12 @@ 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. * * @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..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 c7ba8b7..6dbf6b5 100644 --- a/src/Testing/TestResponseMacros.php +++ b/src/Testing/TestResponseMacros.php @@ -4,11 +4,14 @@ use Closure; +/** + * @mixin \Illuminate\Testing\TestResponse + */ 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/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 diff --git a/tests/Http/JsonApiResponseTest.php b/tests/Http/JsonApiResponseTest.php index 77b7bff..9f09982 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() @@ -320,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')); }); } @@ -379,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']); @@ -427,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']); @@ -443,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']);