Skip to content

feat: Link-Template (RFC 9652), link sets (RFC 9264) and api-catalog (RFC 9727) - #8468

Open
Spomky wants to merge 3 commits into
api-platform:4.4from
Spomky:feat/link-template-rfc9652
Open

feat: Link-Template (RFC 9652), link sets (RFC 9264) and api-catalog (RFC 9727)#8468
Spomky wants to merge 3 commits into
api-platform:4.4from
Spomky:feat/link-template-rfc9652

Conversation

@Spomky

@Spomky Spomky commented Aug 19, 2026

Copy link
Copy Markdown
Contributor
Q A
Branch? 4.4
Tickets Closes #6924
License MIT
Doc PR api-platform/docs#TODO

Three RFCs, three self-contained commits, so they can be split into separate PRs if preferred.

1. Link-Template header (RFC 9652) — closes #6924

Symfony\Component\WebLink\HttpHeaderSerializer skips every link whose href is a URI template:

foreach ($links as $link) {
    if ($link->isTemplated()) {
        continue;
    }
    // ...
}

So a templated web link declared on an operation was silently dropped:

#[Get(uriTemplate: 'books/{id}', links: [new Link('author', '/books/{id}/author')])]

AddLinkHeaderProcessor now serializes those into a Link-Template header:

Link-Template: "/books/{id}/author"; rel="author"; anchor="#{id}"

Full RFC 9652 support: templated anchor, var-base, target attributes as structured field
parameters, 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 Link header.

2. Link set serializer (RFC 9264)

ApiPlatform\State\Util\JsonLinksetSerializer turns a list of PSR-13 links into an
application/linkset+json document, grouped by link context then by relation type.

3. api-catalog well-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.

GET /.well-known/api-catalog

HTTP/1.1 200 OK
Content-Type: application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"
Link: </.well-known/api-catalog>; rel="api-catalog"
{"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: false disables it too.

Per section 2, a HEAD request answers with the api-catalog link relation. Per section 3, the
entrypoint 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

  • The two serializers delegate to symfony/web-link 8.2 and later, which gained them in
    [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.
  • Two Laravel tests were updated: the entrypoint now carries the api-catalog link relation, which
    is an additive change to the Link header.
  • Unrelated, noticed while working on this: HttpOperation::$links only reaches the link provider
    through HydraLinkProcessor, so web links declared on an operation are ignored when Hydra is
    disabled. Left untouched here.

Spomky added 3 commits August 19, 2026 10:11
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 soyuka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great stuff! This is my human review I'll also run a quick agent check, thanks!

Comment on lines +84 to +101
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);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we put this on main (5.0 target) maybe we can even remove 7.4


declare(strict_types=1);

namespace ApiPlatform\State\Util;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
*

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

*
* @author Florent Morselli <florent.morselli@spomky-labs.com>
*/
final class JsonLinksetSerializer

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Target main and use this constraint.

*
* @author Florent Morselli <florent.morselli@spomky-labs.com>
*/
final class LinkTemplateHeaderSerializer

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same I'm wondering how a user can extend that, mark it as @internal

@soyuka soyuka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: Accept without negotiation. Both actions set it but always return application/linkset+json. RFC 9727 §4.2 only says the publisher MAY negotiate. Either drop the header or actually negotiate the other formats.
  • Templated href emitted as a Display String. In the LinkTemplateHeaderSerializer fallback, a non-ASCII href is 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. ApiCatalogAction and ApiCatalogController are identical. A shared response builder in Documentation would remove one copy.
  • hideHydraOperation reused 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

  • GET returns the linkset with the RFC 9727 profile parameter; HEAD carries the api-catalog Link (§2).
  • The entrypoint advertises the catalog with rel="api-catalog" (§3).
  • Templated links are split out of Link into Link-Template; rel/anchor are Strings, boolean parameters are bare keys, key syntax and Display String encoding match RFC 9651.
  • In the linkset, media/title/type are single-valued while hreflang and extension attributes are arrays, as RFC 9264 §4.2 requires.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants