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}