1use anyhow::Result;
9use gize_core::naming::table_name;
10use gize_core::{FieldType, Manifest, ModelSpec};
11use serde_json::{Value, json};
12
13pub fn operations(manifest: &Manifest) -> Result<Vec<(String, String)>> {
16 let mut ops = Vec::new();
17 for module in &manifest.modules {
18 let table = &module.name;
19 ops.push(("get".into(), format!("/{table}")));
20 ops.push(("post".into(), format!("/{table}")));
21 ops.push(("get".into(), format!("/{table}/{{id}}")));
22 ops.push(("put".into(), format!("/{table}/{{id}}")));
23 ops.push(("delete".into(), format!("/{table}/{{id}}")));
24 if table == "users" {
25 ops.push(("post".into(), "/users/register".into()));
26 ops.push(("post".into(), "/users/login".into()));
27 }
28 }
29 Ok(ops)
30}
31
32pub fn spec_json(manifest: &Manifest) -> Result<Value> {
34 let mut paths = serde_json::Map::new();
35 let mut schemas = serde_json::Map::new();
36
37 for module in &manifest.modules {
38 let model = module.model_spec()?;
39 let table = table_name(&model.name);
40 let name = &model.name;
41
42 let hidden: &[&str] = if table == "users" { &["password"] } else { &[] };
45 schemas.insert(name.clone(), model_response_schema(&model, hidden));
46 schemas.insert(format!("Create{name}"), dto_schema(&model));
47 schemas.insert(format!("Update{name}"), dto_schema(&model));
48
49 paths.insert(format!("/{table}"), collection_path(&table, name));
50 paths.insert(format!("/{table}/{{id}}"), item_path(&table, name));
51
52 if table == "users" {
53 schemas.insert("LoginRequest".into(), login_request_schema());
54 schemas.insert("TokenResponse".into(), token_response_schema());
55 paths.insert("/users/register".into(), register_path());
56 paths.insert("/users/login".into(), login_path());
57 }
58 }
59
60 Ok(json!({
61 "openapi": "3.0.3",
62 "info": {
63 "title": format!("{} API", manifest.project.name),
64 "version": "0.1.0",
65 "description": "Generated by Gize (see ADR-010)."
66 },
67 "components": {
68 "securitySchemes": {
69 "bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" }
70 },
71 "schemas": Value::Object(schemas)
72 },
73 "paths": Value::Object(paths)
74 }))
75}
76
77fn field_schema(ty: FieldType) -> Value {
79 match ty {
80 FieldType::String => json!({ "type": "string" }),
81 FieldType::Bool => json!({ "type": "boolean" }),
82 FieldType::I32 => json!({ "type": "integer", "format": "int32" }),
83 FieldType::I64 => json!({ "type": "integer", "format": "int64" }),
84 FieldType::F64 => json!({ "type": "number", "format": "double" }),
85 FieldType::Uuid => json!({ "type": "string", "format": "uuid" }),
86 FieldType::DateTime => json!({ "type": "string", "format": "date-time" }),
87 }
88}
89
90fn model_response_schema(model: &ModelSpec, hidden: &[&str]) -> Value {
92 let mut props = serde_json::Map::new();
93 props.insert("id".into(), field_schema(FieldType::Uuid));
94 for f in &model.fields {
95 if hidden.contains(&f.name.as_str()) {
96 continue;
97 }
98 props.insert(f.name.clone(), field_schema(f.ty));
99 }
100 props.insert("created_at".into(), field_schema(FieldType::DateTime));
101 props.insert("updated_at".into(), field_schema(FieldType::DateTime));
102 json!({ "type": "object", "properties": Value::Object(props) })
103}
104
105fn dto_schema(model: &ModelSpec) -> Value {
107 let mut props = serde_json::Map::new();
108 let mut required = Vec::new();
109 for f in &model.fields {
110 props.insert(f.name.clone(), field_schema(f.ty));
111 required.push(Value::String(f.name.clone()));
112 }
113 json!({ "type": "object", "properties": Value::Object(props), "required": required })
114}
115
116fn login_request_schema() -> Value {
117 json!({
118 "type": "object",
119 "properties": { "email": { "type": "string", "format": "email" }, "password": { "type": "string" } },
120 "required": ["email", "password"]
121 })
122}
123
124fn token_response_schema() -> Value {
125 json!({ "type": "object", "properties": { "token": { "type": "string" } }, "required": ["token"] })
126}
127
128fn schema_ref(name: &str) -> Value {
129 json!({ "$ref": format!("#/components/schemas/{name}") })
130}
131
132fn json_content(schema: Value) -> Value {
133 json!({ "content": { "application/json": { "schema": schema } } })
134}
135
136fn collection_path(table: &str, name: &str) -> Value {
138 json!({
139 "get": {
140 "tags": [table],
141 "summary": format!("List {table}"),
142 "responses": {
143 "200": json_content(json!({ "type": "array", "items": schema_ref(name) }))
144 }
145 },
146 "post": {
147 "tags": [table],
148 "summary": format!("Create a {name}"),
149 "security": [{ "bearerAuth": [] }],
150 "requestBody": json_content(schema_ref(&format!("Create{name}"))),
151 "responses": {
152 "201": json_content(schema_ref(name)),
153 "401": { "description": "unauthorized" },
154 "409": { "description": "conflict" },
155 "422": { "description": "validation failed" }
156 }
157 }
158 })
159}
160
161fn item_path(table: &str, name: &str) -> Value {
163 let id_param = json!([{
164 "name": "id", "in": "path", "required": true,
165 "schema": { "type": "string", "format": "uuid" }
166 }]);
167 json!({
168 "get": {
169 "tags": [table],
170 "summary": format!("Get a {name}"),
171 "parameters": id_param,
172 "responses": {
173 "200": json_content(schema_ref(name)),
174 "404": { "description": "not found" }
175 }
176 },
177 "put": {
178 "tags": [table],
179 "summary": format!("Update a {name}"),
180 "security": [{ "bearerAuth": [] }],
181 "parameters": id_param,
182 "requestBody": json_content(schema_ref(&format!("Update{name}"))),
183 "responses": {
184 "200": json_content(schema_ref(name)),
185 "401": { "description": "unauthorized" },
186 "404": { "description": "not found" },
187 "409": { "description": "conflict" },
188 "422": { "description": "validation failed" }
189 }
190 },
191 "delete": {
192 "tags": [table],
193 "summary": format!("Delete a {name}"),
194 "security": [{ "bearerAuth": [] }],
195 "parameters": id_param,
196 "responses": {
197 "204": { "description": "deleted" },
198 "401": { "description": "unauthorized" },
199 "404": { "description": "not found" }
200 }
201 }
202 })
203}
204
205fn register_path() -> Value {
206 json!({
207 "post": {
208 "tags": ["users"],
209 "summary": "Register a new user and receive a token",
210 "requestBody": json_content(schema_ref("CreateUser")),
211 "responses": {
212 "201": json_content(schema_ref("TokenResponse")),
213 "409": { "description": "conflict" },
214 "422": { "description": "validation failed" }
215 }
216 }
217 })
218}
219
220fn login_path() -> Value {
221 json!({
222 "post": {
223 "tags": ["users"],
224 "summary": "Exchange credentials for a token",
225 "requestBody": json_content(schema_ref("LoginRequest")),
226 "responses": {
227 "200": json_content(schema_ref("TokenResponse")),
228 "401": { "description": "invalid credentials" },
229 "422": { "description": "validation failed" }
230 }
231 }
232 })
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use gize_core::{Module, Relation};
239
240 fn blog_manifest() -> Manifest {
241 let mut m = Manifest::new("blog");
242 m.upsert_module(Module {
243 name: "users".to_string(),
244 fields: vec![
245 "name:String".to_string(),
246 "email:String".to_string(),
247 "password:String".to_string(),
248 "is_admin:bool".to_string(),
249 ],
250 belongs_to: vec![],
251 });
252 m.upsert_module(Module {
253 name: "posts".to_string(),
254 fields: vec!["title:String".to_string()],
255 belongs_to: vec![Relation {
256 field: "author".to_string(),
257 target: "users".to_string(),
258 }],
259 });
260 m
261 }
262
263 #[test]
264 fn emits_crud_and_auth_paths() {
265 let spec = spec_json(&blog_manifest()).unwrap();
266 let paths = spec["paths"].as_object().unwrap();
267 for p in [
268 "/users",
269 "/users/{id}",
270 "/users/register",
271 "/users/login",
272 "/posts",
273 "/posts/{id}",
274 ] {
275 assert!(paths.contains_key(p), "missing path {p}");
276 }
277 assert!(paths["/posts"]["post"].get("security").is_some());
279 assert!(paths["/posts"]["get"].get("security").is_none());
280 }
281
282 #[test]
283 fn user_response_hides_password_but_dto_keeps_it() {
284 let spec = spec_json(&blog_manifest()).unwrap();
285 let schemas = &spec["components"]["schemas"];
286 assert!(schemas["User"]["properties"].get("password").is_none());
287 assert!(schemas["User"]["properties"].get("is_admin").is_some());
288 assert!(
289 schemas["CreateUser"]["properties"]
290 .get("password")
291 .is_some()
292 );
293 }
294
295 #[test]
296 fn relationship_fk_is_in_the_schema() {
297 let spec = spec_json(&blog_manifest()).unwrap();
298 let post = &spec["components"]["schemas"]["Post"]["properties"];
299 assert!(
300 post.get("author_id").is_some(),
301 "FK column should appear in the schema"
302 );
303 assert_eq!(post["author_id"]["format"], "uuid");
304 }
305
306 #[test]
307 fn operations_match_paths() {
308 let m = blog_manifest();
309 let ops = operations(&m).unwrap();
310 let spec = spec_json(&m).unwrap();
311 let paths = spec["paths"].as_object().unwrap();
312 for (method, path) in ops {
313 assert!(
314 paths.get(&path).and_then(|p| p.get(&method)).is_some(),
315 "spec missing {method} {path}"
316 );
317 }
318 }
319}