pub mod cli;
pub mod config;
mod coverage;
pub mod deps;
mod diagnostic;
pub mod emit;
pub mod error;
pub mod filter;
pub mod ir;
pub mod loader;
pub mod lower;
pub mod naming;
pub mod package;
use std::path::Path;
pub use crate::config::Config;
pub use crate::error::Error;
pub use crate::error::Result;
use crate::ir::Module;
use crate::ir::ServerUrls;
use crate::ir::Service;
use crate::loader::Spec;
pub use crate::package::GeneratedFile;
pub use crate::package::GeneratedPackage;
pub use crate::package::PackageDrift;
pub use crate::package::check_package;
pub use crate::package::write_package;
pub fn warnings_reported() -> usize {
return diagnostic::reported_count();
}
enum Lowered {
Models {
module: Module,
server_urls: Option<ServerUrls>,
},
Service {
module: Module,
service: Service,
server_urls: Option<ServerUrls>,
targets: emit::Targets,
},
}
fn lower_spec(spec_path: &Path, config: &Config) -> Result<Lowered> {
if config.generate.embedded_spec {
return Err(Error::Unimplemented("embedded-spec".to_owned()));
}
let want_server = config.generate.std_http_server;
let mut spec = Spec::load_for(spec_path, want_server, &config.output_options)?;
spec.apply_filters(&config.output_options);
let want_client = config.generate.client;
let server_urls = if config.generate.server_urls {
lower::lower_server_urls(&spec)?
} else {
None
};
if !(config.generate.models || want_server || want_client) {
return Ok(Lowered::Models {
module: Module::default(),
server_urls,
});
}
let type_name_suffix = config.output_options.type_name_suffix.as_deref();
let names = lower::type_renames(&spec, type_name_suffix)?;
let mut module = lower::generate_models(&spec, &names)?;
if want_server || want_client {
let response_type_suffix = config
.output_options
.response_type_suffix
.as_deref()
.filter(|suffix| return !suffix.is_empty())
.unwrap_or(crate::config::DEFAULT_RESPONSE_SUFFIX);
let mut service = lower::generate_service(&spec, &config.import_mapping, response_type_suffix)?;
lower::rewrite_service(&mut service, names.renames());
lower::split_by_direction(&mut module, Some(&mut service));
if !config.output_options.skip_prune {
lower::prune_unused_models(&mut module, &service);
}
names.check_emitted(&module)?;
lower::check_duplicate_models(&module)?;
lower::box_recursive_types(&mut module)?;
let targets = emit::Targets {
server: want_server,
client: want_client,
};
let mut reserved = emit::reserved_type_names(targets);
if !emit::uses_nullable(&module, Some(&service)) {
reserved.retain(|name| return name.name != "Nullable");
}
lower::check_type_name_collisions(&service, &module, &reserved)?;
lower::check_prelude_shadowing(&module, targets)?;
return Ok(Lowered::Service {
module,
service,
server_urls,
targets,
});
}
lower::split_by_direction(&mut module, None);
names.check_emitted(&module)?;
lower::check_duplicate_models(&module)?;
if emit::uses_nullable(&module, None) {
lower::check_type_name_collisions(
&Service::default(),
&module,
&emit::reserved_type_names(emit::Targets::default()),
)?;
}
lower::check_prelude_shadowing(&module, emit::Targets::default())?;
lower::box_recursive_types(&mut module)?;
return Ok(Lowered::Models { module, server_urls });
}
pub fn generate(spec_path: &Path, config: &Config) -> Result<String> {
return match lower_spec(spec_path, config)? {
Lowered::Models { module, server_urls } => emit::emit_module(&module, server_urls.as_ref()),
Lowered::Service {
module,
service,
server_urls,
targets,
} => emit::emit_flat(&module, &service, server_urls.as_ref(), targets),
};
}
pub fn generate_package(spec_path: &Path, config: &Config, output_path: &Path) -> Result<GeneratedPackage> {
return match lower_spec(spec_path, config)? {
Lowered::Models { module, server_urls } => Ok(GeneratedPackage::new(
emit::emit_module(&module, server_urls.as_ref())?,
Vec::new(),
)),
Lowered::Service {
module,
service,
server_urls,
targets,
} => {
let stem = package::companion_of(output_path)?
.file_name()
.map(|stem| return stem.to_string_lossy().into_owned())
.ok_or_else(|| {
return Error::UnsplittableOutput {
path: output_path.display().to_string(),
};
})?;
emit::emit_package(&module, &service, server_urls.as_ref(), targets, &stem)
}
};
}
pub fn generate_to_file(spec_path: &Path, config: &Config, output_path: &Path) -> Result<()> {
let code = generate(spec_path, config)?;
return write_output(output_path, &code);
}
pub fn generate_models_string(spec_path: &Path) -> Result<String> {
let spec = Spec::load(spec_path)?;
let names = lower::type_renames(&spec, None)?;
let mut module = lower::generate_models(&spec, &names)?;
lower::split_by_direction(&mut module, None);
names.check_emitted(&module)?;
lower::check_duplicate_models(&module)?;
lower::check_prelude_shadowing(&module, emit::Targets::default())?;
lower::box_recursive_types(&mut module)?;
let code = emit::emit_module(&module, None)?;
return Ok(code);
}
pub fn generate_models_to_file(spec_path: &Path, output_path: &Path) -> Result<()> {
let code = generate_models_string(spec_path)?;
return write_output(output_path, &code);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Drift {
None,
Absent,
Differs,
}
pub fn check_output(output_path: &Path, code: &str) -> Result<Drift> {
let existing = match std::fs::read(output_path) {
Ok(existing) => existing,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
return Ok(Drift::Absent);
}
Err(source) => {
return Err(Error::ReadOutput {
path: output_path.display().to_string(),
source,
});
}
};
if existing == code.as_bytes() {
return Ok(Drift::None);
}
return Ok(Drift::Differs);
}
pub fn write_output(output_path: &Path, code: &str) -> Result<()> {
if let Some(parent) = output_path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|source| {
return Error::WriteOutput {
path: output_path.display().to_string(),
source,
};
})?;
}
std::fs::write(output_path, code).map_err(|source| {
return Error::WriteOutput {
path: output_path.display().to_string(),
source,
};
})?;
return Ok(());
}
#[cfg(test)]
mod tests {
use super::*;
struct TestDir {
path: std::path::PathBuf,
}
impl TestDir {
fn new(test_name: &str) -> Self {
let unique = format!(
"oapi-codegen-check-{test_name}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after Unix epoch")
.as_nanos(),
);
let path = std::env::temp_dir().join(unique);
std::fs::create_dir_all(&path).expect("create test directory");
return Self { path };
}
fn join(&self, file: &str) -> std::path::PathBuf {
return self.path.join(file);
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
#[test]
fn a_wrapped_reference_generates_what_the_bare_reference_generates() {
const NOTE: &str = "the note beside the reference";
let spec = |property: &str| {
return format!(
"openapi: 3.0.3
info: {{title: Demo, version: 1.0.0}}
paths:
/holders:
post:
operationId: putHolder
requestBody:
required: true
content: {{application/json: {{schema: {{$ref: '#/components/schemas/Holder'}}}}}}
responses:
'200':
description: ok
content: {{application/json: {{schema: {{$ref: '#/components/schemas/Holder'}}}}}}
components:
schemas:
Plain: {{type: string, maxLength: 3}}
Served: {{type: string, readOnly: true}}
Sent: {{type: string, writeOnly: true}}
Holder:
type: object
required: [plain, served]
properties:
plain: {}
served: {}
sent: {}
",
property.replace("NAME", "Plain"),
property.replace("NAME", "Served"),
property.replace("NAME", "Sent"),
);
};
let dir = TestDir::new("wrapped-reference");
std::fs::write(
dir.join("config.yaml"),
"package: demo\ngenerate: {models: true, std-http-server: true, client: true}\n",
)
.expect("write the configuration");
let config = Config::load(&dir.join("config.yaml")).expect("valid configuration");
let generate_from = |property: &str| {
std::fs::write(dir.join("spec.yaml"), spec(property)).expect("write the spec");
return generate(&dir.join("spec.yaml"), &config).expect("the spec generates");
};
let bare = generate_from("{$ref: '#/components/schemas/NAME'}");
let wrapped = generate_from(&format!(
"{{allOf: [{{$ref: '#/components/schemas/NAME'}}], description: {NOTE}}}"
));
assert!(bare.contains("HolderRequest"), "the marked targets split the model");
let without_notes: Vec<&str> = wrapped.lines().filter(|line| return !line.contains(NOTE)).collect();
assert_eq!(without_notes, bare.lines().collect::<Vec<&str>>());
assert!(wrapped.contains(NOTE), "the description reaches the output");
let beside = generate_from(&format!("{{$ref: '#/components/schemas/NAME', description: {NOTE}}}"));
assert_eq!(beside, wrapped);
}
#[test]
fn a_described_reference_merges_and_uploads_like_the_bare_reference() {
const NOTE: &str = "the note beside the reference";
const TARGET_NOTE: &str = "The kinds there are.";
let spec = |property: &str, kind: &str| {
let wrapper_note = if property.contains(NOTE) {
format!(", description: {NOTE}")
} else {
String::new()
};
return format!(
"openapi: 3.0.3
info: {{title: Demo, version: 1.0.0}}
paths:
/uploads:
post:
operationId: upload
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [kind]
properties:
kind: {property}
file: {{type: string, format: binary}}
responses:
'200':
description: ok
content: {{application/json: {{schema: {{$ref: '#/components/schemas/Merged'}}}}}}
'201':
description: made
content: {{application/json: {{schema: {{$ref: '#/components/schemas/Both'}}}}}}
'202':
description: queued
content: {{application/json: {{schema: {{$ref: '#/components/schemas/Across'}}}}}}
components:
schemas:
Kind: {kind}
SameKind: {{$ref: '#/components/schemas/Kind'}}
Plain: {{type: string}}
Base: {{type: object, properties: {{kind: {{$ref: '#/components/schemas/Kind'}}, other: {{$ref: '#/components/schemas/SameKind'}}, maybe: {{allOf: [{{$ref: '#/components/schemas/Plain'}}], nullable: true}}}}}}
Extra: {{type: object, properties: {{kind: {property}, other: {property}, maybe: {{allOf: [{{$ref: '#/components/schemas/Plain'}}], nullable: true{wrapper_note}}}}}}}
Third: {{type: object, properties: {{other: {{$ref: '#/components/schemas/Kind'}}}}}}
Half: {{type: object, properties: {{h: {{type: string}}}}}}
Pair: {{allOf: [{{$ref: '#/components/schemas/Half'}}, {{$ref: '#/components/schemas/Half'}}]}}
Left: {{type: object, properties: {{pair: {{$ref: '#/components/schemas/Pair'}}}}}}
Right: {{type: object, properties: {{pair: {{allOf: [{{$ref: '#/components/schemas/Half'}}, {{$ref: '#/components/schemas/Half'}}]{wrapper_note}}}}}}}
Both: {{allOf: [{{$ref: '#/components/schemas/Left'}}, {{$ref: '#/components/schemas/Right'}}]}}
CompA: {{allOf: [{{type: object, properties: {{p: {{$ref: '#/components/schemas/Kind'}}}}}}, {{$ref: '#/components/schemas/Half'}}]}}
CompB: {{allOf: [{{type: object, properties: {{p: {property}}}}}, {{$ref: '#/components/schemas/Half'}}]}}
Up: {{type: object, properties: {{comp: {{$ref: '#/components/schemas/CompA'}}}}}}
Down: {{type: object, properties: {{comp: {{$ref: '#/components/schemas/CompB'}}}}}}
Across: {{allOf: [{{$ref: '#/components/schemas/Up'}}, {{$ref: '#/components/schemas/Down'}}]}}
Merged: {{allOf: [{{$ref: '#/components/schemas/Base'}}, {{$ref: '#/components/schemas/Extra'}}, {{$ref: '#/components/schemas/Third'}}]}}
"
);
};
let dir = TestDir::new("described-reference");
std::fs::write(
dir.join("config.yaml"),
"package: demo\ngenerate: {models: true, std-http-server: true}\n",
)
.expect("write the configuration");
let config = Config::load(&dir.join("config.yaml")).expect("valid configuration");
let generate_from = |property: &str, kind: &str| {
std::fs::write(dir.join("spec.yaml"), spec(property, kind)).expect("write the spec");
return generate(&dir.join("spec.yaml"), &config).expect("the spec generates");
};
let bare_ref = "{$ref: '#/components/schemas/Kind'}";
let described_ref = format!("{{$ref: '#/components/schemas/Kind', description: {NOTE}}}");
let plain_kind = "{type: string, enum: [a, b]}";
let bare = generate_from(bare_ref, plain_kind);
let described = generate_from(&described_ref, plain_kind);
assert!(bare.contains("pub struct Merged"), "the overlap merges");
let without_notes: Vec<&str> = described.lines().filter(|line| return !line.contains(NOTE)).collect();
assert_eq!(without_notes, bare.lines().collect::<Vec<&str>>());
assert_eq!(described.matches(NOTE).count(), 5, "{described}");
assert!(described.contains("pub comp: Option<CompA>"), "{described}");
let described_kind = format!("{{type: string, enum: [a, b], description: {TARGET_NOTE}}}");
let documented = generate_from(&described_ref, &described_kind);
assert!(
documented.contains(&format!(
" /// {NOTE}\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub other"
)),
"{documented}"
);
assert!(
!documented.contains(&format!(
" /// {TARGET_NOTE}\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub other"
)),
"{documented}"
);
}
#[test]
fn a_read_only_wrapper_keeps_a_multipart_field_out_of_the_request() {
let dir = TestDir::new("multipart-wrapper-mark");
std::fs::write(
dir.join("spec.yaml"),
"openapi: 3.0.3
info: {title: Demo, version: 1.0.0}
paths:
/uploads:
post:
operationId: upload
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [served, sent, elsewhere]
properties:
served: {allOf: [{$ref: '#/components/schemas/Plain'}], readOnly: true}
sent: {allOf: [{$ref: '#/components/schemas/Plain'}], description: a note}
elsewhere: {allOf: [{$ref: 'other.yaml#/components/schemas/Plain'}], readOnly: true}
responses:
'204': {description: ok}
components:
schemas:
Plain: {type: string}
",
)
.expect("write the spec");
std::fs::write(
dir.join("other.yaml"),
"openapi: 3.0.3\ninfo: {title: Other, version: 1.0.0}\npaths: {}\ncomponents:\n schemas:\n Plain: {type: string}\n",
)
.expect("write the other document");
std::fs::write(
dir.join("config.yaml"),
"package: demo\ngenerate: {models: true, std-http-server: true}\n",
)
.expect("write the configuration");
let config = Config::load(&dir.join("config.yaml")).expect("valid configuration");
let code = generate(&dir.join("spec.yaml"), &config).expect("the spec generates");
assert!(code.contains("\"sent\""), "the plain field is a part: {code}");
assert!(!code.contains("\"served\""), "the read-only field is no part: {code}");
assert!(
!code.contains("\"elsewhere\""),
"the read-only cross-file field is no part: {code}"
);
for (wrapper, target) in [("readOnly", "writeOnly"), ("writeOnly", "readOnly")] {
std::fs::write(
dir.join("spec.yaml"),
format!(
"openapi: 3.0.3
info: {{title: Demo, version: 1.0.0}}
paths:
/uploads:
post:
operationId: upload
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
torn: {{allOf: [{{$ref: '#/components/schemas/Marked'}}], {wrapper}: true}}
responses:
'204': {{description: ok}}
components:
schemas:
Marked: {{type: string, {target}: true}}
"
),
)
.expect("write the spec");
let error = generate(&dir.join("spec.yaml"), &config).expect_err("both marks leave no direction");
assert!(error.to_string().contains("readOnly"), "{wrapper} on {target}: {error}");
}
}
#[test]
fn check_output_reports_an_absent_file_as_drift() {
let dir = TestDir::new("absent");
let drift = check_output(&dir.join("out.rs"), "pub struct Widget;\n").expect("check an absent file");
assert_eq!(drift, Drift::Absent);
}
#[test]
fn check_output_reports_equal_content_as_no_drift() {
let dir = TestDir::new("equal");
let path = dir.join("out.rs");
let code = "pub struct Widget;\n";
std::fs::write(&path, code).expect("write the output file");
let drift = check_output(&path, code).expect("check an equal file");
assert_eq!(drift, Drift::None);
}
#[test]
fn check_output_reports_different_content_as_drift() {
let dir = TestDir::new("differs");
let path = dir.join("out.rs");
std::fs::write(&path, "pub struct Widget;\n").expect("write the output file");
let drift = check_output(&path, "pub struct Gadget;\n").expect("check a stale file");
assert_eq!(drift, Drift::Differs);
}
#[test]
fn check_output_compares_exactly() {
let dir = TestDir::new("exact");
let path = dir.join("out.rs");
std::fs::write(&path, "pub struct Widget;").expect("write the output file");
let drift = check_output(&path, "pub struct Widget;\n").expect("check a file with no trailing newline");
assert_eq!(drift, Drift::Differs);
}
#[test]
fn check_output_reports_content_that_is_not_utf8_as_drift() {
let dir = TestDir::new("not-utf8");
let path = dir.join("out.rs");
std::fs::write(&path, [0xFF_u8, 0xFE_u8]).expect("write the output file");
let drift = check_output(&path, "pub struct Widget;\n").expect("check a file that is not UTF-8");
assert_eq!(drift, Drift::Differs);
}
#[test]
fn check_output_writes_nothing() {
let dir = TestDir::new("readonly");
let path = dir.join("out.rs");
let existing = "pub struct Widget;\n";
std::fs::write(&path, existing).expect("write the output file");
let drift = check_output(&path, "pub struct Gadget;\n").expect("check a stale file");
assert_eq!(drift, Drift::Differs);
let after = std::fs::read_to_string(&path).expect("read the output file back");
assert_eq!(after, existing, "`check_output` must not change the file");
}
#[test]
fn check_output_fails_on_a_path_it_cannot_read() {
let dir = TestDir::new("unreadable");
let path = dir.join("out.rs");
std::fs::create_dir(&path).expect("create a directory where a file belongs");
let error = check_output(&path, "pub struct Widget;\n").expect_err("a directory is not readable as a file");
assert!(
matches!(error, Error::ReadOutput { .. }),
"expected `ReadOutput`, got {error:?}"
);
}
}