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}