diff --git a/src/Adapter/AdapterInterface.php b/src/Adapter/AdapterInterface.php index 2da88fe..fdb0a21 100644 --- a/src/Adapter/AdapterInterface.php +++ b/src/Adapter/AdapterInterface.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Adapter; use Closure; @@ -151,20 +160,20 @@ interface AdapterInterface * * @param $model * @param HasOne $relationship - * @param array $fields + * @param bool $linkage * @return mixed|null */ - public function getHasOne($model, HasOne $relationship, array $fields = null); + public function getHasOne($model, HasOne $relationship, bool $linkage); /** * Get a list of models for a has-many relationship for the model. * * @param $model * @param HasMany $relationship - * @param array $fields + * @param bool $linkage * @return array */ - public function getHasMany($model, HasMany $relationship, array $fields = null): array; + public function getHasMany($model, HasMany $relationship, bool $linkage): array; /** * Apply an attribute value to the model. @@ -219,9 +228,11 @@ interface AdapterInterface * @param array $relationships * @param Closure $scope Should be called to give the deepest relationship * an opportunity to scope the query that will fetch related resources + * @param bool $linkage true if we just need the IDs of the related + * resources and not their full data * @return mixed */ - public function load(array $models, array $relationships, Closure $scope): void; + public function load(array $models, array $relationships, Closure $scope, bool $linkage): void; /** * Load information about the IDs of related resources onto a collection @@ -231,5 +242,5 @@ interface AdapterInterface * @param Relationship $relationship * @return mixed */ - public function loadIds(array $models, Relationship $relationship): void; + // public function loadIds(array $models, Relationship $relationship): void; } diff --git a/src/Adapter/EloquentAdapter.php b/src/Adapter/EloquentAdapter.php index ddf12eb..b327b1b 100644 --- a/src/Adapter/EloquentAdapter.php +++ b/src/Adapter/EloquentAdapter.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Adapter; use Closure; @@ -69,12 +78,15 @@ class EloquentAdapter implements AdapterInterface return $model->{$this->getAttributeProperty($attribute)}; } - public function getHasOne($model, HasOne $relationship, array $fields = null) + public function getHasOne($model, HasOne $relationship, bool $linkage) { $relation = $this->getEloquentRelation($model, $relationship); - // comment - if ($fields === ['id'] && $relation instanceof BelongsTo) { + // If it's a belongs-to relationship and we only need to get the ID, + // then we don't have to actually load the relation because the ID is + // stored in a column directly on the model. We will mock up a related + // model with the value of the ID filled. + if ($linkage && $relation instanceof BelongsTo) { if ($key = $model->{$relation->getForeignKeyName()}) { $related = $relation->getRelated(); @@ -87,7 +99,7 @@ class EloquentAdapter implements AdapterInterface return $this->getRelationValue($model, $relationship); } - public function getHasMany($model, HasMany $relationship, array $fields = null): array + public function getHasMany($model, HasMany $relationship, bool $linkage): array { $collection = $this->getRelationValue($model, $relationship); @@ -188,38 +200,42 @@ class EloquentAdapter implements AdapterInterface $query->take($limit)->skip($offset); } - public function load(array $models, array $relationships, Closure $scope): void + public function load(array $models, array $relationships, Closure $scope, bool $linkage): void { + // TODO: Find the relation on the model that we're after. If it's a + // belongs-to relation, and we only need linkage, then we won't need + // to load anything as the related ID is store directly on the model. + (new Collection($models))->loadMissing([ $this->getRelationshipPath($relationships) => $scope ]); } - public function loadIds(array $models, Relationship $relationship): void - { - if (empty($models)) { - return; - } - - $property = $this->getRelationshipProperty($relationship); - $relation = $models[0]->$property(); - - // If it's a belongs-to relationship, then the ID is stored on the model - // itself, so we don't need to load anything in advance. - if ($relation instanceof BelongsTo) { - return; - } - - (new Collection($models))->loadMissing([ - $property => function ($query) use ($relation) { - $query->select($relation->getRelated()->getKeyName()); - - if (! $relation instanceof BelongsToMany) { - $query->addSelect($relation->getForeignKeyName()); - } - } - ]); - } + // public function loadIds(array $models, Relationship $relationship): void + // { + // if (empty($models)) { + // return; + // } + // + // $property = $this->getRelationshipProperty($relationship); + // $relation = $models[0]->$property(); + // + // // If it's a belongs-to relationship, then the ID is stored on the model + // // itself, so we don't need to load anything in advance. + // if ($relation instanceof BelongsTo) { + // return; + // } + // + // (new Collection($models))->loadMissing([ + // $property => function ($query) use ($relation) { + // $query->select($relation->getRelated()->getKeyName()); + // + // if (! $relation instanceof BelongsToMany) { + // $query->addSelect($relation->getForeignKeyName()); + // } + // } + // ]); + // } private function getAttributeProperty(Attribute $attribute): string { diff --git a/src/ErrorProviderInterface.php b/src/ErrorProviderInterface.php index 4b55237..83e8b4f 100644 --- a/src/ErrorProviderInterface.php +++ b/src/ErrorProviderInterface.php @@ -1,10 +1,29 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer; +use JsonApiPhp\JsonApi\Error; + interface ErrorProviderInterface { + /** + * Get JSON:API error objects that represent this error. + * + * @return Error[] + */ public function getJsonApiErrors(): array; + /** + * Get the most generally applicable HTTP error code for this error. + */ public function getJsonApiStatus(): string; } diff --git a/src/Exception/BadRequestException.php b/src/Exception/BadRequestException.php index 3523e07..c249cf5 100644 --- a/src/Exception/BadRequestException.php +++ b/src/Exception/BadRequestException.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Exception; use DomainException; diff --git a/src/Exception/ForbiddenException.php b/src/Exception/ForbiddenException.php index 2113190..c069f1c 100644 --- a/src/Exception/ForbiddenException.php +++ b/src/Exception/ForbiddenException.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Exception; use DomainException; diff --git a/src/Exception/InternalServerErrorException.php b/src/Exception/InternalServerErrorException.php index 3dc5fe9..99dc9a6 100644 --- a/src/Exception/InternalServerErrorException.php +++ b/src/Exception/InternalServerErrorException.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Exception; use JsonApiPhp\JsonApi\Error; diff --git a/src/Exception/MethodNotAllowedException.php b/src/Exception/MethodNotAllowedException.php index b6b63db..1698f24 100644 --- a/src/Exception/MethodNotAllowedException.php +++ b/src/Exception/MethodNotAllowedException.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Exception; use DomainException as DomainExceptionAlias; diff --git a/src/Exception/NotAcceptableException.php b/src/Exception/NotAcceptableException.php index 0b4b875..5dd9cc6 100644 --- a/src/Exception/NotAcceptableException.php +++ b/src/Exception/NotAcceptableException.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Exception; use JsonApiPhp\JsonApi\Error; diff --git a/src/Exception/NotImplementedException.php b/src/Exception/NotImplementedException.php index 0af1992..a9d275f 100644 --- a/src/Exception/NotImplementedException.php +++ b/src/Exception/NotImplementedException.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Exception; use DomainException; diff --git a/src/Exception/ResourceNotFoundException.php b/src/Exception/ResourceNotFoundException.php index eef6d49..a1335c9 100644 --- a/src/Exception/ResourceNotFoundException.php +++ b/src/Exception/ResourceNotFoundException.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Exception; use JsonApiPhp\JsonApi\Error; diff --git a/src/Exception/UnauthorizedException.php b/src/Exception/UnauthorizedException.php index a11ad89..7a28296 100644 --- a/src/Exception/UnauthorizedException.php +++ b/src/Exception/UnauthorizedException.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Exception; use DomainException; diff --git a/src/Exception/UnprocessableEntityException.php b/src/Exception/UnprocessableEntityException.php index 6a01632..8ffb385 100644 --- a/src/Exception/UnprocessableEntityException.php +++ b/src/Exception/UnprocessableEntityException.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Exception; use DomainException; diff --git a/src/Exception/UnsupportedMediaTypeException.php b/src/Exception/UnsupportedMediaTypeException.php index 46ffdf2..ce755c5 100644 --- a/src/Exception/UnsupportedMediaTypeException.php +++ b/src/Exception/UnsupportedMediaTypeException.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Exception; use JsonApiPhp\JsonApi\Error; diff --git a/src/Handler/Concerns/FindsResources.php b/src/Handler/Concerns/FindsResources.php index a37e32a..3ec0d7e 100644 --- a/src/Handler/Concerns/FindsResources.php +++ b/src/Handler/Concerns/FindsResources.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Handler\Concerns; use Psr\Http\Message\ServerRequestInterface as Request; @@ -9,13 +18,18 @@ use function Tobyz\JsonApiServer\run_callbacks; trait FindsResources { + /** + * Find a resource within the API after applying scopes for the resource type. + * + * @throws ResourceNotFoundException if the resource is not found. + */ private function findResource(Request $request, ResourceType $resource, string $id) { $adapter = $resource->getAdapter(); $query = $adapter->query(); - run_callbacks($resource->getSchema()->getScopes(), [$query, $request, $id]); + run_callbacks($resource->getSchema()->getListeners('scope'), [$query, $request, $id]); $model = $adapter->find($query, $id); diff --git a/src/Handler/Concerns/IncludesData.php b/src/Handler/Concerns/IncludesData.php index e5706ad..a516f81 100644 --- a/src/Handler/Concerns/IncludesData.php +++ b/src/Handler/Concerns/IncludesData.php @@ -1,15 +1,27 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Handler\Concerns; -use Closure; use Psr\Http\Message\ServerRequestInterface as Request; -use function Tobyz\JsonApiServer\evaluate; use Tobyz\JsonApiServer\Exception\BadRequestException; +use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\Schema\Relationship; use function Tobyz\JsonApiServer\run_callbacks; +/** + * @property JsonApi $api + * @property ResourceType $resource + */ trait IncludesData { private function getInclude(Request $request): array @@ -32,10 +44,9 @@ trait IncludesData $tree = []; foreach (explode(',', $include) as $path) { - $keys = explode('.', $path); $array = &$tree; - foreach ($keys as $key) { + foreach (explode('.', $path) as $key) { if (! isset($array[$key])) { $array[$key] = []; } @@ -52,7 +63,8 @@ trait IncludesData $fields = $resource->getSchema()->getFields(); foreach ($include as $name => $nested) { - if (! isset($fields[$name]) + if ( + ! isset($fields[$name]) || ! $fields[$name] instanceof Relationship || ! $fields[$name]->isIncludable() ) { @@ -69,69 +81,42 @@ trait IncludesData } } - private function buildRelationshipTrails(ResourceType $resource, array $include): array - { - $fields = $resource->getSchema()->getFields(); - $trails = []; - - foreach ($include as $name => $nested) { - $relationship = $fields[$name]; - - if ($relationship->getLoadable()) { - $trails[] = [$relationship]; - } - - if ($type = $fields[$name]->getType()) { - $relatedResource = $this->api->getResource($type); - - $trails = array_merge( - $trails, - array_map( - function ($trail) use ($relationship) { - return array_merge([$relationship], $trail); - }, - $this->buildRelationshipTrails($relatedResource, $nested) - ) - ); - } - } - - return $trails; - } - private function loadRelationships(array $models, array $include, Request $request) { - $adapter = $this->resource->getAdapter(); - $fields = $this->resource->getSchema()->getFields(); + $this->loadRelationshipsAtLevel($models, [], $this->resource, $include, $request); + } - $trails = $this->buildRelationshipTrails($this->resource, $include); - $loaded = []; - - foreach ($trails as $relationships) { - $relationship = end($relationships); - - if (($load = $relationship->getLoadable()) instanceof Closure) { - $load($models, $relationships, false, $request); - } else { - $scope = function ($query) use ($request, $relationship) { - run_callbacks($relationship->getScopes(), [$query, $request]); - }; - - $adapter->load($models, $relationships, $scope); - } - - $loaded[] = $relationships[0]; - } + private function loadRelationshipsAtLevel(array $models, array $relationshipPath, ResourceType $resource, array $include, Request $request) + { + $adapter = $resource->getAdapter(); + $fields = $resource->getSchema()->getFields(); foreach ($fields as $name => $field) { - if (! $field instanceof Relationship || ! evaluate($field->getLinkage(), [$request]) || ! $field->getLoadable() || in_array($field, $loaded)) { + if ( + ! $field instanceof Relationship + || (! $field->isLinkage() && ! isset($include[$name])) + ) { continue; } - if (($load = $field->getLoadable()) instanceof Closure) { - $load($models, true); - } else { - $adapter->loadIds($models, $field); + $nextRelationshipPath = array_merge($relationshipPath, [$field]); + + if ($load = $field->isLoadable()) { + if (is_callable($load)) { + $load($models, $nextRelationshipPath, $field->isLinkage(), $request); + } else { + $scope = function ($query) use ($request, $field) { + run_callbacks($field->getListeners('scope'), [$query, $request]); + }; + + $adapter->load($models, $nextRelationshipPath, $scope, $field->isLinkage()); + } + } + + if (isset($include[$name]) && is_string($type = $field->getType())) { + $relatedResource = $this->api->getResource($type); + + $this->loadRelationshipsAtLevel($models, $nextRelationshipPath, $relatedResource, $include[$name] ?? [], $request); } } } diff --git a/src/Handler/Concerns/SavesData.php b/src/Handler/Concerns/SavesData.php index 4548f21..1c17de0 100644 --- a/src/Handler/Concerns/SavesData.php +++ b/src/Handler/Concerns/SavesData.php @@ -1,34 +1,61 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Handler\Concerns; use Psr\Http\Message\ServerRequestInterface as Request; -use function Tobyz\JsonApiServer\evaluate; -use function Tobyz\JsonApiServer\get_value; -use function Tobyz\JsonApiServer\has_value; -use function Tobyz\JsonApiServer\set_value; use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\UnprocessableEntityException; -use function Tobyz\JsonApiServer\run_callbacks; +use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\Schema\Attribute; use Tobyz\JsonApiServer\Schema\HasMany; use Tobyz\JsonApiServer\Schema\HasOne; use Tobyz\JsonApiServer\Schema\Relationship; +use function Tobyz\JsonApiServer\evaluate; +use function Tobyz\JsonApiServer\get_value; +use function Tobyz\JsonApiServer\has_value; +use function Tobyz\JsonApiServer\run_callbacks; +use function Tobyz\JsonApiServer\set_value; +/** + * @property JsonApi $api + * @property ResourceType $resource + */ trait SavesData { use FindsResources; - private function parseData($body): array + /** + * Parse and validate a JSON:API document's `data` member. + * + * @throws BadRequestException if the `data` member is invalid. + */ + private function parseData($body, $model = null): array { - if (! is_array($body) && ! is_object($body)) { - throw new BadRequestException; - } - $body = (array) $body; - if (! isset($body['data'])) { - throw new BadRequestException('Root data attribute missing'); + if (! isset($body['data']) || ! is_array($body['data'])) { + throw new BadRequestException('data must be an object'); + } + + if (! isset($body['data']['type']) || $body['data']['type'] !== $this->resource->getType()) { + throw new BadRequestException('data.type does not match the resource type'); + } + + if ($model) { + $id = $this->resource->getAdapter()->getId($model); + if (! isset($body['data']['id']) || $body['data']['id'] !== $id) { + throw new BadRequestException('data.id does not match the resource ID'); + } } if (isset($body['data']['attributes']) && ! is_array($body['data']['attributes'])) { @@ -45,7 +72,12 @@ trait SavesData ); } - private function getModelForIdentifier(Request $request, $identifier, array $validTypes = null) + /** + * Get the model corresponding to the given identifier. + * + * @throws BadRequestException if the identifier is invalid. + */ + private function getModelForIdentifier(Request $request, array $identifier, array $validTypes = null) { if (! isset($identifier['type'])) { throw new BadRequestException('type not specified'); @@ -64,12 +96,20 @@ trait SavesData return $this->findResource($request, $resource, $identifier['id']); } + /** + * Assert that the fields contained within a data object are valid. + */ private function validateFields(array $data, $model, Request $request) { $this->assertFieldsExist($data); $this->assertFieldsWritable($data, $model, $request); } + /** + * Assert that the fields contained within a data object exist in the schema. + * + * @throws BadRequestException if a field is unknown. + */ private function assertFieldsExist(array $data) { $fields = $this->resource->getSchema()->getFields(); @@ -83,15 +123,23 @@ trait SavesData } } + /** + * Assert that the fields contained within a data object are writable. + * + * @throws BadRequestException if a field is not writable. + */ private function assertFieldsWritable(array $data, $model, Request $request) { foreach ($this->resource->getSchema()->getFields() as $field) { - if (has_value($data, $field) && ! evaluate($field->getWritable(), [$model, $request])) { + if (has_value($data, $field) && ! evaluate($field->isWritable(), [$model, $request])) { throw new BadRequestException("Field [{$field->getName()}] is not writable"); } } } + /** + * Replace relationship linkage within a data object with models. + */ private function loadRelatedResources(array &$data, Request $request) { foreach ($this->resource->getSchema()->getFields() as $field) { @@ -102,7 +150,7 @@ trait SavesData $value = get_value($data, $field); if (isset($value['data'])) { - $allowedTypes = $field->getAllowedTypes(); + $allowedTypes = (array) $field->getType(); if ($field instanceof HasOne) { set_value($data, $field, $this->getModelForIdentifier($request, $value['data'], $allowedTypes)); @@ -117,12 +165,17 @@ trait SavesData } } - private function assertDataValid(array $data, $model, Request $request, bool $all): void + /** + * Assert that the field values within a data object pass validation. + * + * @throws UnprocessableEntityException if any fields do not pass validation. + */ + private function assertDataValid(array $data, $model, Request $request, bool $validateAll): void { $failures = []; foreach ($this->resource->getSchema()->getFields() as $field) { - if (! $all && ! has_value($data, $field)) { + if (! $validateAll && ! has_value($data, $field)) { continue; } @@ -141,6 +194,9 @@ trait SavesData } } + /** + * Set field values from a data object to the model instance. + */ private function setValues(array $data, $model, Request $request) { $adapter = $this->resource->getAdapter(); @@ -169,21 +225,30 @@ trait SavesData } } + /** + * Save the model and its fields. + */ private function save(array $data, $model, Request $request) { $this->saveModel($model, $request); $this->saveFields($data, $model, $request); } + /** + * Save the model. + */ private function saveModel($model, Request $request) { - if ($saver = $this->resource->getSchema()->getSaver()) { - $saver($model, $request); + if ($saveCallback = $this->resource->getSchema()->getSaveCallback()) { + $saveCallback($model, $request); } else { $this->resource->getAdapter()->save($model); } } + /** + * Save any fields that were not saved with the model. + */ private function saveFields(array $data, $model, Request $request) { $adapter = $this->resource->getAdapter(); @@ -195,8 +260,8 @@ trait SavesData $value = get_value($data, $field); - if ($saver = $field->getSaver()) { - $saver($model, $value, $request); + if ($saveCallback = $field->getSaveCallback()) { + $saveCallback($model, $value, $request); } elseif ($field instanceof HasMany) { $adapter->saveHasMany($model, $field, $value); } @@ -205,6 +270,9 @@ trait SavesData $this->runSavedCallbacks($data, $model, $request); } + /** + * Run field saved listeners. + */ private function runSavedCallbacks(array $data, $model, Request $request) { foreach ($this->resource->getSchema()->getFields() as $field) { diff --git a/src/Handler/Create.php b/src/Handler/Create.php index de99860..bd5ff90 100644 --- a/src/Handler/Create.php +++ b/src/Handler/Create.php @@ -1,15 +1,24 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Handler; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface; +use Tobyz\JsonApiServer\Exception\ForbiddenException; +use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\ResourceType; use function Tobyz\JsonApiServer\evaluate; use function Tobyz\JsonApiServer\has_value; -use Tobyz\JsonApiServer\JsonApi; -use Tobyz\JsonApiServer\Exception\ForbiddenException; -use Tobyz\JsonApiServer\ResourceType; use function Tobyz\JsonApiServer\run_callbacks; use function Tobyz\JsonApiServer\set_value; @@ -26,11 +35,16 @@ class Create implements RequestHandlerInterface $this->resource = $resource; } + /** + * Handle a request to create a resource. + * + * @throws ForbiddenException if the resource is not creatable. + */ public function handle(Request $request): Response { $schema = $this->resource->getSchema(); - if (! evaluate($schema->getCreatable(), [$request])) { + if (! evaluate($schema->isCreatable(), [$request])) { throw new ForbiddenException; } @@ -56,9 +70,9 @@ class Create implements RequestHandlerInterface private function createModel(Request $request) { - $creator = $this->resource->getSchema()->getCreator(); + $createModel = $this->resource->getSchema()->getCreateModelCallback(); - return $creator ? $creator($request) : $this->resource->getAdapter()->create(); + return $createModel ? $createModel($request) : $this->resource->getAdapter()->create(); } private function fillDefaultValues(array &$data, Request $request) diff --git a/src/Handler/Delete.php b/src/Handler/Delete.php index 8452f9a..16c89bf 100644 --- a/src/Handler/Delete.php +++ b/src/Handler/Delete.php @@ -1,15 +1,24 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Handler; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface; -use function Tobyz\JsonApiServer\evaluate; use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\ResourceType; -use function Tobyz\JsonApiServer\run_callbacks; use Zend\Diactoros\Response\EmptyResponse; +use function Tobyz\JsonApiServer\evaluate; +use function Tobyz\JsonApiServer\run_callbacks; class Delete implements RequestHandlerInterface { @@ -22,18 +31,23 @@ class Delete implements RequestHandlerInterface $this->model = $model; } + /** + * Handle a request to delete a resource. + * + * @throws ForbiddenException if the resource is not deletable. + */ public function handle(Request $request): Response { $schema = $this->resource->getSchema(); - if (! evaluate($schema->getDeletable(), [$this->model, $request])) { + if (! evaluate($schema->isDeletable(), [$this->model, $request])) { throw new ForbiddenException; } run_callbacks($schema->getListeners('deleting'), [$this->model, $request]); - if ($deleter = $this->resource->getSchema()->getDelete()) { - $deleter($this->model, $request); + if ($deleteCallback = $schema->getDeleteCallback()) { + $deleteCallback($this->model, $request); } else { $this->resource->getAdapter()->delete($this->model); } diff --git a/src/Handler/Index.php b/src/Handler/Index.php index 1ad6d85..8959f93 100644 --- a/src/Handler/Index.php +++ b/src/Handler/Index.php @@ -1,24 +1,32 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Handler; -use Closure; use JsonApiPhp\JsonApi as Structure; use JsonApiPhp\JsonApi\Link\LastLink; use JsonApiPhp\JsonApi\Link\NextLink; use JsonApiPhp\JsonApi\Link\PrevLink; -use JsonApiPhp\JsonApi\Meta; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface; -use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\Exception\BadRequestException; +use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\JsonApiResponse; use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\Schema\Attribute; use Tobyz\JsonApiServer\Schema\HasMany; use Tobyz\JsonApiServer\Schema\HasOne; use Tobyz\JsonApiServer\Serializer; +use function Tobyz\JsonApiServer\run_callbacks; class Index implements RequestHandlerInterface { @@ -33,35 +41,81 @@ class Index implements RequestHandlerInterface $this->resource = $resource; } + /** + * Handle a request to show a resource listing. + */ public function handle(Request $request): Response { - $include = $this->getInclude($request); - - $request = $this->extractQueryParams($request); - $adapter = $this->resource->getAdapter(); $schema = $this->resource->getSchema(); + run_callbacks($schema->getListeners('listing'), [&$request]); + $query = $adapter->query(); - foreach ($schema->getScopes() as $scope) { - $request = $scope($query, $request, null) ?: $request; + run_callbacks($schema->getListeners('scope'), [$query, $request, null]); + + $include = $this->getInclude($request); + + $this->filter($query, $request); + $this->sort($query, $request); + + $total = $schema->isCountable() ? $adapter->count($query) : null; + + [$offset, $limit] = $this->paginate($query, $request); + + $models = $adapter->get($query); + + $this->loadRelationships($models, $include, $request); + + run_callbacks($schema->getListeners('listed'), [$models, $request]); + + $serializer = new Serializer($this->api, $request); + + foreach ($models as $model) { + $serializer->add($this->resource, $model, $include); } - if ($filter = $request->getAttribute('jsonApiFilter')) { - $this->filter($query, $filter, $request); + return new JsonApiResponse( + new Structure\CompoundDocument( + new Structure\PaginatedCollection( + new Structure\Pagination(...$this->buildPaginationLinks($request, $offset, $limit, count($models), $total)), + new Structure\ResourceCollection(...$serializer->primary()) + ), + new Structure\Included(...$serializer->included()), + new Structure\Link\SelfLink($this->buildUrl($request)), + new Structure\Meta('offset', $offset), + new Structure\Meta('limit', $limit), + ...($total !== null ? [new Structure\Meta('total', $total)] : []) + ) + ); + } + + private function buildUrl(Request $request, array $overrideParams = []): string + { + [$selfUrl] = explode('?', $request->getUri(), 2); + + $queryParams = array_replace_recursive($request->getQueryParams(), $overrideParams); + + if (isset($queryParams['page']['offset']) && $queryParams['page']['offset'] <= 0) { + unset($queryParams['page']['offset']); } - $offset = $request->getAttribute('jsonApiOffset'); - $limit = $request->getAttribute('jsonApiLimit'); - $total = null; + if (isset($queryParams['filter'])) { + foreach ($queryParams['filter'] as $k => &$v) { + $v = $v === null ? '' : $v; + } + } + $queryString = http_build_query($queryParams); + + return $selfUrl.($queryString ? '?'.$queryString : ''); + } + + private function buildPaginationLinks(Request $request, int $offset, ?int $limit, int $count, ?int $total) + { $paginationLinks = []; - $members = [ - new Structure\Link\SelfLink($this->buildUrl($request)), - new Structure\Meta('offset', $offset), - new Structure\Meta('limit', $limit), - ]; + $schema = $this->resource->getSchema(); if ($offset > 0) { $paginationLinks[] = new Structure\Link\FirstLink($this->buildUrl($request, ['page' => ['offset' => 0]])); @@ -77,165 +131,53 @@ class Index implements RequestHandlerInterface $paginationLinks[] = new PrevLink($this->buildUrl($request, $params)); } - if ($schema->isCountable() && $schema->getPaginate()) { - $total = $adapter->count($query); - - $members[] = new Meta('total', $total); - - if ($offset + $limit < $total) { - $paginationLinks[] = new LastLink($this->buildUrl($request, ['page' => ['offset' => floor(($total - 1) / $limit) * $limit]])); - } + if ($schema->isCountable() && $schema->getPerPage() && $offset + $limit < $total) { + $paginationLinks[] = new LastLink($this->buildUrl($request, ['page' => ['offset' => floor(($total - 1) / $limit) * $limit]])); } - if ($sort = $request->getAttribute('jsonApiSort')) { - $this->sort($query, $sort, $request); - } - - $this->paginate($query, $request); - - $models = $adapter->get($query); - - if ((count($models) === $limit && $total === null) || $offset + $limit < $total) { + if (($total === null && $count === $limit) || $offset + $limit < $total) { $paginationLinks[] = new NextLink($this->buildUrl($request, ['page' => ['offset' => $offset + $limit]])); } - $this->loadRelationships($models, $include, $request); - - $serializer = new Serializer($this->api, $request); - - foreach ($models as $model) { - $serializer->add($this->resource, $model, $include); - } - - return new JsonApiResponse( - new Structure\CompoundDocument( - new Structure\PaginatedCollection( - new Structure\Pagination(...$paginationLinks), - new Structure\ResourceCollection(...$serializer->primary()) - ), - new Structure\Included(...$serializer->included()), - ...$members - ) - ); + return $paginationLinks; } - private function buildUrl(Request $request, array $overrideParams = []): string - { - [$selfUrl] = explode('?', $request->getUri(), 2); - $queryParams = $request->getQueryParams(); - - $queryParams = array_replace_recursive($queryParams, $overrideParams); - - if (isset($queryParams['page']['offset']) && $queryParams['page']['offset'] <= 0) { - unset($queryParams['page']['offset']); - } - - if (isset($queryParams['filter'])) { - foreach ($queryParams['filter'] as $k => &$v) { - if ($v === null) { - $v = ''; - } - } - } - - $queryString = http_build_query($queryParams); - - return $selfUrl.($queryString ? '?'.$queryString : ''); - } - - private function extractQueryParams(Request $request): Request + private function sort($query, Request $request) { $schema = $this->resource->getSchema(); - $queryParams = $request->getQueryParams(); - - $limit = $this->resource->getSchema()->getPaginate(); - - if (isset($queryParams['page']['limit'])) { - $limit = $queryParams['page']['limit']; - - if ((! is_int($limit) && ! ctype_digit($limit)) || $limit < 1) { - throw new BadRequestException('page[limit] must be a positive integer', 'page[limit]'); - } - - $limit = min($this->resource->getSchema()->getLimit(), $limit); + if (! $sort = $request->getQueryParams()['sort'] ?? $schema->getDefaultSort()) { + return; } - $offset = 0; - - if (isset($queryParams['page']['offset'])) { - $offset = $queryParams['page']['offset']; - - if ((! is_int($offset) && ! ctype_digit($offset)) || $offset < 0) { - throw new BadRequestException('page[offset] must be a non-negative integer', 'page[offset]'); - } - } - - $request = $request - ->withAttribute('jsonApiLimit', $limit) - ->withAttribute('jsonApiOffset', $offset); - - $sort = $queryParams['sort'] ?? $this->resource->getSchema()->getDefaultSort(); - - if ($sort) { - $sort = $this->parseSort($sort); - $fields = $schema->getFields(); - - foreach ($sort as $name => $direction) { - if (! isset($fields[$name]) - || ! $fields[$name] instanceof Attribute - || ! $fields[$name]->getSortable() - ) { - throw new BadRequestException("Invalid sort field [$name]", 'sort'); - } - } - } - - $request = $request->withAttribute('jsonApiSort', $sort); - - $filter = $queryParams['filter'] ?? null; - - if ($filter) { - if (! is_array($filter)) { - throw new BadRequestException('filter must be an array', 'filter'); - } - - $fields = $schema->getFields(); - - foreach ($filter as $name => $value) { - if ($name !== 'id' && (! isset($fields[$name]) || ! $fields[$name]->getFilterable())) { - throw new BadRequestException("Invalid filter [$name]", "filter[$name]"); - } - } - } - - $request = $request->withAttribute('jsonApiFilter', $filter); - - return $request; - } - - private function sort($query, array $sort, Request $request) - { - $schema = $this->resource->getSchema(); $adapter = $this->resource->getAdapter(); + $sortFields = $schema->getSortFields(); + $fields = $schema->getFields(); - foreach ($sort as $name => $direction) { - $attribute = $schema->getFields()[$name]; - - if (($sorter = $attribute->getSortable()) instanceof Closure) { - $sorter($query, $direction, $request); - } else { - $adapter->sortByAttribute($query, $attribute, $direction); + foreach ($this->parseSort($sort) as $name => $direction) { + if (isset($sortFields[$name])) { + $sortFields[$name]($query, $direction, $request); + continue; } + + if ( + isset($fields[$name]) + && $fields[$name] instanceof Attribute + && $fields[$name]->isSortable() + ) { + $adapter->sortByAttribute($query, $fields[$name], $direction); + continue; + } + + throw new BadRequestException("Invalid sort field [$name]", 'sort'); } } private function parseSort(string $string): array { $sort = []; - $fields = explode(',', $string); - foreach ($fields as $field) { + foreach (explode(',', $string) as $field) { if ($field[0] === '-') { $field = substr($field, 1); $direction = 'desc'; @@ -251,18 +193,51 @@ class Index implements RequestHandlerInterface private function paginate($query, Request $request) { - $limit = $request->getAttribute('jsonApiLimit'); - $offset = $request->getAttribute('jsonApiOffset'); + $schema = $this->resource->getSchema(); + $queryParams = $request->getQueryParams(); + $limit = $schema->getPerPage(); + + if (isset($queryParams['page']['limit'])) { + $limit = $queryParams['page']['limit']; + + if (! ctype_digit(strval($limit)) || $limit < 1) { + throw new BadRequestException('page[limit] must be a positive integer', 'page[limit]'); + } + + $limit = min($schema->getLimit(), $limit); + } + + $offset = 0; + + if (isset($queryParams['page']['offset'])) { + $offset = $queryParams['page']['offset']; + + if (! ctype_digit(strval($offset)) || $offset < 0) { + throw new BadRequestException('page[offset] must be a non-negative integer', 'page[offset]'); + } + } if ($limit || $offset) { $this->resource->getAdapter()->paginate($query, $limit, $offset); } + + return [$offset, $limit]; } - private function filter($query, $filter, Request $request) + private function filter($query, Request $request) { + if (! $filter = $request->getQueryParams()['filter'] ?? null) { + return; + } + + if (! is_array($filter)) { + throw new BadRequestException('filter must be an array', 'filter'); + } + $schema = $this->resource->getSchema(); $adapter = $this->resource->getAdapter(); + $filters = $schema->getFilters(); + $fields = $schema->getFields(); foreach ($filter as $name => $value) { if ($name === 'id') { @@ -270,19 +245,25 @@ class Index implements RequestHandlerInterface continue; } - $field = $schema->getFields()[$name]; - - if (($filter = $field->getFilterable()) instanceof Closure) { - $filter($query, $value, $request); - } elseif ($field instanceof Attribute) { - $adapter->filterByAttribute($query, $field, $value); - } elseif ($field instanceof HasOne) { - $value = explode(',', $value); - $adapter->filterByHasOne($query, $field, $value); - } elseif ($field instanceof HasMany) { - $value = explode(',', $value); - $adapter->filterByHasMany($query, $field, $value); + if (isset($filters[$name])) { + $filters[$name]($query, $value, $request); + continue; } + + if (isset($fields[$name]) && $fields[$name]->isFilterable()) { + if ($fields[$name] instanceof Attribute) { + $adapter->filterByAttribute($query, $fields[$name], $value); + } elseif ($fields[$name] instanceof HasOne) { + $value = explode(',', $value); + $adapter->filterByHasOne($query, $fields[$name], $value); + } elseif ($fields[$name] instanceof HasMany) { + $value = explode(',', $value); + $adapter->filterByHasMany($query, $fields[$name], $value); + } + continue; + } + + throw new BadRequestException("Invalid filter [$name]", "filter[$name]"); } } } diff --git a/src/Handler/Show.php b/src/Handler/Show.php index 4c55962..0e29ed7 100644 --- a/src/Handler/Show.php +++ b/src/Handler/Show.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Handler; use JsonApiPhp\JsonApi\CompoundDocument; @@ -11,6 +20,7 @@ use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\JsonApiResponse; use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\Serializer; +use function Tobyz\JsonApiServer\run_callbacks; class Show implements RequestHandlerInterface { @@ -27,15 +37,19 @@ class Show implements RequestHandlerInterface $this->model = $model; } + /** + * Handle a request to show a resource. + */ public function handle(Request $request): Response { $include = $this->getInclude($request); $this->loadRelationships([$this->model], $include, $request); - $serializer = new Serializer($this->api, $request); + run_callbacks($this->resource->getSchema()->getListeners('show'), [$this->model, $request]); - $serializer->add($this->resource, $this->model, $include, true); + $serializer = new Serializer($this->api, $request); + $serializer->addSingle($this->resource, $this->model, $include); return new JsonApiResponse( new CompoundDocument( diff --git a/src/Handler/Update.php b/src/Handler/Update.php index 818d628..7671359 100644 --- a/src/Handler/Update.php +++ b/src/Handler/Update.php @@ -1,14 +1,23 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Handler; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface; -use function Tobyz\JsonApiServer\evaluate; -use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\Exception\ForbiddenException; +use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\ResourceType; +use function Tobyz\JsonApiServer\evaluate; use function Tobyz\JsonApiServer\run_callbacks; class Update implements RequestHandlerInterface @@ -26,15 +35,20 @@ class Update implements RequestHandlerInterface $this->model = $model; } + /** + * Handle a request to update a resource. + * + * @throws ForbiddenException if the resource is not updatable. + */ public function handle(Request $request): Response { $schema = $this->resource->getSchema(); - if (! evaluate($schema->getUpdatable(), [$this->model, $request])) { + if (! evaluate($schema->isUpdatable(), [$this->model, $request])) { throw new ForbiddenException; } - $data = $this->parseData($request->getParsedBody()); + $data = $this->parseData($request->getParsedBody(), $this->model); $this->validateFields($data, $this->model, $request); $this->loadRelatedResources($data, $request); @@ -47,6 +61,7 @@ class Update implements RequestHandlerInterface run_callbacks($schema->getListeners('updated'), [$this->model, $request]); - return (new Show($this->api, $this->resource, $this->model))->handle($request); + return (new Show($this->api, $this->resource, $this->model)) + ->handle($request); } } diff --git a/src/Http/MediaTypes.php b/src/Http/MediaTypes.php index a9cceef..fc67a8b 100644 --- a/src/Http/MediaTypes.php +++ b/src/Http/MediaTypes.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Http; class MediaTypes diff --git a/src/JsonApi.php b/src/JsonApi.php index a065094..d5d5f36 100644 --- a/src/JsonApi.php +++ b/src/JsonApi.php @@ -1,12 +1,21 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer; -use Closure; use JsonApiPhp\JsonApi\ErrorDocument; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface; +use Tobyz\JsonApiServer\Adapter\AdapterInterface; use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\InternalServerErrorException; use Tobyz\JsonApiServer\Exception\MethodNotAllowedException; @@ -31,16 +40,27 @@ final class JsonApi implements RequestHandlerInterface $this->baseUrl = $baseUrl; } - public function resource(string $type, $adapter, Closure $buildSchema = null): void + /** + * Define a new resource type. + */ + public function resource(string $type, AdapterInterface $adapter, callable $buildSchema = null): void { $this->resources[$type] = new ResourceType($type, $adapter, $buildSchema); } + /** + * Get defined resource types. + */ public function getResources(): array { return $this->resources; } + /** + * Get a resource type. + * + * @throws ResourceNotFoundException if the resource type has not been defined. + */ public function getResource(string $type): ResourceType { if (! isset($this->resources[$type])) { @@ -50,6 +70,15 @@ final class JsonApi implements RequestHandlerInterface return $this->resources[$type]; } + /** + * Handle a request. + * + * @throws UnsupportedMediaTypeException if the request Content-Type header is invalid + * @throws NotAcceptableException if the request Accept header is invalid + * @throws MethodNotAllowedException if the request method is invalid + * @throws BadRequestException if the request URI is invalid + * @throws NotImplementedException + */ public function handle(Request $request): Response { $this->validateRequest($request); @@ -112,11 +141,7 @@ final class JsonApi implements RequestHandlerInterface $mediaTypes = new MediaTypes($header); - if ($mediaTypes->containsExactly('*/*')) { - return; - } - - if ($mediaTypes->containsExactly(self::CONTENT_TYPE)) { + if ($mediaTypes->containsExactly('*/*') || $mediaTypes->containsExactly(self::CONTENT_TYPE)) { return; } @@ -172,6 +197,12 @@ final class JsonApi implements RequestHandlerInterface } } + /** + * Convert an exception into a JSON:API error document response. + * + * If the exception is not an instance of ErrorProviderInterface, an + * Internal Server Error response will be produced. + */ public function error($e) { if (! $e instanceof ErrorProviderInterface) { @@ -188,6 +219,9 @@ final class JsonApi implements RequestHandlerInterface return new JsonApiResponse($data, $status); } + /** + * Get the base URL for the API. + */ public function getBaseUrl(): string { return $this->baseUrl; diff --git a/src/JsonApiResponse.php b/src/JsonApiResponse.php index 85a1a01..6ce441b 100644 --- a/src/JsonApiResponse.php +++ b/src/JsonApiResponse.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer; use Zend\Diactoros\Response\JsonResponse; diff --git a/src/ResourceType.php b/src/ResourceType.php index 09f403d..c23d27c 100644 --- a/src/ResourceType.php +++ b/src/ResourceType.php @@ -1,8 +1,16 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer; -use Closure; use Tobyz\JsonApiServer\Adapter\AdapterInterface; use Tobyz\JsonApiServer\Schema\Type; @@ -13,7 +21,7 @@ final class ResourceType private $buildSchema; private $schema; - public function __construct(string $type, AdapterInterface $adapter, Closure $buildSchema = null) + public function __construct(string $type, AdapterInterface $adapter, callable $buildSchema = null) { $this->type = $type; $this->adapter = $adapter; diff --git a/src/Schema/Attribute.php b/src/Schema/Attribute.php index c1608d9..2d5c20b 100644 --- a/src/Schema/Attribute.php +++ b/src/Schema/Attribute.php @@ -1,20 +1,38 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ -use Closure; +namespace Tobyz\JsonApiServer\Schema; final class Attribute extends Field { private $sortable = false; - public function sortable(Closure $callback = null) + public function getLocation(): string { - $this->sortable = $callback ?: true; + return 'attributes'; + } + + /** + * Allow this attribute to be used for sorting the resource listing. + */ + public function sortable() + { + $this->sortable = true; return $this; } + /** + * Disallow this attribute to be used for sorting the resource listing. + */ public function notSortable() { $this->sortable = false; @@ -22,13 +40,8 @@ final class Attribute extends Field return $this; } - public function getSortable() + public function isSortable(): bool { return $this->sortable; } - - public function getLocation(): string - { - return 'attributes'; - } } diff --git a/src/Schema/Concerns/HasListeners.php b/src/Schema/Concerns/HasListeners.php index 82e25b8..ea5184f 100644 --- a/src/Schema/Concerns/HasListeners.php +++ b/src/Schema/Concerns/HasListeners.php @@ -13,9 +13,9 @@ namespace Tobyz\JsonApiServer\Schema\Concerns; trait HasListeners { - private $listeners = []; + protected $listeners = []; - public function getListeners(string $event) + public function getListeners(string $event): array { return $this->listeners[$event] ?? []; } diff --git a/src/Schema/Field.php b/src/Schema/Field.php index d1e885f..0f44136 100644 --- a/src/Schema/Field.php +++ b/src/Schema/Field.php @@ -1,10 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Schema; -use Closure; -use function Tobyz\JsonApiServer\negate; use Tobyz\JsonApiServer\Schema\Concerns\HasListeners; +use function Tobyz\JsonApiServer\negate; use function Tobyz\JsonApiServer\wrap; abstract class Field @@ -16,10 +24,10 @@ abstract class Field private $visible = true; private $single = false; private $writable = false; - private $getter; - private $setter; - private $saver; - private $default; + private $getCallback; + private $setCallback; + private $saveCallback; + private $defaultCallback; private $filterable = false; public function __construct(string $name) @@ -27,8 +35,15 @@ abstract class Field $this->name = $name; } + /** + * Get the location of the field within a JSON:API resource object + * ('attributes' or 'relationships'). + */ abstract public function getLocation(): string; + /** + * Set the model property to which this field corresponds. + */ public function property(string $property) { $this->property = $property; @@ -36,14 +51,20 @@ abstract class Field return $this; } - public function visible(Closure $condition = null) + /** + * Allow this field to be seen. + */ + public function visible(callable $condition = null) { $this->visible = $condition ?: true; return $this; } - public function hidden(Closure $condition = null) + /** + * Disallow this field to be seen. + */ + public function hidden(callable $condition = null) { $this->visible = $condition ? negate($condition) : false; @@ -51,9 +72,11 @@ abstract class Field } /** - * Indicates that the field should only be visible on single root resources + * Only show this field on single root resources. * - * @return $this + * This is useful if a field requires an expensive calculation for each + * individual resource (eg. n+1 query problem). In this case it may be + * desirable to only have the field show when viewing a single resource. */ public function single() { @@ -62,69 +85,109 @@ abstract class Field return $this; } - public function writable(Closure $condition = null) + /** + * Allow this field to be written. + */ + public function writable(callable $condition = null) { $this->writable = $condition ?: true; return $this; } - public function readonly(Closure $condition = null) + /** + * Disallow this field to be written. + */ + public function readonly(callable $condition = null) { $this->writable = $condition ? negate($condition) : false; return $this; } + /** + * Define the value of this field. + * + * If null, the adapter will be used to get the value of this field. + * + * @param null|string|callable $value + */ public function get($value) { - $this->getter = wrap($value); + $this->getCallback = $value === null ? null : wrap($value); return $this; } - public function set(Closure $callback) + /** + * Set the callback to apply a new value for this field to the model. + * + * If null, the adapter will be used to set the field on the model. + */ + public function set(?callable $callback) { - $this->setter = $callback; + $this->setCallback = $callback; return $this; } - public function save(Closure $callback) + /** + * Set the callback to save this field to the model. + * + * If specified, the adapter will NOT be used to set the field on the model. + */ + public function save(?callable $callback) { - $this->saver = $callback; + $this->saveCallback = $callback; return $this; } - public function saved(Closure $callback) + /** + * Run a callback after this field has been saved. + */ + public function onSaved(callable $callback) { $this->listeners['saved'][] = $callback; return $this; } + /** + * Set a default value for this field to be used when creating a resource. + * + * @param null|string|callable $value + */ public function default($value) { - $this->default = wrap($value); + $this->defaultCallback = wrap($value); return $this; } - public function validate(Closure $callback) + /** + * Add a validation callback for this field. + */ + public function validate(callable $callback) { $this->listeners['validate'][] = $callback; return $this; } - public function filterable(Closure $callback = null) + /** + * Allow this field to be used for filtering the resource listing. + */ + public function filterable() { - $this->filterable = $callback ?: true; + $this->filterable = true; return $this; } + /** + * Disallow this field to be used for filtering the resource listing. + */ public function notFilterable() { $this->filterable = false; @@ -137,53 +200,47 @@ abstract class Field return $this->name; } - public function getProperty() + public function getProperty(): ?string { return $this->property; } - /** - * @return bool|Closure - */ - public function getVisible() + public function isVisible() { return $this->visible; } - public function getSingle() + public function isSingle(): bool { return $this->single; } - public function getWritable() + public function isWritable() { return $this->writable; } - public function getGetter() + public function getGetCallback() { - return $this->getter; + return $this->getCallback; } - public function getSetter() + public function getSetCallback(): ?callable { - return $this->setter; + return $this->setCallback; } - public function getSaver() + public function getSaveCallback(): ?callable { - return $this->saver; + return $this->saveCallback; } - public function getDefault() + public function getDefaultCallback() { - return $this->default; + return $this->defaultCallback; } - /** - * @return bool|Closure - */ - public function getFilterable() + public function isFilterable(): bool { return $this->filterable; } diff --git a/src/Schema/HasMany.php b/src/Schema/HasMany.php index 32fb2ff..9bfab44 100644 --- a/src/Schema/HasMany.php +++ b/src/Schema/HasMany.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Schema; final class HasMany extends Relationship diff --git a/src/Schema/HasOne.php b/src/Schema/HasOne.php index b111c95..709f433 100644 --- a/src/Schema/HasOne.php +++ b/src/Schema/HasOne.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Schema; use Doctrine\Common\Inflector\Inflector; diff --git a/src/Schema/Meta.php b/src/Schema/Meta.php index 153fa3f..b0691cb 100644 --- a/src/Schema/Meta.php +++ b/src/Schema/Meta.php @@ -1,19 +1,25 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ -use Closure; -use function Tobyz\JsonApiServer\wrap; +namespace Tobyz\JsonApiServer\Schema; final class Meta { private $name; private $value; - public function __construct(string $name, $value) + public function __construct(string $name, callable $value) { $this->name = $name; - $this->value = wrap($value); + $this->value = $value; } public function getName(): string @@ -21,7 +27,7 @@ final class Meta return $this->name; } - public function getValue(): Closure + public function getValue(): callable { return $this->value; } diff --git a/src/Schema/Relationship.php b/src/Schema/Relationship.php index 4a4f23f..f4bd54c 100644 --- a/src/Schema/Relationship.php +++ b/src/Schema/Relationship.php @@ -1,20 +1,32 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ -use Closure; -use function Tobyz\JsonApiServer\negate; +namespace Tobyz\JsonApiServer\Schema; abstract class Relationship extends Field { private $type; - private $allowedTypes; private $linkage = false; private $links = true; private $loadable = true; private $includable = false; - private $scopes = []; + public function getLocation(): string + { + return 'relationships'; + } + + /** + * Set the resource type that this relationship is to. + */ public function type($type) { $this->type = $type; @@ -22,35 +34,52 @@ abstract class Relationship extends Field return $this; } + /** + * Define this as a polymorphic relationship. + */ public function polymorphic(array $types = null) { - $this->type = null; - $this->allowedTypes = $types; + $this->type = $types; return $this; } - public function linkage(Closure $condition = null) + /** + * Show resource linkage for the relationship. + */ + public function linkage() { - $this->linkage = $condition ?: true; + $this->linkage = true; return $this; } - public function noLinkage(Closure $condition = null) + /** + * Do not show resource linkage for the relationship. + */ + public function noLinkage() { - $this->linkage = $condition ? negate($condition) : false; + $this->linkage = false; return $this; } - public function loadable(Closure $callback = null) + /** + * Allow the relationship data to be eager-loaded into the model collection. + * + * This is used to prevent the n+1 query problem. If null, the adapter will + * be used to eager-load relationship data into the model collection. + */ + public function loadable(callable $callback = null) { $this->loadable = $callback ?: true; return $this; } + /** + * Do not eager-load relationship data into the model collection. + */ public function notLoadable() { $this->loadable = false; @@ -58,6 +87,9 @@ abstract class Relationship extends Field return $this; } + /** + * Allow the relationship data to be included in a compound document. + */ public function includable() { $this->includable = true; @@ -65,6 +97,9 @@ abstract class Relationship extends Field return $this; } + /** + * Do not allow the relationship data to be included in a compound document. + */ public function notIncludable() { $this->includable = false; @@ -72,16 +107,9 @@ abstract class Relationship extends Field return $this; } - public function getType(): ?string - { - return $this->type; - } - - public function getAllowedTypes(): ?array - { - return $this->allowedTypes; - } - + /** + * Show links for the relationship. + */ public function links() { $this->links = true; @@ -89,6 +117,9 @@ abstract class Relationship extends Field return $this; } + /** + * Do not show links for the relationship. + */ public function noLinks() { $this->links = false; @@ -96,20 +127,33 @@ abstract class Relationship extends Field return $this; } - public function getLinkage() + /** + * Apply a scope to the query to eager-load the relationship data. + */ + public function scope(callable $callback) + { + $this->listeners['scope'][] = $callback; + } + + public function getType() + { + return $this->type; + } + + public function isLinkage(): bool { return $this->linkage; } - public function hasLinks(): bool + public function isLinks(): bool { return $this->links; } /** - * @return bool|Closure + * @return bool|callable */ - public function getLoadable() + public function isLoadable() { return $this->loadable; } @@ -118,19 +162,4 @@ abstract class Relationship extends Field { return $this->includable; } - - public function getLocation(): string - { - return 'relationships'; - } - - public function scope(Closure $callback) - { - $this->scopes[] = $callback; - } - - public function getScopes(): array - { - return $this->scopes; - } } diff --git a/src/Schema/Type.php b/src/Schema/Type.php index 43390b6..c31080b 100644 --- a/src/Schema/Type.php +++ b/src/Schema/Type.php @@ -1,10 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer\Schema; -use Closure; -use function Tobyz\JsonApiServer\negate; use Tobyz\JsonApiServer\Schema\Concerns\HasListeners; +use function Tobyz\JsonApiServer\negate; final class Type { @@ -12,29 +20,49 @@ final class Type private $fields = []; private $meta = []; - private $paginate = 20; + private $filters = []; + private $sortFields = []; + private $perPage = 20; private $limit = 50; private $countable = true; + private $listable = true; private $defaultSort; private $defaultFilter; - private $scopes = []; - private $saver; + private $saveCallback; + private $createModelCallback; private $creatable = false; - private $create; private $updatable = false; private $deletable = false; - private $delete; + private $deleteCallback; + /** + * Add an attribute to the resource type. + * + * If an attribute has already been defined with this name, it will be + * returned. Otherwise, the field will be overwritten. + */ public function attribute(string $name): Attribute { return $this->field(Attribute::class, $name); } + /** + * Add a has-one relationship to the resource type. + * + * If a has-one relationship has already been defined with this name, it + * will be returned. Otherwise, the field will be overwritten. + */ public function hasOne(string $name): HasOne { return $this->field(HasOne::class, $name); } + /** + * Add a has-many relationship to the resource type. + * + * If a has-many relationship has already been defined with this name, it + * will be returned. Otherwise, the field will be overwritten. + */ public function hasMany(string $name): HasMany { return $this->field(HasMany::class, $name); @@ -49,14 +77,17 @@ final class Type return $this->fields[$name]; } - public function removeField(string $name) + /** + * Remove a field from the resource type. + */ + public function removeField(string $name): void { unset($this->fields[$name]); - - return $this; } /** + * Get the resource type's fields. + * * @return Field[] */ public function getFields(): array @@ -64,240 +95,408 @@ final class Type return $this->fields; } - public function meta(string $name, $value) + /** + * Add a meta attribute to the resource type. + */ + public function meta(string $name, callable $value): Meta { return $this->meta[$name] = new Meta($name, $value); } - public function removeMeta(string $name) + /** + * Remove a meta attribute from the resource type. + */ + public function removeMeta(string $name): void { unset($this->meta[$name]); - - return $this; } + /** + * Get the resource type's meta attributes. + * + * @return Meta[] + */ public function getMeta(): array { return $this->meta; } - public function filter(string $name, Closure $callback) + /** + * Add a filter to the resource type. + */ + public function filter(string $name, callable $callback): void { - $this->attribute($name) - ->hidden() - ->filterable($callback); - - return $this; + $this->filters[$name] = $callback; } - public function paginate(int $perPage) + /** + * Get the resource type's filters. + */ + public function getFilters(): array { - $this->paginate = $perPage; + return $this->filters; } - public function dontPaginate() + /** + * Add a sort field to the resource type. + */ + public function sort(string $name, callable $callback): void { - $this->paginate = null; + $this->sortFields[$name] = $callback; } - public function getPaginate(): ?int + /** + * Get the resource type's sort fields. + */ + public function getSortFields(): array { - return $this->paginate; + return $this->sortFields; } - public function limit(int $limit) + /** + * Paginate the listing of the resource type. + */ + public function paginate(int $perPage): void + { + $this->perPage = $perPage; + } + + /** + * Don't paginate the listing of the resource type. + */ + public function dontPaginate(): void + { + $this->perPage = null; + } + + /** + * Get the number of records to list per page, or null if the list should + * not be paginated. + */ + public function getPerPage(): ?int + { + return $this->perPage; + } + + /** + * Limit the maximum number of records that can be listed. + */ + public function limit(int $limit): void { $this->limit = $limit; } - public function noLimit() + /** + * Allow unlimited records to be listed. + */ + public function noLimit(): void { $this->limit = null; } + /** + * Get the maximum number of records that can be listed, or null if there + * is no limit. + */ public function getLimit(): int { return $this->limit; } - public function countable() + /** + * Mark the resource type as countable. + */ + public function countable(): void { $this->countable = true; } - public function uncountable() + /** + * Mark the resource type as uncountable. + */ + public function uncountable(): void { $this->countable = false; } + /** + * Get whether or not the resource type is countable. + */ public function isCountable(): bool { return $this->countable; } - public function scope(Closure $callback) + /** + * Apply a scope to the query to fetch record(s). + */ + public function scope(callable $callback): void { - $this->scopes[] = $callback; + $this->listeners['scope'][] = $callback; } - public function getScopes(): array + /** + * Run a callback before a resource is shown. + */ + public function onShowing(callable $callback): void { - return $this->scopes; + $this->listeners['showing'][] = $callback; } - public function create(?Closure $callback) + /** + * Run a callback when a resource is shown. + */ + public function onShown(callable $callback): void { - $this->create = $callback; + $this->listeners['shown'][] = $callback; } - public function getCreator() + /** + * Allow the resource type to be listed. + */ + public function listable(callable $condition = null): void { - return $this->create; + $this->listable = $condition ?: true; } - public function creatable(Closure $condition = null) + /** + * Disallow the resource type to be listed. + */ + public function notListable(callable $condition = null): void + { + $this->listable = $condition ? negate($condition) : false; + } + + /** + * Get whether or not the resource type is allowed to be listed. + */ + public function isListable() + { + return $this->listable; + } + + /** + * Run a callback before the resource type is listed. + */ + public function onListing(callable $callback): void + { + $this->listeners['listing'][] = $callback; + } + + /** + * Run a callback when the resource type is listed. + */ + public function onListed(callable $callback): void + { + $this->listeners['listed'][] = $callback; + } + + /** + * Set the callback to create a new model instance. + * + * If null, the adapter will be used to create new model instances. + */ + public function createModel(?callable $callback): void + { + $this->createModelCallback = $callback; + } + + /** + * Get the callback to create a new model instance. + */ + public function getCreateModelCallback(): ?callable + { + return $this->createModelCallback; + } + + /** + * Allow the resource type to be created. + */ + public function creatable(callable $condition = null): void { $this->creatable = $condition ?: true; - - return $this; } - public function notCreatable(Closure $condition = null) + /** + * Disallow the resource type to be created. + */ + public function notCreatable(callable $condition = null): void { $this->creatable = $condition ? negate($condition) : false; - - return $this; } - public function getCreatable() + /** + * Get whether or not the resource type is allowed to be created. + */ + public function isCreatable() { return $this->creatable; } - public function creating(Closure $callback) + /** + * Run a callback before a resource is created. + */ + public function onCreating(callable $callback): void { $this->listeners['creating'][] = $callback; - - return $this; } - public function created(Closure $callback) + /** + * Run a callback after a resource has been created. + */ + public function onCreated(callable $callback): void { $this->listeners['created'][] = $callback; - - return $this; } - public function updatable(Closure $condition = null) + /** + * Allow the resource type to be updated. + */ + public function updatable(callable $condition = null): void { $this->updatable = $condition ?: true; - - return $this; } - public function notUpdatable(Closure $condition = null) + /** + * Disallow the resource type to be updated. + */ + public function notUpdatable(callable $condition = null): void { $this->updatable = $condition ? negate($condition) : false; - - return $this; } - public function getUpdatable() + /** + * Get whether or not the resource type is allowed to be updated. + */ + public function isUpdatable() { return $this->updatable; } - public function updating(Closure $callback) + /** + * Run a callback before a resource has been updated. + */ + public function onUpdating(callable $callback): void { $this->listeners['updating'][] = $callback; - - return $this; } - public function updated(Closure $callback) + /** + * Run a callback after a resource has been updated. + */ + public function onUpdated(callable $callback): void { $this->listeners['updated'][] = $callback; - - return $this; } - public function save(?Closure $callback) + /** + * Set the callback to save a model instance. + * + * If null, the adapter will be used to save model instances. + */ + public function save(?callable $callback): void { - $this->saver = $callback; - - return $this; + $this->saveCallback = $callback; } - public function getSaver() + /** + * Get the callback to save a model instance. + */ + public function getSaveCallback(): ?callable { - return $this->saver; + return $this->saveCallback; } - public function deletable(Closure $condition = null) + /** + * Allow the resource type to be deleted. + */ + public function deletable(callable $condition = null): void { $this->deletable = $condition ?: true; - - return $this; } - public function notDeletable(Closure $condition = null) + /** + * Disallow the resource type to be deleted. + */ + public function notDeletable(callable $condition = null): void { $this->deletable = $condition ? negate($condition) : false; - - return $this; } - public function getDeletable() + /** + * Get whether or not the resource type is allowed to be deleted. + */ + public function isDeletable() { return $this->deletable; } - public function delete(?Closure $callback) + /** + * Set the callback to delete a model instance. + * + * If null, the adapter will be used to delete model instances. + */ + public function delete(?callable $callback): void { - $this->delete = $callback; - - return $this; + $this->deleteCallback = $callback; } - public function getDelete() + /** + * Get the callback to delete a model instance. + */ + public function getDeleteCallback(): ?callable { - return $this->delete; + return $this->deleteCallback; } - public function deleting(Closure $callback) + /** + * Run a callback before a resource has been deleted. + */ + public function onDeleting(callable $callback): void { $this->listeners['deleting'][] = $callback; - - return $this; } - public function deleted(Closure $callback) + /** + * Run a callback after a resource has been deleted. + */ + public function onDeleted(callable $callback): void { $this->listeners['deleted'][] = $callback; - - return $this; } - public function defaultSort(?string $sort) + /** + * Set the default sort parameter value to be used if none is specified in + * the query string. + */ + public function defaultSort(?string $sort): void { $this->defaultSort = $sort; - - return $this; } - public function getDefaultSort() + /** + * Get the default sort parameter value to be used if none is specified in + * the query string. + */ + public function getDefaultSort(): ?string { return $this->defaultSort; } - public function defaultFilter(?array $filter) + /** + * Set the default filter parameter value to be used if none is specified in + * the query string. + */ + public function defaultFilter(?array $filter): void { $this->defaultFilter = $filter; - - return $this; } - public function getDefaultFilter() + /** + * Get the default filter parameter value to be used if none is specified in + * the query string. + */ + public function getDefaultFilter(): ?array { return $this->defaultFilter; } diff --git a/src/Serializer.php b/src/Serializer.php index 694f750..1800e9d 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer; use DateTime; @@ -30,13 +39,18 @@ final class Serializer $this->request = $request; } - public function add(ResourceType $resource, $model, array $include, bool $single = false) + public function add(ResourceType $resource, $model, array $include, bool $single = false): void { $data = $this->addToMap($resource, $model, $include, $single); $this->primary[] = $data['type'].':'.$data['id']; } + public function addSingle(ResourceType $resource, $model, array $include): void + { + $this->add($resource, $model, $include, true); + } + private function addToMap(ResourceType $resource, $model, array $include, bool $single = false) { $adapter = $resource->getAdapter(); @@ -69,11 +83,11 @@ final class Serializer continue; } - if ($field->getSingle() && ! $single) { + if ($field->isSingle() && ! $single) { continue; } - if (! evaluate($field->getVisible(), [$model, $this->request])) { + if (! evaluate($field->isVisible(), [$model, $this->request])) { continue; } @@ -81,12 +95,12 @@ final class Serializer $value = $this->attribute($field, $model, $adapter); } elseif ($field instanceof Schema\Relationship) { $isIncluded = isset($include[$name]); - $isLinkage = evaluate($field->getLinkage(), [$this->request]); + $isLinkage = evaluate($field->isLinkage(), [$this->request]); if (! $isIncluded && ! $isLinkage) { $value = $this->emptyRelationship($field, $resourceUrl); } elseif ($field instanceof Schema\HasOne) { - $value = $this->toOne($field, $model, $adapter, $isIncluded, $isLinkage, $include[$name] ?? [], $resourceUrl); + $value = $this->toOne($field, $model, $adapter, $isIncluded, $isLinkage, $include[$name] ?? [], $resourceUrl, $single); } elseif ($field instanceof Schema\HasMany) { $value = $this->toMany($field, $model, $adapter, $isIncluded, $isLinkage, $include[$name] ?? [], $resourceUrl); } @@ -114,7 +128,7 @@ final class Serializer private function attribute(Attribute $field, $model, AdapterInterface $adapter): Structure\Attribute { - if ($getter = $field->getGetter()) { + if ($getter = $field->getGetCallback()) { $value = $getter($model, $this->request); } else { $value = $adapter->getAttribute($model, $field); @@ -127,11 +141,11 @@ final class Serializer return new Structure\Attribute($field->getName(), $value); } - private function toOne(Schema\HasOne $field, $model, AdapterInterface $adapter, bool $isIncluded, bool $isLinkage, array $include, string $resourceUrl) + private function toOne(Schema\HasOne $field, $model, AdapterInterface $adapter, bool $isIncluded, bool $isLinkage, array $include, string $resourceUrl, bool $single = false) { $links = $this->getRelationshipLinks($field, $resourceUrl); - $value = $isIncluded ? (($getter = $field->getGetter()) ? $getter($model, $this->request) : $adapter->getHasOne($model, $field)) : ($isLinkage && $field->getLoadable() ? $adapter->getHasOne($model, $field, ['id']) : null); + $value = $isIncluded ? (($getter = $field->getGetCallback()) ? $getter($model, $this->request) : $adapter->getHasOne($model, $field, false)) : ($isLinkage ? $adapter->getHasOne($model, $field, true) : null); if (! $value) { return new Structure\ToNull( @@ -141,7 +155,7 @@ final class Serializer } if ($isIncluded) { - $identifier = $this->addRelated($field, $value, $include); + $identifier = $this->addRelated($field, $value, $include, $single); } else { $identifier = $this->relatedResourceIdentifier($field, $value); } @@ -156,10 +170,10 @@ final class Serializer private function toMany(Schema\HasMany $field, $model, AdapterInterface $adapter, bool $isIncluded, bool $isLinkage, array $include, string $resourceUrl) { - if ($getter = $field->getGetter()) { + if ($getter = $field->getGetCallback()) { $value = $getter($model, $this->request); } else { - $value = ($isLinkage || $isIncluded) ? $adapter->getHasMany($model, $field) : null; + $value = ($isLinkage || $isIncluded) ? $adapter->getHasMany($model, $field, false) : null; } $identifiers = []; @@ -197,7 +211,7 @@ final class Serializer private function getRelationshipLinks(Relationship $field, string $resourceUrl): array { - if (! $field->hasLinks()) { + if (! $field->isLinks()) { return []; } @@ -207,12 +221,12 @@ final class Serializer ]; } - private function addRelated(Relationship $field, $model, array $include): ResourceIdentifier + private function addRelated(Relationship $field, $model, array $include, bool $single = false): ResourceIdentifier { - $relatedResource = $field->getType() ? $this->api->getResource($field->getType()) : $this->resourceForModel($model); + $relatedResource = is_string($field->getType()) ? $this->api->getResource($field->getType()) : $this->resourceForModel($model); return $this->resourceIdentifier( - $this->addToMap($relatedResource, $model, $include) + $this->addToMap($relatedResource, $model, $include, $single) ); } @@ -286,7 +300,7 @@ final class Serializer { $type = $field->getType(); - $relatedResource = $type ? $this->api->getResource($type) : $this->resourceForModel($model); + $relatedResource = is_string($type) ? $this->api->getResource($type) : $this->resourceForModel($model); return $this->resourceIdentifier([ 'type' => $relatedResource->getType(), diff --git a/src/functions.php b/src/functions.php index 18d9fb8..65632a6 100644 --- a/src/functions.php +++ b/src/functions.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Tobyz\JsonApiServer; use Closure; @@ -49,16 +58,3 @@ function set_value(array &$data, Field $field, $value) { $data[$field->getLocation()][$field->getName()] = $value; } - -function array_set(array $array, $key, $value) -{ - $keys = explode('.', $key); - - while (count($keys) > 1) { - $array = &$array[array_shift($keys)]; - } - - $array[array_shift($keys)] = $value; - - return $array; -}