Skip to main content

cufflink_types/openapi/
mod.rs

1//! OpenAPI spec generation from a [`ServiceManifest`].
2//!
3//! This module is used by both the platform (server-side) and the CLI
4//! (local generation without deployment).
5
6mod check;
7mod crud;
8mod custom;
9mod schema;
10
11pub use check::validate;
12
13use crate::{ServiceManifest, TableDefinition};
14use custom::Components;
15use schema::Nullable;
16use serde_json::{json, Map, Value};
17use std::fmt;
18
19/// Where a spec's paths are rooted.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum PathStyle<'a> {
22    /// Full `/svc/{tenant}/{service}/…` paths: the spec of an undocumented
23    /// service, unchanged.
24    Absolute { tenant: &'a str },
25    /// Paths relative to the `servers`, which default to the service's own
26    /// base under `tenant`: what a client generator needs.
27    ServerRelative { tenant: &'a str },
28}
29
30/// Server-relative once the service documents itself (`openapi` options or
31/// any route `doc`), absolute otherwise.
32pub fn default_style<'a>(manifest: &ServiceManifest, tenant: &'a str) -> PathStyle<'a> {
33    let documented = manifest.openapi.is_some()
34        || manifest
35            .custom_routes
36            .iter()
37            .any(|route| route.doc.is_some());
38    if documented {
39        PathStyle::ServerRelative { tenant }
40    } else {
41        PathStyle::Absolute { tenant }
42    }
43}
44
45/// A manifest whose docs cannot form a spec.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct OpenApiError(pub String);
48
49impl fmt::Display for OpenApiError {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        f.write_str(&self.0)
52    }
53}
54
55impl std::error::Error for OpenApiError {}
56
57/// The service's OpenAPI 3.1 document.
58pub fn generate_openapi(
59    manifest: &ServiceManifest,
60    style: PathStyle<'_>,
61) -> Result<Value, OpenApiError> {
62    match style {
63        PathStyle::Absolute { tenant } => Ok(absolute(manifest, tenant)),
64        PathStyle::ServerRelative { tenant } => server_relative(manifest, tenant),
65    }
66}
67
68/// The spec a deployed or local service is served with: in its
69/// [`default_style`].
70pub fn service_spec(manifest: &ServiceManifest, tenant: &str) -> Result<Value, OpenApiError> {
71    generate_openapi(manifest, default_style(manifest, tenant))
72}
73
74fn insert_operations(
75    paths: &mut Map<String, Value>,
76    operations: impl IntoIterator<Item = (String, String, Value)>,
77) {
78    for (path, method, operation) in operations {
79        if let Some(obj) = paths
80            .entry(path)
81            .or_insert_with(|| json!({}))
82            .as_object_mut()
83        {
84            obj.insert(method, operation);
85        }
86    }
87}
88
89fn absolute(manifest: &ServiceManifest, tenant_slug: &str) -> Value {
90    let mut paths: Map<String, Value> = manifest
91        .tables
92        .iter()
93        .flat_map(|table| {
94            let base = format!("/svc/{}/{}/{}", tenant_slug, manifest.name, table.name);
95            crud::table_paths(base.clone(), format!("{base}/:id"), table)
96        })
97        .collect();
98    insert_operations(
99        &mut paths,
100        manifest
101            .custom_routes
102            .iter()
103            .filter(|route| custom::published(route))
104            .map(|route| custom::route_operation(tenant_slug, &manifest.name, route)),
105    );
106    let schemas: Map<String, Value> = manifest
107        .tables
108        .iter()
109        .flat_map(|table| schema::table_schemas(table, Nullable::Flag))
110        .chain(std::iter::once(("Pagination".into(), schema::pagination())))
111        .collect();
112
113    json!({
114        "openapi": "3.1.0",
115        "info": {
116            "title": manifest.name,
117            "version": manifest.version.as_deref().unwrap_or("0.1.0")
118        },
119        "paths": Value::Object(paths),
120        "components": {
121            "schemas": Value::Object(schemas)
122        }
123    })
124}
125
126/// The schemes a CRUD table's operations require.
127fn table_security(table: &TableDefinition, scheme: &str) -> Vec<Value> {
128    if table.auth_required {
129        vec![json!({ scheme: [] })]
130    } else {
131        Vec::new()
132    }
133}
134
135fn server_relative(manifest: &ServiceManifest, tenant: &str) -> Result<Value, OpenApiError> {
136    validate(manifest)?;
137    let options = manifest.openapi.clone().unwrap_or_default();
138    let scheme = options.security_scheme();
139    let components = Map::from_iter(manifest.components.clone());
140    let lookup = Components(&components);
141
142    let custom: Vec<(String, String, Value)> = manifest
143        .custom_routes
144        .iter()
145        .filter(|route| custom::published(route))
146        .map(|route| {
147            let security = check::route_security(route, scheme);
148            custom::relative_operation(manifest, route, &security, &lookup)
149        })
150        .collect();
151    let crud = manifest.tables.iter().flat_map(|table| {
152        let security = table_security(table, scheme);
153        crud::table_paths(
154            format!("/{}", table.name),
155            format!("/{}/{{id}}", table.name),
156            table,
157        )
158        .into_iter()
159        .map(move |(path, operations)| (path, with_security(operations, &security)))
160    });
161    let mut paths: Map<String, Value> = crud.collect();
162    insert_operations(&mut paths, custom);
163
164    let uses = |name: &str| {
165        paths.values().any(|operations| {
166            operations
167                .as_object()
168                .into_iter()
169                .flatten()
170                .any(|(_, operation)| {
171                    operation["security"]
172                        .as_array()
173                        .is_some_and(|requirements| {
174                            requirements.iter().any(|r| r.get(name).is_some())
175                        })
176                })
177        })
178    };
179    let security_schemes: Map<String, Value> = [
180        uses(scheme).then(|| {
181            (
182                scheme.to_string(),
183                json!({ "type": "http", "scheme": "bearer" }),
184            )
185        }),
186        uses(check::API_KEY_SCHEME).then(|| {
187            (
188                check::API_KEY_SCHEME.to_string(),
189                json!({ "type": "apiKey", "in": "header", "name": "X-Api-Key" }),
190            )
191        }),
192    ]
193    .into_iter()
194    .flatten()
195    .collect();
196    let schemas: Map<String, Value> = manifest
197        .tables
198        .iter()
199        .flat_map(|table| schema::table_schemas(table, Nullable::TypeUnion))
200        .chain(
201            (!manifest.tables.is_empty()).then(|| ("Pagination".to_string(), schema::pagination())),
202        )
203        .chain(components)
204        .collect();
205    let mut component_map = Map::from_iter([("schemas".to_string(), Value::Object(schemas))]);
206    if !security_schemes.is_empty() {
207        component_map.insert("securitySchemes".into(), Value::Object(security_schemes));
208    }
209
210    let mut info = Map::from_iter([
211        (
212            "title".to_string(),
213            json!(options.title.as_deref().unwrap_or(&manifest.name)),
214        ),
215        (
216            "version".to_string(),
217            json!(options
218                .version
219                .as_deref()
220                .or(manifest.version.as_deref())
221                .unwrap_or("0.1.0")),
222        ),
223    ]);
224    if let Some(description) = &options.description {
225        info.insert("description".into(), json!(description));
226    }
227    let servers = if options.servers.is_empty() {
228        json!([{
229            "url": format!("{{origin}}/svc/{{tenant}}/{}", manifest.name),
230            "variables": {
231                "origin": { "default": "" },
232                "tenant": { "default": tenant },
233            },
234        }])
235    } else {
236        json!(options.servers)
237    };
238    let mut document = Map::from_iter([
239        ("openapi".to_string(), json!("3.1.0")),
240        ("info".to_string(), Value::Object(info)),
241        ("servers".to_string(), servers),
242        ("paths".to_string(), Value::Object(paths)),
243        ("components".to_string(), Value::Object(component_map)),
244    ]);
245    if !options.tags.is_empty() {
246        document.insert("tags".into(), json!(options.tags));
247    }
248    Ok(Value::Object(document))
249}
250
251/// Each operation of a path item with `security`, when there is any.
252fn with_security(mut operations: Value, security: &[Value]) -> Value {
253    if !security.is_empty() {
254        operations
255            .as_object_mut()
256            .into_iter()
257            .flat_map(|operations| operations.values_mut())
258            .filter_map(Value::as_object_mut)
259            .for_each(|operation| {
260                operation.insert("security".into(), json!(security));
261            });
262    }
263    operations
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::{
270        ColumnDefinition, ColumnType, CustomRouteDefinition, ServiceManifest, ServiceMode,
271        TableDefinition,
272    };
273
274    fn test_manifest() -> ServiceManifest {
275        ServiceManifest {
276            name: "test-service".into(),
277            version: Some("1.0.0".into()),
278            tables: vec![TableDefinition {
279                name: "todos".into(),
280                columns: vec![
281                    ColumnDefinition {
282                        name: "id".into(),
283                        column_type: ColumnType::Uuid,
284                        primary_key: true,
285                        nullable: false,
286                        auto_generate: true,
287                        default_value: None,
288                        references: None,
289                        on_delete: None,
290                        unique: false,
291                        validations: vec![],
292                        ..Default::default()
293                    },
294                    ColumnDefinition {
295                        name: "title".into(),
296                        column_type: ColumnType::Text,
297                        primary_key: false,
298                        nullable: false,
299                        auto_generate: false,
300                        default_value: None,
301                        references: None,
302                        on_delete: None,
303                        unique: false,
304                        validations: vec![],
305                        ..Default::default()
306                    },
307                    ColumnDefinition {
308                        name: "done".into(),
309                        column_type: ColumnType::Boolean,
310                        primary_key: false,
311                        nullable: false,
312                        auto_generate: false,
313                        default_value: Some("false".into()),
314                        references: None,
315                        on_delete: None,
316                        unique: false,
317                        validations: vec![],
318                        ..Default::default()
319                    },
320                ],
321                indexes: vec![],
322                soft_delete: false,
323                owner_field: None,
324                auth_required: false,
325                permission_area: None,
326                owner_self_service: false,
327                hooks: None,
328                scoped_by: None,
329                ..Default::default()
330            }],
331            cells: vec![],
332            events: vec![],
333            subscriptions: vec![],
334            custom_routes: vec![],
335            mode: ServiceMode::Crud,
336            authorization: None,
337            on_migrate: None,
338            publishes: None,
339            required_platform_features: vec![],
340            graphql: false,
341            ..Default::default()
342        }
343    }
344
345    #[test]
346    fn test_generates_valid_openapi() {
347        let manifest = test_manifest();
348        let spec = generate_openapi(
349            &manifest,
350            PathStyle::Absolute {
351                tenant: "test-tenant",
352            },
353        )
354        .expect("spec");
355
356        assert_eq!(spec["openapi"], "3.1.0");
357        assert_eq!(spec["info"]["title"], "test-service");
358        assert_eq!(spec["info"]["version"], "1.0.0");
359    }
360
361    #[test]
362    fn test_generates_crud_paths() {
363        let manifest = test_manifest();
364        let spec =
365            generate_openapi(&manifest, PathStyle::Absolute { tenant: "acme" }).expect("spec");
366
367        let paths = spec["paths"].as_object().unwrap();
368        assert!(paths.contains_key("/svc/acme/test-service/todos"));
369        assert!(paths.contains_key("/svc/acme/test-service/todos/:id"));
370    }
371
372    #[test]
373    fn test_generates_schemas() {
374        let manifest = test_manifest();
375        let spec =
376            generate_openapi(&manifest, PathStyle::Absolute { tenant: "acme" }).expect("spec");
377
378        let schemas = spec["components"]["schemas"].as_object().unwrap();
379        assert!(schemas.contains_key("Todos"));
380        assert!(schemas.contains_key("TodosCreate"));
381        assert!(schemas.contains_key("Pagination"));
382    }
383
384    #[test]
385    fn a_restricted_column_carries_its_permissions_and_is_not_required_of_a_response() {
386        let mut manifest = test_manifest();
387        let todos = &mut manifest.tables[0];
388        todos.permission_area = Some("todos".into());
389        todos.columns[1].access = Some(crate::ColumnAccess {
390            read: Some("internal".into()),
391            write: None,
392        });
393        todos.columns[2].access = Some(crate::ColumnAccess {
394            read: None,
395            write: Some("manage".into()),
396        });
397        let spec =
398            generate_openapi(&manifest, PathStyle::Absolute { tenant: "acme" }).expect("spec");
399        let schemas = &spec["components"]["schemas"];
400        for schema in ["Todos", "TodosCreate"] {
401            let properties = &schemas[schema]["properties"];
402            assert_eq!(
403                properties["title"]["x-cufflink-access"],
404                serde_json::json!({"read": "todos:internal"}),
405                "{schema}"
406            );
407            assert_eq!(
408                properties["done"]["x-cufflink-access"],
409                serde_json::json!({"write": "todos:manage"}),
410                "{schema}"
411            );
412        }
413        assert_eq!(
414            schemas["Todos"]["required"],
415            serde_json::json!(["id", "done"])
416        );
417        assert_eq!(
418            schemas["TodosCreate"]["required"],
419            serde_json::json!(["title"])
420        );
421        let plain = generate_openapi(&test_manifest(), PathStyle::Absolute { tenant: "acme" })
422            .expect("spec");
423        assert_eq!(
424            plain["components"]["schemas"]["Todos"]["required"],
425            serde_json::json!(["id", "title", "done"])
426        );
427        assert!(!plain.to_string().contains("x-cufflink-access"));
428    }
429
430    #[test]
431    fn rows_require_every_column_and_bodies_follow_the_platform() {
432        let mut manifest = test_manifest();
433        let table = &mut manifest.tables[0];
434        table.owner_field = Some("owner_id".into());
435        table.columns.push(ColumnDefinition {
436            name: "owner_id".into(),
437            column_type: ColumnType::Uuid,
438            ..Default::default()
439        });
440        table.columns.push(ColumnDefinition {
441            name: "notes".into(),
442            column_type: ColumnType::Jsonb,
443            nullable: true,
444            ..Default::default()
445        });
446        let spec = generate_openapi(&manifest, PathStyle::ServerRelative { tenant: "acme" })
447            .expect("spec");
448        let schemas = &spec["components"]["schemas"];
449        assert_eq!(
450            schemas["Todos"]["required"],
451            serde_json::json!(["id", "title", "done", "owner_id", "notes"])
452        );
453        assert_eq!(
454            schemas["Todos"]["properties"]["notes"],
455            serde_json::json!({})
456        );
457        assert_eq!(
458            schemas["TodosCreate"]["required"],
459            serde_json::json!(["title"])
460        );
461        assert_eq!(schemas["TodosUpdate"]["required"], serde_json::json!([]));
462        assert!(schemas["TodosUpdate"]["properties"].get("id").is_none());
463        let put = spec["paths"]
464            .as_object()
465            .unwrap()
466            .iter()
467            .find_map(|(_, item)| item.get("put"));
468        assert_eq!(
469            put.expect("put")["requestBody"]["content"]["application/json"]["schema"]["$ref"],
470            "#/components/schemas/TodosUpdate"
471        );
472    }
473
474    #[test]
475    fn test_create_schema_excludes_auto_generated() {
476        let manifest = test_manifest();
477        let spec =
478            generate_openapi(&manifest, PathStyle::Absolute { tenant: "acme" }).expect("spec");
479
480        let create_props = spec["components"]["schemas"]["TodosCreate"]["properties"]
481            .as_object()
482            .unwrap();
483        assert!(!create_props.contains_key("id"));
484        assert!(create_props.contains_key("title"));
485    }
486
487    #[test]
488    fn test_custom_routes() {
489        let mut manifest = test_manifest();
490        manifest.custom_routes.push(CustomRouteDefinition {
491            method: "POST".into(),
492            path: "/complete/:id".into(),
493            handler: "complete_todo".into(),
494            job: None,
495            posture: None,
496            forward_auth: false,
497            ..Default::default()
498        });
499
500        let spec =
501            generate_openapi(&manifest, PathStyle::Absolute { tenant: "acme" }).expect("spec");
502        let paths = spec["paths"].as_object().unwrap();
503        assert!(paths.contains_key("/svc/acme/test-service/_fn/complete_todo"));
504    }
505
506    #[test]
507    fn test_default_version() {
508        let mut manifest = test_manifest();
509        manifest.version = None;
510        let spec =
511            generate_openapi(&manifest, PathStyle::Absolute { tenant: "acme" }).expect("spec");
512        assert_eq!(spec["info"]["version"], "0.1.0");
513    }
514
515    #[test]
516    fn output_matches_the_golden_specs() {
517        for (manifest, golden) in [
518            (
519                include_str!("../../fixtures/sample_manifest.json"),
520                include_str!("../../fixtures/openapi/sample.json"),
521            ),
522            (
523                include_str!("../../fixtures/todo_manifest.json"),
524                include_str!("../../fixtures/openapi/todo.json"),
525            ),
526            (
527                include_str!("../../fixtures/blog_manifest.json"),
528                include_str!("../../fixtures/openapi/blog.json"),
529            ),
530        ] {
531            let manifest: ServiceManifest = serde_json::from_str(manifest).expect("manifest");
532            let golden: Value = serde_json::from_str(golden).expect("golden");
533            assert_eq!(
534                generate_openapi(&manifest, PathStyle::Absolute { tenant: "acme" }).expect("spec"),
535                golden,
536                "{}",
537                manifest.name
538            );
539        }
540    }
541
542    #[test]
543    fn test_local_slug() {
544        let manifest = test_manifest();
545        let spec =
546            generate_openapi(&manifest, PathStyle::Absolute { tenant: "local" }).expect("spec");
547
548        let paths = spec["paths"].as_object().unwrap();
549        assert!(paths.contains_key("/svc/local/test-service/todos"));
550    }
551
552    fn documented_route(
553        method: &str,
554        path: &str,
555        handler: &str,
556        posture: Option<crate::RoutePosture>,
557        doc: crate::RouteDoc,
558    ) -> CustomRouteDefinition {
559        CustomRouteDefinition {
560            method: method.into(),
561            path: path.into(),
562            handler: handler.into(),
563            posture,
564            doc: Some(doc),
565            ..Default::default()
566        }
567    }
568
569    fn relative(manifest: &ServiceManifest) -> Value {
570        generate_openapi(manifest, PathStyle::ServerRelative { tenant: "acme" }).expect("spec")
571    }
572
573    #[test]
574    fn documented_services_default_to_server_relative_paths() {
575        let mut manifest = test_manifest();
576        assert_eq!(
577            default_style(&manifest, "acme"),
578            PathStyle::Absolute { tenant: "acme" }
579        );
580        manifest.custom_routes.push(documented_route(
581            "GET",
582            "/x",
583            "x",
584            None,
585            crate::RouteDoc::default(),
586        ));
587        assert_eq!(
588            default_style(&manifest, "acme"),
589            PathStyle::ServerRelative { tenant: "acme" }
590        );
591    }
592
593    #[test]
594    fn server_relative_specs_default_their_server_and_use_3_1_nullability() {
595        let mut manifest = test_manifest();
596        manifest.tables[0].columns[1].nullable = true;
597        manifest.openapi = Some(crate::OpenApiOptions::default());
598        let spec = relative(&manifest);
599        assert_eq!(
600            spec["servers"],
601            json!([{"url": "{origin}/svc/{tenant}/test-service",
602                "variables": {"origin": {"default": ""}, "tenant": {"default": "acme"}}}])
603        );
604        let paths = spec["paths"].as_object().expect("paths");
605        assert!(paths.contains_key("/todos") && paths.contains_key("/todos/{id}"));
606        assert_eq!(
607            spec["components"]["schemas"]["Todos"]["properties"]["title"],
608            json!({"type": ["string", "null"]})
609        );
610        assert!(spec.get("tags").is_none());
611        assert!(spec["components"].get("securitySchemes").is_none());
612
613        manifest.openapi = Some(crate::OpenApiOptions {
614            title: Some("Loans".into()),
615            version: Some("2.0.0".into()),
616            description: Some(String::new()),
617            servers: vec![crate::ServerDoc {
618                url: "https://api.example.com/api".into(),
619                description: Some("Production".into()),
620            }],
621            tags: vec![crate::TagDoc {
622                name: "Loans Service".into(),
623                description: Some("Loans Service API".into()),
624            }],
625            security_scheme: None,
626        });
627        let spec = relative(&manifest);
628        assert_eq!(
629            spec["info"],
630            json!({"title": "Loans", "version": "2.0.0", "description": ""})
631        );
632        assert_eq!(
633            spec["servers"],
634            json!([{"url": "https://api.example.com/api", "description": "Production"}])
635        );
636        assert_eq!(
637            spec["tags"],
638            json!([{"name": "Loans Service", "description": "Loans Service API"}])
639        );
640    }
641
642    #[test]
643    fn documented_routes_carry_parameters_bodies_responses_and_security() {
644        use crate::{BodyDoc, MediaDoc, ResponseDoc, RouteDoc, RoutePosture, RoutingMode};
645        let mut manifest = test_manifest();
646        manifest.tables.clear();
647        manifest.routing = RoutingMode::Strict;
648        manifest.components.insert(
649            "ApiLoan".into(),
650            json!({"type": "object", "properties": {"id": {"type": "string"}}}),
651        );
652        let doc = RouteDoc {
653            tags: vec!["loan".into()],
654            summary: Some("Update a loan".into()),
655            deprecated: true,
656            path_params: Some(json!({"type": "object", "required": ["loan_id"],
657                "properties": {"loan_id": {"type": "string", "description": "Loan identifier"}}})),
658            query: Some(
659                json!({"type": "object", "required": ["force"], "properties": {
660                "force": {"type": "boolean"}, "page": {"type": ["integer", "null"], "format": "int32"}}}),
661            ),
662            request: Some(BodyDoc {
663                content_type: "application/json".into(),
664                schema: Some(json!({"$ref": "#/components/schemas/ApiLoan"})),
665                required: true,
666            }),
667            responses: [
668                (
669                    200,
670                    ResponseDoc {
671                        description: "Updated".into(),
672                        content: vec![MediaDoc {
673                            content_type: "application/json".into(),
674                            schema: Some(json!({"$ref": "#/components/schemas/ApiLoan"})),
675                        }],
676                    },
677                ),
678                (
679                    403,
680                    ResponseDoc {
681                        description: "Forbidden".into(),
682                        content: vec![],
683                    },
684                ),
685            ]
686            .into(),
687            ..Default::default()
688        };
689        manifest.custom_routes = vec![
690            documented_route(
691                "PUT",
692                "/loans/{loan_id}",
693                "update_loan",
694                Some(RoutePosture::Permission {
695                    area: "loans".into(),
696                    operation: "edit".into(),
697                }),
698                doc,
699            ),
700            documented_route(
701                "GET",
702                "/loans/{loan_id}/pdf",
703                "loan_pdf",
704                Some(RoutePosture::Public {
705                    reason: "shared link".into(),
706                }),
707                RouteDoc {
708                    responses: [(
709                        200,
710                        ResponseDoc {
711                            description: "PDF".into(),
712                            content: vec![MediaDoc {
713                                content_type: "application/pdf".into(),
714                                schema: None,
715                            }],
716                        },
717                    )]
718                    .into(),
719                    ..Default::default()
720                },
721            ),
722        ];
723        let spec = relative(&manifest);
724        let update = &spec["paths"]["/loans/{loan_id}"]["put"];
725        assert_eq!(update["operationId"], "update_loan");
726        assert_eq!(update["tags"], json!(["loan"]));
727        assert_eq!(update["summary"], "Update a loan");
728        assert_eq!(update["deprecated"], true);
729        assert_eq!(
730            update["parameters"],
731            json!([
732                {"name": "loan_id", "in": "path", "required": true, "schema": {"type": "string"},
733                    "description": "Loan identifier"},
734                {"name": "force", "in": "query", "required": true, "schema": {"type": "boolean"}},
735                {"name": "page", "in": "query", "required": false,
736                    "schema": {"type": ["integer", "null"], "format": "int32"}},
737            ])
738        );
739        assert_eq!(
740            update["requestBody"],
741            json!({"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiLoan"}}}, "required": true})
742        );
743        assert_eq!(
744            update["responses"]["403"],
745            json!({"description": "Forbidden"})
746        );
747        assert_eq!(update["security"], json!([{"bearer_auth": []}]));
748
749        let pdf = &spec["paths"]["/loans/{loan_id}/pdf"]["get"];
750        assert_eq!(pdf["tags"], json!(["test-service"]));
751        assert_eq!(pdf["parameters"][0]["schema"], json!({"type": "string"}));
752        assert_eq!(
753            pdf["responses"]["200"],
754            json!({"description": "PDF", "content": {"application/pdf": {}}})
755        );
756        assert!(
757            pdf.get("security").is_none(),
758            "public routes need no credentials"
759        );
760        assert!(pdf.get("requestBody").is_none());
761        assert_eq!(
762            spec["components"]["securitySchemes"],
763            json!({"bearer_auth": {"type": "http", "scheme": "bearer"}})
764        );
765        assert_eq!(spec["components"]["schemas"]["ApiLoan"]["type"], "object");
766    }
767
768    #[test]
769    fn legacy_routes_of_a_documented_service_use_their_fn_alias() {
770        let mut manifest = test_manifest();
771        manifest.custom_routes.push(CustomRouteDefinition {
772            method: "POST".into(),
773            path: "/complete/:id".into(),
774            handler: "complete_todo".into(),
775            posture: Some(crate::RoutePosture::Authenticated),
776            ..Default::default()
777        });
778        manifest.openapi = Some(crate::OpenApiOptions::default());
779        let spec = relative(&manifest);
780        let operation = &spec["paths"]["/_fn/complete_todo"]["post"];
781        assert_eq!(operation["operationId"], "complete_todo");
782        assert_eq!(operation["requestBody"]["required"], false);
783        assert_eq!(operation["responses"]["200"]["description"], "Success");
784        assert_eq!(
785            spec["paths"]["/todos"]["get"]["operationId"], "list_todos",
786            "CRUD operations are server-relative too"
787        );
788    }
789
790    #[test]
791    fn internal_routes_are_left_out_of_both_specs() {
792        let mut manifest = test_manifest();
793        manifest.custom_routes.push(CustomRouteDefinition {
794            method: "POST".into(),
795            path: "/reprice".into(),
796            handler: "reprice".into(),
797            posture: Some(crate::RoutePosture::Internal {
798                callers: vec!["pricing".into()],
799            }),
800            ..Default::default()
801        });
802        let absolute =
803            generate_openapi(&manifest, PathStyle::Absolute { tenant: "acme" }).expect("spec");
804        assert!(absolute["paths"]
805            .get("/svc/acme/test-service/_fn/reprice")
806            .is_none());
807        manifest.openapi = Some(crate::OpenApiOptions::default());
808        assert!(relative(&manifest)["paths"].get("/_fn/reprice").is_none());
809    }
810
811    #[test]
812    fn api_key_routes_require_the_api_key_scheme() {
813        let mut manifest = test_manifest();
814        manifest.openapi = Some(crate::OpenApiOptions::default());
815        manifest.custom_routes.push(CustomRouteDefinition {
816            method: "POST".into(),
817            path: "/ingest".into(),
818            handler: "ingest_book".into(),
819            posture: Some(crate::RoutePosture::ApiKey {
820                scope: "books:ingest".into(),
821            }),
822            ..Default::default()
823        });
824        let spec = relative(&manifest);
825        assert_eq!(
826            spec["paths"]["/_fn/ingest_book"]["post"]["security"],
827            json!([{"api_key": []}])
828        );
829        assert_eq!(
830            spec["components"]["securitySchemes"],
831            json!({"api_key": {"type": "apiKey", "in": "header", "name": "X-Api-Key"}})
832        );
833    }
834
835    #[test]
836    fn a_crud_operation_id_clash_is_a_generation_error() {
837        let mut manifest = test_manifest();
838        manifest.custom_routes.push(documented_route(
839            "GET",
840            "/x",
841            "list_todos",
842            None,
843            crate::RouteDoc::default(),
844        ));
845        assert_eq!(
846            generate_openapi(&manifest, PathStyle::ServerRelative { tenant: "acme" }),
847            Err(OpenApiError(
848                "operation id 'list_todos' is used more than once".into()
849            ))
850        );
851        assert!(
852            generate_openapi(&manifest, PathStyle::Absolute { tenant: "acme" }).is_ok(),
853            "the absolute spec is today's and never refused"
854        );
855    }
856
857    fn private_loans() -> crate::TableDefinition {
858        serde_json::from_value(json!({
859            "name": "loans", "managed": false,
860            "columns": [{"name": "id", "column_type": "uuid", "primary_key": true}]
861        }))
862        .expect("table")
863    }
864
865    #[test]
866    fn a_private_table_has_no_paths_schemas_or_operation_ids() {
867        let served = test_manifest();
868        let mut manifest = served.clone();
869        manifest.private_tables = vec![private_loans()];
870        manifest.custom_routes.push(documented_route(
871            "GET",
872            "/loans",
873            "list_loans",
874            None,
875            crate::RouteDoc::default(),
876        ));
877        for style in [
878            PathStyle::Absolute { tenant: "acme" },
879            PathStyle::ServerRelative { tenant: "acme" },
880        ] {
881            let spec = generate_openapi(&manifest, style).expect("list_loans is the route's alone");
882            let text = spec.to_string();
883            for absent in [
884                "\"Loans\"",
885                "LoansCreate",
886                "create_loan",
887                "/loans/{id}",
888                "/loans/:id",
889            ] {
890                assert!(!text.contains(absent), "{absent} in {text}");
891            }
892            let mut without = manifest.clone();
893            without.private_tables.clear();
894            assert_eq!(spec, generate_openapi(&without, style).expect("spec"));
895        }
896
897        let only_private = ServiceManifest {
898            tables: vec![],
899            ..manifest.clone()
900        };
901        let undeclared = ServiceManifest {
902            private_tables: vec![],
903            ..only_private.clone()
904        };
905        for style in [
906            PathStyle::Absolute { tenant: "acme" },
907            PathStyle::ServerRelative { tenant: "acme" },
908        ] {
909            assert_eq!(
910                generate_openapi(&only_private, style),
911                generate_openapi(&undeclared, style)
912            );
913        }
914    }
915}