1use crate::{ColumnType, ServiceManifest, TableDefinition};
7use serde_json::{json, Map, Value};
8
9pub fn generate_openapi(manifest: &ServiceManifest, tenant_slug: &str) -> Value {
14 let mut paths = Map::new();
15 let mut schemas = Map::new();
16
17 for table in &manifest.tables {
18 let base_path = format!("/svc/{}/{}/{}", tenant_slug, manifest.name, table.name);
19 let item_path = format!("{}/:id", base_path);
20 let schema_name = to_pascal_case(&table.name);
21 let create_schema_name = format!("{}Create", schema_name);
22
23 schemas.insert(schema_name.clone(), table_to_schema(table));
25 schemas.insert(create_schema_name.clone(), table_to_create_schema(table));
26
27 paths.insert(
29 base_path.clone(),
30 json!({
31 "get": {
32 "summary": format!("List {}", table.name),
33 "operationId": format!("list_{}", table.name),
34 "tags": [table.name],
35 "parameters": list_parameters(table),
36 "responses": {
37 "200": {
38 "description": "Paginated list",
39 "content": {
40 "application/json": {
41 "schema": {
42 "type": "object",
43 "properties": {
44 "results": {
45 "type": "array",
46 "items": { "$ref": format!("#/components/schemas/{}", schema_name) }
47 },
48 "pagination": { "$ref": "#/components/schemas/Pagination" }
49 }
50 }
51 }
52 }
53 }
54 }
55 },
56 "post": {
57 "summary": format!("Create {}", singular(&table.name)),
58 "operationId": format!("create_{}", singular(&table.name)),
59 "tags": [table.name],
60 "requestBody": {
61 "required": true,
62 "content": {
63 "application/json": {
64 "schema": { "$ref": format!("#/components/schemas/{}", create_schema_name) }
65 }
66 }
67 },
68 "responses": {
69 "200": {
70 "description": "Created",
71 "content": {
72 "application/json": {
73 "schema": { "$ref": format!("#/components/schemas/{}", schema_name) }
74 }
75 }
76 }
77 }
78 }
79 }),
80 );
81
82 paths.insert(
84 item_path,
85 json!({
86 "get": {
87 "summary": format!("Get {} by ID", singular(&table.name)),
88 "operationId": format!("get_{}", singular(&table.name)),
89 "tags": [table.name],
90 "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}],
91 "responses": {
92 "200": {
93 "description": "Found",
94 "content": {
95 "application/json": {
96 "schema": { "$ref": format!("#/components/schemas/{}", schema_name) }
97 }
98 }
99 },
100 "404": { "description": "Not found" }
101 }
102 },
103 "put": {
104 "summary": format!("Update {}", singular(&table.name)),
105 "operationId": format!("update_{}", singular(&table.name)),
106 "tags": [table.name],
107 "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}],
108 "requestBody": {
109 "required": true,
110 "content": {
111 "application/json": {
112 "schema": { "$ref": format!("#/components/schemas/{}", create_schema_name) }
113 }
114 }
115 },
116 "responses": {
117 "200": {
118 "description": "Updated",
119 "content": {
120 "application/json": {
121 "schema": { "$ref": format!("#/components/schemas/{}", schema_name) }
122 }
123 }
124 }
125 }
126 },
127 "delete": {
128 "summary": format!("Delete {}", singular(&table.name)),
129 "operationId": format!("delete_{}", singular(&table.name)),
130 "tags": [table.name],
131 "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}],
132 "responses": {
133 "200": { "description": "Deleted" }
134 }
135 }
136 }),
137 );
138 }
139
140 for route in &manifest.custom_routes {
142 let full_path = format!(
143 "/svc/{}/{}/_fn/{}",
144 tenant_slug, manifest.name, route.handler
145 );
146 let method = route.method.to_lowercase();
147 let operation_id = route.handler.clone();
148
149 let mut parameters = Vec::new();
151 for segment in route.path.split('/') {
152 if let Some(param) = segment.strip_prefix(':') {
153 parameters.push(json!({
154 "name": param,
155 "in": "path",
156 "required": true,
157 "schema": { "type": "string" }
158 }));
159 }
160 }
161
162 let mut operation = json!({
163 "summary": format!("Custom: {}", route.handler),
164 "operationId": operation_id,
165 "tags": ["custom"],
166 "parameters": parameters,
167 "responses": {
168 "200": {
169 "description": "Success",
170 "content": {
171 "application/json": {
172 "schema": { "type": "object" }
173 }
174 }
175 }
176 }
177 });
178
179 if matches!(method.as_str(), "post" | "put" | "patch") {
181 operation["requestBody"] = json!({
182 "required": false,
183 "content": {
184 "application/json": {
185 "schema": { "type": "object" }
186 }
187 }
188 });
189 }
190
191 let path_entry = paths.entry(full_path).or_insert_with(|| json!({}));
192 if let Some(obj) = path_entry.as_object_mut() {
193 obj.insert(method, operation);
194 }
195 }
196
197 schemas.insert(
199 "Pagination".into(),
200 json!({
201 "type": "object",
202 "properties": {
203 "current_page": { "type": "integer" },
204 "per_page": { "type": "integer" },
205 "total_records": { "type": "integer" },
206 "total_pages": { "type": "integer" }
207 }
208 }),
209 );
210
211 json!({
212 "openapi": "3.1.0",
213 "info": {
214 "title": manifest.name,
215 "version": manifest.version.as_deref().unwrap_or("0.1.0")
216 },
217 "paths": Value::Object(paths),
218 "components": {
219 "schemas": Value::Object(schemas)
220 }
221 })
222}
223
224fn column_to_openapi_type(col_type: &ColumnType) -> Value {
225 match col_type {
226 ColumnType::Uuid => json!({"type": "string", "format": "uuid"}),
227 ColumnType::Text => json!({"type": "string"}),
228 ColumnType::Integer => json!({"type": "integer", "format": "int32"}),
229 ColumnType::BigInteger => json!({"type": "integer", "format": "int64"}),
230 ColumnType::Float => json!({"type": "number", "format": "float"}),
231 ColumnType::Double => json!({"type": "number", "format": "double"}),
232 ColumnType::Boolean => json!({"type": "boolean"}),
233 ColumnType::Timestamp => json!({"type": "string", "format": "date-time"}),
234 ColumnType::Date => json!({"type": "string", "format": "date"}),
235 ColumnType::Jsonb => json!({"type": "object"}),
236 }
237}
238
239fn table_to_schema(table: &TableDefinition) -> Value {
240 let mut properties = Map::new();
241 let mut required = Vec::new();
242
243 for col in &table.columns {
244 let mut prop = column_to_openapi_type(&col.column_type);
245 if col.nullable {
246 if let Some(obj) = prop.as_object_mut() {
247 obj.insert("nullable".into(), json!(true));
248 }
249 }
250 properties.insert(col.name.clone(), prop);
251 if !col.nullable {
252 required.push(json!(col.name));
253 }
254 }
255
256 json!({
257 "type": "object",
258 "properties": Value::Object(properties),
259 "required": required
260 })
261}
262
263fn table_to_create_schema(table: &TableDefinition) -> Value {
264 let mut properties = Map::new();
265 let mut required = Vec::new();
266
267 for col in &table.columns {
268 if col.auto_generate {
269 continue;
270 }
271 let mut prop = column_to_openapi_type(&col.column_type);
272 if col.nullable {
273 if let Some(obj) = prop.as_object_mut() {
274 obj.insert("nullable".into(), json!(true));
275 }
276 }
277 properties.insert(col.name.clone(), prop);
278 if !col.nullable && col.default_value.is_none() {
279 required.push(json!(col.name));
280 }
281 }
282
283 json!({
284 "type": "object",
285 "properties": Value::Object(properties),
286 "required": required
287 })
288}
289
290fn list_parameters(table: &TableDefinition) -> Vec<Value> {
291 let mut params = vec![
292 json!({"name": "page", "in": "query", "schema": {"type": "integer"}, "description": "Page number (default: 1)"}),
293 json!({"name": "per_page", "in": "query", "schema": {"type": "integer"}, "description": "Items per page (default: 50, max: 200)"}),
294 json!({"name": "sort", "in": "query", "schema": {"type": "string"}, "description": "Sort by column (prefix - for DESC)"}),
295 json!({"name": "filter", "in": "query", "schema": {"type": "string"}, "description": "JSON filter array"}),
296 ];
297
298 for col in &table.columns {
300 params.push(json!({
301 "name": col.name,
302 "in": "query",
303 "schema": column_to_openapi_type(&col.column_type),
304 "description": format!("Filter by {}", col.name)
305 }));
306 }
307
308 params
309}
310
311fn to_pascal_case(s: &str) -> String {
312 s.split('_')
313 .map(|word| {
314 let mut chars = word.chars();
315 match chars.next() {
316 Some(c) => c.to_uppercase().to_string() + &chars.as_str().to_lowercase(),
317 None => String::new(),
318 }
319 })
320 .collect()
321}
322
323fn singular(s: &str) -> String {
324 if s.ends_with('s') && s.len() > 1 {
325 s[..s.len() - 1].to_string()
326 } else {
327 s.to_string()
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::{
335 ColumnDefinition, ColumnType, CustomRouteDefinition, ServiceManifest, ServiceMode,
336 TableDefinition,
337 };
338
339 fn test_manifest() -> ServiceManifest {
340 ServiceManifest {
341 name: "test-service".into(),
342 version: Some("1.0.0".into()),
343 tables: vec![TableDefinition {
344 name: "todos".into(),
345 columns: vec![
346 ColumnDefinition {
347 name: "id".into(),
348 column_type: ColumnType::Uuid,
349 primary_key: true,
350 nullable: false,
351 auto_generate: true,
352 default_value: None,
353 references: None,
354 on_delete: None,
355 unique: false,
356 validations: vec![],
357 },
358 ColumnDefinition {
359 name: "title".into(),
360 column_type: ColumnType::Text,
361 primary_key: false,
362 nullable: false,
363 auto_generate: false,
364 default_value: None,
365 references: None,
366 on_delete: None,
367 unique: false,
368 validations: vec![],
369 },
370 ColumnDefinition {
371 name: "done".into(),
372 column_type: ColumnType::Boolean,
373 primary_key: false,
374 nullable: false,
375 auto_generate: false,
376 default_value: Some("false".into()),
377 references: None,
378 on_delete: None,
379 unique: false,
380 validations: vec![],
381 },
382 ],
383 indexes: vec![],
384 soft_delete: false,
385 owner_field: None,
386 auth_required: false,
387 permission_area: None,
388 hooks: None,
389 }],
390 cells: vec![],
391 events: vec![],
392 subscriptions: vec![],
393 custom_routes: vec![],
394 mode: ServiceMode::Crud,
395 authorization: None,
396 }
397 }
398
399 #[test]
400 fn test_generates_valid_openapi() {
401 let manifest = test_manifest();
402 let spec = generate_openapi(&manifest, "test-tenant");
403
404 assert_eq!(spec["openapi"], "3.1.0");
405 assert_eq!(spec["info"]["title"], "test-service");
406 assert_eq!(spec["info"]["version"], "1.0.0");
407 }
408
409 #[test]
410 fn test_generates_crud_paths() {
411 let manifest = test_manifest();
412 let spec = generate_openapi(&manifest, "acme");
413
414 let paths = spec["paths"].as_object().unwrap();
415 assert!(paths.contains_key("/svc/acme/test-service/todos"));
416 assert!(paths.contains_key("/svc/acme/test-service/todos/:id"));
417 }
418
419 #[test]
420 fn test_generates_schemas() {
421 let manifest = test_manifest();
422 let spec = generate_openapi(&manifest, "acme");
423
424 let schemas = spec["components"]["schemas"].as_object().unwrap();
425 assert!(schemas.contains_key("Todos"));
426 assert!(schemas.contains_key("TodosCreate"));
427 assert!(schemas.contains_key("Pagination"));
428 }
429
430 #[test]
431 fn test_create_schema_excludes_auto_generated() {
432 let manifest = test_manifest();
433 let spec = generate_openapi(&manifest, "acme");
434
435 let create_props = spec["components"]["schemas"]["TodosCreate"]["properties"]
436 .as_object()
437 .unwrap();
438 assert!(!create_props.contains_key("id"));
440 assert!(create_props.contains_key("title"));
441 }
442
443 #[test]
444 fn test_custom_routes() {
445 let mut manifest = test_manifest();
446 manifest.custom_routes.push(CustomRouteDefinition {
447 method: "POST".into(),
448 path: "/complete/:id".into(),
449 handler: "complete_todo".into(),
450 });
451
452 let spec = generate_openapi(&manifest, "acme");
453 let paths = spec["paths"].as_object().unwrap();
454 assert!(paths.contains_key("/svc/acme/test-service/_fn/complete_todo"));
455 }
456
457 #[test]
458 fn test_default_version() {
459 let mut manifest = test_manifest();
460 manifest.version = None;
461 let spec = generate_openapi(&manifest, "acme");
462 assert_eq!(spec["info"]["version"], "0.1.0");
463 }
464
465 #[test]
466 fn test_local_slug() {
467 let manifest = test_manifest();
468 let spec = generate_openapi(&manifest, "local");
469
470 let paths = spec["paths"].as_object().unwrap();
471 assert!(paths.contains_key("/svc/local/test-service/todos"));
472 }
473}