Skip to main content

etag_actix_middleware/
lib.rs

1//! Actix middleware that computes strong ETags for responses and enforces
2//! conditional request semantics for `If-Match` and `If-None-Match` headers.
3//!
4//! Wrap your Actix `App` with [`ETag`] to automatically add ETag headers to
5//! successful responses and to short-circuit requests when the client's cached
6//! representation is still current. The middleware emits strong ETags by
7//! default; call [`ETag::weak`] when you need weak validators instead.
8//!
9//! # Examples
10//!
11//! ```rust
12//! use actix_web::{web, App, HttpResponse, test, dev::Service};
13//! use etag_actix_middleware::ETag;
14//!
15//! # actix_web::rt::System::new().block_on(async move {
16//! let mut app = test::init_service(
17//!     App::new()
18//!         .wrap(ETag::strong())
19//!         .route("/", web::get().to(|| async { HttpResponse::Ok().body("hello") }))
20//! ).await;
21//!
22//! let response = test::call_service(&mut app, test::TestRequest::get().uri("/").to_request()).await;
23//! assert_eq!(response.status(), actix_web::http::StatusCode::OK);
24//! assert!(response.headers().contains_key(actix_web::http::header::ETAG));
25//! # });
26//! ```
27//!
28//! ```rust
29//! use actix_web::{web, App, HttpResponse, test, dev::Service};
30//! use etag_actix_middleware::ETag;
31//!
32//! # actix_web::rt::System::new().block_on(async move {
33//! let mut app = test::init_service(
34//!     App::new()
35//!         .wrap(ETag::weak())
36//!         .route("/", web::get().to(|| async { HttpResponse::Ok().body("hello") }))
37//! ).await;
38//!
39//! // First response provides the current weak ETag.
40//! let initial = test::call_service(&mut app, test::TestRequest::get().uri("/").to_request()).await;
41//! let etag = initial.headers().get(actix_web::http::header::ETAG).unwrap().clone();
42//!
43//! // Revalidation request with If-None-Match short-circuits to 304 Not Modified.
44//! let request = test::TestRequest::get()
45//!     .uri("/")
46//!     .insert_header((actix_web::http::header::IF_NONE_MATCH, etag))
47//!     .to_request();
48//! let response = test::call_service(&mut app, request).await;
49//! assert_eq!(response.status(), actix_web::http::StatusCode::NOT_MODIFIED);
50//! # });
51//! ```
52
53use actix_web::{
54    Error, HttpResponse,
55    body::{BoxBody, MessageBody, to_bytes},
56    dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready},
57    http::{Method, StatusCode, header},
58    web::Bytes,
59};
60use futures_util::future::{LocalBoxFuture, Ready, ok};
61use std::rc::Rc;
62
63use base64::Engine;
64use xxhash_rust::xxh3::xxh3_128;
65
66/// Middleware that injects ETag headers and evaluates conditional requests.
67///
68/// Use [`ETag::strong`] (default) or [`ETag::weak`] depending on whether your
69/// handlers should produce strong or weak validators.
70#[derive(Clone, Copy)]
71pub struct ETag {
72    strength: Strength,
73}
74
75#[derive(Clone, Copy)]
76enum Strength {
77    Strong,
78    Weak,
79}
80
81impl ETag {
82    /// Constructs middleware using the default strong ETag strategy.
83    pub const fn new() -> Self {
84        Self::strong()
85    }
86
87    /// Constructs middleware that emits strong ETags (the default behaviour).
88    pub const fn strong() -> Self {
89        Self {
90            strength: Strength::Strong,
91        }
92    }
93
94    /// Constructs middleware that emits weak ETags while still honouring
95    /// conditional request handling.
96    pub const fn weak() -> Self {
97        Self {
98            strength: Strength::Weak,
99        }
100    }
101}
102
103impl Default for ETag {
104    fn default() -> Self {
105        Self::strong()
106    }
107}
108
109impl<S, B> Transform<S, ServiceRequest> for ETag
110where
111    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
112    B: MessageBody + 'static,
113    B::Error: Into<Error>,
114{
115    type Response = ServiceResponse<BoxBody>;
116    type Error = Error;
117    type InitError = ();
118    type Transform = ETagMiddleware<S>;
119    type Future = Ready<Result<Self::Transform, Self::InitError>>;
120
121    fn new_transform(&self, service: S) -> Self::Future {
122        ok(ETagMiddleware {
123            service: Rc::new(service),
124            strength: self.strength,
125        })
126    }
127}
128
129/// Internal service wrapper that materializes response bodies before hashing.
130pub struct ETagMiddleware<S> {
131    service: Rc<S>,
132    strength: Strength,
133}
134
135impl<S, B> Service<ServiceRequest> for ETagMiddleware<S>
136where
137    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
138    B: MessageBody + 'static,
139    B::Error: Into<Error>,
140{
141    type Response = ServiceResponse<BoxBody>;
142    type Error = Error;
143    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
144
145    forward_ready!(service);
146
147    fn call(&self, req: ServiceRequest) -> Self::Future {
148        let srv = Rc::clone(&self.service);
149        let strength = self.strength;
150
151        Box::pin(async move {
152            let res = srv.call(req).await?;
153            let (req, res) = res.into_parts();
154            let (mut head, body) = res.into_parts();
155            let body_bytes = to_bytes(body).await.map_err(Into::into)?;
156
157            let etag_value = extract_or_compute_etag(&mut head, &body_bytes, strength);
158
159            if let Some(precondition) = evaluate_conditionals(&req, &etag_value) {
160                return Ok(ServiceResponse::new(req, precondition));
161            }
162
163            let response = head.set_body(body_bytes).map_body(|_, body| body.boxed());
164
165            Ok(ServiceResponse::new(req, response))
166        })
167    }
168}
169
170fn extract_or_compute_etag(
171    head: &mut HttpResponse<()>,
172    body: &Bytes,
173    strength: Strength,
174) -> String {
175    if let Some(value) = head
176        .headers()
177        .get(header::ETAG)
178        .and_then(|value| value.to_str().ok())
179    {
180        return value.trim().to_string();
181    }
182
183    let value = build_entity_tag(body, strength);
184
185    if let Ok(header_value) = header::HeaderValue::from_str(&value) {
186        head.headers_mut().insert(header::ETAG, header_value);
187    }
188
189    value
190}
191
192/// Applies `If-Match`/`If-None-Match` rules and returns a short-circuit response when
193/// the request preconditions resolve without reaching the wrapped service.
194fn evaluate_conditionals(req: &actix_web::HttpRequest, etag: &str) -> Option<HttpResponse> {
195    if let Some(if_match) = req
196        .headers()
197        .get(header::IF_MATCH)
198        .and_then(|h| h.to_str().ok())
199    {
200        if !match_if_match(etag, if_match) {
201            return Some(
202                HttpResponse::build(StatusCode::PRECONDITION_FAILED)
203                    .insert_header((header::ETAG, etag.to_string()))
204                    .finish(),
205            );
206        }
207    }
208
209    if let Some(if_none_match) = req
210        .headers()
211        .get(header::IF_NONE_MATCH)
212        .and_then(|h| h.to_str().ok())
213    {
214        if match_if_none_match(etag, if_none_match) {
215            let status = match *req.method() {
216                Method::GET | Method::HEAD => StatusCode::NOT_MODIFIED,
217                _ => StatusCode::PRECONDITION_FAILED,
218            };
219
220            return Some(
221                HttpResponse::build(status)
222                    .insert_header((header::ETAG, etag.to_string()))
223                    .finish(),
224            );
225        }
226    }
227
228    None
229}
230
231fn match_if_match(etag: &str, header_value: &str) -> bool {
232    header_value
233        .split(',')
234        .map(|value| value.trim())
235        .any(|value| value == "*" || strong_compare(value, etag))
236}
237
238fn match_if_none_match(etag: &str, header_value: &str) -> bool {
239    let etag_core = strip_weak_prefix(etag);
240
241    header_value
242        .split(',')
243        .map(|value| value.trim())
244        .any(|value| {
245            if value == "*" {
246                return true;
247            }
248
249            strip_weak_prefix(value) == etag_core
250        })
251}
252
253fn build_entity_tag(body: &Bytes, strength: Strength) -> String {
254    let response_hash = xxh3_128(body);
255    let base64 = base64::prelude::BASE64_URL_SAFE.encode(response_hash.to_le_bytes());
256
257    match strength {
258        Strength::Strong => format!("\"{:x}-{}\"", base64.len(), base64),
259        Strength::Weak => format!("W/\"{:x}-{}\"", base64.len(), base64),
260    }
261}
262
263fn strong_compare(left: &str, right: &str) -> bool {
264    !is_weak(left) && !is_weak(right) && left == right
265}
266
267fn strip_weak_prefix(value: &str) -> &str {
268    value.strip_prefix("W/").unwrap_or(value)
269}
270
271fn is_weak(value: &str) -> bool {
272    value.starts_with("W/")
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use actix_web::{
279        App, HttpResponse,
280        dev::ServiceResponse,
281        http::header,
282        test::{TestRequest, call_service, init_service},
283        web,
284    };
285
286    fn expected_etag(payload: &[u8], strength: Strength) -> String {
287        let bytes = Bytes::copy_from_slice(payload);
288        build_entity_tag(&bytes, strength)
289    }
290
291    #[actix_web::test]
292    async fn sets_etag_header_when_missing() {
293        let app = init_service(App::new().wrap(ETag::strong()).route(
294            "/",
295            web::get().to(|| async { HttpResponse::Ok().body("hello") }),
296        ))
297        .await;
298
299        let response: ServiceResponse =
300            call_service(&app, TestRequest::get().uri("/").to_request()).await;
301
302        assert_eq!(response.status(), StatusCode::OK);
303        let value = response.headers().get(header::ETAG).unwrap();
304        assert_eq!(
305            value.to_str().unwrap(),
306            expected_etag(b"hello", Strength::Strong)
307        );
308    }
309
310    #[actix_web::test]
311    async fn returns_not_modified_for_matching_if_none_match() {
312        let etag = expected_etag(b"hello", Strength::Strong);
313        let app = init_service(App::new().wrap(ETag::strong()).route(
314            "/",
315            web::get().to(|| async { HttpResponse::Ok().body("hello") }),
316        ))
317        .await;
318
319        let request = TestRequest::get()
320            .uri("/")
321            .insert_header((header::IF_NONE_MATCH, etag.clone()))
322            .to_request();
323        let response: ServiceResponse = call_service(&app, request).await;
324
325        assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
326        assert_eq!(
327            response
328                .headers()
329                .get(header::ETAG)
330                .unwrap()
331                .to_str()
332                .unwrap(),
333            etag
334        );
335    }
336
337    #[actix_web::test]
338    async fn returns_precondition_failed_for_non_matching_if_match() {
339        let app = init_service(App::new().wrap(ETag::strong()).route(
340            "/",
341            web::get().to(|| async { HttpResponse::Ok().body("hello") }),
342        ))
343        .await;
344
345        let request = TestRequest::get()
346            .uri("/")
347            .insert_header((header::IF_MATCH, "\"deadbeef\""))
348            .to_request();
349        let response: ServiceResponse = call_service(&app, request).await;
350
351        assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
352    }
353
354    #[actix_web::test]
355    async fn allows_if_match_when_strong_tag_matches() {
356        let body = b"hello";
357        let expected = expected_etag(body, Strength::Strong);
358        let app = init_service(App::new().wrap(ETag::strong()).route(
359            "/",
360            web::get().to(|| async { HttpResponse::Ok().body("hello") }),
361        ))
362        .await;
363
364        let request = TestRequest::get()
365            .uri("/")
366            .insert_header((header::IF_MATCH, expected.clone()))
367            .to_request();
368        let response: ServiceResponse = call_service(&app, request).await;
369
370        assert_eq!(response.status(), StatusCode::OK);
371        assert_eq!(
372            response
373                .headers()
374                .get(header::ETAG)
375                .unwrap()
376                .to_str()
377                .unwrap(),
378            expected
379        );
380    }
381
382    #[actix_web::test]
383    async fn sets_weak_etag_header_when_configured() {
384        let app = init_service(App::new().wrap(ETag::weak()).route(
385            "/",
386            web::get().to(|| async { HttpResponse::Ok().body("hello") }),
387        ))
388        .await;
389
390        let response: ServiceResponse =
391            call_service(&app, TestRequest::get().uri("/").to_request()).await;
392
393        assert_eq!(response.status(), StatusCode::OK);
394        assert_eq!(
395            response
396                .headers()
397                .get(header::ETAG)
398                .unwrap()
399                .to_str()
400                .unwrap(),
401            expected_etag(b"hello", Strength::Weak)
402        );
403    }
404
405    #[actix_web::test]
406    async fn weak_etag_triggers_not_modified_with_strong_if_none_match() {
407        let etag = expected_etag(b"hello", Strength::Weak);
408        let app = init_service(App::new().wrap(ETag::weak()).route(
409            "/",
410            web::get().to(|| async { HttpResponse::Ok().body("hello") }),
411        ))
412        .await;
413
414        let request = TestRequest::get()
415            .uri("/")
416            .insert_header((header::IF_NONE_MATCH, etag.trim_start_matches("W/")))
417            .to_request();
418        let response: ServiceResponse = call_service(&app, request).await;
419
420        assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
421        assert_eq!(
422            response
423                .headers()
424                .get(header::ETAG)
425                .unwrap()
426                .to_str()
427                .unwrap(),
428            etag
429        );
430    }
431
432    #[actix_web::test]
433    async fn weak_etag_fails_if_match_even_when_value_matches() {
434        let etag = expected_etag(b"hello", Strength::Weak);
435        let app = init_service(App::new().wrap(ETag::weak()).route(
436            "/",
437            web::get().to(|| async { HttpResponse::Ok().body("hello") }),
438        ))
439        .await;
440
441        let request = TestRequest::get()
442            .uri("/")
443            .insert_header((header::IF_MATCH, etag.clone()))
444            .to_request();
445        let response: ServiceResponse = call_service(&app, request).await;
446
447        assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
448    }
449
450    #[actix_web::test]
451    async fn new_method_creates_strong_etag_middleware() {
452        let app = init_service(App::new().wrap(ETag::new()).route(
453            "/",
454            web::get().to(|| async { HttpResponse::Ok().body("hello") }),
455        ))
456        .await;
457
458        let response: ServiceResponse =
459            call_service(&app, TestRequest::get().uri("/").to_request()).await;
460
461        assert_eq!(response.status(), StatusCode::OK);
462        let value = response.headers().get(header::ETAG).unwrap();
463        assert_eq!(
464            value.to_str().unwrap(),
465            expected_etag(b"hello", Strength::Strong)
466        );
467        assert!(!value.to_str().unwrap().starts_with("W/"));
468    }
469
470    #[actix_web::test]
471    async fn default_trait_creates_strong_etag_middleware() {
472        let app = init_service(App::new().wrap(ETag::default()).route(
473            "/",
474            web::get().to(|| async { HttpResponse::Ok().body("hello") }),
475        ))
476        .await;
477
478        let response: ServiceResponse =
479            call_service(&app, TestRequest::get().uri("/").to_request()).await;
480
481        assert_eq!(response.status(), StatusCode::OK);
482        let value = response.headers().get(header::ETAG).unwrap();
483        assert_eq!(
484            value.to_str().unwrap(),
485            expected_etag(b"hello", Strength::Strong)
486        );
487        assert!(!value.to_str().unwrap().starts_with("W/"));
488    }
489
490    #[actix_web::test]
491    async fn if_none_match_with_post_returns_precondition_failed() {
492        let etag = expected_etag(b"hello", Strength::Strong);
493        let app = init_service(App::new().wrap(ETag::strong()).route(
494            "/",
495            web::post().to(|| async { HttpResponse::Ok().body("hello") }),
496        ))
497        .await;
498
499        let request = TestRequest::post()
500            .uri("/")
501            .insert_header((header::IF_NONE_MATCH, etag.clone()))
502            .to_request();
503        let response: ServiceResponse = call_service(&app, request).await;
504
505        assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
506        assert_eq!(
507            response
508                .headers()
509                .get(header::ETAG)
510                .unwrap()
511                .to_str()
512                .unwrap(),
513            etag
514        );
515    }
516
517    #[actix_web::test]
518    async fn if_none_match_with_put_returns_precondition_failed() {
519        let etag = expected_etag(b"hello", Strength::Strong);
520        let app = init_service(App::new().wrap(ETag::strong()).route(
521            "/",
522            web::put().to(|| async { HttpResponse::Ok().body("hello") }),
523        ))
524        .await;
525
526        let request = TestRequest::put()
527            .uri("/")
528            .insert_header((header::IF_NONE_MATCH, etag.clone()))
529            .to_request();
530        let response: ServiceResponse = call_service(&app, request).await;
531
532        assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
533    }
534
535    #[actix_web::test]
536    async fn if_none_match_with_wildcard_matches_any_etag() {
537        let app = init_service(App::new().wrap(ETag::strong()).route(
538            "/",
539            web::get().to(|| async { HttpResponse::Ok().body("hello") }),
540        ))
541        .await;
542
543        let request = TestRequest::get()
544            .uri("/")
545            .insert_header((header::IF_NONE_MATCH, "*"))
546            .to_request();
547        let response: ServiceResponse = call_service(&app, request).await;
548
549        assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
550        assert!(response.headers().contains_key(header::ETAG));
551    }
552
553    #[actix_web::test]
554    async fn if_match_with_wildcard_matches_any_etag() {
555        let app = init_service(App::new().wrap(ETag::strong()).route(
556            "/",
557            web::get().to(|| async { HttpResponse::Ok().body("hello") }),
558        ))
559        .await;
560
561        let request = TestRequest::get()
562            .uri("/")
563            .insert_header((header::IF_MATCH, "*"))
564            .to_request();
565        let response: ServiceResponse = call_service(&app, request).await;
566
567        assert_eq!(response.status(), StatusCode::OK);
568        assert!(response.headers().contains_key(header::ETAG));
569    }
570
571    #[actix_web::test]
572    async fn preserves_handler_set_etag() {
573        let custom_etag = "\"custom-etag-12345\"";
574        let app = init_service(App::new().wrap(ETag::strong()).route(
575            "/",
576            web::get().to(|| async {
577                HttpResponse::Ok()
578                    .insert_header((header::ETAG, "\"custom-etag-12345\""))
579                    .body("hello")
580            }),
581        ))
582        .await;
583
584        let response: ServiceResponse =
585            call_service(&app, TestRequest::get().uri("/").to_request()).await;
586
587        assert_eq!(response.status(), StatusCode::OK);
588        assert_eq!(
589            response
590                .headers()
591                .get(header::ETAG)
592                .unwrap()
593                .to_str()
594                .unwrap(),
595            custom_etag
596        );
597    }
598
599    #[actix_web::test]
600    async fn if_none_match_matches_handler_set_etag() {
601        let custom_etag = "\"custom-etag-12345\"";
602        let app = init_service(App::new().wrap(ETag::strong()).route(
603            "/",
604            web::get().to(|| async {
605                HttpResponse::Ok()
606                    .insert_header((header::ETAG, "\"custom-etag-12345\""))
607                    .body("hello")
608            }),
609        ))
610        .await;
611
612        let request = TestRequest::get()
613            .uri("/")
614            .insert_header((header::IF_NONE_MATCH, custom_etag))
615            .to_request();
616        let response: ServiceResponse = call_service(&app, request).await;
617
618        assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
619    }
620
621    #[actix_web::test]
622    async fn multiple_etags_in_if_none_match() {
623        let etag = expected_etag(b"hello", Strength::Strong);
624        let app = init_service(App::new().wrap(ETag::strong()).route(
625            "/",
626            web::get().to(|| async { HttpResponse::Ok().body("hello") }),
627        ))
628        .await;
629
630        let multiple_etags = format!("\"other-etag\", {}, \"another-etag\"", etag);
631        let request = TestRequest::get()
632            .uri("/")
633            .insert_header((header::IF_NONE_MATCH, multiple_etags))
634            .to_request();
635        let response: ServiceResponse = call_service(&app, request).await;
636
637        assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
638    }
639
640    #[actix_web::test]
641    async fn multiple_etags_in_if_match_with_match() {
642        let etag = expected_etag(b"hello", Strength::Strong);
643        let app = init_service(App::new().wrap(ETag::strong()).route(
644            "/",
645            web::get().to(|| async { HttpResponse::Ok().body("hello") }),
646        ))
647        .await;
648
649        let multiple_etags = format!("\"other-etag\", {}, \"another-etag\"", etag);
650        let request = TestRequest::get()
651            .uri("/")
652            .insert_header((header::IF_MATCH, multiple_etags))
653            .to_request();
654        let response: ServiceResponse = call_service(&app, request).await;
655
656        assert_eq!(response.status(), StatusCode::OK);
657    }
658
659    #[actix_web::test]
660    async fn response_without_precondition_headers_passes_through() {
661        let app = init_service(App::new().wrap(ETag::strong()).route(
662            "/",
663            web::get().to(|| async { HttpResponse::Ok().body("hello") }),
664        ))
665        .await;
666
667        let response: ServiceResponse =
668            call_service(&app, TestRequest::get().uri("/").to_request()).await;
669
670        assert_eq!(response.status(), StatusCode::OK);
671        assert!(response.headers().contains_key(header::ETAG));
672    }
673}