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