rws_storage/storage/
gcs.rs1use super::gcs_credentials::{self, GcsCredentialsProvider};
2use super::{Storage, StorageError};
3use crate::http_client::{Client, RequestBuilder, Response};
4
5#[derive(Clone, Debug)]
7pub struct GcsConfig {
8 pub bucket: String,
9 pub endpoint: String,
13}
14
15impl GcsConfig {
16 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
37pub 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 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 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
90fn 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 if !resp.is_success() && resp.status() != 404 {
133 return Err(Self::error("DELETE", key, &resp));
134 }
135 Ok(())
136 }
137
138 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;