// A minimal HTTP/1 client codec — the wire the consul-backed discovery driver speaks
// to a consul agent. A request is built as an HTTP/1.0 message with `Connection: close`
// (so the response is delimited by the server closing the socket — no chunked framing
// to unpick), and a response is parsed into its status code, headers, and body. Pure
// bytes-in/bytes-out, so it runs and is tested on every backend; the socket that
// carries these bytes to a real consul lives in the native `discov` driver.
///|
/// A malformed HTTP response.
pub suberror Http1Error {
Http1Error(String)
}
///|
/// A parsed HTTP response: the status code, the headers as `(name, value)` pairs with
/// names lower-cased, and the raw body bytes.
pub struct Http1Response {
status : Int
headers : Array[(String, String)]
body : Bytes
}
///|
/// The status code.
pub fn Http1Response::status(self : Http1Response) -> Int {
self.status
}
///|
/// The raw body bytes.
pub fn Http1Response::body(self : Http1Response) -> Bytes {
self.body
}
///|
/// The first value of header `name` (matched case-insensitively), or `None`.
pub fn Http1Response::header(self : Http1Response, name : String) -> String? {
let want = name.to_lower()
for pair in self.headers {
if pair.0 == want {
return Some(pair.1)
}
}
None
}
///|
/// Build an HTTP/1.0 request: request line, `Host`, an optional `Content-Type`, a
/// `Content-Length` for the body, and `Connection: close`, then the body. Sending 1.0
/// with `close` makes the response close-delimited, so the client reads to EOF.
pub fn http1_request(
verb : String,
path : String,
host : String,
body : Bytes,
content_type? : String = "",
) -> Bytes {
let buf = Buffer()
http1_write_str(buf, verb + " " + path + " HTTP/1.0\r\n")
http1_write_str(buf, "Host: " + host + "\r\n")
if content_type != "" {
http1_write_str(buf, "Content-Type: " + content_type + "\r\n")
}
http1_write_str(buf, "Content-Length: " + body.length().to_string() + "\r\n")
http1_write_str(buf, "Connection: close\r\n\r\n")
buf.write_bytes(body)
buf.to_bytes()
}
///|
/// Parse a whole HTTP response (status line, headers, body). The body is everything
/// after the header terminator, trimmed to `Content-Length` when the server sent one.
pub fn http1_parse_response(data : Bytes) -> Http1Response raise Http1Error {
let sep = http1_find_header_end(data)
guard sep >= 0 else { raise Http1Error("no header terminator (CRLF CRLF)") }
let head = @utf8.decode_lossy(data[0:sep])
let lines = http1_split_crlf(head)
guard lines.length() >= 1 else { raise Http1Error("empty response head") }
let status = http1_parse_status(lines[0])
let headers : Array[(String, String)] = []
for i = 1; i < lines.length(); i = i + 1 {
let line = lines[i]
match http1_split_header(line) {
Some(pair) => headers.push(pair)
None => ()
}
}
let body_start = sep + 4
let mut body = if body_start <= data.length() {
data[body_start:data.length()].to_owned()
} else {
b""
}
// Honour an explicit Content-Length if the server sent one and more bytes trailed.
for pair in headers {
if pair.0 == "content-length" {
match http1_parse_int(pair.1) {
Some(n) =>
if n >= 0 && n <= body.length() {
body = body[0:n].to_owned()
}
None => ()
}
}
}
{ status, headers, body, }
}
///|
/// The index of the `\r\n\r\n` that ends the header block, or `-1`.
fn http1_find_header_end(data : Bytes) -> Int {
for i = 0; i + 3 < data.length(); i = i + 1 {
if data[i] == b'\r' &&
data[i + 1] == b'\n' &&
data[i + 2] == b'\r' &&
data[i + 3] == b'\n' {
return i
}
}
-1
}
///|
/// Split a header block on CRLF into its lines (empty lines dropped).
fn http1_split_crlf(head : String) -> Array[String] {
let out : Array[String] = []
let line = Buffer()
let mut i = 0
while i < head.length() {
let c = head[i]
if c == '\r' && i + 1 < head.length() && head[i + 1] == '\n' {
let s = @utf8.decode_lossy(line.to_bytes()[:])
if s != "" {
out.push(s)
}
line.reset()
i += 2
} else {
// ASCII header text; keep the low byte.
line.write_byte((c.to_int() & 0xFF).to_byte())
i += 1
}
}
let s = @utf8.decode_lossy(line.to_bytes()[:])
if s != "" {
out.push(s)
}
out
}
///|
/// Parse the status code out of a status line like `HTTP/1.1 200 OK`.
fn http1_parse_status(line : String) -> Int raise Http1Error {
let mut i = 0
// Skip the HTTP version token.
while i < line.length() && line[i] != ' ' {
i += 1
}
while i < line.length() && line[i] == ' ' {
i += 1
}
let start = i
while i < line.length() && line[i] != ' ' {
i += 1
}
match http1_parse_int(line[start:i].to_owned()) {
Some(n) => n
None => raise Http1Error("no status code in: " + line)
}
}
///|
/// Split a header line `Name: Value` into a lower-cased name and its value (leading
/// space trimmed), or `None` if it has no colon.
fn http1_split_header(line : String) -> (String, String)? {
let mut colon = -1
for i = 0; i < line.length(); i = i + 1 {
if line[i] == ':' {
colon = i
break
}
}
guard colon > 0 else { return None }
let name = line[0:colon].to_owned().to_lower()
let mut vstart = colon + 1
while vstart < line.length() && line[vstart] == ' ' {
vstart += 1
}
Some((name, line[vstart:line.length()].to_owned()))
}
///|
/// Parse a non-negative decimal integer, or `None`.
fn http1_parse_int(s : String) -> Int? {
if s.length() == 0 {
return None
}
let mut n = 0
for i = 0; i < s.length(); i = i + 1 {
let d = s[i].to_int() - 0x30
if d < 0 || d > 9 {
return None
}
n = n * 10 + d
}
Some(n)
}
///|
/// Append the ASCII string `s` to `buf`.
fn http1_write_str(buf : Buffer, s : String) -> Unit {
buf.write_bytes(@utf8.encode(s))
}