feat: Link-Template (RFC 9652), link sets (RFC 9264) and api-catalog (RFC 9727) - #8468
feat: Link-Template (RFC 9652), link sets (RFC 9264) and api-catalog (RFC 9727)#8468Spomky wants to merge 3 commits into
Conversation
Symfony's HttpHeaderSerializer silently drops every link whose href is a URI template, so web links declared with a template on an operation never reached the client. RFC 9652 gives them a home: the Link-Template header field. AddLinkHeaderProcessor now serializes the templated links of the link provider into a Link-Template header, and only sets each header when its serializer produced something. The serializer delegates to symfony/web-link 8.2 and later, and falls back to a backport of it otherwise, as api-platform supports Symfony 7.4. Closes api-platform#6924
Serializes a list of PSR-13 links to an "application/linkset+json" document, grouped by link context then by relation type. Delegates to symfony/web-link 8.2 and later, and falls back to a backport of it otherwise.
RFC 9727 defines the "api-catalog" well-known URI and link relation, and
mandates the RFC 9264 link set format for the document it returns.
GET /.well-known/api-catalog now returns an "application/linkset+json" document,
carrying the profile of RFC 9727, that anchors on the API entrypoint and
advertises the OpenAPI description ("service-desc"), the human-readable
documentation ("service-doc"), the Hydra documentation ("service-meta") and the
exposed collections ("item"). A HEAD request answers with the "api-catalog" link
relation, as section 2 requires, and the entrypoint advertises the catalog with
the same relation so that clients still find it when the API is mounted under a
route prefix.
On Laravel the route sits outside the API prefix, since RFC 8615 roots
well-known URIs at the host.
soyuka
left a comment
There was a problem hiding this comment.
Great stuff! This is my human review I'll also run a quick agent check, thanks!
| if ($this->docsEnabled) { | ||
| foreach (self::DOCUMENTATION_RELATIONS as $format => $rel) { | ||
| if (!$mimeTypes = $this->docsFormats[$format] ?? null) { | ||
| continue; | ||
| } | ||
|
|
||
| // The human-readable documentation is content negotiated, the other ones are explicit | ||
| $parameters = 'html' === $format ? [] : ['_format' => $format]; | ||
|
|
||
| $links[] = (new Link($rel, $this->urlGenerator->generate('api_doc', $parameters, UrlGeneratorInterface::ABS_URL))) | ||
| ->withAttribute('anchor', $entrypoint) | ||
| ->withAttribute('type', $mimeTypes[array_key_first($mimeTypes)]); | ||
| } | ||
| } | ||
|
|
||
| foreach ($this->getCollectionIris() as $iri) { | ||
| $links[] = (new Link('item', $iri))->withAttribute('anchor', $entrypoint); | ||
| } |
There was a problem hiding this comment.
| if ($this->docsEnabled) { | |
| foreach (self::DOCUMENTATION_RELATIONS as $format => $rel) { | |
| if (!$mimeTypes = $this->docsFormats[$format] ?? null) { | |
| continue; | |
| } | |
| // The human-readable documentation is content negotiated, the other ones are explicit | |
| $parameters = 'html' === $format ? [] : ['_format' => $format]; | |
| $links[] = (new Link($rel, $this->urlGenerator->generate('api_doc', $parameters, UrlGeneratorInterface::ABS_URL))) | |
| ->withAttribute('anchor', $entrypoint) | |
| ->withAttribute('type', $mimeTypes[array_key_first($mimeTypes)]); | |
| } | |
| } | |
| foreach ($this->getCollectionIris() as $iri) { | |
| $links[] = (new Link('item', $iri))->withAttribute('anchor', $entrypoint); | |
| } | |
| foreach ($this->getCollectionIris() as $iri) { | |
| $links[] = (new Link('item', $iri))->withAttribute('anchor', $entrypoint); | |
| } | |
| if (!$this->docsEnabled) { | |
| return $links; | |
| } | |
| foreach (self::DOCUMENTATION_RELATIONS as $format => $rel) { | |
| if (!$mimeTypes = $this->docsFormats[$format] ?? null) { | |
| continue; | |
| } | |
| // The human-readable documentation is content negotiated, the other ones are explicit | |
| $parameters = 'html' === $format ? [] : ['_format' => $format]; | |
| $links[] = (new Link($rel, $this->urlGenerator->generate('api_doc', $parameters, UrlGeneratorInterface::ABS_URL))) | |
| ->withAttribute('anchor', $entrypoint) | |
| ->withAttribute('type', $mimeTypes[array_key_first($mimeTypes)]); | |
| } | |
| continue; | ||
| } | ||
|
|
||
| $seen[$shortName] = true; |
There was a problem hiding this comment.
do we really need this? imo it can be that there are several collection endpoints on a resource, not sure what the rfc says about this.
| "php": ">=8.2", | ||
| "api-platform/metadata": "^4.4@alpha" | ||
| "api-platform/metadata": "^4.4@alpha", | ||
| "symfony/web-link": "^7.4 || ^8.0" |
There was a problem hiding this comment.
If we put this on main (5.0 target) maybe we can even remove 7.4
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace ApiPlatform\State\Util; |
There was a problem hiding this comment.
We could put this directly to Documentation\Util as its only useful in there right?
| * implementation below otherwise. | ||
| * | ||
| * @see https://www.rfc-editor.org/rfc/rfc9264.html | ||
| * |
| * | ||
| * @author Florent Morselli <florent.morselli@spomky-labs.com> | ||
| */ | ||
| final class JsonLinksetSerializer |
There was a problem hiding this comment.
probably that we should add an interface if we want this to be overridden in userland, I'm not sure about the benefits though. Mhh actually no interface inside Symfony for that?
| /** | ||
| * Target attributes that RFC 8288 defines as non-repeatable strings. | ||
| * | ||
| * TODO: remove once "symfony/web-link" >= 8.2 is required |
There was a problem hiding this comment.
Target main and use this constraint.
| * | ||
| * @author Florent Morselli <florent.morselli@spomky-labs.com> | ||
| */ | ||
| final class LinkTemplateHeaderSerializer |
There was a problem hiding this comment.
same I'm wondering how a user can extend that, mark it as @internal
soyuka
left a comment
There was a problem hiding this comment.
Note: this review was written by Claude (Claude Code) at @soyuka's request, after reading the diff against RFC 9727, RFC 9652, RFC 9264 and RFC 8631. It complements the human review above; treat it accordingly.
Overall the implementation follows the three RFCs closely. Two findings seem worth addressing, plus a few smaller points.
1. Symfony mounts the catalog under the API prefix (RFC 8615)
The route is added to the api_platform loader collection in src/Symfony/Routing/ApiLoader.php. The Symfony recipe imports that resource with prefix: /api, and the prefix is applied to every route in the collection, so in a default install the catalog ends up at /api/.well-known/api-catalog. RFC 8615 roots well-known URIs at the host, so this is not a well-known URI anymore.
The api-catalog Link header on the entrypoint still points to the right place, so link-following clients work, but plain well-known discovery (GET https://host/.well-known/api-catalog) fails. The Laravel side gets this right by registering the route outside the prefix group.
Suggestion: expose the catalog as a separate routing resource that the recipe imports without a prefix, rather than inside the api_platform loader type. (genid has the same issue, but it is not an IANA-registered well-known URI, so it matters less.)
2. service-meta for the Hydra documentation
ApiCatalogFactory::DOCUMENTATION_RELATIONS maps jsonld to service-meta. RFC 8631 defines service-desc as the machine-readable description of the service and service-meta as general metadata (policies, licensing, …). Hydra ApiDocumentation is a service description, so it belongs under service-desc next to OpenAPI, distinguished by type. Linkset allows several targets per relation, so nothing else changes.
Smaller points
Vary: Acceptwithout negotiation. Both actions set it but always returnapplication/linkset+json. RFC 9727 §4.2 only says the publisher MAY negotiate. Either drop the header or actually negotiate the other formats.- Templated
hrefemitted as a Display String. In theLinkTemplateHeaderSerializerfallback, a non-ASCIIhrefis serialized as%"…". RFC 9652 §2 says the list members are Strings holding a URI Template, so the template should be percent-encoded instead of switching to a Display String. Edge case, but the fallback is exactly where it would bite. - Duplicate action bodies.
ApiCatalogActionandApiCatalogControllerare identical. A shared response builder inDocumentationwould remove one copy. hideHydraOperationreused as "hide from the catalog". Pragmatic, and it is the only existing hook that fits, but the name does not say so. Worth a line in the docs at minimum, since RFC 9727 §8 asks publishers to audit what the catalog exposes.- No enable flag. The catalog ships whenever the entrypoint is enabled. Given the security-audit guidance in RFC 9727 §8, a configuration switch would be reasonable.
Checked and OK against the specs
GETreturns the linkset with the RFC 9727 profile parameter;HEADcarries theapi-catalogLink (§2).- The entrypoint advertises the catalog with
rel="api-catalog"(§3). - Templated links are split out of
LinkintoLink-Template;rel/anchorare Strings, boolean parameters are bare keys, key syntax and Display String encoding match RFC 9651. - In the linkset,
media/title/typeare single-valued whilehreflangand extension attributes are arrays, as RFC 9264 §4.2 requires.
Three RFCs, three self-contained commits, so they can be split into separate PRs if preferred.
1.
Link-Templateheader (RFC 9652) — closes #6924Symfony\Component\WebLink\HttpHeaderSerializerskips every link whose href is a URI template:So a templated web link declared on an operation was silently dropped:
AddLinkHeaderProcessornow serializes those into aLink-Templateheader:Link-Template: "/books/{id}/author"; rel="author"; anchor="#{id}"Full RFC 9652 support: templated
anchor,var-base, target attributes as structured fieldparameters, and Display Strings for non-ASCII values (
title=%"Bj%c3%b6rn J%c3%a4rnsida").Each header is now only set when its serializer returns something, so a response whose links are
all templated no longer gets an empty
Linkheader.2. Link set serializer (RFC 9264)
ApiPlatform\State\Util\JsonLinksetSerializerturns a list of PSR-13 links into anapplication/linkset+jsondocument, grouped by link context then by relation type.3.
api-catalogwell-known URI (RFC 9727)RFC 9727 mandates the RFC 9264 link set format for the API catalog document, which makes it the
natural consumer of the serializer above.
{"linkset": [ {"anchor": "https://example.com/.well-known/api-catalog", "item": [{"href": "https://example.com/"}]}, {"anchor": "https://example.com/", "service-desc": [{"href": "https://example.com/docs.jsonopenapi", "type": "application/vnd.openapi+json"}], "service-doc": [{"href": "https://example.com/docs", "type": "text/html"}], "service-meta": [{"href": "https://example.com/docs.jsonld", "type": "application/ld+json"}], "item": [{"href": "https://example.com/books"}, {"href": "https://example.com/reviews"}]} ]}Everything is derived from metadata API Platform already has, so there is nothing to configure.
The route is registered with the entrypoint, so
enable_entrypoint: falsedisables it too.Per section 2, a
HEADrequest answers with theapi-cataloglink relation. Per section 3, theentrypoint advertises the catalog with the same relation, which is what lets clients find it when
the API is mounted under a route prefix and the document is therefore not at the host root. On
Laravel the route is registered outside the API prefix, since RFC 8615 roots well-known URIs at the
host.
Notes
[FrameworkBundle][WebLink] Add RFC 9264 link sets and the RFC 9652 Link-Template header symfony/symfony#65428, and fall back to a backport of that implementation otherwise, since
api-platform supports Symfony 7.4. Every duplicated part is marked with a
TODO: remove once "symfony/web-link" >= 8.2 is required.api-cataloglink relation, whichis an additive change to the
Linkheader.HttpOperation::$linksonly reaches the link providerthrough
HydraLinkProcessor, so web links declared on an operation are ignored when Hydra isdisabled. Left untouched here.