Skip to content

Commit df2de0b

Browse files
committed
Implement endpoint response hooks and asynchronous processing
1 parent eab2685 commit df2de0b

15 files changed

+939
-94
lines changed

docs/async.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Asynchronous Processing
2+
3+
This library supports the
4+
[JSON:API Asynchronous Processing](https://jsonapi.org/recommendations/#asynchronous-processing)
5+
recommendation, which provides a standardized way to handle long-running
6+
resource creation operations.
7+
8+
## Overview
9+
10+
The async processing pattern works as follows:
11+
12+
1. **Async Creation** (202 Accepted): Client creates a resource that takes time
13+
to process. Server returns a `202 Accepted` status with a `Content-Location`
14+
or `Location` header pointing to a job resource.
15+
2. **Job Polling** (200 OK): Client polls the job resource to check status.
16+
Server returns `200 OK` with optional `Retry-After` header.
17+
3. **Completion** (303 See Other): When processing completes, job resource
18+
returns `303 See Other` with a `Location` header pointing to the created
19+
resource.
20+
21+
## Async Creation
22+
23+
To enable async processing for resource creation, use the `async` method on the
24+
`Create` endpoint:
25+
26+
```php
27+
use Tobyz\JsonApiServer\Endpoint\Create;
28+
29+
class PhotosResource extends Resource
30+
{
31+
public function endpoints(): array
32+
{
33+
return [
34+
Create::make()->async('jobs', function ($model, Context $context) {
35+
if ($this->requiresAsyncProcessing($model)) {
36+
return Job::create([
37+
'status' => 'pending',
38+
'resource_type' => 'photos',
39+
'resource_data' => $model,
40+
]);
41+
}
42+
}),
43+
];
44+
}
45+
}
46+
```
47+
48+
The `async` method takes two parameters:
49+
50+
- **Collection name**: The name of the collection in which the job resource will
51+
be found (e.g. `jobs`)
52+
- **Callback**: A function that receives the model and context, and returns:
53+
- A **job model object**: Will return a `202 Accepted` response containing
54+
the job resource, and a `Content-Location` header pointing to the job
55+
resource
56+
- A **string path**: Will return a `202 Accepted` response with the string
57+
as the `Location`
58+
- **null**: Falls back to synchronous processing with a normal `201 Created`
59+
response
60+
61+
The callback is invoked after the data pipeline has validated and filled the
62+
model, but before it's persisted to storage.
63+
64+
## Job Polling
65+
66+
Use custom headers on your job resource's `Show` endpoint to provide polling
67+
guidance:
68+
69+
```php
70+
use Tobyz\JsonApiServer\Endpoint\Show;
71+
use Tobyz\JsonApiServer\Schema\Header;
72+
use Tobyz\JsonApiServer\Schema\Type\Integer;
73+
74+
class JobsResource extends Resource
75+
{
76+
public function endpoints(): array
77+
{
78+
return [
79+
Show::make()->headers([
80+
Header::make('Retry-After')
81+
->type(Integer::make())
82+
->nullable()
83+
->get(
84+
fn($model) => $model->status === 'pending' ? 60 : null,
85+
),
86+
]),
87+
];
88+
}
89+
}
90+
```
91+
92+
## Completion with See Other
93+
94+
When a job completes, use the `seeOther` convenience method to redirect clients
95+
to the created resource:
96+
97+
```php
98+
use Tobyz\JsonApiServer\Endpoint\Show;
99+
100+
class JobsResource extends Resource
101+
{
102+
public function endpoints(): array
103+
{
104+
return [
105+
Show::make()->seeOther(function ($model, Context $context) {
106+
if ($model->status === 'completed') {
107+
return "photos/$model->result_id";
108+
}
109+
}),
110+
];
111+
}
112+
}
113+
```
114+
115+
The `seeOther` method automatically:
116+
117+
- Returns a `303 See Other` response when the callback returns a value, with the
118+
returned string as the `Location` header
119+
- Adds OpenAPI schema for the 303 response

docs/resources.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,37 @@ The `POST` method is used by default, but you can customize this using the
208208
```php
209209
Endpoint\CollectionAction::make(...)->method('PUT');
210210
```
211+
212+
### Custom Headers
213+
214+
Use the `headers()` method to define custom headers for endpoint responses.
215+
Headers can be static or dynamic based on the model data:
216+
217+
```php
218+
use Tobyz\JsonApiServer\Schema\Header;
219+
use Tobyz\JsonApiServer\Schema\Type;
220+
221+
Endpoint\Show::make()->headers([
222+
Header::make('X-Rate-Limit')
223+
->type(Type\Integer::make())
224+
->get(fn($model) => $model->rate_limit),
225+
226+
Header::make('Cache-Control')->get(fn() => 'max-age=3600'),
227+
]);
228+
```
229+
230+
### Response Callbacks
231+
232+
Use the `response()` method to register callbacks that can modify or replace the
233+
response. Callbacks receive the response object and, when available, the model
234+
and context:
235+
236+
```php
237+
Endpoint\Show::make()->response(function ($response, $model, Context $context) {
238+
if ($model->is_archived) {
239+
return $response->withStatus(410); // Gone
240+
}
241+
242+
return $response->withHeader('X-Version', $model->version);
243+
});
244+
```

src/Endpoint/CollectionAction.php

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

55
use Closure;
6-
use Nyholm\Psr7\Response;
76
use Psr\Http\Message\ResponseInterface;
87
use Tobyz\JsonApiServer\Context;
8+
use Tobyz\JsonApiServer\Endpoint\Concerns\BuildsDocument;
9+
use Tobyz\JsonApiServer\Endpoint\Concerns\HasResponse;
10+
use Tobyz\JsonApiServer\Endpoint\Concerns\HasSchema;
911
use Tobyz\JsonApiServer\Exception\ForbiddenException;
1012
use Tobyz\JsonApiServer\Exception\MethodNotAllowedException;
1113
use Tobyz\JsonApiServer\JsonApi;
@@ -14,10 +16,15 @@
1416
use Tobyz\JsonApiServer\Schema\Concerns\HasDescription;
1517
use Tobyz\JsonApiServer\Schema\Concerns\HasVisibility;
1618

19+
use function Tobyz\JsonApiServer\json_api_response;
20+
1721
class CollectionAction implements Endpoint, OpenApiPathsProvider
1822
{
1923
use HasVisibility;
2024
use HasDescription;
25+
use HasResponse;
26+
use HasSchema;
27+
use BuildsDocument;
2128

2229
public string $method = 'POST';
2330

@@ -55,21 +62,31 @@ public function handle(Context $context): ?ResponseInterface
5562

5663
($this->handler)($context);
5764

58-
return new Response(204);
65+
$response = json_api_response($this->buildDocument($context), status: 204);
66+
67+
return $this->applyResponseHooks($response, $context);
5968
}
6069

6170
public function getOpenApiPaths(Collection $collection, JsonApi $api): array
6271
{
63-
return [
72+
$response = [];
73+
74+
if ($headers = $this->getHeadersSchema($api)) {
75+
$response['headers'] = $headers;
76+
}
77+
78+
$paths = [
6479
"/{$collection->name()}/$this->name" => [
6580
strtolower($this->method) => [
6681
'description' => $this->getDescription(),
6782
'tags' => [$collection->name()],
6883
'responses' => [
69-
'204' => [],
84+
'204' => $response,
7085
],
7186
],
7287
],
7388
];
89+
90+
return $this->mergeSchema($paths);
7491
}
7592
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
namespace Tobyz\JsonApiServer\Endpoint\Concerns;
4+
5+
use Psr\Http\Message\ResponseInterface;
6+
use Tobyz\JsonApiServer\Context;
7+
use Tobyz\JsonApiServer\JsonApi;
8+
use Tobyz\JsonApiServer\Schema\Header;
9+
10+
trait HasResponse
11+
{
12+
/** @var Header[] */
13+
private array $headers = [];
14+
15+
/** @var callable[] */
16+
private array $responseCallbacks = [];
17+
18+
/**
19+
* Set custom headers for the response.
20+
*
21+
* @param Header[] $headers
22+
*/
23+
public function headers(array $headers): static
24+
{
25+
$this->headers = array_merge($this->headers, $headers);
26+
27+
return $this;
28+
}
29+
30+
/**
31+
* Register a callback to customize the response.
32+
*
33+
* The callback receives the response and can return a modified or new response.
34+
*/
35+
public function response(callable $callback): static
36+
{
37+
$this->responseCallbacks[] = $callback;
38+
39+
return $this;
40+
}
41+
42+
/**
43+
* Apply custom headers to the response.
44+
*/
45+
private function applyHeaders(ResponseInterface $response, Context $context): ResponseInterface
46+
{
47+
foreach ($this->headers as $header) {
48+
$headerContext = $context->withField($header);
49+
$value = $header->getValue($headerContext);
50+
51+
if ($value !== null) {
52+
$value = $header->serializeValue($value, $headerContext);
53+
$response = $response->withHeader($header->name, (string) $value);
54+
}
55+
}
56+
57+
return $response;
58+
}
59+
60+
/**
61+
* Apply response callbacks in sequence.
62+
*/
63+
private function applyResponseCallbacks(
64+
ResponseInterface $response,
65+
Context $context,
66+
): ResponseInterface {
67+
foreach ($this->responseCallbacks as $callback) {
68+
$result = isset($context->model)
69+
? $callback($response, $context->model, $context)
70+
: $callback($response, $context);
71+
72+
if ($result instanceof ResponseInterface) {
73+
$response = $result;
74+
}
75+
}
76+
77+
return $response;
78+
}
79+
80+
/**
81+
* Apply response hooks (headers and callbacks).
82+
*/
83+
private function applyResponseHooks(
84+
ResponseInterface $response,
85+
Context $context,
86+
): ResponseInterface {
87+
$response = $this->applyHeaders($response, $context);
88+
$response = $this->applyResponseCallbacks($response, $context);
89+
90+
return $response;
91+
}
92+
93+
/**
94+
* Get OpenAPI headers schema for custom headers.
95+
*/
96+
private function getHeadersSchema(JsonApi $api): array
97+
{
98+
$headers = [];
99+
100+
foreach ($this->headers as $header) {
101+
$headers[$header->name] = [
102+
'description' => $header->description,
103+
'schema' => $header->getSchema($api),
104+
];
105+
}
106+
107+
return $headers;
108+
}
109+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace Tobyz\JsonApiServer\Endpoint\Concerns;
4+
5+
trait HasSchema
6+
{
7+
private array $schema = [];
8+
9+
/**
10+
* Set custom OpenAPI schema to merge with the base schema.
11+
*/
12+
public function schema(array $schema): static
13+
{
14+
$this->schema = array_merge_recursive($this->schema, $schema);
15+
16+
return $this;
17+
}
18+
19+
/**
20+
* Merge custom schema with the base OpenAPI schema.
21+
*/
22+
private function mergeSchema(array $baseSchema): array
23+
{
24+
return array_merge_recursive($baseSchema, $this->schema);
25+
}
26+
}

0 commit comments

Comments
 (0)