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` 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 40cca32..7aab0af 100644 --- a/src/gleam/http.gleam +++ b/src/gleam/http.gleam @@ -10,6 +10,8 @@ import gleam/bool 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). @@ -650,6 +652,270 @@ 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) { + let sep = splitter.new([",", "\"", "\\"]) + header + |> split_link_values(sep, _, []) + |> 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( + 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.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) + } + } + } +} + +// 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( + sep: splitter.Splitter, + remaining: String, + current: String, + acc: List(String), + in_quote: Bool, +) -> List(String) { + 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. + #(before, "\\", rest) if in_quote -> { + case string.pop_grapheme(rest) { + Ok(#(escaped, rest)) -> + find_link_separator( + sep, + rest, + current <> before <> "\\" <> escaped, + acc, + in_quote, + ) + Error(_) -> + find_link_separator( + sep, + rest, + current <> before <> "\\", + 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) { + 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( + 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) { + let sep = splitter.new(["=", " ", "\t", ";"]) + case splitter.split(sep, header) { + #(prefix, "", "") -> + Ok(#(#(name <> string.lowercase(prefix), ""), "")) + + #(prefix, "=", rest) -> + parse_link_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)) + } +} + +fn parse_link_parameter_after_name( + header: String, + name: String, +) -> Result(#(#(String, String), String), Nil) { + case header { + "" -> Ok(#(#(name, ""), "")) + "=" <> rest -> + parse_rfc_2045_parameter_value(string.trim_start(rest), name) + _ -> Ok(#(#(name, ""), header)) + } +} + +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 0e51d96..faf0193 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,143 @@ 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("; title=foo; rel=\"next\"") + == Ok([http.LinkHeader(uri2, [#("title", "foo"), #("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) + +}