Add `cloud_api_client` and `cloud_api_types` crates (#35357)

Marshall Bowers created

This PR adds two new crates for interacting with Cloud:

- `cloud_api_client` - The client that will be used to talk to Cloud.
- `cloud_api_types` - The types for the Cloud API that are shared
between Zed and Cloud.

Release Notes:

- N/A

Change summary

Cargo.lock                                      | 22 +++++
Cargo.toml                                      |  4 +
crates/client/Cargo.toml                        |  1 
crates/client/src/client.rs                     |  5 +
crates/cloud_api_client/Cargo.toml              | 21 +++++
crates/cloud_api_client/LICENSE-APACHE          |  1 
crates/cloud_api_client/src/cloud_api_client.rs | 76 +++++++++++++++++++
crates/cloud_api_types/Cargo.toml               | 16 ++++
crates/cloud_api_types/LICENSE-APACHE           |  1 
crates/cloud_api_types/src/cloud_api_types.rs   | 14 +++
crates/http_client/src/http_client.rs           | 16 ++++
11 files changed, 177 insertions(+)

Detailed changes

Cargo.lock 🔗

@@ -2976,6 +2976,7 @@ dependencies = [
  "base64 0.22.1",
  "chrono",
  "clock",
+ "cloud_api_client",
  "cloud_llm_client",
  "cocoa 0.26.0",
  "collections",
@@ -3031,6 +3032,27 @@ dependencies = [
  "workspace-hack",
 ]
 
+[[package]]
+name = "cloud_api_client"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "cloud_api_types",
+ "futures 0.3.31",
+ "http_client",
+ "parking_lot",
+ "serde_json",
+ "workspace-hack",
+]
+
+[[package]]
+name = "cloud_api_types"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "workspace-hack",
+]
+
 [[package]]
 name = "cloud_llm_client"
 version = "0.1.0"

Cargo.toml 🔗

