1use crate::error::{GcpErrorContext, truncate_for_error};
4use adk_core::Result;
5use google_cloud_auth::credentials::{self, CacheableResource, Credentials};
6use reqwest::header::HeaderMap;
7use reqwest::{Client, Method, RequestBuilder, StatusCode, Url};
8use serde_json::{Map, Value};
9use std::sync::Arc;
10use std::time::Duration;
11use tokio::sync::RwLock;
12
13const CLOUD_PLATFORM_SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform";
14const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
15const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
16const DEFAULT_AUTH_TIMEOUT: Duration = Duration::from_secs(30);
17const DEFAULT_MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
18const DEFAULT_API_VERSION: &str = "v1beta1";
19
20#[derive(Debug)]
26pub struct GcpHttpClientBuilder {
27 errors: GcpErrorContext,
28 endpoint: String,
29 api_version: String,
30 credentials: Option<Credentials>,
31 scopes: Vec<String>,
32 connect_timeout: Duration,
33 request_timeout: Duration,
34 auth_timeout: Duration,
35 max_response_bytes: usize,
36}
37
38impl GcpHttpClientBuilder {
39 #[must_use]
41 pub fn api_version(mut self, api_version: impl Into<String>) -> Self {
42 self.api_version = api_version.into();
43 self
44 }
45
46 #[must_use]
48 pub fn credentials(mut self, credentials: Credentials) -> Self {
49 self.credentials = Some(credentials);
50 self
51 }
52
53 #[must_use]
57 pub fn scopes<I, S>(mut self, scopes: I) -> Self
58 where
59 I: IntoIterator<Item = S>,
60 S: Into<String>,
61 {
62 self.scopes = scopes.into_iter().map(Into::into).collect();
63 self
64 }
65
66 #[must_use]
68 pub fn connect_timeout(mut self, timeout: Duration) -> Self {
69 self.connect_timeout = timeout;
70 self
71 }
72
73 #[must_use]
75 pub fn request_timeout(mut self, timeout: Duration) -> Self {
76 self.request_timeout = timeout;
77 self
78 }
79
80 #[must_use]
82 pub fn auth_timeout(mut self, timeout: Duration) -> Self {
83 self.auth_timeout = timeout;
84 self
85 }
86
87 #[must_use]
93 pub fn max_response_bytes(mut self, max_response_bytes: usize) -> Self {
94 self.max_response_bytes = max_response_bytes;
95 self
96 }
97
98 pub fn build(self) -> Result<GcpHttpClient> {
107 let errors = self.errors;
108 let base_url = validate_endpoint(&self.endpoint, &errors)?;
109
110 let credentials = match self.credentials {
111 Some(credentials) => credentials,
112 None => credentials::Builder::default().with_scopes(&self.scopes).build().map_err(
113 |error| {
114 let error = truncate_for_error(&error.to_string());
115 errors.unauthorized(format!(
116 "failed to build {} ADC credentials: {error}",
117 errors.subject(),
118 ))
119 },
120 )?,
121 };
122
123 let http_client = Client::builder()
124 .connect_timeout(self.connect_timeout)
125 .timeout(self.request_timeout)
126 .redirect(reqwest::redirect::Policy::none())
127 .build()
128 .map_err(|error| {
129 let error = truncate_for_error(&error.without_url().to_string());
130 errors.invalid_response(format!(
131 "failed to build bounded {} HTTP client: {error}",
132 errors.subject(),
133 ))
134 })?;
135
136 Ok(GcpHttpClient {
137 http_client,
138 base_url,
139 api_version: self.api_version,
140 credentials,
141 auth_headers: Arc::new(RwLock::new(None)),
142 request_timeout: self.request_timeout,
143 auth_timeout: self.auth_timeout,
144 max_response_bytes: self.max_response_bytes,
145 errors,
146 })
147 }
148}
149
150#[derive(Debug)]
197pub struct GcpHttpClient {
198 http_client: Client,
199 base_url: Url,
200 api_version: String,
201 credentials: Credentials,
202 auth_headers: Arc<RwLock<Option<HeaderMap>>>,
203 request_timeout: Duration,
204 auth_timeout: Duration,
205 max_response_bytes: usize,
206 errors: GcpErrorContext,
207}
208
209impl GcpHttpClient {
210 pub fn builder(errors: GcpErrorContext, endpoint: impl Into<String>) -> GcpHttpClientBuilder {
212 GcpHttpClientBuilder {
213 errors,
214 endpoint: endpoint.into(),
215 api_version: DEFAULT_API_VERSION.to_string(),
216 credentials: None,
217 scopes: vec![CLOUD_PLATFORM_SCOPE.to_string()],
218 connect_timeout: DEFAULT_CONNECT_TIMEOUT,
219 request_timeout: DEFAULT_REQUEST_TIMEOUT,
220 auth_timeout: DEFAULT_AUTH_TIMEOUT,
221 max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
222 }
223 }
224
225 pub fn errors(&self) -> &GcpErrorContext {
227 &self.errors
228 }
229
230 pub fn max_response_bytes(&self) -> usize {
232 self.max_response_bytes
233 }
234
235 #[must_use]
241 pub fn with_max_response_bytes(mut self, max_response_bytes: usize) -> Self {
242 self.max_response_bytes = max_response_bytes;
243 self
244 }
245
246 pub fn url(&self, path: &str) -> Result<Url> {
252 let mut url = self.base_url.clone();
253 url.set_path(&format!("/{}/{}", self.api_version, path.trim_start_matches('/')));
254 Ok(url)
255 }
256
257 pub async fn request(&self, method: Method, path: &str) -> Result<RequestBuilder> {
264 let url = self.url(path)?;
265 let request = self.http_client.request(method, url);
266 self.apply_auth(request).await
267 }
268
269 pub async fn apply_auth(&self, request: RequestBuilder) -> Result<RequestBuilder> {
276 let headers = self.auth_headers().await?;
277 Ok(request.headers(headers))
278 }
279
280 pub async fn auth_headers(&self) -> Result<HeaderMap> {
288 let cacheable_headers =
289 tokio::time::timeout(self.auth_timeout, self.credentials.headers(Default::default()))
290 .await
291 .map_err(|_| {
292 self.errors.timeout(format!(
293 "{} credential header acquisition timed out after {} seconds",
294 self.errors.subject(),
295 self.auth_timeout.as_secs_f64(),
296 ))
297 })?
298 .map_err(|error| self.errors.credentials_error(&error))?;
299
300 match cacheable_headers {
301 CacheableResource::New { data, .. } => {
302 *self.auth_headers.write().await = Some(data.clone());
303 Ok(data)
304 }
305 CacheableResource::NotModified => {
306 self.auth_headers.read().await.clone().ok_or_else(|| {
307 self.errors.unauthorized(
308 "google cloud credentials returned NotModified before any cached auth headers were available",
309 )
310 })
311 }
312 }
313 }
314
315 pub async fn send_value(&self, request: RequestBuilder) -> Result<Value> {
325 match self.send_value_internal(request, false).await? {
326 Some((value, _)) => Ok(value),
327 None => Ok(Value::Object(Map::new())),
328 }
329 }
330
331 pub async fn send_value_counted(&self, request: RequestBuilder) -> Result<(Value, usize)> {
340 match self.send_value_internal(request, false).await? {
341 Some(value) => Ok(value),
342 None => Ok((Value::Object(Map::new()), 0)),
343 }
344 }
345
346 pub async fn send_value_allow_not_found(
353 &self,
354 request: RequestBuilder,
355 ) -> Result<Option<Value>> {
356 self.send_value_internal(request, true).await.map(|option| option.map(|(value, _)| value))
357 }
358
359 async fn send_value_internal(
360 &self,
361 request: RequestBuilder,
362 allow_not_found: bool,
363 ) -> Result<Option<(Value, usize)>> {
364 let (status, body) = tokio::time::timeout(self.request_timeout, async {
365 let mut response =
366 request.send().await.map_err(|error| self.errors.transport_error(error))?;
367 let status = response.status();
368 if let Some(declared) = response.content_length()
369 && declared > self.max_response_bytes as u64
370 {
371 return Err(self
372 .errors
373 .response_too_large(
374 "response Content-Length",
375 self.max_response_bytes,
376 declared,
377 )
378 .with_upstream_status(status.as_u16()));
379 }
380 let capacity = response
381 .content_length()
382 .and_then(|length| usize::try_from(length).ok())
383 .unwrap_or_default();
384 let mut body = Vec::with_capacity(capacity);
385 while let Some(chunk) = response.chunk().await.map_err(|error| {
386 let timeout = error.is_timeout();
387 let detail = truncate_for_error(&error.without_url().to_string());
388 let error = if timeout {
389 self.errors.timeout(format!(
390 "{} response body timed out: {detail}",
391 self.errors.subject(),
392 ))
393 } else {
394 self.errors.unavailable(format!(
395 "failed to read {} response body: {detail}",
396 self.errors.subject(),
397 ))
398 };
399 error.with_upstream_status(status.as_u16())
400 })? {
401 let observed = body.len().checked_add(chunk.len()).ok_or_else(|| {
402 self.errors
403 .response_too_large("response body", self.max_response_bytes, u64::MAX)
404 .with_upstream_status(status.as_u16())
405 })?;
406 if observed > self.max_response_bytes {
407 return Err(self
408 .errors
409 .response_too_large(
410 "response body",
411 self.max_response_bytes,
412 observed as u64,
413 )
414 .with_upstream_status(status.as_u16()));
415 }
416 body.extend_from_slice(&chunk);
417 }
418 Ok::<_, adk_core::AdkError>((status, body))
419 })
420 .await
421 .map_err(|_| {
422 self.errors.timeout(format!(
423 "{} request timed out after {} seconds",
424 self.errors.subject(),
425 self.request_timeout.as_secs(),
426 ))
427 })??;
428
429 if allow_not_found && status == StatusCode::NOT_FOUND {
430 return Ok(None);
431 }
432
433 if !status.is_success() {
434 let body = String::from_utf8_lossy(&body);
435 let body = if body.trim().is_empty() { "<empty body>" } else { body.as_ref() };
436 return Err(self.errors.status_error(status, body));
437 }
438
439 let body_len = body.len();
440 if body.iter().all(u8::is_ascii_whitespace) {
441 return Ok(Some((Value::Object(Map::new()), body_len)));
442 }
443
444 let value = serde_json::from_slice(&body).map_err(|error| {
445 let error = truncate_for_error(&error.to_string());
446 self.errors
447 .invalid_response(format!(
448 "failed to parse {} response JSON: {error}",
449 self.errors.subject(),
450 ))
451 .with_upstream_status(status.as_u16())
452 })?;
453 Ok(Some((value, body_len)))
454 }
455}
456
457fn validate_endpoint(endpoint: &str, errors: &GcpErrorContext) -> Result<Url> {
463 let url = Url::parse(endpoint).map_err(|error| {
464 let error = truncate_for_error(&error.to_string());
465 errors.invalid_input(format!("invalid {} endpoint URL: {error}", errors.subject()))
466 })?;
467 if url.scheme() != "https" && !(url.scheme() == "http" && is_loopback_host(&url)) {
468 return Err(errors.invalid_input(format!(
469 "{} endpoint must use HTTPS for secure transmission",
470 errors.subject(),
471 )));
472 }
473 if !url.username().is_empty()
474 || url.password().is_some()
475 || url.query().is_some()
476 || url.fragment().is_some()
477 || url.path() != "/"
478 {
479 return Err(errors.invalid_input(format!(
480 "{} endpoint must be an origin without userinfo, path, query, or fragment",
481 errors.subject(),
482 )));
483 }
484 Ok(url)
485}
486
487fn is_loopback_host(url: &Url) -> bool {
488 matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1"))
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use crate::error::GcpErrorCodes;
495 use adk_core::{ErrorCategory, ErrorComponent};
496
497 const CODES: GcpErrorCodes = GcpErrorCodes {
498 invalid_input: "session.vertex.invalid_input",
499 unauthorized: "session.vertex.unauthorized",
500 forbidden: "session.vertex.forbidden",
501 not_found: "session.vertex.not_found",
502 rate_limited: "session.vertex.rate_limited",
503 timeout: "session.vertex.timeout",
504 unavailable: "session.vertex.unavailable",
505 credentials_unavailable: "session.vertex.credentials_unavailable",
506 invalid_response: "session.vertex.invalid_response",
507 invalid_request: "session.vertex.invalid_request",
508 upstream_error: "session.vertex.upstream_error",
509 operation_failed: "session.vertex.operation_failed",
510 };
511
512 fn errors() -> GcpErrorContext {
513 GcpErrorContext::new(ErrorComponent::Session, CODES, "vertex session")
514 }
515
516 #[test]
517 fn endpoint_validation_requires_secure_origins() {
518 assert!(
519 validate_endpoint("https://us-central1-aiplatform.googleapis.com", &errors()).is_ok()
520 );
521 assert!(validate_endpoint("http://127.0.0.1:8080", &errors()).is_ok());
522 assert!(validate_endpoint("http://localhost:8080", &errors()).is_ok());
523
524 let rejected = [
525 "http://example.com",
526 "ftp://example.com",
527 "https://user:pass@example.com",
528 "https://example.com/path",
529 "https://example.com?query=1",
530 "https://example.com#fragment",
531 "not a url",
532 ];
533 for endpoint in rejected {
534 let error = validate_endpoint(endpoint, &errors()).unwrap_err();
535 assert_eq!(error.category, ErrorCategory::InvalidInput, "accepted {endpoint:?}");
536 }
537 }
538
539 #[tokio::test]
540 async fn url_prefixes_the_api_version() {
541 let client = GcpHttpClient::builder(errors(), "https://example.googleapis.com")
542 .api_version("v1")
543 .credentials(
544 google_cloud_auth::credentials::api_key_credentials::Builder::new("k").build(),
545 )
546 .build()
547 .unwrap();
548 let url = client.url("projects/p/locations/l/reasoningEngines/1").unwrap();
549 assert_eq!(
550 url.as_str(),
551 "https://example.googleapis.com/v1/projects/p/locations/l/reasoningEngines/1",
552 );
553 }
554}