Skip to main content

openfigi_rs/endpoint/
mapping.rs

1//! # OpenFIGI Mapping Endpoint
2//!
3//! Request builders for interacting with the [/mapping](https://www.openfigi.com/api/documentation#v3-post-mapping) endpoint of the OpenFIGI API.
4//! Provides both single and bulk mapping functionality with fluent builder patterns.
5//!
6//! ## Key Features
7//!
8//! - **Single Mapping**: Build and send individual mapping requests
9//! - **Bulk Mapping**: Batch multiple mapping requests in a single request
10//! - **Fluent API**: Chainable method calls for easy configuration
11//! - **Validation**: Automatic validation of request limits and API key requirements
12//!
13//! ## Examples
14//!
15//! ### Single Mapping Request
16//!
17//! ```rust
18//! use openfigi_rs::client::OpenFIGIClient;
19//! use openfigi_rs::model::enums::{IdType, Currency, ExchCode};
20//! use serde_json::json;
21//!
22//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
23//! let client = OpenFIGIClient::new();
24//!
25//! let result = client
26//!     .mapping(IdType::ID_ISIN, json!("US4592001014"))
27//!     .currency(Currency::USD)
28//!     .exch_code(ExchCode::US)
29//!     .send()
30//!     .await?;
31//! # Ok(())
32//! # }
33//! ```
34//!
35//! ### Bulk Mapping Request
36//!
37//! ```rust
38//! use openfigi_rs::client::OpenFIGIClient;
39//! use openfigi_rs::model::request::MappingRequest;
40//! use openfigi_rs::model::enums::IdType;
41//! use serde_json::json;
42//!
43//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
44//! let client = OpenFIGIClient::new();
45//!
46//! let requests = vec![
47//!     MappingRequest::builder()
48//!         .id_type(IdType::ID_ISIN)
49//!         .id_value(json!("US4592001014"))
50//!         .build()?,
51//!     MappingRequest::builder()
52//!         .id_type(IdType::TICKER)
53//!         .id_value(json!("AAPL"))
54//!         .build()?,
55//! ];
56//!
57//! let result = client
58//!     .bulk_mapping()
59//!     .add_requests(requests)
60//!     .send()
61//!     .await?;
62//! # Ok(())
63//! # }
64//! ```
65
66use crate::{
67    DEFAULT_ENDPOINT_MAPPING,
68    client::OpenFIGIClient,
69    error::{OpenFIGIError, OtherErrorKind, Result},
70    impl_filter_builder,
71    model::{
72        enums::{
73            Currency, ExchCode, IdType, MarketSecDesc, MicCode, OptionType, SecurityType,
74            SecurityType2, StateCode,
75        },
76        request::{MappingRequest, MappingRequestBuilder, RequestFilters},
77        response::{MappingData, MappingResponses},
78    },
79};
80use chrono::NaiveDate;
81use reqwest::Method;
82
83/// Builder for constructing single mapping requests to the `/mapping` endpoint.
84///
85/// Provides a fluent API for configuring mapping request parameters and executing requests.
86/// Created via [`OpenFIGIClient::mapping`] with required ID type and value parameters.
87///
88/// # Examples
89///
90/// ```rust
91/// use openfigi_rs::client::OpenFIGIClient;
92/// use openfigi_rs::model::enums::{IdType, Currency};
93/// use serde_json::json;
94///
95/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
96/// let client = OpenFIGIClient::new();
97///
98/// let response = client
99///     .mapping(IdType::ID_ISIN, json!("US4592001014"))
100///     .currency(Currency::USD)
101///     .send()
102///     .await?;
103/// # Ok(())
104/// # }
105/// ```
106pub struct SingleMappingRequestBuilder {
107    client: OpenFIGIClient,
108    request_builder: MappingRequestBuilder,
109}
110
111impl SingleMappingRequestBuilder {
112    /// Sets the required ID type for the mapping request.
113    #[must_use]
114    pub fn id_type(mut self, id_type: IdType) -> Self {
115        self.request_builder = self.request_builder.id_type(id_type);
116        self
117    }
118
119    /// Sets the required ID value for the mapping request.
120    #[must_use]
121    pub fn id_value<T: Into<serde_json::Value>>(mut self, id_value: T) -> Self {
122        self.request_builder = self.request_builder.id_value(id_value);
123        self
124    }
125
126    /// Mutable access to the request filters, delegating to the inner `MappingRequestBuilder`.
127    pub fn filters_mut(&mut self) -> &mut RequestFilters {
128        self.request_builder.filters_mut()
129    }
130
131    // Bring in common builder methods for filtering logic
132    impl_filter_builder!();
133
134    /// Sends the mapping request to `/mapping` endpoint and returns the raw HTTP response.
135    ///
136    /// This is useful when you need access to headers, status codes, or want to handle
137    /// the response parsing yourself.
138    ///
139    /// # Errors
140    ///
141    /// Returns an [`crate::error::OpenFIGIError`] if the mapping request is invalid or if the HTTP request fails.
142    pub async fn send_raw(self) -> Result<reqwest::Response> {
143        let request = self.request_builder.build()?;
144        let requests = vec![request];
145        self.client
146            .request(DEFAULT_ENDPOINT_MAPPING, Method::POST)
147            .body(&requests)
148            .send()
149            .await
150    }
151
152    /// Sends the mapping request to `/mapping` endpoint and returns parsed results.
153    ///
154    /// # Errors
155    ///
156    /// Returns an [`crate::error::OpenFIGIError`] if the mapping request is invalid, if the HTTP request fails,
157    /// or if the response cannot be parsed.
158    #[expect(clippy::missing_panics_doc)]
159    pub async fn send(self) -> Result<MappingData> {
160        let client = self.client.clone();
161        let raw_response = self.send_raw().await?;
162
163        let mut results = client.parse_list_response(raw_response).await?;
164
165        // Take the first element, ensuring the iterator is consumed and the Vec is empty.
166        if results.len() == 1 {
167            // The unwrap is safe due to the length check.
168            results.pop().unwrap()
169        } else {
170            Err(OpenFIGIError::other_error(
171                OtherErrorKind::UnexpectedApiResponse,
172                format!(
173                    "Expected 1 result for single mapping, but got {}",
174                    results.len()
175                ),
176            ))
177        }
178    }
179}
180
181/// Builder for bulk mapping requests to the `/mapping` endpoint.
182///
183/// Allows batching multiple mapping requests into a single API request for improved efficiency.
184/// Automatically validates request count limits based on API key availability.
185///
186/// # Limits
187///
188/// - Without API key: Maximum 5 requests per request
189/// - With API key: Maximum 100 requests per request
190///
191/// # Examples
192///
193/// ```rust
194/// use openfigi_rs::client::OpenFIGIClient;
195/// use openfigi_rs::model::request::MappingRequest;
196/// use openfigi_rs::model::enums::IdType;
197/// use serde_json::json;
198///
199/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
200/// let client = OpenFIGIClient::new();
201/// let requests = vec![
202///     MappingRequest::new(IdType::ID_ISIN, json!("US4592001014")),
203///     MappingRequest::new(IdType::TICKER, json!("AAPL")),
204/// ];
205///
206/// let result = client
207///     .bulk_mapping()
208///     .add_requests(requests)
209///     .send()
210///     .await?;
211/// # Ok(())
212/// # }
213/// ```
214pub struct BulkMappingRequestBuilder {
215    client: OpenFIGIClient,
216    requests: Vec<MappingRequest>,
217}
218
219impl BulkMappingRequestBuilder {
220    /// Adds a single mapping request to the bulk request.
221    #[must_use]
222    pub fn add_request(mut self, request: MappingRequest) -> Self {
223        self.requests.push(request);
224        self
225    }
226
227    /// Adds multiple mapping requests to the bulk request.
228    #[must_use]
229    pub fn add_requests(mut self, requests: Vec<MappingRequest>) -> Self {
230        self.requests.extend(requests);
231        self
232    }
233
234    /// Adds a new, fully configured mapping request to the bulk request using a fluent builder.
235    ///
236    /// This method provides a closure that receives a `MappingRequestBuilder`,
237    /// allowing you to configure a single mapping request with any required filters before
238    /// it's added to the bulk request.
239    ///
240    /// # Errors
241    ///
242    /// Returns an `OpenFIGIError` if the configured request fails validation (e.g.,
243    /// if `id_type` or `id_value` are missing).
244    ///
245    /// # Examples
246    ///
247    /// ```rust,no_run
248    /// # use openfigi_rs::client::OpenFIGIClient;
249    /// # use openfigi_rs::model::enums::{IdType, Currency, ExchCode};
250    /// # use serde_json::json;
251    /// #
252    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
253    /// # let client = OpenFIGIClient::new();
254    /// let result = client
255    ///     .bulk_mapping()
256    ///     .add_request_with(|j| j.id_type(IdType::ID_ISIN).id_value("US4592001014"))? // Simple job
257    ///     .add_request_with(|j| { // Complex mapping request with filters
258    ///         j.id_type(IdType::TICKER)
259    ///             .id_value("IBM")
260    ///             .currency(Currency::USD)
261    ///             .exch_code(ExchCode::US)
262    ///     })?
263    ///     .send()
264    ///     .await?;
265    /// # Ok(())
266    /// # }
267    /// ```
268    pub fn add_request_with<F>(mut self, config: F) -> Result<Self>
269    where
270        F: FnOnce(MappingRequestBuilder) -> MappingRequestBuilder,
271    {
272        let builder = MappingRequest::builder();
273        let configured_builder = config(builder);
274
275        // Build the request and propagate any errors using the `?` operator.
276        let request = configured_builder.build()?;
277
278        // If building succeeds, add the request to our list.
279        self.requests.push(request);
280        Ok(self)
281    }
282
283    /// Sends the bulk mapping request to `/mapping` endpoint and returns the raw HTTP response.
284    ///
285    /// This is useful when you need access to headers, status codes, or want to handle
286    /// the response parsing yourself.
287    ///
288    /// # Errors
289    ///
290    /// Returns an [`crate::error::OpenFIGIError`] if the bulk mapping request is invalid or if the HTTP request fails.
291    pub async fn send_raw(self) -> Result<reqwest::Response> {
292        if self.requests.is_empty() {
293            return Err(OpenFIGIError::other_error(
294                OtherErrorKind::Validation,
295                "No requests to send",
296            ));
297        } else if !self.client.has_api_key() && self.requests.len() > 5 {
298            return Err(OpenFIGIError::other_error(
299                OtherErrorKind::Validation,
300                "Bulk mapping request cannot exceed 5 requests without an API key",
301            ));
302        } else if self.requests.len() > 100 {
303            return Err(OpenFIGIError::other_error(
304                OtherErrorKind::Validation,
305                "Bulk mapping request cannot exceed 100 requests",
306            ));
307        }
308
309        self.client
310            .request(DEFAULT_ENDPOINT_MAPPING, Method::POST)
311            .body(&self.requests)
312            .send()
313            .await
314    }
315
316    /// Sends the mapping request to `/mapping` endpoint and returns parsed results.
317    ///
318    /// # Errors
319    ///
320    /// Returns an [`crate::error::OpenFIGIError`] if the mapping request is invalid, if the HTTP request fails,
321    /// or if the response cannot be parsed.
322    pub async fn send(self) -> Result<MappingResponses> {
323        let client = self.client.clone();
324        let raw_response = self.send_raw().await?;
325
326        let results = client.parse_list_response(raw_response).await?;
327
328        Ok(MappingResponses::new(results))
329    }
330}
331
332impl OpenFIGIClient {
333    /// Creates a new [`SingleMappingRequestBuilder`] for configuring and executing a single mapping request.
334    ///
335    /// # Arguments
336    ///
337    /// * `id_type` - The type of identifier to map
338    /// * `id_value` - The identifier value to map
339    ///
340    /// # Examples
341    ///
342    /// ```rust
343    /// use openfigi_rs::client::OpenFIGIClient;
344    /// use openfigi_rs::model::enums::IdType;
345    ///
346    /// let client = OpenFIGIClient::new();
347    /// let builder = client.mapping(IdType::ID_ISIN, "US4592001014");
348    /// ```
349    #[must_use]
350    pub fn mapping<T: Into<serde_json::Value>>(
351        &self,
352        id_type: IdType,
353        id_value: T,
354    ) -> SingleMappingRequestBuilder {
355        SingleMappingRequestBuilder {
356            client: self.clone(),
357            request_builder: MappingRequestBuilder::new()
358                .id_type(id_type)
359                .id_value(id_value),
360        }
361    }
362
363    /// Creates a new [`BulkMappingRequestBuilder`] for batching multiple mapping requests.
364    ///
365    /// # Examples
366    ///
367    /// ```rust
368    /// use openfigi_rs::client::OpenFIGIClient;
369    ///
370    /// let client = OpenFIGIClient::new();
371    /// let builder = client.bulk_mapping();
372    /// ```
373    #[must_use]
374    pub fn bulk_mapping(&self) -> BulkMappingRequestBuilder {
375        BulkMappingRequestBuilder {
376            client: self.clone(),
377            requests: Vec::new(),
378        }
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use crate::client::OpenFIGIClient;
386    use serde_json::json;
387
388    fn create_test_client() -> OpenFIGIClient {
389        OpenFIGIClient::new()
390    }
391
392    fn create_test_client_with_api_key() -> OpenFIGIClient {
393        OpenFIGIClient::builder()
394            .api_key("test_key")
395            .build()
396            .expect("Failed to create test client")
397    }
398
399    #[test]
400    fn test_single_mapping_request_builder_creation() {
401        let client = create_test_client();
402        let builder = client.mapping(IdType::ID_ISIN, json!("US4592001014"));
403
404        // Builder should be created successfully with correct client reference
405        assert_eq!(builder.client.base_url(), client.base_url());
406        assert_eq!(builder.client.has_api_key(), client.has_api_key());
407
408        // Test that we can build a valid mapping request from the builder
409        let request_result = builder.request_builder.build();
410        assert!(
411            request_result.is_ok(),
412            "Builder should create a valid mapping request"
413        );
414
415        let request = request_result.expect("Builder should create a valid mapping request");
416        assert_eq!(request.id_type, IdType::ID_ISIN);
417        assert_eq!(request.id_value, json!("US4592001014"));
418    }
419
420    #[test]
421    fn test_single_mapping_request_builder_chaining() {
422        let client = create_test_client();
423        let builder = client
424            .mapping(IdType::ID_ISIN, json!("US4592001014"))
425            .exch_code(ExchCode::US)
426            .currency(Currency::USD)
427            .market_sec_des(MarketSecDesc::Equity)
428            .security_type(SecurityType::CommonStock)
429            .include_unlisted_equities(true);
430
431        // Verify that all chained parameters were properly set
432        let request = builder
433            .request_builder
434            .build()
435            .expect("Should build valid mapping request");
436
437        // Check all the chained values are correctly set
438        assert_eq!(request.id_type, IdType::ID_ISIN);
439        assert_eq!(request.id_value, json!("US4592001014"));
440        assert_eq!(request.filters.exch_code, Some(ExchCode::US));
441        assert_eq!(request.filters.currency, Some(Currency::USD));
442        assert_eq!(request.filters.market_sec_des, Some(MarketSecDesc::Equity));
443        assert_eq!(
444            request.filters.security_type,
445            Some(SecurityType::CommonStock)
446        );
447        assert_eq!(request.filters.include_unlisted_equities, Some(true));
448
449        // Verify client reference is preserved
450        assert_eq!(builder.client.base_url(), client.base_url());
451    }
452
453    #[test]
454    fn test_single_mapping_request_builder_option_fields() {
455        let client = create_test_client();
456        let builder = client
457            .mapping(IdType::TICKER, json!("AAPL"))
458            .option_type(OptionType::Call)
459            .strike([Some(150.0), Some(200.0)])
460            .contract_size([Some(100.0), None])
461            .coupon([None, Some(5.0)]);
462
463        // Verify that option-specific fields are properly set
464        let request = builder
465            .request_builder
466            .build()
467            .expect("Should build valid mapping request");
468
469        assert_eq!(request.id_type, IdType::TICKER);
470        assert_eq!(request.id_value, json!("AAPL"));
471        assert_eq!(request.filters.option_type, Some(OptionType::Call));
472        assert_eq!(request.filters.strike, Some([Some(150.0), Some(200.0)]));
473        assert_eq!(request.filters.contract_size, Some([Some(100.0), None]));
474        assert_eq!(request.filters.coupon, Some([None, Some(5.0)]));
475
476        // Verify client reference is preserved
477        assert_eq!(builder.client.base_url(), client.base_url());
478    }
479
480    #[test]
481    fn test_single_mapping_request_builder_date_fields() {
482        let client = create_test_client();
483        let expiration_start =
484            NaiveDate::from_ymd_opt(2024, 1, 1).expect("Should create valid expiration_start date");
485        let expiration_end =
486            NaiveDate::from_ymd_opt(2024, 12, 31).expect("Should create valid expiration_end date");
487        let maturity_start =
488            NaiveDate::from_ymd_opt(2025, 1, 1).expect("Should create valid maturity_start date");
489
490        let builder = client
491            .mapping(IdType::ID_CUSIP, json!("037833100"))
492            .expiration([Some(expiration_start), Some(expiration_end)])
493            .maturity([Some(maturity_start), None])
494            .state_code(StateCode::CA);
495
496        // Verify that date and state fields are properly set
497        let request = builder
498            .request_builder
499            .build()
500            .expect("Should build valid mapping request");
501
502        assert_eq!(request.id_type, IdType::ID_CUSIP);
503        assert_eq!(request.id_value, json!("037833100"));
504        assert_eq!(
505            request.filters.expiration,
506            Some([Some(expiration_start), Some(expiration_end)])
507        );
508        assert_eq!(request.filters.maturity, Some([Some(maturity_start), None]));
509        assert_eq!(request.filters.state_code, Some(StateCode::CA));
510
511        // Verify client reference is preserved
512        assert_eq!(builder.client.base_url(), client.base_url());
513    }
514
515    #[test]
516    fn test_bulk_mapping_request_builder_creation() {
517        let client = create_test_client();
518        let builder = client.bulk_mapping();
519
520        // Builder should be created with empty requests
521        assert_eq!(builder.requests.len(), 0);
522        assert_eq!(builder.client.base_url(), client.base_url());
523    }
524
525    #[test]
526    fn test_bulk_mapping_request_builder_add_request() {
527        let client = create_test_client();
528        let request = MappingRequest::new(IdType::ID_ISIN, json!("US4592001014"));
529
530        let builder = client.bulk_mapping().add_request(request);
531
532        // Verify that exactly one request was added
533        assert_eq!(builder.requests.len(), 1);
534
535        // Verify that the added request has the correct properties
536        let added_request = &builder.requests[0];
537        assert_eq!(added_request.id_type, IdType::ID_ISIN);
538        assert_eq!(added_request.id_value, json!("US4592001014"));
539
540        // Verify client reference is preserved
541        assert_eq!(builder.client.base_url(), client.base_url());
542        assert_eq!(builder.client.has_api_key(), client.has_api_key());
543    }
544
545    #[test]
546    fn test_bulk_mapping_request_builder_add_requests() {
547        let client = create_test_client();
548        let requests = vec![
549            MappingRequest::new(IdType::ID_ISIN, json!("US4592001014")),
550            MappingRequest::new(IdType::ID_ISIN, json!("US0378331005")),
551            MappingRequest::new(IdType::TICKER, json!("MSFT")),
552        ];
553
554        let builder = client.bulk_mapping().add_requests(requests);
555
556        // Verify that exactly three requests were added
557        assert_eq!(builder.requests.len(), 3);
558
559        // Verify that the added requests have the correct properties
560        assert_eq!(builder.requests[0].id_type, IdType::ID_ISIN);
561        assert_eq!(builder.requests[0].id_value, json!("US4592001014"));
562
563        assert_eq!(builder.requests[1].id_type, IdType::ID_ISIN);
564        assert_eq!(builder.requests[1].id_value, json!("US0378331005"));
565
566        assert_eq!(builder.requests[2].id_type, IdType::TICKER);
567        assert_eq!(builder.requests[2].id_value, json!("MSFT"));
568
569        // Verify client reference is preserved
570        assert_eq!(builder.client.base_url(), client.base_url());
571        assert_eq!(builder.client.has_api_key(), client.has_api_key());
572    }
573
574    #[test]
575    fn test_bulk_mapping_request_builder_chaining() {
576        let client = create_test_client();
577        let request1 = MappingRequest::new(IdType::ID_ISIN, json!("US4592001014"));
578        let request2 = MappingRequest::new(IdType::ID_ISIN, json!("US0378331005"));
579        let additional_requests = vec![
580            MappingRequest::new(IdType::TICKER, json!("MSFT")),
581            MappingRequest::new(IdType::TICKER, json!("GOOGL")),
582        ];
583
584        let builder = client
585            .bulk_mapping()
586            .add_request(request1)
587            .add_request(request2)
588            .add_requests(additional_requests);
589
590        // Verify that exactly four requests were added through chaining
591        assert_eq!(builder.requests.len(), 4);
592
593        // Verify that each request was added in the correct order with correct properties
594        assert_eq!(builder.requests[0].id_type, IdType::ID_ISIN);
595        assert_eq!(builder.requests[0].id_value, json!("US4592001014"));
596
597        assert_eq!(builder.requests[1].id_type, IdType::ID_ISIN);
598        assert_eq!(builder.requests[1].id_value, json!("US0378331005"));
599
600        assert_eq!(builder.requests[2].id_type, IdType::TICKER);
601        assert_eq!(builder.requests[2].id_value, json!("MSFT"));
602
603        assert_eq!(builder.requests[3].id_type, IdType::TICKER);
604        assert_eq!(builder.requests[3].id_value, json!("GOOGL"));
605
606        // Verify client reference is preserved
607        assert_eq!(builder.client.base_url(), client.base_url());
608        assert_eq!(builder.client.has_api_key(), client.has_api_key());
609    }
610
611    #[tokio::test]
612    async fn test_bulk_mapping_empty_requests_error() {
613        let client = create_test_client();
614        let builder = client.bulk_mapping();
615
616        let result = builder.send().await;
617
618        assert!(result.is_err());
619        if let Err(OpenFIGIError::OtherError { kind, .. }) = result {
620            assert_eq!(kind, OtherErrorKind::Validation);
621        } else {
622            panic!("Expected validation error for empty requests");
623        }
624    }
625
626    #[tokio::test]
627    async fn test_bulk_mapping_too_many_requests_without_api_key() {
628        let client = create_test_client(); // No API key
629        let requests = (0..6)
630            .map(|i| MappingRequest::new(IdType::TICKER, json!(format!("TEST{}", i))))
631            .collect();
632
633        let builder = client.bulk_mapping().add_requests(requests);
634
635        let result = builder.send_raw().await;
636
637        assert!(result.is_err());
638        if let Err(OpenFIGIError::OtherError { kind, .. }) = result {
639            assert_eq!(kind, OtherErrorKind::Validation);
640        } else {
641            panic!("Expected validation error for too many requests without API key");
642        }
643    }
644
645    #[tokio::test]
646    async fn test_bulk_mapping_too_many_requests_with_api_key() {
647        let client = create_test_client_with_api_key();
648        let requests = (0..101)
649            .map(|i| MappingRequest::new(IdType::TICKER, json!(format!("TEST{}", i))))
650            .collect();
651
652        let builder = client.bulk_mapping().add_requests(requests);
653
654        let result = builder.send_raw().await;
655
656        assert!(result.is_err());
657        if let Err(OpenFIGIError::OtherError { kind, .. }) = result {
658            assert_eq!(kind, OtherErrorKind::Validation);
659        } else {
660            panic!("Expected validation error for too many requests even with API key");
661        }
662    }
663}