Skip to main content

gize_openapi/
lib.rs

1//! OpenAPI spec generation for Gize (ADR-010).
2//!
3//! The spec is generated from the **manifest** (`gize.toml`) — the same source of truth the
4//! CRUD, auth and admin generators use — so it matches the generated routes *by construction*
5//! rather than by hand-kept annotations. Output is an OpenAPI 3.0.3 document as
6//! `serde_json::Value`; the CLI writes it to `openapi.json` and the app serves it.
7
8use anyhow::Result;
9use gize_core::naming::table_name;
10use gize_core::{Api, FieldType, Manifest, ModelSpec};
11use serde_json::{Value, json};
12
13/// The set of `(method, path)` operations Gize generates for a module. Used both to build the
14/// spec and by the parity test that checks the spec against the router.
15pub fn operations(manifest: &Manifest) -> Result<Vec<(String, String)>> {
16    // A versioned project (ADR-016) serves every route under `/api/v1`, so the spec's paths
17    // carry the same prefix and stay aligned with the router by construction.
18    let prefix = api_prefix(manifest);
19    let mut ops = Vec::new();
20    for module in &manifest.modules {
21        let table = &module.name;
22        ops.push(("get".into(), format!("{prefix}/{table}")));
23        ops.push(("post".into(), format!("{prefix}/{table}")));
24        ops.push(("get".into(), format!("{prefix}/{table}/{{id}}")));
25        ops.push(("put".into(), format!("{prefix}/{table}/{{id}}")));
26        ops.push(("delete".into(), format!("{prefix}/{table}/{{id}}")));
27        if table == "users" {
28            ops.push(("post".into(), format!("{prefix}/users/register")));
29            ops.push(("post".into(), format!("{prefix}/users/login")));
30            ops.push(("get".into(), format!("{prefix}/users/me")));
31        }
32    }
33    Ok(ops)
34}
35
36/// The API route prefix a versioned project mounts under (e.g. `/api/v1`), or empty when the
37/// project is unversioned (ADR-016).
38fn api_prefix(manifest: &Manifest) -> String {
39    manifest
40        .api
41        .as_ref()
42        .map(Api::mount_path)
43        .unwrap_or_default()
44}
45
46/// Render the full OpenAPI document for a project from its manifest.
47pub fn spec_json(manifest: &Manifest) -> Result<Value> {
48    let prefix = api_prefix(manifest);
49    let mut paths = serde_json::Map::new();
50    let mut schemas = serde_json::Map::new();
51
52    for module in &manifest.modules {
53        let model = module.model_spec()?;
54        let table = table_name(&model.name);
55        let name = &model.name;
56
57        // Component schemas: the response model plus the Create/Update request bodies.
58        // `users` hides `password` in responses (it holds a hash; see the generated model).
59        let hidden: &[&str] = if table == "users" { &["password"] } else { &[] };
60        schemas.insert(name.clone(), model_response_schema(&model, hidden));
61        schemas.insert(format!("{name}Page"), page_schema(name));
62        schemas.insert(format!("Create{name}"), dto_schema(&model));
63        schemas.insert(format!("Update{name}"), dto_schema(&model));
64
65        // The `users` resource is admin-gated end to end, including reads (ADR-021).
66        let guard_reads = table == "users";
67        paths.insert(
68            format!("{prefix}/{table}"),
69            collection_path(&table, name, guard_reads),
70        );
71        paths.insert(
72            format!("{prefix}/{table}/{{id}}"),
73            item_path(&table, name, guard_reads),
74        );
75
76        if table == "users" {
77            schemas.insert("LoginRequest".into(), login_request_schema());
78            schemas.insert("TokenResponse".into(), token_response_schema());
79            paths.insert(format!("{prefix}/users/register"), register_path());
80            paths.insert(format!("{prefix}/users/login"), login_path());
81            paths.insert(format!("{prefix}/users/me"), me_path());
82        }
83    }
84
85    Ok(json!({
86        "openapi": "3.0.3",
87        "info": {
88            "title": format!("{} API", manifest.project.name),
89            "version": "0.1.0",
90            "description": "Generated by Gize (see ADR-010)."
91        },
92        "components": {
93            "securitySchemes": {
94                "bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" }
95            },
96            "schemas": Value::Object(schemas)
97        },
98        "paths": Value::Object(paths)
99    }))
100}
101
102/// The OpenAPI schema fragment for a scalar field type.
103fn field_schema(ty: FieldType) -> Value {
104    match ty {
105        FieldType::String => json!({ "type": "string" }),
106        FieldType::Bool => json!({ "type": "boolean" }),
107        FieldType::I32 => json!({ "type": "integer", "format": "int32" }),
108        FieldType::I64 => json!({ "type": "integer", "format": "int64" }),
109        FieldType::F64 => json!({ "type": "number", "format": "double" }),
110        FieldType::Uuid => json!({ "type": "string", "format": "uuid" }),
111        FieldType::DateTime => json!({ "type": "string", "format": "date-time" }),
112    }
113}
114
115/// The response schema for a model: `id`, its (visible) fields, and timestamps.
116fn model_response_schema(model: &ModelSpec, hidden: &[&str]) -> Value {
117    let mut props = serde_json::Map::new();
118    props.insert("id".into(), field_schema(FieldType::Uuid));
119    for f in &model.fields {
120        if hidden.contains(&f.name.as_str()) {
121            continue;
122        }
123        props.insert(f.name.clone(), field_schema(f.ty));
124    }
125    props.insert("created_at".into(), field_schema(FieldType::DateTime));
126    props.insert("updated_at".into(), field_schema(FieldType::DateTime));
127    json!({ "type": "object", "properties": Value::Object(props) })
128}
129
130/// The request-body schema for a Create/Update DTO: every declared field, all required.
131fn dto_schema(model: &ModelSpec) -> Value {
132    let mut props = serde_json::Map::new();
133    let mut required = Vec::new();
134    for f in &model.fields {
135        props.insert(f.name.clone(), field_schema(f.ty));
136        required.push(Value::String(f.name.clone()));
137    }
138    json!({ "type": "object", "properties": Value::Object(props), "required": required })
139}
140
141fn login_request_schema() -> Value {
142    json!({
143        "type": "object",
144        "properties": { "email": { "type": "string", "format": "email" }, "password": { "type": "string" } },
145        "required": ["email", "password"]
146    })
147}
148
149fn token_response_schema() -> Value {
150    json!({ "type": "object", "properties": { "token": { "type": "string" } }, "required": ["token"] })
151}
152
153/// The page envelope a list route returns (ADR-022).
154fn page_schema(name: &str) -> Value {
155    json!({
156        "type": "object",
157        "required": ["items", "page", "per_page", "total"],
158        "properties": {
159            "items": { "type": "array", "items": schema_ref(name) },
160            "page": { "type": "integer", "format": "int32", "minimum": 1 },
161            "per_page": { "type": "integer", "format": "int32", "minimum": 1, "maximum": 100 },
162            "total": { "type": "integer", "format": "int64", "minimum": 0 }
163        }
164    })
165}
166
167/// The `page` / `per_page` query parameters of a list route (ADR-022).
168fn page_params() -> Value {
169    json!([
170        {
171            "name": "page", "in": "query", "required": false,
172            "description": "1-based page number.",
173            "schema": { "type": "integer", "format": "int32", "minimum": 1, "default": 1 }
174        },
175        {
176            "name": "per_page", "in": "query", "required": false,
177            "description": "Items per page.",
178            "schema": {
179                "type": "integer", "format": "int32", "minimum": 1, "maximum": 100, "default": 25
180            }
181        }
182    ])
183}
184
185fn schema_ref(name: &str) -> Value {
186    json!({ "$ref": format!("#/components/schemas/{name}") })
187}
188
189fn json_content(schema: Value) -> Value {
190    json!({ "content": { "application/json": { "schema": schema } } })
191}
192
193/// Add a response to an operation's `responses` map (no-op if the shape is unexpected).
194fn add_response(op: &mut Value, code: &str, description: &str) {
195    if let Some(responses) = op.get_mut("responses").and_then(|r| r.as_object_mut()) {
196        responses.insert(code.to_string(), json!({ "description": description }));
197    }
198}
199
200/// Mark an otherwise-public read operation as admin-gated: require a bearer token and document
201/// the 401/403 responses. Used for the `users` resource (ADR-021).
202fn guard_read(op: &mut Value) {
203    op["security"] = json!([{ "bearerAuth": [] }]);
204    add_response(op, "401", "unauthorized");
205    add_response(op, "403", "forbidden");
206}
207
208/// `/<table>` — list and create. Reads are public unless `guard_reads` (the `users` resource,
209/// ADR-021), where the list is admin-gated and every write also documents 403.
210fn collection_path(table: &str, name: &str, guard_reads: bool) -> Value {
211    let mut get_op = json!({
212        "tags": [table],
213        "summary": format!("List {table}"),
214        "parameters": page_params(),
215        "responses": {
216            "200": json_content(schema_ref(&format!("{name}Page"))),
217            "422": { "description": "invalid page or per_page" }
218        }
219    });
220    let mut post_op = json!({
221        "tags": [table],
222        "summary": format!("Create a {name}"),
223        "security": [{ "bearerAuth": [] }],
224        "requestBody": json_content(schema_ref(&format!("Create{name}"))),
225        "responses": {
226            "201": json_content(schema_ref(name)),
227            "401": { "description": "unauthorized" },
228            "409": { "description": "conflict" },
229            "422": { "description": "validation failed" }
230        }
231    });
232    if guard_reads {
233        guard_read(&mut get_op);
234        add_response(&mut post_op, "403", "forbidden");
235    }
236    json!({ "get": get_op, "post": post_op })
237}
238
239/// `/<table>/{id}` — show, update and delete. Reads are public unless `guard_reads` (the
240/// `users` resource, ADR-021), where show is admin-gated and every write also documents 403.
241fn item_path(table: &str, name: &str, guard_reads: bool) -> Value {
242    let id_param = json!([{
243        "name": "id", "in": "path", "required": true,
244        "schema": { "type": "string", "format": "uuid" }
245    }]);
246    let mut get_op = json!({
247        "tags": [table],
248        "summary": format!("Get a {name}"),
249        "parameters": id_param,
250        "responses": {
251            "200": json_content(schema_ref(name)),
252            "404": { "description": "not found" }
253        }
254    });
255    let mut put_op = json!({
256        "tags": [table],
257        "summary": format!("Update a {name}"),
258        "security": [{ "bearerAuth": [] }],
259        "parameters": id_param,
260        "requestBody": json_content(schema_ref(&format!("Update{name}"))),
261        "responses": {
262            "200": json_content(schema_ref(name)),
263            "401": { "description": "unauthorized" },
264            "404": { "description": "not found" },
265            "409": { "description": "conflict" },
266            "422": { "description": "validation failed" }
267        }
268    });
269    let mut delete_op = json!({
270        "tags": [table],
271        "summary": format!("Delete a {name}"),
272        "security": [{ "bearerAuth": [] }],
273        "parameters": id_param,
274        "responses": {
275            "204": { "description": "deleted" },
276            "401": { "description": "unauthorized" },
277            "404": { "description": "not found" }
278        }
279    });
280    if guard_reads {
281        guard_read(&mut get_op);
282        add_response(&mut put_op, "403", "forbidden");
283        add_response(&mut delete_op, "403", "forbidden");
284    }
285    json!({ "get": get_op, "put": put_op, "delete": delete_op })
286}
287
288fn register_path() -> Value {
289    json!({
290        "post": {
291            "tags": ["users"],
292            "summary": "Register a new user and receive a token",
293            "requestBody": json_content(schema_ref("CreateUser")),
294            "responses": {
295                "201": json_content(schema_ref("TokenResponse")),
296                "409": { "description": "conflict" },
297                "422": { "description": "validation failed" }
298            }
299        }
300    })
301}
302
303fn login_path() -> Value {
304    json!({
305        "post": {
306            "tags": ["users"],
307            "summary": "Exchange credentials for a token",
308            "requestBody": json_content(schema_ref("LoginRequest")),
309            "responses": {
310                "200": json_content(schema_ref("TokenResponse")),
311                "401": { "description": "invalid credentials" },
312                "422": { "description": "validation failed" }
313            }
314        }
315    })
316}
317
318/// `/users/me` — self-service read of the caller's own record. Needs any valid bearer token
319/// (not admin); the user is identified by the token's `sub` claim.
320fn me_path() -> Value {
321    json!({
322        "get": {
323            "tags": ["users"],
324            "summary": "Get the authenticated user's own record",
325            "security": [{ "bearerAuth": [] }],
326            "responses": {
327                "200": json_content(schema_ref("User")),
328                "401": { "description": "unauthorized" },
329                "404": { "description": "not found" }
330            }
331        }
332    })
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use gize_core::{Module, Relation};
339
340    fn blog_manifest() -> Manifest {
341        let mut m = Manifest::new("blog");
342        m.upsert_module(Module {
343            name: "users".to_string(),
344            model: None,
345            fields: vec![
346                "name:String".to_string(),
347                "email:String".to_string(),
348                "password:String".to_string(),
349                "is_admin:bool".to_string(),
350            ],
351            belongs_to: vec![],
352        });
353        m.upsert_module(Module {
354            name: "posts".to_string(),
355            model: None,
356            fields: vec!["title:String".to_string()],
357            belongs_to: vec![Relation {
358                field: "author".to_string(),
359                target: "users".to_string(),
360            }],
361        });
362        m
363    }
364
365    #[test]
366    fn emits_crud_and_auth_paths() {
367        let spec = spec_json(&blog_manifest()).unwrap();
368        let paths = spec["paths"].as_object().unwrap();
369        for p in [
370            "/users",
371            "/users/{id}",
372            "/users/register",
373            "/users/login",
374            "/posts",
375            "/posts/{id}",
376        ] {
377            assert!(paths.contains_key(p), "missing path {p}");
378        }
379        // Generic resource: write ops are guarded, reads are public.
380        assert!(paths["/posts"]["post"].get("security").is_some());
381        assert!(paths["/posts"]["get"].get("security").is_none());
382        // `users` is admin-gated end to end, including reads (ADR-021), and reads document 403.
383        assert!(paths["/users"]["get"].get("security").is_some());
384        assert!(paths["/users/{id}"]["get"].get("security").is_some());
385        assert!(paths["/users"]["get"]["responses"].get("403").is_some());
386        assert!(paths["/users"]["post"]["responses"].get("403").is_some());
387        assert!(
388            paths["/users/{id}"]["delete"]["responses"]
389                .get("403")
390                .is_some()
391        );
392        // register/login stay public.
393        assert!(paths["/users/register"]["post"].get("security").is_none());
394        assert!(paths["/users/login"]["post"].get("security").is_none());
395        // `/users/me` is self-service: it requires a bearer token but is not admin-gated.
396        assert!(paths.contains_key("/users/me"), "missing path /users/me");
397        assert!(paths["/users/me"]["get"].get("security").is_some());
398        assert_eq!(
399            paths["/users/me"]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
400                ["$ref"],
401            "#/components/schemas/User"
402        );
403    }
404
405    #[test]
406    fn list_routes_document_the_page_envelope() {
407        let spec = spec_json(&blog_manifest()).unwrap();
408        let list = &spec["paths"]["/posts"]["get"];
409        let names: Vec<_> = list["parameters"]
410            .as_array()
411            .unwrap()
412            .iter()
413            .map(|p| p["name"].as_str().unwrap())
414            .collect();
415        assert_eq!(names, ["page", "per_page"]);
416        assert_eq!(list["parameters"][1]["schema"]["maximum"], 100);
417        assert_eq!(
418            list["responses"]["200"]["content"]["application/json"]["schema"]["$ref"],
419            "#/components/schemas/PostPage"
420        );
421        assert!(list["responses"].get("422").is_some());
422        let page = &spec["components"]["schemas"]["PostPage"];
423        assert_eq!(
424            page["properties"]["items"]["items"]["$ref"],
425            "#/components/schemas/Post"
426        );
427        assert!(spec["components"]["schemas"].get("UserPage").is_some());
428    }
429
430    #[test]
431    fn user_response_hides_password_but_dto_keeps_it() {
432        let spec = spec_json(&blog_manifest()).unwrap();
433        let schemas = &spec["components"]["schemas"];
434        assert!(schemas["User"]["properties"].get("password").is_none());
435        assert!(schemas["User"]["properties"].get("is_admin").is_some());
436        assert!(
437            schemas["CreateUser"]["properties"]
438                .get("password")
439                .is_some()
440        );
441    }
442
443    #[test]
444    fn relationship_fk_is_in_the_schema() {
445        let spec = spec_json(&blog_manifest()).unwrap();
446        let post = &spec["components"]["schemas"]["Post"]["properties"];
447        assert!(
448            post.get("author_id").is_some(),
449            "FK column should appear in the schema"
450        );
451        assert_eq!(post["author_id"]["format"], "uuid");
452    }
453
454    #[test]
455    fn operations_match_paths() {
456        let m = blog_manifest();
457        let ops = operations(&m).unwrap();
458        let spec = spec_json(&m).unwrap();
459        let paths = spec["paths"].as_object().unwrap();
460        for (method, path) in ops {
461            assert!(
462                paths.get(&path).and_then(|p| p.get(&method)).is_some(),
463                "spec missing {method} {path}"
464            );
465        }
466    }
467}