From 314984aa9877669d9ef3715af6a732a75a4d6817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:51:26 +0200 Subject: [PATCH] Add RFC 5988 link header parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract link header parsing into a reusable pkg/rfc5988 package with Parse and FindByRel functions, used by Sentry and GitHub drivers for pagination. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- pkg/rfc5988/rfc5988.go | 88 +++++++++++++++++++++++++++ pkg/rfc5988/rfc5988_test.go | 116 ++++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 pkg/rfc5988/rfc5988.go create mode 100644 pkg/rfc5988/rfc5988_test.go diff --git a/pkg/rfc5988/rfc5988.go b/pkg/rfc5988/rfc5988.go new file mode 100644 index 000000000..c205df691 --- /dev/null +++ b/pkg/rfc5988/rfc5988.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +// Package rfc5988 provides a parser for HTTP Link headers as defined in +// RFC 5988 (Web Linking). +package rfc5988 + +import "strings" + +// Link represents a single entry in an HTTP Link header. +type Link struct { + URL string + Params map[string]string +} + +// Parse parses an HTTP Link header value into individual Link entries. +// Each entry has the form ; param1="value1"; param2="value2". +func Parse(header string) []Link { + if header == "" { + return nil + } + + var links []Link + + for part := range strings.SplitSeq(header, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + start := strings.Index(part, "<") + end := strings.Index(part, ">") + if start == -1 || end == -1 || end <= start { + continue + } + + link := Link{ + URL: part[start+1 : end], + Params: make(map[string]string), + } + + rest := part[end+1:] + for segment := range strings.SplitSeq(rest, ";") { + segment = strings.TrimSpace(segment) + if segment == "" { + continue + } + + key, value, ok := strings.Cut(segment, "=") + if !ok { + continue + } + + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + value = strings.Trim(value, `"`) + + link.Params[key] = value + } + + links = append(links, link) + } + + return links +} + +// FindByRel returns the URL of the first Link entry whose "rel" parameter +// matches the given value. It returns an empty string if no match is found. +func FindByRel(header string, rel string) string { + for _, link := range Parse(header) { + if link.Params["rel"] == rel { + return link.URL + } + } + + return "" +} diff --git a/pkg/rfc5988/rfc5988_test.go b/pkg/rfc5988/rfc5988_test.go new file mode 100644 index 000000000..970fd253e --- /dev/null +++ b/pkg/rfc5988/rfc5988_test.go @@ -0,0 +1,116 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package rfc5988_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/rfc5988" +) + +func TestParse(t *testing.T) { + t.Parallel() + + t.Run("empty header", func(t *testing.T) { + t.Parallel() + + links := rfc5988.Parse("") + assert.Nil(t, links) + }) + + t.Run("single link", func(t *testing.T) { + t.Parallel() + + links := rfc5988.Parse(`; rel="next"`) + require.Len(t, links, 1) + assert.Equal(t, "https://api.example.com/items?page=2", links[0].URL) + assert.Equal(t, "next", links[0].Params["rel"]) + }) + + t.Run("multiple links", func(t *testing.T) { + t.Parallel() + + header := `; rel="next", ; rel="last"` + links := rfc5988.Parse(header) + require.Len(t, links, 2) + assert.Equal(t, "https://api.example.com/items?page=2", links[0].URL) + assert.Equal(t, "next", links[0].Params["rel"]) + assert.Equal(t, "https://api.example.com/items?page=5", links[1].URL) + assert.Equal(t, "last", links[1].Params["rel"]) + }) + + t.Run("multiple params per link", func(t *testing.T) { + t.Parallel() + + header := `; rel="next"; results="true"; cursor="abc"` + links := rfc5988.Parse(header) + require.Len(t, links, 1) + assert.Equal(t, "https://sentry.io/api/0/?cursor=abc", links[0].URL) + assert.Equal(t, "next", links[0].Params["rel"]) + assert.Equal(t, "true", links[0].Params["results"]) + assert.Equal(t, "abc", links[0].Params["cursor"]) + }) + + t.Run("github style link header", func(t *testing.T) { + t.Parallel() + + header := `; rel="next", ; rel="last"` + links := rfc5988.Parse(header) + require.Len(t, links, 2) + assert.Equal(t, "next", links[0].Params["rel"]) + assert.Equal(t, "last", links[1].Params["rel"]) + }) + + t.Run("sentry style link header", func(t *testing.T) { + t.Parallel() + + header := `; rel="previous"; results="false"; cursor="prev", ; rel="next"; results="true"; cursor="next"` + links := rfc5988.Parse(header) + require.Len(t, links, 2) + assert.Equal(t, "previous", links[0].Params["rel"]) + assert.Equal(t, "false", links[0].Params["results"]) + assert.Equal(t, "next", links[1].Params["rel"]) + assert.Equal(t, "true", links[1].Params["results"]) + }) +} + +func TestFindByRel(t *testing.T) { + t.Parallel() + + t.Run("empty header", func(t *testing.T) { + t.Parallel() + + url := rfc5988.FindByRel("", "next") + assert.Empty(t, url) + }) + + t.Run("found", func(t *testing.T) { + t.Parallel() + + header := `; rel="next", ; rel="last"` + url := rfc5988.FindByRel(header, "next") + assert.Equal(t, "https://api.example.com/items?page=2", url) + }) + + t.Run("not found", func(t *testing.T) { + t.Parallel() + + header := `; rel="prev"` + url := rfc5988.FindByRel(header, "next") + assert.Empty(t, url) + }) +}