moonfetch

    An HTTP and WebSocket client for MoonBit.

    http
    http-client
    client
    fetch
    requests
    httpx
    reqwest
    async
    http1
    http2
    https
    tls
    alpn
    redirect
    gzip
    deflate
    json
    form
    multipart
    stream
    streaming
    sse
    server-sent-events
    websocket
    ws
    wss
    cookie
    cookie-jar
    proxy
    connect
    socks5
    h2c
    http2-multiplexing
    rfc6265
    rfc6455
    rfc1928
    rfc7578
    rfc9110
    rfc9112
    rfc9113
    moonbit
    Download zip
    Version
    0.1.6
    License
    Apache-2.0
    Last updated
    11 hours ago
    Downloads
    1K

    #moonfetch

    An HTTP client for MoonBit, after httpx and reqwest: one call for a request, a Client that keeps its connections, HTTP/1.1 and HTTP/2 chosen by ALPN, streamed bodies, cookies, proxies and a WebSocket client.

    // One request.
    let r = @moonfetch.get("https://example.com/")
    r.status // 200
    r.version // "HTTP/2"
    r.text()

    // A client: connections kept per origin, defaults for every request.
    let api = @moonfetch.Client::new(
    base_url="https://api.example.com/v1",
    headers=[("authorization", "Bearer \{token}")],
    )
    defer api.close()
    let user = api.get("/users/7").checked().json()
    let made = api.post("/users", body=Json({ "name": "moon" }))
    let form = api.post("/login", body=Form([("user", "moon"), ("pass", secret)]))

    Run moon run src/examples/tour --target native to fetch a page over HTTP/2.

    #What it does

    VersionsHTTP/1.1 and HTTP/2 by ALPN over TLS (moontls), HTTP/1.1 in the clear, and HTTP/2 in the clear with h2c
    Connectionsidle HTTP/1.1 connections kept per origin; one HTTP/2 connection per origin carries every request to it, many at once
    BodiesBody is one value: Text, Bytes, Json, Form, Multipart, Stream; the content type follows unless a header names one
    StreamingClient::stream returns as soon as the head is in; Stream::next hands over pieces as they arrive, for server-sent events and downloads
    Boundsa buffered body stops at Limits::body while it is being read, not after
    Redirectsup to 10 followed; a 303, and a 301 or 302 to a POST, become a GET; authorization and cookie stay with their origin
    Timeouts5 seconds for the whole exchange, redirects included; a stream bounds the head and each read instead
    Compressiongzip and deflate asked for and decoded; a request with its own accept-encoding gets the body as sent
    Cookiesa Jar follows RFC 6265bis: domain and path matching, expiry, the __Host- and __Secure- prefixes, the Public Suffix List
    ProxiesCONNECT for an HTTP proxy, SOCKS5 for a SOCKS one, with a username and password for either
    Retriesa kept connection the server closed while idle, or an HTTP/2 connection it went away from, is replaced and an idempotent request sent again; a stream the server refused is sent again whatever the method, and a request with a streamed body is never replayed
    Certificateschecked against the Mozilla roots by mooncred; there is no switch that turns checking off: trust your own CA by giving tls roots that hold it
    ErrorsRefused for what moonfetch refuses (Status, Redirects, Reset, Timeout, Url, Exceeded, Proxy); a socket, TLS or framing error passes through as it is

    #Settings

    Client::new takes the defaults; each request may replace timeout and redirects, and its headers replace the client's of the same name.

    SettingDefaultFrom
    timeout5000 mshttpx
    redirects10reqwest, requests
    idle10 connections per originurllib3
    http2onreqwest, curl
    h2coff—
    compressiononhttpx, reqwest
    limits.body32 MiB— (a stream has no bound)
    HTTP/2 receive window4 MiB per stream, 1 GiB per connectionGo

    #Streaming

    stream sends the request, follows redirects, and returns once the response head is in. Each next gives a Data piece or the End with its trailers; the pieces are the octets as they came, not a line at a time.

    let feed = client.stream(Method::Get, "/events")
    while feed.next() is Some(piece) {
    match piece {
    Data(bytes) => handle(@sse.decode(@utf8.decode_lossy(bytes[:])))
    End(_) => break
    }
    }

    A stream is not bounded — that is what makes it a stream — so a caller that reads one to the end passes its own bound: feed.bytes(limit=1 << 20). A buffered request uses Limits::body and stops as the octets pass it.

    Streaming does not ask for compression: a coding would have to be undone on the fly. Ask for it yourself if you mean to.

    #Request bodies

    Text, Bytes, Json and Form are whole bodies. Multipart is a form with files, and Stream is a body produced while the request is sent:

    let upload = Part::file("doc", "notes.txt", @fs.read_file("notes.txt"), content_type="text/plain")
    client.post("/upload", body=Multipart([Part::field("owner", "moon"), upload]))

    // A body of exactly 4096 octets, produced a kibibyte at a time.
    let source = Source::sized(4096, () => next_kibibyte())
    client.put("/blob", body=Stream(source))

    A Source with a length declares content-length; without one the body is chunked on HTTP/1.1 and unframed on HTTP/2.

    #Cookies

    let jar = Jar::new()
    let browser = Client::new(jar~)
    browser.get("https://example.com/login") // what the server set is kept
    browser.get("https://example.com/account") // and sent back, where it matches

    One jar may be shared by several clients and by tasks; it lives as long as the process, since persistence is not part of it.

    #Proxies

    let proxied = Client::new(proxy=Proxy::parse("http://user:pass@proxy.internal:3128"))
    let socks = Client::new(proxy=Proxy::parse("socks5://proxy.internal:1080"))

    An http request through an HTTP proxy goes in absolute form; an https one is tunnelled with CONNECT. SOCKS5 sends the host as a name and lets the proxy resolve it.

    #WebSocket

    moonfetch/ws shares the client's TLS roots, proxy and timeouts:

    let client = Client::new()
    let socket = @ws.connect(client, "wss://example.com/chat", protocols=["chat"])
    socket.send_text("hello")
    while socket.recv() is Some(message) {
    match message {
    Text(text) => handle(text)
    Binary(bytes) => handle(bytes)
    Close(status~, ..) => break
    }
    }
    socket.close(status=1000)

    Pings are answered and fragmented messages put back together inside recv, so a caller sees messages, not frames.

    #Beside the official client

    moonbitlang/async/http has a client too. What differs:

    async/httpmoonfetch
    HTTP/2noby ALPN, many streams on one connection
    Streamingwhole bodiesStream, a piece at a time
    Cookies, proxiesmanualJar, CONNECT and SOCKS5
    WebSocketa separate clientmoonfetch/ws, on the same client
    TLSthe system's (OpenSSL, SChannel)moontls, the same on every platform
    Connection reuseone connection per Clienta pool per origin
    Redirects, compressionnoyes
    Turning certificate checks offtrust=NoVerificationnot possible; give the roots instead

    The sockets are moonbitlang/async's, so moonfetch runs on the native and wasm backends. A browser host and HTTP/3 are not part of this version.

    #Install

    moon add moonbitstack/moonfetch

    #Licence

    Apache-2.0. See LICENSE.

    Refused

    pub(all) suberror Refused {
    Status(status~ : Int, url~ : String)
    Redirects(limit~ : Int)
    Reset(code~ : Int)
    Timeout(limit~ : Int)
    Url(String)
    Exceeded(limit~ : Int, got~ : Int)
    Proxy(String)
    } derive(Eq,
    Debug
    )

    What moonfetch itself refuses. Errors from below pass through as they are: a socket that will not connect, a TLS alert, a response that does not parse.
    impl Show for Refused

    Refused::equal

    fn Refused::equal(Refused, Refused) -> Bool

    Refused::not_equal

    fn Refused::not_equal(x : Refused, y : Refused) -> Bool

    Refused::output

    fn Refused::output(self : Refused, logger : &Logger) -> Unit

    Refused::to_repr

    Refused::to_string

    fn Refused::to_string(self : Refused) -> String

    Body

    pub(all) enum Body {
    Empty
    Text(String)
    Bytes(Bytes)
    Json(
    Json
    )
    Form(Array[(String, String)])
    Multipart(Array[Part])
    Stream(Source)
    } derive(Eq)

    A request body. One value, so a request cannot carry two bodies for the library to choose between.

    Body::equal

    fn Body::equal(Body, Body) -> Bool

    Body::not_equal

    fn Body::not_equal(x : Body, y : Body) -> Bool

    Body::to_repr

    Chunk

    pub(all) enum Chunk {
    Data(Bytes)
    End(Array[(String, String)])
    }

    A piece of a response body as it arrives.

    Client

    type Client

    An HTTP client that keeps its connections: idle HTTP/1.1 ones per origin, and one HTTP/2 connection per origin for every request to it. Close it when done with it.

    Client::close

    fn Client::close(self : Client) -> Unit

    Close every connection the client keeps.

    Client::connect

    async fn Client::connect(self : Client, url : StringView) ->
    Conn

    A fresh connection to the URL's origin, with the client's TLS and proxy rules applied and nothing else on it. The caller owns it; it is not taken from the pool and is not kept. For a protocol that starts as HTTP and then becomes something else — WebSocket, say.

    Client::delete

    async fn Client::delete(self : Client, url : StringView, query? : Array[(String, String)], headers? : Array[(String, String)], timeout? : Int, redirects? : Int) -> Response

    Client::get

    async fn Client::get(self : Client, url : StringView, query? : Array[(String, String)], headers? : Array[(String, String)], timeout? : Int, redirects? : Int) -> Response

    Client::head

    async fn Client::head(self : Client, url : StringView, query? : Array[(String, String)], headers? : Array[(String, String)], timeout? : Int, redirects? : Int) -> Response

    Client::new

    fn Client::new(base_url? : String, headers? : Array[(String, String)], timeout? : Int?, redirects? : Int, http2? : Bool, compression? : Bool, idle? : Int, h2c? : Bool, limits? : Limits, jar? : Jar, proxy? : Proxy, tls? :
    Client
    ) -> Client raise Refused

    A client.

    base_url is the prefix relative URLs are joined to, as httpx joins them: its path is kept, so /users under https://a.example/api is https://a.example/api/users. headers go on every request. timeout bounds each request, redirects included; None waits for ever. redirects caps how many are followed; 0 follows none. http2 offers HTTP/2 by ALPN; h2c speaks HTTP/2 with prior knowledge over http, where there is no ALPN to ask. compression asks for gzip or deflate and decodes what comes back, as httpx and reqwest do; a request that names its own accept-encoding gets the body as sent. limits bounds what a buffered response may hold. jar keeps cookies across requests. proxy sends the connection through an HTTP or SOCKS5 proxy. tls judges servers' certificates, the Mozilla roots when not given; its ALPN list is set by http2.

    Client::options

    async fn Client::options(self : Client, url : StringView, query? : Array[(String, String)], headers? : Array[(String, String)], timeout? : Int, redirects? : Int) -> Response

    Client::patch

    async fn Client::patch(self : Client, url : StringView, body? : Body, query? : Array[(String, String)], headers? : Array[(String, String)], timeout? : Int, redirects? : Int) -> Response

    Client::post

    async fn Client::post(self : Client, url : StringView, body? : Body, query? : Array[(String, String)], headers? : Array[(String, String)], timeout? : Int, redirects? : Int) -> Response

    Client::put

    async fn Client::put(self : Client, url : StringView, body? : Body, query? : Array[(String, String)], headers? : Array[(String, String)], timeout? : Int, redirects? : Int) -> Response

    Client::request

    async fn Client::request(self : Client, verb :
    Method
    , url : StringView, query? : Array[(String, String)], headers? : Array[(String, String)], body? : Body, timeout? : Int, redirects? : Int) -> Response

    Send a request and read the response whole.

    query is added to the URL's own query; headers replace the client's of the same name; timeout and redirects replace the client's for this request.

    Client::stream

    async fn Client::stream(self : Client, verb :
    Method
    , url : StringView, query? : Array[(String, String)], headers? : Array[(String, String)], body? : Body, timeout? : Int, redirects? : Int) -> Stream

    Send a request and return once its head is in; the body is read with Stream::next.

    Redirects are followed as request follows them, the body of each hop read and dropped first. timeout bounds reading the head and each later Stream::next, not the whole exchange, which is the point of streaming.

    Jar

    pub struct Jar {
    // private fields
    }

    A cookie store for a Client (RFC 6265bis).

    One jar may be shared by several clients and by tasks: every change goes through a lock. Persistence is not part of it — a jar lives as long as the process does.

    Jar::clear

    fn Jar::clear(self : Jar) -> Unit

    Jar::cookies

    async fn Jar::cookies(self : Jar, url :
    Url
    ) -> String

    The Cookie header for a request to url: the cookies that match, longest path first and oldest first within a path (RFC 6265bis §5.4).

    Jar::new

    fn Jar::new() -> Jar

    An empty jar.

    Jar::size

    fn Jar::size(self : Jar) -> Int

    How many cookies the jar holds.

    Jar::store

    async fn Jar::store(self : Jar, url :
    Url
    , values : Array[String]) -> Unit

    Take the cookies out of a response (RFC 6265bis §5.3).

    Limits

    pub(all) struct Limits {
    head : Int
    fields : Int
    body : Int
    }

    What a client reads of a response before it refuses it.

    head bounds the status line and header block, fields the number of header fields, and body the body as it arrives, before any content coding is undone. The body bound is checked as the octets come in, so a server that would send more is stopped early rather than after the allocation.

    Limits::new

    fn Limits::new(head? : Int, fields? : Int, body? : Int) -> Limits

    The same bounds with some replaced.

    Part

    pub struct Part {
    name : String
    content : Bytes
    filename : String?
    content_type : String?
    } derive(Eq)

    One part of a multipart/form-data body (RFC 7578): a form field when filename is None, a file otherwise.

    Part::content

    fn Part::content(self : Part) -> Bytes

    Part::content_type

    fn Part::content_type(self : Part) -> String?

    Part::equal

    fn Part::equal(Part, Part) -> Bool

    Part::field

    fn Part::field(name : StringView, text : StringView) -> Part

    A form field.

    Part::file

    fn Part::file(name : StringView, filename : StringView, content : Bytes, content_type? : StringView) -> Part

    A file part. content_type defaults to application/octet-stream, as browsers do.

    Part::filename

    fn Part::filename(self : Part) -> String?

    Part::name

    fn Part::name(self : Part) -> String

    Part::not_equal

    fn Part::not_equal(x : Part, y : Part) -> Bool

    Proxy

    pub(all) struct Proxy {
    scheme : String
    host : String
    port : Int
    user : String?
    password : String?
    }

    A proxy a client sends its connections through.

    http and https are the CONNECT method of RFC 9110 §9.3.6 — an https proxy speaks TLS to the proxy itself — and socks5 is RFC 1928, with the host sent as a name so the proxy resolves it. The user and password, when present, authenticate to the proxy: Basic for CONNECT, username and password for SOCKS5.

    Proxy::parse

    fn Proxy::parse(text : StringView) -> Proxy raise Refused

    A proxy from a URL: http://user:pass@host:1080, socks5://host:1080.

    Response

    pub(all) struct Response {
    status : Int
    reason : String
    version : String
    headers : Array[(String, String)]
    url : String
    body : Bytes
    } derive(Eq,
    Debug
    )

    A response, read whole. Header names are lower case, as HTTP/2 writes them, and keep the order they arrived in.

    Response::checked

    fn Response::checked(self : Response) -> Response raise Refused

    The response itself, or Status for a 4xx or 5xx, as reqwest's error_for_status does.

    Response::equal

    fn Response::equal(Response, Response) -> Bool

    Response::header

    fn Response::header(self : Response, name : StringView) -> String?

    The first value of the header name, matched without regard to case.

    Response::is_success

    fn Response::is_success(self : Response) -> Bool

    Whether the status is 2xx.

    Response::json

    The body parsed as JSON.

    Response::not_equal

    fn Response::not_equal(x : Response, y : Response) -> Bool

    Response::text

    fn Response::text(self : Response) -> String

    The body as UTF-8 text; a malformed sequence reads as U+FFFD.

    Response::to_repr

    Response::values

    fn Response::values(self : Response, name : StringView) -> Array[String]

    Every value of the header name, in order: what set-cookie needs.

    Source

    pub struct Source {
    length : Int64?
    step : async () -> Bytes?
    }

    A request body that is not known when the request is made: the steps are called as the connection has room for more.
    impl Eq for Source

    Source::equal

    fn Source::equal(self : Source, other : Source) -> Bool

    Source::length

    fn Source::length(self : Source) -> Int64?

    The length the source promises, when it promises one.

    Source::new

    fn Source::new(step : async () -> Bytes?) -> Source

    A body whose length is not known in advance; HTTP/1.1 sends it chunked, HTTP/2 as data frames.

    Source::next

    async fn Source::next(self : Source) -> Bytes?

    The next piece, or None at the end.

    Source::not_equal

    fn Source::not_equal(self : Source, other : Source) -> Bool

    Source::sized

    fn Source::sized(length : Int64, step : async () -> Bytes?) -> Source

    A body of exactly length octets: HTTP/1.1 and HTTP/2 both declare the length, so no chunking and no close-delimited body. A source that ends early or runs long fails the request.

    Source::to_repr

    Stream

    pub struct Stream {
    // private fields
    }

    A response whose body is read a piece at a time, for a body too large to hold or one that never ends: a server-sent event feed, a download that is written as it comes.

    One task reads one stream. close gives up the rest; a program that just drops it leaves the exchange unfinished until the client is closed.

    Stream::bytes

    async fn Stream::bytes(self : Stream, limit? : Int) -> Bytes

    Read the rest of the body, refusing to hold more than limit octets. The stream is over afterwards.

    Stream::close

    fn Stream::close(self : Stream) -> Unit

    Give up the rest of the body: an HTTP/2 stream is reset, an HTTP/1.1 connection is closed.

    Stream::header

    fn Stream::header(self : Stream, name : StringView) -> String?

    The first value of the header name, matched without regard to case.

    Stream::headers

    fn Stream::headers(self : Stream) -> Array[(String, String)]

    Stream::next

    async fn Stream::next(self : Stream) -> Chunk?

    Read the next piece, None once the body has been read to its end.

    Stream::reason

    fn Stream::reason(self : Stream) -> String

    Stream::status

    fn Stream::status(self : Stream) -> Int

    Stream::text

    async fn Stream::text(self : Stream, limit? : Int) -> String

    The rest of the body as UTF-8 text; a malformed sequence reads as U+FFFD.

    Stream::url

    fn Stream::url(self : Stream) -> String

    Stream::version

    fn Stream::version(self : Stream) -> String

    delete

    async fn delete(url : StringView, query? : Array[(String, String)], headers? : Array[(String, String)], timeout? : Int, redirects? : Int) -> Response

    get

    async fn get(url : StringView, query? : Array[(String, String)], headers? : Array[(String, String)], timeout? : Int, redirects? : Int) -> Response

    async fn head(url : StringView, query? : Array[(String, String)], headers? : Array[(String, String)], timeout? : Int, redirects? : Int) -> Response

    idle

    let idle : Int

    How many idle HTTP/1.1 connections are kept per origin: urllib3's 10.

    limits

    let limits : Limits

    The bounds a client uses when none are given: a 64 KiB head, 256 fields, and a 32 MiB body.

    No client of the common ones bounds a body by default; a library that returns a Response with the whole body in memory has to, or one answer can exhaust the process. 32 MiB covers the API payloads a buffered call is for; anything larger is read as a Stream, which is not bounded.

    options

    async fn options(url : StringView, query? : Array[(String, String)], headers? : Array[(String, String)], timeout? : Int, redirects? : Int) -> Response

    patch

    async fn patch(url : StringView, body? : Body, query? : Array[(String, String)], headers? : Array[(String, String)], timeout? : Int, redirects? : Int) -> Response

    post

    async fn post(url : StringView, body? : Body, query? : Array[(String, String)], headers? : Array[(String, String)], timeout? : Int, redirects? : Int) -> Response

    put

    async fn put(url : StringView, body? : Body, query? : Array[(String, String)], headers? : Array[(String, String)], timeout? : Int, redirects? : Int) -> Response

    redirects

    let redirects : Int

    How many redirects are followed when nothing else is said: reqwest's and requests' 10.

    request

    async fn request(verb :
    Method
    , url : StringView, query? : Array[(String, String)], headers? : Array[(String, String)], body? : Body, timeout? : Int, redirects? : Int) -> Response

    One request on a client of its own, closed afterwards. A program that makes more than a few requests keeps a Client instead.

    timeout

    let timeout : Int

    How long a request may take when nothing else is said, in milliseconds: httpx's 5 seconds.