use lazy_regex::regex;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use crate::common::reference::RefOr;
use crate::common::reference::ResolveReference;
use crate::v3_0::parameter::{InCookie, InHeader, InPath, InQuery, Parameter};
use crate::v3_0::path_item::PathItem;
use crate::v3_0::security_scheme::SecurityScheme;
use crate::v3_0::spec::Spec;
use crate::v3_0::tag::Tag;
use crate::validation::Options;
use crate::validation::{Context, PushError};
enum ResolvedParam<'a> {
Item(&'a Parameter),
UnresolvedInternal,
UnresolvedExternal,
}
fn resolve_parameter<'a>(spec: &'a Spec, p: &'a RefOr<Parameter>) -> ResolvedParam<'a> {
match p {
RefOr::Item(p) => ResolvedParam::Item(p),
RefOr::Ref(r) => {
if r.reference.starts_with("#/") {
match <Spec as ResolveReference<Parameter>>::resolve_reference(spec, &r.reference) {
Some(p) => ResolvedParam::Item(p),
None => ResolvedParam::UnresolvedInternal,
}
} else {
ResolvedParam::UnresolvedExternal
}
}
}
}
fn parameter_identity(p: &Parameter) -> (&str, &'static str) {
match p {
Parameter::Path(p) => (in_path_name(p), "path"),
Parameter::Query(q) => (in_query_name(q), "query"),
Parameter::Header(h) => (in_header_name(h), "header"),
Parameter::Cookie(c) => (in_cookie_name(c), "cookie"),
}
}
fn in_path_name(p: &InPath) -> &str {
&p.name
}
fn in_query_name(q: &InQuery) -> &str {
&q.name
}
fn in_header_name(h: &InHeader) -> &str {
&h.name
}
fn in_cookie_name(c: &InCookie) -> &str {
&c.name
}
fn path_template_variables(template: &str) -> BTreeSet<String> {
let re = regex!(r"\{([^}]+)\}");
re.captures_iter(template)
.map(|c| c.get(1).unwrap().as_str().to_owned())
.collect()
}
fn canonical_path(template: &str) -> String {
regex!(r"\{[^}]+\}")
.replace_all(template, "{}")
.into_owned()
}
pub fn validate_operation_parameters(
ctx: &mut Context<Spec>,
op_path: &str,
template: &str,
path_item_params: Option<&[RefOr<Parameter>]>,
op_params: Option<&[RefOr<Parameter>]>,
) {
let template_vars = path_template_variables(template);
let ignore_external = ctx.is_option(Options::IgnoreExternalReferences);
let mut has_unresolved_external = false;
fn dup_pass(
ctx: &mut Context<Spec>,
op_path: &str,
params: &[RefOr<Parameter>],
origin: &str,
ignore_external: bool,
has_unresolved_external: &mut bool,
) {
let mut seen: HashMap<(String, &'static str), usize> = HashMap::new();
for (i, raw) in params.iter().enumerate() {
let r = resolve_parameter(ctx.spec, raw);
match r {
ResolvedParam::Item(p) => {
let (name, loc) = parameter_identity(p);
let key = (name.to_owned(), loc);
*seen.entry(key.clone()).or_insert(0) += 1;
if seen[&key] == 2 {
ctx.error(
op_path.to_owned(),
format_args!(
".parameters: duplicate parameter `{name}` in `{loc}` ({origin}[{i}])"
),
);
}
}
ResolvedParam::UnresolvedExternal if ignore_external => {
*has_unresolved_external = true;
}
_ => {}
}
}
}
if let Some(p) = path_item_params {
dup_pass(
ctx,
op_path,
p,
"path-item",
ignore_external,
&mut has_unresolved_external,
);
}
if let Some(p) = op_params {
dup_pass(
ctx,
op_path,
p,
"operation",
ignore_external,
&mut has_unresolved_external,
);
}
#[derive(Clone, Copy)]
enum Kind {
Path,
Other,
}
fn kind_of(p: &Parameter) -> Kind {
match p {
Parameter::Path(_) => Kind::Path,
_ => Kind::Other,
}
}
let mut merged: HashMap<(String, &'static str), Kind> = HashMap::new();
for params in [path_item_params, op_params].into_iter().flatten() {
for raw in params {
if let ResolvedParam::Item(p) = resolve_parameter(ctx.spec, raw) {
let (name, loc) = parameter_identity(p);
merged.insert((name.to_owned(), loc), kind_of(p));
}
}
}
let mut declared_path_params: BTreeSet<String> = BTreeSet::new();
for ((name, _loc), kind) in &merged {
if matches!(kind, Kind::Path) {
declared_path_params.insert(name.clone());
}
}
let skip_template_var_missing = has_unresolved_external;
if !skip_template_var_missing {
for var in &template_vars {
if !declared_path_params.contains(var) {
ctx.error(
op_path.to_owned(),
format_args!(
".parameters: path template variable `{{{var}}}` has no matching `in: path` parameter"
),
);
}
}
}
for declared in &declared_path_params {
if !template_vars.contains(declared) {
ctx.error(
op_path.to_owned(),
format_args!(
".parameters: path parameter `{declared}` does not match any `{{name}}` in the path template"
),
);
}
}
}
pub fn validate_security_requirements(
ctx: &mut Context<Spec>,
path: &str,
requirements: &[BTreeMap<String, Vec<String>>],
) {
let schemes_map = ctx
.spec
.components
.as_ref()
.and_then(|c| c.security_schemes.as_ref());
for (i, req) in requirements.iter().enumerate() {
for (name, scopes) in req {
let scheme_ref = format!("#/components/securitySchemes/{name}");
ctx.visit(scheme_ref.clone());
let Some(map) = schemes_map else {
ctx.error(
path.to_owned(),
format_args!(
"[{i}].`{name}`: no `components.securitySchemes` on the spec to resolve against"
),
);
continue;
};
let Some(scheme_or) = map.get(name) else {
ctx.error(
path.to_owned(),
format_args!("[{i}].`{name}`: not declared in `components.securitySchemes`"),
);
continue;
};
let Ok(scheme) = scheme_or.get_item(ctx.spec) else {
continue;
};
match scheme {
SecurityScheme::OAuth2(o) => {
for scope in scopes {
let scope_ref = format!("{scheme_ref}/{scope}");
ctx.visit(scope_ref);
let in_any = [
o.flows
.implicit
.as_ref()
.map(|f| f.scopes.contains_key(scope)),
o.flows
.password
.as_ref()
.map(|f| f.scopes.contains_key(scope)),
o.flows
.client_credentials
.as_ref()
.map(|f| f.scopes.contains_key(scope)),
o.flows
.authorization_code
.as_ref()
.map(|f| f.scopes.contains_key(scope)),
]
.into_iter()
.flatten()
.any(|x| x);
if !in_any {
ctx.error(
path.to_owned(),
format_args!(
"[{i}].`{name}`: scope `{scope}` not declared in any flow's scopes"
),
);
}
}
}
SecurityScheme::OpenIdConnect(_) => {
}
SecurityScheme::ApiKey(_) | SecurityScheme::HTTP(_) => {
if !scopes.is_empty() {
ctx.error(
path.to_owned(),
format_args!(
"[{i}].`{name}`: scheme of type `{scheme}` requires an empty scope list, found {n}",
n = scopes.len()
),
);
}
}
}
}
}
}
pub fn validate_tag_uniqueness(ctx: &mut Context<Spec>, tags: &[Tag]) {
let mut counts: HashMap<&str, usize> = HashMap::new();
for t in tags {
*counts.entry(t.name.as_str()).or_insert(0) += 1;
}
for (name, n) in counts {
if n > 1 {
ctx.error(
"#.tags".to_owned(),
format_args!("duplicate tag name `{name}` (found {n} times)"),
);
}
}
}
pub fn validate_path_template_uniqueness(
ctx: &mut Context<Spec>,
paths: &BTreeMap<String, PathItem>,
) {
let mut canonicals: HashMap<String, Vec<&str>> = HashMap::new();
for key in paths.keys() {
if key.starts_with("x-") {
continue;
}
canonicals
.entry(canonical_path(key))
.or_default()
.push(key.as_str());
}
for (canonical, keys) in canonicals {
if keys.len() > 1 {
ctx.error(
"#.paths".to_owned(),
format_args!(
"templated paths {keys:?} collapse to the same shape `{canonical}`; OAS forbids equivalent templates"
),
);
}
}
}
pub fn validate_path_item(ctx: &mut Context<Spec>, template: &str, path: &str, item: &PathItem) {
let pi_params = item.parameters.as_deref();
if let Some(ops) = &item.operations {
for (method, op) in ops {
let op_path = format!("{path}.{method}");
validate_operation_parameters(
ctx,
&op_path,
template,
pi_params,
op.parameters.as_deref(),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::v3_0::components::Components;
use crate::v3_0::parameter::{InCookie, InHeader, InPath, InQuery};
use crate::v3_0::security_scheme::{
ApiKeyLocation, ApiKeySecurityScheme, HttpScheme, HttpSecurityScheme, ImplicitOAuth2Flow,
OAuth2Flows, OAuth2SecurityScheme, OpenIdConnectSecurityScheme, SecurityScheme,
};
use crate::v3_0::spec::Spec;
use crate::validation::Context;
use crate::validation::Options;
use crate::validation::ValidationErrorsExt;
fn path_param(name: &str) -> RefOr<Parameter> {
RefOr::new_item(Parameter::Path(Box::new(InPath {
name: name.into(),
description: None,
required: true,
deprecated: None,
style: None,
explode: None,
schema: None,
example: None,
examples: None,
content: None,
extensions: None,
})))
}
fn query_param(name: &str) -> RefOr<Parameter> {
RefOr::new_item(Parameter::Query(Box::new(InQuery {
name: name.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,
})))
}
fn header_param(name: &str) -> RefOr<Parameter> {
RefOr::new_item(Parameter::Header(Box::new(InHeader {
name: name.into(),
description: None,
required: None,
deprecated: None,
style: None,
explode: None,
schema: None,
example: None,
examples: None,
content: None,
extensions: None,
})))
}
fn cookie_param(name: &str) -> RefOr<Parameter> {
RefOr::new_item(Parameter::Cookie(Box::new(InCookie {
name: name.into(),
description: None,
required: None,
deprecated: None,
style: None,
explode: None,
schema: None,
example: None,
examples: None,
content: None,
extensions: None,
})))
}
fn spec_with_components(c: Components) -> Spec {
Spec {
components: Some(c),
..Default::default()
}
}
#[test]
fn duplicate_param_within_level_flagged() {
let spec: &'static Spec = Box::leak(Box::new(Spec::default()));
let mut ctx = Context::new(spec, Options::new());
let params = vec![query_param("q"), query_param("q")];
validate_operation_parameters(&mut ctx, "op", "/p", None, Some(¶ms));
assert!(
ctx.errors
.iter()
.any(|e| e.contains("duplicate parameter `q`")),
"errors: {:?}",
ctx.errors
);
}
#[test]
fn op_overrides_path_item_no_dup_error() {
let spec: &'static Spec = Box::leak(Box::new(Spec::default()));
let mut ctx = Context::new(spec, Options::new());
let pi = vec![query_param("limit")];
let op = vec![query_param("limit")];
validate_operation_parameters(&mut ctx, "op", "/p", Some(&pi), Some(&op));
assert!(
ctx.errors.is_empty(),
"override should not be a duplicate: {:?}",
ctx.errors
);
}
#[test]
fn cookie_and_header_locations_distinct() {
let spec: &'static Spec = Box::leak(Box::new(Spec::default()));
let mut ctx = Context::new(spec, Options::new());
let params = vec![header_param("session"), cookie_param("session")];
validate_operation_parameters(&mut ctx, "op", "/p", None, Some(¶ms));
assert!(
ctx.errors.is_empty(),
"different locations should not duplicate: {:?}",
ctx.errors
);
}
#[test]
fn template_var_missing_path_param() {
let spec: &'static Spec = Box::leak(Box::new(Spec::default()));
let mut ctx = Context::new(spec, Options::new());
validate_operation_parameters(&mut ctx, "op", "/users/{id}", None, None);
assert!(
ctx.errors
.iter()
.any(|e| e.contains("path template variable `{id}`")),
"errors: {:?}",
ctx.errors
);
}
#[test]
fn path_param_without_template_var() {
let spec: &'static Spec = Box::leak(Box::new(Spec::default()));
let mut ctx = Context::new(spec, Options::new());
let params = vec![path_param("id")];
validate_operation_parameters(&mut ctx, "op", "/no-vars", None, Some(¶ms));
assert!(
ctx.errors
.mentions("path parameter `id` does not match any `{name}` in the path template"),
"errors: {:?}",
ctx.errors
);
}
#[test]
fn external_ref_suppresses_template_correspondence_under_ignore_external() {
let spec: &'static Spec = Box::leak(Box::new(Spec::default()));
let params: Vec<RefOr<Parameter>> = vec![RefOr::new_ref(
"https://other.example/spec.yaml#/components/parameters/PetId",
)];
let mut ctx = Context::new(spec, Options::new());
validate_operation_parameters(&mut ctx, "op", "/users/{id}", None, Some(¶ms));
assert!(
ctx.errors
.iter()
.any(|e| e.contains("template variable `{id}`")),
"errors: {:?}",
ctx.errors
);
let mut ctx = Context::new(spec, Options::IgnoreExternalReferences.only());
validate_operation_parameters(&mut ctx, "op", "/users/{id}", None, Some(¶ms));
assert!(
!ctx.errors.mentions("template variable"),
"errors: {:?}",
ctx.errors
);
}
#[test]
fn template_correspondence_ok() {
let spec: &'static Spec = Box::leak(Box::new(Spec::default()));
let mut ctx = Context::new(spec, Options::new());
let params = vec![path_param("id")];
validate_operation_parameters(&mut ctx, "op", "/users/{id}", None, Some(¶ms));
assert!(ctx.errors.is_empty(), "errors: {:?}", ctx.errors);
}
#[test]
fn equivalent_templates_are_flagged() {
let mut paths: BTreeMap<String, PathItem> = BTreeMap::new();
paths.insert("/pets/{id}".into(), PathItem::default());
paths.insert("/pets/{name}".into(), PathItem::default());
let spec: &'static Spec = Box::leak(Box::new(Spec::default()));
let mut ctx = Context::new(spec, Options::new());
validate_path_template_uniqueness(&mut ctx, &paths);
assert!(
ctx.errors
.iter()
.any(|e| e.contains("collapse to the same shape")),
"errors: {:?}",
ctx.errors
);
}
#[test]
fn distinct_templates_are_not_flagged() {
let mut paths: BTreeMap<String, PathItem> = BTreeMap::new();
paths.insert("/pets/{id}".into(), PathItem::default());
paths.insert("/pets/{id}/owner".into(), PathItem::default());
let spec: &'static Spec = Box::leak(Box::new(Spec::default()));
let mut ctx = Context::new(spec, Options::new());
validate_path_template_uniqueness(&mut ctx, &paths);
assert!(ctx.errors.is_empty(), "errors: {:?}", ctx.errors);
}
#[test]
fn duplicate_tag_names_flagged() {
let spec: &'static Spec = Box::leak(Box::new(Spec::default()));
let mut ctx = Context::new(spec, Options::new());
let tags = vec![
Tag {
name: "pets".into(),
..Default::default()
},
Tag {
name: "pets".into(),
..Default::default()
},
];
validate_tag_uniqueness(&mut ctx, &tags);
assert!(
ctx.errors
.iter()
.any(|e| e.contains("duplicate tag name `pets`")),
"errors: {:?}",
ctx.errors
);
}
#[test]
fn security_apikey_with_scopes_invalid() {
let mut schemes = BTreeMap::new();
schemes.insert(
"ak".to_owned(),
RefOr::new_item(SecurityScheme::ApiKey(Box::new(ApiKeySecurityScheme {
name: "X".into(),
location: ApiKeyLocation::Header,
description: None,
extensions: None,
}))),
);
let comp = Components {
security_schemes: Some(schemes),
..Default::default()
};
let spec: &'static Spec = Box::leak(Box::new(spec_with_components(comp)));
let mut ctx = Context::new(spec, Options::new());
let mut req: BTreeMap<String, Vec<String>> = BTreeMap::new();
req.insert("ak".to_owned(), vec!["read".to_owned()]);
validate_security_requirements(&mut ctx, "#.security", &[req]);
assert!(
ctx.errors
.iter()
.any(|e| e.contains("requires an empty scope list")),
"errors: {:?}",
ctx.errors
);
}
#[test]
fn security_http_with_scopes_invalid() {
let mut schemes = BTreeMap::new();
schemes.insert(
"h".to_owned(),
RefOr::new_item(SecurityScheme::HTTP(Box::new(HttpSecurityScheme {
scheme: HttpScheme::Bearer,
bearer_format: None,
description: None,
extensions: None,
}))),
);
let comp = Components {
security_schemes: Some(schemes),
..Default::default()
};
let spec: &'static Spec = Box::leak(Box::new(spec_with_components(comp)));
let mut ctx = Context::new(spec, Options::new());
let mut req: BTreeMap<String, Vec<String>> = BTreeMap::new();
req.insert("h".to_owned(), vec!["read".to_owned()]);
validate_security_requirements(&mut ctx, "#.security", &[req]);
assert!(
ctx.errors
.iter()
.any(|e| e.contains("requires an empty scope list")),
"errors: {:?}",
ctx.errors
);
}
#[test]
fn security_oauth2_undefined_scope() {
let 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,
}),
..Default::default()
};
let mut schemes = BTreeMap::new();
schemes.insert(
"o".to_owned(),
RefOr::new_item(SecurityScheme::OAuth2(Box::new(OAuth2SecurityScheme {
flows,
description: None,
extensions: None,
}))),
);
let comp = Components {
security_schemes: Some(schemes),
..Default::default()
};
let spec: &'static Spec = Box::leak(Box::new(spec_with_components(comp)));
let mut ctx = Context::new(spec, Options::new());
let mut req: BTreeMap<String, Vec<String>> = BTreeMap::new();
req.insert("o".to_owned(), vec!["write".to_owned()]);
validate_security_requirements(&mut ctx, "#.security", &[req]);
assert!(
ctx.errors
.iter()
.any(|e| e.contains("scope `write` not declared")),
"errors: {:?}",
ctx.errors
);
}
#[test]
fn security_openidconnect_accepts_any_scopes() {
let mut schemes = BTreeMap::new();
schemes.insert(
"oid".to_owned(),
RefOr::new_item(SecurityScheme::OpenIdConnect(Box::new(
OpenIdConnectSecurityScheme {
open_id_connect_url: "https://x.example/.well-known".into(),
description: None,
extensions: None,
},
))),
);
let comp = Components {
security_schemes: Some(schemes),
..Default::default()
};
let spec: &'static Spec = Box::leak(Box::new(spec_with_components(comp)));
let mut ctx = Context::new(spec, Options::new());
let mut req: BTreeMap<String, Vec<String>> = BTreeMap::new();
req.insert(
"oid".to_owned(),
vec!["openid".to_owned(), "email".to_owned()],
);
validate_security_requirements(&mut ctx, "#.security", &[req]);
assert!(
ctx.errors.is_empty(),
"openIdConnect should accept any scopes: {:?}",
ctx.errors
);
}
#[test]
fn security_missing_scheme_reported() {
let comp = Components {
security_schemes: Some(BTreeMap::new()),
..Default::default()
};
let spec: &'static Spec = Box::leak(Box::new(spec_with_components(comp)));
let mut ctx = Context::new(spec, Options::new());
let mut req: BTreeMap<String, Vec<String>> = BTreeMap::new();
req.insert("missing".to_owned(), vec![]);
validate_security_requirements(&mut ctx, "#.security", &[req]);
assert!(
ctx.errors.mentions("not declared"),
"errors: {:?}",
ctx.errors
);
}
#[test]
fn internal_ref_parameter_resolves_and_validates() {
use crate::v3_0::components::Components;
let mut params = BTreeMap::new();
params.insert(
"limit".to_owned(),
RefOr::new_item(Parameter::Query(Box::new(InQuery {
name: "limit".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,
}))),
);
let comp = Components {
parameters: Some(params),
..Default::default()
};
let spec_val = Spec {
components: Some(comp),
..Default::default()
};
let spec: &'static Spec = Box::leak(Box::new(spec_val));
let mut ctx = Context::new(spec, Options::new());
let ref_param: RefOr<Parameter> = RefOr::new_ref("#/components/parameters/limit");
validate_operation_parameters(&mut ctx, "op", "/items", None, Some(&[ref_param]));
assert!(ctx.errors.is_empty(), "errors: {:?}", ctx.errors);
}
#[test]
fn internal_ref_parameter_unresolved_is_silent() {
let spec: &'static Spec = Box::leak(Box::new(Spec::default()));
let mut ctx = Context::new(spec, Options::new());
let ref_param: RefOr<Parameter> = RefOr::new_ref("#/components/parameters/ghost");
validate_operation_parameters(&mut ctx, "op", "/items", None, Some(&[ref_param]));
assert!(
!ctx.errors.mentions("duplicate"),
"no duplicate error for unresolved internal ref: {:?}",
ctx.errors
);
}
#[test]
fn path_template_uniqueness_skips_x_extension_keys() {
let mut paths: BTreeMap<String, PathItem> = BTreeMap::new();
paths.insert("x-foo".into(), PathItem::default());
paths.insert("x-bar".into(), PathItem::default());
paths.insert("/real/{id}".into(), PathItem::default());
let spec: &'static Spec = Box::leak(Box::new(Spec::default()));
let mut ctx = Context::new(spec, Options::new());
validate_path_template_uniqueness(&mut ctx, &paths);
assert!(ctx.errors.is_empty(), "errors: {:?}", ctx.errors);
}
#[test]
fn path_item_with_no_operations_validated_without_panic() {
let spec: &'static Spec = Box::leak(Box::new(Spec::default()));
let mut ctx = Context::new(spec, Options::new());
let item = PathItem::default();
validate_path_item(&mut ctx, "/empty", "#.paths[/empty]", &item);
assert!(ctx.errors.is_empty(), "errors: {:?}", ctx.errors);
}
#[test]
fn security_scheme_ref_that_does_not_resolve_is_skipped() {
use crate::v3_0::components::Components;
let mut schemes = BTreeMap::new();
schemes.insert(
"ghost".to_owned(),
RefOr::new_ref("#/components/securitySchemes/ghost"),
);
let comp = Components {
security_schemes: Some(schemes),
..Default::default()
};
let spec: &'static Spec = Box::leak(Box::new(spec_with_components(comp)));
let mut ctx = Context::new(spec, Options::new());
let mut req: BTreeMap<String, Vec<String>> = BTreeMap::new();
req.insert("ghost".to_owned(), vec![]);
validate_security_requirements(&mut ctx, "#.security", &[req]);
}
}