diff --git a/dispatch.mbt b/dispatch.mbt index e485f84..50e437e 100644 --- a/dispatch.mbt +++ b/dispatch.mbt @@ -1,17 +1,56 @@ +///| +/// Split a raw HTTP request-target into its path and query string. +/// +/// Routes match against the path; the query is retained separately on the +/// `HttpRequest`. A `#fragment` is never used for routing, so it is stripped +/// from the query rather than exposed. +fn split_request_target(target : String) -> (String, String) { + let (path, rest) = match target.find("?") { + Some(q) => (target[:q].to_owned(), target[q + 1:].to_owned()) + None => (target, "") + } + let query = match rest.find("#") { + Some(f) => rest[:f].to_owned() + None => rest + } + (path, query) +} + +///| +/// Whether a request carries a body: POST/PUT/PATCH always may, and any +/// method with an explicit (non-empty) `content-length` or a +/// `transfer-encoding` (i.e. chunked) frame is treated as having a body. +/// The same rule is used by every backend so bodies are read consistently. +fn request_has_body( + http_method : String, + headers : Map[@http.CaseInsensitiveString, StringView], +) -> Bool { + match http_method { + "POST" | "PUT" | "PATCH" => true + _ => + headers.get("transfer-encoding") is Some(_) || + headers + .get("content-length") + .map(value => value.to_owned().trim() != "0") + .unwrap_or(false) + } +} + ///| pub async fn dispatch_http( mocket : Mocket, http_method : String, url : String, - headers : Map[StringView, StringView], + headers : Map[@http.CaseInsensitiveString, StringView], raw_body : Bytes, ) -> HttpResponse { - let (params, handler) = match mocket.find_route(http_method, url) { + let (path, query) = split_request_target(url) + let (params, handler) = match mocket.find_route(http_method, path) { Some((h, p)) => (p, h) _ => ({}, handle_not_found()) } let event = { - req: { http_method, url, raw_body, headers }, + req: { http_method, url: path, query, raw_body, headers }, res: HttpResponse::new(OK), params, } diff --git a/mocket.js.mbt b/mocket.js.mbt index 948e19a..3f84508 100644 --- a/mocket.js.mbt +++ b/mocket.js.mbt @@ -307,8 +307,8 @@ fn listen_port(address : String) -> Int { pub fn listen_ffi(mocket : Mocket, address : String) -> Unit { let port = listen_port(address) let server = create_server(fn(req, res, _) { - // 优化:简化 headers 转换逻辑 - let string_headers : Map[StringView, StringView] = Map([]) + // 构造大小写不敏感的头部映射表(HTTP 字段名不区分大小写) + let string_headers : Map[@http.CaseInsensitiveString, StringView] = Map([]) let json_val = req.headers().to_value().to_json() catch { _ => { res.write_head(400, @js.Object::new().to_value()) @@ -322,34 +322,22 @@ pub fn listen_ffi(mocket : Mocket, address : String) -> Unit { return } - // 批量转换 headers,减少单个处理的开销 headers.each(fn(key, value) { if value is String(v) { string_headers.set(key, v.to_string_view()) } }) - let (params, handler) = match - mocket.find_route(req.req_method(), req.url()) { - Some((h, p)) => (p, h) - _ => ({}, handle_not_found()) - } - let event = { - req: { - http_method: req.req_method(), - url: req.url(), - headers: string_headers, - raw_body: b"", - }, - res: HttpResponse::new(OK), - params, - } + + let http_method = req.req_method() + let url = req.url() + let should_read_body = request_has_body(http_method, string_headers) async_run(() => { - // 如果是 post,先等待 data 事件 - if event.req.http_method == "POST" { + let mut raw = b"" + if should_read_body { let buffer = Buffer() let mut total_size = 0 let mut exceeded = false - suspend(fn(res, _) { + suspend(fn(done, _) { req.on("data", data => { if !exceeded { let chunk = node_body_chunk_to_bytes(data) @@ -361,7 +349,7 @@ pub fn listen_ffi(mocket : Mocket, address : String) -> Unit { } } }) - req.on("end", _ => res(())) + req.on("end", _ => done(())) }) catch { _ => () } @@ -370,35 +358,33 @@ pub fn listen_ffi(mocket : Mocket, address : String) -> Unit { res.end(@js.Value::cast_from("Request body too large")) return } - event.req.raw_body = buffer.to_bytes() + raw = buffer.to_bytes() } - // 执行中间件链和处理器 - let responder = mocket.execute_middlewares(event, handler) catch { - err => mocket.handle_request_error(event, err) + // 交给统一的 dispatch_http:路由、查询拆分、中间件与错误处理全部一致。 + let response = dispatch_http( + mocket, http_method, url, string_headers, raw, + ) catch { + _ => + HttpResponse::new(InternalServerError).body("Internal Server Error") } - // let boundary = "----------------moonbit-" + port.to_string() - responder.options(event.res) res.write_head( - event.res.status_code.to_int(), + response.status_code.to_int(), { - let headers_obj = @js.Value::from_json(event.res.headers.to_json()) catch { + let mut headers_obj = @js.Value::from_json(response.headers.to_json()) catch { _ => @js.Object::new().to_value() } - if !event.res.cookies.is_empty() { - let cookies = event.res.cookies + if !response.cookies.is_empty() { + let cookies = response.cookies .values() - .map(fn(c) { Show::to_string(c) }) + .map(fn(cookie) { Show::to_string(cookie) }) .to_array() set_js_property(headers_obj, "Set-Cookie", array_to_js(cookies)) } headers_obj }, ) - let buf = Buffer() - responder.output(buf) - event.res.raw_body = buf.to_bytes() - res.end(@js.Value::cast_from(event.res.raw_body)) + res.end(@js.Value::cast_from(response.raw_body)) }) }) start_server(server, address, websocket_accept_key) diff --git a/mocket.native.mbt b/mocket.native.mbt index aa13027..0721ed9 100644 --- a/mocket.native.mbt +++ b/mocket.native.mbt @@ -62,18 +62,18 @@ fn request_method_to_string(meth : @http.RequestMethod) -> String { ///| fn string_headers_to_views( headers : Map[@http.CaseInsensitiveString, String], -) -> Map[StringView, StringView] { - let out : Map[StringView, StringView] = Map([]) - headers.each((key, value) => out.set(Show::to_string(key), value)) +) -> Map[@http.CaseInsensitiveString, StringView] { + let out : Map[@http.CaseInsensitiveString, StringView] = Map([]) + headers.each((key, value) => out.set(key, value)) out } ///| fn view_headers_to_strings( - headers : Map[StringView, StringView], + headers : Map[@http.CaseInsensitiveString, StringView], ) -> Map[@http.CaseInsensitiveString, String] { let out : Map[@http.CaseInsensitiveString, String] = Map([]) - headers.each((key, value) => out.set(key.to_owned(), value.to_owned())) + headers.each((key, value) => out.set(key, value.to_owned())) out } @@ -121,27 +121,6 @@ fn is_websocket_upgrade(request : @http.Request) -> Bool { header_equals(request.headers, "upgrade", "websocket") } -///| -fn request_has_body(request : @http.Request) -> Bool { - match request.meth { - Post | Put | Patch => true - _ => - request.headers.get("transfer-encoding") is Some(_) || - request.headers - .get("content-length") - .map(value => value.trim() != "0") - .unwrap_or(false) - } -} - -///| -fn request_route_path(path : String) -> String { - match path.find("?") { - Some(query_start) => path[:query_start].to_owned() - None => path - } -} - ///| fn find_ws_route( mocket : Mocket, @@ -374,6 +353,8 @@ async fn send_native_response( conn.end_response() } +///| + ///| async fn handle_http_request( mocket : Mocket, @@ -381,7 +362,11 @@ async fn handle_http_request( body_reader : &@io.Reader, conn : @http.ServerConnection, ) -> Unit { - let raw_body = if request_has_body(request) { + let headers = string_headers_to_views(request.headers) + let raw_body = if request_has_body( + request_method_to_string(request.meth), + headers, + ) { let content_length = request.headers .get("content-length") .map(s => @string.parse_int(s.trim()) catch { _ => 0 }) @@ -398,11 +383,12 @@ async fn handle_http_request( } else { b"" } + // `dispatch_http` normalizes `request.path` into a path + query internally. let response = dispatch_http( mocket, request_method_to_string(request.meth), - request_route_path(request.path), - string_headers_to_views(request.headers), + request.path, + headers, raw_body, ) catch { err => { diff --git a/native/mongoose/mongoose.mbt b/native/mongoose/mongoose.mbt index ef01a19..e939d72 100644 --- a/native/mongoose/mongoose.mbt +++ b/native/mongoose/mongoose.mbt @@ -118,8 +118,10 @@ fn from_cbytes(bytes : Bytes) -> String { } ///| -fn parse_headers(headers_text : String) -> Map[StringView, StringView] { - let headers : Map[StringView, StringView] = Map([]) +fn parse_headers( + headers_text : String, +) -> Map[@http.CaseInsensitiveString, StringView] { + let headers : Map[@http.CaseInsensitiveString, StringView] = Map([]) headers_text .split("\n") .each(line => { @@ -129,7 +131,7 @@ fn parse_headers(headers_text : String) -> Map[StringView, StringView] { let key = line[:idx].trim() let value = line[idx + 1:].trim() if key != "" { - headers.set(key, value) + headers.set(key.to_owned(), value) } } } diff --git a/native/mongoose/moon.pkg b/native/mongoose/moon.pkg index 2c13fcf..840f198 100644 --- a/native/mongoose/moon.pkg +++ b/native/mongoose/moon.pkg @@ -1,5 +1,6 @@ import { "oboard/mocket", + "moonbitlang/async/http", "moonbitlang/core/encoding/utf8", "moonbitlang/core/string", } diff --git a/pkg.generated.mbti b/pkg.generated.mbti index 5eaf240..4b23aec 100644 --- a/pkg.generated.mbti +++ b/pkg.generated.mbti @@ -2,6 +2,7 @@ package "oboard/mocket" import { + "moonbitlang/async/http", "moonbitlang/core/buffer", "moonbitlang/core/json", } @@ -11,7 +12,7 @@ pub fn __ws_emit(Bytes, Bytes, Bytes) -> Unit pub fn cookie_to_string(Array[CookieItem]) -> String -pub async fn dispatch_http(Mocket, String, String, Map[StringView, StringView], Bytes) -> HttpResponse +pub async fn dispatch_http(Mocket, String, String, Map[@http.CaseInsensitiveString, StringView], Bytes) -> HttpResponse pub fn dispatch_ws_event((WebSocketEvent) -> Unit, WebSocketPeer, String, Bytes) -> Unit @@ -91,7 +92,8 @@ pub impl Responder for Html pub(all) struct HttpRequest { http_method : String url : String - headers : Map[StringView, StringView] + query : String + headers : Map[@http.CaseInsensitiveString, StringView] mut raw_body : Bytes } pub fn[T : BodyReader] HttpRequest::body(Self) -> T raise @@ -101,14 +103,14 @@ pub impl Responder for HttpRequest pub(all) struct HttpResponse { mut status_code : StatusCode - headers : Map[StringView, StringView] + headers : Map[@http.CaseInsensitiveString, StringView] cookies : Map[String, CookieItem] mut raw_body : Bytes } pub fn HttpResponse::body(Self, &Responder) -> Self pub fn HttpResponse::delete_cookie(Self, String) -> Unit pub fn HttpResponse::json(Self, &ToJson) -> Self -pub fn HttpResponse::new(StatusCode, headers? : Map[StringView, StringView], cookies? : Map[String, CookieItem], raw_body? : Bytes) -> Self +pub fn HttpResponse::new(StatusCode, headers? : Map[@http.CaseInsensitiveString, StringView], cookies? : Map[String, CookieItem], raw_body? : Bytes) -> Self pub fn[T : BodyReader] HttpResponse::read_body(Self) -> T raise pub fn HttpResponse::set_cookie(Self, String, String, max_age? : Int, path? : String, domain? : String, secure? : Bool, http_only? : Bool, same_site? : SameSiteOption) -> Unit pub fn HttpResponse::to_responder(Self) -> &Responder diff --git a/request.mbt b/request.mbt index bfae822..b51d24e 100644 --- a/request.mbt +++ b/request.mbt @@ -1,8 +1,14 @@ ///| pub(all) struct HttpRequest { http_method : String + /// The request-target path, without query string or fragment. + /// Routing matches against this value. url : String - headers : Map[StringView, StringView] + /// The raw query string without the leading `?`; empty when absent. + /// Fragments (`#...`) are stripped. + query : String + /// Case-insensitive request headers (HTTP field names are case-insensitive). + headers : Map[@http.CaseInsensitiveString, StringView] mut raw_body : Bytes } @@ -67,6 +73,7 @@ test "read_body" { let req = HttpRequest::{ http_method: "POST", url: "/", + query: "", headers: Map([]), raw_body: b"{\"Hello\":\"World!\"}", } diff --git a/request_conformance.mbt b/request_conformance.mbt new file mode 100644 index 0000000..7d16331 --- /dev/null +++ b/request_conformance.mbt @@ -0,0 +1,87 @@ +///| +/// Request-contract conformance suite. +/// +/// These tests exercise the shared `dispatch_http` entry point, which every +/// backend (native, C/mongoose, JS) now funnels through, so the guarantees +/// below hold identically on the `+js` and `+native` targets (the workspace +/// runs the tests on both). +async test "query string does not break path routing" { + let app = new() + app.get("/hello", _ => "hello") + let response = dispatch_http(app, "GET", "/hello?x=1", {}, b"") + @test.assert_eq(response.status_code.to_int(), 200) + let body : String = response.read_body() + @test.assert_eq(body, "hello") +} + +///| +async test "query string is exposed separately from the route path" { + let app = new() + let captured : Array[String] = [] + app.get("/hello/:name", event => { + captured.push(event.req.url) + captured.push(event.req.query) + "ok" + }) + ignore(dispatch_http(app, "GET", "/hello/moonbit?x=1&y=2", {}, b"")) + @test.assert_eq(captured, ["/hello/moonbit", "x=1&y=2"]) +} + +///| +async test "fragment is stripped from the query string" { + let app = new() + let captured : Array[String] = [] + app.get("/hello", event => { + captured.push(event.req.query) + "ok" + }) + ignore(dispatch_http(app, "GET", "/hello?a=1#frag", {}, b"")) + @test.assert_eq(captured, ["a=1"]) +} + +///| +async test "route with no query string yields an empty query" { + let app = new() + let captured : Array[String] = [] + app.get("/plain", event => { + captured.push(event.req.query) + "ok" + }) + ignore(dispatch_http(app, "GET", "/plain", {}, b"")) + @test.assert_eq(captured, [""]) +} + +///| +async test "PUT and PATCH bodies are delivered to the handler" { + let app = new() + let captured : Array[String] = [] + app.put("/raw1", event => { + let text : String = event.req.body() + captured.push(text) + "accepted" + }) + app.patch("/raw2", event => { + let text : String = event.req.body() + captured.push(text) + "accepted" + }) + ignore(dispatch_http(app, "PUT", "/raw1", {}, b"payload-put")) + ignore(dispatch_http(app, "PATCH", "/raw2", {}, b"payload-patch")) + @test.assert_eq(captured, ["payload-put", "payload-patch"]) +} + +///| +async test "request headers are case-insensitive" { + let app = new() + let captured : Array[StringView?] = [] + app.get("/hdr", event => { + captured.push(event.req.headers.get("X-Test")) + captured.push(event.req.headers.get("x-test")) + captured.push(event.req.headers.get("X-TEST")) + "ok" + }) + let headers : Map[@http.CaseInsensitiveString, StringView] = Map([]) + headers["X-Test"] = "1" + ignore(dispatch_http(app, "GET", "/hdr", headers, b"")) + @test.assert_eq(captured, [Some("1"), Some("1"), Some("1")]) +} diff --git a/responder_test.mbt b/responder_test.mbt index 3e97096..8e8c833 100644 --- a/responder_test.mbt +++ b/responder_test.mbt @@ -75,9 +75,7 @@ async test "custom status html body infers html content type" { ///| async test "json response infers json content type" { let app = new() - app.get("/created", _ => { - HttpResponse::new(Created).json({ "ok": true }) - }) + app.get("/created", _ => HttpResponse::new(Created).json({ "ok": true })) let response = dispatch_http(app, "GET", "/created", {}, b"") inspect(response.status_code.to_int(), content="201") @test.assert_eq( @@ -92,16 +90,14 @@ async test "json response infers json content type" { async test "explicit content type wins over inferred body type" { let app = new() app.get("/custom-body", _ => { - HttpResponse::new( - OK, - headers={ "Content-Type": "application/vnd.custom" }, - ).body("plain text") + HttpResponse::new(OK, headers={ "Content-Type": "application/vnd.custom" }).body( + "plain text", + ) }) app.get("/custom-json", _ => { - HttpResponse::new( - OK, - headers={ "Content-Type": "application/vnd.custom+json" }, - ).json({ "ok": true }) + HttpResponse::new(OK, headers={ + "Content-Type": "application/vnd.custom+json", + }).json({ "ok": true }) }) let body_response = dispatch_http(app, "GET", "/custom-body", {}, b"") diff --git a/response.mbt b/response.mbt index 42c0412..75e757d 100644 --- a/response.mbt +++ b/response.mbt @@ -1,7 +1,7 @@ ///| pub(all) struct HttpResponse { mut status_code : StatusCode - headers : Map[StringView, StringView] + headers : Map[@http.CaseInsensitiveString, StringView] cookies : Map[String, CookieItem] mut raw_body : Bytes } @@ -11,6 +11,7 @@ pub fn[T : BodyReader] HttpResponse::read_body(self : HttpResponse) -> T raise { T::from_request({ http_method: "", url: "", + query: "", headers: self.headers, raw_body: self.raw_body, }) @@ -19,7 +20,7 @@ pub fn[T : BodyReader] HttpResponse::read_body(self : HttpResponse) -> T raise { ///| pub fn HttpResponse::new( status_code : StatusCode, - headers? : Map[StringView, StringView], + headers? : Map[@http.CaseInsensitiveString, StringView], cookies? : Map[String, CookieItem], raw_body? : Bytes, ) -> HttpResponse {