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 op.as_object_mut().unwrap().insert(
413 "requestBody".into(),
414 json!({
415 "required": true,
416 "content": { "application/json": { "schema": schema_val } }
417 }),
418 );
419 }
420
421 let method_key = method_str(rt.method);
422 entry.as_object_mut().unwrap().insert(method_key.into(), op);
423 }
424
425 let mut info_obj = Map::new();
427 info_obj.insert(
428 "title".into(),
429 Value::String(info.title.clone().into_owned()),
430 );
431 info_obj.insert(
432 "version".into(),
433 Value::String(info.version.clone().into_owned()),
434 );
435 if let Some(d) = &info.description {
436 info_obj.insert("description".into(), Value::String(d.clone().into_owned()));
437 }
438 if let Some(t) = &info.terms_of_service {
439 info_obj.insert(
440 "termsOfService".into(),
441 Value::String(t.clone().into_owned()),
442 );
443 }
444 let mut contact = Map::new();
445 if let Some(n) = &info.contact_name {
446 contact.insert("name".into(), Value::String(n.clone().into_owned()));
447 }
448 if let Some(u) = &info.contact_url {
449 contact.insert("url".into(), Value::String(u.clone().into_owned()));
450 }
451 if let Some(e) = &info.contact_email {
452 contact.insert("email".into(), Value::String(e.clone().into_owned()));
453 }
454 if !contact.is_empty() {
455 info_obj.insert("contact".into(), Value::Object(contact));
456 }
457 let mut license = Map::new();
458 if let Some(n) = &info.license_name {
459 license.insert("name".into(), Value::String(n.clone().into_owned()));
460 }
461 if let Some(u) = &info.license_url {
462 license.insert("url".into(), Value::String(u.clone().into_owned()));
463 }
464 if !license.is_empty() {
465 info_obj.insert("license".into(), Value::Object(license));
466 }
467
468 let servers: Vec<Value> = info
469 .servers
470 .iter()
471 .map(|(url, desc)| {
472 let mut m = Map::new();
473 m.insert("url".into(), Value::String(url.clone().into_owned()));
474 if !desc.is_empty() {
475 m.insert(
476 "description".into(),
477 Value::String(desc.clone().into_owned()),
478 );
479 }
480 Value::Object(m)
481 })
482 .collect();
483
484 let tags_section: Vec<Value> = info
485 .tag_descriptions
486 .iter()
487 .map(|(name, desc)| json!({ "name": name.as_ref(), "description": desc.as_ref() }))
488 .collect();
489
490 let mut components_obj = Map::new();
491 components_obj.insert("schemas".into(), Value::Object(components));
492 if !info.security_schemes.is_empty() {
493 let mut schemes: BTreeMap<String, Value> = BTreeMap::new();
494 for (name, sch) in &info.security_schemes {
495 schemes.insert(name.clone().into_owned(), sch.to_value());
496 }
497 components_obj.insert(
498 "securitySchemes".into(),
499 Value::Object(schemes.into_iter().collect()),
500 );
501 }
502
503 let mut doc = Map::new();
504 doc.insert("openapi".into(), Value::String(OPENAPI_VERSION.into()));
505 doc.insert("info".into(), Value::Object(info_obj));
506 if !servers.is_empty() {
507 doc.insert("servers".into(), Value::Array(servers));
508 }
509 if !tags_section.is_empty() {
510 doc.insert("tags".into(), Value::Array(tags_section));
511 }
512 doc.insert("paths".into(), Value::Object(paths));
513 doc.insert("components".into(), Value::Object(components_obj));
514 Value::Object(doc)
515}
516
517fn problem_details_schema() -> Value {
518 json!({
519 "type": "object",
520 "description": "RFC 7807 ProblemDetails",
521 "required": ["type", "title", "status", "detail"],
522 "properties": {
523 "type": { "type": "string" },
524 "title": { "type": "string" },
525 "status": { "type": "integer", "minimum": 100, "maximum": 599 },
526 "detail": { "type": "string" },
527 "errors": {
528 "type": "array",
529 "items": {
530 "type": "object",
531 "required": ["field", "code", "message"],
532 "properties": {
533 "field": { "type": "string" },
534 "code": { "type": "string" },
535 "message": { "type": "string" }
536 }
537 }
538 }
539 }
540 })
541}
542
543fn default_success(m: HttpMethod) -> u16 {
544 match m {
545 HttpMethod::POST => 201,
546 HttpMethod::DELETE => 204,
547 _ => 200,
548 }
549}
550
551fn default_tags_from_path(p: &'static str) -> Vec<&'static str> {
552 let seg = p.trim_start_matches('/').split('/').next().unwrap_or("");
555 if seg.is_empty() || seg.starts_with(':') {
556 Vec::new()
557 } else {
558 vec![seg]
559 }
560}
561
562fn harvest_definitions(schema: &mut Value, components: &mut Map<String, Value>) {
565 let Value::Object(map) = schema else { return };
566 if let Some(Value::Object(defs)) = map.remove("definitions") {
567 for (k, mut v) in defs {
568 rewrite_refs(&mut v);
569 components.entry(k).or_insert(v);
570 }
571 }
572}
573
574fn rewrite_refs(v: &mut Value) {
576 match v {
577 Value::Object(map) => {
578 if let Some(Value::String(s)) = map.get_mut("$ref") {
579 if let Some(name) = s.strip_prefix("#/definitions/") {
580 *s = format!("#/components/schemas/{name}");
581 }
582 }
583 for (_, child) in map.iter_mut() {
584 rewrite_refs(child);
585 }
586 }
587 Value::Array(arr) => {
588 for child in arr {
589 rewrite_refs(child);
590 }
591 }
592 _ => {}
593 }
594}
595
596#[inline]
597fn method_str(m: HttpMethod) -> &'static str {
598 match m {
599 HttpMethod::GET => "get",
600 HttpMethod::POST => "post",
601 HttpMethod::PUT => "put",
602 HttpMethod::DELETE => "delete",
603 HttpMethod::PATCH => "patch",
604 }
605}
606
607fn axum_to_openapi_path(p: &str) -> String {
608 let p = if p.len() > 1 {
613 p.trim_end_matches('/')
614 } else {
615 p
616 };
617 let mut out = String::with_capacity(p.len() + 4);
618 let mut chars = p.chars().peekable();
619 while let Some(c) = chars.next() {
620 if c == ':' {
621 out.push('{');
622 while let Some(&n) = chars.peek() {
623 if n == '/' {
624 break;
625 }
626 out.push(n);
627 chars.next();
628 }
629 out.push('}');
630 } else {
631 out.push(c);
632 }
633 }
634 out
635}
636
637pub const SWAGGER_UI_HTML: &str = r##"<!doctype html>
639<html lang="en">
640<head>
641 <meta charset="utf-8" />
642 <title>arcly-http — API docs</title>
643 <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
644</head>
645<body>
646 <div id="swagger-ui"></div>
647 <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
648 <script>
649 window.onload = () => {
650 window.ui = SwaggerUIBundle({
651 url: '/openapi.json',
652 dom_id: '#swagger-ui',
653 deepLinking: true,
654 persistAuthorization: true,
655 layout: 'BaseLayout',
656 });
657 };
658 </script>
659</body>
660</html>
661"##;
662
663#[cfg(test)]
664mod path_tests {
665 use super::axum_to_openapi_path;
666
667 #[test]
668 fn spec_paths_are_canonical() {
669 assert_eq!(axum_to_openapi_path("/products/"), "/products");
670 assert_eq!(axum_to_openapi_path("/products"), "/products");
671 assert_eq!(axum_to_openapi_path("/users/:id"), "/users/{id}");
672 assert_eq!(axum_to_openapi_path("/"), "/");
673 }
674}