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