1use std::collections::BTreeMap;
9
10use serde_json::{json, Map, Value};
11
12use crate::core::engine::{HttpMethod, ParamLoc, RouteDescriptor};
13
14const OPENAPI_VERSION: &str = "3.0.3";
15
16#[derive(Clone)]
22pub enum SecurityScheme {
23 Bearer { bearer_format: Option<&'static str> },
25 ApiKey {
27 location: ApiKeyIn,
28 name: &'static str,
29 },
30 Basic,
32}
33
34#[derive(Clone, Copy)]
35pub enum ApiKeyIn {
36 Header,
37 Query,
38 Cookie,
39}
40
41impl SecurityScheme {
42 fn to_value(&self) -> Value {
43 match self {
44 SecurityScheme::Bearer { bearer_format } => {
45 let mut v = json!({ "type": "http", "scheme": "bearer" });
46 if let Some(f) = bearer_format {
47 v.as_object_mut()
48 .unwrap()
49 .insert("bearerFormat".into(), Value::String((*f).into()));
50 }
51 v
52 }
53 SecurityScheme::ApiKey { location, name } => json!({
54 "type": "apiKey",
55 "in": match location { ApiKeyIn::Header => "header", ApiKeyIn::Query => "query", ApiKeyIn::Cookie => "cookie" },
56 "name": *name,
57 }),
58 SecurityScheme::Basic => json!({ "type": "http", "scheme": "basic" }),
59 }
60 }
61}
62
63#[derive(Clone, Default)]
66pub struct OpenApiInfo {
67 pub title: &'static str,
68 pub version: &'static str,
69 pub description: Option<&'static str>,
70 pub terms_of_service: Option<&'static str>,
71 pub contact_name: Option<&'static str>,
72 pub contact_url: Option<&'static str>,
73 pub contact_email: Option<&'static str>,
74 pub license_name: Option<&'static str>,
75 pub license_url: Option<&'static str>,
76 pub servers: &'static [(&'static str, &'static str)],
79 pub security_schemes: &'static [(&'static str, SecurityScheme)],
82 pub tag_descriptions: &'static [(&'static str, &'static str)],
84}
85
86pub fn build_spec(info: &OpenApiInfo) -> Value {
89 build_spec_filtered(info, None)
90}
91
92pub fn build_spec_filtered(
96 info: &OpenApiInfo,
97 allowed_controllers: Option<&std::collections::HashSet<&'static str>>,
98) -> Value {
99 let mut paths: Map<String, Value> = Map::new();
100 let mut components: Map<String, Value> = Map::new();
101
102 components.insert("ProblemDetails".into(), problem_details_schema());
104
105 for rt in inventory::iter::<&'static RouteDescriptor> {
106 if let Some(allowed) = allowed_controllers {
107 if !rt.controller.is_empty() && !allowed.contains(rt.controller) {
108 continue;
109 }
110 }
111 let oapi_path = axum_to_openapi_path(rt.path);
112 let entry = paths
113 .entry(oapi_path)
114 .or_insert_with(|| Value::Object(Map::new()));
115
116 let mut parameters: Vec<Value> = rt.spec.params.iter().map(|p| {
118 json!({
119 "name": p.name,
120 "in": match p.loc { ParamLoc::Path => "path", ParamLoc::Query => "query", ParamLoc::Header => "header" },
121 "required": p.required,
122 "schema": (p.schema)(),
123 })
124 }).collect();
125
126 if let Some(qfn) = rt.spec.query_schema {
127 let mut schema = qfn();
128 harvest_definitions(&mut schema, &mut components);
129 rewrite_refs(&mut schema);
130 if let Some(props) = schema.get("properties").and_then(Value::as_object) {
131 let required: std::collections::HashSet<&str> = schema
132 .get("required")
133 .and_then(Value::as_array)
134 .map(|a| a.iter().filter_map(Value::as_str).collect())
135 .unwrap_or_default();
136 for (name, prop_schema) in props {
137 parameters.push(json!({
138 "name": name,
139 "in": "query",
140 "required": required.contains(name.as_str()),
141 "schema": prop_schema,
142 }));
143 }
144 }
145 }
146
147 if rt.spec.idempotent_ttl_secs > 0 {
149 parameters.push(json!({
150 "name": "Idempotency-Key",
151 "in": "header",
152 "required": false,
153 "description": format!(
154 "Optional client-supplied key for safe retries. A repeat \
155 within {}s replays the stored response \
156 (`Idempotency-Replayed: true`); a concurrent duplicate \
157 gets 409.", rt.spec.idempotent_ttl_secs),
158 "schema": { "type": "string", "maxLength": 255 },
159 }));
160 }
161
162 let status = rt
164 .spec
165 .status_code
166 .unwrap_or_else(|| default_success(rt.method));
167 let success_desc = if status == 204 {
168 "No Content"
169 } else {
170 "Successful response"
171 };
172 let success = match (rt.spec.response_schema, status == 204) {
173 (Some(f), false) => {
174 let mut s = f();
175 harvest_definitions(&mut s, &mut components);
176 rewrite_refs(&mut s);
177 json!({
178 "description": success_desc,
179 "content": { "application/json": { "schema": s } }
180 })
181 }
182 _ => json!({ "description": success_desc }),
183 };
184
185 let mut success = success;
187 {
188 let mut headers = Map::new();
189 if rt.spec.idempotent_ttl_secs > 0 {
190 headers.insert("Idempotency-Replayed".into(), json!({
191 "description": "Present (true) when this response was replayed from the idempotency store.",
192 "schema": { "type": "string", "enum": ["true"] },
193 }));
194 }
195 if !rt.spec.sunset.is_empty() {
196 headers.insert(
197 "Deprecation".into(),
198 json!({
199 "description": "RFC 8594 — this endpoint is deprecated.",
200 "schema": { "type": "string", "enum": ["true"] },
201 }),
202 );
203 headers.insert(
204 "Sunset".into(),
205 json!({
206 "description": "RFC 8594 — date after which this endpoint may be removed.",
207 "schema": { "type": "string" },
208 }),
209 );
210 }
211 if !headers.is_empty() {
212 success["headers"] = Value::Object(headers);
213 }
214 }
215
216 let problem_ref = json!({
218 "description": "",
219 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } }
220 });
221 let mut responses = Map::new();
222 responses.insert(status.to_string(), success);
223 let forbidden_desc = if rt.spec.policies.is_empty() {
224 "Forbidden".to_string()
225 } else {
226 format!(
227 "Forbidden — requires ABAC policies: {} (default-deny; rules hot-reload at runtime)",
228 rt.spec.policies.join(", "),
229 )
230 };
231 let mut error_codes: Vec<(String, String)> = vec![
232 ("400".into(), "Bad request".into()),
233 ("401".into(), "Unauthorized".into()),
234 ("403".into(), forbidden_desc),
235 ("404".into(), "Not found".into()),
236 ("422".into(), "Validation failed".into()),
237 ("500".into(), "Internal error".into()),
238 ];
239 if rt.spec.idempotent_ttl_secs > 0 {
240 error_codes.push((
241 "409".into(),
242 "Conflict — a request with this Idempotency-Key is already in flight".into(),
243 ));
244 }
245 if rt.spec.timeout_ms > 0 {
246 error_codes.push((
247 "504".into(),
248 format!(
249 "Gateway Timeout — handler exceeded its {}ms deadline (work cancelled{})",
250 rt.spec.timeout_ms,
251 if rt.spec.transactional {
252 "; transaction rolled back"
253 } else {
254 ""
255 },
256 ),
257 ));
258 }
259 for (code, desc) in error_codes {
260 let mut entry = problem_ref.clone();
261 entry["description"] = Value::String(desc);
262 responses.insert(code, entry);
263 }
264
265 let tags: Vec<&'static str> = if rt.spec.tags.is_empty() {
266 default_tags_from_path(rt.path)
267 } else {
268 rt.spec.tags.to_vec()
269 };
270
271 let security: Vec<Value> = rt
272 .spec
273 .security
274 .iter()
275 .map(|s| {
276 let mut m = Map::new();
278 m.insert((*s).into(), Value::Array(vec![]));
279 Value::Object(m)
280 })
281 .collect();
282
283 let mut op = json!({
284 "summary": rt.spec.summary,
285 "operationId": rt.spec.operation_id,
286 "tags": tags,
287 "parameters": parameters,
288 "responses": responses,
289 "deprecated": rt.spec.deprecated || !rt.spec.sunset.is_empty(),
291 });
292
293 if !rt.spec.api_version.is_empty() {
296 op["x-api-version"] = Value::String(rt.spec.api_version.into());
297 }
298 if !rt.spec.sunset.is_empty() {
299 op["x-sunset"] = Value::String(rt.spec.sunset.into());
300 }
301 if rt.spec.idempotent_ttl_secs > 0 {
302 op["x-arcly-idempotent-ttl-secs"] = json!(rt.spec.idempotent_ttl_secs);
303 }
304 if !rt.spec.policies.is_empty() {
305 op["x-arcly-policies"] = json!(rt.spec.policies);
306 }
307 if !rt.spec.audit_action.is_empty() {
308 op["x-arcly-audit"] = json!({
309 "action": rt.spec.audit_action,
310 "resource": rt.spec.audit_resource,
311 });
312 }
313 if rt.spec.timeout_ms > 0 {
314 op["x-arcly-timeout-ms"] = json!(rt.spec.timeout_ms);
315 }
316 if rt.spec.transactional {
317 op["x-arcly-transactional"] = Value::Bool(true);
318 }
319 if !rt.spec.mask_fields.is_empty() {
320 op["x-arcly-masked-fields"] = json!(rt.spec.mask_fields);
321 }
322 if !rt.spec.description.is_empty() {
323 op["description"] = Value::String(rt.spec.description.to_string());
324 }
325 if !security.is_empty() {
326 op["security"] = Value::Array(security);
327 }
328
329 if rt.spec.has_body {
330 let schema_val = match rt.spec.body_schema {
331 Some(f) => {
332 let mut s = f();
333 harvest_definitions(&mut s, &mut components);
334 rewrite_refs(&mut s);
335 s
336 }
337 None => json!({ "type": "object" }),
338 };
339 op.as_object_mut().unwrap().insert(
340 "requestBody".into(),
341 json!({
342 "required": true,
343 "content": { "application/json": { "schema": schema_val } }
344 }),
345 );
346 }
347
348 let method_key = method_str(rt.method);
349 entry.as_object_mut().unwrap().insert(method_key.into(), op);
350 }
351
352 let mut info_obj = Map::new();
354 info_obj.insert("title".into(), Value::String(info.title.into()));
355 info_obj.insert("version".into(), Value::String(info.version.into()));
356 if let Some(d) = info.description {
357 info_obj.insert("description".into(), Value::String(d.into()));
358 }
359 if let Some(t) = info.terms_of_service {
360 info_obj.insert("termsOfService".into(), Value::String(t.into()));
361 }
362 let mut contact = Map::new();
363 if let Some(n) = info.contact_name {
364 contact.insert("name".into(), Value::String(n.into()));
365 }
366 if let Some(u) = info.contact_url {
367 contact.insert("url".into(), Value::String(u.into()));
368 }
369 if let Some(e) = info.contact_email {
370 contact.insert("email".into(), Value::String(e.into()));
371 }
372 if !contact.is_empty() {
373 info_obj.insert("contact".into(), Value::Object(contact));
374 }
375 let mut license = Map::new();
376 if let Some(n) = info.license_name {
377 license.insert("name".into(), Value::String(n.into()));
378 }
379 if let Some(u) = info.license_url {
380 license.insert("url".into(), Value::String(u.into()));
381 }
382 if !license.is_empty() {
383 info_obj.insert("license".into(), Value::Object(license));
384 }
385
386 let servers: Vec<Value> = info
387 .servers
388 .iter()
389 .map(|(url, desc)| {
390 let mut m = Map::new();
391 m.insert("url".into(), Value::String((*url).into()));
392 if !desc.is_empty() {
393 m.insert("description".into(), Value::String((*desc).into()));
394 }
395 Value::Object(m)
396 })
397 .collect();
398
399 let tags_section: Vec<Value> = info
400 .tag_descriptions
401 .iter()
402 .map(|(name, desc)| json!({ "name": *name, "description": *desc }))
403 .collect();
404
405 let mut components_obj = Map::new();
406 components_obj.insert("schemas".into(), Value::Object(components));
407 if !info.security_schemes.is_empty() {
408 let mut schemes: BTreeMap<String, Value> = BTreeMap::new();
409 for (name, sch) in info.security_schemes {
410 schemes.insert((*name).into(), sch.to_value());
411 }
412 components_obj.insert(
413 "securitySchemes".into(),
414 Value::Object(schemes.into_iter().collect()),
415 );
416 }
417
418 let mut doc = Map::new();
419 doc.insert("openapi".into(), Value::String(OPENAPI_VERSION.into()));
420 doc.insert("info".into(), Value::Object(info_obj));
421 if !servers.is_empty() {
422 doc.insert("servers".into(), Value::Array(servers));
423 }
424 if !tags_section.is_empty() {
425 doc.insert("tags".into(), Value::Array(tags_section));
426 }
427 doc.insert("paths".into(), Value::Object(paths));
428 doc.insert("components".into(), Value::Object(components_obj));
429 Value::Object(doc)
430}
431
432fn problem_details_schema() -> Value {
433 json!({
434 "type": "object",
435 "description": "RFC 7807 ProblemDetails",
436 "required": ["type", "title", "status", "detail"],
437 "properties": {
438 "type": { "type": "string" },
439 "title": { "type": "string" },
440 "status": { "type": "integer", "minimum": 100, "maximum": 599 },
441 "detail": { "type": "string" },
442 "errors": {
443 "type": "array",
444 "items": {
445 "type": "object",
446 "required": ["field", "code", "message"],
447 "properties": {
448 "field": { "type": "string" },
449 "code": { "type": "string" },
450 "message": { "type": "string" }
451 }
452 }
453 }
454 }
455 })
456}
457
458fn default_success(m: HttpMethod) -> u16 {
459 match m {
460 HttpMethod::POST => 201,
461 HttpMethod::DELETE => 204,
462 _ => 200,
463 }
464}
465
466fn default_tags_from_path(p: &'static str) -> Vec<&'static str> {
467 let seg = p.trim_start_matches('/').split('/').next().unwrap_or("");
470 if seg.is_empty() || seg.starts_with(':') {
471 Vec::new()
472 } else {
473 vec![seg]
474 }
475}
476
477fn harvest_definitions(schema: &mut Value, components: &mut Map<String, Value>) {
480 let Value::Object(map) = schema else { return };
481 if let Some(Value::Object(defs)) = map.remove("definitions") {
482 for (k, mut v) in defs {
483 rewrite_refs(&mut v);
484 components.entry(k).or_insert(v);
485 }
486 }
487}
488
489fn rewrite_refs(v: &mut Value) {
491 match v {
492 Value::Object(map) => {
493 if let Some(Value::String(s)) = map.get_mut("$ref") {
494 if let Some(name) = s.strip_prefix("#/definitions/") {
495 *s = format!("#/components/schemas/{name}");
496 }
497 }
498 for (_, child) in map.iter_mut() {
499 rewrite_refs(child);
500 }
501 }
502 Value::Array(arr) => {
503 for child in arr {
504 rewrite_refs(child);
505 }
506 }
507 _ => {}
508 }
509}
510
511#[inline]
512fn method_str(m: HttpMethod) -> &'static str {
513 match m {
514 HttpMethod::GET => "get",
515 HttpMethod::POST => "post",
516 HttpMethod::PUT => "put",
517 HttpMethod::DELETE => "delete",
518 HttpMethod::PATCH => "patch",
519 }
520}
521
522fn axum_to_openapi_path(p: &str) -> String {
523 let mut out = String::with_capacity(p.len() + 4);
524 let mut chars = p.chars().peekable();
525 while let Some(c) = chars.next() {
526 if c == ':' {
527 out.push('{');
528 while let Some(&n) = chars.peek() {
529 if n == '/' {
530 break;
531 }
532 out.push(n);
533 chars.next();
534 }
535 out.push('}');
536 } else {
537 out.push(c);
538 }
539 }
540 out
541}
542
543pub const SWAGGER_UI_HTML: &str = r##"<!doctype html>
545<html lang="en">
546<head>
547 <meta charset="utf-8" />
548 <title>arcly-http — API docs</title>
549 <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
550</head>
551<body>
552 <div id="swagger-ui"></div>
553 <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
554 <script>
555 window.onload = () => {
556 window.ui = SwaggerUIBundle({
557 url: '/openapi.json',
558 dom_id: '#swagger-ui',
559 deepLinking: true,
560 persistAuthorization: true,
561 layout: 'BaseLayout',
562 });
563 };
564 </script>
565</body>
566</html>
567"##;