HttpClient
HttpClient: high-level composable HTTP client with step pipeline.
This is Layer 3 of March's HTTP library. It provides a Req-style three-phase pipeline (request steps, response steps, error steps) built on top of HttpTransport.
Usage: let client = HttpClient.new_client() |> HttpClient.add_request_step("auth", HttpClient.Steps.bearer_auth("my-token")) |> HttpClient.with_redirects(5) let resp = HttpClient.get(client, "http://api.example.com/users")
Types
TransportError mirrors HttpTransport.TransportError (no imports in March).
Step entry types: wrap a name + function
Functions
Append a named error step. An error step is fn (req, err) -> ErrorRecovery: return Recover(resp) to turn the error into a successful response, or Fail(err2) to pass a (possibly new) error on to the next error step.
Examples
march> HttpClient.list_steps(HttpClient.add_error_step(HttpClient.new_client(), "fallback", fn (_, e) -> HttpClient.Fail(e)))
["error:fallback"]Append a named request step. A request step is fn req -> Result(req, HttpError); steps run in the order they were added, before the request is sent. Names are only labels (see list_steps); duplicates are allowed.
Examples
march> HttpClient.list_steps(HttpClient.add_request_step(HttpClient.new_client(), "auth", HttpClient.step_bearer_auth("t")))
["request:auth"]Append a named response step. A response step is fn (req, resp) -> Result((req, resp), HttpError); steps run in order after the response (and any redirects) arrive.
Examples
march> HttpClient.list_steps(HttpClient.add_response_step(HttpClient.new_client(), "check", HttpClient.step_raise_on_error))
["response:check"]Append x to the end of xs (O(n)); used to keep steps in registration order.
Examples
march> HttpClient.append_to_list([1, 2], 3)
[1, 2, 3]Build a DELETE request for url (empty String body) and run it. An unparseable URL gives Err(StepError("url", ...)).
Examples
march> HttpClient.delete(HttpClient.new_client(), "bad")
Err(StepError("url", "invalid url: bad"))Build a GET request for url (empty String body) and run it through the client. An unparseable URL gives Err(StepError("url", "invalid url: ...")) without running any step.
Examples
HttpClient.get(HttpClient.new_client(), "http://api.example.com/users")
march> HttpClient.get(HttpClient.new_client(), "not a url")
Err(StepError("url", "invalid url: not a url"))Follow redirects for resp, the answer to req. Returns Ok(resp) unchanged when max == 0, when resp is not a 3xx, or when it has no Location header; Err(TooManyRedirects(count)) once count >= max. Otherwise re-issues the request as a body-less GET (whatever the original method and redirect code) over a new connection and recurses. An absolute Location that crosses origins drops credential headers; a relative one keeps all headers and replaces only the path.
Examples
march> let req = Result.unwrap(Http.get("http://example.com/users?page=2"))
march> let sreq = Http.set_body(req, "")
march> HttpClient.handle_redirects(sreq, Http.Response(Http.Status(302), [Http.Header("Location", "/x")], ""), 2, 2)
Err(TooManyRedirects(2))
march> let req = Result.unwrap(Http.get("http://example.com/users?page=2"))
march> let sreq = Http.set_body(req, "")
march> HttpClient.handle_redirects(sreq, Http.Response(Http.Status(302), [], ""), 3, 0) |> Result.map(fn r -> Http.response_status_code(r))
Ok(302)The port a request actually uses: the explicit port, or the scheme default (80 for http, 443 for https).
Examples
march> HttpClient.hc_effective_port(Http.SchemeHttps, None)
443
march> HttpClient.hc_effective_port(Http.SchemeHttp, Some(8080))
8080True for header names that carry credentials and are dropped on a cross-origin redirect: Authorization, Cookie, Proxy-Authorization (case-insensitive).
Examples
march> HttpClient.hc_is_sensitive_header_name("COOKIE")
true
march> HttpClient.hc_is_sensitive_header_name("Accept")
falseTrue when two requests share an origin: same scheme, same host (case-insensitive) and same effective port.
Examples
march> HttpClient.hc_same_origin(Result.unwrap(Http.get("http://Example.com/a")), Result.unwrap(Http.get("http://example.com:80/b")))
true
march> HttpClient.hc_same_origin(Result.unwrap(Http.get("http://example.com/a")), Result.unwrap(Http.get("https://example.com/b")))
falseRemove every credential-bearing header (see hc_is_sensitive_header_name) from a header list, keeping the order of the rest.
Examples
march> HttpClient.hc_strip_sensitive_headers([Http.Header("Cookie", "a"), Http.Header("Accept", "*/*")])
[Header("Accept", "*/*")]List the client's step names in execution order, each prefixed by its phase: "request:", then "response:", then "error:".
Examples
march> HttpClient.new_client() |> HttpClient.add_error_step("e", fn (_, e) -> HttpClient.Fail(e)) |> HttpClient.add_request_step("a", HttpClient.step_default_headers) |> HttpClient.list_steps
["request:a", "error:e"]Create a new bare client with no steps and no redirect/retry.
Examples
march> HttpClient.list_steps(HttpClient.new_client())
[]Build a POST request for url with body bdy and run it. An unparseable URL gives Err(StepError("url", ...)).
Examples
let client = HttpClient.new_client()
|> HttpClient.add_request_step("json", HttpClient.step_content_type("application/json"))
HttpClient.post(client, "http://api.example.com/users", json_body)
march> HttpClient.post(HttpClient.new_client(), "ftp://x", "body")
Err(StepError("url", "invalid url: ftp://x"))Build a PUT request for url with body bdy and run it. An unparseable URL gives Err(StepError("url", ...)).
Examples
HttpClient.put_request(client, "http://api.example.com/users/1", body)Open a new connection for req and continue transport_keepalive on it with retries_left - 1. Returns the connect error if that fails.
Run a request through the client's full step pipeline:
- Run request steps left-to-right; the first
Errshort-circuits. - Send with
HttpTransport.request(a new connection per attempt),
retrying up to `max_retries` extra times on transport errors
(the configured backoff is currently not applied between attempts).- Follow redirects if
with_redirectsset a non-zero limit. - Run response steps left-to-right.
- Any
Errfrom steps 1-4 is fed to the error steps in order; the first
`Recover(resp)` turns it into `Ok(resp)`, otherwise the last error is
returned. Transport errors arrive wrapped in `HttpTransportError`.Examples
let req = Result.unwrap(Http.get("http://api.example.com/users"))
match HttpClient.run(client, Http.set_body(req, "")) do
Ok(resp) -> Http.response_body(resp)
Err(e) -> "request failed"
endOffer err to each ErrorStepEntry in order. The first Recover(resp) wins and gives Ok(resp); a Fail(e2) replaces the error for the next step. With no step recovering, returns Err of the last error.
Examples
march> let req = Result.unwrap(Http.get("http://example.com/users?page=2"))
march> HttpClient.run_error_steps([], req, HttpClient.TooManyRedirects(3))
Err(TooManyRedirects(3))
march> let req = Result.unwrap(Http.get("http://example.com/users?page=2"))
march> HttpClient.run_error_steps([HttpClient.ErrorStepEntry("x", fn (_, _) -> HttpClient.Recover(42))], req, HttpClient.TooManyRedirects(3))
Ok(42)Run req through the pipeline on an already-open connection fd. This is the engine behind the do_request callback of with_connection.
Same phases as run, except that the request is sent with transport_keepalive(fd, req, max_retries + 1): on a send/receive failure the connection is closed and a fresh one opened. Redirects still go over new connections (handle_redirects). Returns Ok(response) or the result of the error steps.
Thread req through a list of RequestStepEntry values left-to-right; stops at the first Err and returns it.
Examples
march> let req = Result.unwrap(Http.get("http://example.com/users?page=2"))
march> let ct = HttpClient.step_content_type("application/json")
march> HttpClient.run_request_steps([HttpClient.RequestStepEntry("ct", ct)], req) |> Result.map(fn r -> Http.get_request_header(r, "content-type"))
Ok(Some("application/json"))Thread (req, resp) through a list of ResponseStepEntry values left-to-right; stops at the first Err and returns it.
Examples
march> let req = Result.unwrap(Http.get("http://example.com/users?page=2"))
march> HttpClient.run_response_steps([HttpClient.ResponseStepEntry("raise", HttpClient.step_raise_on_error)], req, Http.Response(Http.Status(500), [], ""))
Err(StepError("step_raise_on_error", "500"))Returns a request step that re-targets a request at base ++ path: the scheme, host, port and path come from parsing that string, while the method, query, headers and body are kept from the request. Fails with StepError("step_base_url", ...) if the combined URL does not parse.
Examples
march> let req = Result.unwrap(Http.get("http://example.com/users?page=2"))
march> let base = HttpClient.step_base_url("https://api.example.com:8443/v1")
march> Result.map(base(req), fn r -> (Http.host(r), Http.port(r), Http.path(r), Http.query(r)))
Ok(("api.example.com", Some(8443), "/v1/users", Some("page=2")))
march> let req = Result.unwrap(Http.get("http://example.com/users?page=2"))
march> let bad = HttpClient.step_base_url("nope")
march> Result.is_err(bad(req))
trueReturns a request step that sets Authorization: Basic <user>:<pass>.
Note: the credentials are sent as the literal user:pass text, NOT base64-encoded as RFC 7617 requires, so most servers will reject them.
Examples
march> let req = Result.unwrap(Http.get("http://example.com/users?page=2"))
march> let basic = HttpClient.step_basic_auth("ann", "pw")
march> Result.map(basic(req), fn r -> Http.get_request_header(r, "Authorization"))
Ok(Some("Basic ann:pw"))Returns a request step that sets Authorization: Bearer <token>.
Examples
march> let req = Result.unwrap(Http.get("http://example.com/users?page=2"))
march> let auth = HttpClient.step_bearer_auth("tok")
march> Result.map(auth(req), fn r -> Http.get_request_header(r, "Authorization"))
Ok(Some("Bearer tok"))Returns a request step that sets the Content-Type header to ct.
Examples
march> let req = Result.unwrap(Http.get("http://example.com/users?page=2"))
march> let ct = HttpClient.step_content_type("application/json")
march> Result.map(ct(req), fn r -> Http.get_request_header(r, "Content-Type"))
Ok(Some("application/json"))Request step: set User-Agent: march/0.1 and Accept: */*, replacing any existing values. Never fails. Register it with add_request_step(client, "defaults", HttpClient.step_default_headers).
Examples
march> let req = Result.unwrap(Http.get("http://example.com/users?page=2"))
march> Result.map(HttpClient.step_default_headers(req), fn r -> Http.get_request_header(r, "User-Agent"))
Ok(Some("march/0.1"))Response step: return Err(StepError("step_raise_on_error", "<code>")) for any status >= 400, otherwise pass (req, resp) through unchanged.
Examples
march> let req = Result.unwrap(Http.get("http://example.com/users?page=2"))
march> HttpClient.step_raise_on_error(req, Http.Response(Http.Status(404), [], ""))
Err(StepError("step_raise_on_error", "404"))
march> let req = Result.unwrap(Http.get("http://example.com/users?page=2"))
march> HttpClient.step_raise_on_error(req, Http.Response(Http.Status(204), [], "")) |> Result.is_ok
trueStream a GET response body. Calls on_chunk(chunk_string) for each chunk as it arrives over the wire. Returns Ok((status, headers, ())); the values returned by on_chunk are discarded (HttpTransport streams for side effects only).
Only the client's request steps are applied; response steps, error steps, redirects and retries are not. An invalid URL gives Err(StepError("url", ...)); a failing request step's Err is returned as is.
Examples
HttpClient.stream_get(client, "http://example.com/bigfile", fn chunk ->
println("got " ++ int_to_string(string_length(chunk)) ++ " bytes"))
march> HttpClient.stream_get(HttpClient.new_client(), "bad", fn c -> c)
Err(StepError("url", "invalid url: bad"))Send req on the open connection fd. Returns Ok((fd, resp)) on success. On failure, closes fd and, while retries_left > 0, reconnects and tries again (each reconnect consumes two from retries_left); otherwise returns Err(Closed), discarding the original transport error. After a reconnect the returned fd is the new one.
Send req with HttpTransport.request (a fresh connection each time), retrying immediately up to retries_left more times on any transport error. Returns the last error if every attempt fails.
Open a keep-alive connection and run a function with it. The function receives a do_request callback that sends requests on the shared connection. Connection is closed when done.
Returns Ok(result) where result is whatever the callback returned (each do_request(req) itself returns Result(Response, HttpError)), Err(StepError("url", ...)) if url does not parse, or Err(HttpTransportError(e)) if the connection cannot be opened. Only the scheme, host and port of url are used to connect; each request carries its own path. If the callback panics the connection is not closed.
Examples
HttpClient.with_connection(client, "http://api.example.com", fn do_request ->
let users = Result.unwrap(Http.get("http://api.example.com/users"))
let posts = Result.unwrap(Http.get("http://api.example.com/posts"))
let r1 = do_request(Http.set_body(users, ""))
let r2 = do_request(Http.set_body(posts, ""))
(r1, r2))
march> HttpClient.with_connection(HttpClient.new_client(), "bad", fn d -> 1)
Err(StepError("url", "invalid url: bad"))Follow up to max redirects (0, the default, disables following). Every redirect is re-issued as a body-less GET; exceeding the limit yields TooManyRedirects(max).
Examples
march> HttpClient.with_redirects(HttpClient.new_client(), 5) == HttpClient.Client(Nil, Nil, Nil, 5, 0, 0)
trueRetry a failed transport call up to max_attempts extra times (0 disables). Only transport errors are retried, not HTTP error statuses. backoff_ms is stored but run currently retries immediately without sleeping.
Examples
march> HttpClient.with_retry(HttpClient.new_client(), 3, 100) == HttpClient.Client(Nil, Nil, Nil, 0, 3, 100)
true