Skip to main content

openfigi_rs/model/response/
mapping_response.rs

1//! Response models for the OpenFIGI mapping endpoint.
2//!
3//! This module contains the data structures used to represent responses from the
4//! [OpenFIGI mapping endpoint](https://www.openfigi.com/api/documentation#v3-post-mapping).
5//! The mapping endpoint converts third-party identifiers (such as tickers, ISINs, or CUSIPs)
6//! into FIGI identifiers and returns either successful FIGI results or error responses for
7//! each submitted mapping request.
8//!
9//! # Response Structure
10//!
11//! The mapping endpoint returns:
12//! - For a single request either:
13//!   - [`MappingData`] containing successful results with FIGI data
14//!   - [`OpenFIGIError`] when the mapping request fails
15//! - For a batch request:
16//!   - [`MappingResponses`], which wraps a vector of results (successes and errors) corresponding
17//!     to each mapping request in the batch
18//!
19//! # Batch Processing
20//!
21//! The mapping endpoint supports batch requests (up to 100 mapping requests per call with an API key),
22//! making it efficient for processing multiple identifiers at once. Each mapping request
23//! in the batch gets its own result in the response array.
24//!
25//! # Examples
26//!
27//! ```rust
28//! use openfigi_rs::model::response::MappingData;
29//! use serde_json;
30//!
31//! // Single successful mapping result
32//! let json = r#"[{
33//!     "data": [{"figi": "BBG000BLNNH6", "ticker": "IBM", "name": "INTL BUSINESS MACHINES CORP"}]
34//! }]"#;
35//! let response: Vec<MappingData> = serde_json::from_str(json).unwrap();
36//! assert_eq!(response[0].data().len(), 1);
37//! ```
38//!
39//! Note: This module is not intended for direct use by consumers of the OpenFIGI API.
40
41use crate::error::{OpenFIGIError, Result};
42use crate::model::response::common::FigiResult;
43use serde::{Deserialize, Serialize};
44
45/// Ergonomic wrapper for batch responses from the OpenFIGI mapping endpoint (POST /v3/mapping).
46///
47/// This type represents the complete response from the mapping endpoint, which returns
48/// an array of results corresponding to each mapping request submitted in the batch.
49/// Each mapping request in the batch gets its own result in the response array, which is
50/// either a successful [`MappingData`] or an [`OpenFIGIError`] describing why the mapping failed.
51///
52/// # Usage
53///
54/// - Use [`MappingResponses::successes()`] to iterate over all successful mapping results.
55/// - Use [`MappingResponses::failures()`] to iterate over all errors that occurred for individual requests.
56/// - Use [`MappingResponses::len()`] and [`MappingResponses::is_empty()`] for batch size checks.
57#[derive(Debug)]
58pub struct MappingResponses(Vec<Result<MappingData>>);
59
60impl MappingResponses {
61    #[doc(hidden)]
62    /// Creates a new `MappingResponses` from a vector of results.
63    /// This constructor is primarily for internal use
64    /// and testing purposes.
65    pub(crate) fn new(results: Vec<Result<MappingData>>) -> Self {
66        Self(results)
67    }
68
69    /// Returns an iterator over all successful mapping results in the batch, with their indices.
70    ///
71    /// Each item is a tuple `(index, &MappingData)` for a request that was successfully mapped.
72    pub fn successes(&self) -> impl Iterator<Item = (usize, &MappingData)> {
73        self.0
74            .iter()
75            .enumerate()
76            .filter_map(|(i, r)| r.as_ref().ok().map(|data| (i, data)))
77    }
78
79    /// Returns an iterator over all errors for failed mapping requests in the batch, with their indices.
80    ///
81    /// Each item is a tuple `(index, &OpenFIGIError)` for a request that failed to map.
82    pub fn failures(&self) -> impl Iterator<Item = (usize, &OpenFIGIError)> {
83        self.0
84            .iter()
85            .enumerate()
86            .filter_map(|(i, r)| r.as_ref().err().map(|err| (i, err)))
87    }
88
89    /// Returns the total number of mapping results (successes + failures) in the batch.
90    #[must_use]
91    pub fn len(&self) -> usize {
92        self.0.len()
93    }
94
95    /// Returns true if there are no mapping results in the batch.
96    #[must_use]
97    pub fn is_empty(&self) -> bool {
98        self.0.is_empty()
99    }
100
101    /// Returns a reference to the underlying vector of results, preserving order and index.
102    pub fn as_slice(&self) -> &[Result<MappingData>] {
103        &self.0
104    }
105}
106
107/// Successful mapping result containing FIGI data for a single mapping request.
108///
109/// This structure represents the payload returned when a mapping request successfully
110/// finds matching FIGI identifiers for the submitted third-party identifier. A single
111/// mapping request can return multiple FIGI results when the identifier matches multiple
112/// financial instruments (e.g., different share classes or trading venues).
113///
114/// # Multiple Results
115///
116/// Some identifiers may map to multiple FIGIs:
117/// - **Multiple exchanges**: The same instrument may trade on different exchanges
118/// - **Different instrument types**: An identifier might match both the underlying stock and related derivatives
119///
120/// # Field Descriptions
121///
122/// - `data`: Array of FIGI results that match the mapping request criteria
123#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
124pub struct MappingData {
125    /// Array of FIGI results that match the mapping request criteria.
126    ///
127    /// This field contains all the financial instruments that match the submitted
128    /// identifier. The array may contain:
129    /// - A single result for unique identifiers
130    /// - Multiple results when the identifier matches several instruments
131    /// - An empty array if no matches are found (though this typically results in an error instead)
132    ///
133    /// Each FIGI result provides detailed information about the matched financial instrument.
134    pub data: Vec<FigiResult>,
135}
136
137impl MappingData {
138    /// Returns a slice of the FIGI results contained in this mapping response.
139    ///
140    /// Provides access to the financial instrument data returned for the mapping request.
141    #[must_use]
142    pub fn data(&self) -> &[FigiResult] {
143        &self.data
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::{model::response::common::ResponseResult, test_utils::load_test_data};
151
152    /// Helper function to convert raw response results into a `MappingResponses` instance
153    fn from_response_results(raw: Vec<ResponseResult<MappingData>>) -> MappingResponses {
154        MappingResponses::new(
155            raw.into_iter()
156                .map(|res| match res {
157                    ResponseResult::Success(data) => Ok(data),
158                    ResponseResult::Error(err) => Err(OpenFIGIError::response_error(
159                        reqwest::StatusCode::OK,
160                        err.error,
161                        String::new(),
162                    )),
163                })
164                .collect(),
165        )
166    }
167
168    #[test]
169    fn test_deserialize_isin_example() {
170        let json_str = load_test_data("mapping", "isin_example.json");
171
172        let raw: Vec<ResponseResult<MappingData>> =
173            serde_json::from_str(&json_str).expect("Failed to deserialize mapping response");
174        let mapping_response = from_response_results(raw);
175
176        assert_eq!(mapping_response.len(), 1);
177        let response_result = &mapping_response.as_slice()[0];
178        match response_result {
179            Ok(mapping_data) => {
180                let figi_result = mapping_data.data();
181                assert!(!figi_result.is_empty());
182
183                // Check first IBM entry
184                let first_entry = &figi_result[0];
185                assert_eq!(first_entry.figi, "BBG000BLNNH6");
186                assert_eq!(first_entry.ticker, Some("IBM".to_string()));
187                assert_eq!(first_entry.display_name(), "INTL BUSINESS MACHINES CORP");
188
189                // Check if composite_figi and share_class_figi exists
190                assert!(first_entry.has_composite_figi());
191                assert!(first_entry.has_share_class_figi());
192
193                // Verify actual field values from real data
194                assert_eq!(first_entry.composite_figi, Some("BBG000BLNNH6".to_string()));
195                assert_eq!(
196                    first_entry.share_class_figi,
197                    Some("BBG001S5S399".to_string())
198                );
199            }
200            Err(e) => panic!("Expected success, got error: {e}"),
201        }
202    }
203
204    #[test]
205    fn test_deserialize_invalid_identifier() {
206        let json_str = load_test_data("mapping", "invalid_identifier.json");
207        let raw: Vec<ResponseResult<MappingData>> =
208            serde_json::from_str(&json_str).expect("Failed to deserialize mapping response");
209        let mapping_response = from_response_results(raw);
210
211        assert_eq!(mapping_response.len(), 1);
212        let response_result = &mapping_response.as_slice()[0];
213        match response_result {
214            Ok(_) => panic!("Expected error, got success"),
215            Err(OpenFIGIError::ResponseError(resp)) => {
216                assert!(resp.message.contains("Invalid idValue format."));
217            }
218            Err(e) => panic!("Unexpected error variant: {e}"),
219        }
220    }
221
222    #[test]
223    fn test_deserialize_bulk_request() {
224        let json_str = load_test_data("mapping", "bulk_request.json");
225        let raw: Vec<ResponseResult<MappingData>> =
226            serde_json::from_str(&json_str).expect("Failed to deserialize mapping response");
227        let mapping_response = from_response_results(raw);
228
229        assert_eq!(mapping_response.len(), 2);
230
231        // First result should be IBM success
232        let ibm_response_result = &mapping_response.as_slice()[0];
233        match ibm_response_result {
234            Ok(mapping_data) => {
235                let ibm_figi_result = mapping_data.data();
236                assert!(!ibm_figi_result.is_empty());
237                assert_eq!(ibm_figi_result[0].ticker, Some("IBM".to_string()));
238            }
239            Err(e) => panic!("Expected IBM success, got error: {e}"),
240        }
241
242        // Second result should be AAPL success
243        let aapl_response_result = &mapping_response.as_slice()[1];
244        match aapl_response_result {
245            Ok(mapping_data) => {
246                let aapl_figi_result = mapping_data.data();
247                assert!(!aapl_figi_result.is_empty());
248                assert_eq!(aapl_figi_result[0].ticker, Some("AAPL".to_string()));
249            }
250            Err(e) => panic!("Expected AAPL success, got error: {e}"),
251        }
252    }
253
254    #[test]
255    fn test_deserialize_cusip_with_exchange() {
256        let json_str = load_test_data("mapping", "cusip_with_exchange.json");
257        let raw: Vec<ResponseResult<MappingData>> =
258            serde_json::from_str(&json_str).expect("Failed to deserialize mapping response");
259        let mapping_response = from_response_results(raw);
260
261        assert_eq!(mapping_response.len(), 1);
262        let response_result = &mapping_response.as_slice()[0];
263        match response_result {
264            Ok(mapping_data) => {
265                let figi_result = mapping_data.data();
266                assert!(!figi_result.is_empty());
267
268                // Verify we get valid FIGI results
269                for data in figi_result {
270                    assert!(!data.figi.is_empty());
271                    assert!(data.ticker.is_some());
272                }
273            }
274            Err(e) => panic!("Expected success, got error: {e}"),
275        }
276    }
277
278    #[test]
279    fn test_deserialize_ticker_with_security_type() {
280        let json_str = load_test_data("mapping", "ticker_with_security_type.json");
281        let raw: Vec<ResponseResult<MappingData>> =
282            serde_json::from_str(&json_str).expect("Failed to deserialize mapping response");
283        let mapping_response = from_response_results(raw);
284
285        assert_eq!(mapping_response.len(), 1);
286        let response_result = &mapping_response.as_slice()[0];
287        match response_result {
288            Ok(mapping_data) => {
289                let figi_result = mapping_data.data();
290                assert!(!figi_result.is_empty());
291
292                // Verify security type is properly parsed
293                for data in figi_result {
294                    assert!(data.security_type.is_some());
295                    assert!(data.market_sector.is_some());
296                }
297            }
298            Err(e) => panic!("Expected success, got error: {e}"),
299        }
300    }
301
302    #[test]
303    fn test_deserialize_option_example() {
304        let json_str = load_test_data("mapping", "option_example.json");
305        let raw: Vec<ResponseResult<MappingData>> =
306            serde_json::from_str(&json_str).expect("Failed to deserialize mapping response");
307        let mapping_response = from_response_results(raw);
308
309        assert_eq!(mapping_response.len(), 1);
310        let response_result = &mapping_response.as_slice()[0];
311        match response_result {
312            Ok(mapping_data) => {
313                let figi_result = mapping_data.data();
314                for data in figi_result {
315                    assert!(!data.figi.is_empty());
316                }
317            }
318            Err(e) => {
319                // This could be either success or error depending on the option data
320                assert!(!e.to_string().is_empty());
321            }
322        }
323    }
324
325    #[test]
326    fn test_deserialize_currency_mic_example() {
327        let json_str = load_test_data("mapping", "currency_mic_example.json");
328        let raw: Vec<ResponseResult<MappingData>> =
329            serde_json::from_str(&json_str).expect("Failed to deserialize mapping response");
330        let mapping_response = from_response_results(raw);
331
332        assert_eq!(mapping_response.len(), 1);
333        let response_result = &mapping_response.as_slice()[0];
334        match response_result {
335            Ok(mapping_data) => {
336                let figi_result = mapping_data.data();
337                for data in figi_result {
338                    assert!(!data.figi.is_empty());
339                }
340            }
341            Err(e) => {
342                // This could be either success or error depending on the currency data
343                assert!(!e.to_string().is_empty());
344            }
345        }
346    }
347
348    #[test]
349    fn test_figi_result_display_name_fallback() {
350        // Test with only ticker
351        let figi_with_ticker = FigiResult {
352            figi: "BBG000BLNNH6".to_string(),
353            name: None,
354            ticker: Some("IBM".to_string()),
355            security_type: None,
356            market_sector: None,
357            exch_code: None,
358            share_class_figi: None,
359            composite_figi: None,
360            security_type2: None,
361            security_description: None,
362            metadata: None,
363        };
364        assert_eq!(figi_with_ticker.display_name(), "IBM");
365
366        // Test with only FIGI
367        let figi_only = FigiResult {
368            figi: "BBG000BLNNH6".to_string(),
369            name: None,
370            ticker: None,
371            security_type: None,
372            market_sector: None,
373            exch_code: None,
374            share_class_figi: None,
375            composite_figi: None,
376            security_type2: None,
377            security_description: None,
378            metadata: None,
379        };
380        assert_eq!(figi_only.display_name(), "BBG000BLNNH6");
381    }
382}