Skip to content

Commit 7c515ca

Browse files
[Repo Assist] Add Http.ParseLinkHeader utility for RFC 5988 Link headers (#1675)
* Add Http.ParseLinkHeader utility for RFC 5988 Link headers Adds a new static method Http.ParseLinkHeader that parses the 'Link' response header (RFC 5988) into a Map<string, string> from relation type to URL. This is useful for consuming paginated REST APIs such as GitHub, GitLab, etc. that use Link headers to provide next/prev/last page URLs. Also adds documentation in Http.fsx showing a complete pagination example using JsonProvider and ParseLinkHeader together. Closes #805 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: trigger CI checks --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 09903ec commit 7c515ca

4 files changed

Lines changed: 89 additions & 0 deletions

File tree

RELEASE_NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 8.1.0-beta
44

5+
- Add `Http.ParseLinkHeader` utility for parsing RFC 5988 `Link` response headers (used by GitHub, GitLab, and other paginated APIs) into a `Map<string, string>` from relation name to URL (closes #805)
56
- Add `PreferDateTimeOffset` parameter to `CsvProvider`, `JsonProvider`, and `XmlProvider`: when true, date-time values without an explicit timezone offset are inferred as `DateTimeOffset` (using local offset) instead of `DateTime` (closes #1100, #1072)
67
- Make `Http.AppendQueryToUrl` public (closes #1325)
78
- Add `PreferOptionals` parameter to `JsonProvider` and `XmlProvider` (defaults to `true` to match existing behavior; set to `false` to use empty string or `NaN` for missing values, like the CsvProvider default) (closes #649)

docs/library/Http.fsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,47 @@ Http.Request(
358358
)
359359
)
360360

361+
(**
362+
## Paginated APIs (RFC 5988 Link headers)
363+
364+
Many REST APIs — including GitHub, GitLab, and others — use the `Link` response header
365+
(defined by [RFC 5988](https://tools.ietf.org/html/rfc5988)) to indicate pagination URLs.
366+
A typical `Link` header looks like this:
367+
368+
```
369+
<https://api.github.com/repos/octocat/hello-world/releases?page=2>; rel="next",
370+
<https://api.github.com/repos/octocat/hello-world/releases?page=5>; rel="last"
371+
```
372+
373+
The `cref:M:FSharp.Data.Http.ParseLinkHeader` utility parses such a header into a
374+
`Map<string, string>` from relation type to URL. You can then use the result to walk through pages:
375+
*)
376+
377+
(*** do-not-eval ***)
378+
379+
type Release = JsonProvider<"https://api.github.com/repos/fsprojects/FSharp.Data/releases">
380+
381+
let fetchAllReleases () =
382+
let rec loop url acc =
383+
let response =
384+
Http.Request(url, headers = [ HttpRequestHeaders.UserAgent "myapp" ])
385+
386+
let items =
387+
match response.Body with
388+
| Text text -> Release.ParseList text
389+
| Binary _ -> [||]
390+
391+
let acc' = Array.append acc items
392+
393+
match response.Headers |> Map.tryFind HttpResponseHeaders.Link with
394+
| Some linkHeader ->
395+
match Http.ParseLinkHeader(linkHeader) |> Map.tryFind "next" with
396+
| Some nextUrl -> loop nextUrl acc'
397+
| None -> acc'
398+
| None -> acc'
399+
400+
loop "https://api.github.com/repos/fsprojects/FSharp.Data/releases" [||]
401+
361402
(**
362403
## Related articles
363404

src/FSharp.Data.Http/Http.fs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1999,6 +1999,9 @@ type Http private () =
19991999

20002000
static let charsetRegex = Regex("charset=([^;\s]*)", RegexOptions.Compiled)
20012001

2002+
static let linkHeaderPattern =
2003+
Regex(@"<([^>]+)>\s*;\s*rel=""([^""]+)""", RegexOptions.Compiled)
2004+
20022005
/// Correctly encodes large form data values.
20032006
/// See https://blogs.msdn.microsoft.com/yangxind/2006/11/08/dont-use-net-system-uri-unescapedatastring-in-url-decoding/
20042007
/// and https://msdn.microsoft.com/en-us/library/system.uri.escapedatastring(v=vs.110).aspx
@@ -2014,6 +2017,21 @@ type Http private () =
20142017
+ if url.IndexOf('?') >= 0 then "&" else "?"
20152018
+ String.concat "&" [ for k, v in query -> Uri.EscapeDataString k + "=" + Uri.EscapeDataString v ]
20162019

2020+
/// Parses an RFC 5988 Link header value (e.g. from a GitHub or other paginated API response)
2021+
/// and returns a map from relation type to URL.
2022+
///
2023+
/// For example, given the header value:
2024+
/// &lt;https://api.github.com/repos/.../releases?page=2&gt;; rel="next", &lt;...&gt;; rel="last"
2025+
/// this returns: Map [ "next", "https://..."; "last", "https://..." ]
2026+
static member ParseLinkHeader(linkHeader: string) =
2027+
if String.IsNullOrWhiteSpace(linkHeader) then
2028+
Map.empty
2029+
else
2030+
linkHeaderPattern.Matches(linkHeader)
2031+
|> Seq.cast<Match>
2032+
|> Seq.map (fun m -> m.Groups.[2].Value, m.Groups.[1].Value)
2033+
|> Map.ofSeq
2034+
20172035
static member private InnerRequest
20182036
(
20192037
url: string,

tests/FSharp.Data.Core.Tests/Http.fs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,35 @@ let ``AppendQueryToUrl percent-encodes special characters in keys and values`` (
9393
Http.AppendQueryToUrl("https://example.com/search", [ "q", "hello world" ])
9494
|> should equal "https://example.com/search?q=hello%20world"
9595

96+
[<Test>]
97+
let ``ParseLinkHeader returns empty map for empty string`` () =
98+
Http.ParseLinkHeader("") |> should equal Map.empty
99+
100+
[<Test>]
101+
let ``ParseLinkHeader parses next and last relations`` () =
102+
let header =
103+
"<https://api.github.com/repos/octocat/hello-world/releases?page=2>; rel=\"next\", <https://api.github.com/repos/octocat/hello-world/releases?page=5>; rel=\"last\""
104+
let result = Http.ParseLinkHeader(header)
105+
result |> Map.find "next" |> should equal "https://api.github.com/repos/octocat/hello-world/releases?page=2"
106+
result |> Map.find "last" |> should equal "https://api.github.com/repos/octocat/hello-world/releases?page=5"
107+
108+
[<Test>]
109+
let ``ParseLinkHeader parses single relation`` () =
110+
let header = "<https://example.com/items?page=3>; rel=\"next\""
111+
let result = Http.ParseLinkHeader(header)
112+
result |> Map.find "next" |> should equal "https://example.com/items?page=3"
113+
result |> Map.containsKey "prev" |> should equal false
114+
115+
[<Test>]
116+
let ``ParseLinkHeader handles prev, next, first, last`` () =
117+
let header =
118+
"<https://example.com/items?page=1>; rel=\"first\", <https://example.com/items?page=2>; rel=\"prev\", <https://example.com/items?page=4>; rel=\"next\", <https://example.com/items?page=10>; rel=\"last\""
119+
let result = Http.ParseLinkHeader(header)
120+
result |> Map.find "first" |> should equal "https://example.com/items?page=1"
121+
result |> Map.find "prev" |> should equal "https://example.com/items?page=2"
122+
result |> Map.find "next" |> should equal "https://example.com/items?page=4"
123+
result |> Map.find "last" |> should equal "https://example.com/items?page=10"
124+
96125
[<Test>]
97126
let ``Don't throw exceptions on http error`` () =
98127
use localServer = startHttpLocalServer()

0 commit comments

Comments
 (0)