• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pulibrary / bibdata / 413e5868-e826-4c65-9bd3-08065f10090e

27 Aug 2025 08:53PM UTC coverage: 90.738% (-1.7%) from 92.414%
413e5868-e826-4c65-9bd3-08065f10090e

push

circleci

christinach
Update Thumbnail struct to have thumbnail_url only
Update spec and fetch_thumbnail so that test passes

5 of 6 new or added lines in 2 files covered. (83.33%)

7710 of 8497 relevant lines covered (90.74%)

409.25 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.67
/lib/bibdata_rs/src/ephemera/ephemera_folder.rs
1
use crate::solr::{self, AccessFacet, DigitalContent};
2

3
use super::{
4
    born_digital_collection::ephemera_folders_iterator,
5
    ephemera_folder_builder::EphemeraFolderBuilder,
6
};
7
use log::trace;
8
use serde::Deserialize;
9

10
pub mod country;
11
pub mod coverage;
12
pub mod format;
13
pub mod language;
14
pub mod origin_place;
15
pub mod subject;
16

17
use coverage::Coverage;
18
use format::Format;
19
use language::Language;
20
use origin_place::OriginPlace;
21
use serde_json::Value;
22
use subject::Subject;
23

24
#[derive(Deserialize, Debug)]
25
pub struct EphemeraFolder {
26
    pub alternative: Option<Vec<String>>,
27
    pub creator: Option<Vec<String>>,
28
    pub contributor: Option<Vec<String>>,
29
    pub coverage: Option<Vec<Coverage>>,
30
    pub date_created: Option<Vec<String>>,
31
    pub description: Option<Vec<String>>,
32
    pub electronic_access: Option<Vec<solr::ElectronicAccess>>,
33
    pub format: Option<Vec<Format>>,
34
    #[serde(rename = "@id")]
35
    pub id: String,
36
    pub language: Option<Vec<Language>>,
37
    #[serde(rename = "origin_place")]
38
    pub origin: Option<Vec<OriginPlace>>,
39
    pub page_count: Option<String>,
40
    pub provenance: Option<String>,
41
    pub publisher: Option<Vec<String>>,
42
    pub subject: Option<Vec<Subject>>,
43
    pub sort_title: Option<Vec<String>>,
44
    pub thumbnail: Option<Thumbnail>,
45
    pub title: Vec<String>,
46
    pub transliterated_title: Option<Vec<String>>,
47
}
48

49
#[derive(Debug, Deserialize, PartialEq)]
50
pub struct Thumbnail {
51
    #[serde(rename = "@id")]
52
    pub thumbnail_url: String,
53
}
54

