Skip to main content

context7_cli/
api.rs

1/// HTTP client, retry logic, and Context7 API calls.
2///
3/// This module owns the full lifecycle of API interaction:
4/// request building, status handling, and key-rotation retry.
5use anyhow::{bail, Context, Result};
6use rand::seq::SliceRandom;
7use reqwest::StatusCode;
8use serde::{Deserialize, Serialize};
9use tokio::time::{sleep, Duration};
10use tracing::{error, info, warn};
11
12use crate::errors::ErroContext7;
13
14// ─── CONSTANTE ───────────────────────────────────────────────────────────────
15
16const BASE_URL: &str = "https://context7.com/api";
17
18// ─── MODELOS DE RESPOSTA DA API ───────────────────────────────────────────────
19
20/// Represents a single library entry returned by the search endpoint.
21#[derive(Debug, Deserialize, Serialize)]
22pub struct LibrarySearchResult {
23    /// Unique library identifier (e.g. `/facebook/react`).
24    pub id: String,
25    /// Human-readable library title.
26    pub title: String,
27    /// Optional short description of the library.
28    pub description: Option<String>,
29    /// Relevance/trust score returned by the API, if available.
30    pub trust_score: Option<f64>,
31}
32
33/// Represents a single documentation excerpt returned by the docs endpoint.
34#[derive(Debug, Deserialize, Serialize)]
35pub struct DocumentationSnippet {
36    /// Markdown or plain-text content of the snippet.
37    pub content: String,
38    /// Content type hint (e.g. `"text"`, `"code"`), if provided.
39    #[serde(rename = "type")]
40    pub tipo: Option<String>,
41    /// Source URLs associated with this snippet, if provided.
42    pub source_urls: Option<Vec<String>>,
43}
44
45/// Top-level response from the library search endpoint (`GET /api/v1/search`).
46#[derive(Debug, Deserialize)]
47pub struct RespostaListaBibliotecas {
48    /// List of matching libraries.
49    pub results: Vec<LibrarySearchResult>,
50}
51
52/// Top-level response from the documentation endpoint (`GET /api/v1/{library_id}`).
53#[derive(Debug, Deserialize, Serialize)]
54pub struct RespostaDocumentacao {
55    /// Library identifier echoed back by the API, if present.
56    #[allow(dead_code)]
57    pub id: Option<String>,
58    /// Structured documentation snippets (JSON mode).
59    pub snippets: Option<Vec<DocumentationSnippet>>,
60    /// Raw text content (plain-text mode).
61    pub content: Option<String>,
62}
63
64// ─── CLIENTE HTTP ─────────────────────────────────────────────────────────────
65
66/// Creates a reusable HTTP client with rustls-TLS, 30 s timeout, and HTTP/2.
67///
68/// The client should be created once per invocation and shared via `Arc`.
69pub fn criar_cliente_http() -> Result<reqwest::Client> {
70    let cliente = reqwest::Client::builder()
71        .use_rustls_tls()
72        .timeout(Duration::from_secs(30))
73        .user_agent("context7-cli/0.2.0")
74        .pool_max_idle_per_host(4)
75        .build()
76        .context("Falha ao criar cliente HTTP")?;
77
78    Ok(cliente)
79}
80
81// ─── RETRY COM ROTAÇÃO DE CHAVES ──────────────────────────────────────────────
82
83/// Executes an API call with retry and key rotation.
84///
85/// Shuffles a local copy of the provided keys (random draw without replacement)
86/// and retries up to `min(3, keys.len())` times with exponential backoff:
87/// 500 ms → 1 s → 2 s.
88///
89/// The closure receives an owned `String` (clone of the key) to satisfy the
90/// `async move` ownership requirement inside the future.
91pub async fn executar_com_retry<F, Fut, T>(chaves: &[String], operacao: F) -> Result<T>
92where
93    F: Fn(String) -> Fut,
94    Fut: std::future::Future<Output = Result<T, ErroContext7>>,
95{
96    let max_tentativas = 3usize.min(chaves.len());
97
98    // Shuffles a local copy — avoids modifying the caller's vec
99    let mut chaves_embaralhadas = chaves.to_vec();
100    let mut rng = rand::thread_rng();
101    chaves_embaralhadas.shuffle(&mut rng);
102
103    let atrasos_ms = [500u64, 1000, 2000];
104    let mut chaves_falhas_auth = 0usize;
105
106    for (tentativa, chave) in chaves_embaralhadas
107        .into_iter()
108        .take(max_tentativas)
109        .enumerate()
110    {
111        info!("Tentativa {}/{}", tentativa + 1, max_tentativas);
112
113        match operacao(chave).await {
114            Ok(resultado) => return Ok(resultado),
115
116            Err(ErroContext7::ApiRetornou400 { mensagem }) => {
117                // 400 is not transient — abort immediately
118                bail!(ErroContext7::ApiRetornou400 { mensagem });
119            }
120
121            Err(ErroContext7::SemChavesApi) => {
122                chaves_falhas_auth += 1;
123                warn!("Chave de API inválida (401/403), tentando próxima...");
124            }
125
126            Err(e) => {
127                warn!("Falha na tentativa {}: {}", tentativa + 1, e);
128
129                // Backoff before next attempt (not on the last one)
130                if tentativa + 1 < max_tentativas && tentativa < atrasos_ms.len() {
131                    let atraso = Duration::from_millis(atrasos_ms[tentativa]);
132                    info!(
133                        "Aguardando {}ms antes de tentar novamente...",
134                        atraso.as_millis()
135                    );
136                    sleep(atraso).await;
137                }
138            }
139        }
140    }
141
142    if chaves_falhas_auth >= max_tentativas {
143        bail!(ErroContext7::SemChavesApi);
144    }
145
146    bail!(ErroContext7::RetryEsgotado {
147        tentativas: max_tentativas as u32,
148    });
149}
150
151// ─── CHAMADAS À API ───────────────────────────────────────────────────────────
152
153/// Searches for libraries matching `nome` with optional relevance `query_contexto`.
154///
155/// Returns `Err(ErroContext7)` on HTTP errors to enable retry in `executar_com_retry`.
156pub async fn buscar_biblioteca(
157    cliente: &reqwest::Client,
158    chave: &str,
159    nome: &str,
160    query_contexto: &str,
161) -> Result<RespostaListaBibliotecas, ErroContext7> {
162    let url = format!("{}/v1/search", BASE_URL);
163
164    let resposta = cliente
165        .get(&url)
166        .bearer_auth(chave)
167        .query(&[("libraryName", nome), ("query", query_contexto)])
168        .send()
169        .await
170        .map_err(|e| {
171            error!("Erro de rede ao buscar biblioteca: {}", e);
172            ErroContext7::RespostaInvalida { status: 0 }
173        })?;
174
175    tratar_status_resposta(resposta).await
176}
177
178/// Fetches documentation for `library_id` with an optional `query` filter.
179///
180/// `texto_plano = true` requests the `txt` content type instead of JSON.
181/// Returns `Err(ErroContext7)` on HTTP errors to enable retry in `executar_com_retry`.
182pub async fn buscar_documentacao(
183    cliente: &reqwest::Client,
184    chave: &str,
185    library_id: &str,
186    query: Option<&str>,
187    texto_plano: bool,
188) -> Result<RespostaDocumentacao, ErroContext7> {
189    // Normalise library_id: strip leading slash if present
190    let id_normalizado = library_id.trim_start_matches('/');
191    let url = format!("{}/v1/{}", BASE_URL, id_normalizado);
192
193    let tipo = if texto_plano { "txt" } else { "json" };
194
195    let mut construtor = cliente
196        .get(&url)
197        .bearer_auth(chave)
198        .query(&[("type", tipo)]);
199
200    if let Some(q) = query {
201        construtor = construtor.query(&[("query", q)]);
202    }
203
204    let resposta = construtor.send().await.map_err(|e| {
205        error!("Erro de rede ao buscar documentação: {}", e);
206        ErroContext7::RespostaInvalida { status: 0 }
207    })?;
208
209    tratar_status_resposta(resposta).await
210}
211
212/// Maps HTTP status codes to typed `ErroContext7` variants or deserialises success bodies.
213async fn tratar_status_resposta<T: for<'de> Deserialize<'de>>(
214    resposta: reqwest::Response,
215) -> Result<T, ErroContext7> {
216    let status = resposta.status();
217
218    match status {
219        s if s.is_success() => resposta.json::<T>().await.map_err(|e| {
220            error!("Falha ao desserializar resposta JSON: {}", e);
221            ErroContext7::RespostaInvalida {
222                status: status.as_u16(),
223            }
224        }),
225
226        StatusCode::BAD_REQUEST => {
227            let mensagem = resposta
228                .text()
229                .await
230                .unwrap_or_else(|_| "Sem detalhes".to_string());
231            Err(ErroContext7::ApiRetornou400 { mensagem })
232        }
233
234        StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(ErroContext7::SemChavesApi),
235
236        StatusCode::TOO_MANY_REQUESTS => {
237            warn!("Rate limit atingido (429), aguardando retry...");
238            Err(ErroContext7::RespostaInvalida {
239                status: status.as_u16(),
240            })
241        }
242
243        s if s.is_server_error() => {
244            warn!(
245                "Erro do servidor ({}), tentando novamente...",
246                status.as_u16()
247            );
248            Err(ErroContext7::RespostaInvalida {
249                status: status.as_u16(),
250            })
251        }
252
253        _ => Err(ErroContext7::RespostaInvalida {
254            status: status.as_u16(),
255        }),
256    }
257}
258
259// ─── TESTES ───────────────────────────────────────────────────────────────────
260
261#[cfg(test)]
262mod testes {
263    use super::*;
264
265    // ── Desserialização de structs ────────────────────────────────────────────
266
267    #[test]
268    fn testa_deserializacao_library_search_result() {
269        let json = r#"{
270            "id": "/facebook/react",
271            "title": "React",
272            "description": "A JavaScript library for building user interfaces",
273            "trust_score": 95.0
274        }"#;
275
276        let resultado: LibrarySearchResult =
277            serde_json::from_str(json).expect("Deve deserializar LibrarySearchResult");
278
279        assert_eq!(resultado.id, "/facebook/react");
280        assert_eq!(resultado.title, "React");
281        assert_eq!(
282            resultado.description.as_deref(),
283            Some("A JavaScript library for building user interfaces")
284        );
285        assert!((resultado.trust_score.unwrap() - 95.0).abs() < f64::EPSILON);
286    }
287
288    #[test]
289    fn testa_deserializacao_library_search_result_tolerante_campos_faltando() {
290        let json = r#"{
291            "id": "/minimal/lib",
292            "title": "MinimalLib"
293        }"#;
294
295        let resultado: LibrarySearchResult =
296            serde_json::from_str(json).expect("Deve deserializar mesmo com campos ausentes");
297
298        assert_eq!(resultado.id, "/minimal/lib");
299        assert_eq!(resultado.title, "MinimalLib");
300        assert!(resultado.description.is_none(), "description deve ser None");
301        assert!(resultado.trust_score.is_none(), "trust_score deve ser None");
302    }
303
304    #[test]
305    fn testa_deserializacao_documentation_snippet() {
306        let json = r#"{
307            "content": "The Effect Hook lets you perform side effects in function components.",
308            "type": "text",
309            "source_urls": ["https://react.dev/reference/react/useEffect"]
310        }"#;
311
312        let trecho: DocumentationSnippet =
313            serde_json::from_str(json).expect("Deve deserializar DocumentationSnippet");
314
315        assert!(trecho.content.contains("side effects"));
316        assert_eq!(trecho.tipo.as_deref(), Some("text"));
317        let urls = trecho.source_urls.as_ref().expect("Deve ter source_urls");
318        assert_eq!(urls[0], "https://react.dev/reference/react/useEffect");
319    }
320
321    #[test]
322    fn testa_deserializacao_documentation_snippet_sem_campos_opcionais() {
323        let json = r#"{
324            "content": "Conteúdo mínimo do trecho."
325        }"#;
326
327        let trecho: DocumentationSnippet =
328            serde_json::from_str(json).expect("Deve deserializar sem campos opcionais");
329
330        assert_eq!(trecho.content, "Conteúdo mínimo do trecho.");
331        assert!(trecho.tipo.is_none());
332        assert!(trecho.source_urls.is_none());
333    }
334
335    // ── Mock HTTP ─────────────────────────────────────────────────────────────
336
337    #[tokio::test]
338    async fn testa_buscar_biblioteca_com_mock_servidor_retorna_200() {
339        use wiremock::matchers::{method, path};
340        use wiremock::{Mock, MockServer, ResponseTemplate};
341
342        let servidor_mock = MockServer::start().await;
343
344        let resposta_json = serde_json::json!({
345            "results": [
346                {
347                    "id": "/axum-rs/axum",
348                    "title": "axum",
349                    "description": "Framework web para Rust",
350                    "trust_score": 90.0
351                }
352            ]
353        });
354
355        Mock::given(method("GET"))
356            .and(path("/api/v1/search"))
357            .respond_with(ResponseTemplate::new(200).set_body_json(&resposta_json))
358            .mount(&servidor_mock)
359            .await;
360
361        let cliente = reqwest::Client::new();
362        let url = format!("{}/api/v1/search", servidor_mock.uri());
363
364        let resposta = cliente
365            .get(&url)
366            .bearer_auth("ctx7sk-teste-mock")
367            .query(&[("libraryName", "axum"), ("query", "axum")])
368            .send()
369            .await
370            .expect("Deve conectar ao mock server");
371
372        assert!(resposta.status().is_success(), "Status deve ser 200");
373
374        let dados: RespostaListaBibliotecas = resposta
375            .json()
376            .await
377            .expect("Deve deserializar resposta do mock");
378
379        assert_eq!(dados.results.len(), 1);
380        assert_eq!(dados.results[0].id, "/axum-rs/axum");
381        assert_eq!(dados.results[0].title, "axum");
382    }
383
384    #[tokio::test]
385    async fn testa_buscar_documentacao_com_mock_servidor_retorna_200() {
386        use wiremock::matchers::{method, path};
387        use wiremock::{Mock, MockServer, ResponseTemplate};
388
389        let servidor_mock = MockServer::start().await;
390
391        let resposta_json = serde_json::json!({
392            "id": "/axum-rs/axum",
393            "snippets": [
394                {
395                    "content": "O Router do Axum permite definir rotas HTTP de forma declarativa.",
396                    "type": "text",
397                    "source_urls": ["https://docs.rs/axum/latest/axum/struct.Router.html"]
398                }
399            ]
400        });
401
402        Mock::given(method("GET"))
403            .and(path("/api/v1/axum-rs/axum"))
404            .respond_with(ResponseTemplate::new(200).set_body_json(&resposta_json))
405            .mount(&servidor_mock)
406            .await;
407
408        let cliente = reqwest::Client::new();
409        let url = format!("{}/api/v1/axum-rs/axum", servidor_mock.uri());
410
411        let resposta = cliente
412            .get(&url)
413            .bearer_auth("ctx7sk-teste-docs-mock")
414            .query(&[("type", "json"), ("query", "como criar router")])
415            .send()
416            .await
417            .expect("Deve conectar ao mock server");
418
419        assert!(resposta.status().is_success());
420
421        let dados: RespostaDocumentacao = resposta
422            .json()
423            .await
424            .expect("Deve deserializar resposta do mock");
425
426        let trechos = dados.snippets.as_ref().expect("Deve ter snippets");
427        assert_eq!(trechos.len(), 1);
428        assert!(trechos[0].content.contains("Router do Axum"));
429    }
430
431    // ── Shuffle de chaves ─────────────────────────────────────────────────────
432
433    #[test]
434    fn testa_shuffle_chaves_preserva_todos_os_elementos() {
435        let chaves_originais: Vec<String> =
436            (0..10).map(|i| format!("ctx7sk-chave-{:02}", i)).collect();
437
438        let mut chaves_copia = chaves_originais.clone();
439        let mut rng = rand::thread_rng();
440        chaves_copia.shuffle(&mut rng);
441
442        assert_eq!(
443            chaves_copia.len(),
444            chaves_originais.len(),
445            "Shuffle deve preservar todos os elementos"
446        );
447
448        let mut ordenadas_original = chaves_originais.clone();
449        let mut ordenadas_copia = chaves_copia.clone();
450        ordenadas_original.sort();
451        ordenadas_copia.sort();
452        assert_eq!(
453            ordenadas_original, ordenadas_copia,
454            "Shuffle deve conter os mesmos elementos, apenas em ordem diferente"
455        );
456    }
457}