Skip to main content

aspero_json/
lookup.rs

1//! Lookup results.
2
3use aspero_core::ErrorCategory;
4use aspero_engine::{Disagreement, InputLookup, LookupResult, Outcome, Served};
5use serde_json::{Value, json};
6
7use crate::work;
8
9/// How a lookup ended, as its item's `status` says.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum Status {
13    Found,
14    /// No enabled provider looks up this kind of identifier.
15    Unsupported,
16    NotFound,
17    /// Every provider asked failed; the first failure's category.
18    Failed(ErrorCategory),
19}
20
21impl Status {
22    pub fn of(result: &LookupResult) -> Self {
23        if result.record.is_some() {
24            return Status::Found;
25        }
26        if result.attempts.is_empty() {
27            return Status::Unsupported;
28        }
29        let mut failures = result.attempts.iter().filter_map(|a| match &a.outcome {
30            Outcome::Failed(e) => Some(e.category()),
31            _ => None,
32        });
33        match failures.next() {
34            Some(first) if failures.count() + 1 == result.attempts.len() => Status::Failed(first),
35            _ => Status::NotFound,
36        }
37    }
38
39    pub fn as_str(self) -> &'static str {
40        match self {
41            Status::Found => "found",
42            Status::Unsupported => "unsupported",
43            Status::NotFound => "not_found",
44            Status::Failed(_) => "failed",
45        }
46    }
47}
48
49/// An input that isn't an identifier.
50pub fn invalid(input: &str, error: &str) -> Value {
51    json!({"input": input, "status": "invalid", "error": error})
52}
53
54/// One result. Attempts are included when nothing was found (so the caller
55/// sees why) or with `full`.
56pub fn item(input: &str, result: &LookupResult, full: bool) -> Value {
57    let status = Status::of(result);
58    let mut item = json!({"input": input, "id": result.id, "status": status.as_str()});
59    if let Some(record) = &result.record {
60        // Say when the answer didn't come live from the provider.
61        let served = result
62            .attempts
63            .iter()
64            .rfind(|a| matches!(a.outcome, Outcome::Found))
65            .map(|a| a.served);
66        if let Some(s) = served.filter(|s| *s != Served::Live) {
67            item["served"] = json!(s.as_str());
68        }
69        item["work"] = work::work(record, full);
70    }
71    if status == Status::Unsupported {
72        item["error"] = json!(format!(
73            "no enabled provider looks up {} identifiers",
74            result.id.kind()
75        ));
76    }
77    if result.record.is_none() || full {
78        let attempts: Vec<Value> = result
79            .attempts
80            .iter()
81            .map(|a| match &a.outcome {
82                Outcome::Found => json!({"provider": a.provider, "outcome": "found"}),
83                Outcome::NotFound => json!({"provider": a.provider, "outcome": "not_found"}),
84                Outcome::Failed(e) => {
85                    json!({"provider": a.provider, "outcome": "failed", "error": e.to_string()})
86                }
87                _ => json!({"provider": a.provider, "outcome": "unknown"}),
88            })
89            .collect();
90        item["attempts"] = json!(attempts);
91    }
92    item
93}
94
95/// One input's lookup, with whatever extras were asked for.
96pub fn input(input: &str, lookup: &InputLookup, full: bool) -> Value {
97    let looked = match lookup {
98        InputLookup::Invalid(e) => return invalid(input, &e.to_string()),
99        InputLookup::Looked(looked) => looked,
100    };
101    let mut out = item(input, &looked.result, full);
102    if looked.with_punctuation {
103        out["note"] = json!("found with the trailing punctuation kept");
104    }
105    if let Some(found) = &looked.abstract_text {
106        work::set_abstract(&mut out["work"], found.as_ref());
107    }
108    if let Some(latest) = &looked.latest {
109        out["latest"] = work::work(latest, false);
110    }
111    if !looked.disagreements.is_empty() {
112        out["disagreements"] = disagreements(&looked.disagreements);
113    }
114    out
115}
116
117/// Where a record contradicts its DOI's registry: `[{field, value,
118/// <registry>: registry_value}]`.
119pub fn disagreements(found: &[Disagreement]) -> Value {
120    found
121        .iter()
122        .map(|d| {
123            json!({
124                "field": d.field,
125                "value": d.value,
126                d.registry.as_str(): d.registry_value,
127            })
128        })
129        .collect()
130}
131
132#[cfg(test)]
133mod tests {
134    use aspero_core::{Error, ProviderId};
135    use aspero_engine::Attempt;
136
137    use super::*;
138
139    fn result(outcomes: Vec<Outcome>) -> LookupResult {
140        LookupResult {
141            id: "doi:10.1234/x".parse().unwrap(),
142            record: None,
143            attempts: outcomes
144                .into_iter()
145                .map(|outcome| Attempt {
146                    provider: ProviderId::from_static("crossref"),
147                    outcome,
148                    served: Served::Live,
149                })
150                .collect(),
151        }
152    }
153
154    #[test]
155    fn statuses_say_why_nothing_was_found() {
156        assert_eq!(Status::of(&result(vec![])), Status::Unsupported);
157        assert_eq!(
158            Status::of(&result(vec![Outcome::NotFound])),
159            Status::NotFound
160        );
161        let failed = || Outcome::Failed(Error::Timeout);
162        assert_eq!(
163            Status::of(&result(vec![failed(), Outcome::NotFound])),
164            Status::NotFound
165        );
166        assert!(matches!(
167            Status::of(&result(vec![failed(), failed()])),
168            Status::Failed(_)
169        ));
170    }
171
172    #[test]
173    fn items_without_a_record_carry_their_attempts() {
174        assert_eq!(
175            item("10.1234/x", &result(vec![Outcome::NotFound]), false),
176            json!({
177                "input": "10.1234/x",
178                "id": "doi:10.1234/x",
179                "status": "not_found",
180                "attempts": [{"provider": "crossref", "outcome": "not_found"}],
181            })
182        );
183        let unsupported = item("10.1234/x", &result(vec![]), false);
184        assert_eq!(unsupported["status"], "unsupported");
185        assert!(unsupported["error"].as_str().unwrap().contains("doi"));
186    }
187}