openfigi_rs/model/request/search_request.rs
1//! # Search Request Types
2//!
3//! Request structures for the OpenFIGI `/search` endpoint (see [here](https://www.openfigi.com/api/documentation#v3-post-search) for more details).
4//! Provides types for building search requests with fluent builder patterns.
5//!
6//! ## Examples
7//!
8//! ### Basic search request
9//!
10//! ```rust
11//! use openfigi_rs::model::request::SearchRequest;
12//!
13//! let request = SearchRequest::new("AAPL");
14//! ```
15//!
16//! ### Search request with additional filters
17//!
18//! ```rust
19//! use openfigi_rs::model::request::SearchRequest;
20//! use openfigi_rs::model::enums::{Currency, ExchCode};
21//!
22//! let request = SearchRequest::builder()
23//! .query("technology stocks")
24//! .currency(Currency::USD)
25//! .exch_code(ExchCode::US)
26//! .build()
27//! .unwrap();
28//! ```
29//!
30//! Note: This module is not intended for direct use by consumers of the OpenFIGI API.
31
32use crate::{
33 error::{OpenFIGIError, OtherErrorKind, Result},
34 impl_filter_builder,
35 model::{
36 enums::{
37 Currency, ExchCode, MarketSecDesc, MicCode, OptionType, SecurityType, SecurityType2,
38 StateCode,
39 },
40 request::common::RequestFilters,
41 },
42};
43use chrono::NaiveDate;
44use serde::{Deserialize, Serialize};
45
46/// Request structure for the OpenFIGI `/search` endpoint.
47///
48/// Represents a search request that finds FIGIs using keywords and filters.
49/// Requires a search query with optional filtering criteria and pagination support.
50///
51/// # Required Fields
52///
53/// - `query`: Search keywords for finding FIGIs
54///
55/// # Optional Fields
56///
57/// - `start`: Pagination token for retrieving subsequent pages
58/// - `filters`: Additional filtering criteria (flattened into the request JSON)
59///
60/// # Examples
61///
62/// ```rust
63/// use openfigi_rs::model::request::SearchRequest;
64/// use openfigi_rs::model::enums::Currency;
65///
66/// // Simple keyword search
67/// let request = SearchRequest::new("IBM");
68///
69/// // Search with additional filters
70/// let request = SearchRequest::builder()
71/// .query("technology")
72/// .currency(Currency::USD)
73/// .build()
74/// .unwrap();
75/// ```
76#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct SearchRequest {
79 /// Search keywords for finding FIGIs.
80 pub query: String,
81 /// Pagination token for retrieving subsequent result pages.
82 ///
83 /// When more results are available, the response contains a `next` property
84 /// whose value should be sent in succeeding requests as the `start` value
85 /// to retrieve the next "page" of results.
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub start: Option<String>,
88
89 /// Additional filtering criteria applied to the mapping request.
90 ///
91 /// These filters are flattened into the JSON structure and provide
92 /// optional constraints to refine the mapping results see `RequestFilters`.
93 #[serde(flatten)]
94 pub filters: RequestFilters,
95}
96
97impl SearchRequest {
98 /// Creates a new `SearchRequest` with required search keywords.
99 ///
100 /// All filter fields are initialized to their default values (typically `None`).
101 /// Use [`SearchRequest::builder()`] for a more convenient fluent API.
102 ///
103 /// # Arguments
104 ///
105 /// * `query` - Search keywords for FIGIs
106 ///
107 /// # Examples
108 ///
109 /// ```rust
110 /// use openfigi_rs::model::request::SearchRequest;
111 ///
112 /// let request = SearchRequest::new("IBM");
113 /// assert_eq!(request.query, "IBM");
114 /// ```
115 #[must_use]
116 pub fn new(query: impl Into<String>) -> Self {
117 Self {
118 query: query.into(),
119 start: None,
120 filters: RequestFilters::default(),
121 }
122 }
123
124 /// Creates a new `SearchRequestBuilder` for fluent request construction.
125 ///
126 /// Provides a convenient way to build search requests with method chaining.
127 ///
128 /// # Examples
129 ///
130 /// ```rust
131 /// use openfigi_rs::model::request::SearchRequest;
132 /// use openfigi_rs::model::enums::Currency;
133 ///
134 /// let request = SearchRequest::builder()
135 /// .query("AAPL")
136 /// .currency(Currency::USD)
137 /// .build()
138 /// .unwrap();
139 /// ```
140 #[must_use]
141 pub fn builder() -> SearchRequestBuilder {
142 SearchRequestBuilder::new()
143 }
144
145 /// Validates the search request.
146 ///
147 /// Ensures that:
148 /// - All filter validation rules are satisfied
149 /// - No mutually exclusive parameters are set
150 /// - Numeric and date ranges are valid
151 ///
152 /// # Errors
153 ///
154 /// Returns [`OpenFIGIError`] with [`OtherErrorKind::Validation`] if validation fails.
155 ///
156 /// # Examples
157 ///
158 /// ```rust
159 /// use openfigi_rs::model::request::SearchRequest;
160 ///
161 /// let request = SearchRequest::new("IBM");
162 /// assert!(request.validate().is_ok());
163 /// ```
164 pub fn validate(&self) -> Result<()> {
165 // Validate the `RequestFilters` fields
166 self.filters.validate()?;
167 Ok(())
168 }
169}
170
171/// Builder for constructing [`SearchRequest`] instances.
172///
173/// Provides a fluent API for setting search keywords and filter parameters.
174/// All methods return `self` to enable method chaining.
175///
176/// # Required Fields
177///
178/// - `query`: Must be set via [`query()`](Self::query)
179///
180/// # Examples
181///
182/// ```rust
183/// use openfigi_rs::model::request::SearchRequestBuilder;
184/// use openfigi_rs::model::enums::{Currency, ExchCode};
185///
186/// let request = SearchRequestBuilder::new()
187/// .query("technology")
188/// .currency(Currency::USD)
189/// .exch_code(ExchCode::US)
190/// .build()
191/// .unwrap();
192/// ```
193#[derive(Default)]
194pub struct SearchRequestBuilder {
195 query: Option<String>,
196 start: Option<String>,
197 filters: RequestFilters,
198}
199
200impl SearchRequestBuilder {
201 /// Creates a new `SearchRequestBuilder` with all fields unset.
202 ///
203 /// # Examples
204 ///
205 /// ```rust
206 /// use openfigi_rs::model::request::SearchRequestBuilder;
207 ///
208 /// let builder = SearchRequestBuilder::new();
209 /// ```
210 #[must_use]
211 pub fn new() -> Self {
212 Self::default()
213 }
214
215 /// Sets search keywords for the search request.
216 ///
217 /// This field is required and specifies the keywords to search for when
218 /// finding FIGIs.
219 ///
220 /// # Examples
221 ///
222 /// ```rust
223 /// use openfigi_rs::model::request::SearchRequestBuilder;
224 ///
225 /// let builder = SearchRequestBuilder::new().query("AAPL");
226 /// ```
227 #[must_use]
228 pub fn query(mut self, query: impl Into<String>) -> Self {
229 self.query = Some(query.into());
230 self
231 }
232
233 /// Sets the pagination start token.
234 ///
235 /// Used for retrieving subsequent pages of results when the response
236 /// contains a `next` field.
237 ///
238 /// # Examples
239 ///
240 /// ```rust
241 /// use openfigi_rs::model::request::SearchRequestBuilder;
242 ///
243 /// let builder = SearchRequestBuilder::new()
244 /// .query("tech")
245 /// .start("next_page_token");
246 /// ```
247 #[must_use]
248 pub fn start(mut self, start: impl Into<String>) -> Self {
249 self.start = Some(start.into());
250 self
251 }
252
253 /// Mutable access to the request filters.
254 pub fn filters_mut(&mut self) -> &mut RequestFilters {
255 &mut self.filters
256 }
257
258 // Bring in common builder methods for filtering logic
259 impl_filter_builder!();
260
261 /// Builds and validates the `SearchRequest`.
262 ///
263 /// Constructs the final request object and performs validation to ensure
264 /// all requirements are met.
265 ///
266 /// # Errors
267 ///
268 /// Returns [`OpenFIGIError`] if validation fails, such as:
269 /// - Missing required `query` field
270 /// - Mutually exclusive parameters set
271 /// - Invalid parameter ranges
272 ///
273 /// # Examples
274 ///
275 /// ```rust
276 /// use openfigi_rs::model::request::SearchRequestBuilder;
277 ///
278 /// let request = SearchRequestBuilder::new()
279 /// .query("IBM")
280 /// .build()
281 /// .unwrap();
282 /// ```
283 pub fn build(self) -> Result<SearchRequest> {
284 let query = self.query.ok_or_else(|| {
285 OpenFIGIError::other_error(OtherErrorKind::Validation, "query is required")
286 })?;
287 let request = SearchRequest {
288 query,
289 start: self.start,
290 filters: self.filters,
291 };
292 request.validate()?;
293 Ok(request)
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use crate::model::enums::{Currency, ExchCode, MicCode, SecurityType2};
301 use chrono::NaiveDate;
302
303 #[test]
304 fn test_search_request_new_minimal() {
305 let request = SearchRequest::new("ibm");
306 assert_eq!(request.query, "ibm");
307 assert!(request.start.is_none());
308 assert!(request.filters.exch_code.is_none());
309 assert!(request.filters.mic_code.is_none());
310 }
311
312 #[test]
313 fn test_search_request_builder_minimal() {
314 let request = SearchRequest::builder()
315 .query("ibm")
316 .build()
317 .expect("Failed to build search request");
318 assert_eq!(request.query, "ibm");
319 }
320
321 #[test]
322 fn test_search_request_builder_with_currency() {
323 let request = SearchRequest::builder()
324 .query("ibm")
325 .currency(Currency::USD)
326 .build()
327 .expect("Failed to build search request");
328 assert_eq!(request.filters.currency, Some(Currency::USD));
329 }
330
331 #[test]
332 fn test_search_request_validate_exch_and_mic_code_conflict() {
333 let mut request = SearchRequest::new("ibm");
334 request.filters.exch_code = Some(ExchCode::A0);
335 request.filters.mic_code = Some(MicCode::XCME);
336 let result = request.validate();
337 assert!(result.is_err());
338 let msg = format!("{}", result.unwrap_err());
339 assert!(msg.contains("Cannot set both exchCode and micCode"));
340 }
341
342 #[test]
343 fn test_search_request_validate_strike_range() {
344 let mut request = SearchRequest::new("ibm");
345 request.filters.strike = Some([Some(10.0), Some(5.0)]);
346 let result = request.validate();
347 assert!(result.is_err());
348 let msg = format!("{}", result.unwrap_err());
349 assert!(msg.contains("strike: start value cannot be greater than end value"));
350 }
351
352 #[test]
353 fn test_search_request_validate_expiration_required_for_option() {
354 let mut request = SearchRequest::new("ibm");
355 request.filters.security_type2 = Some(SecurityType2::Option);
356 request.filters.expiration = None;
357 let result = request.validate();
358 assert!(result.is_err());
359 let msg = format!("{}", result.unwrap_err());
360 assert!(msg.contains("expiration is required for Option or Warrant security types"));
361 }
362
363 #[test]
364 fn test_search_request_validate_maturity_required_for_pool() {
365 let mut request = SearchRequest::new("ibm");
366 request.filters.security_type2 = Some(SecurityType2::Pool);
367 let result = request.validate();
368 assert!(result.is_err());
369 let msg = format!("{}", result.unwrap_err());
370 assert!(msg.contains("maturity is required for Pool security types"));
371 }
372
373 #[test]
374 fn test_search_request_validate_date_range_too_long() {
375 let mut request = SearchRequest::new("ibm");
376 let start = NaiveDate::from_ymd_opt(2025, 1, 1).expect("Should create a valid date");
377 let end = NaiveDate::from_ymd_opt(2026, 2, 1).expect("Should create a valid date");
378 request.filters.expiration = Some([Some(start), Some(end)]);
379 let result = request.validate();
380 assert!(result.is_err());
381 let msg = format!("{}", result.unwrap_err());
382 assert!(msg.contains("date range cannot exceed 1 year"));
383 }
384
385 #[test]
386 fn test_serialize_deserialize_search_request() {
387 let request = SearchRequest::builder()
388 .query("ibm")
389 .currency(Currency::USD)
390 .build()
391 .expect("Failed to build search request");
392 let serialized =
393 serde_json::to_string(&request).expect("Failed to serialize SearchRequest");
394 let deserialized: SearchRequest =
395 serde_json::from_str(&serialized).expect("Failed to deserialize SearchRequest");
396 assert_eq!(request, deserialized);
397 }
398}