diff --git a/common/types/duration/duration.go b/common/types/duration/duration.go new file mode 100644 index 000000000..90c4a59f1 --- /dev/null +++ b/common/types/duration/duration.go @@ -0,0 +1,67 @@ +package duration + +import ( + "net/url" + "strings" + "time" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/durationpb" +) + +// Duration is a wrapper for durationpb.Duration to provide custom marshaling +// for JSON and URL query strings. +// +// It embeds durationpb.Duration and exposes the .AsDuration() method to +// easily convert to time.Duration. +// +// Example: +// +// customDur := duration.New(30 * time.Second) +// goDur := customDur.AsDuration() +type Duration struct { + internal *durationpb.Duration +} + +// New creates a custom Duration from a standard time.Duration. +func New(d time.Duration) *Duration { + return &Duration{internal: durationpb.New(d)} +} + +// AsDuration returns the underlying time.Duration value. +func (x *Duration) AsDuration() time.Duration { + if x == nil { + return 0 + } + return x.internal.AsDuration() +} + +// MarshalJSON implements the [json.Marshaler] interface +// by marshalling the duration as a protobuf Duration. +func (d Duration) MarshalJSON() ([]byte, error) { + return protojson.Marshal(d.internal) +} + +// EncodeValues implements the [query.Encoder] interface by encoding the +// duration as a string, like "3.3s". +func (d Duration) EncodeValues(key string, v *url.Values) error { + res, err := protojson.Marshal(d.internal) + if err != nil { + return err + } + // remove the quotes from the string + queryValue := strings.Trim(string(res), "\"") + v.Set(key, queryValue) + return nil +} + +// UnmarshalJSON implements the [json.Unmarshaler] interface. It can parse a +// duration from the protobuf Duration. +func (d *Duration) UnmarshalJSON(b []byte) error { + var pb durationpb.Duration + if err := protojson.Unmarshal(b, &pb); err != nil { + return err + } + *d = *New(pb.AsDuration()) + return nil +} diff --git a/common/types/duration/duration_test.go b/common/types/duration/duration_test.go new file mode 100644 index 000000000..d407fd9bd --- /dev/null +++ b/common/types/duration/duration_test.go @@ -0,0 +1,254 @@ +package duration + +import ( + "encoding/json" + "net/url" + "testing" + "time" +) + +func TestAsDuration(t *testing.T) { + d := time.Second * 5 + dur := New(d) + result := dur.AsDuration() + if result != d { + t.Errorf("AsDuration() = %v, want %v", result, d) + } +} + +func TestDuration_MarshalJSON(t *testing.T) { + tests := []struct { + name string + duration Duration + expected string + wantErr bool + }{ + { + name: "zero duration", + duration: *New(0), + expected: "0s", + }, + { + name: "positive duration", + duration: *New(5 * time.Second), + expected: "5s", + }, + { + name: "negative duration", + duration: *New(-2 * time.Minute), + expected: "-120s", + }, + { + name: "negative duration with fractional seconds", + duration: *New(-2*time.Minute + 100*time.Millisecond), + expected: "-119.900s", + }, + { + name: "fractional seconds", + duration: *New(1500 * time.Millisecond), + expected: "1.500s", + }, + { + name: "large duration", + duration: *New(9223372036*time.Second + 854775000*time.Nanosecond), + expected: "9223372036.854775s", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := tt.duration.MarshalJSON() + if (err != nil) != tt.wantErr { + t.Errorf("Duration.MarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if string(result) != `"`+tt.expected+`"` { + t.Errorf("Duration.MarshalJSON() = %v, want %v", string(result), `"`+tt.expected+`"`) + } + }) + } +} + +func TestDuration_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + input string + want Duration + wantErr bool + }{ + { + name: "zero duration", + input: `"0s"`, + want: *New(0), + wantErr: false, + }, + { + name: "positive duration", + input: `"5s"`, + want: *New(5 * time.Second), + wantErr: false, + }, + { + name: "negative duration", + input: `"-2s"`, + want: *New(-2 * time.Second), + wantErr: false, + }, + { + name: "negative duration with fractional seconds", + input: `"-2.1s"`, + want: *New(-2*time.Second - 100*time.Millisecond), + wantErr: false, + }, + { + name: "fractional seconds", + input: `"1.5s"`, + want: *New(1500 * time.Millisecond), + wantErr: false, + }, + { + name: "large duration", + input: `"9223372036.854775000s"`, + want: *New(9223372036*time.Second + 854775000*time.Nanosecond), + wantErr: false, + }, + { + name: "invalid duration format", + input: `"invalid"`, + want: *New(0), + wantErr: true, + }, + { + name: "empty string", + input: `""`, + want: *New(0), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var d Duration + err := d.UnmarshalJSON([]byte(tt.input)) + if (err != nil) != tt.wantErr { + t.Errorf("Duration.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + // We cannot compare Proto messages directly, so we compare the underlying time.Duration + if d.AsDuration() != tt.want.AsDuration() { + t.Errorf("Duration.UnmarshalJSON() = %v, want %v", d.AsDuration(), tt.want.AsDuration()) + } + } + + }) + } +} + +func TestDuration_EncodeValues(t *testing.T) { + tests := []struct { + name string + duration Duration + key string + expected string + }{ + { + name: "zero duration", + duration: *New(0), + key: "duration", + expected: "0s", + }, + { + name: "positive duration", + duration: *New(5 * time.Second), + key: "timeout", + expected: "5s", + }, + { + name: "negative duration", + duration: *New(-2 * time.Minute), + key: "delay", + expected: "-120s", + }, + { + name: "fractional seconds", + duration: *New(1500 * time.Millisecond), + key: "interval", + expected: "1.500s", + }, + { + name: "large duration", + duration: *New(24 * time.Hour), + key: "period", + expected: "86400s", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + values := url.Values{} + err := tt.duration.EncodeValues(tt.key, &values) + if err != nil { + t.Errorf("Duration.EncodeValues() error = %v", err) + return + } + result := values.Get(tt.key) + if result != tt.expected { + t.Errorf("Duration.EncodeValues() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestDuration_JSONRoundTrip(t *testing.T) { + tests := []struct { + name string + duration Duration + }{ + { + name: "zero duration", + duration: *New(0), + }, + { + name: "positive duration", + duration: *New(5 * time.Second), + }, + { + name: "negative duration", + duration: *New(-2 * time.Minute), + }, + { + name: "fractional seconds", + duration: *New(1500 * time.Millisecond), + }, + { + name: "complex duration", + duration: *New(1*time.Hour + 2*time.Minute + 3*time.Second), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Marshal to JSON + jsonData, err := json.Marshal(tt.duration) + if err != nil { + t.Errorf("json.Marshal() error = %v", err) + return + } + + // Unmarshal from JSON + var result Duration + err = json.Unmarshal(jsonData, &result) + if err != nil { + t.Errorf("json.Unmarshal() error = %v", err) + return + } + + // Check that the round trip preserved the value + if result.AsDuration() != tt.duration.AsDuration() { + t.Errorf("Duration.UnmarshalJSON() = %v, want %v", result.AsDuration(), tt.duration.AsDuration()) + } + + }) + } +} diff --git a/common/types/fieldmask/fieldmask.go b/common/types/fieldmask/fieldmask.go new file mode 100644 index 000000000..4aa4a2a36 --- /dev/null +++ b/common/types/fieldmask/fieldmask.go @@ -0,0 +1,63 @@ +package fieldmask + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" +) + +// FieldMask represents a field mask as defined in Google's Well Known Types. +// It is used to specify which fields of a resource should be included or excluded +// in a request or response. +type FieldMask struct { + Paths []string +} + +// New creates a FieldMask from a slice of field paths. +func New(paths []string) *FieldMask { + return &FieldMask{Paths: paths} +} + +// MarshalJSON implements the [json.Marshaler] interface by formatting the +// field mask as a string according to Google Well Known Type +func (f FieldMask) MarshalJSON() ([]byte, error) { + return json.Marshal(strings.Join(f.Paths, ",")) +} + +// UnmarshalJSON implements the [json.Unmarshaler] interface by parsing the +// field mask from a string according to Google Well Known Type +func (f *FieldMask) UnmarshalJSON(data []byte) error { + if f == nil { + return fmt.Errorf("FieldMask.UnmarshalJSON on nil pointer") + } + + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + if s != "" { + f.Paths = strings.Split(s, ",") + } else { + f.Paths = []string{} + } + + return nil +} + +// EncodeValues implements the [query.Encoder] interface by encoding the +// field mask as a string, like "a,b,c". +// If the FieldMask is nil or empty, it returns nil. +// If the url.Values is nil, it returns an error. +func (f *FieldMask) EncodeValues(key string, v *url.Values) error { + if f == nil || len(f.Paths) == 0 { + return nil + } + if v == nil { + return fmt.Errorf("url.Values is nil") + } + + v.Set(key, strings.Join(f.Paths, ",")) + return nil +} diff --git a/common/types/fieldmask/fieldmask_test.go b/common/types/fieldmask/fieldmask_test.go new file mode 100644 index 000000000..506e94d8b --- /dev/null +++ b/common/types/fieldmask/fieldmask_test.go @@ -0,0 +1,210 @@ +package fieldmask + +import ( + "net/url" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestNew(t *testing.T) { + paths := []string{"name", "age", "email"} + fieldMask := New(paths) + + if diff := cmp.Diff(fieldMask.Paths, paths); diff != "" { + t.Errorf("New() paths mismatch (-want +got):\n%s", diff) + } +} + +func TestFieldMask_MarshalJSON(t *testing.T) { + tests := []struct { + name string + fieldMask FieldMask + expected string + wantErr bool + }{ + { + name: "empty fields", + fieldMask: FieldMask{Paths: []string{}}, + expected: `""`, + }, + { + name: "single field", + fieldMask: FieldMask{Paths: []string{"name"}}, + expected: `"name"`, + }, + { + name: "multiple fields", + fieldMask: FieldMask{Paths: []string{"name", "age", "email"}}, + expected: `"name,age,email"`, + }, + { + name: "fields with spaces", + fieldMask: FieldMask{Paths: []string{"first name", "last name"}}, + expected: `"first name,last name"`, + }, + { + name: "fields with special characters", + fieldMask: FieldMask{Paths: []string{"user_id", "created_at", "is_active"}}, + expected: `"user_id,created_at,is_active"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := tt.fieldMask.MarshalJSON() + if (err != nil) != tt.wantErr { + t.Errorf("FieldMask.MarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if string(result) != tt.expected { + t.Errorf("FieldMask.MarshalJSON() = %v, want %v", string(result), tt.expected) + } + }) + } +} + +func TestFieldMask_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + input string + want FieldMask + wantErr bool + }{ + { + name: "empty string", + input: `""`, + want: FieldMask{Paths: []string{}}, + wantErr: false, + }, + { + name: "single field", + input: `"name"`, + want: FieldMask{Paths: []string{"name"}}, + wantErr: false, + }, + { + name: "multiple fields", + input: `"name,age,email"`, + want: FieldMask{Paths: []string{"name", "age", "email"}}, + wantErr: false, + }, + { + name: "fields with spaces", + input: `"first name,last name"`, + want: FieldMask{Paths: []string{"first name", "last name"}}, + wantErr: false, + }, + { + name: "fields with special characters", + input: `"user_id,created_at,is_active"`, + want: FieldMask{Paths: []string{"user_id", "created_at", "is_active"}}, + wantErr: false, + }, + { + name: "trailing comma", + input: `"name,age,"`, + want: FieldMask{Paths: []string{"name", "age", ""}}, + wantErr: false, + }, + { + name: "invalid JSON", + input: `invalid`, + want: FieldMask{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var result FieldMask + err := result.UnmarshalJSON([]byte(tt.input)) + + if (err != nil) != tt.wantErr { + t.Errorf("FieldMask.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if diff := cmp.Diff(result, tt.want); diff != "" { + t.Errorf("FieldMask.UnmarshalJSON() = %v, want %v, diff %v", result, tt.want, diff) + } + }) + } +} + +func TestFieldMask_UnmarshalJSON_NilPointer(t *testing.T) { + var fieldMask *FieldMask + err := fieldMask.UnmarshalJSON([]byte(`"name,age"`)) + + if err == nil { + t.Error("FieldMask.UnmarshalJSON() on nil pointer should return error") + } + + expectedErr := "FieldMask.UnmarshalJSON on nil pointer" + if err.Error() != expectedErr { + t.Errorf("FieldMask.UnmarshalJSON() error = %v, want %v", err.Error(), expectedErr) + } +} + +func TestFieldMask_EncodeValues(t *testing.T) { + tests := []struct { + name string + fieldMask FieldMask + key string + want string + wantErr bool + }{ + { + name: "empty fields", + fieldMask: FieldMask{Paths: []string{}}, + key: "fields", + want: "", + wantErr: false, + }, + { + name: "single field", + fieldMask: FieldMask{Paths: []string{"name"}}, + key: "fields", + want: "name", + wantErr: false, + }, + { + name: "multiple fields", + fieldMask: FieldMask{Paths: []string{"name", "age", "email"}}, + key: "fields", + want: "name,age,email", + wantErr: false, + }, + { + name: "fields with spaces", + fieldMask: FieldMask{Paths: []string{"first name", "last name"}}, + key: "fields", + want: "first name,last name", + wantErr: false, + }, + { + name: "custom key", + fieldMask: FieldMask{Paths: []string{"id", "status"}}, + key: "select", + want: "id,status", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + values := url.Values{} + err := tt.fieldMask.EncodeValues(tt.key, &values) + + if (err != nil) != tt.wantErr { + t.Errorf("FieldMask.EncodeValues() error = %v, wantErr %v", err, tt.wantErr) + return + } + + result := values.Get(tt.key) + if result != tt.want { + t.Errorf("FieldMask.EncodeValues() = %v, want %v", result, tt.want) + } + }) + } +} diff --git a/common/types/time/time.go b/common/types/time/time.go new file mode 100644 index 000000000..349c64f56 --- /dev/null +++ b/common/types/time/time.go @@ -0,0 +1,67 @@ +package time + +import ( + "net/url" + "strings" + "time" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// Time is a wrapper for timestamppb.Timestamp to provide custom marshaling +// for JSON and URL query strings. +// +// It embeds timestamppb.Timestamp and exposes the .AsTime() method to +// easily convert to time.Time. +// +// Example: +// +// customTime := time.New(stdtime.Now()) +// goTime := customTime.AsTime() +type Time struct { + internal *timestamppb.Timestamp +} + +// New creates a custom Time from a standard time.Time. +func New(t time.Time) *Time { + return &Time{internal: timestamppb.New(t)} +} + +// AsTime returns the underlying time.Time value. +func (x *Time) AsTime() time.Time { + if x == nil { + return time.Time{} + } + return x.internal.AsTime() +} + +// MarshalJSON implements the [json.Marshaler] interface +// by marshalling the time as a protobuf Timestamp. +func (t Time) MarshalJSON() ([]byte, error) { + return protojson.Marshal(t.internal) +} + +// UnmarshalJSON implements the [json.Unmarshaler] interface +// by unmarshalling the time from a protobuf Timestamp. +func (t *Time) UnmarshalJSON(b []byte) error { + var pb timestamppb.Timestamp + if err := protojson.Unmarshal(b, &pb); err != nil { + return err + } + *t = *New(pb.AsTime()) + return nil +} + +// EncodeValues implements the [query.Encoder] interface by encoding the +// time as a protobuf Timestamp. +func (t Time) EncodeValues(key string, v *url.Values) error { + res, err := protojson.Marshal(t.internal) + if err != nil { + return err + } + // remove the quotes from the string. + queryValue := strings.Trim(string(res), "\"") + v.Set(key, queryValue) + return nil +} diff --git a/common/types/time/time_test.go b/common/types/time/time_test.go new file mode 100644 index 000000000..1d2c9f50e --- /dev/null +++ b/common/types/time/time_test.go @@ -0,0 +1,278 @@ +package time + +import ( + "encoding/json" + "net/url" + "testing" + "time" +) + +func TestAsTime(t *testing.T) { + now := time.Now() + timeVal := New(now) + result := timeVal.AsTime() + if !result.Equal(now) { + t.Errorf("AsTime() = %v, want %v", result, now) + } +} + +func TestAsTime_NilPointer(t *testing.T) { + var timeVal *Time + result := timeVal.AsTime() + expected := time.Time{} + if !result.Equal(expected) { + t.Errorf("AsTime() on nil = %v, want %v", result, expected) + } +} + +func TestTime_MarshalJSON(t *testing.T) { + tests := []struct { + name string + time Time + expected string + wantErr bool + }{ + { + name: "zero time", + time: *New(time.Time{}), + expected: `"0001-01-01T00:00:00Z"`, + }, + { + name: "specific time", + time: *New(time.Date(2023, 12, 25, 10, 30, 0, 0, time.UTC)), + expected: `"2023-12-25T10:30:00Z"`, + }, + { + name: "time with nanoseconds", + time: *New(time.Date(2023, 12, 25, 10, 30, 0, 123456789, time.UTC)), + expected: `"2023-12-25T10:30:00.123456789Z"`, + }, + { + name: "time with timezone", + time: *New(time.Date(2023, 12, 25, 10, 30, 0, 0, time.FixedZone("EST", -5*3600))), + expected: `"2023-12-25T15:30:00Z"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := tt.time.MarshalJSON() + if (err != nil) != tt.wantErr { + t.Errorf("Time.MarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if string(result) != tt.expected { + t.Errorf("Time.MarshalJSON() = %v, want %v", string(result), tt.expected) + } + }) + } +} + +func TestTime_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + input string + want Time + wantErr bool + }{ + { + name: "zero time", + input: `"0001-01-01T00:00:00Z"`, + want: *New(time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)), + wantErr: false, + }, + { + name: "specific time", + input: `"2023-12-25T10:30:00Z"`, + want: *New(time.Date(2023, 12, 25, 10, 30, 0, 0, time.UTC)), + wantErr: false, + }, + { + name: "time with nanoseconds", + input: `"2023-12-25T10:30:00.123456789Z"`, + want: *New(time.Date(2023, 12, 25, 10, 30, 0, 123456789, time.UTC)), + wantErr: false, + }, + { + name: "time with timezone", + input: `"2023-12-25T10:30:00-05:00"`, + want: *New(time.Date(2023, 12, 25, 10, 30, 0, 0, time.FixedZone("", -5*3600))), + wantErr: false, + }, + { + name: "empty string", + input: `""`, + wantErr: true, + }, + { + name: "invalid time format", + input: `"invalid-time"`, + want: *New(time.Time{}), + wantErr: true, + }, + { + name: "invalid JSON", + input: `"invalid-json"`, + want: *New(time.Time{}), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var timeVal Time + err := timeVal.UnmarshalJSON([]byte(tt.input)) + if (err != nil) != tt.wantErr { + t.Errorf("Time.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !timeVal.AsTime().Equal(tt.want.AsTime()) { + t.Errorf("Time.UnmarshalJSON() = %v, want %v", timeVal, tt.want) + } + }) + } +} + +func TestTime_EncodeValues(t *testing.T) { + tests := []struct { + name string + time Time + key string + expected string + }{ + { + name: "zero time", + time: *New(time.Time{}), + key: "created_at", + expected: "0001-01-01T00:00:00Z", + }, + { + name: "specific time", + time: *New(time.Date(2023, 12, 25, 10, 30, 0, 0, time.UTC)), + key: "updated_at", + expected: "2023-12-25T10:30:00Z", + }, + { + name: "time with nanoseconds", + time: *New(time.Date(2023, 12, 25, 10, 30, 0, 123456789, time.UTC)), + key: "timestamp", + expected: "2023-12-25T10:30:00.123456789Z", + }, + { + name: "time with timezone", + time: *New(time.Date(2023, 12, 25, 10, 30, 0, 0, time.FixedZone("EST", -5*3600))), + key: "local_time", + expected: "2023-12-25T15:30:00Z", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + values := url.Values{} + err := tt.time.EncodeValues(tt.key, &values) + if err != nil { + t.Errorf("Time.EncodeValues() error = %v", err) + return + } + result := values.Get(tt.key) + if result != tt.expected { + t.Errorf("Time.EncodeValues() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestTime_JSONRoundTrip(t *testing.T) { + tests := []struct { + name string + time Time + }{ + { + name: "zero time", + time: *New(time.Time{}), + }, + { + name: "specific time", + time: *New(time.Date(2023, 12, 25, 10, 30, 0, 0, time.UTC)), + }, + { + name: "time with nanoseconds", + time: *New(time.Date(2023, 12, 25, 10, 30, 0, 123456789, time.UTC)), + }, + { + name: "time with timezone", + time: *New(time.Date(2023, 12, 25, 10, 30, 0, 0, time.FixedZone("EST", -5*3600))), + }, + { + name: "current time", + time: *New(time.Now()), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Marshal to JSON + jsonData, err := json.Marshal(tt.time) + if err != nil { + t.Errorf("json.Marshal() error = %v", err) + return + } + + // Unmarshal from JSON + var result Time + err = json.Unmarshal(jsonData, &result) + if err != nil { + t.Errorf("json.Unmarshal() error = %v", err) + return + } + + // Check that the round trip preserved the value + if !result.AsTime().Equal(tt.time.AsTime()) { + t.Errorf("JSON round trip failed: original = %v, result = %v", tt.time, result) + } + }) + } +} + +func TestTime_EdgeCases(t *testing.T) { + tests := []struct { + name string + time time.Time + }{ + { + name: "very old time", + time: time.Date(1000, 1, 1, 0, 0, 0, 0, time.UTC), + }, + { + name: "very future time", + time: time.Date(9999, 12, 31, 23, 59, 59, 999999999, time.UTC), + }, + { + name: "leap year time", + time: time.Date(2024, 2, 29, 12, 0, 0, 0, time.UTC), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + timeVal := *New(tt.time) + + jsonData, err := json.Marshal(timeVal) + if err != nil { + t.Errorf("json.Marshal() error = %v", err) + return + } + + var result Time + err = json.Unmarshal(jsonData, &result) + if err != nil { + t.Errorf("json.Unmarshal() error = %v", err) + return + } + + if !result.AsTime().Equal(tt.time) { + t.Errorf("Time round trip failed: original = %v, result = %v", tt.time, result) + } + }) + } +} diff --git a/go.mod b/go.mod index 9dabdc5ba..9baa954b4 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( golang.org/x/text v0.21.0 golang.org/x/time v0.5.0 google.golang.org/api v0.182.0 + google.golang.org/protobuf v1.34.1 gopkg.in/ini.v1 v1.67.0 ) @@ -41,6 +42,5 @@ require ( golang.org/x/sys v0.28.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect google.golang.org/grpc v1.64.1 // indirect - google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect )