Skip to main content

openfigi_rs/model/request/
mapping_request.rs

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