@@ -6,10 +6,10 @@ mod formatting;
6
6
pub use error:: OgImageError ;
7
7
8
8
use crate :: formatting:: { serialize_bytes, serialize_number, serialize_optional_number} ;
9
- use bytes:: Bytes ;
10
9
use crates_io_env_vars:: var;
11
10
use reqwest:: StatusCode ;
12
11
use serde:: Serialize ;
12
+ use std:: borrow:: Cow ;
13
13
use std:: collections:: HashMap ;
14
14
use std:: path:: { Path , PathBuf } ;
15
15
use tempfile:: NamedTempFile ;
@@ -49,24 +49,19 @@ pub struct OgImageData<'a> {
49
49
pub struct OgImageAuthorData < ' a > {
50
50
/// Author username/name
51
51
pub name : & ' a str ,
52
- /// Optional avatar - either "test-avatar" for the test avatar or a URL
53
- pub avatar : Option < & ' a str > ,
52
+ /// Optional avatar URL
53
+ pub avatar : Option < Cow < ' a , str > > ,
54
54
}
55
55
56
56
impl < ' a > OgImageAuthorData < ' a > {
57
57
/// Creates a new `OgImageAuthorData` with the specified name and optional avatar.
58
- pub const fn new ( name : & ' a str , avatar : Option < & ' a str > ) -> Self {
58
+ pub const fn new ( name : & ' a str , avatar : Option < Cow < ' a , str > > ) -> Self {
59
59
Self { name, avatar }
60
60
}
61
61
62
62
/// Creates a new `OgImageAuthorData` with a URL-based avatar.
63
- pub fn with_url ( name : & ' a str , url : & ' a str ) -> Self {
64
- Self :: new ( name, Some ( url) )
65
- }
66
-
67
- /// Creates a new `OgImageAuthorData` with the test avatar.
68
- pub fn with_test_avatar ( name : & ' a str ) -> Self {
69
- Self :: with_url ( name, "test-avatar" )
63
+ pub fn with_url ( name : & ' a str , url : impl Into < Cow < ' a , str > > ) -> Self {
64
+ Self :: new ( name, Some ( url. into ( ) ) )
70
65
}
71
66
}
72
67
@@ -245,54 +240,46 @@ impl OgImageGenerator {
245
240
"Processing avatar for author {}" , author. name
246
241
) ;
247
242
248
- // Get the bytes either from the included asset or download from URL
249
- let bytes = if * avatar == "test-avatar" {
250
- debug ! ( "Using bundled test avatar" ) ;
251
- // Copy directly from included bytes
252
- Bytes :: from_static ( include_bytes ! ( "../template/assets/test-avatar.png" ) )
253
- } else {
254
- debug ! ( url = %avatar, "Downloading avatar from URL: {avatar}" ) ;
255
- // Download the avatar from the URL
256
- let response = client. get ( * avatar) . send ( ) . await . map_err ( |err| {
257
- OgImageError :: AvatarDownloadError {
258
- url : avatar. to_string ( ) ,
259
- source : err,
260
- }
261
- } ) ?;
262
-
263
- let status = response. status ( ) ;
264
- if status == StatusCode :: NOT_FOUND {
265
- warn ! ( url = %avatar, "Avatar URL returned 404 Not Found" ) ;
266
- continue ; // Skip this avatar if not found
243
+ // Download the avatar from the URL
244
+ debug ! ( url = %avatar, "Downloading avatar from URL: {avatar}" ) ;
245
+ let response = client. get ( avatar. as_ref ( ) ) . send ( ) . await . map_err ( |err| {
246
+ OgImageError :: AvatarDownloadError {
247
+ url : avatar. to_string ( ) ,
248
+ source : err,
267
249
}
250
+ } ) ?;
251
+
252
+ let status = response. status ( ) ;
253
+ if status == StatusCode :: NOT_FOUND {
254
+ warn ! ( url = %avatar, "Avatar URL returned 404 Not Found" ) ;
255
+ continue ; // Skip this avatar if not found
256
+ }
257
+
258
+ if let Err ( err) = response. error_for_status_ref ( ) {
259
+ return Err ( OgImageError :: AvatarDownloadError {
260
+ url : avatar. to_string ( ) ,
261
+ source : err,
262
+ } ) ;
263
+ }
264
+
265
+ let content_length = response. content_length ( ) ;
266
+ debug ! (
267
+ url = %avatar,
268
+ content_length = ?content_length,
269
+ status = %response. status( ) ,
270
+ "Avatar download response received"
271
+ ) ;
268
272
269
- if let Err ( err) = response. error_for_status_ref ( ) {
270
- return Err ( OgImageError :: AvatarDownloadError {
271
- url : avatar. to_string ( ) ,
272
- source : err,
273
- } ) ;
273
+ let bytes = response. bytes ( ) . await ;
274
+ let bytes = bytes. map_err ( |err| {
275
+ error ! ( url = %avatar, error = %err, "Failed to read avatar response bytes" ) ;
276
+ OgImageError :: AvatarDownloadError {
277
+ url : ( * avatar) . to_string ( ) ,
278
+ source : err,
274
279
}
280
+ } ) ?;
275
281
276
- let content_length = response. content_length ( ) ;
277
- debug ! (
278
- url = %avatar,
279
- content_length = ?content_length,
280
- status = %response. status( ) ,
281
- "Avatar download response received"
282
- ) ;
283
-
284
- let bytes = response. bytes ( ) . await ;
285
- let bytes = bytes. map_err ( |err| {
286
- error ! ( url = %avatar, error = %err, "Failed to read avatar response bytes" ) ;
287
- OgImageError :: AvatarDownloadError {
288
- url : ( * avatar) . to_string ( ) ,
289
- source : err,
290
- }
291
- } ) ?;
292
-
293
- debug ! ( url = %avatar, size_bytes = bytes. len( ) , "Avatar downloaded successfully" ) ;
294
- bytes
295
- } ;
282
+ debug ! ( url = %avatar, size_bytes = bytes. len( ) , "Avatar downloaded successfully" ) ;
296
283
297
284
// Detect the image format and determine the appropriate file extension
298
285
let Some ( extension) = Self :: detect_image_format ( & bytes) else {
@@ -336,7 +323,7 @@ impl OgImageGenerator {
336
323
) ;
337
324
338
325
// Store the mapping from the avatar source to the numbered filename
339
- avatar_map. insert ( * avatar, filename) ;
326
+ avatar_map. insert ( avatar. as_ref ( ) , filename) ;
340
327
}
341
328
}
342
329
@@ -597,6 +584,7 @@ impl Default for OgImageGenerator {
597
584
#[ cfg( test) ]
598
585
mod tests {
599
586
use super :: * ;
587
+ use mockito:: { Server , ServerGuard } ;
600
588
use tracing:: dispatcher:: DefaultGuard ;
601
589
use tracing:: { Level , subscriber} ;
602
590
use tracing_subscriber:: fmt;
@@ -611,12 +599,50 @@ mod tests {
611
599
subscriber:: set_default ( subscriber)
612
600
}
613
601
602
+ async fn create_mock_avatar_server ( ) -> ServerGuard {
603
+ let mut server = Server :: new_async ( ) . await ;
604
+
605
+ // Mock for successful PNG avatar download
606
+ server
607
+ . mock ( "GET" , "/test-avatar.png" )
608
+ . with_status ( 200 )
609
+ . with_header ( "content-type" , "image/png" )
610
+ . with_body ( include_bytes ! ( "../template/assets/test-avatar.png" ) )
611
+ . create ( ) ;
612
+
613
+ // Mock for JPEG avatar download
614
+ server
615
+ . mock ( "GET" , "/test-avatar.jpg" )
616
+ . with_status ( 200 )
617
+ . with_header ( "content-type" , "image/jpeg" )
618
+ . with_body ( include_bytes ! ( "../template/assets/test-avatar.jpg" ) )
619
+ . create ( ) ;
620
+
621
+ // Mock for unsupported file type (WebP)
622
+ server
623
+ . mock ( "GET" , "/test-avatar.webp" )
624
+ . with_status ( 200 )
625
+ . with_header ( "content-type" , "image/webp" )
626
+ . with_body ( include_bytes ! ( "../template/assets/test-avatar.webp" ) )
627
+ . create ( ) ;
628
+
629
+ // Mock for 404 avatar download
630
+ server
631
+ . mock ( "GET" , "/missing-avatar.png" )
632
+ . with_status ( 404 )
633
+ . with_header ( "content-type" , "text/plain" )
634
+ . with_body ( "Not Found" )
635
+ . create ( ) ;
636
+
637
+ server
638
+ }
639
+
614
640
const fn author ( name : & str ) -> OgImageAuthorData < ' _ > {
615
641
OgImageAuthorData :: new ( name, None )
616
642
}
617
643
618
- const fn author_with_avatar ( name : & str ) -> OgImageAuthorData < ' _ > {
619
- OgImageAuthorData :: new ( name, Some ( "test-avatar" ) )
644
+ fn author_with_avatar ( name : & str , url : String ) -> OgImageAuthorData < ' _ > {
645
+ OgImageAuthorData :: with_url ( name, url )
620
646
}
621
647
622
648
fn create_minimal_test_data ( ) -> OgImageData < ' static > {
@@ -635,13 +661,18 @@ mod tests {
635
661
}
636
662
}
637
663
638
- fn create_escaping_test_data ( ) -> OgImageData < ' static > {
639
- static AUTHORS : & [ OgImageAuthorData < ' _ > ] = & [
640
- author_with_avatar ( "author \" with quotes\" " ) ,
664
+ fn create_escaping_authors ( server_url : & str ) -> Vec < OgImageAuthorData < ' _ > > {
665
+ vec ! [
666
+ author_with_avatar(
667
+ "author \" with quotes\" " ,
668
+ format!( "{server_url}/test-avatar.png" ) ,
669
+ ) ,
641
670
author( "author\\ with\\ backslashes" ) ,
642
671
author( "author#with#hashes" ) ,
643
- ] ;
672
+ ]
673
+ }
644
674
675
+ fn create_escaping_test_data < ' a > ( authors : & ' a [ OgImageAuthorData < ' a > ] ) -> OgImageData < ' a > {
645
676
OgImageData {
646
677
name : "crate-with-\" quotes\" " ,
647
678
version : "1.0.0-\" beta\" " ,
@@ -654,27 +685,35 @@ mod tests {
654
685
"tag\\ with\\ backslashes" ,
655
686
"tag#with#symbols" ,
656
687
] ,
657
- authors : AUTHORS ,
688
+ authors,
658
689
lines_of_code : Some ( 42 ) ,
659
690
crate_size : 256256 ,
660
691
releases : 5 ,
661
692
}
662
693
}
663
694
664
- fn create_overflow_test_data ( ) -> OgImageData < ' static > {
665
- static AUTHORS : & [ OgImageAuthorData < ' _ > ] = & [
666
- author_with_avatar ( "alice-wonderland" ) ,
695
+ fn create_overflow_authors ( server_url : & str ) -> Vec < OgImageAuthorData < ' _ > > {
696
+ vec ! [
697
+ author_with_avatar( "alice-wonderland" , format! ( "{server_url}/test-avatar.png" ) ) ,
667
698
author( "bob-the-builder" ) ,
668
- author_with_avatar ( "charlie-brown" ) ,
699
+ author_with_avatar( "charlie-brown" , format! ( "{server_url}/test-avatar.jpg" ) ) ,
669
700
author( "diana-prince" ) ,
670
- author_with_avatar ( "edward-scissorhands" ) ,
701
+ author_with_avatar(
702
+ "edward-scissorhands" ,
703
+ format!( "{server_url}/test-avatar.png" ) ,
704
+ ) ,
671
705
author( "fiona-apple" ) ,
672
- author ( "george-washington" ) ,
673
- author_with_avatar ( "helen-keller" ) ,
706
+ author_with_avatar(
707
+ "george-washington" ,
708
+ format!( "{server_url}/test-avatar.webp" ) ,
709
+ ) ,
710
+ author_with_avatar( "helen-keller" , format!( "{server_url}/test-avatar.jpg" ) ) ,
674
711
author( "isaac-newton" ) ,
675
712
author( "jane-doe" ) ,
676
- ] ;
713
+ ]
714
+ }
677
715
716
+ fn create_overflow_test_data < ' a > ( authors : & ' a [ OgImageAuthorData < ' a > ] ) -> OgImageData < ' a > {
678
717
OgImageData {
679
718
name : "super-long-crate-name-for-testing-overflow-behavior" ,
680
719
version : "2.1.0-beta.1+build.12345" ,
@@ -689,7 +728,7 @@ mod tests {
689
728
"serialization" ,
690
729
"networking" ,
691
730
] ,
692
- authors : AUTHORS ,
731
+ authors,
693
732
lines_of_code : Some ( 147000 ) ,
694
733
crate_size : 2847123 ,
695
734
releases : 1432 ,
@@ -757,7 +796,12 @@ mod tests {
757
796
#[ tokio:: test]
758
797
async fn test_generate_og_image_overflow_snapshot ( ) {
759
798
let _guard = init_tracing ( ) ;
760
- let data = create_overflow_test_data ( ) ;
799
+
800
+ let server = create_mock_avatar_server ( ) . await ;
801
+ let server_url = server. url ( ) ;
802
+
803
+ let authors = create_overflow_authors ( & server_url) ;
804
+ let data = create_overflow_test_data ( & authors) ;
761
805
762
806
if let Some ( image_data) = generate_image ( data) . await {
763
807
insta:: assert_binary_snapshot!( "generated_og_image_overflow.png" , image_data) ;
@@ -777,10 +821,44 @@ mod tests {
777
821
#[ tokio:: test]
778
822
async fn test_generate_og_image_escaping_snapshot ( ) {
779
823
let _guard = init_tracing ( ) ;
780
- let data = create_escaping_test_data ( ) ;
824
+
825
+ let server = create_mock_avatar_server ( ) . await ;
826
+ let server_url = server. url ( ) ;
827
+
828
+ let authors = create_escaping_authors ( & server_url) ;
829
+ let data = create_escaping_test_data ( & authors) ;
781
830
782
831
if let Some ( image_data) = generate_image ( data) . await {
783
832
insta:: assert_binary_snapshot!( "generated_og_image_escaping.png" , image_data) ;
784
833
}
785
834
}
835
+
836
+ #[ tokio:: test]
837
+ async fn test_generate_og_image_with_404_avatar ( ) {
838
+ let _guard = init_tracing ( ) ;
839
+
840
+ let server = create_mock_avatar_server ( ) . await ;
841
+ let server_url = server. url ( ) ;
842
+
843
+ // Create test data with a 404 avatar URL - should skip the avatar gracefully
844
+ let authors = vec ! [ author_with_avatar(
845
+ "test-user" ,
846
+ format!( "{server_url}/missing-avatar.png" ) ,
847
+ ) ] ;
848
+ let data = OgImageData {
849
+ name : "test-crate-404" ,
850
+ version : "1.0.0" ,
851
+ description : Some ( "A test crate with 404 avatar" ) ,
852
+ license : Some ( "MIT" ) ,
853
+ tags : & [ "testing" ] ,
854
+ authors : & authors,
855
+ lines_of_code : Some ( 1000 ) ,
856
+ crate_size : 42012 ,
857
+ releases : 1 ,
858
+ } ;
859
+
860
+ if let Some ( image_data) = generate_image ( data) . await {
861
+ insta:: assert_binary_snapshot!( "404-avatar.png" , image_data) ;
862
+ }
863
+ }
786
864
}
0 commit comments