Skip to content

Commit 54232ea

Browse files
committed
Major refactor of endpoints, query parameter validation, and schema generation
1 parent 528c764 commit 54232ea

File tree

86 files changed

+3384
-1630
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

86 files changed

+3384
-1630
lines changed

CHANGELOG.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,21 @@ and this project adheres to
2828
`Context::currentUrl()`)
2929
- Move `Extension\Atomic` to `Extension\Atomic\Atomic`
3030
- Remove `json_api_response()` helper (use `Context::createResponse()` instead)
31+
- Remove `Context::queryParam()` (use `Context::parameter()` for defined
32+
parameters, or `Context::$request->getQueryParameters()`)
3133

3234
### Added
3335

36+
- Major refactor of endpoints, query parameter validation, and OpenAPI schema
37+
generation
38+
- Allow endpoints and pagination implementations to define query parameters
39+
for validation and schema generation
40+
- Allow endpoints to define resource and relationship links
41+
- Introduce `SchemaContext` to store relevant context during schema
42+
generation
43+
- Make `Show` and `Update` aggregate separate `*Resource` and
44+
`*Relationship` endpoints
45+
- Refactor endpoint handlers and traits
3446
- More comprehensive type schema support
3547
- Add `nullable()` method to `Type` classes, allowing sub-types (e.g. array
3648
items or object properties) to be nullable
@@ -124,8 +136,9 @@ and this project adheres to
124136
- Implement the `Resource\Attachable` contract and add `ToMany::attachable()`,
125137
`validateAttach()`, and `validateDetach()` helpers for controlling
126138
relationship mutation endpoints with attach/detach hooks
127-
- Introduce `Endpoint\ResourceEndpoint` and `Endpoint\RelationshipEndpoint` so
128-
endpoints can contribute relationship and resource links during serialization
139+
- Introduce `Endpoint\ProvidesResourceLinks` and
140+
`Endpoint\ProvidesRelationshipLinks` so endpoints can contribute relationship
141+
and resource links during serialization
129142

130143
## [1.0.0-beta.5] - 2025-09-27
131144

docs/context.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,11 @@ class Context
5959
// Get the request path relative to the API base path
6060
public function path(): string;
6161

62-
// Get the value of a query param
63-
public function queryParam(string $name, $default = null): mixed;
62+
// Get the request path segments
63+
public function pathSegments(): array;
64+
65+
// Get the value of a validated query parameter or header
66+
public function parameter(string $name): mixed;
6467

6568
// Get the URL of the current request, optionally with query parameter overrides
6669
public function currentUrl(array $queryParams = []): string;

src/Context.php

Lines changed: 155 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,23 @@
99
use Psr\Http\Message\ResponseInterface;
1010
use Psr\Http\Message\ServerRequestInterface;
1111
use RuntimeException;
12-
use Tobyz\JsonApiServer\Endpoint\Endpoint;
12+
use Tobyz\JsonApiServer\Exception\ErrorProvider;
13+
use Tobyz\JsonApiServer\Exception\Field\InvalidFieldValueException;
14+
use Tobyz\JsonApiServer\Exception\JsonApiErrorsException;
1315
use Tobyz\JsonApiServer\Exception\NotAcceptableException;
16+
use Tobyz\JsonApiServer\Exception\Request\InvalidQueryParameterException;
1417
use Tobyz\JsonApiServer\Exception\Request\InvalidSparseFieldsetsException;
15-
use Tobyz\JsonApiServer\Resource\Collection;
1618
use Tobyz\JsonApiServer\Resource\Resource;
1719
use Tobyz\JsonApiServer\Schema\Field\Field;
20+
use Tobyz\JsonApiServer\Schema\Parameter;
1821
use WeakMap;
1922

