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        }
31    }
32    Ok(ops)
33}
34
35/// The API route prefix a versioned project mounts under (e.g. `/api/v1`), or empty when the
36/// project is unversioned (ADR-016).
37fn api_prefix(manifest: &Manifest) -> String {
38    manifest
39        .api
40        .as_ref()
41        .map(Api::mount_path)
42        .unwrap_or_default()
43}
44
45/// Render the full OpenAPI document for a project from its manifest.
46pub fn spec_json(manifest: &Manifest) -> Result<Value> {
47    let prefix = api_prefix(manifest);
48    let mut paths = serde_json::Map::new();
49    let mut schemas = serde_json::Map::new();
50
51    for module in &manifest.modules {
52        let model = module.model_spec()?;
53        let table = table_name(&model.name);
54        let name = &model.name;
55
56        // Component schemas: the response model plus the Create/Update request bodies.
57        // `users` hides `password` in responses (it holds a hash; see the generated model).
58        let hidden: &[&str] = if table == "users" { &["password"] } else { &[] };
59        schemas.insert(name.clone(), model_response_schema(&model, hidden));
60        schemas.insert(format!("Create{name}"), dto_schema(&model));
61        schemas.insert(format!("Update{name}"), dto_schema(&model));
62
63        // The `users` resource is admin-gated end to end, including reads (ADR-021).
64        let guard_reads = table == "users";
65        paths.insert(
66            format!("{prefix}/{table}"),
67            collection_path(&table, name, guard_reads),
68        );
69        paths.insert(
70            format!("{prefix}/{table}/{{id}}"),
71            item_path(&table, name, guard_reads),
72        );
73
74        if table == "users" {
75            schemas.insert("LoginRequest".into(), login_request_schema());
76            schemas.insert("TokenResponse".into(), token_response_schema());
77            paths.insert(format!("{prefix}/users/register"), register_path());
78            paths.insert(format!("{prefix}/users/login"), login_path());
79        }
80    }
81
82    Ok(json!({
83        "openapi": "3.0.3",
84        "info": {
85            "title": format!("{} API", manifest.project.name),
86            "version": "0.1.0",
87            "description": "Generated by Gize (see ADR-010)."
88        },
89        "components": {
90            "securitySchemes": {
91                "bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" }
92            },
93            "schemas": Value::Object(schemas)
94        },
95        "paths": Value::Object(paths)
96    }))
97}
98
99/// The OpenAPI schema fragment for a scalar field type.
100fn field_schema(ty: FieldType) -> Value {
101    match ty {
102        FieldType::String => json!({ "type": "string" }),
103        FieldType::Bool => json!({ "type": "boolean" }),
104        FieldType::I32 => json!({ "type": "integer", "format": "int32" }),
105        FieldType::I64 => json!({ "type": "integer", "format": "int64" }),
106        FieldType::F64 => json!({ "type": "number", "format": "double" }),
107        FieldType::Uuid => json!({ "type": "string", "format": "uuid" }),
108        FieldType::DateTime => json!({ "type": "string", "format": "date-time" }),
109    }
110}
111
112/// The response schema for a model: `id`, its (visible) fields, and timestamps.
113fn model_response_schema(model: &ModelSpec, hidden: &[&str]) -> Value {
114    let mut props = serde_json::Map::new();
115    props.insert("id".into(), field_schema(FieldType::Uuid));
116    for f in &model.fields {
117        if hidden.contains(&f.name.as_str()) {
118            continue;
119        }
120        props.insert(f.name.clone(), field_schema(f.ty));
121    }
122    props.insert("created_at".into(), field_schema(FieldType::DateTime));
123    props.insert("updated_at".into(), field_schema(FieldType::DateTime));
124    json!({ "type": "object", "properties": Value::Object(props) })
125}
126
127/// The request-body schema for a Create/Update DTO: every declared field, all required.
128fn dto_schema(model: &ModelSpec) -> Value {
129    let mut props = serde_json::Map::new();
130    let mut required = Vec::new();
131    for f in &model.fields {
132        props.insert(f.name.clone(), field_schema(f.ty));
133        required.push(Value::String(f.name.clone()));
134    }
135    json!({ "type": "object", "properties": Value::Object(props), "required": required })
136}
137
138fn login_request_schema() -> Value {
139    json!({
140        "type": "object",
141        "properties": { "email": { "type": "string", "format": "email" }, "password": { "type": "string" } },
142        "required": ["email", "password"]
143    })
144}
145
146fn token_response_schema() -> Value {
147    json!({ "type": "object", "properties": { "token": { "type": "string" } }, "required": ["token"] })
148}
149
150fn schema_ref(name: &str) -> Value {
151    json!({ "$ref": format!("#/components/schemas/{name}") })
152}
153
154fn json_content(schema: Value) -> Value {
155    json!({ "content": { "application/json": { "schema": schema } } })
156}
157
158/// Add a response to an operation's `responses` map (no-op if the shape is unexpected).
159fn add_response(op: &mut Value, code: &str, description: &str) {
160    if let Some(responses) = op.get_mut("responses").and_then(|r| r.as_object_mut()) {
161        responses.insert(code.to_string(), json!({ "description": description }));
162    }
163}
164
165/// Mark an otherwise-public read operation as admin-gated: require a bearer token and document
166/// the 401/403 responses. Used for the `users` resource (ADR-021).
167fn guard_read(op: &mut Value) {
168    op["security"] = json!([{ "bearerAuth": [] }]);
169    add_response(op, "401", "unauthorized");
170    add_response(op, "403", "forbidden");
171}
172
173/// `/<table>` — list and create. Reads are public unless `guard_reads` (the `users` resource,
174/// ADR-021), where the list is admin-gated and every write also documents 403.
175fn collection_path(table: &str, name: &str, guard_reads: bool) -> Value {
176    let mut get_op = json!({
177        "tags": [table],
178        "summary": format!("List {table}"),
179        "responses": {
180            "200": json_content(json!({ "type": "array", "items": schema_ref(name) }))
181        }
182    });
183    let mut post_op = json!({
184        "tags": [table],
185        "summary": format!("Create a {name}"),
186        "security": [{ "bearerAuth": [] }],
187        "requestBody": json_content(schema_ref(&format!("Create{name}"))),
188        "responses": {
189            "201": json_content(schema_ref(name)),
190            "401": { "description": "unauthorized" },
191            "409": { "description": "conflict" },
192            "422": { "description": "validation failed" }
193        }
194    });
195    if guard_reads {
196        guard_read(&mut get_op);
197        add_response(&mut post_op, "403", "forbidden");
198    }
199    json!({ "get": get_op, "post": post_op })
200}
201
202/// `/<table>/{id}` — show, update and delete. Reads are public unless `guard_reads` (the
203/// `users` resource, ADR-021), where show is admin-gated and every write also documents 403.
204fn item_path(table: &str, name: &str, guard_reads: bool) -> Value {
205    let id_param = json!([{
206        "name": "id", "in": "path", "required": true,
207        "schema": { "type": "string", "format": "uuid" }
208    }]);
209    let mut get_op = json!({
210        "tags": [table],
211        "summary": format!("Get a {name}"),
212        "parameters": id_param,
213        "responses": {
214            "200": json_content(schema_ref(name)),
215            "404": { "description": "not found" }
216        }
217    });
218    let mut put_op = json!({
219        "tags": [table],
220        "summary": format!("Update a {name}"),
221        "security": [{ "bearerAuth": [] }],
222        "parameters": id_param,
223        "requestBody": json_content(schema_ref(&format!("Update{name}"))),
224        "responses": {
225            "200": json_content(schema_ref(name)),
226            "401": { "description": "unauthorized" },
227            "404": { "description": "not found" },
228            "409": { "description": "conflict" },
229            "422": { "description": "validation failed" }
230        }
231    });
232    let mut delete_op = json!({
233        "tags": [table],
234        "summary": format!("Delete a {name}"),
235        "security": [{ "bearerAuth": [] }],
236        "parameters": id_param,
237        "responses": {
238            "204": { "description": "deleted" },
239            "401": { "description": "unauthorized" },
240            "404": { "description": "not found" }
241        }
242    });
243    if guard_reads {
244        guard_read(&mut get_op);
245        add_response(&mut put_op, "403", "forbidden");
246        add_response(&mut delete_op, "403", "forbidden");
247    }
248    json!({ "get": get_op, "put": put_op, "delete": delete_op })
249}
250
251fn register_path() -> Value {
252    json!({
253        "post": {
254            "tags": ["users"],
255            "summary": "Register a new user and receive a token",
256            "requestBody": json_content(schema_ref("CreateUser")),
257            "responses": {
258                "201": json_content(schema_ref("TokenResponse")),
259                "409": { "description": "conflict" },
260                "422": { "description": "validation failed" }
261            }
262        }
263    })
264}
265
266fn login_path() -> Value {
267    json!({
268        "post": {
269            "tags": ["users"],
270            "summary": "Exchange credentials for a token",
271            "requestBody": json_content(schema_ref("LoginRequest")),
272            "responses": {
273                "200": json_content(schema_ref("TokenResponse")),
274                "401": { "description": "invalid credentials" },
275                "422": { "description": "validation failed" }
276            }
277        }
278    })
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use gize_core::{Module, Relation};
285
286    fn blog_manifest() -> Manifest {
287        let mut m = Manifest::new("blog");
288        m.upsert_module(Module {
289            name: "users".to_string(),
290            fields: vec![
291                "name:String".to_string(),
292                "email:String".to_string(),
293                "password:String".to_string(),
294                "is_admin:bool".to_string(),
295            ],
296            belongs_to: vec![],
297        });
298        m.upsert_module(Module {
299            name: "posts".to_string(),
300            fields: vec!["title:String".to_string()],
301            belongs_to: vec![Relation {
302                field: "author".to_string(),
303                target: "users".to_string(),
304            }],
305        });
306        m
307    }
308
309    #[test]
310    fn emits_crud_and_auth_paths() {
311        let spec = spec_json(&blog_manifest()).unwrap();
312        let paths = spec["paths"].as_object().unwrap();
313        for p in [
314            "/users",
315            "/users/{id}",
316            "/users/register",
317            "/users/login",
318            "/posts",
319            "/posts/{id}",
320        ] {
321            assert!(paths.contains_key(p), "missing path {p}");
322        }
323        // Generic resource: write ops are guarded, reads are public.
324        assert!(paths["/posts"]["post"].get("security").is_some());
325        assert!(paths["/posts"]["get"].get("security").is_none());
326        // `users` is admin-gated end to end, including reads (ADR-021), and reads document 403.
327        assert!(paths["/users"]["get"].get("security").is_some());
328        assert!(paths["/users/{id}"]["get"].get("security").is_some());
329        assert!(paths["/users"]["get"]["responses"].get("403").is_some());
330        assert!(paths["/users"]["post"]["responses"].get("403").is_some());
331        assert!(
332            paths["/users/{id}"]["delete"]["responses"]
333                .get("403")
334                .is_some()
335        );
336        // register/login stay public.
337        assert!(paths["/users/register"]["post"].get("security").is_none());
338        assert!(paths["/users/login"]["post"].get("security").is_none());
339    }
340
341    #[test]
342    fn user_response_hides_password_but_dto_keeps_it() {
343        let spec = spec_json(&blog_manifest()).unwrap();
344        let schemas = &spec["components"]["schemas"];
345        assert!(schemas["User"]["properties"].get("password").is_none());
346        assert!(schemas["User"]["properties"].get("is_admin").is_some());
347        assert!(
348            schemas["CreateUser"]["properties"]
349                .get("password")
350                .is_some()
351        );
352    }
353
354    #[test]
355    fn relationship_fk_is_in_the_schema() {
356        let spec = spec_json(&blog_manifest()).unwrap();
357        let post = &spec["components"]["schemas"]["Post"]["properties"];
358        assert!(
359            post.get("author_id").is_some(),
360            "FK column should appear in the schema"
361        );
362        assert_eq!(post["author_id"]["format"], "uuid");
363    }
364
365    #[test]
366    fn operations_match_paths() {
367        let m = blog_manifest();
368        let ops = operations(&m).unwrap();
369        let spec = spec_json(&m).unwrap();
370        let paths = spec["paths"].as_object().unwrap();
371        for (method, path) in ops {
372            assert!(
373                paths.get(&path).and_then(|p| p.get(&method)).is_some(),
374                "spec missing {method} {path}"
375            );
376        }
377    }
378}