310 lines
10 KiB
PHP
310 lines
10 KiB
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\Endpoint\Concerns;
|
|
|
|
use Tobyz\JsonApiServer\Context;
|
|
use Tobyz\JsonApiServer\Exception\BadRequestException;
|
|
use Tobyz\JsonApiServer\Exception\ConflictException;
|
|
use Tobyz\JsonApiServer\Exception\ForbiddenException;
|
|
use Tobyz\JsonApiServer\Exception\UnprocessableEntityException;
|
|
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;
|
|
|
|
trait SavesData
|
|
{
|
|
use FindsResources;
|
|
|
|
/**
|
|
* Parse and validate a JSON:API document's `data` member.
|
|
*
|
|
* @throws BadRequestException if the `data` member is invalid.
|
|
*/
|
|
private function parseData(ResourceType $resourceType, $body, $model = null): array
|
|
{
|
|
$body = (array) $body;
|
|
|
|
if (! isset($body['data']) || ! is_array($body['data'])) {
|
|
throw new BadRequestException('data must be an object');
|
|
}
|
|
|
|
if (! isset($body['data']['type'])) {
|
|
throw new BadRequestException('data.type must be present');
|
|
}
|
|
|
|
if ($body['data']['type'] !== $resourceType->getType()) {
|
|
throw new ConflictException('data.type does not match the resource type');
|
|
}
|
|
|
|
if ($model) {
|
|
$id = $resourceType->getAdapter()->getId($model);
|
|
|
|
if (! isset($body['data']['id']) || $body['data']['id'] !== $id) {
|
|
throw new ConflictException('data.id does not match the resource ID');
|
|
}
|
|
} elseif (isset($body['data']['id'])) {
|
|
throw new ForbiddenException('Client-generated IDs are not supported');
|
|
}
|
|
|
|
if (isset($body['data']['attributes']) && ! is_array($body['data']['attributes'])) {
|
|
throw new BadRequestException('data.attributes must be an object');
|
|
}
|
|
|
|
if (isset($body['data']['relationships']) && ! is_array($body['data']['relationships'])) {
|
|
throw new BadRequestException('data.relationships must be an object');
|
|
}
|
|
|
|
return array_merge(
|
|
['attributes' => [], 'relationships' => []],
|
|
$body['data']
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get the model corresponding to the given identifier.
|
|
*
|
|
* @throws BadRequestException if the identifier is invalid.
|
|
*/
|
|
private function getModelForIdentifier(Context $context, array $identifier, array $validTypes = null)
|
|
{
|
|
if (! isset($identifier['type'])) {
|
|
throw new BadRequestException('type not specified');
|
|
}
|
|
|
|
if (! isset($identifier['id'])) {
|
|
throw new BadRequestException('id not specified');
|
|
}
|
|
|
|
if ($validTypes !== null && count($validTypes) && ! in_array($identifier['type'], $validTypes)) {
|
|
throw new BadRequestException("type [{$identifier['type']}] not allowed");
|
|
}
|
|
|
|
$resourceType = $context->getApi()->getResourceType($identifier['type']);
|
|
|
|
return $this->findResource($resourceType, $identifier['id'], $context);
|
|
}
|
|
|
|
/**
|
|
* Assert that the fields contained within a data object are valid.
|
|
*/
|
|
private function validateFields(ResourceType $resourceType, array $data, $model, Context $context)
|
|
{
|
|
$this->assertFieldsExist($resourceType, $data);
|
|
$this->assertFieldsWritable($resourceType, $data, $model, $context);
|
|
}
|
|
|
|
/**
|
|
* Assert that the fields contained within a data object exist in the schema.
|
|
*
|
|
* @throws BadRequestException if a field is unknown.
|
|
*/
|
|
private function assertFieldsExist(ResourceType $resourceType, array $data)
|
|
{
|
|
$fields = $resourceType->getSchema()->getFields();
|
|
|
|
foreach (['attributes', 'relationships'] as $location) {
|
|
foreach ($data[$location] as $name => $value) {
|
|
if (! isset($fields[$name]) || $location !== $fields[$name]->getLocation()) {
|
|
throw new BadRequestException("Unknown field [$name]");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Assert that the fields contained within a data object are writable.
|
|
*
|
|
* @throws BadRequestException if a field is not writable.
|
|
*/
|
|
private function assertFieldsWritable(ResourceType $resourceType, array $data, $model, Context $context)
|
|
{
|
|
foreach ($resourceType->getSchema()->getFields() as $field) {
|
|
if (! has_value($data, $field)) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
! evaluate($field->getWritable(), [$model, $context])
|
|
|| (
|
|
$context->getRequest()->getMethod() !== 'POST'
|
|
&& $field->isWritableOnce()
|
|
)
|
|
) {
|
|
throw new BadRequestException("Field [{$field->getName()}] is not writable");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replace relationship linkage within a data object with models.
|
|
*/
|
|
private function loadRelatedResources(ResourceType $resourceType, array &$data, Context $context)
|
|
{
|
|
foreach ($resourceType->getSchema()->getFields() as $field) {
|
|
if (! $field instanceof Relationship || ! has_value($data, $field)) {
|
|
continue;
|
|
}
|
|
|
|
$value = get_value($data, $field);
|
|
|
|
if (! array_key_exists('data', $value)) {
|
|
throw new BadRequestException('relationship does not include data key');
|
|
}
|
|
|
|
if ($value['data'] !== null) {
|
|
$allowedTypes = (array) $field->getType();
|
|
|
|
if ($field instanceof HasOne) {
|
|
set_value($data, $field, $this->getModelForIdentifier($context, $value['data'], $allowedTypes));
|
|
} elseif ($field instanceof HasMany) {
|
|
set_value($data, $field, array_map(function ($identifier) use ($context, $allowedTypes) {
|
|
return $this->getModelForIdentifier($context, $identifier, $allowedTypes);
|
|
}, $value['data']));
|
|
}
|
|
} else {
|
|
set_value($data, $field, null);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Assert that the field values within a data object pass validation.
|
|
*
|
|
* @throws UnprocessableEntityException if any fields do not pass validation.
|
|
*/
|
|
private function assertDataValid(ResourceType $resourceType, array $data, $model, Context $context, bool $validateAll): void
|
|
{
|
|
$failures = [];
|
|
|
|
foreach ($resourceType->getSchema()->getFields() as $field) {
|
|
if (! $validateAll && ! has_value($data, $field)) {
|
|
continue;
|
|
}
|
|
|
|
$fail = function ($message = null) use (&$failures, $field) {
|
|
$failures[] = compact('field', 'message');
|
|
};
|
|
|
|
run_callbacks(
|
|
$field->getListeners('validate'),
|
|
[$fail, get_value($data, $field), $model, $context, $field]
|
|
);
|
|
}
|
|
|
|
if (count($failures)) {
|
|
throw new UnprocessableEntityException($failures);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set field values from a data object to the model instance.
|
|
*/
|
|
private function setValues(ResourceType $resourceType, array $data, $model, Context $context)
|
|
{
|
|
$adapter = $resourceType->getAdapter();
|
|
|
|
foreach ($resourceType->getSchema()->getFields() as $field) {
|
|
if (! has_value($data, $field)) {
|
|
continue;
|
|
}
|
|
|
|
$value = get_value($data, $field);
|
|
|
|
if ($setCallback = $field->getSetCallback()) {
|
|
$setCallback($model, $value, $context);
|
|
continue;
|
|
}
|
|
|
|
if ($field->getSaveCallback()) {
|
|
continue;
|
|
}
|
|
|
|
if ($field instanceof Attribute) {
|
|
$adapter->setAttribute($model, $field, $value);
|
|
} elseif ($field instanceof HasOne) {
|
|
$adapter->setHasOne($model, $field, $value);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save the model and its fields.
|
|
*/
|
|
private function save(ResourceType $resourceType, array $data, $model, Context $context)
|
|
{
|
|
$this->saveModel($resourceType, $model, $context);
|
|
$this->saveFields($resourceType, $data, $model, $context);
|
|
}
|
|
|
|
/**
|
|
* Save the model.
|
|
*/
|
|
private function saveModel(ResourceType $resourceType, $model, Context $context)
|
|
{
|
|
if ($saveCallback = $resourceType->getSchema()->getSaveCallback()) {
|
|
$saveCallback($model, $context);
|
|
} else {
|
|
$resourceType->getAdapter()->save($model);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save any fields that were not saved with the model.
|
|
*/
|
|
private function saveFields(ResourceType $resourceType, array $data, $model, Context $context)
|
|
{
|
|
$adapter = $resourceType->getAdapter();
|
|
|
|
foreach ($resourceType->getSchema()->getFields() as $field) {
|
|
if (! has_value($data, $field)) {
|
|
continue;
|
|
}
|
|
|
|
$value = get_value($data, $field);
|
|
|
|
if ($saveCallback = $field->getSaveCallback()) {
|
|
$saveCallback($model, $value, $context);
|
|
} elseif ($field instanceof HasMany) {
|
|
$adapter->saveHasMany($model, $field, $value);
|
|
}
|
|
}
|
|
|
|
$this->runSavedCallbacks($resourceType, $data, $model, $context);
|
|
}
|
|
|
|
/**
|
|
* Run field saved listeners.
|
|
*/
|
|
private function runSavedCallbacks(ResourceType $resourceType, array $data, $model, Context $context)
|
|
{
|
|
|
|
foreach ($resourceType->getSchema()->getFields() as $field) {
|
|
if (! has_value($data, $field)) {
|
|
continue;
|
|
}
|
|
|
|
run_callbacks(
|
|
$field->getListeners('saved'),
|
|
[$model, get_value($data, $field), $context]
|
|
);
|
|
}
|
|
}
|
|
}
|