Skip to content

Commit d015070

Browse files
committed
Improve spec compliance for resource creation; add ResourceType::url()
1 parent 7405e07 commit d015070

File tree

6 files changed

+150
-33
lines changed

6 files changed

+150
-33
lines changed

src/Endpoint/Concerns/SavesData.php

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
use Tobyz\JsonApiServer\Context;
1515
use Tobyz\JsonApiServer\Exception\BadRequestException;
16+
use Tobyz\JsonApiServer\Exception\ConflictException;
17+
use Tobyz\JsonApiServer\Exception\ForbiddenException;
1618
use Tobyz\JsonApiServer\Exception\UnprocessableEntityException;
1719
use Tobyz\JsonApiServer\ResourceType;
1820
use Tobyz\JsonApiServer\Schema\Attribute;
@@ -43,16 +45,22 @@ private function parseData(ResourceType $resourceType, $body, $model = null): ar
4345
throw new BadRequestException('data must be an object');
4446
}
4547

46-
if (! isset($body['data']['type']) || $body['data']['type'] !== $resourceType->getType()) {
47-
throw new BadRequestException('data.type does not match the resource type');
48+
if (! isset($body['data']['type'])) {
49+
throw new BadRequestException('data.type must be present');
50+
}
51+
52+
if ($body['data']['type'] !== $resourceType->getType()) {
53+
throw new ConflictException('data.type does not match the resource type');
4854
}
4955

5056
if ($model) {
5157
$id = $resourceType->getAdapter()->getId($model);
5258

5359
if (! isset($body['data']['id']) || $body['data']['id'] !== $id) {
54-
throw new BadRequestException('data.id does not match the resource ID');
60+
throw new ConflictException('data.id does not match the resource ID');
5561
}
62+
} elseif (isset($body['data']['id'])) {
63+
throw new ForbiddenException('Client-generated IDs are not supported');
5664
}
5765

5866
if (isset($body['data']['attributes']) && ! is_array($body['data']['attributes'])) {
@@ -156,7 +164,11 @@ private function loadRelatedResources(ResourceType $resourceType, array &$data,
156164

157165
$value = get_value($data, $field);
158166

159-
if (isset($value['data'])) {
167+
if (! array_key_exists('data', $value)) {
168+
throw new BadRequestException('relationship does not include data key');
169+
}
170+
171+
if ($value['data'] !== null) {
160172
$allowedTypes = (array) $field->getType();
161173

162174
if ($field instanceof HasOne) {

src/Endpoint/Create.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ public function handle(Context $context, ResourceType $resourceType): ResponseIn
5656

5757
return (new Show())
5858
->handle($context, $resourceType, $model)
59-
->withStatus(201);
59+
->withStatus(201)
60+
->withHeader('Location', $resourceType->url($model, $context));
6061
}
6162

6263
private function newModel(ResourceType $resourceType, Context $context)

src/Exception/ConflictException.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
/*
4+
* This file is part of tobyz/json-api-server.
5+
*
6+
* (c) Toby Zerner <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Tobyz\JsonApiServer\Exception;
13+
14+
use DomainException;
15+
use JsonApiPhp\JsonApi\Error;
16+
use Tobyz\JsonApiServer\ErrorProviderInterface;
17+
18+
class ConflictException extends DomainException implements ErrorProviderInterface
19+
{
20+
public function getJsonApiErrors(): array
21+
{
22+
return [
23+
new Error(
24+
new Error\Title('Conflict'),
25+
new Error\Status($this->getJsonApiStatus()),
26+
...($this->message ? [new Error\Detail($this->message)] : [])
27+
)
28+
];
29+
}
30+
31+
public function getJsonApiStatus(): string
32+
{
33+
return '409';
34+
}
35+
}

src/ResourceType.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ public function getSchema(): Type
5454
return $this->schema;
5555
}
5656

57+
public function url($model, Context $context): string
58+
{
59+
$id = $this->adapter->getId($model);
60+
61+
return $context->getApi()->getBasePath()."/$this->type/$id";
62+
}
63+
5764
/**
5865
* Apply the resource type's scopes to a query.
5966
*/

src/Serializer.php

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ private function addToMap(ResourceType $resourceType, $model, array $include): a
8080
'id' => $id,
8181
'fields' => [],
8282
'links' => [
83-
'self' => new Structure\Link\SelfLink($url = $this->resourceUrl($type, $id)),
83+
'self' => new Structure\Link\SelfLink($url = $resourceType->url($model, $this->context)),
8484
],
8585
'meta' => $this->meta($schema->getMeta(), $model)
8686
];
@@ -107,11 +107,6 @@ private function key(string $type, string $id): string
107107
return $type.':'.$id;
108108
}
109109

110-
private function resourceUrl(string $type, string $id): string
111-
{
112-
return $this->context->getApi()->getBasePath()."/$type/$id";
113-
}
114-
115110
/**
116111
* @return Structure\Internal\RelationshipMember[]
117112
*/

tests/specification/CreatingResourcesTest.php

Lines changed: 89 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,17 @@
1111

1212
namespace Tobyz\Tests\JsonApiServer\specification;
1313

14+
use Tobyz\JsonApiServer\Exception\BadRequestException;
15+
use Tobyz\JsonApiServer\Exception\ConflictException;
16+
use Tobyz\JsonApiServer\Exception\ForbiddenException;
17+
use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
1418
use Tobyz\JsonApiServer\JsonApi;
19+
use Tobyz\JsonApiServer\Schema\Type;
1520
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
1621
use Tobyz\Tests\JsonApiServer\MockAdapter;
1722

1823
/**
19-
* @see https://jsonapi.org/format/1.0/#crud-creating
24+
* @see https://jsonapi.org/format/1.1/#crud-creating
2025
*/
2126
class CreatingResourcesTest extends AbstractTestCase
2227
{
@@ -25,55 +30,117 @@ class CreatingResourcesTest extends AbstractTestCase
2530
*/
2631
private $api;
2732

28-
/**
29-
* @var MockAdapter
30-
*/
31-
private $adapter;
32-
3333
public function setUp(): void
3434
{
3535
$this->api = new JsonApi('http://example.com');
3636

37-
$this->adapter = new MockAdapter();
37+
$this->api->resourceType('users', new MockAdapter(), function (Type $type) {
38+
$type->creatable();
39+
$type->attribute('name')->writable();
40+
$type->hasOne('pet')->writable();
41+
});
3842
}
3943

4044
public function test_bad_request_error_if_body_does_not_contain_data_type()
4145
{
42-
$this->markTestIncomplete();
46+
$this->expectException(BadRequestException::class);
47+
48+
$this->api->handle(
49+
$this->buildRequest('POST', '/users')
50+
->withParsedBody([
51+
'data' => [],
52+
])
53+
);
4354
}
4455

4556
public function test_bad_request_error_if_relationship_does_not_contain_data()
4657
{
47-
$this->markTestIncomplete();
58+
$this->expectException(BadRequestException::class);
59+
60+
$this->api->handle(
61+
$this->buildRequest('POST', '/users')
62+
->withParsedBody([
63+
'data' => [
64+
'type' => 'users',
65+
'relationships' => [
66+
'pet' => [],
67+
],
68+
],
69+
])
70+
);
4871
}
4972

5073
public function test_forbidden_error_if_client_generated_id_provided()
5174
{
52-
$this->markTestIncomplete();
53-
}
75+
$this->expectException(ForbiddenException::class);
5476

55-
public function test_created_response_if_resource_successfully_created()
56-
{
57-
$this->markTestIncomplete();
77+
$this->api->handle(
78+
$this->buildRequest('POST', '/users')
79+
->withParsedBody([
80+
'data' => [
81+
'type' => 'users',
82+
'id' => '1',
83+
],
84+
])
85+
);
5886
}
5987

60-
public function test_created_response_includes_created_data()
88+
public function test_created_response_includes_created_data_and_location_header()
6189
{
62-
$this->markTestIncomplete();
63-
}
90+
$response = $this->api->handle(
91+
$this->buildRequest('POST', '/users')
92+
->withParsedBody([
93+
'data' => [
94+
'type' => 'users',
95+
],
96+
])
97+
);
6498

65-
public function test_created_response_includes_location_header_and_matches_self_link()
66-
{
67-
$this->markTestIncomplete();
99+
$this->assertEquals(201, $response->getStatusCode());
100+
$this->assertEquals('http://example.com/users/1', $response->getHeaderLine('location'));
101+
102+
$this->assertJsonApiDocumentSubset([
103+
'data' => [
104+
'type' => 'users',
105+
'id' => '1',
106+
'links' => [
107+
'self' => 'http://example.com/users/1',
108+
],
109+
],
110+
], $response->getBody());
68111
}
69112

70113
public function test_not_found_error_if_references_resource_that_does_not_exist()
71114
{
72-
$this->markTestIncomplete();
115+
$this->expectException(ResourceNotFoundException::class);
116+
117+
$this->api->handle(
118+
$this->buildRequest('POST', '/users')
119+
->withParsedBody([
120+
'data' => [
121+
'type' => 'users',
122+
'relationships' => [
123+
'pet' => [
124+
'data' => ['type' => 'pets', 'id' => '1'],
125+
],
126+
],
127+
],
128+
])
129+
);
73130
}
74131

75132
public function test_conflict_error_if_type_does_not_match_endpoint()
76133
{
77-
$this->markTestIncomplete();
134+
$this->expectException(ConflictException::class);
135+
136+
$this->api->handle(
137+
$this->buildRequest('POST', '/users')
138+
->withParsedBody([
139+
'data' => [
140+
'type' => 'pets',
141+
'id' => '1',
142+
],
143+
])
144+
);
78145
}
79146
}

0 commit comments

Comments
 (0)