Skip to content
Merged
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
115 changes: 115 additions & 0 deletions storage/contexts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright 2025 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
//
// 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.

package storage

import (
"time"

"cloud.google.com/go/storage/internal/apiv2/storagepb"
raw "google.golang.org/api/storage/v1"
)

// ObjectContexts is a container for custom object contexts.
type ObjectContexts struct {
Custom map[string]ObjectCustomContextPayload
}

// ObjectCustomContextPayload holds the value of a user-defined object context and
// other metadata. To delete a key from Custom object contexts, set Delete as true.
type ObjectCustomContextPayload struct {
Value string
Delete bool
// Read-only fields. Any updates to CreateTime and UpdateTime will be ignored.
// These fields are handled by the server.
CreateTime time.Time
UpdateTime time.Time
}

// toContexts converts the raw library's ObjectContexts type to the object contexts.
func toObjectContexts(c *raw.ObjectContexts) *ObjectContexts {
if c == nil {
return nil
}
customContexts := make(map[string]ObjectCustomContextPayload, len(c.Custom))
for k, v := range c.Custom {
customContexts[k] = ObjectCustomContextPayload{
Value: v.Value,
CreateTime: convertTime(v.CreateTime),
UpdateTime: convertTime(v.UpdateTime),
}
}
return &ObjectContexts{
Custom: customContexts,
}
}

// toRawObjectContexts converts the object contexts to the raw library's ObjectContexts type.
func toRawObjectContexts(c *ObjectContexts) *raw.ObjectContexts {
if c == nil {
return nil
}
customContexts := make(map[string]raw.ObjectCustomContextPayload)
for k, v := range c.Custom {
if v.Delete {
// If Delete is true, populate null fields to signify deletion.
customContexts[k] = raw.ObjectCustomContextPayload{NullFields: []string{k}}
} else {
customContexts[k] = raw.ObjectCustomContextPayload{
Value: v.Value,
ForceSendFields: []string{k},
}
}
}
return &raw.ObjectContexts{
Custom: customContexts,
}
}

func toObjectContextsFromProto(c *storagepb.ObjectContexts) *ObjectContexts {
if c == nil {
return nil
}
customContexts := make(map[string]ObjectCustomContextPayload, len(c.GetCustom()))
for k, v := range c.GetCustom() {
customContexts[k] = ObjectCustomContextPayload{
Value: v.GetValue(),
CreateTime: v.GetCreateTime().AsTime(),
UpdateTime: v.GetUpdateTime().AsTime(),
}
}
return &ObjectContexts{
Custom: customContexts,
}
}

func toProtoObjectContexts(c *ObjectContexts) *storagepb.ObjectContexts {
if c == nil {
return nil
}
customContexts := make(map[string]*storagepb.ObjectCustomContextPayload)
for k, v := range c.Custom {
// To delete a key, it is added to gRPC fieldMask and with an empty value
// in gRPC request body. Hence, the key is skipped here in customContexts map.
// See grpcStorageClient.UpdateObject method for more details.
if !v.Delete {
customContexts[k] = &storagepb.ObjectCustomContextPayload{
Value: v.Value,
}
}
}
return &storagepb.ObjectContexts{
Custom: customContexts,
}
}
236 changes: 236 additions & 0 deletions storage/contexts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
// Copyright 2025 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
//
// 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.

package storage

import (
"testing"
"time"

"cloud.google.com/go/storage/internal/apiv2/storagepb"
"github.com/google/go-cmp/cmp"
raw "google.golang.org/api/storage/v1"
"google.golang.org/protobuf/testing/protocmp"
"google.golang.org/protobuf/types/known/timestamppb"
)

func TestToObjectContexts(t *testing.T) {
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)

testCases := []struct {
name string
raw *raw.ObjectContexts
want *ObjectContexts
}{
{
name: "nil raw object contexts",
raw: nil,
want: nil,
},
{
name: "empty custom contexts",
raw: &raw.ObjectContexts{
Custom: map[string]raw.ObjectCustomContextPayload{},
},
want: &ObjectContexts{
Custom: map[string]ObjectCustomContextPayload{},
},
},
{
name: "with custom contexts",
raw: &raw.ObjectContexts{
Custom: map[string]raw.ObjectCustomContextPayload{
"key1": {Value: "value1", CreateTime: now.Format(time.RFC3339Nano), UpdateTime: now.Format(time.RFC3339Nano)},
"key2": {Value: "value2", CreateTime: now.Format(time.RFC3339Nano), UpdateTime: now.Format(time.RFC3339Nano)},
},
},
want: &ObjectContexts{
Custom: map[string]ObjectCustomContextPayload{
"key1": {Value: "value1", CreateTime: now, UpdateTime: now},
"key2": {Value: "value2", CreateTime: now, UpdateTime: now},
},
},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got := toObjectContexts(tc.raw)
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("toObjectContexts() mismatch (-want +got):\n%s", diff)
}
})
}
}

