Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# 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
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
160 changes: 160 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,164 @@
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

# Optional: Verify the message matches what you saw
_(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]
}
)
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]
}
)
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]
}
)
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.
# Currently only supported for the contexts field.
# 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\"";
# @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
# 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
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
Loading