nordnet_api/resources/instrument_search.rs
1//! Resource methods for the `instrument_search` API group.
2//!
3//! # Operations
4//!
5//! | Method | Op | Path |
6//! |--------|----|------|
7//! | GET | `get_attributes` | `/instrument_search/attributes` |
8//! | GET | `search_stocklist` | `/instrument_search/query/stocklist` |
9//! | GET | `search_bullbearlist` | `/instrument_search/query/bullbearlist` |
10//! | GET | `search_minifuturelist` | `/instrument_search/query/minifuturelist` |
11//! | GET | `search_unlimitedturbolist` | `/instrument_search/query/unlimitedturbolist` |
12//! | GET | `search_optionlist_pairs` | `/instrument_search/query/optionlist/pairs` |
13//!
14//!
15//! ## Query parameters
16//!
17//! Each search op carries a sizeable set of optional query parameters. The
18//! parameters are gathered into per-op `*Query` structs (with
19//! `Default::default()` producing the documented "no filters" form) and
20//! forwarded via [`reqwest::Url::query_pairs_mut`] for proper percent
21//! encoding. Required params (only `search_optionlist_pairs` has any) are
22//! plain method arguments.
23//!
24//!
25//! ## 204 No Content
26//!
27//! Only `search_bullbearlist` documents a 204 response. The base
28//! [`Client::get`] surfaces an empty body as a [`Error::Decode`]; the
29//! method here maps that case to an empty [`BullBearListResults`]. The
30//! other ops do not document 204 and so do not perform the mapping.
31//!
32//! Vec-returning ops in this group: none — every op returns a wrapper
33//! struct, so the "204 -> empty Vec" mirror used by the `instruments`
34//! group does not apply directly. The bullbear case maps to an empty
35//! results wrapper instead.
36
37use crate::client::Client;
38use crate::error::Error;
39use nordnet_model::models::instrument_search::{
40 AttributeResults, BullBearListResults, MinifutureListResults, OptionListResults,
41 StocklistResults, UnlimitedTurboListResults,
42};
43
44// ---------------------------------------------------------------------------
45// Query-builder helpers
46// ---------------------------------------------------------------------------
47
48/// Append a query string to a path, omitting the `?` when the query is
49/// empty. Centralises the small piece of formatting shared by every search
50/// op.
51fn with_query(path: &str, qs: &str) -> String {
52 if qs.is_empty() {
53 path.to_owned()
54 } else {
55 format!("{path}?{qs}")
56 }
57}
58
59/// Build the encoded query string from a list of `(name, optional value)`
60/// pairs. `None` values are skipped. Multi-value fields are joined with
61/// commas into a single `name=v1,v2,v3` pair — Swagger 2.0
62/// `collectionFormat=csv`, the format Nordnet's own JS client
63/// (`nordnet/nordnet-next-api`) emits and that the official docs
64/// describe ("Multiple inputs must be comma separated"). Empty
65/// multi-value lists are skipped. All values are percent-encoded by
66/// `reqwest::Url::query_pairs_mut` (commas become `%2C`).
67fn encode_pairs(pairs: &[(&str, Option<&str>)], multi: &[(&str, &[String])]) -> String {
68 let mut url = match reqwest::Url::parse("http://_/") {
69 Ok(u) => u,
70 // The literal above is a valid absolute URL — this branch is
71 // unreachable. Returning an empty string keeps the function total
72 // without panicking.
73 Err(_) => return String::new(),
74 };
75 {
76 let mut q = url.query_pairs_mut();
77 for (name, value) in pairs {
78 if let Some(v) = value {
79 q.append_pair(name, v);
80 }
81 }
82 for (name, values) in multi {
83 if values.is_empty() {
84 continue;
85 }
86 q.append_pair(name, &values.join(","));
87 }
88 }
89 url.query().unwrap_or("").to_owned()
90}
91
92// ---------------------------------------------------------------------------
93// get_attributes query
94// ---------------------------------------------------------------------------
95
96/// Optional query parameters for [`Client::get_attributes`].
97///
98/// Every field is documented as optional. Build via
99/// `AttributesQuery::default()` and field-by-field assignment.
100#[derive(Debug, Clone, Default)]
101pub struct AttributesQuery<'a> {
102 /// Specifies which filters to apply to the search.
103 pub apply_filters: Option<&'a str>,
104 /// Returns only attributes belonging to the specified attribute groups
105 /// (free string per the doc — must be one of the documented enum
106 /// values, e.g. `EXCHANGE_INFO`, `PRICE_INFO`).
107 pub attribute_group: Vec<String>,
108 /// Returns only attributes belonging to the specified entity type
109 /// (free string per the doc — must be one of the documented enum
110 /// values, e.g. `STOCKLIST`, `OPTIONLIST`).
111 pub entity_type: Option<&'a str>,
112 /// Expand attribute values only for the listed attributes. The default
113 /// expand value is `all`.
114 pub expand: Vec<String>,
115 /// Returns minimum and maximum values for the specified attributes.
116 pub minmax: Vec<String>,
117 /// Returns only filterable attributes.
118 pub only_filterable: Option<bool>,
119 /// Returns only returnable attributes.
120 pub only_returnable: Option<bool>,
121 /// Returns only sortable attributes.
122 pub only_sortable: Option<bool>,
123}
124
125fn build_attributes_query(q: &AttributesQuery<'_>) -> String {
126 let only_filterable = q.only_filterable.map(bool_str);
127 let only_returnable = q.only_returnable.map(bool_str);
128 let only_sortable = q.only_sortable.map(bool_str);
129 encode_pairs(
130 &[
131 ("apply_filters", q.apply_filters),
132 ("entity_type", q.entity_type),
133 ("only_filterable", only_filterable),
134 ("only_returnable", only_returnable),
135 ("only_sortable", only_sortable),
136 ],
137 &[
138 ("attribute_group", &q.attribute_group),
139 ("expand", &q.expand),
140 ("minmax", &q.minmax),
141 ],
142 )
143}
144
145fn bool_str(b: bool) -> &'static str {
146 if b {
147 "true"
148 } else {
149 "false"
150 }
151}
152
153// ---------------------------------------------------------------------------
154// search_stocklist query
155// ---------------------------------------------------------------------------
156
157/// Optional query parameters for [`Client::search_stocklist`].
158///
159/// Every field is documented as optional. Defaults match the documented
160/// API defaults (`limit=50`, `offset=0`, `sort_attribute="name"`,
161/// `sort_order="asc"`); pass `None` to omit a parameter and let the
162/// server apply its default.
163#[derive(Debug, Clone, Default)]
164pub struct StocklistQuery<'a> {
165 /// Defines which filters to apply to the search.
166 pub apply_filters: Option<&'a str>,
167 /// Returns only attributes for the given attribute groups.
168 pub attribute_groups: Vec<String>,
169 /// Returns only the given attributes.
170 pub attributes: Vec<String>,
171 /// Free-text search string (instrument name, symbol, or ISIN).
172 pub free_text_search: Option<&'a str>,
173 /// Limits the search results to `limit`.
174 pub limit: Option<i32>,
175 /// Skips the first `offset` search results per group.
176 pub offset: Option<i32>,
177 /// Defines the attribute to sort by (default `name`).
178 pub sort_attribute: Option<&'a str>,
179 /// Defines the sort order (`asc` or `desc`; default `asc`).
180 pub sort_order: Option<&'a str>,
181}
182
183fn build_stocklist_query(q: &StocklistQuery<'_>) -> String {
184 let limit = q.limit.map(|v| v.to_string());
185 let offset = q.offset.map(|v| v.to_string());
186 encode_pairs(
187 &[
188 ("apply_filters", q.apply_filters),
189 ("free_text_search", q.free_text_search),
190 ("limit", limit.as_deref()),
191 ("offset", offset.as_deref()),
192 ("sort_attribute", q.sort_attribute),
193 ("sort_order", q.sort_order),
194 ],
195 &[
196 ("attribute_groups", &q.attribute_groups),
197 ("attributes", &q.attributes),
198 ],
199 )
200}
201
202// ---------------------------------------------------------------------------
203// Bull/Bear, Mini-future, Unlimited-turbo shared list-style query
204// ---------------------------------------------------------------------------
205
206/// Optional query parameters shared by [`Client::search_bullbearlist`],
207/// [`Client::search_minifuturelist`], and
208/// [`Client::search_unlimitedturbolist`].
209///
210/// All three endpoints document the same parameter set (apply_filters,
211/// free_text_search, limit, offset, sort_attribute, sort_order). One
212/// query type is used for all three to avoid trivial duplication.
213#[derive(Debug, Clone, Default)]
214pub struct ListSearchQuery<'a> {
215 /// Specifies which filters to apply to the search.
216 pub apply_filters: Option<&'a str>,
217 /// Free text search for name, symbol and ISIN.
218 pub free_text_search: Option<&'a str>,
219 /// Limits the search results to `limit` instruments.
220 pub limit: Option<i32>,
221 /// Skips the first `offset` search results.
222 pub offset: Option<i32>,
223 /// Defines the attribute to sort by. (`bullbearlist` defaults to
224 /// `name`; the other two endpoints have no documented default.)
225 pub sort_attribute: Option<&'a str>,
226 /// Defines the sort order (`asc` or `desc`).
227 pub sort_order: Option<&'a str>,
228}
229
230fn build_list_search_query(q: &ListSearchQuery<'_>) -> String {
231 let limit = q.limit.map(|v| v.to_string());
232 let offset = q.offset.map(|v| v.to_string());
233 encode_pairs(
234 &[
235 ("apply_filters", q.apply_filters),
236 ("free_text_search", q.free_text_search),
237 ("limit", limit.as_deref()),
238 ("offset", offset.as_deref()),
239 ("sort_attribute", q.sort_attribute),
240 ("sort_order", q.sort_order),
241 ],
242 &[],
243 )
244}
245
246// ---------------------------------------------------------------------------
247// Resource methods
248// ---------------------------------------------------------------------------
249
250impl Client {
251 /// `GET /instrument_search/attributes` — Search for attributes
252 /// available in the instrument search APIs.
253 ///
254 /// # Errors
255 ///
256 /// [`Error::BadRequest`] (400), [`Error::Unauthorized`] (401),
257 /// [`Error::TooManyRequests`] (429), [`Error::ServiceUnavailable`]
258 /// (503).
259 #[doc(alias = "GET /instrument_search/attributes")]
260 pub async fn get_attributes(
261 &self,
262 filters: AttributesQuery<'_>,
263 ) -> Result<AttributeResults, Error> {
264 let qs = build_attributes_query(&filters);
265 let path = with_query("/instrument_search/attributes", &qs);
266 self.get::<AttributeResults>(&path).await
267 }
268
269 /// `GET /instrument_search/query/stocklist` — Search for stocks.
270 ///
271 /// # Errors
272 ///
273 /// [`Error::BadRequest`] (400), [`Error::Unauthorized`] (401),
274 /// [`Error::TooManyRequests`] (429), [`Error::ServiceUnavailable`]
275 /// (503).
276 #[doc(alias = "GET /instrument_search/query/stocklist")]
277 pub async fn search_stocklist(
278 &self,
279 filters: StocklistQuery<'_>,
280 ) -> Result<StocklistResults, Error> {
281 let qs = build_stocklist_query(&filters);
282 let path = with_query("/instrument_search/query/stocklist", &qs);
283 self.get::<StocklistResults>(&path).await
284 }
285
286 /// `GET /instrument_search/query/bullbearlist` — Search, filter and
287 /// sort instruments within the Bull & Bear entity type.
288 ///
289 /// 204 No Content is documented for this op; it is mapped to an empty
290 /// [`BullBearListResults`] (every field defaulted to `None`).
291 ///
292 /// # Errors
293 ///
294 /// [`Error::BadRequest`] (400), [`Error::Unauthorized`] (401),
295 /// [`Error::TooManyRequests`] (429), [`Error::ServiceUnavailable`]
296 /// (503).
297 #[doc(alias = "GET /instrument_search/query/bullbearlist")]
298 pub async fn search_bullbearlist(
299 &self,
300 filters: ListSearchQuery<'_>,
301 ) -> Result<BullBearListResults, Error> {
302 let qs = build_list_search_query(&filters);
303 let path = with_query("/instrument_search/query/bullbearlist", &qs);
304 match self.get::<BullBearListResults>(&path).await {
305 Ok(v) => Ok(v),
306 Err(Error::Decode { ref body, .. }) if body.trim().is_empty() => {
307 Ok(BullBearListResults {
308 results: None,
309 rows: None,
310 total_hits: None,
311 underlying_instrument_id: None,
312 })
313 }
314 Err(e) => Err(e),
315 }
316 }
317
318 /// `GET /instrument_search/query/minifuturelist` — Search, filter and
319 /// sort instruments within the Mini Future entity type.
320 ///
321 /// # Errors
322 ///
323 /// [`Error::BadRequest`] (400), [`Error::Unauthorized`] (401),
324 /// [`Error::TooManyRequests`] (429), [`Error::ServiceUnavailable`]
325 /// (503).
326 #[doc(alias = "GET /instrument_search/query/minifuturelist")]
327 pub async fn search_minifuturelist(
328 &self,
329 filters: ListSearchQuery<'_>,
330 ) -> Result<MinifutureListResults, Error> {
331 let qs = build_list_search_query(&filters);
332 let path = with_query("/instrument_search/query/minifuturelist", &qs);
333 self.get::<MinifutureListResults>(&path).await
334 }
335
336 /// `GET /instrument_search/query/unlimitedturbolist` — Search, filter
337 /// and sort instruments within the Unlimited Turbo entity type.
338 ///
339 /// # Errors
340 ///
341 /// [`Error::BadRequest`] (400), [`Error::Unauthorized`] (401),
342 /// [`Error::TooManyRequests`] (429), [`Error::ServiceUnavailable`]
343 /// (503).
344 #[doc(alias = "GET /instrument_search/query/unlimitedturbolist")]
345 pub async fn search_unlimitedturbolist(
346 &self,
347 filters: ListSearchQuery<'_>,
348 ) -> Result<UnlimitedTurboListResults, Error> {
349 let qs = build_list_search_query(&filters);
350 let path = with_query("/instrument_search/query/unlimitedturbolist", &qs);
351 self.get::<UnlimitedTurboListResults>(&path).await
352 }
353
354 /// `GET /instrument_search/query/optionlist/pairs` — Search for the
355 /// Option Pair (Put-Call) given an underlying instrument and the
356 /// expiration date.
357 ///
358 /// All three parameters are required per the doc:
359 /// - `currency`: option currency (e.g. `"SEK"`).
360 /// - `expire_date`: expiration date as a Nordnet UNIX-millis epoch
361 /// timestamp (`integer(int64)` per the doc).
362 /// - `underlying_symbol`: underlying instrument symbol (e.g. `"ERIC B"`).
363 ///
364 /// # Errors
365 ///
366 /// [`Error::BadRequest`] (400), [`Error::Unauthorized`] (401),
367 /// [`Error::TooManyRequests`] (429), [`Error::ServiceUnavailable`]
368 /// (503).
369 #[doc(alias = "GET /instrument_search/query/optionlist/pairs")]
370 pub async fn search_optionlist_pairs(
371 &self,
372 currency: &str,
373 expire_date: i64,
374 underlying_symbol: &str,
375 ) -> Result<OptionListResults, Error> {
376 let expire_date = expire_date.to_string();
377 let qs = encode_pairs(
378 &[
379 ("currency", Some(currency)),
380 ("expire_date", Some(&expire_date)),
381 ("underlying_symbol", Some(underlying_symbol)),
382 ],
383 &[],
384 );
385 let path = with_query("/instrument_search/query/optionlist/pairs", &qs);
386 self.get::<OptionListResults>(&path).await
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[test]
395 fn with_query_empty_omits_question_mark() {
396 assert_eq!(with_query("/x", ""), "/x");
397 assert_eq!(with_query("/x", "a=1"), "/x?a=1");
398 }
399
400 #[test]
401 fn attributes_query_empty_when_default() {
402 let qs = build_attributes_query(&AttributesQuery::default());
403 assert_eq!(qs, "");
404 }
405
406 #[test]
407 fn attributes_query_includes_scalar_and_repeated() {
408 let qs = build_attributes_query(&AttributesQuery {
409 apply_filters: Some("nordnet_markets=true"),
410 attribute_group: vec!["PRICE_INFO".to_owned(), "EXCHANGE_INFO".to_owned()],
411 entity_type: Some("STOCKLIST"),
412 expand: vec!["market_id".to_owned()],
413 minmax: vec![],
414 only_filterable: Some(true),
415 only_returnable: Some(false),
416 only_sortable: None,
417 });
418 assert_eq!(
419 qs,
420 "apply_filters=nordnet_markets%3Dtrue&entity_type=STOCKLIST&only_filterable=true&only_returnable=false&attribute_group=PRICE_INFO%2CEXCHANGE_INFO&expand=market_id"
421 );
422 }
423
424 #[test]
425 fn stocklist_query_default_empty() {
426 let qs = build_stocklist_query(&StocklistQuery::default());
427 assert_eq!(qs, "");
428 }
429
430 #[test]
431 fn stocklist_query_with_all_fields() {
432 let qs = build_stocklist_query(&StocklistQuery {
433 apply_filters: Some("instrument_type=ESH"),
434 attribute_groups: vec!["PRICE_INFO".to_owned()],
435 attributes: vec!["name".to_owned(), "isin".to_owned()],
436 free_text_search: Some("erics"),
437 limit: Some(25),
438 offset: Some(50),
439 sort_attribute: Some("name"),
440 sort_order: Some("desc"),
441 });
442 assert_eq!(
443 qs,
444 "apply_filters=instrument_type%3DESH&free_text_search=erics&limit=25&offset=50&sort_attribute=name&sort_order=desc&attribute_groups=PRICE_INFO&attributes=name%2Cisin"
445 );
446 }
447
448 #[test]
449 fn list_search_query_default_empty() {
450 let qs = build_list_search_query(&ListSearchQuery::default());
451 assert_eq!(qs, "");
452 }
453
454 #[test]
455 fn list_search_query_includes_pagination() {
456 let qs = build_list_search_query(&ListSearchQuery {
457 apply_filters: None,
458 free_text_search: Some("ERIC"),
459 limit: Some(10),
460 offset: Some(0),
461 sort_attribute: None,
462 sort_order: Some("asc"),
463 });
464 assert_eq!(qs, "free_text_search=ERIC&limit=10&offset=0&sort_order=asc");
465 }
466}