Skip to content

Commit 61dc34a

Browse files
committed
Full support for profiles
1 parent 216e067 commit 61dc34a

File tree

6 files changed

+226
-46
lines changed

6 files changed

+226
-46
lines changed

CHANGELOG.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,13 @@ and this project adheres to
3030

3131
### Added
3232

33-
- Add support for JSON:API profile URIs in `Content-Type` headers via
34-
`Context::activateProfile(string $uri)` method
33+
- Add full support for JSON:API profiles:
34+
- Parse profile URIs from `Accept` header
35+
- `Context::profileRequested(string $uri): bool` - check if a profile was
36+
requested
37+
- `Context::requestedProfiles(): array` - get all requested profile URIs
38+
- `Context::activateProfile(string $uri)` - activate a profile for the
39+
response `Content-Type` header
3540
- Cursor pagination automatically activates the
3641
`https://jsonapi.org/profiles/ethanresnick/cursor-pagination` profile
3742
- Add support for asynchronous processing following the

docs/context.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,15 @@ class Context
8383
// Determine whether a sort field has been requested
8484
public function sortRequested(string $field): bool;
8585

86+
// Determine whether a profile has been requested in the Accept header
87+
public function profileRequested(string $uri): bool;
88+
89+
// Get all requested profile URIs from the Accept header
90+
public function requestedProfiles(): array;
91+
92+
// Get all requested extension URIs from the Accept header
93+
public function requestedExtensions(): array;
94+
8695
// Activate a JSON:API profile for the current response
8796
public function activateProfile(string $uri): static;
8897
}

