Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
189 changes: 189 additions & 0 deletions src/gleam/http.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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: <https://api.example.com/items?page=2>; rel="next",
/// <https://api.example.com/items?page=5>; 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 -> {
Comment thread
jtdowney marked this conversation as resolved.
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 `<URI>`.
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)
}
Comment thread
jtdowney marked this conversation as resolved.
}

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))
Comment thread
jtdowney marked this conversation as resolved.
Outdated

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))
}
}
Comment thread
jtdowney marked this conversation as resolved.

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))
}
}
Comment thread
jtdowney marked this conversation as resolved.

/// A HTTP header is a key-value pair. Header keys must be all lowercase
/// characters.
pub type Header =
Expand Down
138 changes: 138 additions & 0 deletions test/gleam/http_test.gleam
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import gleam/http
import gleam/uri

pub fn parse_method_test() {
assert http.parse_method("Connect") == Ok(http.Other("Connect"))
Expand Down Expand Up @@ -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("<https://example.com/page2>; rel=\"next\"")
== Ok([http.LinkHeader(uri1, [#("rel", "next")])])

assert http.parse_link_header(
"<https://example.com/page2>; rel=\"next\"; title=\"next chapter\"",
)
== Ok([
http.LinkHeader(uri1, [
#("rel", "next"),
#("title", "next chapter"),
]),
])

assert http.parse_link_header("<https://example.com>; REL=\"next\"")
== Ok([http.LinkHeader(uri2, [#("rel", "next")])])

assert http.parse_link_header("<https://example.com>; rel=next")
== Ok([http.LinkHeader(uri2, [#("rel", "next")])])

assert http.parse_link_header("<https://example.com>")
== Ok([http.LinkHeader(uri2, [])])

assert http.parse_link_header("<https://example.com>; rel=\"next\"; anchor")
== Ok([
http.LinkHeader(uri2, [#("rel", "next"), #("anchor", "")]),
])

assert http.parse_link_header("<https://example.com>; rel = \"next\"")
== Ok([http.LinkHeader(uri2, [#("rel", "next")])])

assert http.parse_link_header(
"<https://example.com>; 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(
"<https://example.com/page1>; rel=\"prev\", <https://example.com/page3>; 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(
"</TheBook/chapter2>; rel=\"previous\"; title*=UTF-8'de'letztes%20Kapitel, </TheBook/chapter4>; 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(
"<https://example.com/page2>; rel=\"next\"; title=\"page, two\", <https://example.com/page1>; rel=\"prev\"",
)
== Ok([
http.LinkHeader(uri1, [#("rel", "next"), #("title", "page, two")]),
http.LinkHeader(uri2, [#("rel", "prev")]),
])

assert http.parse_link_header(
"<https://example.com/page2>; 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(", <https://example.com>; rel=\"next\"")
== Ok([http.LinkHeader(uri1, [#("rel", "next")])])

assert http.parse_link_header(
"<https://a.com>; rel=\"prev\", , <https://b.com>; rel=\"next\"",
)
== Ok([
http.LinkHeader(uri2, [#("rel", "prev")]),
http.LinkHeader(uri3, [#("rel", "next")]),
])

assert http.parse_link_header("<https://example.com>; 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<https://example.com>; rel=\"next\"")
== Error(Nil)

assert http.parse_link_header("<https://example.com>; rel=") == Error(Nil)

assert http.parse_link_header("<https://example.com> rel=\"next\"")
== Error(Nil)

assert http.parse_link_header(
"<https://example.com>; rel=\"next\" title=\"foo\"",
)
== Error(Nil)

assert http.parse_link_header("<https://example.com>;") == Error(Nil)

assert http.parse_link_header("<https://example.com>; ") == Error(Nil)

}
Loading