use crate::common::helpers::validate_string_matches;
use crate::common::reference::RefOr;
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::Parameter;
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::spec::Spec;
use crate::validation::Options;
use crate::validation::{Context, PushError, ValidateWithContext};
use lazy_regex::regex;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
pub struct Components {
#[serde(skip_serializing_if = "Option::is_none")]
pub schemas: Option<BTreeMap<String, RefOr<Schema>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub responses: Option<BTreeMap<String, RefOr<Response>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parameters: Option<BTreeMap<String, RefOr<Parameter>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub examples: Option<BTreeMap<String, RefOr<Example>>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "requestBodies")]
pub request_bodies: Option<BTreeMap<String, RefOr<RequestBody>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<BTreeMap<String, RefOr<Header>>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "securitySchemes")]
pub security_schemes: Option<BTreeMap<String, RefOr<SecurityScheme>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub links: Option<BTreeMap<String, RefOr<Link>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub callbacks: Option<BTreeMap<String, RefOr<Callback>>>,
#[serde(flatten)]
#[serde(with = "crate::common::extensions")]
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<BTreeMap<String, serde_json::Value>>,
}
impl ValidateWithContext<Spec> for Components {
fn validate_with_context(&self, ctx: &mut Context<Spec>, path: String) {
let re = regex!(r"^[a-zA-Z0-9.\-_]+$");
if let Some(objs) = &self.schemas {
for (name, obj) in objs {
let reference = format!("#/components/schemas/{name}");
if !ctx.is_visited(&reference) && !ctx.is_option(Options::IgnoreUnusedSchemas) {
ctx.error(reference, "unused");
}
validate_string_matches(name, re, ctx, format!("{path}.schemas[<name>]"));
obj.validate_with_context(ctx, format!("{path}.schemas[{name}]"));
}
}
if let Some(objs) = &self.responses {
for (name, obj) in objs {
let reference = format!("#/components/responses/{name}");
if !ctx.is_visited(&reference) && !ctx.is_option(Options::IgnoreUnusedResponses) {
ctx.error(reference, "unused");
}
validate_string_matches(name, re, ctx, format!("{path}.responses[<name>]"));
obj.validate_with_context(ctx, format!("{path}.responses[{name}]"));
}
}
if let Some(objs) = &self.parameters {
for (name, obj) in objs {
let reference = format!("#/components/parameters/{name}");
if !ctx.is_visited(&reference) && !ctx.is_option(Options::IgnoreUnusedParameters) {
ctx.error(reference, "unused");
}
validate_string_matches(name, re, ctx, format!("{path}.parameters[<name>]"));
obj.validate_with_context(ctx, format!("{path}.parameters[{name}]"));
}
}
if let Some(objs) = &self.examples {
for (name, obj) in objs {
let reference = format!("#/components/examples/{name}");
if !ctx.is_visited(&reference) && !ctx.is_option(Options::IgnoreUnusedExamples) {
ctx.error(reference, "unused");
}
validate_string_matches(name, re, ctx, format!("{path}.examples[<name>]"));
obj.validate_with_context(ctx, format!("{path}.examples[{name}]"));
}
}
if let Some(objs) = &self.request_bodies {
for (name, obj) in objs {
let reference = format!("#/components/requestBodies/{name}");
if !ctx.is_visited(&reference) && !ctx.is_option(Options::IgnoreUnusedRequestBodies)
{
ctx.error(reference, "unused");
}
validate_string_matches(name, re, ctx, format!("{path}.requestBodies[<name>]"));
obj.validate_with_context(ctx, format!("{path}.requestBodies[{name}]"));
}
}
if let Some(objs) = &self.headers {
for (name, obj) in objs {
let reference = format!("#/components/headers/{name}");
if !ctx.is_visited(&reference) && !ctx.is_option(Options::IgnoreUnusedHeaders) {
ctx.error(reference, "unused");
}
validate_string_matches(name, re, ctx, format!("{path}.headers[<name>]"));
obj.validate_with_context(ctx, format!("{path}.headers[{name}]"));
}
}
if let Some(objs) = &self.security_schemes {
for (name, obj) in objs {
let reference = format!("#/components/securitySchemes/{name}");
if !ctx.is_visited(&reference)
&& !ctx.is_option(Options::IgnoreUnusedSecuritySchemes)
{
ctx.error(reference.clone(), "unused");
}
validate_string_matches(name, re, ctx, format!("{path}.securitySchemes[<name>]"));
obj.validate_with_context(ctx, format!("{path}.securitySchemes[{name}]"));
if let Ok(SecurityScheme::OAuth2(oauth2)) = obj.get_item(ctx.spec) {
let mut check_unused = |scopes: &BTreeMap<String, String>| {
for scope in scopes.keys() {
let reference = format!("{reference}/{scope}");
if !ctx.is_visited(&reference)
&& !ctx.is_option(Options::IgnoreUnusedSecuritySchemes)
{
ctx.error(reference, "unused");
}
}
};
if let Some(flow) = &oauth2.flows.implicit {
check_unused(&flow.scopes);
}
if let Some(flow) = &oauth2.flows.password {
check_unused(&flow.scopes);
}
if let Some(flow) = &oauth2.flows.client_credentials {
check_unused(&flow.scopes);
}
if let Some(flow) = &oauth2.flows.authorization_code {
check_unused(&flow.scopes);
}
}
}
}
if let Some(objs) = &self.links {
for (name, obj) in objs {
let reference = format!("#/components/links/{name}");
if !ctx.is_visited(&reference) && !ctx.is_option(Options::IgnoreUnusedLinks) {
ctx.error(reference, "unused");
}
validate_string_matches(name, re, ctx, format!("{path}.links[<name>]"));
obj.validate_with_context(ctx, format!("{path}.links[{name}]"));
}
}
if let Some(objs) = &self.callbacks {
for (name, obj) in objs {
let reference = format!("#/components/callbacks/{name}");
if !ctx.is_visited(&reference) && !ctx.is_option(Options::IgnoreUnusedCallbacks) {
ctx.error(reference, "unused");
}
validate_string_matches(name, re, ctx, format!("{path}.callbacks[<name>]"));
obj.validate_with_context(ctx, format!("{path}.callbacks[{name}]"));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
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::{Schema, SingleSchema, StringSchema};
use crate::v3_0::security_scheme::{
AuthorizationCodeOAuth2Flow, ClientCredentialsOAuth2Flow, ImplicitOAuth2Flow, OAuth2Flows,
OAuth2SecurityScheme, PasswordOAuth2Flow, SecurityScheme,
};
use crate::validation::Context;
use crate::validation::ValidationErrorsExt;
use serde_json::json;
fn map_with<T>(name: &str, t: T) -> BTreeMap<String, RefOr<T>> {
BTreeMap::from([(name.to_owned(), RefOr::new_item(t))])
}
#[test]
fn round_trip_all_kinds() {
let v = json!({
"schemas": {"S": {"type": "string"}},
"responses": {"R": {"description": "ok"}},
"parameters": {"P": {"name": "q", "in": "query", "schema": {"type": "string"}}},
"examples": {"E": {"value": 1}},
"requestBodies": {"RB": {"content": {"application/json": {"schema": {"type": "object"}}}}},
"headers": {"H": {"description": "h"}},
"securitySchemes": {"SS": {"type": "http", "scheme": "Basic"}},
"links": {"L": {"operationId": "op"}},
"callbacks": {"CB": {"{$request.body#/cb}": {"post": {"responses": {"200": {"description": "ok"}}}}}},
"x-tra": "yes"
});
let comp: Components = serde_json::from_value(v.clone()).unwrap();
let re: Components = serde_json::from_value(serde_json::to_value(&comp).unwrap()).unwrap();
assert_eq!(re, comp);
}
#[test]
fn unused_components_each_kind_reports() {
let comp = Components {
schemas: Some(map_with(
"S",
Schema::Single(Box::new(SingleSchema::String(StringSchema::default()))),
)),
responses: Some(map_with("R", Response::default())),
parameters: Some(map_with(
"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,
})),
)),
examples: Some(map_with("E", Example::default())),
request_bodies: Some(map_with("RB", RequestBody::default())),
headers: Some(map_with("H", Header::default())),
security_schemes: Some(map_with(
"SS",
SecurityScheme::OAuth2(Box::new(OAuth2SecurityScheme {
flows: OAuth2Flows {
implicit: Some(ImplicitOAuth2Flow {
authorization_url: "https://x.example/auth".into(),
refresh_url: None,
scopes: BTreeMap::from([("read".to_owned(), "Read".to_owned())]),
extensions: None,
}),
password: Some(PasswordOAuth2Flow {
token_url: "https://x.example/t".into(),
refresh_url: None,
scopes: BTreeMap::from([("write".to_owned(), "Write".to_owned())]),
extensions: None,
}),
client_credentials: Some(ClientCredentialsOAuth2Flow {
token_url: "https://x.example/t".into(),
refresh_url: None,
scopes: BTreeMap::from([("admin".to_owned(), "Admin".to_owned())]),
extensions: None,
}),
authorization_code: Some(AuthorizationCodeOAuth2Flow {
authorization_url: "https://x.example/auth".into(),
token_url: "https://x.example/t".into(),
refresh_url: None,
scopes: BTreeMap::from([("delete".to_owned(), "Delete".to_owned())]),
extensions: None,
}),
extensions: None,
},
description: None,
extensions: None,
})),
)),
links: Some(map_with("L", Link::default())),
callbacks: Some(map_with("CB", Callback::default())),
extensions: None,
};
let spec = Spec {
components: Some(comp.clone()),
..Default::default()
};
let mut ctx = Context::new(&spec, Options::new());
comp.validate_with_context(&mut ctx, "#.components".into());
for path in [
"#/components/schemas/S",
"#/components/responses/R",
"#/components/parameters/P",
"#/components/examples/E",
"#/components/requestBodies/RB",
"#/components/headers/H",
"#/components/securitySchemes/SS",
"#/components/links/L",
"#/components/callbacks/CB",
] {
assert!(
ctx.errors
.iter()
.any(|e| e.contains(path) && e.contains("unused")),
"expected `{path}: unused`: {:?}",
ctx.errors
);
}
for scope in ["read", "write", "admin", "delete"] {
let p = format!("#/components/securitySchemes/SS/{scope}");
assert!(
ctx.errors
.iter()
.any(|e| e.contains(&p) && e.contains("unused")),
"expected unused scope `{scope}`: {:?}",
ctx.errors
);
}
}
#[test]
fn ignored_unused_options_silence_each_kind() {
let comp = Components {
schemas: Some(map_with(
"S",
Schema::Single(Box::new(SingleSchema::String(StringSchema::default()))),
)),
responses: Some(map_with("R", Response::default())),
parameters: Some(map_with(
"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,
})),
)),
examples: Some(map_with("E", Example::default())),
request_bodies: Some(map_with("RB", RequestBody::default())),
headers: Some(map_with("H", Header::default())),
security_schemes: None,
links: Some(map_with("L", Link::default())),
callbacks: Some(map_with("CB", Callback::default())),
extensions: None,
};
let spec = Spec {
components: Some(comp.clone()),
..Default::default()
};
let opts = Options::IgnoreUnusedSchemas
| Options::IgnoreUnusedResponses
| Options::IgnoreUnusedParameters
| Options::IgnoreUnusedExamples
| Options::IgnoreUnusedRequestBodies
| Options::IgnoreUnusedHeaders
| Options::IgnoreUnusedLinks
| Options::IgnoreUnusedCallbacks;
let mut ctx = Context::new(&spec, opts);
comp.validate_with_context(&mut ctx, "#.components".into());
assert!(
!ctx.errors.mentions("unused"),
"no unused errors when ignored: {:?}",
ctx.errors
);
}
#[test]
fn invalid_component_name_reported() {
let mut schemas: BTreeMap<String, RefOr<Schema>> = BTreeMap::new();
schemas.insert(
"bad name".to_owned(),
RefOr::new_item(Schema::Single(Box::new(SingleSchema::String(
StringSchema::default(),
)))),
);
let comp = Components {
schemas: Some(schemas),
..Default::default()
};
let spec = Spec {
components: Some(comp.clone()),
..Default::default()
};
let mut ctx = Context::new(&spec, Options::IgnoreUnusedSchemas.only());
comp.validate_with_context(&mut ctx, "#.components".into());
assert!(
ctx.errors.mentions("must match pattern"),
"expected pattern error: {:?}",
ctx.errors
);
}
#[test]
fn components_default_validates_ok() {
let comp = Components::default();
let spec = Spec::default();
let mut ctx = Context::new(&spec, Options::new());
comp.validate_with_context(&mut ctx, "#.components".into());
assert!(ctx.errors.is_empty(), "unexpected errors: {:?}", ctx.errors);
}
}