Skip to main content

soothfast_spec/schema/
mod.rs

1//! Rustdoc JSON → JSON Schema.
2//!
3//! Generation is rooted at `#[route]` handlers, and handler signatures are
4//! monomorphic, so every type reachable from a route is concrete: generics
5//! resolve by substitution, wire names come through rustdoc's preserved
6//! attributes — serde's, or async-graphql's for the types it serves — and
7//! foreign types resolve by canonical path against a table.
8//!
9//! Three things stay underivable, and all three are *detectable* — the
10//! extractor never guesses a shape it cannot see:
11//!
12//! - **Erased returns** (`impl Trait`, `dyn Trait`) carry only a bound.
13//! - **Custom serializers** (`with` / `serialize_with`) define the wire shape
14//!   in code, so the field's Rust type stops predicting it.
15//! - **Unmapped foreign types** are named exactly but cannot be walked.
16//!
17//! Each becomes a [`Gap`] and an open schema — imprecise, never wrong.
18
19mod adt;
20mod attrs;
21pub mod docs;
22#[cfg(test)]
23mod docs_tests;
24#[cfg(test)]
25mod extract_tests;
26pub mod foreign;
27pub mod graphql_attrs;
28#[cfg(test)]
29mod graphql_tests;
30mod naming;
31pub mod route_sig;
32#[cfg(test)]
33mod route_sig_tests;
34pub mod serde_attrs;
35#[cfg(test)]
36mod transparent_tests;
37pub mod types;
38mod wire_names;
39
40use std::collections::{BTreeMap, BTreeSet};
41
42use serde_json::Value;
43
44pub use docs::Docs;
45pub use foreign::{TypeMapping, TypeTable};
46pub use route_sig::{Extractors, Overrides, RouteShape};
47pub use types::Resolver;
48
49/// A schema plus everything it referenced and everything it could not see.
50#[derive(Debug, Clone)]
51pub struct Extraction {
52    /// The root schema, usually a `$ref` into `components`.
53    pub schema: Value,
54    /// Named schemas the root references, for `components/schemas`.
55    pub components: BTreeMap<String, Value>,
56    /// Places a shape could not be derived. Never empty *and* silent.
57    pub gaps: Vec<Gap>,
58    /// Canonical paths walked out of a workspace-local crate instead of
59    /// being reported as unmapped.
60    pub aux_resolved: BTreeSet<String>,
61}
62
63/// Extract the schema for a named type from a rustdoc JSON document.
64///
65/// Takes one document, or a [`Docs`] carrying the workspace-local crates the
66/// type may actually be defined in. Used for the
67/// `#[route(response = "...")]` override; signature-driven extraction goes
68/// through [`Resolver::resolve`] directly.
69pub fn extract_named<'a>(
70    docs: impl Into<Docs<'a>>,
71    table: &'a TypeTable,
72    name: &str,
73) -> Result<Extraction, String> {
74    let mut resolver = Resolver::new(docs, table)?;
75    let schema = resolver
76        .resolve_by_name(name, name)
77        .ok_or_else(|| format!("no struct or enum named `{name}` in this crate's public API"))?;
78    Ok(Extraction {
79        schema,
80        components: resolver.components,
81        gaps: resolver.gaps,
82        aux_resolved: resolver.aux_resolved,
83    })
84}
85
86/// A place the extractor could not derive a shape, and why.
87///
88/// Every gap names the exact type or field so the report is actionable, and
89/// carries an open schema rather than a guessed one.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum Gap {
92    /// `impl Trait` / `dyn Trait` in return position: only the bound survives.
93    Erased {
94        /// Where it appeared, e.g. `crate::routes::create_item`.
95        at: String,
96        /// The trait bound rustdoc did preserve.
97        bound: String,
98    },
99    /// `#[serde(with = "...")]` — wire shape defined by code, not by type.
100    CustomSerializer {
101        at: String,
102        /// The module or function named by the attribute.
103        with: String,
104    },
105    /// A type outside this crate's rustdoc index with no table mapping.
106    UnmappedForeign {
107        at: String,
108        /// Canonical path, e.g. `chrono::DateTime`.
109        path: String,
110    },
111    /// A rename rule this extractor does not implement, from `rename_all`
112    /// (serde) or `rename_fields`/`rename_items` (async-graphql). Emitting
113    /// names under an unknown rule would rename every one of them wrongly.
114    UnknownRenameRule { at: String, rule: String },
115    /// Rustdoc omits private fields, but serde still serializes them, so the
116    /// field list is known to be incomplete.
117    StrippedFields { at: String },
118    /// A type mapped `transparent` in `[spec.types]`, used without the type
119    /// argument that was supposed to carry its wire shape.
120    TransparentWithoutArgument {
121        at: String,
122        /// Canonical path of the wrapper, e.g. `async_graphql::types::json::Json`.
123        path: String,
124        /// The zero-based argument position the mapping named.
125        arg: usize,
126    },
127    /// A `#[graphql(skip)]`ed field's matching `#[ComplexObject]` resolver
128    /// takes a required argument this schema has no default for, so the
129    /// field's shape could not be recovered.
130    ComplexFieldArgument {
131        at: String,
132        /// The resolver's own parameter name.
133        argument: String,
134    },
135}
136
137impl Gap {
138    /// Where the gap was found, for grouping and reporting.
139    pub fn at(&self) -> &str {
140        match self {
141            Self::Erased { at, .. }
142            | Self::CustomSerializer { at, .. }
143            | Self::UnmappedForeign { at, .. }
144            | Self::UnknownRenameRule { at, .. }
145            | Self::TransparentWithoutArgument { at, .. }
146            | Self::ComplexFieldArgument { at, .. }
147            | Self::StrippedFields { at } => at,
148        }
149    }
150
151    /// One-line explanation naming the cause and the remedy.
152    pub fn explain(&self) -> String {
153        match self {
154            Self::Erased { at, bound } => format!(
155                "{at}: returns an erased type (bound `{bound}`) — the concrete \
156                 shape is not in rustdoc JSON; annotate `response = \"...\"` \
157                 on #[route] or let runtime capture fill it"
158            ),
159            Self::CustomSerializer { at, with } => format!(
160                "{at}: `#[serde(with = \"{with}\")]` defines the wire shape in \
161                 code, so the field type does not predict it; emitted open"
162            ),
163            Self::UnmappedForeign { at, path } => format!(
164                "{at}: foreign type `{path}` has no mapping; add one under \
165                 [spec.types] in soothfast.toml"
166            ),
167            Self::UnknownRenameRule { at, rule } => format!(
168                "{at}: unsupported rename rule `\"{rule}\"`; names left under \
169                 the framework's default rather than renamed wrongly"
170            ),
171            Self::TransparentWithoutArgument { at, path, arg } => format!(
172                "{at}: `{path}` is mapped `transparent` under [spec.types] but \
173                 is used with no type argument at position {arg}, so nothing \
174                 says what it wraps; emitted open"
175            ),
176            Self::StrippedFields { at } => format!(
177                "{at}: has private fields that rustdoc omits but serde still \
178                 serializes; emitted open (regenerate with private items \
179                 documented for the full field set)"
180            ),
181            Self::ComplexFieldArgument { at, argument } => format!(
182                "{at}: the #[ComplexObject] resolver for this field takes a \
183                 required argument `{argument}` with no default a JSON \
184                 Schema can express; emitted open"
185            ),
186        }
187    }
188}