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