Skip to main content

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}