1use std::borrow::Cow;
9use std::collections::BTreeMap;
10
11use serde_json::{json, Map, Value};
12
13use crate::core::engine::{HttpMethod, ParamLoc, RouteDescriptor};
14
15const OPENAPI_VERSION: &str = "3.0.3";
16
17#[derive(Clone)]
23pub enum SecurityScheme {
24 Bearer { bearer_format: Option<&'static str> },
26 ApiKey {
28 location: ApiKeyIn,
29 name: &'static str,
30 },
31 Basic,
33}
34
35#[derive(Clone, Copy)]
36pub enum ApiKeyIn {
37 Header,
38 Query,
39 Cookie,
40}
41
42impl SecurityScheme {
43 fn to_value(&self) -> Value {
44 match self {
45 SecurityScheme::Bearer { bearer_format } => {
46 let mut v = json!({ "type": "http", "scheme": "bearer" });
47 if let Some(f) = bearer_format {
48 v.as_object_mut()
49 .unwrap()
50 .insert("bearerFormat".into(), Value::String((*f).into()));
51 }
52 v
53 }
54 SecurityScheme::ApiKey { location, name } => json!({
55 "type": "apiKey",
56 "in": match location { ApiKeyIn::Header => "header", ApiKeyIn::Query => "query", ApiKeyIn::Cookie => "cookie" },
57 "name": *name,
58 }),
59 SecurityScheme::Basic => json!({ "type": "http", "scheme": "basic" }),
60 }
61 }
62}
63
64#[derive(Clone, Default)]
67#[non_exhaustive]
68pub struct OpenApiInfo {
69 pub title: Cow<'static, str>,
70 pub version: Cow<'static, str>,
71 pub description: Option<Cow<'static, str>>,
72 pub terms_of_service: Option<Cow<'static, str>>,
73 pub contact_name: Option<Cow<'static, str>>,
74 pub contact_url: Option<Cow<'static, str>>,
75 pub contact_email: Option<Cow<'static, str>>,
76 pub license_name: Option<Cow<'static, str>>,
77 pub license_url: Option<Cow<'static, str>>,
78 pub servers: Vec<(Cow<'static, str>, Cow<'static, str>)>,
81 pub security_schemes: Vec<(Cow<'static, str>, SecurityScheme)>,
84 pub tag_descriptions: Vec<(Cow<'static, str>, Cow<'static, str>)>,
86}
87
88impl OpenApiInfo {
89 pub fn new(title: impl Into<Cow<'static, str>>, version: impl Into<Cow<'static, str>>) -> Self {
94 Self {
95 title: title.into(),
96 version: version.into(),
97 ..Default::default()
98 }
99 }
100 pub fn description(mut self, v: impl Into<Cow<'static, str>>) -> Self {
101 self.description = Some(v.into());
102 self
103 }
104 pub fn terms_of_service(mut self, v: impl Into<Cow<'static, str>>) -> Self {
105 self.terms_of_service = Some(v.into());
106 self
107 }
108 pub fn contact_name(mut self, v: impl Into<Cow<'static, str>>) -> Self {
109 self.contact_name = Some(v.into());
110 self
111 }
112 pub fn contact_url(mut self, v: impl Into<Cow<'static, str>>) -> Self {
113 self.contact_url = Some(v.into());
114 self
115 }
116 pub fn contact_email(mut self, v: impl Into<Cow<'static, str>>) -> Self {
117 self.contact_email = Some(v.into());
118 self
119 }
120 pub fn license(
121 mut self,
122 name: impl Into<Cow<'static, str>>,
123 url: impl Into<Cow<'static, str>>,
124 ) -> Self {
125 self.license_name = Some(name.into());
126 self.license_url = Some(url.into());
127 self
128 }
129 pub fn server(
131 mut self,
132 url: impl Into<Cow<'static, str>>,
133 description: impl Into<Cow<'static, str>>,
134 ) -> Self {
135 self.servers.push((url.into(), description.into()));
136 self
137 }
138 pub fn security_scheme(
140 mut self,
141 name: impl Into<Cow<'static, str>>,
142 scheme: SecurityScheme,
143 ) -> Self {
144 self.security_schemes.push((name.into(), scheme));
145 self
146 }
147 pub fn tag(
149 mut self,
150 name: impl Into<Cow<'static, str>>,
151 description: impl Into<Cow<'static, str>>,
152 ) -> Self {
153 self.tag_descriptions
154 .push((name.into(), description.into()));
155 self
156 }
157}
158
159pub fn build_spec(info: &OpenApiInfo) -> Value {
162 build_spec_filtered(info, None)
163}
164
165pub fn duplicate_operation_ids(spec: &Value) -> Vec<(String, Vec<String>)> {
178 let mut by_id: std::collections::BTreeMap<String, Vec<String>> = Default::default();
179 let Some(paths) = spec.get("paths").and_then(Value::as_object) else {
180 return Vec::new();
181 };
182 for (path, ops) in paths {
183 let Some(ops) = ops.as_object() else { continue };
184 for (method, op) in ops {
185 if let Some(id) = op.get("operationId").and_then(Value::as_str) {
186 by_id
187 .entry(id.to_owned())
188 .or_default()
189 .push(format!("{} {path}", method.to_uppercase()));
190 }
191 }
192 }
193 by_id.retain(|_, ops| ops.len() > 1);
194 by_id
195 .into_iter()
196 .map(|(id, mut ops)| {
197 ops.sort();
198 (id, ops)
199 })
200 .collect()
201}
202
203fn warn_on_duplicate_operation_ids(paths: &Map<String, Value>) {
204 let spec = json!({ "paths": Value::Object(paths.clone()) });
205 for (id, ops) in duplicate_operation_ids(&spec) {
206 tracing::warn!(
207 operation_id = %id,
208 operations = %ops.join(", "),
209 "duplicate operationId — the OpenAPI document is invalid and client \
210 generators will emit colliding method names; set operation_id(\"…\") \
211 on all but one of these routes",
212 );
213 }
214}
215
216pub fn build_spec_filtered(
220 info: &OpenApiInfo,
221 allowed_controllers: Option<&std::collections::HashSet<&'static str>>,
222) -> Value {
223 let mut paths: Map<String, Value> = Map::new();
224 let mut components: Map<String, Value> = Map::new();
225
226 components.insert("ProblemDetails".into(), problem_details_schema());
228
229 for rt in inventory::iter::<&'static RouteDescriptor> {
230 if let Some(allowed) = allowed_controllers {
231 if !rt.controller.is_empty() && !allowed.contains(rt.controller) {
232 continue;
233 }
234 }
235 let oapi_path = axum_to_openapi_path(rt.path);
236 let entry = paths
237 .entry(oapi_path)
238 .or_insert_with(|| Value::Object(Map::new()));
239
240 let mut parameters: Vec<Value> = rt.spec.params.iter().map(|p| {
242 json!({
243 "name": p.name,
244 "in": match p.loc { ParamLoc::Path => "path", ParamLoc::Query => "query", ParamLoc::Header => "header" },
245 "required": p.required,
246 "schema": (p.schema)(),
247 })
248 }).collect();
249
250 if let Some(qfn) = rt.spec.query_schema {
251 let mut schema = qfn();
252 harvest_definitions(&mut schema, &mut components);
253 rewrite_refs(&mut schema);
254 if let Some(props) = schema.get("properties").and_then(Value::as_object) {
255 let required: std::collections::HashSet<&str> = schema
256 .get("required")
257 .and_then(Value::as_array)
258 .map(|a| a.iter().filter_map(Value::as_str).collect())
259 .unwrap_or_default();
260 for (name, prop_schema) in props {
261 parameters.push(json!({
262 "name": name,
263 "in": "query",
264 "required": required.contains(name.as_str()),
265 "schema": prop_schema,
266 }));
267 }
268 }
269 }
270
271 if rt.spec.idempotent_ttl_secs > 0 {
273 parameters.push(json!({
274 "name": "Idempotency-Key",
275 "in": "header",
276 "required": false,
277 "description": format!(
278 "Optional client-supplied key for safe retries. A repeat \
279 within {}s replays the stored response \
280 (`Idempotency-Replayed: true`); a concurrent duplicate \
281 gets 409.", rt.spec.idempotent_ttl_secs),
282 "schema": { "type": "string", "maxLength": 255 },
283 }));
284 }
285
286 let status = rt
288 .spec
289 .status_code
290 .unwrap_or_else(|| default_success(rt.method));
291 let success_desc = if status == 204 {
292 "No Content"
293 } else {
294 "Successful response"
295 };
296 let success = match (rt.spec.response_schema, status == 204) {
297 (Some(f), false) => {
298 let mut s = f();
299 harvest_definitions(&mut s, &mut components);
300 rewrite_refs(&mut s);
301 json!({
302 "description": success_desc,
303 "content": { "application/json": { "schema": s } }
304 })
305 }
306 _ => json!({ "description": success_desc }),
307 };
308
309 let mut success = success;
311 {
312 let mut headers = Map::new();
313 if rt.spec.idempotent_ttl_secs > 0 {
314 headers.insert("Idempotency-Replayed".into(), json!({
315 "description": "Present (true) when this response was replayed from the idempotency store.",
316 "schema": { "type": "string", "enum": ["true"] },
317 }));
318 }
319 if !rt.spec.sunset.is_empty() {
320 headers.insert(
321 "Deprecation".into(),
322 json!({
323 "description": "RFC 8594 — this endpoint is deprecated.",
324 "schema": { "type": "string", "enum": ["true"] },
325 }),
326 );
327 headers.insert(
328 "Sunset".into(),
329 json!({
330 "description": "RFC 8594 — date after which this endpoint may be removed.",
331 "schema": { "type": "string" },
332 }),
333 );
334 }
335 if !headers.is_empty() {
336 success["headers"] = Value::Object(headers);
337 }
338 }
339
340 let problem_ref = json!({
342 "description": "",
343 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } }
344 });
345 let mut responses = Map::new();
346 responses.insert(status.to_string(), success);
347 let forbidden_desc = if rt.spec.policies.is_empty() {
348 "Forbidden".to_string()
349 } else {
350 format!(
351 "Forbidden — requires ABAC policies: {} (default-deny; rules hot-reload at runtime)",
352 rt.spec.policies.join(", "),
353 )
354 };
355 let mut error_codes: Vec<(String, String)> = vec![
356 ("400".into(), "Bad request".into()),
357 ("401".into(), "Unauthorized".into()),
358 ("403".into(), forbidden_desc),
359 ("404".into(), "Not found".into()),
360 ("422".into(), "Validation failed".into()),
361 ("500".into(), "Internal error".into()),
362 ];
363 if rt.spec.idempotent_ttl_secs > 0 {
364 error_codes.push((
365 "409".into(),
366 "Conflict — a request with this Idempotency-Key is already in flight".into(),
367 ));
368 }
369 if rt.spec.timeout_ms > 0 {
370 error_codes.push((
371 "504".into(),
372 format!(
373 "Gateway Timeout — handler exceeded its {}ms deadline (work cancelled{})",
374 rt.spec.timeout_ms,
375 if rt.spec.transactional {
376 "; transaction rolled back"
377 } else {
378 ""
379 },
380 ),
381 ));
382 }
383 for (code, desc) in error_codes {
384 let mut entry = problem_ref.clone();
385 entry["description"] = Value::String(desc);
386 responses.insert(code, entry);
387 }
388
389 let tags: Vec<&'static str> = if rt.spec.tags.is_empty() {
390 default_tags_from_path(rt.path)
391 } else {
392 rt.spec.tags.to_vec()
393 };
394
395 let security: Vec<Value> = rt
396 .spec
397 .security
398 .iter()
399 .map(|s| {
400 let mut m = Map::new();
402 m.insert((*s).into(), Value::Array(vec![]));
403 Value::Object(m)
404 })
405 .collect();
406
407 let mut op = json!({
408 "summary": rt.spec.summary,
409 "operationId": rt.spec.operation_id,
410 "tags": tags,
411 "parameters": parameters,
412 "responses": responses,
413 "deprecated": rt.spec.deprecated || !rt.spec.sunset.is_empty(),
415 });
416
417 if !rt.spec.api_version.is_empty() {
420 op["x-api-version"] = Value::String(rt.spec.api_version.into());
421 }
422 if !rt.spec.sunset.is_empty() {
423 op["x-sunset"] = Value::String(rt.spec.sunset.into());
424 }
425 if rt.spec.idempotent_ttl_secs > 0 {
426 op["x-arcly-idempotent-ttl-secs"] = json!(rt.spec.idempotent_ttl_secs);
427 }
428 if !rt.spec.policies.is_empty() {
429 op["x-arcly-policies"] = json!(rt.spec.policies);
430 }
431 if !rt.spec.audit_action.is_empty() {
432 op["x-arcly-audit"] = json!({
433 "action": rt.spec.audit_action,
434 "resource": rt.spec.audit_resource,
435 });
436 }
437 if rt.spec.timeout_ms > 0 {
438 op["x-arcly-timeout-ms"] = json!(rt.spec.timeout_ms);
439 }
440 if rt.spec.transactional {
441 op["x-arcly-transactional"] = Value::Bool(true);
442 }
443 if !rt.spec.mask_fields.is_empty() {
444 op["x-arcly-masked-fields"] = json!(rt.spec.mask_fields);
445 }
446 if !rt.spec.description.is_empty() {
447 op["description"] = Value::String(rt.spec.description.to_string());
448 }
449 if !security.is_empty() {
450 op["security"] = Value::Array(security);
451 }
452
453 if rt.spec.has_body {
454 let schema_val = match rt.spec.body_schema {
455 Some(f) => {
456 let mut s = f();
457 harvest_definitions(&mut s, &mut components);
458 rewrite_refs(&mut s);
459 s
460 }
461 None => json!({ "type": "object" }),
462 };
463 let mut content = serde_json::Map::new();
464 content.insert(
465 rt.spec.consumes.to_string(),
466 json!({ "schema": schema_val }),
467 );
468 op.as_object_mut().unwrap().insert(
469 "requestBody".into(),
470 json!({ "required": true, "content": content }),
471 );
472 }
473
474 let method_key = method_str(rt.method);
475 entry.as_object_mut().unwrap().insert(method_key.into(), op);
476 }
477
478 warn_on_duplicate_operation_ids(&paths);
479
480 let mut info_obj = Map::new();
482 info_obj.insert(
483 "title".into(),
484 Value::String(info.title.clone().into_owned()),
485 );
486 info_obj.insert(
487 "version".into(),
488 Value::String(info.version.clone().into_owned()),
489 );
490 if let Some(d) = &info.description {
491 info_obj.insert("description".into(), Value::String(d.clone().into_owned()));
492 }
493 if let Some(t) = &info.terms_of_service {
494 info_obj.insert(
495 "termsOfService".into(),
496 Value::String(t.clone().into_owned()),
497 );
498 }
499 let mut contact = Map::new();
500 if let Some(n) = &info.contact_name {
501 contact.insert("name".into(), Value::String(n.clone().into_owned()));
502 }
503 if let Some(u) = &info.contact_url {
504 contact.insert("url".into(), Value::String(u.clone().into_owned()));
505 }
506 if let Some(e) = &info.contact_email {
507 contact.insert("email".into(), Value::String(e.clone().into_owned()));
508 }
509 if !contact.is_empty() {
510 info_obj.insert("contact".into(), Value::Object(contact));
511 }
512 let mut license = Map::new();
513 if let Some(n) = &info.license_name {
514 license.insert("name".into(), Value::String(n.clone().into_owned()));
515 }
516 if let Some(u) = &info.license_url {
517 license.insert("url".into(), Value::String(u.clone().into_owned()));
518 }
519 if !license.is_empty() {
520 info_obj.insert("license".into(), Value::Object(license));
521 }
522
523 let servers: Vec<Value> = info
524 .servers
525 .iter()
526 .map(|(url, desc)| {
527 let mut m = Map::new();
528 m.insert("url".into(), Value::String(url.clone().into_owned()));
529 if !desc.is_empty() {
530 m.insert(
531 "description".into(),
532 Value::String(desc.clone().into_owned()),
533 );
534 }
535 Value::Object(m)
536 })
537 .collect();
538
539 let tags_section: Vec<Value> = info
540 .tag_descriptions
541 .iter()
542 .map(|(name, desc)| json!({ "name": name.as_ref(), "description": desc.as_ref() }))
543 .collect();
544
545 let mut components_obj = Map::new();
546 components_obj.insert("schemas".into(), Value::Object(components));
547 if !info.security_schemes.is_empty() {
548 let mut schemes: BTreeMap<String, Value> = BTreeMap::new();
549 for (name, sch) in &info.security_schemes {
550 schemes.insert(name.clone().into_owned(), sch.to_value());
551 }
552 components_obj.insert(
553 "securitySchemes".into(),
554 Value::Object(schemes.into_iter().collect()),
555 );
556 }
557
558 let mut doc = Map::new();
559 doc.insert("openapi".into(), Value::String(OPENAPI_VERSION.into()));
560 doc.insert("info".into(), Value::Object(info_obj));
561 if !servers.is_empty() {
562 doc.insert("servers".into(), Value::Array(servers));
563 }
564 if !tags_section.is_empty() {
565 doc.insert("tags".into(), Value::Array(tags_section));
566 }
567 doc.insert("paths".into(), Value::Object(paths));
568 doc.insert("components".into(), Value::Object(components_obj));
569 Value::Object(doc)
570}
571
572fn problem_details_schema() -> Value {
573 json!({
574 "type": "object",
575 "description": "RFC 7807 ProblemDetails",
576 "required": ["type", "title", "status", "detail"],
577 "properties": {
578 "type": { "type": "string" },
579 "title": { "type": "string" },
580 "status": { "type": "integer", "minimum": 100, "maximum": 599 },
581 "detail": { "type": "string" },
582 "errors": {
583 "type": "array",
584 "items": {
585 "type": "object",
586 "required": ["field", "code", "message"],
587 "properties": {
588 "field": { "type": "string" },
589 "code": { "type": "string" },
590 "message": { "type": "string" }
591 }
592 }
593 }
594 }
595 })
596}
597
598fn default_success(m: HttpMethod) -> u16 {
599 match m {
600 HttpMethod::POST => 201,
601 HttpMethod::DELETE => 204,
602 _ => 200,
603 }
604}
605
606fn default_tags_from_path(p: &'static str) -> Vec<&'static str> {
607 let seg = p.trim_start_matches('/').split('/').next().unwrap_or("");
610 if seg.is_empty() || seg.starts_with(':') {
611 Vec::new()
612 } else {
613 vec![seg]
614 }
615}
616
617fn harvest_definitions(schema: &mut Value, components: &mut Map<String, Value>) {
620 let Value::Object(map) = schema else { return };
621 if let Some(Value::Object(defs)) = map.remove("definitions") {
622 for (k, mut v) in defs {
623 rewrite_refs(&mut v);
624 components.entry(k).or_insert(v);
625 }
626 }
627}
628
629fn rewrite_refs(v: &mut Value) {
631 match v {
632 Value::Object(map) => {
633 if let Some(Value::String(s)) = map.get_mut("$ref") {
634 if let Some(name) = s.strip_prefix("#/definitions/") {
635 *s = format!("#/components/schemas/{name}");
636 }
637 }
638 for (_, child) in map.iter_mut() {
639 rewrite_refs(child);
640 }
641 }
642 Value::Array(arr) => {
643 for child in arr {
644 rewrite_refs(child);
645 }
646 }
647 _ => {}
648 }
649}
650
651#[inline]
652fn method_str(m: HttpMethod) -> &'static str {
653 match m {
654 HttpMethod::GET => "get",
655 HttpMethod::POST => "post",
656 HttpMethod::PUT => "put",
657 HttpMethod::DELETE => "delete",
658 HttpMethod::PATCH => "patch",
659 }
660}
661
662fn axum_to_openapi_path(p: &str) -> String {
663 let p = if p.len() > 1 {
668 p.trim_end_matches('/')
669 } else {
670 p
671 };
672 let mut out = String::with_capacity(p.len() + 4);
673 let mut chars = p.chars().peekable();
674 while let Some(c) = chars.next() {
675 if c == ':' {
676 out.push('{');
677 while let Some(&n) = chars.peek() {
678 if n == '/' {
679 break;
680 }
681 out.push(n);
682 chars.next();
683 }
684 out.push('}');
685 } else {
686 out.push(c);
687 }
688 }
689 out
690}
691
692pub const SWAGGER_UI_HTML: &str = r##"<!doctype html>
694<html lang="en">
695<head>
696 <meta charset="utf-8" />
697 <title>arcly-http — API docs</title>
698 <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
699</head>
700<body>
701 <div id="swagger-ui"></div>
702 <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
703 <script>
704 window.onload = () => {
705 window.ui = SwaggerUIBundle({
706 url: '/openapi.json',
707 dom_id: '#swagger-ui',
708 deepLinking: true,
709 persistAuthorization: true,
710 layout: 'BaseLayout',
711 });
712 };
713 </script>
714</body>
715</html>
716"##;
717
718#[cfg(test)]
719mod path_tests {
720 use super::axum_to_openapi_path;
721
722 #[test]
723 fn spec_paths_are_canonical() {
724 assert_eq!(axum_to_openapi_path("/products/"), "/products");
725 assert_eq!(axum_to_openapi_path("/products"), "/products");
726 assert_eq!(axum_to_openapi_path("/users/:id"), "/users/{id}");
727 assert_eq!(axum_to_openapi_path("/"), "/");
728 }
729}