json-api-server/src/Endpoint/Concerns/SavesData.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]
);
}
}
}