@@ -29,6 +29,8 @@ members = [
     "crates/cli",
     "crates/client",
     "crates/clock",
+    "crates/cloud_api_client",
+    "crates/cloud_api_types",
     "crates/cloud_llm_client",
     "crates/collab",
     "crates/collab_ui",
@@ -251,6 +253,8 @@ channel = { path = "crates/channel" }
 cli = { path = "crates/cli" }
 client = { path = "crates/client" }
 clock = { path = "crates/clock" }
+cloud_api_client = { path = "crates/cloud_api_client" }
+cloud_api_types = { path = "crates/cloud_api_types" }
 cloud_llm_client = { path = "crates/cloud_llm_client" }
 collab = { path = "crates/collab" }
 collab_ui = { path = "crates/collab_ui" }

crates/client/Cargo.toml 🔗

@@ -22,6 +22,7 @@ async-tungstenite = { workspace = true, features = ["tokio", "tokio-rustls-manua
 base64.workspace = true
 chrono = { workspace = true, features = ["serde"] }
 clock.workspace = true
+cloud_api_client.workspace = true
 cloud_llm_client.workspace = true
 collections.workspace = true
 credentials_provider.workspace = true

crates/client/src/client.rs 🔗

@@ -15,6 +15,7 @@ use async_tungstenite::tungstenite::{
 };
 use chrono::{DateTime, Utc};
 use clock::SystemClock;
+use cloud_api_client::CloudApiClient;
 use credentials_provider::CredentialsProvider;
 use futures::{
     AsyncReadExt, FutureExt, SinkExt, Stream, StreamExt, TryFutureExt as _, TryStreamExt,
@@ -213,6 +214,7 @@ pub struct Client {
     id: AtomicU64,
     peer: Arc<Peer>,
     http: Arc<HttpClientWithUrl>,
+    cloud_client: Arc<CloudApiClient>,
     telemetry: Arc<Telemetry>,
     credentials_provider: ClientCredentialsProvider,
     state: RwLock<ClientState>,
@@ -586,6 +588,7 @@ impl Client {
             id: AtomicU64::new(0),
             peer: Peer::new(0),
             telemetry: Telemetry::new(clock, http.clone(), cx),
+            cloud_client: Arc::new(CloudApiClient::new(http.clone())),
             http,
             credentials_provider: ClientCredentialsProvider::new(cx),
             state: Default::default(),
@@ -930,6 +933,8 @@ impl Client {
         }
         let credentials = credentials.unwrap();
         self.set_id(credentials.user_id);
+        self.cloud_client
+            .set_credentials(credentials.user_id as u32, credentials.access_token.clone());
 
         if was_disconnected {
             self.set_status(Status::Connecting, cx);

crates/cloud_api_client/Cargo.toml 🔗

@@ -0,0 +1,21 @@
+[package]
+name = "cloud_api_client"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "Apache-2.0"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/cloud_api_client.rs"
+
+[dependencies]
+anyhow.workspace = true
+cloud_api_types.workspace = true
+futures.workspace = true
+http_client.workspace = true
+parking_lot.workspace = true
+serde_json.workspace = true
+workspace-hack.workspace = true

crates/cloud_api_client/src/cloud_api_client.rs 🔗

@@ -0,0 +1,76 @@
+use std::sync::Arc;
+
+use anyhow::{Result, anyhow};
+pub use cloud_api_types::*;
+use futures::AsyncReadExt as _;
+use http_client::{AsyncBody, HttpClientWithUrl, Method, Request};
+use parking_lot::RwLock;
+
+struct Credentials {
+    user_id: u32,
+    access_token: String,
+}
+
+pub struct CloudApiClient {
+    credentials: RwLock<Option<Credentials>>,
+    http_client: Arc<HttpClientWithUrl>,
+}
+
+impl CloudApiClient {
+    pub fn new(http_client: Arc<HttpClientWithUrl>) -> Self {
+        Self {
+            credentials: RwLock::new(None),
+            http_client,
+        }
+    }
+
+    pub fn set_credentials(&self, user_id: u32, access_token: String) {
+        *self.credentials.write() = Some(Credentials {
+            user_id,
+            access_token,
+        });
+    }
+
+    fn authorization_header(&self) -> Result<String> {
+        let guard = self.credentials.read();
+        let credentials = guard
+            .as_ref()
+            .ok_or_else(|| anyhow!("No credentials provided"))?;
+
+        Ok(format!(
+            "{} {}",
+            credentials.user_id, credentials.access_token
+        ))
+    }
+
+    pub async fn get_authenticated_user(&self) -> Result<AuthenticatedUser> {
+        let request = Request::builder()
+            .method(Method::GET)
+            .uri(
+                self.http_client
+                    .build_zed_cloud_url("/client/users/me", &[])?
+                    .as_ref(),
+            )
+            .header("Content-Type", "application/json")
+            .header("Authorization", self.authorization_header()?)
+            .body(AsyncBody::default())?;
+
+        let mut response = self.http_client.send(request).await?;
+
+        if !response.status().is_success() {
+            let mut body = String::new();
+            response.body_mut().read_to_string(&mut body).await?;
+
+            anyhow::bail!(
+                "Failed to get authenticated user.\nStatus: {:?}\nBody: {body}",
+                response.status()
+            )
+        }
+
+        let mut body = String::new();
+        response.body_mut().read_to_string(&mut body).await?;
+        let response: GetAuthenticatedUserResponse = serde_json::from_str(&body)?;
+
+        Ok(response.user)
+    }
+}

crates/cloud_api_types/Cargo.toml 🔗

@@ -0,0 +1,16 @@
+[package]
+name = "cloud_api_types"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "Apache-2.0"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/cloud_api_types.rs"
+
+[dependencies]
+serde.workspace = true
+workspace-hack.workspace = true

crates/cloud_api_types/src/cloud_api_types.rs 🔗

@@ -0,0 +1,14 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct GetAuthenticatedUserResponse {
+    pub user: AuthenticatedUser,
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct AuthenticatedUser {
+    pub id: i32,
+    pub avatar_url: String,
+    pub github_login: String,
+    pub name: Option<String>,
+}

crates/http_client/src/http_client.rs 🔗

@@ -236,6 +236,22 @@ impl HttpClientWithUrl {
         )?)
     }
 
+    /// Builds a Zed Cloud URL using the given path.
+    pub fn build_zed_cloud_url(&self, path: &str, query: &[(&str, &str)]) -> Result<Url> {
+        let base_url = self.base_url();
+        let base_api_url = match base_url.as_ref() {
+            "https://zed.dev" => "https://cloud.zed.dev",
+            "https://staging.zed.dev" => "https://cloud.zed.dev",
+            "http://localhost:3000" => "http://localhost:8787",
+            other => other,
+        };
+
+        Ok(Url::parse_with_params(
+            &format!("{}{}", base_api_url, path),
+            query,
+        )?)
+    }
+
     /// Builds a Zed LLM URL using the given path.
     pub fn build_zed_llm_url(&self, path: &str, query: &[(&str, &str)]) -> Result<Url> {
         let base_url = self.base_url();