From c1dc91c558d41c44a13a8ce72c8acb59e5af5ad2 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 1 Sep 2021 17:43:23 +1000 Subject: [PATCH] Add jsonapi object, more spec tests --- src/Endpoint/Concerns/BuildsMeta.php | 6 + src/Endpoint/Concerns/SavesData.php | 14 +- src/Endpoint/Delete.php | 2 + src/Endpoint/Index.php | 3 + src/Endpoint/Show.php | 1 + tests/MockAdapter.php | 10 +- tests/specification/FetchingResourcesTest.php | 73 ++++++++--- ...{JsonApiTest.php => JsonApiObjectTest.php} | 23 ++-- tests/specification/OffsetPaginationTest.php | 86 ++++++++----- tests/specification/SparseFieldsetsTest.php | 69 ++++++---- tests/specification/UpdatingResourcesTest.php | 120 ++++++++++++++---- 11 files changed, 284 insertions(+), 123 deletions(-) rename tests/specification/{JsonApiTest.php => JsonApiObjectTest.php} (59%) diff --git a/src/Endpoint/Concerns/BuildsMeta.php b/src/Endpoint/Concerns/BuildsMeta.php index 769ecfe..46bd66d 100644 --- a/src/Endpoint/Concerns/BuildsMeta.php +++ b/src/Endpoint/Concerns/BuildsMeta.php @@ -11,6 +11,7 @@ namespace Tobyz\JsonApiServer\Endpoint\Concerns; +use JsonApiPhp\JsonApi\JsonApi; use JsonApiPhp\JsonApi\Meta; use Tobyz\JsonApiServer\Context; @@ -26,4 +27,9 @@ trait BuildsMeta return $meta; } + + private function buildJsonApiObject(Context $context): JsonApi + { + return new JsonApi('1.1'); + } } diff --git a/src/Endpoint/Concerns/SavesData.php b/src/Endpoint/Concerns/SavesData.php index 7377ce6..4310799 100644 --- a/src/Endpoint/Concerns/SavesData.php +++ b/src/Endpoint/Concerns/SavesData.php @@ -49,20 +49,22 @@ trait SavesData 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'])) { + throw new BadRequestException('data.id must be present'); + } - if (! isset($body['data']['id']) || $body['data']['id'] !== $id) { + if ($body['data']['id'] !== $resourceType->getAdapter()->getId($model)) { 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 ($body['data']['type'] !== $resourceType->getType()) { + throw new ConflictException('data.type does not match the resource type'); + } + if (isset($body['data']['attributes']) && ! is_array($body['data']['attributes'])) { throw new BadRequestException('data.attributes must be an object'); } diff --git a/src/Endpoint/Delete.php b/src/Endpoint/Delete.php index c07223a..96d2af1 100644 --- a/src/Endpoint/Delete.php +++ b/src/Endpoint/Delete.php @@ -54,6 +54,8 @@ class Delete run_callbacks($schema->getListeners('deleted'), [&$model, $context]); if (count($meta = $this->buildMeta($context))) { + $meta[] = $this->buildJsonApiObject($context); + return json_api_response( new MetaDocument(...$meta) ); diff --git a/src/Endpoint/Index.php b/src/Endpoint/Index.php index 0fab39a..bd7c210 100644 --- a/src/Endpoint/Index.php +++ b/src/Endpoint/Index.php @@ -112,6 +112,7 @@ class Index ), new Structure\Included(...$included), new Structure\Link\SelfLink($this->buildUrl($context->getRequest())), + $this->buildJsonApiObject($context), ...$meta ) ); @@ -133,6 +134,8 @@ class Index } } + ksort($queryParams); + $queryString = http_build_query($queryParams, '', '&', PHP_QUERY_RFC3986); return $selfUrl.($queryString ? '?'.$queryString : ''); diff --git a/src/Endpoint/Show.php b/src/Endpoint/Show.php index c167e37..a3f6203 100644 --- a/src/Endpoint/Show.php +++ b/src/Endpoint/Show.php @@ -43,6 +43,7 @@ class Show new CompoundDocument( $primary[0], new Included(...$included), + $this->buildJsonApiObject($context), ...$this->buildMeta($context) ) ); diff --git a/tests/MockAdapter.php b/tests/MockAdapter.php index 00bf4c0..bf1af1f 100644 --- a/tests/MockAdapter.php +++ b/tests/MockAdapter.php @@ -45,7 +45,13 @@ class MockAdapter implements AdapterInterface public function get($query): array { - return array_values($this->models); + $results = array_values($this->models); + + if (isset($query->paginate)) { + $results = array_slice($results, $query->paginate['offset'], $query->paginate['limit']); + } + + return $results; } public function getId($model): string @@ -124,7 +130,7 @@ class MockAdapter implements AdapterInterface public function paginate($query, int $limit, int $offset): void { - $query->paginate[] = [$limit, $offset]; + $query->paginate = compact('limit', 'offset'); } public function load(array $models, array $relationships, $scope, bool $linkage): void diff --git a/tests/specification/FetchingResourcesTest.php b/tests/specification/FetchingResourcesTest.php index 314b76e..cd11281 100644 --- a/tests/specification/FetchingResourcesTest.php +++ b/tests/specification/FetchingResourcesTest.php @@ -11,12 +11,13 @@ namespace Tobyz\Tests\JsonApiServer\specification; +use Tobyz\JsonApiServer\Exception\ResourceNotFoundException; use Tobyz\JsonApiServer\JsonApi; use Tobyz\Tests\JsonApiServer\AbstractTestCase; use Tobyz\Tests\JsonApiServer\MockAdapter; /** - * @see https://jsonapi.org/format/#fetching-resources + * @see https://jsonapi.org/format/1.1/#fetching-resources */ class FetchingResourcesTest extends AbstractTestCase { @@ -25,50 +26,80 @@ class FetchingResourcesTest extends AbstractTestCase */ private $api; - /** - * @var MockAdapter - */ - private $adapter; - public function setUp(): void { $this->api = new JsonApi('http://example.com'); - - $this->adapter = new MockAdapter(); } public function test_data_for_resource_collection_is_array_of_resource_objects() { - $this->markTestIncomplete(); + $adapter = new MockAdapter([ + (object) ['id' => '1'], + (object) ['id' => '2'], + ]); + + $this->api->resourceType('articles', $adapter); + + $response = $this->api->handle( + $this->buildRequest('GET', '/articles') + ); + + $this->assertJsonApiDocumentSubset([ + 'data' => [ + ['type' => 'articles', 'id' => '1'], + ['type' => 'articles', 'id' => '2'], + ] + ], $response->getBody()); } public function test_data_for_empty_resource_collection_is_empty_array() { - $this->markTestIncomplete(); + $this->api->resourceType('articles', new MockAdapter()); + + $response = $this->api->handle( + $this->buildRequest('GET', '/articles') + ); + + $data = json_decode($response->getBody(), true)['data'] ?? null; + + $this->assertIsArray($data); + $this->assertEmpty($data); } public function test_data_for_individual_resource_is_resource_object() { - $this->markTestIncomplete(); + $adapter = new MockAdapter([ + (object) ['id' => '1'], + ]); + + $this->api->resourceType('articles', $adapter); + + $response = $this->api->handle( + $this->buildRequest('GET', '/articles/1') + ); + + $this->assertJsonApiDocumentSubset([ + 'data' => ['type' => 'articles', 'id' => '1'], + ], $response->getBody()); } public function test_not_found_error_if_resource_type_does_not_exist() { - $this->markTestIncomplete(); + $this->expectException(ResourceNotFoundException::class); + + $this->api->handle( + $this->buildRequest('GET', '/articles/1') + ); } public function test_not_found_error_if_resource_does_not_exist() { - $this->markTestIncomplete(); - } + $this->expectException(ResourceNotFoundException::class); - public function test_resource_collection_document_contains_self_link() - { - $this->markTestIncomplete(); - } + $this->api->resourceType('articles', new MockAdapter()); - public function test_resource_document_contains_self_link() - { - $this->markTestIncomplete(); + $this->api->handle( + $this->buildRequest('GET', '/articles/404') + ); } } diff --git a/tests/specification/JsonApiTest.php b/tests/specification/JsonApiObjectTest.php similarity index 59% rename from tests/specification/JsonApiTest.php rename to tests/specification/JsonApiObjectTest.php index 1cdbf2c..95b908e 100644 --- a/tests/specification/JsonApiTest.php +++ b/tests/specification/JsonApiObjectTest.php @@ -16,29 +16,32 @@ use Tobyz\Tests\JsonApiServer\AbstractTestCase; use Tobyz\Tests\JsonApiServer\MockAdapter; /** - * @see https://jsonapi.org/format/#document-jsonapi-object + * @see https://jsonapi.org/format/1.1/#document-jsonapi-object */ -class JsonApiTest extends AbstractTestCase +class JsonApiObjectTest extends AbstractTestCase { /** * @var JsonApi */ private $api; - /** - * @var MockAdapter - */ - private $adapter; - public function setUp(): void { $this->api = new JsonApi('http://example.com'); - $this->adapter = new MockAdapter(); + $this->api->resourceType('articles', new MockAdapter()); } - public function test_document_includes_jsonapi_member_with_version_1_0() + public function test_document_includes_jsonapi_member_with_version_1_1() { - $this->markTestIncomplete(); + $response = $this->api->handle( + $this->buildRequest('GET', '/articles') + ); + + $this->assertJsonApiDocumentSubset([ + 'jsonapi' => [ + 'version' => '1.1', + ], + ], $response->getBody()); } } diff --git a/tests/specification/OffsetPaginationTest.php b/tests/specification/OffsetPaginationTest.php index d26bc96..7a0c751 100644 --- a/tests/specification/OffsetPaginationTest.php +++ b/tests/specification/OffsetPaginationTest.php @@ -12,12 +12,12 @@ namespace Tobyz\Tests\JsonApiServer\specification; 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/#fetching-pagination - * @todo Create a profile for offset pagination strategy + * @see https://jsonapi.org/format/1.1/#fetching-pagination */ class OffsetPaginationTest extends AbstractTestCase { @@ -26,60 +26,84 @@ class OffsetPaginationTest extends AbstractTestCase */ private $api; - /** - * @var MockAdapter - */ - private $adapter; - public function setUp(): void { $this->api = new JsonApi('http://example.com'); - $this->adapter = new MockAdapter(); + $adapter = new MockAdapter( + array_map(function ($i) { + return (object) ['id' => (string) $i]; + }, range(1, 100)) + ); + + $this->api->resourceType('articles', $adapter, function (Type $type) { + $type->paginate(20); + }); } public function test_can_request_limit_on_resource_collection() { - $this->markTestIncomplete(); + $response = $this->api->handle( + $this->buildRequest('GET', '/articles') + ->withQueryParams(['page' => ['limit' => '10']]) + ); + + $data = json_decode($response->getBody(), true)['data'] ?? null; + + $this->assertCount(10, $data); } public function test_can_request_offset_on_resource_collection() { - $this->markTestIncomplete(); + $response = $this->api->handle( + $this->buildRequest('GET', '/articles') + ->withQueryParams(['page' => ['offset' => '5']]) + ); + + $data = json_decode($response->getBody(), true)['data'] ?? null; + + $this->assertEquals('6', $data[0]['id'] ?? null); } - public function test_first_pagination_link_is_correct() + public function test_pagination_links_are_correct_and_retain_query_parameters() { - $this->markTestIncomplete(); - } + $response = $this->api->handle( + $this->buildRequest('GET', '/articles') + ->withQueryParams([ + 'page' => ['offset' => '40'], + 'otherParam' => 'value', + ]) + ); - public function test_last_pagination_link_is_correct() - { - $this->markTestIncomplete(); - } + $links = json_decode($response->getBody(), true)['links'] ?? null; - public function test_next_pagination_link_is_correct() - { - $this->markTestIncomplete(); + $this->assertEquals('/articles?otherParam=value', $links['first'] ?? null); + $this->assertEquals('/articles?otherParam=value&page%5Boffset%5D=80', $links['last'] ?? null); + $this->assertEquals('/articles?otherParam=value&page%5Boffset%5D=60', $links['next'] ?? null); + $this->assertEquals('/articles?otherParam=value&page%5Boffset%5D=20', $links['prev'] ?? null); } public function test_next_pagination_link_is_not_included_on_last_page() { - $this->markTestIncomplete(); + $response = $this->api->handle( + $this->buildRequest('GET', '/articles') + ->withQueryParams(['page' => ['offset' => '80']]) + ); + + $links = json_decode($response->getBody(), true)['links'] ?? null; + + $this->assertNull($links['next'] ?? null); } - public function test_prev_pagination_link_is_correct() + public function test_prev_pagination_link_is_not_included_on_first_page() { - $this->markTestIncomplete(); - } + $response = $this->api->handle( + $this->buildRequest('GET', '/articles') + ->withQueryParams(['page' => ['offset' => '0']]) + ); - public function test_prev_pagination_link_is_not_included_on_last_page() - { - $this->markTestIncomplete(); - } + $links = json_decode($response->getBody(), true)['links'] ?? null; - public function test_pagination_links_retain_other_query_parameters() - { - $this->markTestIncomplete(); + $this->assertNull($links['prev'] ?? null); } } diff --git a/tests/specification/SparseFieldsetsTest.php b/tests/specification/SparseFieldsetsTest.php index 8ebd22b..a6a06dc 100644 --- a/tests/specification/SparseFieldsetsTest.php +++ b/tests/specification/SparseFieldsetsTest.php @@ -12,11 +12,12 @@ namespace Tobyz\Tests\JsonApiServer\specification; 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/#fetching-sparse-fieldsets + * @see https://jsonapi.org/format/1.1/#fetching-sparse-fieldsets */ class SparseFieldsetsTest extends AbstractTestCase { @@ -25,40 +26,56 @@ class SparseFieldsetsTest extends AbstractTestCase */ private $api; - /** - * @var MockAdapter - */ - private $adapter; - public function setUp(): void { $this->api = new JsonApi('http://example.com'); - $this->adapter = new MockAdapter(); + $articlesAdapter = new MockAdapter([ + '1' => (object) [ + 'id' => '1', + 'title' => 'foo', + 'body' => 'bar', + 'user' => (object) [ + 'id' => '1', + 'firstName' => 'Toby', + 'lastName' => 'Zerner', + ], + ], + ]); + + $this->api->resourceType('articles', $articlesAdapter, function (Type $type) { + $type->attribute('title'); + $type->attribute('body'); + $type->hasOne('user')->includable(); + }); + + $this->api->resourceType('users', new MockAdapter(), function (Type $type) { + $type->attribute('firstName'); + $type->attribute('lastName'); + }); } - public function test_can_request_sparse_fieldsets_for_a_type() + public function test_can_request_sparse_fieldsets() { - $this->markTestIncomplete(); - } + $request = $this->api->handle( + $this->buildRequest('GET', '/articles/1') + ->withQueryParams([ + 'include' => 'user', + 'fields' => [ + 'articles' => 'title,user', + 'users' => 'firstName', + ], + ]) + ); - public function test_can_request_sparse_fieldsets_for_multiple_types() - { - $this->markTestIncomplete(); - } + $document = json_decode($request->getBody(), true); - public function test_can_request_sparse_fieldsets_on_resource_collections() - { - $this->markTestIncomplete(); - } + $article = $document['data']['attributes'] ?? []; + $user = $document['included'][0]['attributes'] ?? []; - public function test_can_request_sparse_fieldsets_on_create() - { - $this->markTestIncomplete(); - } - - public function test_can_request_sparse_fieldsets_on_update() - { - $this->markTestIncomplete(); + $this->assertArrayHasKey('title', $article); + $this->assertArrayNotHasKey('body', $article); + $this->assertArrayHasKey('firstName', $user); + $this->assertArrayNotHasKey('lastName', $user); } } diff --git a/tests/specification/UpdatingResourcesTest.php b/tests/specification/UpdatingResourcesTest.php index 68daff2..cd8bf25 100644 --- a/tests/specification/UpdatingResourcesTest.php +++ b/tests/specification/UpdatingResourcesTest.php @@ -11,12 +11,16 @@ namespace Tobyz\Tests\JsonApiServer\specification; +use Tobyz\JsonApiServer\Exception\BadRequestException; +use Tobyz\JsonApiServer\Exception\ConflictException; +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-updating + * @see https://jsonapi.org/format/1.1/#crud-updating */ class UpdatingResourcesTest extends AbstractTestCase { @@ -25,60 +29,122 @@ class UpdatingResourcesTest extends AbstractTestCase */ private $api; - /** - * @var MockAdapter - */ - private $adapter; - public function setUp(): void { $this->api = new JsonApi('http://example.com'); - $this->adapter = new MockAdapter(); + $adapter = new MockAdapter([ + '1' => (object) ['id' => '1', 'name' => 'initial'], + ]); + + $this->api->resourceType('users', $adapter, function (Type $type) { + $type->updatable(); + $type->attribute('name')->writable(); + $type->hasOne('pet')->writable(); + }); } public function test_bad_request_error_if_body_does_not_contain_data_type_and_id() { - $this->markTestIncomplete(); - } + $this->expectException(BadRequestException::class); - public function test_only_included_attributes_are_processed() - { - $this->markTestIncomplete(); - } - - public function test_only_included_relationships_are_processed() - { - $this->markTestIncomplete(); + $this->api->handle( + $this->buildRequest('PATCH', '/users/1') + ->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('PATCH', '/users/1') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'relationships' => [ + 'pet' => [], + ], + ], + ]) + ); } - public function test_ok_response_if_resource_successfully_updated() + public function test_ok_response_with_updated_data_if_resource_successfully_updated() { - $this->markTestIncomplete(); - } + $response = $this->api->handle( + $this->buildRequest('PATCH', '/users/1') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'name' => 'updated' + ], + ], + ]) + ); - public function test_ok_response_includes_updated_data() - { - $this->markTestIncomplete(); + $document = json_decode($response->getBody(), true); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('updated', $document['data']['attributes']['name'] ?? null); } public function test_not_found_error_if_resource_does_not_exist() { - $this->markTestIncomplete(); + $this->expectException(ResourceNotFoundException::class); + + $this->api->handle( + $this->buildRequest('PATCH', '/users/404') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '404', + 'attributes' => [ + 'name' => 'bob', + ], + ], + ]) + ); } public function test_not_found_error_if_references_resource_that_does_not_exist() { - $this->markTestIncomplete(); + $this->expectException(ResourceNotFoundException::class); + + $this->api->handle( + $this->buildRequest('PATCH', '/users/1') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'relationships' => [ + 'pet' => [ + 'data' => ['type' => 'pets', 'id' => '1'], + ], + ], + ], + ]) + ); } public function test_conflict_error_if_type_and_id_does_not_match_endpoint() { - $this->markTestIncomplete(); + $this->expectException(ConflictException::class); + + $this->api->handle( + $this->buildRequest('PATCH', '/users/1') + ->withParsedBody([ + 'data' => [ + 'type' => 'pets', + 'id' => '1', + ], + ]) + ); } }