Improve spec compliance for resource creation; add ResourceType::url()

This commit is contained in:
Toby Zerner 2021-08-31 16:42:30 +10:00
parent 7405e07b93
commit d015070569
6 changed files with 150 additions and 33 deletions

View File

@ -13,6 +13,8 @@ namespace Tobyz\JsonApiServer\Endpoint\Concerns;
use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\ConflictException;
use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\Exception\UnprocessableEntityException; use Tobyz\JsonApiServer\Exception\UnprocessableEntityException;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Schema\Attribute; use Tobyz\JsonApiServer\Schema\Attribute;
@ -43,16 +45,22 @@ trait SavesData
throw new BadRequestException('data must be an object'); throw new BadRequestException('data must be an object');
} }
if (! isset($body['data']['type']) || $body['data']['type'] !== $resourceType->getType()) { if (! isset($body['data']['type'])) {
throw new BadRequestException('data.type does not match the resource 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) { if ($model) {
$id = $resourceType->getAdapter()->getId($model); $id = $resourceType->getAdapter()->getId($model);
if (! isset($body['data']['id']) || $body['data']['id'] !== $id) { if (! isset($body['data']['id']) || $body['data']['id'] !== $id) {
throw new BadRequestException('data.id does not match the resource 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'])) { if (isset($body['data']['attributes']) && ! is_array($body['data']['attributes'])) {
@ -156,7 +164,11 @@ trait SavesData
$value = get_value($data, $field); $value = get_value($data, $field);
if (isset($value['data'])) { if (! array_key_exists('data', $value)) {
throw new BadRequestException('relationship does not include data key');
}
if ($value['data'] !== null) {
$allowedTypes = (array) $field->getType(); $allowedTypes = (array) $field->getType();
if ($field instanceof HasOne) { if ($field instanceof HasOne) {

View File

@ -56,7 +56,8 @@ class Create
return (new Show()) return (new Show())
->handle($context, $resourceType, $model) ->handle($context, $resourceType, $model)
->withStatus(201); ->withStatus(201)
->withHeader('Location', $resourceType->url($model, $context));
} }
private function newModel(ResourceType $resourceType, Context $context) private function newModel(ResourceType $resourceType, Context $context)

View File

@ -0,0 +1,35 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Tobyz\JsonApiServer\Exception;
use DomainException;
use JsonApiPhp\JsonApi\Error;
use Tobyz\JsonApiServer\ErrorProviderInterface;
class ConflictException extends DomainException implements ErrorProviderInterface
{
public function getJsonApiErrors(): array
{
return [
new Error(
new Error\Title('Conflict'),
new Error\Status($this->getJsonApiStatus()),
...($this->message ? [new Error\Detail($this->message)] : [])
)
];
}
public function getJsonApiStatus(): string
{
return '409';
}
}

View File

@ -54,6 +54,13 @@ final class ResourceType
return $this->schema; return $this->schema;
} }
public function url($model, Context $context): string
{
$id = $this->adapter->getId($model);
return $context->getApi()->getBasePath()."/$this->type/$id";
}
/** /**
* Apply the resource type's scopes to a query. * Apply the resource type's scopes to a query.
*/ */

View File

@ -80,7 +80,7 @@ final class Serializer
'id' => $id, 'id' => $id,
'fields' => [], 'fields' => [],
'links' => [ 'links' => [
'self' => new Structure\Link\SelfLink($url = $this->resourceUrl($type, $id)), 'self' => new Structure\Link\SelfLink($url = $resourceType->url($model, $this->context)),
], ],
'meta' => $this->meta($schema->getMeta(), $model) 'meta' => $this->meta($schema->getMeta(), $model)
]; ];
@ -107,11 +107,6 @@ final class Serializer
return $type.':'.$id; return $type.':'.$id;
} }
private function resourceUrl(string $type, string $id): string
{
return $this->context->getApi()->getBasePath()."/$type/$id";
}
/** /**
* @return Structure\Internal\RelationshipMember[] * @return Structure\Internal\RelationshipMember[]
*/ */

View File

@ -11,12 +11,17 @@
namespace Tobyz\Tests\JsonApiServer\specification; namespace Tobyz\Tests\JsonApiServer\specification;
use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\ConflictException;
use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Schema\Type;
use Tobyz\Tests\JsonApiServer\AbstractTestCase; use Tobyz\Tests\JsonApiServer\AbstractTestCase;
use Tobyz\Tests\JsonApiServer\MockAdapter; use Tobyz\Tests\JsonApiServer\MockAdapter;
/** /**
* @see https://jsonapi.org/format/1.0/#crud-creating * @see https://jsonapi.org/format/1.1/#crud-creating
*/ */
class CreatingResourcesTest extends AbstractTestCase class CreatingResourcesTest extends AbstractTestCase
{ {
@ -25,55 +30,117 @@ class CreatingResourcesTest extends AbstractTestCase
*/ */
private $api; private $api;
/**
* @var MockAdapter
*/
private $adapter;
public function setUp(): void public function setUp(): void
{ {
$this->api = new JsonApi('http://example.com'); $this->api = new JsonApi('http://example.com');
$this->adapter = new MockAdapter(); $this->api->resourceType('users', new MockAdapter(), function (Type $type) {
$type->creatable();
$type->attribute('name')->writable();
$type->hasOne('pet')->writable();
});
} }
public function test_bad_request_error_if_body_does_not_contain_data_type() public function test_bad_request_error_if_body_does_not_contain_data_type()
{ {
$this->markTestIncomplete(); $this->expectException(BadRequestException::class);
$this->api->handle(
$this->buildRequest('POST', '/users')
->withParsedBody([
'data' => [],
])
);
} }
public function test_bad_request_error_if_relationship_does_not_contain_data() public function test_bad_request_error_if_relationship_does_not_contain_data()
{ {
$this->markTestIncomplete(); $this->expectException(BadRequestException::class);
$this->api->handle(
$this->buildRequest('POST', '/users')
->withParsedBody([
'data' => [
'type' => 'users',
'relationships' => [
'pet' => [],
],
],
])
);
} }
public function test_forbidden_error_if_client_generated_id_provided() public function test_forbidden_error_if_client_generated_id_provided()
{ {
$this->markTestIncomplete(); $this->expectException(ForbiddenException::class);
$this->api->handle(
$this->buildRequest('POST', '/users')
->withParsedBody([
'data' => [
'type' => 'users',
'id' => '1',
],
])
);
} }
public function test_created_response_if_resource_successfully_created() public function test_created_response_includes_created_data_and_location_header()
{ {
$this->markTestIncomplete(); $response = $this->api->handle(
} $this->buildRequest('POST', '/users')
->withParsedBody([
'data' => [
'type' => 'users',
],
])
);
public function test_created_response_includes_created_data() $this->assertEquals(201, $response->getStatusCode());
{ $this->assertEquals('http://example.com/users/1', $response->getHeaderLine('location'));
$this->markTestIncomplete();
}
public function test_created_response_includes_location_header_and_matches_self_link() $this->assertJsonApiDocumentSubset([
{ 'data' => [
$this->markTestIncomplete(); 'type' => 'users',
'id' => '1',
'links' => [
'self' => 'http://example.com/users/1',
],
],
], $response->getBody());
} }
public function test_not_found_error_if_references_resource_that_does_not_exist() public function test_not_found_error_if_references_resource_that_does_not_exist()
{ {
$this->markTestIncomplete(); $this->expectException(ResourceNotFoundException::class);
$this->api->handle(
$this->buildRequest('POST', '/users')
->withParsedBody([
'data' => [
'type' => 'users',
'relationships' => [
'pet' => [
'data' => ['type' => 'pets', 'id' => '1'],
],
],
],
])
);
} }
public function test_conflict_error_if_type_does_not_match_endpoint() public function test_conflict_error_if_type_does_not_match_endpoint()
{ {
$this->markTestIncomplete(); $this->expectException(ConflictException::class);
$this->api->handle(
$this->buildRequest('POST', '/users')
->withParsedBody([
'data' => [
'type' => 'pets',
'id' => '1',
],
])
);
} }
} }