diff --git a/src/Adapter/AdapterInterface.php b/src/Adapter/AdapterInterface.php index c040cec..8db6dd1 100644 --- a/src/Adapter/AdapterInterface.php +++ b/src/Adapter/AdapterInterface.php @@ -44,7 +44,5 @@ interface AdapterInterface public function paginate($query, int $limit, int $offset); - public function include($query, array $relationships); - - public function load($model, array $relationships); + public function load(array $models, array $relationships); } diff --git a/src/Adapter/EloquentAdapter.php b/src/Adapter/EloquentAdapter.php index 6a72758..43f4655 100644 --- a/src/Adapter/EloquentAdapter.php +++ b/src/Adapter/EloquentAdapter.php @@ -4,9 +4,11 @@ namespace Tobscure\JsonApiServer\Adapter; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Tobscure\JsonApiServer\Schema\Attribute; use Tobscure\JsonApiServer\Schema\HasMany; use Tobscure\JsonApiServer\Schema\HasOne; +use Tobscure\JsonApiServer\Schema\Relationship; class EloquentAdapter implements AdapterInterface { @@ -44,6 +46,11 @@ class EloquentAdapter implements AdapterInterface return $query->get()->all(); } + public function count($query): int + { + return $query->count(); + } + public function getId($model): string { return $model->getKey(); @@ -51,27 +58,48 @@ class EloquentAdapter implements AdapterInterface public function getAttribute($model, Attribute $field) { - return $model->{$field->property}; + return $model->{$this->getAttributeProperty($field)}; + } + + public function getHasOneId($model, HasOne $field) + { + $relation = $model->{$this->getRelationshipProperty($field)}(); + + if ($relation instanceof BelongsTo) { + $related = $relation->getRelated(); + + $key = $model->{$relation->getForeignKeyName()}; + + if ($key) { + return $related->forceFill([$related->getKeyName() => $key]); + } + + return null; + } + + return $model->{$this->getRelationshipProperty($field)}; } public function getHasOne($model, HasOne $field) { - return $model->{$field->property}; + return $model->{$this->getRelationshipProperty($field)}; } public function getHasMany($model, HasMany $field): array { - return $model->{$field->property}->all(); + $collection = $model->{$this->getRelationshipProperty($field)}; + + return $collection ? $collection->all() : []; } public function applyAttribute($model, Attribute $field, $value) { - $model->{$field->property} = $value; + $model->{$this->getAttributeProperty($field)} = $value; } public function applyHasOne($model, HasOne $field, $related) { - $model->{$field->property}()->associate($related); + $model->{$this->getRelationshipProperty($field)}()->associate($related); } public function save($model) @@ -81,7 +109,7 @@ class EloquentAdapter implements AdapterInterface public function saveHasMany($model, HasMany $field, array $related) { - $model->{$field->property}()->sync(Collection::make($related)); + $model->{$this->getRelationshipProperty($field)}()->sync(Collection::make($related)); } public function delete($model) @@ -91,12 +119,12 @@ class EloquentAdapter implements AdapterInterface public function filterByAttribute($query, Attribute $field, $value) { - $query->where($field->property, $value); + $query->where($this->getAttributeProperty($field), $value); } public function filterByHasOne($query, HasOne $field, array $ids) { - $property = $field->property; + $property = $this->getRelationshipProperty($field); $foreignKey = $query->getModel()->{$property}()->getQualifiedForeignKey(); $query->whereIn($foreignKey, $ids); @@ -104,7 +132,7 @@ class EloquentAdapter implements AdapterInterface public function filterByHasMany($query, HasMany $field, array $ids) { - $property = $field->property; + $property = $this->getRelationshipProperty($field); $relation = $query->getModel()->{$property}(); $relatedKey = $relation->getRelated()->getQualifiedKeyName(); @@ -115,7 +143,7 @@ class EloquentAdapter implements AdapterInterface public function sortByAttribute($query, Attribute $field, string $direction) { - $query->orderBy($field->property, $direction); + $query->orderBy($this->getAttributeProperty($field), $direction); } public function paginate($query, int $limit, int $offset) @@ -123,20 +151,48 @@ class EloquentAdapter implements AdapterInterface $query->take($limit)->skip($offset); } - public function include($query, array $trail) + public function load(array $models, array $trail) { - $query->with($this->relationshipTrailToPath($trail)); + (new Collection($models))->load($this->relationshipTrailToPath($trail)); } - public function load($model, array $trail) + public function loadIds(array $models, Relationship $relationship) { - $model->load($this->relationshipTrailToPath($trail)); + if (empty($models)) { + return; + } + + $property = $this->getRelationshipProperty($relationship); + $relation = $models[0]->$property(); + + if ($relation instanceof BelongsTo) { + return; + } + + (new Collection($models))->load([ + $property => function ($query) use ($relation) { + $query->select([ + $relation->getRelated()->getKeyName(), + $relation->getForeignKeyName() + ]); + } + ]); + } + + private function getAttributeProperty(Attribute $field) + { + return $field->property ?: strtolower(preg_replace('/(?name)); + } + + private function getRelationshipProperty(Relationship $field) + { + return $field->property ?: $field->name; } private function relationshipTrailToPath(array $trail) { return implode('.', array_map(function ($relationship) { - return $relationship->property; + return $this->getRelationshipProperty($relationship); }, $trail)); } } diff --git a/src/Api.php b/src/Api.php index b0801b2..176b1f5 100644 --- a/src/Api.php +++ b/src/Api.php @@ -7,7 +7,9 @@ use JsonApiPhp\JsonApi; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface; +use Tobscure\JsonApiServer\Exception\BadRequestException; use Tobscure\JsonApiServer\Exception\MethodNotAllowedException; +use Tobscure\JsonApiServer\Exception\NotImplementedException; use Tobscure\JsonApiServer\Exception\ResourceNotFoundException; use Tobscure\JsonApiServer\Handler\Concerns\FindsResources; @@ -51,10 +53,10 @@ class Api implements RequestHandlerInterface if ($count === 1) { switch ($request->getMethod()) { case 'GET': - return (new Handler\Index($this, $resource))->handle($request); + return $this->handleWithHandler($request, new Handler\Index($this, $resource)); case 'POST': - return (new Handler\Create($this, $resource))->handle($request); + return $this->handleWithHandler($request, new Handler\Create($this, $resource)); default: throw new MethodNotAllowedException; @@ -66,28 +68,32 @@ class Api implements RequestHandlerInterface if ($count === 2) { switch ($request->getMethod()) { case 'PATCH': - return (new Handler\Update($this, $resource, $model))->handle($request); + return $this->handleWithHandler($request, new Handler\Update($this, $resource, $model)); case 'GET': - return (new Handler\Show($this, $resource, $model))->handle($request); + return $this->handleWithHandler($request, new Handler\Show($this, $resource, $model)); case 'DELETE': - return (new Handler\Delete($resource, $model))->handle($request); + return $this->handleWithHandler($request, new Handler\Delete($resource, $model)); default: throw new MethodNotAllowedException; } } - // if ($count === 3) { - // return $this->handleRelated($request, $resource, $model, $segments[2]); - // } + if ($count === 3) { + throw new NotImplementedException; - // if ($count === 4 && $segments[2] === 'relationship') { - // return $this->handleRelationship($request, $resource, $model, $segments[3]); - // } + // return $this->handleRelated($request, $resource, $model, $segments[2]); + } - throw new \RuntimeException; + if ($count === 4 && $segments[2] === 'relationships') { + throw new NotImplementedException; + + // return $this->handleRelationship($request, $resource, $model, $segments[3]); + } + + throw new BadRequestException; } private function stripBasePath(string $path): string @@ -103,13 +109,23 @@ class Api implements RequestHandlerInterface return $path; } - public function error(\Throwable $e) + private function handleWithHandler(Request $request, RequestHandlerInterface $handler) { + $request = $request->withAttribute('jsonApiHandler', $handler); + + return $handler->handle($request); + } + + public function handleError($e) + { + if (! $e instanceof ErrorProviderInterface) { + $e = new Exception\InternalServerErrorException; + } + + $errors = $e->getJsonApiErrors(); + $data = new JsonApi\ErrorDocument( - new JsonApi\Error( - new JsonApi\Error\Title($e->getMessage()), - new JsonApi\Error\Detail((string) $e) - ) + ...$errors ); return new JsonApiResponse($data); diff --git a/src/ErrorProviderInterface.php b/src/ErrorProviderInterface.php new file mode 100644 index 0000000..cd6687d --- /dev/null +++ b/src/ErrorProviderInterface.php @@ -0,0 +1,8 @@ +sourceParameter = $sourceParameter; + } + + public function getJsonApiErrors(): array + { + $members = []; + + if ($this->message) { + $members[] = new Error\Detail($this->message); + } + + if ($this->sourceParameter) { + $members[] = new Error\SourceParameter($this->sourceParameter); + } + + return [ + new Error( + new Error\Title('Bad Request'), + new Error\Status('400'), + ...$members + ) + ]; + } } diff --git a/src/Exception/ForbiddenException.php b/src/Exception/ForbiddenException.php index 7acba10..bfa8702 100644 --- a/src/Exception/ForbiddenException.php +++ b/src/Exception/ForbiddenException.php @@ -2,6 +2,18 @@ namespace Tobscure\JsonApiServer\Exception; -class ForbiddenException extends \DomainException +use JsonApiPhp\JsonApi\Error; +use Tobscure\JsonApiServer\ErrorProviderInterface; + +class BadRequestException extends \DomainException implements ErrorProviderInterface { + public function getJsonApiErrors(): array + { + return [ + new Error( + new Error\Title('Forbidden'), + new Error\Status('403') + ) + ]; + } } diff --git a/src/Exception/InternalServerErrorException.php b/src/Exception/InternalServerErrorException.php new file mode 100644 index 0000000..32fd795 --- /dev/null +++ b/src/Exception/InternalServerErrorException.php @@ -0,0 +1,19 @@ +type = $type; + $this->id = $id; } - public function getStatusCode() + public function getJsonApiErrors(): array { - return 404; + return [ + new Error( + new Error\Title('Resource Not Found'), + new Error\Status('404'), + new Error\Detail($this->getMessage()) + ) + ]; + } + + public function getType(): string + { + return $this->type; + } + + public function getId(): ?string + { + return $this->id; } } diff --git a/src/Exception/UnprocessableEntityException.php b/src/Exception/UnprocessableEntityException.php index 210d1d9..5df10e2 100644 --- a/src/Exception/UnprocessableEntityException.php +++ b/src/Exception/UnprocessableEntityException.php @@ -2,8 +2,18 @@ namespace Tobscure\JsonApiServer\Exception; -use Exception; +use JsonApiPhp\JsonApi\Error; +use Tobscure\JsonApiServer\ErrorProviderInterface; -class UnprocessableEntityException extends \DomainException +class UnprocessableEntityException extends \DomainException implements ErrorProviderInterface { + public function getJsonApiErrors(): array + { + return [ + new Error( + new Error\Title('Unprocessable Entity'), + new Error\Status('422') + ) + ]; + } } diff --git a/src/Handler/Concerns/FindsResources.php b/src/Handler/Concerns/FindsResources.php index c34db93..e6e2285 100644 --- a/src/Handler/Concerns/FindsResources.php +++ b/src/Handler/Concerns/FindsResources.php @@ -15,7 +15,11 @@ trait FindsResources $query = $adapter->query(); foreach ($resource->getSchema()->scopes as $scope) { - $scope($query, $request); + $scope($request, $query); + } + + foreach ($resource->getSchema()->singleScopes as $scope) { + $scope($request, $query, $id); } $model = $adapter->find($query, $id); diff --git a/src/Handler/Concerns/IncludesData.php b/src/Handler/Concerns/IncludesData.php index a563286..ef20730 100644 --- a/src/Handler/Concerns/IncludesData.php +++ b/src/Handler/Concerns/IncludesData.php @@ -22,7 +22,7 @@ trait IncludesData return $include; } - return $this->defaultInclude($this->resource); + return []; } private function parseInclude(string $include): array @@ -54,7 +54,7 @@ trait IncludesData || ! $schema->fields[$name] instanceof Relationship || ($schema->fields[$name] instanceof HasMany && ! $schema->fields[$name]->includable) ) { - throw new BadRequestException("Invalid include [{$path}{$name}]"); + throw new BadRequestException("Invalid include [{$path}{$name}]", 'include'); } $relatedResource = $this->api->getResource($schema->fields[$name]->resource); @@ -63,23 +63,6 @@ trait IncludesData } } - private function defaultInclude(ResourceType $resource): array - { - $include = []; - - foreach ($resource->getSchema()->fields as $name => $field) { - if (! $field instanceof Relationship || ! $field->included) { - continue; - } - - $include[$name] = $this->defaultInclude( - $this->api->getResource($field->resource) - ); - } - - return $include; - } - private function buildRelationshipTrails(ResourceType $resource, array $include): array { $schema = $resource->getSchema(); @@ -88,7 +71,9 @@ trait IncludesData foreach ($include as $name => $nested) { $relationship = $schema->fields[$name]; - $trails[] = [$relationship]; + if ($relationship->loadable) { + $trails[] = [$relationship]; + } $relatedResource = $this->api->getResource($relationship->resource); @@ -105,4 +90,24 @@ trait IncludesData return $trails; } + + private function loadRelationships(array $models, array $include, Request $request) + { + $adapter = $this->resource->getAdapter(); + $schema = $this->resource->getSchema(); + + foreach ($schema->fields as $name => $field) { + if (! $field instanceof Relationship || ! ($field->linkage)($request) || ! $field->loadable) { + continue; + } + + $adapter->loadIds($models, $field); + } + + $trails = $this->buildRelationshipTrails($this->resource, $include); + + foreach ($trails as $relationships) { + $adapter->load($models, $relationships); + } + } } diff --git a/src/Handler/Concerns/SavesData.php b/src/Handler/Concerns/SavesData.php index 4e3299f..feb040c 100644 --- a/src/Handler/Concerns/SavesData.php +++ b/src/Handler/Concerns/SavesData.php @@ -35,6 +35,8 @@ trait SavesData $adapter->save($model); $this->saveFields($data, $model, $request); + + $this->runSavedCallbacks($data, $model, $request); } private function parseData($body): array @@ -65,8 +67,12 @@ trait SavesData private function getModelForIdentifier(Request $request, $identifier) { - if (! isset($identifier['type']) || ! isset($identifier['id'])) { - throw new BadRequestException('type/id not specified'); + if (! isset($identifier['type'])) { + throw new BadRequestException('type not specified'); + } + + if (! isset($identifier['id'])) { + throw new BadRequestException('id not specified'); } $resource = $this->api->getResource($identifier['type']); @@ -96,7 +102,7 @@ trait SavesData foreach ($schema->fields as $name => $field) { $valueProvided = isset($data[$field->location][$name]); - if ($valueProvided && ! ($field->isWritable)($model, $request)) { + if ($valueProvided && ! ($field->isWritable)($request, $model)) { throw new BadRequestException("Field [$name] is not writable"); } } @@ -152,7 +158,7 @@ trait SavesData }; foreach ($field->validators as $validator) { - $validator($fail, $data[$field->location][$name], $model, $request); + $validator($fail, $data[$field->location][$name] ?? null, $request, $model, $field); } } @@ -202,10 +208,25 @@ trait SavesData $value = $data[$field->location][$name]; if ($field->saver) { - ($field->saver)($model, $value, $request); + ($field->saver)($request, $model, $value); } elseif ($field instanceof Schema\HasMany) { $adapter->saveHasMany($model, $field, $value); } } } + + private function runSavedCallbacks(array $data, $model, Request $request) + { + $schema = $this->resource->getSchema(); + + foreach ($schema->fields as $name => $field) { + if (! isset($data[$field->location][$name])) { + continue; + } + + foreach ($field->savedCallbacks as $callback) { + $callback($request, $model, $data[$field->location][$name]); + } + } + } } diff --git a/src/Handler/Create.php b/src/Handler/Create.php index 4b33d3a..a231cf3 100644 --- a/src/Handler/Create.php +++ b/src/Handler/Create.php @@ -24,14 +24,24 @@ class Create implements RequestHandlerInterface public function handle(Request $request): Response { - if (! ($this->resource->getSchema()->isCreatable)($request)) { + $schema = $this->resource->getSchema(); + + if (! ($schema->isCreatable)($request)) { throw new ForbiddenException('You cannot create this resource'); } $model = $this->resource->getAdapter()->create(); + foreach ($schema->creatingCallbacks as $callback) { + $callback($request, $model); + } + $this->save($model, $request, true); + foreach ($schema->createdCallbacks as $callback) { + $callback($request, $model); + } + return (new Show($this->api, $this->resource, $model)) ->handle($request) ->withStatus(201); diff --git a/src/Handler/Delete.php b/src/Handler/Delete.php index 92f52a9..cc641f1 100644 --- a/src/Handler/Delete.php +++ b/src/Handler/Delete.php @@ -22,12 +22,22 @@ class Delete implements RequestHandlerInterface public function handle(Request $request): Response { - if (! ($this->resource->getSchema()->isDeletable)($this->model, $request)) { + $schema = $this->resource->getSchema(); + + if (! ($schema->isDeletable)($request, $this->model)) { throw new ForbiddenException('You cannot delete this resource'); } + foreach ($schema->deletingCallbacks as $callback) { + $callback($request, $this->model); + } + $this->resource->getAdapter()->delete($this->model); + foreach ($schema->deletedCallbacks as $callback) { + $callback($request, $this->model); + } + return new EmptyResponse; } } diff --git a/src/Handler/Index.php b/src/Handler/Index.php index 0908757..e2b2eea 100644 --- a/src/Handler/Index.php +++ b/src/Handler/Index.php @@ -3,6 +3,8 @@ namespace Tobscure\JsonApiServer\Handler; use JsonApiPhp\JsonApi; +use JsonApiPhp\JsonApi\Link; +use JsonApiPhp\JsonApi\Meta; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface; @@ -30,7 +32,71 @@ class Index implements RequestHandlerInterface { $include = $this->getInclude($request); - $models = $this->getModels($include, $request); + $request = $this->extractQueryParams($request); + + $adapter = $this->resource->getAdapter(); + $schema = $this->resource->getSchema(); + + $query = $adapter->query(); + + foreach ($schema->scopes as $scope) { + $scope($request, $query); + } + + foreach ($schema->indexScopes as $scope) { + $scope($request, $query); + } + + if ($filter = $request->getAttribute('jsonApiFilter')) { + $this->filter($query, $filter, $request); + } + + $offset = $request->getAttribute('jsonApiOffset'); + $limit = $request->getAttribute('jsonApiLimit'); + $total = null; + + $paginationLinks = []; + $members = [ + new Link\SelfLink($this->buildUrl($request)) + ]; + + if ($offset > 0) { + $paginationLinks[] = new Link\FirstLink($this->buildUrl($request, ['page' => ['offset' => 0]])); + + $prevOffset = $offset - $limit; + + if ($prevOffset < 0) { + $params = ['page' => ['offset' => 0, 'limit' => $offset]]; + } else { + $params = ['page' => ['offset' => max(0, $prevOffset)]]; + } + + $paginationLinks[] = new Link\PrevLink($this->buildUrl($request, $params)); + } + + if ($schema->countable) { + $total = $adapter->count($query); + + $members[] = new Meta('total', $total); + + if ($offset + $limit < $total) { + $paginationLinks[] = new Link\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) && $total === null) || $offset + $limit < $total) { + $paginationLinks[] = new Link\NextLink($this->buildUrl($request, ['page' => ['offset' => $offset + $limit]])); + } + + $this->loadRelationships($models, $include, $request); $serializer = new Serializer($this->api, $request); @@ -40,52 +106,108 @@ class Index implements RequestHandlerInterface return new JsonApiResponse( new JsonApi\CompoundDocument( - new JsonApi\ResourceCollection(...$serializer->primary()), - new JsonApi\Included(...$serializer->included()) + new JsonApi\PaginatedCollection( + new JsonApi\Pagination(...$paginationLinks), + new JsonApi\ResourceCollection(...$serializer->primary()) + ), + new JsonApi\Included(...$serializer->included()), + ...$members ) ); } - private function getModels(array $include, Request $request) + private function buildUrl(Request $request, array $overrideParams = []): string { - $adapter = $this->resource->getAdapter(); + [$selfUrl] = explode('?', $request->getUri(), 2); + $queryParams = $request->getQueryParams(); - $query = $adapter->query(); + $queryParams = array_replace_recursive($queryParams, $overrideParams); - foreach ($this->resource->getSchema()->scopes as $scope) { - $scope($query, $request); + if (isset($queryParams['page']['offset']) && $queryParams['page']['offset'] <= 0) { + unset($queryParams['page']['offset']); } + $queryString = http_build_query($queryParams); + + return $selfUrl.($queryString ? '?'.$queryString : ''); + } + + private function extractQueryParams(Request $request): Request + { + $schema = $this->resource->getSchema(); + $queryParams = $request->getQueryParams(); - if (isset($queryParams['sort'])) { - $this->sort($query, $queryParams['sort'], $request); + $limit = $this->resource->getSchema()->paginate; + + 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()->limit, $limit); } - if (isset($queryParams['filter'])) { - $this->filter($query, $queryParams['filter'], $request); + $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]'); + } } - $this->paginate($query, $request); + $request = $request + ->withAttribute('jsonApiLimit', $limit) + ->withAttribute('jsonApiOffset', $offset); - $this->include($query, $include); + $sort = $queryParams['sort'] ?? $this->resource->getSchema()->defaultSort; - return $adapter->get($query); + if ($sort) { + $sort = $this->parseSort($sort); + + foreach ($sort as $name => $direction) { + if (! isset($schema->fields[$name]) + || ! $schema->fields[$name] instanceof Schema\Attribute + || ! $schema->fields[$name]->sortable + ) { + 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'); + } + + foreach ($filter as $name => $value) { + if (! isset($schema->fields[$name]) + || ! $schema->fields[$name]->filterable + ) { + throw new BadRequestException("Invalid filter [$name]", "filter[$name]"); + } + } + } + + $request = $request->withAttribute('jsonApiFilter', $filter); + + return $request; } - private function sort($query, string $sort, Request $request) + private function sort($query, array $sort, Request $request) { $schema = $this->resource->getSchema(); $adapter = $this->resource->getAdapter(); - foreach ($this->parseSort($sort) as $name => $direction) { - if (! isset($schema->fields[$name]) - || ! $schema->fields[$name] instanceof Schema\Attribute - || ! $schema->fields[$name]->sortable - ) { - throw new BadRequestException("Invalid sort field [$name]"); - } - + foreach ($sort as $name => $direction) { $attribute = $schema->fields[$name]; if ($attribute->sorter) { @@ -117,19 +239,10 @@ class Index implements RequestHandlerInterface private function paginate($query, Request $request) { - $queryParams = $request->getQueryParams(); + $limit = $request->getAttribute('jsonApiLimit'); + $offset = $request->getAttribute('jsonApiOffset'); - $maxLimit = $this->resource->getSchema()->paginate; - - $limit = isset($queryParams['page']['limit']) ? min($maxLimit, (int) $queryParams['page']['limit']) : $maxLimit; - - $offset = isset($queryParams['page']['offset']) ? (int) $queryParams['page']['offset'] : 0; - - if ($offset < 0) { - throw new BadRequestException('page[offset] must be >=0'); - } - - if ($limit) { + if ($limit || $offset) { $this->resource->getAdapter()->paginate($query, $limit, $offset); } } @@ -139,17 +252,7 @@ class Index implements RequestHandlerInterface $schema = $this->resource->getSchema(); $adapter = $this->resource->getAdapter(); - if (! is_array($filter)) { - throw new BadRequestException('filter must be an array'); - } - foreach ($filter as $name => $value) { - if (! isset($schema->fields[$name]) - || ! $schema->fields[$name]->filterable - ) { - throw new BadRequestException("Invalid filter [$name]"); - } - $field = $schema->fields[$name]; if ($field->filter) { @@ -165,15 +268,4 @@ class Index implements RequestHandlerInterface } } } - - private function include($query, array $include) - { - $adapter = $this->resource->getAdapter(); - - $trails = $this->buildRelationshipTrails($this->resource, $include); - - foreach ($trails as $relationships) { - $adapter->include($query, $relationships); - } - } } diff --git a/src/Handler/Show.php b/src/Handler/Show.php index d8c110c..ebab0dd 100644 --- a/src/Handler/Show.php +++ b/src/Handler/Show.php @@ -30,7 +30,7 @@ class Show implements RequestHandlerInterface { $include = $this->getInclude($request); - $this->load($include); + $this->loadRelationships([$this->model], $include, $request); $serializer = new Serializer($this->api, $request); @@ -43,15 +43,4 @@ class Show implements RequestHandlerInterface ) ); } - - private function load(array $include) - { - $adapter = $this->resource->getAdapter(); - - $trails = $this->buildRelationshipTrails($this->resource, $include); - - foreach ($trails as $relationships) { - $adapter->load($this->model, $relationships); - } - } } diff --git a/src/Handler/Update.php b/src/Handler/Update.php index af238a7..d3f75ad 100644 --- a/src/Handler/Update.php +++ b/src/Handler/Update.php @@ -6,6 +6,7 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface; use Tobscure\JsonApiServer\Api; +use Tobscure\JsonApiServer\Exception\ForbiddenException; use Tobscure\JsonApiServer\ResourceType; class Update implements RequestHandlerInterface @@ -25,10 +26,22 @@ class Update implements RequestHandlerInterface public function handle(Request $request): Response { - $adapter = $this->resource->getAdapter(); + $schema = $this->resource->getSchema(); + + if (! ($schema->isUpdatable)($request, $this->model)) { + throw new ForbiddenException('You cannot update this resource'); + } + + foreach ($schema->updatingCallbacks as $callback) { + $callback($request, $this->model); + } $this->save($this->model, $request); + foreach ($schema->updatedCallbacks as $callback) { + $callback($request, $this->model); + } + return (new Show($this->api, $this->resource, $this->model))->handle($request); } } diff --git a/src/Schema/Attribute.php b/src/Schema/Attribute.php index 847f348..2ce76f2 100644 --- a/src/Schema/Attribute.php +++ b/src/Schema/Attribute.php @@ -13,11 +13,6 @@ class Attribute extends Field public $sortable = false; public $sorter; - public function __construct(string $name) - { - parent::__construct($name); - } - public function sortable(Closure $callback = null) { $this->sortable = true; diff --git a/src/Schema/Builder.php b/src/Schema/Builder.php index dda30dd..6bf6b96 100644 --- a/src/Schema/Builder.php +++ b/src/Schema/Builder.php @@ -7,11 +7,24 @@ use Closure; class Builder { public $fields = []; + public $meta = []; public $paginate = 20; + public $limit = 50; + public $countable = true; public $scopes = []; + public $indexScopes = []; + public $singleScopes = []; public $isVisible; public $isCreatable; + public $creatingCallbacks = []; + public $createdCallbacks = []; + public $isUpdatable; + public $updatingCallbacks = []; + public $updatedCallbacks = []; public $isDeletable; + public $deletingCallbacks = []; + public $deletedCallbacks = []; + public $defaultSort; public function __construct() { @@ -46,16 +59,46 @@ class Builder return $field; } + public function meta(string $name, $value) + { + return $this->meta[$name] = new Meta($name, $value); + } + public function paginate(?int $perPage) { $this->paginate = $perPage; } + public function limit(?int $limit) + { + $this->limit = $limit; + } + + public function countable() + { + $this->countable = true; + } + + public function uncountable() + { + $this->countable = false; + } + public function scope(Closure $callback) { $this->scopes[] = $callback; } + public function scopeIndex(Closure $callback) + { + $this->indexScopes[] = $callback; + } + + public function scopeSingle(Closure $callback) + { + $this->singleScopes[] = $callback; + } + public function creatableIf(Closure $condition) { $this->isCreatable = $condition; @@ -84,6 +127,54 @@ class Builder }); } + public function creating(Closure $callback) + { + $this->creatingCallbacks[] = $callback; + } + + public function created(Closure $callback) + { + $this->createdCallbacks[] = $callback; + } + + public function updatableIf(Closure $condition) + { + $this->isUpdatable = $condition; + + return $this; + } + + public function updatable() + { + return $this->updatableIf(function () { + return true; + }); + } + + public function notUpdatableIf(Closure $condition) + { + return $this->updatableIf(function (...$args) use ($condition) { + return ! $condition(...$args); + }); + } + + public function notUpdatable() + { + return $this->notUpdatableIf(function () { + return true; + }); + } + + public function updating(Closure $callback) + { + $this->updatingCallbacks[] = $callback; + } + + public function updated(Closure $callback) + { + $this->updatedCallbacks[] = $callback; + } + public function deletableIf(Closure $condition) { $this->isDeletable = $condition; @@ -112,6 +203,21 @@ class Builder }); } + public function deleting(Closure $callback) + { + $this->deletingCallbacks[] = $callback; + } + + public function deleted(Closure $callback) + { + $this->deletedCallbacks[] = $callback; + } + + public function defaultSort(string $sort) + { + $this->defaultSort = $sort; + } + private function field(string $class, string $name, string $property = null) { if (! isset($this->fields[$name]) || ! $this->fields[$name] instanceof $class) { diff --git a/src/Schema/Field.php b/src/Schema/Field.php index f58f1a8..2c9220e 100644 --- a/src/Schema/Field.php +++ b/src/Schema/Field.php @@ -13,6 +13,7 @@ abstract class Field public $getter; public $setter; public $saver; + public $savedCallbacks = []; public $default; public $validators = []; public $filterable = false; @@ -20,7 +21,7 @@ abstract class Field public function __construct(string $name) { - $this->name = $this->property = $name; + $this->name = $name; $this->visible(); $this->readonly(); @@ -110,6 +111,13 @@ abstract class Field return $this; } + public function saved(Closure $callback) + { + $this->savedCallbacks[] = $callback; + + return $this; + } + public function default($value) { $this->default = $this->wrap($value); @@ -132,7 +140,7 @@ abstract class Field return $this; } - private function wrap($value) + protected function wrap($value) { if (! $value instanceof Closure) { $value = function () use ($value) { diff --git a/src/Schema/Meta.php b/src/Schema/Meta.php new file mode 100644 index 0000000..6759552 --- /dev/null +++ b/src/Schema/Meta.php @@ -0,0 +1,28 @@ +name = $name; + $this->value = $this->wrap($value); + } + + private function wrap($value) + { + if (! $value instanceof Closure) { + $value = function () use ($value) { + return $value; + }; + } + + return $value; + } +} diff --git a/src/Schema/Relationship.php b/src/Schema/Relationship.php index 561260f..e842865 100644 --- a/src/Schema/Relationship.php +++ b/src/Schema/Relationship.php @@ -4,15 +4,26 @@ namespace Tobscure\JsonApiServer\Schema; use Closure; use Spatie\Macroable\Macroable; +use Tobscure\JsonApiServer\Handler\Show; abstract class Relationship extends Field { use Macroable; public $location = 'relationships'; + public $linkage; + public $hasLinks = true; + public $loadable = true; public $included = false; public $resource; + public function __construct(string $name) + { + parent::__construct($name); + + $this->noLinkage(); + } + public function resource($resource) { $this->resource = $resource; @@ -20,10 +31,59 @@ abstract class Relationship extends Field return $this; } + public function linkageIf(Closure $condition) + { + $this->linkage = $condition; + + return $this; + } + + public function linkage() + { + return $this->linkageIf(function () { + return true; + }); + } + + public function linkageIfSingle() + { + return $this->linkageIf(function ($request) { + return $request->getAttribute('jsonApiHandler') instanceof Show; + }); + } + + public function noLinkage() + { + return $this->linkageIf(function () { + return false; + }); + } + + public function loadable() + { + $this->loadable = true; + + return $this; + } + + public function notLoadable() + { + $this->loadable = false; + + return $this; + } + public function included() { $this->included = true; return $this; } + + public function noLinks() + { + $this->hasLinks = false; + + return $this; + } } diff --git a/src/Serializer.php b/src/Serializer.php index 1f9d72c..2177460 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -37,38 +37,58 @@ class Serializer 'type' => $resource->getType(), 'id' => $adapter->getId($model), 'fields' => [], - 'links' => [] + 'links' => [], + 'meta' => [] ]; + $resourceUrl = $this->api->getBaseUrl().'/'.$data['type'].'/'.$data['id']; + + ksort($schema->fields); + foreach ($schema->fields as $name => $field) { - if (($field instanceof Schema\Relationship && ! isset($include[$name])) - || ! ($field->isVisible)($model, $this->request) - ) { + if (! ($field->isVisible)($this->request, $model)) { continue; } - $value = $this->getValue($field, $adapter, $model); - if ($field instanceof Schema\Attribute) { - $value = $this->attribute($field, $value); - } elseif ($field instanceof Schema\HasOne) { - $value = $this->toOne($field, $value, $include[$name] ?? []); - } elseif ($field instanceof Schema\HasMany) { - $value = $this->toMany($field, $value, $include[$name] ?? []); + $value = $this->attribute($field, $model, $adapter); + } elseif ($field instanceof Schema\Relationship) { + $isIncluded = isset($include[$name]); + $isLinkage = ($field->linkage)($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); + } elseif ($field instanceof Schema\HasMany) { + $value = $this->toMany($field, $model, $adapter, $isIncluded, $isLinkage, $include[$name] ?? [], $resourceUrl); + } } $data['fields'][$name] = $value; } - $data['links']['self'] = new JsonApi\Link\SelfLink($this->api->getBaseUrl().'/'.$data['type'].'/'.$data['id']); + $data['links']['self'] = new JsonApi\Link\SelfLink($resourceUrl); + + ksort($schema->meta); + + foreach ($schema->meta as $name => $meta) { + $data['meta'][$name] = new JsonApi\Meta($meta->name, ($meta->value)($this->request, $model)); + } $this->merge($data); return $data; } - private function attribute(Schema\Attribute $field, $value): JsonApi\Attribute + private function attribute(Schema\Attribute $field, $model, AdapterInterface $adapter): JsonApi\Attribute { + if ($field->getter) { + $value = ($field->getter)($this->request, $model); + } else { + $value = $adapter->getAttribute($model, $field); + } + if ($value instanceof DateTimeInterface) { $value = $value->format(DateTime::RFC3339); } @@ -76,31 +96,84 @@ class Serializer return new JsonApi\Attribute($field->name, $value); } - private function toOne(Schema\Relationship $field, $value, array $include) + private function toOne(Schema\HasOne $field, $model, AdapterInterface $adapter, bool $isIncluded, bool $isLinkage, array $include, string $resourceUrl) { - if (! $value) { - return new JsonApi\ToNull($field->name); + $links = $this->getRelationshipLinks($field, $resourceUrl); + + if ($field->getter) { + $value = ($field->getter)($this->request, $model); + } else { + $value = $isIncluded ? $adapter->getHasOne($model, $field) : ($isLinkage && $field->loadable ? $adapter->getHasOneId($model, $field) : null); } - $identifier = $this->addRelated($field, $value, $include); + if (! $value) { + return new JsonApi\ToNull( + $field->name, + ...$links + ); + } - return new JsonApi\ToOne($field->name, $identifier); + if ($isIncluded) { + $identifier = $this->addRelated($field, $value, $include); + } else { + $identifier = $this->relatedResourceIdentifier($field, $value); + } + + + return new JsonApi\ToOne( + $field->name, + $identifier, + ...$links + ); } - private function toMany(Schema\Relationship $field, $value, array $include): JsonApi\ToMany + private function toMany(Schema\HasMany $field, $model, AdapterInterface $adapter, bool $isIncluded, bool $isLinkage, array $include, string $resourceUrl) { + if ($field->getter) { + $value = ($field->getter)($this->request, $model); + } else { + $value = $isLinkage ? $adapter->getHasMany($model, $field) : null; + } + $identifiers = []; - foreach ($value as $relatedModel) { - $identifiers[] = $this->addRelated($field, $relatedModel, $include); + if ($isIncluded) { + foreach ($value as $relatedModel) { + $identifiers[] = $this->addRelated($field, $relatedModel, $include); + } + } else { + foreach ($value as $relatedModel) { + $identifiers[] = $this->relatedResourceIdentifier($field, $relatedModel); + } } return new JsonApi\ToMany( $field->name, - new JsonApi\ResourceIdentifierCollection(...$identifiers) + new JsonApi\ResourceIdentifierCollection(...$identifiers), + ...$this->getRelationshipLinks($field, $resourceUrl) ); } + private function emptyRelationship(Schema\Relationship $field, string $resourceUrl): JsonApi\EmptyRelationship + { + return new JsonApi\EmptyRelationship( + $field->name, + ...$this->getRelationshipLinks($field, $resourceUrl) + ); + } + + private function getRelationshipLinks(Schema\Relationship $field, string $resourceUrl): array + { + if (! $field->hasLinks) { + return []; + } + + return [ + new JsonApi\Link\SelfLink($resourceUrl.'/relationships/'.$field->name), + new JsonApi\Link\RelatedLink($resourceUrl.'/'.$field->name) + ]; + } + private function addRelated(Schema\Relationship $field, $model, array $include): JsonApi\ResourceIdentifier { $relatedResource = $this->api->getResource($field->resource); @@ -110,19 +183,6 @@ class Serializer ); } - private function getValue(Schema\Field $field, AdapterInterface $adapter, $model) - { - if ($field->getter) { - return ($field->getter)($model, $this->request); - } elseif ($field instanceof Schema\Attribute) { - return $adapter->getAttribute($model, $field); - } elseif ($field instanceof Schema\HasOne) { - return $adapter->getHasOne($model, $field); - } elseif ($field instanceof Schema\HasMany) { - return $adapter->getHasMany($model, $field); - } - } - private function merge($data): void { $key = $data['type'].':'.$data['id']; @@ -130,6 +190,7 @@ class Serializer if (isset($this->map[$key])) { $this->map[$key]['fields'] = array_merge($this->map[$key]['fields'], $data['fields']); $this->map[$key]['links'] = array_merge($this->map[$key]['links'], $data['links']); + $this->map[$key]['meta'] = array_merge($this->map[$key]['meta'], $data['meta']); } else { $this->map[$key] = $data; } @@ -162,7 +223,8 @@ class Serializer $data['type'], $data['id'], ...array_values($data['fields']), - ...array_values($data['links']) + ...array_values($data['links']), + ...array_values($data['meta']) ); } @@ -173,4 +235,14 @@ class Serializer $data['id'] ); } + + private function relatedResourceIdentifier(Schema\Relationship $field, $model) + { + $relatedResource = $this->api->getResource($field->resource); + + return $this->resourceIdentifier([ + 'type' => $field->resource, + 'id' => $relatedResource->getAdapter()->getId($model) + ]); + } } diff --git a/src/StatusProviderInterface.php b/src/StatusProviderInterface.php new file mode 100644 index 0000000..0c99ec1 --- /dev/null +++ b/src/StatusProviderInterface.php @@ -0,0 +1,8 @@ +attribute('writable1')->writable(); $schema->attribute('writable2')->writableIf(function ($arg1, $arg2) use ($adapter, $request) { - $this->assertEquals($adapter->createdModel, $arg1); - $this->assertEquals($request, $arg2); + $this->assertEquals($request, $arg1); + $this->assertEquals($adapter->createdModel, $arg2); return true; }); $schema->attribute('writable3')->readonlyIf(function ($arg1, $arg2) use ($adapter, $request) { - $this->assertEquals($adapter->createdModel, $arg1); - $this->assertEquals($request, $arg2); + $this->assertEquals($request, $arg1); + $this->assertEquals($adapter->createdModel, $arg2); return false; }); }); diff --git a/tests/MockAdapter.php b/tests/MockAdapter.php index c9b150e..c88c785 100644 --- a/tests/MockAdapter.php +++ b/tests/MockAdapter.php @@ -4,6 +4,7 @@ namespace Tobscure\Tests\JsonApiServer; use Tobscure\JsonApiServer\Adapter\AdapterInterface; use Tobscure\JsonApiServer\Schema\Attribute; +use Tobscure\JsonApiServer\Schema\Field; use Tobscure\JsonApiServer\Schema\HasMany; use Tobscure\JsonApiServer\Schema\HasOne; @@ -44,27 +45,27 @@ class MockAdapter implements AdapterInterface public function getAttribute($model, Attribute $attribute) { - return $model->{$attribute->property} ?? 'default'; + return $model->{$this->getProperty($attribute)} ?? 'default'; } public function getHasOne($model, HasOne $relationship) { - return $model->{$relationship->property} ?? null; + return $model->{$this->getProperty($relationship)} ?? null; } public function getHasMany($model, HasMany $relationship): array { - return $model->{$relationship->property} ?? []; + return $model->{$this->getProperty($relationship)} ?? []; } public function applyAttribute($model, Attribute $attribute, $value) { - $model->{$attribute->property} = $value; + $model->{$this->getProperty($attribute)} = $value; } public function applyHasOne($model, HasOne $relationship, $related) { - $model->{$relationship->property} = $related; + $model->{$this->getProperty($relationship)} = $related; } public function save($model) @@ -120,4 +121,9 @@ class MockAdapter implements AdapterInterface { $model->load[] = $relationships; } + + private function getProperty(Field $field) + { + return $field->property ?: $field->name; + } } diff --git a/tests/ShowTest.php b/tests/ShowTest.php index a00bd29..9f22dac 100644 --- a/tests/ShowTest.php +++ b/tests/ShowTest.php @@ -93,8 +93,8 @@ class ShowTest extends AbstractTestCase $api->resource('users', $adapter, function (Builder $schema) use ($model, $request) { $schema->attribute('attribute1') ->get(function ($arg1, $arg2) use ($model, $request) { - $this->assertEquals($model, $arg1); - $this->assertEquals($request, $arg2); + $this->assertEquals($request, $arg1); + $this->assertEquals($model, $arg2); return 'value1'; }); }); @@ -127,14 +127,14 @@ class ShowTest extends AbstractTestCase $schema->attribute('visible2')->visible(); $schema->attribute('visible3')->visibleIf(function ($arg1, $arg2) use ($model, $request) { - $this->assertEquals($model, $arg1); - $this->assertEquals($request, $arg2); + $this->assertEquals($request, $arg1); + $this->assertEquals($model, $arg2); return true; }); $schema->attribute('visible4')->hiddenIf(function ($arg1, $arg2) use ($model, $request) { - $this->assertEquals($model, $arg1); - $this->assertEquals($request, $arg2); + $this->assertEquals($request, $arg1); + $this->assertEquals($model, $arg2); return false; });