use crate::common::bindings::ChannelBindings;
use crate::common::reference::RefOr;
use crate::v2_6::operation::Operation;
use crate::v2_6::parameter::Parameter;
use crate::v2_6::server::placeholders;
use crate::validation::{Context, ValidateWithContext, ValidationOptions};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
pub struct ChannelItem {
#[serde(rename = "$ref", skip_serializing_if = "Option::is_none")]
pub reference: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub servers: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub publish: Option<Operation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subscribe: Option<Operation>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub parameters: BTreeMap<String, RefOr<Parameter>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deprecated: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bindings: Option<RefOr<ChannelBindings>>,
#[serde(flatten)]
#[serde(with = "crate::common::extensions")]
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<BTreeMap<String, serde_json::Value>>,
}
impl ChannelItem {
pub(crate) fn validate_path_parameters(ctx: &mut Context, path: &str, chain: &[&ChannelItem]) {
let declared: BTreeSet<&str> = chain
.iter()
.flat_map(|item| item.parameters.keys().map(String::as_str))
.collect();
let used = placeholders(path);
for name in &used {
if !declared.contains(*name) {
ctx.error_field(
"parameters",
format!("`{{{name}}}` in the channel path is not declared in `parameters`"),
);
}
}
if !ctx.is_option(ValidationOptions::IgnoreUnusedChannelParameter) {
for name in declared {
if !used.contains(&name) {
ctx.error_field(
"parameters",
format!("`{name}` is declared but never used in the channel path"),
);
}
}
}
}
}
impl ChannelItem {
#[must_use]
pub fn is_reference(&self) -> bool {
self.reference.is_some()
}
}
impl ValidateWithContext for ChannelItem {
fn validate_with_context(&self, ctx: &mut Context) {
if let Some(reference) = &self.reference {
ctx.require_non_empty("$ref", reference);
crate::common::reference::check_external(ctx, reference);
crate::common::resolve::check_names_something(ctx, reference);
crate::common::resolve::check_names_kind::<ChannelItem>(ctx, reference, "channels");
}
ctx.validate_map_keys("parameters", &self.parameters);
for (i, server) in self.servers.iter().enumerate() {
if server.is_empty() {
ctx.in_index("servers", i, |ctx| ctx.error("must not be empty"));
} else if self.servers[..i].contains(server) {
ctx.in_index("servers", i, |ctx| {
ctx.error(format!("duplicate server `{server}`"));
});
}
}
for (kind, operation) in [("publish", &self.publish), ("subscribe", &self.subscribe)] {
if let Some(operation) = operation {
ctx.in_field(kind, |ctx| operation.validate_with_context(ctx));
}
}
for (name, parameter) in &self.parameters {
ctx.in_key("parameters", name, |ctx| {
parameter.validate_with_context(ctx);
});
}
if let Some(bindings) = &self.bindings {
ctx.in_field("bindings", |ctx| bindings.validate_with_context(ctx));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use enumset::EnumSet;
use serde_json::json;
fn errors_against(path: &str, value: serde_json::Value) -> Vec<String> {
let item: ChannelItem = serde_json::from_value(value).unwrap();
let mut ctx = Context::with_path(EnumSet::empty(), "#.channels.user");
item.validate_with_context(&mut ctx);
ChannelItem::validate_path_parameters(&mut ctx, path, &[&item]);
ctx.errors.iter().map(ToString::to_string).collect()
}
#[test]
fn round_trips_a_full_channel_item() {
let value = json!({
"description": "User events",
"servers": ["production"],
"publish": { "operationId": "receiveSignup", "message": { "name": "Signup" } },
"subscribe": { "operationId": "sendWelcome", "message": { "name": "Welcome" } },
"parameters": { "userId": { "description": "Id", "schema": { "type": "string" } } },
"deprecated": false,
"bindings": { "kafka": { "topic": "signups" } },
"x-owner": "team"
});
let item: ChannelItem = serde_json::from_value(value.clone()).unwrap();
assert!(item.publish.is_some() && item.subscribe.is_some());
assert_eq!(serde_json::to_value(&item).unwrap(), value);
assert!(errors_against("user/{userId}/signedup", value).is_empty());
}
#[test]
fn path_placeholders_and_parameters_must_agree() {
let errors = errors_against(
"user/{userId}/{tenant}",
json!({ "parameters": { "userId": {}, "unused": {} } }),
);
assert!(
errors
.iter()
.any(|e| e.contains("`{tenant}` in the channel path is not declared")),
"got: {errors:?}"
);
assert!(
errors
.iter()
.any(|e| e.contains("`unused` is declared but never used")),
"got: {errors:?}"
);
}
#[test]
fn unused_parameters_can_be_ignored() {
let item: ChannelItem =
serde_json::from_value(json!({ "parameters": { "unused": {} } })).unwrap();
let mut ctx = Context::with_path(
EnumSet::only(ValidationOptions::IgnoreUnusedChannelParameter),
"#.channels.user",
);
item.validate_with_context(&mut ctx);
ChannelItem::validate_path_parameters(&mut ctx, "user/signedup", &[&item]);
assert!(ctx.errors.is_empty(), "got: {:?}", ctx.errors);
}
#[test]
fn server_names_must_be_non_empty_and_unique() {
let errors = errors_against("user", json!({ "servers": ["prod", "", "prod"] }));
assert!(
errors
.iter()
.any(|e| e == "#.channels.user.servers[1]: must not be empty")
);
assert!(
errors
.iter()
.any(|e| e == "#.channels.user.servers[2]: duplicate server `prod`")
);
}
#[test]
fn invalid_parameter_keys_are_reported() {
let errors = errors_against("user/{bad key}", json!({ "parameters": { "bad key": {} } }));
assert!(
errors
.iter()
.any(|e| e.contains("#.channels.user.parameters.bad key")),
"got: {errors:?}"
);
}
#[test]
fn nested_errors_carry_their_path() {
let errors = errors_against(
"user",
json!({
"publish": { "operationId": "" },
"subscribe": { "tags": [ { "name": "" } ] },
"bindings": { "kafka": 1 }
}),
);
assert!(
errors
.iter()
.any(|e| e == "#.channels.user.publish.operationId: must not be empty")
);
assert!(
errors
.iter()
.any(|e| e == "#.channels.user.subscribe.tags[0].name: must not be empty")
);
assert!(
errors
.iter()
.any(|e| e.starts_with("#.channels.user.bindings.kafka"))
);
}
}