func TestToRawObjectContexts(t *testing.T) {
testCases := []struct {
name string
obj *ObjectContexts
want *raw.ObjectContexts
}{
{
name: "nil object contexts",
obj: nil,
want: nil,
},
{
name: "empty custom contexts",
obj: &ObjectContexts{
Custom: map[string]ObjectCustomContextPayload{},
},
want: &raw.ObjectContexts{
Custom: map[string]raw.ObjectCustomContextPayload{},
},
},
{
name: "with custom contexts",
obj: &ObjectContexts{
Custom: map[string]ObjectCustomContextPayload{
"key1": {Value: "value1"},
"key2": {Value: "value2", Delete: true}, // Should have NullFields
},
},
want: &raw.ObjectContexts{
Custom: map[string]raw.ObjectCustomContextPayload{
"key1": {Value: "value1", ForceSendFields: []string{"key1"}},
"key2": {NullFields: []string{"key2"}},
},
},
},
{
name: "with custom contexts, no delete",
obj: &ObjectContexts{
Custom: map[string]ObjectCustomContextPayload{
"key1": {Value: "value1"},
"key2": {Value: "value2"},
},
},
want: &raw.ObjectContexts{
Custom: map[string]raw.ObjectCustomContextPayload{
"key1": {Value: "value1", ForceSendFields: []string{"key1"}},
"key2": {Value: "value2", ForceSendFields: []string{"key2"}},
},
},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got := toRawObjectContexts(tc.obj)
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("toRawObjectContexts() mismatch (-want +got): %s", diff)
}
})
}
}

func TestToObjectContextsFromProto(t *testing.T) {
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)

testCases := []struct {
name string
proto *storagepb.ObjectContexts
want *ObjectContexts
}{
{
name: "nil proto object contexts",
proto: nil,
want: nil,
},
{
name: "empty custom contexts",
proto: &storagepb.ObjectContexts{
Custom: map[string]*storagepb.ObjectCustomContextPayload{},
},
want: &ObjectContexts{
Custom: map[string]ObjectCustomContextPayload{},
},
},
{
name: "with custom contexts",
proto: &storagepb.ObjectContexts{
Custom: map[string]*storagepb.ObjectCustomContextPayload{
"key1": {Value: "value1", CreateTime: timestamppb.New(now), UpdateTime: timestamppb.New(now)},
"key2": {Value: "value2", CreateTime: timestamppb.New(now), UpdateTime: timestamppb.New(now)},
},
},
want: &ObjectContexts{
Custom: map[string]ObjectCustomContextPayload{
"key1": {Value: "value1", CreateTime: now, UpdateTime: now},
"key2": {Value: "value2", CreateTime: now, UpdateTime: now},
},
},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got := toObjectContextsFromProto(tc.proto)
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("toObjectContextsFromProto() mismatch (-want +got): %s", diff)
}
})
}
}

func TestToProtoObjectContexts(t *testing.T) {
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)

testCases := []struct {
name string
obj *ObjectContexts
want *storagepb.ObjectContexts
}{
{
name: "nil object contexts",
obj: nil,
want: nil,
},
{
name: "empty custom contexts",
obj: &ObjectContexts{
Custom: map[string]ObjectCustomContextPayload{},
},
want: &storagepb.ObjectContexts{
Custom: map[string]*storagepb.ObjectCustomContextPayload{},
},
},
{
name: "with custom contexts",
obj: &ObjectContexts{
Custom: map[string]ObjectCustomContextPayload{
"key1": {Value: "value1", CreateTime: now, UpdateTime: now},
"key2": {Value: "value2", Delete: true}, // Should be skipped in proto conversion
"key3": {Value: "value3"},
},
},
want: &storagepb.ObjectContexts{
Custom: map[string]*storagepb.ObjectCustomContextPayload{
"key1": {Value: "value1"},
"key3": {Value: "value3"},
},
},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got := toProtoObjectContexts(tc.obj)
if diff := cmp.Diff(tc.want, got, protocmp.Transform()); diff != "" {
t.Errorf("toProtoObjectContexts() mismatch (-want +got): %s", diff)
}
})
}
}
13 changes: 13 additions & 0 deletions storage/grpc_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,7 @@ func (c *grpcStorageClient) ListObjects(ctx context.Context, bucket string, q *Q
ReadMask: q.toFieldMask(), // a nil Query still results in a "*" FieldMask
SoftDeleted: it.query.SoftDeleted,
IncludeFoldersAsPrefixes: it.query.IncludeFoldersAsPrefixes,
Filter: it.query.Filter,
}
if s.userProject != "" {
ctx = setUserProjectMetadata(ctx, s.userProject)
Expand Down Expand Up @@ -630,6 +631,18 @@ func (c *grpcStorageClient) UpdateObject(ctx context.Context, params *updateObje
}
}

if uattrs.Contexts != nil && uattrs.Contexts.Custom != nil {
if len(uattrs.Contexts.Custom) == 0 {
// pass fieldMask with no key value and empty map to delete all keys
fieldMask.Paths = append(fieldMask.Paths, "contexts.custom")
} else {
for key := range uattrs.Contexts.Custom {
// pass fieldMask with key value with empty value in map to delete key
fieldMask.Paths = append(fieldMask.Paths, fmt.Sprintf("contexts.custom.%s", key))
}
}
}

req.UpdateMask = fieldMask

if len(fieldMask.Paths) < 1 {
Expand Down
Loading
Loading