This commit is contained in:
Toby Zerner 2019-11-16 17:50:07 +10:30
parent 8a4a09bfeb
commit 5d76c0f45a
35 changed files with 1175 additions and 542 deletions

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;
}

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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
{

View File

@ -1,10 +1,29 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;
}

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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);

View File

@ -1,15 +1,27 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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();
$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);
$this->loadRelationshipsAtLevel($models, [], $this->resource, $include, $request);
}
$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);
$nextRelationshipPath = array_merge($relationshipPath, [$field]);
if ($load = $field->isLoadable()) {
if (is_callable($load)) {
$load($models, $nextRelationshipPath, $field->isLinkage(), $request);
} else {
$adapter->loadIds($models, $field);
$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);
}
}
}

View File

@ -1,34 +1,61 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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) {

View File

@ -1,15 +1,24 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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)

View File

@ -1,15 +1,24 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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);
}

View File

@ -1,24 +1,32 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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);
}
$offset = $request->getAttribute('jsonApiOffset');
$limit = $request->getAttribute('jsonApiLimit');
$total = null;
$paginationLinks = [];
$members = [
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']);
}
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 = [];
$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) {
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 $paginationLinks;
}
return new JsonApiResponse(
new Structure\CompoundDocument(
new Structure\PaginatedCollection(
new Structure\Pagination(...$paginationLinks),
new Structure\ResourceCollection(...$serializer->primary())
),
new Structure\Included(...$serializer->included()),
...$members
)
);
}
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]');
if (! $sort = $request->getQueryParams()['sort'] ?? $schema->getDefaultSort()) {
return;
}
$limit = min($this->resource->getSchema()->getLimit(), $limit);
}
$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 (isset($filters[$name])) {
$filters[$name]($query, $value, $request);
continue;
}
if (($filter = $field->getFilterable()) instanceof Closure) {
$filter($query, $value, $request);
} elseif ($field instanceof Attribute) {
$adapter->filterByAttribute($query, $field, $value);
} elseif ($field instanceof HasOne) {
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, $field, $value);
} elseif ($field instanceof HasMany) {
$adapter->filterByHasOne($query, $fields[$name], $value);
} elseif ($fields[$name] instanceof HasMany) {
$value = explode(',', $value);
$adapter->filterByHasMany($query, $field, $value);
}
$adapter->filterByHasMany($query, $fields[$name], $value);
}
continue;
}
throw new BadRequestException("Invalid filter [$name]", "filter[$name]");
}
}
}

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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(

View File

@ -1,14 +1,23 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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);
}
}

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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

View File

@ -1,12 +1,21 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;

View File

@ -1,8 +1,16 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;

View File

@ -1,20 +1,38 @@
<?php
namespace Tobyz\JsonApiServer\Schema;
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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';
}
}

View File

@ -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] ?? [];
}

View File

@ -1,10 +1,18 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;
}

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;

View File

@ -1,19 +1,25 @@
<?php
namespace Tobyz\JsonApiServer\Schema;
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;
}

View File

@ -1,20 +1,32 @@
<?php
namespace Tobyz\JsonApiServer\Schema;
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;
}
}

View File

@ -1,10 +1,18 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;
}

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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(),

View File

@ -1,5 +1,14 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;
}