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 <?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; namespace Tobyz\JsonApiServer\Adapter;
use Closure; use Closure;
@ -151,20 +160,20 @@ interface AdapterInterface
* *
* @param $model * @param $model
* @param HasOne $relationship * @param HasOne $relationship
* @param array $fields * @param bool $linkage
* @return mixed|null * @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. * Get a list of models for a has-many relationship for the model.
* *
* @param $model * @param $model
* @param HasMany $relationship * @param HasMany $relationship
* @param array $fields * @param bool $linkage
* @return array * @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. * Apply an attribute value to the model.
@ -219,9 +228,11 @@ interface AdapterInterface
* @param array $relationships * @param array $relationships
* @param Closure $scope Should be called to give the deepest relationship * @param Closure $scope Should be called to give the deepest relationship
* an opportunity to scope the query that will fetch related resources * 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 * @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 * Load information about the IDs of related resources onto a collection
@ -231,5 +242,5 @@ interface AdapterInterface
* @param Relationship $relationship * @param Relationship $relationship
* @return mixed * @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 <?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; namespace Tobyz\JsonApiServer\Adapter;
use Closure; use Closure;
@ -69,12 +78,15 @@ class EloquentAdapter implements AdapterInterface
return $model->{$this->getAttributeProperty($attribute)}; 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); $relation = $this->getEloquentRelation($model, $relationship);
// comment // If it's a belongs-to relationship and we only need to get the ID,
if ($fields === ['id'] && $relation instanceof BelongsTo) { // 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()}) { if ($key = $model->{$relation->getForeignKeyName()}) {
$related = $relation->getRelated(); $related = $relation->getRelated();
@ -87,7 +99,7 @@ class EloquentAdapter implements AdapterInterface
return $this->getRelationValue($model, $relationship); 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); $collection = $this->getRelationValue($model, $relationship);
@ -188,38 +200,42 @@ class EloquentAdapter implements AdapterInterface
$query->take($limit)->skip($offset); $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([ (new Collection($models))->loadMissing([
$this->getRelationshipPath($relationships) => $scope $this->getRelationshipPath($relationships) => $scope
]); ]);
} }
public function loadIds(array $models, Relationship $relationship): void // public function loadIds(array $models, Relationship $relationship): void
{ // {
if (empty($models)) { // if (empty($models)) {
return; // return;
} // }
//
$property = $this->getRelationshipProperty($relationship); // $property = $this->getRelationshipProperty($relationship);
$relation = $models[0]->$property(); // $relation = $models[0]->$property();
//
// If it's a belongs-to relationship, then the ID is stored on the model // // 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. // // itself, so we don't need to load anything in advance.
if ($relation instanceof BelongsTo) { // if ($relation instanceof BelongsTo) {
return; // return;
} // }
//
(new Collection($models))->loadMissing([ // (new Collection($models))->loadMissing([
$property => function ($query) use ($relation) { // $property => function ($query) use ($relation) {
$query->select($relation->getRelated()->getKeyName()); // $query->select($relation->getRelated()->getKeyName());
//
if (! $relation instanceof BelongsToMany) { // if (! $relation instanceof BelongsToMany) {
$query->addSelect($relation->getForeignKeyName()); // $query->addSelect($relation->getForeignKeyName());
} // }
} // }
]); // ]);
} // }
private function getAttributeProperty(Attribute $attribute): string private function getAttributeProperty(Attribute $attribute): string
{ {

View File

@ -1,10 +1,29 @@
<?php <?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; namespace Tobyz\JsonApiServer;
use JsonApiPhp\JsonApi\Error;
interface ErrorProviderInterface interface ErrorProviderInterface
{ {
/**
* Get JSON:API error objects that represent this error.
*
* @return Error[]
*/
public function getJsonApiErrors(): array; public function getJsonApiErrors(): array;
/**
* Get the most generally applicable HTTP error code for this error.
*/
public function getJsonApiStatus(): string; public function getJsonApiStatus(): string;
} }

View File

@ -1,5 +1,14 @@
<?php <?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; namespace Tobyz\JsonApiServer\Exception;
use DomainException; use DomainException;

View File

@ -1,5 +1,14 @@
<?php <?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; namespace Tobyz\JsonApiServer\Exception;
use DomainException; use DomainException;

View File

@ -1,5 +1,14 @@
<?php <?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; namespace Tobyz\JsonApiServer\Exception;
use JsonApiPhp\JsonApi\Error; use JsonApiPhp\JsonApi\Error;

View File

@ -1,5 +1,14 @@
<?php <?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; namespace Tobyz\JsonApiServer\Exception;
use DomainException as DomainExceptionAlias; use DomainException as DomainExceptionAlias;

View File

@ -1,5 +1,14 @@
<?php <?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; namespace Tobyz\JsonApiServer\Exception;
use JsonApiPhp\JsonApi\Error; use JsonApiPhp\JsonApi\Error;

View File

@ -1,5 +1,14 @@
<?php <?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; namespace Tobyz\JsonApiServer\Exception;
use DomainException; use DomainException;

View File

@ -1,5 +1,14 @@
<?php <?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; namespace Tobyz\JsonApiServer\Exception;
use JsonApiPhp\JsonApi\Error; use JsonApiPhp\JsonApi\Error;

View File

@ -1,5 +1,14 @@
<?php <?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; namespace Tobyz\JsonApiServer\Exception;
use DomainException; use DomainException;

View File

@ -1,5 +1,14 @@
<?php <?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; namespace Tobyz\JsonApiServer\Exception;
use DomainException; use DomainException;

View File

@ -1,5 +1,14 @@
<?php <?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; namespace Tobyz\JsonApiServer\Exception;
use JsonApiPhp\JsonApi\Error; use JsonApiPhp\JsonApi\Error;

View File

@ -1,5 +1,14 @@
<?php <?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; namespace Tobyz\JsonApiServer\Handler\Concerns;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
@ -9,13 +18,18 @@ use function Tobyz\JsonApiServer\run_callbacks;
trait FindsResources 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) private function findResource(Request $request, ResourceType $resource, string $id)
{ {
$adapter = $resource->getAdapter(); $adapter = $resource->getAdapter();
$query = $adapter->query(); $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); $model = $adapter->find($query, $id);

View File

@ -1,15 +1,27 @@
<?php <?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; namespace Tobyz\JsonApiServer\Handler\Concerns;
use Closure;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use function Tobyz\JsonApiServer\evaluate;
use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Schema\Relationship; use Tobyz\JsonApiServer\Schema\Relationship;
use function Tobyz\JsonApiServer\run_callbacks; use function Tobyz\JsonApiServer\run_callbacks;
/**
* @property JsonApi $api
* @property ResourceType $resource
*/
trait IncludesData trait IncludesData
{ {
private function getInclude(Request $request): array private function getInclude(Request $request): array
@ -32,10 +44,9 @@ trait IncludesData
$tree = []; $tree = [];
foreach (explode(',', $include) as $path) { foreach (explode(',', $include) as $path) {
$keys = explode('.', $path);
$array = &$tree; $array = &$tree;
foreach ($keys as $key) { foreach (explode('.', $path) as $key) {
if (! isset($array[$key])) { if (! isset($array[$key])) {
$array[$key] = []; $array[$key] = [];
} }
@ -52,7 +63,8 @@ trait IncludesData
$fields = $resource->getSchema()->getFields(); $fields = $resource->getSchema()->getFields();
foreach ($include as $name => $nested) { foreach ($include as $name => $nested) {
if (! isset($fields[$name]) if (
! isset($fields[$name])
|| ! $fields[$name] instanceof Relationship || ! $fields[$name] instanceof Relationship
|| ! $fields[$name]->isIncludable() || ! $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) private function loadRelationships(array $models, array $include, Request $request)
{ {
$adapter = $this->resource->getAdapter(); $this->loadRelationshipsAtLevel($models, [], $this->resource, $include, $request);
$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);
} }
$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) { 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; continue;
} }
if (($load = $field->getLoadable()) instanceof Closure) { $nextRelationshipPath = array_merge($relationshipPath, [$field]);
$load($models, true);
if ($load = $field->isLoadable()) {
if (is_callable($load)) {
$load($models, $nextRelationshipPath, $field->isLinkage(), $request);
} else { } 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 <?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; namespace Tobyz\JsonApiServer\Handler\Concerns;
use Psr\Http\Message\ServerRequestInterface as Request; 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\BadRequestException;
use Tobyz\JsonApiServer\Exception\UnprocessableEntityException; 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\Attribute;
use Tobyz\JsonApiServer\Schema\HasMany; use Tobyz\JsonApiServer\Schema\HasMany;
use Tobyz\JsonApiServer\Schema\HasOne; use Tobyz\JsonApiServer\Schema\HasOne;
use Tobyz\JsonApiServer\Schema\Relationship; 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 trait SavesData
{ {
use FindsResources; 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; $body = (array) $body;
if (! isset($body['data'])) { if (! isset($body['data']) || ! is_array($body['data'])) {
throw new BadRequestException('Root data attribute missing'); 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'])) { 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'])) { if (! isset($identifier['type'])) {
throw new BadRequestException('type not specified'); throw new BadRequestException('type not specified');
@ -64,12 +96,20 @@ trait SavesData
return $this->findResource($request, $resource, $identifier['id']); 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) private function validateFields(array $data, $model, Request $request)
{ {
$this->assertFieldsExist($data); $this->assertFieldsExist($data);
$this->assertFieldsWritable($data, $model, $request); $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) private function assertFieldsExist(array $data)
{ {
$fields = $this->resource->getSchema()->getFields(); $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) private function assertFieldsWritable(array $data, $model, Request $request)
{ {
foreach ($this->resource->getSchema()->getFields() as $field) { 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"); 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) private function loadRelatedResources(array &$data, Request $request)
{ {
foreach ($this->resource->getSchema()->getFields() as $field) { foreach ($this->resource->getSchema()->getFields() as $field) {
@ -102,7 +150,7 @@ trait SavesData
$value = get_value($data, $field); $value = get_value($data, $field);
if (isset($value['data'])) { if (isset($value['data'])) {
$allowedTypes = $field->getAllowedTypes(); $allowedTypes = (array) $field->getType();
if ($field instanceof HasOne) { if ($field instanceof HasOne) {
set_value($data, $field, $this->getModelForIdentifier($request, $value['data'], $allowedTypes)); 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 = []; $failures = [];
foreach ($this->resource->getSchema()->getFields() as $field) { foreach ($this->resource->getSchema()->getFields() as $field) {
if (! $all && ! has_value($data, $field)) { if (! $validateAll && ! has_value($data, $field)) {
continue; 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) private function setValues(array $data, $model, Request $request)
{ {
$adapter = $this->resource->getAdapter(); $adapter = $this->resource->getAdapter();
@ -169,21 +225,30 @@ trait SavesData
} }
} }
/**
* Save the model and its fields.
*/
private function save(array $data, $model, Request $request) private function save(array $data, $model, Request $request)
{ {
$this->saveModel($model, $request); $this->saveModel($model, $request);
$this->saveFields($data, $model, $request); $this->saveFields($data, $model, $request);
} }
/**
* Save the model.
*/
private function saveModel($model, Request $request) private function saveModel($model, Request $request)
{ {
if ($saver = $this->resource->getSchema()->getSaver()) { if ($saveCallback = $this->resource->getSchema()->getSaveCallback()) {
$saver($model, $request); $saveCallback($model, $request);
} else { } else {
$this->resource->getAdapter()->save($model); $this->resource->getAdapter()->save($model);
} }
} }
/**
* Save any fields that were not saved with the model.
*/
private function saveFields(array $data, $model, Request $request) private function saveFields(array $data, $model, Request $request)
{ {
$adapter = $this->resource->getAdapter(); $adapter = $this->resource->getAdapter();
@ -195,8 +260,8 @@ trait SavesData
$value = get_value($data, $field); $value = get_value($data, $field);
if ($saver = $field->getSaver()) { if ($saveCallback = $field->getSaveCallback()) {
$saver($model, $value, $request); $saveCallback($model, $value, $request);
} elseif ($field instanceof HasMany) { } elseif ($field instanceof HasMany) {
$adapter->saveHasMany($model, $field, $value); $adapter->saveHasMany($model, $field, $value);
} }
@ -205,6 +270,9 @@ trait SavesData
$this->runSavedCallbacks($data, $model, $request); $this->runSavedCallbacks($data, $model, $request);
} }
/**
* Run field saved listeners.
*/
private function runSavedCallbacks(array $data, $model, Request $request) private function runSavedCallbacks(array $data, $model, Request $request)
{ {
foreach ($this->resource->getSchema()->getFields() as $field) { foreach ($this->resource->getSchema()->getFields() as $field) {

View File

@ -1,15 +1,24 @@
<?php <?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; namespace Tobyz\JsonApiServer\Handler;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface; 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\evaluate;
use function Tobyz\JsonApiServer\has_value; 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\run_callbacks;
use function Tobyz\JsonApiServer\set_value; use function Tobyz\JsonApiServer\set_value;
@ -26,11 +35,16 @@ class Create implements RequestHandlerInterface
$this->resource = $resource; $this->resource = $resource;
} }
/**
* Handle a request to create a resource.
*
* @throws ForbiddenException if the resource is not creatable.
*/
public function handle(Request $request): Response public function handle(Request $request): Response
{ {
$schema = $this->resource->getSchema(); $schema = $this->resource->getSchema();
if (! evaluate($schema->getCreatable(), [$request])) { if (! evaluate($schema->isCreatable(), [$request])) {
throw new ForbiddenException; throw new ForbiddenException;
} }
@ -56,9 +70,9 @@ class Create implements RequestHandlerInterface
private function createModel(Request $request) 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) private function fillDefaultValues(array &$data, Request $request)

View File

@ -1,15 +1,24 @@
<?php <?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; namespace Tobyz\JsonApiServer\Handler;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
use function Tobyz\JsonApiServer\evaluate;
use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use function Tobyz\JsonApiServer\run_callbacks;
use Zend\Diactoros\Response\EmptyResponse; use Zend\Diactoros\Response\EmptyResponse;
use function Tobyz\JsonApiServer\evaluate;
use function Tobyz\JsonApiServer\run_callbacks;
class Delete implements RequestHandlerInterface class Delete implements RequestHandlerInterface
{ {
@ -22,18 +31,23 @@ class Delete implements RequestHandlerInterface
$this->model = $model; $this->model = $model;
} }
/**
* Handle a request to delete a resource.
*
* @throws ForbiddenException if the resource is not deletable.
*/
public function handle(Request $request): Response public function handle(Request $request): Response
{ {
$schema = $this->resource->getSchema(); $schema = $this->resource->getSchema();
if (! evaluate($schema->getDeletable(), [$this->model, $request])) { if (! evaluate($schema->isDeletable(), [$this->model, $request])) {
throw new ForbiddenException; throw new ForbiddenException;
} }
run_callbacks($schema->getListeners('deleting'), [$this->model, $request]); run_callbacks($schema->getListeners('deleting'), [$this->model, $request]);
if ($deleter = $this->resource->getSchema()->getDelete()) { if ($deleteCallback = $schema->getDeleteCallback()) {
$deleter($this->model, $request); $deleteCallback($this->model, $request);
} else { } else {
$this->resource->getAdapter()->delete($this->model); $this->resource->getAdapter()->delete($this->model);
} }

View File

@ -1,24 +1,32 @@
<?php <?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; namespace Tobyz\JsonApiServer\Handler;
use Closure;
use JsonApiPhp\JsonApi as Structure; use JsonApiPhp\JsonApi as Structure;
use JsonApiPhp\JsonApi\Link\LastLink; use JsonApiPhp\JsonApi\Link\LastLink;
use JsonApiPhp\JsonApi\Link\NextLink; use JsonApiPhp\JsonApi\Link\NextLink;
use JsonApiPhp\JsonApi\Link\PrevLink; use JsonApiPhp\JsonApi\Link\PrevLink;
use JsonApiPhp\JsonApi\Meta;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\JsonApiResponse; use Tobyz\JsonApiServer\JsonApiResponse;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Schema\Attribute; use Tobyz\JsonApiServer\Schema\Attribute;
use Tobyz\JsonApiServer\Schema\HasMany; use Tobyz\JsonApiServer\Schema\HasMany;
use Tobyz\JsonApiServer\Schema\HasOne; use Tobyz\JsonApiServer\Schema\HasOne;
use Tobyz\JsonApiServer\Serializer; use Tobyz\JsonApiServer\Serializer;
use function Tobyz\JsonApiServer\run_callbacks;
class Index implements RequestHandlerInterface class Index implements RequestHandlerInterface
{ {
@ -33,35 +41,81 @@ class Index implements RequestHandlerInterface
$this->resource = $resource; $this->resource = $resource;
} }
/**
* Handle a request to show a resource listing.
*/
public function handle(Request $request): Response public function handle(Request $request): Response
{ {
$include = $this->getInclude($request);
$request = $this->extractQueryParams($request);
$adapter = $this->resource->getAdapter(); $adapter = $this->resource->getAdapter();
$schema = $this->resource->getSchema(); $schema = $this->resource->getSchema();
run_callbacks($schema->getListeners('listing'), [&$request]);
$query = $adapter->query(); $query = $adapter->query();
foreach ($schema->getScopes() as $scope) { run_callbacks($schema->getListeners('scope'), [$query, $request, null]);
$request = $scope($query, $request, null) ?: $request;
$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')) { return new JsonApiResponse(
$this->filter($query, $filter, $request); new Structure\CompoundDocument(
} new Structure\PaginatedCollection(
new Structure\Pagination(...$this->buildPaginationLinks($request, $offset, $limit, count($models), $total)),
$offset = $request->getAttribute('jsonApiOffset'); new Structure\ResourceCollection(...$serializer->primary())
$limit = $request->getAttribute('jsonApiLimit'); ),
$total = null; new Structure\Included(...$serializer->included()),
$paginationLinks = [];
$members = [
new Structure\Link\SelfLink($this->buildUrl($request)), new Structure\Link\SelfLink($this->buildUrl($request)),
new Structure\Meta('offset', $offset), new Structure\Meta('offset', $offset),
new Structure\Meta('limit', $limit), 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) { if ($offset > 0) {
$paginationLinks[] = new Structure\Link\FirstLink($this->buildUrl($request, ['page' => ['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)); $paginationLinks[] = new PrevLink($this->buildUrl($request, $params));
} }
if ($schema->isCountable() && $schema->getPaginate()) { if ($schema->isCountable() && $schema->getPerPage() && $offset + $limit < $total) {
$total = $adapter->count($query);
$members[] = new Meta('total', $total);
if ($offset + $limit < $total) {
$paginationLinks[] = new LastLink($this->buildUrl($request, ['page' => ['offset' => floor(($total - 1) / $limit) * $limit]])); $paginationLinks[] = new LastLink($this->buildUrl($request, ['page' => ['offset' => floor(($total - 1) / $limit) * $limit]]));
} }
}
if ($sort = $request->getAttribute('jsonApiSort')) { if (($total === null && $count === $limit) || $offset + $limit < $total) {
$this->sort($query, $sort, $request);
}
$this->paginate($query, $request);
$models = $adapter->get($query);
if ((count($models) === $limit && $total === null) || $offset + $limit < $total) {
$paginationLinks[] = new NextLink($this->buildUrl($request, ['page' => ['offset' => $offset + $limit]])); $paginationLinks[] = new NextLink($this->buildUrl($request, ['page' => ['offset' => $offset + $limit]]));
} }
$this->loadRelationships($models, $include, $request); return $paginationLinks;
$serializer = new Serializer($this->api, $request);
foreach ($models as $model) {
$serializer->add($this->resource, $model, $include);
} }
return new JsonApiResponse( private function sort($query, Request $request)
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
{ {
$schema = $this->resource->getSchema(); $schema = $this->resource->getSchema();
$queryParams = $request->getQueryParams(); if (! $sort = $request->getQueryParams()['sort'] ?? $schema->getDefaultSort()) {
return;
$limit = $this->resource->getSchema()->getPaginate();
if (isset($queryParams['page']['limit'])) {
$limit = $queryParams['page']['limit'];
if ((! is_int($limit) && ! ctype_digit($limit)) || $limit < 1) {
throw new BadRequestException('page[limit] must be a positive integer', 'page[limit]');
} }
$limit = min($this->resource->getSchema()->getLimit(), $limit);
}
$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(); $adapter = $this->resource->getAdapter();
$sortFields = $schema->getSortFields();
$fields = $schema->getFields();
foreach ($sort as $name => $direction) { foreach ($this->parseSort($sort) as $name => $direction) {
$attribute = $schema->getFields()[$name]; if (isset($sortFields[$name])) {
$sortFields[$name]($query, $direction, $request);
if (($sorter = $attribute->getSortable()) instanceof Closure) { continue;
$sorter($query, $direction, $request);
} else {
$adapter->sortByAttribute($query, $attribute, $direction);
} }
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 private function parseSort(string $string): array
{ {
$sort = []; $sort = [];
$fields = explode(',', $string);
foreach ($fields as $field) { foreach (explode(',', $string) as $field) {
if ($field[0] === '-') { if ($field[0] === '-') {
$field = substr($field, 1); $field = substr($field, 1);
$direction = 'desc'; $direction = 'desc';
@ -251,18 +193,51 @@ class Index implements RequestHandlerInterface
private function paginate($query, Request $request) private function paginate($query, Request $request)
{ {
$limit = $request->getAttribute('jsonApiLimit'); $schema = $this->resource->getSchema();
$offset = $request->getAttribute('jsonApiOffset'); $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) { if ($limit || $offset) {
$this->resource->getAdapter()->paginate($query, $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(); $schema = $this->resource->getSchema();
$adapter = $this->resource->getAdapter(); $adapter = $this->resource->getAdapter();
$filters = $schema->getFilters();
$fields = $schema->getFields();
foreach ($filter as $name => $value) { foreach ($filter as $name => $value) {
if ($name === 'id') { if ($name === 'id') {
@ -270,19 +245,25 @@ class Index implements RequestHandlerInterface
continue; continue;
} }
$field = $schema->getFields()[$name]; if (isset($filters[$name])) {
$filters[$name]($query, $value, $request);
continue;
}
if (($filter = $field->getFilterable()) instanceof Closure) { if (isset($fields[$name]) && $fields[$name]->isFilterable()) {
$filter($query, $value, $request); if ($fields[$name] instanceof Attribute) {
} elseif ($field instanceof Attribute) { $adapter->filterByAttribute($query, $fields[$name], $value);
$adapter->filterByAttribute($query, $field, $value); } elseif ($fields[$name] instanceof HasOne) {
} elseif ($field instanceof HasOne) {
$value = explode(',', $value); $value = explode(',', $value);
$adapter->filterByHasOne($query, $field, $value); $adapter->filterByHasOne($query, $fields[$name], $value);
} elseif ($field instanceof HasMany) { } elseif ($fields[$name] instanceof HasMany) {
$value = explode(',', $value); $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 <?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; namespace Tobyz\JsonApiServer\Handler;
use JsonApiPhp\JsonApi\CompoundDocument; use JsonApiPhp\JsonApi\CompoundDocument;
@ -11,6 +20,7 @@ use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\JsonApiResponse; use Tobyz\JsonApiServer\JsonApiResponse;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Serializer; use Tobyz\JsonApiServer\Serializer;
use function Tobyz\JsonApiServer\run_callbacks;
class Show implements RequestHandlerInterface class Show implements RequestHandlerInterface
{ {
@ -27,15 +37,19 @@ class Show implements RequestHandlerInterface
$this->model = $model; $this->model = $model;
} }
/**
* Handle a request to show a resource.
*/
public function handle(Request $request): Response public function handle(Request $request): Response
{ {
$include = $this->getInclude($request); $include = $this->getInclude($request);
$this->loadRelationships([$this->model], $include, $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( return new JsonApiResponse(
new CompoundDocument( new CompoundDocument(

View File

@ -1,14 +1,23 @@
<?php <?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; namespace Tobyz\JsonApiServer\Handler;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
use function Tobyz\JsonApiServer\evaluate;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use function Tobyz\JsonApiServer\evaluate;
use function Tobyz\JsonApiServer\run_callbacks; use function Tobyz\JsonApiServer\run_callbacks;
class Update implements RequestHandlerInterface class Update implements RequestHandlerInterface
@ -26,15 +35,20 @@ class Update implements RequestHandlerInterface
$this->model = $model; $this->model = $model;
} }
/**
* Handle a request to update a resource.
*
* @throws ForbiddenException if the resource is not updatable.
*/
public function handle(Request $request): Response public function handle(Request $request): Response
{ {
$schema = $this->resource->getSchema(); $schema = $this->resource->getSchema();
if (! evaluate($schema->getUpdatable(), [$this->model, $request])) { if (! evaluate($schema->isUpdatable(), [$this->model, $request])) {
throw new ForbiddenException; throw new ForbiddenException;
} }
$data = $this->parseData($request->getParsedBody()); $data = $this->parseData($request->getParsedBody(), $this->model);
$this->validateFields($data, $this->model, $request); $this->validateFields($data, $this->model, $request);
$this->loadRelatedResources($data, $request); $this->loadRelatedResources($data, $request);
@ -47,6 +61,7 @@ class Update implements RequestHandlerInterface
run_callbacks($schema->getListeners('updated'), [$this->model, $request]); 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 <?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; namespace Tobyz\JsonApiServer\Http;
class MediaTypes class MediaTypes

View File

@ -1,12 +1,21 @@
<?php <?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; namespace Tobyz\JsonApiServer;
use Closure;
use JsonApiPhp\JsonApi\ErrorDocument; use JsonApiPhp\JsonApi\ErrorDocument;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
use Tobyz\JsonApiServer\Adapter\AdapterInterface;
use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\InternalServerErrorException; use Tobyz\JsonApiServer\Exception\InternalServerErrorException;
use Tobyz\JsonApiServer\Exception\MethodNotAllowedException; use Tobyz\JsonApiServer\Exception\MethodNotAllowedException;
@ -31,16 +40,27 @@ final class JsonApi implements RequestHandlerInterface
$this->baseUrl = $baseUrl; $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); $this->resources[$type] = new ResourceType($type, $adapter, $buildSchema);
} }
/**
* Get defined resource types.
*/
public function getResources(): array public function getResources(): array
{ {
return $this->resources; return $this->resources;
} }
/**
* Get a resource type.
*
* @throws ResourceNotFoundException if the resource type has not been defined.
*/
public function getResource(string $type): ResourceType public function getResource(string $type): ResourceType
{ {
if (! isset($this->resources[$type])) { if (! isset($this->resources[$type])) {
@ -50,6 +70,15 @@ final class JsonApi implements RequestHandlerInterface
return $this->resources[$type]; 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 public function handle(Request $request): Response
{ {
$this->validateRequest($request); $this->validateRequest($request);
@ -112,11 +141,7 @@ final class JsonApi implements RequestHandlerInterface
$mediaTypes = new MediaTypes($header); $mediaTypes = new MediaTypes($header);
if ($mediaTypes->containsExactly('*/*')) { if ($mediaTypes->containsExactly('*/*') || $mediaTypes->containsExactly(self::CONTENT_TYPE)) {
return;
}
if ($mediaTypes->containsExactly(self::CONTENT_TYPE)) {
return; 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) public function error($e)
{ {
if (! $e instanceof ErrorProviderInterface) { if (! $e instanceof ErrorProviderInterface) {
@ -188,6 +219,9 @@ final class JsonApi implements RequestHandlerInterface
return new JsonApiResponse($data, $status); return new JsonApiResponse($data, $status);
} }
/**
* Get the base URL for the API.
*/
public function getBaseUrl(): string public function getBaseUrl(): string
{ {
return $this->baseUrl; return $this->baseUrl;

View File

@ -1,5 +1,14 @@
<?php <?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; namespace Tobyz\JsonApiServer;
use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Response\JsonResponse;

View File

@ -1,8 +1,16 @@
<?php <?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; namespace Tobyz\JsonApiServer;
use Closure;
use Tobyz\JsonApiServer\Adapter\AdapterInterface; use Tobyz\JsonApiServer\Adapter\AdapterInterface;
use Tobyz\JsonApiServer\Schema\Type; use Tobyz\JsonApiServer\Schema\Type;
@ -13,7 +21,7 @@ final class ResourceType
private $buildSchema; private $buildSchema;
private $schema; 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->type = $type;
$this->adapter = $adapter; $this->adapter = $adapter;

View File

@ -1,20 +1,38 @@
<?php <?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 final class Attribute extends Field
{ {
private $sortable = false; 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; return $this;
} }
/**
* Disallow this attribute to be used for sorting the resource listing.
*/
public function notSortable() public function notSortable()
{ {
$this->sortable = false; $this->sortable = false;
@ -22,13 +40,8 @@ final class Attribute extends Field
return $this; return $this;
} }
public function getSortable() public function isSortable(): bool
{ {
return $this->sortable; return $this->sortable;
} }
public function getLocation(): string
{
return 'attributes';
}
} }

View File

@ -13,9 +13,9 @@ namespace Tobyz\JsonApiServer\Schema\Concerns;
trait HasListeners trait HasListeners
{ {
private $listeners = []; protected $listeners = [];
public function getListeners(string $event) public function getListeners(string $event): array
{ {
return $this->listeners[$event] ?? []; return $this->listeners[$event] ?? [];
} }

View File

@ -1,10 +1,18 @@
<?php <?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; namespace Tobyz\JsonApiServer\Schema;
use Closure;
use function Tobyz\JsonApiServer\negate;
use Tobyz\JsonApiServer\Schema\Concerns\HasListeners; use Tobyz\JsonApiServer\Schema\Concerns\HasListeners;
use function Tobyz\JsonApiServer\negate;
use function Tobyz\JsonApiServer\wrap; use function Tobyz\JsonApiServer\wrap;
abstract class Field abstract class Field
@ -16,10 +24,10 @@ abstract class Field
private $visible = true; private $visible = true;
private $single = false; private $single = false;
private $writable = false; private $writable = false;
private $getter; private $getCallback;
private $setter; private $setCallback;
private $saver; private $saveCallback;
private $default; private $defaultCallback;
private $filterable = false; private $filterable = false;
public function __construct(string $name) public function __construct(string $name)
@ -27,8 +35,15 @@ abstract class Field
$this->name = $name; $this->name = $name;
} }
/**
* Get the location of the field within a JSON:API resource object
* ('attributes' or 'relationships').
*/
abstract public function getLocation(): string; abstract public function getLocation(): string;
/**
* Set the model property to which this field corresponds.
*/
public function property(string $property) public function property(string $property)
{ {
$this->property = $property; $this->property = $property;
@ -36,14 +51,20 @@ abstract class Field
return $this; return $this;
} }
public function visible(Closure $condition = null) /**
* Allow this field to be seen.
*/
public function visible(callable $condition = null)
{ {
$this->visible = $condition ?: true; $this->visible = $condition ?: true;
return $this; 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; $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() public function single()
{ {
@ -62,69 +85,109 @@ abstract class Field
return $this; return $this;
} }
public function writable(Closure $condition = null) /**
* Allow this field to be written.
*/
public function writable(callable $condition = null)
{ {
$this->writable = $condition ?: true; $this->writable = $condition ?: true;
return $this; 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; $this->writable = $condition ? negate($condition) : false;
return $this; 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) public function get($value)
{ {
$this->getter = wrap($value); $this->getCallback = $value === null ? null : wrap($value);
return $this; 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; 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; 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; $this->listeners['saved'][] = $callback;
return $this; 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) public function default($value)
{ {
$this->default = wrap($value); $this->defaultCallback = wrap($value);
return $this; return $this;
} }
public function validate(Closure $callback) /**
* Add a validation callback for this field.
*/
public function validate(callable $callback)
{ {
$this->listeners['validate'][] = $callback; $this->listeners['validate'][] = $callback;
return $this; 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; return $this;
} }
/**
* Disallow this field to be used for filtering the resource listing.
*/
public function notFilterable() public function notFilterable()
{ {
$this->filterable = false; $this->filterable = false;
@ -137,53 +200,47 @@ abstract class Field
return $this->name; return $this->name;
} }
public function getProperty() public function getProperty(): ?string
{ {
return $this->property; return $this->property;
} }
/** public function isVisible()
* @return bool|Closure
*/
public function getVisible()
{ {
return $this->visible; return $this->visible;
} }
public function getSingle() public function isSingle(): bool
{ {
return $this->single; return $this->single;
} }
public function getWritable() public function isWritable()
{ {
return $this->writable; 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;
} }
/** public function isFilterable(): bool
* @return bool|Closure
*/
public function getFilterable()
{ {
return $this->filterable; return $this->filterable;
} }

View File

@ -1,5 +1,14 @@
<?php <?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; namespace Tobyz\JsonApiServer\Schema;
final class HasMany extends Relationship final class HasMany extends Relationship

View File

@ -1,5 +1,14 @@
<?php <?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; namespace Tobyz\JsonApiServer\Schema;
use Doctrine\Common\Inflector\Inflector; use Doctrine\Common\Inflector\Inflector;

View File

@ -1,19 +1,25 @@
<?php <?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;
use function Tobyz\JsonApiServer\wrap;
final class Meta final class Meta
{ {
private $name; private $name;
private $value; private $value;
public function __construct(string $name, $value) public function __construct(string $name, callable $value)
{ {
$this->name = $name; $this->name = $name;
$this->value = wrap($value); $this->value = $value;
} }
public function getName(): string public function getName(): string
@ -21,7 +27,7 @@ final class Meta
return $this->name; return $this->name;
} }
public function getValue(): Closure public function getValue(): callable
{ {
return $this->value; return $this->value;
} }

View File

@ -1,20 +1,32 @@
<?php <?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;
use function Tobyz\JsonApiServer\negate;
abstract class Relationship extends Field abstract class Relationship extends Field
{ {
private $type; private $type;
private $allowedTypes;
private $linkage = false; private $linkage = false;
private $links = true; private $links = true;
private $loadable = true; private $loadable = true;
private $includable = false; private $includable = false;
private $scopes = [];
public function getLocation(): string
{
return 'relationships';
}
/**
* Set the resource type that this relationship is to.
*/
public function type($type) public function type($type)
{ {
$this->type = $type; $this->type = $type;
@ -22,35 +34,52 @@ abstract class Relationship extends Field
return $this; return $this;
} }
/**
* Define this as a polymorphic relationship.
*/
public function polymorphic(array $types = null) public function polymorphic(array $types = null)
{ {
$this->type = null; $this->type = $types;
$this->allowedTypes = $types;
return $this; 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; 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; 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; $this->loadable = $callback ?: true;
return $this; return $this;
} }
/**
* Do not eager-load relationship data into the model collection.
*/
public function notLoadable() public function notLoadable()
{ {
$this->loadable = false; $this->loadable = false;
@ -58,6 +87,9 @@ abstract class Relationship extends Field
return $this; return $this;
} }
/**
* Allow the relationship data to be included in a compound document.
*/
public function includable() public function includable()
{ {
$this->includable = true; $this->includable = true;
@ -65,6 +97,9 @@ abstract class Relationship extends Field
return $this; return $this;
} }
/**
* Do not allow the relationship data to be included in a compound document.
*/
public function notIncludable() public function notIncludable()
{ {
$this->includable = false; $this->includable = false;
@ -72,16 +107,9 @@ abstract class Relationship extends Field
return $this; return $this;
} }
public function getType(): ?string /**
{ * Show links for the relationship.
return $this->type; */
}
public function getAllowedTypes(): ?array
{
return $this->allowedTypes;
}
public function links() public function links()
{ {
$this->links = true; $this->links = true;
@ -89,6 +117,9 @@ abstract class Relationship extends Field
return $this; return $this;
} }
/**
* Do not show links for the relationship.
*/
public function noLinks() public function noLinks()
{ {
$this->links = false; $this->links = false;
@ -96,20 +127,33 @@ abstract class Relationship extends Field
return $this; 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; return $this->linkage;
} }
public function hasLinks(): bool public function isLinks(): bool
{ {
return $this->links; return $this->links;
} }
/** /**
* @return bool|Closure * @return bool|callable
*/ */
public function getLoadable() public function isLoadable()
{ {
return $this->loadable; return $this->loadable;
} }
@ -118,19 +162,4 @@ abstract class Relationship extends Field
{ {
return $this->includable; 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 <?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; namespace Tobyz\JsonApiServer\Schema;
use Closure;
use function Tobyz\JsonApiServer\negate;
use Tobyz\JsonApiServer\Schema\Concerns\HasListeners; use Tobyz\JsonApiServer\Schema\Concerns\HasListeners;
use function Tobyz\JsonApiServer\negate;
final class Type final class Type
{ {
@ -12,29 +20,49 @@ final class Type
private $fields = []; private $fields = [];
private $meta = []; private $meta = [];
private $paginate = 20; private $filters = [];
private $sortFields = [];
private $perPage = 20;
private $limit = 50; private $limit = 50;
private $countable = true; private $countable = true;
private $listable = true;
private $defaultSort; private $defaultSort;
private $defaultFilter; private $defaultFilter;
private $scopes = []; private $saveCallback;
private $saver; private $createModelCallback;
private $creatable = false; private $creatable = false;
private $create;
private $updatable = false; private $updatable = false;
private $deletable = 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 public function attribute(string $name): Attribute
{ {
return $this->field(Attribute::class, $name); 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 public function hasOne(string $name): HasOne
{ {
return $this->field(HasOne::class, $name); 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 public function hasMany(string $name): HasMany
{ {
return $this->field(HasMany::class, $name); return $this->field(HasMany::class, $name);
@ -49,14 +77,17 @@ final class Type
return $this->fields[$name]; 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]); unset($this->fields[$name]);
return $this;
} }
/** /**
* Get the resource type's fields.
*
* @return Field[] * @return Field[]
*/ */
public function getFields(): array public function getFields(): array
@ -64,240 +95,408 @@ final class Type
return $this->fields; 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); 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]); unset($this->meta[$name]);
return $this;
} }
/**
* Get the resource type's meta attributes.
*
* @return Meta[]
*/
public function getMeta(): array public function getMeta(): array
{ {
return $this->meta; 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) $this->filters[$name] = $callback;
->hidden()
->filterable($callback);
return $this;
} }
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; $this->limit = $limit;
} }
public function noLimit() /**
* Allow unlimited records to be listed.
*/
public function noLimit(): void
{ {
$this->limit = null; $this->limit = null;
} }
/**
* Get the maximum number of records that can be listed, or null if there
* is no limit.
*/
public function getLimit(): int public function getLimit(): int
{ {
return $this->limit; return $this->limit;
} }
public function countable() /**
* Mark the resource type as countable.
*/
public function countable(): void
{ {
$this->countable = true; $this->countable = true;
} }
public function uncountable() /**
* Mark the resource type as uncountable.
*/
public function uncountable(): void
{ {
$this->countable = false; $this->countable = false;
} }
/**
* Get whether or not the resource type is countable.
*/
public function isCountable(): bool public function isCountable(): bool
{ {
return $this->countable; 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; $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; $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; 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; $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; $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; $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; $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; 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; $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; $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; $this->saveCallback = $callback;
return $this;
} }
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; $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; $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; 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; $this->deleteCallback = $callback;
return $this;
} }
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; $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; $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; $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; 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; $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; return $this->defaultFilter;
} }

View File

@ -1,5 +1,14 @@
<?php <?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; namespace Tobyz\JsonApiServer;
use DateTime; use DateTime;
@ -30,13 +39,18 @@ final class Serializer
$this->request = $request; $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); $data = $this->addToMap($resource, $model, $include, $single);
$this->primary[] = $data['type'].':'.$data['id']; $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) private function addToMap(ResourceType $resource, $model, array $include, bool $single = false)
{ {
$adapter = $resource->getAdapter(); $adapter = $resource->getAdapter();
@ -69,11 +83,11 @@ final class Serializer
continue; continue;
} }
if ($field->getSingle() && ! $single) { if ($field->isSingle() && ! $single) {
continue; continue;
} }
if (! evaluate($field->getVisible(), [$model, $this->request])) { if (! evaluate($field->isVisible(), [$model, $this->request])) {
continue; continue;
} }
@ -81,12 +95,12 @@ final class Serializer
$value = $this->attribute($field, $model, $adapter); $value = $this->attribute($field, $model, $adapter);
} elseif ($field instanceof Schema\Relationship) { } elseif ($field instanceof Schema\Relationship) {
$isIncluded = isset($include[$name]); $isIncluded = isset($include[$name]);
$isLinkage = evaluate($field->getLinkage(), [$this->request]); $isLinkage = evaluate($field->isLinkage(), [$this->request]);
if (! $isIncluded && ! $isLinkage) { if (! $isIncluded && ! $isLinkage) {
$value = $this->emptyRelationship($field, $resourceUrl); $value = $this->emptyRelationship($field, $resourceUrl);
} elseif ($field instanceof Schema\HasOne) { } 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) { } elseif ($field instanceof Schema\HasMany) {
$value = $this->toMany($field, $model, $adapter, $isIncluded, $isLinkage, $include[$name] ?? [], $resourceUrl); $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 private function attribute(Attribute $field, $model, AdapterInterface $adapter): Structure\Attribute
{ {
if ($getter = $field->getGetter()) { if ($getter = $field->getGetCallback()) {
$value = $getter($model, $this->request); $value = $getter($model, $this->request);
} else { } else {
$value = $adapter->getAttribute($model, $field); $value = $adapter->getAttribute($model, $field);
@ -127,11 +141,11 @@ final class Serializer
return new Structure\Attribute($field->getName(), $value); 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); $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) { if (! $value) {
return new Structure\ToNull( return new Structure\ToNull(
@ -141,7 +155,7 @@ final class Serializer
} }
if ($isIncluded) { if ($isIncluded) {
$identifier = $this->addRelated($field, $value, $include); $identifier = $this->addRelated($field, $value, $include, $single);
} else { } else {
$identifier = $this->relatedResourceIdentifier($field, $value); $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) 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); $value = $getter($model, $this->request);
} else { } else {
$value = ($isLinkage || $isIncluded) ? $adapter->getHasMany($model, $field) : null; $value = ($isLinkage || $isIncluded) ? $adapter->getHasMany($model, $field, false) : null;
} }
$identifiers = []; $identifiers = [];
@ -197,7 +211,7 @@ final class Serializer
private function getRelationshipLinks(Relationship $field, string $resourceUrl): array private function getRelationshipLinks(Relationship $field, string $resourceUrl): array
{ {
if (! $field->hasLinks()) { if (! $field->isLinks()) {
return []; 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( return $this->resourceIdentifier(
$this->addToMap($relatedResource, $model, $include) $this->addToMap($relatedResource, $model, $include, $single)
); );
} }
@ -286,7 +300,7 @@ final class Serializer
{ {
$type = $field->getType(); $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([ return $this->resourceIdentifier([
'type' => $relatedResource->getType(), 'type' => $relatedResource->getType(),

View File

@ -1,5 +1,14 @@
<?php <?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; namespace Tobyz\JsonApiServer;
use Closure; use Closure;
@ -49,16 +58,3 @@ function set_value(array &$data, Field $field, $value)
{ {
$data[$field->getLocation()][$field->getName()] = $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;
}