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(format!("context7-cli/{}", env!("CARGO_PKG_VERSION")))
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::BibliotecaNaoEncontrada { library_id }) => {
155                // Library doesn't exist — no point trying other keys
156                bail!(ErroContext7::BibliotecaNaoEncontrada { library_id });
157            }
158
159            Err(ErroContext7::SemChavesApi) => {
160                chaves_falhas_auth += 1;
161                warn!("Chave de API inválida (401/403), tentando próxima...");
162            }
163
164            Err(ErroContext7::RespostaInvalida { status: 200 }) => {
165                // Parse failure on HTTP 200 — schema mismatch, not a key issue
166                // Short-circuit: no point trying other keys
167                bail!(ErroContext7::RespostaInvalida { status: 200 });
168            }
169
170            Err(e) => {
171                warn!("Falha na tentativa {}: {}", tentativa + 1, e);
172
173                // Backoff before next attempt (not on the last one)
174                if tentativa + 1 < max_tentativas && tentativa < atrasos_ms.len() {
175                    let atraso = Duration::from_millis(atrasos_ms[tentativa]);
176                    info!(
177                        "Aguardando {}ms antes de tentar novamente...",
178                        atraso.as_millis()
179                    );
180                    sleep(atraso).await;
181                }
182            }
183        }
184    }
185
186    if chaves_falhas_auth >= max_tentativas {
187        bail!(ErroContext7::SemChavesApi);
188    }
189
190    bail!(ErroContext7::RetryEsgotado {
191        tentativas: max_tentativas as u32,
192    });
193}
194
195// ─── CHAMADAS À API ───────────────────────────────────────────────────────────
196
197/// Searches for libraries matching `nome` with optional relevance `query_contexto`.
198///
199/// Returns `Err(ErroContext7)` on HTTP errors to enable retry in `executar_com_retry`.
200pub async fn buscar_biblioteca(
201    cliente: &reqwest::Client,
202    chave: &str,
203    nome: &str,
204    query_contexto: &str,
205) -> Result<RespostaListaBibliotecas, ErroContext7> {
206    let url = format!("{}/v1/search", BASE_URL);
207
208    let resposta = cliente
209        .get(&url)
210        .bearer_auth(chave)
211        .query(&[("libraryName", nome), ("query", query_contexto)])
212        .send()
213        .await
214        .map_err(|e| {
215            error!("Erro de rede ao buscar biblioteca: {}", e);
216            ErroContext7::RespostaInvalida { status: 0 }
217        })?;
218
219    tratar_status_resposta(resposta).await
220}
221
222/// Fetches documentation for `library_id` with an optional `query` filter (JSON mode).
223///
224/// Always requests `type=json`. Use [`buscar_documentacao_texto`] for plain-text output.
225/// Returns `Err(ErroContext7)` on HTTP errors to enable retry in `executar_com_retry`.
226pub async fn buscar_documentacao(
227    cliente: &reqwest::Client,
228    chave: &str,
229    library_id: &str,
230    query: Option<&str>,
231) -> Result<RespostaDocumentacao, ErroContext7> {
232    // Normalise library_id: strip leading slash if present
233    let id_normalizado = library_id.trim_start_matches('/');
234    let url = format!("{}/v1/{}", BASE_URL, id_normalizado);
235
236    let mut construtor = cliente
237        .get(&url)
238        .bearer_auth(chave)
239        .query(&[("type", "json")]);
240
241    if let Some(q) = query {
242        construtor = construtor.query(&[("query", q)]);
243    }
244
245    let resposta = construtor.send().await.map_err(|e| {
246        error!("Erro de rede ao buscar documentação: {}", e);
247        ErroContext7::RespostaInvalida { status: 0 }
248    })?;
249
250    tratar_status_resposta(resposta).await.map_err(|e| match e {
251        ErroContext7::RespostaInvalida { status: 404 } => ErroContext7::BibliotecaNaoEncontrada {
252            library_id: library_id.to_string(),
253        },
254        outro => outro,
255    })
256}
257
258/// Fetches documentation for `library_id` as raw plain text (markdown).
259///
260/// Uses `type=txt`. Returns the raw response body as a `String`.
261/// Returns `Err(ErroContext7)` on HTTP errors to enable retry in `executar_com_retry`.
262pub async fn buscar_documentacao_texto(
263    cliente: &reqwest::Client,
264    chave: &str,
265    library_id: &str,
266    query: Option<&str>,
267) -> Result<String, ErroContext7> {
268    let id_normalizado = library_id.trim_start_matches('/');
269    let url = format!("{}/v1/{}", BASE_URL, id_normalizado);
270
271    let mut construtor = cliente
272        .get(&url)
273        .bearer_auth(chave)
274        .query(&[("type", "txt")]);
275
276    if let Some(q) = query {
277        construtor = construtor.query(&[("query", q)]);
278    }
279
280    let resposta = construtor.send().await.map_err(|e| {
281        error!("Erro de rede ao buscar documentação: {}", e);
282        ErroContext7::RespostaInvalida { status: 0 }
283    })?;
284
285    let status = resposta.status();
286
287    if !status.is_success() {
288        match status {
289            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
290                return Err(ErroContext7::SemChavesApi);
291            }
292            StatusCode::BAD_REQUEST => {
293                let mensagem = resposta
294                    .text()
295                    .await
296                    .unwrap_or_else(|_| "Sem detalhes".to_string());
297                return Err(ErroContext7::ApiRetornou400 { mensagem });
298            }
299            StatusCode::NOT_FOUND => {
300                return Err(ErroContext7::BibliotecaNaoEncontrada {
301                    library_id: library_id.to_string(),
302                });
303            }
304            _ => {
305                return Err(ErroContext7::RespostaInvalida {
306                    status: status.as_u16(),
307                });
308            }
309        }
310    }
311
312    resposta
313        .text()
314        .await
315        .map_err(|_| ErroContext7::RespostaInvalida {
316            status: status.as_u16(),
317        })
318}
319
320/// Maps HTTP status codes to typed `ErroContext7` variants or deserialises success bodies.
321async fn tratar_status_resposta<T: for<'de> Deserialize<'de>>(
322    resposta: reqwest::Response,
323) -> Result<T, ErroContext7> {
324    let status = resposta.status();
325
326    match status {
327        s if s.is_success() => resposta.json::<T>().await.map_err(|e| {
328            error!("Falha ao desserializar resposta JSON: {}", e);
329            ErroContext7::RespostaInvalida {
330                status: status.as_u16(),
331            }
332        }),
333
334        StatusCode::BAD_REQUEST => {
335            let mensagem = resposta
336                .text()
337                .await
338                .unwrap_or_else(|_| "Sem detalhes".to_string());
339            Err(ErroContext7::ApiRetornou400 { mensagem })
340        }
341
342        StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(ErroContext7::SemChavesApi),
343
344        StatusCode::TOO_MANY_REQUESTS => {
345            warn!("Rate limit atingido (429), aguardando retry...");
346            Err(ErroContext7::RespostaInvalida {
347                status: status.as_u16(),
348            })
349        }
350
351        s if s.is_server_error() => {
352            warn!(
353                "Erro do servidor ({}), tentando novamente...",
354                status.as_u16()
355            );
356            Err(ErroContext7::RespostaInvalida {
357                status: status.as_u16(),
358            })
359        }
360
361        _ => Err(ErroContext7::RespostaInvalida {
362            status: status.as_u16(),
363        }),
364    }
365}
366
367// ─── TESTES ───────────────────────────────────────────────────────────────────
368
369#[cfg(test)]
370mod testes {
371    use super::*;
372
373    // ── Desserialização de structs ────────────────────────────────────────────
374
375    #[test]
376    fn testa_deserializacao_library_search_result() {
377        let json = r#"{
378            "id": "/facebook/react",
379            "title": "React",
380            "description": "A JavaScript library for building user interfaces",
381            "trustScore": 95.0
382        }"#;
383
384        let resultado: LibrarySearchResult =
385            serde_json::from_str(json).expect("Deve deserializar LibrarySearchResult");
386
387        assert_eq!(resultado.id, "/facebook/react");
388        assert_eq!(resultado.title, "React");
389        assert_eq!(
390            resultado.description.as_deref(),
391            Some("A JavaScript library for building user interfaces")
392        );
393        assert!((resultado.trust_score.unwrap() - 95.0).abs() < f64::EPSILON);
394    }
395
396    #[test]
397    fn testa_deserializacao_library_search_result_tolerante_campos_faltando() {
398        let json = r#"{
399            "id": "/minimal/lib",
400            "title": "MinimalLib"
401        }"#;
402
403        let resultado: LibrarySearchResult =
404            serde_json::from_str(json).expect("Deve deserializar mesmo com campos ausentes");
405
406        assert_eq!(resultado.id, "/minimal/lib");
407        assert_eq!(resultado.title, "MinimalLib");
408        assert!(resultado.description.is_none(), "description deve ser None");
409        assert!(resultado.trust_score.is_none(), "trust_score deve ser None");
410    }
411
412    #[test]
413    fn testa_deserializacao_library_search_result_com_campos_opcionais() {
414        let json = r#"{
415            "id": "/facebook/react",
416            "title": "React",
417            "trustScore": 95.0,
418            "stars": 228000,
419            "totalSnippets": 1500,
420            "totalTokens": 250000,
421            "verified": true,
422            "branch": "main",
423            "state": "active"
424        }"#;
425
426        let resultado: LibrarySearchResult =
427            serde_json::from_str(json).expect("Deve deserializar com campos opcionais");
428
429        assert_eq!(resultado.stars, Some(228_000i64));
430        assert_eq!(resultado.total_snippets, Some(1_500));
431        assert_eq!(resultado.total_tokens, Some(250_000));
432        assert_eq!(resultado.verified, Some(true));
433        assert_eq!(resultado.branch.as_deref(), Some("main"));
434        assert_eq!(resultado.state.as_deref(), Some("active"));
435    }
436
437    #[test]
438    fn testa_deserializacao_documentation_snippet() {
439        let json = r#"{
440            "pageTitle": "React Hooks API",
441            "codeTitle": "useEffect example",
442            "codeDescription": "The Effect Hook lets you perform side effects.",
443            "codeLanguage": "javascript",
444            "codeTokens": 68,
445            "codeId": "https://github.com/facebook/react/blob/main/packages/react/src/ReactHooks.js",
446            "codeList": [
447                {"language": "javascript", "code": "useEffect(() => { /* effect */ }, []);"}
448            ],
449            "relevance": 0.032,
450            "model": "gemini-2.5-flash"
451        }"#;
452
453        let trecho: DocumentationSnippet =
454            serde_json::from_str(json).expect("Deve deserializar DocumentationSnippet");
455
456        assert_eq!(trecho.page_title.as_deref(), Some("React Hooks API"));
457        assert_eq!(trecho.code_title.as_deref(), Some("useEffect example"));
458        assert_eq!(trecho.code_language.as_deref(), Some("javascript"));
459        assert_eq!(trecho.code_tokens, Some(68));
460        let lista = trecho.code_list.as_ref().expect("Deve ter code_list");
461        assert_eq!(lista.len(), 1);
462        assert_eq!(lista[0].language, "javascript");
463        assert!((trecho.relevance.unwrap() - 0.032).abs() < f64::EPSILON);
464    }
465
466    #[test]
467    fn testa_deserializacao_documentation_snippet_sem_campos_opcionais() {
468        let json = r#"{}"#;
469
470        let trecho: DocumentationSnippet =
471            serde_json::from_str(json).expect("Deve deserializar snippet completamente vazio");
472
473        assert!(trecho.page_title.is_none());
474        assert!(trecho.code_title.is_none());
475        assert!(trecho.code_list.is_none());
476    }
477
478    #[test]
479    fn testa_deserializacao_code_block() {
480        let json = r#"{"language": "rust", "code": "fn main() {}"}"#;
481
482        let bloco: CodeBlock = serde_json::from_str(json).expect("Deve deserializar CodeBlock");
483
484        assert_eq!(bloco.language, "rust");
485        assert_eq!(bloco.code, "fn main() {}");
486    }
487
488    // ── Mock HTTP ─────────────────────────────────────────────────────────────
489
490    #[tokio::test]
491    async fn testa_buscar_biblioteca_com_mock_servidor_retorna_200() {
492        use wiremock::matchers::{method, path};
493        use wiremock::{Mock, MockServer, ResponseTemplate};
494
495        let servidor_mock = MockServer::start().await;
496
497        let resposta_json = serde_json::json!({
498            "results": [
499                {
500                    "id": "/axum-rs/axum",
501                    "title": "axum",
502                    "description": "Framework web para Rust",
503                    "trustScore": 90.0
504                }
505            ]
506        });
507
508        Mock::given(method("GET"))
509            .and(path("/api/v1/search"))
510            .respond_with(ResponseTemplate::new(200).set_body_json(&resposta_json))
511            .mount(&servidor_mock)
512            .await;
513
514        let cliente = reqwest::Client::new();
515        let url = format!("{}/api/v1/search", servidor_mock.uri());
516
517        let resposta = cliente
518            .get(&url)
519            .bearer_auth("ctx7sk-teste-mock")
520            .query(&[("libraryName", "axum"), ("query", "axum")])
521            .send()
522            .await
523            .expect("Deve conectar ao mock server");
524
525        assert!(resposta.status().is_success(), "Status deve ser 200");
526
527        let dados: RespostaListaBibliotecas = resposta
528            .json()
529            .await
530            .expect("Deve deserializar resposta do mock");
531
532        assert_eq!(dados.results.len(), 1);
533        assert_eq!(dados.results[0].id, "/axum-rs/axum");
534        assert_eq!(dados.results[0].title, "axum");
535    }
536
537    #[tokio::test]
538    async fn testa_buscar_documentacao_com_mock_servidor_retorna_200() {
539        use wiremock::matchers::{method, path};
540        use wiremock::{Mock, MockServer, ResponseTemplate};
541
542        let servidor_mock = MockServer::start().await;
543
544        let resposta_json = serde_json::json!({
545            "snippets": [
546                {
547                    "pageTitle": "axum::Router",
548                    "codeTitle": "Basic Router setup",
549                    "codeDescription": "O Router do Axum permite definir rotas HTTP de forma declarativa.",
550                    "codeLanguage": "rust",
551                    "codeList": [
552                        {"language": "rust", "code": "let app = Router::new().route(\"/\", get(handler));"}
553                    ]
554                }
555            ]
556        });
557
558        Mock::given(method("GET"))
559            .and(path("/api/v1/axum-rs/axum"))
560            .respond_with(ResponseTemplate::new(200).set_body_json(&resposta_json))
561            .mount(&servidor_mock)
562            .await;
563
564        let cliente = reqwest::Client::new();
565        let url = format!("{}/api/v1/axum-rs/axum", servidor_mock.uri());
566
567        let resposta = cliente
568            .get(&url)
569            .bearer_auth("ctx7sk-teste-docs-mock")
570            .query(&[("type", "json"), ("query", "como criar router")])
571            .send()
572            .await
573            .expect("Deve conectar ao mock server");
574
575        assert!(resposta.status().is_success());
576
577        let dados: RespostaDocumentacao = resposta
578            .json()
579            .await
580            .expect("Deve deserializar resposta do mock");
581
582        let trechos = dados.snippets.as_ref().expect("Deve ter snippets");
583        assert_eq!(trechos.len(), 1);
584        let lista = trechos[0].code_list.as_ref().expect("Deve ter code_list");
585        assert!(lista[0].code.contains("Router::new"));
586    }
587
588    // ── Shuffle de chaves ─────────────────────────────────────────────────────
589
590    #[test]
591    fn testa_shuffle_chaves_preserva_todos_os_elementos() {
592        let chaves_originais: Vec<String> =
593            (0..10).map(|i| format!("ctx7sk-chave-{:02}", i)).collect();
594
595        let mut chaves_copia = chaves_originais.clone();
596        let mut rng = rand::thread_rng();
597        chaves_copia.shuffle(&mut rng);
598
599        assert_eq!(
600            chaves_copia.len(),
601            chaves_originais.len(),
602            "Shuffle deve preservar todos os elementos"
603        );
604
605        let mut ordenadas_original = chaves_originais.clone();
606        let mut ordenadas_copia = chaves_copia.clone();
607        ordenadas_original.sort();
608        ordenadas_copia.sort();
609        assert_eq!(
610            ordenadas_original, ordenadas_copia,
611            "Shuffle deve conter os mesmos elementos, apenas em ordem diferente"
612        );
613    }
614
615    #[test]
616    fn testa_max_tentativas_limitado_a_5() {
617        // Verifica que chaves.len().min(5) funciona corretamente
618        let muitas_chaves: Vec<String> =
619            (0..10).map(|i| format!("ctx7sk-chave-{:02}", i)).collect();
620        let max = muitas_chaves.len().min(5);
621        assert_eq!(
622            max, 5,
623            "Max tentativas deve ser limitado a 5 mesmo com 10 chaves"
624        );
625
626        let poucas_chaves: Vec<String> = vec!["ctx7sk-a".to_string(), "ctx7sk-b".to_string()];
627        let max2 = poucas_chaves.len().min(5);
628        assert_eq!(max2, 2, "Com 2 chaves, max deve ser 2");
629    }
630}