Skip to main content

rest/
route.rs

1use crate::auth::{bearer_token, decode_hs256, JwtClaims, ProjectedClaims};
2use actix_web::{
3    body::{BoxBody, MessageBody},
4    dev::{Service, ServiceRequest, ServiceResponse, Transform},
5    http::{header, Method, StatusCode},
6    Error, HttpMessage, HttpResponse,
7};
8use futures::future::{ok, LocalBoxFuture, Ready};
9use rust_zero_core::{AuthFailure, JwtClaimProjection};
10use serde::{Deserialize, Serialize};
11use std::{
12    collections::{HashMap, HashSet},
13    fmt,
14    future::Future,
15    rc::Rc,
16    sync::Arc,
17    task::{Context, Poll},
18    time::Duration,
19};
20
21/// HS256 authentication inherited by every route in a declarative group unless a route is public.
22#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct RouteJwtConfig {
24    pub secret: String,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub previous_secret: Option<String>,
27    #[serde(default)]
28    pub leeway_seconds: u64,
29    #[serde(default)]
30    pub claim_projection: JwtClaimProjection,
31}
32
33impl fmt::Debug for RouteJwtConfig {
34    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
35        formatter
36            .debug_struct("RouteJwtConfig")
37            .field("secret", &"[REDACTED]")
38            .field(
39                "previous_secret",
40                &self.previous_secret.as_ref().map(|_| "[REDACTED]"),
41            )
42            .field("leeway_seconds", &self.leeway_seconds)
43            .field("claim_projection", &self.claim_projection)
44            .finish()
45    }
46}
47
48/// Policy overrides for one method and route pattern.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct RoutePolicyConfig {
51    pub method: String,
52    pub path: String,
53    #[serde(default)]
54    pub public: bool,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub jwt: Option<RouteJwtConfig>,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub timeout_ms: Option<u64>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub max_body_bytes: Option<usize>,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub priority: Option<bool>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub sse: Option<bool>,
65}
66
67/// A route group with a shared prefix and inheritable policy defaults.
68#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
69pub struct RouteGroupConfig {
70    #[serde(default)]
71    pub prefix: String,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub jwt: Option<RouteJwtConfig>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub timeout_ms: Option<u64>,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub max_body_bytes: Option<usize>,
78    #[serde(default)]
79    pub priority: bool,
80    #[serde(default)]
81    pub sse: bool,
82    /// Named application middleware, applied in declaration order around every route in the group.
83    #[serde(default)]
84    pub middleware: Vec<String>,
85    #[serde(default)]
86    pub routes: Vec<RoutePolicyConfig>,
87}
88
89/// The boxed future returned by application-defined route middleware.
90pub type RouteMiddlewareFuture = LocalBoxFuture<'static, Result<ServiceResponse<BoxBody>, Error>>;
91
92/// The remainder of a declarative route's middleware and handler chain.
93#[derive(Clone)]
94pub struct RouteMiddlewareNext {
95    inner: Rc<dyn Fn(ServiceRequest) -> RouteMiddlewareFuture>,
96}
97
98impl RouteMiddlewareNext {
99    pub fn call(&self, request: ServiceRequest) -> RouteMiddlewareFuture {
100        (self.inner)(request)
101    }
102}
103
104/// Type-erased application middleware that can be registered by name on [`crate::RestServer`].
105pub trait RouteMiddleware: Send + Sync + 'static {
106    fn call(&self, request: ServiceRequest, next: RouteMiddlewareNext) -> RouteMiddlewareFuture;
107}
108
109impl<F, Fut> RouteMiddleware for F
110where
111    F: Fn(ServiceRequest, RouteMiddlewareNext) -> Fut + Send + Sync + 'static,
112    Fut: Future<Output = Result<ServiceResponse<BoxBody>, Error>> + 'static,
113{
114    fn call(&self, request: ServiceRequest, next: RouteMiddlewareNext) -> RouteMiddlewareFuture {
115        Box::pin((self)(request, next))
116    }
117}
118
119#[derive(Debug, Clone)]
120struct CompiledPolicy {
121    method: Method,
122    pattern: String,
123    jwt_secrets: Option<Vec<Arc<[u8]>>>,
124    jwt_leeway_seconds: u64,
125    jwt_claim_projection: JwtClaimProjection,
126    timeout: Option<Duration>,
127    max_body_bytes: Option<usize>,
128    priority: bool,
129    sse: bool,
130    middleware: Arc<[String]>,
131}
132
133/// Effective route settings communicated to the standard transport middleware.
134#[derive(Debug, Clone, Default)]
135pub(crate) struct RequestPolicy {
136    pub timeout: Option<Duration>,
137    pub max_body_bytes: Option<usize>,
138    pub priority: bool,
139    pub sse: bool,
140}
141
142/// Applies compiled declarative route policies before the standard server middleware stack.
143#[derive(Clone, Default)]
144pub(crate) struct RoutePolicies {
145    routes: Arc<[CompiledPolicy]>,
146    middleware: Arc<HashMap<String, Arc<dyn RouteMiddleware>>>,
147}
148
149impl fmt::Debug for RoutePolicies {
150    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
151        formatter
152            .debug_struct("RoutePolicies")
153            .field("routes", &self.routes)
154            .field("middleware_names", &self.middleware.keys())
155            .finish()
156    }
157}
158
159impl RoutePolicies {
160    pub fn compile(groups: &[RouteGroupConfig]) -> Result<Self, String> {
161        let mut routes = Vec::new();
162        let mut unique = HashSet::new();
163
164        for group in groups {
165            validate_prefix(&group.prefix)?;
166            validate_optional_limits(group.timeout_ms, group.max_body_bytes)?;
167            validate_middleware_names(&group.middleware)?;
168            if let Some(jwt) = &group.jwt {
169                validate_jwt(jwt)?;
170            }
171
172            for route in &group.routes {
173                if !route.path.starts_with('/') {
174                    return Err(format!("route path must start with '/': {}", route.path));
175                }
176                validate_optional_limits(route.timeout_ms, route.max_body_bytes)?;
177                if let Some(jwt) = &route.jwt {
178                    validate_jwt(jwt)?;
179                }
180                if route.public && route.jwt.is_some() {
181                    return Err(format!(
182                        "route {} cannot be public and define JWT authentication",
183                        route.path
184                    ));
185                }
186
187                let method = Method::from_bytes(route.method.as_bytes())
188                    .map_err(|_| format!("invalid route method: {}", route.method))?;
189                let pattern = join_route(&group.prefix, &route.path);
190                if !unique.insert((method.clone(), pattern.clone())) {
191                    return Err(format!("duplicate route policy: {method} {pattern}"));
192                }
193
194                let jwt = if route.public {
195                    None
196                } else {
197                    route.jwt.as_ref().or(group.jwt.as_ref())
198                };
199                let (jwt_secrets, jwt_leeway_seconds, jwt_claim_projection) = match jwt {
200                    Some(jwt) => (
201                        Some(jwt_secrets(jwt)),
202                        jwt.leeway_seconds,
203                        jwt.claim_projection.clone(),
204                    ),
205                    None => (None, 0, JwtClaimProjection::default()),
206                };
207                let sse = route.sse.unwrap_or(group.sse);
208                routes.push(CompiledPolicy {
209                    method,
210                    pattern,
211                    jwt_secrets,
212                    jwt_leeway_seconds,
213                    jwt_claim_projection,
214                    timeout: if sse {
215                        None
216                    } else {
217                        route
218                            .timeout_ms
219                            .or(group.timeout_ms)
220                            .map(Duration::from_millis)
221                    },
222                    max_body_bytes: route.max_body_bytes.or(group.max_body_bytes),
223                    priority: route.priority.unwrap_or(group.priority),
224                    sse,
225                    middleware: group.middleware.clone().into(),
226                });
227            }
228        }
229
230        Ok(Self {
231            routes: routes.into(),
232            middleware: Arc::new(HashMap::new()),
233        })
234    }
235
236    pub fn with_middleware(
237        mut self,
238        middleware: HashMap<String, Arc<dyn RouteMiddleware>>,
239    ) -> Result<Self, String> {
240        for route in self.routes.iter() {
241            for name in route.middleware.iter() {
242                if !middleware.contains_key(name) {
243                    return Err(format!("route middleware '{name}' is not registered"));
244                }
245            }
246        }
247        self.middleware = Arc::new(middleware);
248        Ok(self)
249    }
250
251    fn find(&self, method: &Method, pattern: &str) -> Option<&CompiledPolicy> {
252        self.routes
253            .iter()
254            .find(|route| route.method == *method && route.pattern == pattern)
255    }
256}
257
258fn validate_middleware_names(names: &[String]) -> Result<(), String> {
259    let mut unique = HashSet::new();
260    for name in names {
261        if name.trim().is_empty() {
262            return Err("route middleware name must not be empty".to_owned());
263        }
264        if !unique.insert(name) {
265            return Err(format!("duplicate route middleware name: {name}"));
266        }
267    }
268    Ok(())
269}
270
271fn validate_prefix(prefix: &str) -> Result<(), String> {
272    if !prefix.is_empty() && !prefix.starts_with('/') {
273        return Err(format!("route group prefix must start with '/': {prefix}"));
274    }
275    Ok(())
276}
277
278fn validate_optional_limits(
279    timeout_ms: Option<u64>,
280    max_body_bytes: Option<usize>,
281) -> Result<(), String> {
282    if timeout_ms == Some(0) {
283        return Err("route timeout_ms must be greater than zero".to_owned());
284    }
285    if max_body_bytes == Some(0) {
286        return Err("route max_body_bytes must be greater than zero".to_owned());
287    }
288    Ok(())
289}
290
291fn validate_jwt(jwt: &RouteJwtConfig) -> Result<(), String> {
292    if jwt.secret.is_empty() {
293        return Err("route JWT secret must not be empty".to_owned());
294    }
295    if jwt.previous_secret.as_deref() == Some("") {
296        return Err("route previous JWT secret must not be empty".to_owned());
297    }
298    Ok(())
299}
300
301fn jwt_secrets(jwt: &RouteJwtConfig) -> Vec<Arc<[u8]>> {
302    let mut secrets = vec![Arc::from(jwt.secret.as_bytes())];
303    if let Some(previous) = &jwt.previous_secret {
304        secrets.push(Arc::from(previous.as_bytes()));
305    }
306    secrets
307}
308
309fn join_route(prefix: &str, path: &str) -> String {
310    if prefix.is_empty() || prefix == "/" {
311        return path.to_owned();
312    }
313    format!("{}{}", prefix.trim_end_matches('/'), path)
314}
315
316impl<S, B> Transform<S, ServiceRequest> for RoutePolicies
317where
318    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
319    S::Future: 'static,
320    B: MessageBody + 'static,
321{
322    type Response = ServiceResponse<BoxBody>;
323    type Error = Error;
324    type Transform = RoutePoliciesMiddleware<S>;
325    type InitError = ();
326    type Future = Ready<Result<Self::Transform, Self::InitError>>;
327
328    fn new_transform(&self, service: S) -> Self::Future {
329        ok(RoutePoliciesMiddleware {
330            service: Rc::new(service),
331            policies: self.clone(),
332        })
333    }
334}
335
336pub(crate) struct RoutePoliciesMiddleware<S> {
337    service: Rc<S>,
338    policies: RoutePolicies,
339}
340
341impl<S, B> Service<ServiceRequest> for RoutePoliciesMiddleware<S>
342where
343    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
344    S::Future: 'static,
345    B: MessageBody + 'static,
346{
347    type Response = ServiceResponse<BoxBody>;
348    type Error = Error;
349    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
350
351    fn poll_ready(&self, context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
352        self.service.poll_ready(context)
353    }
354
355    fn call(&self, request: ServiceRequest) -> Self::Future {
356        let pattern = request
357            .match_pattern()
358            .unwrap_or_else(|| request.path().to_owned());
359        let policy = self.policies.find(request.method(), &pattern).cloned();
360
361        if let Some(policy) = &policy {
362            if let Some(secrets) = &policy.jwt_secrets {
363                let result = request
364                    .headers()
365                    .get(header::AUTHORIZATION)
366                    .and_then(|value| value.to_str().ok())
367                    .and_then(bearer_token)
368                    .ok_or(AuthFailure::MissingCredentials)
369                    .and_then(|token| {
370                        decode_hs256::<serde_json::Value>(token, secrets, policy.jwt_leeway_seconds)
371                            .map_err(AuthFailure::from)
372                    });
373                let claims = match result {
374                    Ok(claims) => claims,
375                    Err(failure) => {
376                        return Box::pin(async move {
377                            Ok(request.into_response(
378                                HttpResponse::build(StatusCode::UNAUTHORIZED)
379                                    .insert_header((header::WWW_AUTHENTICATE, "Bearer"))
380                                    .json(serde_json::json!({
381                                        "code": failure.code(),
382                                        "message": failure.message(),
383                                    }))
384                                    .map_into_boxed_body(),
385                            ))
386                        });
387                    }
388                };
389                request.extensions_mut().insert(ProjectedClaims(
390                    policy.jwt_claim_projection.project(&claims),
391                ));
392                request.extensions_mut().insert(JwtClaims(claims));
393            }
394
395            request.extensions_mut().insert(RequestPolicy {
396                timeout: policy.timeout,
397                max_body_bytes: policy.max_body_bytes,
398                priority: policy.priority,
399                sse: policy.sse,
400            });
401        }
402
403        let service = Rc::clone(&self.service);
404        let middleware = policy
405            .as_ref()
406            .map(|policy| policy.middleware.clone())
407            .unwrap_or_default();
408        let registry = Arc::clone(&self.policies.middleware);
409        Box::pin(async move {
410            let terminal = RouteMiddlewareNext {
411                inner: Rc::new(move |request| {
412                    let service = Rc::clone(&service);
413                    Box::pin(async move { Ok(service.call(request).await?.map_into_boxed_body()) })
414                }),
415            };
416            let chain = middleware.iter().rev().fold(terminal, |next, name| {
417                let route_middleware = Arc::clone(
418                    registry
419                        .get(name)
420                        .expect("route middleware registry was validated"),
421                );
422                RouteMiddlewareNext {
423                    inner: Rc::new(move |request| route_middleware.call(request, next.clone())),
424                }
425            });
426            let mut response = chain.call(request).await?;
427            if policy.is_some_and(|policy| policy.sse) {
428                response.headers_mut().insert(
429                    header::CONTENT_TYPE,
430                    header::HeaderValue::from_static("text/event-stream"),
431                );
432                response.headers_mut().insert(
433                    header::CACHE_CONTROL,
434                    header::HeaderValue::from_static("no-cache, no-transform"),
435                );
436                response.headers_mut().insert(
437                    header::HeaderName::from_static("x-accel-buffering"),
438                    header::HeaderValue::from_static("no"),
439                );
440            }
441            Ok(response)
442        })
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use crate::{encode_hs256, ConcurrencyLimit, JwtAuth, RequestBodyLimit, Timeout};
450    use actix_web::{
451        http::{header::HeaderValue, StatusCode},
452        test, web, App, HttpRequest, HttpResponse,
453    };
454    use std::sync::Arc;
455    use tokio::sync::Notify;
456
457    fn policy_set() -> RoutePolicies {
458        RoutePolicies::compile(&[RouteGroupConfig {
459            prefix: "/api".to_owned(),
460            jwt: Some(RouteJwtConfig {
461                secret: "secret".to_owned(),
462                previous_secret: None,
463                leeway_seconds: 0,
464                claim_projection: JwtClaimProjection::new([(
465                    "caller".to_owned(),
466                    "sub".to_owned(),
467                )]),
468            }),
469            timeout_ms: Some(50),
470            max_body_bytes: Some(16),
471            routes: vec![
472                RoutePolicyConfig {
473                    method: "GET".to_owned(),
474                    path: "/private/{id}".to_owned(),
475                    public: false,
476                    jwt: None,
477                    timeout_ms: None,
478                    max_body_bytes: None,
479                    priority: None,
480                    sse: None,
481                },
482                RoutePolicyConfig {
483                    method: "GET".to_owned(),
484                    path: "/slow".to_owned(),
485                    public: true,
486                    jwt: None,
487                    timeout_ms: Some(1),
488                    max_body_bytes: None,
489                    priority: None,
490                    sse: None,
491                },
492                RoutePolicyConfig {
493                    method: "POST".to_owned(),
494                    path: "/body".to_owned(),
495                    public: true,
496                    jwt: None,
497                    timeout_ms: None,
498                    max_body_bytes: Some(2),
499                    priority: None,
500                    sse: None,
501                },
502                RoutePolicyConfig {
503                    method: "GET".to_owned(),
504                    path: "/events".to_owned(),
505                    public: true,
506                    jwt: None,
507                    timeout_ms: Some(1),
508                    max_body_bytes: None,
509                    priority: None,
510                    sse: Some(true),
511                },
512            ],
513            ..RouteGroupConfig::default()
514        }])
515        .unwrap()
516    }
517
518    #[actix_web::test]
519    async fn enforces_inherited_and_per_route_policies() {
520        let app = test::init_service(
521            App::new()
522                .wrap(Timeout::new(Duration::from_secs(1)))
523                .wrap(RequestBodyLimit::new(1_024))
524                .wrap(policy_set())
525                .route(
526                    "/api/private/{id}",
527                    web::get().to(|request: HttpRequest| async move {
528                        HttpResponse::Ok().json(serde_json::json!({
529                            "claims": JwtAuth::<serde_json::Value>::claims(&request),
530                            "projected": JwtAuth::<serde_json::Value>::projected_claims(&request),
531                        }))
532                    }),
533                )
534                .route(
535                    "/api/slow",
536                    web::get().to(|| async {
537                        actix_rt::time::sleep(Duration::from_millis(20)).await;
538                        HttpResponse::Ok().finish()
539                    }),
540                )
541                .route(
542                    "/api/body",
543                    web::post().to(|body: web::Bytes| async move { HttpResponse::Ok().body(body) }),
544                )
545                .route(
546                    "/api/events",
547                    web::get().to(|| async {
548                        actix_rt::time::sleep(Duration::from_millis(10)).await;
549                        HttpResponse::Ok().body("data: ready\n\n")
550                    }),
551                ),
552        )
553        .await;
554
555        let unauthorized = test::call_service(
556            &app,
557            test::TestRequest::get().uri("/api/private/7").to_request(),
558        )
559        .await;
560        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
561
562        let token = encode_hs256(&serde_json::json!({ "sub": "42" }), b"secret").unwrap();
563        let authorized = test::call_service(
564            &app,
565            test::TestRequest::get()
566                .uri("/api/private/7")
567                .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
568                .to_request(),
569        )
570        .await;
571        assert_eq!(authorized.status(), StatusCode::OK);
572        let claims: serde_json::Value = test::read_body_json(authorized).await;
573        assert_eq!(claims["claims"]["sub"], "42");
574        assert_eq!(claims["projected"]["caller"], "42");
575
576        let slow =
577            test::try_call_service(&app, test::TestRequest::get().uri("/api/slow").to_request())
578                .await
579                .expect_err("route timeout should reject slow work");
580        assert_eq!(
581            actix_web::error::ResponseError::status_code(slow.as_response_error()),
582            StatusCode::GATEWAY_TIMEOUT
583        );
584
585        let oversized = test::call_service(
586            &app,
587            test::TestRequest::post()
588                .uri("/api/body")
589                .set_payload("abc")
590                .to_request(),
591        )
592        .await;
593        assert_eq!(oversized.status(), StatusCode::PAYLOAD_TOO_LARGE);
594
595        let events = test::call_service(
596            &app,
597            test::TestRequest::get().uri("/api/events").to_request(),
598        )
599        .await;
600        assert_eq!(events.status(), StatusCode::OK);
601        assert_eq!(
602            events.headers().get(header::CONTENT_TYPE).unwrap(),
603            "text/event-stream"
604        );
605        assert_eq!(
606            events.headers().get(header::CACHE_CONTROL).unwrap(),
607            "no-cache, no-transform"
608        );
609    }
610
611    #[actix_web::test]
612    async fn priority_routes_use_reserved_capacity() {
613        let policies = RoutePolicies::compile(&[RouteGroupConfig {
614            routes: vec![RoutePolicyConfig {
615                method: "GET".to_owned(),
616                path: "/priority".to_owned(),
617                public: true,
618                jwt: None,
619                timeout_ms: None,
620                max_body_bytes: None,
621                priority: Some(true),
622                sse: None,
623            }],
624            ..RouteGroupConfig::default()
625        }])
626        .unwrap();
627        let started = Arc::new(Notify::new());
628        let release = Arc::new(Notify::new());
629        let app = test::init_service(
630            App::new()
631                .wrap(ConcurrencyLimit::new(1).with_priority_reserve(1))
632                .wrap(policies)
633                .route(
634                    "/normal",
635                    web::get().to({
636                        let started = Arc::clone(&started);
637                        let release = Arc::clone(&release);
638                        move || {
639                            let started = Arc::clone(&started);
640                            let release = Arc::clone(&release);
641                            async move {
642                                started.notify_one();
643                                release.notified().await;
644                                HttpResponse::Ok().finish()
645                            }
646                        }
647                    }),
648                )
649                .route("/priority", web::get().to(HttpResponse::Ok)),
650        )
651        .await;
652
653        let normal = test::call_service(&app, test::TestRequest::get().uri("/normal").to_request());
654        let priority = async {
655            started.notified().await;
656            let response =
657                test::call_service(&app, test::TestRequest::get().uri("/priority").to_request())
658                    .await;
659            release.notify_one();
660            response
661        };
662        let (normal, priority) = futures::future::join(normal, priority).await;
663        assert_eq!(normal.status(), StatusCode::OK);
664        assert_eq!(priority.status(), StatusCode::OK);
665    }
666
667    #[actix_web::test]
668    async fn named_group_middleware_wraps_only_declared_routes() {
669        let policies = RoutePolicies::compile(&[RouteGroupConfig {
670            prefix: "/api".to_owned(),
671            middleware: vec!["api-key".to_owned(), "response-header".to_owned()],
672            routes: vec![RoutePolicyConfig {
673                method: "GET".to_owned(),
674                path: "/private".to_owned(),
675                public: true,
676                jwt: None,
677                timeout_ms: None,
678                max_body_bytes: None,
679                priority: None,
680                sse: None,
681            }],
682            ..RouteGroupConfig::default()
683        }])
684        .unwrap();
685        let mut middleware: HashMap<String, Arc<dyn RouteMiddleware>> = HashMap::new();
686        middleware.insert(
687            "api-key".to_owned(),
688            Arc::new(
689                |request: ServiceRequest, next: RouteMiddlewareNext| async move {
690                    if request
691                        .headers()
692                        .get("x-api-key")
693                        .and_then(|value| value.to_str().ok())
694                        != Some("valid")
695                    {
696                        return Ok(request
697                            .into_response(HttpResponse::Forbidden().finish())
698                            .map_into_boxed_body());
699                    }
700                    next.call(request).await
701                },
702            ),
703        );
704        middleware.insert(
705            "response-header".to_owned(),
706            Arc::new(
707                |request: ServiceRequest, next: RouteMiddlewareNext| async move {
708                    let mut response = next.call(request).await?;
709                    response.headers_mut().insert(
710                        "x-application-middleware".parse().unwrap(),
711                        HeaderValue::from_static("yes"),
712                    );
713                    Ok(response)
714                },
715            ),
716        );
717        let app = test::init_service(
718            App::new()
719                .wrap(policies.with_middleware(middleware).unwrap())
720                .route("/api/private", web::get().to(HttpResponse::Ok))
721                .route("/public", web::get().to(HttpResponse::Ok)),
722        )
723        .await;
724
725        let forbidden = test::call_service(
726            &app,
727            test::TestRequest::get().uri("/api/private").to_request(),
728        )
729        .await;
730        assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
731        assert!(!forbidden.headers().contains_key("x-application-middleware"));
732
733        let accepted = test::call_service(
734            &app,
735            test::TestRequest::get()
736                .uri("/api/private")
737                .insert_header(("x-api-key", "valid"))
738                .to_request(),
739        )
740        .await;
741        assert_eq!(accepted.status(), StatusCode::OK);
742        assert_eq!(
743            accepted.headers().get("x-application-middleware").unwrap(),
744            "yes"
745        );
746
747        let public =
748            test::call_service(&app, test::TestRequest::get().uri("/public").to_request()).await;
749        assert_eq!(public.status(), StatusCode::OK);
750        assert!(!public.headers().contains_key("x-application-middleware"));
751    }
752
753    #[actix_web::test]
754    async fn rejects_ambiguous_or_invalid_route_policies() {
755        let duplicate = RoutePolicyConfig {
756            method: "GET".to_owned(),
757            path: "/users/{id}".to_owned(),
758            public: true,
759            jwt: None,
760            timeout_ms: None,
761            max_body_bytes: None,
762            priority: None,
763            sse: None,
764        };
765        let error = RoutePolicies::compile(&[RouteGroupConfig {
766            routes: vec![duplicate.clone(), duplicate],
767            ..RouteGroupConfig::default()
768        }])
769        .unwrap_err();
770        assert!(error.contains("duplicate"));
771
772        let missing = RoutePolicies::compile(&[RouteGroupConfig {
773            middleware: vec!["missing".to_owned()],
774            routes: vec![RoutePolicyConfig {
775                method: "GET".to_owned(),
776                path: "/route".to_owned(),
777                public: true,
778                jwt: None,
779                timeout_ms: None,
780                max_body_bytes: None,
781                priority: None,
782                sse: None,
783            }],
784            ..RouteGroupConfig::default()
785        }])
786        .unwrap()
787        .with_middleware(HashMap::new())
788        .unwrap_err();
789        assert!(missing.contains("not registered"));
790    }
791}