1use actus_controller::{
47 DEFAULT_VERBS, ParamDefault, ParamSource, ParamType, RouteDef, Verb, routing,
48};
49use serde_json::{Map, Value, json};
50
51use crate::router::Router;
52
53#[derive(Clone, Debug)]
56pub struct Options {
57 pub title: String,
59 pub version: String,
61 pub description: Option<String>,
63 pub servers: Vec<ServerInfo>,
65}
66
67#[derive(Clone, Debug)]
70pub struct ServerInfo {
71 pub url: String,
73 pub description: Option<String>,
75}
76
77impl Options {
78 pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
81 Self {
82 title: title.into(),
83 version: version.into(),
84 description: None,
85 servers: Vec::new(),
86 }
87 }
88
89 pub fn description(mut self, description: impl Into<String>) -> Self {
91 self.description = Some(description.into());
92 self
93 }
94
95 pub fn server(
97 mut self,
98 url: impl Into<String>,
99 description: Option<impl Into<String>>,
100 ) -> Self {
101 self.servers.push(ServerInfo {
102 url: url.into(),
103 description: description.map(Into::into),
104 });
105 self
106 }
107}
108
109pub fn generate<F>(router: &Router, options: &Options, filter: F) -> Value
116where
117 F: Fn(&str) -> bool,
118{
119 let mut paths: Map<String, Value> = Map::new();
120
121 for (mount, route) in router.routes() {
122 if !filter(mount.as_str()) {
123 continue;
124 }
125 let path = compose_path(&mount, route.pattern);
126 let methods = methods_for(&route);
127 let entry = paths.entry(path.clone()).or_insert_with(|| json!({}));
128 let entry_obj = entry
129 .as_object_mut()
130 .expect("path entry is always a JSON object");
131 for method in methods {
132 entry_obj.insert(method.to_string(), build_operation(&path, method, &route));
137 }
138 }
139
140 let mut info = Map::new();
141 info.insert("title".into(), Value::String(options.title.clone()));
142 info.insert("version".into(), Value::String(options.version.clone()));
143 if let Some(d) = &options.description {
144 info.insert("description".into(), Value::String(d.clone()));
145 }
146
147 let mut spec = Map::new();
148 spec.insert("openapi".into(), Value::String("3.1.0".into()));
149 spec.insert("info".into(), Value::Object(info));
150 if !options.servers.is_empty() {
151 let servers: Vec<Value> = options
152 .servers
153 .iter()
154 .map(|s| {
155 let mut obj = Map::new();
156 obj.insert("url".into(), Value::String(s.url.clone()));
157 if let Some(d) = &s.description {
158 obj.insert("description".into(), Value::String(d.clone()));
159 }
160 Value::Object(obj)
161 })
162 .collect();
163 spec.insert("servers".into(), Value::Array(servers));
164 }
165 spec.insert("paths".into(), Value::Object(paths));
166 Value::Object(spec)
167}
168
169pub fn to_string_pretty(value: &Value) -> String {
172 serde_json::to_string_pretty(value).expect("serde_json::Value is always serializable")
173}
174
175fn compose_path(mount: &str, pattern: &str) -> String {
182 let mount = mount.trim_matches('/');
183 let pattern = pattern.trim_matches('/').replace("{...", "{");
184 match (mount.is_empty(), pattern.is_empty()) {
185 (true, true) => "/".to_string(),
186 (true, false) => format!("/{pattern}"),
187 (false, true) => format!("/{mount}"),
188 (false, false) => format!("/{mount}/{pattern}"),
189 }
190}
191
192fn methods_for(route: &RouteDef) -> Vec<&'static str> {
194 if std::ptr::eq(route.verb, DEFAULT_VERBS) {
199 return DEFAULT_VERBS.iter().map(verb_method).collect();
200 }
201 route.verb.iter().map(verb_method).collect()
202}
203
204fn verb_method(v: &Verb) -> &'static str {
205 match v {
206 Verb::GET => "get",
207 Verb::POST => "post",
208 Verb::PUT => "put",
209 Verb::DELETE => "delete",
210 Verb::PATCH => "patch",
211 Verb::HEAD => "head",
212 Verb::OPTIONS => "options",
213 }
214}
215
216fn build_operation(path: &str, method: &str, route: &RouteDef) -> Value {
217 let mut op = Map::new();
218 op.insert(
219 "operationId".into(),
220 Value::String(operation_id(path, method, route.handler)),
221 );
222
223 if let Some(doc) = route.doc {
224 let trimmed = doc.trim();
225 if !trimmed.is_empty() {
226 let summary = trimmed
229 .lines()
230 .find(|l| !l.trim().is_empty())
231 .map(str::trim)
232 .unwrap_or("");
233 if !summary.is_empty() {
234 op.insert("summary".into(), Value::String(summary.to_string()));
235 }
236 op.insert("description".into(), Value::String(trimmed.to_string()));
237 }
238 }
239
240 let (parameters, request_body) = split_params(route);
241 if !parameters.is_empty() {
242 op.insert("parameters".into(), Value::Array(parameters));
243 }
244 if let Some(body) = request_body {
245 op.insert("requestBody".into(), body);
246 }
247
248 op.insert(
253 "responses".into(),
254 json!({
255 "default": { "description": "Response from the handler." }
256 }),
257 );
258
259 Value::Object(op)
260}
261
262fn operation_id(path: &str, method: &str, handler: &str) -> String {
266 let sanitized: String = path
267 .chars()
268 .map(|c| match c {
269 '/' => '_',
270 '{' | '}' => '_',
271 other => other,
272 })
273 .collect();
274 let trimmed = sanitized.trim_matches('_');
275 if trimmed.is_empty() {
276 format!("{handler}_{method}")
277 } else {
278 let mut collapsed = String::with_capacity(trimmed.len());
281 let mut prev_us = false;
282 for c in trimmed.chars() {
283 if c == '_' {
284 if !prev_us {
285 collapsed.push('_');
286 }
287 prev_us = true;
288 } else {
289 collapsed.push(c);
290 prev_us = false;
291 }
292 }
293 format!("{collapsed}_{handler}_{method}")
294 }
295}
296
297fn split_params(route: &RouteDef) -> (Vec<Value>, Option<Value>) {
299 let mut params: Vec<Value> = Vec::new();
300 let mut body: Option<Value> = None;
301
302 let pattern_has_rest = route.pattern.contains("{...");
303
304 for p in route.params {
305 match p.source {
306 ParamSource::Path => {
307 let mut entry = Map::new();
308 entry.insert("name".into(), Value::String(p.name.to_string()));
309 entry.insert("in".into(), Value::String("path".into()));
310 entry.insert("required".into(), Value::Bool(true));
311 entry.insert("schema".into(), schema_for(p.ty, p.default.as_ref()));
312 if pattern_has_rest && matches!(p.ty, ParamType::String) {
314 if route
318 .pattern
319 .contains(&format!("{{...{name}}}", name = p.name))
320 {
321 entry.insert("x-actus-rest-param".into(), Value::Bool(true));
322 entry.insert(
323 "description".into(),
324 Value::String(
325 "Captures the trailing path (slashes included). Not natively \
326 representable in OpenAPI path templating; treated as a single \
327 segment here."
328 .into(),
329 ),
330 );
331 }
332 }
333 params.push(Value::Object(entry));
334 }
335 ParamSource::Query => {
336 let mut entry = Map::new();
337 entry.insert("name".into(), Value::String(p.name.to_string()));
338 entry.insert("in".into(), Value::String("query".into()));
339 entry.insert(
344 "required".into(),
345 Value::Bool(routing::param_is_required(p)),
346 );
347 entry.insert("schema".into(), schema_for(p.ty, p.default.as_ref()));
348 params.push(Value::Object(entry));
349 }
350 ParamSource::Body => {
351 let (content_type, schema): (&str, Value) = match p.ty {
355 ParamType::Json => ("application/json", json!({})),
356 ParamType::Bytes => (
357 "application/octet-stream",
358 json!({ "type": "string", "format": "binary" }),
359 ),
360 _ => continue, };
362 body = Some(json!({
363 "required": true,
364 "content": {
365 content_type: { "schema": schema }
366 }
367 }));
368 }
369 }
370 }
371
372 (params, body)
373}
374
375fn schema_for(ty: ParamType, default: Option<&ParamDefault>) -> Value {
378 let mut schema = base_schema(ty);
379 if let Some(d) = default {
380 let obj = schema
381 .as_object_mut()
382 .expect("base schema is always object");
383 obj.insert("default".into(), default_to_value(d));
384 }
385 schema
386}
387
388fn base_schema(ty: ParamType) -> Value {
389 match ty {
390 ParamType::String => json!({ "type": "string" }),
391 ParamType::Int => json!({ "type": "integer", "format": "int64" }),
392 ParamType::U64 => json!({ "type": "integer", "format": "int64", "minimum": 0 }),
393 ParamType::U32 => json!({ "type": "integer", "format": "int32", "minimum": 0 }),
394 ParamType::F64 => json!({ "type": "number" }),
395 ParamType::Bool => json!({ "type": "boolean" }),
396 ParamType::StringArray => json!({
397 "type": "array",
398 "items": { "type": "string" }
399 }),
400 ParamType::Json => json!({}), ParamType::Bytes => json!({ "type": "string", "format": "binary" }),
402 }
403}
404
405fn default_to_value(d: &ParamDefault) -> Value {
406 match d {
407 ParamDefault::String(s) => Value::String((*s).to_string()),
408 ParamDefault::Int(i) => Value::from(*i),
409 ParamDefault::U64(u) => Value::from(*u),
410 ParamDefault::U32(u) => Value::from(*u),
411 ParamDefault::F64(f) => Value::from(*f),
412 ParamDefault::Bool(b) => Value::from(*b),
413 }
414}
415
416#[cfg(test)]
420mod tests {
421 use super::*;
422 use crate::router::RouterBuilder;
423 use actus_controller::{Controller, ParamDef, Params};
424 use actus_reply::{Reply, WebError};
425 use std::sync::Arc;
426
427 struct Stub {
431 routes: &'static [RouteDef],
432 }
433
434 #[actus_controller::async_trait]
435 impl Controller for Stub {
436 async fn actus_dispatch(&self, _action: &str, _params: Params) -> Reply {
437 Err(WebError::NotFound)
438 }
439 fn __name(&self) -> &'static str {
440 "stub"
441 }
442 fn actus_describe_routes(&self) -> Vec<RouteDef> {
443 self.routes.to_vec()
444 }
445 }
446
447 fn build_router(mounts: &[(&str, &'static [RouteDef])]) -> Router {
448 let mut b = RouterBuilder::new();
449 for (mount, routes) in mounts {
450 b = b.add_route(mount, Arc::new(Stub { routes }));
451 }
452 b.build()
453 }
454
455 fn opts() -> Options {
456 Options::new("Test API", "1.0.0")
457 }
458
459 #[test]
460 fn shape_basics() {
461 static R: &[RouteDef] = &[RouteDef {
462 pattern: "",
463 handler_id: "handler_0",
464 handler: "list",
465 verb: &[Verb::GET],
466 params: &[],
467 doc: None,
468 }];
469 let router = build_router(&[("api/users", R)]);
470 let spec = generate(&router, &opts(), |_| true);
471
472 assert_eq!(spec["openapi"], "3.1.0");
473 assert_eq!(spec["info"]["title"], "Test API");
474 assert_eq!(spec["info"]["version"], "1.0.0");
475 assert!(spec["paths"]["/api/users"]["get"].is_object());
476 assert_eq!(
477 spec["paths"]["/api/users"]["get"]["operationId"],
478 "api_users_list_get"
479 );
480 assert!(spec["paths"]["/api/users"]["get"]["responses"]["default"].is_object());
482 }
483
484 #[test]
485 fn mount_filter_excludes_non_matching_controllers() {
486 static R: &[RouteDef] = &[RouteDef {
487 pattern: "",
488 handler_id: "handler_0",
489 handler: "h",
490 verb: &[Verb::GET],
491 params: &[],
492 doc: None,
493 }];
494 let router = build_router(&[("api/users", R), ("internal/debug", R)]);
495 let spec = generate(&router, &opts(), |mount| mount.starts_with("api/"));
496
497 assert!(spec["paths"]["/api/users"].is_object());
498 assert!(
499 spec["paths"]["/internal/debug"].is_null(),
500 "filter excluded"
501 );
502 }
503
504 #[test]
505 fn default_verbs_route_emits_both_get_and_post() {
506 static R: &[RouteDef] = &[RouteDef {
507 pattern: "",
508 handler_id: "handler_0",
509 handler: "either",
510 verb: DEFAULT_VERBS, params: &[],
512 doc: None,
513 }];
514 let router = build_router(&[("api/things", R)]);
515 let spec = generate(&router, &opts(), |_| true);
516
517 assert!(spec["paths"]["/api/things"]["get"].is_object());
518 assert!(spec["paths"]["/api/things"]["post"].is_object());
519 }
520
521 #[test]
522 fn the_spec_reports_a_bare_bool_required_because_the_router_enforces_it() {
523 static R: &[RouteDef] = &[RouteDef {
530 pattern: "",
531 handler_id: "handler_0",
532 handler: "del",
533 verb: &[Verb::POST],
534 params: &[
535 ParamDef {
536 name: "confirm",
537 ty: ParamType::Bool,
538 source: ParamSource::Query,
539 default: None,
540 },
541 ParamDef {
542 name: "at_period_end",
543 ty: ParamType::Bool,
544 source: ParamSource::Query,
545 default: Some(ParamDefault::Bool(true)),
546 },
547 ],
548 doc: None,
549 }];
550 let router = build_router(&[("api/cancel", R)]);
551 let spec = generate(&router, &opts(), |_| true);
552 let params = spec["paths"]["/api/cancel"]["post"]["parameters"]
553 .as_array()
554 .expect("parameters array");
555
556 let confirm = ¶ms[0];
557 assert_eq!(confirm["name"], "confirm");
558 assert_eq!(
559 confirm["required"], true,
560 "a bare `bool` is required — the spec must say what `resolve` does"
561 );
562
563 let at_period_end = ¶ms[1];
564 assert_eq!(at_period_end["name"], "at_period_end");
565 assert_eq!(at_period_end["required"], false);
566 assert_eq!(
567 at_period_end["schema"]["default"], true,
568 "and the declared default must be the one advertised"
569 );
570 }
571
572 #[test]
573 fn path_param_marked_required_and_query_default_marked_optional() {
574 static R: &[RouteDef] = &[RouteDef {
575 pattern: "{id}",
576 handler_id: "handler_0",
577 handler: "get",
578 verb: &[Verb::GET],
579 params: &[
580 ParamDef {
581 name: "id",
582 ty: ParamType::U64,
583 source: ParamSource::Path,
584 default: None,
585 },
586 ParamDef {
587 name: "expand",
588 ty: ParamType::Bool,
589 source: ParamSource::Query,
590 default: Some(ParamDefault::Bool(false)),
591 },
592 ParamDef {
593 name: "fields",
594 ty: ParamType::StringArray,
595 source: ParamSource::Query,
596 default: None,
597 },
598 ],
599 doc: None,
600 }];
601 let router = build_router(&[("api/users", R)]);
602 let spec = generate(&router, &opts(), |_| true);
603
604 let params = spec["paths"]["/api/users/{id}"]["get"]["parameters"]
605 .as_array()
606 .expect("parameters array");
607 let id = ¶ms[0];
609 assert_eq!(id["name"], "id");
610 assert_eq!(id["in"], "path");
611 assert_eq!(id["required"], true);
612 assert_eq!(id["schema"]["type"], "integer");
613 assert_eq!(id["schema"]["format"], "int64");
614 assert_eq!(id["schema"]["minimum"], 0);
615
616 let expand = ¶ms[1];
618 assert_eq!(expand["name"], "expand");
619 assert_eq!(expand["in"], "query");
620 assert_eq!(expand["required"], false);
621 assert_eq!(expand["schema"]["type"], "boolean");
622 assert_eq!(expand["schema"]["default"], false);
623
624 let fields = ¶ms[2];
626 assert_eq!(fields["required"], false);
627 assert_eq!(fields["schema"]["type"], "array");
628 assert_eq!(fields["schema"]["items"]["type"], "string");
629 }
630
631 #[test]
632 fn rest_param_is_marked_with_extension() {
633 static R: &[RouteDef] = &[RouteDef {
634 pattern: "{drive}/{...path}",
635 handler_id: "handler_0",
636 handler: "read",
637 verb: &[Verb::GET],
638 params: &[
639 ParamDef {
640 name: "drive",
641 ty: ParamType::String,
642 source: ParamSource::Path,
643 default: None,
644 },
645 ParamDef {
646 name: "path",
647 ty: ParamType::String,
648 source: ParamSource::Path,
649 default: None,
650 },
651 ],
652 doc: None,
653 }];
654 let router = build_router(&[("files", R)]);
655 let spec = generate(&router, &opts(), |_| true);
656
657 let op = &spec["paths"]["/files/{drive}/{path}"]["get"];
659 assert!(
660 op.is_object(),
661 "rest token stripped to /files/{{drive}}/{{path}}"
662 );
663
664 let params = op["parameters"].as_array().unwrap();
665 let drive = ¶ms[0];
666 let path = ¶ms[1];
667 assert!(drive["x-actus-rest-param"].is_null());
669 assert_eq!(path["x-actus-rest-param"], true);
671 assert!(
672 path["description"]
673 .as_str()
674 .unwrap_or("")
675 .contains("trailing path"),
676 );
677 }
678
679 #[test]
680 fn body_params_become_request_body() {
681 static R: &[RouteDef] = &[RouteDef {
682 pattern: "",
683 handler_id: "handler_0",
684 handler: "create",
685 verb: &[Verb::POST],
686 params: &[ParamDef {
687 name: "data",
688 ty: ParamType::Json,
689 source: ParamSource::Body,
690 default: None,
691 }],
692 doc: None,
693 }];
694 let router = build_router(&[("api/users", R)]);
695 let spec = generate(&router, &opts(), |_| true);
696
697 let body = &spec["paths"]["/api/users"]["post"]["requestBody"];
698 assert!(body.is_object());
699 assert_eq!(body["required"], true);
700 assert!(body["content"]["application/json"]["schema"].is_object());
701
702 static R2: &[RouteDef] = &[RouteDef {
704 pattern: "upload",
705 handler_id: "handler_0",
706 handler: "upload",
707 verb: &[Verb::POST],
708 params: &[ParamDef {
709 name: "body",
710 ty: ParamType::Bytes,
711 source: ParamSource::Body,
712 default: None,
713 }],
714 doc: None,
715 }];
716 let router = build_router(&[("api/files", R2)]);
717 let spec = generate(&router, &opts(), |_| true);
718 let body = &spec["paths"]["/api/files/upload"]["post"]["requestBody"];
719 assert!(body["content"]["application/octet-stream"]["schema"]["format"] == "binary");
720 }
721
722 #[test]
723 fn doc_becomes_summary_first_line_and_description_full() {
724 static R: &[RouteDef] = &[RouteDef {
725 pattern: "",
726 handler_id: "handler_0",
727 handler: "list",
728 verb: &[Verb::GET],
729 params: &[],
730 doc: Some(
731 " List items.\n\nThe long form: paginated, sorted by creation time.\nUse `?page=`.",
732 ),
733 }];
734 let router = build_router(&[("api/items", R)]);
735 let spec = generate(&router, &opts(), |_| true);
736 let op = &spec["paths"]["/api/items"]["get"];
737 assert_eq!(op["summary"], "List items.");
738 let desc = op["description"].as_str().unwrap();
740 assert!(desc.starts_with("List items."));
741 assert!(desc.contains("paginated"));
742 }
743
744 #[test]
745 fn options_servers_and_description_round_trip() {
746 static R: &[RouteDef] = &[RouteDef {
747 pattern: "",
748 handler_id: "handler_0",
749 handler: "h",
750 verb: &[Verb::GET],
751 params: &[],
752 doc: None,
753 }];
754 let router = build_router(&[("api", R)]);
755 let options = Options::new("My API", "2.1.0")
756 .description("Awesome")
757 .server("https://api.example.com", Some("prod"))
758 .server("https://staging.api.example.com", None::<&str>);
759 let spec = generate(&router, &options, |_| true);
760
761 assert_eq!(spec["info"]["description"], "Awesome");
762 let servers = spec["servers"].as_array().unwrap();
763 assert_eq!(servers.len(), 2);
764 assert_eq!(servers[0]["url"], "https://api.example.com");
765 assert_eq!(servers[0]["description"], "prod");
766 assert!(servers[1]["description"].is_null());
767 }
768
769 #[test]
770 fn to_string_pretty_is_deterministic_json() {
771 static R: &[RouteDef] = &[RouteDef {
772 pattern: "",
773 handler_id: "handler_0",
774 handler: "h",
775 verb: &[Verb::GET],
776 params: &[],
777 doc: None,
778 }];
779 let router = build_router(&[("api", R)]);
780 let spec = generate(&router, &opts(), |_| true);
781 let pretty = to_string_pretty(&spec);
782 assert!(pretty.starts_with("{\n"));
783 assert!(pretty.contains("\"openapi\": \"3.1.0\""));
784 }
785}