1use anyhow::Result;
9use gize_core::naming::table_name;
10use gize_core::{Api, FieldType, Manifest, ModelSpec};
11use serde_json::{Value, json};
12
13pub fn operations(manifest: &Manifest) -> Result<Vec<(String, String)>> {
16 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
36fn api_prefix(manifest: &Manifest) -> String {
39 manifest
40 .api
41 .as_ref()
42 .map(Api::mount_path)
43 .unwrap_or_default()
44}
45
46pub 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 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 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
103fn 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
116fn 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
131fn 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
142fn 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
167fn 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
181fn 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
207fn 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
214fn guard_read(op: &mut Value) {
217 op["security"] = json!([{ "bearerAuth": [] }]);
218 add_response(op, "401", "unauthorized");
219 add_response(op, "403", "forbidden");
220}
221
222fn 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
253fn 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
332fn 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 assert!(paths["/posts"]["post"].get("security").is_some());
395 assert!(paths["/posts"]["get"].get("security").is_none());
396 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 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 assert!(paths["/users/register"]["post"].get("security").is_none());
416 assert!(paths["/users/login"]["post"].get("security").is_none());
417 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}