#![expect(
clippy::expect_used,
clippy::indexing_slicing,
reason = "a test that cannot build its fixture should fail loudly and name it"
)]
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use typed_openapi::Document;
use typed_openapi::generate::{GenerateError, Settings};
const TOY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/toy.yaml");
const CORRECTIONS: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/corrections.yaml"
);
const CLI: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/cli.yaml");
fn out(name: &str) -> PathBuf {
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(name);
drop(std::fs::remove_dir_all(&dir));
dir
}
fn layered() -> Settings {
Settings::new(TOY).overlay(CORRECTIONS).overlay(CLI)
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("a file the bless step reported writing")
}
fn wrote(dir: &Path, name: &str, contents: &str) -> PathBuf {
let path = dir.join(name);
std::fs::create_dir_all(dir).expect("a directory to write the document into");
std::fs::write(&path, contents).expect("a document this test writes");
path
}
const PATTERNED: &str = r##"
openapi: 3.0.3
info: { title: Patterned, version: "1.0" }
servers: [{ url: "http://localhost:9999" }]
paths:
/things/{sku}:
get:
operationId: getThing
parameters:
- name: sku
in: path
required: true
schema: { $ref: "#/components/schemas/Sku" }
responses:
"200": { description: OK }
components:
schemas:
Sku:
type: string
pattern: "^[A-Z]{3}-[0-9]{4}$"
"##;
#[test]
fn a_generated_newtype_over_a_string_prints_and_needs_no_engine_of_its_own() {
let dir = out("patterned");
let document = wrote(&dir, "sku.yaml", PATTERNED);
Settings::new(&document)
.write_to(&dir)
.expect("the document generates");
let types = read(&dir.join("src/types.rs"));
assert!(
types.contains("impl ::std::fmt::Display for Sku"),
"a generated string newtype cannot be written to a `format!`:\n{types}"
);
assert!(
types.contains("::typed_openapi::regress::Regex"),
"the generated pattern check does not reach the engine through this crate:\n{types}"
);
assert!(
!types.contains("<::regress::Regex>"),
"the generated code names an engine the crate holding it would have to \
depend on:\n{types}"
);
}
#[test]
fn the_written_model_is_the_written_documents_reduction() {
let dir = out("agree");
let written = layered().write_to(&dir).expect("the fixtures generate");
assert_eq!(
written,
vec![
dir.join("spec/toy.overlaid.yaml"),
dir.join("src/types.rs"),
dir.join("src/ops.rs"),
dir.join("src/model.postcard"),
],
"the artefacts, in the layout a generated crate embeds them by"
);
let blob = std::fs::read(&written[3]).expect("the reduced model");
let shipped = Document::from_blob(&blob).expect("the written blob is a reduction");
let fresh =
Document::load(&read(&written[0]), &[]).expect("the written document is a document");
assert!(
shipped == fresh,
"the reduction a binary reads is not the written document's"
);
}
const FORMATTED: &str = r#"
openapi: 3.0.3
info: { title: Formatted, version: "1.0" }
servers: [{ url: "http://localhost:9999" }]
paths:
/prices:
get:
operationId: listPrices
responses:
"200": { description: OK }
components:
schemas:
Price:
type: object
required: [currency]
properties:
currency: { type: string, format: currency }
"#;
#[test]
fn a_replaced_format_becomes_a_type_the_generated_code_never_defines() {
let with = out("replaced");
Settings::new(wrote(&with, "prices.yaml", FORMATTED))
.replace("currency", "crate::Currency")
.write_to(&with)
.expect("the document generates");
let types = read(&with.join("src/types.rs"));
assert!(
types.contains("crate::Currency"),
"`format: currency` did not become the adopter's own type:\n{types}"
);
assert!(
!types.contains("struct Currency"),
"typify defined a type the adopter owns:\n{types}"
);
let without = out("unreplaced");
Settings::new(wrote(&without, "prices.yaml", FORMATTED))
.write_to(&without)
.expect("the document generates");
let types = read(&without.join("src/types.rs"));
assert!(
!types.contains("crate::Currency"),
"a type the adopter never asked for:\n{types}"
);
assert!(
types.contains("pub currency: ::std::string::String"),
"an unreplaced format is the string the document declares:\n{types}"
);
}
const SHAPED: &str = r##"
openapi: 3.0.3
info: { title: Shaped, version: "1.0" }
servers: [{ url: "http://localhost:9999" }]
paths:
/vouchers:
get:
operationId: listVouchers
parameters:
- name: tag
in: query
schema: { type: array, items: { type: string } }
- name: filter
in: query
schema:
type: object
properties:
opened:
type: object
properties:
from: { type: string }
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/Voucher" }
components:
schemas:
Voucher:
type: object
properties:
id: { type: integer }
"##;
#[test]
fn a_list_parameter_is_a_vec_argument_and_one_with_no_flag_is_no_argument() {
let dir = out("shaped");
Settings::new(wrote(&dir, "shaped.yaml", SHAPED))
.write_to(&dir)
.expect("the document generates");
let ops = read(&dir.join("src/ops.rs"));
assert!(
ops.contains("tag: Vec<&str>"),
"the list is not a Vec:\n{ops}"
);
assert!(
ops.contains(r#".each("tag", tag)"#),
"the list does not reach the request builder repeated:\n{ops}"
);
assert!(
!ops.contains("filter:"),
"an argument the wrapper has nowhere to put:\n{ops}"
);
assert!(
ops.contains(
"The document's `filter` parameter is not an argument: it is neither a value \
nor a list of values."
),
"the wrapper does not say what it does not carry:\n{ops}"
);
}
#[test]
fn every_generated_file_names_the_command_that_rewrites_it() {
let dir = out("header");
layered()
.regenerated_by("just bless")
.write_to(&dir)
.expect("the fixtures generate");
for file in ["spec/toy.overlaid.yaml", "src/types.rs", "src/ops.rs"] {
assert!(
read(&dir.join(file)).contains("Generated by `just bless`"),
"{file} does not name the command that rewrites it"
);
}
assert!(
read(&dir.join("src/ops.rs"))
.contains("belongs in `fixtures/corrections.yaml` and `fixtures/cli.yaml`"),
"the generated Rust does not point at the Overlay it was corrected by"
);
}
#[test]
fn a_document_that_is_not_there_is_named() {
let error = Settings::new("nowhere.yaml")
.overlay(CORRECTIONS)
.write_to(out("missing"))
.expect_err("no document to generate from");
assert!(
matches!(&error, GenerateError::Read { path, .. } if path.ends_with("nowhere.yaml")),
"{error}"
);
}
const AWKWARDLY_NAMED: &str = r##"
openapi: 3.0.3
info: { title: Awkward, version: "1.0" }
servers: [{ url: "http://localhost:9999" }]
paths:
/vouchers:
post:
operationId: addVoucher
requestBody:
content:
application/json:
schema: { $ref: "#/components/schemas/Model_voucher" }
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/voucher-summary" }
/vouchers/recent:
get:
operationId: listRecentVouchers
parameters:
- name: since
in: query
schema: { $ref: "#/components/schemas/2fa_stamp" }
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/voucher-summary" }
components:
schemas:
Model_voucher:
type: object
properties:
total: { type: string }
voucher-summary:
type: object
properties:
count: { type: integer }
2fa_stamp:
type: string
"##;
fn named_by_wrappers(ops: &str) -> Vec<String> {
ops.split("crate::types::")
.skip(1)
.map(|after| {
after
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect()
})
.collect()
}
#[test]
fn every_type_a_wrapper_names_is_one_the_generated_schemas_define() {
for (name, document) in [("awkward", AWKWARDLY_NAMED), ("patterned", PATTERNED)] {
let dir = out(name);
Settings::new(wrote(&dir, "document.yaml", document))
.write_to(&dir)
.expect("the document generates");
let types = read(&dir.join("src/types.rs"));
let ops = read(&dir.join("src/ops.rs"));
let named = named_by_wrappers(&ops);
assert!(
!named.is_empty(),
"{name}: no wrapper names a generated type, so this proves nothing"
);
for ty in named {
assert!(
types.contains(&format!("pub struct {ty}"))
|| types.contains(&format!("pub enum {ty}"))
|| types.contains(&format!("pub type {ty}")),
"{name}: a wrapper names `crate::types::{ty}`, which the \
generated schemas do not define:\n{ops}"
);
}
}
}
const KEYWORDED: &str = r##"
openapi: 3.0.3
info: { title: Keyworded, version: "1.0" }
servers: [{ url: "http://localhost:9999" }]
paths:
/vouchers:
get:
operationId: listVouchers
parameters:
- name: type
in: query
schema: { type: string }
- name: ref
in: query
schema: { type: string }
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/Voucher" }
components:
schemas:
Voucher:
type: object
properties:
id: { type: integer }
"##;
#[test]
fn a_parameter_named_after_a_keyword_is_a_raw_identifier_and_keeps_its_wire_name() {
let dir = out("keyworded");
Settings::new(wrote(&dir, "document.yaml", KEYWORDED))
.write_to(&dir)
.expect("a keyword is a spelling problem, not a document this crate refuses");
let ops = read(&dir.join("src/ops.rs"));
assert!(
ops.contains("r#type: Option<&str>") && ops.contains("r#ref: Option<&str>"),
"a keyword parameter is not a raw identifier:\n{ops}"
);
assert!(
ops.contains(r#".maybe("type", r#type)"#) && ops.contains(r#".maybe("ref", r#ref)"#),
"the wire name did not survive the spelling:\n{ops}"
);
}
#[test]
fn an_operation_that_cannot_be_spelled_names_itself() {
let dir = out("unspellable");
let document = KEYWORDED.replace("operationId: listVouchers", "operationId: 2listVouchers");
let failure = Settings::new(wrote(&dir, "document.yaml", &document))
.write_to(&dir)
.expect_err("`2listVouchers` has no spelling as a Rust identifier");
assert_eq!(
failure.to_string(),
"2listVouchers: the operationId has no spelling as a Rust identifier",
"the failure has to name the operation: a generated file is six thousand \
lines and a `syn` position is not something an adopter can open"
);
}
#[test]
fn a_parameter_that_cannot_be_spelled_names_itself_and_its_operation() {
let dir = out("unspellable-param");
let document = KEYWORDED.replace("- name: type", "- name: \"2\"");
let failure = Settings::new(wrote(&dir, "document.yaml", &document))
.write_to(&dir)
.expect_err("`2` has no spelling as a Rust identifier");
assert_eq!(
failure.to_string(),
"listVouchers: parameter `2` has no spelling as a Rust identifier",
);
}
const UNCONSTRAINED: &str = r##"
openapi: 3.0.3
info: { title: Unconstrained, version: "1.0" }
servers: [{ url: "http://localhost:9999" }]
paths:
/vouchers:
get:
operationId: listVouchers
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/Voucher" }
components:
schemas:
Filename:
type: string
description: The name the server gave an uploaded file.
Sku:
type: string
pattern: "^[A-Z]{3}-[0-9]{4}$"
Voucher:
type: object
properties:
attachment: { $ref: "#/components/schemas/Filename" }
sku: { $ref: "#/components/schemas/Sku" }
"##;
#[test]
fn every_string_newtype_prints_and_none_of_them_twice() {
let dir = out("unconstrained");
Settings::new(wrote(&dir, "document.yaml", UNCONSTRAINED))
.write_to(&dir)
.expect("the document generates");
let types = read(&dir.join("src/types.rs"));
for name in ["Filename", "Sku"] {
let written = types
.matches(&format!("impl ::std::fmt::Display for {name} "))
.count();
assert_eq!(
written, 1,
"`{name}` has {written} `Display` impls, and a generated crate \
compiles with exactly one:\n{types}"
);
}
}
const PROSE: &str = r##"
openapi: 3.0.3
info: { title: Prose, version: "1.0" }
servers: [{ url: "http://localhost:9999" }]
paths:
/vouchers:
get:
operationId: listVouchers
summary: |
List the vouchers.
* A summary the vendor hung from a marker.
A summary the vendor indented.
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/Voucher" }
components:
schemas:
Voucher:
type: object
properties:
kind:
type: string
description: |
The kind of the voucher.
A paragraph the vendor indented four spaces.
parts:
type: string
description: |
What a complete voucher needs:
- a contact
- with an address
- and a name
sample:
type: string
description: |
An example of one:
```
{"id": "1"}
```
labelled:
type: string
description: |
An example the vendor labelled:
```rust
this is pseudocode, not Rust
```
and the same with tildes:
~~~rust
nor is this
~~~
buried:
type: string
description: |
An example the vendor indented under its heading:
```
{"id": "1"}
```
highlighted:
type: string
description: |
An example whose language rustdoc does not run:
```json
{"id": "1"}
```
hanging:
type: string
description: |
A list whose content the vendor hung five columns from its marker:
* the content of that item.
hanging_apart:
type: string
description: |
The same, set off from its lead-in by a blank line:
* the content of that item too.
hanging_numbered:
type: string
description: |
An ordered list hung the same way:
1. the content of that item as well.
starred:
type: string
description: |
A list the vendor bulleted with asterisks:
* one item under an asterisk
* and a second one
aside:
type: string
description: |
A description that mentions //! in passing.
"##;
fn doc_comments_alone(source: &str) -> String {
let mut out = String::from("pub struct Docs {\n");
let mut fields = 0;
let mut block = false;
let mut carrying = false;
for line in source.lines() {
let trimmed = line.trim_start();
let is_doc = block || trimmed.starts_with("/**") || trimmed.starts_with("///");
if is_doc {
out.push_str(line);
out.push('\n');
block = (block || trimmed.starts_with("/**")) && !trimmed.ends_with("*/");
carrying = !block;
continue;
}
if carrying {
let _ = writeln!(out, " pub f{fields}: i64,");
fields += 1;
carrying = false;
}
}
out.push_str("}\n");
out
}
#[test]
fn a_vendors_prose_carries_nothing_rustdoc_will_run() {
let dir = out("prose");
Settings::new(wrote(&dir, "document.yaml", PROSE))
.write_to(&dir)
.expect("the document generates");
for (file, alone) in [
("src/types.rs", "types_docs.rs"),
("src/ops.rs", "ops_docs.rs"),
] {
let comments = doc_comments_alone(&read(&dir.join(file)));
let path = wrote(&dir, alone, &comments);
let ran = std::process::Command::new("rustdoc")
.args(["--test", "--edition", "2024"])
.arg(&path)
.output()
.expect("rustdoc is on PATH beside the rustfmt a bless step already needs");
let said = String::from_utf8_lossy(&ran.stdout);
let complained = String::from_utf8_lossy(&ran.stderr);
assert!(
said.contains("running 0 tests"),
"rustdoc found something to run in {file}:\n{said}{complained}\n{comments}"
);
}
}
#[test]
fn prose_that_was_never_code_is_still_prose() {
let dir = out("prose-kept");
Settings::new(wrote(&dir, "document.yaml", PROSE))
.write_to(&dir)
.expect("the document generates");
let types = read(&dir.join("src/types.rs"));
assert!(
types.contains("- a contact") && types.contains(" - with an address"),
"a nested list did not survive the capping:\n{types}"
);
assert!(
types.contains("A paragraph the vendor indented four spaces."),
"the vendor's words did not survive:\n{types}"
);
assert!(
types.contains("```text"),
"a fence naming no language was left as Rust:\n{types}"
);
assert!(
!types.contains("```rust") && !types.contains("~~~rust"),
"a fence the vendor labelled `rust` was left for rustdoc to compile:\n{types}"
);
assert!(
types.contains("```json"),
"a language rustdoc does not run was renamed for nothing:\n{types}"
);
assert!(
types.contains("//! in passing"),
"a line mentioning a doc comment did not survive:\n{types}"
);
assert!(
types.contains("- the content of that item."),
"a hanging list item lost its marker, or kept the gap that opens a code \
block inside it:\n{types}"
);
assert!(
types.contains("1. the content of that item as well."),
"an ordered marker is a list marker too:\n{types}"
);
assert!(
types.contains("- one item under an asterisk") && types.contains("- and a second one"),
"a list the vendor bulleted with asterisks is spelled with a bullet \
rustc leaves alone:\n{types}"
);
assert!(
!types.contains("* one item under an asterisk"),
"an asterisk bullet survives into the comment rustc eats it out of:\n{types}"
);
let ops = read(&dir.join("src/ops.rs"));
assert!(
ops.contains("- A summary the vendor hung from a marker."),
"a hanging list item in a summary was left as the vendor wrote it:\n{ops}"
);
}
const OWNED: &str = r##"
openapi: 3.0.3
info: { title: Owned, version: "1.0" }
servers: [{ url: "http://localhost:9999" }]
paths:
/prices:
get:
operationId: listPrices
parameters:
- name: over
in: query
schema: { $ref: "#/components/schemas/Cents" }
responses:
"200": { description: OK }
components:
schemas:
Cents:
type: string
format: money
"##;
#[test]
fn a_schema_the_adopter_owns_is_named_by_the_adopters_own_path() {
let dir = out("owned");
Settings::new(wrote(&dir, "document.yaml", OWNED))
.replace("money", "cents::Cents")
.write_to(&dir)
.expect("the document generates");
let types = read(&dir.join("src/types.rs"));
let ops = read(&dir.join("src/ops.rs"));
assert!(
ops.contains("over: Option<cents::Cents>"),
"the wrapper does not take the type the adopter owns:\n{ops}"
);
assert!(
!types.contains("struct Cents"),
"typify defined a type the adopter owns:\n{types}"
);
}
const NAMELESS: &str = r#"
openapi: 3.0.3
info: { title: Nameless, version: "1.0" }
servers: [{ url: "http://localhost:9999" }]
paths:
/ping:
get:
operationId: ping
responses:
"200": { description: OK }
"#;
#[test]
fn a_document_that_names_no_schema_still_generates() {
let dir = out("nameless");
Settings::new(wrote(&dir, "document.yaml", NAMELESS))
.write_to(&dir)
.expect("a document with no components is a document");
assert!(
read(&dir.join("src/ops.rs")).contains("pub fn ping(&self)"),
"the operation did not reach a wrapper"
);
assert!(
!read(&dir.join("src/types.rs")).contains("crate::types::"),
"a wrapper names a type in a document that declares none"
);
}
const PROMISED: &str = r#"
openapi: 3.0.3
info: { title: Promised, version: "1.0" }
servers: [{ url: "http://localhost:9999" }]
paths:
/prices:
get:
operationId: listPrices
responses:
"200": { description: OK }
components:
schemas:
Amount:
type: string
format: cash
"#;
fn promise_in(types: &str) -> String {
let block: Vec<&str> = types
.lines()
.skip_while(|line| !line.starts_with("const _: () = {"))
.take_while(|line| *line != "};")
.collect();
format!("{}\n}};\n", block.join("\n"))
}
#[test]
fn a_type_the_adopter_owns_is_held_to_what_replace_promised() {
let dir = out("promised");
Settings::new(wrote(&dir, "document.yaml", PROMISED))
.replace("cash", "crate::Owned")
.write_to(&dir)
.expect("the document generates");
let types = read(&dir.join("src/types.rs"));
assert!(
types.contains("parses_from_a_string::<crate::Owned>()")
&& types.contains("prints_to_a_string::<crate::Owned>()"),
"the promise the generated newtype rests on is not checked:\n{types}"
);
}
const HALF_KEPT: &str = "
pub struct Owned;
impl ::std::str::FromStr for Owned {
type Err = ::std::convert::Infallible;
fn from_str(_: &str) -> ::std::result::Result<Self, Self::Err> {
Ok(Self)
}
}
";
#[test]
fn a_broken_promise_names_the_type_and_the_trait_it_lacks() {
let dir = out("promise-broken");
Settings::new(wrote(&dir, "document.yaml", PROMISED))
.replace("cash", "crate::Owned")
.write_to(&dir)
.expect("the document generates");
let promise = promise_in(&read(&dir.join("src/types.rs")));
let path = wrote(&dir, "half_kept.rs", &format!("{HALF_KEPT}{promise}"));
let ran = std::process::Command::new("rustc")
.args([
"--edition",
"2024",
"--crate-type",
"lib",
"--emit",
"metadata",
])
.arg("-o")
.arg(dir.join("half_kept.rmeta"))
.arg(&path)
.output()
.expect("rustc is on PATH beside the rustfmt a bless step already needs");
let complained = String::from_utf8_lossy(&ran.stderr);
assert!(
complained.contains("the trait `std::fmt::Display` is not implemented for `Owned`"),
"the failure does not name the adopter's type and the trait it lacks:\n{complained}"
);
assert!(
complained.contains("prints_to_a_string"),
"the failure does not point at the line that says what asked for it:\n{complained}"
);
}
const INLINE: &str = r#"
openapi: 3.0.3
info: { title: Inline, version: "1.0" }
servers: [{ url: "http://localhost:9999" }]
paths:
/prices:
get:
operationId: listPrices
responses:
"200": { description: OK }
components:
schemas:
Price:
type: object
properties:
figure: { type: string, format: cash }
"#;
#[test]
fn a_type_nothing_was_written_in_terms_of_is_asked_for_nothing() {
let dir = out("inline");
Settings::new(wrote(&dir, "document.yaml", INLINE))
.replace("cash", "crate::Owned")
.write_to(&dir)
.expect("the document generates");
let types = read(&dir.join("src/types.rs"));
assert!(
types.contains("crate::Owned"),
"the adopter's type did not reach the generated field:\n{types}"
);
assert!(
!types.contains("a_type_named_by_settings_replace"),
"a promise is demanded where nothing rests on it:\n{types}"
);
}
const COLLIDING: &str = r##"
openapi: 3.0.3
info: { title: Colliding, version: "1.0" }
servers: [{ url: "http://localhost:9999" }]
paths:
/vouchers:
post:
operationId: addVoucher
requestBody:
content:
application/json:
schema: { $ref: "#/components/schemas/voucher-summary" }
responses:
"200": { description: OK }
components:
schemas:
voucher-summary:
type: object
properties:
count: { type: integer }
Voucher_Summary:
type: object
properties:
total: { type: string }
"##;
#[test]
fn two_schemas_that_reduce_to_one_type_are_refused_by_name() {
let dir = out("colliding");
let failure = Settings::new(wrote(&dir, "document.yaml", COLLIDING))
.write_to(&dir)
.expect_err("one name cannot be two types");
let said = failure.to_string();
assert!(
said.contains("`voucher-summary`")
&& said.contains("`Voucher_Summary`")
&& said.contains("`VoucherSummary`"),
"the refusal does not name both schemas and the type they share: {said}"
);
}