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