Skip to content

Commit 23b8608

Browse files
committed
Add support for custom resource links
1 parent 6038a8b commit 23b8608

File tree

8 files changed

+203
-1
lines changed

8 files changed

+203
-1
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ and this project adheres to
3434
automatic `jsonapi` object inclusion
3535
- Add `JsonApi::meta()` method for including meta information in the `jsonapi`
3636
object
37+
- Add `Resource::links()` method for defining custom resource-level links
38+
(including `describedby`)
39+
- Add `Schema\Link` class for defining rich link objects with metadata
3740
- Add full support for JSON:API profiles:
3841
- Parse profile URIs from `Accept` header
3942
- `Context::profileRequested(string $uri): bool` - check if a profile was

docs/resources.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,37 @@ class PostsResource extends AbstractResource
126126
}
127127
```
128128

129+
## Links
130+
131+
Define custom links for your resources using the `links()` method. Links can be
132+
static URLs or rich link objects with additional metadata:
133+
134+
```php
135+
use Tobyz\JsonApiServer\Schema\Link;
136+
137+
class PostsResource extends AbstractResource
138+
{
139+
// ...
140+
141+
public function links(): array
142+
{
143+
return [
144+
Link::make('describedby')->get(
145+
fn() => 'https://api.example.com/schemas/posts',
146+
),
147+
148+
Link::make('canonical')->get(
149+
fn($model) => [
150+
'href' => "https://example.com/posts/{$model->slug}",
151+
'title' => 'View on website',
152+
'type' => 'text/html',
153+
],
154+
),
155+
];
156+
}
157+
}
158+
```
159+
129160
## Endpoints
130161

131162
In order to expose endpoints for listing, creating, reading, updating, and

src/Resource/AbstractResource.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ public function meta(): array
3333
return [];
3434
}
3535

36+
public function links(): array
37+
{
38+
return [];
39+
}
40+
3641
public function id(): Id
3742
{
3843
return Id::make();

src/Resource/Resource.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Tobyz\JsonApiServer\Schema\Field\Attribute;
77
use Tobyz\JsonApiServer\Schema\Field\Field;
88
use Tobyz\JsonApiServer\Schema\Id;
9+
use Tobyz\JsonApiServer\Schema\Link;
910

1011
interface Resource
1112
{
@@ -28,6 +29,13 @@ public function fields(): array;
2829
*/
2930
public function meta(): array;
3031

32+
/**
33+
* Get the links for this resource.
34+
*
35+
* @return Link[]
36+
*/
37+
public function links(): array;
38+
3139
/**
3240
* Get the ID schema for this resource.
3341
*/

src/Schema/Link.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
namespace Tobyz\JsonApiServer\Schema;
4+
5+
use Tobyz\JsonApiServer\JsonApi;
6+
use Tobyz\JsonApiServer\Schema\Field\Field;
7+
8+
class Link extends Field
9+
{
10+
public static function make(string $name): static
11+
{
12+
return new static($name);
13+
}
14+
15+
public static function location(): ?string
16+
{
17+
return null;
18+
}
19+
20+
public function getSchema(JsonApi $api): array
21+
{
22+
return parent::getSchema($api) + [
23+
'oneOf' => [
24+
['type' => 'string', 'format' => 'uri'],
25+
[
26+
'type' => 'object',
27+
'required' => ['href'],
28+
'properties' => [
29+
'href' => ['type' => 'string', 'format' => 'uri'],
30+
'rel' => ['type' => 'string'],
31+
'describedby' => [
32+
'oneOf' => [
33+
['type' => 'string', 'format' => 'uri'],
34+
[
35+
'type' => 'object',
36+
'required' => ['href'],
37+
'properties' => [
38+
'href' => ['type' => 'string', 'format' => 'uri'],
39+
],
40+
],
41+
],
42+
],
43+
'title' => ['type' => 'string'],
44+
'type' => ['type' => 'string'],
45+
'hreflang' => ['type' => 'string'],
46+
'meta' => ['type' => 'object'],
47+
],
48+
],
49+
],
50+
];
51+
}
52+
}

src/Serializer.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,22 @@ private function addToMap(Context $context): array
108108
$this->map[$key]['meta'][$k] = $v;
109109
}
110110

111+
// TODO: cache
112+
foreach ($resource->links() as $link) {
113+
$linkContext = $context->withField($link);
114+
115+
if (
116+
array_key_exists($link->name, $this->map[$key]['links'] ?? []) ||
117+
!$link->isVisible($linkContext)
118+
) {
119+
continue;
120+
}
121+
122+
$value = $link->getValue($linkContext);
123+
124+
$this->resolveLinkValue($key, $link, $linkContext, $value);
125+
}
126+
111127
return $this->map[$key];
112128
}
113129

@@ -145,6 +161,19 @@ private function resolveMetaValue(
145161
}
146162
}
147163

164+
private function resolveLinkValue(
165+
string $key,
166+
Field $field,
167+
Context $context,
168+
mixed $value,
169+
): void {
170+
if ($value instanceof Closure) {
171+
$this->deferred[] = fn() => $this->resolveLinkValue($key, $field, $context, $value());
172+
} else {
173+
$this->map[$key]['links'][$field->name] = $field->serializeValue($value, $context);
174+
}
175+
}
176+
148177
/**
149178
* Add an included resource to the document.
150179
*

tests/MockResource.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public function __construct(
4444
private readonly ?Id $id = null,
4545
private readonly array $fields = [],
4646
private readonly array $meta = [],
47+
private readonly array $links = [],
4748
private readonly array $filters = [],
4849
private readonly array $sorts = [],
4950
private readonly ?string $defaultSort = null,
@@ -76,6 +77,11 @@ public function meta(): array
7677
return $this->meta;
7778
}
7879

80+
public function links(): array
81+
{
82+
return $this->links;
83+
}
84+
7985
public function filters(): array
8086
{
8187
return $this->filters;
@@ -155,7 +161,12 @@ public function cursorPaginate(
155161

156162
$slice = array_values($slice);
157163

158-
return new Page($slice, $slice[0] === $models[0], !$rangeTruncated);
164+
return new Page(
165+
results: $slice,
166+
isFirstPage: $slice[0] === $models[0],
167+
isLastPage: !$rangeTruncated,
168+
rangeTruncated: $rangeTruncated ? true : null,
169+
);
159170
}
160171

161172
public function itemCursor($model, object $query, Context $context): string
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
namespace Tobyz\Tests\JsonApiServer\feature;
4+
5+
use Tobyz\JsonApiServer\Endpoint\Show;
6+
use Tobyz\JsonApiServer\JsonApi;
7+
use Tobyz\JsonApiServer\Schema\Link;
8+
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
9+
use Tobyz\Tests\JsonApiServer\MockResource;
10+
11+
class ResourceLinksTest extends AbstractTestCase
12+
{
13+
private JsonApi $api;
14+
15+
public function setUp(): void
16+
{
17+
$this->api = new JsonApi('/api');
18+
}
19+
20+
public function test_resource_links_are_included_in_response()
21+
{
22+
$this->api->resource(
23+
new MockResource(
24+
'articles',
25+
models: [(object) ['id' => '1', 'title' => 'Hello World', 'slug' => 'hello-world']],
26+
endpoints: [Show::make()],
27+
links: [
28+
Link::make('describedby')->get(
29+
fn() => 'https://api.example.com/schemas/articles',
30+
),
31+
32+
Link::make('canonical')->get(
33+
fn($model) => [
34+
'href' => "https://example.com/articles/{$model->slug}",
35+
'title' => 'View on website',
36+
'type' => 'text/html',
37+
],
38+
),
39+
],
40+
),
41+
);
42+
43+
$response = $this->api->handle($this->buildRequest('GET', '/api/articles/1'));
44+
45+
$this->assertJsonApiDocumentSubset(
46+
[
47+
'data' => [
48+
'type' => 'articles',
49+
'id' => '1',
50+
'links' => [
51+
'describedby' => 'https://api.example.com/schemas/articles',
52+
'canonical' => [
53+
'href' => 'https://example.com/articles/hello-world',
54+
'title' => 'View on website',
55+
'type' => 'text/html',
56+
],
57+
],
58+
],
59+
],
60+
$response->getBody(),
61+
);
62+
}
63+
}

0 commit comments

Comments
 (0)