diff --git a/src/Endpoint/Concerns/SavesData.php b/src/Endpoint/Concerns/SavesData.php index 61e3668..7377ce6 100644 --- a/src/Endpoint/Concerns/SavesData.php +++ b/src/Endpoint/Concerns/SavesData.php @@ -13,6 +13,8 @@ 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; @@ -43,16 +45,22 @@ trait SavesData throw new BadRequestException('data must be an object'); } - if (! isset($body['data']['type']) || $body['data']['type'] !== $resourceType->getType()) { - throw new BadRequestException('data.type does not match the resource type'); + 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 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'])) { @@ -156,7 +164,11 @@ trait SavesData $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(); if ($field instanceof HasOne) { diff --git a/src/Endpoint/Create.php b/src/Endpoint/Create.php index a6465ef..0ffcf43 100644 --- a/src/Endpoint/Create.php +++ b/src/Endpoint/Create.php @@ -56,7 +56,8 @@ class Create return (new Show()) ->handle($context, $resourceType, $model) - ->withStatus(201); + ->withStatus(201) + ->withHeader('Location', $resourceType->url($model, $context)); } private function newModel(ResourceType $resourceType, Context $context) diff --git a/src/Exception/ConflictException.php b/src/Exception/ConflictException.php new file mode 100644 index 0000000..550c817 --- /dev/null +++ b/src/Exception/ConflictException.php @@ -0,0 +1,35 @@ + + * + * 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'; + } +} diff --git a/src/ResourceType.php b/src/ResourceType.php index f0ead32..aebdaa8 100644 --- a/src/ResourceType.php +++ b/src/ResourceType.php @@ -54,6 +54,13 @@ final class ResourceType 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. */ diff --git a/src/Serializer.php b/src/Serializer.php index 8b120cc..cded48b 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -80,7 +80,7 @@ final class Serializer 'id' => $id, 'fields' => [], '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) ]; @@ -107,11 +107,6 @@ final class Serializer return $type.':'.$id; } - private function resourceUrl(string $type, string $id): string - { - return $this->context->getApi()->getBasePath()."/$type/$id"; - } - /** * @return Structure\Internal\RelationshipMember[] */ diff --git a/tests/specification/CreatingResourcesTest.php b/tests/specification/CreatingResourcesTest.php index 05294e1..66aff5b 100644 --- a/tests/specification/CreatingResourcesTest.php +++ b/tests/specification/CreatingResourcesTest.php @@ -11,12 +11,17 @@ 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\Schema\Type; use Tobyz\Tests\JsonApiServer\AbstractTestCase; 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 { @@ -25,55 +30,117 @@ class CreatingResourcesTest extends AbstractTestCase */ private $api; - /** - * @var MockAdapter - */ - private $adapter; - public function setUp(): void { $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() { - $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() { - $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() { - $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->markTestIncomplete(); - } + $this->assertEquals(201, $response->getStatusCode()); + $this->assertEquals('http://example.com/users/1', $response->getHeaderLine('location')); - public function test_created_response_includes_location_header_and_matches_self_link() - { - $this->markTestIncomplete(); + $this->assertJsonApiDocumentSubset([ + 'data' => [ + '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() { - $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() { - $this->markTestIncomplete(); + $this->expectException(ConflictException::class); + + $this->api->handle( + $this->buildRequest('POST', '/users') + ->withParsedBody([ + 'data' => [ + 'type' => 'pets', + 'id' => '1', + ], + ]) + ); } }