1use crate::features;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::BTreeMap;
5
6#[derive(Debug, Clone, Default, Serialize, Deserialize)]
7pub struct CustomRouteDefinition {
8 pub method: String,
9 pub path: String,
10 pub handler: String,
11 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub job: Option<JobConfig>,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub posture: Option<RoutePosture>,
23 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
27 pub forward_auth: bool,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub body_limit_bytes: Option<u64>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub timeout_secs: Option<u32>,
34 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
36 pub multipart: bool,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub sse: Option<SseRouteDef>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub doc: Option<RouteDoc>,
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
48pub struct RouteDoc {
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub operation_id: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub summary: Option<String>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub description: Option<String>,
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 pub tags: Vec<String>,
59 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
60 pub deprecated: bool,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub path_params: Option<Value>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub query: Option<Value>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub request: Option<BodyDoc>,
69 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
70 pub responses: BTreeMap<u16, ResponseDoc>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub security: Option<Vec<String>>,
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct BodyDoc {
78 pub content_type: String,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub schema: Option<Value>,
81 pub required: bool,
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85pub struct ResponseDoc {
86 pub description: String,
87 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub content: Vec<MediaDoc>,
89}
90
91impl ResponseDoc {
92 pub fn default_description(status: u16) -> &'static str {
95 match status {
96 200 => "OK",
97 201 => "Created",
98 202 => "Accepted",
99 204 => "No Content",
100 301 => "Moved Permanently",
101 302 => "Found",
102 303 => "See Other",
103 304 => "Not Modified",
104 307 => "Temporary Redirect",
105 308 => "Permanent Redirect",
106 400 => "Bad Request",
107 401 => "Unauthorized",
108 403 => "Forbidden",
109 404 => "Not Found",
110 405 => "Method Not Allowed",
111 409 => "Conflict",
112 413 => "Payload Too Large",
113 415 => "Unsupported Media Type",
114 422 => "Unprocessable Entity",
115 429 => "Too Many Requests",
116 500 => "Internal Server Error",
117 502 => "Bad Gateway",
118 503 => "Service Unavailable",
119 504 => "Gateway Timeout",
120 _ => "Response",
121 }
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128pub struct MediaDoc {
129 pub content_type: String,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub schema: Option<Value>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct SseRouteDef {
138 pub subject: String,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub reducer: Option<String>,
145 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
148 pub anonymous: bool,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum SseSubjectError {
153 Missing(String),
155 InvalidToken(String),
157}
158
159impl std::fmt::Display for SseSubjectError {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 match self {
162 Self::Missing(name) => write!(f, "the event subject needs path parameter '{name}'"),
163 Self::InvalidToken(name) => write!(
164 f,
165 "path parameter '{name}' must be one subject token (no '.', '*', '>' or whitespace)"
166 ),
167 }
168 }
169}
170
171impl std::error::Error for SseSubjectError {}
172
173impl SseRouteDef {
174 pub const SUBJECT_PREFIX: &'static str = "dw.{tenant}.";
177
178 pub const REDUCER_METHOD: &'static str = "SSE_EVENT";
180
181 pub const RESERVED_TOKENS: &'static [&'static str] = &["logs"];
184
185 pub fn subject_violation(&self, params: Option<&[String]>) -> Option<String> {
191 let tokens: Vec<&str> = self.subject.split('.').collect();
192 let malformed = tokens.iter().any(|token| {
193 token.is_empty()
194 || (token.contains(['{', '}']) && placeholder(token).is_none_or(str::is_empty))
195 });
196 if !self.subject.starts_with(Self::SUBJECT_PREFIX) || malformed {
197 return Some(format!(
198 "sse subject '{}' must be dot-separated tokens starting {}",
199 self.subject,
200 Self::SUBJECT_PREFIX
201 ));
202 }
203 let named = tokens.get(2).copied().unwrap_or_default();
204 if matches!(named, "*" | ">") || placeholder(named).is_some() {
205 return Some(format!(
206 "sse subject '{}' must name its events with a literal token after {}",
207 self.subject,
208 Self::SUBJECT_PREFIX
209 ));
210 }
211 if Self::RESERVED_TOKENS.contains(&named) {
212 return Some(format!(
213 "sse subject '{}' reads dw.{{tenant}}.{named}, which the platform reserves",
214 self.subject
215 ));
216 }
217 let unbound = self.placeholders().find(|name| {
218 *name != "tenant" && !params.is_some_and(|params| params.iter().any(|p| p == name))
219 })?;
220 Some(match params {
221 Some(_) => format!(
222 "sse subject placeholder '{{{unbound}}}' is not a path parameter of this strict route"
223 ),
224 None => format!(
225 "sse subject placeholder '{{{unbound}}}' needs routing: strict, where it names a path parameter"
226 ),
227 })
228 }
229
230 pub fn anonymous_violation(&self, anonymous_posture: bool) -> Option<&'static str> {
233 (anonymous_posture && !self.anonymous).then_some(
234 "sse on a public or undeclared posture streams to anonymous callers; \
235 declare anonymous = true to allow it",
236 )
237 }
238
239 pub fn placeholders(&self) -> impl Iterator<Item = &str> {
241 self.subject.split('.').filter_map(placeholder)
242 }
243
244 pub fn resolve(
246 &self,
247 tenant: &str,
248 params: &[(String, String)],
249 ) -> Result<String, SseSubjectError> {
250 self.subject
251 .split('.')
252 .map(|token| match placeholder(token) {
253 None => Ok(token),
254 Some("tenant") => Ok(tenant),
255 Some(name) => params
256 .iter()
257 .find(|(param, _)| param == name)
258 .map(|(_, value)| value.as_str())
259 .ok_or_else(|| SseSubjectError::Missing(name.to_string()))
260 .and_then(|value| {
261 if is_subject_token(value) {
262 Ok(value)
263 } else {
264 Err(SseSubjectError::InvalidToken(name.to_string()))
265 }
266 }),
267 })
268 .collect::<Result<Vec<_>, _>>()
269 .map(|tokens| tokens.join("."))
270 }
271}
272
273fn placeholder(token: &str) -> Option<&str> {
274 token.strip_prefix('{').and_then(|t| t.strip_suffix('}'))
275}
276
277fn is_subject_token(value: &str) -> bool {
278 !value.is_empty()
279 && !value
280 .chars()
281 .any(|c| matches!(c, '.' | '*' | '>') || c.is_whitespace())
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
287#[serde(tag = "kind", rename_all = "snake_case")]
288pub enum RoutePosture {
289 Public { reason: String },
292 Authenticated,
294 Permission { area: String, operation: String },
296 Owner { field: String },
299 Internal { callers: Vec<String> },
303 ApiKey { scope: String },
307 Scoped {
312 area: String,
313 operation: String,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
315 owner_field: Option<String>,
316 },
317}
318
319impl CustomRouteDefinition {
320 pub const DEFAULT_BODY_LIMIT_BYTES: u64 = 4 * 1024 * 1024;
322 pub const MAX_BODY_LIMIT_BYTES: u64 = 64 * 1024 * 1024;
324 pub const TIMEOUT_SECS: std::ops::RangeInclusive<u32> = 1..=120;
326 pub const MULTIPART_METHODS: [&'static str; 3] = ["POST", "PUT", "PATCH"];
328
329 pub fn derived_features(&self) -> impl Iterator<Item = &'static str> {
331 let limits =
332 self.body_limit_bytes.is_some() || self.timeout_secs.is_some() || self.multipart;
333 [
334 self.posture.is_some().then_some(features::ROUTE_POSTURE),
335 limits.then_some(features::ROUTE_LIMITS),
336 self.sse.is_some().then_some(features::SSE_BRIDGE),
337 self.posture.as_ref().and_then(RoutePosture::feature),
338 ]
339 .into_iter()
340 .flatten()
341 }
342
343 pub fn uses_request_v2(&self) -> bool {
347 self.derived_features()
348 .any(|feature| feature != features::ROUTE_POSTURE)
349 }
350}
351
352impl RoutePosture {
353 pub fn is_anonymous(&self) -> bool {
355 matches!(self, RoutePosture::Public { .. })
356 }
357
358 pub fn label(&self) -> &'static str {
360 match self {
361 RoutePosture::Public { .. } => "public",
362 RoutePosture::Authenticated => "authenticated",
363 RoutePosture::Permission { .. } => "permission",
364 RoutePosture::Owner { .. } => "owner",
365 RoutePosture::Internal { .. } => "internal",
366 RoutePosture::ApiKey { .. } => "api_key",
367 RoutePosture::Scoped { .. } => "scoped",
368 }
369 }
370
371 pub fn feature(&self) -> Option<&'static str> {
373 match self {
374 RoutePosture::Internal { .. } => Some(features::INTERNAL_POSTURE),
375 RoutePosture::ApiKey { .. } => Some(features::API_KEY_POSTURE),
376 RoutePosture::Scoped { .. } => Some(features::SCOPED_POSTURE),
377 RoutePosture::Public { .. }
378 | RoutePosture::Authenticated
379 | RoutePosture::Permission { .. }
380 | RoutePosture::Owner { .. } => None,
381 }
382 }
383
384 pub fn admits_caller(callers: &[String], service: &str) -> bool {
387 callers
388 .iter()
389 .any(|caller| caller == "*" || caller == service)
390 }
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
394pub struct JobConfig {
395 pub timeout_secs: u32,
397 pub max_attempts: u32,
400}
401
402impl JobConfig {
403 pub const DEFAULT_TIMEOUT_SECS: u32 = 600;
404 pub const DEFAULT_MAX_ATTEMPTS: u32 = 3;
405}
406
407impl Default for JobConfig {
408 fn default() -> Self {
409 Self {
410 timeout_secs: Self::DEFAULT_TIMEOUT_SECS,
411 max_attempts: Self::DEFAULT_MAX_ATTEMPTS,
412 }
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn job_config_defaults() {
422 let cfg = JobConfig::default();
423 assert_eq!(cfg.timeout_secs, JobConfig::DEFAULT_TIMEOUT_SECS);
424 assert_eq!(cfg.max_attempts, JobConfig::DEFAULT_MAX_ATTEMPTS);
425 }
426
427 #[test]
428 fn custom_route_serde_round_trip_with_job() {
429 let route = CustomRouteDefinition {
430 method: "POST".into(),
431 path: "/run".into(),
432 handler: "do_thing".into(),
433 job: Some(JobConfig {
434 timeout_secs: 900,
435 max_attempts: 2,
436 }),
437 posture: None,
438 forward_auth: false,
439 ..Default::default()
440 };
441 let json = serde_json::to_string(&route).unwrap();
442 let round: CustomRouteDefinition = serde_json::from_str(&json).unwrap();
443 assert_eq!(round.method, "POST");
444 let job = round.job.unwrap();
445 assert_eq!(job.timeout_secs, 900);
446 assert_eq!(job.max_attempts, 2);
447 }
448
449 #[test]
450 fn forward_auth_defaults_off_and_is_only_serialized_when_set() {
451 let stored: CustomRouteDefinition =
452 serde_json::from_str(r#"{"method":"GET","path":"/x","handler":"x"}"#).unwrap();
453 assert!(!stored.forward_auth);
454 let json = serde_json::to_string(&stored).unwrap();
455 assert!(!json.contains("forward_auth"), "{json}");
456 let forwarding = CustomRouteDefinition {
457 forward_auth: true,
458 ..stored
459 };
460 let json = serde_json::to_value(&forwarding).unwrap();
461 assert_eq!(json["forward_auth"], true);
462 }
463
464 #[test]
465 fn custom_route_without_job_serializes_without_field() {
466 let route = CustomRouteDefinition {
467 method: "GET".into(),
468 path: "/x".into(),
469 handler: "x".into(),
470 job: None,
471 posture: None,
472 forward_auth: false,
473 ..Default::default()
474 };
475 let json = serde_json::to_value(&route).unwrap();
476 assert!(json.get("job").is_none(), "absent job should be omitted");
477 assert!(
478 json.get("posture").is_none(),
479 "absent posture should be omitted"
480 );
481 }
482
483 #[test]
484 fn custom_route_legacy_json_without_job_deserializes() {
485 let json = r#"{"method":"GET","path":"/x","handler":"x"}"#;
486 let route: CustomRouteDefinition = serde_json::from_str(json).unwrap();
487 assert!(route.job.is_none());
488 assert!(route.posture.is_none());
489 }
490
491 #[test]
492 fn sse_subjects_substitute_tenant_and_path_parameters() {
493 let sse = SseRouteDef {
494 subject: "dw.{tenant}.quotes.pdf.progress.{lead_id}".into(),
495 reducer: Some("reduce".into()),
496 anonymous: false,
497 };
498 assert_eq!(
499 sse.placeholders().collect::<Vec<_>>(),
500 ["tenant", "lead_id"]
501 );
502 let params = |value: &str| vec![("lead_id".to_string(), value.to_string())];
503 assert_eq!(
504 sse.resolve("acme", ¶ms("L-7")),
505 Ok("dw.acme.quotes.pdf.progress.L-7".into())
506 );
507 for bad in ["a.b", "*", ">", "a b", ""] {
508 assert_eq!(
509 sse.resolve("acme", ¶ms(bad)),
510 Err(SseSubjectError::InvalidToken("lead_id".into())),
511 "{bad}"
512 );
513 }
514 assert_eq!(
515 sse.resolve("acme", &[]),
516 Err(SseSubjectError::Missing("lead_id".into()))
517 );
518 let wildcard = SseRouteDef {
519 subject: "dw.{tenant}.jobs.>".into(),
520 reducer: None,
521 anonymous: false,
522 };
523 assert_eq!(wildcard.resolve("acme", &[]), Ok("dw.acme.jobs.>".into()));
524 }
525
526 #[test]
527 fn sse_subjects_name_unreserved_events_and_bind_their_placeholders() {
528 let sse = |subject: &str| SseRouteDef {
529 subject: subject.into(),
530 reducer: None,
531 anonymous: false,
532 };
533 let params = ["id".to_string()];
534 for ok in [
535 "dw.{tenant}.jobs.>",
536 "dw.{tenant}.q.{id}",
537 "dw.{tenant}.q.*",
538 ] {
539 assert_eq!(sse(ok).subject_violation(Some(¶ms)), None, "{ok}");
540 }
541 for (subject, params, needle) in [
542 (
543 "dw.other.q",
544 Some(¶ms[..]),
545 "must be dot-separated tokens",
546 ),
547 (
548 "dw.{tenant}.",
549 Some(¶ms[..]),
550 "must be dot-separated tokens",
551 ),
552 (
553 "dw.{tenant}.q.{}",
554 Some(¶ms[..]),
555 "must be dot-separated tokens",
556 ),
557 ("dw.{tenant}.>", Some(¶ms[..]), "with a literal token"),
558 (
559 "dw.{tenant}.{id}.x",
560 Some(¶ms[..]),
561 "with a literal token",
562 ),
563 (
564 "dw.{tenant}.logs",
565 Some(¶ms[..]),
566 "the platform reserves",
567 ),
568 (
569 "dw.{tenant}.q.{other}",
570 Some(¶ms[..]),
571 "not a path parameter",
572 ),
573 ("dw.{tenant}.q.{id}", None, "needs routing: strict"),
574 ] {
575 let violation = sse(subject).subject_violation(params).unwrap_or_default();
576 assert!(violation.contains(needle), "{subject}: {violation}");
577 }
578 assert!(sse("dw.{tenant}.q").anonymous_violation(true).is_some());
579 assert!(sse("dw.{tenant}.q").anonymous_violation(false).is_none());
580 let opted_in = SseRouteDef {
581 anonymous: true,
582 ..sse("dw.{tenant}.q")
583 };
584 assert!(opted_in.anonymous_violation(true).is_none());
585 assert_eq!(
586 serde_json::to_value(&opted_in).unwrap(),
587 serde_json::json!({"subject": "dw.{tenant}.q", "anonymous": true})
588 );
589 }
590
591 #[test]
592 fn internal_routes_demand_their_feature_and_admit_listed_callers() {
593 let route = CustomRouteDefinition {
594 posture: Some(RoutePosture::Internal {
595 callers: vec!["pricing".into()],
596 }),
597 ..Default::default()
598 };
599 assert_eq!(
600 route.derived_features().collect::<Vec<_>>(),
601 [features::ROUTE_POSTURE, features::INTERNAL_POSTURE]
602 );
603 assert!(route.uses_request_v2());
604 assert_eq!(
605 serde_json::to_value(route.posture).unwrap(),
606 serde_json::json!({"kind": "internal", "callers": ["pricing"]})
607 );
608 let listed = ["pricing".to_string()];
609 assert!(RoutePosture::admits_caller(&listed, "pricing"));
610 assert!(!RoutePosture::admits_caller(&listed, "assets"));
611 assert!(RoutePosture::admits_caller(&["*".to_string()], "assets"));
612 assert!(!RoutePosture::admits_caller(&[], "assets"));
613 }
614
615 #[test]
616 fn api_key_routes_demand_their_feature() {
617 let route = CustomRouteDefinition {
618 posture: Some(RoutePosture::ApiKey {
619 scope: "leads:ingest".into(),
620 }),
621 ..Default::default()
622 };
623 assert_eq!(
624 route.derived_features().collect::<Vec<_>>(),
625 [features::ROUTE_POSTURE, features::API_KEY_POSTURE]
626 );
627 assert_eq!(
628 serde_json::to_value(route.posture).unwrap(),
629 serde_json::json!({"kind": "api_key", "scope": "leads:ingest"})
630 );
631 }
632
633 #[test]
634 fn scoped_routes_demand_their_feature() {
635 let route = CustomRouteDefinition {
636 posture: Some(RoutePosture::Scoped {
637 area: "assets".into(),
638 operation: "view".into(),
639 owner_field: None,
640 }),
641 ..Default::default()
642 };
643 assert_eq!(
644 route.derived_features().collect::<Vec<_>>(),
645 [features::ROUTE_POSTURE, features::SCOPED_POSTURE]
646 );
647 assert_eq!(
648 serde_json::to_value(route.posture).unwrap(),
649 serde_json::json!({"kind": "scoped", "area": "assets", "operation": "view"})
650 );
651 }
652
653 #[test]
654 fn route_posture_serde_tagged_round_trip() {
655 let cases = [
656 RoutePosture::Public {
657 reason: "stripe webhook".into(),
658 },
659 RoutePosture::Authenticated,
660 RoutePosture::Permission {
661 area: "items".into(),
662 operation: "edit".into(),
663 },
664 RoutePosture::Owner {
665 field: "customer_id".into(),
666 },
667 RoutePosture::Internal {
668 callers: vec!["pricing-service".into()],
669 },
670 RoutePosture::ApiKey {
671 scope: "leads:ingest".into(),
672 },
673 RoutePosture::Scoped {
674 area: "assets".into(),
675 operation: "view".into(),
676 owner_field: Some("customer_id".into()),
677 },
678 RoutePosture::Scoped {
679 area: "assets".into(),
680 operation: "view".into(),
681 owner_field: None,
682 },
683 ];
684 for p in cases {
685 let json = serde_json::to_string(&p).unwrap();
686 let round: RoutePosture = serde_json::from_str(&json).unwrap();
687 assert_eq!(round, p);
688 }
689 let public = serde_json::to_value(RoutePosture::Authenticated).unwrap();
690 assert_eq!(public["kind"], "authenticated");
691 }
692}