1#![cfg_attr(docsrs, feature(doc_cfg))]
2pub mod decode;
21pub mod encode;
22pub mod types;
23
24use faucet_core::FaucetError;
25use gcloud_gax::conn::Environment;
26use gcloud_spanner::admin::AdminClientConfig;
27use gcloud_spanner::admin::client::Client as AdminClient;
28use gcloud_spanner::client::google_cloud_auth::credentials::CredentialsFile;
29use gcloud_spanner::client::{Client, ClientConfig};
30use schemars::JsonSchema;
31use serde::{Deserialize, Serialize};
32use std::time::Duration;
33
34const SESSIONS_PER_CHANNEL: usize = 100;
37
38const SESSION_GET_TIMEOUT: Duration = Duration::from_secs(30);
42
43#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
54#[serde(tag = "type", content = "config", rename_all = "snake_case")]
55pub enum SpannerCredentials {
56 ServiceAccountKeyPath {
58 #[schemars(extend("x-faucet-resource" = "path"))]
60 path: String,
61 },
62 ServiceAccountKey {
65 json: String,
67 },
68 #[default]
72 ApplicationDefault,
73}
74
75impl std::fmt::Debug for SpannerCredentials {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 match self {
78 Self::ServiceAccountKeyPath { path } => f
79 .debug_struct("ServiceAccountKeyPath")
80 .field("path", path)
81 .finish(),
82 Self::ServiceAccountKey { .. } => f
84 .debug_struct("ServiceAccountKey")
85 .field("json", &"***")
86 .finish(),
87 Self::ApplicationDefault => f.debug_struct("ApplicationDefault").finish(),
88 }
89 }
90}
91
92fn default_max_sessions() -> usize {
93 100
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
99pub struct SpannerConnection {
100 pub project_id: String,
102 pub instance: String,
104 pub database: String,
106 #[serde(default)]
108 pub auth: SpannerCredentials,
109 #[serde(default = "default_max_sessions")]
112 pub max_sessions: usize,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
119 #[schemars(extend("x-faucet-resource" = "url"))]
120 pub emulator_host: Option<String>,
121}
122
123impl SpannerConnection {
124 pub fn database_path(&self) -> String {
127 format!(
128 "projects/{}/instances/{}/databases/{}",
129 self.project_id, self.instance, self.database
130 )
131 }
132
133 pub fn validate(&self) -> Result<(), FaucetError> {
135 for (field, value) in [
136 ("project_id", &self.project_id),
137 ("instance", &self.instance),
138 ("database", &self.database),
139 ] {
140 if value.trim().is_empty() {
141 return Err(FaucetError::Config(format!(
142 "spanner: `{field}` must not be empty"
143 )));
144 }
145 }
146 if self.max_sessions == 0 {
147 return Err(FaucetError::Config(
148 "spanner: `max_sessions` must be at least 1".into(),
149 ));
150 }
151 Ok(())
152 }
153
154 pub async fn connect(&self) -> Result<Client, FaucetError> {
157 self.validate()?;
158 let mut config = ClientConfig::default();
159 config.channel_config.num_channels = self.max_sessions.div_ceil(SESSIONS_PER_CHANNEL);
160 config.session_config.min_opened = 1;
161 config.session_config.max_idle = self.max_sessions;
162 config.session_config.max_opened = self.max_sessions;
163 config.session_config.session_get_timeout = SESSION_GET_TIMEOUT;
164 if let Some(host) = &self.emulator_host {
165 config.environment = Environment::Emulator(host.clone());
166 }
167 let config = self.apply_data_credentials(config).await?;
170 Client::new(self.database_path(), config)
171 .await
172 .map_err(|e| FaucetError::Custom(Box::new(e)))
173 }
174
175 pub async fn connect_admin(&self) -> Result<AdminClient, FaucetError> {
178 self.validate()?;
179 let mut config = AdminClientConfig::default();
180 if let Some(host) = &self.emulator_host {
181 config.environment = Environment::Emulator(host.clone());
182 }
183 let config = match &self.auth {
184 SpannerCredentials::ApplicationDefault => config
185 .with_auth()
186 .await
187 .map_err(|e| FaucetError::Auth(format!("spanner ADC: {e}")))?,
188 SpannerCredentials::ServiceAccountKeyPath { path } => config
189 .with_credentials(credentials_from_file(path).await?)
190 .await
191 .map_err(|e| FaucetError::Auth(format!("spanner key file {path}: {e}")))?,
192 SpannerCredentials::ServiceAccountKey { json } => config
193 .with_credentials(credentials_from_str(json).await?)
194 .await
195 .map_err(|e| FaucetError::Auth(format!("spanner inline key: {e}")))?,
196 };
197 AdminClient::new(config)
198 .await
199 .map_err(|e| FaucetError::Custom(Box::new(e)))
200 }
201
202 async fn apply_data_credentials(
203 &self,
204 config: ClientConfig,
205 ) -> Result<ClientConfig, FaucetError> {
206 match &self.auth {
207 SpannerCredentials::ApplicationDefault => config
208 .with_auth()
209 .await
210 .map_err(|e| FaucetError::Auth(format!("spanner ADC: {e}"))),
211 SpannerCredentials::ServiceAccountKeyPath { path } => config
212 .with_credentials(credentials_from_file(path).await?)
213 .await
214 .map_err(|e| FaucetError::Auth(format!("spanner key file {path}: {e}"))),
215 SpannerCredentials::ServiceAccountKey { json } => config
216 .with_credentials(credentials_from_str(json).await?)
217 .await
218 .map_err(|e| FaucetError::Auth(format!("spanner inline key: {e}"))),
219 }
220 }
221}
222
223async fn credentials_from_file(path: &str) -> Result<CredentialsFile, FaucetError> {
224 CredentialsFile::new_from_file(path.to_string())
225 .await
226 .map_err(|e| FaucetError::Auth(format!("spanner key file {path}: {e}")))
227}
228
229async fn credentials_from_str(json: &str) -> Result<CredentialsFile, FaucetError> {
230 CredentialsFile::new_from_str(json)
231 .await
232 .map_err(|e| FaucetError::Auth(format!("spanner inline key: {e}")))
233}
234
235pub fn quote_ident_spanner(ident: &str) -> String {
239 format!("`{}`", ident.replace('\\', "\\\\").replace('`', "\\`"))
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use serde_json::json;
246
247 #[test]
248 fn credentials_serde_shape_is_adjacently_tagged() {
249 let c: SpannerCredentials = serde_json::from_value(json!({
250 "type": "service_account_key_path",
251 "config": {"path": "/tmp/sa.json"}
252 }))
253 .unwrap();
254 assert!(matches!(
255 c,
256 SpannerCredentials::ServiceAccountKeyPath { ref path } if path == "/tmp/sa.json"
257 ));
258 let adc: SpannerCredentials =
259 serde_json::from_value(json!({"type": "application_default"})).unwrap();
260 assert!(matches!(adc, SpannerCredentials::ApplicationDefault));
261 assert!(matches!(
262 SpannerCredentials::default(),
263 SpannerCredentials::ApplicationDefault
264 ));
265 }
266
267 #[test]
268 fn debug_masks_inline_key_material() {
269 let c = SpannerCredentials::ServiceAccountKey {
270 json: "{\"private_key\": \"SECRET\"}".into(),
271 };
272 let rendered = format!("{c:?}");
273 assert!(!rendered.contains("SECRET"));
274 assert!(rendered.contains("***"));
275 }
276
277 fn conn() -> SpannerConnection {
278 SpannerConnection {
279 project_id: "p".into(),
280 instance: "i".into(),
281 database: "d".into(),
282 auth: SpannerCredentials::default(),
283 max_sessions: default_max_sessions(),
284 emulator_host: None,
285 }
286 }
287
288 #[test]
289 fn database_path_shape() {
290 assert_eq!(conn().database_path(), "projects/p/instances/i/databases/d");
291 }
292
293 #[test]
294 fn validate_rejects_empty_fields_and_zero_sessions() {
295 let mut c = conn();
296 c.project_id = " ".into();
297 assert!(matches!(c.validate(), Err(FaucetError::Config(_))));
298 let mut c = conn();
299 c.instance = String::new();
300 assert!(c.validate().is_err());
301 let mut c = conn();
302 c.database = String::new();
303 assert!(c.validate().is_err());
304 let mut c = conn();
305 c.max_sessions = 0;
306 assert!(c.validate().is_err());
307 assert!(conn().validate().is_ok());
308 }
309
310 #[test]
311 fn connection_defaults_from_minimal_config() {
312 let c: SpannerConnection = serde_json::from_value(json!({
313 "project_id": "p", "instance": "i", "database": "d"
314 }))
315 .unwrap();
316 assert_eq!(c.max_sessions, 100);
317 assert!(c.emulator_host.is_none());
318 assert!(matches!(c.auth, SpannerCredentials::ApplicationDefault));
319 }
320
321 #[test]
322 fn quote_ident_backslash_escapes() {
323 assert_eq!(quote_ident_spanner("plain"), "`plain`");
324 assert_eq!(quote_ident_spanner("we`ird"), "`we\\`ird`");
325 assert_eq!(quote_ident_spanner("a\\b"), "`a\\\\b`");
326 }
327
328 #[tokio::test]
329 async fn credentials_from_missing_file_is_a_typed_auth_error() {
330 let Err(err) = credentials_from_file("/definitely/not/here.json").await else {
331 panic!("missing key file unexpectedly loaded");
332 };
333 assert!(matches!(err, FaucetError::Auth(_)));
334 assert!(err.to_string().contains("/definitely/not/here.json"));
335 }
336
337 #[tokio::test]
338 async fn credentials_from_invalid_json_is_a_typed_auth_error() {
339 let Err(err) = credentials_from_str("{not json").await else {
340 panic!("invalid key json unexpectedly parsed");
341 };
342 assert!(matches!(err, FaucetError::Auth(_)));
343 assert!(err.to_string().contains("inline key"));
344 }
345
346 const FAKE_SA: &str = r#"{"type": "service_account", "project_id": "p"}"#;
349
350 #[tokio::test]
355 async fn connect_exercises_every_credential_variant_against_dead_emulator() {
356 let key_path = std::env::temp_dir().join("faucet-spanner-test-sa.json");
357 std::fs::write(&key_path, FAKE_SA).expect("write fake key");
358 let variants = [
359 SpannerCredentials::ApplicationDefault,
360 SpannerCredentials::ServiceAccountKey {
361 json: FAKE_SA.to_string(),
362 },
363 SpannerCredentials::ServiceAccountKeyPath {
364 path: key_path.to_string_lossy().into_owned(),
365 },
366 ];
367 for auth in variants {
368 let mut c = conn();
369 c.emulator_host = Some("127.0.0.1:1".into());
372 c.auth = auth.clone();
373 let Err(err) = c.connect().await else {
374 panic!("data-plane connect unexpectedly succeeded for {auth:?}");
375 };
376 assert!(
377 matches!(err, FaucetError::Custom(_)),
378 "data-plane connect: unexpected error {err:?} for {auth:?}"
379 );
380 let Err(err) = c.connect_admin().await else {
381 panic!("admin connect unexpectedly succeeded for {auth:?}");
382 };
383 assert!(
384 matches!(err, FaucetError::Custom(_)),
385 "admin connect: unexpected error {err:?} for {auth:?}"
386 );
387 }
388 }
389}