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