Skip to content

Commit 6a2aa36

Browse files
committed
feat(moonapi): routers, foreign mounts, the response kinds, and real streaming.
`App::mount` was the only way to compose, and it applied nothing of its own to what it included — no tags, no security, no responses. `Router` and `include_router` do what FastAPI's do, and `mount_handler` takes any moonasgi handler, which is the hook a static-file server would plug into. Path operations carried 6 of FastAPI's 22 keyword arguments. `description`, `operation_id`, `status_code`, `responses`, `name` and `openapi_extra` are there now, the last merged into the operation object last so a caller can override anything generated. `url_for` renders a named route, mount prefix included. Responses: redirect, file, streaming and cookies did not exist, and `to_asgi` sent exactly one chunk with `more_body=false` — so SSE, the one case where streaming is the whole point, buffered its event stream into a single body. A streaming route dispatches through moonasgi's stream path now. Plus the `status` constants FastAPI re-exports, `UploadFile`'s size and headers, and part-count and part-size limits on multipart, which was previously bounded only by whatever had already been buffered. Signed-off-by: Leo Cheng (heke1228) <chengkelfan@qq.com>
1 parent 4d69848 commit 6a2aa36

25 files changed

Lines changed: 3240 additions & 131 deletions

README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,23 +41,27 @@ let docs_page = @moonapi.swagger_ui() // a Swagger UI page
4141

4242
## What's here (`v0`)
4343

