use crate::common::helpers::{validate_not_visited, validate_required_string};
use crate::common::reference::ResolveReference;
use crate::common::reference::{RefOr, resolve_in_map};
use crate::loader::Loader;
use crate::v3_0::callback::Callback;
use crate::v3_0::components::Components;
use crate::v3_0::example::Example;
use crate::v3_0::external_documentation::ExternalDocumentation;
use crate::v3_0::header::Header;
use crate::v3_0::info::Info;
use crate::v3_0::link::Link;
use crate::v3_0::parameter::Parameter;
use crate::v3_0::path_item::Paths;
use crate::v3_0::request_body::RequestBody;
use crate::v3_0::response::Response;
use crate::v3_0::schema::Schema;
use crate::v3_0::security_scheme::SecurityScheme;
use crate::v3_0::server::Server;
use crate::v3_0::tag::Tag;
use crate::v3_0::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,
pub info: Info,
#[serde(skip_serializing_if = "Option::is_none")]
pub servers: Option<Vec<Server>>,
pub paths: 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(skip_serializing_if = "Option::is_none")]
#[serde(rename = "x-tagGroups")]
pub x_tag_groups: Option<Vec<TagGroup>>,
#[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, Deserialize, Serialize, PartialEq, Default)]
pub struct TagGroup {
pub name: String,
pub tags: Vec<String>,
#[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.0.4".to_owned())
}
}
impl Version {
#[allow(non_snake_case)]
pub fn V3_0_0() -> Self {
Self("3.0.0".to_owned())
}
#[allow(non_snake_case)]
pub fn V3_0_1() -> Self {
Self("3.0.1".to_owned())
}
#[allow(non_snake_case)]
pub fn V3_0_2() -> Self {
Self("3.0.2".to_owned())
}
#[allow(non_snake_case)]
pub fn V3_0_3() -> Self {
Self("3.0.3".to_owned())
}
#[allow(non_snake_case)]
pub fn V3_0_4() -> Self {
Self("3.0.4".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 =
"a version matching the OAS 3.0 schema pattern `^3\\.0\\.\\d+(-.+)?$`";
fn matches_oas_3_0_version(s: &str) -> bool {
lazy_regex::regex!(r"^3\.0\.\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.0" {
return Ok(Version("3.0.4".to_owned()));
}
if matches_oas_3_0_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.0" {
return Ok(Version("3.0.4".to_owned()));
}
if matches_oas_3_0_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_0_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))
}
}
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<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 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());
if let Some(servers) = &self.servers {
for (i, server) in servers.iter().enumerate() {
server.validate_with_context(&mut ctx, format!("#.servers[{i}]"))
}
}
for (name, item) in self.paths.iter() {
if let Some(operations) = &item.operations {
for (method, operation) in operations.iter() {
if let Some(operation_id) = &operation.operation_id
&& !ctx
.visited
.insert(format!("#/paths/operations/{operation_id}"))
&& !ctx.is_option(Options::IgnoreNonUniqOperationIDs)
{
ctx.error(
"#".to_owned(),
format!(
".paths[{name}].{method}.operationId: `{operation_id}` already in use"
),
);
}
}
}
}
if let Some(sec) = &self.security {
validate_security_requirements(&mut ctx, "#.security", sec);
}
validate_path_template_uniqueness(&mut ctx, &self.paths.paths);
for (name, item) in self.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(components) = &self.components {
components.validate_with_context(&mut ctx, "#.components".to_owned());
}
if let Some(docs) = &self.external_docs {
docs.validate_with_context(&mut ctx, "#.externalDocs".to_owned())
}
if let Some(tag_groups) = &self.x_tag_groups {
for (i, tag_group) in tag_groups.iter().enumerate() {
tag_group.validate_with_context(&mut ctx, format!("#.x-tagGroups[{i}]"));
}
}
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)
}
}
impl ValidateWithContext<Spec> for TagGroup {
fn validate_with_context(&self, ctx: &mut Context<Spec>, path: String) {
validate_required_string(&self.name, ctx, format!("{path}.name"));
for (i, tag) in self.tags.iter().enumerate() {
validate_required_string(tag, ctx, format!("{path}.tags[{i}]"));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::validation::IGNORE_UNUSED;
use crate::validation::ValidationErrorsExt;
#[test]
fn validate_with_loader_resolves_external_schema_ref() {
let spec: Spec = serde_json::from_value(serde_json::json!({
"openapi": "3.0.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 test_version_deserialize() {
assert_eq!(
serde_json::from_value::<Version>(serde_json::json!("3.0.0")).unwrap(),
Version::V3_0_0(),
"correct openapi version",
);
assert_eq!(
serde_json::from_value::<Version>(serde_json::json!("3.0")).unwrap(),
Version::V3_0_4(),
"3.0 openapi version",
);
assert_eq!(
serde_json::from_value::<Version>(serde_json::json!("foo"))
.unwrap_err()
.to_string(),
"invalid value: string \"foo\", expected a version matching the OAS 3.0 schema pattern `^3\\.0\\.\\d+(-.+)?$`",
"foo as openapi version",
);
assert_eq!(
serde_json::from_value::<Version>(serde_json::json!("3.0.99")).unwrap(),
Version("3.0.99".to_owned()),
"future patch is accepted by the schema regex",
);
assert_eq!(
serde_json::from_value::<Version>(serde_json::json!("3.0.0-rc1")).unwrap(),
Version("3.0.0-rc1".to_owned()),
"prerelease suffix is accepted by the schema regex",
);
assert_eq!(
serde_json::from_value::<Spec>(serde_json::json!({
"openapi": "3.0.4",
"info": {
"title": "foo",
"version": "1",
},
"paths": {},
}))
.unwrap()
.openapi,
Version::V3_0_4(),
"3.0.4 spec.openapi",
);
assert_eq!(
serde_json::from_value::<Spec>(serde_json::json!({
"openapi": "3.0",
"info": {
"title": "foo",
"version": "1",
},
"paths": {},
}))
.unwrap()
.openapi,
Version::V3_0_4(),
"3.0 spec.openapi",
);
assert_eq!(
serde_json::from_value::<Spec>(serde_json::json!({
"openapi": "",
"info": {
"title": "foo",
"version": "1",
},
"paths": {},
}))
.unwrap_err()
.to_string(),
"invalid value: string \"\", expected a version matching the OAS 3.0 schema pattern `^3\\.0\\.\\d+(-.+)?$`",
"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_0_0()).unwrap(),
r#""3.0.0""#,
);
assert_eq!(
serde_json::to_string(&Version::default()).unwrap(),
r#""3.0.4""#,
);
}
#[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_0_0().validate_with_context(&mut ctx, "#.openapi".to_owned());
Version::V3_0_4().validate_with_context(&mut ctx, "#.openapi".to_owned());
"3.0.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\\.0\\.\\d+(-.+)?$"),
"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\\.0\\.\\d+(-.+)?$")),
"Spec::validate surfaces the openapi error: {:?}",
err.errors
);
}
#[test]
fn test_version_try_from_string_normalizes_short_alias() {
let v: Version = "3.0".to_owned().try_into().unwrap();
assert_eq!(v, Version::V3_0_4());
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.0.99").unwrap(),
Version("3.0.99".to_owned())
);
assert_eq!(
Version::from_str("3.0.0-rc1").unwrap(),
Version("3.0.0-rc1".to_owned())
);
assert_eq!(Version::from_str("3.0").unwrap(), Version::V3_0_4());
assert_eq!(
<Version as TryFrom<&str>>::try_from("3.0.7").unwrap(),
Version("3.0.7".to_owned())
);
assert_eq!(
<Version as TryFrom<String>>::try_from("3.0.7".to_owned()).unwrap(),
Version("3.0.7".to_owned())
);
let err = Version::from_str("foo").unwrap_err();
assert_eq!(err, InvalidVersion("foo".to_owned()));
assert!(
err.to_string().contains("3\\.0\\.\\d+(-.+)?$"),
"error message echoes the schema regex: {err}"
);
}
#[test]
fn test_define_schema() {
use crate::v3_0::schema::{SingleSchema, StringSchema};
let mut spec = Spec::default();
let pet_ref = spec
.define_schema("Pet", SingleSchema::from(StringSchema::default()))
.expect("valid name");
match pet_ref {
RefOr::Ref(r) => assert_eq!(r.reference, "#/components/schemas/Pet"),
_ => panic!("expected Ref"),
}
assert!(spec.components.is_some());
assert!(
spec.components
.as_ref()
.unwrap()
.schemas
.as_ref()
.unwrap()
.contains_key("Pet")
);
}
#[test]
fn test_define_replaces_existing() {
use crate::v3_0::schema::{SingleSchema, StringSchema};
let mut spec = Spec::default();
spec.define_schema("Pet", SingleSchema::from(StringSchema::default()))
.unwrap();
spec.define_schema(
"Pet",
SingleSchema::from(StringSchema {
title: Some("Pet".into()),
..Default::default()
}),
)
.unwrap();
let schemas = spec.components.unwrap().schemas.unwrap();
assert_eq!(schemas.len(), 1);
let pet = schemas.get("Pet").unwrap();
match pet {
RefOr::Item(Schema::Single(s)) => match s.as_ref() {
SingleSchema::String(s) => assert_eq!(s.title.as_deref(), Some("Pet")),
_ => panic!("expected String schema"),
},
_ => panic!("expected inline schema"),
}
}
#[test]
fn test_define_rejects_invalid_name() {
use crate::v3_0::schema::{SingleSchema, StringSchema};
let mut spec = Spec::default();
let err = spec
.define_schema("My Pet", SingleSchema::from(StringSchema::default()))
.unwrap_err();
assert_eq!(err.name, "My Pet");
assert!(
spec.components.is_none(),
"name validation must run before mutation"
);
}
#[test]
fn all_define_helpers_insert_and_return_ref() {
use crate::v3_0::callback::Callback;
use crate::v3_0::example::Example;
use crate::v3_0::header::Header;
use crate::v3_0::link::Link;
use crate::v3_0::parameter::{InQuery, Parameter};
use crate::v3_0::request_body::RequestBody;
use crate::v3_0::response::Response;
use crate::v3_0::security_scheme::{HttpScheme, HttpSecurityScheme, SecurityScheme};
let mut spec = Spec::default();
let r = spec.define_response("Ok", Response::default()).unwrap();
assert!(matches!(r, RefOr::Ref(ref rr) if rr.reference == "#/components/responses/Ok"));
let r = spec
.define_parameter(
"Q",
Parameter::Query(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 {
scheme: HttpScheme::Basic,
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 comp = spec.components.as_ref().unwrap();
assert!(comp.responses.as_ref().unwrap().contains_key("Ok"));
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"));
}
#[test]
fn define_helpers_reject_invalid_names() {
use crate::v3_0::callback::Callback;
use crate::v3_0::example::Example;
use crate::v3_0::header::Header;
use crate::v3_0::link::Link;
use crate::v3_0::request_body::RequestBody;
use crate::v3_0::response::Response;
use crate::v3_0::security_scheme::{HttpScheme, 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: HttpScheme::Basic,
bearer_format: None,
description: None,
extensions: None,
})),
)
.is_err()
);
assert!(spec.define_link(bad, Link::default()).is_err());
assert!(spec.define_callback(bad, Callback::default()).is_err());
assert!(spec.components.is_none());
}
#[test]
fn resolve_reference_paths_for_each_component_kind() {
use crate::v3_0::callback::Callback;
use crate::v3_0::example::Example;
use crate::v3_0::header::Header;
use crate::v3_0::link::Link;
use crate::v3_0::parameter::{InQuery, Parameter};
use crate::v3_0::request_body::RequestBody;
use crate::v3_0::response::Response;
use crate::v3_0::schema::{SingleSchema, StringSchema};
use crate::v3_0::security_scheme::{HttpScheme, HttpSecurityScheme, SecurityScheme};
let mut spec = Spec::default();
spec.define_schema("S", SingleSchema::from(StringSchema::default()))
.unwrap();
spec.define_response("R", Response::default()).unwrap();
spec.define_parameter(
"P",
Parameter::Query(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: HttpScheme::Basic,
bearer_format: None,
description: None,
extensions: None,
})),
)
.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()
);
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 component_reference_alias_chain_resolves() {
use crate::v3_0::components::Components;
use crate::v3_0::schema::{SingleSchema, StringSchema};
let mut schemas = BTreeMap::new();
schemas.insert(
"AliasA".to_owned(),
RefOr::new_ref("#/components/schemas/AliasB"),
);
schemas.insert(
"AliasB".to_owned(),
RefOr::new_ref("#/components/schemas/Target"),
);
schemas.insert(
"Target".to_owned(),
RefOr::new_item(SingleSchema::from(StringSchema::default()).into()),
);
let spec = Spec {
components: Some(Components {
schemas: Some(schemas),
..Default::default()
}),
..Default::default()
};
assert!(
<Spec as ResolveReference<Schema>>::resolve_reference(
&spec,
"#/components/schemas/AliasA"
)
.is_some()
);
}
#[test]
fn component_reference_alias_cycle_does_not_recurse_forever() {
use crate::v3_0::components::Components;
use crate::validation::Context;
let mut schemas = BTreeMap::new();
schemas.insert(
"AliasA".to_owned(),
RefOr::new_ref("#/components/schemas/AliasB"),
);
schemas.insert(
"AliasB".to_owned(),
RefOr::new_ref("#/components/schemas/AliasA"),
);
let spec = Spec {
components: Some(Components {
schemas: Some(schemas),
..Default::default()
}),
..Default::default()
};
assert!(
<Spec as ResolveReference<Schema>>::resolve_reference(
&spec,
"#/components/schemas/AliasA"
)
.is_none()
);
let mut ctx = Context::new(&spec, Options::new());
RefOr::<Schema>::new_ref("#/components/schemas/AliasA")
.validate_with_context(&mut ctx, "#.schema".to_owned());
assert!(
ctx.errors.mentions("not found"),
"cycle should be reported as an unresolved ref, not recurse: {:?}",
ctx.errors
);
}
#[test]
fn version_display_all_variants() {
assert_eq!(Version::V3_0_0().to_string(), "3.0.0");
assert_eq!(Version::V3_0_1().to_string(), "3.0.1");
assert_eq!(Version::V3_0_2().to_string(), "3.0.2");
assert_eq!(Version::V3_0_3().to_string(), "3.0.3");
assert_eq!(Version::V3_0_4().to_string(), "3.0.4");
}
#[test]
fn full_spec_validate_drives_path_template_uniqueness() {
use crate::v3_0::path_item::Paths;
use crate::v3_0::response::{Response, Responses};
let mut paths = Paths::default();
let make_op = || crate::v3_0::operation::Operation {
responses: Responses {
default: Some(RefOr::new_item(Response {
description: "ok".into(),
..Default::default()
})),
..Default::default()
},
..Default::default()
};
let mut ops_a: BTreeMap<String, crate::v3_0::operation::Operation> = BTreeMap::new();
ops_a.insert("get".to_owned(), make_op());
let mut ops_b: BTreeMap<String, crate::v3_0::operation::Operation> = BTreeMap::new();
ops_b.insert("get".to_owned(), make_op());
paths.paths.insert(
"/pets/{id}".into(),
crate::v3_0::path_item::PathItem {
operations: Some(ops_a),
..Default::default()
},
);
paths.paths.insert(
"/pets/{name}".into(),
crate::v3_0::path_item::PathItem {
operations: Some(ops_b),
..Default::default()
},
);
let spec = Spec {
info: Info {
title: "x".into(),
version: "1".into(),
..Default::default()
},
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 x_tag_groups_round_trip_and_validate() {
let value = serde_json::json!({
"openapi": "3.0.4",
"info": {
"title": "Pets",
"version": "1.0.0"
},
"paths": {},
"x-tagGroups": [
{
"name": "Animals",
"tags": ["pets"]
}
]
});
let spec: Spec = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(&spec).unwrap(), value);
let mut ctx = Context::new(&spec, Options::new());
spec.x_tag_groups
.as_ref()
.unwrap()
.first()
.unwrap()
.validate_with_context(&mut ctx, "#.x-tagGroups[0]".to_owned());
assert!(ctx.errors.is_empty(), "no errors: {:?}", ctx.errors);
let mut ctx = Context::new(&spec, Options::new());
TagGroup::default().validate_with_context(&mut ctx, "#.x-tagGroups[0]".to_owned());
assert!(
ctx.errors
.has_exact("#.x-tagGroups[0].name: must not be empty"),
"expected name error: {:?}",
ctx.errors
);
}
#[test]
fn ignore_non_uniq_operation_ids_suppresses_duplicate_error() {
let spec: Spec = serde_json::from_value(serde_json::json!({
"openapi": "3.0.4",
"info": {"title": "x", "version": "1"},
"paths": {
"/a": {
"get": {
"operationId": "dup",
"responses": {"200": {"description": "ok"}},
},
},
"/b": {
"get": {
"operationId": "dup",
"responses": {"200": {"description": "ok"}},
},
},
},
}))
.expect("spec must parse");
let err = spec
.validate(IGNORE_UNUSED, None)
.expect_err("duplicate operationId must error by default");
assert!(
err.errors
.iter()
.any(|e| e.contains("`dup` already in use")),
"expected duplicate diagnostic, got: {:?}",
err.errors,
);
let opts = IGNORE_UNUSED | Options::IgnoreNonUniqOperationIDs;
let result = spec.validate(opts, None);
let leftover: Vec<String> = result
.err()
.map(|e| e.errors.iter().map(|e| e.to_string()).collect())
.unwrap_or_default();
assert!(
leftover.iter().all(|s| !s.contains("already in use")),
"IgnoreNonUniqOperationIDs must suppress the duplicate, got: {leftover:?}",
);
}
}