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