Skip to main content

pylon_runtime/
openapi.rs

1use pylon_kernel::AppManifest;
2use serde_json::{json, Value};
3
4/// Generate a complete OpenAPI 3.0.3 specification from an `AppManifest`.
5///
6/// The `base_url` is used as the server URL in the spec. Pass an empty string
7/// or "/" if the server URL should be relative to the host.
8pub 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    // -----------------------------------------------------------------------
13    // Fixed paths
14    // -----------------------------------------------------------------------
15
16    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    // -----------------------------------------------------------------------
138    // Rooms
139    // -----------------------------------------------------------------------
140
141    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    // -----------------------------------------------------------------------
194    // Auth endpoints
195    // -----------------------------------------------------------------------
196
197    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    // -----------------------------------------------------------------------
318    // Entity CRUD paths (generated from manifest)
319    // -----------------------------------------------------------------------
320
321    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        // Build the schema for this entity.
327        let entity_schema = build_entity_schema(entity);
328        schemas.insert(entity.name.clone(), entity_schema);
329
330        // GET + POST /api/entities/{entity}
331        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        // GET + PATCH + DELETE /api/entities/{entity}/{id}
374        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        // GET /api/entities/{entity}/cursor
422        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    // -----------------------------------------------------------------------
448    // Action paths (generated from manifest)
449    // -----------------------------------------------------------------------
450
451    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    // -----------------------------------------------------------------------
485    // Shared schemas
486    // -----------------------------------------------------------------------
487
488    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    // -----------------------------------------------------------------------
507    // Assemble final spec
508    // -----------------------------------------------------------------------
509
510    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
531/// Map an pylon field type string to an OpenAPI schema fragment.
532fn 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
545/// Build an OpenAPI schema object from a `ManifestEntity`.
546fn build_entity_schema(entity: &pylon_kernel::ManifestEntity) -> Value {
547    build_fields_schema_with_id(&entity.fields)
548}
549
550/// Build an OpenAPI schema from a slice of fields, prepending an `id` property.
551fn 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
571/// Build an OpenAPI schema from a slice of fields (no implicit `id`).
572fn 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// ---------------------------------------------------------------------------
596// Tests
597// ---------------------------------------------------------------------------
598
599#[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        // Round-trip through string to verify it's valid JSON.
736        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        // User entity
747        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        // Post entity
761        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        // Collection path has GET and POST
775        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        // Item path has GET, PATCH, DELETE
780        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        // postId is required, notify is optional
814        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        // string -> string
839        assert_eq!(user["properties"]["email"]["type"], "string");
840        // int -> integer
841        assert_eq!(user["properties"]["age"]["type"], "integer");
842        // float -> number
843        assert_eq!(user["properties"]["score"]["type"], "number");
844        // bool -> boolean
845        assert_eq!(user["properties"]["active"]["type"], "boolean");
846        // datetime -> string with format date-time
847        assert_eq!(user["properties"]["createdAt"]["type"], "string");
848        assert_eq!(user["properties"]["createdAt"]["format"], "date-time");
849        // richtext -> string
850        assert_eq!(user["properties"]["bio"]["type"], "string");
851
852        let post = &spec["components"]["schemas"]["Post"];
853        // id(User) -> string
854        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        // id, email, active are required (non-optional)
864        assert!(required.contains(&json!("id")));
865        assert!(required.contains(&json!("email")));
866        assert!(required.contains(&json!("active")));
867
868        // age, score, createdAt, bio are optional
869        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        // Only fixed paths + Error schema should exist.
916        let schemas = spec["components"]["schemas"].as_object().unwrap();
917        assert!(schemas.contains_key("Error"));
918        assert_eq!(schemas.len(), 1);
919    }
920}