http_client.rs

  1mod async_body;
  2#[cfg(not(target_family = "wasm"))]
  3pub mod github;
  4#[cfg(not(target_family = "wasm"))]
  5pub mod github_download;
  6
  7pub use anyhow::{Result, anyhow};
  8pub use async_body::{AsyncBody, Inner, Json};
  9use derive_more::Deref;
 10use http::HeaderValue;
 11pub use http::{self, Method, Request, Response, StatusCode, Uri, request::Builder};
 12
 13use futures::future::BoxFuture;
 14use parking_lot::Mutex;
 15use serde::Serialize;
 16use std::sync::Arc;
 17#[cfg(feature = "test-support")]
 18use std::{any::type_name, fmt};
 19pub use url::{Host, Url};
 20
 21#[derive(Default, Debug, Clone, PartialEq, Eq, Hash)]
 22pub enum RedirectPolicy {
 23    #[default]
 24    NoFollow,
 25    FollowLimit(u32),
 26    FollowAll,
 27}
 28pub struct FollowRedirects(pub bool);
 29
 30pub trait HttpRequestExt {
 31    /// Conditionally modify self with the given closure.
 32    fn when(self, condition: bool, then: impl FnOnce(Self) -> Self) -> Self
 33    where
 34        Self: Sized,
 35    {
 36        if condition { then(self) } else { self }
 37    }
 38
 39    /// Conditionally unwrap and modify self with the given closure, if the given option is Some.
 40    fn when_some<T>(self, option: Option<T>, then: impl FnOnce(Self, T) -> Self) -> Self
 41    where
 42        Self: Sized,
 43    {
 44        match option {
 45            Some(value) => then(self, value),
 46            None => self,
 47        }
 48    }
 49
 50    /// Whether or not to follow redirects
 51    fn follow_redirects(self, follow: RedirectPolicy) -> Self;
 52}
 53
 54impl HttpRequestExt for http::request::Builder {
 55    fn follow_redirects(self, follow: RedirectPolicy) -> Self {
 56        self.extension(follow)
 57    }
 58}
 59
 60pub trait HttpClient: 'static + Send + Sync {
 61    fn user_agent(&self) -> Option<&HeaderValue>;
 62
 63    fn proxy(&self) -> Option<&Url>;
 64
 65    fn send(
 66        &self,
 67        req: http::Request<AsyncBody>,
 68    ) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>>;
 69
 70    fn get(
 71        &self,
 72        uri: &str,
 73        body: AsyncBody,
 74        follow_redirects: bool,
 75    ) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>> {
 76        let request = Builder::new()
 77            .uri(uri)
 78            .follow_redirects(if follow_redirects {
 79                RedirectPolicy::FollowAll
 80            } else {
 81                RedirectPolicy::NoFollow
 82            })
 83            .body(body);
 84
 85        match request {
 86            Ok(request) => self.send(request),
 87            Err(e) => Box::pin(async move { Err(e.into()) }),
 88        }
 89    }
 90
 91    fn post_json(
 92        &self,
 93        uri: &str,
 94        body: AsyncBody,
 95    ) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>> {
 96        let request = Builder::new()
 97            .uri(uri)
 98            .method(Method::POST)
 99            .header("Content-Type", "application/json")
100            .body(body);
101
102        match request {
103            Ok(request) => self.send(request),
104            Err(e) => Box::pin(async move { Err(e.into()) }),
105        }
106    }
107
108    #[cfg(feature = "test-support")]
109    fn as_fake(&self) -> &FakeHttpClient {
110        panic!("called as_fake on {}", type_name::<Self>())
111    }
112}
113
114/// An [`HttpClient`] that may have a proxy.
115#[derive(Deref)]
116pub struct HttpClientWithProxy {
117    #[deref]
118    client: Arc<dyn HttpClient>,
119    proxy: Option<Url>,
120}
121
122impl HttpClientWithProxy {
123    /// Returns a new [`HttpClientWithProxy`] with the given proxy URL.
124    pub fn new(client: Arc<dyn HttpClient>, proxy_url: Option<String>) -> Self {
125        let proxy_url = proxy_url
126            .and_then(|proxy| proxy.parse().ok())
127            .or_else(read_proxy_from_env);
128
129        Self::new_url(client, proxy_url)
130    }
131    pub fn new_url(client: Arc<dyn HttpClient>, proxy_url: Option<Url>) -> Self {
132        Self {
133            client,
134            proxy: proxy_url,
135        }
136    }
137}
138
139impl HttpClient for HttpClientWithProxy {
140    fn send(
141        &self,
142        req: Request<AsyncBody>,
143    ) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>> {
144        self.client.send(req)
145    }
146
147    fn user_agent(&self) -> Option<&HeaderValue> {
148        self.client.user_agent()
149    }
150
151    fn proxy(&self) -> Option<&Url> {
152        self.proxy.as_ref()
153    }
154
155    #[cfg(feature = "test-support")]
156    fn as_fake(&self) -> &FakeHttpClient {
157        self.client.as_fake()
158    }
159}
160
161/// An [`HttpClient`] that has a base URL.
162#[derive(Deref)]
163pub struct HttpClientWithUrl {
164    base_url: Mutex<String>,
165    #[deref]
166    client: HttpClientWithProxy,
167}
168
169impl HttpClientWithUrl {
170    /// Returns a new [`HttpClientWithUrl`] with the given base URL.
171    pub fn new(
172        client: Arc<dyn HttpClient>,
173        base_url: impl Into<String>,
174        proxy_url: Option<String>,
175    ) -> Self {
176        let client = HttpClientWithProxy::new(client, proxy_url);
177
178        Self {
179            base_url: Mutex::new(base_url.into()),
180            client,
181        }
182    }
183
184    pub fn new_url(
185        client: Arc<dyn HttpClient>,
186        base_url: impl Into<String>,
187        proxy_url: Option<Url>,
188    ) -> Self {
189        let client = HttpClientWithProxy::new_url(client, proxy_url);
190
191        Self {
192            base_url: Mutex::new(base_url.into()),
193            client,
194        }
195    }
196
197    /// Returns the base URL.
198    pub fn base_url(&self) -> String {
199        self.base_url.lock().clone()
200    }
201
202    /// Sets the base URL.
203    pub fn set_base_url(&self, base_url: impl Into<String>) {
204        let base_url = base_url.into();
205        *self.base_url.lock() = base_url;
206    }
207
208    /// Builds a URL using the given path.
209    pub fn build_url(&self, path: &str) -> String {
210        format!("{}{}", self.base_url(), path)
211    }
212
213    /// Builds a Zed API URL using the given path.
214    pub fn build_zed_api_url(&self, path: &str, query: &[(&str, &str)]) -> Result<Url> {
215        let base_url = self.base_url();
216        let base_api_url = match base_url.as_ref() {
217            "https://zed.dev" => "https://api.zed.dev",
218            "https://staging.zed.dev" => "https://api-staging.zed.dev",
219            "http://localhost:3000" => "http://localhost:8080",
220            other => other,
221        };
222
223        Ok(Url::parse_with_params(
224            &format!("{}{}", base_api_url, path),
225            query,
226        )?)
227    }
228
229    /// Builds a Zed Cloud URL using the given path.
230    pub fn build_zed_cloud_url(&self, path: &str) -> Result<Url> {
231        let base_url = self.base_url();
232        let base_api_url = match base_url.as_ref() {
233            "https://zed.dev" => "https://cloud.zed.dev",
234            "https://staging.zed.dev" => "https://cloud.zed.dev",
235            "http://localhost:3000" => "http://localhost:8787",
236            other => other,
237        };
238
239        Ok(Url::parse(&format!("{}{}", base_api_url, path))?)
240    }
241
242    /// Builds a Zed Cloud URL using the given path and query params.
243    pub fn build_zed_cloud_url_with_query(&self, path: &str, query: impl Serialize) -> Result<Url> {
244        let base_url = self.base_url();
245        let base_api_url = match base_url.as_ref() {
246            "https://zed.dev" => "https://cloud.zed.dev",
247            "https://staging.zed.dev" => "https://cloud.zed.dev",
248            "http://localhost:3000" => "http://localhost:8787",
249            other => other,
250        };
251        let query = serde_urlencoded::to_string(&query)?;
252        Ok(Url::parse(&format!("{}{}?{}", base_api_url, path, query))?)
253    }
254
255    /// Builds a Zed LLM URL using the given path.
256    pub fn build_zed_llm_url(&self, path: &str, query: &[(&str, &str)]) -> Result<Url> {
257        let base_url = self.base_url();
258        let base_api_url = match base_url.as_ref() {
259            "https://zed.dev" => "https://cloud.zed.dev",
260            "https://staging.zed.dev" => "https://llm-staging.zed.dev",
261            "http://localhost:3000" => "http://localhost:8787",
262            other => other,
263        };
264
265        Ok(Url::parse_with_params(
266            &format!("{}{}", base_api_url, path),
267            query,
268        )?)
269    }
270}
271
272impl HttpClient for HttpClientWithUrl {
273    fn send(
274        &self,
275        req: Request<AsyncBody>,
276    ) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>> {
277        self.client.send(req)
278    }
279
280    fn user_agent(&self) -> Option<&HeaderValue> {
281        self.client.user_agent()
282    }
283
284    fn proxy(&self) -> Option<&Url> {
285        self.client.proxy.as_ref()
286    }
287
288    #[cfg(feature = "test-support")]
289    fn as_fake(&self) -> &FakeHttpClient {
290        self.client.as_fake()
291    }
292}
293
294/// Generate a styled HTML page for OAuth callback responses.
295///
296/// Returns a complete HTML document (no HTTP headers) with a centered card
297/// layout styled to match Zed's dark theme. The `title` is rendered as a
298/// heading and `message` as body text below it.
299pub fn oauth_callback_page(title: &str, message: &str) -> String {
300    format!(
301        r#"<!DOCTYPE html>
302<html lang="en">
303<head>
304<meta charset="utf-8">
305<meta name="viewport" content="width=device-width, initial-scale=1">
306<title>{title} — Zed</title>
307<style>
308  * {{ margin: 0; padding: 0; box-sizing: border-box; }}
309  body {{
310    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
311    background: #1e1e2e;
312    color: #cdd6f4;
313    display: flex;
314    align-items: center;
315    justify-content: center;
316    min-height: 100vh;
317    padding: 1rem;
318  }}
319  .card {{
320    background: #313244;
321    border-radius: 12px;
322    padding: 2.5rem;
323    max-width: 420px;
324    width: 100%;
325    text-align: center;
326    box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
327  }}
328  .icon {{
329    width: 48px;
330    height: 48px;
331    margin: 0 auto 1.5rem;
332    background: #a6e3a1;
333    border-radius: 50%;
334    display: flex;
335    align-items: center;
336    justify-content: center;
337  }}
338  .icon svg {{
339    width: 24px;
340    height: 24px;
341    stroke: #1e1e2e;
342    stroke-width: 3;
343    fill: none;
344  }}
345  h1 {{
346    font-size: 1.25rem;
347    font-weight: 600;
348    margin-bottom: 0.75rem;
349    color: #cdd6f4;
350  }}
351  p {{
352    font-size: 0.925rem;
353    line-height: 1.5;
354    color: #a6adc8;
355  }}
356  .brand {{
357    margin-top: 1.5rem;
358    font-size: 0.8rem;
359    color: #585b70;
360    letter-spacing: 0.05em;
361  }}
362</style>
363</head>
364<body>
365<div class="card">
366  <div class="icon">
367    <svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
368  </div>
369  <h1>{title}</h1>
370  <p>{message}</p>
371  <div class="brand">Zed</div>
372</div>
373</body>
374</html>"#,
375        title = title,
376        message = message,
377    )
378}
379
380pub fn read_proxy_from_env() -> Option<Url> {
381    const ENV_VARS: &[&str] = &[
382        "ALL_PROXY",
383        "all_proxy",
384        "HTTPS_PROXY",
385        "https_proxy",
386        "HTTP_PROXY",
387        "http_proxy",
388    ];
389
390    ENV_VARS
391        .iter()
392        .find_map(|var| std::env::var(var).ok())
393        .and_then(|env| env.parse().ok())
394}
395
396pub fn read_no_proxy_from_env() -> Option<String> {
397    const ENV_VARS: &[&str] = &["NO_PROXY", "no_proxy"];
398
399    ENV_VARS.iter().find_map(|var| std::env::var(var).ok())
400}
401
402pub struct BlockedHttpClient;
403
404impl BlockedHttpClient {
405    pub fn new() -> Self {
406        BlockedHttpClient
407    }
408}
409
410impl HttpClient for BlockedHttpClient {
411    fn send(
412        &self,
413        _req: Request<AsyncBody>,
414    ) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>> {
415        Box::pin(async {
416            Err(std::io::Error::new(
417                std::io::ErrorKind::PermissionDenied,
418                "BlockedHttpClient disallowed request",
419            )
420            .into())
421        })
422    }
423
424    fn user_agent(&self) -> Option<&HeaderValue> {
425        None
426    }
427
428    fn proxy(&self) -> Option<&Url> {
429        None
430    }
431
432    #[cfg(feature = "test-support")]
433    fn as_fake(&self) -> &FakeHttpClient {
434        panic!("called as_fake on {}", type_name::<Self>())
435    }
436}
437
438#[cfg(feature = "test-support")]
439type FakeHttpHandler = Arc<
440    dyn Fn(Request<AsyncBody>) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>>
441        + Send
442        + Sync
443        + 'static,
444>;
445
446#[cfg(feature = "test-support")]
447pub struct FakeHttpClient {
448    handler: Mutex<Option<FakeHttpHandler>>,
449    user_agent: HeaderValue,
450}
451
452#[cfg(feature = "test-support")]
453impl FakeHttpClient {
454    pub fn create<Fut, F>(handler: F) -> Arc<HttpClientWithUrl>
455    where
456        Fut: futures::Future<Output = anyhow::Result<Response<AsyncBody>>> + Send + 'static,
457        F: Fn(Request<AsyncBody>) -> Fut + Send + Sync + 'static,
458    {
459        Arc::new(HttpClientWithUrl {
460            base_url: Mutex::new("http://test.example".into()),
461            client: HttpClientWithProxy {
462                client: Arc::new(Self {
463                    handler: Mutex::new(Some(Arc::new(move |req| Box::pin(handler(req))))),
464                    user_agent: HeaderValue::from_static(type_name::<Self>()),
465                }),
466                proxy: None,
467            },
468        })
469    }
470
471    pub fn with_404_response() -> Arc<HttpClientWithUrl> {
472        log::warn!("Using fake HTTP client with 404 response");
473        Self::create(|_| async move {
474            Ok(Response::builder()
475                .status(404)
476                .body(Default::default())
477                .unwrap())
478        })
479    }
480
481    pub fn with_200_response() -> Arc<HttpClientWithUrl> {
482        log::warn!("Using fake HTTP client with 200 response");
483        Self::create(|_| async move {
484            Ok(Response::builder()
485                .status(200)
486                .body(Default::default())
487                .unwrap())
488        })
489    }
490
491    pub fn replace_handler<Fut, F>(&self, new_handler: F)
492    where
493        Fut: futures::Future<Output = anyhow::Result<Response<AsyncBody>>> + Send + 'static,
494        F: Fn(FakeHttpHandler, Request<AsyncBody>) -> Fut + Send + Sync + 'static,
495    {
496        let mut handler = self.handler.lock();
497        let old_handler = handler.take().unwrap();
498        *handler = Some(Arc::new(move |req| {
499            Box::pin(new_handler(old_handler.clone(), req))
500        }));
501    }
502}
503
504#[cfg(feature = "test-support")]
505impl fmt::Debug for FakeHttpClient {
506    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
507        f.debug_struct("FakeHttpClient").finish()
508    }
509}
510
511#[cfg(feature = "test-support")]
512impl HttpClient for FakeHttpClient {
513    fn send(
514        &self,
515        req: Request<AsyncBody>,
516    ) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>> {
517        ((self.handler.lock().as_ref().unwrap())(req)) as _
518    }
519
520    fn user_agent(&self) -> Option<&HeaderValue> {
521        Some(&self.user_agent)
522    }
523
524    fn proxy(&self) -> Option<&Url> {
525        None
526    }
527
528    fn as_fake(&self) -> &FakeHttpClient {
529        self
530    }
531}