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;
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: