Florent Morselli
Contributed by Florent Morselli in #65428

The WebLink component manages the Link HTTP header defined by Web Linking (RFC 8288). Most applications use it to preload and prefetch assets, but links are also how HTTP APIs tell clients where related resources are. Symfony 8.2 adds the two specifications that build on Web Linking and were missing from the component: the Link-Template header (RFC 9652) and link sets (RFC 9264).

The Link header can only carry concrete URLs. When the target isn't known in advance (e.g. the URL of any item of a collection) you describe it with a URI template such as /users/{id}. The Link class already supported templated links, but there was no header to send them in, so Symfony silently dropped them from the response.

In Symfony 8.2, links added to the response are split between two headers: those pointing to a concrete URL go to Link and those using a URI template go to the new Link-Template header:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/Controller/UserController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\WebLink\Link;

class UserController extends AbstractController
{
    #[Route('/users', name: 'user_index')]
    public function index(Request $request): Response
    {
        $this->addLink($request, new Link('preload', '/app.css')->withAttribute('as', 'style'));
        $this->addLink($request, new Link('item', '/users/{id}'));

        // the response includes these headers:
        // Link: </app.css>; rel="preload"; as="style"
        // Link-Template: "/users/{id}"; rel="item"

        return $this->render('user/index.html.twig');
    }
}

You don't have to change anything in your application: the existing Link headers stay the same.

When consuming third-party APIs, use the new LinkTemplateHeaderParser class to turn the header back into Link objects:

1
2
3
4
5
6
7
8
9
use Symfony\Component\WebLink\LinkTemplateHeaderParser;

// $response is the HttpClient response of the request made to the API
$headers = $response->getHeaders()['link-template'] ?? [];
$links = new LinkTemplateHeaderParser()->parse($headers)->getLinks();

$links[0]->getHref();     // '/users/{id}'
$links[0]->isTemplated(); // true
$links[0]->getRels();     // ['item']

Headers are not always the right place for links: there may be too many of them, or you may want to publish them without making clients request each resource. A link set is a collection of links published as a standalone document. For example, RFC 9727 uses link sets to list the APIs of a host at the /.well-known/api-catalog URL.

RFC 9264 defines two formats for these documents. The application/linkset format uses the same syntax as the Link header, so the existing serializer and parser already handle it. For the application/linkset+json format, inject the new JsonLinksetSerializer class in your services and controllers:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// src/Controller/ApiCatalogController.php
namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\WebLink\JsonLinksetSerializer;
use Symfony\Component\WebLink\Link;

class ApiCatalogController
{
    #[Route('/.well-known/api-catalog', name: 'api_catalog')]
    public function __invoke(JsonLinksetSerializer $serializer): Response
    {
        $links = [
            new Link(Link::REL_SERVICE_DESC, 'https://example.com/api/books/openapi.yaml')
                ->withAttribute('anchor', 'https://example.com/api/books')
                ->withAttribute('type', 'application/yaml'),
            new Link(Link::REL_SERVICE_DOC, 'https://example.com/docs/books')
                ->withAttribute('anchor', 'https://example.com/api/books')
                ->withAttribute('type', 'text/html'),
        ];

        // the second argument is an optional bitmask of json_encode() options
        $document = $serializer->serialize($links, \JSON_UNESCAPED_SLASHES);

        return new Response($document, headers: [
            'Content-Type' => 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"',
        ]);
    }
}

In the resulting document (formatted here for readability), links are grouped by link context (their anchor attribute) and then by relation type:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
    "linkset": [
        {
            "anchor": "https://example.com/api/books",
            "service-desc": [
                {
                    "href": "https://example.com/api/books/openapi.yaml",
                    "type": "application/yaml"
                }
            ],
            "service-doc": [
                {
                    "href": "https://example.com/docs/books",
                    "type": "text/html"
                }
            ]
        }
    ]
}

Use the new JsonLinksetParser class to read link set documents:

1
2
3
4
5
6
7
8
use Symfony\Component\WebLink\JsonLinksetParser;

$links = new JsonLinksetParser()->parse($document)->getLinks();

$links[0]->getHref(); // 'https://example.com/api/books/openapi.yaml'
$links[0]->getRels(); // ['service-desc']
// ['anchor' => 'https://example.com/api/books', 'type' => 'application/yaml']
$links[0]->getAttributes();

Finally, to tell clients that a resource has a link set, add a link with the linkset relation type to its response:

1
2
$this->addLink($request, new Link(Link::REL_LINKSET, '/books/1234/links')
    ->withAttribute('type', 'application/linkset+json'));
Published in #Living on the edge