From adf86e7903ffd30988fdefb44d25da36c9464eff Mon Sep 17 00:00:00 2001 From: John Downey Date: Mon, 9 Mar 2026 17:18:59 -0500 Subject: [PATCH 1/4] Add Link header parsing Closes #78 --- src/gleam/http.gleam | 189 +++++++++++++++++++++++++++++++++++++ test/gleam/http_test.gleam | 138 +++++++++++++++++++++++++++ 2 files changed, 327 insertions(+) diff --git a/src/gleam/http.gleam b/src/gleam/http.gleam index 40cca32..71dd9b3 100644 --- a/src/gleam/http.gleam +++ b/src/gleam/http.gleam @@ -10,6 +10,7 @@ import gleam/bool import gleam/list import gleam/result import gleam/string +import gleam/uri /// HTTP standard method as defined by [RFC 2616](https://tools.ietf.org/html/rfc2616), /// and PATCH which is defined by [RFC 5789](https://tools.ietf.org/html/rfc5789). @@ -650,6 +651,194 @@ fn parse_rfc_2045_parameter_unquoted_value( } } +/// A link parsed from a Link header value. +/// +/// Link headers are used in REST APIs for pagination and discoverability, +/// as described by [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288.html). +/// +/// The `uri` field contains the parsed URI reference from within the angle brackets, +/// and the `parameters` field contains any link parameters such as `rel`, +/// `title`, etc. Parameter names are lowercased. Extended parameters +/// (e.g. `title*`) are not decoded and are returned as raw strings. +/// +pub type LinkHeader { + LinkHeader(uri: uri.Uri, parameters: List(#(String, String))) +} + +/// Parse a Link header value into its component links and parameters. +/// +/// Parses the syntax described by +/// [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288.html). +/// Link headers are commonly used in REST APIs for pagination and resource +/// navigation. For example, an API might return: +/// +/// ``` +/// Link: ; rel="next", +/// ; rel="last" +/// ``` +/// +/// Parameters are returned in the order they appear. Duplicate parameters +/// (such as multiple `rel` or `title` values) are preserved as-is; it is +/// the caller's responsibility to select the appropriate value, for example +/// by using `list.find` to get the first occurrence. +/// +pub fn parse_link_header(header: String) -> Result(List(LinkHeader), Nil) { + header + |> split_link_values([]) + |> list.try_map(parse_link_value) +} + +// Link values are comma-separated, but commas can also appear inside quoted +// parameter values like `title="page, two"`. We split in two phases: +// +// 1. Use `split_once(_, ">")` to quickly jump past the URI (which is always +// enclosed in angle brackets). The `>` character is not valid in URIs +// per RFC 3986, so the first `>` always closes the URI. +// +// 2. Scan the remaining params character-by-character to find the comma +// separator, skipping commas inside quoted strings. +// +fn split_link_values(remaining: String, acc: List(String)) -> List(String) { + case string.trim(remaining) { + "" -> list.reverse(acc) + trimmed -> { + case string.pop_grapheme(trimmed) { + // Skip empty list elements + Ok(#(",", rest)) -> split_link_values(rest, acc) + _ -> { + case string.split_once(trimmed, ">") { + // No closing `>`, so include the text as-is. parse_link_value + // will reject it, propagating Error through try_map. + Error(_) -> list.reverse([trimmed, ..acc]) + Ok(#(before_close, after_close)) -> + find_link_separator(after_close, before_close <> ">", acc, False) + } + } + } + } + } +} + +// Walk the params portion after `>` looking for an unquoted comma. +// Escaped characters inside quotes (e.g. `\"`) are consumed in pairs +// so they don't toggle the quote state. +fn find_link_separator( + remaining: String, + current: String, + acc: List(String), + in_quote: Bool, +) -> List(String) { + case string.pop_grapheme(remaining) { + Error(_) -> { + case string.trim(current) { + "" -> list.reverse(acc) + trimmed -> list.reverse([trimmed, ..acc]) + } + } + + // Inside quotes, a backslash escapes the next character. + Ok(#("\\", rest)) if in_quote -> { + case string.pop_grapheme(rest) { + Ok(#(escaped, rest)) -> + find_link_separator(rest, current <> "\\" <> escaped, acc, in_quote) + Error(_) -> find_link_separator(rest, current <> "\\", acc, in_quote) + } + } + + Ok(#("\"", rest)) -> + find_link_separator(rest, current <> "\"", acc, !in_quote) + + // Unquoted comma means this link value is complete. Hand the rest + // back to split_link_values to find the next ``. + Ok(#(",", rest)) if !in_quote -> + split_link_values(rest, [string.trim(current), ..acc]) + + Ok(#(char, rest)) -> + find_link_separator(rest, current <> char, acc, in_quote) + } +} + +fn parse_link_value(link_value: String) -> Result(LinkHeader, Nil) { + use #(before_open, after_open) <- result.try(string.split_once( + link_value, + "<", + )) + use <- bool.guard(when: string.trim(before_open) != "", return: Error(Nil)) + + use #(uri_string, params) <- result.try(string.split_once(after_open, ">")) + use uri <- result.try(uri.parse(uri_string)) + + parse_link_parameters(params, []) + |> result.map(LinkHeader(uri, _)) +} + +fn parse_link_parameters( + header: String, + parameters: List(#(String, String)), +) -> Result(List(#(String, String)), Nil) { + case string.pop_grapheme(header) { + Error(Nil) -> Ok(list.reverse(parameters)) + + Ok(#(" ", rest)) | Ok(#("\t", rest)) -> + parse_link_parameters(rest, parameters) + + Ok(#(";", rest)) -> parse_link_parameters_after_semicolon(rest, parameters) + + _ -> Error(Nil) + } +} + +fn parse_link_parameters_after_semicolon( + header: String, + parameters: List(#(String, String)), +) -> Result(List(#(String, String)), Nil) { + case string.pop_grapheme(header) { + // Trailing semicolon with no parameter name is malformed + Error(Nil) -> Error(Nil) + + Ok(#(" ", rest)) | Ok(#("\t", rest)) -> + parse_link_parameters_after_semicolon(rest, parameters) + + Ok(#(grapheme, rest)) -> { + let name = string.lowercase(grapheme) + use #(parameter, rest) <- result.try(parse_link_parameter_name(rest, name)) + parse_link_parameters(rest, [parameter, ..parameters]) + } + } +} + +fn parse_link_parameter_name( + header: String, + name: String, +) -> Result(#(#(String, String), String), Nil) { + case string.pop_grapheme(header) { + Error(Nil) -> Ok(#(#(name, ""), "")) + + Ok(#("=", rest)) -> + parse_rfc_2045_parameter_value(string.trim_start(rest), name) + + Ok(#(" ", rest)) | Ok(#("\t", rest)) -> + parse_link_parameter_after_name(string.trim_start(rest), name) + + Ok(#(";", rest)) -> Ok(#(#(name, ""), ";" <> rest)) + + Ok(#(grapheme, rest)) -> + parse_link_parameter_name(rest, name <> string.lowercase(grapheme)) + } +} + +fn parse_link_parameter_after_name( + header: String, + name: String, +) -> Result(#(#(String, String), String), Nil) { + case string.pop_grapheme(header) { + Error(Nil) -> Ok(#(#(name, ""), "")) + Ok(#("=", rest)) -> + parse_rfc_2045_parameter_value(string.trim_start(rest), name) + _ -> Ok(#(#(name, ""), header)) + } +} + /// A HTTP header is a key-value pair. Header keys must be all lowercase /// characters. pub type Header = diff --git a/test/gleam/http_test.gleam b/test/gleam/http_test.gleam index 0e51d96..6805d83 100644 --- a/test/gleam/http_test.gleam +++ b/test/gleam/http_test.gleam @@ -1,4 +1,5 @@ import gleam/http +import gleam/uri pub fn parse_method_test() { assert http.parse_method("Connect") == Ok(http.Other("Connect")) @@ -145,3 +146,140 @@ pub fn parse_content_disposition_1_test() { assert http.parse_content_disposition("file; filename=\"file2") == Error(Nil) } + +pub fn parse_link_header_test() { + let assert Ok(uri1) = uri.parse("https://example.com/page2") + let assert Ok(uri2) = uri.parse("https://example.com") + + assert http.parse_link_header("; rel=\"next\"") + == Ok([http.LinkHeader(uri1, [#("rel", "next")])]) + + assert http.parse_link_header( + "; rel=\"next\"; title=\"next chapter\"", + ) + == Ok([ + http.LinkHeader(uri1, [ + #("rel", "next"), + #("title", "next chapter"), + ]), + ]) + + assert http.parse_link_header("; REL=\"next\"") + == Ok([http.LinkHeader(uri2, [#("rel", "next")])]) + + assert http.parse_link_header("; rel=next") + == Ok([http.LinkHeader(uri2, [#("rel", "next")])]) + + assert http.parse_link_header("") + == Ok([http.LinkHeader(uri2, [])]) + + assert http.parse_link_header("; rel=\"next\"; anchor") + == Ok([ + http.LinkHeader(uri2, [#("rel", "next"), #("anchor", "")]), + ]) + + assert http.parse_link_header("; rel = \"next\"") + == Ok([http.LinkHeader(uri2, [#("rel", "next")])]) + + assert http.parse_link_header( + "; rel=\"next\"; rel=\"prev\"", + ) + == Ok([ + http.LinkHeader(uri2, [#("rel", "next"), #("rel", "prev")]), + ]) + + assert http.parse_link_header("") == Ok([]) +} + +pub fn parse_link_header_multiple_links_test() { + let assert Ok(uri1) = uri.parse("https://example.com/page1") + let assert Ok(uri2) = uri.parse("https://example.com/page3") + assert http.parse_link_header( + "; rel=\"prev\", ; rel=\"next\"", + ) + == Ok([ + http.LinkHeader(uri1, [#("rel", "prev")]), + http.LinkHeader(uri2, [#("rel", "next")]), + ]) + + let assert Ok(uri3) = uri.parse("/TheBook/chapter2") + let assert Ok(uri4) = uri.parse("/TheBook/chapter4") + assert http.parse_link_header( + "; rel=\"previous\"; title*=UTF-8'de'letztes%20Kapitel, ; rel=\"next\"; title*=UTF-8'de'n%c3%a4chstes%20Kapitel", + ) + == Ok([ + http.LinkHeader(uri3, [ + #("rel", "previous"), + #("title*", "UTF-8'de'letztes%20Kapitel"), + ]), + http.LinkHeader(uri4, [ + #("rel", "next"), + #("title*", "UTF-8'de'n%c3%a4chstes%20Kapitel"), + ]), + ]) +} + +pub fn parse_link_header_quoting_test() { + let assert Ok(uri1) = uri.parse("https://example.com/page2") + let assert Ok(uri2) = uri.parse("https://example.com/page1") + assert http.parse_link_header( + "; rel=\"next\"; title=\"page, two\", ; rel=\"prev\"", + ) + == Ok([ + http.LinkHeader(uri1, [#("rel", "next"), #("title", "page, two")]), + http.LinkHeader(uri2, [#("rel", "prev")]), + ]) + + assert http.parse_link_header( + "; rel=\"next\"; title=\"say \\\"hello\\\"\"", + ) + == Ok([ + http.LinkHeader(uri1, [ + #("rel", "next"), + #("title", "say \"hello\""), + ]), + ]) +} + +pub fn parse_link_header_empty_elements_test() { + let assert Ok(uri1) = uri.parse("https://example.com") + let assert Ok(uri2) = uri.parse("https://a.com") + let assert Ok(uri3) = uri.parse("https://b.com") + + assert http.parse_link_header(", ; rel=\"next\"") + == Ok([http.LinkHeader(uri1, [#("rel", "next")])]) + + assert http.parse_link_header( + "; rel=\"prev\", , ; rel=\"next\"", + ) + == Ok([ + http.LinkHeader(uri2, [#("rel", "prev")]), + http.LinkHeader(uri3, [#("rel", "next")]), + ]) + + assert http.parse_link_header("; rel=\"next\",") + == Ok([http.LinkHeader(uri1, [#("rel", "next")])]) +} + +pub fn parse_link_header_error_test() { + assert http.parse_link_header("https://example.com; rel=\"next\"") + == Error(Nil) + + assert http.parse_link_header("garbage; rel=\"next\"") + == Error(Nil) + + assert http.parse_link_header("; rel=") == Error(Nil) + + assert http.parse_link_header(" rel=\"next\"") + == Error(Nil) + + assert http.parse_link_header( + "; rel=\"next\" title=\"foo\"", + ) + == Error(Nil) + + assert http.parse_link_header(";") == Error(Nil) + + assert http.parse_link_header("; ") == Error(Nil) + +} From 53937381eddaf092d68dcffa3eeddad694fc5630 Mon Sep 17 00:00:00 2001 From: John Downey Date: Mon, 9 Mar 2026 20:45:15 -0500 Subject: [PATCH 2/4] Add CHANGELOG entry for Link header parsing --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71a228d..c741435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- The `gleam/http` module gains the `LinkHeader` type and `parse_link_header` + function for parsing Link headers. + ## v4.3.0 - 2025-10-02 - The performance of the `parse_multipart_headers` and `parse_multipart_body` From 130235ffac5cc5ca8bbb7cf4289725b70737025c Mon Sep 17 00:00:00 2001 From: John Downey Date: Sun, 15 Mar 2026 13:02:18 -0500 Subject: [PATCH 3/4] Use splitter package and pattern matching in link parser --- gleam.toml | 1 + src/gleam/http.gleam | 173 +++++++++++++++++++++++++++++-------------- 2 files changed, 117 insertions(+), 57 deletions(-) diff --git a/gleam.toml b/gleam.toml index 59f7bce..4bbd93c 100644 --- a/gleam.toml +++ b/gleam.toml @@ -12,6 +12,7 @@ links = [ [dependencies] gleam_stdlib = ">= 0.45.0 and < 2.0.0" +splitter = ">= 1.2.0 and < 2.0.0" [dev-dependencies] gleeunit = "~> 1.0" diff --git a/src/gleam/http.gleam b/src/gleam/http.gleam index 71dd9b3..ab783ba 100644 --- a/src/gleam/http.gleam +++ b/src/gleam/http.gleam @@ -11,6 +11,7 @@ import gleam/list import gleam/result import gleam/string import gleam/uri +import splitter /// HTTP standard method as defined by [RFC 2616](https://tools.ietf.org/html/rfc2616), /// and PATCH which is defined by [RFC 5789](https://tools.ietf.org/html/rfc5789). @@ -683,8 +684,9 @@ pub type LinkHeader { /// by using `list.find` to get the first occurrence. /// pub fn parse_link_header(header: String) -> Result(List(LinkHeader), Nil) { + let sep = splitter.new([",", "\"", "\\"]) header - |> split_link_values([]) + |> split_link_values(sep, _, []) |> list.try_map(parse_link_value) } @@ -698,22 +700,21 @@ pub fn parse_link_header(header: String) -> Result(List(LinkHeader), Nil) { // 2. Scan the remaining params character-by-character to find the comma // separator, skipping commas inside quoted strings. // -fn split_link_values(remaining: String, acc: List(String)) -> List(String) { +fn split_link_values( + sep: splitter.Splitter, + remaining: String, + acc: List(String), +) -> List(String) { case string.trim(remaining) { "" -> list.reverse(acc) + "," <> rest -> split_link_values(sep, rest, acc) trimmed -> { - case string.pop_grapheme(trimmed) { - // Skip empty list elements - Ok(#(",", rest)) -> split_link_values(rest, acc) - _ -> { - case string.split_once(trimmed, ">") { - // No closing `>`, so include the text as-is. parse_link_value - // will reject it, propagating Error through try_map. - Error(_) -> list.reverse([trimmed, ..acc]) - Ok(#(before_close, after_close)) -> - find_link_separator(after_close, before_close <> ">", acc, False) - } - } + case string.split_once(trimmed, ">") { + // No closing `>`, so include the text as-is. parse_link_value + // will reject it, propagating Error through try_map. + Error(_) -> list.reverse([trimmed, ..acc]) + Ok(#(before_close, after_close)) -> + find_link_separator(sep, after_close, before_close <> ">", acc, False) } } } @@ -723,53 +724,102 @@ fn split_link_values(remaining: String, acc: List(String)) -> List(String) { // Escaped characters inside quotes (e.g. `\"`) are consumed in pairs // so they don't toggle the quote state. fn find_link_separator( + sep: splitter.Splitter, remaining: String, current: String, acc: List(String), in_quote: Bool, ) -> List(String) { - case string.pop_grapheme(remaining) { - Error(_) -> { - case string.trim(current) { + case splitter.split(sep, remaining) { + // No delimiter found — end of input. + #(rest, "", "") -> { + case string.trim(current <> rest) { "" -> list.reverse(acc) trimmed -> list.reverse([trimmed, ..acc]) } } // Inside quotes, a backslash escapes the next character. - Ok(#("\\", rest)) if in_quote -> { + #(before, "\\", rest) if in_quote -> { case string.pop_grapheme(rest) { Ok(#(escaped, rest)) -> - find_link_separator(rest, current <> "\\" <> escaped, acc, in_quote) - Error(_) -> find_link_separator(rest, current <> "\\", acc, in_quote) + find_link_separator( + sep, + rest, + current <> before <> "\\" <> escaped, + acc, + in_quote, + ) + Error(_) -> + find_link_separator( + sep, + rest, + current <> before <> "\\", + acc, + in_quote, + ) } } - Ok(#("\"", rest)) -> - find_link_separator(rest, current <> "\"", acc, !in_quote) - - // Unquoted comma means this link value is complete. Hand the rest - // back to split_link_values to find the next ``. - Ok(#(",", rest)) if !in_quote -> - split_link_values(rest, [string.trim(current), ..acc]) - - Ok(#(char, rest)) -> - find_link_separator(rest, current <> char, acc, in_quote) + // Backslash outside quotes — not special, just include it. + #(before, "\\", rest) -> + find_link_separator( + sep, + rest, + current <> before <> "\\", + acc, + in_quote, + ) + + // Quote toggles in_quote state. + #(before, "\"", rest) -> + find_link_separator( + sep, + rest, + current <> before <> "\"", + acc, + !in_quote, + ) + + // Comma inside quotes — not a separator, keep going. + #(before, ",", rest) if in_quote -> + find_link_separator( + sep, + rest, + current <> before <> ",", + acc, + in_quote, + ) + + // Unquoted comma means this link value is complete. + #(before, ",", rest) -> + split_link_values(sep, rest, [string.trim(current <> before), ..acc]) + + #(before, delim, rest) -> + find_link_separator( + sep, + rest, + current <> before <> delim, + acc, + in_quote, + ) } } fn parse_link_value(link_value: String) -> Result(LinkHeader, Nil) { - use #(before_open, after_open) <- result.try(string.split_once( - link_value, - "<", - )) - use <- bool.guard(when: string.trim(before_open) != "", return: Error(Nil)) - - use #(uri_string, params) <- result.try(string.split_once(after_open, ">")) - use uri <- result.try(uri.parse(uri_string)) - - parse_link_parameters(params, []) - |> result.map(LinkHeader(uri, _)) + case string.trim_start(link_value) { + "<" <> after_open -> { + use #(uri_string, params) <- result.try(string.split_once( + after_open, + ">", + )) + use uri <- result.try(uri.parse(uri_string)) + + parse_link_parameters(params, []) + |> result.map(LinkHeader(uri, _)) + } + _ -> Error(Nil) + } } fn parse_link_parameters( @@ -811,19 +861,28 @@ fn parse_link_parameter_name( header: String, name: String, ) -> Result(#(#(String, String), String), Nil) { - case string.pop_grapheme(header) { - Error(Nil) -> Ok(#(#(name, ""), "")) - - Ok(#("=", rest)) -> - parse_rfc_2045_parameter_value(string.trim_start(rest), name) - - Ok(#(" ", rest)) | Ok(#("\t", rest)) -> - parse_link_parameter_after_name(string.trim_start(rest), name) - - Ok(#(";", rest)) -> Ok(#(#(name, ""), ";" <> rest)) - - Ok(#(grapheme, rest)) -> - parse_link_parameter_name(rest, name <> string.lowercase(grapheme)) + let sep = splitter.new(["=", " ", "\t", ";"]) + case splitter.split(sep, header) { + #(prefix, "", "") -> + Ok(#(#(name <> string.lowercase(prefix), ""), "")) + + #(prefix, "=", rest) -> + parse_rfc_2045_parameter_value( + string.trim_start(rest), + name <> string.lowercase(prefix), + ) + + #(prefix, " ", rest) | #(prefix, "\t", rest) -> + parse_link_parameter_after_name( + string.trim_start(rest), + name <> string.lowercase(prefix), + ) + + #(prefix, ";", rest) -> + Ok(#(#(name <> string.lowercase(prefix), ""), ";" <> rest)) + + #(prefix, _, rest) -> + Ok(#(#(name <> string.lowercase(prefix), ""), rest)) } } @@ -831,9 +890,9 @@ fn parse_link_parameter_after_name( header: String, name: String, ) -> Result(#(#(String, String), String), Nil) { - case string.pop_grapheme(header) { - Error(Nil) -> Ok(#(#(name, ""), "")) - Ok(#("=", rest)) -> + case header { + "" -> Ok(#(#(name, ""), "")) + "=" <> rest -> parse_rfc_2045_parameter_value(string.trim_start(rest), name) _ -> Ok(#(#(name, ""), header)) } From 2eea4441743cdde35140079ef201d57a17486c4d Mon Sep 17 00:00:00 2001 From: John Downey Date: Mon, 23 Mar 2026 17:11:06 -0400 Subject: [PATCH 4/4] Fix unquoted link param values consuming semicolon parse_rfc_2045_parameter_unquoted_value consumes the ";" delimiter, so parse_link_parameters cannot find it to recognize the next parameter. Add parse_link_parameter_value that uses a splitter to preserve ";" in the returned rest. --- src/gleam/http.gleam | 20 +++++++++++++++++++- test/gleam/http_test.gleam | 3 +++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/gleam/http.gleam b/src/gleam/http.gleam index ab783ba..7aab0af 100644 --- a/src/gleam/http.gleam +++ b/src/gleam/http.gleam @@ -867,7 +867,7 @@ fn parse_link_parameter_name( Ok(#(#(name <> string.lowercase(prefix), ""), "")) #(prefix, "=", rest) -> - parse_rfc_2045_parameter_value( + parse_link_parameter_value( string.trim_start(rest), name <> string.lowercase(prefix), ) @@ -898,6 +898,24 @@ fn parse_link_parameter_after_name( } } +fn parse_link_parameter_value( + header: String, + name: String, +) -> Result(#(#(String, String), String), Nil) { + case header { + "" -> Error(Nil) + "\"" <> rest -> parse_rfc_2045_parameter_quoted_value(rest, name, "") + _ -> { + let sep = splitter.new([";", " ", "\t"]) + case splitter.split(sep, header) { + #("", _, _) -> Error(Nil) + #(value, ";", after) -> Ok(#(#(name, value), ";" <> after)) + #(value, _, after) -> Ok(#(#(name, value), after)) + } + } + } +} + /// A HTTP header is a key-value pair. Header keys must be all lowercase /// characters. pub type Header = diff --git a/test/gleam/http_test.gleam b/test/gleam/http_test.gleam index 6805d83..faf0193 100644 --- a/test/gleam/http_test.gleam +++ b/test/gleam/http_test.gleam @@ -170,6 +170,9 @@ pub fn parse_link_header_test() { assert http.parse_link_header("; rel=next") == Ok([http.LinkHeader(uri2, [#("rel", "next")])]) + assert http.parse_link_header("; title=foo; rel=\"next\"") + == Ok([http.LinkHeader(uri2, [#("title", "foo"), #("rel", "next")])]) + assert http.parse_link_header("") == Ok([http.LinkHeader(uri2, [])])