diff --git a/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1.IntegrationTests/MoveObjectTest.cs b/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1.IntegrationTests/MoveObjectTest.cs new file mode 100644 index 000000000000..d27271252b88 --- /dev/null +++ b/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1.IntegrationTests/MoveObjectTest.cs @@ -0,0 +1,99 @@ +// Copyright 2025 Google Inc. All Rights Reserved. +// +// 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 +// +// http://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. + +using Google.Cloud.ClientTesting; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Xunit; +using static Google.Cloud.Storage.V1.IntegrationTests.TestHelpers; + +namespace Google.Cloud.Storage.V1.IntegrationTests; + +[Collection(nameof(StorageFixture))] +public class MoveObjectTest +{ + private readonly StorageFixture _fixture; + private readonly string _bucket; + private readonly string _originName; + private readonly string _destinationName; + private readonly string _contentType; + private readonly MemoryStream _data; + + public MoveObjectTest(StorageFixture fixture) + { + _fixture = fixture; + _bucket = _fixture.HnsBucket; + _originName = IdGenerator.FromGuid(); + _destinationName = IdGenerator.FromGuid(); + _contentType = "application/octet-stream"; + _data = GenerateData(100); + } + + [Fact] + public async Task MoveObjectAsync() + { + await _fixture.Client.UploadObjectAsync(_bucket, _originName, _contentType, _data); + + await _fixture.Client.MoveObjectAsync(_bucket, _originName, _destinationName); + + var objects = _fixture.Client.ListObjects(_bucket); + Assert.DoesNotContain(objects, obj => obj.Name == _originName); + Assert.Contains(objects, obj => obj.Name == _destinationName); + + using var stream = new MemoryStream(); + await _fixture.Client.DownloadObjectAsync(_bucket, _destinationName, stream); + Assert.Equal(_data.ToArray(), stream.ToArray()); + } + + [Fact] + public async Task MoveObjectAsync_GenerationMismatch_Fails() + { + await _fixture.Client.UploadObjectAsync(_bucket, _originName, _contentType, _data); + + var exception = await Assert.ThrowsAsync(() => _fixture.Client.MoveObjectAsync( + _bucket, _originName, _destinationName, + new MoveObjectOptions { IfSourceGenerationMatch = 1 })); + Assert.Equal(HttpStatusCode.PreconditionFailed, exception.HttpStatusCode); + } + + [Fact] + public async Task MoveObjectAsync_MetaGenerationMismatch_Fails() + { + await _fixture.Client.UploadObjectAsync(_bucket, _originName, _contentType, _data); + + var exception = await Assert.ThrowsAsync(() => _fixture.Client.MoveObjectAsync( + _bucket, _originName, _destinationName, + new MoveObjectOptions { IfSourceMetagenerationMatch = 0 })); + Assert.Equal(HttpStatusCode.PreconditionFailed, exception.HttpStatusCode); + } + + [Fact] + public async Task MoveObjectAsync_PreconditionsMatch() + { + var origin = await _fixture.Client.UploadObjectAsync(_bucket, _originName, _contentType, _data); + + await _fixture.Client.MoveObjectAsync( + _bucket, _originName, _destinationName, + new MoveObjectOptions + { + IfSourceMetagenerationMatch = origin.Metageneration, + IfSourceGenerationMatch = origin.Generation + }); + var objects = _fixture.Client.ListObjects(_bucket); + + Assert.Contains(objects, obj => obj.Name == _destinationName); + } +} diff --git a/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1.IntegrationTests/StorageFixture.cs b/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1.IntegrationTests/StorageFixture.cs index 9973b04e598b..5b27f08810a8 100644 --- a/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1.IntegrationTests/StorageFixture.cs +++ b/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1.IntegrationTests/StorageFixture.cs @@ -61,6 +61,11 @@ public sealed class StorageFixture : CloudProjectFixtureBase, ICollectionFixture /// public string SoftDeleteBucket => BucketPrefix + "-soft-delete"; + /// + /// Name of a bucket with hierarchical namespace enabled + /// + public string HnsBucket => BucketPrefix + "-hns"; + /// /// A small amount of content. Do not mutate the array. /// @@ -167,6 +172,7 @@ public StorageFixture() CreateBucket(LabelsTestBucket, multiVersion: false); CreateBucket(InitiallyEmptyBucket, multiVersion: false); CreateBucket(SoftDeleteBucket, multiVersion: false, softDelete: true); + CreateBucket(HnsBucket, multiVersion: false, hnsEnabled: true); RequesterPaysClient = CreateRequesterPaysClient(); if (RequesterPaysClient != null) @@ -249,7 +255,7 @@ void CreateObject() } - internal Bucket CreateBucket(string name, bool multiVersion, bool softDelete = false, bool registerForDeletion = true) + internal Bucket CreateBucket(string name, bool multiVersion, bool softDelete = false, bool registerForDeletion = true, bool hnsEnabled = false) { var bucket = Client.CreateBucket(ProjectId, new Bucket @@ -258,6 +264,11 @@ internal Bucket CreateBucket(string name, bool multiVersion, bool softDelete = f Versioning = new Bucket.VersioningData { Enabled = multiVersion }, // The minimum allowed for soft delete is 7 days. SoftDeletePolicy = softDelete ? new Bucket.SoftDeletePolicyData { RetentionDurationSeconds = (int) TimeSpan.FromDays(7).TotalSeconds } : null, + IamConfiguration = hnsEnabled ? new Bucket.IamConfigurationData + { + UniformBucketLevelAccess = new Bucket.IamConfigurationData.UniformBucketLevelAccessData { Enabled = true } + } : null, + HierarchicalNamespace = hnsEnabled ? new Bucket.HierarchicalNamespaceData { Enabled = true } : null, }); SleepAfterBucketCreateDelete(); if (registerForDeletion) diff --git a/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1.Tests/MoveObjectOptionsTest.cs b/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1.Tests/MoveObjectOptionsTest.cs new file mode 100644 index 000000000000..acc05c624bb1 --- /dev/null +++ b/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1.Tests/MoveObjectOptionsTest.cs @@ -0,0 +1,111 @@ +// Copyright 2025 Google Inc. All Rights Reserved. +// +// 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 +// +// http://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. + +using System; +using Xunit; +using static Google.Apis.Storage.v1.ObjectsResource; + +namespace Google.Cloud.Storage.V1.Tests; + +public class MoveObjectOptionsTest +{ + [Fact] + public void ModifyRequest_DefaultOptions() + { + var request = new MoveRequest(null, "sourceBucket", "sourceObject", "destObject"); + var options = new MoveObjectOptions(); + options.ModifyRequest(request); + Assert.Null(request.IfGenerationMatch); + Assert.Null(request.IfGenerationNotMatch); + Assert.Null(request.IfMetagenerationMatch); + Assert.Null(request.IfMetagenerationNotMatch); + Assert.Null(request.IfSourceGenerationMatch); + Assert.Null(request.IfSourceGenerationNotMatch); + Assert.Null(request.IfSourceMetagenerationMatch); + Assert.Null(request.IfSourceMetagenerationNotMatch); + Assert.Null(request.UserProject); + } + + [Fact] + public void ModifyRequest_AllOptions_PositiveMatch() + { + var request = new MoveRequest(null, "sourceBucket", "sourceObject", "destObject"); + var options = new MoveObjectOptions + { + IfGenerationMatch = 1L, + IfMetagenerationMatch = 2L, + IfSourceGenerationMatch = 3L, + IfSourceMetagenerationMatch = 4L, + UserProject = "proj" + }; + options.ModifyRequest(request); + Assert.Equal(1L, request.IfGenerationMatch); + Assert.Null(request.IfGenerationNotMatch); + Assert.Equal(2L, request.IfMetagenerationMatch); + Assert.Null(request.IfMetagenerationNotMatch); + Assert.Equal(3L, request.IfSourceGenerationMatch); + Assert.Null(request.IfSourceGenerationNotMatch); + Assert.Equal(4L, request.IfSourceMetagenerationMatch); + Assert.Null(request.IfSourceMetagenerationNotMatch); + Assert.Equal("proj", request.UserProject); + } + + [Fact] + public void ModifyRequest_AllOptions_NegativeMatch() + { + var request = new MoveRequest(null, "sourceBucket", "sourceObject", "destObject"); + var options = new MoveObjectOptions + { + IfGenerationNotMatch = 1L, + IfMetagenerationNotMatch = 2L, + IfSourceGenerationNotMatch = 3L, + IfSourceMetagenerationNotMatch = 4L, + }; + options.ModifyRequest(request); + Assert.Null(request.IfGenerationMatch); + Assert.Equal(1L, request.IfGenerationNotMatch); + Assert.Null(request.IfMetagenerationMatch); + Assert.Equal(2L, request.IfMetagenerationNotMatch); + Assert.Null(request.IfSourceGenerationMatch); + Assert.Equal(3L, request.IfSourceGenerationNotMatch); + Assert.Null(request.IfSourceMetagenerationMatch); + Assert.Equal(4L, request.IfSourceMetagenerationNotMatch); + } + + [Fact] + public void ModifyRequest_MatchNotMatchConflicts() + { + var request = new MoveRequest(null, "sourceBucket", "sourceObject", "destObject"); + Assert.Throws(() => + { + var options = new MoveObjectOptions { IfGenerationMatch = 1L, IfGenerationNotMatch = 2L }; + options.ModifyRequest(request); + }); + Assert.Throws(() => + { + var options = new MoveObjectOptions { IfMetagenerationMatch = 1L, IfMetagenerationNotMatch = 2L }; + options.ModifyRequest(request); + }); + Assert.Throws(() => + { + var options = new MoveObjectOptions { IfSourceGenerationMatch = 1L, IfSourceGenerationNotMatch = 2L }; + options.ModifyRequest(request); + }); + Assert.Throws(() => + { + var options = new MoveObjectOptions { IfSourceMetagenerationMatch = 1L, IfSourceMetagenerationNotMatch = 2L }; + options.ModifyRequest(request); + }); + } +} diff --git a/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1.csproj b/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1.csproj index 01cd97a63efe..50a83676cf70 100644 --- a/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1.csproj +++ b/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1.csproj @@ -10,7 +10,7 @@ - + diff --git a/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1/MoveObjectOptions.cs b/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1/MoveObjectOptions.cs new file mode 100644 index 000000000000..ddb9928ffc6f --- /dev/null +++ b/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1/MoveObjectOptions.cs @@ -0,0 +1,142 @@ +// Copyright 2025 Google Inc. All Rights Reserved. +// +// 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 +// +// http://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. + +using System; +using static Google.Apis.Storage.v1.ObjectsResource; +using Object = Google.Apis.Storage.v1.Data.Object; + +namespace Google.Cloud.Storage.V1; + +/// +/// Options for MoveObject operations. +/// +public sealed class MoveObjectOptions +{ + /// + /// Precondition for moving: the object is only moved if the existing destination object's + /// generation matches the given value. + /// + public long? IfGenerationMatch { get; set; } + + /// + /// Precondition for moving: the object is only moved if the existing destination object's + /// generation does not match the given value. + /// + public long? IfGenerationNotMatch { get; set; } + + /// + /// Precondition for moving: the object is only moved if the existing destination object's + /// meta-generation matches the given value. + /// + public long? IfMetagenerationMatch { get; set; } + + /// + /// Precondition for moving: the object is only moved if the existing destination object's + /// meta-generation does not match the given value. + /// + public long? IfMetagenerationNotMatch { get; set; } + + /// + /// Precondition for moving: the object is only moved if the source object's + /// generation matches the given value. + /// + public long? IfSourceGenerationMatch { get; set; } + + /// + /// Precondition for moving: the object is only moved if the source object's + /// generation does not match the given value. + /// + public long? IfSourceGenerationNotMatch { get; set; } + + /// + /// Precondition for moving: the object is only moved if the source object's + /// meta-generation matches the given value. + /// + public long? IfSourceMetagenerationMatch { get; set; } + + /// + /// Precondition for moving: the object is only moved if the source object's + /// meta-generation does not match the given value. + /// + public long? IfSourceMetagenerationNotMatch { get; set; } + + /// + /// If set, this is the ID of the project which will be billed for the request. + /// The caller must have suitable permissions for the project being billed. + /// + public string UserProject { get; set; } + + /// + /// Options to pass custom retry configuration for each API request. + /// + public RetryOptions RetryOptions { get; set; } + + internal void ModifyRequest(MoveRequest request) + { + // Note the use of ArgumentException here, as this will basically be the result of invalid + // options being passed to a public method. + if (IfGenerationMatch != null && IfGenerationNotMatch != null) + { + throw new ArgumentException($"Cannot specify {nameof(IfGenerationMatch)} and {nameof(IfGenerationNotMatch)} in the same options", "options"); + } + if (IfMetagenerationMatch != null && IfMetagenerationNotMatch != null) + { + throw new ArgumentException($"Cannot specify {nameof(IfMetagenerationMatch)} and {nameof(IfMetagenerationNotMatch)} in the same options", "options"); + } + if (IfSourceGenerationMatch != null && IfSourceGenerationNotMatch != null) + { + throw new ArgumentException($"Cannot specify {nameof(IfSourceGenerationMatch)} and {nameof(IfSourceGenerationNotMatch)} in the same options", "options"); + } + if (IfSourceMetagenerationMatch != null && IfSourceMetagenerationNotMatch != null) + { + throw new ArgumentException($"Cannot specify {nameof(IfSourceMetagenerationMatch)} and {nameof(IfSourceMetagenerationNotMatch)} in the same options", "options"); + } + if (IfGenerationMatch != null) + { + request.IfGenerationMatch = IfGenerationMatch; + } + if (IfGenerationNotMatch != null) + { + request.IfGenerationNotMatch = IfGenerationNotMatch; + } + if (IfMetagenerationMatch != null) + { + request.IfMetagenerationMatch = IfMetagenerationMatch; + } + if (IfMetagenerationNotMatch != null) + { + request.IfMetagenerationNotMatch = IfMetagenerationNotMatch; + } + if (IfSourceGenerationMatch != null) + { + request.IfSourceGenerationMatch = IfSourceGenerationMatch; + } + if (IfSourceGenerationNotMatch != null) + { + request.IfSourceGenerationNotMatch = IfSourceGenerationNotMatch; + } + if (IfSourceMetagenerationMatch != null) + { + request.IfSourceMetagenerationMatch = IfSourceMetagenerationMatch; + } + if (IfSourceMetagenerationNotMatch != null) + { + request.IfSourceMetagenerationNotMatch = IfSourceMetagenerationNotMatch; + } + if (UserProject != null) + { + request.UserProject = UserProject; + } + } +} diff --git a/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1/StorageClient.MoveObject.cs b/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1/StorageClient.MoveObject.cs new file mode 100644 index 000000000000..7e410a2daa7f --- /dev/null +++ b/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1/StorageClient.MoveObject.cs @@ -0,0 +1,64 @@ +// Copyright 2025 Google Inc. All Rights Reserved. +// +// 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 +// +// http://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. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Object = Google.Apis.Storage.v1.Data.Object; + +namespace Google.Cloud.Storage.V1; + +public abstract partial class StorageClient +{ + /// + /// Moves an object within a bucket with hierarchical namespace enabled. This method uses the + /// moveObject underlying API operation for more flexibility and reliability. + /// + /// Name of the bucket containing the object to move. Must not be null. + /// The name of the source object to move within the bucket. Must not be null. + /// The name of the new object to move to within the bucket. Must not be null. + /// Additional options for the move operation. May be null, in which case appropriate + /// defaults will be used. + /// The representation of the new storage object resulting from the move. + public virtual Object MoveObject( + string sourceBucket, + string sourceObjectName, + string destinationObjectName, + MoveObjectOptions options = null) + { + throw new NotImplementedException(); + } + + /// + /// Moves an object within a bucket with hierarchical namespace enabled. This method uses the + /// moveObject underlying API operation for more flexibility and reliability. + /// + /// Name of the bucket containing the object to move. Must not be null. + /// The name of the source object to move within the bucket. Must not be null. + /// The name of the new object to move to within the bucket. Must not be null. + /// Additional options for the move operation. May be null, in which case appropriate + /// defaults will be used. + /// The token to monitor for cancellation requests. + /// A task representing the asynchronous operation, with a result returning the + /// representation of the new storage object resulting from the move. + public virtual Task MoveObjectAsync( + string sourceBucket, + string sourceObjectName, + string destinationObjectName, + MoveObjectOptions options = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } +} diff --git a/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1/StorageClientImpl.MoveObject.cs b/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1/StorageClientImpl.MoveObject.cs new file mode 100644 index 000000000000..84ed16d2958d --- /dev/null +++ b/apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1/StorageClientImpl.MoveObject.cs @@ -0,0 +1,65 @@ +// Copyright 2025 Google Inc. All Rights Reserved. +// +// 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 +// +// http://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. + +using Google.Api.Gax; +using Google.Apis.Storage.v1; +using System.Threading; +using System.Threading.Tasks; +using Object = Google.Apis.Storage.v1.Data.Object; + +namespace Google.Cloud.Storage.V1; + +public sealed partial class StorageClientImpl : StorageClient +{ + /// + public override Object MoveObject( + string sourceBucket, + string sourceObjectName, + string destinationObjectName, + MoveObjectOptions options = null) + { + var request = CreateMoveObjectRequest(sourceBucket, sourceObjectName, destinationObjectName, options); + var response = request.Execute(); + return response; + } + + /// + public override async Task MoveObjectAsync( + string sourceBucket, + string sourceObjectName, + string destinationObjectName, + MoveObjectOptions options = null, + CancellationToken cancellationToken = default) + { + var request = CreateMoveObjectRequest(sourceBucket, sourceObjectName, destinationObjectName, options); + var response = await request.ExecuteAsync(cancellationToken).ConfigureAwait(false); + return response; + } + + private ObjectsResource.MoveRequest CreateMoveObjectRequest( + string sourceBucket, + string sourceObjectName, + string destinationObjectName, + MoveObjectOptions options) + { + GaxPreconditions.CheckNotNull(sourceBucket, nameof(sourceBucket)); + GaxPreconditions.CheckNotNull(sourceObjectName, nameof(sourceObjectName)); + GaxPreconditions.CheckNotNull(destinationObjectName, nameof(destinationObjectName)); + var request = Service.Objects.Move(sourceBucket, sourceObjectName, destinationObjectName); + options?.ModifyRequest(request); + RetryOptions retryOptions = options?.RetryOptions ?? RetryOptions.MaybeIdempotent(options?.IfGenerationMatch); + MarkAsRetriable(request, retryOptions); + return request; + } +} diff --git a/generator-input/apis.json b/generator-input/apis.json index bb3955102090..8034cffa99a5 100644 --- a/generator-input/apis.json +++ b/generator-input/apis.json @@ -5249,7 +5249,7 @@ "description": "Recommended Google client library to access the Google Cloud Storage API. It wraps the Google.Apis.Storage.v1 client library, making common operations simpler in client code. Google Cloud Storage stores and retrieves potentially large, immutable data objects.", "dependencies": { "Google.Api.Gax.Rest": "default", - "Google.Apis.Storage.v1": "1.68.0.3604" + "Google.Apis.Storage.v1": "1.69.0.3707" }, "testDependencies": { "Google.Api.Gax.Testing": "default", @@ -6728,4 +6728,4 @@ ] } ] -} \ No newline at end of file +}