typed_openapi/generate.rs
1//! The bless step: a vendor's OpenAPI document and an adopter's Overlay in,
2//! one corrected document and the Rust an adopter compiles against out.
3//!
4//! An adoption runs this once per vendor revision and commits everything it
5//! writes. That is what makes a vendor change reviewable: the diff after a
6//! bless run is the answer to "what did the vendor do?", in Rust rather than in
7//! YAML.
8//!
9//! # What it writes
10//!
11//! Four artefacts under one directory, all derived from a single Overlay
12//! application so that none of them can describe a different API:
13//!
14//! - `spec/<name>.overlaid.yaml` — the corrected document, and the reviewable
15//! record of what everything below it came from.
16//! - `src/types.rs` — `components.schemas` as Rust types, from typify, with the
17//! adopter's own types substituted wherever [`Settings::replace`] says.
18//! - `src/ops.rs` — one typed wrapper per operation, the closed `OperationId`
19//! set, and the `(operationId, method, path)` inventory a hand-written
20//! operation asserts against.
21//! - `src/model.postcard` — that same document already reduced to the facts a
22//! command line needs, so a shipped binary parses no YAML and enables no
23//! feature that could.
24//!
25//! # Using it
26//!
27//! ```no_run
28//! # fn main() -> Result<(), typed_openapi::generate::GenerateError> {
29//! typed_openapi::generate::Settings::new("spec/vendor.yaml")
30//! .overlay("spec/corrections.yaml")
31//! .overlay("spec/cli.yaml")
32//! .replace("money", "money::Money")
33//! .write_to("api-generated")?;
34//! # Ok(())
35//! # }
36//! ```
37//!
38//! `examples/toy/xtask` is that call in a binary, written to be copied.
39//!
40//! # Corrections come in layers
41//!
42//! [`Settings::overlay`] may be called more than once, and the order of the
43//! calls is the order the Overlays are applied: each one corrects the document
44//! the ones before it produced. What an adoption puts in which layer is its
45//! own affair — this crate reads an ordered list of standard Overlay documents
46//! and nothing more. `docs/overlay.md` recommends a split, and the example
47//! keeps it.
48//!
49//! # Every Overlay is applied strictly
50//!
51//! [`overlay::apply`] uses `ErrorOnZeroMatch`, so a correction whose target the
52//! vendor has renamed or retyped fails here rather than lapsing quietly. A
53//! correction that stops applying is the loudest thing a vendor revision can
54//! do, and this is where it is heard. The failure names the layer it is in.
55
56mod ops;
57mod types;
58
59use std::io;
60use std::path::{Path, PathBuf};
61
62use thiserror::Error;
63
64use crate::model::DocumentError;
65use crate::overlay::OverlayError;
66use crate::{Document, LoadError, overlay};
67
68/// The command a generated file tells its reader to run.
69///
70/// `cargo run -p xtask -- bless` is the convention this crate documents; an
71/// adoption that spells it differently says so with
72/// [`Settings::regenerated_by`], because the line is a promise to whoever opens
73/// the file next.
74const DEFAULT_COMMAND: &str = "cargo run -p xtask -- bless";
75
76/// The one exemption generated source gets from an adopter's lints.
77///
78/// It is an inner attribute rather than a wrapping module so that the exemption
79/// travels with the file it exempts, and it is the same block in every
80/// generated file so that an adopter can grep for it.
81const ALLOW: &str = "\
82#![allow(
83 clippy::all,
84 clippy::pedantic,
85 clippy::restriction,
86 missing_debug_implementations,
87 unreachable_pub,
88 unused,
89 rustdoc::all,
90 reason = \"generated source is not graded on style; the allow covers this \\
91 module and nothing else\"
92)]
93";
94
95/// What a bless step generates, and the two things only the adopter can say.
96///
97/// The vendor's document and the adopter's Overlays are the input and a
98/// directory is the output; everything between them is derived. The two
99/// settings are the two facts the documents do not carry: which Rust types the
100/// adopter already owns for which vendor formats, and what command regenerates
101/// the result.
102#[derive(Debug, Clone)]
103pub struct Settings {
104 document: PathBuf,
105 overlays: Vec<PathBuf>,
106 replacements: Vec<(String, String)>,
107 command: String,
108}
109
110impl Settings {
111 /// Generate from the vendor's document, uncorrected.
112 ///
113 /// A path rather than contents: the generated files name every document
114 /// they came from so that a reader can find them, and the corrected
115 /// document is written under the vendor document's own name.
116 ///
117 /// Corrections are layers over it — [`Settings::overlay`], once per layer.
118 #[must_use]
119 pub fn new(document: impl Into<PathBuf>) -> Self {
120 Self {
121 document: document.into(),
122 overlays: Vec::new(),
123 replacements: Vec::new(),
124 command: DEFAULT_COMMAND.to_owned(),
125 }
126 }
127
128 /// Lay one Overlay over the document, after every Overlay already named.
129 ///
130 /// Call it once per layer. The order of the calls is the order the layers
131 /// are applied, because a later layer corrects the document the earlier
132 /// ones produced — so two layers that touch the same node are not
133 /// interchangeable, and the last one wins.
134 ///
135 /// A layer that fails names itself, which is the practical reason to have
136 /// more than one: a tripwire that stops the bless says which file to open.
137 #[must_use]
138 pub fn overlay(mut self, overlay: impl Into<PathBuf>) -> Self {
139 self.overlays.push(overlay.into());
140 self
141 }
142
143 /// Emit `rust_type` wherever the document declares `format`.
144 ///
145 /// The adopter owns a Rust type for a vendor format — an amount of money, a
146 /// customer number, a posting key — and wants it in the generated structs
147 /// rather than the `String` the document would otherwise produce.
148 ///
149 /// Keying on the format rather than on a schema name is what keeps the
150 /// substitution honest: the shape being replaced is read out of the
151 /// document, so the rule the CLI validates against and the rule the Rust
152 /// type stands for are the same bytes. `rust_type` is written into the
153 /// generated source verbatim, so it is a path the generated crate can name.
154 #[must_use]
155 pub fn replace(mut self, format: impl Into<String>, rust_type: impl Into<String>) -> Self {
156 self.replacements.push((format.into(), rust_type.into()));
157 self
158 }
159
160 /// Name the command that regenerates, for the header of every written file.
161 ///
162 /// The default is `cargo run -p xtask -- bless`.
163 #[must_use]
164 pub fn regenerated_by(mut self, command: impl Into<String>) -> Self {
165 self.command = command.into();
166 self
167 }
168
169 /// Write the four artefacts under `crate_dir`, and answer with their paths.
170 ///
171 /// The sink is a directory rather than four values the caller places,
172 /// because the layout is not the caller's to choose: the generated crate
173 /// embeds the corrected document and the reduced model by relative path,
174 /// and the header of each Rust file states where the others are. One
175 /// argument buys all four files in the arrangement they have to be in.
176 ///
177 /// Every Rust file is handed to `rustfmt` after it is written, so what
178 /// lands in the tree is what `cargo fmt --check` expects and the bless step
179 /// stays one command.
180 pub fn write_to(&self, crate_dir: impl AsRef<Path>) -> Result<Vec<PathBuf>, GenerateError> {
181 let dir = crate_dir.as_ref();
182 let corrected = self.correct()?;
183
184 let spec = dir.join("spec").join(self.corrected_name());
185 let types = dir.join("src/types.rs");
186 let ops = dir.join("src/ops.rs");
187 let model = dir.join("src/model.postcard");
188
189 let header = self.rust_header(&spec);
190 let document = format!("{}{}", self.document_header(), corrected.yaml);
191
192 write_bytes(&spec, document.as_bytes())?;
193 write_rust(
194 &types,
195 &types::emit(&corrected.api, &header, &self.replacements)?,
196 )?;
197 write_rust(&ops, &ops::emit(&corrected.api, &corrected.model, &header)?)?;
198 write_model(&model, &corrected.model)?;
199
200 Ok(vec![spec, types, ops, model])
201 }
202
203 /// Every layer, laid over the document in order, in the three views the
204 /// artefacts need.
205 fn correct(&self) -> Result<Corrected, GenerateError> {
206 let fault = |path: &Path| {
207 let path = path.to_path_buf();
208 move |source| GenerateError::Overlay { path, source }
209 };
210
211 let mut overlaid = overlay::parse(&read(&self.document)?).map_err(fault(&self.document))?;
212 for layer in &self.overlays {
213 overlaid = overlay::apply(overlaid, &read(layer)?).map_err(fault(layer))?;
214 }
215 let yaml = serde_yaml_ng::to_string(&overlaid).map_err(GenerateError::Yaml)?;
216
217 // Refuse to emit against a document this crate cannot build a CLI from.
218 // Everything below trusts that this succeeded.
219 let model = Document::load(&yaml, &[]).map_err(GenerateError::Unusable)?;
220 let api = serde_json::from_value(overlaid).map_err(GenerateError::NotOpenApi)?;
221
222 Ok(Corrected { yaml, model, api })
223 }
224
225 /// The corrected document's file name: the vendor's, with `overlaid` in it.
226 fn corrected_name(&self) -> String {
227 let stem = self
228 .document
229 .file_stem()
230 .unwrap_or(self.document.as_os_str())
231 .to_string_lossy();
232 format!("{stem}.overlaid.yaml")
233 }
234
235 /// What the corrected document says above its first line: the command
236 /// that rewrites it, and every document it was built from in the order
237 /// they were applied — which is what a reader needs to reproduce it.
238 fn document_header(&self) -> String {
239 format!(
240 "# Generated by `{}` from {}.\n\
241 # Do not edit: every correction belongs in an Overlay.\n",
242 self.command,
243 listed(&self.inputs(file_name)),
244 )
245 }
246
247 /// What every generated Rust file says above its first line: how to rewrite
248 /// it, where a correction belongs instead, and the one exemption generated
249 /// source gets from an adopter's lints.
250 fn rust_header(&self, corrected: &Path) -> String {
251 let belongs = match self.overlays.as_slice() {
252 [] => format!("is generated from `{}`", locator(&self.document)),
253 layers => {
254 let named: Vec<String> = layers
255 .iter()
256 .map(|path| format!("`{}`", locator(path)))
257 .collect();
258 format!("belongs in {}", listed(&named))
259 }
260 };
261 format!(
262 "//! Generated by `{}` from `{}`.\n\
263 //! Do not edit: every correction {belongs}.\n\
264 {ALLOW}",
265 self.command,
266 locator(corrected),
267 )
268 }
269
270 /// The vendor's document and every layer over it, in the order they are
271 /// applied, named the way `name` names a path.
272 fn inputs(&self, name: impl Fn(&Path) -> String) -> Vec<String> {
273 std::iter::once(&self.document)
274 .chain(&self.overlays)
275 .map(|path| name(path))
276 .collect()
277 }
278}
279
280/// A list as prose: `a`, then `a and b`, then `a, b and c`.
281fn listed(items: &[String]) -> String {
282 match items.split_last() {
283 None => String::new(),
284 Some((last, [])) => last.clone(),
285 Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
286 }
287}
288
289/// One Overlay application, in the three views the four artefacts are emitted
290/// from.
291///
292/// Producing all three from one application is the whole point: the YAML that
293/// is committed, the reduction a binary reads and the object model the Rust is
294/// emitted from are the same correction, so no two artefacts can describe a
295/// different API.
296struct Corrected {
297 /// The corrected document, as it is committed.
298 yaml: String,
299 /// That document reduced to the facts a command line needs.
300 model: Document,
301 /// That document as the OpenAPI object model the emitters read.
302 api: openapiv3::OpenAPI,
303}
304
305/// The shortest form of a path a reader can act on: the directory holding it
306/// and its name.
307///
308/// A bare file name is ambiguous once an adoption has more than one `spec`
309/// directory, and an absolute path is true only on the machine that generated.
310fn locator(path: &Path) -> String {
311 match path.parent().and_then(Path::file_name) {
312 Some(parent) => format!("{}/{}", parent.to_string_lossy(), file_name(path)),
313 None => file_name(path),
314 }
315}
316
317fn file_name(path: &Path) -> String {
318 path.file_name()
319 .unwrap_or(path.as_os_str())
320 .to_string_lossy()
321 .into_owned()
322}
323
324fn read(path: &Path) -> Result<String, GenerateError> {
325 std::fs::read_to_string(path).map_err(|source| GenerateError::Read {
326 path: path.to_path_buf(),
327 source,
328 })
329}
330
331/// Write a file, creating the directory it goes in if it is missing.
332fn write_bytes(path: &Path, contents: &[u8]) -> Result<(), GenerateError> {
333 if let Some(parent) = path.parent() {
334 std::fs::create_dir_all(parent).map_err(|source| GenerateError::Write {
335 path: parent.to_path_buf(),
336 source,
337 })?;
338 }
339 std::fs::write(path, contents).map_err(|source| GenerateError::Write {
340 path: path.to_path_buf(),
341 source,
342 })
343}
344
345/// Write the reduction a binary loads, after reading it straight back.
346///
347/// `model` is the one [`Document::load`] a bless step makes — the same value
348/// the generated types and wrappers were emitted from — so encoding it is the
349/// only way the blob and the document can come apart. The read-back proves they
350/// have not: what a binary will deserialise is what this step reduced.
351fn write_model(path: &Path, model: &Document) -> Result<(), GenerateError> {
352 let blob = model.to_blob().map_err(GenerateError::Blob)?;
353 let read_back = Document::from_blob(&blob).map_err(GenerateError::Blob)?;
354 if &read_back != model {
355 return Err(GenerateError::RoundTrip);
356 }
357 write_bytes(path, &blob)
358}
359
360fn write_rust(path: &Path, source: &str) -> Result<(), GenerateError> {
361 write_bytes(path, source.as_bytes())?;
362 rustfmt(path)
363}
364
365/// Hand a written file to `rustfmt`.
366///
367/// Formatting the file on disk rather than piping the source through keeps the
368/// result identical to what an adopter's own `cargo fmt` would produce, which
369/// is the only reason a generated file can sit under `cargo fmt --check` at
370/// all.
371fn rustfmt(path: &Path) -> Result<(), GenerateError> {
372 let status = std::process::Command::new("rustfmt")
373 .arg("--edition")
374 .arg("2024")
375 .arg(path)
376 .status()
377 .map_err(|source| {
378 if source.kind() == io::ErrorKind::NotFound {
379 GenerateError::RustfmtMissing
380 } else {
381 GenerateError::RustfmtSpawn {
382 path: path.to_path_buf(),
383 source,
384 }
385 }
386 })?;
387 if status.success() {
388 Ok(())
389 } else {
390 Err(GenerateError::RustfmtFailed {
391 path: path.to_path_buf(),
392 })
393 }
394}
395
396/// Why a bless step stopped.
397///
398/// Most variants are the document saying something this generator has no Rust
399/// spelling for; the rest are the two documents, the filesystem, or `rustfmt`.
400/// A bless step reports and exits, so nothing here is meant to be branched on —
401/// it is meant to name the thing to go and fix.
402#[derive(Debug, Error)]
403#[non_exhaustive]
404pub enum GenerateError {
405 #[error("reading {path}: {source}")]
406 Read {
407 path: PathBuf,
408 #[source]
409 source: io::Error,
410 },
411 #[error("writing {path}: {source}")]
412 Write {
413 path: PathBuf,
414 #[source]
415 source: io::Error,
416 },
417 /// A document or a layer over it that does not read, or does not apply.
418 /// `path` is the file to go and open: with corrections split across
419 /// layers, which one stopped the bless is the first thing to know.
420 #[error("{path}: {source}")]
421 Overlay {
422 path: PathBuf,
423 #[source]
424 source: OverlayError,
425 },
426 #[error("the overlaid document is not representable as YAML: {0}")]
427 Yaml(#[source] serde_yaml_ng::Error),
428 #[error("the overlaid document does not describe a usable CLI: {0}")]
429 Unusable(#[source] LoadError),
430 #[error("the overlaid document is not an OpenAPI 3 document: {0}")]
431 NotOpenApi(#[source] serde_json::Error),
432 #[error("schema `{name}` is not representable as JSON: {source}")]
433 Schema {
434 name: String,
435 #[source]
436 source: serde_json::Error,
437 },
438 #[error("typify cannot build Rust types from the document's schemas: {0}")]
439 Typify(#[source] typify::Error),
440 #[error("{0}")]
441 Unsupported(String),
442 #[error("the generated {file} is not valid Rust: {source}")]
443 NotRust {
444 file: &'static str,
445 #[source]
446 source: syn::Error,
447 },
448 #[error("the reduced model does not survive a round trip: {0}")]
449 Blob(#[source] DocumentError),
450 #[error("the reduced model is not the document's reduction after a round trip")]
451 RoundTrip,
452 #[error("rustfmt is not on PATH, and a bless step formats every Rust file it writes")]
453 RustfmtMissing,
454 #[error("running rustfmt on {path}: {source}")]
455 RustfmtSpawn {
456 path: PathBuf,
457 #[source]
458 source: io::Error,
459 },
460 #[error("rustfmt rejected the generated {path}")]
461 RustfmtFailed { path: PathBuf },
462}