use google_cloud_gax::options::RequestOptions;
use google_cloud_gax::options::internal::{RequestOptionsExt, set_default_idempotency};
use http::{HeaderMap, HeaderValue};
pub(crate) const IDEMPOTENCY_TOKEN_HEADER: &str = "x-goog-gcs-idempotency-token";
fn read(options: RequestOptions) -> RequestOptions {
set_default_idempotency(options, true)
}
pub(crate) fn mutation(options: RequestOptions, idempotent: bool) -> RequestOptions {
let options = set_default_idempotency(options, idempotent);
if options.idempotent() != Some(true) {
return options;
}
add_token(options)
}
pub(crate) fn add_token(options: RequestOptions) -> RequestOptions {
if options
.get_extension::<HeaderMap>()
.is_some_and(|h| h.contains_key(IDEMPOTENCY_TOKEN_HEADER))
{
return options;
}
let mut headers = options
.get_extension::<HeaderMap>()
.cloned()
.unwrap_or_default();
let token = HeaderValue::try_from(uuid::Uuid::new_v4().to_string())
.expect("a hyphenated UUID is always a valid header value");
headers.insert(IDEMPOTENCY_TOKEN_HEADER, token);
options.insert_extension(headers)
}
impl crate::model::GetObjectRequest {
pub(crate) fn resolve_idempotency(&self, options: RequestOptions) -> RequestOptions {
read(options)
}
}
impl crate::model::ListObjectsRequest {
pub(crate) fn resolve_idempotency(&self, options: RequestOptions) -> RequestOptions {
read(options)
}
}
impl crate::model::GetBucketRequest {
pub(crate) fn resolve_idempotency(&self, options: RequestOptions) -> RequestOptions {
read(options)
}
}
impl crate::model::ListBucketsRequest {
pub(crate) fn resolve_idempotency(&self, options: RequestOptions) -> RequestOptions {
read(options)
}
}
impl crate::model::CreateBucketRequest {
pub(crate) fn resolve_idempotency(&self, options: RequestOptions) -> RequestOptions {
mutation(options, true)
}
}
impl crate::model::DeleteBucketRequest {
pub(crate) fn resolve_idempotency(&self, options: RequestOptions) -> RequestOptions {
mutation(options, true)
}
}
impl crate::model::LockBucketRetentionPolicyRequest {
pub(crate) fn resolve_idempotency(&self, options: RequestOptions) -> RequestOptions {
mutation(options, true)
}
}
impl crate::model::UpdateBucketRequest {
pub(crate) fn resolve_idempotency(&self, options: RequestOptions) -> RequestOptions {
mutation(options, self.if_metageneration_match.is_some())
}
}
impl crate::model::ComposeObjectRequest {
pub(crate) fn resolve_idempotency(&self, options: RequestOptions) -> RequestOptions {
mutation(options, self.if_generation_match.is_some())
}
}
impl crate::model::DeleteObjectRequest {
pub(crate) fn resolve_idempotency(&self, options: RequestOptions) -> RequestOptions {
mutation(
options,
self.generation > 0 || self.if_generation_match.is_some(),
)
}
}
impl crate::model::RestoreObjectRequest {
pub(crate) fn resolve_idempotency(&self, options: RequestOptions) -> RequestOptions {
mutation(options, self.if_generation_match.is_some())
}
}
impl crate::model::UpdateObjectRequest {
pub(crate) fn resolve_idempotency(&self, options: RequestOptions) -> RequestOptions {
mutation(options, self.if_metageneration_match.is_some())
}
}
impl crate::model::RewriteObjectRequest {
pub(crate) fn resolve_idempotency(&self, options: RequestOptions) -> RequestOptions {
mutation(options, self.if_generation_match.is_some())
}
}
impl crate::model::MoveObjectRequest {
pub(crate) fn resolve_idempotency(&self, options: RequestOptions) -> RequestOptions {
mutation(options, self.if_generation_match.is_some())
}
}
impl crate::model::WriteObjectSpec {
pub(crate) fn is_idempotent(&self) -> bool {
self.if_generation_match.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{
ComposeObjectRequest, CreateBucketRequest, DeleteBucketRequest, DeleteObjectRequest,
GetBucketRequest, GetObjectRequest, ListBucketsRequest, ListObjectsRequest,
LockBucketRetentionPolicyRequest, MoveObjectRequest, RestoreObjectRequest,
RewriteObjectRequest, UpdateBucketRequest, UpdateObjectRequest, WriteObjectSpec,
};
use test_case::test_case;
fn token(options: &RequestOptions) -> Option<&HeaderValue> {
options
.get_extension::<HeaderMap>()
.and_then(|h| h.get(IDEMPOTENCY_TOKEN_HEADER))
}
fn assert_mutation(got: RequestOptions, want: bool) {
assert_eq!(got.idempotent(), Some(want));
assert_eq!(token(&got).is_some(), want, "token iff idempotent");
}
#[test]
fn reads_are_idempotent_without_token() {
for got in [
GetObjectRequest::new().resolve_idempotency(RequestOptions::default()),
ListObjectsRequest::new().resolve_idempotency(RequestOptions::default()),
GetBucketRequest::new().resolve_idempotency(RequestOptions::default()),
ListBucketsRequest::new().resolve_idempotency(RequestOptions::default()),
] {
assert_eq!(got.idempotent(), Some(true));
assert!(token(&got).is_none(), "{got:?}");
}
}
#[test]
fn bucket_create_delete_lock_are_always_idempotent() {
for got in [
CreateBucketRequest::new().resolve_idempotency(RequestOptions::default()),
DeleteBucketRequest::new().resolve_idempotency(RequestOptions::default()),
LockBucketRetentionPolicyRequest::new().resolve_idempotency(RequestOptions::default()),
] {
assert_mutation(got, true);
}
}
#[test_case(UpdateBucketRequest::new(), false; "unconditioned")]
#[test_case(UpdateBucketRequest::new().set_if_metageneration_match(1), true; "if_metageneration_match")]
#[test_case(UpdateBucketRequest::new().set_if_metageneration_not_match(1), false; "if_metageneration_not_match")]
fn update_bucket(req: UpdateBucketRequest, want: bool) {
assert_mutation(req.resolve_idempotency(RequestOptions::default()), want);
}
#[test_case(UpdateObjectRequest::new(), false; "unconditioned")]
#[test_case(UpdateObjectRequest::new().set_if_metageneration_match(1), true; "if_metageneration_match")]
#[test_case(UpdateObjectRequest::new().set_if_metageneration_not_match(1), false; "if_metageneration_not_match")]
#[test_case(UpdateObjectRequest::new().set_if_generation_match(1), false; "if_generation_match alone")]
fn update_object(req: UpdateObjectRequest, want: bool) {
assert_mutation(req.resolve_idempotency(RequestOptions::default()), want);
}
#[test_case(ComposeObjectRequest::new(), false; "unconditioned")]
#[test_case(ComposeObjectRequest::new().set_if_generation_match(1), true; "if_generation_match")]
#[test_case(ComposeObjectRequest::new().set_if_metageneration_match(1), false; "if_metageneration_match alone")]
fn compose_object(req: ComposeObjectRequest, want: bool) {
assert_mutation(req.resolve_idempotency(RequestOptions::default()), want);
}
#[test_case(DeleteObjectRequest::new(), false; "unconditioned")]
#[test_case(DeleteObjectRequest::new().set_generation(1), true; "generation")]
#[test_case(DeleteObjectRequest::new().set_if_generation_match(1), true; "if_generation_match")]
#[test_case(DeleteObjectRequest::new().set_if_generation_not_match(1), false; "if_generation_not_match")]
#[test_case(DeleteObjectRequest::new().set_if_metageneration_match(1), false; "if_metageneration_match alone")]
fn delete_object(req: DeleteObjectRequest, want: bool) {
assert_mutation(req.resolve_idempotency(RequestOptions::default()), want);
}
#[test_case(RestoreObjectRequest::new().set_generation(1), false; "generation alone")]
#[test_case(RestoreObjectRequest::new().set_generation(1).set_if_generation_match(1), true; "if_generation_match")]
#[test_case(RestoreObjectRequest::new().set_generation(1).set_if_generation_not_match(1), false; "if_generation_not_match")]
fn restore_object(req: RestoreObjectRequest, want: bool) {
assert_mutation(req.resolve_idempotency(RequestOptions::default()), want);
}
#[test_case(RewriteObjectRequest::new(), false; "unconditioned")]
#[test_case(RewriteObjectRequest::new().set_if_generation_match(1), true; "if_generation_match")]
#[test_case(RewriteObjectRequest::new().set_if_generation_not_match(1), false; "if_generation_not_match")]
#[test_case(RewriteObjectRequest::new().set_if_source_generation_match(1), false; "if_source_generation_match alone")]
fn rewrite_object(req: RewriteObjectRequest, want: bool) {
assert_mutation(req.resolve_idempotency(RequestOptions::default()), want);
}
#[test_case(MoveObjectRequest::new(), false; "unconditioned")]
#[test_case(MoveObjectRequest::new().set_if_generation_match(1), true; "if_generation_match")]
#[test_case(MoveObjectRequest::new().set_if_generation_not_match(1), false; "if_generation_not_match")]
#[test_case(MoveObjectRequest::new().set_if_source_generation_match(1), false; "if_source_generation_match alone")]
fn move_object(req: MoveObjectRequest, want: bool) {
assert_mutation(req.resolve_idempotency(RequestOptions::default()), want);
}
#[test_case(WriteObjectSpec::new(), false; "unconditioned")]
#[test_case(WriteObjectSpec::new().set_if_generation_match(0), true; "if_generation_match")]
#[test_case(WriteObjectSpec::new().set_if_generation_not_match(0), false; "if_generation_not_match")]
#[test_case(WriteObjectSpec::new().set_if_metageneration_match(1), false; "if_metageneration_match alone")]
fn write_object_spec(spec: WriteObjectSpec, want: bool) {
assert_eq!(spec.is_idempotent(), want);
}
#[test_case(true, false; "with_idempotency(true) on unconditioned")]
#[test_case(false, true; "with_idempotency(false) on conditioned")]
fn explicit_override_wins(explicit: bool, rule: bool) {
let mut options = RequestOptions::default();
options.set_idempotency(explicit);
assert_mutation(mutation(options, rule), explicit);
}
#[test]
fn existing_token_is_kept() {
let mut headers = HeaderMap::new();
headers.insert(
IDEMPOTENCY_TOKEN_HEADER,
HeaderValue::from_static("caller-token"),
);
let options = RequestOptions::default().insert_extension(headers);
let got = mutation(options, true);
assert_eq!(
token(&got).map(|v| v.as_bytes()),
Some("caller-token".as_bytes())
);
}
#[test]
fn other_headers_are_kept() {
let mut headers = HeaderMap::new();
headers.insert("x-goog-custom", HeaderValue::from_static("keep-me"));
let options = RequestOptions::default().insert_extension(headers);
let got = mutation(options, true);
let headers = got.get_extension::<HeaderMap>().expect("headers are set");
assert_eq!(
headers.get("x-goog-custom").map(|v| v.as_bytes()),
Some("keep-me".as_bytes())
);
assert!(token(&got).is_some());
}
#[test]
fn generated_transport_uses_hook() {
let source = include_str!("generated/gapic/transport.rs");
assert!(source.contains("req.resolve_idempotency(options)"));
assert!(
!source.contains("set_default_idempotency("),
"regenerate with `idempotency_hook: resolve_idempotency` in librarian.yaml"
);
}
}