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