use crate::common::bindings::ServerBindings;
use crate::common::reference::RefOr;
use crate::v2_6::security_scheme::SecurityRequirement;
use crate::v2_6::tag::Tag;
use crate::validation::{Context, ValidateWithContext};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
pub struct Server {
pub url: String,
pub protocol: String,
#[serde(rename = "protocolVersion", skip_serializing_if = "Option::is_none")]
pub protocol_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub variables: BTreeMap<String, RefOr<ServerVariable>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub security: Vec<SecurityRequirement>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<Tag>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bindings: Option<RefOr<ServerBindings>>,
#[serde(flatten)]
#[serde(with = "crate::common::extensions")]
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<BTreeMap<String, serde_json::Value>>,
}
impl ValidateWithContext for Server {
fn validate_with_context(&self, ctx: &mut Context) {
ctx.require_non_empty("url", &self.url);
ctx.require_non_empty("protocol", &self.protocol);
for placeholder in placeholders(&self.url) {
if !self.variables.contains_key(placeholder) {
ctx.error_field(
"variables",
format!("`{{{placeholder}}}` is not declared in `variables`"),
);
}
}
for (name, variable) in &self.variables {
ctx.in_key("variables", name, |ctx| variable.validate_with_context(ctx));
}
for (i, requirement) in self.security.iter().enumerate() {
ctx.in_index("security", i, |ctx| requirement.validate_with_context(ctx));
}
crate::v2_6::message::validate_tags(ctx, &self.tags);
if let Some(bindings) = &self.bindings {
ctx.in_field("bindings", |ctx| bindings.validate_with_context(ctx));
}
}
}
pub(crate) fn placeholders(input: &str) -> Vec<&str> {
let mut found = Vec::new();
let mut rest = input;
while let Some(start) = rest.find('{') {
let after = &rest[start + 1..];
let Some(end) = after.find('}') else { break };
let name = &after[..end];
if !name.is_empty() {
found.push(name);
}
rest = &after[end + 1..];
}
found
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
pub struct ServerVariable {
#[serde(rename = "enum", default, skip_serializing_if = "Vec::is_empty")]
pub enum_values: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub examples: Vec<String>,
#[serde(flatten)]
#[serde(with = "crate::common::extensions")]
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<BTreeMap<String, serde_json::Value>>,
}
impl ValidateWithContext for ServerVariable {
fn validate_with_context(&self, ctx: &mut Context) {
for (i, value) in self.enum_values.iter().enumerate() {
if self.enum_values[..i].contains(value) {
ctx.in_index("enum", i, |ctx| {
ctx.error(format!("duplicate value `{value}`"))
});
}
}
if let Some(default) = &self.default
&& !self.enum_values.is_empty()
&& !self.enum_values.contains(default)
{
ctx.error_field(
"default",
format!("`{default}` is not one of the values listed in `enum`"),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use enumset::EnumSet;
use serde_json::json;
fn errors_for(value: serde_json::Value) -> Vec<String> {
let server: Server = serde_json::from_value(value).unwrap();
let mut ctx = Context::with_path(EnumSet::empty(), "#.servers.prod");
server.validate_with_context(&mut ctx);
ctx.errors.iter().map(ToString::to_string).collect()
}
#[test]
fn round_trips_a_full_server() {
let value = json!({
"url": "amqp://{stage}.example.com:{port}",
"protocol": "amqp",
"protocolVersion": "0.9.1",
"description": "The production broker",
"variables": {
"stage": { "enum": ["prod", "staging"], "default": "prod" },
"port": { "default": "5672", "examples": ["5672"] }
},
"security": [ { "user_pass": [] } ],
"tags": [ { "name": "prod" } ],
"bindings": { "amqp": { "bindingVersion": "0.3.0" } },
"x-team": "infra"
});
let server: Server = serde_json::from_value(value.clone()).unwrap();
assert_eq!(server.protocol, "amqp");
assert_eq!(serde_json::to_value(&server).unwrap(), value);
assert!(errors_for(value).is_empty());
}
#[test]
fn url_and_protocol_are_required() {
let errors = errors_for(json!({ "url": "", "protocol": "" }));
assert!(
errors
.iter()
.any(|e| e == "#.servers.prod.url: must not be empty")
);
assert!(
errors
.iter()
.any(|e| e == "#.servers.prod.protocol: must not be empty")
);
}
#[test]
fn undeclared_url_placeholders_are_reported() {
let errors = errors_for(json!({
"url": "amqp://{stage}.example.com:{port}",
"protocol": "amqp",
"variables": { "port": { "default": "5672" } }
}));
assert!(
errors
.iter()
.any(|e| e.contains("`{stage}` is not declared"))
);
assert!(!errors.iter().any(|e| e.contains("`{port}`")));
}
#[test]
fn placeholders_handles_edge_cases() {
assert_eq!(placeholders("{a}.b.{c}"), vec!["a", "c"]);
assert_eq!(placeholders("no-placeholders"), Vec::<&str>::new());
assert_eq!(placeholders("{}"), Vec::<&str>::new());
assert_eq!(placeholders("{a}.{unclosed"), vec!["a"]);
}
#[test]
fn nested_objects_report_under_their_own_path() {
let errors = errors_for(json!({
"url": "amqp://e",
"protocol": "amqp",
"variables": { "v": { "enum": ["a"], "default": "b" } },
"security": [ { "auth": ["read", "read"] } ],
"tags": [ { "name": "" } ],
"bindings": { "amqp": 1 }
}));
assert!(
errors
.iter()
.any(|e| e.starts_with("#.servers.prod.variables.v.default"))
);
assert!(
errors
.iter()
.any(|e| e == "#.servers.prod.security[0].auth: duplicate scope `read`")
);
assert!(
errors
.iter()
.any(|e| e == "#.servers.prod.tags[0].name: must not be empty")
);
assert!(
errors
.iter()
.any(|e| e.starts_with("#.servers.prod.bindings.amqp"))
);
}
#[test]
fn tags_and_variable_enums_must_be_unique() {
let errors = errors_for(json!({
"url": "amqp://e",
"protocol": "amqp",
"tags": [ { "name": "a" }, { "name": "a" } ],
"variables": { "v": { "enum": ["a", "b", "a"] } }
}));
assert!(
errors
.iter()
.any(|e| e == "#.servers.prod.tags[1]: duplicate tag")
);
assert!(
errors
.iter()
.any(|e| e == "#.servers.prod.variables.v.enum[2]: duplicate value `a`"),
"got: {errors:?}"
);
}
#[test]
fn a_server_has_no_external_docs_field_in_2_6() {
let server: Server = serde_json::from_value(json!({
"url": "amqp://e",
"protocol": "amqp",
"externalDocs": { "url": "https://e" }
}))
.unwrap();
assert_eq!(
serde_json::to_value(&server).unwrap(),
json!({ "url": "amqp://e", "protocol": "amqp" })
);
}
#[test]
fn server_variable_default_must_be_enumerated() {
let variable: ServerVariable =
serde_json::from_value(json!({ "enum": ["a"], "default": "z" })).unwrap();
let mut ctx = Context::with_path(EnumSet::empty(), "#.variables.v");
variable.validate_with_context(&mut ctx);
assert!(ctx.errors[0].contains("is not one of the values listed in `enum`"));
let free: ServerVariable = serde_json::from_value(json!({ "default": "z" })).unwrap();
let mut ctx = Context::with_path(EnumSet::empty(), "#.variables.v");
free.validate_with_context(&mut ctx);
assert!(ctx.errors.is_empty());
}
}