Skip to main content

soothfast_spec/schema/
foreign.rs

1//! Canonical-path → JSON Schema mappings for types outside this crate.
2//!
3//! Foreign types are absent from rustdoc's `index` but present in its `paths`
4//! with a fully-qualified path, so they are always *identifiable* even when
5//! they cannot be walked. This table turns that identity into a schema.
6//!
7//! Entries are only added where the serde wire format is known with
8//! certainty. An unmapped type becomes a reported gap and an open schema —
9//! a wrong mapping would be silently incorrect, which is worse.
10
11use std::collections::BTreeMap;
12
13use serde_json::{Value, json};
14
15/// What a table entry says about a type it cannot walk.
16///
17/// Most entries are a literal schema, but a *transparent newtype wrapper*
18/// (`#[serde(transparent)] struct Json<T>(pub T)`) has no wire shape of its
19/// own — it is exactly its type argument, so no literal can describe it
20/// without erasing whatever it wraps.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum TypeMapping {
23    /// A JSON Schema substituted wherever the type appears.
24    Literal(Value),
25    /// The type's wire shape is that of one of its generic arguments.
26    Transparent {
27        /// Which type argument carries the shape, zero-based.
28        arg: usize,
29    },
30}
31
32impl TypeMapping {
33    /// The literal schema, when this entry is one.
34    pub fn as_literal(&self) -> Option<&Value> {
35        match self {
36            Self::Literal(v) => Some(v),
37            Self::Transparent { .. } => None,
38        }
39    }
40}
41
42impl From<Value> for TypeMapping {
43    fn from(schema: Value) -> Self {
44        Self::Literal(schema)
45    }
46}
47
48/// Built-in and user-supplied mappings from canonical type path to schema.
49#[derive(Debug, Clone)]
50pub struct TypeTable {
51    map: BTreeMap<String, TypeMapping>,
52}
53
54impl Default for TypeTable {
55    fn default() -> Self {
56        Self::builtin()
57    }
58}
59
60impl TypeTable {
61    /// The curated set: std types plus ecosystem types whose serde
62    /// representation is stable and unambiguous.
63    pub fn builtin() -> Self {
64        let mut map = BTreeMap::new();
65        let mut put = |k: &str, v: Value| {
66            map.insert(k.to_string(), TypeMapping::Literal(v));
67        };
68
69        let string = || json!({ "type": "string" });
70        put("alloc::string::String", string());
71        put("str", string());
72        put("std::path::PathBuf", string());
73        put("std::path::Path", string());
74        put("std::ffi::OsString", string());
75
76        put("core::net::IpAddr", json!({ "type": "string" }));
77        put(
78            "core::net::Ipv4Addr",
79            json!({ "type": "string", "format": "ipv4" }),
80        );
81        put(
82            "core::net::Ipv6Addr",
83            json!({ "type": "string", "format": "ipv6" }),
84        );
85        put("std::net::SocketAddr", string());
86
87        // serde renders these as structs, not as scalars — a "duration is a
88        // number" guess would be wrong on the wire.
89        put(
90            "core::time::Duration",
91            json!({
92                "type": "object",
93                "properties": { "secs": { "type": "integer", "minimum": 0 },
94                                "nanos": { "type": "integer", "minimum": 0 } },
95                "required": ["secs", "nanos"],
96            }),
97        );
98        put(
99            "std::time::SystemTime",
100            json!({
101                "type": "object",
102                "properties": { "secs_since_epoch": { "type": "integer" },
103                                "nanos_since_epoch": { "type": "integer" } },
104                "required": ["secs_since_epoch", "nanos_since_epoch"],
105            }),
106        );
107
108        put("uuid::Uuid", json!({ "type": "string", "format": "uuid" }));
109        put("url::Url", json!({ "type": "string", "format": "uri" }));
110
111        let date_time = || json!({ "type": "string", "format": "date-time" });
112        put("chrono::DateTime", date_time());
113        put("chrono::naive::datetime::NaiveDateTime", date_time());
114        put(
115            "chrono::naive::date::NaiveDate",
116            json!({ "type": "string", "format": "date" }),
117        );
118        put(
119            "chrono::naive::time::NaiveTime",
120            json!({ "type": "string", "format": "time" }),
121        );
122        put("time::OffsetDateTime", date_time());
123
124        // Arbitrary-precision numerics serialize as strings to survive the
125        // round trip through JSON's f64.
126        put("rust_decimal::Decimal", string());
127        put("bigdecimal::BigDecimal", string());
128
129        // Any JSON value: genuinely unconstrained, not a gap.
130        put("serde_json::value::Value", json!({}));
131
132        Self { map }
133    }
134
135    /// An empty table, for tests that want to observe gap reporting.
136    pub fn empty() -> Self {
137        Self {
138            map: BTreeMap::new(),
139        }
140    }
141
142    /// Add or override a mapping. User entries win over built-ins, so a crate
143    /// with a custom serializer for a std type can correct the default.
144    ///
145    /// A bare [`Value`] is a literal schema, so existing callers keep working
146    /// unchanged; pass a [`TypeMapping`] for anything else.
147    pub fn insert(&mut self, path: impl Into<String>, mapping: impl Into<TypeMapping>) {
148        self.map.insert(path.into(), mapping.into());
149    }
150
151    /// Look a canonical path up, accepting the shorter forms users actually
152    /// write in config: `uuid::Uuid` for `uuid::Uuid`, and the bare type name
153    /// when a crate re-exports from a private module.
154    pub fn lookup(&self, canonical: &str) -> Option<&TypeMapping> {
155        if let Some(v) = self.map.get(canonical) {
156            return Some(v);
157        }
158        let segments: Vec<&str> = canonical.split("::").collect();
159        if let (Some(first), Some(last)) = (segments.first(), segments.last()) {
160            if segments.len() > 2
161                && let Some(v) = self.map.get(&format!("{first}::{last}"))
162            {
163                return Some(v);
164            }
165            // Last resort: match on type name alone. Ambiguity here is real
166            // but rare, and losing the mapping means a spurious gap.
167            if let Some((_, v)) = self
168                .map
169                .iter()
170                .find(|(k, _)| k.rsplit("::").next() == Some(*last))
171            {
172                return Some(v);
173            }
174        }
175        None
176    }
177
178    /// Number of entries, for reporting how a config extended the defaults.
179    pub fn len(&self) -> usize {
180        self.map.len()
181    }
182
183    /// True when no mappings are registered.
184    pub fn is_empty(&self) -> bool {
185        self.map.is_empty()
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    /// The literal schema at a path, for the many tests that expect one.
194    fn literal<'a>(t: &'a TypeTable, path: &str) -> Option<&'a Value> {
195        t.lookup(path).and_then(TypeMapping::as_literal)
196    }
197
198    #[test]
199    fn maps_std_string_by_canonical_path() {
200        let t = TypeTable::builtin();
201        assert_eq!(
202            literal(&t, "alloc::string::String"),
203            Some(&json!({"type":"string"}))
204        );
205    }
206
207    #[test]
208    fn duration_maps_to_the_struct_serde_actually_emits() {
209        let t = TypeTable::builtin();
210        let d = literal(&t, "core::time::Duration").expect("Duration mapped");
211        assert_eq!(d["type"], "object");
212        assert_eq!(d["required"][0], "secs");
213    }
214
215    #[test]
216    fn accepts_the_short_form_users_write_in_config() {
217        let mut t = TypeTable::empty();
218        t.insert("uuid::Uuid", json!({"type":"string","format":"uuid"}));
219        // rustdoc reports the definition site, which may be a private module.
220        let found = literal(&t, "uuid::builder::Uuid").expect("short form matches");
221        assert_eq!(found["format"], "uuid");
222    }
223
224    #[test]
225    fn a_transparent_entry_is_kept_apart_from_a_literal_one() {
226        let mut t = TypeTable::empty();
227        t.insert("async_graphql::types::json::Json", json!({}));
228        assert_eq!(
229            literal(&t, "async_graphql::types::json::Json"),
230            Some(&json!({})),
231            "an empty table is a literal open schema, not a directive"
232        );
233        t.insert(
234            "async_graphql::types::json::Json",
235            TypeMapping::Transparent { arg: 0 },
236        );
237        assert_eq!(
238            t.lookup("async_graphql::types::json::Json"),
239            Some(&TypeMapping::Transparent { arg: 0 })
240        );
241        assert_eq!(literal(&t, "async_graphql::types::json::Json"), None);
242    }
243
244    #[test]
245    fn unmapped_type_returns_none_rather_than_a_guess() {
246        let t = TypeTable::builtin();
247        assert_eq!(t.lookup("some_crate::Exotic"), None);
248    }
249
250    #[test]
251    fn user_entries_override_builtins() {
252        let mut t = TypeTable::builtin();
253        t.insert("core::time::Duration", json!({"type":"integer"}));
254        let d = literal(&t, "core::time::Duration").expect("still mapped");
255        assert_eq!(d["type"], "integer");
256    }
257}