Skip to main content

aspero_json/
lookup.rs

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