zed_extension_api: Add `HttpRequestBuilder` (#16165)

Marshall Bowers created

This PR adds an `HttpRequestBuilder` to the extension API to allow for a
more ergonomic way for constructing HTTP requests within extensions.

The HTTP client functionality is now also exposed via the
`zed_extension_api::http_client` module instead of top-level.

Release Notes:

- N/A

Change summary

crates/extension_api/src/extension_api.rs |  6 -
crates/extension_api/src/http_client.rs   | 95 +++++++++++++++++++++++++
crates/extension_api/src/settings.rs      |  2 
extensions/gleam/src/gleam.rs             | 25 ++---
extensions/gleam/src/hexdocs.rs           | 31 ++++---
5 files changed, 125 insertions(+), 34 deletions(-)

Detailed changes

crates/extension_api/src/extension_api.rs 🔗

@@ -1,6 +1,6 @@
 //! The Zed Rust Extension API allows you write extensions for [Zed](https://zed.dev/) in Rust.
 
-/// Provides access to Zed settings.
+pub mod http_client;
 pub mod settings;
 
 use core::fmt;
@@ -19,10 +19,6 @@ pub use wit::{
         github_release_by_tag_name, latest_github_release, GithubRelease, GithubReleaseAsset,
         GithubReleaseOptions,
     },
-    zed::extension::http_client::{
-        fetch, fetch_stream, HttpMethod, HttpRequest, HttpResponse, HttpResponseStream,
-        RedirectPolicy,
-    },
     zed::extension::nodejs::{
         node_binary_path, npm_install_package, npm_package_installed_version,
         npm_package_latest_version,

crates/extension_api/src/http_client.rs 🔗

@@ -0,0 +1,95 @@
+//! An HTTP client.
+
+pub use crate::wit::zed::extension::http_client::{
+    fetch, fetch_stream, HttpMethod, HttpRequest, HttpResponse, HttpResponseStream, RedirectPolicy,
+};
+
+impl HttpRequest {
+    /// Returns a builder for an [`HttpRequest`].
+    pub fn builder() -> HttpRequestBuilder {
+        HttpRequestBuilder::new()
+    }
+
+    /// Executes the [`HttpRequest`] with [`fetch`].
+    pub fn fetch(&self) -> Result<HttpResponse, String> {
+        fetch(self)
+    }
+
+    /// Executes the [`HttpRequest`] with [`fetch_stream`].
+    pub fn fetch_stream(&self) -> Result<HttpResponseStream, String> {
+        fetch_stream(&self)
+    }
+}
+
+/// A builder for an [`HttpRequest`].
+#[derive(Clone)]
+pub struct HttpRequestBuilder {
+    method: Option<HttpMethod>,
+    url: Option<String>,
+    headers: Vec<(String, String)>,
+    body: Option<Vec<u8>>,
+    redirect_policy: RedirectPolicy,
+}
+
+impl HttpRequestBuilder {
+    /// Returns a new [`HttpRequestBuilder`].
+    pub fn new() -> Self {
+        HttpRequestBuilder {
+            method: None,
+            url: None,
+            headers: Vec::new(),
+            body: None,
+            redirect_policy: RedirectPolicy::NoFollow,
+        }
+    }
+
+    /// Sets the HTTP method for the request.
+    pub fn method(mut self, method: HttpMethod) -> Self {
+        self.method = Some(method);
+        self
+    }
+
+    /// Sets the URL for the request.
+    pub fn url(mut self, url: impl Into<String>) -> Self {
+        self.url = Some(url.into());
+        self
+    }
+
+    /// Adds a header to the request.
+    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
+        self.headers.push((name.into(), value.into()));
+        self
+    }
+
+    /// Adds the specified headers to the request.
+    pub fn headers(mut self, headers: impl IntoIterator<Item = (String, String)>) -> Self {
+        self.headers.extend(headers);
+        self
+    }
+
+    /// Sets the body of the request.
+    pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
+        self.body = Some(body.into());
+        self
+    }
+
+    /// Sets the redirect policy for the request.
+    pub fn redirect_policy(mut self, policy: RedirectPolicy) -> Self {
+        self.redirect_policy = policy;
+        self
+    }
+
+    /// Builds the [`HttpRequest`].
+    pub fn build(self) -> Result<HttpRequest, String> {
+        let method = self.method.ok_or_else(|| "Method not set".to_string())?;
+        let url = self.url.ok_or_else(|| "URL not set".to_string())?;
+
+        Ok(HttpRequest {
+            method,
+            url,
+            headers: self.headers,
+            body: self.body,
+            redirect_policy: self.redirect_policy,
+        })
+    }
+}

