Http

An HTTP client for live dashboards and remote data visualization.

Requests are built and read with the shared roc-lang/http Request and Response types, exactly as in basic-cli. This module adds the effects and the small JSON and UTF-8 conveniences.

An app that names Request or Response declares the http package in its own header, beside the platform:

app [Model, program] {
    rr: platform "../../platform/main.roc",
    http: "https://github.com/roc-lang/http/releases/download/1.0.0/6ZUwqYhCS8PU9Mo6MF7oV82ET2o7KYb57CLKDq4cq4sS.tar.zst",
}

import rr.Http
import http.Request

The platform entry above is the path the repository's own examples use; outside the checkout it is the platform declaration from the latest release. The http line is the version this platform is built against, and an app that names Request or Response must declare that same one.

Http.get! and Http.get_utf8! take a URL and hand back decoded data, so an app that uses only those needs no package dependency of its own.

Http.send! waits, so it belongs inside Task.spawn!:

update! = |model, input| {
    if input.devices.key_pressed(KeyR) {
        Task.spawn!(
            input,
            || match Http.get_utf8!("http://127.0.0.1:8000/data.json") {
                Ok(body) => Loaded(body)
                Err(InvalidUrl(_)) => Failed("that is not a URL this platform will fetch")
                Err(HttpErr(Timeout)) => Failed("the request timed out")
                Err(HttpErr(_)) => Failed("the request failed at the network layer")
                Err(BadBody(_)) => Failed("the reply was not valid UTF-8")
                Err(_) => Failed("the request failed")
            },
        )
    }
    Ok(model)
}

The task parks on the host's socket while the frame loop keeps drawing, and the closure's return value arrives on a later Input.messages. send! is legal in init!, where it blocks startup, and in tasks, where it parks the task; it is refused in update! and render!.

Up to three redirects are followed, and the Response is the one at the end of that chain.

Every send carries a deadline and a hard cap on the response body, taken from Http.default_config: thirty seconds, and eight megabytes. Pass a Config to send_with! for different ones, and 0 in either field to disable that limit. A request that sets TimeoutMilliseconds itself overrides the config's deadline; NoTimeout on a request means the config's deadline applies, so an ordinarily built request is never sent without one.

The body cap is measured after decompression, and it is a refusal rather than a truncation: a response over the cap fails the send, because a truncated body decodes into wrong data rather than into an error. What it bounds is how much of this process's memory a remote server can choose to use.

An HTTP status is not an error. A 404 or a 503 arrives as Ok(response) carrying that status; only a failure to complete the exchange is HttpErr.

get! and send_json! have a _ where the decoded or encoded type would be. That is static dispatch: the JSON parser and encoder are chosen from the type the call site expects, so the same get! answers a List(Reading) in one place and a { name : Str } in another, and there is no decoder to pass in. Annotate the binding, or the field the value goes into, and the inference does the rest.

TLS: https URLs are served by Zig's std.crypto.tls against the operating system's certificate store -- on Linux the usual /etc/ssl bundle. Nothing here configures a custom certificate authority or turns verification off. A store that cannot be loaded fails the send with HttpErr(Other(...)) saying so, rather than continuing unverified.

default_config : Config

Thirty seconds, and eight megabytes of response body.

The timeout is generous enough for a slow dashboard endpoint and short enough that a task waiting on a dead server is eventually collected. The body cap holds a large JSON payload but not an accidental video.

send! : Request => Try(Response, [InvalidUrl(ParseErr), HttpErr(TransportErr), ..])

Validate and send an HTTP request under default_config.

The request URI must be an absolute HTTP or HTTPS URL accepted by Url. An invalid URL answers InvalidUrl before any host effect occurs. Fragments are removed, because they are client-side identifiers and are not sent.

The method must be one of the nine RFC methods. QUERY and any Unknown(ext) method fail as HttpErr(Other(...)) naming the method, also before any host effect occurs: the host's method type has no representation for either, and reporting it as Other keeps an app's exhaustive match over TransportErr from breaking over a request no host could send.

