diff --git a/backend/pkg/httpserver/create_notification_channel.go b/backend/pkg/httpserver/create_notification_channel.go index 81f886664..94969d06f 100644 --- a/backend/pkg/httpserver/create_notification_channel.go +++ b/backend/pkg/httpserver/create_notification_channel.go @@ -37,8 +37,12 @@ func validateNotificationChannel(input *backend.CreateNotificationChannelRequest if err := httputils.ValidateSlackWebhookURL(cfg.Url); err != nil { fieldErrors.addFieldError("config.url", err) } + } else if cfg, err := input.Config.AsRSSConfig(); err == nil && cfg.Type == backend.RSSConfigTypeRss { + // RSS channels currently have no configuration fields to validate. + _ = cfg } else { - fieldErrors.addFieldError("config", errors.New("invalid config: only webhook channels can be created manually")) + fieldErrors.addFieldError("config", + errors.New("invalid config: only webhook or rss channels can be created manually")) } if fieldErrors.hasErrors() { diff --git a/backend/pkg/httpserver/create_notification_channel_test.go b/backend/pkg/httpserver/create_notification_channel_test.go index eb3f9650d..e3aa56d77 100644 --- a/backend/pkg/httpserver/create_notification_channel_test.go +++ b/backend/pkg/httpserver/create_notification_channel_test.go @@ -87,6 +87,51 @@ func TestCreateNotificationChannel(t *testing.T) { "updated_at": "2000-01-01T00:00:00Z" }`), }, + { + name: "success rss", + requestBody: ` +{ + "name": "My RSS Feed", + "config": { + "type": "rss" + } +}`, + storerCfg: &MockCreateNotificationChannelConfig{ + expectedUserID: testUser.ID, + expectedRequest: backend.CreateNotificationChannelRequest{ + Name: "My RSS Feed", + Config: newTestCreateNotificationChannelConfig(t, backend.RSSConfig{ + Type: backend.RSSConfigTypeRss, + }), + }, + output: &backend.NotificationChannelResponse{ + Id: "channel456", + Name: "My RSS Feed", + Type: backend.NotificationChannelResponseTypeRss, + Config: newTestNotificationChannelConfig(t, backend.RSSConfig{ + Type: backend.RSSConfigTypeRss, + }), + Status: backend.NotificationChannelStatusEnabled, + CreatedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), + }, + err: nil, + }, + expectedCallCount: 1, + expectedResponse: testJSONResponse(201, ` +{ + "id": "channel456", + "name": "My RSS Feed", + "type": "rss", + "config": { + "type": "rss" + }, + "status": "enabled", + "created_at": "2000-01-01T00:00:00Z", + "updated_at": "2000-01-01T00:00:00Z" +}`), + }, + { name: "reject email config", // Attempt to create an email channel manually should be rejected. @@ -106,7 +151,7 @@ func TestCreateNotificationChannel(t *testing.T) { "code": 400, "message": "input validation errors", "errors": { - "config": "invalid config: only webhook channels can be created manually" + "config": "invalid config: only webhook or rss channels can be created manually" } }`), }, diff --git a/backend/pkg/httpserver/update_notification_channel.go b/backend/pkg/httpserver/update_notification_channel.go index 9f2d15d28..441fed523 100644 --- a/backend/pkg/httpserver/update_notification_channel.go +++ b/backend/pkg/httpserver/update_notification_channel.go @@ -49,9 +49,17 @@ func validateUpdateNotificationChannel(request *backend.UpdateNotificationChanne if err := httputils.ValidateSlackWebhookURL(cfg.Url); err != nil { fieldErrors.addFieldError("config.url", err) } + } else if cfg, err := request.Config.AsRSSConfig(); err == nil && + cfg.Type == backend.RSSConfigTypeRss { + // RSS channels currently have no configuration fields to validate. + _ = cfg } else { - fieldErrors.addFieldError("config", errors.New("invalid config: only webhook updates are supported")) + fieldErrors.addFieldError( + "config", + errors.New("invalid config: only webhook or rss updates are supported"), + ) } + } } diff --git a/backend/pkg/httpserver/update_notification_channel_test.go b/backend/pkg/httpserver/update_notification_channel_test.go index c96e720ad..c71ab73b7 100644 --- a/backend/pkg/httpserver/update_notification_channel_test.go +++ b/backend/pkg/httpserver/update_notification_channel_test.go @@ -102,6 +102,65 @@ func TestUpdateNotificationChannel_Restrictions(t *testing.T) { }, expectedUpdateCount: 1, }, + + { + name: "success rss update", + requestBody: ` +{ + "name": "Updated RSS", + "update_mask": ["name"] +}`, + expectedStatus: 200, + expectedResponseBody: ` +{ + "id": "channel123", + "name": "Updated RSS", + "type": "rss", + "config": { + "type": "rss" + }, + "status": "enabled", + "created_at": "2000-01-01T00:00:00Z", + "updated_at": "2000-01-01T00:00:00Z" +}`, + expectFetch: true, + expectedGetOutput: &backend.NotificationChannelResponse{ + Id: "channel123", + Name: "Old RSS", + Type: backend.NotificationChannelResponseTypeRss, + Config: newTestNotificationChannelConfig(t, backend.RSSConfig{ + Type: backend.RSSConfigTypeRss, + }), + Status: backend.NotificationChannelStatusEnabled, + CreatedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), + }, + updateStorerCfg: &MockUpdateNotificationChannelConfig{ + expectedUserID: testUser.ID, + expectedChannelID: "channel123", + expectedRequest: backend.UpdateNotificationChannelRequest{ + Config: nil, + Name: new("Updated RSS"), + UpdateMask: []backend.UpdateNotificationChannelRequestUpdateMask{ + backend.UpdateNotificationChannelRequestMaskName, + }, + }, + output: &backend.NotificationChannelResponse{ + Id: "channel123", + Name: "Updated RSS", + Type: backend.NotificationChannelResponseTypeRss, + Config: newTestNotificationChannelConfig(t, backend.RSSConfig{ + Type: backend.RSSConfigTypeRss, + }), + Status: backend.NotificationChannelStatusEnabled, + CreatedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), + }, + err: nil, + }, + expectedUpdateCount: 1, + }, + { name: "reject update to existing email channel (rename)", requestBody: ` @@ -159,7 +218,7 @@ func TestUpdateNotificationChannel_Restrictions(t *testing.T) { "code": 400, "message": "input validation errors", "errors": { - "config": "invalid config: only webhook updates are supported" + "config": "invalid config: only webhook or rss updates are supported" } }`, expectFetch: false, diff --git a/lib/gcpspanner/notification_channel.go b/lib/gcpspanner/notification_channel.go index 51521fa24..584f5abc3 100644 --- a/lib/gcpspanner/notification_channel.go +++ b/lib/gcpspanner/notification_channel.go @@ -68,6 +68,7 @@ type NotificationChannelType string const ( NotificationChannelTypeEmail NotificationChannelType = "email" NotificationChannelTypeWebhook NotificationChannelType = "webhook" + NotificationChannelTypeRSS NotificationChannelType = "rss" ) func getAllNotificationTypes() []NotificationChannelType { @@ -76,6 +77,7 @@ func getAllNotificationTypes() []NotificationChannelType { types := map[NotificationChannelType]any{ NotificationChannelTypeEmail: nil, NotificationChannelTypeWebhook: nil, + NotificationChannelTypeRSS: nil, } ret := make([]NotificationChannelType, 0, len(types)) @@ -84,7 +86,6 @@ func getAllNotificationTypes() []NotificationChannelType { } return ret - } // CreateNotificationChannelRequest is the request to create a channel. @@ -175,6 +176,8 @@ func (c *NotificationChannel) toSpanner() *spannerNotificationChannel { configData = c.EmailConfig case NotificationChannelTypeWebhook: configData = c.WebhookConfig + case NotificationChannelTypeRSS: + configData = nil } var config spanner.NullJSON @@ -201,6 +204,8 @@ func (sc *spannerNotificationChannel) toPublic() (*NotificationChannel, error) { channelType = NotificationChannelTypeEmail case string(NotificationChannelTypeWebhook): channelType = NotificationChannelTypeWebhook + case string(NotificationChannelTypeRSS): + channelType = NotificationChannelTypeRSS default: return nil, fmt.Errorf("unknown notification channel type '%s'", sc.Type) } @@ -258,6 +263,9 @@ func loadSubscriptionConfigs( } ret.WebhookConfig = &webhookConfig ret.EmailConfig = nil + case NotificationChannelTypeRSS: + ret.WebhookConfig = nil + ret.EmailConfig = nil } return ret, nil diff --git a/lib/gcpspanner/spanneradapters/backend.go b/lib/gcpspanner/spanneradapters/backend.go index 9e017abfc..78659491a 100644 --- a/lib/gcpspanner/spanneradapters/backend.go +++ b/lib/gcpspanner/spanneradapters/backend.go @@ -19,6 +19,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "log/slog" "math/big" "slices" @@ -188,8 +189,13 @@ func (s *Backend) ListSavedSearchNotificationEvents(ctx context.Context, for _, e := range notifEvents { var summaryBytes []byte if e.Summary.Valid { - summaryBytes, _ = json.Marshal(e.Summary.Value) + var err error + summaryBytes, err = json.Marshal(e.Summary.Value) + if err != nil { + return nil, fmt.Errorf("failed to marshal summary for event %s: %w", e.ID, err) + } } + events = append(events, backendtypes.SavedSearchNotificationEvent{ ID: e.ID, SavedSearchID: e.SavedSearchID, @@ -598,6 +604,8 @@ func (s *Backend) CreateNotificationChannel(ctx context.Context, if cfg, err := req.Config.AsWebhookConfig(); err == nil && cfg.Type == backend.Webhook { channelType = gcpspanner.NotificationChannelTypeWebhook spannerWebhookConfig = s.toSpannerWebhookConfig(&cfg) + } else if cfg, err := req.Config.AsRSSConfig(); err == nil && cfg.Type == backend.RSSConfigTypeRss { + channelType = gcpspanner.NotificationChannelTypeRSS } else { return nil, errors.New("invalid notification channel request: missing or invalid config") } @@ -677,9 +685,14 @@ func (s *Backend) UpdateNotificationChannel( updateReq.WebhookConfig.Value = &gcpspanner.WebhookConfig{ URL: cfg.Url, } + } else if cfg, err := req.Config.AsRSSConfig(); err == nil && + cfg.Type == backend.RSSConfigTypeRss { + updateReq.Type.IsSet = true + updateReq.Type.Value = gcpspanner.NotificationChannelTypeRSS } else { return nil, errors.New("invalid notification channel update: unsupported config type") } + } } @@ -708,6 +721,8 @@ func getChannelSortKey(channel gcpspanner.NotificationChannel) string { if channel.WebhookConfig != nil { return channel.WebhookConfig.URL } + case gcpspanner.NotificationChannelTypeRSS: + // RSS channels don't have a specific configuration field to use as a sort key. } return "" // Default sort key if type is unknown or has no specific key. @@ -717,7 +732,6 @@ func getChannelSortKey(channel gcpspanner.NotificationChannel) string { // notification channel to backend notification channel. func toBackendNotificationChannel(channel *gcpspanner.NotificationChannel) *backend.NotificationChannelResponse { if channel == nil { - return nil } @@ -726,22 +740,23 @@ func toBackendNotificationChannel(channel *gcpspanner.NotificationChannel) *back switch channel.Type { case gcpspanner.NotificationChannelTypeEmail: if channel.EmailConfig != nil { - bytes, _ := json.Marshal(backend.EmailConfig{ + _ = config.FromEmailConfig(backend.EmailConfig{ Type: backend.EmailConfigTypeEmail, Address: openapi_types.Email(channel.EmailConfig.Address), }) - // UnmarshalJSON() is confusingly named - it just makes a copy of 'bytes' to store in config. - _ = config.UnmarshalJSON(bytes) } case gcpspanner.NotificationChannelTypeWebhook: if channel.WebhookConfig != nil { - bytes, _ := json.Marshal(backend.WebhookConfig{ + _ = config.FromWebhookConfig(backend.WebhookConfig{ Type: backend.Webhook, Url: channel.WebhookConfig.URL, }) - // UnmarshalJSON() is confusingly named - it just makes a copy of 'bytes' to store in config. - _ = config.UnmarshalJSON(bytes) } + case gcpspanner.NotificationChannelTypeRSS: + _ = config.FromRSSConfig(backend.RSSConfig{ + Type: backend.RSSConfigTypeRss, + }) + } return &backend.NotificationChannelResponse{