20-
class Context
23+
class Context extends SchemaContext
2124
{
22-
public ?Collection $collection = null;
23-
public ?Resource $resource = null;
24-
public ?Endpoint $endpoint = null;
2525
public ?object $query = null;
2626
public ?Serializer $serializer = null;
2727
public ?object $model = null;
2828
public ?array $data = null;
29-
public ?Field $field = null;
3029
public ?array $include = null;
3130
public ArrayObject $documentMeta;
3231
public ArrayObject $documentLinks;
@@ -35,23 +34,24 @@ class Context
3534

3635
private ?array $body;
3736
private ?string $path;
37+
private ?array $pathSegments = null;
3838
private ?array $requestedExtensions = null;
3939
private ?array $requestedProfiles = null;
40+
private array $parameters = [];
41+
private ?array $queryParameterMap = null;
4042

41-
private WeakMap $endpoints;
4243
private WeakMap $resourceIds;
4344
private WeakMap $modelIds;
44-
private WeakMap $fields;
4545
private WeakMap $sparseFields;
4646

47-
public function __construct(public JsonApi $api, public ServerRequestInterface $request)
47+
public function __construct(JsonApi $api, public ServerRequestInterface $request)
4848
{
49+
parent::__construct($api);
50+
4951
$this->parseAcceptHeader();
5052

51-
$this->endpoints = new WeakMap();
5253
$this->resourceIds = new WeakMap();
5354
$this->modelIds = new WeakMap();
54-
$this->fields = new WeakMap();
5555
$this->sparseFields = new WeakMap();
5656

5757
$this->documentMeta = new ArrayObject();
@@ -61,14 +61,6 @@ public function __construct(public JsonApi $api, public ServerRequestInterface $
6161
$this->resourceMeta = new WeakMap();
6262
}
6363

64-
/**
65-
* Get the value of a query param.
66-
*/
67-
public function queryParam(string $name, $default = null)
68-
{
69-
return $this->request->getQueryParams()[$name] ?? $default;
70-
}
71-
7264
/**
7365
* Get the request method.
7466
*/
@@ -88,6 +80,21 @@ public function path(): string
8880
);
8981
}
9082

83+
public function pathSegments(): array
84+
{
85+
return $this->pathSegments ??= array_values(
86+
array_filter(explode('/', trim($this->path(), '/'))),
87+
);
88+
}
89+
90+
public function withPathSegments(array $segments): static
91+
{
92+
$new = clone $this;
93+
$new->pathSegments = array_values($segments);
94+
95+
return $new;
96+
}
97+
9198
/**
9299
* Get the URL of the current request, optionally with query parameter overrides.
93100
*/
@@ -121,19 +128,6 @@ public function body(): ?array
121128
json_decode($this->request->getBody()->getContents(), true);
122129
}
123130

