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;
13use crate::i18n::{t, Mensagem};
14
15// ─── CONSTANTE ───────────────────────────────────────────────────────────────
16
17const BASE_URL: &str = "https://context7.com/api";
18
19// ─── MODELOS DE RESPOSTA DA API ───────────────────────────────────────────────
20
21/// Represents a single library entry returned by the search endpoint.
22#[derive(Debug, Deserialize, Serialize)]
23#[serde(rename_all = "camelCase")]
24pub struct LibrarySearchResult {
25    /// Unique library identifier (e.g. `/facebook/react`).
26    pub id: String,
27    /// Human-readable library title.
28    pub title: String,
29    /// Optional short description of the library.
30    pub description: Option<String>,
31    /// Relevance/trust score returned by the API, if available.
32    pub trust_score: Option<f64>,
33    /// Number of GitHub stars, if available. The API returns `-1` when unavailable.
34    pub stars: Option<i64>,
35    /// Total number of documentation snippets indexed.
36    pub total_snippets: Option<u64>,
37    /// Total number of tokens indexed.
38    pub total_tokens: Option<u64>,
39    /// Whether the library has been verified by the Context7 team.
40    pub verified: Option<bool>,
41    /// Git branch used for indexing.
42    pub branch: Option<String>,
43    /// Indexing state (e.g. "active", "pending").
44    pub state: Option<String>,
45}
46
47/// A single code block within a documentation snippet.
48#[derive(Debug, Deserialize, Serialize, Clone)]
49pub struct CodeBlock {
50    /// Programming language of the code (e.g. `"rust"`, `"bash"`).
51    pub language: String,
52    /// Source code content.
53    pub code: String,
54}
55
56/// Represents a single documentation excerpt returned by the docs endpoint (JSON mode).
57#[derive(Debug, Deserialize, Serialize, Clone)]
58#[serde(rename_all = "camelCase")]
59pub struct DocumentationSnippet {
60    /// Page title of the source documentation page, if available.
61    pub page_title: Option<String>,
62    /// Title of this specific code snippet, if available.
63    pub code_title: Option<String>,
64    /// Description accompanying the code snippet, if available.
65    pub code_description: Option<String>,
66    /// Primary programming language of the snippet, if available.
67    pub code_language: Option<String>,
68    /// Number of tokens in this snippet, if available.
69    pub code_tokens: Option<u64>,
70    /// Unique identifier or source URL of this snippet, if available.
71    pub code_id: Option<String>,
72    /// List of code blocks contained in this snippet.
73    pub code_list: Option<Vec<CodeBlock>>,
74    /// Relevance score for the query, if available.
75    pub relevance: Option<f64>,
76    /// Model used to generate or rank this snippet, if available.
77    pub model: Option<String>,
78}
79
80/// Top-level response from the library search endpoint (`GET /api/v1/search`).
81#[derive(Debug, Deserialize)]
82pub struct RespostaListaBibliotecas {
83    /// List of matching libraries.
84    pub results: Vec<LibrarySearchResult>,
85}
86
87/// Top-level response from the documentation endpoint (`GET /api/v1/{library_id}`).
88#[derive(Debug, Deserialize, Serialize)]
89pub struct RespostaDocumentacao {
90    /// Structured documentation snippets (JSON mode).
91    pub snippets: Option<Vec<DocumentationSnippet>>,
92}
93
94// ─── CLIENTE HTTP ─────────────────────────────────────────────────────────────
95
96/// Creates a reusable HTTP client with rustls-TLS, 30 s timeout, and HTTP/2.
97///
98/// The client should be created once per invocation and shared via `Arc`.
99pub fn criar_cliente_http() -> Result<reqwest::Client> {
100    let cliente = reqwest::Client::builder()
101        .use_rustls_tls()
102        .timeout(Duration::from_secs(30))
103        .user_agent("context7-cli/0.2.1")
104        .pool_max_idle_per_host(4)
105        .build()
106        .with_context(|| t(Mensagem::FalhaCriarClienteHttp))?;
107
108    Ok(cliente)
109}
110
111// ─── RETRY COM ROTAÇÃO DE CHAVES ──────────────────────────────────────────────
112
113/// Executes an API call with retry and key rotation.
114///
115/// Shuffles a local copy of the provided keys (random draw without replacement)
116/// and retries up to `min(keys.len(), 5)` times with exponential backoff:
117/// 500 ms → 1 s → 2 s.
118///
119/// Short-circuits immediately on parse errors (status 200 but JSON failed) —
120/// retrying with another key would not help in that case.
121///
122/// The closure receives an owned `String` (clone of the key) to satisfy the
123/// `async move` ownership requirement inside the future.
124pub async fn executar_com_retry<F, Fut, T>(chaves: &[String], operacao: F) -> Result<T>
125where
126    F: Fn(String) -> Fut,
127    Fut: std::future::Future<Output = Result<T, ErroContext7>>,
128{
129    let max_tentativas = chaves.len().min(5);
130
131    // Shuffles a local copy — avoids modifying the caller's vec
132    let mut chaves_embaralhadas = chaves.to_vec();
133    let mut rng = rand::thread_rng();
134    chaves_embaralhadas.shuffle(&mut rng);
135
136    let atrasos_ms = [500u64, 1000, 2000];
137    let mut chaves_falhas_auth = 0usize;
138
139    for (tentativa, chave) in chaves_embaralhadas
140        .into_iter()
141        .take(max_tentativas)
142        .enumerate()
143    {
144        info!("Tentativa {}/{}", tentativa + 1, max_tentativas);
145
146        match operacao(chave).await {
147            Ok(resultado) => return Ok(resultado),
148
149            Err(ErroContext7::ApiRetornou400 { mensagem }) => {
150                // 400 is not transient — abort immediately
151                bail!(ErroContext7::ApiRetornou400 { mensagem });
152            }
153
154            Err(ErroContext7::SemChavesApi) => {
155                chaves_falhas_auth += 1;
156                warn!("Chave de API inválida (401/403), tentando próxima...");
157            }
158
159            Err(ErroContext7::RespostaInvalida { status: 200 }) => {
160                // Parse failure on HTTP 200 — schema mismatch, not a key issue
161                // Short-circuit: no point trying other keys
162                bail!(ErroContext7::RespostaInvalida { status: 200 });
163            }
164
165            Err(e) => {
166                warn!("Falha na tentativa {}: {}", tentativa + 1, e);
167
168                // Backoff before next attempt (not on the last one)
169                if tentativa + 1 < max_tentativas && tentativa < atrasos_ms.len() {
170                    let atraso = Duration::from_millis(atrasos_ms[tentativa]);
171                    info!(
172                        "Aguardando {}ms antes de tentar novamente...",
173                        atraso.as_millis()
174                    );
175                    sleep(atraso).await;
176                }
177            }
178        }
179    }
180
181    if chaves_falhas_auth >= max_tentativas {
182        bail!(ErroContext7::SemChavesApi);
183    }
184
185    bail!(ErroContext7::RetryEsgotado {
186        tentativas: max_tentativas as u32,
187    });
188}
189
190// ─── CHAMADAS À API ───────────────────────────────────────────────────────────
191
192/// Searches for libraries matching `nome` with optional relevance `query_contexto`.
193///
194/// Returns `Err(ErroContext7)` on HTTP errors to enable retry in `executar_com_retry`.
195pub async fn buscar_biblioteca(
196    cliente: &reqwest::Client,
197    chave: &str,
198    nome: &str,
199    query_contexto: &str,
200) -> Result<RespostaListaBibliotecas, ErroContext7> {
201    let url = format!("{}/v1/search", BASE_URL);
202
203    let resposta = cliente
204        .get(&url)
205        .bearer_auth(chave)
206        .query(&[("libraryName", nome), ("query", query_contexto)])
207        .send()
208        .await
209        .map_err(|e| {
210            error!("Erro de rede ao buscar biblioteca: {}", e);
211            ErroContext7::RespostaInvalida { status: 0 }
212        })?;
213
214    tratar_status_resposta(resposta).await
215}
216
217/// Fetches documentation for `library_id` with an optional `query` filter (JSON mode).
218///
219/// Always requests `type=json`. Use [`buscar_documentacao_texto`] for plain-text output.
220/// Returns `Err(ErroContext7)` on HTTP errors to enable retry in `executar_com_retry`.
221pub async fn buscar_documentacao(
222    cliente: &reqwest::Client,
223    chave: &str,
224    library_id: &str,
225    query: Option<&str>,
226) -> Result<RespostaDocumentacao, ErroContext7> {
227    // Normalise library_id: strip leading slash if present
228    let id_normalizado = library_id.trim_start_matches('/');
229    let url = format!("{}/v1/{}", BASE_URL, id_normalizado);
230
231    let mut construtor = cliente
232        .get(&url)
233        .bearer_auth(chave)
234        .query(&[("type", "json")]);
235
236    if let Some(q) = query {
237        construtor = construtor.query(&[("query", q)]);
238    }
239
240    let resposta = construtor.send().await.map_err(|e| {
241        error!("Erro de rede ao buscar documentação: {}", e);
242        ErroContext7::RespostaInvalida { status: 0 }
243    })?;
244
245    tratar_status_resposta(resposta).await
246}
247
248/// Fetches documentation for `library_id` as raw plain text (markdown).
249///
250/// Uses `type=txt`. Returns the raw response body as a `String`.
251/// Returns `Err(ErroContext7)` on HTTP errors to enable retry in `executar_com_retry`.
252pub async fn buscar_documentacao_texto(
253    cliente: &reqwest::Client,
254    chave: &str,
255    library_id: &str,
256    query: Option<&str>,
257) -> Result<String, ErroContext7> {
258    let id_normalizado = library_id.trim_start_matches('/');
259    let url = format!("{}/v1/{}", BASE_URL, id_normalizado);
260
261    let mut construtor = cliente
262        .get(&url)
263        .bearer_auth(chave)
264        .query(&[("type", "txt")]);
265
266    if let Some(q) = query {
267        construtor = construtor.query(&[("query", q)]);
268    }
269
270    let resposta = construtor.send().await.map_err(|e| {
271        error!("Erro de rede ao buscar documentação: {}", e);
272        ErroContext7::RespostaInvalida { status: 0 }
273    })?;
274
275    let status = resposta.status();
276
277    if !status.is_success() {
278        match status {
279            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
280                return Err(ErroContext7::SemChavesApi);
281            }
282            StatusCode::BAD_REQUEST => {
283                let mensagem = resposta
284                    .text()
285                    .await
286                    .unwrap_or_else(|_| "Sem detalhes".to_string());
287                return Err(ErroContext7::ApiRetornou400 { mensagem });
288            }
289            _ => {
290                return Err(ErroContext7::RespostaInvalida {
291                    status: status.as_u16(),
292                });
293            }
294        }
295    }
296
297    resposta
298        .text()
299        .await
300        .map_err(|_| ErroContext7::RespostaInvalida {
301            status: status.as_u16(),
302        })
303}
304
305/// Maps HTTP status codes to typed `ErroContext7` variants or deserialises success bodies.
306async fn tratar_status_resposta<T: for<'de> Deserialize<'de>>(
307    resposta: reqwest::Response,
308) -> Result<T, ErroContext7> {
309    let status = resposta.status();
310
311    match status {
312        s if s.is_success() => resposta.json::<T>().await.map_err(|e| {
313            error!("Falha ao desserializar resposta JSON: {}", e);
314            ErroContext7::RespostaInvalida {
315                status: status.as_u16(),
316            }
317        }),
318
319        StatusCode::BAD_REQUEST => {
320            let mensagem = resposta
321                .text()
322                .await
323                .unwrap_or_else(|_| "Sem detalhes".to_string());
324            Err(ErroContext7::ApiRetornou400 { mensagem })
325        }
326
327        StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(ErroContext7::SemChavesApi),
328
329        StatusCode::TOO_MANY_REQUESTS => {
330            warn!("Rate limit atingido (429), aguardando retry...");
331            Err(ErroContext7::RespostaInvalida {
332                status: status.as_u16(),
333            })
334        }
335
336        s if s.is_server_error() => {
337            warn!(
338                "Erro do servidor ({}), tentando novamente...",
339                status.as_u16()
340            );
341            Err(ErroContext7::RespostaInvalida {
342                status: status.as_u16(),
343            })
344        }
345
346        _ => Err(ErroContext7::RespostaInvalida {
347            status: status.as_u16(),
348        }),
349    }
350}
351
352// ─── TESTES ───────────────────────────────────────────────────────────────────
353
354#[cfg(test)]
355mod testes {
356    use super::*;
357
358    // ── Desserialização de structs ────────────────────────────────────────────
359
360    #[test]
361    fn testa_deserializacao_library_search_result() {
362        let json = r#"{
363            "id": "/facebook/react",
364            "title": "React",
365            "description": "A JavaScript library for building user interfaces",
366            "trustScore": 95.0
367        }"#;
368
369        let resultado: LibrarySearchResult =
370            serde_json::from_str(json).expect("Deve deserializar LibrarySearchResult");
371
372        assert_eq!(resultado.id, "/facebook/react");
373        assert_eq!(resultado.title, "React");
374        assert_eq!(
375            resultado.description.as_deref(),
376            Some("A JavaScript library for building user interfaces")
377        );
378        assert!((resultado.trust_score.unwrap() - 95.0).abs() < f64::EPSILON);
379    }
380
381    #[test]
382    fn testa_deserializacao_library_search_result_tolerante_campos_faltando() {
383        let json = r#"{
384            "id": "/minimal/lib",
385            "title": "MinimalLib"
386        }"#;
387
388        let resultado: LibrarySearchResult =
389            serde_json::from_str(json).expect("Deve deserializar mesmo com campos ausentes");
390
391        assert_eq!(resultado.id, "/minimal/lib");
392        assert_eq!(resultado.title, "MinimalLib");
393        assert!(resultado.description.is_none(), "description deve ser None");
394        assert!(resultado.trust_score.is_none(), "trust_score deve ser None");
395    }
396
397    #[test]
398    fn testa_deserializacao_library_search_result_com_campos_opcionais() {
399        let json = r#"{
400            "id": "/facebook/react",
401            "title": "React",
402            "trustScore": 95.0,
403            "stars": 228000,
404            "totalSnippets": 1500,
405            "totalTokens": 250000,
406            "verified": true,
407            "branch": "main",
408            "state": "active"
409        }"#;
410
411        let resultado: LibrarySearchResult =
412            serde_json::from_str(json).expect("Deve deserializar com campos opcionais");
413
414        assert_eq!(resultado.stars, Some(228_000i64));
415        assert_eq!(resultado.total_snippets, Some(1_500));
416        assert_eq!(resultado.total_tokens, Some(250_000));
417        assert_eq!(resultado.verified, Some(true));
418        assert_eq!(resultado.branch.as_deref(), Some("main"));
419        assert_eq!(resultado.state.as_deref(), Some("active"));
420    }
421
422    #[test]
423    fn testa_deserializacao_documentation_snippet() {
424        let json = r#"{
425            "pageTitle": "React Hooks API",
426            "codeTitle": "useEffect example",
427            "codeDescription": "The Effect Hook lets you perform side effects.",
428            "codeLanguage": "javascript",
429            "codeTokens": 68,
430            "codeId": "https://github.com/facebook/react/blob/main/packages/react/src/ReactHooks.js",
431            "codeList": [
432                {"language": "javascript", "code": "useEffect(() => { /* effect */ }, []);"}
433            ],
434            "relevance": 0.032,
435            "model": "gemini-2.5-flash"
436        }"#;
437
438        let trecho: DocumentationSnippet =
439            serde_json::from_str(json).expect("Deve deserializar DocumentationSnippet");
440
441        assert_eq!(trecho.page_title.as_deref(), Some("React Hooks API"));
442        assert_eq!(trecho.code_title.as_deref(), Some("useEffect example"));
443        assert_eq!(trecho.code_language.as_deref(), Some("javascript"));
444        assert_eq!(trecho.code_tokens, Some(68));
445        let lista = trecho.code_list.as_ref().expect("Deve ter code_list");
446        assert_eq!(lista.len(), 1);
447        assert_eq!(lista[0].language, "javascript");
448        assert!((trecho.relevance.unwrap() - 0.032).abs() < f64::EPSILON);
449    }
450
451    #[test]
452    fn testa_deserializacao_documentation_snippet_sem_campos_opcionais() {
453        let json = r#"{}"#;
454
455        let trecho: DocumentationSnippet =
456            serde_json::from_str(json).expect("Deve deserializar snippet completamente vazio");
457
458        assert!(trecho.page_title.is_none());
459        assert!(trecho.code_title.is_none());
460        assert!(trecho.code_list.is_none());
461    }
462
463    #[test]
464    fn testa_deserializacao_code_block() {
465        let json = r#"{"language": "rust", "code": "fn main() {}"}"#;
466
467        let bloco: CodeBlock = serde_json::from_str(json).expect("Deve deserializar CodeBlock");
468
469        assert_eq!(bloco.language, "rust");
470        assert_eq!(bloco.code, "fn main() {}");
471    }
472
473    // ── Mock HTTP ─────────────────────────────────────────────────────────────
474
475    #[tokio::test]
476    async fn testa_buscar_biblioteca_com_mock_servidor_retorna_200() {
477        use wiremock::matchers::{method, path};
478        use wiremock::{Mock, MockServer, ResponseTemplate};
479
480        let servidor_mock = MockServer::start().await;
481
482        let resposta_json = serde_json::json!({
483            "results": [
484                {
485                    "id": "/axum-rs/axum",
486                    "title": "axum",
487                    "description": "Framework web para Rust",
488                    "trustScore": 90.0
489                }
490            ]
491        });
492
493        Mock::given(method("GET"))
494            .and(path("/api/v1/search"))
495            .respond_with(ResponseTemplate::new(200).set_body_json(&resposta_json))
496            .mount(&servidor_mock)
497            .await;
498
499        let cliente = reqwest::Client::new();
500        let url = format!("{}/api/v1/search", servidor_mock.uri());
501
502        let resposta = cliente
503            .get(&url)
504            .bearer_auth("ctx7sk-teste-mock")
505            .query(&[("libraryName", "axum"), ("query", "axum")])
506            .send()
507            .await
508            .expect("Deve conectar ao mock server");
509
510        assert!(resposta.status().is_success(), "Status deve ser 200");
511
512        let dados: RespostaListaBibliotecas = resposta
513            .json()
514            .await
515            .expect("Deve deserializar resposta do mock");
516
517        assert_eq!(dados.results.len(), 1);
518        assert_eq!(dados.results[0].id, "/axum-rs/axum");
519        assert_eq!(dados.results[0].title, "axum");
520    }
521
522    #[tokio::test]
523    async fn testa_buscar_documentacao_com_mock_servidor_retorna_200() {
524        use wiremock::matchers::{method, path};
525        use wiremock::{Mock, MockServer, ResponseTemplate};
526
527        let servidor_mock = MockServer::start().await;
528
529        let resposta_json = serde_json::json!({
530            "snippets": [
531                {
532                    "pageTitle": "axum::Router",
533                    "codeTitle": "Basic Router setup",
534                    "codeDescription": "O Router do Axum permite definir rotas HTTP de forma declarativa.",
535                    "codeLanguage": "rust",
536                    "codeList": [
537                        {"language": "rust", "code": "let app = Router::new().route(\"/\", get(handler));"}
538                    ]
539                }
540            ]
541        });
542
543        Mock::given(method("GET"))
544            .and(path("/api/v1/axum-rs/axum"))
545            .respond_with(ResponseTemplate::new(200).set_body_json(&resposta_json))
546            .mount(&servidor_mock)
547            .await;
548
549        let cliente = reqwest::Client::new();
550        let url = format!("{}/api/v1/axum-rs/axum", servidor_mock.uri());
551
552        let resposta = cliente
553            .get(&url)
554            .bearer_auth("ctx7sk-teste-docs-mock")
555            .query(&[("type", "json"), ("query", "como criar router")])
556            .send()
557            .await
558            .expect("Deve conectar ao mock server");
559
560        assert!(resposta.status().is_success());
561
562        let dados: RespostaDocumentacao = resposta
563            .json()
564            .await
565            .expect("Deve deserializar resposta do mock");
566
567        let trechos = dados.snippets.as_ref().expect("Deve ter snippets");
568        assert_eq!(trechos.len(), 1);
569        let lista = trechos[0].code_list.as_ref().expect("Deve ter code_list");
570        assert!(lista[0].code.contains("Router::new"));
571    }
572
573    // ── Shuffle de chaves ─────────────────────────────────────────────────────
574
575    #[test]
576    fn testa_shuffle_chaves_preserva_todos_os_elementos() {
577        let chaves_originais: Vec<String> =
578            (0..10).map(|i| format!("ctx7sk-chave-{:02}", i)).collect();
579
580        let mut chaves_copia = chaves_originais.clone();
581        let mut rng = rand::thread_rng();
582        chaves_copia.shuffle(&mut rng);
583
584        assert_eq!(
585            chaves_copia.len(),
586            chaves_originais.len(),
587            "Shuffle deve preservar todos os elementos"
588        );
589
590        let mut ordenadas_original = chaves_originais.clone();
591        let mut ordenadas_copia = chaves_copia.clone();
592        ordenadas_original.sort();
593        ordenadas_copia.sort();
594        assert_eq!(
595            ordenadas_original, ordenadas_copia,
596            "Shuffle deve conter os mesmos elementos, apenas em ordem diferente"
597        );
598    }
599
600    #[test]
601    fn testa_max_tentativas_limitado_a_5() {
602        // Verifica que chaves.len().min(5) funciona corretamente
603        let muitas_chaves: Vec<String> =
604            (0..10).map(|i| format!("ctx7sk-chave-{:02}", i)).collect();
605        let max = muitas_chaves.len().min(5);
606        assert_eq!(
607            max, 5,
608            "Max tentativas deve ser limitado a 5 mesmo com 10 chaves"
609        );
610
611        let poucas_chaves: Vec<String> = vec!["ctx7sk-a".to_string(), "ctx7sk-b".to_string()];
612        let max2 = poucas_chaves.len().min(5);
613        assert_eq!(max2, 2, "Com 2 chaves, max deve ser 2");
614    }
615}