use crate::features;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CustomRouteDefinition {
pub method: String,
pub path: String,
pub handler: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub job: Option<JobConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub posture: Option<RoutePosture>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub forward_auth: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body_limit_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u32>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub multipart: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sse: Option<SseRouteDef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doc: Option<RouteDoc>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RouteDoc {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operation_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub deprecated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_params: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request: Option<BodyDoc>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub responses: BTreeMap<u16, ResponseDoc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub security: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BodyDoc {
pub content_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<Value>,
pub required: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResponseDoc {
pub description: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub content: Vec<MediaDoc>,
}
impl ResponseDoc {
pub fn default_description(status: u16) -> &'static str {
match status {
200 => "OK",
201 => "Created",
202 => "Accepted",
204 => "No Content",
301 => "Moved Permanently",
302 => "Found",
303 => "See Other",
304 => "Not Modified",
307 => "Temporary Redirect",
308 => "Permanent Redirect",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
409 => "Conflict",
413 => "Payload Too Large",
415 => "Unsupported Media Type",
422 => "Unprocessable Entity",
429 => "Too Many Requests",
500 => "Internal Server Error",
502 => "Bad Gateway",
503 => "Service Unavailable",
504 => "Gateway Timeout",
_ => "Response",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MediaDoc {
pub content_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SseRouteDef {
pub subject: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reducer: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub anonymous: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SseSubjectError {
Missing(String),
InvalidToken(String),
}
impl std::fmt::Display for SseSubjectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Missing(name) => write!(f, "the event subject needs path parameter '{name}'"),
Self::InvalidToken(name) => write!(
f,
"path parameter '{name}' must be one subject token (no '.', '*', '>' or whitespace)"
),
}
}
}
impl std::error::Error for SseSubjectError {}
impl SseRouteDef {
pub const SUBJECT_PREFIX: &'static str = "dw.{tenant}.";
pub const REDUCER_METHOD: &'static str = "SSE_EVENT";
pub const RESERVED_TOKENS: &'static [&'static str] = &["logs"];
pub fn subject_violation(&self, params: Option<&[String]>) -> Option<String> {
let tokens: Vec<&str> = self.subject.split('.').collect();
let malformed = tokens.iter().any(|token| {
token.is_empty()
|| (token.contains(['{', '}']) && placeholder(token).is_none_or(str::is_empty))
});
if !self.subject.starts_with(Self::SUBJECT_PREFIX) || malformed {
return Some(format!(
"sse subject '{}' must be dot-separated tokens starting {}",
self.subject,
Self::SUBJECT_PREFIX
));
}
let named = tokens.get(2).copied().unwrap_or_default();
if matches!(named, "*" | ">") || placeholder(named).is_some() {
return Some(format!(
"sse subject '{}' must name its events with a literal token after {}",
self.subject,
Self::SUBJECT_PREFIX
));
}
if Self::RESERVED_TOKENS.contains(&named) {
return Some(format!(
"sse subject '{}' reads dw.{{tenant}}.{named}, which the platform reserves",
self.subject
));
}
let unbound = self.placeholders().find(|name| {
*name != "tenant" && !params.is_some_and(|params| params.iter().any(|p| p == name))
})?;
Some(match params {
Some(_) => format!(
"sse subject placeholder '{{{unbound}}}' is not a path parameter of this strict route"
),
None => format!(
"sse subject placeholder '{{{unbound}}}' needs routing: strict, where it names a path parameter"
),
})
}
pub fn anonymous_violation(&self, anonymous_posture: bool) -> Option<&'static str> {
(anonymous_posture && !self.anonymous).then_some(
"sse on a public or undeclared posture streams to anonymous callers; \
declare anonymous = true to allow it",
)
}
pub fn placeholders(&self) -> impl Iterator<Item = &str> {
self.subject.split('.').filter_map(placeholder)
}
pub fn resolve(
&self,
tenant: &str,
params: &[(String, String)],
) -> Result<String, SseSubjectError> {
self.subject
.split('.')
.map(|token| match placeholder(token) {
None => Ok(token),
Some("tenant") => Ok(tenant),
Some(name) => params
.iter()
.find(|(param, _)| param == name)
.map(|(_, value)| value.as_str())
.ok_or_else(|| SseSubjectError::Missing(name.to_string()))
.and_then(|value| {
if is_subject_token(value) {
Ok(value)
} else {
Err(SseSubjectError::InvalidToken(name.to_string()))
}
}),
})
.collect::<Result<Vec<_>, _>>()
.map(|tokens| tokens.join("."))
}
}
fn placeholder(token: &str) -> Option<&str> {
token.strip_prefix('{').and_then(|t| t.strip_suffix('}'))
}
fn is_subject_token(value: &str) -> bool {
!value.is_empty()
&& !value
.chars()
.any(|c| matches!(c, '.' | '*' | '>') || c.is_whitespace())
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RoutePosture {
Public { reason: String },
Authenticated,
Permission { area: String, operation: String },
Owner { field: String },
Internal { callers: Vec<String> },
ApiKey { scope: String },
Scoped {
area: String,
operation: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
owner_field: Option<String>,
},
}
impl CustomRouteDefinition {
pub const DEFAULT_BODY_LIMIT_BYTES: u64 = 4 * 1024 * 1024;
pub const MAX_BODY_LIMIT_BYTES: u64 = 64 * 1024 * 1024;
pub const TIMEOUT_SECS: std::ops::RangeInclusive<u32> = 1..=120;
pub const MULTIPART_METHODS: [&'static str; 3] = ["POST", "PUT", "PATCH"];
pub fn derived_features(&self) -> impl Iterator<Item = &'static str> {
let limits =
self.body_limit_bytes.is_some() || self.timeout_secs.is_some() || self.multipart;
[
self.posture.is_some().then_some(features::ROUTE_POSTURE),
limits.then_some(features::ROUTE_LIMITS),
self.sse.is_some().then_some(features::SSE_BRIDGE),
self.posture.as_ref().and_then(RoutePosture::feature),
]
.into_iter()
.flatten()
}
pub fn uses_request_v2(&self) -> bool {
self.derived_features()
.any(|feature| feature != features::ROUTE_POSTURE)
}
}
impl RoutePosture {
pub fn is_anonymous(&self) -> bool {
matches!(self, RoutePosture::Public { .. })
}
pub fn label(&self) -> &'static str {
match self {
RoutePosture::Public { .. } => "public",
RoutePosture::Authenticated => "authenticated",
RoutePosture::Permission { .. } => "permission",
RoutePosture::Owner { .. } => "owner",
RoutePosture::Internal { .. } => "internal",
RoutePosture::ApiKey { .. } => "api_key",
RoutePosture::Scoped { .. } => "scoped",
}
}
pub fn feature(&self) -> Option<&'static str> {
match self {
RoutePosture::Internal { .. } => Some(features::INTERNAL_POSTURE),
RoutePosture::ApiKey { .. } => Some(features::API_KEY_POSTURE),
RoutePosture::Scoped { .. } => Some(features::SCOPED_POSTURE),
RoutePosture::Public { .. }
| RoutePosture::Authenticated
| RoutePosture::Permission { .. }
| RoutePosture::Owner { .. } => None,
}
}
pub fn admits_caller(callers: &[String], service: &str) -> bool {
callers
.iter()
.any(|caller| caller == "*" || caller == service)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct JobConfig {
pub timeout_secs: u32,
pub max_attempts: u32,
}
impl JobConfig {
pub const DEFAULT_TIMEOUT_SECS: u32 = 600;
pub const DEFAULT_MAX_ATTEMPTS: u32 = 3;
}
impl Default for JobConfig {
fn default() -> Self {
Self {
timeout_secs: Self::DEFAULT_TIMEOUT_SECS,
max_attempts: Self::DEFAULT_MAX_ATTEMPTS,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn job_config_defaults() {
let cfg = JobConfig::default();
assert_eq!(cfg.timeout_secs, JobConfig::DEFAULT_TIMEOUT_SECS);
assert_eq!(cfg.max_attempts, JobConfig::DEFAULT_MAX_ATTEMPTS);
}
#[test]
fn custom_route_serde_round_trip_with_job() {
let route = CustomRouteDefinition {
method: "POST".into(),
path: "/run".into(),
handler: "do_thing".into(),
job: Some(JobConfig {
timeout_secs: 900,
max_attempts: 2,
}),
posture: None,
forward_auth: false,
..Default::default()
};
let json = serde_json::to_string(&route).unwrap();
let round: CustomRouteDefinition = serde_json::from_str(&json).unwrap();
assert_eq!(round.method, "POST");
let job = round.job.unwrap();
assert_eq!(job.timeout_secs, 900);
assert_eq!(job.max_attempts, 2);
}
#[test]
fn forward_auth_defaults_off_and_is_only_serialized_when_set() {
let stored: CustomRouteDefinition =
serde_json::from_str(r#"{"method":"GET","path":"/x","handler":"x"}"#).unwrap();
assert!(!stored.forward_auth);
let json = serde_json::to_string(&stored).unwrap();
assert!(!json.contains("forward_auth"), "{json}");
let forwarding = CustomRouteDefinition {
forward_auth: true,
..stored
};
let json = serde_json::to_value(&forwarding).unwrap();
assert_eq!(json["forward_auth"], true);
}
#[test]
fn custom_route_without_job_serializes_without_field() {
let route = CustomRouteDefinition {
method: "GET".into(),
path: "/x".into(),
handler: "x".into(),
job: None,
posture: None,
forward_auth: false,
..Default::default()
};
let json = serde_json::to_value(&route).unwrap();
assert!(json.get("job").is_none(), "absent job should be omitted");
assert!(
json.get("posture").is_none(),
"absent posture should be omitted"
);
}
#[test]
fn custom_route_legacy_json_without_job_deserializes() {
let json = r#"{"method":"GET","path":"/x","handler":"x"}"#;
let route: CustomRouteDefinition = serde_json::from_str(json).unwrap();
assert!(route.job.is_none());
assert!(route.posture.is_none());
}
#[test]
fn sse_subjects_substitute_tenant_and_path_parameters() {
let sse = SseRouteDef {
subject: "dw.{tenant}.quotes.pdf.progress.{lead_id}".into(),
reducer: Some("reduce".into()),
anonymous: false,
};
assert_eq!(
sse.placeholders().collect::<Vec<_>>(),
["tenant", "lead_id"]
);
let params = |value: &str| vec![("lead_id".to_string(), value.to_string())];
assert_eq!(
sse.resolve("acme", ¶ms("L-7")),
Ok("dw.acme.quotes.pdf.progress.L-7".into())
);
for bad in ["a.b", "*", ">", "a b", ""] {
assert_eq!(
sse.resolve("acme", ¶ms(bad)),
Err(SseSubjectError::InvalidToken("lead_id".into())),
"{bad}"
);
}
assert_eq!(
sse.resolve("acme", &[]),
Err(SseSubjectError::Missing("lead_id".into()))
);
let wildcard = SseRouteDef {
subject: "dw.{tenant}.jobs.>".into(),
reducer: None,
anonymous: false,
};
assert_eq!(wildcard.resolve("acme", &[]), Ok("dw.acme.jobs.>".into()));
}
#[test]
fn sse_subjects_name_unreserved_events_and_bind_their_placeholders() {
let sse = |subject: &str| SseRouteDef {
subject: subject.into(),
reducer: None,
anonymous: false,
};
let params = ["id".to_string()];
for ok in [
"dw.{tenant}.jobs.>",
"dw.{tenant}.q.{id}",
"dw.{tenant}.q.*",
] {
assert_eq!(sse(ok).subject_violation(Some(¶ms)), None, "{ok}");
}
for (subject, params, needle) in [
(
"dw.other.q",
Some(¶ms[..]),
"must be dot-separated tokens",
),
(
"dw.{tenant}.",
Some(¶ms[..]),
"must be dot-separated tokens",
),
(
"dw.{tenant}.q.{}",
Some(¶ms[..]),
"must be dot-separated tokens",
),
("dw.{tenant}.>", Some(¶ms[..]), "with a literal token"),
(
"dw.{tenant}.{id}.x",
Some(¶ms[..]),
"with a literal token",
),
(
"dw.{tenant}.logs",
Some(¶ms[..]),
"the platform reserves",
),
(
"dw.{tenant}.q.{other}",
Some(¶ms[..]),
"not a path parameter",
),
("dw.{tenant}.q.{id}", None, "needs routing: strict"),
] {
let violation = sse(subject).subject_violation(params).unwrap_or_default();
assert!(violation.contains(needle), "{subject}: {violation}");
}
assert!(sse("dw.{tenant}.q").anonymous_violation(true).is_some());
assert!(sse("dw.{tenant}.q").anonymous_violation(false).is_none());
let opted_in = SseRouteDef {
anonymous: true,
..sse("dw.{tenant}.q")
};
assert!(opted_in.anonymous_violation(true).is_none());
assert_eq!(
serde_json::to_value(&opted_in).unwrap(),
serde_json::json!({"subject": "dw.{tenant}.q", "anonymous": true})
);
}
#[test]
fn internal_routes_demand_their_feature_and_admit_listed_callers() {
let route = CustomRouteDefinition {
posture: Some(RoutePosture::Internal {
callers: vec!["pricing".into()],
}),
..Default::default()
};
assert_eq!(
route.derived_features().collect::<Vec<_>>(),
[features::ROUTE_POSTURE, features::INTERNAL_POSTURE]
);
assert!(route.uses_request_v2());
assert_eq!(
serde_json::to_value(route.posture).unwrap(),
serde_json::json!({"kind": "internal", "callers": ["pricing"]})
);
let listed = ["pricing".to_string()];
assert!(RoutePosture::admits_caller(&listed, "pricing"));
assert!(!RoutePosture::admits_caller(&listed, "assets"));
assert!(RoutePosture::admits_caller(&["*".to_string()], "assets"));
assert!(!RoutePosture::admits_caller(&[], "assets"));
}
#[test]
fn api_key_routes_demand_their_feature() {
let route = CustomRouteDefinition {
posture: Some(RoutePosture::ApiKey {
scope: "leads:ingest".into(),
}),
..Default::default()
};
assert_eq!(
route.derived_features().collect::<Vec<_>>(),
[features::ROUTE_POSTURE, features::API_KEY_POSTURE]
);
assert_eq!(
serde_json::to_value(route.posture).unwrap(),
serde_json::json!({"kind": "api_key", "scope": "leads:ingest"})
);
}
#[test]
fn scoped_routes_demand_their_feature() {
let route = CustomRouteDefinition {
posture: Some(RoutePosture::Scoped {
area: "assets".into(),
operation: "view".into(),
owner_field: None,
}),
..Default::default()
};
assert_eq!(
route.derived_features().collect::<Vec<_>>(),
[features::ROUTE_POSTURE, features::SCOPED_POSTURE]
);
assert_eq!(
serde_json::to_value(route.posture).unwrap(),
serde_json::json!({"kind": "scoped", "area": "assets", "operation": "view"})
);
}
#[test]
fn route_posture_serde_tagged_round_trip() {
let cases = [
RoutePosture::Public {
reason: "stripe webhook".into(),
},
RoutePosture::Authenticated,
RoutePosture::Permission {
area: "items".into(),
operation: "edit".into(),
},
RoutePosture::Owner {
field: "customer_id".into(),
},
RoutePosture::Internal {
callers: vec!["pricing-service".into()],
},
RoutePosture::ApiKey {
scope: "leads:ingest".into(),
},
RoutePosture::Scoped {
area: "assets".into(),
operation: "view".into(),
owner_field: Some("customer_id".into()),
},
RoutePosture::Scoped {
area: "assets".into(),
operation: "view".into(),
owner_field: None,
},
];
for p in cases {
let json = serde_json::to_string(&p).unwrap();
let round: RoutePosture = serde_json::from_str(&json).unwrap();
assert_eq!(round, p);
}
let public = serde_json::to_value(RoutePosture::Authenticated).unwrap();
assert_eq!(public["kind"], "authenticated");
}
}