Skip to main content

cima_rs/
api_client.rs

1use anyhow::{Context, Result};
2use reqwest::Client;
3use serde::de::DeserializeOwned;
4use std::time::Duration;
5use tracing::instrument;
6
7const BASE_URL: &str = "https://cima.aemps.es/cima/rest";
8const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
9
10/// Client for interacting with the CIMA REST API
11#[derive(Clone, Debug)]
12pub struct CimaClient {
13    base_url: String,
14    pub(crate) client: Client,
15}
16
17impl CimaClient {
18    /// Create a new CIMA client with default configuration
19    pub fn new() -> Result<Self> {
20        Self::with_base_url(BASE_URL)
21    }
22
23    /// Create a client with a custom base URL (useful for testing)
24    pub fn with_base_url(base_url: &str) -> Result<Self> {
25        tracing::debug!(base_url, "Creating CIMA client");
26
27        let client = Client::builder()
28            .timeout(DEFAULT_TIMEOUT)
29            .user_agent("cima-rs/0.0.1")
30            .build()
31            .context("Failed to create HTTP client")?;
32
33        Ok(Self {
34            base_url: base_url.to_string(),
35            client,
36        })
37    }
38
39    /// Construye una URL completa para un endpoint
40    pub(crate) fn build_url(&self, endpoint: &str) -> String {
41        format!("{}/{}", self.base_url, endpoint)
42    }
43
44    /// Realiza una petición GET y deserializa la respuesta JSON
45    #[instrument(skip(self), fields(url))]
46    pub(crate) async fn get<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T> {
47        let url = self.build_url(endpoint);
48        tracing::Span::current().record("url", &url);
49
50        tracing::debug!("Sending GET request");
51
52        let response = self
53            .client
54            .get(&url)
55            .send()
56            .await
57            .with_context(|| format!("Failed to send GET request to {}", url))?;
58
59        let status = response.status();
60        tracing::debug!(%status, "Received response");
61
62        if !status.is_success() {
63            tracing::error!(%status, %url, "API returned error status");
64            anyhow::bail!("API returned error status {}: {}", status, url);
65        }
66
67        response
68            .json::<T>()
69            .await
70            .with_context(|| format!("Failed to deserialize JSON response from {}", url))
71    }
72
73    /// Realiza una petición GET con parámetros query
74    #[instrument(skip(self, params), fields(url, param_count = params.len()))]
75    pub(crate) async fn get_with_params<T: DeserializeOwned>(
76        &self,
77        endpoint: &str,
78        params: &[(&str, String)],
79    ) -> Result<T> {
80        let mut url = self.build_url(endpoint);
81
82        // Build query string manually
83        if !params.is_empty() {
84            url.push('?');
85            for (i, (key, value)) in params.iter().enumerate() {
86                if i > 0 {
87                    url.push('&');
88                }
89                url.push_str(key);
90                url.push('=');
91                url.push_str(&urlencoding::encode(value));
92            }
93        }
94
95        tracing::Span::current().record("url", &url);
96        tracing::debug!(params = ?params, "Sending GET request with parameters");
97
98        let response = self
99            .client
100            .get(&url)
101            .send()
102            .await
103            .with_context(|| format!("Failed to send GET request to {}", url))?;
104
105        let status = response.status();
106        tracing::debug!(%status, "Received response");
107
108        if !status.is_success() {
109            tracing::error!(%status, %url, "API returned error status");
110            anyhow::bail!("API returned error status {}: {}", status, url);
111        }
112
113        response
114            .json::<T>()
115            .await
116            .with_context(|| format!("Failed to deserialize JSON response from {}", url))
117    }
118
119    /// Realiza una petición POST con body JSON
120    #[instrument(skip(self, body), fields(url))]
121    pub(crate) async fn post<T: DeserializeOwned, B: serde::Serialize + ?Sized>(
122        &self,
123        endpoint: &str,
124        body: &B,
125    ) -> Result<T> {
126        let url = self.build_url(endpoint);
127        tracing::Span::current().record("url", &url);
128
129        tracing::debug!("Sending POST request");
130
131        let response = self
132            .client
133            .post(&url)
134            .json(body)
135            .send()
136            .await
137            .with_context(|| format!("Failed to send POST request to {}", url))?;
138
139        let status = response.status();
140        tracing::debug!(%status, "Received response");
141
142        if !status.is_success() {
143            tracing::error!(%status, %url, "API returned error status");
144            anyhow::bail!("API returned error status {}: {}", status, url);
145        }
146
147        response
148            .json::<T>()
149            .await
150            .with_context(|| format!("Failed to deserialize JSON response from {}", url))
151    }
152}
153
154impl Default for CimaClient {
155    fn default() -> Self {
156        Self::new().expect("Failed to create default CIMA client")
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn test_build_url() {
166        let client = CimaClient::new().unwrap();
167        assert_eq!(
168            client.build_url("medicamento"),
169            "https://cima.aemps.es/cima/rest/medicamento"
170        );
171    }
172
173    #[test]
174    fn test_custom_base_url() {
175        let client = CimaClient::with_base_url("http://localhost:8080").unwrap();
176        assert_eq!(client.build_url("test"), "http://localhost:8080/test");
177    }
178}