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")]
40 pub max_concurrent: Option<u32>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub sse: Option<SseRouteDef>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub doc: Option<RouteDoc>,
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
50 pub audit_redact: Vec<String>,
51}
52
53#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
56pub struct RouteDoc {
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub operation_id: Option<String>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub summary: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub description: Option<String>,
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
66 pub tags: Vec<String>,
67 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
68 pub deprecated: bool,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub path_params: Option<Value>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub query: Option<Value>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub request: Option<BodyDoc>,
77 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
78 pub responses: BTreeMap<u16, ResponseDoc>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub security: Option<Vec<String>>,
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85pub struct BodyDoc {
86 pub content_type: String,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub schema: Option<Value>,
89 pub required: bool,
90}
91
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93pub struct ResponseDoc {
94 pub description: String,
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub content: Vec<MediaDoc>,
97}
98
99impl ResponseDoc {
100 pub fn default_description(status: u16) -> &'static str {
103 match status {
104 200 => "OK",
105 201 => "Created",
106 202 => "Accepted",
107 204 => "No Content",
108 301 => "Moved Permanently",
109 302 => "Found",
110 303 => "See Other",
111 304 => "Not Modified",
112 307 => "Temporary Redirect",
113 308 => "Permanent Redirect",
114 400 => "Bad Request",
115 401 => "Unauthorized",
116 403 => "Forbidden",
117 404 => "Not Found",
118 405 => "Method Not Allowed",
119 409 => "Conflict",
120 413 => "Payload Too Large",
121 415 => "Unsupported Media Type",
122 422 => "Unprocessable Entity",
123 429 => "Too Many Requests",
124 500 => "Internal Server Error",
125 502 => "Bad Gateway",
126 503 => "Service Unavailable",
127 504 => "Gateway Timeout",
128 _ => "Response",
129 }
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct MediaDoc {
137 pub content_type: String,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub schema: Option<Value>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct SseRouteDef {
146 pub subject: String,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub reducer: Option<String>,
153 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
156 pub anonymous: bool,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum SseSubjectError {
161 Missing(String),
163 InvalidToken(String),
165}
166
167impl std::fmt::Display for SseSubjectError {
168 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169 match self {
170 Self::Missing(name) => write!(f, "the event subject needs path parameter '{name}'"),
171 Self::InvalidToken(name) => write!(
172 f,
173 "path parameter '{name}' must be one subject token (no '.', '*', '>' or whitespace)"
174 ),
175 }
176 }
177}
178
179impl std::error::Error for SseSubjectError {}
180
181impl SseRouteDef {
182 pub const SUBJECT_PREFIX: &'static str = "dw.{tenant}.";
185
186 pub const REDUCER_METHOD: &'static str = "SSE_EVENT";
188
189 pub const RESERVED_TOKENS: &'static [&'static str] = &["logs"];
192
193 pub fn subject_violation(&self, params: Option<&[String]>) -> Option<String> {
199 let tokens: Vec<&str> = self.subject.split('.').collect();
200 let malformed = tokens.iter().any(|token| {
201 token.is_empty()
202 || (token.contains(['{', '}']) && placeholder(token).is_none_or(str::is_empty))
203 });
204 if !self.subject.starts_with(Self::SUBJECT_PREFIX) || malformed {
205 return Some(format!(
206 "sse subject '{}' must be dot-separated tokens starting {}",
207 self.subject,
208 Self::SUBJECT_PREFIX
209 ));
210 }
211 let named = tokens.get(2).copied().unwrap_or_default();
212 if matches!(named, "*" | ">") || placeholder(named).is_some() {
213 return Some(format!(
214 "sse subject '{}' must name its events with a literal token after {}",
215 self.subject,
216 Self::SUBJECT_PREFIX
217 ));
218 }
219 if Self::RESERVED_TOKENS.contains(&named) {
220 return Some(format!(
221 "sse subject '{}' reads dw.{{tenant}}.{named}, which the platform reserves",
222 self.subject
223 ));
224 }
225 let unbound = self.placeholders().find(|name| {
226 *name != "tenant" && !params.is_some_and(|params| params.iter().any(|p| p == name))
227 })?;
228 Some(match params {
229 Some(_) => format!(
230 "sse subject placeholder '{{{unbound}}}' is not a path parameter of this strict route"
231 ),
232 None => format!(
233 "sse subject placeholder '{{{unbound}}}' needs routing: strict, where it names a path parameter"
234 ),
235 })
236 }
237
238 pub fn anonymous_violation(&self, anonymous_posture: bool) -> Option<&'static str> {
241 (anonymous_posture && !self.anonymous).then_some(
242 "sse on a public or undeclared posture streams to anonymous callers; \
243 declare anonymous = true to allow it",
244 )
245 }
246
247 pub fn placeholders(&self) -> impl Iterator<Item = &str> {
249 self.subject.split('.').filter_map(placeholder)
250 }
251
252 pub fn resolve(
254 &self,
255 tenant: &str,
256 params: &[(String, String)],
257 ) -> Result<String, SseSubjectError> {
258 self.subject
259 .split('.')
260 .map(|token| match placeholder(token) {
261 None => Ok(token),
262 Some("tenant") => Ok(tenant),
263 Some(name) => params
264 .iter()
265 .find(|(param, _)| param == name)
266 .map(|(_, value)| value.as_str())
267 .ok_or_else(|| SseSubjectError::Missing(name.to_string()))
268 .and_then(|value| {
269 if is_subject_token(value) {
270 Ok(value)
271 } else {
272 Err(SseSubjectError::InvalidToken(name.to_string()))
273 }
274 }),
275 })
276 .collect::<Result<Vec<_>, _>>()
277 .map(|tokens| tokens.join("."))
278 }
279}
280
281fn placeholder(token: &str) -> Option<&str> {
282 token.strip_prefix('{').and_then(|t| t.strip_suffix('}'))
283}
284
285fn is_subject_token(value: &str) -> bool {
286 !value.is_empty()
287 && !value
288 .chars()
289 .any(|c| matches!(c, '.' | '*' | '>') || c.is_whitespace())
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
295#[serde(tag = "kind", rename_all = "snake_case")]
296pub enum RoutePosture {
297 Public { reason: String },
300 Authenticated,
302 Permission { area: String, operation: String },
304 Owner { field: String },
307 Internal { callers: Vec<String> },
311 ApiKey { scope: String },
315 Scoped {
320 area: String,
321 operation: String,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
323 owner_field: Option<String>,
324 },
325}
326
327impl CustomRouteDefinition {
328 pub const DEFAULT_BODY_LIMIT_BYTES: u64 = 4 * 1024 * 1024;
330 pub const MAX_BODY_LIMIT_BYTES: u64 = 64 * 1024 * 1024;
332 pub const TIMEOUT_SECS: std::ops::RangeInclusive<u32> = 1..=120;
334 pub const MULTIPART_METHODS: [&'static str; 3] = ["POST", "PUT", "PATCH"];
336 pub const MAX_CONCURRENT: std::ops::RangeInclusive<u32> = 1..=1024;
338
339 pub fn derived_features(&self) -> impl Iterator<Item = &'static str> {
341 let jobs_v2 = self.job.as_ref().is_some_and(JobConfig::uses_v2);
342 self.request_features()
343 .chain(jobs_v2.then_some(features::JOBS_V2))
344 .chain(
345 self.max_concurrent
346 .is_some()
347 .then_some(features::ROUTE_CONCURRENCY),
348 )
349 }
350
351 fn request_features(&self) -> impl Iterator<Item = &'static str> {
354 let limits =
355 self.body_limit_bytes.is_some() || self.timeout_secs.is_some() || self.multipart;
356 [
357 self.posture.is_some().then_some(features::ROUTE_POSTURE),
358 limits.then_some(features::ROUTE_LIMITS),
359 self.sse.is_some().then_some(features::SSE_BRIDGE),
360 self.posture.as_ref().and_then(RoutePosture::feature),
361 (!self.audit_redact.is_empty()).then_some(features::AUDIT_REDACTION),
362 ]
363 .into_iter()
364 .flatten()
365 }
366
367 pub fn uses_request_v2(&self) -> bool {
371 self.request_features()
372 .any(|feature| feature != features::ROUTE_POSTURE)
373 }
374}
375
376impl RoutePosture {
377 pub fn is_anonymous(&self) -> bool {
379 matches!(self, RoutePosture::Public { .. })
380 }
381
382 pub fn label(&self) -> &'static str {
384 match self {
385 RoutePosture::Public { .. } => "public",
386 RoutePosture::Authenticated => "authenticated",
387 RoutePosture::Permission { .. } => "permission",
388 RoutePosture::Owner { .. } => "owner",
389 RoutePosture::Internal { .. } => "internal",
390 RoutePosture::ApiKey { .. } => "api_key",
391 RoutePosture::Scoped { .. } => "scoped",
392 }
393 }
394
395 pub fn feature(&self) -> Option<&'static str> {
397 match self {
398 RoutePosture::Internal { .. } => Some(features::INTERNAL_POSTURE),
399 RoutePosture::ApiKey { .. } => Some(features::API_KEY_POSTURE),
400 RoutePosture::Scoped { .. } => Some(features::SCOPED_POSTURE),
401 RoutePosture::Public { .. }
402 | RoutePosture::Authenticated
403 | RoutePosture::Permission { .. }
404 | RoutePosture::Owner { .. } => None,
405 }
406 }
407
408 pub fn admits_caller(callers: &[String], service: &str) -> bool {
411 callers
412 .iter()
413 .any(|caller| caller == "*" || caller == service)
414 }
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
418pub struct JobConfig {
419 pub timeout_secs: u32,
421 pub max_attempts: u32,
424 #[serde(default, skip_serializing_if = "Vec::is_empty")]
426 pub retry_on: Vec<RetryOn>,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub backoff: Option<BackoffSpec>,
430}
431
432impl JobConfig {
433 pub const DEFAULT_TIMEOUT_SECS: u32 = 600;
434 pub const DEFAULT_MAX_ATTEMPTS: u32 = 3;
435
436 pub fn uses_v2(&self) -> bool {
439 !self.retry_on.is_empty() || self.backoff.is_some()
440 }
441}
442
443impl Default for JobConfig {
444 fn default() -> Self {
445 Self {
446 timeout_secs: Self::DEFAULT_TIMEOUT_SECS,
447 max_attempts: Self::DEFAULT_MAX_ATTEMPTS,
448 retry_on: Vec::new(),
449 backoff: None,
450 }
451 }
452}
453
454#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
456#[serde(rename_all = "snake_case")]
457pub enum RetryOn {
458 Error,
460 Timeout,
462}
463
464impl RetryOn {
465 pub fn as_str(self) -> &'static str {
466 match self {
467 RetryOn::Error => "error",
468 RetryOn::Timeout => "timeout",
469 }
470 }
471}
472
473#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
475pub struct BackoffSpec {
476 #[serde(default = "BackoffSpec::default_base_secs")]
477 pub base_secs: u32,
478 #[serde(default = "BackoffSpec::default_max_secs")]
479 pub max_secs: u32,
480}
481
482impl BackoffSpec {
483 pub const DEFAULT_BASE_SECS: u32 = 30;
484 pub const DEFAULT_MAX_SECS: u32 = 3600;
485 pub const SECS: std::ops::RangeInclusive<u32> = 1..=86_400;
487
488 fn default_base_secs() -> u32 {
489 Self::DEFAULT_BASE_SECS
490 }
491
492 fn default_max_secs() -> u32 {
493 Self::DEFAULT_MAX_SECS
494 }
495}
496
497impl Default for BackoffSpec {
498 fn default() -> Self {
499 Self {
500 base_secs: Self::DEFAULT_BASE_SECS,
501 max_secs: Self::DEFAULT_MAX_SECS,
502 }
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 #[test]
511 fn job_config_defaults() {
512 let cfg = JobConfig::default();
513 assert_eq!(cfg.timeout_secs, JobConfig::DEFAULT_TIMEOUT_SECS);
514 assert_eq!(cfg.max_attempts, JobConfig::DEFAULT_MAX_ATTEMPTS);
515 assert!(!cfg.uses_v2());
516 assert_eq!(
517 serde_json::to_value(&cfg).unwrap(),
518 serde_json::json!({"timeout_secs": 600, "max_attempts": 3})
519 );
520 }
521
522 #[test]
523 fn job_retry_fields_round_trip_and_demand_jobs_v2() {
524 let cfg = JobConfig {
525 retry_on: vec![RetryOn::Error, RetryOn::Timeout],
526 backoff: Some(BackoffSpec {
527 base_secs: 5,
528 max_secs: 600,
529 }),
530 ..Default::default()
531 };
532 let wire = serde_json::json!({
533 "timeout_secs": 600,
534 "max_attempts": 3,
535 "retry_on": ["error", "timeout"],
536 "backoff": {"base_secs": 5, "max_secs": 600}
537 });
538 assert_eq!(serde_json::to_value(&cfg).unwrap(), wire);
539 assert_eq!(serde_json::from_value::<JobConfig>(wire).unwrap(), cfg);
540 assert_eq!(
541 serde_json::from_value::<BackoffSpec>(serde_json::json!({})).unwrap(),
542 BackoffSpec::default()
543 );
544 let route = |job| CustomRouteDefinition {
545 job: Some(job),
546 ..Default::default()
547 };
548 assert_eq!(route(JobConfig::default()).derived_features().count(), 0);
549 assert!(!route(cfg.clone()).uses_request_v2());
550 assert_eq!(
551 route(cfg).derived_features().collect::<Vec<_>>(),
552 [features::JOBS_V2]
553 );
554 }
555
556 #[test]
557 fn custom_route_serde_round_trip_with_job() {
558 let route = CustomRouteDefinition {
559 method: "POST".into(),
560 path: "/run".into(),
561 handler: "do_thing".into(),
562 job: Some(JobConfig {
563 timeout_secs: 900,
564 max_attempts: 2,
565 ..Default::default()
566 }),
567 posture: None,
568 forward_auth: false,
569 ..Default::default()
570 };
571 let json = serde_json::to_string(&route).unwrap();
572 let round: CustomRouteDefinition = serde_json::from_str(&json).unwrap();
573 assert_eq!(round.method, "POST");
574 let job = round.job.unwrap();
575 assert_eq!(job.timeout_secs, 900);
576 assert_eq!(job.max_attempts, 2);
577 }
578
579 #[test]
580 fn forward_auth_defaults_off_and_is_only_serialized_when_set() {
581 let stored: CustomRouteDefinition =
582 serde_json::from_str(r#"{"method":"GET","path":"/x","handler":"x"}"#).unwrap();
583 assert!(!stored.forward_auth);
584 let json = serde_json::to_string(&stored).unwrap();
585 assert!(!json.contains("forward_auth"), "{json}");
586 let forwarding = CustomRouteDefinition {
587 forward_auth: true,
588 ..stored
589 };
590 let json = serde_json::to_value(&forwarding).unwrap();
591 assert_eq!(json["forward_auth"], true);
592 }
593
594 #[test]
595 fn custom_route_without_job_serializes_without_field() {
596 let route = CustomRouteDefinition {
597 method: "GET".into(),
598 path: "/x".into(),
599 handler: "x".into(),
600 job: None,
601 posture: None,
602 forward_auth: false,
603 ..Default::default()
604 };
605 let json = serde_json::to_value(&route).unwrap();
606 assert!(json.get("job").is_none(), "absent job should be omitted");
607 assert!(
608 json.get("posture").is_none(),
609 "absent posture should be omitted"
610 );
611 }
612
613 #[test]
614 fn custom_route_legacy_json_without_job_deserializes() {
615 let json = r#"{"method":"GET","path":"/x","handler":"x"}"#;
616 let route: CustomRouteDefinition = serde_json::from_str(json).unwrap();
617 assert!(route.job.is_none());
618 assert!(route.posture.is_none());
619 }
620
621 #[test]
622 fn sse_subjects_substitute_tenant_and_path_parameters() {
623 let sse = SseRouteDef {
624 subject: "dw.{tenant}.receipts.pdf.progress.{book_id}".into(),
625 reducer: Some("reduce".into()),
626 anonymous: false,
627 };
628 assert_eq!(
629 sse.placeholders().collect::<Vec<_>>(),
630 ["tenant", "book_id"]
631 );
632 let params = |value: &str| vec![("book_id".to_string(), value.to_string())];
633 assert_eq!(
634 sse.resolve("acme", ¶ms("L-7")),
635 Ok("dw.acme.receipts.pdf.progress.L-7".into())
636 );
637 for bad in ["a.b", "*", ">", "a b", ""] {
638 assert_eq!(
639 sse.resolve("acme", ¶ms(bad)),
640 Err(SseSubjectError::InvalidToken("book_id".into())),
641 "{bad}"
642 );
643 }
644 assert_eq!(
645 sse.resolve("acme", &[]),
646 Err(SseSubjectError::Missing("book_id".into()))
647 );
648 let wildcard = SseRouteDef {
649 subject: "dw.{tenant}.jobs.>".into(),
650 reducer: None,
651 anonymous: false,
652 };
653 assert_eq!(wildcard.resolve("acme", &[]), Ok("dw.acme.jobs.>".into()));
654 }
655
656 #[test]
657 fn sse_subjects_name_unreserved_events_and_bind_their_placeholders() {
658 let sse = |subject: &str| SseRouteDef {
659 subject: subject.into(),
660 reducer: None,
661 anonymous: false,
662 };
663 let params = ["id".to_string()];
664 for ok in [
665 "dw.{tenant}.jobs.>",
666 "dw.{tenant}.q.{id}",
667 "dw.{tenant}.q.*",
668 ] {
669 assert_eq!(sse(ok).subject_violation(Some(¶ms)), None, "{ok}");
670 }
671 for (subject, params, needle) in [
672 (
673 "dw.other.q",
674 Some(¶ms[..]),
675 "must be dot-separated tokens",
676 ),
677 (
678 "dw.{tenant}.",
679 Some(¶ms[..]),
680 "must be dot-separated tokens",
681 ),
682 (
683 "dw.{tenant}.q.{}",
684 Some(¶ms[..]),
685 "must be dot-separated tokens",
686 ),
687 ("dw.{tenant}.>", Some(¶ms[..]), "with a literal token"),
688 (
689 "dw.{tenant}.{id}.x",
690 Some(¶ms[..]),
691 "with a literal token",
692 ),
693 (
694 "dw.{tenant}.logs",
695 Some(¶ms[..]),
696 "the platform reserves",
697 ),
698 (
699 "dw.{tenant}.q.{other}",
700 Some(¶ms[..]),
701 "not a path parameter",
702 ),
703 ("dw.{tenant}.q.{id}", None, "needs routing: strict"),
704 ] {
705 let violation = sse(subject).subject_violation(params).unwrap_or_default();
706 assert!(violation.contains(needle), "{subject}: {violation}");
707 }
708 assert!(sse("dw.{tenant}.q").anonymous_violation(true).is_some());
709 assert!(sse("dw.{tenant}.q").anonymous_violation(false).is_none());
710 let opted_in = SseRouteDef {
711 anonymous: true,
712 ..sse("dw.{tenant}.q")
713 };
714 assert!(opted_in.anonymous_violation(true).is_none());
715 assert_eq!(
716 serde_json::to_value(&opted_in).unwrap(),
717 serde_json::json!({"subject": "dw.{tenant}.q", "anonymous": true})
718 );
719 }
720
721 #[test]
722 fn internal_routes_demand_their_feature_and_admit_listed_callers() {
723 let route = CustomRouteDefinition {
724 posture: Some(RoutePosture::Internal {
725 callers: vec!["pricing".into()],
726 }),
727 ..Default::default()
728 };
729 assert_eq!(
730 route.derived_features().collect::<Vec<_>>(),
731 [features::ROUTE_POSTURE, features::INTERNAL_POSTURE]
732 );
733 assert!(route.uses_request_v2());
734 assert_eq!(
735 serde_json::to_value(route.posture).unwrap(),
736 serde_json::json!({"kind": "internal", "callers": ["pricing"]})
737 );
738 let listed = ["pricing".to_string()];
739 assert!(RoutePosture::admits_caller(&listed, "pricing"));
740 assert!(!RoutePosture::admits_caller(&listed, "assets"));
741 assert!(RoutePosture::admits_caller(&["*".to_string()], "assets"));
742 assert!(!RoutePosture::admits_caller(&[], "assets"));
743 }
744
745 #[test]
746 fn api_key_routes_demand_their_feature() {
747 let route = CustomRouteDefinition {
748 posture: Some(RoutePosture::ApiKey {
749 scope: "books:ingest".into(),
750 }),
751 ..Default::default()
752 };
753 assert_eq!(
754 route.derived_features().collect::<Vec<_>>(),
755 [features::ROUTE_POSTURE, features::API_KEY_POSTURE]
756 );
757 assert_eq!(
758 serde_json::to_value(route.posture).unwrap(),
759 serde_json::json!({"kind": "api_key", "scope": "books:ingest"})
760 );
761 }
762
763 #[test]
764 fn scoped_routes_demand_their_feature() {
765 let route = CustomRouteDefinition {
766 posture: Some(RoutePosture::Scoped {
767 area: "assets".into(),
768 operation: "view".into(),
769 owner_field: None,
770 }),
771 ..Default::default()
772 };
773 assert_eq!(
774 route.derived_features().collect::<Vec<_>>(),
775 [features::ROUTE_POSTURE, features::SCOPED_POSTURE]
776 );
777 assert_eq!(
778 serde_json::to_value(route.posture).unwrap(),
779 serde_json::json!({"kind": "scoped", "area": "assets", "operation": "view"})
780 );
781 }
782
783 #[test]
784 fn route_posture_serde_tagged_round_trip() {
785 let cases = [
786 RoutePosture::Public {
787 reason: "stripe webhook".into(),
788 },
789 RoutePosture::Authenticated,
790 RoutePosture::Permission {
791 area: "items".into(),
792 operation: "edit".into(),
793 },
794 RoutePosture::Owner {
795 field: "customer_id".into(),
796 },
797 RoutePosture::Internal {
798 callers: vec!["pricing-service".into()],
799 },
800 RoutePosture::ApiKey {
801 scope: "books:ingest".into(),
802 },
803 RoutePosture::Scoped {
804 area: "assets".into(),
805 operation: "view".into(),
806 owner_field: Some("customer_id".into()),
807 },
808 RoutePosture::Scoped {
809 area: "assets".into(),
810 operation: "view".into(),
811 owner_field: None,
812 },
813 ];
814 for p in cases {
815 let json = serde_json::to_string(&p).unwrap();
816 let round: RoutePosture = serde_json::from_str(&json).unwrap();
817 assert_eq!(round, p);
818 }
819 let public = serde_json::to_value(RoutePosture::Authenticated).unwrap();
820 assert_eq!(public["kind"], "authenticated");
821 }
822}