Link headers

edit

A Link header (RFC 8288) lists links related to a response: the next page of a collection, a resource to preload, the canonical URL of a document. LinkHeaderValue, in the Meziantou.Framework.Http package, parses it.

dotnet package add Meziantou.Framework.Http

EnumerateLinkHeaders parses every Link header of a response, or of any HttpHeaders, and returns one LinkHeaderValue per link:

using var response = new HttpResponseMessage();
response.Headers.Add("Link", "</items?page=2>; rel=next, </items?page=5>; rel=last");

var links = new List<string>();
foreach (var item in response.Headers.EnumerateLinkHeaders())
{
    links.Add($"{item.Rel}: {item.Url}");
}

Assert.Equal(["next: /items?page=2", "last: /items?page=5"], links);

LinkHeaderValue.Parse does the same from an HttpResponseMessage, an HttpHeaders, a string or a ReadOnlySpan<char>. A header can hold several links separated by commas, and a response can have several Link headers: the links of all of them are returned, in order.

GetLink returns the first link of a relation type, and GetLinkUrl its URL, or null when there is none:

// Follow pagination links
var nextUrl = response.Headers.EnumerateLinkHeaders().GetLinkUrl("next");
Assert.Equal("/items?page=2", nextUrl);

The relation type is compared case-insensitively. A link can declare several relation types separated by spaces (rel="next last"): it matches each of them, but not a part of one.

Url is the target as written between < and >. It is often relative: resolve it against the URI of the request before following it:

using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/items?page=1");
using var response = new HttpResponseMessage { RequestMessage = request };
response.Headers.Add("Link", "</items?page=2>; rel=next");

// Url is the target as written in the header. Resolve a relative target against the request URI.
var next = response.Headers.EnumerateLinkHeaders().GetLinkUrl("next");
Assert.NotNull(next);
var nextUri = new Uri(response.RequestMessage.RequestUri!, next);
Assert.Equal(new Uri("https://api.example.com/items?page=2"), nextUri);

Parameters lists the parameters of a link in order, and GetParameterValue returns the value of one, or null when the link does not have it:

var links = LinkHeaderValue.Parse("</style.css>; rel=preload; as=style; fetchpriority=\"high\"");
var link = links.GetLink("preload");
Assert.Equal("style", link?.GetParameterValue("as"));
  • Parameter names are lowercased, and GetParameterValue compares them case-insensitively.
  • A quoted value is unquoted and unescaped (\" becomes "). An unquoted value is trimmed.
  • A parameter without a value (; crossorigin) has an empty value.
  • Rel is the value of the rel parameter, or an empty string when there is none.
var links = LinkHeaderValue.Parse("<https://example.com/chapter2>; REL=\"next last\"; title=\"Chapter \\\"2\\\"\"; hreflang=en");
var link = Assert.Single(links);

// The names of the parameters are lowercased, and quoted values are unescaped
Assert.Equal(["rel", "title", "hreflang"], link.Parameters.Select(parameter => parameter.Key));
Assert.Equal("Chapter \"2\"", link.GetParameterValue("Title"));

// Rel holds the whole value; GetLink matches each of its space-separated relation types
Assert.Equal("next last", link.Rel);
Assert.Same(link, links.GetLink("last"));

Malformed headers

Parsing never throws on a malformed value. An element that does not start with a <target> is skipped, and the links that follow it are still returned:

// An element that is not a link-value is skipped, and the following ones are still read
var links = LinkHeaderValue.Parse("not-a-link, </a>; rel=first, , </b>; rel=second");
Assert.Equal(["/a", "/b"], links.Select(link => link.Url));

// A header without a link-value yields no link
Assert.Empty(LinkHeaderValue.Parse("rel=next"));

A comma inside the target (<https://example.com/a,b>) or inside a quoted value does not split the link.