Skip to content

Commit 56a9ccf

Browse files
committed
feat(moonapi): typed body extractors + dependency-injection container.
Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
1 parent 967f268 commit 56a9ccf

8 files changed

Lines changed: 497 additions & 2 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,16 @@ let docs_page = @moonapi.swagger_ui() // a Swagger UI page
4444
- **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).
4545
- **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).
4646
- **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.
47+
- **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.
48+
- **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`.
4749
- **Swagger UI**`swagger_ui()` returns a ready-to-serve documentation page.
4850
- **Responses**`text` and `json` helpers over `moonasgi.Response`.
4951

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

5254
## Roadmap (transliterating FastAPI)
5355

54-
The descriptor tree (`Endpoint` / `Param` / `Schema`) is in place — walked once for full OpenAPI 3.1 body schemas and validation. Next: `derive(FromJson)` extractors that deserialise a validated body into a struct; a dependency-injection container (provider registry + `yield` teardown + overrides); the full extractor set (`Header` / `Cookie` / `Form` / `File` with a self-built multipart parser); security (OAuth2 password + scopes, JWT); response-model filtering, exception handlers, CORS / GZip middleware, background tasks, streaming / SSE, WS routes, and codegen'd request schemas via `moonctl`.
56+
The descriptor tree (`Endpoint` / `Param` / `Schema`) is in place — walked once for full OpenAPI 3.1 body schemas and validation — and now the typed `derive(FromJson)` body extractors (`Context::body` / `body_validated`) and the dependency-injection container (provider registry + request-scoped resolution + `yield` teardown + `dependency_overrides`) sit on top of it. Next: the full extractor set (`Header` / `Cookie` / `Form` / `File` with a self-built multipart parser); security (OAuth2 password + scopes, JWT); response-model filtering, exception handlers, CORS / GZip middleware, background tasks, streaming / SSE, WS routes, and codegen'd request schemas via `moonctl`.
5557

5658
## License
5759

body.mbt

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
///|
2+
/// Deserialise the JSON request body into a user type `T`, `None` when the body
3+
/// is absent, is not valid JSON, or does not shape-match `T`. This is the
4+
/// unchecked, best-effort extractor — the total counterpart of writing
5+
/// `item: Item` on a FastAPI handler when you don't want the framework's 422.
6+
/// `T` describes itself with `derive(@json.FromJson)`; the deserialisation is
7+
/// core's, so it stays faithful to the JSON shape without any reflection.
8+
pub fn[T : @json.FromJson] Context::body(self : Context) -> T? {
9+
match self.body_json() {
10+
None => None
11+
Some(j) => Some(@json.from_json(j)) catch { _ => None }
12+
}
13+
}
14+
15+
///|
16+
/// The validated typed-body extractor — the faithful equivalent of FastAPI
17+
/// declaring a pydantic model parameter: the body is checked against the
18+
/// endpoint's descriptor `schema` (the same tree that emits the OpenAPI body
19+
/// schema), and only if it conforms is it deserialised into `T`. On failure it
20+
/// yields the FastAPI-shaped `ValidationError` list (located under `["body",
21+
/// ...]`), ready for `unprocessable`; on success it yields the built `T`.
22+
///
23+
/// Reusing `validate_schema` here is the point of the descriptor tree: schema
24+
/// emission, request validation, and typed deserialisation are all driven off
25+
/// one source of truth, exactly as pydantic derives all three from one model.
26+
/// Because validation runs first, `@json.from_json` is reached only for a
27+
/// shape-conforming value; the final `catch` keeps the extractor total for the
28+
/// residual cases a scalar schema cannot express (e.g. an out-of-range integer).
29+
pub fn[T : @json.FromJson] Context::body_validated(
30+
self : Context,
31+
schema : Schema,
32+
) -> Result[T, Array[ValidationError]] {
33+
let raw = decode_field(self.request.body)
34+
if raw == "" {
35+
return Err([ValidationError::missing(["body"])])
36+
}
37+
let j = @json.parse(raw) catch {
38+
_ =>
39+
return Err([
40+
ValidationError::type_error(
41+
["body"],
42+
"json_invalid",
43+
"Input should be valid JSON",
44+
),
45+
])
46+
}
47+
let errs : Array[ValidationError] = []
48+
validate_schema(schema, j, ["body"], errs)
49+
if errs.length() > 0 {
50+
return Err(errs)
51+
}
52+
let parsed : T? = Some(@json.from_json(j)) catch { _ => None }
53+
match parsed {
54+
Some(v) => Ok(v)
55+
None =>
56+
Err([
57+
ValidationError::type_error(
58+
["body"],
59+
"model_type",
60+
"Input should be a valid object",
61+
),
62+
])
63+
}
64+
}

di.mbt

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// A dependency-injection container — the explicit, MoonBit-idiomatic equivalent
2+
// of FastAPI's `Depends`. FastAPI reads a dependency's callable off the handler
3+
// signature and resolves it per request, caching the result and running any
4+
// `yield` teardown afterwards. MoonBit has no runtime reflection and no `Any`,
5+
// so the container is a first-class value keyed by name, and its dependency
6+
// value type `V` is an explicit parameter: for a single dependency type `V` is
7+
// that type; for several, `V` is a user-defined sum type wrapping them — the
8+
// exhaustive, type-safe stand-in for Python's dynamic `Any` (cf. axum's typemap
9+
// + downcast, Go's `interface{}` + type assertion). Everything else — the
10+
// registry, request-scoped one-shot resolution, `yield`-style teardown, and
11+
// `dependency_overrides` — is modelled faithfully.
12+
13+
///|
14+
/// A provider: a keyed factory that builds a request-scoped dependency value,
15+
/// with an optional teardown run after the handler (FastAPI's `yield`
16+
/// dependencies, whose post-`yield` body is cleanup). The `factory` runs at most
17+
/// once per request scope; the `teardown` receives the produced value.
18+
pub(all) struct Provider[V] {
19+
factory : () -> V
20+
teardown : (V) -> Unit
21+
}
22+
23+
///|
24+
/// Build a provider. `teardown` defaults to a no-op — the common "plain value,
25+
/// nothing to release" case.
26+
pub fn[V] Provider::new(
27+
factory : () -> V,
28+
teardown? : (V) -> Unit = _v => (),
29+
) -> Provider[V] {
30+
{ factory, teardown }
31+
}
32+
33+
///|
34+
/// The provider registry: `key -> Provider`, plus a separate `overrides` map
35+
/// that shadows it. Overrides are FastAPI's `app.dependency_overrides` — a test
36+
/// swaps a real dependency (a live DB session) for a fake without touching the
37+
/// routes. A registered override always wins over the base provider.
38+
pub(all) struct Container[V] {
39+
providers : Map[String, Provider[V]]
40+
overrides : Map[String, Provider[V]]
41+
}
42+
43+
///|
44+
/// An empty container.
45+
pub fn[V] Container::new() -> Container[V] {
46+
{ providers: Map([]), overrides: Map([]) }
47+
}
48+
49+
///|
50+
/// Register a base provider under `key` (last registration wins), returning the
51+
/// container so registrations can chain.
52+
pub fn[V] Container::provide(
53+
self : Container[V],
54+
key : String,
55+
factory : () -> V,
56+
teardown? : (V) -> Unit = _v => (),
57+
) -> Container[V] {
58+
self.providers[key] = Provider::new(factory, teardown~)
59+
self
60+
}
61+
62+
///|
63+
/// Register a dependency override for `key` — FastAPI's
64+
/// `app.dependency_overrides[dep] = fake`. Takes precedence over the base
65+
/// provider until cleared.
66+
pub fn[V] Container::override_(
67+
self : Container[V],
68+
key : String,
69+
factory : () -> V,
70+
teardown? : (V) -> Unit = _v => (),
71+
) -> Container[V] {
72+
self.overrides[key] = Provider::new(factory, teardown~)
73+
self
74+
}
75+
76+
///|
77+
/// Drop the override for `key` (no-op if none), restoring the base provider.
78+
pub fn[V] Container::clear_override(self : Container[V], key : String) -> Unit {
79+
self.overrides.remove(key)
80+
}
81+
82+
///|
83+
/// Drop every override — the usual test teardown that returns the container to
84+
/// its production wiring.
85+
pub fn[V] Container::clear_overrides(self : Container[V]) -> Unit {
86+
self.overrides.clear()
87+
}
88+
89+
///|
90+
/// The effective provider for `key`: an override if one is registered, else the
91+
/// base provider, else `None`.
92+
fn[V] Container::resolve(self : Container[V], key : String) -> Provider[V]? {
93+
match self.overrides.get(key) {
94+
Some(p) => Some(p)
95+
None => self.providers.get(key)
96+
}
97+
}
98+
99+
///|
100+
/// A request-scoped resolution scope. Each dependency is built at most once and
101+
/// its value cached for the life of the scope (FastAPI's per-request dependency
102+
/// cache), and each built value's teardown is recorded to run — in reverse
103+
/// registration order (LIFO) — when the scope closes. Open one per request,
104+
/// resolve dependencies through it, then `close` it (or use `Container::run`).
105+
pub struct Scope[V] {
106+
container : Container[V]
107+
cache : Map[String, V]
108+
teardowns : Array[() -> Unit]
109+
}
110+
111+
///|
112+
/// Open a fresh request scope over this container.
113+
pub fn[V] Container::open_scope(self : Container[V]) -> Scope[V] {
114+
{ container: self, cache: Map([]), teardowns: [] }
115+
}
116+
117+
///|
118+
/// Resolve `key` within this scope: return the already-built instance if the
119+
/// dependency was resolved earlier in the same request; otherwise run its
120+
/// factory once, cache the value, register its teardown, and return it. `None`
121+
/// when no provider (or override) is registered for `key`.
122+
pub fn[V] Scope::get(self : Scope[V], key : String) -> V? {
123+
match self.cache.get(key) {
124+
Some(v) => Some(v)
125+
None =>
126+
match self.container.resolve(key) {
127+
None => None
128+
Some(prov) => {
129+
let v = (prov.factory)()
130+
self.cache[key] = v
131+
let td = prov.teardown
132+
self.teardowns.push(() => td(v))
133+
Some(v)
134+
}
135+
}
136+
}
137+
}
138+
139+
///|
140+
/// Run every recorded teardown in LIFO order and clear them, so a closed scope
141+
/// is inert. Mirrors FastAPI unwinding `yield` dependencies in reverse — the
142+
/// last opened is torn down first.
143+
pub fn[V] Scope::close(self : Scope[V]) -> Unit {
144+
for i = self.teardowns.length() - 1; i >= 0; i = i - 1 {
145+
self.teardowns[i]()
146+
}
147+
self.teardowns.clear()
148+
}
149+
150+
///|
151+
/// Run `handler` inside a fresh request scope, then tear the scope down — the
152+
/// setup/teardown pair wrapped around a handler, exactly as a FastAPI `yield`
153+
/// dependency brackets the request. The handler resolves whatever it needs
154+
/// through the scope; every dependency built during the call is released
155+
/// (LIFO) once it returns, then the response is handed back.
156+
pub fn[V] Container::run(
157+
self : Container[V],
158+
handler : (Scope[V]) -> @moonasgi.Response,
159+
) -> @moonasgi.Response {
160+
let scope = self.open_scope()
161+
let resp = handler(scope)
162+
scope.close()
163+
resp
164+
}

di_demo.mbt

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// A worked example wiring the two new pieces together: a dependency provides a
2+
// value, a typed request body is deserialised + validated off its descriptor,
3+
// and the handler uses both — FastAPI's `def greet(body: GreetReq, greeter: str
4+
// = Depends(...))` in explicit MoonBit form.
5+
6+
///|
7+
/// The request body of `POST /greet`. `derive(@json.FromJson)` lets
8+
/// `Context::body_validated` build it after the descriptor accepts the payload;
9+
/// its schema and struct fields agree (both require `name`), so a schema-valid
10+
/// body always deserialises.
11+
pub(all) struct GreetReq {
12+
name : String
13+
} derive(ToJson, FromJson, Eq)
14+
15+
///|
16+
/// The `GreetReq` descriptor — one required string field.
17+
pub fn GreetReq::schema() -> Schema {
18+
Schema::object("GreetReq", [Field::new("name", SStr)])
19+
}
20+
21+
///|
22+
/// The dependency value type of the greet app. A sum type wrapping every
23+
/// dependency this app injects — the explicit, exhaustive stand-in for FastAPI
24+
/// resolving heterogeneous `Depends` values dynamically.
25+
pub(all) enum Dep {
26+
Greeting(String)
27+
} derive(Eq)
28+
29+
///|
30+
/// Build the greet application over a caller-supplied dependency `container`, so
31+
/// a test can register `dependency_overrides` on the same container before or
32+
/// between requests. `POST /greet` resolves the `"greeting"` dependency, reads a
33+
/// validated `GreetReq` body, and answers `{"message": "<greeting>, <name>"}`;
34+
/// a malformed body gets a FastAPI-shaped `422`. The dependency scope brackets
35+
/// each request, so any `yield` teardown runs once the handler returns.
36+
pub fn greet_app(container : Container[Dep]) -> App {
37+
let app = App::new()
38+
let ep = Endpoint::new(request_body=Some(GreetReq::schema()), responses=[
39+
ResponseSpec::new(200, description="the greeting"),
40+
ResponseSpec::new(422, description="Validation Error"),
41+
])
42+
app.post(
43+
"/greet",
44+
ctx => {
45+
container.run(scope => {
46+
let greeting = match scope.get("greeting") {
47+
Some(Greeting(g)) => g
48+
None => "Hello"
49+
}
50+
match (ctx.body_validated(GreetReq::schema()) : Result[GreetReq, _]) {
51+
Err(errs) => unprocessable(errs)
52+
Ok(req) => {
53+
let m : Map[String, Json] = Map([
54+
("message", "\{greeting}, \{req.name}".to_json()),
55+
])
56+
json(200, m.to_json())
57+
}
58+
}
59+
})
60+
},
61+
summary="greet a user",
62+
endpoint=Some(ep),
63+
)
64+
app
65+
}

0 commit comments

Comments
 (0)