noyalib/schema_codegen.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! JSON Schema codegen for Rust types.
5//!
6//! Derive [`JsonSchema`] for a Rust type and emit a JSON Schema
7//! 2020-12 document that describes its YAML shape. Useful for:
8//!
9//! - **Contract sharing.** Hand the generated schema to teams that
10//! consume your YAML configs (CI, CRD generators, IDE plugins,
11//! doc sites) without re-deriving the contract by hand.
12//! - **Self-documenting configs.** `#[doc]` strings on a struct's
13//! fields propagate into the schema's `description` field, so the
14//! schema doubles as the manual.
15//! - **Cross-language workflow.** A YAML schema artefact can be
16//! consumed by any JSON Schema implementation in any language.
17//!
18//! This module is gated behind the `schema` Cargo feature (off by
19//! default). Schema *validation* of a YAML document against the
20//! emitted schema lives in [`crate::validate_against_schema`] —
21//! enable the `validate-schema` feature for that path.
22//!
23//! # Examples
24//!
25//! ```
26//! use noyalib::{schema_for, schema_for_yaml, JsonSchema};
27//!
28//! #[derive(serde::Serialize, serde::Deserialize, JsonSchema)]
29//! struct ServerConfig {
30//! /// Port the server binds on.
31//! port: u16,
32//! /// Hostname or IP.
33//! host: String,
34//! }
35//!
36//! // As a `noyalib::Value` for further processing.
37//! let v = schema_for::<ServerConfig>().unwrap();
38//! assert_eq!(v["title"].as_str(), Some("ServerConfig"));
39//!
40//! // As YAML text for sharing.
41//! let yaml = schema_for_yaml::<ServerConfig>().unwrap();
42//! assert!(yaml.contains("title: ServerConfig"));
43//! assert!(yaml.contains("port:"));
44//! ```
45
46use crate::error::{Error, Result};
47// Via the prelude, not `std`: `schema` without `std` is a valid
48// combination (schemars and serde_json both build on alloc), checked
49// by the weekly feature-powerset sweep.
50use crate::prelude::{Cow, String, format};
51use crate::value::Value;
52
53/// Re-export of the `schemars` derive macro and trait. Deriving
54/// [`JsonSchema`] on your Rust type is what makes
55/// [`schema_for`] / [`schema_for_yaml`] produce a schema for it.
56///
57/// # Downstream `Cargo.toml`
58///
59/// The proc-macro that powers `#[derive(JsonSchema)]` expands to
60/// code that references `::schemars::...` paths in the *call
61/// site's* dep graph. That means downstream crates that derive
62/// [`JsonSchema`] must add `schemars` to their own
63/// `Cargo.toml` alongside `noyalib`:
64///
65/// ```toml
66/// [dependencies]
67/// noyalib = { version = "0.0.7", features = ["schema"] }
68/// schemars = "1.2"
69/// ```
70///
71/// (Same precedent as `serde::Deserialize` — derive macros that
72/// re-export through a wrapper crate cannot rewrite the
73/// generated paths without an explicit
74/// `#[schemars(crate = "...")]` attribute on every type.)
75///
76/// # Examples
77///
78/// ```
79/// use noyalib::JsonSchema;
80///
81/// #[derive(JsonSchema)]
82/// struct Cfg { port: u16 }
83/// ```
84pub use schemars::JsonSchema;
85
86/// Generate the JSON Schema 2020-12 document for `T` and parse it
87/// into a [`crate::Value`] tree, ready for indexing, transcoding,
88/// or further programmatic walking.
89///
90/// The schema honours `#[doc]` comments on fields (they become
91/// `description` properties), `#[serde(rename = "...")]` (renames
92/// the schema property), `#[serde(default)]` (drops the property
93/// from `required`), and the rest of the conventions documented
94/// upstream in [`schemars`].
95///
96/// # Errors
97///
98/// - The schema serializer fails (vanishingly unlikely; schemas
99/// never contain non-serializable shapes).
100/// - The serialized JSON cannot be re-parsed as YAML (would
101/// indicate a noyalib parser bug, since JSON ⊂ YAML 1.2).
102///
103/// # Examples
104///
105/// ```
106/// use noyalib::{schema_for, JsonSchema};
107///
108/// #[derive(JsonSchema)]
109/// struct Cfg { port: u16 }
110///
111/// let schema = schema_for::<Cfg>().unwrap();
112/// assert_eq!(schema["type"].as_str(), Some("object"));
113/// ```
114pub fn schema_for<T: JsonSchema>() -> Result<Value> {
115 let mut generator = schemars::SchemaGenerator::default();
116 let schema = generator.root_schema_for::<T>();
117 // JSON ⊂ YAML 1.2 — round-trip through JSON text is the most
118 // direct path that doesn't require a `Schema` → `Value`
119 // hand-mapping.
120 let json = serde_json::to_string(&schema)
121 .map_err(|e| Error::Parse(format!("schema_for: schema serialization failed: {e}")))?;
122 crate::from_str::<Value>(&json)
123}
124
125/// Generate the JSON Schema 2020-12 document for `T` and emit it
126/// as YAML text — ready to write to disk, share with downstream
127/// consumers, or check into version control alongside the type
128/// definition.
129///
130/// The output uses noyalib's standard serializer settings; round-
131/// tripping the YAML back through [`crate::from_str`] yields an
132/// equivalent [`Value`] tree.
133///
134/// # Errors
135///
136/// As [`schema_for`], plus YAML emission errors (likewise extremely
137/// unlikely for schema-shaped data).
138///
139/// # Examples
140///
141/// ```
142/// use noyalib::{schema_for_yaml, JsonSchema};
143///
144/// #[derive(JsonSchema)]
145/// struct Cfg { port: u16 }
146///
147/// let yaml = schema_for_yaml::<Cfg>().unwrap();
148/// assert!(yaml.contains("type: object"));
149/// ```
150pub fn schema_for_yaml<T: JsonSchema>() -> Result<String> {
151 let mut generator = schemars::SchemaGenerator::default();
152 let schema = generator.root_schema_for::<T>();
153 crate::to_string(&schema)
154}
155
156/// `JsonSchema` for [`crate::Value`] — the dynamic value tree
157/// admits any JSON-expressible shape, so the schema we emit is
158/// the JSON Schema 2020-12 idiom for "any value": `{"oneOf":
159/// [...]}` enumerating null, boolean, number, string, array,
160/// and object. Object values use a recursive `additionalProperties`
161/// reference so deeply-nested `Value` payloads typecheck without
162/// a depth bound.
163///
164/// This impl lets users derive [`JsonSchema`] on a struct that
165/// has a `noyalib::Value` field — e.g. when the field's type is
166/// "any user-supplied YAML scalar / mapping / sequence" and the
167/// caller still wants a published schema for the surrounding
168/// shape.
169///
170/// # Examples
171///
172/// ```
173/// use noyalib::{schema_for, JsonSchema, Value};
174///
175/// #[derive(serde::Serialize, serde::Deserialize, JsonSchema)]
176/// struct Envelope {
177/// id: String,
178/// payload: Value,
179/// }
180///
181/// let schema = schema_for::<Envelope>().unwrap();
182/// // The `payload` field is described by the `Value` schema —
183/// // a oneOf union covering every JSON-expressible shape.
184/// assert_eq!(schema["type"].as_str(), Some("object"));
185/// assert!(matches!(schema["properties"]["payload"], Value::Mapping(_)));
186/// ```
187impl JsonSchema for Value {
188 fn schema_name() -> Cow<'static, str> {
189 Cow::Borrowed("YamlValue")
190 }
191
192 fn schema_id() -> Cow<'static, str> {
193 Cow::Borrowed("noyalib::Value")
194 }
195
196 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
197 // `serde_json::json!` is the path-of-least-resistance to
198 // a `Schema` value. The shape mirrors what
199 // `schemars::Schema::default()` plus a `oneOf` would
200 // produce by hand, but the macro keeps the noise down.
201 schemars::Schema::try_from(serde_json::json!({
202 "oneOf": [
203 { "type": "null" },
204 { "type": "boolean" },
205 { "type": "number" },
206 { "type": "string" },
207 {
208 "type": "array",
209 "items": { "$ref": "#/$defs/YamlValue" }
210 },
211 {
212 "type": "object",
213 "additionalProperties": { "$ref": "#/$defs/YamlValue" }
214 }
215 ],
216 "title": "YamlValue",
217 "description": "Any YAML 1.2 scalar, sequence, or mapping value. \
218 Recursive — sequence items and mapping values are \
219 themselves `YamlValue`s."
220 }))
221 .expect("YamlValue schema must be a valid JSON Schema document")
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[derive(serde::Serialize, serde::Deserialize, JsonSchema)]
230 #[allow(dead_code)]
231 struct Cfg {
232 port: u16,
233 name: String,
234 }
235
236 #[test]
237 fn schema_for_returns_object_schema() {
238 let v = schema_for::<Cfg>().unwrap();
239 assert_eq!(v["type"].as_str(), Some("object"));
240 assert_eq!(v["title"].as_str(), Some("Cfg"));
241 let required = match &v["required"] {
242 Value::Sequence(s) => s.clone(),
243 other => panic!("expected required to be a sequence, got {other:?}"),
244 };
245 let names: Vec<&str> = required.iter().filter_map(Value::as_str).collect();
246 assert!(names.contains(&"port"));
247 assert!(names.contains(&"name"));
248 }
249
250 #[test]
251 fn schema_for_yaml_round_trips_to_value() {
252 let yaml = schema_for_yaml::<Cfg>().unwrap();
253 let parsed: Value = crate::from_str(&yaml).unwrap();
254 let direct = schema_for::<Cfg>().unwrap();
255 assert_eq!(parsed, direct);
256 }
257
258 #[test]
259 fn schema_records_field_constraints() {
260 let v = schema_for::<Cfg>().unwrap();
261 let port = &v["properties"]["port"];
262 assert_eq!(port["type"].as_str(), Some("integer"));
263 // schemars 1.x emits min/max for fixed-width integers.
264 assert_eq!(port["minimum"].as_i64(), Some(0));
265 assert_eq!(port["maximum"].as_i64(), Some(65_535));
266 }
267
268 #[derive(serde::Serialize, serde::Deserialize, JsonSchema)]
269 #[allow(dead_code)]
270 struct WithDoc {
271 /// Bound TCP port.
272 port: u16,
273 }
274
275 #[test]
276 fn doc_comments_become_descriptions() {
277 let v = schema_for::<WithDoc>().unwrap();
278 let desc = v["properties"]["port"]["description"].as_str();
279 assert_eq!(desc, Some("Bound TCP port."));
280 }
281
282 #[derive(serde::Serialize, serde::Deserialize, JsonSchema)]
283 #[allow(dead_code)]
284 struct WithDefault {
285 port: u16,
286 #[serde(default)]
287 host: String,
288 }
289
290 #[test]
291 fn serde_default_drops_field_from_required() {
292 let v = schema_for::<WithDefault>().unwrap();
293 let required = match &v["required"] {
294 Value::Sequence(s) => s.clone(),
295 other => panic!("expected required, got {other:?}"),
296 };
297 let names: Vec<&str> = required.iter().filter_map(Value::as_str).collect();
298 assert!(names.contains(&"port"));
299 assert!(
300 !names.contains(&"host"),
301 "default-bearing field should not be required"
302 );
303 }
304
305 #[derive(serde::Serialize, serde::Deserialize, JsonSchema)]
306 #[allow(dead_code)]
307 struct Renamed {
308 #[serde(rename = "bind_port")]
309 port: u16,
310 }
311
312 #[test]
313 fn serde_rename_renames_schema_property() {
314 let v = schema_for::<Renamed>().unwrap();
315 let props = match &v["properties"] {
316 Value::Mapping(m) => m.clone(),
317 other => panic!("expected properties to be a Mapping, got {other:?}"),
318 };
319 assert!(props.contains_key("bind_port"));
320 assert!(!props.contains_key("port"));
321 }
322}