docs/profiles.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Profiles
2+
3+
[Profiles](https://jsonapi.org/format/1.1/#profiles) allow clients and servers
4+
to communicate about additional semantics or constraints applied to a JSON:API
5+
implementation. Unlike extensions, profiles can be safely ignored by the server
6+
if they are not recognized.
7+
8+
## Requested Profiles
9+
10+
When a client includes a profile in their `Accept` header, you can check if it
11+
was requested using the `profileRequested` method on the context:
12+
13+
```php
14+
use Tobyz\JsonApiServer\Context;
15+
16+
if ($context->profileRequested('https://example.com/my-profile')) {
17+
// Client has requested this profile
18+
// Optionally activate profile-specific behavior
19+
}
20+
```
21+
22+
You can also get all requested profile URIs as an array:
23+
24+
```php
25+
$profiles = $context->requestedProfiles();
26+
// ['https://example.com/profile1', 'https://example.com/profile2']
27+
```
28+
29+
## Activating Profiles
30+
31+
When you implement profile-specific behavior, you should activate the profile so
32+
it appears in the response `Content-Type` header:
33+
34+
```php
35+
if ($context->profileRequested('https://example.com/my-profile')) {
36+
$context->activateProfile('https://example.com/my-profile');
37+
38+
// Add profile-specific data or behavior
39+
}
40+
```
41+
42+
The activated profile URIs will automatically be included in the response
43+
`Content-Type` header:
44+
45+
```
46+
Content-Type: application/vnd.api+json; profile="https://example.com/my-profile"
47+
```

src/Context.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
namespace Tobyz\JsonApiServer;
44

55
use ArrayObject;
6+
use HttpAccept\AcceptParser;
67
use Psr\Http\Message\ServerRequestInterface;
78
use RuntimeException;
89
use Tobyz\JsonApiServer\Endpoint\Endpoint;
10+
use Tobyz\JsonApiServer\Exception\NotAcceptableException;
911
use Tobyz\JsonApiServer\Exception\Request\InvalidSparseFieldsetsException;
1012
use Tobyz\JsonApiServer\Resource\Collection;
1113
use Tobyz\JsonApiServer\Resource\Resource;
@@ -29,6 +31,8 @@ class Context
2931

3032
private ?array $body;
3133
private ?string $path;
34+
private ?array $requestedExtensions = null;
35+
private ?array $requestedProfiles = null;
3236

3337
private WeakMap $endpoints;
3438
private WeakMap $resourceIds;
@@ -38,6 +42,8 @@ class Context
3842

3943
public function __construct(public JsonApi $api, public ServerRequestInterface $request)
4044
{
45+
$this->parseAcceptHeader();
46+
4147
$this->endpoints = new WeakMap();
4248
$this->resourceIds = new WeakMap();
4349
$this->modelIds = new WeakMap();
@@ -211,13 +217,93 @@ public function sortRequested(string $field): bool
211217
return false;
212218
}
213219

220+
/**
221+
* Determine whether a profile has been requested.
222+
*/
223+
public function profileRequested(string $uri): bool
224+
{
225+
return in_array($uri, $this->requestedProfiles());
226+
}
227+
228+
/**
229+
* Get all requested profile URIs.
230+
*
231+
* @return array
232+
*/
233+
public function requestedProfiles(): array
234+
{
235+
if ($this->requestedProfiles === null) {
236+
$this->parseAcceptHeader();
237+
}
238+
239+
return $this->requestedProfiles;
240+
}
241+
242+
/**
243+
* Get all requested extension URIs from Accept header.
244+
*
245+
* @return array
246+
*/
247+
public function requestedExtensions(): array
248+
{
249+
if ($this->requestedExtensions === null) {
250+
$this->parseAcceptHeader();
251+
}
252+
253+
return $this->requestedExtensions;
254+
}
255+
256+
private function parseAcceptHeader(): void
257+
{
258+
$accept = $this->request->getHeaderLine('Accept');
259+
260+
if (!$accept) {
261+
$this->requestedProfiles = [];
262+
$this->requestedExtensions = [];
263+
return;
264+
}
265+
266+
$list = (new AcceptParser())->parse($accept);
267+
268+
foreach ($list as $mediaType) {
269+
if (!in_array($mediaType->name(), [$this->api::MEDIA_TYPE, '*/*'])) {
270+
continue;
271+
}
272+
273+
if (array_diff(array_keys($mediaType->parameters()), ['ext', 'profile'])) {
274+
continue;
275+
}
276+
277+
$extensionUris = $mediaType->hasParamater('ext')
278+
? explode(' ', $mediaType->getParameter('ext'))
279+
: [];
280+
281+
if (array_diff($extensionUris, array_keys($this->api->extensions))) {
282+
continue;
283+
}
284+
285+
$profileUris = $mediaType->hasParamater('profile')
286+
? explode(' ', $mediaType->getParameter('profile'))
287+
: [];
288+
289+
$this->requestedProfiles = $profileUris;
290+
$this->requestedExtensions = $extensionUris;
291+
return;
292+
}
293+
294+
throw new NotAcceptableException();
295+
}
296+
214297
public function withRequest(ServerRequestInterface $request): static
215298
{
216299
$new = clone $this;
217300
$new->request = $request;
218301
$new->sparseFields = new WeakMap();
219302
$new->body = null;
220303
$new->path = null;
304+
$new->requestedProfiles = null;
305+
$new->requestedExtensions = null;
306+
$new->parseAcceptHeader();
221307
return $new;
222308
}
223309

src/JsonApi.php

Lines changed: 4 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
namespace Tobyz\JsonApiServer;
44

5-
use HttpAccept\AcceptParser;
65
use HttpAccept\ContentTypeParser;
76
use InvalidArgumentException;
87
use Psr\Http\Message\ResponseInterface as Response;
@@ -12,7 +11,6 @@
1211
use Tobyz\JsonApiServer\Exception\InternalServerErrorException;
1312
use Tobyz\JsonApiServer\Exception\JsonApiErrorsException;
1413
use Tobyz\JsonApiServer\Exception\MethodNotAllowedException;
15-
use Tobyz\JsonApiServer\Exception\NotAcceptableException;
1614
use Tobyz\JsonApiServer\Exception\NotFoundException;
1715
use Tobyz\JsonApiServer\Exception\Request\InvalidQueryParameterException;
1816
use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
@@ -132,11 +130,6 @@ public function errors(array $errors): static
132130

133131
/**
134132
* Handle a request.
135-
*
136-
* @throws UnsupportedMediaTypeException if the request Content-Type header is invalid
137-
* @throws NotAcceptableException if the request Accept header is invalid
138-
* @throws MethodNotAllowedException if the request method is invalid
139-
* @throws BadRequestException if the request URI is invalid
140133
*/
141134
public function handle(Request $request): Response
142135
{
@@ -181,15 +174,13 @@ public function handle(Request $request): Response
181174

182175
private function runExtensions(Context $context): ?Response
183176
{
184-
$request = $context->request;
185-
186-
$contentTypeExtensionUris = $this->getContentTypeExtensionUris($request);
187-
$acceptableExtensionUris = $this->getAcceptableExtensionUris($request);
177+
$contentTypeExtensionUris = $this->getContentTypeExtensionUris($context->request);
178+
$acceptExtensionUris = $context->requestedExtensions();
188179

189180
$activeExtensions = array_intersect_key(
190181
$this->extensions,
191182
array_flip($contentTypeExtensionUris),
192-
array_flip($acceptableExtensionUris),
183+
array_flip($acceptExtensionUris),
193184
);
194185

195186
foreach ($activeExtensions as $extension) {
@@ -226,7 +217,7 @@ private function getContentTypeExtensionUris(Request $request): array
226217

227218
try {
228219
$type = (new ContentTypeParser())->parse($contentType);
229-
} catch (InvalidArgumentException $e) {
220+
} catch (InvalidArgumentException) {
230221
throw new UnsupportedMediaTypeException();
231222
}
232223

@@ -247,37 +238,6 @@ private function getContentTypeExtensionUris(Request $request): array
247238
return $extensionUris;
248239
}
249240

250-
private function getAcceptableExtensionUris(Request $request): array
251-
{
252-
if (!($accept = $request->getHeaderLine('Accept'))) {
253-
return [];
254-
}
255-
256-
$list = (new AcceptParser())->parse($accept);
257-
258-
foreach ($list as $mediaType) {
259-
if (!in_array($mediaType->name(), [JsonApi::MEDIA_TYPE, '*/*'])) {
260-
continue;
261-
}
262-
263-
if (!empty(array_diff(array_keys($mediaType->parameters()), ['ext', 'profile']))) {
264-
continue;
265-
}
266-
267-
$extensionUris = $mediaType->hasParamater('ext')
268-
? explode(' ', $mediaType->getParameter('ext'))
269-
: [];
270-
271-
if (!empty(array_diff($extensionUris, array_keys($this->extensions)))) {
272-
continue;
273-
}
274-
275-
return $extensionUris;
276-
}
277-
278-
throw new NotAcceptableException();
279-
}
280-
281241
/**
282242
* Convert an exception into a JSON:API error document response.
283243
*/

tests/specification/ContentNegotiationTest.php

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,77 @@ public function test_responds_with_vary_header()
107107

108108
$this->assertEquals('Accept', $response->getHeaderLine('vary'));
109109
}
110+
111+
public function test_requested_profiles_can_be_read()
112+
{
113+
$this->api = new JsonApi();
114+
115+
$capturedProfiles = null;
116+
117+
$resource = new MockResource('users', models: [(object) ['id' => '1']], endpoints: [
118+
Show::make()->response(function ($response, $model, $context) use (&$capturedProfiles) {
119+
$capturedProfiles = $context->requestedProfiles();
120+
}),
121+
]);
122+
123+
$this->api->resource($resource);
124+
125+
$request = $this->buildRequest('GET', '/users/1')->withHeader(
126+
'Accept',
127+
'application/vnd.api+json; profile="https://example.com/profile1 https://example.com/profile2"',
128+
);
129+
130+
$this->api->handle($request);
131+
132+
$this->assertEquals(
133+
['https://example.com/profile1', 'https://example.com/profile2'],
134+
$capturedProfiles,
135+
);
136+
}
137+
138+
public function test_activated_profiles_appear_in_content_type()
139+
{
140+
$this->api = new JsonApi();
141+
142+
$resource = new MockResource('users', models: [(object) ['id' => '1']], endpoints: [
143+
Show::make()->response(function ($response, $model, $context) {
144+
$context->activateProfile('https://example.com/profile1');
145+
$context->activateProfile('https://example.com/profile2');
146+
}),
147+
]);
148+
149+
$this->api->resource($resource);
150+
151+
$response = $this->api->handle($this->buildRequest('GET', '/users/1'));
152+
153+
$this->assertEquals(
154+
'application/vnd.api+json; profile="https://example.com/profile1 https://example.com/profile2"',
155+
$response->getHeaderLine('Content-Type'),
156+
);
157+
}
158+
159+
public function test_profiles_from_lines_with_unsupported_extensions_are_ignored()
160+
{
161+
$this->api = new JsonApi();
162+
163+
$capturedProfiles = null;
164+
165+
$resource = new MockResource('users', models: [(object) ['id' => '1']], endpoints: [
166+
Show::make()->response(function ($response, $model, $context) use (&$capturedProfiles) {
167+
$capturedProfiles = $context->requestedProfiles();
168+
}),
169+
]);
170+
171+
$this->api->resource($resource);
172+
173+
$request = $this->buildRequest('GET', '/users/1')->withHeader(
174+
'Accept',
175+
'application/vnd.api+json; ext="https://unsupported.com/ext"; profile="https://example.com/should-be-ignored", application/vnd.api+json; profile="https://example.com/valid-profile"',
176+
);
177+
178+
$this->api->handle($request);
179+
180+
// Should only get the profile from the second (valid) Accept line
181+
$this->assertEquals(['https://example.com/valid-profile'], $capturedProfiles);
182+
}
110183
}

0 commit comments

Comments
 (0)