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;
18pub mod package;
19
20use std::path::Path;
21
22pub use crate::config::Config;
23pub use crate::error::Error;
24pub use crate::error::Result;
25use crate::ir::Module;
26use crate::ir::ServerUrls;
27use crate::ir::Service;
28use crate::loader::Spec;
29pub use crate::package::GeneratedFile;
30pub use crate::package::GeneratedPackage;
31pub use crate::package::PackageDrift;
32pub use crate::package::check_package;
33pub use crate::package::write_package;
34
35/// Everything one run lowers from a spec, ready for either output layout.
36enum Lowered {
37    /// The run emits models, and optionally server-URL constants, only.
38    Models {
39        /// The component models to emit.
40        module: Module,
41        /// The server-URL items, when the feature is enabled.
42        server_urls: Option<ServerUrls>,
43    },
44    /// The run emits a service: models, per-operation types, and at least one
45    /// of the server and client interfaces.
46    Service {
47        /// The component models the service references.
48        module: Module,
49        /// The lowered operations.
50        service: Service,
51        /// The server-URL items, when the feature is enabled.
52        server_urls: Option<ServerUrls>,
53        /// Which generator interfaces the configuration asked for.
54        targets: emit::Targets,
55    },
56}
57
58/// Load a spec and lower it according to `config`, stopping before emission.
59///
60/// Both output layouts run exactly the same pipeline, so this holds every step
61/// between loading and emitting: filtering, name resolution, lowering, pruning,
62/// and the validation passes that reject a spec the emitter cannot express.
63fn lower_spec(spec_path: &Path, config: &Config) -> Result<Lowered> {
64    if config.generate.embedded_spec {
65        return Err(Error::Unimplemented("embedded-spec".to_owned()));
66    }
67    let mut spec = Spec::load(spec_path)?;
68    spec.apply_filters(&config.output_options);
69    let want_server = config.generate.std_http_server;
70    let want_client = config.generate.client;
71    let server_urls = if config.generate.server_urls {
72        lower::lower_server_urls(&spec)?
73    } else {
74        None
75    };
76    // A run that emits no type resolves no type name. `server-urls` on its own
77    // emits constants only, so it must not read `type-name-suffix` and must not
78    // report a collision between two schemas that it never looks at. This matches
79    // `response-type-suffix`, which only a server or client run reads.
80    if !(config.generate.models || want_server || want_client) {
81        return Ok(Lowered::Models {
82            module: Module::default(),
83            server_urls,
84        });
85    }
86    // A set but useless suffix is an error, and not silently "unset". The
87    // resolution checks it, so every caller of `type_renames` gets the check.
88    // See `lower::rename::checked_suffix`.
89    let type_name_suffix = config.output_options.type_name_suffix.as_deref();
90    // Resolve the type names one time and share them. An unresolved collision is
91    // held inside `names` and reported below, after pruning decides which models
92    // the file holds.
93    let names = lower::type_renames(&spec, type_name_suffix)?;
94    let mut module = lower::generate_models(&spec, &names)?;
95    if want_server || want_client {
96        let response_type_suffix = config
97            .output_options
98            .response_type_suffix
99            .as_deref()
100            .filter(|suffix| return !suffix.is_empty())
101            .unwrap_or(crate::config::DEFAULT_RESPONSE_SUFFIX);
102        let mut service = lower::generate_service(&spec, &config.import_mapping, response_type_suffix)?;
103        lower::rewrite_service(&mut service, names.renames());
104        if !config.output_options.skip_prune {
105            lower::prune_unused_models(&mut module, &service);
106        }
107        // The module is final here, so a collision between two pruned schemas is
108        // no longer a problem and only a surviving one is reported. With
109        // `skip-prune` the module holds every schema, so every collision reports.
110        names.check_emitted(&module)?;
111        // A hoisted inline type carries no component name, so the resolution pass
112        // above cannot see it. The final item names can still hold a duplicate.
113        lower::check_duplicate_models(&module)?;
114        // After pruning, so a cycle among dropped models is not reported.
115        lower::box_recursive_types(&mut module)?;
116        let targets = emit::Targets {
117            server: want_server,
118            client: want_client,
119        };
120        lower::check_type_name_collisions(&service, &module, &emit::reserved_type_names(targets))?;
121        lower::check_prelude_shadowing(&module, targets)?;
122        return Ok(Lowered::Service {
123            module,
124            service,
125            server_urls,
126            targets,
127        });
128    }
129    // Models-only generation prunes nothing, so the module holds every schema and
130    // every collision reports.
131    names.check_emitted(&module)?;
132    lower::check_duplicate_models(&module)?;
133    lower::check_prelude_shadowing(&module, emit::Targets::default())?;
134    lower::box_recursive_types(&mut module)?;
135    return Ok(Lowered::Models { module, server_urls });
136}
137
138/// Generate Rust from a spec file according to `configuration`, returning the source.
139///
140/// Models are emitted when `generate.models` is set, or implicitly when the
141/// server or client is generated (so referenced types are in scope). The axum
142/// server interface is appended when `generate.std-http-server` is set. The
143/// blocking `reqwest` client is appended when `generate.client` is set. Models,
144/// per-operation types, and both generators are emitted flat at the crate root,
145/// so server and client can share one file.
146///
147/// Use [`generate_package`] for the layout the CLI writes, which splits the same
148/// items across a module tree.
149pub fn generate(spec_path: &Path, config: &Config) -> Result<String> {
150    return match lower_spec(spec_path, config)? {
151        Lowered::Models { module, server_urls } => emit::emit_module(&module, server_urls.as_ref()),
152        Lowered::Service {
153            module,
154            service,
155            server_urls,
156            targets,
157        } => emit::emit_flat(&module, &service, server_urls.as_ref(), targets),
158    };
159}
160
161/// Generate Rust from a spec file according to `configuration`, returning every
162/// file the run produces.
163///
164/// A run that lowers operations splits its output across a module tree: the file
165/// at `output_path` becomes a facade of re-exports, and a companion directory
166/// named after that file's stem holds one module per concern (`models`,
167/// `operations`, `server`, `client`, `server_urls`) with one file per operation
168/// underneath. Every generated name stays reachable from the root file, so a
169/// consumer that already mounts it needs no change.
170///
171/// `output_path` is read for its file stem only, which names that companion
172/// directory. Nothing is read from or written to disk here; see
173/// [`write_package`] and [`check_package`].
174///
175/// A models-only run has no operations to split, so it produces the single file
176/// it always has.
177pub fn generate_package(spec_path: &Path, config: &Config, output_path: &Path) -> Result<GeneratedPackage> {
178    return match lower_spec(spec_path, config)? {
179        Lowered::Models { module, server_urls } => Ok(GeneratedPackage::new(
180            emit::emit_module(&module, server_urls.as_ref())?,
181            Vec::new(),
182        )),
183        Lowered::Service {
184            module,
185            service,
186            server_urls,
187            targets,
188        } => {
189            let stem = package::companion_of(output_path)?
190                .file_name()
191                .map(|stem| return stem.to_string_lossy().into_owned())
192                .ok_or_else(|| {
193                    return Error::UnsplittableOutput {
194                        path: output_path.display().to_string(),
195                    };
196                })?;
197            emit::emit_package(&module, &service, server_urls.as_ref(), targets, &stem)
198        }
199    };
200}
201
202/// Generate Rust from a spec file according to `configuration` and write it to
203/// `output_path`, creating parent directories as needed.
204pub fn generate_to_file(spec_path: &Path, config: &Config, output_path: &Path) -> Result<()> {
205    let code = generate(spec_path, config)?;
206    return write_output(output_path, &code);
207}
208
209/// Generate Rust models from a spec file and return the formatted source.
210///
211/// This entry point takes no config, so two schema names that collapse onto one
212/// Rust identifier are an error. Every schema becomes an item, because no
213/// operation exists to prune against. Use [`generate`] with
214/// `output-options.type-name-suffix` to resolve such a collision by config.
215pub fn generate_models_string(spec_path: &Path) -> Result<String> {
216    let spec = Spec::load(spec_path)?;
217    let names = lower::type_renames(&spec, None)?;
218    let mut module = lower::generate_models(&spec, &names)?;
219    // Every schema becomes an item here, so every collision reaches the file.
220    names.check_emitted(&module)?;
221    lower::check_duplicate_models(&module)?;
222    lower::check_prelude_shadowing(&module, emit::Targets::default())?;
223    lower::box_recursive_types(&mut module)?;
224    let code = emit::emit_module(&module, None)?;
225    return Ok(code);
226}
227
228/// Generate Rust models from a spec file and write them to `output_path`,
229/// creating parent directories as needed.
230pub fn generate_models_to_file(spec_path: &Path, output_path: &Path) -> Result<()> {
231    let code = generate_models_string(spec_path)?;
232    return write_output(output_path, &code);
233}
234
235/// What a comparison of generated code against an output file found.
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum Drift {
238    /// The output file holds the generated code.
239    None,
240    /// The output file does not exist. Generation creates it, so this is drift
241    /// and not a read failure.
242    Absent,
243    /// The output file exists and holds different content.
244    Differs,
245}
246
247/// Compare `code` with the content of `output_path` and report the difference.
248///
249/// This reads the file and writes nothing, so a caller can gate a build on stale
250/// generated code. The comparison is exact, because the generator formats every
251/// output through `prettyplease` and therefore produces one byte sequence for one
252/// input.
253///
254/// The comparison reads bytes and not text. Generated Rust is always UTF-8, so a
255/// file that is not gives [`Drift::Differs`]. That is what the file is, and it
256/// also keeps a hand-edited or truncated file on the drift path where the remedy
257/// applies, rather than on the error path where it does not.
258///
259/// # Errors
260///
261/// Returns [`Error::ReadOutput`] when the file exists and cannot be read, for
262/// example a directory in place of a file. An absent file gives [`Drift::Absent`]
263/// and not an error, because generation creates it.
264pub fn check_output(output_path: &Path, code: &str) -> Result<Drift> {
265    let existing = match std::fs::read(output_path) {
266        Ok(existing) => existing,
267        Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
268            return Ok(Drift::Absent);
269        }
270        Err(source) => {
271            return Err(Error::ReadOutput {
272                path: output_path.display().to_string(),
273                source,
274            });
275        }
276    };
277    if existing == code.as_bytes() {
278        return Ok(Drift::None);
279    }
280    return Ok(Drift::Differs);
281}
282
283/// Write generated source to `output_path`, creating parent directories.
284pub fn write_output(output_path: &Path, code: &str) -> Result<()> {
285    if let Some(parent) = output_path.parent()
286        && !parent.as_os_str().is_empty()
287    {
288        std::fs::create_dir_all(parent).map_err(|source| {
289            return Error::WriteOutput {
290                path: output_path.display().to_string(),
291                source,
292            };
293        })?;
294    }
295    std::fs::write(output_path, code).map_err(|source| {
296        return Error::WriteOutput {
297            path: output_path.display().to_string(),
298            source,
299        };
300    })?;
301    return Ok(());
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    /// A directory under the temp directory that goes away with the test.
309    struct TestDir {
310        path: std::path::PathBuf,
311    }
312
313    impl TestDir {
314        fn new(test_name: &str) -> Self {
315            let unique = format!(
316                "oapi-codegen-check-{test_name}-{}-{}",
317                std::process::id(),
318                std::time::SystemTime::now()
319                    .duration_since(std::time::UNIX_EPOCH)
320                    .expect("system clock should be after Unix epoch")
321                    .as_nanos(),
322            );
323            let path = std::env::temp_dir().join(unique);
324            std::fs::create_dir_all(&path).expect("create test directory");
325            return Self { path };
326        }
327
328        fn join(&self, file: &str) -> std::path::PathBuf {
329            return self.path.join(file);
330        }
331    }
332
333    impl Drop for TestDir {
334        fn drop(&mut self) {
335            let _ = std::fs::remove_dir_all(&self.path);
336        }
337    }
338
339    #[test]
340    fn check_output_reports_an_absent_file_as_drift() {
341        let dir = TestDir::new("absent");
342        // Generation creates the file, so absence is drift and not a read failure.
343        let drift = check_output(&dir.join("out.rs"), "pub struct Widget;\n").expect("check an absent file");
344        assert_eq!(drift, Drift::Absent);
345    }
346
347    #[test]
348    fn check_output_reports_equal_content_as_no_drift() {
349        let dir = TestDir::new("equal");
350        let path = dir.join("out.rs");
351        let code = "pub struct Widget;\n";
352        std::fs::write(&path, code).expect("write the output file");
353        let drift = check_output(&path, code).expect("check an equal file");
354        assert_eq!(drift, Drift::None);
355    }
356
357    #[test]
358    fn check_output_reports_different_content_as_drift() {
359        let dir = TestDir::new("differs");
360        let path = dir.join("out.rs");
361        std::fs::write(&path, "pub struct Widget;\n").expect("write the output file");
362        let drift = check_output(&path, "pub struct Gadget;\n").expect("check a stale file");
363        assert_eq!(drift, Drift::Differs);
364    }
365
366    #[test]
367    fn check_output_compares_exactly() {
368        let dir = TestDir::new("exact");
369        let path = dir.join("out.rs");
370        // The generator formats every output, so one input gives one byte
371        // sequence. A trailing newline is therefore a real difference and not
372        // noise to normalise away.
373        std::fs::write(&path, "pub struct Widget;").expect("write the output file");
374        let drift = check_output(&path, "pub struct Widget;\n").expect("check a file with no trailing newline");
375        assert_eq!(drift, Drift::Differs);
376    }
377
378    #[test]
379    fn check_output_reports_content_that_is_not_utf8_as_drift() {
380        let dir = TestDir::new("not-utf8");
381        let path = dir.join("out.rs");
382        // Generated Rust is always UTF-8, so such a file is a differing file and
383        // not an unreadable one. The remedy for drift applies, and the remedy for
384        // a read failure does not.
385        std::fs::write(&path, [0xFF_u8, 0xFE_u8]).expect("write the output file");
386        let drift = check_output(&path, "pub struct Widget;\n").expect("check a file that is not UTF-8");
387        assert_eq!(drift, Drift::Differs);
388    }
389
390    #[test]
391    fn check_output_writes_nothing() {
392        let dir = TestDir::new("readonly");
393        let path = dir.join("out.rs");
394        let existing = "pub struct Widget;\n";
395        std::fs::write(&path, existing).expect("write the output file");
396        let drift = check_output(&path, "pub struct Gadget;\n").expect("check a stale file");
397        assert_eq!(drift, Drift::Differs);
398        let after = std::fs::read_to_string(&path).expect("read the output file back");
399        assert_eq!(after, existing, "`check_output` must not change the file");
400    }
401
402    #[test]
403    fn check_output_fails_on_a_path_it_cannot_read() {
404        let dir = TestDir::new("unreadable");
405        let path = dir.join("out.rs");
406        // A directory exists but holds no string content, so this is a read
407        // failure and not drift.
408        std::fs::create_dir(&path).expect("create a directory where a file belongs");
409        let error = check_output(&path, "pub struct Widget;\n").expect_err("a directory is not readable as a file");
410        assert!(
411            matches!(error, Error::ReadOutput { .. }),
412            "expected `ReadOutput`, got {error:?}"
413        );
414    }
415}