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!("Create{name}"), dto_schema(&model));
62 schemas.insert(format!("Update{name}"), dto_schema(&model));
63
64 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
101fn 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
114fn 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
129fn 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
160fn 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
167fn guard_read(op: &mut Value) {
170 op["security"] = json!([{ "bearerAuth": [] }]);
171 add_response(op, "401", "unauthorized");
172 add_response(op, "403", "forbidden");
173}
174
175fn 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
204fn 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
283fn 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 assert!(paths["/posts"]["post"].get("security").is_some());
344 assert!(paths["/posts"]["get"].get("security").is_none());
345 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 assert!(paths["/users/register"]["post"].get("security").is_none());
357 assert!(paths["/users/login"]["post"].get("security").is_none());
358 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}