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 build_spec_filtered(
169 info: &OpenApiInfo,
170 allowed_controllers: Option<&std::collections::HashSet<&'static str>>,
171) -> Value {
172 let mut paths: Map<String, Value> = Map::new();
173 let mut components: Map<String, Value> = Map::new();
174
175 components.insert("ProblemDetails".into(), problem_details_schema());
177
178 for rt in inventory::iter::<&'static RouteDescriptor> {
179 if let Some(allowed) = allowed_controllers {
180 if !rt.controller.is_empty() && !allowed.contains(rt.controller) {
181 continue;
182 }
183 }
184 let oapi_path = axum_to_openapi_path(rt.path);
185 let entry = paths
186 .entry(oapi_path)
187 .or_insert_with(|| Value::Object(Map::new()));
188
189 let mut parameters: Vec<Value> = rt.spec.params.iter().map(|p| {
191 json!({
192 "name": p.name,
193 "in": match p.loc { ParamLoc::Path => "path", ParamLoc::Query => "query", ParamLoc::Header => "header" },
194 "required": p.required,
195 "schema": (p.schema)(),
196 })
197 }).collect();
198
199 if let Some(qfn) = rt.spec.query_schema {
200 let mut schema = qfn();
201 harvest_definitions(&mut schema, &mut components);
202 rewrite_refs(&mut schema);
203 if let Some(props) = schema.get("properties").and_then(Value::as_object) {
204 let required: std::collections::HashSet<&str> = schema
205 .get("required")
206 .and_then(Value::as_array)
207 .map(|a| a.iter().filter_map(Value::as_str).collect())
208 .unwrap_or_default();
209 for (name, prop_schema) in props {
210 parameters.push(json!({
211 "name": name,
212 "in": "query",
213 "required": required.contains(name.as_str()),
214 "schema": prop_schema,
215 }));
216 }
217 }
218 }
219
220 if rt.spec.idempotent_ttl_secs > 0 {
222 parameters.push(json!({
223 "name": "Idempotency-Key",
224 "in": "header",
225 "required": false,
226 "description": format!(
227 "Optional client-supplied key for safe retries. A repeat \
228 within {}s replays the stored response \
229 (`Idempotency-Replayed: true`); a concurrent duplicate \
230 gets 409.", rt.spec.idempotent_ttl_secs),
231 "schema": { "type": "string", "maxLength": 255 },
232 }));
233 }
234
235 let status = rt
237 .spec
238 .status_code
239 .unwrap_or_else(|| default_success(rt.method));
240 let success_desc = if status == 204 {
241 "No Content"
242 } else {
243 "Successful response"
244 };
245 let success = match (rt.spec.response_schema, status == 204) {
246 (Some(f), false) => {
247 let mut s = f();
248 harvest_definitions(&mut s, &mut components);
249 rewrite_refs(&mut s);
250 json!({
251 "description": success_desc,
252 "content": { "application/json": { "schema": s } }
253 })
254 }
255 _ => json!({ "description": success_desc }),
256 };
257
258 let mut success = success;
260 {
261 let mut headers = Map::new();
262 if rt.spec.idempotent_ttl_secs > 0 {
263 headers.insert("Idempotency-Replayed".into(), json!({
264 "description": "Present (true) when this response was replayed from the idempotency store.",
265 "schema": { "type": "string", "enum": ["true"] },
266 }));
267 }
268 if !rt.spec.sunset.is_empty() {
269 headers.insert(
270 "Deprecation".into(),
271 json!({
272 "description": "RFC 8594 — this endpoint is deprecated.",
273 "schema": { "type": "string", "enum": ["true"] },
274 }),
275 );
276 headers.insert(
277 "Sunset".into(),
278 json!({
279 "description": "RFC 8594 — date after which this endpoint may be removed.",
280 "schema": { "type": "string" },
281 }),
282 );
283 }
284 if !headers.is_empty() {
285 success["headers"] = Value::Object(headers);
286 }
287 }
288
289 let problem_ref = json!({
291 "description": "",
292 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } }
293 });
294 let mut responses = Map::new();
295 responses.insert(status.to_string(), success);
296 let forbidden_desc = if rt.spec.policies.is_empty() {
297 "Forbidden".to_string()
298 } else {
299 format!(
300 "Forbidden — requires ABAC policies: {} (default-deny; rules hot-reload at runtime)",
301 rt.spec.policies.join(", "),
302 )
303 };
304 let mut error_codes: Vec<(String, String)> = vec![
305 ("400".into(), "Bad request".into()),
306 ("401".into(), "Unauthorized".into()),
307 ("403".into(), forbidden_desc),
308 ("404".into(), "Not found".into()),
309 ("422".into(), "Validation failed".into()),
310 ("500".into(), "Internal error".into()),
311 ];
312 if rt.spec.idempotent_ttl_secs > 0 {
313 error_codes.push((
314 "409".into(),
315 "Conflict — a request with this Idempotency-Key is already in flight".into(),
316 ));
317 }
318 if rt.spec.timeout_ms > 0 {
319 error_codes.push((
320 "504".into(),
321 format!(
322 "Gateway Timeout — handler exceeded its {}ms deadline (work cancelled{})",
323 rt.spec.timeout_ms,
324 if rt.spec.transactional {
325 "; transaction rolled back"
326 } else {
327 ""
328 },
329 ),
330 ));
331 }
332 for (code, desc) in error_codes {
333 let mut entry = problem_ref.clone();
334 entry["description"] = Value::String(desc);
335 responses.insert(code, entry);
336 }
337
338 let tags: Vec<&'static str> = if rt.spec.tags.is_empty() {
339 default_tags_from_path(rt.path)
340 } else {
341 rt.spec.tags.to_vec()
342 };
343
344 let security: Vec<Value> = rt
345 .spec
346 .security
347 .iter()
348 .map(|s| {
349 let mut m = Map::new();
351 m.insert((*s).into(), Value::Array(vec![]));
352 Value::Object(m)
353 })
354 .collect();
355
356 let mut op = json!({
357 "summary": rt.spec.summary,
358 "operationId": rt.spec.operation_id,
359 "tags": tags,
360 "parameters": parameters,
361 "responses": responses,
362 "deprecated": rt.spec.deprecated || !rt.spec.sunset.is_empty(),
364 });
365
366 if !rt.spec.api_version.is_empty() {
369 op["x-api-version"] = Value::String(rt.spec.api_version.into());
370 }
371 if !rt.spec.sunset.is_empty() {
372 op["x-sunset"] = Value::String(rt.spec.sunset.into());
373 }
374 if rt.spec.idempotent_ttl_secs > 0 {
375 op["x-arcly-idempotent-ttl-secs"] = json!(rt.spec.idempotent_ttl_secs);
376 }
377 if !rt.spec.policies.is_empty() {
378 op["x-arcly-policies"] = json!(rt.spec.policies);
379 }
380 if !rt.spec.audit_action.is_empty() {
381 op["x-arcly-audit"] = json!({
382 "action": rt.spec.audit_action,
383 "resource": rt.spec.audit_resource,
384 });
385 }
386 if rt.spec.timeout_ms > 0 {
387 op["x-arcly-timeout-ms"] = json!(rt.spec.timeout_ms);
388 }
389 if rt.spec.transactional {
390 op["x-arcly-transactional"] = Value::Bool(true);
391 }
392 if !rt.spec.mask_fields.is_empty() {
393 op["x-arcly-masked-fields"] = json!(rt.spec.mask_fields);
394 }
395 if !rt.spec.description.is_empty() {
396 op["description"] = Value::String(rt.spec.description.to_string());
397 }
398 if !security.is_empty() {
399 op["security"] = Value::Array(security);
400 }
401
402 if rt.spec.has_body {
403 let schema_val = match rt.spec.body_schema {
404 Some(f) => {
405 let mut s = f();
406 harvest_definitions(&mut s, &mut components);
407 rewrite_refs(&mut s);
408 s
409 }
410 None => json!({ "type": "object" }),
411 };
412 let mut content = serde_json::Map::new();
413 content.insert(
414 rt.spec.consumes.to_string(),
415 json!({ "schema": schema_val }),
416 );
417 op.as_object_mut().unwrap().insert(
418 "requestBody".into(),
419 json!({ "required": true, "content": content }),
420 );
421 }
422
423 let method_key = method_str(rt.method);
424 entry.as_object_mut().unwrap().insert(method_key.into(), op);
425 }
426
427 let mut info_obj = Map::new();
429 info_obj.insert(
430 "title".into(),
431 Value::String(info.title.clone().into_owned()),
432 );
433 info_obj.insert(
434 "version".into(),
435 Value::String(info.version.clone().into_owned()),
436 );
437 if let Some(d) = &info.description {
438 info_obj.insert("description".into(), Value::String(d.clone().into_owned()));
439 }
440 if let Some(t) = &info.terms_of_service {
441 info_obj.insert(
442 "termsOfService".into(),
443 Value::String(t.clone().into_owned()),
444 );
445 }
446 let mut contact = Map::new();
447 if let Some(n) = &info.contact_name {
448 contact.insert("name".into(), Value::String(n.clone().into_owned()));
449 }
450 if let Some(u) = &info.contact_url {
451 contact.insert("url".into(), Value::String(u.clone().into_owned()));
452 }
453 if let Some(e) = &info.contact_email {
454 contact.insert("email".into(), Value::String(e.clone().into_owned()));
455 }
456 if !contact.is_empty() {
457 info_obj.insert("contact".into(), Value::Object(contact));
458 }
459 let mut license = Map::new();
460 if let Some(n) = &info.license_name {
461 license.insert("name".into(), Value::String(n.clone().into_owned()));
462 }
463 if let Some(u) = &info.license_url {
464 license.insert("url".into(), Value::String(u.clone().into_owned()));
465 }
466 if !license.is_empty() {
467 info_obj.insert("license".into(), Value::Object(license));
468 }
469
470 let servers: Vec<Value> = info
471 .servers
472 .iter()
473 .map(|(url, desc)| {
474 let mut m = Map::new();
475 m.insert("url".into(), Value::String(url.clone().into_owned()));
476 if !desc.is_empty() {
477 m.insert(
478 "description".into(),
479 Value::String(desc.clone().into_owned()),
480 );
481 }
482 Value::Object(m)
483 })
484 .collect();
485
486 let tags_section: Vec<Value> = info
487 .tag_descriptions
488 .iter()
489 .map(|(name, desc)| json!({ "name": name.as_ref(), "description": desc.as_ref() }))
490 .collect();
491
492 let mut components_obj = Map::new();
493 components_obj.insert("schemas".into(), Value::Object(components));
494 if !info.security_schemes.is_empty() {
495 let mut schemes: BTreeMap<String, Value> = BTreeMap::new();
496 for (name, sch) in &info.security_schemes {
497 schemes.insert(name.clone().into_owned(), sch.to_value());
498 }
499 components_obj.insert(
500 "securitySchemes".into(),
501 Value::Object(schemes.into_iter().collect()),
502 );
503 }
504
505 let mut doc = Map::new();
506 doc.insert("openapi".into(), Value::String(OPENAPI_VERSION.into()));
507 doc.insert("info".into(), Value::Object(info_obj));
508 if !servers.is_empty() {
509 doc.insert("servers".into(), Value::Array(servers));
510 }
511 if !tags_section.is_empty() {
512 doc.insert("tags".into(), Value::Array(tags_section));
513 }
514 doc.insert("paths".into(), Value::Object(paths));
515 doc.insert("components".into(), Value::Object(components_obj));
516 Value::Object(doc)
517}
518
519fn problem_details_schema() -> Value {
520 json!({
521 "type": "object",
522 "description": "RFC 7807 ProblemDetails",
523 "required": ["type", "title", "status", "detail"],
524 "properties": {
525 "type": { "type": "string" },
526 "title": { "type": "string" },
527 "status": { "type": "integer", "minimum": 100, "maximum": 599 },
528 "detail": { "type": "string" },
529 "errors": {
530 "type": "array",
531 "items": {
532 "type": "object",
533 "required": ["field", "code", "message"],
534 "properties": {
535 "field": { "type": "string" },
536 "code": { "type": "string" },
537 "message": { "type": "string" }
538 }
539 }
540 }
541 }
542 })
543}
544
545fn default_success(m: HttpMethod) -> u16 {
546 match m {
547 HttpMethod::POST => 201,
548 HttpMethod::DELETE => 204,
549 _ => 200,
550 }
551}
552
553fn default_tags_from_path(p: &'static str) -> Vec<&'static str> {
554 let seg = p.trim_start_matches('/').split('/').next().unwrap_or("");
557 if seg.is_empty() || seg.starts_with(':') {
558 Vec::new()
559 } else {
560 vec![seg]
561 }
562}
563
564fn harvest_definitions(schema: &mut Value, components: &mut Map<String, Value>) {
567 let Value::Object(map) = schema else { return };
568 if let Some(Value::Object(defs)) = map.remove("definitions") {
569 for (k, mut v) in defs {
570 rewrite_refs(&mut v);
571 components.entry(k).or_insert(v);
572 }
573 }
574}
575
576fn rewrite_refs(v: &mut Value) {
578 match v {
579 Value::Object(map) => {
580 if let Some(Value::String(s)) = map.get_mut("$ref") {
581 if let Some(name) = s.strip_prefix("#/definitions/") {
582 *s = format!("#/components/schemas/{name}");
583 }
584 }
585 for (_, child) in map.iter_mut() {
586 rewrite_refs(child);
587 }
588 }
589 Value::Array(arr) => {
590 for child in arr {
591 rewrite_refs(child);
592 }
593 }
594 _ => {}
595 }
596}
597
598#[inline]
599fn method_str(m: HttpMethod) -> &'static str {
600 match m {
601 HttpMethod::GET => "get",
602 HttpMethod::POST => "post",
603 HttpMethod::PUT => "put",
604 HttpMethod::DELETE => "delete",
605 HttpMethod::PATCH => "patch",
606 }
607}
608
609fn axum_to_openapi_path(p: &str) -> String {
610 let p = if p.len() > 1 {
615 p.trim_end_matches('/')
616 } else {
617 p
618 };
619 let mut out = String::with_capacity(p.len() + 4);
620 let mut chars = p.chars().peekable();
621 while let Some(c) = chars.next() {
622 if c == ':' {
623 out.push('{');
624 while let Some(&n) = chars.peek() {
625 if n == '/' {
626 break;
627 }
628 out.push(n);
629 chars.next();
630 }
631 out.push('}');
632 } else {
633 out.push(c);
634 }
635 }
636 out
637}
638
639pub const SWAGGER_UI_HTML: &str = r##"<!doctype html>
641<html lang="en">
642<head>
643 <meta charset="utf-8" />
644 <title>arcly-http — API docs</title>
645 <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
646</head>
647<body>
648 <div id="swagger-ui"></div>
649 <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
650 <script>
651 window.onload = () => {
652 window.ui = SwaggerUIBundle({
653 url: '/openapi.json',
654 dom_id: '#swagger-ui',
655 deepLinking: true,
656 persistAuthorization: true,
657 layout: 'BaseLayout',
658 });
659 };
660 </script>
661</body>
662</html>
663"##;
664
665#[cfg(test)]
666mod path_tests {
667 use super::axum_to_openapi_path;
668
669 #[test]
670 fn spec_paths_are_canonical() {
671 assert_eq!(axum_to_openapi_path("/products/"), "/products");
672 assert_eq!(axum_to_openapi_path("/products"), "/products");
673 assert_eq!(axum_to_openapi_path("/users/:id"), "/users/{id}");
674 assert_eq!(axum_to_openapi_path("/"), "/");
675 }
676}