papers_openalex/params.rs
1/// Query parameters shared by all 7 list endpoints (works, authors, sources,
2/// institutions, topics, publishers, funders). All fields are optional.
3///
4/// Supports both struct-update syntax and the bon builder pattern:
5///
6/// ```
7/// use papers_openalex::ListParams;
8///
9/// // Struct-update syntax
10/// let params = ListParams {
11/// search: Some("machine learning".into()),
12/// per_page: Some(10),
13/// ..Default::default()
14/// };
15///
16/// // Builder syntax
17/// let params = ListParams::builder()
18/// .search("machine learning")
19/// .filter("publication_year:2024,is_oa:true")
20/// .sort("cited_by_count:desc")
21/// .per_page(10)
22/// .page(1)
23/// .build();
24/// ```
25///
26/// # Pagination
27///
28/// Two pagination modes are available (mutually exclusive):
29///
30/// - **Offset pagination:** set `page` (max `page * per_page <= 10,000`)
31/// - **Cursor pagination:** set `cursor` to `"*"` for the first page, then pass
32/// [`ListMeta::next_cursor`](crate::ListMeta::next_cursor) from the previous
33/// response. When `next_cursor` is `None`, there are no more results.
34///
35/// # Sampling
36///
37/// Set `sample` to get a random sample instead of paginated results. Use `seed`
38/// for reproducibility.
39///
40/// # Grouping
41///
42/// Set `group_by` to aggregate results by a field. The response will include a
43/// [`group_by`](crate::ListResponse::group_by) array with key, display name,
44/// and count for each group.
45#[derive(Debug, Default, Clone, bon::Builder)]
46#[builder(on(String, into))]
47pub struct ListParams {
48 /// Filter expression. Comma-separated AND conditions, pipe (`|`) for OR
49 /// (max 50 alternatives). Supports negation (`!`), comparison (`>`, `<`),
50 /// and ranges (`2020-2023`).
51 ///
52 /// Example: `"publication_year:2024,is_oa:true"` or `"type:article|preprint"`
53 pub filter: Option<String>,
54
55 /// Full-text search query. For works, searches across title, abstract, and
56 /// fulltext. For other entities, searches `display_name`. Supports stemming
57 /// and stop-word removal.
58 pub search: Option<String>,
59
60 /// Sort field with optional direction suffix. Append `:desc` for descending
61 /// order. Multiple fields can be comma-separated.
62 ///
63 /// Available fields: `display_name`, `cited_by_count`, `works_count`,
64 /// `publication_date`, `relevance_score` (only with active search).
65 ///
66 /// Example: `"cited_by_count:desc"`
67 pub sort: Option<String>,
68
69 /// Results per page (1-200, default 25).
70 ///
71 /// Note: the API query key is `per-page` (hyphenated); this field handles
72 /// the mapping automatically.
73 pub per_page: Option<u32>,
74
75 /// Page number for offset pagination. Maximum accessible:
76 /// `page * per_page <= 10,000`. For deeper results, use cursor pagination.
77 pub page: Option<u32>,
78
79 /// Cursor for cursor-based pagination. Start with `"*"`, then pass
80 /// `meta.next_cursor` from the previous response. When `next_cursor` is
81 /// `None`, there are no more results. Mutually exclusive with `page`.
82 pub cursor: Option<String>,
83
84 /// Return a random sample of this many results instead of paginated results.
85 /// Use with `seed` for reproducibility.
86 pub sample: Option<u32>,
87
88 /// Seed value for reproducible random sampling. Only meaningful when
89 /// `sample` is set.
90 pub seed: Option<u32>,
91
92 /// Comma-separated list of fields to include in the response. Reduces
93 /// payload size. Unselected fields will be omitted.
94 ///
95 /// Example: `"id,display_name,cited_by_count"`
96 pub select: Option<String>,
97
98 /// Aggregate results by a field and return counts. The response will include
99 /// a `group_by` array with `key`, `key_display_name`, and `count` for each
100 /// group.
101 ///
102 /// Example: `"type"` groups works by article/preprint/etc.
103 pub group_by: Option<String>,
104}
105
106impl ListParams {
107 pub(crate) fn to_query_pairs(&self) -> Vec<(&str, String)> {
108 let mut pairs = Vec::new();
109 if let Some(v) = &self.filter {
110 pairs.push(("filter", v.clone()));
111 }
112 if let Some(v) = &self.search {
113 pairs.push(("search", v.clone()));
114 }
115 if let Some(v) = &self.sort {
116 pairs.push(("sort", v.clone()));
117 }
118 if let Some(v) = self.per_page {
119 pairs.push(("per-page", v.to_string()));
120 }
121 if let Some(v) = self.page {
122 pairs.push(("page", v.to_string()));
123 }
124 if let Some(v) = &self.cursor {
125 pairs.push(("cursor", v.clone()));
126 }
127 if let Some(v) = self.sample {
128 pairs.push(("sample", v.to_string()));
129 }
130 if let Some(v) = self.seed {
131 pairs.push(("seed", v.to_string()));
132 }
133 if let Some(v) = &self.select {
134 pairs.push(("select", v.clone()));
135 }
136 if let Some(v) = &self.group_by {
137 pairs.push(("group_by", v.clone()));
138 }
139 pairs
140 }
141}
142
143/// Query parameters for single-entity GET endpoints. Only field selection is
144/// supported.
145///
146/// ```
147/// use papers_openalex::GetParams;
148///
149/// // No field selection (return full entity)
150/// let params = GetParams::default();
151///
152/// // Select specific fields to reduce payload
153/// let params = GetParams::builder()
154/// .select("id,display_name,cited_by_count")
155/// .build();
156/// ```
157#[derive(Debug, Default, Clone, bon::Builder)]
158#[builder(on(String, into))]
159pub struct GetParams {
160 /// Comma-separated list of fields to include in the response.
161 ///
162 /// Example: `"id,display_name,cited_by_count"`
163 pub select: Option<String>,
164}
165
166impl GetParams {
167 pub(crate) fn to_query_pairs(&self) -> Vec<(&str, String)> {
168 let mut pairs = Vec::new();
169 if let Some(v) = &self.select {
170 pairs.push(("select", v.clone()));
171 }
172 pairs
173 }
174}
175
176/// Parameters for semantic search (`/find/works`). Uses AI embeddings to find
177/// conceptually similar works. Requires an API key. Costs 1,000 credits per
178/// request.
179///
180/// ```
181/// use papers_openalex::FindWorksParams;
182///
183/// let params = FindWorksParams::builder()
184/// .query("machine learning for drug discovery")
185/// .count(10)
186/// .filter("publication_year:>2020")
187/// .build();
188/// ```
189#[derive(Debug, Clone, bon::Builder)]
190#[builder(on(String, into))]
191pub struct FindWorksParams {
192 /// Text to find similar works for. Can be a title, abstract, or research
193 /// question. Maximum 10,000 characters. For POST requests, this is sent in
194 /// the JSON body as `{"query": "..."}`.
195 pub query: String,
196
197 /// Number of results to return (1-100, default 25). Results are ranked by
198 /// similarity score.
199 pub count: Option<u32>,
200
201 /// Filter expression to constrain results (same syntax as list endpoints).
202 /// Applied after semantic ranking.
203 pub filter: Option<String>,
204}
205
206impl FindWorksParams {
207 pub(crate) fn to_query_pairs(&self) -> Vec<(&str, String)> {
208 let mut pairs = Vec::new();
209 pairs.push(("query", self.query.clone()));
210 if let Some(v) = self.count {
211 pairs.push(("count", v.to_string()));
212 }
213 if let Some(v) = &self.filter {
214 pairs.push(("filter", v.clone()));
215 }
216 pairs
217 }
218
219 pub(crate) fn to_post_query_pairs(&self) -> Vec<(&str, String)> {
220 let mut pairs = Vec::new();
221 if let Some(v) = self.count {
222 pairs.push(("count", v.to_string()));
223 }
224 if let Some(v) = &self.filter {
225 pairs.push(("filter", v.clone()));
226 }
227 pairs
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn test_list_params_default() {
237 let params = ListParams::default();
238 assert!(params.filter.is_none());
239 assert!(params.search.is_none());
240 assert!(params.sort.is_none());
241 assert!(params.per_page.is_none());
242 assert!(params.page.is_none());
243 assert!(params.cursor.is_none());
244 assert!(params.sample.is_none());
245 assert!(params.seed.is_none());
246 assert!(params.select.is_none());
247 assert!(params.group_by.is_none());
248 }
249
250 #[test]
251 fn test_list_params_builder() {
252 let params = ListParams::builder()
253 .search("machine learning")
254 .per_page(10)
255 .sort("cited_by_count:desc")
256 .build();
257 assert_eq!(params.search.as_deref(), Some("machine learning"));
258 assert_eq!(params.per_page, Some(10));
259 assert_eq!(params.sort.as_deref(), Some("cited_by_count:desc"));
260 }
261
262 #[test]
263 fn test_list_params_struct_update() {
264 let params = ListParams {
265 search: Some("test".into()),
266 per_page: Some(5),
267 ..Default::default()
268 };
269 assert_eq!(params.search.as_deref(), Some("test"));
270 assert_eq!(params.per_page, Some(5));
271 assert!(params.filter.is_none());
272 }
273
274 #[test]
275 fn test_get_params_default() {
276 let params = GetParams::default();
277 assert!(params.select.is_none());
278 }
279
280 #[test]
281 fn test_find_works_params_builder() {
282 let params = FindWorksParams::builder()
283 .query("drug discovery")
284 .count(10)
285 .build();
286 assert_eq!(params.query, "drug discovery");
287 assert_eq!(params.count, Some(10));
288 assert!(params.filter.is_none());
289 }
290}