use crate::common::bindings::ServerBindings;
use crate::common::reference::RefOr;
use crate::v3_0::external_documentation::ExternalDocumentation;
use crate::v3_0::security_scheme::SecurityScheme;
use crate::v3_0::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 host: String,
pub protocol: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pathname: Option<String>,
#[serde(rename = "protocolVersion", skip_serializing_if = "Option::is_none")]
pub protocol_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: 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<RefOr<SecurityScheme>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<RefOr<Tag>>,
#[serde(rename = "externalDocs", skip_serializing_if = "Option::is_none")]
pub external_docs: Option<RefOr<ExternalDocumentation>>,
#[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("host", &self.host);
ctx.require_non_empty("protocol", &self.protocol);
if self.host.contains("://") {
ctx.error_field(
"host",
"must not include a scheme — use `protocol` for that",
);
}
if let Some(pathname) = &self.pathname
&& !pathname.is_empty()
&& !pathname.starts_with('/')
{
ctx.error_field("pathname", "must start with `/`");
}
let mut declared: Vec<&str> = Vec::new();
for name in self.variables.keys() {
declared.push(name.as_str());
}
let address = match &self.pathname {
Some(pathname) => format!("{}{pathname}", self.host),
None => self.host.clone(),
};
for placeholder in placeholders(&address) {
if !declared.contains(&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, scheme) in self.security.iter().enumerate() {
ctx.in_index("security", i, |ctx| scheme.validate_with_context(ctx));
}
for (i, tag) in self.tags.iter().enumerate() {
ctx.in_index("tags", i, |ctx| tag.validate_with_context(ctx));
}
if let Some(docs) = &self.external_docs {
ctx.in_field("externalDocs", |ctx| docs.validate_with_context(ctx));
}
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) {
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!({
"host": "rabbitmq.example.com:5672",
"protocol": "amqp",
"pathname": "/production",
"protocolVersion": "0.9.1",
"title": "Production",
"summary": "Prod broker",
"description": "The production broker",
"variables": { "port": { "default": "5672", "enum": ["5672", "5673"] } },
"security": [ { "type": "userPassword" } ],
"tags": [ { "name": "prod" } ],
"externalDocs": { "url": "https://example.com" },
"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 host_and_protocol_are_required() {
let errors = errors_for(json!({ "host": "", "protocol": "" }));
assert!(
errors
.iter()
.any(|e| e == "#.servers.prod.host: must not be empty")
);
assert!(
errors
.iter()
.any(|e| e == "#.servers.prod.protocol: must not be empty")
);
}
#[test]
fn host_must_not_repeat_the_scheme() {
let errors = errors_for(json!({ "host": "kafka://broker:9092", "protocol": "kafka" }));
assert!(
errors
.iter()
.any(|e| e.contains("must not include a scheme")),
"got: {errors:?}"
);
}
#[test]
fn pathname_must_be_rooted() {
let errors = errors_for(json!({ "host": "h", "protocol": "ws", "pathname": "ws" }));
assert!(
errors
.iter()
.any(|e| e == "#.servers.prod.pathname: must start with `/`")
);
assert!(errors_for(json!({ "host": "h", "protocol": "ws", "pathname": "" })).is_empty());
}
#[test]
fn undeclared_host_and_path_placeholders_are_reported() {
let errors = errors_for(json!({
"host": "{stage}.example.com:{port}",
"protocol": "kafka",
"pathname": "/{tenant}",
"variables": { "port": { "default": "9092" } }
}));
assert!(
errors
.iter()
.any(|e| e.contains("`{stage}` is not declared"))
);
assert!(
errors
.iter()
.any(|e| e.contains("`{tenant}` 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!({
"host": "h",
"protocol": "p",
"variables": { "v": { "enum": ["a"], "default": "b" } },
"security": [ { "type": "http" } ],
"tags": [ { "name": "" } ],
"externalDocs": { "url": "" },
"bindings": { "kafka": "not-an-object" }
}));
assert!(
errors
.iter()
.any(|e| e.starts_with("#.servers.prod.variables.v.default"))
);
assert!(
errors
.iter()
.any(|e| e.starts_with("#.servers.prod.security[0].scheme"))
);
assert!(
errors
.iter()
.any(|e| e == "#.servers.prod.tags[0].name: must not be empty")
);
assert!(
errors
.iter()
.any(|e| e == "#.servers.prod.externalDocs.url: must not be empty")
);
assert!(
errors
.iter()
.any(|e| e.starts_with("#.servers.prod.bindings.kafka"))
);
}
#[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());
}
}