From ef49a86e34311dd768b76b136207c8a2131bf151 Mon Sep 17 00:00:00 2001 From: Robbie McKinstry Date: Sat, 20 Dec 2025 00:54:45 -0500 Subject: [PATCH] Add Vercel client starter kit --- src/adapters/vercel/mod.rs | 128 +++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 src/adapters/vercel/mod.rs diff --git a/src/adapters/vercel/mod.rs b/src/adapters/vercel/mod.rs new file mode 100644 index 0000000..e0053a7 --- /dev/null +++ b/src/adapters/vercel/mod.rs @@ -0,0 +1,128 @@ +use std::sync::OnceLock; + +use derive_getters::Getters; +use reqwest::{header, Client}; +use serde::{Deserialize, Serialize}; +use url::Url; + +static URL: OnceLock = OnceLock::new(); + +fn init_url() -> Url { + // NOTE: The trailing slash is important here, otherwise the URL parsing will fail! + Url::parse("https://api.vercel.com/").unwrap() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum TeamIdentifier { + Id { team_id: String }, + Slug { slug: String }, +} + +impl TeamIdentifier { + pub fn id(id: impl Into) -> Self { + Self::Id { team_id: id.into() } + } + + pub fn slug(slug: impl Into) -> Self { + Self::Slug { slug: slug.into() } + } +} + +#[derive(Clone, Getters)] +pub struct VercelClient { + http: Client, + url: Url, + team: Option, +} + +impl VercelClient { + pub fn new(token: impl AsRef, team: Option) -> anyhow::Result { + let mut headers = header::HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + header::HeaderValue::from_str(&format!("Bearer {}", token.as_ref()))?, + ); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("application/json"), + ); + + let http = Client::builder() + .default_headers(headers) + .build()?; + + Ok(Self { + http, + url: URL.get_or_init(init_url).clone(), + team, + }) + } + + fn add_team_query(&self, url: &mut Url) { + if let Some(team) = &self.team { + match team { + TeamIdentifier::Id { team_id } => { + url.query_pairs_mut().append_pair("teamId", team_id); + } + TeamIdentifier::Slug { slug } => { + url.query_pairs_mut().append_pair("slug", slug); + } + } + } + } + + pub async fn get_project(&self, id_or_name: impl AsRef) -> anyhow::Result { + let mut url = self.url.clone(); + url.set_path(&format!("v9/projects/{}", id_or_name.as_ref())); + self.add_team_query(&mut url); + + let response = self.http.get(url).send().await?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); + anyhow::bail!("Failed to get project: {} - {}", status, text); + } + + let project = response.json::().await?; + Ok(project) + } +} + +// Response types for the project endpoint +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Project { + pub id: String, + pub name: String, + pub account_id: String, + pub updated_at: Option, + pub created_at: u64, + pub framework: Option, + pub dev_command: Option, + pub build_command: Option, + pub output_directory: Option, + pub root_directory: Option, + pub directory_listing: bool, + pub node_version: String, + pub live: Option, + pub env: Option>, + pub git_fork_protection: Option, + pub git_lfs: Option, + pub link: Option, + pub source_files_outside_root_directory: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvVariable { + pub key: String, + pub value: String, + #[serde(rename = "type")] + pub var_type: String, + pub id: Option, + pub created_at: Option, + pub updated_at: Option, + pub target: Option>, +}