extensions/gleam/src/gleam.rs 🔗

@@ -1,11 +1,11 @@
 mod hexdocs;
 
 use std::{fs, io};
+use zed::http_client::{HttpMethod, HttpRequest, RedirectPolicy};
 use zed::lsp::CompletionKind;
 use zed::{
-    CodeLabel, CodeLabelSpan, HttpMethod, HttpRequest, KeyValueStore, LanguageServerId,
-    RedirectPolicy, SlashCommand, SlashCommandArgumentCompletion, SlashCommandOutput,
-    SlashCommandOutputSection,
+    CodeLabel, CodeLabelSpan, KeyValueStore, LanguageServerId, SlashCommand,
+    SlashCommandArgumentCompletion, SlashCommandOutput, SlashCommandOutputSection,
 };
 use zed_extension_api::{self as zed, Result};
 
@@ -194,23 +194,20 @@ impl zed::Extension for GleamExtension {
                     .ok_or_else(|| "missing package name".to_string())?;
                 let module_path = components.map(ToString::to_string).collect::<Vec<_>>();
 
-                let response = zed::fetch(&HttpRequest {
-                    method: HttpMethod::Get,
-                    url: format!(
+                let response = HttpRequest::builder()
+                    .method(HttpMethod::Get)
+                    .url(format!(
                         "https://hexdocs.pm/{package_name}{maybe_path}",
                         maybe_path = if !module_path.is_empty() {
                             format!("/{}.html", module_path.join("/"))
                         } else {
                             String::new()
                         }
-                    ),
-                    headers: vec![(
-                        "User-Agent".to_string(),
-                        "Zed (Gleam Extension)".to_string(),
-                    )],
-                    body: None,
-                    redirect_policy: RedirectPolicy::FollowAll,
-                })?;
+                    ))
+                    .header("User-Agent", "Zed (Gleam Extension)")
+                    .redirect_policy(RedirectPolicy::FollowAll)
+                    .build()?
+                    .fetch()?;
 
                 let (markdown, _modules) =
                     convert_hexdocs_to_markdown(&mut io::Cursor::new(response.body))?;

extensions/gleam/src/hexdocs.rs 🔗

@@ -11,7 +11,8 @@ use html_to_markdown::{
     StartTagOutcome, TagHandler,
 };
 use zed_extension_api::{
-    self as zed, HttpMethod, HttpRequest, KeyValueStore, RedirectPolicy, Result,
+    http_client::{HttpMethod, HttpRequest, RedirectPolicy},
+    KeyValueStore, Result,
 };
 
 pub fn index(package: String, database: &KeyValueStore) -> Result<()> {
@@ -20,13 +21,13 @@ pub fn index(package: String, database: &KeyValueStore) -> Result<()> {
         "Zed (Gleam Extension)".to_string(),
     )];
 
-    let response = zed::fetch(&HttpRequest {
-        method: HttpMethod::Get,
-        url: format!("https://hexdocs.pm/{package}"),
-        headers: headers.clone(),
-        body: None,
-        redirect_policy: RedirectPolicy::FollowAll,
-    })?;
+    let response = HttpRequest::builder()
+        .method(HttpMethod::Get)
+        .url(format!("https://hexdocs.pm/{package}"))
+        .headers(headers.clone())
+        .redirect_policy(RedirectPolicy::FollowAll)
+        .build()?
+        .fetch()?;
 
     let (package_root_markdown, modules) =
         convert_hexdocs_to_markdown(&mut io::Cursor::new(&response.body))?;
@@ -34,13 +35,13 @@ pub fn index(package: String, database: &KeyValueStore) -> Result<()> {
     database.insert(&package, &package_root_markdown)?;
 
     for module in modules {
-        let response = zed::fetch(&HttpRequest {
-            method: HttpMethod::Get,
-            url: format!("https://hexdocs.pm/{package}/{module}.html"),
-            headers: headers.clone(),
-            body: None,
-            redirect_policy: RedirectPolicy::FollowAll,
-        })?;
+        let response = HttpRequest::builder()
+            .method(HttpMethod::Get)
+            .url(format!("https://hexdocs.pm/{package}/{module}.html"))
+            .headers(headers.clone())
+            .redirect_policy(RedirectPolicy::FollowAll)
+            .build()?
+            .fetch()?;
 
         let (markdown, _modules) =
             convert_hexdocs_to_markdown(&mut io::Cursor::new(&response.body))?;