Skip to main content

rws_storage/storage/
gcs.rs

1use super::gcs_credentials::{self, GcsCredentialsProvider};
2use super::{Storage, StorageError};
3use crate::http_client::{Client, RequestBuilder, Response};
4
5/// Connection details for a Google Cloud Storage bucket.
6#[derive(Clone, Debug)]
7pub struct GcsConfig {
8    pub bucket: String,
9    /// Scheme + host of the JSON API, `https://storage.googleapis.com` by
10    /// default. Override for an emulator (fake-gcs-server) or a private
11    /// endpoint.
12    pub endpoint: String,
13}
14
15impl GcsConfig {
16    /// Read configuration from environment variables.
17    ///
18    /// | Variable | Default |
19    /// |---|---|
20    /// | `RWS_GCS_BUCKET` | **(required)** |
21    /// | `RWS_GCS_ENDPOINT` | `https://storage.googleapis.com` |
22    ///
23    /// Credentials are found separately — see [`GcsStorage`].
24    pub fn from_env() -> Result<Self, StorageError> {
25        let bucket = std::env::var("RWS_GCS_BUCKET")
26            .ok()
27            .filter(|b| !b.trim().is_empty())
28            .ok_or_else(|| StorageError::new("RWS_GCS_BUCKET environment variable is required"))?;
29        let endpoint = std::env::var("RWS_GCS_ENDPOINT")
30            .ok()
31            .filter(|e| !e.trim().is_empty())
32            .unwrap_or_else(|| "https://storage.googleapis.com".to_string());
33        Ok(GcsConfig { bucket, endpoint })
34    }
35}
36
37/// Google Cloud Storage, through its JSON API and the outbound HTTP client
38/// (`crate::http_client::Client`) — no Google SDK.
39///
40/// Requests carry an OAuth 2.0 access token, found as Google's client
41/// libraries find Application Default Credentials: `RWS_GCS_ACCESS_TOKEN`,
42/// then the file in `GOOGLE_APPLICATION_CREDENTIALS` (a service account key
43/// or `authorized_user`), then gcloud's
44/// `application_default_credentials.json`, then the metadata server
45/// (Compute Engine, GKE Workload Identity, Cloud Run). Tokens are cached and
46/// refreshed before they expire. `RWS_GCS_CREDENTIAL_SOURCE=token|file|metadata|none`
47/// picks one; `none` sends no credentials (emulators).
48pub struct GcsStorage {
49    config: GcsConfig,
50    client: Client,
51    credentials: GcsCredentialsProvider,
52}
53
54impl GcsStorage {
55    pub fn new(config: GcsConfig) -> Self {
56        GcsStorage::with_source(config, gcs_credentials::detect())
57    }
58
59    pub(super) fn with_source(config: GcsConfig, source: gcs_credentials::Source) -> Self {
60        GcsStorage { config, client: Client::new(), credentials: GcsCredentialsProvider::new(source) }
61    }
62
63    /// Shortcut for `GcsStorage::new(GcsConfig::from_env()?)`.
64    pub fn from_env() -> Result<Self, StorageError> {
65        Ok(GcsStorage::new(GcsConfig::from_env()?))
66    }
67
68    fn endpoint(&self) -> &str {
69        self.config.endpoint.trim_end_matches('/')
70    }
71
72    /// `/storage/v1/b/{bucket}/o/{object}`: the object name is one path
73    /// segment, `/` included, so it's encoded too.
74    fn object_url(&self, key: &str) -> String {
75        format!("{}/storage/v1/b/{}/o/{}", self.endpoint(), encode(&self.config.bucket), encode(key))
76    }
77
78    fn authorize<'a>(&self, builder: RequestBuilder<'a>) -> Result<RequestBuilder<'a>, StorageError> {
79        Ok(match self.credentials.authorization()? {
80            Some(value) => builder.header("Authorization", &value),
81            None => builder,
82        })
83    }
84
85    fn error(action: &str, key: &str, resp: &Response) -> StorageError {
86        StorageError::new(format!("GCS {action} '{key}' failed: HTTP {} {}", resp.status(), resp.text().unwrap_or_default()))
87    }
88}
89
90/// Percent-encodes everything but the unreserved set (`A-Z a-z 0-9 - . _ ~`).
91fn encode(s: &str) -> String {
92    let mut out = String::with_capacity(s.len());
93    for b in s.bytes() {
94        match b {
95            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => out.push(b as char),
96            _ => out.push_str(&format!("%{b:02X}")),
97        }
98    }
99    out
100}
101
102impl Storage for GcsStorage {
103    fn put(&self, key: &str, data: &[u8], content_type: &str) -> Result<String, StorageError> {
104        let url = format!("{}/upload/storage/v1/b/{}/o?uploadType=media&name={}", self.endpoint(), encode(&self.config.bucket), encode(key));
105        let content_type = if content_type.is_empty() { "application/octet-stream" } else { content_type };
106        let resp = self
107            .authorize(self.client.post(&url).header("Content-Type", content_type))?
108            .body(data.to_vec())
109            .send()
110            .map_err(|e| StorageError::new(format!("GCS PUT '{key}' failed: {e}")))?;
111        if !resp.is_success() {
112            return Err(Self::error("PUT", key, &resp));
113        }
114        Ok(key.to_string())
115    }
116
117    fn get(&self, key: &str) -> Result<Vec<u8>, StorageError> {
118        let url = format!("{}?alt=media", self.object_url(key));
119        let resp = self.authorize(self.client.get(&url))?.send().map_err(|e| StorageError::new(format!("GCS GET '{key}' failed: {e}")))?;
120        if !resp.is_success() {
121            return Err(Self::error("GET", key, &resp));
122        }
123        Ok(resp.bytes().to_vec())
124    }
125
126    fn delete(&self, key: &str) -> Result<(), StorageError> {
127        let resp = self
128            .authorize(self.client.delete(&self.object_url(key)))?
129            .send()
130            .map_err(|e| StorageError::new(format!("GCS DELETE '{key}' failed: {e}")))?;
131        // A missing object is a no-op, as `Storage::delete` promises.
132        if !resp.is_success() && resp.status() != 404 {
133            return Err(Self::error("DELETE", key, &resp));
134        }
135        Ok(())
136    }
137
138    /// `{endpoint}/{bucket}/{key}` — the object's public URL
139    /// (`https://storage.googleapis.com/...`), which works for objects
140    /// readable by `allUsers`; private ones need a signed URL.
141    fn url(&self, key: &str) -> String {
142        let key = key.split('/').map(encode).collect::<Vec<_>>().join("/");
143        format!("{}/{}/{}", self.endpoint(), self.config.bucket, key)
144    }
145}
146
147#[cfg(test)]
148mod tests;