@hraness/wordcell 0.24.1 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,705 @@
1
+ //! Explicit opt-in scholarly discovery. Indexed links are not fetched body evidence.
2
+ use super::FailureCode;
3
+ use metadata_search_engine_rs::models::SearchResult;
4
+ use reqwest::{Client, header};
5
+ use serde_json::Value;
6
+ use std::time::Duration;
7
+ use url::Url;
8
+
9
+ const ENDPOINT: &str = "https://api.openalex.org/works";
10
+ const MAX_BODY: usize = 2 * 1024 * 1024;
11
+ const MAX_TEXT: usize = 16 * 1024;
12
+ pub struct Discovery {
13
+ pub results: Vec<SearchResult>,
14
+ pub reported_cost_usd: Option<f64>,
15
+ }
16
+ impl std::fmt::Debug for Discovery {
17
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18
+ f.write_str("Discovery")
19
+ }
20
+ }
21
+ type Outcome = Result<Discovery, FailureCode>;
22
+
23
+ pub async fn search(client: &Client, query: &str, limit: usize, timeout: Duration) -> Outcome {
24
+ search_at(client, ENDPOINT, query, limit, timeout).await
25
+ }
26
+
27
+ async fn search_at(
28
+ client: &Client,
29
+ endpoint: &str,
30
+ query: &str,
31
+ limit: usize,
32
+ timeout: Duration,
33
+ ) -> Outcome {
34
+ tokio::time::timeout(timeout, async {
35
+ let mut response = client
36
+ .get(endpoint)
37
+ .query(&[("search", query), ("per-page", &limit.to_string())])
38
+ .send()
39
+ .await
40
+ .map_err(|error| super::transport_code(&error, FailureCode::Http))?;
41
+ if !response.status().is_success() {
42
+ return Err(super::status_code(response.status().as_u16()));
43
+ }
44
+ let content_type = response
45
+ .headers()
46
+ .get(header::CONTENT_TYPE)
47
+ .and_then(|v| v.to_str().ok())
48
+ .unwrap_or("")
49
+ .split(';')
50
+ .next()
51
+ .unwrap_or("")
52
+ .trim();
53
+ if !["application/json"]
54
+ .iter()
55
+ .any(|v| content_type.eq_ignore_ascii_case(v))
56
+ {
57
+ return Err(FailureCode::ContentType);
58
+ }
59
+ if response.headers().contains_key(header::CONTENT_ENCODING) {
60
+ return Err(FailureCode::ContentEncoding);
61
+ }
62
+ if response
63
+ .content_length()
64
+ .is_some_and(|length| length > MAX_BODY as u64)
65
+ {
66
+ return Err(FailureCode::BodyLimit);
67
+ }
68
+ let mut body = Vec::new();
69
+ while let Some(chunk) = response
70
+ .chunk()
71
+ .await
72
+ .map_err(|error| super::transport_code(&error, FailureCode::HttpBody))?
73
+ {
74
+ if chunk.len() > MAX_BODY.saturating_sub(body.len()) {
75
+ return Err(FailureCode::BodyLimit);
76
+ }
77
+ body.extend_from_slice(&chunk);
78
+ }
79
+ parse(
80
+ std::str::from_utf8(&body).map_err(|_| FailureCode::BodyEncoding)?,
81
+ limit,
82
+ query,
83
+ )
84
+ })
85
+ .await
86
+ .map_err(|_| FailureCode::Timeout)?
87
+ }
88
+
89
+ fn destination(raw: &str) -> Result<Option<String>, FailureCode> {
90
+ if raw.len() > 8192 {
91
+ return Err(FailureCode::BodyLimit);
92
+ }
93
+ let Ok(url) = Url::parse(raw) else {
94
+ return Ok(None);
95
+ };
96
+ let Some(host) = url.host_str() else {
97
+ return Ok(None);
98
+ };
99
+ let host = host.trim_end_matches('.');
100
+ if url.scheme() != "https"
101
+ || !url.username().is_empty()
102
+ || url.password().is_some()
103
+ || url.port().is_some_and(|p| p != 443)
104
+ || !host.contains('.')
105
+ || host.ends_with(".localhost")
106
+ || host.ends_with(".local")
107
+ || host
108
+ .trim_matches(['[', ']'])
109
+ .parse()
110
+ .is_ok_and(super::is_private_or_reserved)
111
+ {
112
+ return Ok(None);
113
+ }
114
+ Ok(Some(url.into()))
115
+ }
116
+
117
+ fn parse(body: &str, limit: usize, query: &str) -> Outcome {
118
+ if body.len() > MAX_BODY {
119
+ return Err(FailureCode::BodyLimit);
120
+ }
121
+ if limit == 0 || limit > 20 {
122
+ return Err(FailureCode::ResultLimit);
123
+ }
124
+ let value: Value = serde_json::from_str(body).map_err(|_| FailureCode::Parse)?;
125
+ let meta = value
126
+ .get("meta")
127
+ .and_then(Value::as_object)
128
+ .ok_or(FailureCode::Parse)?;
129
+ if meta.get("page").and_then(Value::as_u64) != Some(1)
130
+ || meta.get("per_page").and_then(Value::as_u64) != Some(limit as u64)
131
+ || meta.get("count").and_then(Value::as_u64).is_none()
132
+ {
133
+ return Err(FailureCode::Parse);
134
+ }
135
+ let observed = meta
136
+ .get("x_query")
137
+ .and_then(|x| x.get("oqo"))
138
+ .ok_or(FailureCode::QueryMismatch)?;
139
+ let filters = observed
140
+ .get("filter_rows")
141
+ .and_then(Value::as_array)
142
+ .ok_or(FailureCode::QueryMismatch)?;
143
+ if observed.get("get_rows").and_then(Value::as_str) != Some("works")
144
+ || filters.len() != 1
145
+ || filters[0].get("column_id").and_then(Value::as_str) != Some("fulltext.search")
146
+ || filters[0].get("operator").and_then(Value::as_str) != Some("has")
147
+ || filters[0].get("value").and_then(Value::as_str) != Some(query)
148
+ {
149
+ return Err(FailureCode::QueryMismatch);
150
+ }
151
+ let rows = value
152
+ .get("results")
153
+ .and_then(Value::as_array)
154
+ .ok_or(FailureCode::Parse)?;
155
+ if rows.len() > limit {
156
+ return Err(FailureCode::ResultLimit);
157
+ }
158
+ if (rows.is_empty() && meta["count"].as_u64().unwrap() != 0)
159
+ || meta["count"].as_u64().unwrap() < rows.len() as u64
160
+ {
161
+ return Err(FailureCode::Parse);
162
+ }
163
+ let reported_cost_usd = match meta.get("cost_usd") {
164
+ None => None,
165
+ Some(value) => {
166
+ let cost = value.as_f64().ok_or(FailureCode::Parse)?;
167
+ if !cost.is_finite() || !(0.0..=1_000_000.0).contains(&cost) {
168
+ return Err(FailureCode::Parse);
169
+ }
170
+ Some(cost)
171
+ }
172
+ };
173
+ let mut known_retracted_ids = std::collections::HashSet::new();
174
+ let mut observed_ids = Vec::with_capacity(rows.len());
175
+ let mut results = Vec::new();
176
+ for row in rows {
177
+ let id = row
178
+ .get("id")
179
+ .and_then(Value::as_str)
180
+ .ok_or(FailureCode::Parse)?;
181
+ let suffix = id
182
+ .strip_prefix("https://openalex.org/W")
183
+ .ok_or(FailureCode::Parse)?;
184
+ if suffix.is_empty() || suffix.len() > 24 || !suffix.bytes().all(|b| b.is_ascii_digit()) {
185
+ return Err(FailureCode::Parse);
186
+ }
187
+ let title = row
188
+ .get("title")
189
+ .and_then(Value::as_str)
190
+ .ok_or(FailureCode::Parse)?;
191
+ if title.trim().is_empty() || title.len() > MAX_TEXT {
192
+ return Err(FailureCode::Parse);
193
+ }
194
+ let title = title.split_whitespace().collect::<Vec<_>>().join(" ");
195
+ // Absence is unknown; only an explicit flag or title marker excludes a work.
196
+ let retracted = match row.get("is_retracted") {
197
+ None => false,
198
+ Some(value) => value.as_bool().ok_or(FailureCode::Parse)?,
199
+ };
200
+ const RETRACTED_PREFIX: &str = "RETRACTED ARTICLE:";
201
+ let retracted = retracted
202
+ || title
203
+ .get(..RETRACTED_PREFIX.len())
204
+ .is_some_and(|prefix| prefix.eq_ignore_ascii_case(RETRACTED_PREFIX));
205
+ observed_ids.push(id);
206
+ let mut candidates = Vec::new();
207
+ let mut locations = Vec::new();
208
+ for field in ["best_oa_location", "primary_location"] {
209
+ match row.get(field) {
210
+ None | Some(Value::Null) => {}
211
+ Some(Value::Object(location)) => {
212
+ if field == "best_oa_location"
213
+ && location.get("is_oa").and_then(Value::as_bool) != Some(true)
214
+ {
215
+ return Err(FailureCode::Parse);
216
+ }
217
+ locations.push(location);
218
+ }
219
+ _ => return Err(FailureCode::Parse),
220
+ }
221
+ }
222
+ match row.get("locations") {
223
+ None | Some(Value::Null) => {}
224
+ Some(Value::Array(values)) if values.len() <= 256 => {
225
+ for value in values {
226
+ let location = value.as_object().ok_or(FailureCode::Parse)?;
227
+ if location.get("is_oa").and_then(Value::as_bool).is_none() {
228
+ return Err(FailureCode::Parse);
229
+ }
230
+ if location["is_oa"] == true {
231
+ locations.push(location);
232
+ }
233
+ }
234
+ }
235
+ _ => return Err(FailureCode::Parse),
236
+ }
237
+ for location in locations {
238
+ for key in ["landing_page_url", "pdf_url"] {
239
+ match location.get(key) {
240
+ None | Some(Value::Null) => {}
241
+ Some(Value::String(raw)) => {
242
+ let Some(url) = destination(raw)? else {
243
+ continue;
244
+ };
245
+ let parsed = Url::parse(&url).unwrap();
246
+ let host = parsed.host_str().unwrap();
247
+ let archive_article = (host == "europepmc.org"
248
+ || host == "www.europepmc.org")
249
+ && parsed.path().starts_with("/articles/")
250
+ || host == "pmc.ncbi.nlm.nih.gov"
251
+ && parsed.path().starts_with("/articles/")
252
+ || (host == "www.ncbi.nlm.nih.gov" || host == "ncbi.nlm.nih.gov")
253
+ && parsed.path().starts_with("/pmc/articles/");
254
+ let priority = if host == "doi.org" {
255
+ 4
256
+ } else if key == "pdf_url"
257
+ || parsed.path().to_ascii_lowercase().ends_with(".pdf")
258
+ {
259
+ 2
260
+ } else if archive_article {
261
+ 0
262
+ } else if location.get("is_oa").and_then(Value::as_bool) == Some(true) {
263
+ 1
264
+ } else {
265
+ 3
266
+ };
267
+ candidates.push((priority, url));
268
+ }
269
+ _ => return Err(FailureCode::Parse),
270
+ }
271
+ }
272
+ }
273
+ match row.get("doi") {
274
+ None | Some(Value::Null) => {}
275
+ Some(Value::String(raw)) => {
276
+ if let Some(url) = destination(raw)? {
277
+ if Url::parse(&url).unwrap().host_str() != Some("doi.org") {
278
+ return Err(FailureCode::Parse);
279
+ }
280
+ candidates.push((4, url));
281
+ }
282
+ }
283
+ _ => return Err(FailureCode::Parse),
284
+ }
285
+ // Validate every row's bounded fields even when its work will be omitted.
286
+ if retracted {
287
+ known_retracted_ids.insert(id);
288
+ continue;
289
+ }
290
+ // Prefer observed direct destinations over DOI redirectors; preserve stable location order.
291
+ candidates.sort_by_key(|(priority, _)| *priority);
292
+ let Some((_, url)) = candidates.into_iter().next() else {
293
+ continue;
294
+ };
295
+ results.push((
296
+ id,
297
+ SearchResult {
298
+ title,
299
+ url,
300
+ snippet: Some("Scholarly index metadata; full text not fetched.".into()),
301
+ source_engine: "openalex".into(),
302
+ },
303
+ ));
304
+ }
305
+ // A duplicate row cannot reintroduce an identity explicitly marked retracted.
306
+ // Deduplicate only admitted works so excluded identities cannot hide usable rows.
307
+ let mut seen = std::collections::HashSet::new();
308
+ let mut seen_ids = std::collections::HashSet::new();
309
+ let results: Vec<_> = results
310
+ .into_iter()
311
+ .filter(|(id, _)| !known_retracted_ids.contains(id))
312
+ .filter(|(id, result)| seen_ids.insert(*id) && seen.insert(result.url.clone()))
313
+ .map(|(_, result)| result)
314
+ .collect();
315
+ if results.is_empty()
316
+ && observed_ids
317
+ .iter()
318
+ .any(|id| !known_retracted_ids.contains(id))
319
+ {
320
+ return Err(FailureCode::UnverifiedEmpty);
321
+ }
322
+ Ok(Discovery {
323
+ results,
324
+ reported_cost_usd,
325
+ })
326
+ }
327
+
328
+ #[cfg(test)]
329
+ mod tests {
330
+ use super::*;
331
+ use serde_json::json;
332
+ fn row() -> Value {
333
+ serde_json::json!({"id":"https://openalex.org/W123", "title":"Microalgae harvesting energy efficiency", "doi":"https://doi.org/10.1234/paper", "best_oa_location":{"is_oa":true,"landing_page_url":"https://repository.example/paper","pdf_url":"https://repository.example/paper.pdf"}})
334
+ }
335
+ fn response(rows: Vec<Value>) -> Value {
336
+ serde_json::json!({"meta":{"page":1,"per_page":3,"count":rows.len(),"x_query":{"oqo":{"get_rows":"works","filter_rows":[{"column_id":"fulltext.search","operator":"has","value":"microalgae"}]}}},"results":rows})
337
+ }
338
+ fn check(value: Value) -> Outcome {
339
+ parse(&value.to_string(), 3, "microalgae")
340
+ }
341
+ #[test]
342
+ fn omits_known_retractions_and_preserves_cost_for_policy_empty_results() {
343
+ let mut retracted = row();
344
+ retracted["is_retracted"] = json!(true);
345
+ let mut value = response(vec![retracted.clone()]);
346
+ value["meta"]["cost_usd"] = json!(0.0001);
347
+ let result = check(value).unwrap();
348
+ assert!(result.results.is_empty());
349
+ assert_eq!(result.reported_cost_usd, Some(0.0001));
350
+
351
+ let mut good = row();
352
+ good["id"] = json!("https://openalex.org/W456");
353
+ good["is_retracted"] = json!(false);
354
+ let result = check(response(vec![retracted, good])).unwrap();
355
+ assert_eq!(result.results.len(), 1);
356
+ assert_eq!(
357
+ result.results[0].title,
358
+ "Microalgae harvesting energy efficiency"
359
+ );
360
+ // Missing status remains admissible metadata, without a verified-status claim.
361
+ let result = check(response(vec![row()])).unwrap();
362
+ assert_eq!(result.results.len(), 1);
363
+ assert_eq!(
364
+ result.results[0].snippet.as_deref(),
365
+ Some("Scholarly index metadata; full text not fetched.")
366
+ );
367
+ }
368
+ #[test]
369
+ fn retraction_marker_is_anchored_and_does_not_exclude_scholarship_about_retractions() {
370
+ for title in [
371
+ "RETRACTED ARTICLE: Microalgae",
372
+ " retracted article:Microalgae",
373
+ "RETRACTED\n ARTICLE: Microalgae",
374
+ ] {
375
+ let mut r = row();
376
+ r["title"] = json!(title);
377
+ r["is_retracted"] = json!(false);
378
+ assert!(
379
+ check(response(vec![r])).unwrap().results.is_empty(),
380
+ "{title}"
381
+ );
382
+ }
383
+ for title in [
384
+ "A study of retractions",
385
+ "Analysis of RETRACTED ARTICLE: labels",
386
+ "RETRACTED ARTICLES: a review",
387
+ "Retraction notices in science",
388
+ "論文について",
389
+ ] {
390
+ let mut r = row();
391
+ r["title"] = json!(title);
392
+ assert_eq!(
393
+ check(response(vec![r])).unwrap().results.len(),
394
+ 1,
395
+ "{title}"
396
+ );
397
+ }
398
+ }
399
+ #[test]
400
+ fn rejects_malformed_status_even_with_an_explicit_title_marker() {
401
+ for status in [Value::Null, json!("true"), json!(1), json!([]), json!({})] {
402
+ for title in ["Microalgae harvesting", "RETRACTED ARTICLE: Microalgae"] {
403
+ let mut r = row();
404
+ r["title"] = json!(title);
405
+ r["is_retracted"] = status.clone();
406
+ assert_eq!(check(response(vec![r])).unwrap_err(), FailureCode::Parse);
407
+ }
408
+ }
409
+ }
410
+ #[test]
411
+ fn policy_filter_preserves_unusable_link_failures_and_row_validation() {
412
+ let mut retracted = row();
413
+ retracted["is_retracted"] = json!(true);
414
+ let mut unusable = row();
415
+ unusable["id"] = json!("https://openalex.org/W456");
416
+ unusable["best_oa_location"] = Value::Null;
417
+ unusable["doi"] = Value::Null;
418
+ assert_eq!(
419
+ check(response(vec![retracted.clone(), unusable])).unwrap_err(),
420
+ FailureCode::UnverifiedEmpty
421
+ );
422
+ retracted["doi"] = json!(42);
423
+ assert_eq!(
424
+ check(response(vec![retracted.clone()])).unwrap_err(),
425
+ FailureCode::Parse
426
+ );
427
+ retracted["doi"] = Value::Null;
428
+ retracted["locations"] = json!([{"is_oa":true,"landing_page_url":"x".repeat(8193)}]);
429
+ assert_eq!(
430
+ check(response(vec![retracted])).unwrap_err(),
431
+ FailureCode::BodyLimit
432
+ );
433
+ }
434
+ #[test]
435
+ fn duplicate_work_cannot_reintroduce_a_known_retraction_in_either_order() {
436
+ let mut retracted = row();
437
+ retracted["is_retracted"] = json!(true);
438
+ for rows in [vec![row(), retracted.clone()], vec![retracted, row()]] {
439
+ assert!(check(response(rows)).unwrap().results.is_empty());
440
+ }
441
+ }
442
+ #[test]
443
+ fn excluded_identity_cannot_consume_a_valid_works_url_deduplication_slot() {
444
+ let mut retracted = row();
445
+ retracted["is_retracted"] = json!(true);
446
+ let mut good = row();
447
+ good["id"] = json!("https://openalex.org/W456");
448
+ good["title"] = json!("Allowed work at the shared destination");
449
+ let result = check(response(vec![row(), good, retracted])).unwrap();
450
+ assert_eq!(result.results.len(), 1);
451
+ assert_eq!(
452
+ result.results[0].title,
453
+ "Allowed work at the shared destination"
454
+ );
455
+ assert_eq!(result.results[0].url, "https://repository.example/paper");
456
+ }
457
+ #[test]
458
+ fn discovery_prefers_observed_oa_landing_and_deduplicates() {
459
+ let results = check(response(vec![row(), row()])).unwrap().results;
460
+ assert_eq!(results.len(), 1);
461
+ assert_eq!(results[0].url, "https://repository.example/paper");
462
+ assert_eq!(results[0].source_engine, "openalex");
463
+ assert_eq!(
464
+ results[0].snippet.as_deref(),
465
+ Some("Scholarly index metadata; full text not fetched.")
466
+ );
467
+ let mut r = row();
468
+ r["best_oa_location"] = Value::Null;
469
+ assert_eq!(
470
+ check(response(vec![r])).unwrap().results[0].url,
471
+ "https://doi.org/10.1234/paper"
472
+ );
473
+ assert!(check(response(vec![])).unwrap().results.is_empty());
474
+ }
475
+ #[test]
476
+ fn prefers_only_observed_open_archive_html_locations() {
477
+ let mut r = row();
478
+ r["locations"] = json!([{ "is_oa":true,"landing_page_url":"https://www.ncbi.nlm.nih.gov/pmc/articles/3172406","pdf_url":null }]);
479
+ assert_eq!(
480
+ check(response(vec![r.clone()])).unwrap().results[0].url,
481
+ "https://www.ncbi.nlm.nih.gov/pmc/articles/3172406"
482
+ );
483
+ r["locations"][0]["is_oa"] = json!(false);
484
+ assert_eq!(
485
+ check(response(vec![r.clone()])).unwrap().results[0].url,
486
+ "https://repository.example/paper"
487
+ );
488
+ r["locations"][0]["is_oa"] = json!(true);
489
+ r["locations"][0]["landing_page_url"] = json!("https://127.0.0.1/x");
490
+ assert_eq!(
491
+ check(response(vec![r])).unwrap().results[0].url,
492
+ "https://repository.example/paper"
493
+ );
494
+ let mut bad = response(vec![row()]);
495
+ bad["meta"]["cost_usd"] = json!(-1);
496
+ assert!(check(bad).is_err());
497
+ }
498
+ #[test]
499
+ fn rejects_schema_query_and_bounds() {
500
+ for path in [
501
+ "/meta/page",
502
+ "/meta/per_page",
503
+ "/meta/count",
504
+ "/results/0/title",
505
+ "/results/0/id",
506
+ "/results/0/best_oa_location/is_oa",
507
+ ] {
508
+ let mut v = response(vec![row()]);
509
+ *v.pointer_mut(path).unwrap() = Value::Null;
510
+ assert!(check(v).is_err(), "{path}");
511
+ }
512
+ let mut wrong = response(vec![]);
513
+ wrong["meta"]["x_query"]["oqo"]["filter_rows"][0]["value"] = Value::String("other".into());
514
+ assert_eq!(check(wrong).unwrap_err(), FailureCode::QueryMismatch);
515
+ assert_eq!(
516
+ check(response(vec![row(), row(), row(), row()])).unwrap_err(),
517
+ FailureCode::ResultLimit
518
+ );
519
+ assert_eq!(
520
+ parse(&" ".repeat(MAX_BODY + 1), 3, "microalgae").unwrap_err(),
521
+ FailureCode::BodyLimit
522
+ );
523
+ assert!(parse("not JSON", 3, "microalgae").is_err());
524
+ assert_eq!(
525
+ parse("{}", 0, "microalgae").unwrap_err(),
526
+ FailureCode::ResultLimit
527
+ );
528
+ }
529
+ #[test]
530
+ fn filters_unusable_destinations_without_losing_valid_rows_or_promoting_urls() {
531
+ for raw in [
532
+ "http://repository.example/paper",
533
+ "https://127.0.0.1/a",
534
+ "https://10.0.0.1/a",
535
+ "https://user:password@example.com/a",
536
+ "https://[::1]/a",
537
+ "https://localhost/a",
538
+ "https://localhost./a",
539
+ "https://host.local./a",
540
+ "https://host.localhost./a",
541
+ "file:///tmp/a",
542
+ "https://example.com:8443/a",
543
+ "not a URL",
544
+ "",
545
+ ] {
546
+ let mut unusable = row();
547
+ unusable["best_oa_location"]["landing_page_url"] = json!(raw);
548
+ unusable["best_oa_location"]["pdf_url"] = json!(raw);
549
+ unusable["doi"] = json!("http://doi.org/10.1234/old");
550
+ assert_eq!(
551
+ check(response(vec![unusable.clone()])).unwrap_err(),
552
+ FailureCode::UnverifiedEmpty,
553
+ "{raw}"
554
+ );
555
+ let mut good = row();
556
+ good["id"] = json!("https://openalex.org/W456");
557
+ let results = check(response(vec![unusable.clone(), good]))
558
+ .unwrap()
559
+ .results;
560
+ assert_eq!(results.len(), 1, "{raw}");
561
+ assert_eq!(results[0].url, "https://repository.example/paper", "{raw}");
562
+ unusable["locations"] = json!([{"is_oa":true,"landing_page_url":"https://europepmc.org/articles/PMC3172406"}]);
563
+ let results = check(response(vec![unusable])).unwrap().results;
564
+ assert_eq!(results.len(), 1, "{raw}");
565
+ assert_eq!(
566
+ results[0].url, "https://europepmc.org/articles/PMC3172406",
567
+ "{raw}"
568
+ );
569
+ }
570
+ }
571
+ #[test]
572
+ fn keeps_destination_type_and_size_failures_after_valid_candidates() {
573
+ for key in ["landing_page_url", "pdf_url"] {
574
+ for value in [json!(42), json!(false), json!([]), json!({})] {
575
+ let mut r = row();
576
+ r["locations"] = json!([{"is_oa":true,key:value}]);
577
+ assert_eq!(check(response(vec![r])).unwrap_err(), FailureCode::Parse);
578
+ }
579
+ }
580
+ let mut r = row();
581
+ r["doi"] = json!(42);
582
+ assert_eq!(check(response(vec![r])).unwrap_err(), FailureCode::Parse);
583
+ let mut r = row();
584
+ r["doi"] = json!("https://other.example/paper");
585
+ assert_eq!(check(response(vec![r])).unwrap_err(), FailureCode::Parse);
586
+ let mut r = row();
587
+ r["locations"] = json!([{"is_oa":true,"landing_page_url":"x".repeat(8193)}]);
588
+ assert_eq!(
589
+ check(response(vec![r])).unwrap_err(),
590
+ FailureCode::BodyLimit
591
+ );
592
+ }
593
+ #[test]
594
+ fn pins_fixed_json_request_and_bounds_transport() {
595
+ use std::io::{Read, Write};
596
+ for (status, kind, body, expected) in [
597
+ (
598
+ "200 OK",
599
+ "application/json",
600
+ response(vec![row()]).to_string(),
601
+ None,
602
+ ),
603
+ (
604
+ "302 Found",
605
+ "application/json",
606
+ String::new(),
607
+ Some(FailureCode::HttpRedirect),
608
+ ),
609
+ (
610
+ "403 Forbidden",
611
+ "application/json",
612
+ String::new(),
613
+ Some(FailureCode::HttpForbidden),
614
+ ),
615
+ (
616
+ "200 OK",
617
+ "text/html",
618
+ "<html>challenge</html>".into(),
619
+ Some(FailureCode::ContentType),
620
+ ),
621
+ (
622
+ "200 OK",
623
+ "application/json",
624
+ "x".repeat(MAX_BODY + 1),
625
+ Some(FailureCode::BodyLimit),
626
+ ),
627
+ ] {
628
+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
629
+ listener.set_nonblocking(true).unwrap();
630
+ let endpoint = format!("http://{}/works", listener.local_addr().unwrap());
631
+ let server = std::thread::spawn(move || {
632
+ let deadline = std::time::Instant::now() + Duration::from_secs(4);
633
+ let (mut socket, _) = loop {
634
+ match listener.accept() {
635
+ Ok(value) => break value,
636
+ Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
637
+ assert!(
638
+ std::time::Instant::now() < deadline,
639
+ "fixture accept deadline"
640
+ );
641
+ std::thread::sleep(Duration::from_millis(5));
642
+ }
643
+ Err(error) => panic!("{error}"),
644
+ }
645
+ };
646
+ socket.set_nonblocking(false).unwrap();
647
+ socket
648
+ .set_write_timeout(Some(Duration::from_secs(3)))
649
+ .unwrap();
650
+ socket
651
+ .set_read_timeout(Some(Duration::from_secs(3)))
652
+ .unwrap();
653
+ let mut bytes = Vec::new();
654
+ let mut byte = [0];
655
+ while !bytes.ends_with(b"\r\n\r\n") {
656
+ assert!(bytes.len() < 8192);
657
+ socket.read_exact(&mut byte).unwrap();
658
+ bytes.push(byte[0]);
659
+ }
660
+ let request = String::from_utf8(bytes).unwrap();
661
+ assert!(
662
+ request.starts_with("GET /works?search=microalgae&per-page=3 HTTP/1.1\r\n")
663
+ );
664
+ assert!(!request.to_lowercase().contains("authorization:"));
665
+ assert!(!request.to_lowercase().contains("cookie:"));
666
+ let head = format!(
667
+ "HTTP/1.1 {status}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\nLocation: http://127.0.0.1:1/never\r\nConnection: close\r\n\r\n",
668
+ body.len()
669
+ );
670
+ socket.write_all(head.as_bytes()).unwrap();
671
+ let _ = socket.write_all(body.as_bytes());
672
+ });
673
+ let runtime = tokio::runtime::Builder::new_current_thread()
674
+ .enable_all()
675
+ .build()
676
+ .unwrap();
677
+ let result = runtime.block_on(async {
678
+ let client = Client::builder()
679
+ .no_proxy()
680
+ .redirect(reqwest::redirect::Policy::none())
681
+ .build()
682
+ .unwrap();
683
+ search_at(&client, &endpoint, "microalgae", 3, Duration::from_secs(3)).await
684
+ });
685
+ server.join().unwrap();
686
+ if let Some(code) = expected {
687
+ assert_eq!(result.unwrap_err(), code);
688
+ } else {
689
+ assert_eq!(result.unwrap().results.len(), 1);
690
+ }
691
+ }
692
+ }
693
+ #[test]
694
+ #[ignore = "requires explicitly supplied retained JSON; performs no network requests"]
695
+ fn retained_public_response_offline() {
696
+ let path =
697
+ std::env::var("WORDCELL_OPENALEX_RETAINED_JSON").expect("supply retained JSON path");
698
+ let body = std::fs::read_to_string(path).unwrap();
699
+ let result = parse(&body, 10, "microalgae harvesting energy efficiency").unwrap();
700
+ assert_eq!(result.reported_cost_usd, Some(0.001));
701
+ let rows = result.results;
702
+ assert!(!rows.is_empty());
703
+ assert!(rows.iter().any(|r| r.title.contains("harvesting")));
704
+ }
705
+ }