From 4e24be25ed7b298f5784e836111a76cb6a14ad2d Mon Sep 17 00:00:00 2001 From: Joshua Tan Date: Wed, 22 Apr 2026 05:37:14 +0000 Subject: [PATCH 1/2] feat(gax): add user_project to RequestOptions Adds a `with_user_project` method to `Storage` and `StorageControl` request builders to support Requester Pays buckets. When set, the internal gRPC and HTTP transports emit an `x-goog-user-project` header which takes precedence over any `quota_project_id` configured in the client credentials. --- src/gax-internal/src/grpc.rs | 22 ++- src/gax-internal/src/http.rs | 10 +- src/gax-internal/tests/grpc_user_project.rs | 140 ++++++++++++++++ src/gax-internal/tests/http_user_project.rs | 128 +++++++++++++++ src/gax/src/options.rs | 50 ++++++ src/storage/src/builder_ext.rs | 63 +++++++ src/storage/src/storage/open_object.rs | 65 ++++++++ src/storage/src/storage/read_object.rs | 172 +++++++++++++++++++- src/storage/src/storage/request_options.rs | 23 +++ src/storage/src/storage/write_object.rs | 73 +++++++++ src/storage/tests/user_project_control.rs | 56 +++++++ 11 files changed, 794 insertions(+), 8 deletions(-) create mode 100644 src/gax-internal/tests/grpc_user_project.rs create mode 100644 src/gax-internal/tests/http_user_project.rs create mode 100644 src/storage/tests/user_project_control.rs diff --git a/src/gax-internal/src/grpc.rs b/src/gax-internal/src/grpc.rs index b2d1878d52..3e5ada3a30 100644 --- a/src/gax-internal/src/grpc.rs +++ b/src/gax-internal/src/grpc.rs @@ -34,6 +34,7 @@ use google_cloud_gax::client_builder::Result as ClientBuilderResult; use google_cloud_gax::error::Error; use google_cloud_gax::exponential_backoff::ExponentialBackoff; use google_cloud_gax::options::RequestOptions; +use google_cloud_gax::options::internal::{RequestOptionsExt, UserProject}; use google_cloud_gax::polling_backoff_policy::PollingBackoffPolicy; use google_cloud_gax::polling_error_policy::{ Aip194Strict as PollingAip194Strict, PollingErrorPolicy, @@ -51,6 +52,7 @@ use std::time::Duration; // A tonic::transport::Channel always has a Buffer layer. const DEFAULT_REQUEST_BUFFER_CAPACITY: usize = 1024; +const X_GOOG_USER_PROJECT: &str = "x-goog-user-project"; pub type GrpcService = Channel; @@ -207,7 +209,7 @@ impl Client { { use ::tonic::IntoStreamingRequest; let headers = Self::make_headers(api_client_header, request_params, &options).await?; - let headers = self.add_auth_headers(headers).await?; + let headers = self.add_auth_headers(headers, &options).await?; let metadata = tonic::MetadataMap::from_headers(headers); let request = ::tonic::Request::from_parts(metadata, extensions, request); let codec = tonic_prost::ProstCodec::::default(); @@ -272,7 +274,7 @@ impl Client { { use ::tonic::IntoRequest; let headers = Self::make_headers(api_client_header, request_params, &options).await?; - let headers = self.add_auth_headers(headers).await?; + let headers = self.add_auth_headers(headers, &options).await?; let metadata = tonic::MetadataMap::from_headers(headers); let mut request = ::tonic::Request::from_parts(metadata, extensions, request); if let Some(attempt_timeout) = options.attempt_timeout() { @@ -400,7 +402,7 @@ impl Client { }; #[allow(unused_mut)] - let mut headers = self.add_auth_headers(headers).await?; + let mut headers = self.add_auth_headers(headers, options).await?; crate::observability::propagation::inject_context(&span, &mut headers); @@ -529,17 +531,27 @@ impl Client { .map_err(BuilderError::cred) } - async fn add_auth_headers(&self, mut headers: http::HeaderMap) -> Result { + async fn add_auth_headers( + &self, + mut headers: http::HeaderMap, + options: &RequestOptions, + ) -> Result { let h = self .credentials .headers(http::Extensions::new()) .await .map_err(Error::authentication)?; - let CacheableResource::New { data, .. } = h else { + let CacheableResource::New { mut data, .. } = h else { unreachable!("headers are not cached"); }; + if let Some(up) = options.get_extension::() { + data.insert( + http::header::HeaderName::from_static(X_GOOG_USER_PROJECT), + up.as_value().clone(), + ); + } headers.extend(data); Ok(headers) } diff --git a/src/gax-internal/src/http.rs b/src/gax-internal/src/http.rs index c406662ab5..ed14771343 100644 --- a/src/gax-internal/src/http.rs +++ b/src/gax-internal/src/http.rs @@ -38,6 +38,7 @@ use google_cloud_gax::client_builder::Result as ClientBuilderResult; use google_cloud_gax::error::{Error, rpc::Status}; use google_cloud_gax::exponential_backoff::ExponentialBackoff; use google_cloud_gax::options::RequestOptions; +use google_cloud_gax::options::internal::{RequestOptionsExt, UserProject}; use google_cloud_gax::polling_backoff_policy::PollingBackoffPolicy; use google_cloud_gax::polling_error_policy::{ Aip194Strict as PollingAip194Strict, PollingErrorPolicy, @@ -55,6 +56,8 @@ use std::sync::Arc; use std::time::Duration; use tracing::Instrument; +const X_GOOG_USER_PROJECT: &str = "x-goog-user-project"; + #[derive(Clone, Debug)] pub struct ReqwestClient { inner: ::reqwest::Client, @@ -401,7 +404,12 @@ impl ReqwestClient { builder = match self.cred.headers(Extensions::new()).await { Err(e) => return Err(Error::authentication(e)), - Ok(CacheableResource::New { data, .. }) => builder.headers(data), + Ok(CacheableResource::New { mut data, .. }) => { + if let Some(up) = options.get_extension::() { + data.insert(X_GOOG_USER_PROJECT, up.as_value().clone()); + } + builder.headers(data) + } Ok(CacheableResource::NotModified) => unreachable!("headers are not cached"), }; builder.build().map_err(map_send_error) diff --git a/src/gax-internal/tests/grpc_user_project.rs b/src/gax-internal/tests/grpc_user_project.rs new file mode 100644 index 0000000000..bddf07fe1d --- /dev/null +++ b/src/gax-internal/tests/grpc_user_project.rs @@ -0,0 +1,140 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +mod mock_credentials; + +#[cfg(all(test, feature = "_internal-grpc-client"))] +mod tests { + use super::mock_credentials::{MockCredentials, mock_credentials}; + use google_cloud_auth::credentials::{CacheableResource, Credentials, EntityTag}; + use google_cloud_gax::Result; + use google_cloud_gax::options::RequestOptions; + use google_cloud_gax::options::internal::{RequestOptionsExt, UserProject}; + use google_cloud_gax_internal::grpc; + use grpc_server::{builder, google, start_echo_server}; + use http::{HeaderMap, HeaderValue}; + + const PROJECT_NAME: &str = "project_lazy_dog"; + const CRED_QUOTA_PROJECT: &str = "cred_quota_project"; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn user_project_emits_header() -> anyhow::Result<()> { + let (endpoint, _server) = start_echo_server().await?; + let client = builder(endpoint) + .with_credentials(mock_credentials()) + .build() + .await?; + + let options = RequestOptions::default().insert_extension(UserProject::new(PROJECT_NAME)); + let response = send_request(client, options).await?; + assert_eq!( + response + .metadata + .get("x-goog-user-project") + .map(String::as_str), + Some(PROJECT_NAME) + ); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn no_user_project_no_header() -> anyhow::Result<()> { + let (endpoint, _server) = start_echo_server().await?; + let client = builder(endpoint) + .with_credentials(mock_credentials()) + .build() + .await?; + + let response = send_request(client, RequestOptions::default()).await?; + assert!( + !response.metadata.contains_key("x-goog-user-project"), + "{:?}", + response.metadata + ); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn user_project_strips_credential_quota_project() -> anyhow::Result<()> { + let (endpoint, _server) = start_echo_server().await?; + + let mut mock = MockCredentials::new(); + mock.expect_headers().returning(|_exts| { + let mut map = HeaderMap::new(); + map.insert( + http::header::AUTHORIZATION, + HeaderValue::from_static("Bearer test-token"), + ); + map.insert( + "x-goog-user-project", + HeaderValue::from_static(CRED_QUOTA_PROJECT), + ); + Ok(CacheableResource::New { + data: map, + entity_tag: EntityTag::default(), + }) + }); + + let client = builder(endpoint) + .with_credentials(Credentials::from(mock)) + .build() + .await?; + + let options = RequestOptions::default().insert_extension(UserProject::new(PROJECT_NAME)); + let response = send_request(client, options).await?; + + assert_eq!( + response + .metadata + .get("x-goog-user-project") + .map(String::as_str), + Some(PROJECT_NAME) + ); + assert!( + !response.metadata.values().any(|v| v == CRED_QUOTA_PROJECT), + "credential's quota_project value leaked onto the wire: {:?}", + response.metadata + ); + Ok(()) + } + + async fn send_request( + client: grpc::Client, + options: RequestOptions, + ) -> Result { + let extensions = { + let mut e = tonic::Extensions::new(); + e.insert(tonic::GrpcMethod::new( + "google.test.v1.EchoServices", + "Echo", + )); + e + }; + let request = google::test::v1::EchoRequest { + message: "message".into(), + ..google::test::v1::EchoRequest::default() + }; + client + .execute( + extensions, + http::uri::PathAndQuery::from_static("/google.test.v1.EchoService/Echo"), + request, + options, + "test-only-api-client/1.0", + "", + ) + .await + .map(tonic::Response::into_inner) + } +} diff --git a/src/gax-internal/tests/http_user_project.rs b/src/gax-internal/tests/http_user_project.rs new file mode 100644 index 0000000000..cfdae60cfa --- /dev/null +++ b/src/gax-internal/tests/http_user_project.rs @@ -0,0 +1,128 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +mod mock_credentials; + +#[cfg(all(test, feature = "_internal-http-client"))] +mod tests { + use super::mock_credentials::{MockCredentials, mock_credentials}; + use google_cloud_auth::credentials::{CacheableResource, Credentials, EntityTag}; + use google_cloud_gax::options::RequestOptions; + use google_cloud_gax::options::internal::{RequestOptionsExt, UserProject}; + use http::{HeaderMap, HeaderValue}; + use serde_json::json; + + const PROJECT_NAME: &str = "project_lazy_dog"; + const CRED_QUOTA_PROJECT: &str = "cred_quota_project"; + + #[tokio::test] + async fn user_project_emits_header() -> anyhow::Result<()> { + let (endpoint, _server) = echo_server::start().await?; + let client = echo_server::builder(endpoint) + .with_credentials(Credentials::from(mock_credentials())) + .build() + .await?; + + let builder = client.builder(reqwest::Method::GET, "/echo".into()); + let options = RequestOptions::default().insert_extension(UserProject::new(PROJECT_NAME)); + let response: serde_json::Value = client + .execute(builder, Some(json!({})), options) + .await? + .into_body(); + assert_eq!( + get_header_value(&response, "x-goog-user-project").as_deref(), + Some(PROJECT_NAME), + "{response:?}" + ); + Ok(()) + } + + #[tokio::test] + async fn no_user_project_no_header() -> anyhow::Result<()> { + let (endpoint, _server) = echo_server::start().await?; + let client = echo_server::builder(endpoint) + .with_credentials(Credentials::from(mock_credentials())) + .build() + .await?; + + let builder = client.builder(reqwest::Method::GET, "/echo".into()); + let response: serde_json::Value = client + .execute(builder, Some(json!({})), RequestOptions::default()) + .await? + .into_body(); + assert!( + get_header_value(&response, "x-goog-user-project").is_none(), + "{response:?}" + ); + Ok(()) + } + + #[tokio::test] + async fn user_project_strips_credential_quota_project() -> anyhow::Result<()> { + let (endpoint, _server) = echo_server::start().await?; + + let mut mock = MockCredentials::new(); + mock.expect_headers().returning(|_exts| { + let mut map = HeaderMap::new(); + map.insert( + http::header::AUTHORIZATION, + HeaderValue::from_static("Bearer test-token"), + ); + map.insert( + "x-goog-user-project", + HeaderValue::from_static(CRED_QUOTA_PROJECT), + ); + Ok(CacheableResource::New { + data: map, + entity_tag: EntityTag::default(), + }) + }); + + let client = echo_server::builder(endpoint) + .with_credentials(Credentials::from(mock)) + .build() + .await?; + + let builder = client.builder(reqwest::Method::GET, "/echo".into()); + let options = RequestOptions::default().insert_extension(UserProject::new(PROJECT_NAME)); + let response: serde_json::Value = client + .execute(builder, Some(json!({})), options) + .await? + .into_body(); + + assert_eq!( + get_header_value(&response, "x-goog-user-project").as_deref(), + Some(PROJECT_NAME), + "{response:?}" + ); + let headers = response.get("headers").and_then(|h| h.as_object()); + let leaked = headers + .map(|h| h.values().any(|v| v.as_str() == Some(CRED_QUOTA_PROJECT))) + .unwrap_or(false); + assert!( + !leaked, + "credential's quota_project value leaked onto the wire: {response:?}" + ); + Ok(()) + } + + fn get_header_value(response: &serde_json::Value, name: &str) -> Option { + response + .as_object() + .and_then(|o| o.get("headers")) + .and_then(|h| h.get(name)) + .and_then(|v| v.as_str()) + .map(str::to_string) + } +} diff --git a/src/gax/src/options.rs b/src/gax/src/options.rs index 0c6d8f7c32..4e119b2b3a 100644 --- a/src/gax/src/options.rs +++ b/src/gax/src/options.rs @@ -237,6 +237,11 @@ pub mod internal { fn insert_extension(self, value: T) -> Self where T: Clone + Send + Sync + 'static; + + /// Sets an extension value in-place. + fn insert_extension_mut(&mut self, value: T) + where + T: Clone + Send + Sync + 'static; } impl sealed::OptionsExt for RequestOptions {} @@ -255,6 +260,13 @@ pub mod internal { let _ = self.extensions.insert(value); self } + + fn insert_extension_mut(&mut self, value: T) + where + T: Clone + Send + Sync + 'static, + { + self.extensions.insert(value); + } } #[derive(Debug, Clone, Default, PartialEq)] @@ -263,6 +275,31 @@ pub mod internal { #[derive(Debug, Clone, Default, PartialEq)] pub struct ResourceName(pub String); + /// Per-request billing project. When present, `gax-internal`'s gRPC and + /// HTTP transports emit an `x-goog-user-project` header carrying this + /// value and drop any `x-goog-user-project` header the credentials + /// provider would have emitted from its configured `quota_project_id`, + /// so the wire carries exactly one `x-goog-user-project`. + #[derive(Debug, Clone, PartialEq)] + pub struct UserProject(http::HeaderValue); + + impl UserProject { + /// Creates a new `UserProject` extension. + /// + /// # Panics + /// + /// Panics if the project ID contains non-visible ASCII characters. + pub fn new(project: impl Into) -> Self { + let val = http::HeaderValue::from_str(&project.into()).expect("invalid project id"); + Self(val) + } + + /// Returns the underlying header value. + pub fn as_value(&self) -> &http::HeaderValue { + &self.0 + } + } + // Cannot remove this function, as that would break any client libraries // that are released and use this function. #[deprecated] @@ -481,4 +518,17 @@ mod tests { Ok(()) } + + #[test] + fn user_project() { + const PROJECT_NAME: &str = "project_lazy_dog"; + let up = UserProject::new(PROJECT_NAME); + assert_eq!(up.as_value(), PROJECT_NAME); + } + + #[test] + #[should_panic(expected = "invalid project id")] + fn user_project_invalid_project_id_panics() { + let _ = UserProject::new("invalid\nproject"); + } } diff --git a/src/storage/src/builder_ext.rs b/src/storage/src/builder_ext.rs index c5ff652207..53a3492e70 100644 --- a/src/storage/src/builder_ext.rs +++ b/src/storage/src/builder_ext.rs @@ -15,6 +15,8 @@ //! Extends [builder][crate::builder] with types that improve type safety and/or //! ergonomics. +use google_cloud_gax::options::internal::{RequestOptionsExt, UserProject}; + /// An extension trait for `RewriteObject` to provide a convenient way /// to poll a rewrite operation until it is complete. #[async_trait::async_trait] @@ -71,6 +73,45 @@ impl RewriteObjectExt for crate::builder::storage_control::RewriteObject { } } +/// Adds `.with_user_project(...)` to every [StorageControl] request +/// builder. +/// +/// Required for [Requester Pays] buckets. The value overrides any +/// `quota_project_id` configured on the credentials; the credential-level +/// header is suppressed for this RPC. +/// +/// # Example +/// ``` +/// # use google_cloud_storage::client::StorageControl; +/// # use google_cloud_storage::builder_ext::UserProjectExt; +/// # async fn sample(client: &StorageControl) -> anyhow::Result<()> { +/// let bucket = client +/// .get_bucket() +/// .set_name("projects/_/buckets/my-bucket") +/// .with_user_project("my-billing-project") +/// .send() +/// .await?; +/// # Ok(()) } +/// ``` +/// +/// [Requester Pays]: https://cloud.google.com/storage/docs/requester-pays +/// [StorageControl]: crate::client::StorageControl +pub trait UserProjectExt: Sized { + /// Sets the project that will be billed for this request. + fn with_user_project(self, project: impl Into) -> Self; +} + +impl UserProjectExt for T +where + T: google_cloud_gax::options::internal::RequestBuilder, +{ + fn with_user_project(mut self, project: impl Into) -> Self { + self.request_options() + .insert_extension_mut(UserProject::new(project)); + self + } +} + #[cfg(test)] mod tests { use super::*; @@ -78,6 +119,7 @@ mod tests { use crate::model::{Object, RewriteObjectRequest, RewriteResponse}; use google_cloud_gax::error::rpc::{Code, Status}; use google_cloud_gax::options::RequestOptions; + use google_cloud_gax::options::internal::RequestBuilder; use google_cloud_gax::response::Response; mockall::mock! { @@ -88,6 +130,27 @@ mod tests { } } + #[test] + fn with_user_project_sets_extensions() { + const PROJECT_NAME: &str = "project_lazy_dog"; + let client = StorageControl::from_stub(MockStorageControl::new()); + let mut builder = client.get_bucket(); + builder = builder.with_user_project(PROJECT_NAME); + + let opts = builder.request_options(); + assert_eq!( + opts.get_extension::(), + Some(&UserProject::new(PROJECT_NAME)) + ); + } + + #[test] + #[should_panic(expected = "invalid project id")] + fn with_user_project_panic() { + let client = StorageControl::from_stub(MockStorageControl::new()); + let _ = client.get_bucket().with_user_project("invalid\nproject"); + } + #[tokio::test] async fn test_rewrite_until_done() -> anyhow::Result<()> { let mut mock = MockStorageControl::new(); diff --git a/src/storage/src/storage/open_object.rs b/src/storage/src/storage/open_object.rs index 4352385974..c4f9b00913 100644 --- a/src/storage/src/storage/open_object.rs +++ b/src/storage/src/storage/open_object.rs @@ -416,6 +416,30 @@ impl OpenObject { self.options.user_agent = Some(user_agent.into()); self } + + /// Sets the project that will be billed for this request. + /// + /// Required for [Requester Pays] buckets. The value overrides any + /// `quota_project_id` configured on the credentials; the credential-level + /// header is suppressed for this RPC. + /// + /// # Example + /// ``` + /// # use google_cloud_storage::client::Storage; + /// # async fn sample(client: &Storage) -> anyhow::Result<()> { + /// let response = client + /// .open_object("projects/_/buckets/my-bucket", "my-object") + /// .with_user_project("my-billing-project") + /// .send() + /// .await?; + /// # Ok(()) } + /// ``` + /// + /// [Requester Pays]: https://cloud.google.com/storage/docs/requester-pays + pub fn with_user_project(mut self, project: impl Into) -> Self { + self.options.with_user_project(project); + self + } } #[cfg(test)] @@ -776,6 +800,47 @@ mod tests { Ok(()) } + #[tokio::test] + async fn user_project() -> anyhow::Result<()> { + const PROJECT_NAME: &str = "project_lazy_dog"; + let (tx, rx) = tokio::sync::mpsc::channel::>(1); + let initial = BidiReadObjectResponse { + metadata: Some(ProtoObject { + bucket: BUCKET_NAME.to_string(), + name: OBJECT_NAME.to_string(), + generation: 123456, + ..ProtoObject::default() + }), + ..BidiReadObjectResponse::default() + }; + tx.send(Ok(initial)).await?; + + let mut mock = MockStorage::new(); + mock.expect_bidi_read_object().return_once(|request| { + let user_project = request + .metadata() + .get("x-goog-user-project") + .and_then(|v| v.to_str().ok()) + .expect("x-goog-user-project should be set"); + assert_eq!(user_project, PROJECT_NAME); + Ok(TonicResponse::from(rx)) + }); + let (endpoint, _server) = start(BIND_ADDRESS, mock).await?; + + let client = Storage::builder() + .with_credentials(Anonymous::new().build()) + .with_endpoint(endpoint) + .build() + .await?; + + let _descriptor = client + .open_object(BUCKET_NAME, OBJECT_NAME) + .with_user_project(PROJECT_NAME) + .send() + .await?; + Ok(()) + } + #[derive(Debug)] struct StorageStub; impl crate::stub::Storage for StorageStub {} diff --git a/src/storage/src/storage/read_object.rs b/src/storage/src/storage/read_object.rs index b29d09ec4b..114be40974 100644 --- a/src/storage/src/storage/read_object.rs +++ b/src/storage/src/storage/read_object.rs @@ -462,6 +462,30 @@ where self } + /// Sets the project that will be billed for this request. + /// + /// Required for [Requester Pays] buckets. The value overrides any + /// `quota_project_id` configured on the credentials; the credential-level + /// header is suppressed for this RPC. + /// + /// # Example + /// ``` + /// # use google_cloud_storage::client::Storage; + /// # async fn sample(client: &Storage) -> anyhow::Result<()> { + /// let response = client + /// .read_object("projects/_/buckets/my-bucket", "my-object") + /// .with_user_project("my-billing-project") + /// .send() + /// .await?; + /// # Ok(()) } + /// ``` + /// + /// [Requester Pays]: https://cloud.google.com/storage/docs/requester-pays + pub fn with_user_project(mut self, project: impl Into) -> Self { + self.options.with_user_project(project); + self + } + /// Sends the request. pub async fn send(self) -> Result { self.stub.read_object(self.request, self.options).await @@ -666,9 +690,10 @@ mod tests { use base64::Engine; use futures::TryStreamExt; use google_cloud_auth::credentials::{ - anonymous::Builder as Anonymous, testing::error_credentials, + CacheableResource, Credentials, EntityTag, anonymous::Builder as Anonymous, + testing::error_credentials, }; - use httptest::{Expectation, Server, matchers::*, responders::status_code}; + use httptest::{Expectation, Server, all_of, cycle, matchers::*, responders::status_code}; use std::collections::HashMap; use std::error::Error; use std::sync::Arc; @@ -676,6 +701,21 @@ mod tests { type Result = anyhow::Result<()>; + mockall::mock! { + #[derive(Debug)] + Credentials {} + impl google_cloud_auth::credentials::CredentialsProvider for Credentials { + async fn headers( + &self, + extensions: http::Extensions, + ) -> std::result::Result< + google_cloud_auth::credentials::CacheableResource, + google_cloud_gax::error::CredentialsError, + >; + async fn universe_domain(&self) -> Option; + } + } + async fn http_request_builder( inner: Arc, builder: ReadObject, @@ -1136,6 +1176,134 @@ mod tests { Ok(()) } + #[tokio::test] + async fn read_object_with_user_project() -> Result { + const PROJECT_NAME: &str = "project_lazy_dog"; + let server = Server::run(); + server.expect( + Expectation::matching(all_of![ + request::method_path("GET", "/storage/v1/b/test-bucket/o/test-object"), + request::headers(contains(("x-goog-user-project", PROJECT_NAME))), + ]) + .respond_with( + status_code(200) + .body("hello world") + .append_header("x-goog-generation", 123456), + ), + ); + + let client = Storage::builder() + .with_endpoint(format!("http://{}", server.addr())) + .with_credentials(Anonymous::new().build()) + .build() + .await?; + let mut reader = client + .read_object("projects/_/buckets/test-bucket", "test-object") + .with_user_project(PROJECT_NAME) + .send() + .await?; + let mut got = Vec::new(); + while let Some(b) = reader.next().await.transpose()? { + got.extend_from_slice(&b); + } + assert_eq!(bytes::Bytes::from_owner(got), "hello world"); + + Ok(()) + } + + #[tokio::test] + async fn read_object_strips_credential_quota_project() -> Result { + const PROJECT_NAME: &str = "project_lazy_dog"; + const CRED_QUOTA_PROJECT: &str = "cred_quota_project"; + let server = Server::run(); + server.expect( + Expectation::matching(all_of![ + request::method_path("GET", "/storage/v1/b/test-bucket/o/test-object"), + request::headers(contains(("x-goog-user-project", PROJECT_NAME))), + request::headers(not(contains(("x-goog-user-project", CRED_QUOTA_PROJECT)))), + ]) + .times(1) + .respond_with( + status_code(200) + .body("hello world") + .append_header("x-goog-generation", 123456), + ), + ); + + let mut mock = MockCredentials::new(); + mock.expect_headers().returning(|_exts: http::Extensions| { + let mut map = http::HeaderMap::new(); + map.insert( + http::header::AUTHORIZATION, + http::HeaderValue::from_static("Bearer test-token"), + ); + map.insert( + "x-goog-user-project", + http::HeaderValue::from_static(CRED_QUOTA_PROJECT), + ); + Ok(CacheableResource::New { + data: map, + entity_tag: EntityTag::default(), + }) + }); + mock.expect_universe_domain().returning(|| None); + + let client = Storage::builder() + .with_endpoint(format!("http://{}", server.addr())) + .with_credentials(Credentials::from(mock)) + .build() + .await?; + let mut reader = client + .read_object("projects/_/buckets/test-bucket", "test-object") + .with_user_project(PROJECT_NAME) + .send() + .await?; + let mut got = Vec::new(); + while let Some(b) = reader.next().await.transpose()? { + got.extend_from_slice(&b); + } + assert_eq!(bytes::Bytes::from_owner(got), "hello world"); + + Ok(()) + } + + #[tokio::test] + async fn read_object_retry_preserves_user_project() -> Result { + const PROJECT_NAME: &str = "project_lazy_dog"; + let server = Server::run(); + server.expect( + Expectation::matching(all_of![ + request::method_path("GET", "/storage/v1/b/test-bucket/o/test-object"), + request::headers(contains(("x-goog-user-project", PROJECT_NAME))), + ]) + .times(2) + .respond_with(cycle![ + status_code(503), + status_code(200) + .body("hello") + .append_header("x-goog-generation", 1) + ]), + ); + + let client = Storage::builder() + .with_endpoint(format!("http://{}", server.addr())) + .with_credentials(Anonymous::new().build()) + .build() + .await?; + let mut reader = client + .read_object("projects/_/buckets/test-bucket", "test-object") + .with_user_project(PROJECT_NAME) + .send() + .await?; + let mut got = Vec::new(); + while let Some(b) = reader.next().await.transpose()? { + got.extend_from_slice(&b); + } + assert_eq!(bytes::Bytes::from_owner(got), "hello"); + + Ok(()) + } + #[tokio::test] async fn read_object() -> Result { let inner = test_inner_client(test_builder()).await; diff --git a/src/storage/src/storage/request_options.rs b/src/storage/src/storage/request_options.rs index 8fe57b181f..4c5786f567 100644 --- a/src/storage/src/storage/request_options.rs +++ b/src/storage/src/storage/request_options.rs @@ -22,6 +22,7 @@ use crate::{ use gaxi::options::ClientConfig; use google_cloud_gax::{ backoff_policy::BackoffPolicy, + options::internal::{RequestOptionsExt, UserProject}, retry_policy::RetryPolicy, retry_throttler::{AdaptiveThrottler, SharedRetryThrottler}, }; @@ -46,6 +47,7 @@ pub struct RequestOptions { pub(crate) common_options: CommonOptions, pub(crate) bidi_attempt_timeout: Duration, pub(crate) user_agent: Option, + pub(crate) user_project: Option, } impl RequestOptions { @@ -122,6 +124,11 @@ impl RequestOptions { self.user_agent = Some(v.into()); } + /// Sets the project that will be billed for this request. + pub fn with_user_project(&mut self, v: impl Into) { + self.user_project = Some(UserProject::new(v)); + } + fn new_with_policies( retry_policy: Arc, backoff_policy: Arc, @@ -141,6 +148,7 @@ impl RequestOptions { automatic_decompression: false, bidi_attempt_timeout: DEFAULT_BIDI_ATTEMPT_TIMEOUT, user_agent: None, + user_project: None, } } @@ -155,6 +163,9 @@ impl RequestOptions { if let Some(s) = &self.user_agent { options.set_user_agent(s); } + if let Some(up) = &self.user_project { + options.insert_extension_mut(up.clone()); + } options } } @@ -199,4 +210,16 @@ mod tests { let got = options.gax(); assert_eq!(got.user_agent().as_deref(), Some(user_agent)); } + + #[test] + fn gax_user_project() { + const PROJECT_NAME: &str = "project_lazy_dog"; + let mut options = RequestOptions::new(); + options.with_user_project(PROJECT_NAME); + let got = options.gax(); + assert_eq!( + got.get_extension::(), + Some(&UserProject::new(PROJECT_NAME)) + ); + } } diff --git a/src/storage/src/storage/write_object.rs b/src/storage/src/storage/write_object.rs index 8cc0bd7b7f..5bd4cc40e1 100644 --- a/src/storage/src/storage/write_object.rs +++ b/src/storage/src/storage/write_object.rs @@ -824,6 +824,30 @@ where self } + /// Sets the project that will be billed for this request. + /// + /// Required for [Requester Pays] buckets. The value overrides any + /// `quota_project_id` configured on the credentials; the credential-level + /// header is suppressed for this RPC. + /// + /// # Example + /// ``` + /// # use google_cloud_storage::client::Storage; + /// # async fn sample(client: &Storage) -> anyhow::Result<()> { + /// let response = client + /// .write_object("projects/_/buckets/my-bucket", "my-object", "hello") + /// .with_user_project("my-billing-project") + /// .send_buffered() + /// .await?; + /// # Ok(()) } + /// ``` + /// + /// [Requester Pays]: https://cloud.google.com/storage/docs/requester-pays + pub fn with_user_project(mut self, project: impl Into) -> Self { + self.options.with_user_project(project); + self + } + fn mut_resource(&mut self) -> &mut crate::model::Object { self.request .spec @@ -1649,6 +1673,55 @@ mod tests { Ok(()) } + #[tokio::test] + async fn write_object_with_user_project() -> Result { + const PROJECT_NAME: &str = "project_lazy_dog"; + let server = Server::run(); + let session = server.url("/upload/session/test-only-001"); + let path = session.path().to_string(); + + server.expect( + Expectation::matching(all_of![ + request::method_path("POST", "/upload/storage/v1/b/test-bucket/o"), + request::headers(contains(("x-goog-user-project", PROJECT_NAME))), + request::query(url_decoded(contains(("uploadType", "resumable")))), + ]) + .times(1) + .respond_with(status_code(200).append_header("location", session.to_string())), + ); + + server.expect( + Expectation::matching(all_of![ + request::method_path("PUT", path), + request::headers(contains(("x-goog-user-project", PROJECT_NAME))), + ]) + .times(1) + .respond_with( + status_code(200) + .append_header(http::header::CONTENT_TYPE, "application/json") + .body(serde_json::to_string(&crate::model::Object::new()).unwrap()), + ), + ); + + let client = Storage::builder() + .with_endpoint(format!("http://{}", server.addr())) + .with_credentials(Anonymous::new().build()) + .build() + .await?; + let _ = client + .write_object( + "projects/_/buckets/test-bucket", + "test-object", + "hello world", + ) + .with_user_project(PROJECT_NAME) + .with_resumable_upload_threshold(0_usize) + .send_unbuffered() + .await?; + + Ok(()) + } + #[tokio::test] async fn debug() -> Result { let client = test_builder().build().await?; diff --git a/src/storage/tests/user_project_control.rs b/src/storage/tests/user_project_control.rs new file mode 100644 index 0000000000..66f56048a1 --- /dev/null +++ b/src/storage/tests/user_project_control.rs @@ -0,0 +1,56 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#[cfg(test)] +mod tests { + use gaxi::grpc::tonic::Response; + use gcs::builder_ext::UserProjectExt; + use gcs::client::StorageControl; + use google_cloud_auth::credentials::anonymous::Builder as Anonymous; + use google_cloud_storage as gcs; + use storage_grpc_mock::{MockStorage, google, start}; + + const PROJECT_NAME: &str = "project_lazy_dog"; + + #[tokio::test] + async fn get_bucket_with_user_project() -> anyhow::Result<()> { + let mut mock = MockStorage::new(); + mock.expect_get_bucket() + .withf(|req| { + req.metadata() + .get("x-goog-user-project") + .and_then(|v| v.to_str().ok()) + == Some(PROJECT_NAME) + }) + .times(1) + .returning(|_| Ok(Response::new(google::storage::v2::Bucket::default()))); + + let (endpoint, _server) = start("0.0.0.0:0", mock).await?; + + let client = StorageControl::builder() + .with_endpoint(endpoint) + .with_credentials(Anonymous::new().build()) + .build() + .await?; + + let _ = client + .get_bucket() + .set_name("projects/_/buckets/my-bucket") + .with_user_project(PROJECT_NAME) + .send() + .await?; + + Ok(()) + } +} From 3a8efbc5c052dbf415671a5bf10334255c91eb9d Mon Sep 17 00:00:00 2001 From: Joshua Tan Date: Fri, 24 Apr 2026 06:05:21 +0000 Subject: [PATCH 2/2] focus on gax changes only. address review feedback --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/gax-internal/Cargo.toml | 2 +- src/gax-internal/src/grpc.rs | 32 ++-- src/gax-internal/src/http.rs | 36 ++-- src/gax-internal/src/http/reqwest.rs | 1 - src/gax-internal/tests/grpc_user_project.rs | 23 +-- src/gax-internal/tests/http_user_project.rs | 23 +-- src/gax/src/options.rs | 107 ++++++------ src/storage/src/builder_ext.rs | 63 ------- src/storage/src/storage/open_object.rs | 65 -------- src/storage/src/storage/read_object.rs | 172 +------------------- src/storage/src/storage/request_options.rs | 23 --- src/storage/src/storage/write_object.rs | 73 --------- src/storage/tests/user_project_control.rs | 56 ------- 15 files changed, 116 insertions(+), 564 deletions(-) delete mode 100644 src/storage/tests/user_project_control.rs diff --git a/Cargo.lock b/Cargo.lock index 67168b7922..8988583458 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2859,7 +2859,7 @@ dependencies = [ [[package]] name = "google-cloud-gax-internal" -version = "0.7.12" +version = "0.7.13" dependencies = [ "anyhow", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 561ee12e05..f24432896c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -470,7 +470,7 @@ tokio-stream = { default-features = false, version = "0.1.16" } # Local packages used as dependencies. google-cloud-auth = { default-features = false, version = "1.9.0", path = "src/auth" } google-cloud-gax = { default-features = false, version = "1.10.0", path = "src/gax" } -gaxi = { default-features = false, version = "0.7.12", path = "src/gax-internal", package = "google-cloud-gax-internal" } +gaxi = { default-features = false, version = "0.7.13", path = "src/gax-internal", package = "google-cloud-gax-internal" } wkt = { default-features = false, version = "1.3.0", path = "src/wkt", package = "google-cloud-wkt" } google-cloud-wkt = { default-features = false, version = "1.3.0", path = "src/wkt", package = "google-cloud-wkt" } google-cloud-api = { default-features = false, version = "1.5.0", path = "src/generated/api/types" } diff --git a/src/gax-internal/Cargo.toml b/src/gax-internal/Cargo.toml index cba373343a..a6ea2f362a 100644 --- a/src/gax-internal/Cargo.toml +++ b/src/gax-internal/Cargo.toml @@ -14,7 +14,7 @@ [package] name = "google-cloud-gax-internal" -version = "0.7.12" +version = "0.7.13" description = "Google Cloud Client Libraries for Rust - Implementation Details" build = "build.rs" # Inherit other attributes from the workspace. diff --git a/src/gax-internal/src/grpc.rs b/src/gax-internal/src/grpc.rs index 3e5ada3a30..7366fb1e9e 100644 --- a/src/gax-internal/src/grpc.rs +++ b/src/gax-internal/src/grpc.rs @@ -34,7 +34,6 @@ use google_cloud_gax::client_builder::Result as ClientBuilderResult; use google_cloud_gax::error::Error; use google_cloud_gax::exponential_backoff::ExponentialBackoff; use google_cloud_gax::options::RequestOptions; -use google_cloud_gax::options::internal::{RequestOptionsExt, UserProject}; use google_cloud_gax::polling_backoff_policy::PollingBackoffPolicy; use google_cloud_gax::polling_error_policy::{ Aip194Strict as PollingAip194Strict, PollingErrorPolicy, @@ -209,7 +208,7 @@ impl Client { { use ::tonic::IntoStreamingRequest; let headers = Self::make_headers(api_client_header, request_params, &options).await?; - let headers = self.add_auth_headers(headers, &options).await?; + let headers = self.add_auth_headers(headers).await?; let metadata = tonic::MetadataMap::from_headers(headers); let request = ::tonic::Request::from_parts(metadata, extensions, request); let codec = tonic_prost::ProstCodec::::default(); @@ -274,7 +273,7 @@ impl Client { { use ::tonic::IntoRequest; let headers = Self::make_headers(api_client_header, request_params, &options).await?; - let headers = self.add_auth_headers(headers, &options).await?; + let headers = self.add_auth_headers(headers).await?; let metadata = tonic::MetadataMap::from_headers(headers); let mut request = ::tonic::Request::from_parts(metadata, extensions, request); if let Some(attempt_timeout) = options.attempt_timeout() { @@ -402,7 +401,7 @@ impl Client { }; #[allow(unused_mut)] - let mut headers = self.add_auth_headers(headers, options).await?; + let mut headers = self.add_auth_headers(headers).await?; crate::observability::propagation::inject_context(&span, &mut headers); @@ -531,11 +530,7 @@ impl Client { .map_err(BuilderError::cred) } - async fn add_auth_headers( - &self, - mut headers: http::HeaderMap, - options: &RequestOptions, - ) -> Result { + async fn add_auth_headers(&self, headers: http::HeaderMap) -> Result { let h = self .credentials .headers(http::Extensions::new()) @@ -546,14 +541,9 @@ impl Client { unreachable!("headers are not cached"); }; - if let Some(up) = options.get_extension::() { - data.insert( - http::header::HeaderName::from_static(X_GOOG_USER_PROJECT), - up.as_value().clone(), - ); - } - headers.extend(data); - Ok(headers) + // Note that client headers override credential headers (e.g. for `x-goog-user-project`). + data.extend(headers); + Ok(data) } async fn make_headers( @@ -563,11 +553,17 @@ impl Client { ) -> Result { let mut headers = HeaderMap::new(); if let Some(user_agent) = options.user_agent() { - headers.append( + headers.insert( http::header::USER_AGENT, http::header::HeaderValue::from_str(user_agent).map_err(Error::ser)?, ); } + if let Some(user_project) = options.user_project() { + headers.insert( + http::header::HeaderName::from_static(X_GOOG_USER_PROJECT), + http::header::HeaderValue::from_str(user_project).map_err(Error::ser)?, + ); + } headers.append( http::header::HeaderName::from_static("x-goog-api-client"), http::header::HeaderValue::from_static(api_client_header), diff --git a/src/gax-internal/src/http.rs b/src/gax-internal/src/http.rs index ed14771343..3aac19444f 100644 --- a/src/gax-internal/src/http.rs +++ b/src/gax-internal/src/http.rs @@ -38,7 +38,6 @@ use google_cloud_gax::client_builder::Result as ClientBuilderResult; use google_cloud_gax::error::{Error, rpc::Status}; use google_cloud_gax::exponential_backoff::ExponentialBackoff; use google_cloud_gax::options::RequestOptions; -use google_cloud_gax::options::internal::{RequestOptionsExt, UserProject}; use google_cloud_gax::polling_backoff_policy::PollingBackoffPolicy; use google_cloud_gax::polling_error_policy::{ Aip194Strict as PollingAip194Strict, PollingErrorPolicy, @@ -389,29 +388,32 @@ impl ReqwestClient { options: &RequestOptions, remaining_time: Option, ) -> Result { - builder = if let Some(user_agent) = options.user_agent() { - builder.header( - reqwest::USER_AGENT, - reqwest::HeaderValue::from_str(user_agent).map_err(Error::ser)?, - ) - } else { - builder - }; - builder = effective_timeout(options, remaining_time) .into_iter() .fold(builder, |b, t| b.timeout(t)); - builder = match self.cred.headers(Extensions::new()).await { + let mut headers = match self.cred.headers(Extensions::new()).await { Err(e) => return Err(Error::authentication(e)), - Ok(CacheableResource::New { mut data, .. }) => { - if let Some(up) = options.get_extension::() { - data.insert(X_GOOG_USER_PROJECT, up.as_value().clone()); - } - builder.headers(data) - } + Ok(CacheableResource::New { data, .. }) => data, Ok(CacheableResource::NotModified) => unreachable!("headers are not cached"), }; + + if let Some(user_agent) = options.user_agent() { + headers.insert( + http::header::USER_AGENT, + http::header::HeaderValue::from_str(user_agent).map_err(Error::ser)?, + ); + } + + if let Some(user_project) = options.user_project() { + headers.insert( + http::header::HeaderName::from_static(X_GOOG_USER_PROJECT), + http::header::HeaderValue::from_str(user_project).map_err(Error::ser)?, + ); + } + + builder = builder.headers(headers); + builder.build().map_err(map_send_error) } diff --git a/src/gax-internal/src/http/reqwest.rs b/src/gax-internal/src/http/reqwest.rs index 1c280fff13..67cb0d2a73 100644 --- a/src/gax-internal/src/http/reqwest.rs +++ b/src/gax-internal/src/http/reqwest.rs @@ -22,7 +22,6 @@ pub use reqwest::Request; pub use reqwest::RequestBuilder; pub use reqwest::Response; pub use reqwest::StatusCode; -pub(crate) use reqwest::header::USER_AGENT; pub use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; #[cfg(feature = "_internal-http-multipart")] pub use reqwest::multipart; diff --git a/src/gax-internal/tests/grpc_user_project.rs b/src/gax-internal/tests/grpc_user_project.rs index bddf07fe1d..89b854e6ef 100644 --- a/src/gax-internal/tests/grpc_user_project.rs +++ b/src/gax-internal/tests/grpc_user_project.rs @@ -20,13 +20,13 @@ mod tests { use google_cloud_auth::credentials::{CacheableResource, Credentials, EntityTag}; use google_cloud_gax::Result; use google_cloud_gax::options::RequestOptions; - use google_cloud_gax::options::internal::{RequestOptionsExt, UserProject}; use google_cloud_gax_internal::grpc; use grpc_server::{builder, google, start_echo_server}; use http::{HeaderMap, HeaderValue}; - const PROJECT_NAME: &str = "project_lazy_dog"; + const X_GOOG_USER_PROJECT: &str = "x-goog-user-project"; const CRED_QUOTA_PROJECT: &str = "cred_quota_project"; + const USER_PROJECT_NAME: &str = "project_lazy_dog"; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn user_project_emits_header() -> anyhow::Result<()> { @@ -36,14 +36,15 @@ mod tests { .build() .await?; - let options = RequestOptions::default().insert_extension(UserProject::new(PROJECT_NAME)); + let mut options = RequestOptions::default(); + options.set_user_project(USER_PROJECT_NAME); let response = send_request(client, options).await?; assert_eq!( response .metadata - .get("x-goog-user-project") + .get(X_GOOG_USER_PROJECT) .map(String::as_str), - Some(PROJECT_NAME) + Some(USER_PROJECT_NAME) ); Ok(()) } @@ -58,7 +59,7 @@ mod tests { let response = send_request(client, RequestOptions::default()).await?; assert!( - !response.metadata.contains_key("x-goog-user-project"), + !response.metadata.contains_key(X_GOOG_USER_PROJECT), "{:?}", response.metadata ); @@ -77,7 +78,7 @@ mod tests { HeaderValue::from_static("Bearer test-token"), ); map.insert( - "x-goog-user-project", + X_GOOG_USER_PROJECT, HeaderValue::from_static(CRED_QUOTA_PROJECT), ); Ok(CacheableResource::New { @@ -85,21 +86,23 @@ mod tests { entity_tag: EntityTag::default(), }) }); + mock.expect_universe_domain().returning(|| None); let client = builder(endpoint) .with_credentials(Credentials::from(mock)) .build() .await?; - let options = RequestOptions::default().insert_extension(UserProject::new(PROJECT_NAME)); + let mut options = RequestOptions::default(); + options.set_user_project(USER_PROJECT_NAME); let response = send_request(client, options).await?; assert_eq!( response .metadata - .get("x-goog-user-project") + .get(X_GOOG_USER_PROJECT) .map(String::as_str), - Some(PROJECT_NAME) + Some(USER_PROJECT_NAME) ); assert!( !response.metadata.values().any(|v| v == CRED_QUOTA_PROJECT), diff --git a/src/gax-internal/tests/http_user_project.rs b/src/gax-internal/tests/http_user_project.rs index cfdae60cfa..c4ea0b56b8 100644 --- a/src/gax-internal/tests/http_user_project.rs +++ b/src/gax-internal/tests/http_user_project.rs @@ -19,12 +19,12 @@ mod tests { use super::mock_credentials::{MockCredentials, mock_credentials}; use google_cloud_auth::credentials::{CacheableResource, Credentials, EntityTag}; use google_cloud_gax::options::RequestOptions; - use google_cloud_gax::options::internal::{RequestOptionsExt, UserProject}; use http::{HeaderMap, HeaderValue}; use serde_json::json; - const PROJECT_NAME: &str = "project_lazy_dog"; + const X_GOOG_USER_PROJECT: &str = "x-goog-user-project"; const CRED_QUOTA_PROJECT: &str = "cred_quota_project"; + const USER_PROJECT_NAME: &str = "project_lazy_dog"; #[tokio::test] async fn user_project_emits_header() -> anyhow::Result<()> { @@ -35,14 +35,15 @@ mod tests { .await?; let builder = client.builder(reqwest::Method::GET, "/echo".into()); - let options = RequestOptions::default().insert_extension(UserProject::new(PROJECT_NAME)); + let mut options = RequestOptions::default(); + options.set_user_project(USER_PROJECT_NAME); let response: serde_json::Value = client .execute(builder, Some(json!({})), options) .await? .into_body(); assert_eq!( - get_header_value(&response, "x-goog-user-project").as_deref(), - Some(PROJECT_NAME), + get_header_value(&response, X_GOOG_USER_PROJECT).as_deref(), + Some(USER_PROJECT_NAME), "{response:?}" ); Ok(()) @@ -62,7 +63,7 @@ mod tests { .await? .into_body(); assert!( - get_header_value(&response, "x-goog-user-project").is_none(), + get_header_value(&response, X_GOOG_USER_PROJECT).is_none(), "{response:?}" ); Ok(()) @@ -80,7 +81,7 @@ mod tests { HeaderValue::from_static("Bearer test-token"), ); map.insert( - "x-goog-user-project", + X_GOOG_USER_PROJECT, HeaderValue::from_static(CRED_QUOTA_PROJECT), ); Ok(CacheableResource::New { @@ -88,6 +89,7 @@ mod tests { entity_tag: EntityTag::default(), }) }); + mock.expect_universe_domain().returning(|| None); let client = echo_server::builder(endpoint) .with_credentials(Credentials::from(mock)) @@ -95,15 +97,16 @@ mod tests { .await?; let builder = client.builder(reqwest::Method::GET, "/echo".into()); - let options = RequestOptions::default().insert_extension(UserProject::new(PROJECT_NAME)); + let mut options = RequestOptions::default(); + options.set_user_project(USER_PROJECT_NAME); let response: serde_json::Value = client .execute(builder, Some(json!({})), options) .await? .into_body(); assert_eq!( - get_header_value(&response, "x-goog-user-project").as_deref(), - Some(PROJECT_NAME), + get_header_value(&response, X_GOOG_USER_PROJECT).as_deref(), + Some(USER_PROJECT_NAME), "{response:?}" ); let headers = response.get("headers").and_then(|h| h.as_object()); diff --git a/src/gax/src/options.rs b/src/gax/src/options.rs index 4e119b2b3a..ac94cf759a 100644 --- a/src/gax/src/options.rs +++ b/src/gax/src/options.rs @@ -43,6 +43,7 @@ use std::sync::Arc; pub struct RequestOptions { idempotent: Option, user_agent: Option, + user_project: Option, attempt_timeout: Option, retry_policy: Option>, backoff_policy: Option>, @@ -92,6 +93,22 @@ impl RequestOptions { &self.user_agent } + /// Sets the user project for the request. + /// + /// When present, `gax-internal`'s gRPC and HTTP transports emit an + /// `x-goog-user-project` header carrying this value and drop any + /// `x-goog-user-project` header the credentials provider would have + /// emitted from its configured `quota_project_id`, so the wire + /// carries exactly one `x-goog-user-project`. + pub fn set_user_project>(&mut self, v: T) { + self.user_project = Some(v.into()); + } + + /// Gets the current user project. + pub fn user_project(&self) -> &Option { + &self.user_project + } + /// Sets the per-attempt timeout. /// /// When using a retry loop, this affects the timeout for each attempt. The @@ -169,6 +186,15 @@ pub trait RequestOptionsBuilder: internal::RequestBuilder { /// Set the user agent header. fn with_user_agent>(self, v: V) -> Self; + /// Sets the user project for the request. + /// + /// When present, `gax-internal`'s gRPC and HTTP transports emit an + /// `x-goog-user-project` header carrying this value and drop any + /// `x-goog-user-project` header the credentials provider would have + /// emitted from its configured `quota_project_id`, so the wire + /// carries exactly one `x-goog-user-project`. + fn with_user_project>(self, v: V) -> Self; + /// Sets the per-attempt timeout. /// /// When using a retry loop, this affects the timeout for each attempt. The @@ -237,11 +263,6 @@ pub mod internal { fn insert_extension(self, value: T) -> Self where T: Clone + Send + Sync + 'static; - - /// Sets an extension value in-place. - fn insert_extension_mut(&mut self, value: T) - where - T: Clone + Send + Sync + 'static; } impl sealed::OptionsExt for RequestOptions {} @@ -260,13 +281,6 @@ pub mod internal { let _ = self.extensions.insert(value); self } - - fn insert_extension_mut(&mut self, value: T) - where - T: Clone + Send + Sync + 'static, - { - self.extensions.insert(value); - } } #[derive(Debug, Clone, Default, PartialEq)] @@ -275,31 +289,6 @@ pub mod internal { #[derive(Debug, Clone, Default, PartialEq)] pub struct ResourceName(pub String); - /// Per-request billing project. When present, `gax-internal`'s gRPC and - /// HTTP transports emit an `x-goog-user-project` header carrying this - /// value and drop any `x-goog-user-project` header the credentials - /// provider would have emitted from its configured `quota_project_id`, - /// so the wire carries exactly one `x-goog-user-project`. - #[derive(Debug, Clone, PartialEq)] - pub struct UserProject(http::HeaderValue); - - impl UserProject { - /// Creates a new `UserProject` extension. - /// - /// # Panics - /// - /// Panics if the project ID contains non-visible ASCII characters. - pub fn new(project: impl Into) -> Self { - let val = http::HeaderValue::from_str(&project.into()).expect("invalid project id"); - Self(val) - } - - /// Returns the underlying header value. - pub fn as_value(&self) -> &http::HeaderValue { - &self.0 - } - } - // Cannot remove this function, as that would break any client libraries // that are released and use this function. #[deprecated] @@ -333,6 +322,11 @@ where self } + fn with_user_project>(mut self, v: V) -> Self { + self.request_options().set_user_project(v); + self + } + fn with_attempt_timeout>(mut self, v: V) -> Self { self.request_options().set_attempt_timeout(v); self @@ -394,6 +388,9 @@ mod tests { #[test] fn request_options() { + const USER_AGENT: &str = "test-only"; + const USER_PROJECT: &str = "test-project"; + let mut opts = RequestOptions::default(); assert_eq!(opts.idempotent, None); @@ -402,13 +399,16 @@ mod tests { opts.set_idempotency(false); assert_eq!(opts.idempotent(), Some(false)); - opts.set_user_agent("test-only"); - assert_eq!(opts.user_agent().as_deref(), Some("test-only")); + opts.set_user_agent(USER_AGENT); + assert_eq!(opts.user_agent().as_deref(), Some(USER_AGENT)); assert_eq!(opts.attempt_timeout(), &None); + opts.set_user_project(USER_PROJECT); + assert_eq!(opts.user_project().as_deref(), Some(USER_PROJECT)); + let d = Duration::from_secs(123); opts.set_attempt_timeout(d); - assert_eq!(opts.user_agent().as_deref(), Some("test-only")); + assert_eq!(opts.user_agent().as_deref(), Some(USER_AGENT)); assert_eq!(opts.attempt_timeout(), &Some(d)); opts.set_retry_policy(LimitedAttemptCount::new(3)); @@ -462,8 +462,12 @@ mod tests { #[test] fn request_options_builder() -> anyhow::Result<()> { + const USER_AGENT: &str = "test-only"; + const USER_PROJECT: &str = "test-project"; + let mut builder = TestBuilder::default(); assert_eq!(builder.request_options().user_agent(), &None); + assert_eq!(builder.request_options().user_project(), &None); assert_eq!(builder.request_options().attempt_timeout(), &None); let mut builder = TestBuilder::default().with_idempotency(true); @@ -471,13 +475,19 @@ mod tests { let mut builder = TestBuilder::default().with_idempotency(false); assert_eq!(builder.request_options().idempotent(), Some(false)); - let mut builder = TestBuilder::default().with_user_agent("test-only"); + let mut builder = TestBuilder::default().with_user_agent(USER_AGENT); assert_eq!( builder.request_options().user_agent().as_deref(), - Some("test-only") + Some(USER_AGENT) ); assert_eq!(builder.request_options().attempt_timeout(), &None); + let mut builder = TestBuilder::default().with_user_project(USER_PROJECT); + assert_eq!( + builder.request_options().user_project().as_deref(), + Some(USER_PROJECT) + ); + let d = Duration::from_secs(123); let mut builder = TestBuilder::default().with_attempt_timeout(d); assert_eq!(builder.request_options().user_agent(), &None); @@ -518,17 +528,4 @@ mod tests { Ok(()) } - - #[test] - fn user_project() { - const PROJECT_NAME: &str = "project_lazy_dog"; - let up = UserProject::new(PROJECT_NAME); - assert_eq!(up.as_value(), PROJECT_NAME); - } - - #[test] - #[should_panic(expected = "invalid project id")] - fn user_project_invalid_project_id_panics() { - let _ = UserProject::new("invalid\nproject"); - } } diff --git a/src/storage/src/builder_ext.rs b/src/storage/src/builder_ext.rs index 53a3492e70..c5ff652207 100644 --- a/src/storage/src/builder_ext.rs +++ b/src/storage/src/builder_ext.rs @@ -15,8 +15,6 @@ //! Extends [builder][crate::builder] with types that improve type safety and/or //! ergonomics. -use google_cloud_gax::options::internal::{RequestOptionsExt, UserProject}; - /// An extension trait for `RewriteObject` to provide a convenient way /// to poll a rewrite operation until it is complete. #[async_trait::async_trait] @@ -73,45 +71,6 @@ impl RewriteObjectExt for crate::builder::storage_control::RewriteObject { } } -/// Adds `.with_user_project(...)` to every [StorageControl] request -/// builder. -/// -/// Required for [Requester Pays] buckets. The value overrides any -/// `quota_project_id` configured on the credentials; the credential-level -/// header is suppressed for this RPC. -/// -/// # Example -/// ``` -/// # use google_cloud_storage::client::StorageControl; -/// # use google_cloud_storage::builder_ext::UserProjectExt; -/// # async fn sample(client: &StorageControl) -> anyhow::Result<()> { -/// let bucket = client -/// .get_bucket() -/// .set_name("projects/_/buckets/my-bucket") -/// .with_user_project("my-billing-project") -/// .send() -/// .await?; -/// # Ok(()) } -/// ``` -/// -/// [Requester Pays]: https://cloud.google.com/storage/docs/requester-pays -/// [StorageControl]: crate::client::StorageControl -pub trait UserProjectExt: Sized { - /// Sets the project that will be billed for this request. - fn with_user_project(self, project: impl Into) -> Self; -} - -impl UserProjectExt for T -where - T: google_cloud_gax::options::internal::RequestBuilder, -{ - fn with_user_project(mut self, project: impl Into) -> Self { - self.request_options() - .insert_extension_mut(UserProject::new(project)); - self - } -} - #[cfg(test)] mod tests { use super::*; @@ -119,7 +78,6 @@ mod tests { use crate::model::{Object, RewriteObjectRequest, RewriteResponse}; use google_cloud_gax::error::rpc::{Code, Status}; use google_cloud_gax::options::RequestOptions; - use google_cloud_gax::options::internal::RequestBuilder; use google_cloud_gax::response::Response; mockall::mock! { @@ -130,27 +88,6 @@ mod tests { } } - #[test] - fn with_user_project_sets_extensions() { - const PROJECT_NAME: &str = "project_lazy_dog"; - let client = StorageControl::from_stub(MockStorageControl::new()); - let mut builder = client.get_bucket(); - builder = builder.with_user_project(PROJECT_NAME); - - let opts = builder.request_options(); - assert_eq!( - opts.get_extension::(), - Some(&UserProject::new(PROJECT_NAME)) - ); - } - - #[test] - #[should_panic(expected = "invalid project id")] - fn with_user_project_panic() { - let client = StorageControl::from_stub(MockStorageControl::new()); - let _ = client.get_bucket().with_user_project("invalid\nproject"); - } - #[tokio::test] async fn test_rewrite_until_done() -> anyhow::Result<()> { let mut mock = MockStorageControl::new(); diff --git a/src/storage/src/storage/open_object.rs b/src/storage/src/storage/open_object.rs index c4f9b00913..4352385974 100644 --- a/src/storage/src/storage/open_object.rs +++ b/src/storage/src/storage/open_object.rs @@ -416,30 +416,6 @@ impl OpenObject { self.options.user_agent = Some(user_agent.into()); self } - - /// Sets the project that will be billed for this request. - /// - /// Required for [Requester Pays] buckets. The value overrides any - /// `quota_project_id` configured on the credentials; the credential-level - /// header is suppressed for this RPC. - /// - /// # Example - /// ``` - /// # use google_cloud_storage::client::Storage; - /// # async fn sample(client: &Storage) -> anyhow::Result<()> { - /// let response = client - /// .open_object("projects/_/buckets/my-bucket", "my-object") - /// .with_user_project("my-billing-project") - /// .send() - /// .await?; - /// # Ok(()) } - /// ``` - /// - /// [Requester Pays]: https://cloud.google.com/storage/docs/requester-pays - pub fn with_user_project(mut self, project: impl Into) -> Self { - self.options.with_user_project(project); - self - } } #[cfg(test)] @@ -800,47 +776,6 @@ mod tests { Ok(()) } - #[tokio::test] - async fn user_project() -> anyhow::Result<()> { - const PROJECT_NAME: &str = "project_lazy_dog"; - let (tx, rx) = tokio::sync::mpsc::channel::>(1); - let initial = BidiReadObjectResponse { - metadata: Some(ProtoObject { - bucket: BUCKET_NAME.to_string(), - name: OBJECT_NAME.to_string(), - generation: 123456, - ..ProtoObject::default() - }), - ..BidiReadObjectResponse::default() - }; - tx.send(Ok(initial)).await?; - - let mut mock = MockStorage::new(); - mock.expect_bidi_read_object().return_once(|request| { - let user_project = request - .metadata() - .get("x-goog-user-project") - .and_then(|v| v.to_str().ok()) - .expect("x-goog-user-project should be set"); - assert_eq!(user_project, PROJECT_NAME); - Ok(TonicResponse::from(rx)) - }); - let (endpoint, _server) = start(BIND_ADDRESS, mock).await?; - - let client = Storage::builder() - .with_credentials(Anonymous::new().build()) - .with_endpoint(endpoint) - .build() - .await?; - - let _descriptor = client - .open_object(BUCKET_NAME, OBJECT_NAME) - .with_user_project(PROJECT_NAME) - .send() - .await?; - Ok(()) - } - #[derive(Debug)] struct StorageStub; impl crate::stub::Storage for StorageStub {} diff --git a/src/storage/src/storage/read_object.rs b/src/storage/src/storage/read_object.rs index 114be40974..b29d09ec4b 100644 --- a/src/storage/src/storage/read_object.rs +++ b/src/storage/src/storage/read_object.rs @@ -462,30 +462,6 @@ where self } - /// Sets the project that will be billed for this request. - /// - /// Required for [Requester Pays] buckets. The value overrides any - /// `quota_project_id` configured on the credentials; the credential-level - /// header is suppressed for this RPC. - /// - /// # Example - /// ``` - /// # use google_cloud_storage::client::Storage; - /// # async fn sample(client: &Storage) -> anyhow::Result<()> { - /// let response = client - /// .read_object("projects/_/buckets/my-bucket", "my-object") - /// .with_user_project("my-billing-project") - /// .send() - /// .await?; - /// # Ok(()) } - /// ``` - /// - /// [Requester Pays]: https://cloud.google.com/storage/docs/requester-pays - pub fn with_user_project(mut self, project: impl Into) -> Self { - self.options.with_user_project(project); - self - } - /// Sends the request. pub async fn send(self) -> Result { self.stub.read_object(self.request, self.options).await @@ -690,10 +666,9 @@ mod tests { use base64::Engine; use futures::TryStreamExt; use google_cloud_auth::credentials::{ - CacheableResource, Credentials, EntityTag, anonymous::Builder as Anonymous, - testing::error_credentials, + anonymous::Builder as Anonymous, testing::error_credentials, }; - use httptest::{Expectation, Server, all_of, cycle, matchers::*, responders::status_code}; + use httptest::{Expectation, Server, matchers::*, responders::status_code}; use std::collections::HashMap; use std::error::Error; use std::sync::Arc; @@ -701,21 +676,6 @@ mod tests { type Result = anyhow::Result<()>; - mockall::mock! { - #[derive(Debug)] - Credentials {} - impl google_cloud_auth::credentials::CredentialsProvider for Credentials { - async fn headers( - &self, - extensions: http::Extensions, - ) -> std::result::Result< - google_cloud_auth::credentials::CacheableResource, - google_cloud_gax::error::CredentialsError, - >; - async fn universe_domain(&self) -> Option; - } - } - async fn http_request_builder( inner: Arc, builder: ReadObject, @@ -1176,134 +1136,6 @@ mod tests { Ok(()) } - #[tokio::test] - async fn read_object_with_user_project() -> Result { - const PROJECT_NAME: &str = "project_lazy_dog"; - let server = Server::run(); - server.expect( - Expectation::matching(all_of![ - request::method_path("GET", "/storage/v1/b/test-bucket/o/test-object"), - request::headers(contains(("x-goog-user-project", PROJECT_NAME))), - ]) - .respond_with( - status_code(200) - .body("hello world") - .append_header("x-goog-generation", 123456), - ), - ); - - let client = Storage::builder() - .with_endpoint(format!("http://{}", server.addr())) - .with_credentials(Anonymous::new().build()) - .build() - .await?; - let mut reader = client - .read_object("projects/_/buckets/test-bucket", "test-object") - .with_user_project(PROJECT_NAME) - .send() - .await?; - let mut got = Vec::new(); - while let Some(b) = reader.next().await.transpose()? { - got.extend_from_slice(&b); - } - assert_eq!(bytes::Bytes::from_owner(got), "hello world"); - - Ok(()) - } - - #[tokio::test] - async fn read_object_strips_credential_quota_project() -> Result { - const PROJECT_NAME: &str = "project_lazy_dog"; - const CRED_QUOTA_PROJECT: &str = "cred_quota_project"; - let server = Server::run(); - server.expect( - Expectation::matching(all_of![ - request::method_path("GET", "/storage/v1/b/test-bucket/o/test-object"), - request::headers(contains(("x-goog-user-project", PROJECT_NAME))), - request::headers(not(contains(("x-goog-user-project", CRED_QUOTA_PROJECT)))), - ]) - .times(1) - .respond_with( - status_code(200) - .body("hello world") - .append_header("x-goog-generation", 123456), - ), - ); - - let mut mock = MockCredentials::new(); - mock.expect_headers().returning(|_exts: http::Extensions| { - let mut map = http::HeaderMap::new(); - map.insert( - http::header::AUTHORIZATION, - http::HeaderValue::from_static("Bearer test-token"), - ); - map.insert( - "x-goog-user-project", - http::HeaderValue::from_static(CRED_QUOTA_PROJECT), - ); - Ok(CacheableResource::New { - data: map, - entity_tag: EntityTag::default(), - }) - }); - mock.expect_universe_domain().returning(|| None); - - let client = Storage::builder() - .with_endpoint(format!("http://{}", server.addr())) - .with_credentials(Credentials::from(mock)) - .build() - .await?; - let mut reader = client - .read_object("projects/_/buckets/test-bucket", "test-object") - .with_user_project(PROJECT_NAME) - .send() - .await?; - let mut got = Vec::new(); - while let Some(b) = reader.next().await.transpose()? { - got.extend_from_slice(&b); - } - assert_eq!(bytes::Bytes::from_owner(got), "hello world"); - - Ok(()) - } - - #[tokio::test] - async fn read_object_retry_preserves_user_project() -> Result { - const PROJECT_NAME: &str = "project_lazy_dog"; - let server = Server::run(); - server.expect( - Expectation::matching(all_of![ - request::method_path("GET", "/storage/v1/b/test-bucket/o/test-object"), - request::headers(contains(("x-goog-user-project", PROJECT_NAME))), - ]) - .times(2) - .respond_with(cycle![ - status_code(503), - status_code(200) - .body("hello") - .append_header("x-goog-generation", 1) - ]), - ); - - let client = Storage::builder() - .with_endpoint(format!("http://{}", server.addr())) - .with_credentials(Anonymous::new().build()) - .build() - .await?; - let mut reader = client - .read_object("projects/_/buckets/test-bucket", "test-object") - .with_user_project(PROJECT_NAME) - .send() - .await?; - let mut got = Vec::new(); - while let Some(b) = reader.next().await.transpose()? { - got.extend_from_slice(&b); - } - assert_eq!(bytes::Bytes::from_owner(got), "hello"); - - Ok(()) - } - #[tokio::test] async fn read_object() -> Result { let inner = test_inner_client(test_builder()).await; diff --git a/src/storage/src/storage/request_options.rs b/src/storage/src/storage/request_options.rs index 4c5786f567..8fe57b181f 100644 --- a/src/storage/src/storage/request_options.rs +++ b/src/storage/src/storage/request_options.rs @@ -22,7 +22,6 @@ use crate::{ use gaxi::options::ClientConfig; use google_cloud_gax::{ backoff_policy::BackoffPolicy, - options::internal::{RequestOptionsExt, UserProject}, retry_policy::RetryPolicy, retry_throttler::{AdaptiveThrottler, SharedRetryThrottler}, }; @@ -47,7 +46,6 @@ pub struct RequestOptions { pub(crate) common_options: CommonOptions, pub(crate) bidi_attempt_timeout: Duration, pub(crate) user_agent: Option, - pub(crate) user_project: Option, } impl RequestOptions { @@ -124,11 +122,6 @@ impl RequestOptions { self.user_agent = Some(v.into()); } - /// Sets the project that will be billed for this request. - pub fn with_user_project(&mut self, v: impl Into) { - self.user_project = Some(UserProject::new(v)); - } - fn new_with_policies( retry_policy: Arc, backoff_policy: Arc, @@ -148,7 +141,6 @@ impl RequestOptions { automatic_decompression: false, bidi_attempt_timeout: DEFAULT_BIDI_ATTEMPT_TIMEOUT, user_agent: None, - user_project: None, } } @@ -163,9 +155,6 @@ impl RequestOptions { if let Some(s) = &self.user_agent { options.set_user_agent(s); } - if let Some(up) = &self.user_project { - options.insert_extension_mut(up.clone()); - } options } } @@ -210,16 +199,4 @@ mod tests { let got = options.gax(); assert_eq!(got.user_agent().as_deref(), Some(user_agent)); } - - #[test] - fn gax_user_project() { - const PROJECT_NAME: &str = "project_lazy_dog"; - let mut options = RequestOptions::new(); - options.with_user_project(PROJECT_NAME); - let got = options.gax(); - assert_eq!( - got.get_extension::(), - Some(&UserProject::new(PROJECT_NAME)) - ); - } } diff --git a/src/storage/src/storage/write_object.rs b/src/storage/src/storage/write_object.rs index 5bd4cc40e1..8cc0bd7b7f 100644 --- a/src/storage/src/storage/write_object.rs +++ b/src/storage/src/storage/write_object.rs @@ -824,30 +824,6 @@ where self } - /// Sets the project that will be billed for this request. - /// - /// Required for [Requester Pays] buckets. The value overrides any - /// `quota_project_id` configured on the credentials; the credential-level - /// header is suppressed for this RPC. - /// - /// # Example - /// ``` - /// # use google_cloud_storage::client::Storage; - /// # async fn sample(client: &Storage) -> anyhow::Result<()> { - /// let response = client - /// .write_object("projects/_/buckets/my-bucket", "my-object", "hello") - /// .with_user_project("my-billing-project") - /// .send_buffered() - /// .await?; - /// # Ok(()) } - /// ``` - /// - /// [Requester Pays]: https://cloud.google.com/storage/docs/requester-pays - pub fn with_user_project(mut self, project: impl Into) -> Self { - self.options.with_user_project(project); - self - } - fn mut_resource(&mut self) -> &mut crate::model::Object { self.request .spec @@ -1673,55 +1649,6 @@ mod tests { Ok(()) } - #[tokio::test] - async fn write_object_with_user_project() -> Result { - const PROJECT_NAME: &str = "project_lazy_dog"; - let server = Server::run(); - let session = server.url("/upload/session/test-only-001"); - let path = session.path().to_string(); - - server.expect( - Expectation::matching(all_of![ - request::method_path("POST", "/upload/storage/v1/b/test-bucket/o"), - request::headers(contains(("x-goog-user-project", PROJECT_NAME))), - request::query(url_decoded(contains(("uploadType", "resumable")))), - ]) - .times(1) - .respond_with(status_code(200).append_header("location", session.to_string())), - ); - - server.expect( - Expectation::matching(all_of![ - request::method_path("PUT", path), - request::headers(contains(("x-goog-user-project", PROJECT_NAME))), - ]) - .times(1) - .respond_with( - status_code(200) - .append_header(http::header::CONTENT_TYPE, "application/json") - .body(serde_json::to_string(&crate::model::Object::new()).unwrap()), - ), - ); - - let client = Storage::builder() - .with_endpoint(format!("http://{}", server.addr())) - .with_credentials(Anonymous::new().build()) - .build() - .await?; - let _ = client - .write_object( - "projects/_/buckets/test-bucket", - "test-object", - "hello world", - ) - .with_user_project(PROJECT_NAME) - .with_resumable_upload_threshold(0_usize) - .send_unbuffered() - .await?; - - Ok(()) - } - #[tokio::test] async fn debug() -> Result { let client = test_builder().build().await?; diff --git a/src/storage/tests/user_project_control.rs b/src/storage/tests/user_project_control.rs deleted file mode 100644 index 66f56048a1..0000000000 --- a/src/storage/tests/user_project_control.rs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -#[cfg(test)] -mod tests { - use gaxi::grpc::tonic::Response; - use gcs::builder_ext::UserProjectExt; - use gcs::client::StorageControl; - use google_cloud_auth::credentials::anonymous::Builder as Anonymous; - use google_cloud_storage as gcs; - use storage_grpc_mock::{MockStorage, google, start}; - - const PROJECT_NAME: &str = "project_lazy_dog"; - - #[tokio::test] - async fn get_bucket_with_user_project() -> anyhow::Result<()> { - let mut mock = MockStorage::new(); - mock.expect_get_bucket() - .withf(|req| { - req.metadata() - .get("x-goog-user-project") - .and_then(|v| v.to_str().ok()) - == Some(PROJECT_NAME) - }) - .times(1) - .returning(|_| Ok(Response::new(google::storage::v2::Bucket::default()))); - - let (endpoint, _server) = start("0.0.0.0:0", mock).await?; - - let client = StorageControl::builder() - .with_endpoint(endpoint) - .with_credentials(Anonymous::new().build()) - .build() - .await?; - - let _ = client - .get_bucket() - .set_name("projects/_/buckets/my-bucket") - .with_user_project(PROJECT_NAME) - .send() - .await?; - - Ok(()) - } -}