Skip to main content

soothfast_spec/schema/
route_sig.rs

1//! Handler signatures → the wire contract they implement.
2//!
3//! A handler's parameters and return type already state what it accepts and
4//! returns; restating that in attributes is the duplication this avoids. The
5//! framework's extractor wrappers (`Json<T>`, `Query<T>`, ...) are what mark
6//! which parameters are part of the contract and which are ambient context,
7//! so they are matched through an extensible table rather than a fixed list —
8//! an unlisted framework should cost a config line, not a code change.
9
10use 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/// What a handler parameter contributes to the wire contract.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum Role {
21    /// A request body of the given content type.
22    Body(String),
23    /// Query-string parameters, expanded from the wrapped type's fields.
24    Query,
25    /// Path parameters, matched against the route template's placeholders.
26    Path,
27    /// Header parameters.
28    Header,
29    /// Ambient context (state, connection info) — not part of the contract.
30    Ignored,
31}
32
33/// Maps extractor wrapper types to the role they play.
34#[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    /// Wrappers used by the common Rust web frameworks, matched on the final
47    /// path segment so `web::Json` and `Json` resolve alike.
48    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            // Ambient context: present in the signature, absent from the wire.
60            ("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    /// Register or override a wrapper, for frameworks outside the built-ins.
75    pub fn insert(&mut self, wrapper: impl Into<String>, role: Role) {
76        self.map.insert(wrapper.into(), role);
77    }
78
79    /// The role of a wrapper type, by its final path segment.
80    pub fn role(&self, path: &str) -> Option<&Role> {
81        self.map.get(path.rsplit("::").next().unwrap_or(path))
82    }
83}
84
85/// One query, path or header parameter.
86#[derive(Debug, Clone, PartialEq)]
87pub struct Parameter {
88    pub name: String,
89    /// OpenAPI `in`: `query`, `path` or `header`.
90    pub location: String,
91    pub required: bool,
92    pub schema: Value,
93}
94
95/// A request body and the content type it arrives as.
96#[derive(Debug, Clone, PartialEq)]
97pub struct RequestBody {
98    pub content_type: String,
99    pub schema: Value,
100    pub required: bool,
101}
102
103/// One response, keyed by status code in [`RouteShape`].
104#[derive(Debug, Clone, PartialEq)]
105pub struct Response {
106    /// Empty for a no-content response.
107    pub content_type: String,
108    pub schema: Value,
109    /// Response header names, rendered by dialects that can express them.
110    pub headers: Vec<String>,
111    /// Human description; dialects fall back to the status code's phrase.
112    pub description: Option<String>,
113}
114
115impl Response {
116    /// A JSON-bodied response with no headers or custom description.
117    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    /// A response with no body at all.
127    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/// Everything a handler signature says about its wire contract.
138#[derive(Debug, Clone, Default)]
139pub struct RouteShape {
140    /// First paragraph of the handler's doc comment, if it has one.
141    pub summary: Option<String>,
142    pub request: Option<RequestBody>,
143    pub parameters: Vec<Parameter>,
144    /// Status code (or `default`) → response.
145    pub responses: BTreeMap<String, Response>,
146    pub components: BTreeMap<String, Value>,
147    pub gaps: Vec<Gap>,
148    /// Canonical paths walked out of a workspace-local crate — resolved
149    /// rather than reported, and named only so a summary can say where the
150    /// shapes came from.
151    pub aux_resolved: BTreeSet<String>,
152}
153
154/// Attribute overrides for what inference cannot see.
155///
156/// `request` and `response` take a type name, or `[Type]` for a bare
157/// array of it.
158#[derive(Debug, Clone, Default)]
159pub struct Overrides {
160    /// Type name for the request body.
161    pub request: Option<String>,
162    /// Type name for the success response — the escape hatch for erased
163    /// returns, which carry only a trait bound.
164    pub response: Option<String>,
165    /// Success status code; defaults to 200, or 204 for a unit return.
166    pub status: Option<u16>,
167    /// Type whose fields flatten into query parameters — for marker fns
168    /// whose empty signature carries no `Query<T>` extractor to infer from.
169    ///
170    /// A field naming one of the path's `{placeholder}`s types that path
171    /// parameter instead of adding a query one — `r#type` and `type_` both
172    /// name `{type}`, since a URL template carries no Rust escapes.
173    pub params: Option<String>,
174    /// Types for the path's `{placeholder}`s, as either `"name: Type"`
175    /// bindings (comma-separated) or one struct name whose field names do
176    /// the same job. Placeholders nothing names stay open strings.
177    pub path_params: Option<String>,
178}
179
180impl Overrides {
181    /// Whether these overrides alone are enough to build a
182    /// [`detached_shape`] — a detached marker's whole contract is its
183    /// attributes, so any one of them (not just `response`) is sufficient.
184    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/// The route template's `{placeholder}`s and the schemas claimed for them.
193///
194/// Held apart from the shape's parameter list so path parameters can be
195/// appended in *template* order at the end, whatever order they were typed
196/// in — a struct's fields arrive sorted, and the URL's order is the one a
197/// reader checks against.
198#[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    /// The placeholder a name declares, and whether it spelled it exactly.
213    ///
214    /// Rust spells a keyword field `r#type` or `type_` where the URL template
215    /// only ever says `{type}`, so those escapes are stripped for a second
216    /// pass rather than forcing a `#[serde(rename)]` nobody wants.
217    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    /// Type a placeholder, displacing anything already claimed for it.
229    fn claim(&mut self, placeholder: &str, schema: Value) {
230        self.typed.insert(placeholder.to_string(), schema);
231    }
232
233    /// Type a placeholder only if nothing firmer has. Keeps an exact name
234    /// match winning over an escape-stripped one whatever order they arrive.
235    fn claim_weak(&mut self, placeholder: &str, schema: Value) {
236        self.typed.entry(placeholder.to_string()).or_insert(schema);
237    }
238
239    /// The schema for a placeholder — an open string when nothing typed it.
240    fn schema(&self, placeholder: &str) -> Value {
241        self.typed
242            .get(placeholder)
243            .cloned()
244            .unwrap_or_else(|| json!({"type": "string"}))
245    }
246}
247
248/// Strip the spellings Rust needs for a keyword identifier, which a URL
249/// template never carries: `r#type` and `type_` both name `{type}`.
250fn unescape(name: &str) -> &str {
251    name.strip_prefix("r#")
252        .unwrap_or(name)
253        .trim_end_matches('_')
254}
255
256/// Infer the wire contract of one handler.
257///
258/// `handler_path` is the `#[route]` id (`app::routes::create_item`) and
259/// `route_path` the URL template, whose `{placeholders}` name tuple path
260/// parameters that the Rust signature leaves positional.
261pub 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    // Last, so an explicit `path_params` displaces whatever a params field
294    // or a `Path<T>` extractor claimed for the same placeholder.
295    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
304/// Infer a shape from the handler's own rustdoc item, then apply overrides.
305fn 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    // Overrides win: they exist for shapes inference provably cannot see.
342    apply_overrides(r, &mut shape, overrides, handler_path, paths);
343    Ok(shape)
344}
345
346/// Build a shape purely from attribute overrides.
347///
348/// For a detached marker the attributes are the whole contract, so a type
349/// name that cannot be resolved is an error here — the silent tolerance
350/// [`apply_overrides`] extends to inferred shapes would leave an empty
351/// operation behind a typo.
352fn 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
396/// Replace the shape's query parameters with a struct schema's fields.
397///
398/// A field naming one of the path's `{placeholder}`s types *that* parameter
399/// instead of adding a query one: the URL template is the more specific
400/// declaration, and emitting both would put one name on the wire twice. A
401/// route that genuinely wants the same name in both places renames one side
402/// (`#[serde(rename)]` on the field, or the placeholder).
403///
404/// Returns false when the schema has no named fields to expand.
405fn 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
438/// Resolve the `path_params` override into schemas keyed by placeholder.
439///
440/// Two spellings, told apart by whether an entry binds a name:
441/// `"type: HolderType, statement: StatementType"` types listed placeholders
442/// one at a time, and a bare `"ItemPath"` hands the job to a struct whose
443/// field names do the same. Naming a placeholder the path doesn't have is an
444/// error — it is always a typo, and silence would leave an open string
445/// behind it.
446fn 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
484/// Type placeholders from a struct schema's fields. Fields naming no
485/// placeholder are ignored — one path struct can serve several routes.
486/// Returns false when the schema has no named fields to expand.
487fn 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
500/// Split `"name: Type"` at the binding colon — the one that is not half of a
501/// `::` path separator, so `sector: crate::Sector` reads as intended.
502fn 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
518/// Append a required path parameter for every `{placeholder}` in the route
519/// template that nothing else declared, in template order, typed by whatever
520/// claimed it and an open string otherwise.
521fn 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            // An override still wins over what a `Path<T>` extractor typed.
529            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
543/// Ambient inputs shared across one handler's inference.
544struct Ctx<'c> {
545    extractors: &'c Extractors,
546    /// `{placeholder}` names in the route template, in order.
547    placeholders: &'c [String],
548    /// The handler path, used as the root of gap locations.
549    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; // Bare types in parameter position are framework context.
562    };
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
588/// Expand an extractor's wrapped type into individual parameters.
589///
590/// A struct contributes one parameter per field; a tuple is positional and
591/// takes its names from the route template; a scalar takes the template's
592/// sole placeholder.
593fn 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    // A scalar extractor names itself from the route template.
639    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    // `Result<T, E>` contributes both the success and the error contract.
657    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    // `(StatusCode, Json<T>)` — the code is a runtime value, so the body is
672    // what the signature actually pins down.
673    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
707/// Resolve a type, stepping through a body extractor wrapper if present.
708fn 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
721/// Apply attribute overrides, replacing whatever inference produced.
722fn 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        // An override answers an erased return, so drop the gap it raised.
755        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    // A status override with no response override just relabels the success.
762    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
778/// Follow a `$ref` into the components map, if it is one.
779fn 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
791/// `{id}` placeholders in a route template, in order.
792fn 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}