Skip to content

Commit 079c4d9

Browse files
feat(storage): add object contexts in Go GCS SDK (#13390)
1 parent c01b4af commit 079c4d9

File tree

6 files changed

+713
-5
lines changed

6 files changed

+713
-5
lines changed

storage/contexts.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package storage
16+
17+
import (
18+
"time"
19+
20+
"cloud.google.com/go/storage/internal/apiv2/storagepb"
21+
raw "google.golang.org/api/storage/v1"
22+
)
23+
24+
// ObjectContexts is a container for custom object contexts.
25+
type ObjectContexts struct {
26+
Custom map[string]ObjectCustomContextPayload
27+
}
28+
29+
// ObjectCustomContextPayload holds the value of a user-defined object context and
30+
// other metadata. To delete a key from Custom object contexts, set Delete as true.
31+
type ObjectCustomContextPayload struct {
32+
Value string
33+
Delete bool
34+
// Read-only fields. Any updates to CreateTime and UpdateTime will be ignored.
35+
// These fields are handled by the server.
36+
CreateTime time.Time
37+
UpdateTime time.Time
38+
}
39+
40+
// toContexts converts the raw library's ObjectContexts type to the object contexts.
41+
func toObjectContexts(c *raw.ObjectContexts) *ObjectContexts {
42+
if c == nil {
43+
return nil
44+
}
45+
customContexts := make(map[string]ObjectCustomContextPayload, len(c.Custom))
46+
for k, v := range c.Custom {
47+
customContexts[k] = ObjectCustomContextPayload{
48+
Value: v.Value,
49+
CreateTime: convertTime(v.CreateTime),
50+
UpdateTime: convertTime(v.UpdateTime),
51+
}
52+
}
53+
return &ObjectContexts{
54+
Custom: customContexts,
55+
}
56+
}
57+
58+
// toRawObjectContexts converts the object contexts to the raw library's ObjectContexts type.
59+
func toRawObjectContexts(c *ObjectContexts) *raw.ObjectContexts {
60+
if c == nil {
61+
return nil
62+
}
63+
customContexts := make(map[string]raw.ObjectCustomContextPayload)
64+
for k, v := range c.Custom {
65+
if v.Delete {
66+
// If Delete is true, populate null fields to signify deletion.
67+
customContexts[k] = raw.ObjectCustomContextPayload{NullFields: []string{k}}
68+
} else {
69+
customContexts[k] = raw.ObjectCustomContextPayload{
70+
Value: v.Value,
71+
ForceSendFields: []string{k},
72+
}
73+
}
74+
}
75+
return &raw.ObjectContexts{
76+
Custom: customContexts,
77+
}
78+
}
79+
80+
func toObjectContextsFromProto(c *storagepb.ObjectContexts) *ObjectContexts {
81+
if c == nil {
82+
return nil
83+
}
84+
customContexts := make(map[string]ObjectCustomContextPayload, len(c.GetCustom()))
85+
for k, v := range c.GetCustom() {
86+
customContexts[k] = ObjectCustomContextPayload{
87+
Value: v.GetValue(),
88+
CreateTime: v.GetCreateTime().AsTime(),
89+
UpdateTime: v.GetUpdateTime().AsTime(),
90+
}
91+
}
92+
return &ObjectContexts{
93+
Custom: customContexts,
94+
}
95+
}
96+
97+
func toProtoObjectContexts(c *ObjectContexts) *storagepb.ObjectContexts {
98+
if c == nil {
99+
return nil
100+
}
101+
customContexts := make(map[string]*storagepb.ObjectCustomContextPayload)
102+
for k, v := range c.Custom {
103+
// To delete a key, it is added to gRPC fieldMask and with an empty value
104+
// in gRPC request body. Hence, the key is skipped here in customContexts map.
105+
// See grpcStorageClient.UpdateObject method for more details.
106+
if !v.Delete {
107+
customContexts[k] = &storagepb.ObjectCustomContextPayload{
108+
Value: v.Value,
109+
}
110+
}
111+
}
112+
return &storagepb.ObjectContexts{
113+
Custom: customContexts,
114+
}
115+
}

storage/contexts_test.go

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package storage
16+
17+
import (
18+
"testing"
19+
"time"
20+
21+
"cloud.google.com/go/storage/internal/apiv2/storagepb"
22+
"github.com/google/go-cmp/cmp"
23+
raw "google.golang.org/api/storage/v1"
24+
"google.golang.org/protobuf/testing/protocmp"
25+
"google.golang.org/protobuf/types/known/timestamppb"
26+
)
27+
28+
func TestToObjectContexts(t *testing.T) {
29+
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
30+
31+
testCases := []struct {
32+
name string
33+
raw *raw.ObjectContexts
34+
want *ObjectContexts
35+
}{
36+
{
37+
name: "nil raw object contexts",
38+
raw: nil,
39+
want: nil,
40+
},
41+
{
42+
name: "empty custom contexts",
43+
raw: &raw.ObjectContexts{
44+
Custom: map[string]raw.ObjectCustomContextPayload{},
45+
},
46+
want: &ObjectContexts{
47+
Custom: map[string]ObjectCustomContextPayload{},
48+
},
49+
},
50+
{
51+
name: "with custom contexts",
52+
raw: &raw.ObjectContexts{
53+
Custom: map[string]raw.ObjectCustomContextPayload{
54+
"key1": {Value: "value1", CreateTime: now.Format(time.RFC3339Nano), UpdateTime: now.Format(time.RFC3339Nano)},
55+
"key2": {Value: "value2", CreateTime: now.Format(time.RFC3339Nano), UpdateTime: now.Format(time.RFC3339Nano)},
56+
},
57+
},
58+
want: &ObjectContexts{
59+
Custom: map[string]ObjectCustomContextPayload{
60+
"key1": {Value: "value1", CreateTime: now, UpdateTime: now},
61+
"key2": {Value: "value2", CreateTime: now, UpdateTime: now},
62+
},
63+
},
64+
},
65+
}
66+
67+
for _, tc := range testCases {
68+
t.Run(tc.name, func(t *testing.T) {
69+
got := toObjectContexts(tc.raw)
70+
if diff := cmp.Diff(tc.want, got); diff != "" {
71+
t.Errorf("toObjectContexts() mismatch (-want +got):\n%s", diff)
72+
}
73+
})
74+
}
75+
}
76+
77+
func TestToRawObjectContexts(t *testing.T) {
78+
testCases := []struct {
79+
name string
80+
obj *ObjectContexts
81+
want *raw.ObjectContexts
82+
}{
83+
{
84+
name: "nil object contexts",
85+
obj: nil,
86+
want: nil,
87+
},
88+
{
89+
name: "empty custom contexts",
90+
obj: &ObjectContexts{
91+
Custom: map[string]ObjectCustomContextPayload{},
92+
},
93+
want: &raw.ObjectContexts{
94+
Custom: map[string]raw.ObjectCustomContextPayload{},
95+
},
96+
},
97+
{
98+
name: "with custom contexts",
99+
obj: &ObjectContexts{
100+
Custom: map[string]ObjectCustomContextPayload{
101+
"key1": {Value: "value1"},
102+
"key2": {Value: "value2", Delete: true}, // Should have NullFields
103+
},
104+
},
105+
want: &raw.ObjectContexts{
106+
Custom: map[string]raw.ObjectCustomContextPayload{
107+
"key1": {Value: "value1", ForceSendFields: []string{"key1"}},
108+
"key2": {NullFields: []string{"key2"}},
109+
},
110+
},
111+
},
112+
{
113+
name: "with custom contexts, no delete",
114+
obj: &ObjectContexts{
115+
Custom: map[string]ObjectCustomContextPayload{
116+
"key1": {Value: "value1"},
117+
"key2": {Value: "value2"},
118+
},
119+
},
120+
want: &raw.ObjectContexts{
121+
Custom: map[string]raw.ObjectCustomContextPayload{
122+
"key1": {Value: "value1", ForceSendFields: []string{"key1"}},
123+
"key2": {Value: "value2", ForceSendFields: []string{"key2"}},
124+
},
125+
},
126+
},
127+
}
128+
129+
for _, tc := range testCases {
130+
t.Run(tc.name, func(t *testing.T) {
131+
got := toRawObjectContexts(tc.obj)
132+
if diff := cmp.Diff(tc.want, got); diff != "" {
133+
t.Errorf("toRawObjectContexts() mismatch (-want +got): %s", diff)
134+
}
135+
})
136+
}
137+
}
138+
139+
func TestToObjectContextsFromProto(t *testing.T) {
140+
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
141+
142+
testCases := []struct {
143+
name string
144+
proto *storagepb.ObjectContexts
145+
want *ObjectContexts
146+
}{
147+
{
148+
name: "nil proto object contexts",
149+
proto: nil,
150+
want: nil,
151+
},
152+
{
153+
name: "empty custom contexts",
154+
proto: &storagepb.ObjectContexts{
155+
Custom: map[string]*storagepb.ObjectCustomContextPayload{},
156+
},
157+
want: &ObjectContexts{
158+
Custom: map[string]ObjectCustomContextPayload{},
159+
},
160+
},
161+
{
162+
name: "with custom contexts",
163+
proto: &storagepb.ObjectContexts{
164+
Custom: map[string]*storagepb.ObjectCustomContextPayload{
165+
"key1": {Value: "value1", CreateTime: timestamppb.New(now), UpdateTime: timestamppb.New(now)},
166+
"key2": {Value: "value2", CreateTime: timestamppb.New(now), UpdateTime: timestamppb.New(now)},
167+
},
168+
},
169+
want: &ObjectContexts{
170+
Custom: map[string]ObjectCustomContextPayload{
171+
"key1": {Value: "value1", CreateTime: now, UpdateTime: now},
172+
"key2": {Value: "value2", CreateTime: now, UpdateTime: now},
173+
},
174+
},
175+
},
176+
}
177+
178+
for _, tc := range testCases {
179+
t.Run(tc.name, func(t *testing.T) {
180+
got := toObjectContextsFromProto(tc.proto)
181+
if diff := cmp.Diff(tc.want, got); diff != "" {
182+
t.Errorf("toObjectContextsFromProto() mismatch (-want +got): %s", diff)
183+
}
184+
})
185+
}
186+
}
187+
188+
func TestToProtoObjectContexts(t *testing.T) {
189+
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
190+
191+
testCases := []struct {
192+
name string
193+
obj *ObjectContexts
194+
want *storagepb.ObjectContexts
195+
}{
196+
{
197+
name: "nil object contexts",
198+
obj: nil,
199+
want: nil,
200+
},
201+
{
202+
name: "empty custom contexts",
203+
obj: &ObjectContexts{
204+
Custom: map[string]ObjectCustomContextPayload{},
205+
},
206+
want: &storagepb.ObjectContexts{
207+
Custom: map[string]*storagepb.ObjectCustomContextPayload{},
208+
},
209+
},
210+
{
211+
name: "with custom contexts",
212+
obj: &ObjectContexts{
213+
Custom: map[string]ObjectCustomContextPayload{
214+
"key1": {Value: "value1", CreateTime: now, UpdateTime: now},
215+
"key2": {Value: "value2", Delete: true}, // Should be skipped in proto conversion
216+
"key3": {Value: "value3"},
217+
},
218+
},
219+
want: &storagepb.ObjectContexts{
220+
Custom: map[string]*storagepb.ObjectCustomContextPayload{
221+
"key1": {Value: "value1"},
222+
"key3": {Value: "value3"},
223+
},
224+
},
225+
},
226+
}
227+
228+
for _, tc := range testCases {
229+
t.Run(tc.name, func(t *testing.T) {
230+
got := toProtoObjectContexts(tc.obj)
231+
if diff := cmp.Diff(tc.want, got, protocmp.Transform()); diff != "" {
232+
t.Errorf("toProtoObjectContexts() mismatch (-want +got): %s", diff)
233+
}
234+
})
235+
}
236+
}

storage/grpc_client.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,7 @@ func (c *grpcStorageClient) ListObjects(ctx context.Context, bucket string, q *Q
460460
ReadMask: q.toFieldMask(), // a nil Query still results in a "*" FieldMask
461461
SoftDeleted: it.query.SoftDeleted,
462462
IncludeFoldersAsPrefixes: it.query.IncludeFoldersAsPrefixes,
463+
Filter: it.query.Filter,
463464
}
464465
if s.userProject != "" {
465466
ctx = setUserProjectMetadata(ctx, s.userProject)
@@ -630,6 +631,18 @@ func (c *grpcStorageClient) UpdateObject(ctx context.Context, params *updateObje
630631
}
631632
}
632633

634+
if uattrs.Contexts != nil && uattrs.Contexts.Custom != nil {
635+
if len(uattrs.Contexts.Custom) == 0 {
636+
// pass fieldMask with no key value and empty map to delete all keys
637+
fieldMask.Paths = append(fieldMask.Paths, "contexts.custom")
638+
} else {
639+
for key := range uattrs.Contexts.Custom {
640+
// pass fieldMask with key value with empty value in map to delete key
641+
fieldMask.Paths = append(fieldMask.Paths, fmt.Sprintf("contexts.custom.%s", key))
642+
}
643+
}
644+
}
645+
633646
req.UpdateMask = fieldMask
634647

635648
if len(fieldMask.Paths) < 1 {

0 commit comments

Comments
 (0)