Skip to main content

papers_openalex/
response.rs

1use serde::{Deserialize, Serialize};
2
3// ── List response ──────────────────────────────────────────────────────
4
5/// Paginated list response returned by all 7 list endpoints.
6///
7/// Contains metadata about the query, a page of results, and optional group-by
8/// aggregations.
9///
10/// # Example response shape
11///
12/// ```json
13/// {
14///   "meta": {"count": 288286684, "db_response_time_ms": 109, "page": 1, "per_page": 1, "next_cursor": null, "groups_count": null},
15///   "results": [{"id": "https://openalex.org/W3038568908", "display_name": "..."}],
16///   "group_by": []
17/// }
18/// ```
19///
20/// When cursor pagination is used, `meta.page` is `None` and `meta.next_cursor`
21/// contains the cursor for the next page (or `None` if no more results).
22///
23/// When `group_by` is used, the `group_by` array is populated and
24/// `meta.groups_count` is non-null.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ListResponse<T> {
27    /// Metadata about the query: total count, pagination state, timing.
28    pub meta: ListMeta,
29
30    /// The page of entity results.
31    pub results: Vec<T>,
32
33    /// Group-by aggregation results. Empty unless `group_by` was specified in
34    /// the request.
35    #[serde(default)]
36    pub group_by: Vec<GroupByResult>,
37}
38
39/// Metadata returned with every list response.
40///
41/// ```json
42/// {"count": 288286684, "db_response_time_ms": 109, "page": 1, "per_page": 1, "next_cursor": null, "groups_count": null}
43/// ```
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ListMeta {
46    /// Total number of entities matching the query (before pagination).
47    pub count: i64,
48
49    /// Server-side query execution time in milliseconds.
50    pub db_response_time_ms: i64,
51
52    /// Current page number. `None` when cursor pagination is used.
53    pub page: Option<i32>,
54
55    /// Number of results per page.
56    pub per_page: Option<i32>,
57
58    /// Cursor for the next page of results. `None` when there are no more
59    /// results or when offset pagination is used. Pass this value as
60    /// [`ListParams::cursor`](crate::ListParams::cursor) to fetch the next
61    /// page.
62    pub next_cursor: Option<String>,
63
64    /// Number of distinct groups when `group_by` is used. `None` otherwise.
65    pub groups_count: Option<i64>,
66}
67
68/// A single group in a `group_by` aggregation result.
69///
70/// ```json
71/// {"key": "https://openalex.org/types/article", "key_display_name": "article", "count": 209055572}
72/// ```
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct GroupByResult {
75    /// The raw key value for this group (often an OpenAlex URI).
76    pub key: String,
77
78    /// Human-readable display name for this group.
79    pub key_display_name: String,
80
81    /// Number of entities in this group.
82    pub count: i64,
83}
84
85// ── Autocomplete response ──────────────────────────────────────────────
86
87/// Response from any of the 7 autocomplete endpoints. Returns up to 10 results
88/// sorted by citation count.
89///
90/// Fast type-ahead search (~200ms). Each result includes an entity ID, display
91/// name, contextual hint (e.g. institution name for authors, host organization
92/// for sources), and a `filter_key` for use in subsequent list queries.
93///
94/// # Example
95///
96/// ```json
97/// {
98///   "meta": {"count": 955, "db_response_time_ms": 30, "page": 1, "per_page": 10},
99///   "results": [{
100///     "id": "https://openalex.org/A5024159082",
101///     "short_id": "authors/A5024159082",
102///     "display_name": "Einstein",
103///     "hint": "Helios Hospital Berlin-Buch, Germany",
104///     "cited_by_count": 1,
105///     "works_count": 2,
106///     "entity_type": "author",
107///     "external_id": null,
108///     "filter_key": "authorships.author.id"
109///   }]
110/// }
111/// ```
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct AutocompleteResponse {
114    /// Metadata about the autocomplete query.
115    pub meta: AutocompleteMeta,
116
117    /// Up to 10 autocomplete results, sorted by citation count.
118    pub results: Vec<AutocompleteResult>,
119}
120
121/// Metadata for an autocomplete response.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct AutocompleteMeta {
124    /// Total number of entities matching the query prefix.
125    pub count: i64,
126
127    /// Server-side query execution time in milliseconds.
128    pub db_response_time_ms: i64,
129
130    /// Always 1 (autocomplete does not support pagination).
131    pub page: i32,
132
133    /// Always 10 (autocomplete returns at most 10 results).
134    pub per_page: i32,
135}
136
137/// A single autocomplete result.
138///
139/// The `hint` field contains contextual information that varies by entity type:
140/// - **works:** first author name
141/// - **authors:** last known institution and country
142/// - **sources:** host organization
143/// - **institutions:** city and country
144/// - **publishers:** country
145/// - **funders:** country and description
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct AutocompleteResult {
148    /// Full OpenAlex URI (e.g. `"https://openalex.org/A5024159082"`).
149    pub id: String,
150
151    /// Short ID path (e.g. `"authors/A5024159082"`).
152    pub short_id: Option<String>,
153
154    /// Human-readable entity name.
155    pub display_name: String,
156
157    /// Contextual hint whose meaning varies by entity type (see struct docs).
158    pub hint: Option<String>,
159
160    /// Total citation count for this entity.
161    pub cited_by_count: Option<i64>,
162
163    /// Total works count for this entity.
164    pub works_count: Option<i64>,
165
166    /// Entity type: one of `"work"`, `"author"`, `"source"`, `"institution"`,
167    /// `"publisher"`, `"funder"`. Returns `null` for subfield autocomplete
168    /// (known API quirk).
169    pub entity_type: Option<String>,
170
171    /// External identifier (e.g. ISSN for sources, ROR for institutions).
172    /// `None` if not available.
173    pub external_id: Option<String>,
174
175    /// The filter field name to use this result in subsequent list queries. For
176    /// example, `"authorships.author.id"` for authors, or
177    /// `"primary_location.source.id"` for sources.
178    pub filter_key: Option<String>,
179}
180
181// ── Find works response ────────────────────────────────────────────────
182
183/// Response from the `/find/works` semantic search endpoint.
184///
185/// Returns works ranked by AI similarity score (0-1). Requires an API key.
186///
187/// # Example
188///
189/// ```json
190/// {
191///   "results": [
192///     {"score": 0.92, "id": "https://openalex.org/W...", "display_name": "Machine Learning for Drug Discovery", ...},
193///     {"score": 0.87, "id": "https://openalex.org/W...", "display_name": "Deep Learning in Drug Design", ...}
194///   ]
195/// }
196/// ```
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct FindWorksResponse {
199    /// Optional metadata (structure varies).
200    pub meta: Option<serde_json::Value>,
201
202    /// Works ranked by similarity score. Each result contains a `score` field
203    /// and the remaining Work fields flattened into `work`.
204    pub results: Vec<FindWorksResult>,
205}
206
207/// A single result from semantic search, containing a similarity score and the
208/// work data.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct FindWorksResult {
211    /// AI similarity score between 0.0 and 1.0, where 1.0 is most similar to
212    /// the query.
213    pub score: f64,
214
215    /// The work entity data as a JSON value. Contains the same fields as
216    /// [`Work`](crate::Work) but flattened alongside `score`.
217    #[serde(flatten)]
218    pub work: serde_json::Value,
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_deserialize_list_response() {
227        let json = r#"{
228            "meta": {"count": 288286684, "db_response_time_ms": 109, "page": 1, "per_page": 1, "next_cursor": null, "groups_count": null},
229            "results": [{"id": "https://openalex.org/W3038568908", "display_name": "Test Work"}],
230            "group_by": []
231        }"#;
232        let resp: ListResponse<serde_json::Value> =
233            serde_json::from_str(json).expect("Failed to deserialize ListResponse");
234        assert_eq!(resp.meta.count, 288286684);
235        assert_eq!(resp.results.len(), 1);
236        assert!(resp.group_by.is_empty());
237    }
238
239    #[test]
240    fn test_deserialize_list_with_cursor() {
241        let json = r#"{
242            "meta": {"count": 288286684, "db_response_time_ms": 125, "page": null, "per_page": 1, "next_cursor": "IlsxMDAuMC...", "groups_count": null},
243            "results": [{"id": "test"}],
244            "group_by": []
245        }"#;
246        let resp: ListResponse<serde_json::Value> =
247            serde_json::from_str(json).expect("Failed to deserialize cursor response");
248        assert!(resp.meta.page.is_none());
249        assert_eq!(
250            resp.meta.next_cursor.as_deref(),
251            Some("IlsxMDAuMC...")
252        );
253    }
254
255    #[test]
256    fn test_deserialize_list_with_group_by() {
257        let json = r#"{
258            "meta": {"count": 288286684, "db_response_time_ms": 85, "page": 1, "per_page": 1, "next_cursor": null, "groups_count": 1},
259            "results": [],
260            "group_by": [{"key": "https://openalex.org/types/article", "key_display_name": "article", "count": 209055572}]
261        }"#;
262        let resp: ListResponse<serde_json::Value> =
263            serde_json::from_str(json).expect("Failed to deserialize group_by response");
264        assert_eq!(resp.meta.groups_count, Some(1));
265        assert_eq!(resp.group_by.len(), 1);
266        assert_eq!(resp.group_by[0].key_display_name, "article");
267        assert_eq!(resp.group_by[0].count, 209055572);
268    }
269
270    #[test]
271    fn test_deserialize_autocomplete_response() {
272        let json = r#"{
273            "meta": {"count": 955, "db_response_time_ms": 30, "page": 1, "per_page": 10},
274            "results": [{
275                "id": "https://openalex.org/A5024159082",
276                "short_id": "authors/A5024159082",
277                "display_name": "Einstein",
278                "hint": "Helios Hospital Berlin-Buch, Germany",
279                "cited_by_count": 1,
280                "works_count": 2,
281                "entity_type": "author",
282                "external_id": null,
283                "filter_key": "authorships.author.id"
284            }]
285        }"#;
286        let resp: AutocompleteResponse =
287            serde_json::from_str(json).expect("Failed to deserialize AutocompleteResponse");
288        assert_eq!(resp.meta.count, 955);
289        assert_eq!(resp.results.len(), 1);
290        assert_eq!(resp.results[0].display_name, "Einstein");
291        assert_eq!(resp.results[0].entity_type.as_deref(), Some("author"));
292        assert_eq!(
293            resp.results[0].filter_key.as_deref(),
294            Some("authorships.author.id")
295        );
296        assert_eq!(
297            resp.results[0].short_id.as_deref(),
298            Some("authors/A5024159082")
299        );
300    }
301}