1pub 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
37pub fn warnings_reported() -> usize {
43 return diagnostic::reported_count();
44}
45
46enum Lowered {
48 Models {
50 module: Module,
52 server_urls: Option<ServerUrls>,
54 },
55 Service {
58 module: Module,
60 service: Service,
62 server_urls: Option<ServerUrls>,
64 targets: emit::Targets,
66 },
67}
68
69fn lower_spec(spec_path: &Path, config: &Config) -> Result<Lowered> {
75 if config.generate.embedded_spec {
76 return Err(Error::Unimplemented("embedded-spec".to_owned()));
77 }
78 let want_server = config.generate.std_http_server;
79 let mut spec = Spec::load_with(spec_path, &config.generate, &config.output_options)?;
80 spec.apply_filters(&config.output_options);
81 let want_client = config.generate.client;
82 let server_urls = if config.generate.server_urls {
83 lower::lower_server_urls(&spec)?
84 } else {
85 None
86 };
87 if !(config.generate.models || want_server || want_client) {
92 return Ok(Lowered::Models {
93 module: Module::default(),
94 server_urls,
95 });
96 }
97 let type_name_suffix = config.output_options.type_name_suffix.as_deref();
101 let names = lower::type_renames(&spec, type_name_suffix)?;
105 let mut module = lower::generate_models(&spec, &names)?;
106 if want_server || want_client {
107 let response_type_suffix = config
108 .output_options
109 .response_type_suffix
110 .as_deref()
111 .filter(|suffix| return !suffix.is_empty())
112 .unwrap_or(crate::config::DEFAULT_RESPONSE_SUFFIX);
113 let mut service = lower::generate_service(&spec, &config.import_mapping, response_type_suffix)?;
114 lower::rewrite_service(&mut service, names.renames());
115 lower::split_by_direction(&mut module, Some(&mut service));
118 if !config.output_options.skip_prune {
119 lower::prune_unused_models(&mut module, &service);
120 }
121 names.check_emitted(&module)?;
125 lower::check_duplicate_models(&module)?;
128 lower::box_recursive_types(&mut module)?;
130 let targets = emit::Targets {
131 server: want_server,
132 client: want_client,
133 };
134 let mut reserved = emit::reserved_type_names(targets);
135 if !emit::uses_nullable(&module, Some(&service)) {
136 reserved.retain(|name| return name.name != "Nullable");
137 }
138 lower::check_type_name_collisions(&service, &module, &reserved)?;
139 lower::check_prelude_shadowing(&module, targets)?;
140 return Ok(Lowered::Service {
141 module,
142 service,
143 server_urls,
144 targets,
145 });
146 }
147 lower::split_by_direction(&mut module, None);
150 names.check_emitted(&module)?;
151 lower::check_duplicate_models(&module)?;
152 if emit::uses_nullable(&module, None) {
153 lower::check_type_name_collisions(
154 &Service::default(),
155 &module,
156 &emit::reserved_type_names(emit::Targets::default()),
157 )?;
158 }
159 lower::check_prelude_shadowing(&module, emit::Targets::default())?;
160 lower::box_recursive_types(&mut module)?;
161 return Ok(Lowered::Models { module, server_urls });
162}
163
164pub fn generate(spec_path: &Path, config: &Config) -> Result<String> {
176 return match lower_spec(spec_path, config)? {
177 Lowered::Models { module, server_urls } => emit::emit_module(&module, server_urls.as_ref()),
178 Lowered::Service {
179 module,
180 service,
181 server_urls,
182 targets,
183 } => emit::emit_flat(&module, &service, server_urls.as_ref(), targets),
184 };
185}
186
187pub fn generate_package(spec_path: &Path, config: &Config, output_path: &Path) -> Result<GeneratedPackage> {
204 return match lower_spec(spec_path, config)? {
205 Lowered::Models { module, server_urls } => Ok(GeneratedPackage::new(
206 emit::emit_module(&module, server_urls.as_ref())?,
207 Vec::new(),
208 )),
209 Lowered::Service {
210 module,
211 service,
212 server_urls,
213 targets,
214 } => {
215 let stem = package::companion_of(output_path)?
216 .file_name()
217 .map(|stem| return stem.to_string_lossy().into_owned())
218 .ok_or_else(|| {
219 return Error::UnsplittableOutput {
220 path: output_path.display().to_string(),
221 };
222 })?;
223 emit::emit_package(&module, &service, server_urls.as_ref(), targets, &stem)
224 }
225 };
226}
227
228pub fn generate_to_file(spec_path: &Path, config: &Config, output_path: &Path) -> Result<()> {
231 let code = generate(spec_path, config)?;
232 return write_output(output_path, &code);
233}
234
235pub fn generate_models_string(spec_path: &Path) -> Result<String> {
242 let spec = Spec::load(spec_path)?;
243 let names = lower::type_renames(&spec, None)?;
244 let mut module = lower::generate_models(&spec, &names)?;
245 lower::split_by_direction(&mut module, None);
246 names.check_emitted(&module)?;
248 lower::check_duplicate_models(&module)?;
249 lower::check_prelude_shadowing(&module, emit::Targets::default())?;
250 lower::box_recursive_types(&mut module)?;
251 let code = emit::emit_module(&module, None)?;
252 return Ok(code);
253}
254
255pub fn generate_models_to_file(spec_path: &Path, output_path: &Path) -> Result<()> {
258 let code = generate_models_string(spec_path)?;
259 return write_output(output_path, &code);
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub enum Drift {
265 None,
267 Absent,
270 Differs,
272}
273
274pub fn check_output(output_path: &Path, code: &str) -> Result<Drift> {
292 let existing = match std::fs::read(output_path) {
293 Ok(existing) => existing,
294 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
295 return Ok(Drift::Absent);
296 }
297 Err(source) => {
298 return Err(Error::ReadOutput {
299 path: output_path.display().to_string(),
300 source,
301 });
302 }
303 };
304 if existing == code.as_bytes() {
305 return Ok(Drift::None);
306 }
307 return Ok(Drift::Differs);
308}
309
310pub fn write_output(output_path: &Path, code: &str) -> Result<()> {
312 if let Some(parent) = output_path.parent()
313 && !parent.as_os_str().is_empty()
314 {
315 std::fs::create_dir_all(parent).map_err(|source| {
316 return Error::WriteOutput {
317 path: output_path.display().to_string(),
318 source,
319 };
320 })?;
321 }
322 std::fs::write(output_path, code).map_err(|source| {
323 return Error::WriteOutput {
324 path: output_path.display().to_string(),
325 source,
326 };
327 })?;
328 return Ok(());
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 struct TestDir {
337 path: std::path::PathBuf,
338 }
339
340 impl TestDir {
341 fn new(test_name: &str) -> Self {
342 let unique = format!(
343 "oapi-codegen-check-{test_name}-{}-{}",
344 std::process::id(),
345 std::time::SystemTime::now()
346 .duration_since(std::time::UNIX_EPOCH)
347 .expect("system clock should be after Unix epoch")
348 .as_nanos(),
349 );
350 let path = std::env::temp_dir().join(unique);
351 std::fs::create_dir_all(&path).expect("create test directory");
352 return Self { path };
353 }
354
355 fn join(&self, file: &str) -> std::path::PathBuf {
356 return self.path.join(file);
357 }
358 }
359
360 impl Drop for TestDir {
361 fn drop(&mut self) {
362 let _ = std::fs::remove_dir_all(&self.path);
363 }
364 }
365
366 #[test]
372 fn a_wrapped_reference_generates_what_the_bare_reference_generates() {
373 const NOTE: &str = "the note beside the reference";
374 let spec = |property: &str| {
375 return format!(
376 "openapi: 3.0.3
377info: {{title: Demo, version: 1.0.0}}
378paths:
379 /holders:
380 post:
381 operationId: putHolder
382 requestBody:
383 required: true
384 content: {{application/json: {{schema: {{$ref: '#/components/schemas/Holder'}}}}}}
385 responses:
386 '200':
387 description: ok
388 content: {{application/json: {{schema: {{$ref: '#/components/schemas/Holder'}}}}}}
389components:
390 schemas:
391 Plain: {{type: string, maxLength: 3}}
392 Served: {{type: string, readOnly: true}}
393 Sent: {{type: string, writeOnly: true}}
394 Holder:
395 type: object
396 required: [plain, served]
397 properties:
398 plain: {}
399 served: {}
400 sent: {}
401",
402 property.replace("NAME", "Plain"),
403 property.replace("NAME", "Served"),
404 property.replace("NAME", "Sent"),
405 );
406 };
407 let dir = TestDir::new("wrapped-reference");
408 std::fs::write(
409 dir.join("config.yaml"),
410 "package: demo\ngenerate: {models: true, std-http-server: true, client: true}\n",
411 )
412 .expect("write the configuration");
413 let config = Config::load(&dir.join("config.yaml")).expect("valid configuration");
414 let generate_from = |property: &str| {
415 std::fs::write(dir.join("spec.yaml"), spec(property)).expect("write the spec");
416 return generate(&dir.join("spec.yaml"), &config).expect("the spec generates");
417 };
418
419 let bare = generate_from("{$ref: '#/components/schemas/NAME'}");
420 let wrapped = generate_from(&format!(
421 "{{allOf: [{{$ref: '#/components/schemas/NAME'}}], description: {NOTE}}}"
422 ));
423
424 assert!(bare.contains("HolderRequest"), "the marked targets split the model");
425 let without_notes: Vec<&str> = wrapped.lines().filter(|line| return !line.contains(NOTE)).collect();
426 assert_eq!(without_notes, bare.lines().collect::<Vec<&str>>());
427 assert!(wrapped.contains(NOTE), "the description reaches the output");
428
429 let beside = generate_from(&format!("{{$ref: '#/components/schemas/NAME', description: {NOTE}}}"));
431 assert_eq!(beside, wrapped);
432 }
433
434 #[test]
439 fn a_described_reference_merges_and_uploads_like_the_bare_reference() {
440 const NOTE: &str = "the note beside the reference";
441 const TARGET_NOTE: &str = "The kinds there are.";
442 let spec = |property: &str, kind: &str| {
443 let wrapper_note = if property.contains(NOTE) {
446 format!(", description: {NOTE}")
447 } else {
448 String::new()
449 };
450 return format!(
451 "openapi: 3.0.3
452info: {{title: Demo, version: 1.0.0}}
453paths:
454 /uploads:
455 post:
456 operationId: upload
457 requestBody:
458 required: true
459 content:
460 multipart/form-data:
461 schema:
462 type: object
463 required: [kind]
464 properties:
465 kind: {property}
466 file: {{type: string, format: binary}}
467 responses:
468 '200':
469 description: ok
470 content: {{application/json: {{schema: {{$ref: '#/components/schemas/Merged'}}}}}}
471 '201':
472 description: made
473 content: {{application/json: {{schema: {{$ref: '#/components/schemas/Both'}}}}}}
474 '202':
475 description: queued
476 content: {{application/json: {{schema: {{$ref: '#/components/schemas/Across'}}}}}}
477components:
478 schemas:
479 Kind: {kind}
480 SameKind: {{$ref: '#/components/schemas/Kind'}}
481 Plain: {{type: string}}
482 Base: {{type: object, properties: {{kind: {{$ref: '#/components/schemas/Kind'}}, other: {{$ref: '#/components/schemas/SameKind'}}, maybe: {{allOf: [{{$ref: '#/components/schemas/Plain'}}], nullable: true}}}}}}
483 Extra: {{type: object, properties: {{kind: {property}, other: {property}, maybe: {{allOf: [{{$ref: '#/components/schemas/Plain'}}], nullable: true{wrapper_note}}}}}}}
484 Third: {{type: object, properties: {{other: {{$ref: '#/components/schemas/Kind'}}}}}}
485 Half: {{type: object, properties: {{h: {{type: string}}}}}}
486 Pair: {{allOf: [{{$ref: '#/components/schemas/Half'}}, {{$ref: '#/components/schemas/Half'}}]}}
487 Left: {{type: object, properties: {{pair: {{$ref: '#/components/schemas/Pair'}}}}}}
488 Right: {{type: object, properties: {{pair: {{allOf: [{{$ref: '#/components/schemas/Half'}}, {{$ref: '#/components/schemas/Half'}}]{wrapper_note}}}}}}}
489 Both: {{allOf: [{{$ref: '#/components/schemas/Left'}}, {{$ref: '#/components/schemas/Right'}}]}}
490 CompA: {{allOf: [{{type: object, properties: {{p: {{$ref: '#/components/schemas/Kind'}}}}}}, {{$ref: '#/components/schemas/Half'}}]}}
491 CompB: {{allOf: [{{type: object, properties: {{p: {property}}}}}, {{$ref: '#/components/schemas/Half'}}]}}
492 Up: {{type: object, properties: {{comp: {{$ref: '#/components/schemas/CompA'}}}}}}
493 Down: {{type: object, properties: {{comp: {{$ref: '#/components/schemas/CompB'}}}}}}
494 Across: {{allOf: [{{$ref: '#/components/schemas/Up'}}, {{$ref: '#/components/schemas/Down'}}]}}
495 Merged: {{allOf: [{{$ref: '#/components/schemas/Base'}}, {{$ref: '#/components/schemas/Extra'}}, {{$ref: '#/components/schemas/Third'}}]}}
496"
497 );
498 };
499 let dir = TestDir::new("described-reference");
500 std::fs::write(
501 dir.join("config.yaml"),
502 "package: demo\ngenerate: {models: true, std-http-server: true}\n",
503 )
504 .expect("write the configuration");
505 let config = Config::load(&dir.join("config.yaml")).expect("valid configuration");
506 let generate_from = |property: &str, kind: &str| {
507 std::fs::write(dir.join("spec.yaml"), spec(property, kind)).expect("write the spec");
508 return generate(&dir.join("spec.yaml"), &config).expect("the spec generates");
509 };
510 let bare_ref = "{$ref: '#/components/schemas/Kind'}";
511 let described_ref = format!("{{$ref: '#/components/schemas/Kind', description: {NOTE}}}");
512
513 let plain_kind = "{type: string, enum: [a, b]}";
514 let bare = generate_from(bare_ref, plain_kind);
515 let described = generate_from(&described_ref, plain_kind);
516
517 assert!(bare.contains("pub struct Merged"), "the overlap merges");
518 let without_notes: Vec<&str> = described.lines().filter(|line| return !line.contains(NOTE)).collect();
519 assert_eq!(without_notes, bare.lines().collect::<Vec<&str>>());
520 assert_eq!(described.matches(NOTE).count(), 5, "{described}");
528 assert!(described.contains("pub comp: Option<CompA>"), "{described}");
531
532 let described_kind = format!("{{type: string, enum: [a, b], description: {TARGET_NOTE}}}");
535 let documented = generate_from(&described_ref, &described_kind);
536 assert!(
537 documented.contains(&format!(
538 " /// {NOTE}\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub other"
539 )),
540 "{documented}"
541 );
542 assert!(
543 !documented.contains(&format!(
544 " /// {TARGET_NOTE}\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub other"
545 )),
546 "{documented}"
547 );
548 }
549
550 #[test]
554 fn a_read_only_wrapper_keeps_a_multipart_field_out_of_the_request() {
555 let dir = TestDir::new("multipart-wrapper-mark");
556 std::fs::write(
557 dir.join("spec.yaml"),
558 "openapi: 3.0.3
559info: {title: Demo, version: 1.0.0}
560paths:
561 /uploads:
562 post:
563 operationId: upload
564 requestBody:
565 required: true
566 content:
567 multipart/form-data:
568 schema:
569 type: object
570 required: [served, sent, elsewhere]
571 properties:
572 served: {allOf: [{$ref: '#/components/schemas/Plain'}], readOnly: true}
573 sent: {allOf: [{$ref: '#/components/schemas/Plain'}], description: a note}
574 elsewhere: {allOf: [{$ref: 'other.yaml#/components/schemas/Plain'}], readOnly: true}
575 responses:
576 '204': {description: ok}
577components:
578 schemas:
579 Plain: {type: string}
580",
581 )
582 .expect("write the spec");
583 std::fs::write(
586 dir.join("other.yaml"),
587 "openapi: 3.0.3\ninfo: {title: Other, version: 1.0.0}\npaths: {}\ncomponents:\n schemas:\n Plain: {type: string}\n",
588 )
589 .expect("write the other document");
590 std::fs::write(
591 dir.join("config.yaml"),
592 "package: demo\ngenerate: {models: true, std-http-server: true}\n",
593 )
594 .expect("write the configuration");
595 let config = Config::load(&dir.join("config.yaml")).expect("valid configuration");
596
597 let code = generate(&dir.join("spec.yaml"), &config).expect("the spec generates");
598
599 assert!(code.contains("\"sent\""), "the plain field is a part: {code}");
600 assert!(!code.contains("\"served\""), "the read-only field is no part: {code}");
601 assert!(
602 !code.contains("\"elsewhere\""),
603 "the read-only cross-file field is no part: {code}"
604 );
605
606 for (wrapper, target) in [("readOnly", "writeOnly"), ("writeOnly", "readOnly")] {
609 std::fs::write(
610 dir.join("spec.yaml"),
611 format!(
612 "openapi: 3.0.3
613info: {{title: Demo, version: 1.0.0}}
614paths:
615 /uploads:
616 post:
617 operationId: upload
618 requestBody:
619 required: true
620 content:
621 multipart/form-data:
622 schema:
623 type: object
624 properties:
625 torn: {{allOf: [{{$ref: '#/components/schemas/Marked'}}], {wrapper}: true}}
626 responses:
627 '204': {{description: ok}}
628components:
629 schemas:
630 Marked: {{type: string, {target}: true}}
631"
632 ),
633 )
634 .expect("write the spec");
635 let error = generate(&dir.join("spec.yaml"), &config).expect_err("both marks leave no direction");
636 assert!(error.to_string().contains("readOnly"), "{wrapper} on {target}: {error}");
637 }
638 }
639
640 #[test]
641 fn check_output_reports_an_absent_file_as_drift() {
642 let dir = TestDir::new("absent");
643 let drift = check_output(&dir.join("out.rs"), "pub struct Widget;\n").expect("check an absent file");
645 assert_eq!(drift, Drift::Absent);
646 }
647
648 #[test]
649 fn check_output_reports_equal_content_as_no_drift() {
650 let dir = TestDir::new("equal");
651 let path = dir.join("out.rs");
652 let code = "pub struct Widget;\n";
653 std::fs::write(&path, code).expect("write the output file");
654 let drift = check_output(&path, code).expect("check an equal file");
655 assert_eq!(drift, Drift::None);
656 }
657
658 #[test]
659 fn check_output_reports_different_content_as_drift() {
660 let dir = TestDir::new("differs");
661 let path = dir.join("out.rs");
662 std::fs::write(&path, "pub struct Widget;\n").expect("write the output file");
663 let drift = check_output(&path, "pub struct Gadget;\n").expect("check a stale file");
664 assert_eq!(drift, Drift::Differs);
665 }
666
667 #[test]
668 fn check_output_compares_exactly() {
669 let dir = TestDir::new("exact");
670 let path = dir.join("out.rs");
671 std::fs::write(&path, "pub struct Widget;").expect("write the output file");
675 let drift = check_output(&path, "pub struct Widget;\n").expect("check a file with no trailing newline");
676 assert_eq!(drift, Drift::Differs);
677 }
678
679 #[test]
680 fn check_output_reports_content_that_is_not_utf8_as_drift() {
681 let dir = TestDir::new("not-utf8");
682 let path = dir.join("out.rs");
683 std::fs::write(&path, [0xFF_u8, 0xFE_u8]).expect("write the output file");
687 let drift = check_output(&path, "pub struct Widget;\n").expect("check a file that is not UTF-8");
688 assert_eq!(drift, Drift::Differs);
689 }
690
691 #[test]
692 fn check_output_writes_nothing() {
693 let dir = TestDir::new("readonly");
694 let path = dir.join("out.rs");
695 let existing = "pub struct Widget;\n";
696 std::fs::write(&path, existing).expect("write the output file");
697 let drift = check_output(&path, "pub struct Gadget;\n").expect("check a stale file");
698 assert_eq!(drift, Drift::Differs);
699 let after = std::fs::read_to_string(&path).expect("read the output file back");
700 assert_eq!(after, existing, "`check_output` must not change the file");
701 }
702
703 #[test]
704 fn check_output_fails_on_a_path_it_cannot_read() {
705 let dir = TestDir::new("unreadable");
706 let path = dir.join("out.rs");
707 std::fs::create_dir(&path).expect("create a directory where a file belongs");
710 let error = check_output(&path, "pub struct Widget;\n").expect_err("a directory is not readable as a file");
711 assert!(
712 matches!(error, Error::ReadOutput { .. }),
713 "expected `ReadOutput`, got {error:?}"
714 );
715 }
716}