Skip to main content

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}