55
impl EphemeraFolder {
56
    pub fn builder() -> EphemeraFolderBuilder {
24✔
57
        EphemeraFolderBuilder::new()
24✔
58
    }
24✔
59

60
    pub fn solr_formats(&self) -> Vec<solr::FormatFacet> {
58✔
61
        match &self.format {
58✔
62
            Some(formats) => formats.iter().filter_map(|f| f.pref_label).collect(),
38✔
63
            None => vec![],
20✔
64
        }
65
    }
58✔
66

67
    pub fn coverage_labels(&self) -> Vec<String> {
59✔
68
        match &self.coverage {
59✔
69
            Some(coverage_vector) => coverage_vector
39✔
70
                .iter()
39✔
71
                .filter(|coverage| coverage.exact_match.accepted_vocabulary())
114✔
72
                .map(|coverage| coverage.label.clone())
113✔
73
                .collect(),
39✔
74
            None => vec![],
20✔
75
        }
76
    }
59✔
77

78
    pub fn origin_place_labels(&self) -> Vec<String> {
174✔
79
        match &self.origin {
174✔
80
            Some(origin_vector) => origin_vector
117✔
81
                .iter()
117✔
82
                .filter(|origin| origin.exact_match.accepted_vocabulary())
117✔
83
                .map(|origin| origin.label.clone())
117✔
84
                .collect(),
117✔
85
            None => vec![],
57✔
86
        }
87
    }
174✔
88

89
    pub fn homoit_subject_labels(&self) -> Option<Vec<String>> {
116✔
90
        self.subject.as_ref().map(|subjects| {
116✔
91
            subjects
78✔
92
                .iter()
78✔
93
                .filter(|s| s.exact_match.accepted_homoit_vocabulary())
152✔
94
                .map(|s| s.label.clone())
78✔
95
                .collect()
78✔
96
        })
78✔
97
    }
116✔
98

99
    pub fn lc_subject_labels(&self) -> Option<Vec<String>> {
116✔
100
        self.subject.as_ref().map(|subjects| {
116✔
101
            subjects
78✔
102
                .iter()
78✔
103
                .filter(|s| s.exact_match.accepted_loc_vocabulary())
152✔
104
                .map(|s| s.label.clone())
150✔
105
                .collect()
78✔
106
        })
78✔
107
    }
116✔
108

109
    pub fn language_labels(&self) -> Vec<String> {
116✔
110
        match &self.language {
116✔
111
            Some(languages) => languages.iter().map(|l| l.label.clone()).collect(),
148✔
112
            None => vec![],
42✔
113
        }
114
    }
116✔
115

116
    pub fn all_contributors(&self) -> Vec<String> {
116✔
117
        let mut all_contributors = Vec::default();
116✔
118
        all_contributors.extend(self.creator.clone().unwrap_or_default());
116✔
119
        all_contributors.extend(self.contributor.clone().unwrap_or_default());
116✔
120
        all_contributors
116✔
121
    }
116✔
122

123
    pub fn first_contibutor(&self) -> Option<String> {
58✔
124
        self.all_contributors().first().cloned()
58✔
125
    }
58✔
126

127
    pub fn date_created_year(&self) -> Option<i16> {
58✔
128
        self.date_created
58✔
129
            .as_ref()?
58✔
130
            .iter()
40✔
131
            .find_map(|date_str| date_str.get(0..4)?.parse::<i16>().ok())
40✔
132
    }
58✔
133

134
    pub fn other_title_display_combined(&self) -> Vec<String> {
58✔
135
        let mut combined = self.alternative.clone().unwrap_or_default();
58✔
136
        combined.extend(self.transliterated_title.clone().unwrap_or_default());
58✔
137
        combined
58✔
138
    }
58✔
139

140
    pub fn concat_page_count(&self) -> Vec<String> {
58✔
141
        match self.page_count.clone() {
58✔
142
            Some(page_count) => vec![format!("pages: {}", page_count)],
38✔
143
            None => Vec::new(),
20✔
144
        }
145
    }
58✔
146

147
    pub fn date_created_publisher_combined(&self) -> Vec<String> {
58✔
148
        let mut combined = self.date_created.clone().unwrap_or_default();
58✔
149
        combined.extend(self.publisher.clone().unwrap_or_default());
58✔
150
        combined
58✔
151
    }
58✔
152

153
    pub fn origin_place_publisher_date_created_combined(&self) -> Vec<String> {
116✔
154
        let origin = self
116✔
155
            .origin_place_labels()
116✔
156
            .first()
116✔
157
            .cloned()
116✔
158
            .unwrap_or_default();
116✔
159
        let publisher = self
116✔
160
            .publisher
116✔
161
            .clone()
116✔
162
            .unwrap_or_default()
116✔
163
            .first()
116✔
164
            .cloned()
116✔
165
            .unwrap_or_default();
116✔
166
        let date = self
116✔
167
            .date_created
116✔
168
            .clone()
116✔
169
            .unwrap_or_default()
116✔
170
            .first()
116✔
171
            .cloned()
116✔
172
            .unwrap_or_default();
116✔
173
        let mut result = Vec::new();
116✔
174
        if !origin.is_empty() || !publisher.is_empty() || !date.is_empty() {
116✔
175
            let mut s = String::new();
84✔
176
            if !origin.is_empty() {
84✔
177
                s.push_str(&origin);
78✔
178
            }
78✔
179
            if !publisher.is_empty() {
84✔
180
                if !s.is_empty() {
80✔
181
                    s.push_str(": ");
76✔
182
                }
76✔
183
                s.push_str(&publisher);
80✔
184
            }
4✔
185
            if !date.is_empty() {
84✔
186
                if !s.is_empty() {
80✔
187
                    s.push_str(", ");
78✔
188
                }
78✔
189
                s.push_str(&date);
80✔
190
            }
4✔
191
            result.push(s);
84✔
192
        }
32✔
193
        result
116✔
194
    }
116✔
195
    pub fn first_sort_title(&self) -> Option<String> {
58✔
196
        self.sort_title
58✔
197
            .as_ref()
58✔
198
            .or(Some(&self.title))?
58✔
199
            .first()
58✔
200
            .cloned()
58✔
201
    }
58✔
202
    pub fn access_facet(&self) -> Option<AccessFacet> {
58✔
203
        Some(AccessFacet::Online)
58✔
204
    }
58✔
205
    pub async fn fetch_thumbnail(&self, domain: &str) -> Result<Option<Thumbnail>, anyhow::Error> {
1✔
206
        let manifest_url = format!(
1✔
207
            "{domain}/concern/ephemera_folders/{}/manifest",
1✔
208
            self.normalized_id()
1✔
209
        );
210
        let resp = reqwest::get(&manifest_url).await?.text().await?;
1✔
211
        let manifest: Value = serde_json::from_str(&resp)?;
1✔
212
        if let Some(thumbnail_json) = manifest.get("thumbnail") {
1✔
213
            let thumbnail: Thumbnail = serde_json::from_value(thumbnail_json.clone())?;
1✔
214
            Ok(Some(thumbnail))
1✔
215
        } else {
NEW
216
            Ok(None)
×
217
        }
218
    }
1✔
219
    pub fn electronic_access(&self) -> Option<solr::ElectronicAccess> {
59✔
220
        Some(solr::ElectronicAccess {
59✔
221
            url: self.id.clone(),
59✔
222
            link_text: "Online Content".to_owned(),
59✔
223
            link_description: Some("Born Digital Monographic Reports and Papers".to_owned()),
59✔
224
            iiif_manifest_paths: Some(format!(
59✔
225
                "https://figgy.princeton.edu/concern/ephemera_folders/{}/manifest",
59✔
226
                self.normalized_id()
59✔
227
            )),
59✔
228
            digital_content: Some(DigitalContent {
59✔
229
                link_text: vec!["Digital content".to_owned()],
59✔
230
                url: format!(
59✔
231
                    "https://catalog-staging.princeton.edu/catalog/{}#view",
59✔
232
                    self.normalized_id()
59✔
233
                ),
59✔
234
            }),
59✔
235
        })
59✔
236
    }
59✔
237
    pub fn normalized_id(&self) -> String {
177✔
238
        self.id
177✔
239
            .split('/')
177✔
240
            .last()
177✔
241
            .unwrap_or(&self.id)
177✔
242
            .trim()
177✔
243
            .to_string()
177✔
244
    }
177✔
245
}
246

