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}