HTTP

Parse HTTP/1.x requests and responses, following the message syntax of RFC 9112 and the field syntax of RFC 9110.

HTTP.parse_request and HTTP.parse_response read one message from the start of a byte list and return it with the bytes that follow (the next pipelined message). HTTP.parse_requests reads a whole pipeline. They fail with InvalidHttp(Error), where the HTTP.Error gives the byte offset of the problem. HTTP.request and HTTP.response are the same parsers for use inside a larger Parser.

expect {
    bytes = "GET /hello HTTP/1.1\r\nHost: example.com\r\n\r\n".to_utf8()
    match HTTP.parse_request(bytes) {
        Ok({ request, rest }) => request.method == Get and request.target == "/hello" and rest == []
        Err(InvalidHttp(_)) => False
    }
}

Message framing (RFC 9112 section 6.3):

  • Transfer-Encoding: chunked bodies are decoded; chunk extensions and trailer fields are validated and discarded.
  • Content-Length bodies are exactly that many bytes.
  • A request with neither has no body. A response with neither runs to the end of the input (the connection close). Responses with a 1xx, 204 or 304 status never have a body. Responses to HEAD and 2xx responses to CONNECT also have no body, but this parser cannot see the request: parse those heads with a status that implies no body or strip the framing fields.

Messages whose framing is ambiguous are rejected rather than guessed at, because a parser that disagrees with a proxy about where a message ends is open to request smuggling: both Transfer-Encoding and Content-Length, conflicting or malformed Content-Length values, any transfer coding other than a single chunked, Transfer-Encoding in an HTTP/1.0 message, line folding (obs-fold), whitespace between a field name and its colon, control characters in field values, and line endings other than CRLF.

HTTP/1.1 requests must have exactly one Host field. Field names keep their case; field values have surrounding whitespace removed. Field values and reason phrases must be valid UTF-8 to be represented as Str; other obs-text bytes are rejected.

header : List(Header), Str -> Try(Str, [Missing])

The value of the first header with this name, compared case-insensitively, or Err(Missing).

expect {
    headers = [{ name: "Content-Type", value: "text/plain" }]
    HTTP.header(headers, "content-type") == Ok("text/plain") and HTTP.header(headers, "Host") == Err(Missing)
}
parse_request : List(U8) -> Try({ request : Request, rest : List(U8) }, [InvalidHttp(Error)])

Parse one HTTP request from the start of bytes: request line, header fields, and the body its framing describes.

Returns the request and the rest of the bytes after it, which is the next pipelined message, if any.

expect {
    bytes = "POST /notes HTTP/1.1\r\nHost: a\r\nContent-Length: 2\r\n\r\nhiGET".to_utf8()
    match HTTP.parse_request(bytes) {
        Ok({ request, rest }) => request.body == "hi".to_utf8() and rest == "GET".to_utf8()
        Err(InvalidHttp(_)) => False
    }
}
parse_response : List(U8) -> Try({ response : Response, rest : List(U8) }, [InvalidHttp(Error)])

Parse one HTTP response from the start of bytes: status line, header fields, and the body its framing describes.

A response with no Content-Length or Transfer-Encoding takes the rest of the input as its body, so its rest is empty.

expect {
    bytes = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".to_utf8()
    match HTTP.parse_response(bytes) {
        Ok({ response, rest: _ }) => response.status_code == 404 and response.reason == "Not Found"
        Err(InvalidHttp(_)) => False
    }
}
parse_requests : List(U8) -> Try(List(Request), [InvalidHttp(Error)])

Parse every request in bytes, a pipeline of zero or more requests that must end exactly at the end of the last one.

A failure's offset counts from the start of bytes.

expect {
    bytes = "GET /a HTTP/1.1\r\nHost: a\r\n\r\nGET /b HTTP/1.1\r\nHost: a\r\n\r\n".to_utf8()
    HTTP.parse_requests(bytes).map_ok(|requests| requests.map(|r| r.target)) == Ok(["/a", "/b"])
}
request : Parser(Bytes, Request)

A Parser for one HTTP request, like HTTP.parse_request, for use inside a larger parser.

It leaves the bytes after the message unconsumed. A failure is a ParseError whose message starts with invalid HTTP request: and whose offset is where the problem is.

expect {
    text = "GET /hello HTTP/1.1\r\nHost: example.com\r\n\r\n"
    match Utf8.parse_str(HTTP.request, text) {
        Ok(req) => req.method == Get and HTTP.header(req.headers, "host") == Ok("example.com")
        Err(_) => False
    }
}
response : Parser(Bytes, Response)

A Parser for one HTTP response, like HTTP.parse_response, for use inside a larger parser.

A failure is a ParseError whose message starts with invalid HTTP response: and whose offset is where the problem is.

Method : [Options, Get, Post, Put, Delete, Head, Trace, Connect, Patch, Extension(Str)]

A request method (RFC 9110 section 9).

The methods RFC 9110 and RFC 5789 define get their own tags. Any other method token is kept as Extension(name). Method names are case-sensitive, so get is Extension("get"), not Get.

Version : { major : U8, minor : U8 }

An HTTP protocol version such as HTTP/1.1, as { major: 1, minor: 1 }.

Each part is a single digit, as RFC 9112 requires.

Header : { name : Str, value : Str }

One header field: the name as written (case preserved) and the value with surrounding whitespace removed.

Fields keep their order in the message, and repeated fields are kept. Use HTTP.header to look one up by name.

Request : {
    method : Method,
    target : Str,
    version : Version,
    headers : List(Header),
    body : List(U8),
}

A parsed HTTP request message.

target is the request-target exactly as written (it is not decoded or normalized). body is the message body after any chunked transfer coding has been removed.

Response : {
    version : Version,
    status_code : U16,
    reason : Str,
    headers : List(Header),
    body : List(U8),
}

A parsed HTTP response message.

status_code is the three-digit status code and reason the reason phrase, which may be empty. body is decoded like a request body.

Error : { offset : U64, message : Str }

Why a message was rejected: message names the broken rule and offset is the byte offset in the input where the problem is.

Problems with the message as a whole, such as a missing Host field, are at offset 0, the start of the message.