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