diff --git a/composer.json b/composer.json index a09de20..5737128 100644 --- a/composer.json +++ b/composer.json @@ -25,12 +25,12 @@ "require": { "php": "^8.2", "ext-json": "*", - "laravel-json-api/core": "^4.0", - "laravel-json-api/eloquent": "^4.0", - "laravel-json-api/encoder-neomerx": "^4.0", - "laravel-json-api/exceptions": "^3.0", - "laravel-json-api/spec": "^3.0", - "laravel-json-api/validation": "^4.0", + "laravel-json-api/core": "^5.0", + "laravel-json-api/eloquent": "dev-feature/validation", + "laravel-json-api/encoder-neomerx": "^5.0", + "laravel-json-api/exceptions": "^4.0", + "laravel-json-api/spec": "^4.0", + "laravel-json-api/validation": "^5.0", "laravel/framework": "^11.0" }, "require-dev": { @@ -65,7 +65,7 @@ ] } }, - "minimum-stability": "stable", + "minimum-stability": "dev", "prefer-stable": true, "config": { "sort-packages": true diff --git a/src/Http/Controllers/Actions/FetchMany.php b/src/Http/Controllers/Actions/FetchMany.php index 9a2eb13..8b33cb7 100644 --- a/src/Http/Controllers/Actions/FetchMany.php +++ b/src/Http/Controllers/Actions/FetchMany.php @@ -11,48 +11,23 @@ namespace LaravelJsonApi\Laravel\Http\Controllers\Actions; -use Illuminate\Contracts\Support\Responsable; -use Illuminate\Http\Response; -use LaravelJsonApi\Contracts\Routing\Route; -use LaravelJsonApi\Contracts\Store\Store as StoreContract; +use LaravelJsonApi\Contracts\Http\Actions\FetchMany as FetchManyContract; use LaravelJsonApi\Core\Responses\DataResponse; -use LaravelJsonApi\Laravel\Http\Requests\ResourceQuery; +use LaravelJsonApi\Laravel\Http\Requests\JsonApiRequest; trait FetchMany { - /** - * Fetch zero to many JSON API resources. + * Fetch zero-to-many JSON:API resources. * - * @param Route $route - * @param StoreContract $store - * @return Responsable|Response + * @param JsonApiRequest $request + * @param FetchManyContract $action + * @return DataResponse */ - public function index(Route $route, StoreContract $store) + public function index(JsonApiRequest $request, FetchManyContract $action): DataResponse { - $request = ResourceQuery::queryMany( - $resourceType = $route->resourceType() - ); - - $response = null; - - if (method_exists($this, 'searching')) { - $response = $this->searching($request); - } - - if ($response) { - return $response; - } - - $data = $store - ->queryAll($resourceType) - ->withRequest($request) - ->firstOrPaginate($request->page()); - - if (method_exists($this, 'searched')) { - $response = $this->searched($data, $request); - } - - return $response ?: DataResponse::make($data)->withQueryParameters($request); + return $action + ->withHooks($this) + ->execute($request); } } diff --git a/src/Http/Controllers/Actions/FetchOne.php b/src/Http/Controllers/Actions/FetchOne.php index 1c091b8..c29a733 100644 --- a/src/Http/Controllers/Actions/FetchOne.php +++ b/src/Http/Controllers/Actions/FetchOne.php @@ -11,48 +11,23 @@ namespace LaravelJsonApi\Laravel\Http\Controllers\Actions; -use Illuminate\Contracts\Support\Responsable; -use Illuminate\Http\Response; -use LaravelJsonApi\Contracts\Routing\Route; -use LaravelJsonApi\Contracts\Store\Store as StoreContract; +use LaravelJsonApi\Contracts\Http\Actions\FetchOne as FetchOneContract; use LaravelJsonApi\Core\Responses\DataResponse; -use LaravelJsonApi\Laravel\Http\Requests\ResourceQuery; +use LaravelJsonApi\Laravel\Http\Requests\JsonApiRequest; trait FetchOne { - /** - * Fetch zero to one JSON API resource by id. + * Fetch zero to one JSON:API resource by id. * - * @param Route $route - * @param StoreContract $store - * @return Responsable|Response + * @param JsonApiRequest $request + * @param FetchOneContract $action + * @return DataResponse */ - public function show(Route $route, StoreContract $store) + public function show(JsonApiRequest $request, FetchOneContract $action): DataResponse { - $request = ResourceQuery::queryOne( - $resourceType = $route->resourceType() - ); - - $response = null; - - if (method_exists($this, 'reading')) { - $response = $this->reading($request); - } - - if ($response) { - return $response; - } - - $model = $store - ->queryOne($resourceType, $route->modelOrResourceId()) - ->withRequest($request) - ->first(); - - if (method_exists($this, 'read')) { - $response = $this->read($model, $request); - } - - return $response ?: DataResponse::make($model)->withQueryParameters($request); + return $action + ->withHooks($this) + ->execute($request); } } diff --git a/src/Http/Controllers/Actions/Store.php b/src/Http/Controllers/Actions/Store.php index 07a5fd7..9ee6583 100644 --- a/src/Http/Controllers/Actions/Store.php +++ b/src/Http/Controllers/Actions/Store.php @@ -11,60 +11,23 @@ namespace LaravelJsonApi\Laravel\Http\Controllers\Actions; -use Illuminate\Contracts\Support\Responsable; -use Illuminate\Http\Response; -use LaravelJsonApi\Contracts\Routing\Route; -use LaravelJsonApi\Contracts\Store\Store as StoreContract; +use LaravelJsonApi\Contracts\Http\Actions\Store as StoreContract; use LaravelJsonApi\Core\Responses\DataResponse; -use LaravelJsonApi\Laravel\Http\Requests\ResourceQuery; -use LaravelJsonApi\Laravel\Http\Requests\ResourceRequest; +use LaravelJsonApi\Laravel\Http\Requests\JsonApiRequest; trait Store { - /** * Create a new resource. * - * @param Route $route - * @param StoreContract $store - * @return Responsable|Response + * @param JsonApiRequest $request + * @param StoreContract $action + * @return DataResponse */ - public function store(Route $route, StoreContract $store) + public function store(JsonApiRequest $request, StoreContract $action): DataResponse { - $request = ResourceRequest::forResource( - $resourceType = $route->resourceType() - ); - - $query = ResourceQuery::queryOne($resourceType); - $response = null; - - if (method_exists($this, 'saving')) { - $response = $this->saving(null, $request, $query); - } - - if (!$response && method_exists($this, 'creating')) { - $response = $this->creating($request, $query); - } - - if ($response) { - return $response; - } - - $model = $store - ->create($resourceType) - ->withRequest($query) - ->store($request->validated()); - - if (method_exists($this, 'created')) { - $response = $this->created($model, $request, $query); - } - - if (!$response && method_exists($this, 'saved')) { - $response = $this->saved($model, $request, $query); - } - - return $response ?? DataResponse::make($model) - ->withQueryParameters($query) - ->didCreate(); + return $action + ->withHooks($this) + ->execute($request); } } diff --git a/src/Http/Requests/FormRequest.php b/src/Http/Requests/FormRequest.php index 1ff4f8e..b398e5f 100644 --- a/src/Http/Requests/FormRequest.php +++ b/src/Http/Requests/FormRequest.php @@ -27,6 +27,58 @@ class FormRequest extends BaseFormRequest */ public const JSON_API_MEDIA_TYPE = 'application/vnd.api+json'; + + /** + * Get the validator instance for the request. + * + * @return \Illuminate\Contracts\Validation\Validator + */ + public function makeValidator(array $input): \Illuminate\Contracts\Validation\Validator + { + $factory = $this->container->make(\Illuminate\Contracts\Validation\Factory::class); + + $validator = $this->createDefaultValidatorWithInput($factory, $input); + + if (method_exists($this, 'withValidator')) { + $this->withValidator($validator); + } + + if (method_exists($this, 'after')) { + $validator->after($this->container->call( + $this->after(...), + ['validator' => $validator] + )); + } + + $this->setValidator($validator); + + return $this->validator; + } + + /** + * Create the default validator instance. + * + * @param \Illuminate\Contracts\Validation\Factory $factory + * @return \Illuminate\Contracts\Validation\Validator + */ + protected function createDefaultValidatorWithInput(\Illuminate\Contracts\Validation\Factory $factory, array $input) + { + $rules = method_exists($this, 'rules') ? $this->container->call([$this, 'rules']) : []; + + $validator = $factory->make( + $input, $rules, + $this->messages(), $this->attributes() + )->stopOnFirstFailure($this->stopOnFirstFailure); + + if ($this->isPrecognitive()) { + $validator->setRules( + $this->filterPrecognitiveRules($validator->getRulesWithoutPlaceholders()) + ); + } + + return $validator; + } + /** * @return bool */ diff --git a/src/Http/Requests/JsonApiRequest.php b/src/Http/Requests/JsonApiRequest.php new file mode 100644 index 0000000..c39f887 --- /dev/null +++ b/src/Http/Requests/JsonApiRequest.php @@ -0,0 +1,254 @@ +getAcceptableContentTypes(); + + return isset($acceptable[0]) && self::JSON_API_MEDIA_TYPE === $acceptable[0]; + } + + /** + * @return bool + */ + public function acceptsJsonApi(): bool + { + return $this->accepts(self::JSON_API_MEDIA_TYPE); + } + + /** + * Determine if the request is sending JSON API content. + * + * @return bool + */ + public function isJsonApi(): bool + { + return $this->matchesType(self::JSON_API_MEDIA_TYPE, $this->header('CONTENT_TYPE')); + } + + /** + * Is this a request to view any resource? (Index action.) + * + * @return bool + */ + public function isViewingAny(): bool + { + return $this->isMethod('GET') && $this->doesntHaveResourceId() && $this->isNotRelationship(); + } + + /** + * Is this a request to view a specific resource? (Read action.) + * + * @return bool + */ + public function isViewingOne(): bool + { + return $this->isMethod('GET') && $this->hasResourceId() && $this->isNotRelationship(); + } + + /** + * Is this a request to view related resources in a relationship? (Show-related action.) + * + * @return bool + */ + public function isViewingRelated(): bool + { + return $this->isMethod('GET') && $this->isRelationship() && !$this->urlHasRelationships(); + } + + /** + * Is this a request to view resource identifiers in a relationship? (Show-relationship action.) + * + * @return bool + */ + public function isViewingRelationship(): bool + { + return $this->isMethod('GET') && $this->isRelationship() && $this->urlHasRelationships(); + } + + /** + * Is this a request to create a resource? + * + * @return bool + */ + public function isCreating(): bool + { + return $this->isMethod('POST') && $this->isNotRelationship(); + } + + /** + * Is this a request to update a resource? + * + * @return bool + */ + public function isUpdating(): bool + { + return $this->isMethod('PATCH') && $this->isNotRelationship(); + } + + /** + * Is this a request to create or update a resource? + * + * @return bool + */ + public function isCreatingOrUpdating(): bool + { + return $this->isCreating() || $this->isUpdating(); + } + + /** + * Is this a request to replace a resource relationship? + * + * @return bool + */ + public function isUpdatingRelationship(): bool + { + return $this->isMethod('PATCH') && $this->isRelationship(); + } + + /** + * Is this a request to attach records to a resource relationship? + * + * @return bool + */ + public function isAttachingRelationship(): bool + { + return $this->isMethod('POST') && $this->isRelationship(); + } + + /** + * Is this a request to detach records from a resource relationship? + * + * @return bool + */ + public function isDetachingRelationship(): bool + { + return $this->isMethod('DELETE') && $this->isRelationship(); + } + + /** + * Is this a request to modify a resource relationship? + * + * @return bool + */ + public function isModifyingRelationship(): bool + { + return $this->isUpdatingRelationship() || + $this->isAttachingRelationship() || + $this->isDetachingRelationship(); + } + + /** + * @return bool + */ + public function isDeleting(): bool + { + return $this->isMethod('DELETE') && $this->isNotRelationship(); + } + + /** + * Is this a request to view or modify a relationship? + * + * @return bool + */ + public function isRelationship(): bool + { + return $this->jsonApi()->route()->hasRelation(); + } + + /** + * Is this a request to not view a relationship? + * + * @return bool + */ + public function isNotRelationship(): bool + { + return !$this->isRelationship(); + } + + /** + * Get the field name for a relationship request. + * + * @return string|null + */ + public function getFieldName(): ?string + { + $route = $this->jsonApi()->route(); + + if ($route->hasRelation()) { + return $route->fieldName(); + } + + return null; + } + + /** + * @return JsonApiService + */ + final protected function jsonApi(): JsonApiService + { + return $this->container->make(JsonApiService::class); + } + + /** + * Is there a resource id? + * + * @return bool + */ + private function hasResourceId(): bool + { + return $this->jsonApi()->route()->hasResourceId(); + } + + /** + * Is the request not for a specific resource? + * + * @return bool + */ + private function doesntHaveResourceId(): bool + { + return !$this->hasResourceId(); + } + + /** + * Does the URL contain the keyword "relationships". + * + * @return bool + */ + private function urlHasRelationships(): bool + { + return Str::of($this->url())->contains('relationships'); + } +} diff --git a/src/Http/Requests/ResourceQuery.php b/src/Http/Requests/ResourceQuery.php index 116d5b9..2985175 100644 --- a/src/Http/Requests/ResourceQuery.php +++ b/src/Http/Requests/ResourceQuery.php @@ -67,9 +67,9 @@ public static function guessQueryManyUsing(callable $resolver): void * Resolve the request instance when querying many resources. * * @param string $resourceType - * @return QueryParameters|ResourceQuery + * @return ResourceQuery */ - public static function queryMany(string $resourceType): QueryParameters + public static function queryMany(string $resourceType): ResourceQuery { $resolver = self::$queryManyResolver ?: new RequestResolver(RequestResolver::COLLECTION_QUERY); @@ -230,6 +230,14 @@ public function unrecognisedParameters(): array ])->all(); } + /** + * @return array + */ + public function toQuery(): array + { + throw new \RuntimeException('Not implemented.'); + } + /** * Get the model that the request relates to, if the URL has a resource id. * diff --git a/src/Routing/Registrar.php b/src/Routing/Registrar.php index 61b23e3..9484875 100644 --- a/src/Routing/Registrar.php +++ b/src/Routing/Registrar.php @@ -13,7 +13,6 @@ use Illuminate\Contracts\Routing\Registrar as RegistrarContract; use LaravelJsonApi\Contracts\Server\Repository; -use LaravelJsonApi\Core\Server\ServerRepository; class Registrar { @@ -48,15 +47,9 @@ public function __construct(RegistrarContract $router, Repository $servers) */ public function server(string $name): PendingServerRegistration { - // TODO add the `once` method to the server repository interface - $server = match(true) { - $this->servers instanceof ServerRepository => $this->servers->once($name), - default => $this->servers->server($name), - }; - return new PendingServerRegistration( $this->router, - $server, + $this->servers->once($name), ); } } diff --git a/src/Routing/ResourceRegistrar.php b/src/Routing/ResourceRegistrar.php index 265fc79..1bb778b 100644 --- a/src/Routing/ResourceRegistrar.php +++ b/src/Routing/ResourceRegistrar.php @@ -309,9 +309,9 @@ protected function addResourceDestroy(string $resourceType, string $controller, private function getResourceUri(string $resourceType): string { return $this->server - ->schemas() - ->schemaFor($resourceType) - ->uriType(); + ->statics() + ->schemaForType($resourceType) + ->getUriType(); } /** diff --git a/src/Routing/Route.php b/src/Routing/Route.php index ca037a7..bb32b68 100644 --- a/src/Routing/Route.php +++ b/src/Routing/Route.php @@ -167,9 +167,9 @@ public function schema(): Schema */ public function authorizer(): Authorizer { - return $this->container->make( - $this->schema()->authorizer() - ); + return $this->server + ->authorizers() + ->authorizerFor($this->resourceType()); } /** diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index b71c3cf..65161a4 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -13,9 +13,15 @@ use Illuminate\Container\Container; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Contracts\Pipeline\Pipeline; use Illuminate\Routing\Router; use Illuminate\Support\ServiceProvider as BaseServiceProvider; use LaravelJsonApi\Contracts; +use LaravelJsonApi\Core\Bus\Commands\Dispatcher as CommandDispatcher; +use LaravelJsonApi\Core\Bus\Queries\Dispatcher as QueryDispatcher; +use LaravelJsonApi\Core\Http\Actions\FetchMany; +use LaravelJsonApi\Core\Http\Actions\FetchOne; +use LaravelJsonApi\Core\Http\Actions\Store; use LaravelJsonApi\Core\JsonApiService; use LaravelJsonApi\Core\Server\ServerRepository; use LaravelJsonApi\Core\Support\AppResolver; @@ -64,6 +70,10 @@ public function register(): void $this->bindAuthorizer(); $this->bindService(); $this->bindServer(); + $this->bindActionsCommandsAndQueries(); + + /** @TODO wtf? why isn't it working without this? */ + $this->app->bind(Pipeline::class, \Illuminate\Pipeline\Pipeline::class); } /** @@ -126,5 +136,26 @@ private function bindServer(): void $this->app->bind(Contracts\Resources\Container::class, static function (Application $app) { return $app->make(Contracts\Server\Server::class)->resources(); }); + + $this->app->bind(Contracts\Auth\Container::class, static function (Application $app) { + return $app->make(Contracts\Server\Server::class)->authorizers(); + }); + } + + /** + * @return void + */ + private function bindActionsCommandsAndQueries(): void + { + /** Actions */ + $this->app->bind(Contracts\Http\Actions\FetchMany::class, FetchMany::class); + $this->app->bind(Contracts\Http\Actions\FetchOne::class, FetchOne::class); + $this->app->bind(Contracts\Http\Actions\Store::class, Store::class); + + /** Commands */ + $this->app->bind(Contracts\Bus\Commands\Dispatcher::class, CommandDispatcher::class); + + /** Queries */ + $this->app->bind(Contracts\Bus\Queries\Dispatcher::class, QueryDispatcher::class); } } diff --git a/tests/dummy/app/JsonApi/V1/Comments/CommentSchema.php b/tests/dummy/app/JsonApi/V1/Comments/CommentSchema.php index 6ad6366..361bf0a 100644 --- a/tests/dummy/app/JsonApi/V1/Comments/CommentSchema.php +++ b/tests/dummy/app/JsonApi/V1/Comments/CommentSchema.php @@ -12,6 +12,7 @@ namespace App\JsonApi\V1\Comments; use App\Models\Comment; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; use LaravelJsonApi\Eloquent\Fields\Relations\BelongsTo; @@ -20,16 +21,9 @@ use LaravelJsonApi\Eloquent\Pagination\PagePagination; use LaravelJsonApi\Eloquent\Schema; +#[Model(Comment::class)] class CommentSchema extends Schema { - - /** - * The model the schema corresponds to. - * - * @var string - */ - public static string $model = Comment::class; - /** * @inheritDoc */ diff --git a/tests/dummy/app/JsonApi/V1/Images/ImageSchema.php b/tests/dummy/app/JsonApi/V1/Images/ImageSchema.php index 02aba0e..bb54969 100644 --- a/tests/dummy/app/JsonApi/V1/Images/ImageSchema.php +++ b/tests/dummy/app/JsonApi/V1/Images/ImageSchema.php @@ -12,6 +12,7 @@ namespace App\JsonApi\V1\Images; use App\Models\Image; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; use LaravelJsonApi\Eloquent\Fields\Str; @@ -19,16 +20,9 @@ use LaravelJsonApi\Eloquent\Pagination\PagePagination; use LaravelJsonApi\Eloquent\Schema; +#[Model(Image::class)] class ImageSchema extends Schema { - - /** - * The model the schema corresponds to. - * - * @var string - */ - public static string $model = Image::class; - /** * @inheritDoc */ diff --git a/tests/dummy/app/JsonApi/V1/Phones/PhoneSchema.php b/tests/dummy/app/JsonApi/V1/Phones/PhoneSchema.php index c237ca3..f6c5041 100644 --- a/tests/dummy/app/JsonApi/V1/Phones/PhoneSchema.php +++ b/tests/dummy/app/JsonApi/V1/Phones/PhoneSchema.php @@ -12,11 +12,13 @@ namespace App\JsonApi\V1\Phones; use App\Models\Phone; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; use LaravelJsonApi\Eloquent\Fields\Str; use LaravelJsonApi\Eloquent\Schema; +#[Model(Phone::class)] class PhoneSchema extends Schema { /** diff --git a/tests/dummy/app/JsonApi/V1/Posts/PostCollectionQuery.php b/tests/dummy/app/JsonApi/V1/Posts/PostCollectionQuery.php deleted file mode 100644 index d66bb64..0000000 --- a/tests/dummy/app/JsonApi/V1/Posts/PostCollectionQuery.php +++ /dev/null @@ -1,64 +0,0 @@ - [ - 'nullable', - 'array', - JsonApiRule::fieldSets(), - ], - 'filter' => [ - 'nullable', - 'array', - JsonApiRule::filter(), - ], - 'filter.id' => ['array'], - 'filter.id.*' => ['integer'], - 'filter.published' => [JsonApiRule::boolean()->asString()], - 'filter.slug' => ['string'], - 'include' => [ - 'nullable', - 'string', - JsonApiRule::includePaths(), - ], - 'page' => [ - 'nullable', - 'array', - JsonApiRule::page(), - ], - 'sort' => [ - 'nullable', - 'string', - JsonApiRule::sort(), - ], - 'withCount' => [ - 'nullable', - 'string', - JsonApiRule::countable(), - ], - ]; - } -} diff --git a/tests/dummy/app/JsonApi/V1/Posts/PostQuery.php b/tests/dummy/app/JsonApi/V1/Posts/PostQuery.php deleted file mode 100644 index 872d6ec..0000000 --- a/tests/dummy/app/JsonApi/V1/Posts/PostQuery.php +++ /dev/null @@ -1,54 +0,0 @@ - [ - 'nullable', - 'array', - JsonApiRule::fieldSets(), - ], - 'filter' => [ - 'nullable', - 'array', - JsonApiRule::filter()->forget('id'), - ], - 'filter.published' => ['boolean'], - 'filter.slug' => ['string'], - 'include' => [ - 'nullable', - 'string', - JsonApiRule::includePaths(), - ], - 'page' => JsonApiRule::notSupported(), - 'sort' => JsonApiRule::notSupported(), - 'withCount' => [ - 'nullable', - 'string', - JsonApiRule::countable(), - ], - ]; - } -} diff --git a/tests/dummy/app/JsonApi/V1/Posts/PostRequest.php b/tests/dummy/app/JsonApi/V1/Posts/PostRequest.php deleted file mode 100644 index c0f9ebc..0000000 --- a/tests/dummy/app/JsonApi/V1/Posts/PostRequest.php +++ /dev/null @@ -1,74 +0,0 @@ -model()) { - $unique->ignore($post); - } - - return [ - 'content' => ['required', 'string'], - 'deletedAt' => ['nullable', JsonApiRule::dateTime()], - 'media' => JsonApiRule::toMany(), - 'slug' => ['required', 'string', $unique], - 'synopsis' => ['required', 'string'], - 'tags' => JsonApiRule::toMany(), - 'title' => ['required', 'string'], - ]; - } - - /** - * @return array - */ - public function deleteRules(): array - { - return [ - 'meta.no_comments' => 'accepted', - ]; - } - - /** - * @return array - */ - public function deleteMessages(): array - { - return [ - 'meta.no_comments.accepted' => 'Cannot delete a post with comments.', - ]; - } - - /** - * @param Post $post - * @return array - */ - public function metaForDelete(Post $post): array - { - return [ - 'no_comments' => $post->comments()->doesntExist(), - ]; - } -} diff --git a/tests/dummy/app/JsonApi/V1/Posts/PostSchema.php b/tests/dummy/app/JsonApi/V1/Posts/PostSchema.php index ca9abd6..04c8b69 100644 --- a/tests/dummy/app/JsonApi/V1/Posts/PostSchema.php +++ b/tests/dummy/app/JsonApi/V1/Posts/PostSchema.php @@ -12,6 +12,9 @@ namespace App\JsonApi\V1\Posts; use App\Models\Post; +use Illuminate\Http\Request; +use Illuminate\Validation\Rule; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; use LaravelJsonApi\Eloquent\Fields\Relations\BelongsTo; @@ -24,23 +27,16 @@ use LaravelJsonApi\Eloquent\Filters\Scope; use LaravelJsonApi\Eloquent\Filters\Where; use LaravelJsonApi\Eloquent\Filters\WhereIdIn; -use LaravelJsonApi\Eloquent\Pagination\MultiPagination; use LaravelJsonApi\Eloquent\Pagination\PagePagination; use LaravelJsonApi\Eloquent\Schema; use LaravelJsonApi\Eloquent\SoftDeletes; use LaravelJsonApi\Eloquent\Sorting\SortCountable; +#[Model(Post::class)] class PostSchema extends Schema { use SoftDeletes; - /** - * The model the schema corresponds to. - * - * @var string - */ - public static string $model = Post::class; - /** * The maximum depth of include paths. * @@ -62,7 +58,7 @@ public function fields(): array ID::make(), BelongsTo::make('author')->type('users')->readOnly(), HasMany::make('comments')->canCount()->readOnly(), - Str::make('content'), + Str::make('content')->rules('required'), DateTime::make('createdAt')->sortable()->readOnly(), SoftDelete::make('deletedAt')->sortable(), MorphToMany::make('media', [ @@ -70,10 +66,13 @@ public function fields(): array BelongsToMany::make('videos'), ])->canCount(), DateTime::make('publishedAt')->sortable(), - Str::make('slug'), - Str::make('synopsis'), + Str::make('slug') + ->rules('required') + ->creationRules(Rule::unique('posts')) + ->updateRules(fn($r, Post $model) => Rule::unique('posts')->ignore($model)), + Str::make('synopsis')->rules('required'), BelongsToMany::make('tags')->canCount()->mustValidate(), - Str::make('title')->sortable(), + Str::make('title')->sortable()->rules('required'), DateTime::make('updatedAt')->sortable()->readOnly(), ]; } @@ -84,9 +83,9 @@ public function fields(): array public function filters(): array { return [ - WhereIdIn::make($this)->delimiter(','), + WhereIdIn::make($this)->delimiter(',')->onlyToMany(), Scope::make('published', 'wherePublished')->asBoolean(), - Where::make('slug')->singular(), + Where::make('slug')->singular()->rules('string'), OnlyTrashed::make('trashed'), ]; } @@ -104,15 +103,52 @@ public function sortables(): iterable /** * @inheritDoc */ - public function pagination(): MultiPagination + public function pagination(): PagePagination + { + // TODO add validation to the multi-paginator. +// return new MultiPagination( +// PagePagination::make()->withoutNestedMeta(), +// PagePagination::make() +// ->withoutNestedMeta() +// ->withSimplePagination() +// ->withPageKey('current-page') +// ->withPerPageKey('per-page') +// ); + + return PagePagination::make() + ->withoutNestedMeta() + ->withMaxPerPage(200); + } + + /** + * @return array + */ + public function deletionRules(): array { - return new MultiPagination( - PagePagination::make()->withoutNestedMeta(), - PagePagination::make() - ->withoutNestedMeta() - ->withSimplePagination() - ->withPageKey('current-page') - ->withPerPageKey('per-page') - ); + return [ + 'meta.no_comments' => 'accepted', + ]; + } + + /** + * @return array + */ + public function deletionMessages(): array + { + return [ + 'meta.no_comments.accepted' => 'Cannot delete a post with comments.', + ]; + } + + /** + * @param Request|null $request + * @param Post $post + * @return array + */ + public function metaForDeletion(?Request $request, Post $post): array + { + return [ + 'no_comments' => $post->comments()->doesntExist(), + ]; } } diff --git a/tests/dummy/app/JsonApi/V1/Tags/TagSchema.php b/tests/dummy/app/JsonApi/V1/Tags/TagSchema.php index 99a5d89..f9d6eb1 100644 --- a/tests/dummy/app/JsonApi/V1/Tags/TagSchema.php +++ b/tests/dummy/app/JsonApi/V1/Tags/TagSchema.php @@ -12,6 +12,7 @@ namespace App\JsonApi\V1\Tags; use App\Models\Tag; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; use LaravelJsonApi\Eloquent\Fields\Relations\BelongsToMany; @@ -20,16 +21,9 @@ use LaravelJsonApi\Eloquent\Pagination\PagePagination; use LaravelJsonApi\Eloquent\Schema; +#[Model(Tag::class)] class TagSchema extends Schema { - - /** - * The model the schema corresponds to. - * - * @var string - */ - public static string $model = Tag::class; - /** * @inheritDoc */ diff --git a/tests/dummy/app/JsonApi/V1/Users/UserQuery.php b/tests/dummy/app/JsonApi/V1/Users/UserQuery.php deleted file mode 100644 index 228fa6a..0000000 --- a/tests/dummy/app/JsonApi/V1/Users/UserQuery.php +++ /dev/null @@ -1,45 +0,0 @@ - [ - 'nullable', - 'array', - JsonApiRule::fieldSets(), - ], - 'filter' => [ - 'nullable', - 'array', - JsonApiRule::filter()->forget('id'), - ], - 'include' => [ - 'nullable', - 'string', - JsonApiRule::includePaths(), - ], - 'page' => JsonApiRule::notSupported(), - 'sort' => JsonApiRule::notSupported(), - ]; - } -} diff --git a/tests/dummy/app/JsonApi/V1/Users/UserRequest.php b/tests/dummy/app/JsonApi/V1/Users/UserRequest.php deleted file mode 100644 index bc64190..0000000 --- a/tests/dummy/app/JsonApi/V1/Users/UserRequest.php +++ /dev/null @@ -1,28 +0,0 @@ - JsonApiRule::toOne(), - ]; - } -} diff --git a/tests/dummy/app/JsonApi/V1/Users/UserSchema.php b/tests/dummy/app/JsonApi/V1/Users/UserSchema.php index 69436ca..cc59839 100644 --- a/tests/dummy/app/JsonApi/V1/Users/UserSchema.php +++ b/tests/dummy/app/JsonApi/V1/Users/UserSchema.php @@ -12,6 +12,7 @@ namespace App\JsonApi\V1\Users; use App\Models\User; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; use LaravelJsonApi\Eloquent\Fields\Relations\HasOne; @@ -21,16 +22,9 @@ use LaravelJsonApi\Eloquent\Pagination\PagePagination; use LaravelJsonApi\Eloquent\Schema; +#[Model(User::class)] class UserSchema extends Schema { - - /** - * The model the schema corresponds to. - * - * @var string - */ - public static string $model = User::class; - /** * @inheritDoc */ @@ -51,8 +45,8 @@ public function fields(): array public function filters(): array { return [ - WhereIdIn::make($this)->delimiter(','), - Where::make('email')->singular(), + WhereIdIn::make($this)->delimiter(',')->onlyToMany(), + Where::make('email')->singular()->rules('email'), ]; } diff --git a/tests/dummy/app/JsonApi/V1/Videos/VideoRequest.php b/tests/dummy/app/JsonApi/V1/Videos/VideoRequest.php deleted file mode 100644 index aeb1fce..0000000 --- a/tests/dummy/app/JsonApi/V1/Videos/VideoRequest.php +++ /dev/null @@ -1,33 +0,0 @@ - ['nullable', JsonApiRule::clientId()], - 'tags' => JsonApiRule::toMany(), - 'title' => ['required', 'string'], - 'url' => ['required', 'string'], - ]; - } - -} diff --git a/tests/dummy/app/JsonApi/V1/Videos/VideoSchema.php b/tests/dummy/app/JsonApi/V1/Videos/VideoSchema.php index 908fdf1..ef0c40e 100644 --- a/tests/dummy/app/JsonApi/V1/Videos/VideoSchema.php +++ b/tests/dummy/app/JsonApi/V1/Videos/VideoSchema.php @@ -12,6 +12,7 @@ namespace App\JsonApi\V1\Videos; use App\Models\Video; +use LaravelJsonApi\Core\Schema\Attributes\Model; use LaravelJsonApi\Eloquent\Fields\DateTime; use LaravelJsonApi\Eloquent\Fields\ID; use LaravelJsonApi\Eloquent\Fields\Relations\BelongsToMany; @@ -20,23 +21,16 @@ use LaravelJsonApi\Eloquent\Pagination\PagePagination; use LaravelJsonApi\Eloquent\Schema; +#[Model(Video::class)] class VideoSchema extends Schema { - - /** - * The model the schema corresponds to. - * - * @var string - */ - public static string $model = Video::class; - /** * @inheritDoc */ public function fields(): array { return [ - ID::make()->uuid()->clientIds(), + ID::make()->uuid()->clientIds()->nullable(), DateTime::make('createdAt')->sortable()->readOnly(), BelongsToMany::make('tags')->canCount(), Str::make('title')->sortable(), diff --git a/tests/dummy/tests/Api/V1/Posts/ReadTest.php b/tests/dummy/tests/Api/V1/Posts/ReadTest.php index 900a4b7..fdded17 100644 --- a/tests/dummy/tests/Api/V1/Posts/ReadTest.php +++ b/tests/dummy/tests/Api/V1/Posts/ReadTest.php @@ -19,7 +19,6 @@ class ReadTest extends TestCase { - public function test(): void { $post = Post::factory()->create(); diff --git a/tests/lib/Integration/Routing/TestCase.php b/tests/lib/Integration/Routing/TestCase.php index e520ec1..6bf7b63 100644 --- a/tests/lib/Integration/Routing/TestCase.php +++ b/tests/lib/Integration/Routing/TestCase.php @@ -18,6 +18,8 @@ use LaravelJsonApi\Contracts\Schema\ID; use LaravelJsonApi\Contracts\Schema\Relation; use LaravelJsonApi\Contracts\Schema\Schema; +use LaravelJsonApi\Contracts\Schema\StaticSchema\StaticContainer; +use LaravelJsonApi\Contracts\Schema\StaticSchema\StaticSchema; use LaravelJsonApi\Contracts\Server\Repository; use LaravelJsonApi\Contracts\Server\Server; use LaravelJsonApi\Laravel\Tests\Integration\TestCase as BaseTestCase; @@ -45,9 +47,9 @@ protected function setUp(): void /** * @param string $name - * @return Server|MockObject + * @return Server&MockObject */ - protected function createServer(string $name): Server + protected function createServer(string $name): Server&MockObject { $mock = $this->createMock(Server::class); $mock->method('name')->willReturn($name); @@ -57,27 +59,33 @@ protected function createServer(string $name): Server } /** - * @param Server|MockObject $server + * @param Server&MockObject $server * @param string $name * @param string|null $pattern * @param string|null $uriType - * @return Schema|MockObject + * @return Schema&MockObject */ protected function createSchema( - Server $server, + Server&MockObject $server, string $name, string $pattern = null, string $uriType = null - ): Schema + ): Schema&MockObject { + $static = $this->createMock(StaticSchema::class); + $static->method('getUriType')->willReturn($uriType ?: $name); + + $statics = $this->createMock(StaticContainer::class); + $statics->method('schemaForType')->with($name)->willReturn($static); + $schema = $this->createMock(Schema::class); - $schema->method('uriType')->willReturn($uriType ?: $name); $schema->method('id')->willReturn($id = $this->createMock(ID::class)); $id->method('pattern')->willReturn($pattern ?: '[0-9]+'); $schemas = $this->createMock(Container::class); $schemas->method('schemaFor')->with($name)->willReturn($schema); + $server->method('statics')->willReturn($statics); $server->method('schemas')->willReturn($schemas); return $schema;