Skip to content

Commit 492c5df

Browse files
committed
Add support for JSON:API relationship URLs
This release adds comprehensive support for JSON:API relationship URLs, including fetching relationships, related resources, and relationship mutations. New features: - GET /resources/{id}/relationships/{name} - Fetch relationship linkage - GET /resources/{id}/{name} - Fetch related resources with full filtering, sorting, and pagination support for to-many relationships - PATCH /resources/{id}/relationships/{name} - Replace relationships - POST /resources/{id}/relationships/{name} - Add to to-many relationships - DELETE /resources/{id}/relationships/{name} - Remove from to-many relationships - Attachable contract for custom relationship mutation handling - Automatic self and related links on relationship fields - RelatedListable interface for queryable to-many relationships BREAKING CHANGES: 1. Listable interface now requires defaultSort() and pagination() methods - Previously configured on Index endpoint, now on the resource - Allows pagination/sorting to be reused for related resources - Migration: Implement these methods, return null for no defaults 2. Paginatable::paginate() now returns Page instead of mutating query - Change signature to return new Page($results, $isLastPage) - Update custom Pagination implementations accordingly 3. Context now uses documentMeta and documentLinks (ArrayObject) - Replace usage of old meta/links properties - Allows callbacks to add document-level metadata 4. Serializer API changed - no longer accepts Context in constructor - Pass context to addPrimary() and addIncluded() instead - Affects custom code directly using Serializer Documentation has been reorganized with dedicated pages for filtering, sorting, and pagination. OpenAPI schema generation updated to include relationship endpoint schemas.
1 parent 50f01eb commit 492c5df

Some content is hidden

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

57 files changed

+2685
-799
lines changed

CHANGELOG.md

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,37 @@ and this project adheres to
1010

1111
### ⚠️ Breaking Changes
1212

13-
- Change `Resource\Paginatable::paginate()` to accept the resolved offset and
14-
limit integers (plus the request context) and return the page of results
15-
instead of mutating the query in place
13+
- Change `Resource\Paginatable::paginate()` signature to accept the resolved
14+
offset and limit integers (plus the request context) and return the page of
15+
results instead of mutating the query in place
1616
- New contract for custom `Pagination` implementations:
17-
- Return results from the `paginate(object $query, Context $context): Page`
18-
- Add `meta` and `links` via the `Context` object
17+
- Add `paginate(object $query, Context $context): Page` method which should
18+
return a page of results
19+
- Remove `meta` and `links` methods; set this data in `paginate` via the
20+
`Context` object
21+
- `Resource\\Listable` implementations must now provide `defaultSort()` and
22+
`pagination()` methods so defaults can be reused when listing related data
23+
- Refactor `Serializer` so it is instantiated without a `Context` and expects
24+
the resource context to be provided to `addPrimary()` / `addIncluded()`
1925

2026
### Added
2127

2228
- Add cursor pagination support via `Endpoint\Index::cursorPaginate()` and the
2329
`Resource\CursorPaginatable` contract, following the
24-
`ethanresnick/cursor-pagination` profile
25-
- Introduce `MaxPageSizeExceededException` and
26-
`RangePaginationNotSupportedException` with JSON:API profile links for cursor
27-
pagination errors
30+
`ethanresnick/cursor-pagination` JSON:API profile
2831
- Allow exceptions to attach `meta` and `links` members to the error response
29-
- Add `Context::$meta` and `Context::$links` as `ArrayObject` instances to allow
30-
callbacks to add meta information to the response document
32+
- Add `Context::$documentMeta` and `Context::$documentLinks` as `ArrayObject`
33+
instances to allow callbacks to add meta information to the response document
34+
- Support JSON:API relationship URLs via the `Show` endpoint, adding automatic
35+
`self` / `related` links, and paginated/filterable/sortable to-many responses
36+
when the related resource is `Listable`
37+
- Allow the `Update` endpoint to handle `PATCH` / `POST` / `DELETE` requests to
38+
relationship URLs and return the updated relationship document
39+
- Implement the `Resource\Attachable` contract and add `ToMany::attachable()`,
40+
`validateAttach()`, and `validateDetach()` helpers for controlling
41+
relationship mutation endpoints with attach/detach hooks
42+
- Introduce `Endpoint\ResourceEndpoint` and `Endpoint\RelationshipEndpoint` so
43+
endpoints can contribute relationship and resource links during serialization
3144

3245
## [1.0.0-beta.5] - 2025-09-27
3346

