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