1mod 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum PathStyle<'a> {
22 Absolute { tenant: &'a str },
25 ServerRelative { tenant: &'a str },
28}
29
30pub 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#[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
57pub 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
68pub 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
126fn 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
251fn 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 },
293 ColumnDefinition {
294 name: "title".into(),
295 column_type: ColumnType::Text,
296 primary_key: false,
297 nullable: false,
298 auto_generate: false,
299 default_value: None,
300 references: None,
301 on_delete: None,
302 unique: false,
303 validations: vec![],
304 },
305 ColumnDefinition {
306 name: "done".into(),
307 column_type: ColumnType::Boolean,
308 primary_key: false,
309 nullable: false,
310 auto_generate: false,
311 default_value: Some("false".into()),
312 references: None,
313 on_delete: None,
314 unique: false,
315 validations: vec![],
316 },
317 ],
318 indexes: vec![],
319 soft_delete: false,
320 owner_field: None,
321 auth_required: false,
322 permission_area: None,
323 owner_self_service: false,
324 hooks: None,
325 scoped_by: None,
326 ..Default::default()
327 }],
328 cells: vec![],
329 events: vec![],
330 subscriptions: vec![],
331 custom_routes: vec![],
332 mode: ServiceMode::Crud,
333 authorization: None,
334 on_migrate: None,
335 publishes: None,
336 required_platform_features: vec![],
337 graphql: false,
338 ..Default::default()
339 }
340 }
341
342 #[test]
343 fn test_generates_valid_openapi() {
344 let manifest = test_manifest();
345 let spec = generate_openapi(
346 &manifest,
347 PathStyle::Absolute {
348 tenant: "test-tenant",
349 },
350 )
351 .expect("spec");
352
353 assert_eq!(spec["openapi"], "3.1.0");
354 assert_eq!(spec["info"]["title"], "test-service");
355 assert_eq!(spec["info"]["version"], "1.0.0");
356 }
357
358 #[test]
359 fn test_generates_crud_paths() {
360 let manifest = test_manifest();
361 let spec =
362 generate_openapi(&manifest, PathStyle::Absolute { tenant: "acme" }).expect("spec");
363
364 let paths = spec["paths"].as_object().unwrap();
365 assert!(paths.contains_key("/svc/acme/test-service/todos"));
366 assert!(paths.contains_key("/svc/acme/test-service/todos/:id"));
367 }
368
369 #[test]
370 fn test_generates_schemas() {
371 let manifest = test_manifest();
372 let spec =
373 generate_openapi(&manifest, PathStyle::Absolute { tenant: "acme" }).expect("spec");
374
375 let schemas = spec["components"]["schemas"].as_object().unwrap();
376 assert!(schemas.contains_key("Todos"));
377 assert!(schemas.contains_key("TodosCreate"));
378 assert!(schemas.contains_key("Pagination"));
379 }
380
381 #[test]
382 fn test_create_schema_excludes_auto_generated() {
383 let manifest = test_manifest();
384 let spec =
385 generate_openapi(&manifest, PathStyle::Absolute { tenant: "acme" }).expect("spec");
386
387 let create_props = spec["components"]["schemas"]["TodosCreate"]["properties"]
388 .as_object()
389 .unwrap();
390 assert!(!create_props.contains_key("id"));
392 assert!(create_props.contains_key("title"));
393 }
394
395 #[test]
396 fn test_custom_routes() {
397 let mut manifest = test_manifest();
398 manifest.custom_routes.push(CustomRouteDefinition {
399 method: "POST".into(),
400 path: "/complete/:id".into(),
401 handler: "complete_todo".into(),
402 job: None,
403 posture: None,
404 forward_auth: false,
405 ..Default::default()
406 });
407
408 let spec =
409 generate_openapi(&manifest, PathStyle::Absolute { tenant: "acme" }).expect("spec");
410 let paths = spec["paths"].as_object().unwrap();
411 assert!(paths.contains_key("/svc/acme/test-service/_fn/complete_todo"));
412 }
413
414 #[test]
415 fn test_default_version() {
416 let mut manifest = test_manifest();
417 manifest.version = None;
418 let spec =
419 generate_openapi(&manifest, PathStyle::Absolute { tenant: "acme" }).expect("spec");
420 assert_eq!(spec["info"]["version"], "0.1.0");
421 }
422
423 #[test]
424 fn output_matches_the_golden_specs() {
425 for (manifest, golden) in [
426 (
427 include_str!("../../fixtures/starmoire_manifest.json"),
428 include_str!("../../fixtures/openapi/starmoire.json"),
429 ),
430 (
431 include_str!("../../fixtures/todo_manifest.json"),
432 include_str!("../../fixtures/openapi/todo.json"),
433 ),
434 (
435 include_str!("../../fixtures/blog_manifest.json"),
436 include_str!("../../fixtures/openapi/blog.json"),
437 ),
438 ] {
439 let manifest: ServiceManifest = serde_json::from_str(manifest).expect("manifest");
440 let golden: Value = serde_json::from_str(golden).expect("golden");
441 assert_eq!(
442 generate_openapi(&manifest, PathStyle::Absolute { tenant: "acme" }).expect("spec"),
443 golden,
444 "{}",
445 manifest.name
446 );
447 }
448 }
449
450 #[test]
451 fn test_local_slug() {
452 let manifest = test_manifest();
453 let spec =
454 generate_openapi(&manifest, PathStyle::Absolute { tenant: "local" }).expect("spec");
455
456 let paths = spec["paths"].as_object().unwrap();
457 assert!(paths.contains_key("/svc/local/test-service/todos"));
458 }
459
460 fn documented_route(
461 method: &str,
462 path: &str,
463 handler: &str,
464 posture: Option<crate::RoutePosture>,
465 doc: crate::RouteDoc,
466 ) -> CustomRouteDefinition {
467 CustomRouteDefinition {
468 method: method.into(),
469 path: path.into(),
470 handler: handler.into(),
471 posture,
472 doc: Some(doc),
473 ..Default::default()
474 }
475 }
476
477 fn relative(manifest: &ServiceManifest) -> Value {
478 generate_openapi(manifest, PathStyle::ServerRelative { tenant: "acme" }).expect("spec")
479 }
480
481 #[test]
482 fn documented_services_default_to_server_relative_paths() {
483 let mut manifest = test_manifest();
484 assert_eq!(
485 default_style(&manifest, "acme"),
486 PathStyle::Absolute { tenant: "acme" }
487 );
488 manifest.custom_routes.push(documented_route(
489 "GET",
490 "/x",
491 "x",
492 None,
493 crate::RouteDoc::default(),
494 ));
495 assert_eq!(
496 default_style(&manifest, "acme"),
497 PathStyle::ServerRelative { tenant: "acme" }
498 );
499 }
500
501 #[test]
502 fn server_relative_specs_default_their_server_and_use_3_1_nullability() {
503 let mut manifest = test_manifest();
504 manifest.tables[0].columns[1].nullable = true;
505 manifest.openapi = Some(crate::OpenApiOptions::default());
506 let spec = relative(&manifest);
507 assert_eq!(
508 spec["servers"],
509 json!([{"url": "{origin}/svc/{tenant}/test-service",
510 "variables": {"origin": {"default": ""}, "tenant": {"default": "acme"}}}])
511 );
512 let paths = spec["paths"].as_object().expect("paths");
513 assert!(paths.contains_key("/todos") && paths.contains_key("/todos/{id}"));
514 assert_eq!(
515 spec["components"]["schemas"]["Todos"]["properties"]["title"],
516 json!({"type": ["string", "null"]})
517 );
518 assert!(spec.get("tags").is_none());
519 assert!(spec["components"].get("securitySchemes").is_none());
520
521 manifest.openapi = Some(crate::OpenApiOptions {
522 title: Some("Notes".into()),
523 version: Some("2.0.0".into()),
524 description: Some(String::new()),
525 servers: vec![crate::ServerDoc {
526 url: "https://api.example.com/api".into(),
527 description: Some("Production".into()),
528 }],
529 tags: vec![crate::TagDoc {
530 name: "Notes Service".into(),
531 description: Some("Notes Service API".into()),
532 }],
533 security_scheme: None,
534 });
535 let spec = relative(&manifest);
536 assert_eq!(
537 spec["info"],
538 json!({"title": "Notes", "version": "2.0.0", "description": ""})
539 );
540 assert_eq!(
541 spec["servers"],
542 json!([{"url": "https://api.example.com/api", "description": "Production"}])
543 );
544 assert_eq!(
545 spec["tags"],
546 json!([{"name": "Notes Service", "description": "Notes Service API"}])
547 );
548 }
549
550 #[test]
551 fn documented_routes_carry_parameters_bodies_responses_and_security() {
552 use crate::{BodyDoc, MediaDoc, ResponseDoc, RouteDoc, RoutePosture, RoutingMode};
553 let mut manifest = test_manifest();
554 manifest.tables.clear();
555 manifest.routing = RoutingMode::Strict;
556 manifest.components.insert(
557 "ApiNote".into(),
558 json!({"type": "object", "properties": {"id": {"type": "string"}}}),
559 );
560 let doc = RouteDoc {
561 tags: vec!["note".into()],
562 summary: Some("Update a note".into()),
563 deprecated: true,
564 path_params: Some(json!({"type": "object", "required": ["note_id"],
565 "properties": {"note_id": {"type": "string", "description": "Note identifier"}}})),
566 query: Some(
567 json!({"type": "object", "required": ["force"], "properties": {
568 "force": {"type": "boolean"}, "page": {"type": ["integer", "null"], "format": "int32"}}}),
569 ),
570 request: Some(BodyDoc {
571 content_type: "application/json".into(),
572 schema: Some(json!({"$ref": "#/components/schemas/ApiNote"})),
573 required: true,
574 }),
575 responses: [
576 (
577 200,
578 ResponseDoc {
579 description: "Updated".into(),
580 content: vec![MediaDoc {
581 content_type: "application/json".into(),
582 schema: Some(json!({"$ref": "#/components/schemas/ApiNote"})),
583 }],
584 },
585 ),
586 (
587 403,
588 ResponseDoc {
589 description: "Forbidden".into(),
590 content: vec![],
591 },
592 ),
593 ]
594 .into(),
595 ..Default::default()
596 };
597 manifest.custom_routes = vec![
598 documented_route(
599 "PUT",
600 "/notes/{note_id}",
601 "update_note",
602 Some(RoutePosture::Permission {
603 area: "notes".into(),
604 operation: "edit".into(),
605 }),
606 doc,
607 ),
608 documented_route(
609 "GET",
610 "/notes/{note_id}/pdf",
611 "note_pdf",
612 Some(RoutePosture::Public {
613 reason: "shared link".into(),
614 }),
615 RouteDoc {
616 responses: [(
617 200,
618 ResponseDoc {
619 description: "PDF".into(),
620 content: vec![MediaDoc {
621 content_type: "application/pdf".into(),
622 schema: None,
623 }],
624 },
625 )]
626 .into(),
627 ..Default::default()
628 },
629 ),
630 ];
631 let spec = relative(&manifest);
632 let update = &spec["paths"]["/notes/{note_id}"]["put"];
633 assert_eq!(update["operationId"], "update_note");
634 assert_eq!(update["tags"], json!(["note"]));
635 assert_eq!(update["summary"], "Update a note");
636 assert_eq!(update["deprecated"], true);
637 assert_eq!(
638 update["parameters"],
639 json!([
640 {"name": "note_id", "in": "path", "required": true, "schema": {"type": "string"},
641 "description": "Note identifier"},
642 {"name": "force", "in": "query", "required": true, "schema": {"type": "boolean"}},
643 {"name": "page", "in": "query", "required": false,
644 "schema": {"type": ["integer", "null"], "format": "int32"}},
645 ])
646 );
647 assert_eq!(
648 update["requestBody"],
649 json!({"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiNote"}}}, "required": true})
650 );
651 assert_eq!(
652 update["responses"]["403"],
653 json!({"description": "Forbidden"})
654 );
655 assert_eq!(update["security"], json!([{"bearer_auth": []}]));
656
657 let pdf = &spec["paths"]["/notes/{note_id}/pdf"]["get"];
658 assert_eq!(pdf["tags"], json!(["test-service"]));
659 assert_eq!(pdf["parameters"][0]["schema"], json!({"type": "string"}));
660 assert_eq!(
661 pdf["responses"]["200"],
662 json!({"description": "PDF", "content": {"application/pdf": {}}})
663 );
664 assert!(
665 pdf.get("security").is_none(),
666 "public routes need no credentials"
667 );
668 assert!(pdf.get("requestBody").is_none());
669 assert_eq!(
670 spec["components"]["securitySchemes"],
671 json!({"bearer_auth": {"type": "http", "scheme": "bearer"}})
672 );
673 assert_eq!(spec["components"]["schemas"]["ApiNote"]["type"], "object");
674 }
675
676 #[test]
677 fn legacy_routes_of_a_documented_service_use_their_fn_alias() {
678 let mut manifest = test_manifest();
679 manifest.custom_routes.push(CustomRouteDefinition {
680 method: "POST".into(),
681 path: "/complete/:id".into(),
682 handler: "complete_todo".into(),
683 posture: Some(crate::RoutePosture::Authenticated),
684 ..Default::default()
685 });
686 manifest.openapi = Some(crate::OpenApiOptions::default());
687 let spec = relative(&manifest);
688 let operation = &spec["paths"]["/_fn/complete_todo"]["post"];
689 assert_eq!(operation["operationId"], "complete_todo");
690 assert_eq!(operation["requestBody"]["required"], false);
691 assert_eq!(operation["responses"]["200"]["description"], "Success");
692 assert_eq!(
693 spec["paths"]["/todos"]["get"]["operationId"], "list_todos",
694 "CRUD operations are server-relative too"
695 );
696 }
697
698 #[test]
699 fn internal_routes_are_left_out_of_both_specs() {
700 let mut manifest = test_manifest();
701 manifest.custom_routes.push(CustomRouteDefinition {
702 method: "POST".into(),
703 path: "/reprice".into(),
704 handler: "reprice".into(),
705 posture: Some(crate::RoutePosture::Internal {
706 callers: vec!["pricing".into()],
707 }),
708 ..Default::default()
709 });
710 let absolute =
711 generate_openapi(&manifest, PathStyle::Absolute { tenant: "acme" }).expect("spec");
712 assert!(absolute["paths"]
713 .get("/svc/acme/test-service/_fn/reprice")
714 .is_none());
715 manifest.openapi = Some(crate::OpenApiOptions::default());
716 assert!(relative(&manifest)["paths"].get("/_fn/reprice").is_none());
717 }
718
719 #[test]
720 fn api_key_routes_require_the_api_key_scheme() {
721 let mut manifest = test_manifest();
722 manifest.openapi = Some(crate::OpenApiOptions::default());
723 manifest.custom_routes.push(CustomRouteDefinition {
724 method: "POST".into(),
725 path: "/ingest".into(),
726 handler: "ingest_lead".into(),
727 posture: Some(crate::RoutePosture::ApiKey {
728 scope: "leads:ingest".into(),
729 }),
730 ..Default::default()
731 });
732 let spec = relative(&manifest);
733 assert_eq!(
734 spec["paths"]["/_fn/ingest_lead"]["post"]["security"],
735 json!([{"api_key": []}])
736 );
737 assert_eq!(
738 spec["components"]["securitySchemes"],
739 json!({"api_key": {"type": "apiKey", "in": "header", "name": "X-Api-Key"}})
740 );
741 }
742
743 #[test]
744 fn a_crud_operation_id_clash_is_a_generation_error() {
745 let mut manifest = test_manifest();
746 manifest.custom_routes.push(documented_route(
747 "GET",
748 "/x",
749 "list_todos",
750 None,
751 crate::RouteDoc::default(),
752 ));
753 assert_eq!(
754 generate_openapi(&manifest, PathStyle::ServerRelative { tenant: "acme" }),
755 Err(OpenApiError(
756 "operation id 'list_todos' is used more than once".into()
757 ))
758 );
759 assert!(
760 generate_openapi(&manifest, PathStyle::Absolute { tenant: "acme" }).is_ok(),
761 "the absolute spec is today's and never refused"
762 );
763 }
764
765 fn private_notes() -> crate::TableDefinition {
766 serde_json::from_value(json!({
767 "name": "notes", "managed": false,
768 "columns": [{"name": "id", "column_type": "uuid", "primary_key": true}]
769 }))
770 .expect("table")
771 }
772
773 #[test]
774 fn a_private_table_has_no_paths_schemas_or_operation_ids() {
775 let served = test_manifest();
776 let mut manifest = served.clone();
777 manifest.private_tables = vec![private_notes()];
778 manifest.custom_routes.push(documented_route(
779 "GET",
780 "/notes",
781 "list_notes",
782 None,
783 crate::RouteDoc::default(),
784 ));
785 for style in [
786 PathStyle::Absolute { tenant: "acme" },
787 PathStyle::ServerRelative { tenant: "acme" },
788 ] {
789 let spec = generate_openapi(&manifest, style).expect("list_notes is the route's alone");
790 let text = spec.to_string();
791 for absent in [
792 "\"Notes\"",
793 "NotesCreate",
794 "create_note",
795 "/notes/{id}",
796 "/notes/:id",
797 ] {
798 assert!(!text.contains(absent), "{absent} in {text}");
799 }
800 let mut without = manifest.clone();
801 without.private_tables.clear();
802 assert_eq!(spec, generate_openapi(&without, style).expect("spec"));
803 }
804
805 let only_private = ServiceManifest {
806 tables: vec![],
807 ..manifest.clone()
808 };
809 let undeclared = ServiceManifest {
810 private_tables: vec![],
811 ..only_private.clone()
812 };
813 for style in [
814 PathStyle::Absolute { tenant: "acme" },
815 PathStyle::ServerRelative { tenant: "acme" },
816 ] {
817 assert_eq!(
818 generate_openapi(&only_private, style),
819 generate_openapi(&undeclared, style)
820 );
821 }
822 }
823}