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