Skip to main content

oapi_codegen/
lib.rs

1//! `oapi-codegen` — generate idiomatic Rust from OpenAPI 3 specifications.
2//!
3//! The pipeline is: load a spec ([`loader`]), lower its component schemas into
4//! an intermediate representation ([`lower::schema`] → [`ir`]) and, for the server
5//! generator, its operations ([`lower::paths`] → [`ir`]), then emit formatted Rust
6//! source ([`emit`]). [`Config`] mirrors `oapi-codegen`'s YAML configuration.
7
8pub mod cli;
9pub mod config;
10pub mod deps;
11pub mod emit;
12pub mod error;
13pub mod filter;
14pub mod ir;
15pub mod loader;
16pub mod lower;
17pub mod naming;
18
19use std::path::Path;
20
21pub use crate::config::Config;
22pub use crate::error::Error;
23pub use crate::error::Result;
24use crate::ir::Module;
25use crate::loader::Spec;
26
27/// Generate Rust from a spec file according to `configuration`, returning the source.
28///
29/// Models are emitted when `generate.models` is set, or implicitly when the
30/// server or client is generated (so referenced types are in scope). The axum
31/// server interface is appended when `generate.std-http-server` is set. The
32/// blocking `reqwest` client is appended when `generate.client` is set. Models,
33/// per-operation types, and both generators are emitted flat at the crate root,
34/// so server and client can share one file.
35pub fn generate(spec_path: &Path, config: &Config) -> Result<String> {
36    if config.generate.embedded_spec {
37        return Err(Error::Unimplemented("embedded-spec".to_owned()));
38    }
39    let mut spec = Spec::load(spec_path)?;
40    spec.apply_filters(&config.output_options);
41    let want_server = config.generate.std_http_server;
42    let want_client = config.generate.client;
43    let server_urls = if config.generate.server_urls {
44        lower::lower_server_urls(&spec)?
45    } else {
46        None
47    };
48    // A run that emits no type resolves no type name. `server-urls` on its own
49    // emits constants only, so it must not read `type-name-suffix` and must not
50    // report a collision between two schemas that it never looks at. This matches
51    // `response-type-suffix`, which only a server or client run reads.
52    if !(config.generate.models || want_server || want_client) {
53        return emit::emit_module(&Module::default(), server_urls.as_ref());
54    }
55    // A set but useless suffix is an error, and not silently "unset". The
56    // resolution checks it, so every caller of `type_renames` gets the check.
57    // See `lower::rename::checked_suffix`.
58    let type_name_suffix = config.output_options.type_name_suffix.as_deref();
59    // Resolve the type names one time and share them. An unresolved collision is
60    // held inside `names` and reported below, after pruning decides which models
61    // the file holds.
62    let names = lower::type_renames(&spec, type_name_suffix)?;
63    let mut module = lower::generate_models(&spec, &names)?;
64    if want_server || want_client {
65        let response_type_suffix = config
66            .output_options
67            .response_type_suffix
68            .as_deref()
69            .filter(|suffix| return !suffix.is_empty())
70            .unwrap_or(crate::config::DEFAULT_RESPONSE_SUFFIX);
71        let mut service = lower::generate_service(&spec, &config.import_mapping, response_type_suffix)?;
72        lower::rewrite_service(&mut service, names.renames());
73        if !config.output_options.skip_prune {
74            lower::prune_unused_models(&mut module, &service);
75        }
76        // The module is final here, so a collision between two pruned schemas is
77        // no longer a problem and only a surviving one is reported. With
78        // `skip-prune` the module holds every schema, so every collision reports.
79        names.check_emitted(&module)?;
80        // A hoisted inline type carries no component name, so the resolution pass
81        // above cannot see it. The final item names can still hold a duplicate.
82        lower::check_duplicate_models(&module)?;
83        // After pruning, so a cycle among dropped models is not reported.
84        lower::box_recursive_types(&mut module)?;
85        let targets = emit::Targets {
86            server: want_server,
87            client: want_client,
88        };
89        lower::check_type_name_collisions(&service, &module, &emit::reserved_type_names(targets))?;
90        lower::check_prelude_shadowing(&module, targets)?;
91        return emit::emit_flat(&module, &service, server_urls.as_ref(), targets);
92    }
93    // Models-only generation prunes nothing, so the module holds every schema and
94    // every collision reports.
95    names.check_emitted(&module)?;
96    lower::check_duplicate_models(&module)?;
97    lower::check_prelude_shadowing(&module, emit::Targets::default())?;
98    lower::box_recursive_types(&mut module)?;
99    return emit::emit_module(&module, server_urls.as_ref());
100}
101
102/// Generate Rust from a spec file according to `configuration` and write it to
103/// `output_path`, creating parent directories as needed.
104pub fn generate_to_file(spec_path: &Path, config: &Config, output_path: &Path) -> Result<()> {
105    let code = generate(spec_path, config)?;
106    return write_output(output_path, &code);
107}
108
109/// Generate Rust models from a spec file and return the formatted source.
110///
111/// This entry point takes no config, so two schema names that collapse onto one
112/// Rust identifier are an error. Every schema becomes an item, because no
113/// operation exists to prune against. Use [`generate`] with
114/// `output-options.type-name-suffix` to resolve such a collision by config.
115pub fn generate_models_string(spec_path: &Path) -> Result<String> {
116    let spec = Spec::load(spec_path)?;
117    let names = lower::type_renames(&spec, None)?;
118    let mut module = lower::generate_models(&spec, &names)?;
119    // Every schema becomes an item here, so every collision reaches the file.
120    names.check_emitted(&module)?;
121    lower::check_duplicate_models(&module)?;
122    lower::check_prelude_shadowing(&module, emit::Targets::default())?;
123    lower::box_recursive_types(&mut module)?;
124    let code = emit::emit_module(&module, None)?;
125    return Ok(code);
126}
127
128/// Generate Rust models from a spec file and write them to `output_path`,
129/// creating parent directories as needed.
130pub fn generate_models_to_file(spec_path: &Path, output_path: &Path) -> Result<()> {
131    let code = generate_models_string(spec_path)?;
132    return write_output(output_path, &code);
133}
134
135/// What a comparison of generated code against an output file found.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum Drift {
138    /// The output file holds the generated code.
139    None,
140    /// The output file does not exist. Generation creates it, so this is drift
141    /// and not a read failure.
142    Absent,
143    /// The output file exists and holds different content.
144    Differs,
145}
146
147/// Compare `code` with the content of `output_path` and report the difference.
148///
149/// This reads the file and writes nothing, so a caller can gate a build on stale
150/// generated code. The comparison is exact, because the generator formats every
151/// output through `prettyplease` and therefore produces one byte sequence for one
152/// input.
153///
154/// The comparison reads bytes and not text. Generated Rust is always UTF-8, so a
155/// file that is not gives [`Drift::Differs`]. That is what the file is, and it
156/// also keeps a hand-edited or truncated file on the drift path where the remedy
157/// applies, rather than on the error path where it does not.
158///
159/// # Errors
160///
161/// Returns [`Error::ReadOutput`] when the file exists and cannot be read, for
162/// example a directory in place of a file. An absent file gives [`Drift::Absent`]
163/// and not an error, because generation creates it.
164pub fn check_output(output_path: &Path, code: &str) -> Result<Drift> {
165    let existing = match std::fs::read(output_path) {
166        Ok(existing) => existing,
167        Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
168            return Ok(Drift::Absent);
169        }
170        Err(source) => {
171            return Err(Error::ReadOutput {
172                path: output_path.display().to_string(),
173                source,
174            });
175        }
176    };
177    if existing == code.as_bytes() {
178        return Ok(Drift::None);
179    }
180    return Ok(Drift::Differs);
181}
182
183/// Write generated source to `output_path`, creating parent directories.
184pub fn write_output(output_path: &Path, code: &str) -> Result<()> {
185    if let Some(parent) = output_path.parent()
186        && !parent.as_os_str().is_empty()
187    {
188        std::fs::create_dir_all(parent).map_err(|source| {
189            return Error::WriteOutput {
190                path: output_path.display().to_string(),
191                source,
192            };
193        })?;
194    }
195    std::fs::write(output_path, code).map_err(|source| {
196        return Error::WriteOutput {
197            path: output_path.display().to_string(),
198            source,
199        };
200    })?;
201    return Ok(());
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    /// A directory under the temp directory that goes away with the test.
209    struct TestDir {
210        path: std::path::PathBuf,
211    }
212
213    impl TestDir {
214        fn new(test_name: &str) -> Self {
215            let unique = format!(
216                "oapi-codegen-check-{test_name}-{}-{}",
217                std::process::id(),
218                std::time::SystemTime::now()
219                    .duration_since(std::time::UNIX_EPOCH)
220                    .expect("system clock should be after Unix epoch")
221                    .as_nanos(),
222            );
223            let path = std::env::temp_dir().join(unique);
224            std::fs::create_dir_all(&path).expect("create test directory");
225            return Self { path };
226        }
227
228        fn join(&self, file: &str) -> std::path::PathBuf {
229            return self.path.join(file);
230        }
231    }
232
233    impl Drop for TestDir {
234        fn drop(&mut self) {
235            let _ = std::fs::remove_dir_all(&self.path);
236        }
237    }
238
239    #[test]
240    fn check_output_reports_an_absent_file_as_drift() {
241        let dir = TestDir::new("absent");
242        // Generation creates the file, so absence is drift and not a read failure.
243        let drift = check_output(&dir.join("out.rs"), "pub struct Widget;\n").expect("check an absent file");
244        assert_eq!(drift, Drift::Absent);
245    }
246
247    #[test]
248    fn check_output_reports_equal_content_as_no_drift() {
249        let dir = TestDir::new("equal");
250        let path = dir.join("out.rs");
251        let code = "pub struct Widget;\n";
252        std::fs::write(&path, code).expect("write the output file");
253        let drift = check_output(&path, code).expect("check an equal file");
254        assert_eq!(drift, Drift::None);
255    }
256
257    #[test]
258    fn check_output_reports_different_content_as_drift() {
259        let dir = TestDir::new("differs");
260        let path = dir.join("out.rs");
261        std::fs::write(&path, "pub struct Widget;\n").expect("write the output file");
262        let drift = check_output(&path, "pub struct Gadget;\n").expect("check a stale file");
263        assert_eq!(drift, Drift::Differs);
264    }
265
266    #[test]
267    fn check_output_compares_exactly() {
268        let dir = TestDir::new("exact");
269        let path = dir.join("out.rs");
270        // The generator formats every output, so one input gives one byte
271        // sequence. A trailing newline is therefore a real difference and not
272        // noise to normalise away.
273        std::fs::write(&path, "pub struct Widget;").expect("write the output file");
274        let drift = check_output(&path, "pub struct Widget;\n").expect("check a file with no trailing newline");
275        assert_eq!(drift, Drift::Differs);
276    }
277
278    #[test]
279    fn check_output_reports_content_that_is_not_utf8_as_drift() {
280        let dir = TestDir::new("not-utf8");
281        let path = dir.join("out.rs");
282        // Generated Rust is always UTF-8, so such a file is a differing file and
283        // not an unreadable one. The remedy for drift applies, and the remedy for
284        // a read failure does not.
285        std::fs::write(&path, [0xFF_u8, 0xFE_u8]).expect("write the output file");
286        let drift = check_output(&path, "pub struct Widget;\n").expect("check a file that is not UTF-8");
287        assert_eq!(drift, Drift::Differs);
288    }
289
290    #[test]
291    fn check_output_writes_nothing() {
292        let dir = TestDir::new("readonly");
293        let path = dir.join("out.rs");
294        let existing = "pub struct Widget;\n";
295        std::fs::write(&path, existing).expect("write the output file");
296        let drift = check_output(&path, "pub struct Gadget;\n").expect("check a stale file");
297        assert_eq!(drift, Drift::Differs);
298        let after = std::fs::read_to_string(&path).expect("read the output file back");
299        assert_eq!(after, existing, "`check_output` must not change the file");
300    }
301
302    #[test]
303    fn check_output_fails_on_a_path_it_cannot_read() {
304        let dir = TestDir::new("unreadable");
305        let path = dir.join("out.rs");
306        // A directory exists but holds no string content, so this is a read
307        // failure and not drift.
308        std::fs::create_dir(&path).expect("create a directory where a file belongs");
309        let error = check_output(&path, "pub struct Widget;\n").expect_err("a directory is not readable as a file");
310        assert!(
311            matches!(error, Error::ReadOutput { .. }),
312            "expected `ReadOutput`, got {error:?}"
313        );
314    }
315}