use crate::{
emit::downgrade,
model::{
document::{Document, SpecVersion},
info::Info,
},
};
fn document() -> Document {
Document::new(SpecVersion::V3_1, Info::new("Orders", "1.0.0"))
}
#[test]
fn a_bare_document_declares_paths_and_nothing_else() {
let json = document().to_json().expect("serializable");
assert!(json.contains(r#""openapi": "3.1.2""#));
assert!(json.contains(r#""title": "Orders""#));
assert!(json.contains(r#""paths": {}"#));
assert!(!json.contains("components"));
}
#[test]
fn emitting_the_declared_version_is_a_no_op() {
let emitted = document()
.emit(SpecVersion::V3_1)
.expect("no 3.2 constructs");
assert_eq!(emitted.openapi, "3.1.2");
}
#[test]
fn a_document_using_no_three_two_construct_has_no_blockers() {
assert!(downgrade::three_two_only_constructs(&document()).is_empty());
}
#[cfg(feature = "openapi32")]
mod blockers {
use std::{collections::BTreeSet, fs, path::Path};
use super::document;
use crate::{
emit::downgrade::three_two_only_constructs,
model::{
body::{encoding::Encoding, media_type::MediaType},
components::ComponentName,
document::{Document, SpecVersion},
example::Example,
parameter::{Parameter, ParameterIn, style::Style},
paths::{item::PathItem, method::Method, operation::Operation, template::PathTemplate},
reference::RefOr,
response::{Response, Responses},
schema::{Schema, discriminator::Discriminator, object::SchemaObject, xml::Xml},
security::{
SecurityScheme,
oauth::{OAuthFlow, OAuthFlows},
},
server::Server,
tag::Tag,
},
validate::violation::SpecError,
};
fn with_operation(build: impl FnOnce(Operation) -> Operation) -> Document {
let mut document = document();
let template = PathTemplate::parse("/orders").expect("a valid template");
document.paths.insert(
&template,
PathItem::new().with_operation(Method::Get, build(Operation::default())),
);
document
}
fn with_request_content(content: MediaType) -> Document {
with_operation(|operation| {
operation.with_request_body(crate::model::body::RequestBody::new(
"application/json",
content,
))
})
}
fn in_request_body(field: &str) -> String {
format!("#/paths/~1orders/get/requestBody/content/application~1json/{field}")
}
fn with_security_scheme(scheme: SecurityScheme) -> Document {
let mut document = document();
document.components.insert_security_scheme(
&ComponentName::new("Guard").expect("a legal component key"),
scheme,
);
document
}
fn in_security_scheme(field: &str) -> String {
format!("#/components/securitySchemes/Guard/{field}")
}
fn oauth2(flows: OAuthFlows) -> SecurityScheme {
SecurityScheme::OAuth2 {
flows: Box::new(flows),
oauth2_metadata_url: None,
description: None,
deprecated: None,
extensions: crate::model::extensions::Extensions::new(),
}
}
#[expect(clippy::too_many_lines)]
fn ledger() -> Vec<Vec<(Document, String)>> {
vec![
vec![(
Document {
self_uri: Some("https://example.com/orders".to_owned()),
..document()
},
"#/$self".to_owned(),
)],
vec![(
Document {
servers: vec![Server {
name: Some("eu".to_owned()),
..Server::new("https://example.com")
}],
..document()
},
"#/servers/0/name".to_owned(),
)],
vec![
(
Document {
tags: vec![Tag {
summary: Some("Orders".to_owned()),
..Tag::new("orders")
}],
..document()
},
"#/tags/0/summary".to_owned(),
),
(
Document {
tags: vec![Tag::new("orders").with_parent("root")],
..document()
},
"#/tags/0/parent".to_owned(),
),
(
Document {
tags: vec![Tag::new("orders").with_kind("nav")],
..document()
},
"#/tags/0/kind".to_owned(),
),
],
vec![(
{
let mut document = document();
document.components.media_types.insert(
"application/json".to_owned(),
RefOr::Item(MediaType::new(Schema::any())),
);
document
},
"#/components/mediaTypes".to_owned(),
)],
vec![(
{
let mut document = document();
let template = PathTemplate::parse("/orders").expect("a valid template");
document.paths.insert(
&template,
PathItem {
query: Some(Box::new(Operation::default())),
..PathItem::new()
},
);
document
},
"#/paths/~1orders/query".to_owned(),
)],
vec![(
{
let mut document = document();
let template = PathTemplate::parse("/orders").expect("a valid template");
let mut item = PathItem::new();
item.additional_operations
.insert("PURGE".to_owned(), Box::new(Operation::default()));
document.paths.insert(&template, item);
document
},
"#/paths/~1orders/additionalOperations".to_owned(),
)],
vec![(
with_operation(|operation| {
operation.with_parameter(Parameter::new(
"filter",
ParameterIn::Querystring,
Schema::any(),
))
}),
"#/paths/~1orders/get/parameters/filter".to_owned(),
)],
vec![(
with_operation(|operation| {
operation.with_parameter(
Parameter::header("session", Schema::any())
.with_style(Style::Cookie, false),
)
}),
"#/paths/~1orders/get/parameters/session/style".to_owned(),
)],
vec![(
with_operation(|operation| {
operation.with_responses(Responses::new().with(
200,
Response {
summary: Some("Created".to_owned()),
..Response::new("ok")
},
))
}),
"#/paths/~1orders/get/responses/200/summary".to_owned(),
)],
vec![
(
with_request_content(MediaType::sequential(Schema::any())),
in_request_body("itemSchema"),
),
(
with_request_content({
let mut content = MediaType::new(Schema::any());
content.prefix_encoding = Some(vec![Encoding::new("text/plain")]);
content
}),
in_request_body("prefixEncoding"),
),
(
with_request_content({
let mut content = MediaType::new(Schema::any());
content.item_encoding = Some(Box::new(Encoding::new("text/plain")));
content
}),
in_request_body("itemEncoding"),
),
],
vec![
(
with_security_scheme(SecurityScheme::ApiKey {
name: "X-Api-Key".to_owned(),
location: ParameterIn::Header,
description: None,
deprecated: Some(true),
extensions: crate::model::extensions::Extensions::new(),
}),
in_security_scheme("deprecated"),
),
(
with_security_scheme(SecurityScheme::Http {
scheme: "bearer".to_owned(),
bearer_format: None,
description: None,
deprecated: Some(true),
extensions: crate::model::extensions::Extensions::new(),
}),
in_security_scheme("deprecated"),
),
(
with_security_scheme(SecurityScheme::MutualTls {
description: None,
deprecated: Some(true),
extensions: crate::model::extensions::Extensions::new(),
}),
in_security_scheme("deprecated"),
),
(
with_security_scheme(SecurityScheme::OpenIdConnect {
open_id_connect_url: "https://auth.example.com/.well-known".to_owned(),
description: None,
deprecated: Some(true),
extensions: crate::model::extensions::Extensions::new(),
}),
in_security_scheme("deprecated"),
),
(
with_security_scheme(SecurityScheme::OAuth2 {
flows: Box::new(OAuthFlows::default()),
oauth2_metadata_url: None,
description: None,
deprecated: Some(true),
extensions: crate::model::extensions::Extensions::new(),
}),
in_security_scheme("deprecated"),
),
],
vec![(
with_security_scheme(SecurityScheme::OAuth2 {
flows: Box::new(OAuthFlows::default()),
oauth2_metadata_url: Some(
"https://auth.example.com/.well-known/oauth-authorization-server"
.to_owned(),
),
description: None,
deprecated: None,
extensions: crate::model::extensions::Extensions::new(),
}),
in_security_scheme("oauth2MetadataUrl"),
)],
vec![(
with_security_scheme(oauth2(OAuthFlows {
device_authorization: Some(OAuthFlow::new([(
"orders:read".to_owned(),
"Read orders".to_owned(),
)])),
..OAuthFlows::default()
})),
in_security_scheme("flows/deviceAuthorization"),
)],
vec![(
with_security_scheme(oauth2(OAuthFlows {
authorization_code: Some(OAuthFlow {
device_authorization_url: Some(
"https://auth.example.com/device".to_owned(),
),
..OAuthFlow::new([("orders:read".to_owned(), "Read orders".to_owned())])
}),
..OAuthFlows::default()
})),
in_security_scheme("flows/authorizationCode/deviceAuthorizationUrl"),
)],
vec![
(
with_request_content(
MediaType::new(Schema::any()).with_encoding(
"part",
Encoding {
encoding: [("inner".to_owned(), Encoding::new("text/plain"))]
.into_iter()
.collect(),
..Encoding::new("multipart/mixed")
},
),
),
in_request_body("encoding/part/encoding"),
),
(
with_request_content(MediaType::new(Schema::any()).with_encoding(
"part",
Encoding {
prefix_encoding: Some(vec![Encoding::new("text/plain")]),
..Encoding::new("multipart/mixed")
},
)),
in_request_body("encoding/part/prefixEncoding"),
),
(
with_request_content(MediaType::new(Schema::any()).with_encoding(
"part",
Encoding {
item_encoding: Some(Box::new(Encoding::new("text/plain"))),
..Encoding::new("multipart/mixed")
},
)),
in_request_body("encoding/part/itemEncoding"),
),
],
vec![
(
with_request_content(
MediaType::new(Schema::any())
.with_named_example("e", Example::data(serde_json::json!({"id": 1}))),
),
in_request_body("examples/e/dataValue"),
),
(
with_request_content(
MediaType::new(Schema::any())
.with_named_example("e", Example::serialized("id=1")),
),
in_request_body("examples/e/serializedValue"),
),
(
with_request_content(MediaType::new(Schema::any()).with_named_example(
"e",
Example::data_external(
serde_json::json!({"id": 1}),
"https://example.com/e.json",
),
)),
in_request_body("examples/e/dataValue"),
),
],
vec![
(
with_request_content(MediaType::new(Schema::Object(Box::new(SchemaObject {
xml: Some(Xml {
node_type: Some("element".to_owned()),
..Xml::default()
}),
..SchemaObject::default()
})))),
in_request_body("schema/xml/nodeType"),
),
(
with_request_content(MediaType::new(Schema::Object(Box::new(SchemaObject {
discriminator: Some(Discriminator {
default_mapping: Some("#/components/schemas/Fallback".to_owned()),
..Discriminator::new("kind")
}),
..SchemaObject::default()
})))),
in_request_body("schema/discriminator/defaultMapping"),
),
],
]
}
#[test]
fn each_construct_is_reported_at_the_location_it_lives_at() {
for (document, expected) in ledger().into_iter().flatten() {
assert_eq!(
three_two_only_constructs(&document),
vec![expected],
"a document using only this construct must report only it"
);
}
}
#[test]
fn a_document_using_one_refuses_to_emit_as_three_one() {
for (document, expected) in ledger().into_iter().flatten() {
let error = document
.emit(SpecVersion::V3_1)
.expect_err("a 3.2 construct cannot be emitted as 3.1");
assert_eq!(
error,
SpecError::RequiresV3_2 {
blockers: vec![expected],
}
);
}
}
#[expect(clippy::too_many_lines)]
fn gated_wire_names() -> BTreeSet<String> {
const GATE: &str = "#[cfg(feature = \"openapi32\")]";
fn walk(directory: &Path, names: &mut BTreeSet<String>) {
let entries = fs::read_dir(directory).expect("the model sources are beside this test");
for entry in entries.map(|entry| entry.expect("a readable directory entry")) {
let path = entry.path();
if path.is_dir() {
walk(&path, names);
} else if path.extension().is_some_and(|ext| ext == "rs") {
collect(
&fs::read_to_string(&path).expect("a readable source file"),
names,
);
}
}
}
fn collect(source: &str, names: &mut BTreeSet<String>) {
let lines: Vec<&str> = source.lines().map(str::trim).collect();
for (index, line) in lines.iter().enumerate() {
if *line != GATE {
continue;
}
let mut cursor = index + 1;
let mut depth = 0i32;
while let Some(current) = lines.get(cursor) {
let open = current.matches('[').count() + current.matches('(').count();
let close = current.matches(']').count() + current.matches(')').count();
let attribute = depth > 0 || current.starts_with("#[");
if !attribute && !current.starts_with("//") && !current.is_empty() {
break;
}
depth += i32::try_from(open).expect("a short line")
- i32::try_from(close).expect("a short line");
cursor += 1;
}
let Some(declaration) = lines.get(cursor) else {
continue;
};
let Some(identifier) = field_identifier(declaration) else {
continue;
};
if !declared_in_a_serde_item(&lines, index) {
continue;
}
let attributes = lines[index + 1..cursor].join(" ");
names.insert(rename_in(&attributes).unwrap_or_else(|| camel_case(&identifier)));
}
}
fn declared_in_a_serde_item(lines: &[&str], gate: usize) -> bool {
let header = lines[..gate].iter().rposition(|line| {
let declaration = line.strip_prefix("pub ").unwrap_or(line);
declaration.starts_with("enum ") || declaration.starts_with("struct ")
});
let Some(header) = header else {
return false;
};
lines[..header]
.iter()
.rev()
.take_while(|line| line.starts_with("#[") || line.starts_with("//"))
.any(|line| line.contains("Serialize"))
}
fn field_identifier(line: &str) -> Option<String> {
let declaration = line.strip_prefix("pub ").unwrap_or(line);
let (identifier, rest) = declaration.split_once(": ")?;
let identifier = identifier.trim();
let names_a_type = rest.starts_with(|c: char| c.is_ascii_uppercase())
|| ["bool", "u16", "u32", "u64", "f64", "usize", "String"]
.iter()
.any(|primitive| rest.starts_with(primitive));
let names_a_field = !identifier.is_empty()
&& identifier.starts_with(|c: char| c.is_ascii_lowercase() || c == '_')
&& identifier
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_');
(names_a_type && names_a_field && line.ends_with(',')).then(|| identifier.to_owned())
}
fn camel_case(identifier: &str) -> String {
let mut camel = String::with_capacity(identifier.len());
let mut capitalize = false;
for character in identifier.chars() {
if character == '_' {
capitalize = true;
} else if capitalize {
camel.extend(character.to_uppercase());
capitalize = false;
} else {
camel.push(character);
}
}
camel
}
fn rename_in(attributes: &str) -> Option<String> {
let (_, rest) = attributes.split_once("rename = \"")?;
let (value, _) = rest.split_once('"')?;
Some(value.to_owned())
}
let mut names = BTreeSet::new();
walk(
&Path::new(env!("CARGO_MANIFEST_DIR")).join("src/model"),
&mut names,
);
assert!(
!names.is_empty(),
"the scan found no gated field, so it is measuring nothing"
);
names
}
#[test]
fn every_three_two_field_is_reported() {
const REPORTER: &str = concat!(
include_str!("downgrade.rs"),
include_str!("downgrade/walk.rs")
);
let segments: BTreeSet<&str> = REPORTER
.split('"')
.skip(1)
.step_by(2)
.flat_map(|literal| literal.split('/'))
.collect();
let unreported: Vec<String> = gated_wire_names()
.into_iter()
.filter(|name| !segments.contains(name.as_str()))
.collect();
assert!(
unreported.is_empty(),
"the model gates {unreported:?} behind `openapi32` and the downgrade names \
none of them as a location segment, so a document carrying one emits as 3.1 \
with no complaint"
);
}
#[test]
fn every_construct_has_a_case() {
const SOURCE: &str = concat!(
include_str!("downgrade.rs"),
include_str!("downgrade/walk.rs")
);
let sites = SOURCE.matches("blockers.push(").count();
assert_eq!(
ledger().len(),
sites,
"`downgrade.rs` reports from {sites} site(s) and {} are covered; a construct added \
without a case is one that can stop being detected, which makes a 3.2 document emit \
as 3.1 with no complaint",
ledger().len()
);
}
}
#[cfg(feature = "openapi32")]
mod reach {
use super::document;
use crate::model::{
body::{RequestBody, media_type::MediaType},
callback::Callback,
components::ComponentName,
document::{Document, SpecVersion},
example::Example,
parameter::{Parameter, ParameterIn, header::Header},
paths::{item::PathItem, method::Method, operation::Operation, template::PathTemplate},
reference::RefOr,
response::{Response, Responses},
schema::Schema,
};
fn key() -> ComponentName {
ComponentName::new("Reused").expect("a legal component key")
}
fn summarised() -> Response {
Response {
summary: Some("The order".to_owned()),
..Response::new("ok")
}
}
fn item_with_summarised_response() -> PathItem {
PathItem::new().with_operation(
Method::Get,
Operation {
responses: Responses::new().with(200, summarised()),
..Operation::default()
},
)
}
fn sequential() -> MediaType {
MediaType::sequential(Schema::any())
}
#[expect(clippy::too_many_lines)]
fn cases() -> Vec<(&'static str, Document, String)> {
let mut cases = Vec::new();
let orders = PathTemplate::parse("/orders").expect("a valid template");
cases.push((
"webhooks",
{
let mut document = document();
document
.webhooks
.insert("orderPlaced".to_owned(), item_with_summarised_response());
document
},
"#/webhooks/orderPlaced/get/responses/200/summary".to_owned(),
));
cases.push((
"components.pathItems",
{
let mut document = document();
document
.components
.path_items
.insert("Shared".to_owned(), item_with_summarised_response());
document
},
"#/components/pathItems/Shared/get/responses/200/summary".to_owned(),
));
cases.push((
"components.responses",
{
let mut document = document();
document
.components
.responses
.insert(key().to_string(), RefOr::Item(summarised()));
document
},
"#/components/responses/Reused/summary".to_owned(),
));
cases.push((
"components.parameters",
{
let mut document = document();
document.components.parameters.insert(
key().to_string(),
RefOr::Item(Parameter::new("q", ParameterIn::Querystring, Schema::any())),
);
document
},
"#/components/parameters/Reused".to_owned(),
));
cases.push((
"components.requestBodies",
{
let mut document = document();
document.components.request_bodies.insert(
key().to_string(),
RefOr::Item(RequestBody::new("application/json", sequential())),
);
document
},
"#/components/requestBodies/Reused/content/application~1json/itemSchema".to_owned(),
));
cases.push((
"components.headers",
{
let mut document = document();
document.components.headers.insert(
key().to_string(),
RefOr::Item(Header::with_content("application/json", sequential())),
);
document
},
"#/components/headers/Reused/content/application~1json/itemSchema".to_owned(),
));
cases.push((
"components.examples",
{
let mut document = document();
document
.components
.examples
.insert(key().to_string(), RefOr::Item(Example::serialized("id=1")));
document
},
"#/components/examples/Reused/serializedValue".to_owned(),
));
cases.push((
"components.callbacks",
{
let mut document = document();
let mut callback = Callback::new();
callback.items.insert(
"{$request.body#/url}".to_owned(),
RefOr::Item(item_with_summarised_response()),
);
document
.components
.callbacks
.insert(key().to_string(), RefOr::Item(callback));
document
},
"#/components/callbacks/Reused/{$request.body#~1url}/get/responses/200/summary"
.to_owned(),
));
cases.push((
"pathItem.parameters",
{
let mut document = document();
document.paths.insert(
&orders,
PathItem {
parameters: vec![RefOr::Item(Parameter::new(
"q",
ParameterIn::Querystring,
Schema::any(),
))],
..PathItem::new()
},
);
document
},
"#/paths/~1orders/parameters/q".to_owned(),
));
cases.push((
"operation.callbacks",
{
let mut document = document();
let mut callback = Callback::new();
callback.items.insert(
"{$request.body#/url}".to_owned(),
RefOr::Item(item_with_summarised_response()),
);
document.paths.insert(
&orders,
PathItem::new().with_operation(
Method::Get,
Operation {
callbacks: [("onData".to_owned(), RefOr::Item(callback))]
.into_iter()
.collect(),
..Operation::default()
},
),
);
document
},
"#/paths/~1orders/get/callbacks/onData/{$request.body#~1url}/get/responses/200/summary"
.to_owned(),
));
cases.push((
"responses.default",
{
let mut document = document();
document.paths.insert(
&orders,
PathItem::new().with_operation(
Method::Get,
Operation {
responses: Responses::new().with_default(summarised()),
..Operation::default()
},
),
);
document
},
"#/paths/~1orders/get/responses/default/summary".to_owned(),
));
cases
}
#[test]
fn every_container_is_walked() {
for (container, document, expected) in cases() {
assert_eq!(
crate::emit::downgrade::three_two_only_constructs(&document),
vec![expected],
"a 3.2 construct in `{container}` must be found and reported there"
);
}
}
#[test]
fn a_construct_in_any_container_refuses_to_emit_as_three_one() {
for (container, document, _) in cases() {
document.emit(SpecVersion::V3_1).expect_err(container);
}
}
}
#[cfg(feature = "openapi32")]
mod positions {
use super::document;
use crate::model::{
document::{Document, SpecVersion},
link::Link,
paths::{item::PathItem, method::Method, operation::Operation, template::PathTemplate},
reference::RefOr,
response::{Response, Responses},
server::Server,
};
fn named() -> Server {
Server {
name: Some("production".to_owned()),
..Server::new("https://api.example.com")
}
}
fn cases() -> Vec<(&'static str, Document, String)> {
let orders = PathTemplate::parse("/orders").expect("a valid template");
let mut cases = Vec::new();
cases.push((
"the document root",
{
let mut document = document();
document.servers.push(named());
document
},
"#/servers/0/name".to_owned(),
));
cases.push((
"a path item",
{
let mut document = document();
document.paths.insert(
&orders,
PathItem {
servers: vec![named()],
..PathItem::new()
},
);
document
},
"#/paths/~1orders/servers/0/name".to_owned(),
));
cases.push((
"an operation",
{
let mut document = document();
document.paths.insert(
&orders,
PathItem::new().with_operation(
Method::Get,
Operation {
servers: vec![named()],
..Operation::default()
},
),
);
document
},
"#/paths/~1orders/get/servers/0/name".to_owned(),
));
cases.push((
"a link on a response",
{
let mut document = document();
let mut link = Link::to_operation("getOrder");
link.server = Some(named());
let response = Response {
links: [("next".to_owned(), RefOr::Item(link))]
.into_iter()
.collect(),
..Response::new("ok")
};
document.paths.insert(
&orders,
PathItem::new().with_operation(
Method::Get,
Operation {
responses: Responses::new().with(200, response),
..Operation::default()
},
),
);
document
},
"#/paths/~1orders/get/responses/200/links/next/server/name".to_owned(),
));
cases
}
#[test]
fn every_position_a_server_hangs_in_is_read() {
for (position, document, expected) in cases() {
assert_eq!(
crate::emit::downgrade::three_two_only_constructs(&document),
vec![expected],
"a 3.2 `Server.name` in {position} must be found and reported there"
);
}
}
#[test]
fn a_named_server_in_any_position_refuses_to_emit_as_three_one() {
for (position, document, _) in cases() {
document.emit(SpecVersion::V3_1).expect_err(position);
}
}
}