Legal in init!, where it blocks startup, and in tasks, where it parks the task; refused in update! and render!.

request = Request.from_method(GET).with_uri("https://www.roc-lang.org")
response = Http.send!(request)?
send_with! : Config, Request => Try(Response, [InvalidUrl(ParseErr), HttpErr(TransportErr), ..])

Validate and send an HTTP request under explicit limits.

The same validation, the same phases, and the same outcomes as send!; only the deadline and the body cap differ.

slow = { ..Http.default_config, timeout_ms: 2_000 }
response = Http.send_with!(slow, request)?

Legal in init!, where it blocks startup, and in tasks, where it parks the task; refused in update! and render!.

with_json_body : Request, _ -> Try(Request, [JsonErr(_), ..])

Encode a value as JSON and set it as the request body.

This uses Roc's builtin JSON encoder, so the value's type determines the encoder through static dispatch. A Content-Type: application/json header is added.

send_json! : Request, _ => Try(Response, [JsonErr(_), InvalidUrl(ParseErr), HttpErr(TransportErr), ..])

Encode a value as JSON, attach it to the request body, and send it.

Legal in init!, where it blocks startup, and in tasks, where it parks the task; refused in update! and render!.

get_utf8! : Url => Try(Str, [BadBody(Str), InvalidUrl(ParseErr), HttpErr(TransportErr), ..])

Perform an HTTP GET and decode the response body as a UTF-8 Str.

The argument is a validated Url. Quoted literals work through Url.from_quote, so a URL written out in the source is checked at compile time; a string built at runtime goes through Url.parse.

A body that is not valid UTF-8 answers BadBody(Str). That is this function's own decoding failure, and is not the transport's MalformedResponse: the reply arrived and was a well-formed HTTP response, it just is not text. The status is not inspected, so an error page comes back as the Str the server sent.

hello_str = Http.get_utf8!("http://localhost:8000")?

Legal in init!, where it blocks startup, and in tasks, where it parks the task; refused in update! and render!.

decode_json_response : Response -> Try(_, [BadBody(Str), JsonErr(_), ..])

Decode a response body as JSON.

This uses Roc's builtin JSON parser, so the expected result type determines the parser through static dispatch.

get! : Url => Try(_, [BadBody(Str), InvalidUrl(ParseErr), HttpErr(TransportErr), JsonErr(_), ..])

Perform an HTTP GET and decode the response body as JSON.

The expected result type selects the parser through static dispatch. The status is not inspected, so a JSON error page decodes if it happens to fit the expected shape. Same phases as send!.

payload : Try({ foo : Str }, _)
payload = Http.get!("http://localhost:8000")

Legal in init!, where it blocks startup, and in tasks, where it parks the task; refused in update! and render!.

TransportErr : [Timeout, NetworkError, MalformedResponse, Other(List(U8))]

Why the exchange did not produce an HTTP response.

An HTTP status is never one of these: a 404 or a 503 is an Ok response.

Timeout is the deadline expiring before the exchange finished. NetworkError is a connection that could not be made or did not survive the exchange -- a refused port, a dropped socket, an unreachable host. MalformedResponse is a reply that arrived but was not a well-formed HTTP response. Other carries the host's own description as UTF-8 bytes: a name that would not resolve, a body over max_response_bytes, a certificate store that could not be loaded, or a method this platform cannot send.

Config : {
    timeout_ms : U64,
    max_response_bytes : U64,
}

Per-send limits.

timeout_ms is the deadline for the whole exchange: connect, send, response head, and body. 0 means no deadline, which is only ever appropriate against a server you control. A request that sets TimeoutMilliseconds itself overrides this value.

max_response_bytes caps the decompressed response body, and 0 means no cap. A response that would exceed it fails with Other rather than being truncated, because a truncated body silently decodes into wrong data.

A plain record; build one with { ..Http.default_config, timeout_ms: 5_000 } rather than a chain of with_* calls.