use crate::common::helpers::validate_not_visited;
use crate::common::reference::{RefOr, ResolveReference, resolve_in_map};
use crate::loader::Loader;
use crate::v3_2::callback::Callback;
use crate::v3_2::components::Components;
use crate::v3_2::example::Example;
use crate::v3_2::external_documentation::ExternalDocumentation;
use crate::v3_2::header::Header;
use crate::v3_2::info::Info;
use crate::v3_2::link::Link;
use crate::v3_2::operation::Operation;
use crate::v3_2::parameter::Parameter;
use crate::v3_2::path_item::{PathItem, Paths};
use crate::v3_2::request_body::RequestBody;
use crate::v3_2::response::Response;
use crate::v3_2::schema::Schema;
use crate::v3_2::security_scheme::SecurityScheme;
use crate::v3_2::server::Server;
use crate::v3_2::tag::Tag;
use crate::v3_2::validation::{
validate_path_item, validate_path_template_uniqueness, validate_security_requirements,
validate_tag_uniqueness,
};
use crate::validation::{
Context, InvalidComponentName, PushError, ValidateWithContext, check_component_name,
};
use crate::validation::{Error, Options, Validate};
use enumset::EnumSet;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;
use std::fmt::{Display, Formatter};
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct Spec {
pub openapi: Version,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "$self")]
pub self_uri: Option<String>,
pub info: Info,
#[serde(skip_serializing_if = "Option::is_none")]
pub json_schema_dialect: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub servers: Option<Vec<Server>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub paths: Option<Paths>,
#[serde(skip_serializing_if = "Option::is_none")]
pub webhooks: Option<Paths>,
#[serde(skip_serializing_if = "Option::is_none")]
pub components: Option<Components>,
#[serde(skip_serializing_if = "Option::is_none")]
pub security: Option<Vec<BTreeMap<String, Vec<String>>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<Tag>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_docs: Option<ExternalDocumentation>,
#[serde(flatten)]
#[serde(with = "crate::common::extensions")]
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<BTreeMap<String, serde_json::Value>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Version(String);
impl Default for Version {
fn default() -> Self {
Self("3.2.0".to_owned())
}
}
impl Version {
#[allow(non_snake_case)]
pub fn V3_2_0() -> Self {
Self("3.2.0".to_owned())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Display for Version {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
f.write_str(&self.0)
}
}
impl serde::Serialize for Version {
fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
ser.serialize_str(&self.0)
}
}
const VERSION_SCHEMA_DESCRIPTION: &str =
"`3.2.<patch>` semver, optionally with a `-<prerelease>` suffix";
fn matches_oas_3_2_version(s: &str) -> bool {
lazy_regex::regex!(r"^3\.2\.\d+(-.+)?$").is_match(s)
}
impl<'de> serde::Deserialize<'de> for Version {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
Version::try_from(String::deserialize(de)?).map_err(|InvalidVersion(s)| {
serde::de::Error::invalid_value(
serde::de::Unexpected::Str(&s),
&VERSION_SCHEMA_DESCRIPTION,
)
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InvalidVersion(pub String);
impl fmt::Display for InvalidVersion {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
write!(
f,
"version {:?} must be {VERSION_SCHEMA_DESCRIPTION}",
self.0
)
}
}
impl std::error::Error for InvalidVersion {}
impl std::str::FromStr for Version {
type Err = InvalidVersion;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s == "3.2" {
return Ok(Version("3.2.0".to_owned()));
}
if matches_oas_3_2_version(s) {
Ok(Version(s.to_owned()))
} else {
Err(InvalidVersion(s.to_owned()))
}
}
}
impl TryFrom<&str> for Version {
type Error = InvalidVersion;
fn try_from(s: &str) -> Result<Self, Self::Error> {
s.parse()
}
}
impl TryFrom<String> for Version {
type Error = InvalidVersion;
fn try_from(s: String) -> Result<Self, Self::Error> {
if s == "3.2" {
return Ok(Version("3.2.0".to_owned()));
}
if matches_oas_3_2_version(&s) {
Ok(Version(s))
} else {
Err(InvalidVersion(s))
}
}
}
impl ValidateWithContext<Spec> for Version {
fn validate_with_context(&self, ctx: &mut Context<Spec>, path: String) {
if !matches_oas_3_2_version(&self.0) {
ctx.error(path, format_args!("must be {VERSION_SCHEMA_DESCRIPTION}"));
}
}
}
impl Spec {
pub fn define_schema(
&mut self,
name: impl Into<String>,
schema: impl Into<Schema>,
) -> Result<RefOr<Schema>, InvalidComponentName> {
let name = name.into();
check_component_name(&name)?;
let reference = format!("#/components/schemas/{name}");
self.components
.get_or_insert_with(Default::default)
.schemas
.get_or_insert_with(Default::default)
.insert(name, RefOr::new_item(schema.into()));
Ok(RefOr::new_ref(reference))
}
pub fn define_response(
&mut self,
name: impl Into<String>,
response: Response,
) -> Result<RefOr<Response>, InvalidComponentName> {
let name = name.into();
check_component_name(&name)?;
let reference = format!("#/components/responses/{name}");
self.components
.get_or_insert_with(Default::default)
.responses
.get_or_insert_with(Default::default)
.insert(name, RefOr::new_item(response));
Ok(RefOr::new_ref(reference))
}
pub fn define_parameter(
&mut self,
name: impl Into<String>,
parameter: Parameter,
) -> Result<RefOr<Parameter>, InvalidComponentName> {
let name = name.into();
check_component_name(&name)?;
let reference = format!("#/components/parameters/{name}");
self.components
.get_or_insert_with(Default::default)
.parameters
.get_or_insert_with(Default::default)
.insert(name, RefOr::new_item(parameter));
Ok(RefOr::new_ref(reference))
}
pub fn define_example(
&mut self,
name: impl Into<String>,
example: Example,
) -> Result<RefOr<Example>, InvalidComponentName> {
let name = name.into();
check_component_name(&name)?;
let reference = format!("#/components/examples/{name}");
self.components
.get_or_insert_with(Default::default)
.examples
.get_or_insert_with(Default::default)
.insert(name, RefOr::new_item(example));
Ok(RefOr::new_ref(reference))
}
pub fn define_request_body(
&mut self,
name: impl Into<String>,
request_body: RequestBody,
) -> Result<RefOr<RequestBody>, InvalidComponentName> {
let name = name.into();
check_component_name(&name)?;
let reference = format!("#/components/requestBodies/{name}");
self.components
.get_or_insert_with(Default::default)
.request_bodies
.get_or_insert_with(Default::default)
.insert(name, RefOr::new_item(request_body));
Ok(RefOr::new_ref(reference))
}
pub fn define_header(
&mut self,
name: impl Into<String>,
header: Header,
) -> Result<RefOr<Header>, InvalidComponentName> {
let name = name.into();
check_component_name(&name)?;
let reference = format!("#/components/headers/{name}");
self.components
.get_or_insert_with(Default::default)
.headers
.get_or_insert_with(Default::default)
.insert(name, RefOr::new_item(header));
Ok(RefOr::new_ref(reference))
}
pub fn define_security_scheme(
&mut self,
name: impl Into<String>,
scheme: SecurityScheme,
) -> Result<RefOr<SecurityScheme>, InvalidComponentName> {
let name = name.into();
check_component_name(&name)?;
let reference = format!("#/components/securitySchemes/{name}");
self.components
.get_or_insert_with(Default::default)
.security_schemes
.get_or_insert_with(Default::default)
.insert(name, RefOr::new_item(scheme));
Ok(RefOr::new_ref(reference))
}
pub fn define_link(
&mut self,
name: impl Into<String>,
link: Link,
) -> Result<RefOr<Link>, InvalidComponentName> {
let name = name.into();
check_component_name(&name)?;
let reference = format!("#/components/links/{name}");
self.components
.get_or_insert_with(Default::default)
.links
.get_or_insert_with(Default::default)
.insert(name, RefOr::new_item(link));
Ok(RefOr::new_ref(reference))
}
pub fn define_callback(
&mut self,
name: impl Into<String>,
callback: Callback,
) -> Result<RefOr<Callback>, InvalidComponentName> {
let name = name.into();
check_component_name(&name)?;
let reference = format!("#/components/callbacks/{name}");
self.components
.get_or_insert_with(Default::default)
.callbacks
.get_or_insert_with(Default::default)
.insert(name, RefOr::new_item(callback));
Ok(RefOr::new_ref(reference))
}
pub fn define_path_item(
&mut self,
name: impl Into<String>,
path_item: PathItem,
) -> Result<PathItem, InvalidComponentName> {
let name = name.into();
check_component_name(&name)?;
let reference = format!("#/components/pathItems/{name}");
self.components
.get_or_insert_with(Default::default)
.path_items
.get_or_insert_with(Default::default)
.insert(name, path_item);
Ok(PathItem {
reference: Some(reference),
..Default::default()
})
}
pub fn collapse(
&mut self,
loader: Option<&mut Loader>,
) -> Result<(), crate::v3_2::collapse::CollapseError> {
crate::v3_2::collapse::collapse_spec(self, loader)
}
}
impl ResolveReference<Response> for Spec {
fn resolve_reference(&self, reference: &str) -> Option<&Response> {
self.components
.as_ref()
.and_then(|x| resolve_in_map(self, reference, "#/components/responses/", &x.responses))
}
}
impl ResolveReference<Parameter> for Spec {
fn resolve_reference(&self, reference: &str) -> Option<&Parameter> {
self.components.as_ref().and_then(|x| {
resolve_in_map(self, reference, "#/components/parameters/", &x.parameters)
})
}
}
impl ResolveReference<RequestBody> for Spec {
fn resolve_reference(&self, reference: &str) -> Option<&RequestBody> {
self.components.as_ref().and_then(|x| {
resolve_in_map(
self,
reference,
"#/components/requestBodies/",
&x.request_bodies,
)
})
}
}
impl ResolveReference<Header> for Spec {
fn resolve_reference(&self, reference: &str) -> Option<&Header> {
self.components
.as_ref()
.and_then(|x| resolve_in_map(self, reference, "#/components/headers/", &x.headers))
}
}
impl ResolveReference<Schema> for Spec {
fn resolve_reference(&self, reference: &str) -> Option<&Schema> {
self.components
.as_ref()
.and_then(|x| resolve_in_map(self, reference, "#/components/schemas/", &x.schemas))
}
}
impl ResolveReference<Example> for Spec {
fn resolve_reference(&self, reference: &str) -> Option<&Example> {
self.components
.as_ref()
.and_then(|x| resolve_in_map(self, reference, "#/components/examples/", &x.examples))
}
}
impl ResolveReference<Callback> for Spec {
fn resolve_reference(&self, reference: &str) -> Option<&Callback> {
self.components
.as_ref()
.and_then(|x| resolve_in_map(self, reference, "#/components/callbacks/", &x.callbacks))
}
}
impl ResolveReference<PathItem> for Spec {
fn resolve_reference(&self, reference: &str) -> Option<&PathItem> {
let key = reference.strip_prefix("#/components/pathItems/")?;
self.components
.as_ref()
.and_then(|c| c.path_items.as_ref())
.and_then(|m| m.get(key))
}
}
impl ResolveReference<Link> for Spec {
fn resolve_reference(&self, reference: &str) -> Option<&Link> {
self.components
.as_ref()
.and_then(|x| resolve_in_map(self, reference, "#/components/links/", &x.links))
}
}
impl ResolveReference<SecurityScheme> for Spec {
fn resolve_reference(&self, reference: &str) -> Option<&SecurityScheme> {
self.components.as_ref().and_then(|x| {
resolve_in_map(
self,
reference,
"#/components/securitySchemes/",
&x.security_schemes,
)
})
}
}
impl ResolveReference<Tag> for Spec {
fn resolve_reference(&self, reference: &str) -> Option<&Tag> {
self.tags.as_ref().and_then(|x| {
x.iter()
.find(|x| x.name == reference.trim_start_matches("#/tags/"))
})
}
}
impl ResolveReference<crate::v3_2::media_type::MediaType> for Spec {
fn resolve_reference(&self, reference: &str) -> Option<&crate::v3_2::media_type::MediaType> {
self.components.as_ref().and_then(|x| {
resolve_in_map(self, reference, "#/components/mediaTypes/", &x.media_types)
})
}
}
fn walk_path_item_ops<'a>(
item: &'a PathItem,
location: String,
spec: &'a Spec,
out: &mut Vec<(&'a Operation, String)>,
seen_cb: &mut std::collections::HashSet<*const Callback>,
) {
if let Some(operations) = &item.operations {
for (method, op) in operations {
walk_op(op, format!("{location}.{method}"), spec, out, seen_cb);
}
}
if let Some(extra) = &item.additional_operations {
for (method, op) in extra {
walk_op(
op,
format!("{location}.additionalOperations[{method}]"),
spec,
out,
seen_cb,
);
}
}
}
fn walk_op<'a>(
op: &'a Operation,
op_loc: String,
spec: &'a Spec,
out: &mut Vec<(&'a Operation, String)>,
seen_cb: &mut std::collections::HashSet<*const Callback>,
) {
out.push((op, op_loc.clone()));
if let Some(cbs) = &op.callbacks {
for (cb_name, cb_ref) in cbs {
if let Ok(cb) = cb_ref.get_item(spec)
&& seen_cb.insert(cb as *const Callback)
{
for (expr, pi) in &cb.paths {
walk_path_item_ops(
pi,
format!("{op_loc}.callbacks[{cb_name}][{expr}]"),
spec,
out,
seen_cb,
);
}
}
}
}
}
impl Spec {
fn validate_inner<'a>(
&'a self,
options: EnumSet<Options>,
loader: Option<&'a mut Loader>,
) -> Result<(), Error> {
let mut ctx = match loader {
Some(l) => Context::new(self, options).with_loader(l),
None => Context::new(self, options),
};
self.openapi
.validate_with_context(&mut ctx, "#.openapi".to_owned());
self.info
.validate_with_context(&mut ctx, "#.info".to_owned());
crate::common::helpers::validate_optional_uri(
&self.json_schema_dialect,
&mut ctx,
"#.jsonSchemaDialect".to_owned(),
);
crate::common::helpers::validate_optional_uri(
&self.self_uri,
&mut ctx,
"#.$self".to_owned(),
);
if let Some(self_uri) = &self.self_uri
&& self_uri.contains('#')
{
ctx.error("#.$self".to_owned(), "MUST NOT contain a fragment (`#`)");
}
if let Some(servers) = &self.servers {
for (i, server) in servers.iter().enumerate() {
server.validate_with_context(&mut ctx, format!("#.servers[{i}]"))
}
}
let mut found: Vec<(&Operation, String)> = Vec::new();
let mut seen_cb: std::collections::HashSet<*const Callback> =
std::collections::HashSet::new();
if let Some(paths) = &self.paths {
for (name, item) in paths.iter() {
walk_path_item_ops(
item,
format!("paths[{name}]"),
self,
&mut found,
&mut seen_cb,
);
}
}
if let Some(webhooks) = &self.webhooks {
for (name, item) in webhooks.iter() {
walk_path_item_ops(
item,
format!("webhooks[{name}]"),
self,
&mut found,
&mut seen_cb,
);
}
}
if let Some(components) = &self.components {
if let Some(map) = &components.path_items {
for (name, item) in map.iter() {
walk_path_item_ops(
item,
format!("components.pathItems[{name}]"),
self,
&mut found,
&mut seen_cb,
);
}
}
if let Some(cbs) = &components.callbacks {
for (cb_name, cb_ref) in cbs {
if let Ok(cb) = cb_ref.get_item(self)
&& seen_cb.insert(cb as *const Callback)
{
for (expr, pi) in &cb.paths {
walk_path_item_ops(
pi,
format!("components.callbacks[{cb_name}][{expr}]"),
self,
&mut found,
&mut seen_cb,
);
}
}
}
}
}
for (op, location) in found {
if let Some(operation_id) = &op.operation_id
&& !ctx
.visited
.insert(format!("#/paths/operations/{operation_id}"))
&& !ctx.is_option(Options::IgnoreNonUniqOperationIDs)
{
ctx.error(
"#".to_owned(),
format_args!(".{location}.operationId: `{operation_id}` already in use"),
);
}
}
if let Some(sec) = &self.security {
validate_security_requirements(&mut ctx, "#.security", sec);
}
if let Some(paths) = &self.paths {
validate_path_template_uniqueness(&mut ctx, "#.paths", &paths.paths);
for (name, item) in paths.iter() {
let path = format!("#.paths[{name}]");
if !name.starts_with('/') {
ctx.error(path.clone(), "must start with `/`");
}
item.validate_with_context(&mut ctx, path.clone());
validate_path_item(&mut ctx, name, &path, item);
}
}
if let Some(webhooks) = &self.webhooks {
for (name, item) in webhooks.iter() {
let path = format!("#.webhooks[{name}]");
item.validate_with_context(&mut ctx, path);
}
}
if let Some(components) = &self.components {
components.validate_with_context(&mut ctx, "#.components".to_owned());
}
if self.components.is_none() && self.paths.is_none() && self.webhooks.is_none() {
ctx.error(
"#".into(),
"at least one of `paths`, `webhooks` or `components` must be used",
);
}
if let Some(docs) = &self.external_docs {
docs.validate_with_context(&mut ctx, "#.externalDocs".to_owned())
}
if let Some(tags) = &self.tags {
validate_tag_uniqueness(&mut ctx, tags);
for tag in tags.iter() {
let path = format!("#/tags/{}", tag.name);
validate_not_visited(tag, &mut ctx, Options::IgnoreUnusedTags, path);
}
}
ctx.into()
}
}
impl Validate for Spec {
fn validate(
&self,
options: EnumSet<Options>,
loader: Option<&mut Loader>,
) -> Result<(), Error> {
self.validate_inner(options, loader)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::validation::IGNORE_UNUSED;
#[test]
fn validate_with_loader_resolves_external_schema_ref() {
let spec: Spec = serde_json::from_value(serde_json::json!({
"openapi": "3.2.0",
"info": { "title": "test", "version": "1.0" },
"paths": {},
"components": {
"schemas": {
"PetRef": { "$ref": "external.json#/Pet" }
}
}
}))
.expect("spec must parse");
let err = spec
.validate(IGNORE_UNUSED, None)
.expect_err("external ref must error when no loader is attached");
assert!(
err.errors
.iter()
.any(|e| e.contains("external.json#/Pet") && e.contains("not supported")),
"expected `not supported` error, got: {:?}",
err.errors,
);
let mut loader = Loader::new();
loader
.preload_resource(
"external.json",
serde_json::json!({
"Pet": { "type": "object", "properties": {} }
}),
)
.expect("preload must succeed");
spec.validate(IGNORE_UNUSED, Some(&mut loader))
.expect("validation must succeed when external ref is preloaded");
let mut empty_loader = Loader::new();
let err = spec
.validate(IGNORE_UNUSED, Some(&mut empty_loader))
.expect_err("missing fetcher must surface as a validation error");
assert!(
err.errors
.iter()
.any(|e| { e.contains("external.json#/Pet") && e.contains("failed to resolve") }),
"expected `failed to resolve` error, got: {:?}",
err.errors,
);
}
#[test]
fn loader_typed_cache_avoids_redundant_deserialization() {
let spec: Spec = serde_json::from_value(serde_json::json!({
"openapi": "3.2.0",
"info": { "title": "test", "version": "1.0" },
"paths": {},
"components": {
"schemas": {
"A": { "$ref": "external.json#/Pet" },
"B": { "$ref": "external.json#/Pet" }
}
}
}))
.expect("spec must parse");
let mut loader = Loader::new();
loader
.preload_resource(
"external.json",
serde_json::json!({
"Pet": { "type": "object", "properties": {} }
}),
)
.unwrap();
spec.validate(IGNORE_UNUSED, Some(&mut loader))
.expect("two refs to the same external pointer must validate");
let first: Schema = loader
.resolve_reference_as("external.json#/Pet")
.expect("first lookup");
let second: Schema = loader
.resolve_reference_as("external.json#/Pet")
.expect("cached lookup");
assert_eq!(first, second, "cached typed value must round-trip equal");
}
#[test]
fn test_version_deserialize() {
assert_eq!(
serde_json::from_value::<Version>(serde_json::json!("3.2.0")).unwrap(),
Version::V3_2_0(),
"correct openapi version",
);
assert_eq!(
serde_json::from_value::<Version>(serde_json::json!("3.2")).unwrap(),
Version::V3_2_0(),
"`3.2` short alias is accepted",
);
assert!(
serde_json::from_value::<Version>(serde_json::json!("foo"))
.unwrap_err()
.to_string()
.contains("expected `3.2.<patch>` semver"),
"foo as openapi version",
);
for ok in ["3.2.0", "3.2.1", "3.2.42", "3.2.0-rc1", "3.2.7-beta.3"] {
let v: Version = serde_json::from_value(serde_json::json!(ok))
.expect("must accept patch/prerelease");
assert_eq!(v.as_str(), ok, "round-trip `{ok}`");
}
assert_eq!(
serde_json::from_value::<Spec>(serde_json::json!({
"openapi": "3.2.0",
"info": {
"title": "foo",
"version": "1",
},
"paths": {},
}))
.unwrap()
.openapi,
Version::V3_2_0(),
"3.2.0 spec.openapi",
);
assert_eq!(
serde_json::from_value::<Spec>(serde_json::json!({
"openapi": "3.2",
"info": {"title": "foo", "version": "1"},
"paths": {},
}))
.unwrap()
.openapi,
Version::V3_2_0(),
"`3.2` short alias accepted at Spec level too",
);
assert!(
serde_json::from_value::<Spec>(serde_json::json!({
"openapi": "",
"info": {
"title": "foo",
"version": "1",
},
"paths": {},
}))
.unwrap_err()
.to_string()
.contains("expected `3.2.<patch>` semver"),
"empty spec.openapi",
);
assert_eq!(
serde_json::from_value::<Spec>(serde_json::json!({
"info": {
"title": "foo",
"version": "1",
},
"paths": {},
}))
.unwrap_err()
.to_string(),
"missing field `openapi`",
"missing spec.openapi",
);
}
#[test]
fn test_version_serialize() {
assert_eq!(
serde_json::to_string(&Version::V3_2_0()).unwrap(),
r#""3.2.0""#,
);
assert_eq!(
serde_json::to_string(&Version::default()).unwrap(),
r#""3.2.0""#,
);
}
#[test]
fn test_version_validate() {
let spec = Spec::default();
let mut ctx = Context::new(&spec, Options::new());
Version::default().validate_with_context(&mut ctx, "#.openapi".to_owned());
Version::V3_2_0().validate_with_context(&mut ctx, "#.openapi".to_owned());
"3.2.99"
.parse::<Version>()
.unwrap()
.validate_with_context(&mut ctx, "#.openapi".to_owned());
assert!(ctx.errors.is_empty(), "errors: {:?}", ctx.errors);
}
#[test]
fn test_version_validate_rejects_invalid() {
let spec = Spec::default();
let mut ctx = Context::new(&spec, Options::new());
Version("garbage".to_owned()).validate_with_context(&mut ctx, "#.openapi".to_owned());
assert_eq!(ctx.errors.len(), 1);
assert!(
ctx.errors[0].contains("#.openapi") && ctx.errors[0].contains("3.2.<patch>"),
"errors: {:?}",
ctx.errors
);
}
#[test]
fn test_spec_validate_surfaces_invalid_openapi() {
let mut spec = Spec {
openapi: Version("3.5.0".to_owned()),
..Default::default()
};
spec.info.title = "test".to_owned();
spec.info.version = "1".to_owned();
let err = spec.validate(Options::new(), None).unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.contains("#.openapi") && e.contains("3.2.<patch>")),
"Spec::validate surfaces the openapi error: {:?}",
err.errors
);
}
#[test]
fn test_version_try_from_string_normalizes_short_alias() {
let v: Version = "3.2".to_owned().try_into().unwrap();
assert_eq!(v, Version::V3_2_0());
let err: InvalidVersion = Version::try_from("nope".to_owned()).unwrap_err();
assert_eq!(err.0, "nope");
}
#[test]
fn test_version_parse_programmatically() {
use std::str::FromStr;
assert_eq!(
Version::from_str("3.2.99").unwrap(),
Version("3.2.99".to_owned())
);
assert_eq!(
Version::from_str("3.2.0-rc1").unwrap(),
Version("3.2.0-rc1".to_owned())
);
assert_eq!(Version::from_str("3.2").unwrap(), Version::V3_2_0());
assert_eq!(
<Version as TryFrom<&str>>::try_from("3.2.7").unwrap(),
Version("3.2.7".to_owned())
);
assert_eq!(
<Version as TryFrom<String>>::try_from("3.2.7".to_owned()).unwrap(),
Version("3.2.7".to_owned())
);
let err = Version::from_str("foo").unwrap_err();
assert_eq!(err, InvalidVersion("foo".to_owned()));
assert!(
err.to_string().contains("3.2.<patch>"),
"error message describes the schema: {err}"
);
}
#[test]
fn full_spec_validate_drives_path_template_uniqueness() {
use crate::v3_2::operation::Operation;
use crate::v3_2::path_item::Paths;
use crate::v3_2::response::Responses;
let make_op = || Operation {
responses: Some(Responses {
responses: Some(BTreeMap::from([(
"200".to_owned(),
RefOr::new_item(Response {
description: Some("ok".into()),
..Default::default()
}),
)])),
..Default::default()
}),
..Default::default()
};
let mut ops_a: BTreeMap<String, Operation> = BTreeMap::new();
ops_a.insert("get".to_owned(), make_op());
let mut ops_b: BTreeMap<String, Operation> = BTreeMap::new();
ops_b.insert("get".to_owned(), make_op());
let mut paths = Paths::default();
paths.paths.insert(
"/pets/{id}".into(),
PathItem {
operations: Some(ops_a),
..Default::default()
},
);
paths.paths.insert(
"/pets/{name}".into(),
PathItem {
operations: Some(ops_b),
..Default::default()
},
);
let spec = Spec {
info: Info {
title: "x".into(),
version: "1".into(),
..Default::default()
},
paths: Some(paths),
..Default::default()
};
let err = spec.validate(Options::new(), None).unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.contains("collapse to the same shape")),
"expected equivalent-template error: {:?}",
err.errors
);
}
#[test]
fn webhooks_validation_runs() {
use crate::v3_2::operation::Operation;
use crate::v3_2::path_item::Paths;
use crate::v3_2::response::Responses;
let mut ops: BTreeMap<String, Operation> = BTreeMap::new();
ops.insert(
"post".to_owned(),
Operation {
responses: Some(Responses {
responses: Some(BTreeMap::from([(
"200".to_owned(),
RefOr::new_item(Response {
description: Some("ok".into()),
..Default::default()
}),
)])),
..Default::default()
}),
security: Some(vec![{
let mut req = BTreeMap::new();
req.insert("missing-scheme".to_owned(), vec![]);
req
}]),
..Default::default()
},
);
let mut webhooks = Paths::default();
webhooks.paths.insert(
"newPet".to_owned(),
PathItem {
operations: Some(ops),
..Default::default()
},
);
let spec = Spec {
info: Info {
title: "x".into(),
version: "1".into(),
..Default::default()
},
webhooks: Some(webhooks),
..Default::default()
};
let err = spec.validate(Options::new(), None).unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.contains("missing-scheme") && e.contains("post.security")),
"expected webhook-nested security validation: {:?}",
err.errors
);
}
#[test]
fn operation_id_unique_across_paths_and_webhooks() {
use crate::v3_2::operation::Operation;
use crate::v3_2::path_item::Paths;
use crate::v3_2::response::Responses;
let make_op = |id: &str| Operation {
operation_id: Some(id.to_owned()),
responses: Some(Responses {
responses: Some(BTreeMap::from([(
"200".to_owned(),
RefOr::new_item(Response {
description: Some("ok".into()),
..Default::default()
}),
)])),
..Default::default()
}),
..Default::default()
};
let mut path_ops: BTreeMap<String, Operation> = BTreeMap::new();
path_ops.insert("get".to_owned(), make_op("dup"));
let mut webhook_ops: BTreeMap<String, Operation> = BTreeMap::new();
webhook_ops.insert("post".to_owned(), make_op("dup"));
let mut paths = Paths::default();
paths.paths.insert(
"/pets".to_owned(),
PathItem {
operations: Some(path_ops),
..Default::default()
},
);
let mut webhooks = Paths::default();
webhooks.paths.insert(
"petCreated".to_owned(),
PathItem {
operations: Some(webhook_ops),
..Default::default()
},
);
let spec = Spec {
info: Info {
title: "x".into(),
version: "1".into(),
..Default::default()
},
paths: Some(paths),
webhooks: Some(webhooks),
..Default::default()
};
let err = spec.validate(Options::new(), None).unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.contains("`dup` already in use")),
"expected operationId duplicate across paths/webhooks: {:?}",
err.errors
);
let result = spec.validate(Options::IgnoreNonUniqOperationIDs.into(), None);
let errors_with_ignore: Vec<String> = result
.err()
.map(|e| e.errors.iter().map(|e| e.to_string()).collect())
.unwrap_or_default();
assert!(
errors_with_ignore
.iter()
.all(|s| !s.contains("already in use")),
"IgnoreNonUniqOperationIDs must suppress the duplicate, got: {errors_with_ignore:?}",
);
}
#[test]
fn all_define_helpers_insert_and_return_ref() {
use crate::v3_2::callback::Callback;
use crate::v3_2::example::Example;
use crate::v3_2::header::Header;
use crate::v3_2::link::Link;
use crate::v3_2::parameter::{InQuery, Parameter};
use crate::v3_2::request_body::RequestBody;
use crate::v3_2::response::Response;
use crate::v3_2::schema::{SingleSchema, StringSchema};
use crate::v3_2::security_scheme::{HttpSecurityScheme, SecurityScheme};
let mut spec = Spec::default();
let r = spec
.define_schema(
"S",
Schema::Single(Box::new(SingleSchema::String(StringSchema::default()))),
)
.unwrap();
assert!(matches!(r, RefOr::Ref(ref rr) if rr.reference == "#/components/schemas/S"));
let r = spec.define_response("R", Response::default()).unwrap();
assert!(matches!(r, RefOr::Ref(ref rr) if rr.reference == "#/components/responses/R"));
let r = spec
.define_parameter(
"Q",
Parameter::Query(Box::new(InQuery {
name: "q".into(),
description: None,
required: None,
deprecated: None,
allow_empty_value: None,
style: None,
explode: None,
allow_reserved: None,
schema: None,
example: None,
examples: None,
content: None,
extensions: None,
})),
)
.unwrap();
assert!(matches!(r, RefOr::Ref(ref rr) if rr.reference == "#/components/parameters/Q"));
let r = spec.define_example("Ex", Example::default()).unwrap();
assert!(matches!(r, RefOr::Ref(ref rr) if rr.reference == "#/components/examples/Ex"));
let r = spec
.define_request_body("RB", RequestBody::default())
.unwrap();
assert!(matches!(r, RefOr::Ref(ref rr) if rr.reference == "#/components/requestBodies/RB"));
let r = spec.define_header("H", Header::default()).unwrap();
assert!(matches!(r, RefOr::Ref(ref rr) if rr.reference == "#/components/headers/H"));
let r = spec
.define_security_scheme(
"S",
SecurityScheme::HTTP(Box::new(HttpSecurityScheme {
deprecated: None,
scheme: "Basic".into(),
bearer_format: None,
description: None,
extensions: None,
})),
)
.unwrap();
assert!(
matches!(r, RefOr::Ref(ref rr) if rr.reference == "#/components/securitySchemes/S")
);
let r = spec.define_link("L", Link::default()).unwrap();
assert!(matches!(r, RefOr::Ref(ref rr) if rr.reference == "#/components/links/L"));
let r = spec.define_callback("CB", Callback::default()).unwrap();
assert!(matches!(r, RefOr::Ref(ref rr) if rr.reference == "#/components/callbacks/CB"));
let pi = spec.define_path_item("PI", PathItem::default()).unwrap();
assert_eq!(pi.reference.as_deref(), Some("#/components/pathItems/PI"),);
let comp = spec.components.as_ref().unwrap();
assert!(comp.schemas.as_ref().unwrap().contains_key("S"));
assert!(comp.responses.as_ref().unwrap().contains_key("R"));
assert!(comp.parameters.as_ref().unwrap().contains_key("Q"));
assert!(comp.examples.as_ref().unwrap().contains_key("Ex"));
assert!(comp.request_bodies.as_ref().unwrap().contains_key("RB"));
assert!(comp.headers.as_ref().unwrap().contains_key("H"));
assert!(comp.security_schemes.as_ref().unwrap().contains_key("S"));
assert!(comp.links.as_ref().unwrap().contains_key("L"));
assert!(comp.callbacks.as_ref().unwrap().contains_key("CB"));
assert!(comp.path_items.as_ref().unwrap().contains_key("PI"));
}
#[test]
fn define_helpers_reject_invalid_names() {
use crate::v3_2::callback::Callback;
use crate::v3_2::example::Example;
use crate::v3_2::header::Header;
use crate::v3_2::link::Link;
use crate::v3_2::request_body::RequestBody;
use crate::v3_2::response::Response;
use crate::v3_2::security_scheme::{HttpSecurityScheme, SecurityScheme};
let mut spec = Spec::default();
let bad = "x y";
assert!(spec.define_response(bad, Response::default()).is_err());
assert!(spec.define_example(bad, Example::default()).is_err());
assert!(
spec.define_request_body(bad, RequestBody::default())
.is_err()
);
assert!(spec.define_header(bad, Header::default()).is_err());
assert!(
spec.define_security_scheme(
bad,
SecurityScheme::HTTP(Box::new(HttpSecurityScheme {
scheme: "Basic".into(),
..Default::default()
})),
)
.is_err()
);
assert!(spec.define_link(bad, Link::default()).is_err());
assert!(spec.define_callback(bad, Callback::default()).is_err());
assert!(spec.define_path_item(bad, PathItem::default()).is_err());
assert!(spec.components.is_none());
}
#[test]
fn resolve_reference_paths_for_each_component_kind() {
use crate::v3_2::callback::Callback;
use crate::v3_2::example::Example;
use crate::v3_2::header::Header;
use crate::v3_2::link::Link;
use crate::v3_2::parameter::{InQuery, Parameter};
use crate::v3_2::request_body::RequestBody;
use crate::v3_2::response::Response;
use crate::v3_2::schema::{SingleSchema, StringSchema};
use crate::v3_2::security_scheme::{HttpSecurityScheme, SecurityScheme};
let mut spec = Spec::default();
spec.define_schema(
"S",
Schema::Single(Box::new(SingleSchema::String(StringSchema::default()))),
)
.unwrap();
spec.define_response("R", Response::default()).unwrap();
spec.define_parameter(
"P",
Parameter::Query(Box::new(InQuery {
name: "q".into(),
description: None,
required: None,
deprecated: None,
allow_empty_value: None,
style: None,
explode: None,
allow_reserved: None,
schema: None,
example: None,
examples: None,
content: None,
extensions: None,
})),
)
.unwrap();
spec.define_request_body("RB", RequestBody::default())
.unwrap();
spec.define_header("H", Header::default()).unwrap();
spec.define_example("E", Example::default()).unwrap();
spec.define_callback("CB", Callback::default()).unwrap();
spec.define_link("L", Link::default()).unwrap();
spec.define_security_scheme(
"SS",
SecurityScheme::HTTP(Box::new(HttpSecurityScheme {
scheme: "Basic".into(),
..Default::default()
})),
)
.unwrap();
spec.define_path_item("PI", PathItem::default()).unwrap();
assert!(
<Spec as ResolveReference<Schema>>::resolve_reference(&spec, "#/components/schemas/S")
.is_some()
);
assert!(
<Spec as ResolveReference<Response>>::resolve_reference(
&spec,
"#/components/responses/R"
)
.is_some()
);
assert!(
<Spec as ResolveReference<Parameter>>::resolve_reference(
&spec,
"#/components/parameters/P"
)
.is_some()
);
assert!(
<Spec as ResolveReference<RequestBody>>::resolve_reference(
&spec,
"#/components/requestBodies/RB"
)
.is_some()
);
assert!(
<Spec as ResolveReference<Header>>::resolve_reference(&spec, "#/components/headers/H")
.is_some()
);
assert!(
<Spec as ResolveReference<Example>>::resolve_reference(
&spec,
"#/components/examples/E"
)
.is_some()
);
assert!(
<Spec as ResolveReference<Callback>>::resolve_reference(
&spec,
"#/components/callbacks/CB"
)
.is_some()
);
assert!(
<Spec as ResolveReference<Link>>::resolve_reference(&spec, "#/components/links/L")
.is_some()
);
assert!(
<Spec as ResolveReference<SecurityScheme>>::resolve_reference(
&spec,
"#/components/securitySchemes/SS"
)
.is_some()
);
assert!(
<Spec as ResolveReference<PathItem>>::resolve_reference(
&spec,
"#/components/pathItems/PI"
)
.is_some()
);
assert!(
<Spec as ResolveReference<Schema>>::resolve_reference(
&spec,
"#/components/parameters/S"
)
.is_none()
);
let spec = Spec {
tags: Some(vec![Tag {
name: "pets".into(),
..Default::default()
}]),
..Default::default()
};
assert!(<Spec as ResolveReference<Tag>>::resolve_reference(&spec, "#/tags/pets").is_some());
assert!(
<Spec as ResolveReference<Tag>>::resolve_reference(&spec, "#/tags/missing").is_none()
);
}
#[test]
fn version_display_all_variants() {
assert_eq!(Version::V3_2_0().to_string(), "3.2.0");
}
#[test]
fn json_schema_dialect_uri_validated() {
let spec = Spec {
info: Info {
title: "x".into(),
version: "1".into(),
..Default::default()
},
json_schema_dialect: Some("urn:example:dialect".into()),
paths: Some(Default::default()),
..Default::default()
};
assert!(spec.validate(Options::new(), None).is_ok());
let spec = Spec {
info: Info {
title: "x".into(),
version: "1".into(),
..Default::default()
},
json_schema_dialect: Some("not a uri".into()),
paths: Some(Default::default()),
..Default::default()
};
let err = spec.validate(Options::new(), None).unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.contains("jsonSchemaDialect") && e.contains("must be a valid URI")),
"errors: {:?}",
err.errors
);
let spec = Spec {
info: Info {
title: "x".into(),
version: "1".into(),
..Default::default()
},
json_schema_dialect: Some("".into()),
paths: Some(Default::default()),
..Default::default()
};
let err = spec.validate(Options::new(), None).unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.contains("jsonSchemaDialect") && e.contains("must be a valid URI")),
"errors: {:?}",
err.errors
);
}
#[test]
fn self_uri_with_fragment_errors() {
let spec = Spec {
info: Info {
title: "x".into(),
version: "1".into(),
..Default::default()
},
self_uri: Some("https://example.com/api.openapi#section".into()),
paths: Some(Default::default()),
..Default::default()
};
let err = spec.validate(Options::new(), None).unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.contains("$self") && e.contains("MUST NOT contain a fragment")),
"expected fragment error: {:?}",
err.errors
);
}
#[test]
fn paths_must_start_with_slash() {
let mut paths = Paths::default();
paths.paths.insert("pets".to_owned(), PathItem::default()); let spec = Spec {
info: Info {
title: "x".into(),
version: "1".into(),
..Default::default()
},
paths: Some(paths),
..Default::default()
};
let err = spec.validate(Options::new(), None).unwrap_err();
assert!(
err.errors.iter().any(|e| e.contains("must start with `/`")),
"expected slash error: {:?}",
err.errors
);
}
#[test]
fn additional_operations_op_id_uniqueness() {
use crate::v3_2::operation::Operation;
use crate::v3_2::response::{Response, Responses};
let make_op = |id: &str| Operation {
operation_id: Some(id.to_owned()),
responses: Some(Responses {
responses: Some(BTreeMap::from([(
"200".to_owned(),
RefOr::new_item(Response {
description: Some("ok".into()),
..Default::default()
}),
)])),
..Default::default()
}),
..Default::default()
};
let mut additional_ops = BTreeMap::new();
additional_ops.insert("SEARCH".to_owned(), make_op("dup"));
let mut paths = Paths::default();
paths.paths.insert(
"/a".to_owned(),
PathItem {
operations: Some(BTreeMap::from([("get".to_owned(), make_op("dup"))])),
additional_operations: Some(additional_ops),
..Default::default()
},
);
let spec = Spec {
info: Info {
title: "x".into(),
version: "1".into(),
..Default::default()
},
paths: Some(paths),
..Default::default()
};
let err = spec.validate(Options::new(), None).unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.contains("operationId") && e.contains("`dup`")),
"expected duplicate operationId via additionalOperations: {:?}",
err.errors
);
}
#[test]
fn top_level_security_validated() {
use crate::v3_2::security_scheme::{ApiKeyLocation, ApiKeySecurityScheme, SecurityScheme};
let mut schemes = BTreeMap::new();
schemes.insert(
"apiKey".to_owned(),
RefOr::new_item(SecurityScheme::ApiKey(Box::new(ApiKeySecurityScheme {
name: "X-API-Key".into(),
location: ApiKeyLocation::Header,
..Default::default()
}))),
);
let spec = Spec {
info: Info {
title: "x".into(),
version: "1".into(),
..Default::default()
},
security: Some(vec![BTreeMap::from([(
"missing-scheme".to_owned(),
vec![],
)])]),
components: Some(crate::v3_2::components::Components {
security_schemes: Some(schemes),
..Default::default()
}),
paths: Some(Default::default()),
..Default::default()
};
let err = spec.validate(Options::new(), None).unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.contains("missing-scheme") && e.contains("not declared")),
"expected security error: {:?}",
err.errors
);
}
#[test]
fn components_callbacks_op_ids_walked() {
use crate::v3_2::callback::Callback;
use crate::v3_2::components::Components;
use crate::v3_2::operation::Operation;
use crate::v3_2::response::{Response, Responses};
let make_op = |id: &str| Operation {
operation_id: Some(id.to_owned()),
responses: Some(Responses {
responses: Some(BTreeMap::from([(
"200".to_owned(),
RefOr::new_item(Response {
description: Some("ok".into()),
..Default::default()
}),
)])),
..Default::default()
}),
..Default::default()
};
let mut cb_paths = BTreeMap::new();
cb_paths.insert(
"expr".to_owned(),
PathItem {
operations: Some(BTreeMap::from([("post".to_owned(), make_op("cbOp"))])),
..Default::default()
},
);
let comp = Components {
callbacks: Some(BTreeMap::from([(
"MyCb".to_owned(),
RefOr::new_item(Callback {
paths: cb_paths,
..Default::default()
}),
)])),
..Default::default()
};
let mut paths = Paths::default();
paths.paths.insert(
"/a".to_owned(),
PathItem {
operations: Some(BTreeMap::from([("get".to_owned(), make_op("cbOp"))])),
..Default::default()
},
);
let spec = Spec {
info: Info {
title: "x".into(),
version: "1".into(),
..Default::default()
},
paths: Some(paths),
components: Some(comp),
..Default::default()
};
let err = spec.validate(Options::new(), None).unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.contains("operationId") && e.contains("`cbOp`")),
"expected dup-id via components.callbacks: {:?}",
err.errors
);
}
#[test]
fn self_uri_round_trip_and_validated() {
let v = serde_json::json!({
"openapi": "3.2.0",
"$self": "https://example.com/api.openapi",
"info": {"title": "x", "version": "1"},
"paths": {}
});
let spec: Spec = serde_json::from_value(v.clone()).unwrap();
assert_eq!(
spec.self_uri.as_deref(),
Some("https://example.com/api.openapi")
);
assert_eq!(serde_json::to_value(&spec).unwrap(), v);
let spec = Spec {
info: Info {
title: "x".into(),
version: "1".into(),
..Default::default()
},
self_uri: Some("".into()),
paths: Some(Default::default()),
..Default::default()
};
let err = spec.validate(Options::new(), None).unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.contains("$self") && e.contains("must be a valid URI")),
"errors: {:?}",
err.errors
);
}
#[test]
fn op_id_unique_across_paths_webhooks_components_pathitems() {
use crate::v3_2::components::Components;
use crate::v3_2::operation::Operation;
use crate::v3_2::response::Responses;
let make_op = |id: &str| Operation {
operation_id: Some(id.to_owned()),
responses: Some(Responses {
responses: Some(BTreeMap::from([(
"200".to_owned(),
RefOr::new_item(Response {
description: Some("ok".into()),
..Default::default()
}),
)])),
..Default::default()
}),
..Default::default()
};
let mut path_ops: BTreeMap<String, Operation> = BTreeMap::new();
path_ops.insert("get".to_owned(), make_op("dup"));
let mut paths = Paths::default();
paths.paths.insert(
"/pets".to_owned(),
PathItem {
operations: Some(path_ops),
..Default::default()
},
);
let mut pi_ops: BTreeMap<String, Operation> = BTreeMap::new();
pi_ops.insert("get".to_owned(), make_op("dup"));
let comp = Components {
path_items: Some(BTreeMap::from([(
"Reusable".to_owned(),
PathItem {
operations: Some(pi_ops),
..Default::default()
},
)])),
..Default::default()
};
let spec = Spec {
info: Info {
title: "x".into(),
version: "1".into(),
..Default::default()
},
paths: Some(paths),
components: Some(comp),
..Default::default()
};
let err = spec.validate(Options::new(), None).unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.contains("`dup` already in use")),
"expected duplicate-operationId across paths + components.pathItems: {:?}",
err.errors
);
}
#[test]
fn link_resolves_op_id_defined_in_components_path_items() {
use crate::v3_2::components::Components;
use crate::v3_2::operation::Operation;
use crate::v3_2::response::Responses;
let mut pi_ops: BTreeMap<String, Operation> = BTreeMap::new();
pi_ops.insert(
"get".to_owned(),
Operation {
operation_id: Some("pickPet".to_owned()),
responses: Some(Responses {
responses: Some(BTreeMap::from([(
"200".to_owned(),
RefOr::new_item(Response {
description: Some("ok".into()),
..Default::default()
}),
)])),
..Default::default()
}),
..Default::default()
},
);
let comp = Components {
path_items: Some(BTreeMap::from([(
"Reusable".to_owned(),
PathItem {
operations: Some(pi_ops),
..Default::default()
},
)])),
..Default::default()
};
let mut links_map = BTreeMap::new();
links_map.insert(
"next".to_owned(),
RefOr::new_item(Link {
operation_id: Some("pickPet".to_owned()),
..Default::default()
}),
);
let response = Response {
description: Some("ok".into()),
links: Some(links_map),
..Default::default()
};
let mut responses_map = BTreeMap::new();
responses_map.insert("200".to_owned(), RefOr::new_item(response));
let responses = Responses {
responses: Some(responses_map),
..Default::default()
};
let mut path_ops: BTreeMap<String, Operation> = BTreeMap::new();
path_ops.insert(
"get".to_owned(),
Operation {
responses: Some(responses),
..Default::default()
},
);
let mut paths = Paths::default();
paths.paths.insert(
"/pets".to_owned(),
PathItem {
operations: Some(path_ops),
..Default::default()
},
);
let spec = Spec {
info: Info {
title: "x".into(),
version: "1".into(),
..Default::default()
},
paths: Some(paths),
components: Some(comp),
..Default::default()
};
let res = spec.validate(Options::new(), None);
if let Err(err) = &res {
assert!(
err.errors
.iter()
.all(|e| !e.contains("missing operation with id `pickPet`")),
"Link.operationId should resolve via components.pathItems: {:?}",
err.errors
);
}
}
#[test]
fn license_identifier_url_mutex() {
let spec = Spec {
info: Info {
title: "x".into(),
version: "1".into(),
license: Some(crate::v3_2::info::License {
name: "MIT".into(),
identifier: Some("MIT".into()),
url: Some("https://example.com/license".into()),
..Default::default()
}),
..Default::default()
},
paths: Some(Default::default()),
..Default::default()
};
let err = spec.validate(Options::new(), None).unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.contains("`identifier` and `url` are mutually exclusive")),
"expected license mutex error: {:?}",
err.errors
);
}
#[test]
fn components_path_items_op_id_not_double_counted() {
use crate::v3_2::components::Components;
use crate::v3_2::operation::Operation;
use crate::v3_2::path_item::PathItem;
use crate::v3_2::response::{Response, Responses};
let op = Operation {
operation_id: Some("reuse".into()),
responses: Some(Responses {
responses: Some(BTreeMap::from([(
"200".to_owned(),
RefOr::new_item(Response {
description: Some("ok".into()),
..Default::default()
}),
)])),
..Default::default()
}),
..Default::default()
};
let mut ops = BTreeMap::new();
ops.insert("get".to_owned(), op);
let pi = PathItem {
operations: Some(ops),
..Default::default()
};
let comp = Components {
path_items: Some(BTreeMap::from([("Reusable".to_owned(), pi)])),
..Default::default()
};
let spec = Spec {
components: Some(comp),
paths: Some(Default::default()),
..Default::default()
};
let res = spec.validate(Options::new(), None);
match res {
Ok(_) => {}
Err(e) => {
assert!(
e.errors.iter().all(|s| !s.contains("already in use")),
"spurious duplicate-id error: {:?}",
e.errors
);
}
}
}
#[test]
fn webhook_keys_no_path_template_uniqueness() {
use crate::v3_2::operation::Operation;
use crate::v3_2::path_item::{PathItem, Paths};
use crate::v3_2::response::{Response, Responses};
let op = Operation {
responses: Some(Responses {
responses: Some(BTreeMap::from([(
"200".to_owned(),
RefOr::new_item(Response {
description: Some("ok".into()),
..Default::default()
}),
)])),
..Default::default()
}),
..Default::default()
};
let mut ops = BTreeMap::new();
ops.insert("post".to_owned(), op);
let pi = PathItem {
operations: Some(ops),
..Default::default()
};
let mut webhooks = Paths::default();
webhooks.paths.insert("pet-{kind}".to_owned(), pi.clone());
webhooks.paths.insert("user-{kind}".to_owned(), pi);
let spec = Spec {
webhooks: Some(webhooks),
..Default::default()
};
let res = spec.validate(Options::new(), None);
if let Err(e) = res {
assert!(
e.errors.iter().all(|s| !s.contains("collapse to the same")),
"webhook templates wrongly flagged: {:?}",
e.errors
);
}
}
#[test]
fn operation_id_uniqueness_descends_into_callbacks() {
use crate::v3_2::callback::Callback;
use crate::v3_2::operation::Operation;
use crate::v3_2::path_item::{PathItem, Paths};
use crate::v3_2::response::{Response, Responses};
let make_op = |id: &str| Operation {
operation_id: Some(id.to_owned()),
responses: Some(Responses {
responses: Some(BTreeMap::from([(
"200".to_owned(),
RefOr::new_item(Response {
description: Some("ok".into()),
..Default::default()
}),
)])),
..Default::default()
}),
..Default::default()
};
let mut cb_paths = BTreeMap::new();
cb_paths.insert(
"expr".to_owned(),
PathItem {
operations: Some(BTreeMap::from([("post".to_owned(), make_op("dup"))])),
..Default::default()
},
);
let mut callbacks = BTreeMap::new();
callbacks.insert(
"ping".to_owned(),
RefOr::new_item(Callback {
paths: cb_paths,
..Default::default()
}),
);
let outer = Operation {
operation_id: Some("dup".to_owned()),
responses: make_op("ignored").responses,
callbacks: Some(callbacks),
..Default::default()
};
let mut ops = BTreeMap::new();
ops.insert("post".to_owned(), outer);
let mut paths = Paths::default();
paths.paths.insert(
"/a".to_owned(),
PathItem {
operations: Some(ops),
..Default::default()
},
);
let spec = Spec {
paths: Some(paths),
..Default::default()
};
let err = spec.validate(Options::new(), None).unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.contains("operationId") && e.contains("`dup`")),
"expected duplicate-id across callback boundary: {:?}",
err.errors
);
}
#[test]
fn link_operation_id_resolves_in_inline_callback() {
use crate::v3_2::callback::Callback;
use crate::v3_2::link::Link;
use crate::v3_2::operation::Operation;
use crate::v3_2::path_item::{PathItem, Paths};
use crate::v3_2::response::{Response, Responses};
let make_op = |id: Option<&str>| Operation {
operation_id: id.map(str::to_owned),
responses: Some(Responses {
responses: Some(BTreeMap::from([(
"200".to_owned(),
RefOr::new_item(Response {
description: Some("ok".into()),
..Default::default()
}),
)])),
..Default::default()
}),
..Default::default()
};
let mut cb_paths = BTreeMap::new();
cb_paths.insert(
"expr".to_owned(),
PathItem {
operations: Some(BTreeMap::from([(
"post".to_owned(),
make_op(Some("inCallback")),
)])),
..Default::default()
},
);
let mut callbacks = BTreeMap::new();
callbacks.insert(
"ping".to_owned(),
RefOr::new_item(Callback {
paths: cb_paths,
..Default::default()
}),
);
let mut links = BTreeMap::new();
links.insert(
"next".to_owned(),
RefOr::new_item(Link {
operation_id: Some("inCallback".to_owned()),
..Default::default()
}),
);
let outer = Operation {
responses: Some(Responses {
responses: Some(BTreeMap::from([(
"200".to_owned(),
RefOr::new_item(Response {
description: Some("ok".into()),
links: Some(links),
..Default::default()
}),
)])),
..Default::default()
}),
callbacks: Some(callbacks),
..Default::default()
};
let mut ops = BTreeMap::new();
ops.insert("post".to_owned(), outer);
let mut paths = Paths::default();
paths.paths.insert(
"/a".to_owned(),
PathItem {
operations: Some(ops),
..Default::default()
},
);
let spec = Spec {
paths: Some(paths),
..Default::default()
};
let res = spec.validate(Options::new(), None);
if let Err(e) = res {
assert!(
e.errors.iter().all(|s| !s.contains("inCallback")),
"Link.operationId in callback must resolve: {:?}",
e.errors
);
}
}
#[test]
fn x_tag_groups_round_trip_via_generic_extensions() {
let groups = serde_json::json!([
{"name": "Public API", "tags": ["pets"]}
]);
let value = serde_json::json!({
"openapi": "3.2.0",
"info": {"title": "Pets", "version": "1"},
"paths": {},
"tags": [{"name": "pets"}],
"x-tagGroups": groups.clone(),
});
let spec: Spec = serde_json::from_value(value.clone()).unwrap();
assert_eq!(
spec.extensions.as_ref().and_then(|m| m.get("x-tagGroups")),
Some(&groups)
);
assert_eq!(serde_json::to_value(&spec).unwrap(), value);
}
#[test]
fn walk_op_into_callbacks_covers_seen_cb_block() {
use crate::v3_2::callback::Callback;
use crate::v3_2::operation::Operation;
use crate::v3_2::path_item::{PathItem, Paths};
use crate::v3_2::response::{Response, Responses};
let make_resp = || Responses {
responses: Some(BTreeMap::from([(
"200".to_owned(),
RefOr::new_item(Response {
description: Some("ok".into()),
..Default::default()
}),
)])),
..Default::default()
};
let mut cb_paths = BTreeMap::new();
cb_paths.insert(
"expr".to_owned(),
PathItem {
operations: Some(BTreeMap::from([(
"post".to_owned(),
Operation {
operation_id: Some("cbOp".to_owned()),
responses: Some(make_resp()),
..Default::default()
},
)])),
..Default::default()
},
);
let cb = Callback {
paths: cb_paths,
..Default::default()
};
let comp = crate::v3_2::components::Components {
callbacks: Some(BTreeMap::from([(
"SharedCb".to_owned(),
RefOr::new_item(cb),
)])),
..Default::default()
};
let mut op_callbacks = BTreeMap::new();
op_callbacks.insert(
"ping".to_owned(),
RefOr::new_ref("#/components/callbacks/SharedCb".to_owned()),
);
let outer_op = Operation {
operation_id: Some("outerOp".to_owned()),
responses: Some(make_resp()),
callbacks: Some(op_callbacks),
..Default::default()
};
let mut ops = BTreeMap::new();
ops.insert("post".to_owned(), outer_op);
let mut paths = Paths::default();
paths.paths.insert(
"/sub".to_owned(),
PathItem {
operations: Some(ops),
..Default::default()
},
);
let spec = Spec {
paths: Some(paths),
components: Some(comp),
..Default::default()
};
let res = spec.validate(Options::new(), None);
let _ = res;
}
#[test]
fn walk_components_callbacks_covers_seen_cb_block() {
use crate::v3_2::callback::Callback;
use crate::v3_2::operation::Operation;
use crate::v3_2::path_item::PathItem;
use crate::v3_2::response::{Response, Responses};
let make_resp = || Responses {
responses: Some(BTreeMap::from([(
"200".to_owned(),
RefOr::new_item(Response {
description: Some("ok".into()),
..Default::default()
}),
)])),
..Default::default()
};
let mut inner_ops = BTreeMap::new();
inner_ops.insert(
"get".to_owned(),
Operation {
operation_id: Some("compCbOp".to_owned()),
responses: Some(make_resp()),
..Default::default()
},
);
let mut cb_paths = BTreeMap::new();
cb_paths.insert(
"expr".to_owned(),
PathItem {
operations: Some(inner_ops),
..Default::default()
},
);
let cb = Callback {
paths: cb_paths,
..Default::default()
};
let comp = crate::v3_2::components::Components {
callbacks: Some(BTreeMap::from([(
"OnEvent".to_owned(),
RefOr::new_item(cb),
)])),
..Default::default()
};
let spec = Spec {
paths: Some(Default::default()),
components: Some(comp),
..Default::default()
};
let res = spec.validate(Options::new(), None);
let _ = res; }
#[test]
fn walk_op_seen_cb_insert_false_branch_covered() {
use crate::v3_2::callback::Callback;
use crate::v3_2::operation::Operation;
use crate::v3_2::path_item::{PathItem, Paths};
use crate::v3_2::response::{Response, Responses};
let make_resp = || Responses {
responses: Some(BTreeMap::from([(
"200".to_owned(),
RefOr::new_item(Response {
description: Some("ok".into()),
..Default::default()
}),
)])),
..Default::default()
};
let cb = Callback {
paths: BTreeMap::new(), ..Default::default()
};
let comp = crate::v3_2::components::Components {
callbacks: Some(BTreeMap::from([("Shared".to_owned(), RefOr::new_item(cb))])),
..Default::default()
};
let make_op_with_shared_cb = || {
let mut cbs = BTreeMap::new();
cbs.insert(
"ping".to_owned(),
RefOr::new_ref("#/components/callbacks/Shared".to_owned()),
);
Operation {
responses: Some(make_resp()),
callbacks: Some(cbs),
..Default::default()
}
};
let mut ops = BTreeMap::new();
ops.insert("get".to_owned(), make_op_with_shared_cb());
ops.insert("post".to_owned(), make_op_with_shared_cb());
let mut paths = Paths::default();
paths.paths.insert(
"/a".to_owned(),
PathItem {
operations: Some(ops),
..Default::default()
},
);
let spec = Spec {
paths: Some(paths),
components: Some(comp),
..Default::default()
};
let res = spec.validate(Options::new(), None);
let _ = res;
}
}