1use std::collections::{BTreeMap, BTreeSet};
11
12use serde_json::{Value, json};
13
14use super::docs::Docs;
15use super::types::{Resolver, Subst};
16use super::{Gap, TypeTable};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum Role {
21 Body(String),
23 Query,
25 Path,
27 Header,
29 Ignored,
31}
32
33#[derive(Debug, Clone)]
35pub struct Extractors {
36 map: BTreeMap<String, Role>,
37}
38
39impl Default for Extractors {
40 fn default() -> Self {
41 Self::builtin()
42 }
43}
44
45impl Extractors {
46 pub fn builtin() -> Self {
49 let json = Role::Body("application/json".into());
50 let form = Role::Body("application/x-www-form-urlencoded".into());
51 let mut map = BTreeMap::new();
52 for (k, v) in [
53 ("Json", json.clone()),
54 ("Form", form),
55 ("Bytes", Role::Body("application/octet-stream".into())),
56 ("Query", Role::Query),
57 ("Path", Role::Path),
58 ("TypedHeader", Role::Header),
59 ("State", Role::Ignored),
61 ("Extension", Role::Ignored),
62 ("Data", Role::Ignored),
63 ("HeaderMap", Role::Ignored),
64 ("Request", Role::Ignored),
65 ("ConnectInfo", Role::Ignored),
66 ("Method", Role::Ignored),
67 ("Uri", Role::Ignored),
68 ] {
69 map.insert(k.to_string(), v);
70 }
71 Self { map }
72 }
73
74 pub fn insert(&mut self, wrapper: impl Into<String>, role: Role) {
76 self.map.insert(wrapper.into(), role);
77 }
78
79 pub fn role(&self, path: &str) -> Option<&Role> {
81 self.map.get(path.rsplit("::").next().unwrap_or(path))
82 }
83}
84
85#[derive(Debug, Clone, PartialEq)]
87pub struct Parameter {
88 pub name: String,
89 pub location: String,
91 pub required: bool,
92 pub schema: Value,
93}
94
95#[derive(Debug, Clone, PartialEq)]
97pub struct RequestBody {
98 pub content_type: String,
99 pub schema: Value,
100 pub required: bool,
101}
102
103#[derive(Debug, Clone, PartialEq)]
105pub struct Response {
106 pub content_type: String,
108 pub schema: Value,
109 pub headers: Vec<String>,
111 pub description: Option<String>,
113}
114
115impl Response {
116 pub fn json(schema: Value) -> Self {
118 Response {
119 content_type: "application/json".into(),
120 schema,
121 headers: Vec::new(),
122 description: None,
123 }
124 }
125
126 pub fn empty() -> Self {
128 Response {
129 content_type: String::new(),
130 schema: Value::Null,
131 headers: Vec::new(),
132 description: None,
133 }
134 }
135}
136
137#[derive(Debug, Clone, Default)]
139pub struct RouteShape {
140 pub summary: Option<String>,
142 pub request: Option<RequestBody>,
143 pub parameters: Vec<Parameter>,
144 pub responses: BTreeMap<String, Response>,
146 pub components: BTreeMap<String, Value>,
147 pub gaps: Vec<Gap>,
148 pub aux_resolved: BTreeSet<String>,
152}
153
154#[derive(Debug, Clone, Default)]
159pub struct Overrides {
160 pub request: Option<String>,
162 pub response: Option<String>,
165 pub status: Option<u16>,
167 pub params: Option<String>,
174 pub path_params: Option<String>,
178}
179
180impl Overrides {
181 fn is_detached_contract(&self) -> bool {
185 self.request.is_some()
186 || self.response.is_some()
187 || self.params.is_some()
188 || self.path_params.is_some()
189 }
190}
191
192#[derive(Debug, Default)]
199struct PathTypes {
200 names: Vec<String>,
201 typed: BTreeMap<String, Value>,
202}
203
204impl PathTypes {
205 fn new(route_path: &str) -> Self {
206 PathTypes {
207 names: path_placeholders(route_path),
208 typed: BTreeMap::new(),
209 }
210 }
211
212 fn claimed_by(&self, name: &str) -> Option<(String, bool)> {
218 if let Some(exact) = self.names.iter().find(|n| n.as_str() == name) {
219 return Some((exact.clone(), true));
220 }
221 let bare = unescape(name);
222 (!bare.is_empty())
223 .then(|| self.names.iter().find(|n| unescape(n) == bare))
224 .flatten()
225 .map(|n| (n.clone(), false))
226 }
227
228 fn claim(&mut self, placeholder: &str, schema: Value) {
230 self.typed.insert(placeholder.to_string(), schema);
231 }
232
233 fn claim_weak(&mut self, placeholder: &str, schema: Value) {
236 self.typed.entry(placeholder.to_string()).or_insert(schema);
237 }
238
239 fn schema(&self, placeholder: &str) -> Value {
241 self.typed
242 .get(placeholder)
243 .cloned()
244 .unwrap_or_else(|| json!({"type": "string"}))
245 }
246}
247
248fn unescape(name: &str) -> &str {
251 name.strip_prefix("r#")
252 .unwrap_or(name)
253 .trim_end_matches('_')
254}
255
256pub fn infer<'a>(
262 docs: impl Into<Docs<'a>>,
263 table: &'a TypeTable,
264 extractors: &Extractors,
265 handler_path: &str,
266 route_path: &str,
267 overrides: &Overrides,
268) -> Result<RouteShape, String> {
269 let mut r = Resolver::new(docs, table)?;
270 let mut paths = PathTypes::new(route_path);
271 let mut shape = match r.find_by_path(handler_path).cloned() {
272 Some(item) => inferred_shape(
273 &mut r,
274 extractors,
275 handler_path,
276 overrides,
277 &item,
278 &mut paths,
279 )?,
280 None if overrides.is_detached_contract() => {
281 detached_shape(&mut r, overrides, handler_path, &mut paths)?
282 }
283 None => {
284 return Err(format!(
285 "handler `{handler_path}` is not in this crate's rustdoc JSON — \
286 private items need `--document-private-items`, and a marker fn \
287 outside the lib target needs `request = \"...\"`, `response = \"...\"`, \
288 or `params = \"...\"`"
289 ));
290 }
291 };
292
293 resolve_path_params(&mut r, overrides, handler_path, &mut paths)?;
296 synthesize_path_params(&mut shape, &paths);
297
298 shape.components = r.components;
299 shape.gaps = r.gaps;
300 shape.aux_resolved = r.aux_resolved;
301 Ok(shape)
302}
303
304fn inferred_shape(
306 r: &mut Resolver,
307 extractors: &Extractors,
308 handler_path: &str,
309 overrides: &Overrides,
310 item: &Value,
311 paths: &mut PathTypes,
312) -> Result<RouteShape, String> {
313 let sig = item["inner"]["function"]["sig"].clone();
314 if sig.is_null() {
315 return Err(format!("`{handler_path}` is not a function"));
316 }
317
318 let subst = Subst::new();
319 let mut shape = RouteShape {
320 summary: item["docs"]
321 .as_str()
322 .filter(|d| !d.is_empty())
323 .map(|d| d.split("\n\n").next().unwrap_or(d).trim().to_string()),
324 ..RouteShape::default()
325 };
326 let placeholders = paths.names.clone();
327 let ctx = Ctx {
328 extractors,
329 placeholders: &placeholders,
330 at: handler_path,
331 subst: &subst,
332 };
333
334 for input in sig["inputs"].as_array().cloned().unwrap_or_default() {
335 let name = input[0].as_str().unwrap_or("").to_string();
336 classify_input(r, &ctx, &mut shape, &name, &input[1]);
337 }
338
339 resolve_output(r, &ctx, &mut shape, &sig["output"]);
340
341 apply_overrides(r, &mut shape, overrides, handler_path, paths);
343 Ok(shape)
344}
345
346fn detached_shape(
353 r: &mut Resolver,
354 o: &Overrides,
355 at: &str,
356 paths: &mut PathTypes,
357) -> Result<RouteShape, String> {
358 let named = |r: &mut Resolver, name: &str, role: &str| {
359 resolve_named_in(r, name, at).ok_or_else(|| {
360 format!(
361 "{role} type `{name}` for detached route `{at}` is not in \
362 this crate's rustdoc JSON"
363 )
364 })
365 };
366 let mut shape = RouteShape::default();
367
368 if let Some(name) = &o.request {
369 let schema = named(r, name, "request")?;
370 shape.request = Some(RequestBody {
371 content_type: "application/json".into(),
372 schema,
373 required: true,
374 });
375 }
376 if let Some(name) = &o.params {
377 let schema = named(r, name, "params")?;
378 if !expand_query_override(r, &mut shape, &schema, paths) {
379 return Err(format!(
380 "params type `{name}` for detached route `{at}` must be a \
381 struct with named fields"
382 ));
383 }
384 }
385 if let Some(name) = &o.response {
386 let schema = named(r, name, "response")?;
387 let status = o
388 .status
389 .map(|s| s.to_string())
390 .unwrap_or_else(|| "200".into());
391 shape.responses.insert(status, Response::json(schema));
392 }
393 Ok(shape)
394}
395
396fn expand_query_override(
406 r: &Resolver,
407 shape: &mut RouteShape,
408 schema: &Value,
409 paths: &mut PathTypes,
410) -> bool {
411 let object = deref(schema, &r.components);
412 let Some(props) = object.get("properties").and_then(|p| p.as_object()) else {
413 return false;
414 };
415 let required: Vec<&str> = object["required"]
416 .as_array()
417 .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
418 .unwrap_or_default();
419 shape.parameters.retain(|p| p.location != "query");
420 for (name, schema) in props {
421 if let Some((placeholder, exact)) = paths.claimed_by(name) {
422 match exact {
423 true => paths.claim(&placeholder, schema.clone()),
424 false => paths.claim_weak(&placeholder, schema.clone()),
425 }
426 continue;
427 }
428 shape.parameters.push(Parameter {
429 name: name.clone(),
430 location: "query".into(),
431 required: required.contains(&name.as_str()),
432 schema: schema.clone(),
433 });
434 }
435 true
436}
437
438fn resolve_path_params(
447 r: &mut Resolver,
448 o: &Overrides,
449 at: &str,
450 paths: &mut PathTypes,
451) -> Result<(), String> {
452 let Some(spec) = &o.path_params else {
453 return Ok(());
454 };
455 let missing = |ty: &str| {
456 format!(
457 "path_params type `{ty}` for route `{at}` is not in this crate's \
458 rustdoc JSON"
459 )
460 };
461 for entry in spec.split(',').map(str::trim).filter(|e| !e.is_empty()) {
462 let Some((name, ty)) = split_binding(entry) else {
463 let schema = resolve_named_in(r, entry, at).ok_or_else(|| missing(entry))?;
464 if !expand_path_struct(r, &schema, paths) {
465 return Err(format!(
466 "path_params type `{entry}` for route `{at}` must be a struct \
467 with named fields, or a `name: Type` binding"
468 ));
469 }
470 continue;
471 };
472 let Some((placeholder, _)) = paths.claimed_by(name) else {
473 return Err(format!(
474 "path_params names `{name}` for route `{at}`, which is not a \
475 `{{placeholder}}` in its path"
476 ));
477 };
478 let schema = resolve_named_in(r, ty, at).ok_or_else(|| missing(ty))?;
479 paths.claim(&placeholder, schema);
480 }
481 Ok(())
482}
483
484fn expand_path_struct(r: &Resolver, schema: &Value, paths: &mut PathTypes) -> bool {
488 let object = deref(schema, &r.components);
489 let Some(props) = object.get("properties").and_then(|p| p.as_object()) else {
490 return false;
491 };
492 for (name, schema) in props {
493 if let Some((placeholder, _)) = paths.claimed_by(name) {
494 paths.claim(&placeholder, schema.clone());
495 }
496 }
497 true
498}
499
500fn split_binding(entry: &str) -> Option<(&str, &str)> {
503 let bytes = entry.as_bytes();
504 let mut i = 0;
505 while i < bytes.len() {
506 if bytes[i] != b':' {
507 i += 1;
508 } else if bytes.get(i + 1) == Some(&b':') {
509 i += 2;
510 } else {
511 let (name, ty) = (entry[..i].trim(), entry[i + 1..].trim());
512 return (!name.is_empty() && !ty.is_empty()).then_some((name, ty));
513 }
514 }
515 None
516}
517
518fn synthesize_path_params(shape: &mut RouteShape, paths: &PathTypes) {
522 for name in &paths.names {
523 if let Some(declared) = shape
524 .parameters
525 .iter_mut()
526 .find(|p| p.location == "path" && &p.name == name)
527 {
528 if let Some(schema) = paths.typed.get(name) {
530 declared.schema = schema.clone();
531 }
532 continue;
533 }
534 shape.parameters.push(Parameter {
535 name: name.clone(),
536 location: "path".into(),
537 required: true,
538 schema: paths.schema(name),
539 });
540 }
541}
542
543struct Ctx<'c> {
545 extractors: &'c Extractors,
546 placeholders: &'c [String],
548 at: &'c str,
550 subst: &'c Subst,
551}
552
553fn classify_input(
554 r: &mut Resolver,
555 ctx: &Ctx,
556 shape: &mut RouteShape,
557 param_name: &str,
558 ty: &Value,
559) {
560 let Some(wrapper) = ty["resolved_path"]["path"].as_str() else {
561 return; };
563 let Some(role) = ctx.extractors.role(wrapper).cloned() else {
564 return;
565 };
566 let inner = first_arg(&ty["resolved_path"]);
567 let where_ = format!("{}({param_name})", ctx.at);
568
569 match role {
570 Role::Body(content_type) => {
571 let schema = match &inner {
572 Some(t) => r.resolve(t, ctx.subst, &where_),
573 None => json!({}),
574 };
575 shape.request = Some(RequestBody {
576 content_type,
577 schema,
578 required: true,
579 });
580 }
581 Role::Query => expand_params(r, ctx, shape, inner.as_ref(), "query", &where_),
582 Role::Path => expand_params(r, ctx, shape, inner.as_ref(), "path", &where_),
583 Role::Header => expand_params(r, ctx, shape, inner.as_ref(), "header", &where_),
584 Role::Ignored => {}
585 }
586}
587
588fn expand_params(
594 r: &mut Resolver,
595 ctx: &Ctx,
596 shape: &mut RouteShape,
597 inner: Option<&Value>,
598 location: &str,
599 at: &str,
600) {
601 let Some(inner) = inner else { return };
602 let placeholders = ctx.placeholders;
603
604 if let Some(elems) = inner.get("tuple").and_then(|t| t.as_array()) {
605 for (i, elem) in elems.iter().enumerate() {
606 let schema = r.resolve(elem, ctx.subst, at);
607 shape.parameters.push(Parameter {
608 name: placeholders
609 .get(i)
610 .cloned()
611 .unwrap_or_else(|| format!("p{i}")),
612 location: location.to_string(),
613 required: true,
614 schema,
615 });
616 }
617 return;
618 }
619
620 let resolved = r.resolve(inner, ctx.subst, at);
621 let object = deref(&resolved, &r.components);
622 if let Some(props) = object.get("properties").and_then(|p| p.as_object()) {
623 let required: Vec<&str> = object["required"]
624 .as_array()
625 .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
626 .unwrap_or_default();
627 for (name, schema) in props {
628 shape.parameters.push(Parameter {
629 name: name.clone(),
630 location: location.to_string(),
631 required: required.contains(&name.as_str()),
632 schema: schema.clone(),
633 });
634 }
635 return;
636 }
637
638 shape.parameters.push(Parameter {
640 name: placeholders
641 .first()
642 .cloned()
643 .unwrap_or_else(|| "param".into()),
644 location: location.to_string(),
645 required: true,
646 schema: resolved,
647 });
648}
649
650fn resolve_output(r: &mut Resolver, ctx: &Ctx, shape: &mut RouteShape, output: &Value) {
651 if output.is_null() {
652 shape.responses.insert("204".into(), Response::empty());
653 return;
654 }
655
656 if output["resolved_path"]["path"].as_str() == Some("Result") {
658 let args = all_args(&output["resolved_path"]);
659 if let Some(ok) = args.first() {
660 resolve_output(r, ctx, shape, ok);
661 }
662 if let Some(err) = args.get(1) {
663 let schema = unwrap_body(r, ctx, err);
664 shape
665 .responses
666 .insert("default".into(), Response::json(schema));
667 }
668 return;
669 }
670
671 if let Some(elems) = output.get("tuple").and_then(|t| t.as_array()) {
674 for elem in elems {
675 if elem["resolved_path"]["path"]
676 .as_str()
677 .and_then(|p| ctx.extractors.role(p))
678 .is_some_and(|role| matches!(role, Role::Body(_)))
679 {
680 resolve_output(r, ctx, shape, elem);
681 return;
682 }
683 }
684 }
685
686 let content_type = output["resolved_path"]["path"]
687 .as_str()
688 .and_then(|p| ctx.extractors.role(p))
689 .and_then(|role| match role {
690 Role::Body(ct) => Some(ct.clone()),
691 _ => None,
692 })
693 .unwrap_or_else(|| "application/json".into());
694
695 let schema = unwrap_body(r, ctx, output);
696 shape.responses.insert(
697 "200".into(),
698 Response {
699 content_type,
700 schema,
701 headers: Vec::new(),
702 description: None,
703 },
704 );
705}
706
707fn unwrap_body(r: &mut Resolver, ctx: &Ctx, ty: &Value) -> Value {
709 let is_body = ty["resolved_path"]["path"]
710 .as_str()
711 .and_then(|p| ctx.extractors.role(p))
712 .is_some_and(|role| matches!(role, Role::Body(_)));
713 let target = if is_body {
714 first_arg(&ty["resolved_path"]).unwrap_or_else(|| ty.clone())
715 } else {
716 ty.clone()
717 };
718 r.resolve(&target, ctx.subst, ctx.at)
719}
720
721fn apply_overrides(
723 r: &mut Resolver,
724 shape: &mut RouteShape,
725 o: &Overrides,
726 at: &str,
727 paths: &mut PathTypes,
728) {
729 if let Some(name) = &o.params
730 && let Some(schema) = resolve_named_in(r, name, at)
731 {
732 expand_query_override(r, shape, &schema, paths);
733 }
734 if let Some(name) = &o.request
735 && let Some(schema) = resolve_named_in(r, name, at)
736 {
737 shape.request = Some(RequestBody {
738 content_type: shape
739 .request
740 .as_ref()
741 .map(|b| b.content_type.clone())
742 .unwrap_or_else(|| "application/json".into()),
743 schema,
744 required: true,
745 });
746 }
747 if let Some(name) = &o.response
748 && let Some(schema) = resolve_named_in(r, name, at)
749 {
750 let status = o
751 .status
752 .map(|s| s.to_string())
753 .unwrap_or_else(|| "200".into());
754 r.gaps
756 .retain(|g| !matches!(g, Gap::Erased { at: a, .. } if a == at));
757 shape.responses.remove("200");
758 shape.responses.insert(status, Response::json(schema));
759 return;
760 }
761 if let Some(status) = o.status
763 && let Some(resp) = shape.responses.remove("200")
764 {
765 shape.responses.insert(status.to_string(), resp);
766 }
767}
768
769fn resolve_named_in(r: &mut Resolver, expr: &str, at: &str) -> Option<Value> {
770 let expr = expr.trim();
771 if let Some(inner) = expr.strip_prefix('[').and_then(|e| e.strip_suffix(']')) {
772 let items = resolve_named_in(r, inner, at)?;
773 return Some(json!({ "type": "array", "items": items }));
774 }
775 r.resolve_by_name(expr, at)
776}
777
778fn deref(schema: &Value, components: &BTreeMap<String, Value>) -> Value {
780 match schema["$ref"].as_str() {
781 Some(r) => r
782 .rsplit('/')
783 .next()
784 .and_then(|name| components.get(name))
785 .cloned()
786 .unwrap_or_else(|| schema.clone()),
787 None => schema.clone(),
788 }
789}
790
791fn path_placeholders(route_path: &str) -> Vec<String> {
793 let mut out = Vec::new();
794 let mut rest = route_path;
795 while let Some(open) = rest.find('{') {
796 let Some(close) = rest[open..].find('}') else {
797 break;
798 };
799 out.push(rest[open + 1..open + close].to_string());
800 rest = &rest[open + close + 1..];
801 }
802 out
803}
804
805fn first_arg(rp: &Value) -> Option<Value> {
806 all_args(rp).into_iter().next()
807}
808
809fn all_args(rp: &Value) -> Vec<Value> {
810 rp["args"]["angle_bracketed"]["args"]
811 .as_array()
812 .map(|args| args.iter().filter_map(|a| a.get("type").cloned()).collect())
813 .unwrap_or_default()
814}