1use crate::optimizer::{
16 ImageError, load_image, optimize_avif, optimize_jpeg, optimize_png,
17 optimize_webp,
18};
19use async_trait::async_trait;
20use bytes::{Bytes, BytesMut};
21use ctor::ctor;
22use pingap_config::PluginConf;
23use pingap_core::HTTP_HEADER_TRANSFER_CHUNKED;
24use pingap_core::{
25 Ctx, Plugin, PluginStep, RequestPluginResult, ResponsePluginResult,
26};
27use pingap_core::{ModifyResponseBody, ResponseBodyPluginResult};
28use pingap_plugin::{
29 Error, get_hash_key, get_int_conf, get_plugin_factory, get_str_conf,
30};
31use pingora::http::ResponseHeader;
32use pingora::proxy::Session;
33use std::borrow::Cow;
34use std::collections::HashSet;
35use std::convert::TryFrom;
36use std::sync::Arc;
37use tracing::{debug, warn};
38
39mod optimizer;
40
41const PLUGIN_ID: &str = "_image_optimize_";
42
43const MAX_IMAGE_SIZE: usize = 20 * 1024 * 1024;
46
47const OUTPUT_FORMATS: &[&str] = &["avif", "webp", "jpeg", "png"];
49
50type Result<T, E = Error> = std::result::Result<T, E>;
51
52struct ImageOptimizer {
53 image_type: String,
55 png_quality: u8,
56 jpeg_quality: u8,
57 avif_quality: u8,
58 avif_speed: u8,
59 webp_quality: u8,
60 format_type: String,
62 buffer: BytesMut,
63 passthrough: bool,
66}
67
68fn run_blocking<T>(f: impl FnOnce() -> T) -> T {
77 use tokio::runtime::{Handle, RuntimeFlavor};
78 match Handle::try_current() {
79 Ok(handle) if handle.runtime_flavor() == RuntimeFlavor::MultiThread => {
80 tokio::task::block_in_place(f)
81 },
82 _ => f(),
83 }
84}
85
86impl ImageOptimizer {
87 fn optimize(&self, data: &[u8]) -> Result<Vec<u8>, ImageError> {
88 let info = load_image(data, &self.image_type)?;
89 match self.format_type.as_str() {
90 "jpeg" => optimize_jpeg(&info, self.jpeg_quality),
91 "avif" => optimize_avif(&info, self.avif_quality, self.avif_speed),
92 "webp" => optimize_webp(&info, self.webp_quality),
93 _ => optimize_png(&info, self.png_quality),
94 }
95 }
96}
97
98impl ModifyResponseBody for ImageOptimizer {
99 fn handle(
100 &mut self,
101 _session: &Session,
102 body: &mut Option<bytes::Bytes>,
103 end_of_stream: bool,
104 ) -> pingora::Result<()> {
105 if self.passthrough {
106 return Ok(());
107 }
108 if let Some(data) = body {
109 if self.buffer.len() + data.len() > MAX_IMAGE_SIZE {
113 self.passthrough = true;
114 self.buffer.extend_from_slice(data);
115 *data = self.buffer.split().freeze();
116 return Ok(());
117 }
118 self.buffer.extend_from_slice(data);
119 data.clear();
120 }
121 if !end_of_stream {
122 return Ok(());
123 }
124 if self.buffer.is_empty() {
126 return Ok(());
127 }
128 let original = self.buffer.split().freeze();
129 let optimized = run_blocking(|| self.optimize(&original));
130 *body = Some(match optimized {
131 Ok(data) => Bytes::from(data),
132 Err(e) => {
135 warn!(
136 error = %e,
137 image_type = self.image_type,
138 format_type = self.format_type,
139 "optimize image fail, the original is sent"
140 );
141 original
142 },
143 });
144 Ok(())
145 }
146 fn name(&self) -> &str {
147 "image_optimization"
148 }
149}
150
151pub struct ImageOptim {
152 handler_id: String,
155 hash_value: String,
158 support_types: HashSet<String>,
159 output_types: Vec<String>,
161 output_mimes: Vec<String>,
163 key_alternatives: Arc<Vec<Vec<String>>>,
166 png_quality: u8,
167 jpeg_quality: u8,
168 avif_quality: u8,
169 avif_speed: u8,
170}
171
172fn selections(mimes: &[String]) -> Vec<Vec<String>> {
174 let mut sorted = mimes.to_vec();
175 sorted.sort();
176 sorted.dedup();
177 (0..1usize << sorted.len())
178 .map(|picked| {
179 sorted
180 .iter()
181 .enumerate()
182 .filter(|(index, _)| picked & (1 << index) != 0)
183 .map(|(_, mime)| mime.clone())
184 .collect()
185 })
186 .collect()
187}
188
189impl TryFrom<&PluginConf> for ImageOptim {
190 type Error = Error;
191 fn try_from(value: &PluginConf) -> Result<Self> {
192 debug!(params = value.to_string(), "new image optimizer plugin");
193 let hash_value = get_hash_key(value);
194
195 let output_types: Vec<String> = get_str_conf(value, "output_types")
196 .split(',')
197 .map(|s| s.trim())
198 .filter(|s| !s.is_empty())
199 .map(|s| s.to_string())
200 .collect();
201
202 if let Some(format) = output_types
206 .iter()
207 .find(|format| !OUTPUT_FORMATS.contains(&format.as_str()))
208 {
209 return Err(Error::Invalid {
210 category: "image_optim".to_string(),
211 message: format!(
212 "output type {format} is not supported, expected one of {}",
213 OUTPUT_FORMATS.join(", ")
214 ),
215 });
216 }
217 let output_mimes: Vec<String> = output_types
218 .iter()
219 .map(|format| format!("image/{}", format))
220 .collect();
221
222 let level = |key: &str, max: i64, default: u8| -> Result<u8, Error> {
225 match get_int_conf(value, key) {
226 0 => Ok(default),
227 level if (1..=max).contains(&level) => Ok(level as u8),
228 level => Err(Error::Invalid {
229 category: "image_optim".to_string(),
230 message: format!(
231 "{key} should be between 1 and {max}, got {level}"
232 ),
233 }),
234 }
235 };
236 let png_quality = level("png_quality", 100, 90)?;
237 let jpeg_quality = level("jpeg_quality", 100, 80)?;
238 let avif_quality = level("avif_quality", 100, 75)?;
239 let avif_speed = level("avif_speed", 10, 3)?;
240 Ok(Self {
241 hash_value,
242 handler_id: pingap_plugin::new_body_handler_id(PLUGIN_ID),
243 support_types: HashSet::from([
244 "jpeg".to_string(),
245 "png".to_string(),
246 ]),
247 output_types,
248 key_alternatives: Arc::new(selections(&output_mimes)),
249 output_mimes,
250 png_quality,
251 jpeg_quality,
252 avif_quality,
253 avif_speed,
254 })
255 }
256}
257
258impl ImageOptim {
259 pub fn new(params: &PluginConf) -> Result<Self> {
260 Self::try_from(params)
261 }
262}
263
264#[async_trait]
265impl Plugin for ImageOptim {
266 fn config_key(&self) -> Cow<'_, str> {
268 Cow::Borrowed(&self.hash_value)
269 }
270 async fn handle_request(
271 &self,
272 step: PluginStep,
273 session: &mut Session,
274 ctx: &mut Ctx,
275 ) -> pingora::Result<RequestPluginResult> {
276 if step != PluginStep::EarlyRequest {
282 return Ok(RequestPluginResult::Skipped);
283 }
284
285 let accept = session
286 .get_header(http::header::ACCEPT)
287 .and_then(|accept| accept.to_str().ok())
288 .unwrap_or_default();
289 let mut accept_images: Vec<_> = self
290 .output_mimes
291 .iter()
292 .filter(|mime| accept.contains(*mime))
293 .cloned()
294 .collect();
295 accept_images.sort();
296 accept_images.dedup();
298 ctx.push_cache_key_variant(
299 accept_images,
300 self.key_alternatives.clone(),
301 );
302 Ok(RequestPluginResult::Continue)
303 }
304 fn handle_upstream_response(
305 &self,
306 session: &mut Session,
307 ctx: &mut Ctx,
308 upstream_response: &mut ResponseHeader,
309 ) -> pingora::Result<ResponsePluginResult> {
310 if upstream_response.status != http::StatusCode::OK
314 || upstream_response
315 .headers
316 .contains_key(http::header::CONTENT_ENCODING)
317 {
318 return Ok(ResponsePluginResult::Unchanged);
319 }
320 let content_type = if let Some(value) =
321 upstream_response.headers.get(http::header::CONTENT_TYPE)
322 {
323 value.to_str().unwrap_or_default()
324 } else {
325 return Ok(ResponsePluginResult::Unchanged);
326 };
327
328 let media_type = content_type.split(';').next().unwrap_or_default();
330 let Some(image_type) = media_type.trim().strip_prefix("image/") else {
331 return Ok(ResponsePluginResult::Unchanged);
332 };
333
334 if !self.support_types.contains(image_type) {
335 return Ok(ResponsePluginResult::Unchanged);
336 }
337
338 let Some(accept) = session.get_header(http::header::ACCEPT) else {
339 return Ok(ResponsePluginResult::Unchanged);
340 };
341 let Ok(accept_str) = accept.to_str() else {
342 return Ok(ResponsePluginResult::Unchanged);
343 };
344
345 let content_length = upstream_response
349 .headers
350 .get(http::header::CONTENT_LENGTH)
351 .and_then(|value| value.to_str().ok())
352 .and_then(|value| value.parse::<usize>().ok());
353 if content_length.is_some_and(|size| size > MAX_IMAGE_SIZE) {
354 return Ok(ResponsePluginResult::Unchanged);
355 }
356
357 let image_type = image_type.to_string();
358 let format_type = self
364 .output_types
365 .iter()
366 .zip(self.output_mimes.iter())
367 .find(|(_, mime)| accept_str.contains(mime.as_str()))
368 .map(|(format, _)| format.clone())
369 .unwrap_or_else(|| image_type.clone());
370 upstream_response.remove_header(&http::header::CONTENT_LENGTH);
372 upstream_response.remove_header(&http::header::ACCEPT_RANGES);
376 let _ = upstream_response.insert_header(
378 http::header::TRANSFER_ENCODING,
379 HTTP_HEADER_TRANSFER_CHUNKED.1.clone(),
380 );
381 let _ = upstream_response.insert_header(
382 http::header::CONTENT_TYPE,
383 format!("image/{format_type}"),
384 );
385 let capacity = content_length.unwrap_or(8192);
386
387 ctx.add_modify_body_handler(
388 &self.handler_id,
389 Box::new(ImageOptimizer {
390 image_type,
391 png_quality: self.png_quality,
392 jpeg_quality: self.jpeg_quality,
393 avif_quality: self.avif_quality,
394 avif_speed: self.avif_speed,
395 webp_quality: 100,
397 format_type,
398 buffer: BytesMut::with_capacity(capacity),
399 passthrough: false,
400 }),
401 );
402 Ok(ResponsePluginResult::Modified)
403 }
404 async fn handle_response(
414 &self,
415 _session: &mut Session,
416 _ctx: &mut Ctx,
417 upstream_response: &mut ResponseHeader,
418 ) -> pingora::Result<ResponsePluginResult> {
419 let ours = upstream_response
420 .headers
421 .get(http::header::CONTENT_TYPE)
422 .and_then(|value| value.to_str().ok())
423 .and_then(|value| value.split(';').next())
424 .and_then(|value| value.trim().strip_prefix("image/"))
425 .is_some_and(|kind| {
426 self.support_types.contains(kind)
427 || self.output_types.iter().any(|output| output == kind)
428 });
429 if ours && pingap_plugin::weaken_etag(upstream_response) {
430 return Ok(ResponsePluginResult::Modified);
431 }
432 Ok(ResponsePluginResult::Unchanged)
433 }
434 fn handle_upstream_response_body(
435 &self,
436 session: &mut Session,
437 ctx: &mut Ctx,
438 body: &mut Option<bytes::Bytes>,
439 end_of_stream: bool,
440 ) -> pingora::Result<ResponseBodyPluginResult> {
441 if let Some(modifier) = ctx.get_modify_body_handler(&self.handler_id) {
442 modifier.handle(session, body, end_of_stream)?;
443 let result = if end_of_stream {
444 ResponseBodyPluginResult::FullyReplaced
445 } else {
446 ResponseBodyPluginResult::PartialReplaced
447 };
448 Ok(result)
449 } else {
450 Ok(ResponseBodyPluginResult::Unchanged)
451 }
452 }
453}
454
455#[ctor(unsafe)]
456fn init() {
457 get_plugin_factory().register("image_optim", |params| {
458 Ok(Arc::new(ImageOptim::new(params)?))
459 });
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use pingap_config::PluginConf;
466 use pingap_core::{Ctx, Plugin};
467 use pingora::modules::http::HttpModules;
468 use pretty_assertions::assert_eq;
469 use tokio_test::io::Builder;
470
471 #[test]
472 fn test_new_image_optimize() {
473 let optim = ImageOptim::try_from(
474 &toml::from_str::<PluginConf>(
475 r###"
476avif_quality = 75
477avif_speed = 3
478category = "image_optim"
479jpeg_quality = 80
480output_types = "avif,webp"
481png_quality = 90
482"###,
483 )
484 .unwrap(),
485 )
486 .unwrap();
487
488 assert_eq!(
489 HashSet::from(["jpeg".to_string(), "png".to_string(),]),
490 optim.support_types
491 );
492 assert_eq!(
493 vec!["image/avif".to_string(), "image/webp".to_string()],
494 optim.output_mimes
495 );
496 assert_eq!(90, optim.png_quality);
497 assert_eq!(80, optim.jpeg_quality);
498 assert_eq!(75, optim.avif_quality);
499 assert_eq!(3, optim.avif_speed);
500 }
501
502 #[tokio::test]
505 async fn test_image_validator_is_weak_on_the_way_out() {
506 let optim = new_optim("avif,webp");
507 let sent = async |content_type: &str| {
508 let mut session = new_session("").await;
509 let mut resp = ResponseHeader::build(200, None).unwrap();
510 resp.insert_header("Content-Type", content_type).unwrap();
511 resp.insert_header("ETag", "\"v1\"").unwrap();
512 optim
513 .handle_response(&mut session, &mut Ctx::default(), &mut resp)
514 .await
515 .unwrap();
516 resp.headers
517 .get("ETag")
518 .unwrap()
519 .to_str()
520 .unwrap()
521 .to_string()
522 };
523 assert_eq!("W/\"v1\"", sent("image/webp").await);
524 assert_eq!("W/\"v1\"", sent("image/jpeg; q=1").await);
525 assert_eq!("\"v1\"", sent("image/svg+xml").await);
527 assert_eq!("\"v1\"", sent("text/html").await);
528 }
529
530 #[test]
533 fn test_levels_out_of_range_are_rejected() {
534 let build = |conf: &str| {
535 ImageOptim::try_from(&toml::from_str::<PluginConf>(conf).unwrap())
536 };
537 for conf in [
538 "png_quality = 300",
539 "png_quality = 101",
540 "jpeg_quality = 256",
541 "avif_quality = -1",
542 "avif_speed = 11",
543 "avif_speed = 266",
544 ] {
545 let message =
546 build(conf).err().map(|e| e.to_string()).unwrap_or_default();
547 assert_eq!(true, message.contains("should be between"), "{conf}");
548 }
549 let optim = build("jpeg_quality = 0").unwrap();
551 assert_eq!(
552 (90, 80, 75, 3),
553 (
554 optim.png_quality,
555 optim.jpeg_quality,
556 optim.avif_quality,
557 optim.avif_speed
558 )
559 );
560 let optim =
561 build("png_quality = 1\njpeg_quality = 100\navif_speed = 10")
562 .unwrap();
563 assert_eq!(
564 (1, 100, 10),
565 (optim.png_quality, optim.jpeg_quality, optim.avif_speed)
566 );
567 }
568
569 #[tokio::test]
570 async fn test_image_optimize_handle_request() {
571 let optim = ImageOptim::try_from(
572 &toml::from_str::<PluginConf>(
573 r###"
574avif_quality = 75
575avif_speed = 3
576category = "image_optim"
577jpeg_quality = 80
578output_types = "avif,webp"
579png_quality = 90
580"###,
581 )
582 .unwrap(),
583 )
584 .unwrap();
585 {
587 let headers = [""].join("\r\n");
588 let input_header = format!(
589 "GET /vicanso/pingap?size=1 HTTP/1.1\r\n{headers}\r\n\r\n"
590 );
591 let mock_io = Builder::new().read(input_header.as_bytes()).build();
592 let mut session = Session::new_h1_with_modules(
593 Box::new(mock_io),
594 &HttpModules::new(),
595 );
596 session.read_request().await.unwrap();
597 let mut ctx = Ctx::default();
598
599 let result = optim
600 .handle_request(
601 PluginStep::EarlyRequest,
602 &mut session,
603 &mut ctx,
604 )
605 .await
606 .unwrap();
607
608 assert_eq!(true, RequestPluginResult::Continue == result);
609 let cache = ctx.cache.unwrap();
612 assert_eq!(Some(vec![]), cache.keys);
613 let variants = cache.key_variants.unwrap();
614 assert_eq!((0, 0), (variants[0].at, variants[0].len));
615 assert_eq!(
616 vec![
617 vec![],
618 vec!["image/avif".to_string()],
619 vec!["image/webp".to_string()],
620 vec!["image/avif".to_string(), "image/webp".to_string()],
621 ],
622 *variants[0].alternatives
623 );
624 }
625
626 {
628 let headers = ["Accept: image/avif"].join("\r\n");
629 let input_header = format!(
630 "GET /vicanso/pingap?size=1 HTTP/1.1\r\n{headers}\r\n\r\n"
631 );
632 let mock_io = Builder::new().read(input_header.as_bytes()).build();
633 let mut session = Session::new_h1_with_modules(
634 Box::new(mock_io),
635 &HttpModules::new(),
636 );
637 session.read_request().await.unwrap();
638 let mut ctx = Ctx::default();
639
640 let result = optim
641 .handle_request(
642 PluginStep::EarlyRequest,
643 &mut session,
644 &mut ctx,
645 )
646 .await
647 .unwrap();
648
649 assert_eq!(true, RequestPluginResult::Continue == result);
650 assert_eq!(
651 vec!["image/avif".to_string()],
652 ctx.cache.unwrap().keys.unwrap()
653 );
654 }
655
656 {
658 let headers = ["Accept: image/webp, image/avif"].join("\r\n");
659 let input_header = format!(
660 "GET /vicanso/pingap?size=1 HTTP/1.1\r\n{headers}\r\n\r\n"
661 );
662 let mock_io = Builder::new().read(input_header.as_bytes()).build();
663 let mut session = Session::new_h1_with_modules(
664 Box::new(mock_io),
665 &HttpModules::new(),
666 );
667 session.read_request().await.unwrap();
668 let mut ctx = Ctx::default();
669
670 let result = optim
671 .handle_request(
672 PluginStep::EarlyRequest,
673 &mut session,
674 &mut ctx,
675 )
676 .await
677 .unwrap();
678
679 assert_eq!(true, RequestPluginResult::Continue == result);
680 assert_eq!(
681 vec!["image/avif".to_string(), "image/webp".to_string()],
682 ctx.cache.unwrap().keys.unwrap()
683 );
684 }
685 }
686
687 #[tokio::test]
688 async fn test_image_optimize_handle_upstream_response() {
689 let optim = ImageOptim::try_from(
690 &toml::from_str::<PluginConf>(
691 r###"
692avif_quality = 75
693avif_speed = 3
694category = "image_optim"
695jpeg_quality = 80
696output_types = "avif,webp"
697png_quality = 90
698"###,
699 )
700 .unwrap(),
701 )
702 .unwrap();
703
704 let headers = ["Accept: image/webp, image/avif"].join("\r\n");
705 let input_header =
706 format!("GET /vicanso/pingap?size=1 HTTP/1.1\r\n{headers}\r\n\r\n");
707 let mock_io = Builder::new().read(input_header.as_bytes()).build();
708 let mut session = Session::new_h1_with_modules(
709 Box::new(mock_io),
710 &HttpModules::new(),
711 );
712 session.read_request().await.unwrap();
713 let mut ctx = Ctx::default();
714 let mut upstream_response = ResponseHeader::build(200, None).unwrap();
715
716 let result = optim
718 .handle_upstream_response(
719 &mut session,
720 &mut ctx,
721 &mut upstream_response,
722 )
723 .unwrap();
724 assert_eq!(true, ResponsePluginResult::Unchanged == result);
725
726 upstream_response
728 .append_header("content-type", "application/json")
729 .unwrap();
730 let result = optim
731 .handle_upstream_response(
732 &mut session,
733 &mut ctx,
734 &mut upstream_response,
735 )
736 .unwrap();
737 assert_eq!(true, ResponsePluginResult::Unchanged == result);
738
739 upstream_response
741 .insert_header("content-type", "image/png")
742 .unwrap();
743 let result = optim
744 .handle_upstream_response(
745 &mut session,
746 &mut ctx,
747 &mut upstream_response,
748 )
749 .unwrap();
750 assert_eq!(
751 "chunked",
752 upstream_response.headers.get("transfer-encoding").unwrap()
753 );
754 assert_eq!(true, ResponsePluginResult::Modified == result);
755 assert_eq!(
756 true,
757 ctx.get_modify_body_handler(&optim.handler_id).is_some()
758 );
759 assert_eq!(
761 "image/avif",
762 upstream_response.headers.get("content-type").unwrap()
763 );
764 }
765
766 fn new_optim(output_types: &str) -> ImageOptim {
767 ImageOptim::try_from(
768 &toml::from_str::<PluginConf>(&format!(
769 "category = \"image_optim\"\noutput_types = \"{output_types}\""
770 ))
771 .unwrap(),
772 )
773 .unwrap()
774 }
775
776 async fn new_session(accept: &str) -> Session {
777 let input_header =
778 format!("GET /a.png HTTP/1.1\r\nAccept: {accept}\r\n\r\n");
779 let mock_io = Builder::new().read(input_header.as_bytes()).build();
780 let mut session = Session::new_h1_with_modules(
781 Box::new(mock_io),
782 &HttpModules::new(),
783 );
784 session.read_request().await.unwrap();
785 session
786 }
787
788 fn sample_png() -> Vec<u8> {
789 use image::ImageEncoder;
790 let img = image::RgbaImage::from_fn(8, 6, |x, y| {
791 image::Rgba([(x * 30) as u8, (y * 40) as u8, 0, 255])
792 });
793 let mut png = Vec::new();
794 image::codecs::png::PngEncoder::new(&mut png)
795 .write_image(img.as_raw(), 8, 6, image::ExtendedColorType::Rgba8)
796 .unwrap();
797 png
798 }
799
800 #[test]
801 fn test_unknown_output_type_is_rejected() {
802 let err = ImageOptim::try_from(
803 &toml::from_str::<PluginConf>(
804 "category = \"image_optim\"\noutput_types = \"avif,bmp\"",
805 )
806 .unwrap(),
807 )
808 .err()
809 .unwrap()
810 .to_string();
811 assert_eq!(
812 "Plugin image_optim invalid, message: output type bmp is not supported, expected one of avif, webp, jpeg, png",
813 err
814 );
815 }
816
817 #[tokio::test]
819 async fn test_image_optimize_response_header() {
820 let optim = new_optim("avif,webp");
821 let run =
822 async |accept: &str, status: u16, headers: &[(&str, &str)]| {
823 let mut session = new_session(accept).await;
824 let mut ctx = Ctx::default();
825 let mut resp = ResponseHeader::build(status, None).unwrap();
826 for (name, value) in headers {
827 resp.insert_header(name.to_string(), *value).unwrap();
828 }
829 let result = optim
830 .handle_upstream_response(&mut session, &mut ctx, &mut resp)
831 .unwrap();
832 let header = |name: &str| {
833 resp.headers
834 .get(name)
835 .map(|value| value.to_str().unwrap().to_string())
836 };
837 (
838 result == ResponsePluginResult::Modified,
839 header("content-type"),
840 header("content-length"),
841 )
842 };
843 let text = |value: &str| Some(value.to_string());
844 let png = [("content-type", "image/png"), ("content-length", "100")];
845
846 assert_eq!(
847 (true, text("image/webp"), None),
848 run("image/webp", 200, &png).await
849 );
850 assert_eq!(
853 (true, text("image/png"), None),
854 run("image/*", 200, &png).await
855 );
856 assert_eq!(
858 (true, text("image/avif"), None),
859 run(
860 "image/avif",
861 200,
862 &[("content-type", "image/jpeg; charset=binary")]
863 )
864 .await
865 );
866
867 let too_large = (MAX_IMAGE_SIZE + 1).to_string();
869 assert_eq!(
870 (false, text("image/png"), Some(too_large.clone())),
871 run(
872 "image/webp",
873 200,
874 &[
875 ("content-type", "image/png"),
876 ("content-length", &too_large)
877 ]
878 )
879 .await
880 );
881 assert_eq!(
883 (false, text("image/png"), text("100")),
884 run("image/webp", 206, &png).await
885 );
886 assert_eq!(
888 (false, text("image/gif"), None),
889 run("image/webp", 200, &[("content-type", "image/gif")]).await
890 );
891 assert_eq!(
893 (false, text("image/png"), text("100")),
894 run(
895 "image/webp",
896 200,
897 &[
898 ("content-type", "image/png"),
899 ("content-length", "100"),
900 ("content-encoding", "gzip")
901 ]
902 )
903 .await
904 );
905 }
906
907 #[tokio::test]
910 async fn test_image_optimize_body() {
911 let session = new_session("image/webp").await;
912 let new_optimizer = |format_type: &str| ImageOptimizer {
913 image_type: "png".to_string(),
914 png_quality: 90,
915 jpeg_quality: 80,
916 avif_quality: 75,
917 avif_speed: 10,
918 webp_quality: 100,
919 format_type: format_type.to_string(),
920 buffer: BytesMut::new(),
921 passthrough: false,
922 };
923 let png = sample_png();
924
925 for (format_type, expected) in [
928 ("webp", image::ImageFormat::WebP),
929 ("avif", image::ImageFormat::Avif),
930 ("jpeg", image::ImageFormat::Jpeg),
931 ("png", image::ImageFormat::Png),
932 ] {
933 let mut optimizer = new_optimizer(format_type);
934 let (head, tail) = png.split_at(png.len() / 2);
936 let mut body = Some(Bytes::copy_from_slice(head));
937 optimizer.handle(&session, &mut body, false).unwrap();
938 assert_eq!(Some(Bytes::new()), body, "{format_type}");
939 let mut body = Some(Bytes::copy_from_slice(tail));
940 optimizer.handle(&session, &mut body, true).unwrap();
941 assert_eq!(
942 Some(expected),
943 image::guess_format(&body.unwrap()).ok(),
944 "{format_type}"
945 );
946 }
947
948 let mut optimizer = new_optimizer("webp");
950 let mut body = None;
951 optimizer.handle(&session, &mut body, true).unwrap();
952 assert_eq!(None, body);
953
954 let mut optimizer = new_optimizer("webp");
956 let mut body = Some(Bytes::from_static(b"not an image"));
957 optimizer.handle(&session, &mut body, true).unwrap();
958 assert_eq!(Some(Bytes::from_static(b"not an image")), body);
959
960 let mut optimizer = new_optimizer("webp");
963 let chunk = Bytes::from(vec![1u8; MAX_IMAGE_SIZE / 2 + 1]);
964 let mut body = Some(chunk.clone());
965 optimizer.handle(&session, &mut body, false).unwrap();
966 assert_eq!(Some(Bytes::new()), body);
967 let mut body = Some(chunk.clone());
968 optimizer.handle(&session, &mut body, false).unwrap();
969 assert_eq!(Some(2 * chunk.len()), body.map(|data| data.len()));
970 assert_eq!(0, optimizer.buffer.len());
971 let mut body = Some(Bytes::from_static(b"tail"));
972 optimizer.handle(&session, &mut body, true).unwrap();
973 assert_eq!(Some(Bytes::from_static(b"tail")), body);
974 }
975
976 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
979 async fn test_image_optimize_on_multi_thread_runtime() {
980 let png = sample_png();
981 let output = tokio::spawn(async move {
982 let session = new_session("image/webp").await;
983 let mut optimizer = ImageOptimizer {
984 image_type: "png".to_string(),
985 png_quality: 90,
986 jpeg_quality: 80,
987 avif_quality: 75,
988 avif_speed: 10,
989 webp_quality: 100,
990 format_type: "webp".to_string(),
991 buffer: BytesMut::new(),
992 passthrough: false,
993 };
994 let mut body = Some(Bytes::from(png));
995 optimizer.handle(&session, &mut body, true).unwrap();
996 body.unwrap()
997 })
998 .await
999 .unwrap();
1000 assert_eq!(
1001 Some(image::ImageFormat::WebP),
1002 image::guess_format(&output).ok()
1003 );
1004 }
1005}