Skip to main content

nordnet_api/resources/
main_search.rs

1//! Resource methods for the `main_search` API group.
2//!
3//! # Operations
4//!
5//! | Method | Op | Path |
6//! |--------|----|------|
7//! | GET | `search` | `/main_search` |
8//!
9//!
10//! ## Query parameters
11//!
12//! `GET /main_search` takes one required and four optional query params:
13//!
14//! | Param | Form | Notes |
15//! |-------|------|-------|
16//! | `query` | string, required | Search string. |
17//! | `instrument_group` | enum, multi | Repeats once per value (`?instrument_group=EQUITY&instrument_group=ETF`). |
18//! | `limit` | int32, default 5 | |
19//! | `offset` | int32, default 0 | |
20//! | `search_space` | enum, default ALL | |
21//!
22//! `instrument_group` and `search_space` enum values are passed through
23//! as `&str` for now (pragmatic; documented in the task contract). A
24//! later phase may introduce typed enums.
25//!
26//!
27//! ## 204 / 404 No Content
28//!
29//! Both 204 and 404 responses carry no body. The base [`Client::get`]
30//! treats those as a [`Error::Decode`] over an empty body; this method
31//! maps them to an empty `Vec<MainSearchResponse>`, mirroring the
32//! `get_country` pattern.
33
34use crate::client::Client;
35use crate::error::Error;
36use nordnet_model::models::main_search::MainSearchResponse;
37
38/// Build the encoded query string for [`Client::search`].
39///
40/// Uses `reqwest::Url::query_pairs_mut` so all percent-encoding follows
41/// the standard URL form rules (matching what the Nordnet API expects
42/// from a browser-typed request). The placeholder host is never sent
43/// anywhere — only the encoded query suffix is extracted.
44fn build_search_query(
45    query: &str,
46    instrument_group: Option<&[&str]>,
47    limit: Option<i32>,
48    offset: Option<i32>,
49    search_space: Option<&str>,
50) -> String {
51    // Placeholder host; only the query string is extracted.
52    let mut url = match reqwest::Url::parse("http://_/") {
53        Ok(u) => u,
54        // The literal above is a valid absolute URL — this branch is
55        // unreachable. Returning a bare `?query=...` here keeps the
56        // function total without panicking.
57        Err(_) => return format!("query={}", urlencoding_minimal(query)),
58    };
59    {
60        let mut pairs = url.query_pairs_mut();
61        pairs.append_pair("query", query);
62        if let Some(groups) = instrument_group {
63            for g in groups {
64                pairs.append_pair("instrument_group", g);
65            }
66        }
67        if let Some(l) = limit {
68            pairs.append_pair("limit", &l.to_string());
69        }
70        if let Some(o) = offset {
71            pairs.append_pair("offset", &o.to_string());
72        }
73        if let Some(s) = search_space {
74            pairs.append_pair("search_space", s);
75        }
76    }
77    url.query().unwrap_or("").to_owned()
78}
79
80/// Minimal fallback percent-encoder used only on the unreachable URL
81/// parse-failure path inside [`build_search_query`]. Encodes anything
82/// outside the unreserved set (`A-Z`, `a-z`, `0-9`, `-_.~`) as `%HH`.
83fn urlencoding_minimal(s: &str) -> String {
84    let mut out = String::with_capacity(s.len());
85    for byte in s.bytes() {
86        match byte {
87            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
88                out.push(byte as char);
89            }
90            _ => out.push_str(&format!("%{:02X}", byte)),
91        }
92    }
93    out
94}
95
96impl Client {
97    /// `GET /main_search` — Returns the instruments, news, and pages
98    /// matching the given search criteria.
99    ///
100    /// # Parameters
101    ///
102    /// - `query` — required search string.
103    /// - `instrument_group` — optional list of instrument groups to
104    ///   restrict the search to (`EQUITY`, `PINV`, `FUND`, `ETF`,
105    ///   `ETC`, `WARRANT`, `DERIVATIVES`, `INDICATOR`, `OTHER`). When
106    ///   `None`, results from every group are returned. Encoded as
107    ///   repeated `instrument_group=...` pairs.
108    /// - `limit` — optional per-group result limit (default 5).
109    /// - `offset` — optional per-group result offset (default 0).
110    /// - `search_space` — optional search space (`ALL`, `INSTRUMENTS`,
111    ///   `NEWS`, `CMS`, `BLOG`, `INSTRUMENTS_NEWS`, `INSTRUMENTS_CMS`,
112    ///   `NEWS_CMS`, `NEWS_BLOG`, `NEWS_BLOG_CMS`). Default `ALL`.
113    ///
114    /// # Errors
115    ///
116    /// Returns [`Error::BadRequest`] (400), [`Error::Unauthorized`]
117    /// (401), [`Error::TooManyRequests`] (429), or
118    /// [`Error::ServiceUnavailable`] (503) as documented.
119    ///
120    /// HTTP 204 (No Content) and 404 (No Content) responses are mapped
121    /// to an empty `Vec<MainSearchResponse>` (per the `get_country`
122    /// precedent).
123    #[doc(alias = "GET /main_search")]
124    pub async fn search(
125        &self,
126        query: &str,
127        instrument_group: Option<&[&str]>,
128        limit: Option<i32>,
129        offset: Option<i32>,
130        search_space: Option<&str>,
131    ) -> Result<Vec<MainSearchResponse>, Error> {
132        let qs = build_search_query(query, instrument_group, limit, offset, search_space);
133        let path = format!("/main_search?{qs}");
134        match self.get::<Vec<MainSearchResponse>>(&path).await {
135            Ok(v) => Ok(v),
136            // 204 / 404 No Content — base client surfaces this as a
137            // Decode error over an empty body. Map it to an empty Vec.
138            Err(Error::Decode { ref body, .. }) if body.trim().is_empty() => Ok(vec![]),
139            Err(e) => Err(e),
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn build_query_required_only() {
150        let qs = build_search_query("Volvo", None, None, None, None);
151        assert_eq!(qs, "query=Volvo");
152    }
153
154    #[test]
155    fn build_query_all_params() {
156        let qs = build_search_query(
157            "Volvo B",
158            Some(&["EQUITY", "ETF"]),
159            Some(10),
160            Some(5),
161            Some("INSTRUMENTS"),
162        );
163        // `application/x-www-form-urlencoded` encodes spaces as `+`.
164        assert_eq!(
165            qs,
166            "query=Volvo+B&instrument_group=EQUITY&instrument_group=ETF&limit=10&offset=5&search_space=INSTRUMENTS"
167        );
168    }
169
170    #[test]
171    fn build_query_percent_encodes_special_chars() {
172        let qs = build_search_query("a&b=c", None, None, None, None);
173        // `&` and `=` must be percent-encoded so they don't fracture the
174        // query string. `reqwest::Url` uses form-url-encoding rules.
175        assert_eq!(qs, "query=a%26b%3Dc");
176    }
177
178    #[test]
179    fn build_query_empty_instrument_group_omits_param() {
180        let qs = build_search_query("x", Some(&[]), None, None, None);
181        assert_eq!(qs, "query=x");
182    }
183}