124-
/**
125-
* Get a resource by its type.
126-
*/
127-
public function resource(string $type): Resource
128-
{
129-
return $this->api->getResource($type);
130-
}
131-
132-
public function endpoints(Collection $collection): array
133-
{
134-
return $this->endpoints[$collection] ??= $collection->endpoints();
135-
}
136-
137131
public function id(Resource $resource, $model): string
138132
{
139133
if (isset($this->modelIds[$model])) {
@@ -145,26 +139,6 @@ public function id(Resource $resource, $model): string
145139
return $this->modelIds[$model] = $id->serializeValue($id->getValue($this), $this);
146140
}
147141

148-
/**
149-
* Get the fields for the given resource, keyed by name.
150-
*
151-
* @return array<string, Field>
152-
*/
153-
public function fields(Resource $resource): array
154-
{
155-
if (isset($this->fields[$resource])) {
156-
return $this->fields[$resource];
157-
}
158-
159-
$fields = [];
160-
161-
foreach ($resource->fields() as $field) {
162-
$fields[$field->name] = $field;
163-
}
164-
165-
return $this->fields[$resource] = $fields;
166-
}
167-
168142
/**
169143
* Get only the requested fields for the given resource, keyed by name.
170144
*
@@ -178,7 +152,7 @@ public function sparseFields(Resource $resource): array
178152

179153
$fields = $this->fields($resource);
180154
$type = $resource->type();
181-
$fieldsParam = $this->queryParam('fields');
155+
$fieldsParam = $this->parameter('fields');
182156

183157
if (is_array($fieldsParam) && array_key_exists($type, $fieldsParam)) {
184158
$requested = $fieldsParam[$type];
@@ -210,7 +184,7 @@ public function fieldRequested(string $type, string $field): bool
210184
*/
211185
public function sortRequested(string $field): bool
212186
{
213-
if ($sort = $this->queryParam('sort')) {
187+
if ($sort = $this->parameter('sort')) {
214188
foreach (parse_sort_string($sort) as [$name, $direction]) {
215189
if ($name === $field) {
216190
return true;
@@ -305,8 +279,10 @@ public function withRequest(ServerRequestInterface $request): static
305279
$new->sparseFields = new WeakMap();
306280
$new->body = null;
307281
$new->path = null;
282+
$new->pathSegments = null;
308283
$new->requestedProfiles = null;
309284
$new->requestedExtensions = null;
285+
$new->queryParameterMap = null;
310286
$new->parseAcceptHeader();
311287
return $new;
312288
}
@@ -325,27 +301,6 @@ public function withData(?array $data): static
325301
return $new;
326302
}
327303

328-
public function withCollection(?Collection $collection): static
329-
{
330-
$new = clone $this;
331-
$new->collection = $collection;
332-
return $new;
333-
}
334-
335-
public function withResource(?Resource $resource): static
336-
{
337-
$new = clone $this;
338-
$new->resource = $resource;
339-
return $new;
340-
}
341-
342-
public function withEndpoint(?Endpoint $endpoint): static
343-
{
344-
$new = clone $this;
345-
$new->endpoint = $endpoint;
346-
return $new;
347-
}
348-
349304
public function withQuery(?object $query): static
350305
{
351306
$new = clone $this;
@@ -367,13 +322,6 @@ public function withModel(?object $model): static
367322
return $new;
368323
}
369324

370-
public function withField(?Field $field): static
371-
{
372-
$new = clone $this;
373-
$new->field = $field;
374-
return $new;
375-
}
376-
377325
public function withInclude(?array $include): static
378326
{
379327
$new = clone $this;
@@ -395,6 +343,130 @@ public function activateProfile(string $uri): static
395343
return $this;
396344
}
397345

346+
/**
347+
* Load and validate parameters from the request.
348+
*
349+
* @param Parameter[] $parameters
350+
*/
351+
public function withParameters(array $parameters): static
352+
{
353+
$context = clone $this;
354+
$context->parameters = [];
355+
356+
$this->validateQueryParameters(
357+
array_filter($parameters, fn(Parameter $p) => $p->in === 'query'),
358+
);
359+
360+
$errors = [];
361+
362+
foreach ($parameters as $parameter) {
363+
$value = $this->extractParameterValue($parameter);
364+
365+
if ($value === null && $parameter->default) {
366+
$value = ($parameter->default)();
367+
}
368+
369+
$value = $parameter->deserializeValue($value, $context);
370+
371+
if ($value === null && !$parameter->required) {
372+
continue;
373+
}
374+
375+
$fail = function ($error = []) use (&$errors, $parameter) {
376+
if (!$error instanceof ErrorProvider) {
377+
$error = new InvalidFieldValueException(
378+
is_scalar($error) ? ['detail' => (string) $error] : $error,
379+
);
380+
}
381+
382+
$errors[] = $error->source(['parameter' => $parameter->name]);
383+
};
384+
385+
$parameter->validateValue($value, $fail, $context);
386+
387+
$context->parameters[$parameter->name] = $value;
388+
}
389+
390+
if ($errors) {
391+
throw new JsonApiErrorsException($errors);
392+
}
393+
394+
return $context;
395+
}
396+
397+
/**
398+
* Get a validated parameter value.
399+
*/
400+
public function parameter(string $name): mixed
401+
{
402+
return $this->parameters[$name] ?? null;
403+
}
404+
405+
private function validateQueryParameters(array $parameters): void
406+
{
407+
foreach ($this->request->getQueryParams() as $key => $value) {
408+
if (!ctype_lower($key)) {
409+
continue;
410+
}
411+
412+
foreach ($this->flattenQueryParameters([$key => $value]) as $flattenedKey => $v) {
413+
$matched = false;
414+
415+
foreach ($parameters as $parameter) {
416+
if (
417+
$flattenedKey === $parameter->name ||
418+
str_starts_with($flattenedKey, $parameter->name . '[')
419+
) {
420+
$matched = true;
421+
}
422+
}
423+
424+
if (!$matched) {
425+
throw new InvalidQueryParameterException($flattenedKey);
426+
}
427+
}
428+
}
429+
}
430+
431+
private function flattenQueryParameters(array $params, string $prefix = ''): array
432+
{
433+
$result = [];
434+
435+
foreach ($params as $key => $value) {
436+
$newKey = $prefix ? "{$prefix}[{$key}]" : $key;
437+
438+
if (is_array($value)) {
439+
$result += $this->flattenQueryParameters($value, $newKey);
440+
} else {
441+
$result[$newKey] = $value;
442+
}
443+
}
444+
445+
return $result;
446+
}
447+
448+
private function extractParameterValue(Parameter $param): mixed
449+
{
450+
return match ($param->in) {
451+
'query' => $this->getNestedQueryParam($param->name),
452+
'header' => $this->request->getHeaderLine($param->name) ?: null,
453+
default => null,
454+
};
455+
}
456+
457+
private function getNestedQueryParam(string $name): mixed
458+
{
459+
$value = $this->request->getQueryParams();
460+
461+
preg_match_all('/[^\[\]]+/', $name, $matches);
462+
463+
foreach ($matches[0] ?? [] as $segment) {
464+
$value = $value[$segment] ?? null;
465+
}
466+
467+
return $value;
468+
}
469+
398470
public function forModel(array $collections, ?object $model): static
399471
{
400472
$new = clone $this;

0 commit comments

Comments
 (0)