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