diff --git a/notifications/README.md b/notifications/README.md index 9e277277..7bf77077 100644 --- a/notifications/README.md +++ b/notifications/README.md @@ -10,36 +10,30 @@ A multi-channel notification router with a pluggable provider model. Your applic ## Usage -```go -import notification "github.com/OpenNSW/core/notifications" +Each provider takes its own typed config in its constructor and is handed, ready to send, to `NewManager`: -manager, err := notification.NewManager( - notification.Config{ - Providers: map[notification.ChannelType]map[string]any{ - notification.ChannelEmail: { - "api_key": "sg-xxxxx", - "from_address": "noreply@example.com", - }, - notification.ChannelSMS: { - "account_sid": "ACxxxxx", - "auth_token": "xxxxx", - "from_number": "+61400000000", - }, - }, - }, - myEmailProvider, - mySMSProvider, +```go +import ( + notification "github.com/OpenNSW/core/notifications" + "github.com/OpenNSW/core/notifications/providers" ) +sms, err := providers.NewSMSProvider(cfg.Notification.SMS) +email, err := providers.NewEmailProvider(cfg.Notification.Email) + +manager, err := notification.NewManager(sms, email) + err = manager.Send(ctx, notification.Request{ - Channel: notification.ChannelEmail, - To: "applicant@example.com", - Subject: "Application received", - Body: "Your application #12345 has been received and is under review.", + Channel: notification.ChannelEmail, + To: "applicant@example.com", + Subject: "Application received", + Body: "Your application #12345 has been received and is under review.", HTMLBody: "
Your application #12345 has been received.
", }) ``` +`NewManager` returns an error when it gets no providers, a nil one, or two for the same channel. + ## Channels | Constant | Value | @@ -53,48 +47,52 @@ Implement `notification.Provider`: ```go type Provider interface { - Type() ChannelType - Configure(cfg json.RawMessage) error + Type() ChannelType Send(ctx context.Context, req Request) error } ``` - `Type()` declares which channel this provider handles. -- `Configure` is called at startup with the provider's block from `Config.Providers`, re-marshaled to JSON (so existing `Provider` implementations are unaffected by how the block was sourced). - `Send` delivers the message. -```go -type MyEmailProvider struct { - apiKey string -} +Give the provider its own exported config type, with `yaml` tags, and a constructor that validates it and returns a ready provider, as `providers.NewSMSProvider` and `providers.NewEmailProvider` do: -func (p *MyEmailProvider) Type() notification.ChannelType { return notification.ChannelEmail } - -func (p *MyEmailProvider) Configure(cfg json.RawMessage) error { - var c struct{ APIKey string `json:"api_key"` } - if err := json.Unmarshal(cfg, &c); err != nil { return err } - p.apiKey = c.APIKey - return nil +```go +type MyEmailConfig struct { + APIKey string `yaml:"apiKey"` } -func (p *MyEmailProvider) Send(ctx context.Context, req notification.Request) error { - // send via your email API - return nil +func NewMyEmailProvider(cfg MyEmailConfig) (*MyEmailProvider, error) { + if cfg.APIKey == "" { + return nil, errors.New("apiKey is required") + } + return &MyEmailProvider{apiKey: cfg.APIKey}, nil } ``` ## Provider configuration -`Config.Providers` holds provider-specific configuration keyed by channel type — no standalone config file is needed. It carries a `yaml` struct tag (`providers`), so it can be embedded in a larger application config struct and populated generically, e.g. via [`configyaml.LoadAndExpand`](../configyaml/README.md) so a provider's API key can be sourced from an env var or a mounted file instead of living in the checked-in config: +An application embeds the provider configs in its own config struct and loads it with [`configyaml.LoadAndExpand`](../configyaml/README.md), so a provider's credentials can come from an env var or a mounted file instead of the checked-in config: + +```go +type AppConfig struct { + Notification struct { + SMS providers.SMSConfig `yaml:"sms"` + Email providers.EmailConfig `yaml:"email"` + } `yaml:"notification"` +} +``` ```yaml notification: - providers: - email: - api_key: "{{env:SENDGRID_API_KEY}}" - from_address: noreply@example.com - sms: - account_sid: ACxxxxx - auth_token: "{{env:SMS_AUTH_TOKEN}}" - from_number: "+61400000000" + sms: + baseURL: https://sms.example.com + userName: nsw + password: "{{env:SMS_PASSWORD}}" + sidCode: NSW + email: + baseURL: https://email.example.com + token: "{{env:EMAIL_TOKEN}}" ``` + +The config fields are typed, so a value decodes into its field's type: a secret that only looks like a number (a password of `12345678`, a SID code of `0123`) stays the string it was. The email `token` is the bearer token itself; configyaml resolves the placeholder, and the provider uses it as written. diff --git a/notifications/config.go b/notifications/config.go deleted file mode 100644 index 09e35749..00000000 --- a/notifications/config.go +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2026 Lanka Software Foundation - -package notification - -import "errors" - -// ErrProvidersRequired is returned by Config.Validate when Providers is empty. -var ErrProvidersRequired = errors.New("notification providers configuration is required") - -// Config holds the notifications subsystem configuration: one settings block -// per channel, keyed by ChannelType (e.g. "email", "sms"), handed to the -// matching Provider's Configure call. -// -// Config carries yaml struct tags, so it can be embedded in a larger -// application config struct and populated generically (e.g. via -// yaml.Unmarshal, or configyaml.LoadAndExpand for {{env:}}/{{file:}} secret -// placeholders within a provider's block) instead of pointing at its own -// standalone config file. -type Config struct { - Providers map[ChannelType]map[string]any `yaml:"providers"` -} - -// Validate returns ErrProvidersRequired when Providers is empty. -func (c Config) Validate() error { - if len(c.Providers) == 0 { - return ErrProvidersRequired - } - return nil -} diff --git a/notifications/config_test.go b/notifications/config_test.go deleted file mode 100644 index 6bb7db2c..00000000 --- a/notifications/config_test.go +++ /dev/null @@ -1,40 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2026 Lanka Software Foundation - -package notification - -import ( - "errors" - "testing" -) - -func TestConfig_Validate(t *testing.T) { - t.Parallel() - tests := []struct { - name string - config Config - wantErr error - }{ - {name: "no providers", config: Config{}, wantErr: ErrProvidersRequired}, - {name: "empty providers map", config: Config{Providers: map[ChannelType]map[string]any{}}, wantErr: ErrProvidersRequired}, - {name: "valid providers", config: Config{Providers: map[ChannelType]map[string]any{ - ChannelEmail: {"host": "localhost"}, - }}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - err := tt.config.Validate() - if tt.wantErr != nil { - if !errors.Is(err, tt.wantErr) { - t.Errorf("got %v, want %v", err, tt.wantErr) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - } -} diff --git a/notifications/go.mod b/notifications/go.mod index 555ec1c8..3564cb5b 100644 --- a/notifications/go.mod +++ b/notifications/go.mod @@ -4,5 +4,7 @@ go 1.26 require ( github.com/OpenNSW/core/remote v0.8.0 - github.com/OpenNSW/core/secret v0.2.0 + gopkg.in/yaml.v3 v3.0.1 ) + +require github.com/OpenNSW/core/secret v0.2.0 // indirect diff --git a/notifications/go.sum b/notifications/go.sum index 4460318d..4452f5c7 100644 --- a/notifications/go.sum +++ b/notifications/go.sum @@ -8,5 +8,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/notifications/manager.go b/notifications/manager.go index 6676da63..cb19bcaa 100644 --- a/notifications/manager.go +++ b/notifications/manager.go @@ -5,7 +5,6 @@ package notification import ( "context" - "encoding/json" "errors" "fmt" "log/slog" @@ -17,13 +16,16 @@ type Manager struct { providers map[ChannelType]Provider } -// NewManager configures each provider from its block in cfg.Providers and -// returns a ready Manager. Returns an error if cfg is invalid, a provider's -// channel is missing from cfg.Providers, or any provider's Configure call -// fails. -func NewManager(cfg Config, providers ...Provider) (*Manager, error) { - if err := cfg.Validate(); err != nil { - return nil, fmt.Errorf("invalid notification config: %w", err) +// ErrNoProviders is returned by NewManager when it is given no providers. +var ErrNoProviders = errors.New("notification: at least one provider is required") + +// NewManager returns a Manager that routes each request to the provider for +// its channel. The providers are already configured by their constructors. +// Returns an error if none is given, one is nil, or two handle the same +// channel. +func NewManager(providers ...Provider) (*Manager, error) { + if len(providers) == 0 { + return nil, ErrNoProviders } m := &Manager{ @@ -34,20 +36,6 @@ func NewManager(cfg Config, providers ...Provider) (*Manager, error) { if p == nil { return nil, errors.New("nil provider passed to NewManager") } - block, ok := cfg.Providers[p.Type()] - if !ok { - return nil, fmt.Errorf("no config for %q provider", p.Type()) - } - // Providers still configure from json.RawMessage (unchanged interface, - // so every existing Provider keeps working); the block just arrives - // from cfg.Providers now instead of a standalone JSON file. - raw, err := json.Marshal(block) - if err != nil { - return nil, fmt.Errorf("marshal %q provider config: %w", p.Type(), err) - } - if err := p.Configure(raw); err != nil { - return nil, fmt.Errorf("configure %q provider: %w", p.Type(), err) - } if _, dup := m.providers[p.Type()]; dup { return nil, fmt.Errorf("duplicate notification provider for channel %q", p.Type()) } diff --git a/notifications/manager_test.go b/notifications/manager_test.go index 81839c0a..4ee93218 100644 --- a/notifications/manager_test.go +++ b/notifications/manager_test.go @@ -5,27 +5,19 @@ package notification import ( "context" - "encoding/json" "errors" "testing" ) // stubProvider is a test double for Provider. type stubProvider struct { - channelType ChannelType - configureErr error - sendErr error - configureCalled bool - sendCalled bool + channelType ChannelType + sendErr error + sendCalled bool } func (s *stubProvider) Type() ChannelType { return s.channelType } -func (s *stubProvider) Configure(_ json.RawMessage) error { - s.configureCalled = true - return s.configureErr -} - func (s *stubProvider) Send(_ context.Context, _ Request) error { s.sendCalled = true return s.sendErr @@ -34,24 +26,14 @@ func (s *stubProvider) Send(_ context.Context, _ Request) error { func TestNewManager(t *testing.T) { t.Parallel() - validReq := func(ch ChannelType) Request { - return Request{Channel: ch, To: "a@b.com", Body: "hi"} - } - - t.Run("happy path — single provider", func(t *testing.T) { + t.Run("routes to the provider for the channel", func(t *testing.T) { t.Parallel() - cfg := Config{Providers: map[ChannelType]map[string]any{ - ChannelEmail: {"host": "localhost"}, - }} p := &stubProvider{channelType: ChannelEmail} - m, err := NewManager(cfg, p) + m, err := NewManager(p) if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !p.configureCalled { - t.Error("Configure was not called") + t.Fatalf("NewManager: %v", err) } - if err := m.Send(context.Background(), validReq(ChannelEmail)); err != nil { + if err := m.Send(context.Background(), Request{Channel: ChannelEmail, To: "a@b.com", Body: "hi"}); err != nil { t.Errorf("Send: %v", err) } if !p.sendCalled { @@ -59,45 +41,24 @@ func TestNewManager(t *testing.T) { } }) - t.Run("missing config key for provider", func(t *testing.T) { - t.Parallel() - cfg := Config{Providers: map[ChannelType]map[string]any{ - ChannelSMS: {"host": "localhost"}, - }} - p := &stubProvider{channelType: ChannelEmail} - _, err := NewManager(cfg, p) - if err == nil { - t.Fatal("expected error, got nil") - } - }) - - t.Run("Configure failure propagates", func(t *testing.T) { + t.Run("no providers", func(t *testing.T) { t.Parallel() - cfg := Config{Providers: map[ChannelType]map[string]any{ChannelEmail: {}}} - configErr := errors.New("bad config") - p := &stubProvider{channelType: ChannelEmail, configureErr: configErr} - _, err := NewManager(cfg, p) - if !errors.Is(err, configErr) { - t.Errorf("got %v, want wrapping %v", err, configErr) + if _, err := NewManager(); !errors.Is(err, ErrNoProviders) { + t.Errorf("got %v, want ErrNoProviders", err) } }) - t.Run("invalid Config: no providers", func(t *testing.T) { + t.Run("nil provider", func(t *testing.T) { t.Parallel() - _, err := NewManager(Config{}) - if !errors.Is(err, ErrProvidersRequired) { - t.Errorf("got %v, want ErrProvidersRequired", err) + if _, err := NewManager(nil); err == nil { + t.Fatal("expected an error for a nil provider, got nil") } }) - t.Run("duplicate channel type returns error", func(t *testing.T) { + t.Run("two providers for one channel", func(t *testing.T) { t.Parallel() - cfg := Config{Providers: map[ChannelType]map[string]any{ChannelEmail: {"a": 1}}} - p1 := &stubProvider{channelType: ChannelEmail} - p2 := &stubProvider{channelType: ChannelEmail} - _, err := NewManager(cfg, p1, p2) - if err == nil { - t.Fatal("expected error for duplicate channel type, got nil") + if _, err := NewManager(&stubProvider{channelType: ChannelEmail}, &stubProvider{channelType: ChannelEmail}); err == nil { + t.Fatal("expected an error for a duplicate channel, got nil") } }) } @@ -107,8 +68,7 @@ func TestManager_Send(t *testing.T) { makeManager := func(t *testing.T, p *stubProvider) *Manager { t.Helper() - cfg := Config{Providers: map[ChannelType]map[string]any{p.channelType: {}}} - m, err := NewManager(cfg, p) + m, err := NewManager(p) if err != nil { t.Fatalf("NewManager: %v", err) } diff --git a/notifications/provider.go b/notifications/provider.go index 4a25e721..91cd3d2c 100644 --- a/notifications/provider.go +++ b/notifications/provider.go @@ -3,14 +3,12 @@ package notification -import ( - "context" - "encoding/json" -) +import "context" // Provider is implemented by each notification channel (email, SMS, etc.). +// A provider is built ready to send, from its own config type, by its +// constructor (e.g. providers.NewSMSProvider), and handed to NewManager. type Provider interface { Type() ChannelType - Configure(cfg json.RawMessage) error Send(ctx context.Context, req Request) error } diff --git a/notifications/providers/email.go b/notifications/providers/email.go index b9903863..3f9fdec4 100644 --- a/notifications/providers/email.go +++ b/notifications/providers/email.go @@ -5,7 +5,6 @@ package providers import ( "context" - "encoding/json" "errors" "fmt" "net/http" @@ -13,12 +12,16 @@ import ( notification "github.com/OpenNSW/core/notifications" "github.com/OpenNSW/core/remote" "github.com/OpenNSW/core/remote/auth" - "github.com/OpenNSW/core/secret" ) -type emailConfig struct { - BaseURL string `json:"baseURL"` - Token secret.SecretRef `json:"token"` +// EmailConfig is the email provider's configuration. It carries yaml tags so +// an application can embed it in its own config and load it with +// configyaml.LoadAndExpand. Token is the bearer token itself: a {{env:...}} +// or {{file:...}} placeholder is resolved by configyaml before the provider +// sees it, so it is used as written and never resolved a second time. +type EmailConfig struct { + BaseURL string `yaml:"baseURL"` + Token string `yaml:"token"` } type emailRequest struct { @@ -33,35 +36,22 @@ type EmailProvider struct { client *remote.Client } -// NewEmailProvider returns a new EmailProvider ready for Configure. -func NewEmailProvider() *EmailProvider { - return &EmailProvider{} -} - -func (e *EmailProvider) Type() notification.ChannelType { return notification.ChannelEmail } - -func (e *EmailProvider) Configure(raw json.RawMessage) error { - var cfg emailConfig - if err := json.Unmarshal(raw, &cfg); err != nil { - return fmt.Errorf("unmarshal email config: %w", err) - } +// NewEmailProvider validates cfg and returns an EmailProvider ready to send. +func NewEmailProvider(cfg EmailConfig) (*EmailProvider, error) { if cfg.BaseURL == "" { - return errors.New("baseURL is required") + return nil, errors.New("email: baseURL is required") } if err := validateBaseURL(cfg.BaseURL); err != nil { - return err + return nil, fmt.Errorf("email: %w", err) } - token, err := cfg.Token.Resolve() - if err != nil { - return fmt.Errorf("resolve email token: %w", err) + if cfg.Token == "" { + return nil, errors.New("email: token is required") } - if token == "" { - return errors.New("token is required") - } - e.client = remote.NewClient(cfg.BaseURL, remote.WithAuthenticator(auth.NewBearer(token))) - return nil + return &EmailProvider{client: remote.NewClient(cfg.BaseURL, remote.WithAuthenticator(auth.NewBearer(cfg.Token)))}, nil } +func (e *EmailProvider) Type() notification.ChannelType { return notification.ChannelEmail } + func (e *EmailProvider) Send(ctx context.Context, req notification.Request) error { if e.client == nil { return errors.New("email provider not configured") diff --git a/notifications/providers/providers_test.go b/notifications/providers/providers_test.go new file mode 100644 index 00000000..5e672497 --- /dev/null +++ b/notifications/providers/providers_test.go @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Lanka Software Foundation + +package providers_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gopkg.in/yaml.v3" + + notification "github.com/OpenNSW/core/notifications" + "github.com/OpenNSW/core/notifications/providers" +) + +// An application embeds the provider configs in its own config, as below. +// The unquoted values decode the way configyaml's resolved placeholders do: +// YAML would read them as a number, an octal number and a bool if the fields +// weren't strings. +func TestConfigs_KeepNumberLookingValuesAsWritten(t *testing.T) { + t.Parallel() + var cfg struct { + Notification struct { + SMS providers.SMSConfig `yaml:"sms"` + Email providers.EmailConfig `yaml:"email"` + } `yaml:"notification"` + } + err := yaml.Unmarshal([]byte(` +notification: + sms: + baseURL: https://sms.example.com + userName: user + password: 12345678 + sidCode: 0123 + email: + baseURL: https://email.example.com + token: true +`), &cfg) + if err != nil { + t.Fatalf("yaml.Unmarshal: %v", err) + } + + sms, email := cfg.Notification.SMS, cfg.Notification.Email + if sms.Password != "12345678" || sms.SIDCode != "0123" || email.Token != "true" { + t.Errorf("password, sidCode, token = %q, %q, %q; want \"12345678\", \"0123\", \"true\"", sms.Password, sms.SIDCode, email.Token) + } + if _, err := providers.NewSMSProvider(sms); err != nil { + t.Errorf("NewSMSProvider: %v", err) + } + if _, err := providers.NewEmailProvider(email); err != nil { + t.Errorf("NewEmailProvider: %v", err) + } +} + +func TestNewSMSProvider_Validates(t *testing.T) { + t.Parallel() + valid := providers.SMSConfig{BaseURL: "https://sms.example.com", SIDCode: "sid", UserName: "user", Password: "pass"} + for _, tc := range []struct { + name string + mutate func(*providers.SMSConfig) + want string + }{ + {"no baseURL", func(c *providers.SMSConfig) { c.BaseURL = "" }, "baseURL is required"}, + {"plain-HTTP baseURL", func(c *providers.SMSConfig) { c.BaseURL = "http://sms.example.com" }, "HTTPS"}, + {"no sidCode", func(c *providers.SMSConfig) { c.SIDCode = "" }, "sidCode is required"}, + {"no userName", func(c *providers.SMSConfig) { c.UserName = "" }, "userName is required"}, + {"no password", func(c *providers.SMSConfig) { c.Password = "" }, "password is required"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + cfg := valid + tc.mutate(&cfg) + if _, err := providers.NewSMSProvider(cfg); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("NewSMSProvider = %v, want an error containing %q", err, tc.want) + } + }) + } +} + +func TestNewEmailProvider_Validates(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + cfg providers.EmailConfig + want string + }{ + {"no baseURL", providers.EmailConfig{Token: "t"}, "baseURL is required"}, + {"no token", providers.EmailConfig{BaseURL: "https://email.example.com"}, "token is required"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if _, err := providers.NewEmailProvider(tc.cfg); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("NewEmailProvider = %v, want an error containing %q", err, tc.want) + } + }) + } +} + +// The token is used exactly as configured. configyaml has already resolved +// any placeholder, so a token that happens to look like a secret reference +// ("file:...", "env:...") must not be resolved again. +func TestEmailProvider_UsesTokenAsWritten(t *testing.T) { + t.Parallel() + got := make(chan string, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got <- r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + const token = "file:/not/a/path/on/this/machine" + p, err := providers.NewEmailProvider(providers.EmailConfig{BaseURL: srv.URL, Token: token}) + if err != nil { + t.Fatalf("NewEmailProvider: %v", err) + } + if err := p.Send(context.Background(), notification.Request{Channel: notification.ChannelEmail, To: "a@b.com", Body: "hi"}); err != nil { + t.Fatalf("Send: %v", err) + } + if auth := <-got; auth != "Bearer "+token { + t.Errorf("Authorization = %q, want %q", auth, "Bearer "+token) + } +} diff --git a/notifications/providers/sms.go b/notifications/providers/sms.go index 5eeba2ca..23eccbe8 100644 --- a/notifications/providers/sms.go +++ b/notifications/providers/sms.go @@ -5,7 +5,6 @@ package providers import ( "context" - "encoding/json" "errors" "fmt" "net/http" @@ -14,11 +13,16 @@ import ( "github.com/OpenNSW/core/remote" ) -type smsConfig struct { - BaseURL string `json:"baseURL"` - SIDCode string `json:"sidCode"` - UserName string `json:"userName"` - Password string `json:"password"` +// SMSConfig is the SMS provider's configuration. It carries yaml tags so an +// application can embed it in its own config and load it with +// configyaml.LoadAndExpand. Every field is a string, so a secret that only +// looks like a number (a password of 12345678, a SID code of 0123) decodes as +// written. +type SMSConfig struct { + BaseURL string `yaml:"baseURL"` + SIDCode string `yaml:"sidCode"` + UserName string `yaml:"userName"` + Password string `yaml:"password"` } // SMSRequest matches the GovSMS V1 API envelope. @@ -33,40 +37,32 @@ type SMSRequest struct { // SMSProvider sends SMS via the GovSMS service. type SMSProvider struct { - cfg smsConfig + cfg SMSConfig client *remote.Client } -// NewSMSProvider returns an SMSProvider ready for Configure. -func NewSMSProvider() *SMSProvider { - return &SMSProvider{} -} - -func (s *SMSProvider) Type() notification.ChannelType { return notification.ChannelSMS } - -func (s *SMSProvider) Configure(raw json.RawMessage) error { - if err := json.Unmarshal(raw, &s.cfg); err != nil { - return fmt.Errorf("unmarshal sms config: %w", err) +// NewSMSProvider validates cfg and returns an SMSProvider ready to send. +func NewSMSProvider(cfg SMSConfig) (*SMSProvider, error) { + if cfg.BaseURL == "" { + return nil, errors.New("sms: baseURL is required") } - if s.cfg.BaseURL == "" { - return errors.New("baseURL is required") + if err := validateBaseURL(cfg.BaseURL); err != nil { + return nil, fmt.Errorf("sms: %w", err) } - if err := validateBaseURL(s.cfg.BaseURL); err != nil { - return err + if cfg.SIDCode == "" { + return nil, errors.New("sms: sidCode is required") } - if s.cfg.SIDCode == "" { - return errors.New("sidCode is required") + if cfg.UserName == "" { + return nil, errors.New("sms: userName is required") } - if s.cfg.UserName == "" { - return errors.New("userName is required") + if cfg.Password == "" { + return nil, errors.New("sms: password is required") } - if s.cfg.Password == "" { - return errors.New("password is required") - } - s.client = remote.NewClient(s.cfg.BaseURL) - return nil + return &SMSProvider{cfg: cfg, client: remote.NewClient(cfg.BaseURL)}, nil } +func (s *SMSProvider) Type() notification.ChannelType { return notification.ChannelSMS } + func (s *SMSProvider) Send(ctx context.Context, req notification.Request) error { if s.client == nil { return errors.New("sms provider not configured") diff --git a/notifications/test/integration/sms_integration_test.go b/notifications/test/integration/sms_integration_test.go index 0cf6c51a..f7792d39 100644 --- a/notifications/test/integration/sms_integration_test.go +++ b/notifications/test/integration/sms_integration_test.go @@ -36,16 +36,16 @@ func TestSMSIntegration(t *testing.T) { })) defer srv.Close() - cfg := notification.Config{Providers: map[notification.ChannelType]map[string]any{ - notification.ChannelSMS: { - "baseURL": srv.URL, - "sidCode": "TEST_SID", - "userName": "test_user", - "password": "test_pass", - }, - }} - - manager, err := notification.NewManager(cfg, providers.NewSMSProvider()) + sms, err := providers.NewSMSProvider(providers.SMSConfig{ + BaseURL: srv.URL, + SIDCode: "TEST_SID", + UserName: "test_user", + Password: "test_pass", + }) + if err != nil { + t.Fatalf("NewSMSProvider: %v", err) + } + manager, err := notification.NewManager(sms) if err != nil { t.Fatalf("NewManager: %v", err) }