1use pylon_kernel::AppManifest;
2use serde_json::{json, Value};
3
4pub fn generate_openapi(manifest: &AppManifest, base_url: &str) -> Value {
9 let mut paths = serde_json::Map::new();
10 let mut schemas = serde_json::Map::new();
11
12 paths.insert(
17 "/health".into(),
18 json!({
19 "get": {
20 "operationId": "healthCheck",
21 "summary": "Health check",
22 "tags": ["system"],
23 "responses": {
24 "200": {
25 "description": "Server is healthy",
26 "content": { "application/json": { "schema": {
27 "type": "object",
28 "properties": {
29 "status": { "type": "string" },
30 "version": { "type": "string" },
31 "uptime_secs": { "type": "integer" }
32 }
33 }}}
34 }
35 }
36 }
37 }),
38 );
39
40 paths.insert("/api/manifest".into(), json!({
41 "get": {
42 "operationId": "getManifest",
43 "summary": "Get application manifest",
44 "tags": ["system"],
45 "responses": {
46 "200": { "description": "Application manifest", "content": { "application/json": { "schema": { "type": "object" } } } }
47 }
48 }
49 }));
50
51 paths.insert("/api/openapi.json".into(), json!({
52 "get": {
53 "operationId": "getOpenApiSpec",
54 "summary": "Get OpenAPI specification",
55 "tags": ["system"],
56 "responses": {
57 "200": { "description": "OpenAPI 3.0.3 spec", "content": { "application/json": { "schema": { "type": "object" } } } }
58 }
59 }
60 }));
61
62 paths.insert("/api/query".into(), json!({
63 "post": {
64 "operationId": "graphQuery",
65 "summary": "Execute a graph query",
66 "tags": ["query"],
67 "security": [{ "BearerAuth": [] }],
68 "requestBody": {
69 "required": true,
70 "content": { "application/json": { "schema": { "type": "object" } } }
71 },
72 "responses": {
73 "200": { "description": "Query result", "content": { "application/json": { "schema": { "type": "object" } } } },
74 "400": { "description": "Invalid query", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
75 }
76 }
77 }));
78
79 paths.insert("/api/batch".into(), json!({
80 "post": {
81 "operationId": "batchOperations",
82 "summary": "Execute batch operations",
83 "tags": ["batch"],
84 "security": [{ "BearerAuth": [] }],
85 "requestBody": {
86 "required": true,
87 "content": { "application/json": { "schema": {
88 "type": "object",
89 "properties": {
90 "operations": {
91 "type": "array",
92 "items": { "type": "object" }
93 }
94 },
95 "required": ["operations"]
96 }}}
97 },
98 "responses": {
99 "200": { "description": "Batch results", "content": { "application/json": { "schema": { "type": "object" } } } }
100 }
101 }
102 }));
103
104 paths.insert("/api/transact".into(), json!({
105 "post": {
106 "operationId": "atomicTransaction",
107 "summary": "Execute an atomic transaction",
108 "tags": ["batch"],
109 "security": [{ "BearerAuth": [] }],
110 "requestBody": {
111 "required": true,
112 "content": { "application/json": { "schema": {
113 "type": "array",
114 "items": { "type": "object" }
115 }}}
116 },
117 "responses": {
118 "200": { "description": "Transaction committed", "content": { "application/json": { "schema": { "type": "object" } } } },
119 "400": { "description": "Transaction rolled back", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
120 }
121 }
122 }));
123
124 paths.insert("/api/export".into(), json!({
125 "get": {
126 "operationId": "exportAll",
127 "summary": "Export all data (admin only)",
128 "tags": ["admin"],
129 "security": [{ "BearerAuth": [] }],
130 "responses": {
131 "200": { "description": "Full data export", "content": { "application/json": { "schema": { "type": "object" } } } },
132 "403": { "description": "Forbidden", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
133 }
134 }
135 }));
136
137 paths.insert("/api/rooms".into(), json!({
142 "get": {
143 "operationId": "listRooms",
144 "summary": "List active rooms",
145 "tags": ["rooms"],
146 "responses": {
147 "200": { "description": "List of rooms", "content": { "application/json": { "schema": {
148 "type": "array",
149 "items": {
150 "type": "object",
151 "properties": {
152 "name": { "type": "string" },
153 "members": { "type": "integer" }
154 }
155 }
156 }}}}
157 }
158 }
159 }));
160
161 for (path, op_id, summary) in [
162 ("/api/rooms/join", "joinRoom", "Join a room"),
163 ("/api/rooms/leave", "leaveRoom", "Leave a room"),
164 (
165 "/api/rooms/presence",
166 "updatePresence",
167 "Update presence in a room",
168 ),
169 (
170 "/api/rooms/broadcast",
171 "broadcastToRoom",
172 "Broadcast a message to a room",
173 ),
174 ] {
175 paths.insert(path.into(), json!({
176 "post": {
177 "operationId": op_id,
178 "summary": summary,
179 "tags": ["rooms"],
180 "security": [{ "BearerAuth": [] }],
181 "requestBody": {
182 "required": true,
183 "content": { "application/json": { "schema": { "type": "object" } } }
184 },
185 "responses": {
186 "200": { "description": "Success", "content": { "application/json": { "schema": { "type": "object" } } } },
187 "401": { "description": "Auth required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
188 }
189 }
190 }));
191 }
192
193 paths.insert("/api/auth/session".into(), json!({
198 "post": {
199 "operationId": "createSession",
200 "summary": "Create a session",
201 "tags": ["auth"],
202 "requestBody": {
203 "required": true,
204 "content": { "application/json": { "schema": {
205 "type": "object",
206 "properties": { "user_id": { "type": "string" } },
207 "required": ["user_id"]
208 }}}
209 },
210 "responses": {
211 "201": { "description": "Session created", "content": { "application/json": { "schema": {
212 "type": "object",
213 "properties": {
214 "token": { "type": "string" },
215 "user_id": { "type": "string" }
216 }
217 }}}}
218 }
219 },
220 "delete": {
221 "operationId": "revokeSession",
222 "summary": "Revoke current session",
223 "tags": ["auth"],
224 "security": [{ "BearerAuth": [] }],
225 "responses": {
226 "200": { "description": "Session revoked", "content": { "application/json": { "schema": { "type": "object" } } } }
227 }
228 }
229 }));
230
231 paths.insert("/api/auth/guest".into(), json!({
232 "post": {
233 "operationId": "createGuestSession",
234 "summary": "Create a guest session",
235 "tags": ["auth"],
236 "responses": {
237 "201": { "description": "Guest session created", "content": { "application/json": { "schema": {
238 "type": "object",
239 "properties": {
240 "token": { "type": "string" },
241 "user_id": { "type": "string" },
242 "guest": { "type": "boolean" }
243 }
244 }}}}
245 }
246 }
247 }));
248
249 paths.insert("/api/auth/magic/send".into(), json!({
250 "post": {
251 "operationId": "sendMagicCode",
252 "summary": "Send a magic login code",
253 "tags": ["auth"],
254 "requestBody": {
255 "required": true,
256 "content": { "application/json": { "schema": {
257 "type": "object",
258 "properties": { "email": { "type": "string", "format": "email" } },
259 "required": ["email"]
260 }}}
261 },
262 "responses": {
263 "200": { "description": "Code sent", "content": { "application/json": { "schema": { "type": "object" } } } }
264 }
265 }
266 }));
267
268 paths.insert("/api/auth/magic/verify".into(), json!({
269 "post": {
270 "operationId": "verifyMagicCode",
271 "summary": "Verify a magic login code",
272 "tags": ["auth"],
273 "requestBody": {
274 "required": true,
275 "content": { "application/json": { "schema": {
276 "type": "object",
277 "properties": {
278 "email": { "type": "string", "format": "email" },
279 "code": { "type": "string" }
280 },
281 "required": ["email", "code"]
282 }}}
283 },
284 "responses": {
285 "200": { "description": "Verified and session created", "content": { "application/json": { "schema": {
286 "type": "object",
287 "properties": {
288 "token": { "type": "string" },
289 "user_id": { "type": "string" }
290 }
291 }}}},
292 "401": { "description": "Invalid code", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
293 }
294 }
295 }));
296
297 paths.insert("/api/auth/providers".into(), json!({
298 "get": {
299 "operationId": "listAuthProviders",
300 "summary": "List available OAuth providers",
301 "tags": ["auth"],
302 "responses": {
303 "200": { "description": "Provider list", "content": { "application/json": { "schema": {
304 "type": "array",
305 "items": {
306 "type": "object",
307 "properties": {
308 "provider": { "type": "string" },
309 "auth_url": { "type": "string" }
310 }
311 }
312 }}}}
313 }
314 }
315 }));
316
317 for entity in &manifest.entities {
322 let entity_lower = entity.name.to_lowercase();
323 let schema_ref = format!("#/components/schemas/{}", entity.name);
324 let tag = entity.name.clone();
325
326 let entity_schema = build_entity_schema(entity);
328 schemas.insert(entity.name.clone(), entity_schema);
329
330 let collection_path = format!("/api/entities/{entity_lower}");
332 paths.insert(collection_path, json!({
333 "get": {
334 "operationId": format!("list{}", entity.name),
335 "summary": format!("List all {} entities", entity.name),
336 "tags": [tag],
337 "security": [{ "BearerAuth": [] }],
338 "parameters": [
339 { "name": "limit", "in": "query", "schema": { "type": "integer" }, "description": "Maximum number of results" },
340 { "name": "offset", "in": "query", "schema": { "type": "integer", "default": 0 }, "description": "Number of results to skip" }
341 ],
342 "responses": {
343 "200": { "description": format!("List of {}", entity.name), "content": { "application/json": { "schema": {
344 "type": "object",
345 "properties": {
346 "data": { "type": "array", "items": { "$ref": &schema_ref } },
347 "total": { "type": "integer" },
348 "offset": { "type": "integer" },
349 "limit": { "type": "integer", "nullable": true }
350 }
351 }}}}
352 }
353 },
354 "post": {
355 "operationId": format!("create{}", entity.name),
356 "summary": format!("Create a new {}", entity.name),
357 "tags": [tag],
358 "security": [{ "BearerAuth": [] }],
359 "requestBody": {
360 "required": true,
361 "content": { "application/json": { "schema": { "$ref": &schema_ref } } }
362 },
363 "responses": {
364 "201": { "description": "Created", "content": { "application/json": { "schema": {
365 "type": "object",
366 "properties": { "id": { "type": "string" } }
367 }}}},
368 "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
369 }
370 }
371 }));
372
373 let item_path = format!("/api/entities/{entity_lower}/{{id}}");
375 paths.insert(item_path, json!({
376 "get": {
377 "operationId": format!("get{}ById", entity.name),
378 "summary": format!("Get a {} by ID", entity.name),
379 "tags": [tag],
380 "security": [{ "BearerAuth": [] }],
381 "parameters": [
382 { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
383 ],
384 "responses": {
385 "200": { "description": format!("{} found", entity.name), "content": { "application/json": { "schema": { "$ref": &schema_ref } } } },
386 "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
387 }
388 },
389 "patch": {
390 "operationId": format!("update{}", entity.name),
391 "summary": format!("Update a {}", entity.name),
392 "tags": [tag],
393 "security": [{ "BearerAuth": [] }],
394 "parameters": [
395 { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
396 ],
397 "requestBody": {
398 "required": true,
399 "content": { "application/json": { "schema": { "$ref": &schema_ref } } }
400 },
401 "responses": {
402 "200": { "description": "Updated", "content": { "application/json": { "schema": { "type": "object", "properties": { "updated": { "type": "boolean" } } } } } },
403 "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
404 }
405 },
406 "delete": {
407 "operationId": format!("delete{}", entity.name),
408 "summary": format!("Delete a {}", entity.name),
409 "tags": [tag],
410 "security": [{ "BearerAuth": [] }],
411 "parameters": [
412 { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
413 ],
414 "responses": {
415 "200": { "description": "Deleted", "content": { "application/json": { "schema": { "type": "object", "properties": { "deleted": { "type": "boolean" } } } } } },
416 "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
417 }
418 }
419 }));
420
421 let cursor_path = format!("/api/entities/{entity_lower}/cursor");
423 paths.insert(cursor_path, json!({
424 "get": {
425 "operationId": format!("list{}ByCursor", entity.name),
426 "summary": format!("Cursor-paginated list of {}", entity.name),
427 "tags": [tag],
428 "security": [{ "BearerAuth": [] }],
429 "parameters": [
430 { "name": "after", "in": "query", "schema": { "type": "string" }, "description": "Cursor: ID of the last item from the previous page" },
431 { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 100 }, "description": "Maximum number of results" }
432 ],
433 "responses": {
434 "200": { "description": format!("Paginated {} list", entity.name), "content": { "application/json": { "schema": {
435 "type": "object",
436 "properties": {
437 "data": { "type": "array", "items": { "$ref": &schema_ref } },
438 "next_cursor": { "type": "string", "nullable": true },
439 "has_more": { "type": "boolean" }
440 }
441 }}}}
442 }
443 }
444 }));
445 }
446
447 for action in &manifest.actions {
452 let action_lower = action.name.to_lowercase();
453 let input_schema_name = format!("{}Input", action.name);
454 let input_schema = build_fields_schema(&action.input);
455 schemas.insert(input_schema_name.clone(), input_schema);
456
457 let path = format!("/api/actions/{action_lower}");
458 paths.insert(path, json!({
459 "post": {
460 "operationId": format!("execute{}", action.name),
461 "summary": format!("Execute the {} action", action.name),
462 "tags": ["actions"],
463 "security": [{ "BearerAuth": [] }],
464 "requestBody": {
465 "required": true,
466 "content": { "application/json": { "schema": { "$ref": format!("#/components/schemas/{input_schema_name}") } } }
467 },
468 "responses": {
469 "200": { "description": "Action executed", "content": { "application/json": { "schema": {
470 "type": "object",
471 "properties": {
472 "action": { "type": "string" },
473 "input": { "type": "object" },
474 "executed": { "type": "boolean" }
475 }
476 }}}},
477 "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
478 "404": { "description": "Action not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
479 }
480 }
481 }));
482 }
483
484 schemas.insert(
489 "Error".into(),
490 json!({
491 "type": "object",
492 "properties": {
493 "error": {
494 "type": "object",
495 "properties": {
496 "code": { "type": "string" },
497 "message": { "type": "string" },
498 "hint": { "type": "string" }
499 },
500 "required": ["code", "message"]
501 }
502 }
503 }),
504 );
505
506 json!({
511 "openapi": "3.0.3",
512 "info": {
513 "title": manifest.name,
514 "version": manifest.version,
515 "description": format!("Auto-generated API documentation for {}", manifest.name)
516 },
517 "servers": [{ "url": base_url }],
518 "paths": Value::Object(paths),
519 "components": {
520 "schemas": Value::Object(schemas),
521 "securitySchemes": {
522 "BearerAuth": {
523 "type": "http",
524 "scheme": "bearer"
525 }
526 }
527 }
528 })
529}
530
531fn map_field_type(field_type: &str) -> Value {
533 match field_type {
534 "string" => json!({ "type": "string" }),
535 "int" => json!({ "type": "integer" }),
536 "float" => json!({ "type": "number" }),
537 "bool" => json!({ "type": "boolean" }),
538 "datetime" => json!({ "type": "string", "format": "date-time" }),
539 "richtext" => json!({ "type": "string" }),
540 t if t.starts_with("id(") => json!({ "type": "string" }),
541 _ => json!({ "type": "string" }),
542 }
543}
544
545fn build_entity_schema(entity: &pylon_kernel::ManifestEntity) -> Value {
547 build_fields_schema_with_id(&entity.fields)
548}
549
550fn build_fields_schema_with_id(fields: &[pylon_kernel::ManifestField]) -> Value {
552 let mut properties = serde_json::Map::new();
553 let mut required = vec!["id".to_string()];
554
555 properties.insert("id".into(), json!({ "type": "string" }));
556
557 for field in fields {
558 properties.insert(field.name.clone(), map_field_type(&field.field_type));
559 if !field.optional {
560 required.push(field.name.clone());
561 }
562 }
563
564 json!({
565 "type": "object",
566 "properties": Value::Object(properties),
567 "required": required
568 })
569}
570
571fn build_fields_schema(fields: &[pylon_kernel::ManifestField]) -> Value {
573 let mut properties = serde_json::Map::new();
574 let mut required = Vec::new();
575
576 for field in fields {
577 properties.insert(field.name.clone(), map_field_type(&field.field_type));
578 if !field.optional {
579 required.push(field.name.clone());
580 }
581 }
582
583 let mut schema = json!({
584 "type": "object",
585 "properties": Value::Object(properties)
586 });
587
588 if !required.is_empty() {
589 schema["required"] = json!(required);
590 }
591
592 schema
593}
594
595#[cfg(test)]
600mod tests {
601 use super::*;
602 use pylon_kernel::{ManifestAction, ManifestEntity, ManifestField, ManifestIndex};
603
604 fn sample_manifest() -> AppManifest {
605 AppManifest {
606 manifest_version: 1,
607 name: "TestApp".into(),
608 version: "0.1.0".into(),
609 entities: vec![
610 ManifestEntity {
611 name: "User".into(),
612 fields: vec![
613 ManifestField {
614 name: "email".into(),
615 field_type: "string".into(),
616 optional: false,
617 unique: true,
618 crdt: None,
619 },
620 ManifestField {
621 name: "age".into(),
622 field_type: "int".into(),
623 optional: true,
624 unique: false,
625 crdt: None,
626 },
627 ManifestField {
628 name: "score".into(),
629 field_type: "float".into(),
630 optional: true,
631 unique: false,
632 crdt: None,
633 },
634 ManifestField {
635 name: "active".into(),
636 field_type: "bool".into(),
637 optional: false,
638 unique: false,
639 crdt: None,
640 },
641 ManifestField {
642 name: "createdAt".into(),
643 field_type: "datetime".into(),
644 optional: true,
645 unique: false,
646 crdt: None,
647 },
648 ManifestField {
649 name: "bio".into(),
650 field_type: "richtext".into(),
651 optional: true,
652 unique: false,
653 crdt: None,
654 },
655 ],
656 indexes: vec![ManifestIndex {
657 name: "email_idx".into(),
658 fields: vec!["email".into()],
659 unique: true,
660 }],
661 relations: vec![],
662 search: None,
663 crdt: true,
664 },
665 ManifestEntity {
666 name: "Post".into(),
667 fields: vec![
668 ManifestField {
669 name: "title".into(),
670 field_type: "string".into(),
671 optional: false,
672 unique: false,
673 crdt: None,
674 },
675 ManifestField {
676 name: "authorId".into(),
677 field_type: "id(User)".into(),
678 optional: false,
679 unique: false,
680 crdt: None,
681 },
682 ],
683 indexes: vec![],
684 relations: vec![],
685 search: None,
686 crdt: true,
687 },
688 ],
689 routes: vec![],
690 queries: vec![],
691 actions: vec![ManifestAction {
692 name: "PublishPost".into(),
693 input: vec![
694 ManifestField {
695 name: "postId".into(),
696 field_type: "id(Post)".into(),
697 optional: false,
698 unique: false,
699 crdt: None,
700 },
701 ManifestField {
702 name: "notify".into(),
703 field_type: "bool".into(),
704 optional: true,
705 unique: false,
706 crdt: None,
707 },
708 ],
709 }],
710 policies: vec![],
711 auth: Default::default(),
712 }
713 }
714
715 #[test]
716 fn spec_has_correct_structure() {
717 let spec = generate_openapi(&sample_manifest(), "http://localhost:3000");
718
719 assert_eq!(spec["openapi"], "3.0.3");
720 assert_eq!(spec["info"]["title"], "TestApp");
721 assert_eq!(spec["info"]["version"], "0.1.0");
722 assert!(spec["info"]["description"]
723 .as_str()
724 .unwrap()
725 .contains("TestApp"));
726 assert_eq!(spec["servers"][0]["url"], "http://localhost:3000");
727 assert!(spec["paths"].is_object());
728 assert!(spec["components"]["schemas"].is_object());
729 assert!(spec["components"]["securitySchemes"]["BearerAuth"].is_object());
730 }
731
732 #[test]
733 fn spec_is_valid_json() {
734 let spec = generate_openapi(&sample_manifest(), "/");
735 let json_str = serde_json::to_string(&spec).unwrap();
737 let reparsed: Value = serde_json::from_str(&json_str).unwrap();
738 assert_eq!(spec, reparsed);
739 }
740
741 #[test]
742 fn entity_paths_generated_for_each_entity() {
743 let spec = generate_openapi(&sample_manifest(), "/");
744 let paths = spec["paths"].as_object().unwrap();
745
746 assert!(
748 paths.contains_key("/api/entities/user"),
749 "missing collection path for User"
750 );
751 assert!(
752 paths.contains_key("/api/entities/user/{id}"),
753 "missing item path for User"
754 );
755 assert!(
756 paths.contains_key("/api/entities/user/cursor"),
757 "missing cursor path for User"
758 );
759
760 assert!(
762 paths.contains_key("/api/entities/post"),
763 "missing collection path for Post"
764 );
765 assert!(
766 paths.contains_key("/api/entities/post/{id}"),
767 "missing item path for Post"
768 );
769 assert!(
770 paths.contains_key("/api/entities/post/cursor"),
771 "missing cursor path for Post"
772 );
773
774 let user_collection = &paths["/api/entities/user"];
776 assert!(user_collection.get("get").is_some());
777 assert!(user_collection.get("post").is_some());
778
779 let user_item = &paths["/api/entities/user/{id}"];
781 assert!(user_item.get("get").is_some());
782 assert!(user_item.get("patch").is_some());
783 assert!(user_item.get("delete").is_some());
784 }
785
786 #[test]
787 fn action_paths_generated() {
788 let spec = generate_openapi(&sample_manifest(), "/");
789 let paths = spec["paths"].as_object().unwrap();
790
791 assert!(
792 paths.contains_key("/api/actions/publishpost"),
793 "missing action path"
794 );
795 let action_path = &paths["/api/actions/publishpost"];
796 assert!(action_path.get("post").is_some());
797 assert_eq!(action_path["post"]["operationId"], "executePublishPost");
798 }
799
800 #[test]
801 fn action_input_schema_generated() {
802 let spec = generate_openapi(&sample_manifest(), "/");
803 let schemas = spec["components"]["schemas"].as_object().unwrap();
804
805 assert!(
806 schemas.contains_key("PublishPostInput"),
807 "missing action input schema"
808 );
809 let input = &schemas["PublishPostInput"];
810 assert!(input["properties"]["postId"].is_object());
811 assert!(input["properties"]["notify"].is_object());
812
813 let required = input["required"].as_array().unwrap();
815 assert!(required.contains(&json!("postId")));
816 assert!(!required.contains(&json!("notify")));
817 }
818
819 #[test]
820 fn entity_schemas_generated() {
821 let spec = generate_openapi(&sample_manifest(), "/");
822 let schemas = spec["components"]["schemas"].as_object().unwrap();
823
824 assert!(schemas.contains_key("User"));
825 assert!(schemas.contains_key("Post"));
826
827 let user = &schemas["User"];
828 assert!(user["properties"]["id"].is_object());
829 assert!(user["properties"]["email"].is_object());
830 assert!(user["properties"]["age"].is_object());
831 }
832
833 #[test]
834 fn field_types_mapped_correctly() {
835 let spec = generate_openapi(&sample_manifest(), "/");
836 let user = &spec["components"]["schemas"]["User"];
837
838 assert_eq!(user["properties"]["email"]["type"], "string");
840 assert_eq!(user["properties"]["age"]["type"], "integer");
842 assert_eq!(user["properties"]["score"]["type"], "number");
844 assert_eq!(user["properties"]["active"]["type"], "boolean");
846 assert_eq!(user["properties"]["createdAt"]["type"], "string");
848 assert_eq!(user["properties"]["createdAt"]["format"], "date-time");
849 assert_eq!(user["properties"]["bio"]["type"], "string");
851
852 let post = &spec["components"]["schemas"]["Post"];
853 assert_eq!(post["properties"]["authorId"]["type"], "string");
855 }
856
857 #[test]
858 fn required_fields_in_schema() {
859 let spec = generate_openapi(&sample_manifest(), "/");
860 let user = &spec["components"]["schemas"]["User"];
861 let required = user["required"].as_array().unwrap();
862
863 assert!(required.contains(&json!("id")));
865 assert!(required.contains(&json!("email")));
866 assert!(required.contains(&json!("active")));
867
868 assert!(!required.contains(&json!("age")));
870 assert!(!required.contains(&json!("score")));
871 assert!(!required.contains(&json!("createdAt")));
872 assert!(!required.contains(&json!("bio")));
873 }
874
875 #[test]
876 fn fixed_paths_present() {
877 let spec = generate_openapi(&sample_manifest(), "/");
878 let paths = spec["paths"].as_object().unwrap();
879
880 assert!(paths.contains_key("/health"));
881 assert!(paths.contains_key("/api/manifest"));
882 assert!(paths.contains_key("/api/query"));
883 assert!(paths.contains_key("/api/batch"));
884 assert!(paths.contains_key("/api/transact"));
885 assert!(paths.contains_key("/api/export"));
886 assert!(paths.contains_key("/api/rooms"));
887 assert!(paths.contains_key("/api/rooms/join"));
888 assert!(paths.contains_key("/api/rooms/leave"));
889 assert!(paths.contains_key("/api/rooms/presence"));
890 assert!(paths.contains_key("/api/rooms/broadcast"));
891 assert!(paths.contains_key("/api/auth/session"));
892 assert!(paths.contains_key("/api/auth/guest"));
893 assert!(paths.contains_key("/api/auth/magic/send"));
894 assert!(paths.contains_key("/api/auth/magic/verify"));
895 assert!(paths.contains_key("/api/auth/providers"));
896 }
897
898 #[test]
899 fn empty_manifest_produces_valid_spec() {
900 let manifest = AppManifest {
901 manifest_version: 1,
902 name: "Empty".into(),
903 version: "0.0.0".into(),
904 entities: vec![],
905 routes: vec![],
906 queries: vec![],
907 actions: vec![],
908 policies: vec![],
909 auth: Default::default(),
910 };
911 let spec = generate_openapi(&manifest, "");
912
913 assert_eq!(spec["openapi"], "3.0.3");
914 assert_eq!(spec["info"]["title"], "Empty");
915 let schemas = spec["components"]["schemas"].as_object().unwrap();
917 assert!(schemas.contains_key("Error"));
918 assert_eq!(schemas.len(), 1);
919 }
920}