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