///|
/// A parsed HTTP/1.1 request line + header block, produced by
/// `parse_http1_request` from any `@io.Reader` transport (a plain socket, or —
/// the reason this codec exists — a `@tls.Tls` stream, over which the async
/// library's own `@http.ServerConnection` cannot be built because it is welded
/// to `@socket.Tcp`). This is the self-built HTTP/1.1 request parser mandated by
/// the roadmap (§6.2 "自建解析器"), kept transport-agnostic so it drives both the
/// TLS accept path and, in later batches, the full h11 knob surface.
priv struct Http1Request {
http_method : String
target : String
version : String
headers : Map[String, String]
content_length : Int
}
///|
/// Index of the first `ch` in `s` at or after `from`, or `-1`. Operates on the
/// `UInt16` code units `String` indexing yields; HTTP request/header syntax is
/// ASCII so code-unit scanning is exact.
fn index_of(s : String, ch : UInt16, from : Int) -> Int {
let n = s.length()
for i = from; i < n; i = i + 1 {
if s[i] == ch {
return i
}
}
-1
}
///|
/// Split an HTTP/1.1 request line `METHOD SP request-target SP HTTP-version`
/// into its three tokens, or `None` if it is malformed (fewer than two spaces).
fn split_request_line(line : String) -> (String, String, String)? {
let sp1 = index_of(line, ' ', 0)
guard sp1 >= 0 else { return None }
let sp2 = index_of(line, ' ', sp1 + 1)
guard sp2 >= 0 else { return None }
let verb = line[0:sp1].to_owned()
let target = line[sp1 + 1:sp2].to_owned()
let version = line[sp2 + 1:line.length()].to_owned()
Some((verb, target, version))
}
///|
/// Parse a `Content-Length` field value into a non-negative `Int`, stopping at
/// the first non-digit (lenient like a production parser's fast path).
fn parse_content_length(s : String) -> Int {
let t = ascii_trim(s)
let mut acc = 0
for i = 0; i < t.length(); i = i + 1 {
let c = t[i]
if c >= '0' && c <= '9' {
acc = acc * 10 + (c.to_int() - 48)
} else {
return acc
}
}
acc
}
///|
/// Read one HTTP/1.1 request head (request line + header block, terminated by a
/// blank line) from `reader`. Returns `None` on a clean end-of-stream — the
/// signal the keep-alive accept loop uses to stop reading further requests from
/// a closed connection — or on a malformed request line. Header names are
/// lowercased per ASGI's latin1-lowercased convention; values are OWS-trimmed
/// per RFC 7230 §3.2.
async fn parse_http1_request(reader : &@io.Reader) -> Http1Request? {
let line = reader.read_until("\r\n")
guard line is Some(request_line) else { return None }
guard split_request_line(request_line) is Some((verb, target, version)) else {
return None
}
let headers : Map[String, String] = Map([])
for ;; {
let h = reader.read_until("\r\n")
guard h is Some(header_line) else { break }
if header_line.length() == 0 {
break
}
let colon = index_of(header_line, ':', 0)
if colon >= 0 {
let key = ascii_trim(header_line[0:colon].to_owned()).to_lower()
let value = ascii_trim(
header_line[colon + 1:header_line.length()].to_owned(),
)
headers[key] = value
}
}
let content_length = match headers.get("content-length") {
Some(v) => parse_content_length(v)
None => 0
}
Some({ http_method: verb, target, version, headers, content_length })
}
///|
/// Lowercase hexadecimal encoding of a non-negative `Int`, for HTTP/1.1 chunked
/// transfer-encoding chunk-size prefixes.
fn hex_of(n : Int) -> String {
let table = [
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
]
if n == 0 {
return "0"
}
let digits : Array[Char] = []
let mut v = n
while v > 0 {
digits.push(table[v % 16])
v = v / 16
}
let out = StringBuilder::new()
for i = digits.length() - 1; i >= 0; i = i - 1 {
out.write_char(digits[i])
}
out.to_string()
}
///|
/// Should the connection be kept alive after this request? HTTP/1.1 defaults to
/// keep-alive unless `Connection: close`; HTTP/1.0 defaults to close unless
/// `Connection: keep-alive` (RFC 7230 §6.3).
fn should_keep_alive(req : Http1Request) -> Bool {
let conn = match req.headers.get("connection") {
Some(c) => c.to_lower()
None => ""
}
if req.version == "HTTP/1.0" {
conn.contains("keep-alive")
} else {
!conn.contains("close")
}
}
///|
/// Write one HTTP/1.1 chunked-encoding chunk. A zero-length body is skipped so
/// it is never mistaken for the terminating chunk.
async fn write_chunk(writer : &@io.Writer, body : Bytes) -> Unit {
if body.length() == 0 {
return
}
writer.write("\{hex_of(body.length())}\r\n")
writer.write(body)
writer.write("\r\n")
}
///|
/// Drive a moonasgi application over a single already-established HTTP/1.1
/// transport (`reader`/`writer` are the same `@tls.Tls` stream on the TLS path).
/// Builds the `Http` `Scope`, a `Receive` that streams the `Content-Length`
/// request body, and a `Send` that serialises the response:
///
/// * a single-shot response (`http.response.body` with `more_body=false` first)
/// is framed with `Content-Length`;
/// * a streamed response (`more_body=true`) switches to `Transfer-Encoding:
/// chunked`, one chunk per body event, terminated by the final `0\r\n\r\n`.
///
/// Framing headers the app supplies (`content-length` / `transfer-encoding`) are
/// dropped in favour of the ones this codec computes. Returns whether the
/// connection should be kept alive for a further request.
async fn dispatch_http1(
app : @moonasgi.AsgiApp,
req : Http1Request,
reader : &@io.Reader,
writer : &@io.Writer,
scheme~ : String,
) -> Bool {
let (path, query) = split_query(req.target)
let scope = @moonasgi.Scope::Http({
http_version: if req.version == "HTTP/1.0" {
"1.0"
} else {
"1.1"
},
http_method: req.http_method,
scheme,
path,
raw_path: @utf8.encode(path),
query_string: @utf8.encode(query),
root_path: "",
headers: headers_to_pairs(req.headers),
client: None,
server: None,
asgi: @moonasgi.AsgiVersion::http(),
extensions: @moonasgi.Extensions::none(),
state: Map([]),
})
let body_sent = Ref(false)
let receive : @moonasgi.Receive = () => {
if body_sent.val {
@moonasgi.Event::HttpDisconnect
} else {
body_sent.val = true
if req.content_length == 0 {
@moonasgi.Event::HttpRequest(body=b"", more_body=false)
} else {
let chunk = reader.read_exactly(req.content_length)
@moonasgi.Event::HttpRequest(body=chunk, more_body=false)
}
}
}
let resp_status = Ref(200)
let resp_headers : Ref[Array[(String, String)]] = Ref([])
let started = Ref(false)
let chunked = Ref(false)
let ended = Ref(false)
let head = fn(extra : String) -> String {
let sb = StringBuilder::new()
sb.write_string(
"HTTP/1.1 \{resp_status.val} \{reason(resp_status.val)}\r\n",
)
for pair in resp_headers.val {
let kl = pair.0.to_lower()
if kl != "content-length" && kl != "transfer-encoding" {
sb.write_string("\{pair.0}: \{pair.1}\r\n")
}
}
sb.write_string(extra)
sb.to_string()
}
let send : @moonasgi.Send = event => {
match event {
HttpResponseStart(status~, headers~, ..) => {
resp_status.val = status
resp_headers.val = headers
}
HttpResponseBody(body~, more_body~) =>
if !started.val {
started.val = true
if !more_body {
writer.write(head("content-length: \{body.length()}\r\n\r\n"))
writer.write(body)
ended.val = true
} else {
chunked.val = true
writer.write(head("transfer-encoding: chunked\r\n\r\n"))
write_chunk(writer, body)
}
} else if chunked.val {
write_chunk(writer, body)
if !more_body {
writer.write("0\r\n\r\n")
ended.val = true
}
}
_ => ()
}
}
app(scope, receive, send)
if !started.val {
writer.write(head("content-length: 0\r\n\r\n"))
} else if chunked.val && !ended.val {
writer.write("0\r\n\r\n")
}
should_keep_alive(req)
}
///|
test "http1 codec: request-line split, content-length, hex, keep-alive" {
assert_eq(
split_request_line("GET /a?b=1 HTTP/1.1"),
Some(("GET", "/a?b=1", "HTTP/1.1")),
)
assert_eq(split_request_line("garbage"), None)
assert_eq(parse_content_length(" 42 "), 42)
assert_eq(parse_content_length("0"), 0)
assert_eq(hex_of(0), "0")
assert_eq(hex_of(255), "ff")
assert_eq(hex_of(4096), "1000")
let ka_11 : Http1Request = {
http_method: "GET",
target: "/",
version: "HTTP/1.1",
headers: Map([]),
content_length: 0,
}
assert_eq(should_keep_alive(ka_11), true)
let close_11 : Http1Request = {
..ka_11,
headers: Map([("connection", "close")]),
}
assert_eq(should_keep_alive(close_11), false)
let ka_10 : Http1Request = {
..ka_11,
version: "HTTP/1.0",
headers: Map([("connection", "keep-alive")]),
}
assert_eq(should_keep_alive(ka_10), true)
let close_10 : Http1Request = { ..ka_11, version: "HTTP/1.0" }
assert_eq(should_keep_alive(close_10), false)
}