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 ..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}