Add jsonapi object, more spec tests

This commit is contained in:
Toby Zerner 2021-09-01 17:43:23 +10:00
parent d678a2ed9e
commit c1dc91c558
11 changed files with 284 additions and 123 deletions

View File

@ -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');
}
}

View File

@ -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');
}

View File

@ -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)
);

View File

@ -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 : '');

View File

@ -43,6 +43,7 @@ class Show
new CompoundDocument(
$primary[0],
new Included(...$included),
$this->buildJsonApiObject($context),
...$this->buildMeta($context)
)
);

View File

@ -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

View File

@ -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')
);
}
}

View File

@ -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());
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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',
],
])
);
}
}