@@ -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,34 @@ 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 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 404 avatar download
614
+ server
615
+ . mock ( "GET" , "/missing-avatar.png" )
616
+ . with_status ( 404 )
617
+ . with_header ( "content-type" , "text/plain" )
618
+ . with_body ( "Not Found" )
619
+ . create ( ) ;
620
+
621
+ server
622
+ }
623
+
614
624
const fn author ( name : & str ) -> OgImageAuthorData < ' _ > {
615
625
OgImageAuthorData :: new ( name, None )
616
626
}
617
627
618
- const fn author_with_avatar ( name : & str ) -> OgImageAuthorData < ' _ > {
619
- OgImageAuthorData :: new ( name, Some ( "test-avatar" ) )
628
+ fn author_with_avatar ( name : & str , url : String ) -> OgImageAuthorData < ' _ > {
629
+ OgImageAuthorData :: with_url ( name, url )
620
630
}
621
631
622
632
fn create_minimal_test_data ( ) -> OgImageData < ' static > {
@@ -635,13 +645,18 @@ mod tests {
635
645
}
636
646
}
637
647
638
- fn create_escaping_test_data ( ) -> OgImageData < ' static > {
639
- static AUTHORS : & [ OgImageAuthorData < ' _ > ] = & [
640
- author_with_avatar ( "author \" with quotes\" " ) ,
648
+ fn create_escaping_authors ( server_url : & str ) -> Vec < OgImageAuthorData < ' _ > > {
649
+ vec ! [
650
+ author_with_avatar(
651
+ "author \" with quotes\" " ,
652
+ format!( "{server_url}/test-avatar.png" ) ,
653
+ ) ,
641
654
author( "author\\ with\\ backslashes" ) ,
642
655
author( "author#with#hashes" ) ,
643
- ] ;
656
+ ]
657
+ }
644
658
659
+ fn create_escaping_test_data < ' a > ( authors : & ' a [ OgImageAuthorData < ' a > ] ) -> OgImageData < ' a > {
645
660
OgImageData {
646
661
name : "crate-with-\" quotes\" " ,
647
662
version : "1.0.0-\" beta\" " ,
@@ -654,27 +669,30 @@ mod tests {
654
669
"tag\\ with\\ backslashes" ,
655
670
"tag#with#symbols" ,
656
671
] ,
657
- authors : AUTHORS ,
672
+ authors,
658
673
lines_of_code : Some ( 42 ) ,
659
674
crate_size : 256256 ,
660
675
releases : 5 ,
661
676
}
662
677
}
663
678
664
- fn create_overflow_test_data ( ) -> OgImageData < ' static > {
665
- static AUTHORS : & [ OgImageAuthorData < ' _ > ] = & [
666
- author_with_avatar ( "alice-wonderland" ) ,
679
+ fn create_overflow_authors ( server_url : & str ) -> Vec < OgImageAuthorData < ' _ > > {
680
+ let avatar_url = format ! ( "{server_url}/test-avatar.png" ) ;
681
+ vec ! [
682
+ author_with_avatar( "alice-wonderland" , avatar_url. clone( ) ) ,
667
683
author( "bob-the-builder" ) ,
668
- author_with_avatar ( "charlie-brown" ) ,
684
+ author_with_avatar( "charlie-brown" , avatar_url . clone ( ) ) ,
669
685
author( "diana-prince" ) ,
670
- author_with_avatar ( "edward-scissorhands" ) ,
686
+ author_with_avatar( "edward-scissorhands" , avatar_url . clone ( ) ) ,
671
687
author( "fiona-apple" ) ,
672
688
author( "george-washington" ) ,
673
- author_with_avatar ( "helen-keller" ) ,
689
+ author_with_avatar( "helen-keller" , avatar_url . clone ( ) ) ,
674
690
author( "isaac-newton" ) ,
675
691
author( "jane-doe" ) ,
676
- ] ;
692
+ ]
693
+ }
677
694
695
+ fn create_overflow_test_data < ' a > ( authors : & ' a [ OgImageAuthorData < ' a > ] ) -> OgImageData < ' a > {
678
696
OgImageData {
679
697
name : "super-long-crate-name-for-testing-overflow-behavior" ,
680
698
version : "2.1.0-beta.1+build.12345" ,
@@ -689,7 +707,7 @@ mod tests {
689
707
"serialization" ,
690
708
"networking" ,
691
709
] ,
692
- authors : AUTHORS ,
710
+ authors,
693
711
lines_of_code : Some ( 147000 ) ,
694
712
crate_size : 2847123 ,
695
713
releases : 1432 ,
@@ -757,7 +775,12 @@ mod tests {
757
775
#[ tokio:: test]
758
776
async fn test_generate_og_image_overflow_snapshot ( ) {
759
777
let _guard = init_tracing ( ) ;
760
- let data = create_overflow_test_data ( ) ;
778
+
779
+ let server = create_mock_avatar_server ( ) . await ;
780
+ let server_url = server. url ( ) ;
781
+
782
+ let authors = create_overflow_authors ( & server_url) ;
783
+ let data = create_overflow_test_data ( & authors) ;
761
784
762
785
if let Some ( image_data) = generate_image ( data) . await {
763
786
insta:: assert_binary_snapshot!( "generated_og_image_overflow.png" , image_data) ;
@@ -777,10 +800,44 @@ mod tests {
777
800
#[ tokio:: test]
778
801
async fn test_generate_og_image_escaping_snapshot ( ) {
779
802
let _guard = init_tracing ( ) ;
780
- let data = create_escaping_test_data ( ) ;
803
+
804
+ let server = create_mock_avatar_server ( ) . await ;
805
+ let server_url = server. url ( ) ;
806
+
807
+ let authors = create_escaping_authors ( & server_url) ;
808
+ let data = create_escaping_test_data ( & authors) ;
781
809
782
810
if let Some ( image_data) = generate_image ( data) . await {
783
811
insta:: assert_binary_snapshot!( "generated_og_image_escaping.png" , image_data) ;
784
812
}
785
813
}
814
+
815
+ #[ tokio:: test]
816
+ async fn test_generate_og_image_with_404_avatar ( ) {
817
+ let _guard = init_tracing ( ) ;
818
+
819
+ let server = create_mock_avatar_server ( ) . await ;
820
+ let server_url = server. url ( ) ;
821
+
822
+ // Create test data with a 404 avatar URL - should skip the avatar gracefully
823
+ let authors = vec ! [ author_with_avatar(
824
+ "test-user" ,
825
+ format!( "{server_url}/missing-avatar.png" ) ,
826
+ ) ] ;
827
+ let data = OgImageData {
828
+ name : "test-crate-404" ,
829
+ version : "1.0.0" ,
830
+ description : Some ( "A test crate with 404 avatar" ) ,
831
+ license : Some ( "MIT" ) ,
832
+ tags : & [ "testing" ] ,
833
+ authors : & authors,
834
+ lines_of_code : Some ( 1000 ) ,
835
+ crate_size : 42012 ,
836
+ releases : 1 ,
837
+ } ;
838
+
839
+ if let Some ( image_data) = generate_image ( data) . await {
840
+ insta:: assert_binary_snapshot!( "404-avatar.png" , image_data) ;
841
+ }
842
+ }
786
843
}
0 commit comments