247
#[allow(dead_code)]
248
#[derive(Deserialize, Debug)]
249
pub struct ItemResponse {
250
    pub data: Vec<EphemeraFolder>,
251
}
252

253
pub fn json_ephemera_document(url: String) -> Result<String, magnus::Error> {
2✔
254
    #[cfg(not(test))]
255
    env_logger::init();
256
    let rt = tokio::runtime::Runtime::new()
2✔
257
        .map_err(|e| magnus::Error::new(magnus::exception::runtime_error(), e.to_string()))?;
2✔
258
    rt.block_on(async {
2✔
259
        let folder_results = ephemera_folders_iterator(&url, 1_000)
2✔
260
            .await
2✔
261
            .map_err(|e| magnus::Error::new(magnus::exception::runtime_error(), e.to_string()))?;
2✔
262
        let combined_json = folder_results.join(",");
2✔
263
        trace!("The JSON that we will post to Solr is {}", combined_json);
2✔
264
        Ok(combined_json)
2✔
265
    })
2✔
266
}
2✔
267

268
#[cfg(test)]
269
mod tests {
270
    use crate::{
271
        ephemera::{ephemera_folder::country::ExactMatch, CatalogClient},
272
        solr,
273
        testing_support::{preserving_envvar, preserving_envvar_async},
274
    };
275

276
    use super::*;
277
    use std::fs::File;
278
    use std::io::BufReader;
279

280
    #[tokio::test]
281
    async fn test_get_item_data() {
1✔
282
        preserving_envvar_async("FIGGY_BORN_DIGITAL_EPHEMERA_URL", || async {
1✔
283
            let mut server = mockito::Server::new_async().await;
1✔
284

1✔
285
            let mock = server
1✔
286
                .mock(
1✔
287
                    "GET",
1✔
288
                    "/catalog/af4a941d-96a4-463e-9043-cfa512e5eddd.jsonld",
1✔
289
                )
1✔
290
                .with_status(200)
1✔
291
                .with_header("content-type", "application/json")
1✔
292
                .with_body_from_file("../../spec/fixtures/files/ephemera/ephemera1.json")
1✔
293
                .create_async()
1✔
294
                .await;
1✔
295

1✔
296
            let client = CatalogClient::new(server.url());
1✔
297
            let result = client
1✔
298
                .get_item_data("af4a941d-96a4-463e-9043-cfa512e5eddd")
1✔
299
                .await;
1✔
300

1✔
301
            mock.assert_async().await;
1✔
302

1✔
303
            match result {
1✔
304
                Ok(_response) => assert!(true),
1✔
305
                Err(e) => panic!("Request failed: {}", e),
1✔
306
            }
1✔
307
        })
2✔
308
        .await
1✔
309
    }
1✔
310

311
    #[test]
312
    fn it_can_read_the_format_from_json_ld() {
1✔
313
        let file = File::open("../../spec/fixtures/files/ephemera/ephemera1.json").unwrap();
1✔
314
        let reader = BufReader::new(file);
1✔
315

316
        let ephemera_folder_item: EphemeraFolder = serde_json::from_reader(reader).unwrap();
1✔
317
        assert_eq!(
1✔
318
            ephemera_folder_item.format.unwrap()[0].pref_label,
1✔
319
            Some(solr::FormatFacet::Book)
320
        );
321
    }
1✔
322

323
    #[tokio::test]
324
    async fn it_can_read_the_digital_content_from_json_ld() {
1✔
325
        let file = File::open("../../spec/fixtures/files/ephemera/ephemera1.json").unwrap();
1✔
326
        let reader = BufReader::new(file);
1✔
327

328
        let ephemera_folder_item: EphemeraFolder = serde_json::from_reader(reader).unwrap();
1✔
329
        let digital_content = ephemera_folder_item
1✔
330
            .electronic_access()
1✔
331
            .unwrap()
1✔
332
            .digital_content
1✔
333
            .unwrap();
1✔
334
        assert_eq!(
1✔
335
            digital_content.link_text,
336
            vec!["Digital content".to_string()]
1✔
337
        );
338
        assert_eq!(
1✔
339
            digital_content.url,
1✔
340
            "https://catalog-staging.princeton.edu/catalog/af4a941d-96a4-463e-9043-cfa512e5eddd#view".to_string()
1✔
341
        );
1✔
342
    }
1✔
343

344
    #[test]
345
    fn test_json_ephemera_document() {
1✔
346
        preserving_envvar("FIGGY_BORN_DIGITAL_EPHEMERA_URL", || {
1✔
347
            let mut server = mockito::Server::new();
1✔
348

349
            let folder_mock = server
1✔
350
                .mock("GET", "/catalog.json?f%5Bephemera_project_ssim%5D%5B%5D=Born+Digital+Monographic+Reports+and+Papers&f%5Bhuman_readable_type_ssim%5D%5B%5D=Ephemera+Folder&f%5Bstate_ssim%5D%5B%5D=complete&per_page=100&q=")
1✔
351
                .with_status(200)
1✔
352
                .with_header("content-type", "application/json")
1✔
353
                .with_body_from_file("../../spec/fixtures/files/ephemera/ephemera_folders.json")
1✔
354
                .create();
1✔
355

356
            let item_mock = server
1✔
357
                .mock(
1✔
358
                    "GET",
1✔
359
                    mockito::Matcher::Regex(
1✔
360
                        r"^/catalog/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}.jsonld$"
1✔
361
                            .to_string(),
1✔
362
                    ),
1✔
363
                )
1✔
364
                .with_status(200)
1✔
365
                .with_header("content-type", "application/json")
1✔
366
                .with_body_from_file("../../spec/fixtures/files/ephemera/ephemera1.json")
1✔
367
                .expect(12)
1✔
368
                .create();
1✔
369

370
            let result = json_ephemera_document(server.url().to_string()).unwrap();
1✔
371

372
            let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1✔
373
            assert!(parsed.is_array());
1✔
374

375
            folder_mock.assert();
1✔
376
            item_mock.assert();
1✔
377
        });
1✔
378
    }
1✔
379

380
    mod no_transliterated_title {
381

382
        use rb_sys_test_helpers::ruby_test;
383

384
        use super::*;
385
        #[ruby_test]
386
        fn test_json_ephemera_document_with_no_transliterated_title() {
387
            let mut server = mockito::Server::new();
388

389
            let folder_mock = server
390
                .mock("GET", "/catalog.json?f%5Bephemera_project_ssim%5D%5B%5D=Born+Digital+Monographic+Reports+and+Papers&f%5Bhuman_readable_type_ssim%5D%5B%5D=Ephemera+Folder&f%5Bstate_ssim%5D%5B%5D=complete&per_page=100&q=")
391
                .with_status(200)
392
                .with_header("content-type", "application/json")
393
                .with_body_from_file("../../spec/fixtures/files/ephemera/ephemera_folders.json")
394
                .create();
395

396
            let item_mock = server
397
                .mock(
398
                    "GET",
399
                    mockito::Matcher::Regex(
400
                        r"^/catalog/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}.jsonld$"
401
                            .to_string(),
402
                    ),
403
                )
404
                .with_status(200)
405
                .with_header("content-type", "application/json")
406
                .with_body_from_file(
407
                    "../../spec/fixtures/files/ephemera/ephemera_no_transliterated_title.json",
408
                )
409
                .expect(12)
410
                .create();
411

412
            let result = json_ephemera_document(server.url().to_string()).unwrap();
413
            let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
414
            assert!(parsed.is_array());
415
            folder_mock.assert();
416
            item_mock.assert();
417
        }
418
    }
419

420
    #[test]
421
    fn it_can_return_coverage_labels_for_authorized_vocabularies() {
1✔
422
        let item = EphemeraFolder::builder()
1✔
423
            .id("123ABC".to_string())
1✔
424
            .title(vec!["The worst book ever!".to_string()])
1✔
425
            .coverage(vec![
1✔
426
                Coverage {
1✔
427
                    exact_match: ExactMatch {
1✔
428
                        id: "http://id.loc.gov/vocabulary/countries/an".to_string(),
1✔
429
                    },
1✔
430
                    label: "Andorra".to_string(),
1✔
431
                },
1✔
432
                Coverage {
1✔
433
                    exact_match: ExactMatch {
1✔
434
                        id: "http://bad-bad-bad".to_string(),
1✔
435
                    },
1✔
436
                    label: "Anguilla".to_string(),
1✔
437
                },
1✔
438
            ])
439
            .build()
1✔
440
            .unwrap();
1✔
441
        assert_eq!(item.coverage_labels(), vec!["Andorra".to_string()]);
1✔
442
    }
1✔
443
    #[tokio::test]
444
    async fn it_can_fetch_thumbnail() {
1✔
445
        let folder = EphemeraFolder::builder()
1✔
446
            .id("fa30780e-dfd8-4545-b1b0-b3eec9fca96b".to_string())
1✔
447
            .title(vec!["The worst book ever!".to_string()])
1✔
448
            .build()
1✔
449
            .unwrap();
1✔
450
        let mut server = mockito::Server::new_async().await;
1✔
451

452
        let mock = server
1✔
453
            .mock(
1✔
454
                "GET",
1✔
455
                "/concern/ephemera_folders/fa30780e-dfd8-4545-b1b0-b3eec9fca96b/manifest",
1✔
456
            )
1✔
457
            .with_status(200)
1✔
458
            .with_header("content-type", "application/json")
1✔
459
            .with_body_from_file("../../spec/fixtures/files/ephemera/ephemera_manifest.json")
1✔
460
            .create_async()
1✔
461
            .await;
1✔
462
        let result = folder
1✔
463
            .fetch_thumbnail(&server.url())
1✔
464
            .await
1✔
465
            .unwrap()
1✔
466
            .unwrap();
1✔
467
        mock.assert_async().await;
1✔
468

469
        assert_eq!(result.thumbnail_url, "https://iiif-cloud.princeton.edu/iiif/2/c9%2Fa6%2F2b%2Fc9a62b81f8014b13933f4cf462c092dc%2Fintermediate_file/full/!200,150/0/default.jpg".to_string());
1✔
470
    }
1✔
471
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc