Skip to content
Open
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 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.

require "storage_helper"

describe Google::Cloud::Storage::Bucket, :contexts, :storage do
let(:bucket_name) { $bucket_names[0] }
let :bucket do
storage.bucket(bucket_name) ||
storage.create_bucket(bucket_name)
end
let(:custom_context_key1) { "my-custom-key" }
let(:custom_context_value1) { "my-custom-value" }
let(:custom_context_key2) { "my-custom-key-2" }
let(:custom_context_value2) { "my-custom-value-2" }
let(:local_file) { "acceptance/data/CloudPlatform_128px_Retina.png" }
let(:file_name) { "CloudLogo1" }
let(:file_name2) { "CloudLogo2" }

before(:all) do
bucket.create_file local_file, file_name
bucket.create_file local_file, file_name2
custom_hash1 = context_custom_hash custom_context_key: custom_context_key1, custom_context_value: custom_context_value1
custom_hash2 = context_custom_hash custom_context_key: custom_context_key2, custom_context_value: custom_context_value2
Comment thread
shubhangi-google marked this conversation as resolved.
Comment on lines +34 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The local variables custom_hash1 and custom_hash2 are assigned but never used. They can be removed to improve code clarity.

set_object_contexts bucket_name: bucket.name, file_name: file_name, custom_context_key: custom_context_key1, custom_context_value: custom_context_value1
set_object_contexts bucket_name: bucket.name, file_name: file_name2, custom_context_key: custom_context_key2, custom_context_value: custom_context_value2
end

it "lists objects with a specific context key and value" do
list = bucket.files filter: "contexts.\"#{custom_context_key1}\"=\"#{custom_context_value1}\""
list.each do |file|
_(file.name).must_equal file_name
end
end

it "lists objects with a specific context key" do
list = bucket.files filter: "contexts.\"#{custom_context_key1}\":*"
list.each do |file|
_(file.name).must_equal file_name
end
end

it "lists objects that do not have a specific context key" do
list = bucket.files filter: "-contexts.\"#{custom_context_key1}\":*"
list.each do |file|
_(file.name).wont_equal file_name
end
end

it "lists objects that do not have a specific context key and value" do
list = bucket.files filter: "-contexts.\"#{custom_context_key2}\"=\"#{custom_context_value2}\""
list.each do |file|
_(file.name).must_equal file_name
_(file.name).wont_equal file_name2
end
end

end
157 changes: 157 additions & 0 deletions google-cloud-storage/acceptance/storage/file_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1044,4 +1044,161 @@
expect { uploaded_file.retention = retention }.must_raise Google::Cloud::PermissionDeniedError
end
end

describe "object contexts" do
let(:custom_context_key1) { "my-custom-key" }
let(:custom_context_value1) { "my-custom-value" }
let(:custom_context_key2) { "my-custom-key-2" }
let(:custom_context_value2) { "my-custom-value-2" }
let(:local_file) { "acceptance/data/CloudPlatform_128px_Retina.png" }
let(:file_name) { "CloudLogo1" }

before do
bucket.create_file local_file, file_name
end

it "sets and retrieves custom context key and value" do
file = bucket.file file_name
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
custom: context_custom_hash(custom_context_key: custom_context_key1 ,custom_context_value: custom_context_value1)
)
file.reload!
_(file.contexts.custom[custom_context_key1].value).must_equal custom_context_value1
end

it "rejects special characters in custom context key and value" do
invalid_key = 'my"-invalid-key'
custom_value = 'my-custom-value'

file = bucket.file file_name

err = _ {
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
custom: context_custom_hash(custom_context_key: invalid_key, custom_context_value: custom_value)
)
}.must_raise Google::Cloud::InvalidArgumentError

_(err.message).must_match(/Object context key cannot contain/)

invalid_key = 'my-custom-key'
custom_value = 'my-invalid/value'

err = _ {
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
custom: context_custom_hash(custom_context_key: invalid_key, custom_context_value: custom_value)
)
}.must_raise Google::Cloud::InvalidArgumentError

_(err.message).must_match(/Object context value cannot contain/)
end

it "rejects unicode characters in keys and values" do
invalid_key = '🚀-launcher'
custom_value = 'my-custom-value'
file = bucket.file file_name
err = _ {
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
custom: context_custom_hash(custom_context_key: invalid_key, custom_context_value: custom_value)
)
}.must_raise Google::Cloud::InvalidArgumentError
_(err.message).must_match(/Object context key must start with an alphanumeric character./)

invalid_key = "my-custom-key"
custom_value = '✨-sparkle'

err = _ {
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
custom: context_custom_hash(custom_context_key: invalid_key, custom_context_value: custom_value)
)
}.must_raise Google::Cloud::InvalidArgumentError

_(err.message).must_match(/Object context value must start with an alphanumeric character./)
end

it "modifies existing custom context key and value" do
file = bucket.file file_name
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
custom: context_custom_hash(custom_context_key: custom_context_key1 ,custom_context_value: custom_context_value1)
)
file.reload!
_(file.contexts.custom[custom_context_key1].value).must_equal custom_context_value1

file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
custom: context_custom_hash(custom_context_key: custom_context_key1 ,custom_context_value: custom_context_value2)
)
file.reload!
_(file.contexts.custom[custom_context_key1].value).must_equal custom_context_value2
end

it "overwrites existing context key and value" do
file = bucket.file file_name
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
custom: context_custom_hash(custom_context_key: custom_context_key1 ,custom_context_value: custom_context_value1)
)
file.reload!
_(file.contexts.custom[custom_context_key1].value).must_equal custom_context_value1

file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
custom: context_custom_hash(custom_context_key: custom_context_key2 ,custom_context_value: custom_context_value2)
)
file.reload!
_(file.contexts.custom[custom_context_key2].value).must_equal custom_context_value2
end

it "sets and retrieves multiple custom context keys and values" do
file = bucket.file file_name
custom_hash1 = context_custom_hash custom_context_key: custom_context_key1, custom_context_value: custom_context_value1
custom_hash2 = context_custom_hash custom_context_key: custom_context_key2, custom_context_value: custom_context_value2

file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
custom: {
custom_context_key1 => custom_hash1[custom_context_key1],
custom_context_key2 => custom_hash2[custom_context_key2]
}
)
Comment on lines +1150 to +1158
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The construction of the custom hash can be simplified by merging the hashes returned by context_custom_hash. This makes the code more concise and readable.

      custom_hash1 = context_custom_hash custom_context_key: custom_context_key1, custom_context_value: custom_context_value1
      custom_hash2 = context_custom_hash custom_context_key: custom_context_key2, custom_context_value: custom_context_value2

      file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
        custom: custom_hash1.merge(custom_hash2)
      )

file.reload!
_(file.contexts.custom[custom_context_key1].value).must_equal custom_context_value1
_(file.contexts.custom[custom_context_key2].value).must_equal custom_context_value2
end

it "removes individual context" do
file = bucket.file file_name
custom_hash1 = context_custom_hash custom_context_key: custom_context_key1, custom_context_value: custom_context_value1
custom_hash2 = context_custom_hash custom_context_key: custom_context_key2, custom_context_value: custom_context_value2
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
custom: {
custom_context_key1 => custom_hash1[custom_context_key1],
custom_context_key2 => custom_hash2[custom_context_key2]
}
)
Comment on lines +1166 to +1173
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The construction of the custom hash can be simplified by merging the hashes returned by context_custom_hash.

      custom_hash1 = context_custom_hash custom_context_key: custom_context_key1, custom_context_value: custom_context_value1
      custom_hash2 = context_custom_hash custom_context_key: custom_context_key2, custom_context_value: custom_context_value2
      file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
        custom: custom_hash1.merge(custom_hash2)
      )

file.reload!
_(file.contexts.custom[custom_context_key1].value).must_equal custom_context_value1
_(file.contexts.custom[custom_context_key2].value).must_equal custom_context_value2

file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
custom: {
custom_context_key1 => nil
}
)
file.reload!
_(file.contexts.custom[custom_context_key1]).must_be_nil
_(file.contexts.custom[custom_context_key2].value).must_equal custom_context_value2
end

it "clears all contexts" do
file = bucket.file file_name
custom_hash1 = context_custom_hash custom_context_key: custom_context_key1, custom_context_value: custom_context_value1
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
custom: {
custom_context_key1=> custom_hash1[custom_context_key1]
}
)
Comment on lines +1190 to +1195
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This can be simplified. Since custom_hash1 is already the hash you need for the custom property, you can pass it directly.

      custom_hash1 = context_custom_hash custom_context_key: custom_context_key1, custom_context_value: custom_context_value1
      file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
        custom: custom_hash1
      )

file.reload!
_(file.contexts.custom[custom_context_key1].value).must_equal custom_context_value1

file.contexts = nil
file.reload!
_(file.contexts).must_be_nil
end
end
end
21 changes: 21 additions & 0 deletions google-cloud-storage/acceptance/storage_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,27 @@ def clean_up_storage_bucket bucket
puts "Error while cleaning up bucket #{bucket.name}\n\n#{e}"
end

def set_object_contexts bucket_name:, file_name:, custom_context_key:, custom_context_value:
bucket = storage.bucket bucket_name
file = bucket.file file_name
contexts = Google::Apis::StorageV1::Object::Contexts.new(
custom: context_custom_hash(custom_context_key: custom_context_key, custom_context_value: custom_context_value)
)
file.update do |file|
file.contexts = contexts
end
end

def context_custom_hash custom_context_key: ,custom_context_value:
payload = Google::Apis::StorageV1::ObjectCustomContextPayload.new(
value: custom_context_value
)
custom_hash = {
custom_context_key => payload
}
custom_hash
end

Minitest.after_run do
clean_up_storage_buckets
if $storage_2
Expand Down
33 changes: 30 additions & 3 deletions google-cloud-storage/lib/google/cloud/storage/bucket.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1439,7 +1439,23 @@ def delete if_metageneration_match: nil, if_metageneration_not_match: nil
# Only applicable if delimiter is set to '/'.
# @param [Boolean] soft_deleted If true, only soft-deleted object
# versions will be listed. The default is false.
# @param [String] filter An optional string for filtering listed objects.
# Supported fields: contexts
# If delimiter is set, the returned prefixes are exempt from this filter
# List any object that has a context with the specified key attached
# filter = "contexts.\"KEY\":*";
#
# List any object that has a context with the specified key attached and value attached
# filter = "contexts.\"keyA\"=\"valueA\""
#
# List any object that does not have a context with the specified key attached
# filter = "-contexts.\"KEY\":*";
#
# List any object that has a context with the specified key and value attached
# filter = "contexts.\"KEY\"=\"VALUE\"";
#
# List any object that does not have a context with the specified key and value attached
# filter = "-contexts.\"KEY\"=\"VALUE\"";
Comment thread
shubhangi-google marked this conversation as resolved.
Comment on lines +1445 to +1458
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The semicolons at the end of some of the filter examples are inconsistent and not idiomatic for Ruby code examples. Please remove them for consistency.

        #  List any object that has a context with the specified key attached
        #  filter = "contexts.\"KEY\":*"
        #
        #  List any object that has a context with the specified key attached and value attached
        #  filter = "contexts.\"keyA\"=\"valueA\""
        #
        #  List any object that does not have a context with the specified key attached
        #  filter = "-contexts.\"KEY\":*"
        #
        #  List any object that has a context with the specified key and value attached
        #  filter = "contexts.\"KEY\"=\"VALUE\""
        #
        #  List any object that does not have a context with the specified key and value attached
        #  filter = "-contexts.\"KEY\"=\"VALUE\""

# @return [Array<Google::Cloud::Storage::File>] (See
# {Google::Cloud::Storage::File::List})
#
Expand All @@ -1465,23 +1481,34 @@ def delete if_metageneration_match: nil, if_metageneration_not_match: nil
# puts file.name
# end
#
# @example Filter files by context:
# require "google/cloud/storage"
# storage = Google::Cloud::Storage.new
# bucket = storage.bucket "my-bucket"
# files = bucket.files filter: "contexts.\"myKey\"=\"myValue\""
# files.each do |file|
# puts file.name
# end
#
def files prefix: nil, delimiter: nil, token: nil, max: nil,
versions: nil, match_glob: nil, include_folders_as_prefixes: nil,
soft_deleted: nil
soft_deleted: nil, filter: nil
ensure_service!
gapi = service.list_files name, prefix: prefix, delimiter: delimiter,
token: token, max: max,
versions: versions,
user_project: user_project,
match_glob: match_glob,
include_folders_as_prefixes: include_folders_as_prefixes,
soft_deleted: soft_deleted
soft_deleted: soft_deleted,
filter: filter
File::List.from_gapi gapi, service, name, prefix, delimiter, max,
versions,
user_project: user_project,
match_glob: match_glob,
include_folders_as_prefixes: include_folders_as_prefixes,
soft_deleted: soft_deleted
soft_deleted: soft_deleted,
filter: filter
end
alias find_files files

Expand Down
39 changes: 39 additions & 0 deletions google-cloud-storage/lib/google/cloud/storage/file.rb
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,45 @@ def content_type= content_type
update_gapi! :content_type
end

##
# User-defined object contexts. Each object context is a key-
# payload pair, where the key provides the identification and the payload holds
# the associated value and additional metadata.
# Object contexts are used to provide additional information about an object
# @return [Google::Apis::StorageV1::Object::Contexts, nil] The object contexts, or `nil` if there are none.

def contexts
@gapi.contexts
end

##
# Sets the object context.
# To pass generation and/or metageneration preconditions, call this
# method within a block passed to {#update}.
# @param [Google::Apis::StorageV1::Object::Contexts] contexts The object contexts to set.
# @see https://docs.cloud.google.com/storage/docs/use-object-contexts#attach-modify-contexts Object Contexts documentation
# @example
# require "google/cloud/storage"
# storage = Google::Cloud::Storage.new
Comment on lines +379 to +392
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There are a few minor formatting issues in the documentation comments that should be addressed for consistency:

  • An extra blank line on line 379 should be removed.
  • An extra space before ## on line 384 should be removed.
  • The code example starting on line 391 is over-indented.

# bucket = storage.bucket "my-bucket"
# file = bucket.file "path/to/my-file.ext"
# payload = Google::Apis::StorageV1::ObjectCustomContextPayload.new(
# value: "your-custom-context-value"
# )
# custom_hash = {
# "your-custom-context-key" => payload
# }
# contexts = Google::Apis::StorageV1::Object::Contexts.new(
# custom: custom_hash
# )
# file.update do |file|
# file.contexts = contexts
# end
def contexts= contexts
@gapi.contexts = contexts
update_gapi! :contexts
end

##
# A custom time specified by the user for the file, or `nil`.
#
Expand Down
3 changes: 2 additions & 1 deletion google-cloud-storage/lib/google/cloud/storage/file/list.rb
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ def self.from_gapi gapi_list, service, bucket = nil, prefix = nil,
delimiter = nil, max = nil, versions = nil,
user_project: nil, match_glob: nil,
include_folders_as_prefixes: nil,
soft_deleted: nil
soft_deleted: nil, filter: nil
files = new(Array(gapi_list.items).map do |gapi_object|
File.from_gapi gapi_object, service, user_project: user_project
end)
Expand All @@ -183,6 +183,7 @@ def self.from_gapi gapi_list, service, bucket = nil, prefix = nil,
files.instance_variable_set :@match_glob, match_glob
files.instance_variable_set :@include_folders_as_prefixes, include_folders_as_prefixes
files.instance_variable_set :@soft_deleted, soft_deleted
files.instance_variable_set :@filter, filter
files
end

Expand Down
Loading