A pure, I/O-free HTTP/1.1 message model for Standard ML, following
RFC 9110 (semantics) and
RFC 9112 (message syntax). Everything
is a deterministic input -> output function over strings; there are no
sockets, threads, or OS calls in the core. That makes the whole thing
reproducible and testable without a network.
Builds and tests identically under MLton and Poly/ML.
- Case-insensitive headers (
Headers): order-preserving multimap withget/getAll/getCombined(duplicates joined with,per RFC 9110 §5.2), plusadd/set/remove. - Status table (
Status): reason phrases and class predicates (isSuccess,isRedirect,isClientError,isServerError). - Request/response records with parsing and serialization that round-trip byte-for-byte.
- Framing as pure decoders (
Http):Content-LengthandTransfer-Encoding: chunkeddecode/encode (RFC 9112 §7.1). - URI integration via vendored
sml-uri:targetUriparses a request target into aUri.uri. - CLI
bin/httpthat parses a raw request from stdin.
type request =
{ method : string, target : string, version : string
, headers : Headers.headers, body : string }
type response =
{ version : string, status : int, reason : string
, headers : Headers.headers, body : string }
val parseRequest : string -> request option
val parseResponse : string -> response option
val serializeRequest : request -> string
val serializeResponse : response -> string
val targetUri : request -> Uri.uri
val text : int -> string -> response (* text/plain helper *)
val html : string -> response (* 200 text/html *)
val response : int -> Headers.headers -> string -> response
val redirect : string -> response (* 302 Found + Location *)
val redirectWith : int -> string -> response
(* request builders (version defaults to HTTP/1.1) *)
val get : string -> request
val delete : string -> request
val post : string -> string -> request (* target body *)
val put : string -> string -> request (* target body *)
(* framing *)
val decodeBody : Headers.headers -> string -> string option
val decodeChunked : string -> string option
val encodeChunked : string -> stringval SOME req = Http.parseRequest "GET /a?b=c HTTP/1.1\r\nHost: x\r\n\r\n"
val "GET" = #method req
val SOME "x" = Headers.get (#headers req) "host" (* case-insensitive *)
val [("b","c")] = Uri.queryParams (Http.targetUri req)
val resp = Http.text 200 "hi"
(* "HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\n
Content-Length: 2\r\n\r\nhi" *)
val out = Http.serializeResponse respConvenience constructors for common messages. Request builders default the
version to HTTP/1.1; post/put set Content-Length from the body.
redirect produces a 302 Found with a Location header, and html a 200
text/html response.
Http.serializeRequest (Http.get "/a?b=c") (* "GET /a?b=c HTTP/1.1\r\n\r\n" *)
Http.serializeRequest (Http.post "/submit" "hello")
(* "POST /submit HTTP/1.1\r\nContent-Length: 5\r\n\r\nhello" *)
Http.serializeResponse (Http.redirect "/new") (* "...302 Found\r\nLocation: /new\r\n\r\n" *)
Http.serializeResponse (Http.html "<h1>hi</h1>") (* Content-Type: text/html; charset=utf-8 *)$ printf 'GET /a?b=c HTTP/1.1\r\nHost: x\r\n\r\n' | ./bin/http parse-req
method: GET
target: /a?b=c
version: HTTP/1.1
header: Host = x
body-bytes: 0
make test # MLton
make test-poly # Poly/ML
make all-tests # both
make cli # build bin/http (MLton)
make example # build + run the demo62 deterministic checks, green under both compilers.
Numeric fields parsed from untrusted messages (response status code,
Content-Length) are range-checked via IntInf and bounded to a fixed 32-bit
signed range, so an oversized value returns NONE instead of raising
Overflow. This matters because on this toolchain MLton's Int is 32-bit and
Poly/ML's is 63-bit (both fixed width; only IntInf is arbitrary precision),
so an unchecked parse would crash on MLton and diverge from Poly/ML.
examples/demo.sml builds, serializes, and parses fixed
HTTP/1.1 messages and shows chunked encoding. It is pure string processing, so
the output is identical on every run and on both compilers (wire CRLF is shown
as plain newlines for readability). Run it with:
$ make example
GET request:
GET /search?q=ml&lang=sml HTTP/1.1
Host: example.com
Accept: text/html
POST request (Content-Length set by Http.post):
POST /submit HTTP/1.1
Content-Length: 17
name=ml&year=2026
200 response (Http.text):
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 13
Hello, world
Parse a request from the wire:
method=GET target=/a/b?x=1 path=/a/b query=x=1 Host=h.example
encodeChunked "Hello, world":
c
Hello, world
0
Add to your sml.pkg:
require {
github.com/sjqtentacles/sml-http
}
then smlpkg sync, or vendor it under lib/github.com/sjqtentacles/sml-http/
and reference its sml-http.mlb.
lib/github.com/sjqtentacles/
sml-http/
headers.{sig,sml} case-insensitive header multimap
status.{sig,sml} status codes + reason phrases
http.{sig,sml} request/response records, parse/serialize, framing
sources.mlb sml-http.mlb
sml-uri/ vendored dependency (committed)
bin/http.{sml,mlb} CLI
test/ Harness suite (62 checks)
sml-uri is committed under lib/github.com/sjqtentacles/sml-uri/ so make
needs no network. Rebuild the vendored copy with smlpkg sync if you bump the
dependency.
MIT. See LICENSE.