44-
- **Routing**`App::get/post/put/patch/delete/route`, `:param` path segments extracted into `Context::param`, correct `404` (no path) vs `405` (path but not method).
44+
- **Routing**`App::get/post/put/patch/delete/route`, `:param` path segments extracted into `Context::param`, correct `404` (no path) vs `405` (path but not method). A route takes the operation arguments FastAPI's path operations take — `summary`, `description`, `tags`, `deprecated`, `operation_id`, `status_code`, `responses`, `name`, `include_in_schema`, and an `openapi_extra` fragment merged over the generated operation object — and `App::url_for(name, params)` resolves a named route back to its path (← `url_path_for`), mount prefix included.
45+
- **Routers**`Router` collects routes away from any application, and `App::include_router(router, prefix=, tags=, security=, responses=, deprecated=, include_in_schema=)` folds them in as the app's own (← FastAPI's `APIRouter` / `include_router`). The arguments given at the join reach every route in the group: tags and security requirements lead the route's own, group responses are documented under it, and `deprecated` / `include_in_schema` mark or hide the whole set. A router is a value, so the same one can be included twice under different prefixes.
4546
- **Descriptor tree** — a runtime `Schema` / `Param` / `Endpoint` tree (a one-first-class-value substitute for FastAPI's from-signature reflection) that a typed route carries. Walked **once** to (a) emit **complete** OpenAPI request/response body schemas — objects, arrays, scalars, `required`, with named models hoisted under `components/schemas` and referenced by `$ref` — and (b) drive request validation off the same tree. User structs describe themselves with `derive(ToJson)` + a `T::schema()` associated function + a one-line `ToSchema` bridge (the mctl-friendly shape).
4647
- **Multi-version OpenAPI**`App::openapi` / `openapi_json` emit **Swagger 2.0, OpenAPI 3.0.3, and OpenAPI 3.1.0** from the same routes and descriptors (3.x `requestBody` + `components/schemas`; 2.0 body-parameter + `definitions`), because a good FastAPI is not pinned to one spec version.
4748
- **Typed body extractors**`Context::body[T]` deserialises the JSON body into a `derive(FromJson)` struct; `Context::body_validated[T]` first checks it against the endpoint descriptor and returns either the built value or a FastAPI-shaped `422` error list — schema emission, validation, and deserialisation all off one descriptor.
4849
- **Dependency injection** — a `Container` (provider registry + `dependency_overrides`) with request-scoped, one-shot resolution (per-request caching) and `yield`-style teardown run LIFO around the handler — the explicit MoonBit equivalent of FastAPI's `Depends`.
4950
- **OAuth2 + JWT security** — a `/token` password-grant endpoint issues an HS256 JWT (`create_access_token`), and an `OAuth2PasswordBearer` reads the `Authorization: Bearer` header, verifies the token, and enforces scopes: `401` on a missing or invalid/expired token, `403` when a valid token lacks a required scope. The SHA-256 / HMAC-SHA256 pair is self-built (`crypto.mbt`), checked against the NIST and RFC 4231 vectors; the `alg: "none"` downgrade is refused and signatures compare in constant time.
50-
- **Form & file extractors**`Context::form` parses both an `application/x-www-form-urlencoded` body (percent- and `+`-decoded) and a `multipart/form-data` body, splitting the boundary stream into `FormField`s and byte-exact `UploadFile`s (filename + content-type + raw bytes). `Context::oauth2_password_form` reads the OAuth2 password form off it.
51+
- **Form & file extractors**`Context::form` parses both an `application/x-www-form-urlencoded` body (percent- and `+`-decoded) and a `multipart/form-data` body, splitting the boundary stream into `FormField`s and byte-exact `UploadFile`s (filename + content-type + `size` + the part's own `headers` + raw bytes). A body is attacker-controlled and already buffered, so `FormLimits` bounds what one may spend — at most `max_parts` parts of `max_part_size` bytes, Starlette's 1000 × 1 MiB by default — and a body over either bound comes back as `None`, refused whole rather than truncated. `Context::oauth2_password_form` reads the OAuth2 password form off it.
5152
- **response_model**`filter_response` / `json_model` validate a handler's return value against a declared `Schema` and project it down to exactly the model's fields, so a route can hold a richer object internally than it exposes (an id, a password hash) and still emit only what it promised.
5253
- **Middleware & exception handlers** — an outer middleware chain (`App::middleware`) with `cors(...)` (preflight + actual-request headers, configurable origins/methods/headers, credentials, exposed headers) and `gzip(...)` (a real DEFLATE compressor, below), plus exception handlers (`App::exception_handler`): a handler `raise`s an `HttpException` and the app maps it to a response, falling through to a built-in `{"detail": ...}` for `HttpException` and a `500` for anything else. `App::add_status_handler(code, ...)` swaps in a custom response for any error status — a routing `404` / `405` or a raised exception's status — so an app can serve its own error pages.
5354
- **Per-operation security** — a route carries `security=[SecurityRequirement::new(scheme, scopes=[...])]`. `App::secure_oauth2(name, bearer)` both surfaces the scheme in the spec and registers it as a guard, so the app pulls and verifies the bearer token and checks the route's scopes **before** the handler — `401` unauthenticated, `403` on a missing scope — and emits the requirement as the operation's OpenAPI `security` array. A bare `add_security_scheme` documents without enforcing (FastAPI's split between a scheme and a wired dependency).
5455
- **Background tasks** — a background-aware route (`App::route_bg`) receives a `BackgroundTasks` queue; `add_task` defers work that the app runs, in order, **after** the response is sent (← FastAPI's `BackgroundTasks`), so a slow write never delays the client.
55-
- **Sub-application mounting**`App::mount(prefix, subapp)` composes routers: a request under `prefix` is routed by the sub-app with the prefix stripped (its own middleware, security, and background tasks apply), and the sub-app's routes and security schemes fold into the parent's merged OpenAPI document under the prefix. Mounts nest.
56-
- **Server-Sent Events**`ServerSentEvent` frames per the WHATWG event-stream format (`id` / `event` / `retry` / multi-line `data` / `:` comments) and `sse_response` builds the `text/event-stream` envelope.
56+
- **Sub-application mounting**`App::mount(prefix, subapp)` composes routers: a request under `prefix` is routed by the sub-app with the prefix stripped (its own middleware, security, and background tasks apply), and the sub-app's routes and security schemes fold into the parent's merged OpenAPI document under the prefix. Mounts nest. `App::mount_handler(prefix, handler)` mounts a foreign moonasgi `Handler` the same way — a third-party component, or static files — which the app routes to but does not document.
57+
- **Streaming responses**`App::stream(path, handler)` / `App::route_stream(verb, ...)` register a route returning a `moonasgi.StreamingResponse`, whose chunks reach the client as separate body events — a client reads the first long before the last one exists. `App::handle_with_stream` is the chunk-level view a test reads. A middleware is typed buffered-in, buffered-out, so one that rewrites the body (`gzip`) collapses the reply to a single chunk rather than cutting new bytes at boundaries that no longer describe them.
58+
- **Server-Sent Events**`ServerSentEvent` frames per the WHATWG event-stream format (`id` / `event` / `retry` / multi-line `data` / `:` comments); `sse_response` is a `text/event-stream` stream of those frames, **one chunk per event**, so each dispatches on arrival. Hand it to `App::stream`. (An event stream delivered as one body is not an event stream — it is a file shaped like one.)
5759
- **WebSocket routes**`App::websocket(path, handler)` over the moonasgi WS SEAM. The handler drives a `WebSocket` (accept / receive / send / close); it's a synchronous core, so `drive_websocket` runs it against an in-memory frame queue in a test and `App::to_asgi` serves it over the async transport.
5860
- **Security schemes**`App::add_security_scheme` surfaces the OAuth2 / JWT layer in the emitted spec (`components/securitySchemes` in 3.x, `securityDefinitions` in 2.0); `OAuth2PasswordBearer::scheme` builds the password-flow object.
5961
- **Swagger UI**`swagger_ui()` returns a ready-to-serve documentation page.
60-
- **Responses**`text` and `json` helpers over `moonasgi.Response`.
62+
- **Responses**`text`, `html`, `json` and `json_model` helpers over `moonasgi.Response`, plus `redirect(url, status=307)` (← `RedirectResponse`; the URL is encoded over the characters a URI reserves for structure, so an already-encoded URL passes through and a smuggled `CRLF` cannot open a header) and `file_response(content, filename=, media_type=, inline=)` (← `FileResponse`: a media type guessed from the extension, `Content-Length`, and a `Content-Disposition` that falls back to RFC 6266's `filename*` when the name will not survive quoting). It takes bytes rather than a path because moonapi has no filesystem — the same app runs on wasm, js and native.
63+
- **Cookies**`set_cookie(resp, name, value, max_age=, expires=, path=, domain=, secure=, http_only=, same_site=)` and `delete_cookie(...)` (← `response.set_cookie` / `delete_cookie`). Each returns a new response carrying one more `Set-Cookie`, which is the correct wire shape: two cookies are two headers, never one folded field. Names, values and attributes are stripped of the octets RFC 6265 forbids, so a value cannot forge an attribute or open a header of its own; `delete_cookie` expires by both `Max-Age=0` and a 1970 date. `Context::cookie` reads them back.
64+
- **Status constants** — the 63 `HTTP_*` and 15 `WS_*` names FastAPI re-exports from Starlette (`HTTP_404_NOT_FOUND`, `WS_1008_POLICY_VIOLATION`), so a route table shows a deliberate `307` rather than a bare number.
6165

6266
Verified across all backends (`wasm`, `wasm-gc`, `js`, `native`) in CI, 0 warnings under `--deny-warn`.
6367

0 commit comments

Comments
 (0)