Skip to main content

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}