Skip to content

Commit f966930

Browse files
committed
feat(event_producer): implement gcs adapters
Introduces the `gcpgcsadapters` package to connect the `EventProducer` domain logic to Google Cloud Storage. This includes: - `EventProducer`: An adapter that implements the `BlobStorage` interface, handling path construction (bucket/dirs/key) and delegating read/write operations to the underlying GCS client. - Support for setting content-type ("application/json") on uploads.
1 parent 961d80c commit f966930

File tree

2 files changed

+260
-0
lines changed

2 files changed

+260
-0
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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 gcpgcsadapters
16+
17+
import (
18+
"context"
19+
"path"
20+
21+
"github.com/GoogleChrome/webstatus.dev/lib/blobtypes"
22+
)
23+
24+
type EventProducerBlobStorageClient interface {
25+
WriteBlob(ctx context.Context, path string, data []byte, opts ...blobtypes.WriteOption) error
26+
ReadBlob(ctx context.Context, path string, opts ...blobtypes.ReadOption) (*blobtypes.Blob, error)
27+
}
28+
29+
type EventProducer struct {
30+
client EventProducerBlobStorageClient
31+
bucketName string
32+
}
33+
34+
func NewEventProducer(client EventProducerBlobStorageClient, bucketName string) *EventProducer {
35+
return &EventProducer{client: client, bucketName: bucketName}
36+
}
37+
38+
func (e *EventProducer) Store(ctx context.Context, dirs []string, key string, data []byte) (string, error) {
39+
filepath := append([]string{e.bucketName}, dirs...)
40+
// Add the key as the final element.
41+
filepath = append(filepath, key)
42+
path := path.Join(filepath...)
43+
if err := e.client.WriteBlob(ctx, path, data, blobtypes.WithContentType("application/json")); err != nil {
44+
return "", err
45+
}
46+
47+
return path, nil
48+
}
49+
50+
func (e *EventProducer) Get(ctx context.Context, fullpath string) ([]byte, error) {
51+
blob, err := e.client.ReadBlob(ctx, fullpath)
52+
if err != nil {
53+
return nil, err
54+
}
55+
56+
return blob.Data, nil
57+
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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 gcpgcsadapters
16+
17+
import (
18+
"context"
19+
"errors"
20+
"testing"
21+
22+
"github.com/GoogleChrome/webstatus.dev/lib/blobtypes"
23+
)
24+
25+
type mockBlobStorageClient struct {
26+
writeBlobCalled bool
27+
writeBlobReq struct {
28+
path string
29+
data []byte
30+
opts []blobtypes.WriteOption
31+
}
32+
writeBlobErr error
33+
34+
readBlobCalled bool
35+
readBlobReq struct {
36+
path string
37+
}
38+
readBlobResp *blobtypes.Blob
39+
readBlobErr error
40+
}
41+
42+
func (m *mockBlobStorageClient) WriteBlob(_ context.Context, path string, data []byte,
43+
opts ...blobtypes.WriteOption) error {
44+
m.writeBlobCalled = true
45+
m.writeBlobReq.path = path
46+
m.writeBlobReq.data = data
47+
m.writeBlobReq.opts = opts
48+
49+
return m.writeBlobErr
50+
}
51+
52+
func (m *mockBlobStorageClient) ReadBlob(_ context.Context, path string,
53+
_ ...blobtypes.ReadOption) (*blobtypes.Blob, error) {
54+
m.readBlobCalled = true
55+
m.readBlobReq.path = path
56+
57+
return m.readBlobResp, m.readBlobErr
58+
}
59+
60+
func TestStore(t *testing.T) {
61+
bucketName := "test-bucket"
62+
data := []byte("test-data")
63+
64+
tests := []struct {
65+
name string
66+
dirs []string
67+
key string
68+
mockErr error
69+
expectedPath string
70+
wantErr bool
71+
}{
72+
{
73+
name: "root directory",
74+
dirs: []string{},
75+
key: "file.json",
76+
mockErr: nil,
77+
expectedPath: "test-bucket/file.json",
78+
wantErr: false,
79+
},
80+
{
81+
name: "nested directory",
82+
dirs: []string{"folder1", "folder2"},
83+
key: "file.json",
84+
mockErr: nil,
85+
expectedPath: "test-bucket/folder1/folder2/file.json",
86+
wantErr: false,
87+
},
88+
{
89+
name: "write error",
90+
dirs: []string{"folder"},
91+
key: "file.json",
92+
mockErr: errors.New("gcs error"),
93+
expectedPath: "test-bucket/folder/file.json",
94+
wantErr: true,
95+
},
96+
}
97+
98+
for _, tc := range tests {
99+
t.Run(tc.name, func(t *testing.T) {
100+
mock := new(mockBlobStorageClient)
101+
mock.writeBlobErr = tc.mockErr
102+
adapter := NewEventProducer(mock, bucketName)
103+
104+
path, err := adapter.Store(context.Background(), tc.dirs, tc.key, data)
105+
106+
if (err != nil) != tc.wantErr {
107+
t.Errorf("Store() error = %v, wantErr %v", err, tc.wantErr)
108+
}
109+
110+
if !mock.writeBlobCalled {
111+
t.Fatal("WriteBlob not called")
112+
}
113+
114+
if mock.writeBlobReq.path != tc.expectedPath {
115+
t.Errorf("path mismatch: got %q, want %q", mock.writeBlobReq.path, tc.expectedPath)
116+
}
117+
if string(mock.writeBlobReq.data) != string(data) {
118+
t.Errorf("data mismatch")
119+
}
120+
121+
// Verify returned path matches the full path sent to GCS
122+
if err == nil && path != tc.expectedPath {
123+
t.Errorf("returned path mismatch: got %q, want %q", path, tc.expectedPath)
124+
}
125+
126+
// Verify the options include the correct content type
127+
foundContentType := false
128+
for _, opt := range mock.writeBlobReq.opts {
129+
var config blobtypes.WriteSettings
130+
opt(&config)
131+
if config.ContentType != nil && *config.ContentType == "application/json" {
132+
foundContentType = true
133+
134+
break
135+
}
136+
}
137+
if !foundContentType {
138+
t.Error("content type option not set to application/json")
139+
}
140+
})
141+
}
142+
}
143+
144+
func TestGet(t *testing.T) {
145+
bucketName := "test-bucket"
146+
fullPath := "test-bucket/folder/file.json"
147+
data := []byte("test-data")
148+
149+
tests := []struct {
150+
name string
151+
mockResp *blobtypes.Blob
152+
mockErr error
153+
wantData []byte
154+
wantErr bool
155+
}{
156+
{
157+
name: "success",
158+
mockResp: &blobtypes.Blob{
159+
Data: data,
160+
ContentType: "application/json",
161+
Metadata: nil,
162+
Generation: 1,
163+
},
164+
mockErr: nil,
165+
wantData: data,
166+
wantErr: false,
167+
},
168+
{
169+
name: "read error",
170+
mockResp: nil,
171+
mockErr: errors.New("gcs error"),
172+
wantData: nil,
173+
wantErr: true,
174+
},
175+
}
176+
177+
for _, tc := range tests {
178+
t.Run(tc.name, func(t *testing.T) {
179+
mock := new(mockBlobStorageClient)
180+
mock.readBlobResp = tc.mockResp
181+
mock.readBlobErr = tc.mockErr
182+
adapter := NewEventProducer(mock, bucketName)
183+
184+
gotData, err := adapter.Get(context.Background(), fullPath)
185+
186+
if (err != nil) != tc.wantErr {
187+
t.Errorf("Get() error = %v, wantErr %v", err, tc.wantErr)
188+
}
189+
190+
if !mock.readBlobCalled {
191+
t.Fatal("ReadBlob not called")
192+
}
193+
194+
if mock.readBlobReq.path != fullPath {
195+
t.Errorf("path mismatch: got %q, want %q", mock.readBlobReq.path, fullPath)
196+
}
197+
198+
if err == nil && string(gotData) != string(tc.wantData) {
199+
t.Errorf("data mismatch: got %q, want %q", gotData, tc.wantData)
200+
}
201+
})
202+
}
203+
}

0 commit comments

Comments
 (0)