docs/.vitepress/config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ export default defineConfig({
2121
text: 'Resources',
2222
items: [
2323
{ text: 'Defining Resources', link: '/resources' },
24-
{ text: 'Defining Fields', link: '/fields' },
24+
{ text: 'Fields', link: '/fields' },
2525
{ text: 'Attributes', link: '/attributes' },
2626
{ text: 'Relationships', link: '/relationships' },
27+
{ text: 'Filtering', link: '/filtering' },
28+
{ text: 'Sorting', link: '/sorting' },
29+
{ text: 'Pagination', link: '/pagination' },
2730
],
2831
},
2932
{

docs/collections.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ also used for defining
1010

1111
## Defining Collections
1212

13-
To define a heterogeneous collection, create a new class that implements the
14-
`Tobyz\JsonApi\Resource\Collection` interface:
13+
To define a heterogeneous collection, create a new class that extends
14+
`Tobyz\JsonApi\Resource\AbstractCollection`, and implement the required methods:
1515

1616
```php
1717
use Tobyz\JsonApiServer\Resource\Collection;

docs/context.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ class Context
4141
// that are included
4242
public ?array $include = null;
4343

44+
// Data to be returned in the document's meta object
45+
public ArrayObject $documentMeta;
46+
47+
// Links to be returned in the document's links object
48+
public ArrayObject $documentLinks;
49+
4450
// Get the request method
4551
public function method(): string;
4652

docs/fields.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Defining Fields
1+
# Fields
22

33
A resource object's attributes and relationships are collectively called its
44
"fields".

docs/filtering.md

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Filtering
2+
3+
The JSON:API specification reserves the `filter` query parameter for
4+
[filtering resources](https://jsonapi.org/format/#fetching-filtering).
5+
6+
To define filters that can be used in this query parameter, add them to your
7+
`Listable` resource's `filters` method:
8+
9+
```php
10+
class PostsResource extends Resource implements Listable
11+
{
12+
// ...
13+
14+
public function filters(): array
15+
{
16+
return [
17+
ExampleFilter::make('example'), // [!code ++]
18+
];
19+
}
20+
}
21+
```
22+
23+
::: tip Laravel Integration
24+
For Eloquent-backed resources, a number of [filters](laravel.md#filters) are
25+
provided to make it easy to implement filtering on your resource.
26+
:::
27+
28+
## Inline Filters
29+
30+
The easiest way to define a filter is to use the `CustomFilter` class, which
31+
accepts the name of the filter parameter and a callback to apply the filter to
32+
the query. The value received by a filter can be a string or an array, so you
33+
will need to handle both:
34+
35+
```php
36+
use Tobyz\JsonApiServer\Schema\CustomFilter;
37+
38+
CustomFilter::make('name', function (
39+
$query,
40+
string|array $value,
41+
Context $context,
42+
) {
43+
$query->whereIn('name', (array) $value);
44+
});
45+
```
46+
47+
Now the filter can be applied like so:
48+
49+
```http
50+
GET /posts?filter[name]=Toby
51+
GET /posts?filter[name][]=Toby&filter[name][]=Franz
52+
```
53+
54+
## Boolean Filters
55+
56+
By default it is assumed that each filter applied to the query will be combined
57+
with a logical `AND`. When a resource implements
58+
`Tobyz\JsonApiServer\Resource\SupportsBooleanFilters` you can express more
59+
complex logic with `AND`, `OR`, and `NOT` groups.
60+
61+
Boolean groups are expressed by nesting objects under the `filter` parameter.
62+
You may use either associative objects or indexed lists of clauses. Each clause
63+
can be another filter or another boolean group.
64+
65+
```http
66+
GET /posts
67+
?filter[and][0][status]=published
68+
&filter[and][1][or][0][views][gt]=100
69+
&filter[and][1][or][1][not][status]=archived
70+
```
71+
72+
In this request every result must be published, and it must also either have
73+
more than 100 views or it is not archived.
74+
75+
```http
76+
GET /posts
77+
?filter[or][0][status]=draft
78+
&filter[or][1][status]=published
79+
&filter[or][1][not][comments]=0
80+
```
81+
82+
This request returns drafts, or posts that are published and have comments. The
83+
second example also shows that in certain cases you can omit `[and]` groups and
84+
numeric indices; sibling filters at the same level default to `AND` behaviour.
85+
86+
## Writing Filters
87+
88+
To create your own filter class, extend the `Tobyz\JsonApiServer\Schema\Filter`
89+
class and implement the `apply` method:
90+
91+
```php
92+
use Tobyz\JsonApiServer\Context;
93+
use Tobyz\JsonApiServer\Schema\Filter;
94+
95+
class WhereIn extends Filter
96+
{
97+
public function apply(
98+
object $query,
99+
string|array $value,
100+
Context $context,
101+
): void {
102+
$query->whereIn($this->name, $value);
103+
}
104+
}
105+
```
106+
107+
## Visibility
108+
109+
If you want to restrict the ability to use a filter, use the `visible` or
110+
`hidden` method, passing a closure that returns a boolean value:
111+
112+
```php
113+
WhereIn::make('example')->visible(
114+
fn(Context $context) => $context->request->getAttribute('isAdmin'),
115+
);
116+
```

docs/index.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ support for:
1717
- **Creating** resources (`POST /articles`)
1818
- **Updating** resources (`PATCH /articles/1`)
1919
- **Deleting** resources (`DELETE /articles/1`)
20+
- **Related resource** URLs (`GET /articles/1/author`)
21+
- **Relationship** URLs (`/articles/1/relationships/author`)
2022
- **Content negotiation**
2123
- **Error handling**
2224
- **Extensions** including Atomic Operations
@@ -63,7 +65,7 @@ class UsersResource extends EloquentResource
6365
{
6466
return [
6567
Endpoint\Show::make(),
66-
Endpoint\Index::make()->paginate(),
68+
Endpoint\Index::make(),
6769
Endpoint\Create::make()->visible(Laravel\can('create')),
6870
Endpoint\Update::make()->visible(Laravel\can('update')),
6971
Endpoint\Delete::make()->visible(Laravel\can('delete')),

0 commit comments

Comments
 (0)