diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..1cb5ebb --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,256 @@ +# Architecture + +This document describes how `go_notify_yourself` is put together, with enough precision that a +human contributor **or a coding agent** can add a new provider correctly without guessing. For a +usage-focused quick-start, see [README.md](./README.md). For integrating this module into your own +project end-to-end, see [docs/INTEGRATION.md](./docs/INTEGRATION.md). + +## 1. Module overview + +`go_notify_yourself` is a standalone, dependency-free Go module for outbound notification delivery. +It has four layers: + +1. **Root package (`notify`)** — `Message` (the generic, provider-agnostic payload), `Sender` (the + uniform dispatch interface every provider implements), and the provider **registry** + (`Register`/`New`/`RegisteredTypes` in `factory.go`) that ties everything below together. +2. **`transport`** — the shared, SSRF-safe outbound HTTP primitive (`transport.Wrapper`) that every + HTTP-based provider dispatches through: destination validation, retry/backoff, redirect + re-validation, and request/response size caps. +3. **`providers/*`** — one package per notification service (`discord`, `slack`, `gotify`, + `pushover`, `ntfy`, `telegram`, `webhook`, `email`), each exposing a typed `Config` struct, a + `New(...)` constructor, and a `Client` implementing `notify.Sender`. +4. **`providers/all`** — a blank-import bundle that registers every built-in provider package with + the root registry in one line, for consumers who want zero-touch discovery. + +The module never assumes anything about the host application. Every environment-specific concern — +the HTTP client, the SSRF policy, the SMTP transport, HTML templates — is a constructor-injected +interface supplied by the caller (`transport.ClientFactory`, `transport.URLValidator`, +`email.Mailer`, `email.TemplateRenderer`). This is why `go_notify_yourself` must never import +anything from a host application's own module — see the non-negotiable design rule in +[CLAUDE.md](./CLAUDE.md). + +## 2. The `Sender` contract + +```go +type Sender interface { + Send(ctx context.Context, msg Message) error +} +``` + +Every provider package's `Client` implements this one method, and every implementation is expected +to: + +- **Respect `ctx`** — honor cancellation/deadlines rather than blocking indefinitely (the HTTP-based + providers get this for free via `transport.Wrapper.Send`, which threads `ctx` through + `http.NewRequestWithContext`). +- **Wrap errors, never swallow them** — `Send` returns a descriptive, `fmt.Errorf`-wrapped error on + failure; it never logs-and-returns-nil. +- **Never panic** — a bad destination, a malformed template, or a provider's non-2xx response is + always a returned `error`, never a panic. Panicking is reserved for the registry's own + programmer-error checks (§5) — a `Sender.Send` call is a runtime data-flow path, not a + configuration-time assertion. + +The "why": a host application typically fans one `Message` out to N configured destinations +(`[]notify.Sender`). A uniform contract — same method, same error-vs-panic discipline, same +context-respecting behavior — lets that fan-out loop treat every provider identically, regardless +of whether it's an HTTP webhook or email: + +```go +for _, s := range senders { + if err := s.Send(ctx, msg); err != nil { + log.Printf("notify: %v", err) + } +} +``` + +## 3. Adding a new provider — step-by-step + +### 3.1 File/package layout + +``` +providers// + .go # Config, Client, New(cfg, w) / New(cfg), Send — the core implementation + _test.go # table-driven Send tests, config validation tests + register.go # init()-time notify.Register("", factory) — kept separate from + # .go so registry plumbing doesn't clutter the core constructor logic + register_test.go # registration round-trip tests +``` + +`` is lowercase, no underscores (`webpush`, not `web_push`) — see §3.6 on why this matters. + +### 3.2 The `Config` struct convention + +- An exported `Config` struct with exported fields, documented with doc comments. +- HTTP-based providers include `Template` and `CustomTemplate string` fields, feeding the shared + `providers/internal/render` template engine (`Template` selects `"minimal"`/`"detailed"`/ + `"custom"`; `CustomTemplate` supplies the template string when `"custom"`). +- `email` is the **documented exception**: its `Config` has no `Template`/`CustomTemplate` fields at + all, because email doesn't dispatch a JSON payload — it composes an HTML body via a host-supplied + `TemplateRenderer` instead. Don't force every provider into the HTTP-shaped `Config` convention; + follow what the provider's transport actually needs. + +### 3.3 The `New` constructor convention + +- HTTP-based providers: `func New(cfg Config, w *transport.Wrapper) *Client` — `w` is the shared + dispatch primitive (see `transport.NewWrapper`), constructed once per host application and + threaded into every HTTP-based provider's `New`. Never construct your own `*http.Client` inside a + provider package; always dispatch through the injected `*transport.Wrapper`. +- `email` is the one exception: `func New(cfg Config) *Client` — no `*transport.Wrapper` parameter, + because email never dials HTTP itself (it hands off to `cfg.Mailer`). + +### 3.4 Compile-time interface assertion + +Every provider package includes, right after its `Client` type: + +```go +var _ notify.Sender = (*Client)(nil) +``` + +This is required, not optional — it turns "`Client` stopped implementing `Sender`" into a build +failure at the point of the mistake, rather than a runtime type-assertion failure somewhere else. + +### 3.5 The `Register`/factory pattern + +`register.go`'s exact template (HTTP-based providers — see `providers/discord/register.go` for the +canonical example): + +```go +package + +import ( + "fmt" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/providers/internal/regconfig" + "github.com/Wikid82/go_notify_yourself/transport" +) + +func init() { + notify.Register("", func(config map[string]any) (notify.Sender, error) { + w, ok := config["transport"].(*transport.Wrapper) + if !ok || w == nil { + return nil, fmt.Errorf(`: config["transport"] must be a non-nil *transport.Wrapper`) + } + cfg := Config{ + SomeField: regconfig.StringField(config, "some_field"), + // ... one line per Config field + } + return New(cfg, w), nil + }) +} +``` + +Key/type conventions: + +- `"transport"` is reserved for the shared `*transport.Wrapper`, required by every HTTP-based + provider's factory. +- Every other `Config` field is expected under its **lowercase snake_case field name** — e.g. + `Config.WebhookURL` → `config["webhook_url"]`, `Config.APIToken` → `config["api_token"]`. +- Use the shared `providers/internal/regconfig` helpers (`StringField`, `StringSliceField`) to + extract plain string/`[]string` values — don't hand-roll the same type-assert-with-default + boilerplate in every `register.go`. +- Behavioral/non-serializable values (interfaces, closures — see `providers/email/register.go`) are + type-asserted directly out of the map under their own well-known key; there is no generic + extraction helper for these because they're inherently provider-specific. +- **Factories must return an error, never panic, on bad/missing config.** A missing `"transport"` + key or a config with a required field wrong-typed is a *caller* mistake at runtime (e.g. Charon's + adapter built the map wrong) — return `fmt.Errorf`, not `panic`. Contrast with `Register` itself + (§5), which panics on a nil/duplicate registration — that's a *programmer* mistake, caught once at + `init()` time. + +### 3.6 Adding to `providers/all` + +Add one blank-import line to `providers/all/all.go`: + +```go +_ "github.com/Wikid82/go_notify_yourself/providers/" +``` + +**This step has no compiler safety net.** A new provider that registers itself correctly but isn't +added to `providers/all` still works fine for a consumer that hand-picks +`import _ "…/providers/"` directly — it just silently isn't part of the "one import gets +everything" bundle. `providers/all/all_test.go` has a `TestAll_RegistersEveryBuiltInProvider` test +asserting `len(notify.RegisteredTypes()) == wantProviderCount` — **bump `wantProviderCount` in that +test whenever you add a provider**, or the test fails loudly instead of silently missing your +addition. + +### 3.7 Naming conventions + +Provider package names are lowercase, no underscores, and **must match the `Register` key exactly** +(package `webpush` registers as `notify.Register("webpush", ...)`, not `"web_push"` or `"WebPush"`). +This matters because `Register` panics on a duplicate name (§5) — two providers accidentally +choosing the same string is a same-binary collision, and consistent naming is the only thing +preventing an easy, avoidable one. + +### 3.8 Test expectations + +Every provider package's tests should cover, mirroring the existing eight providers' patterns: + +- **Table-driven `Send` tests** against a fake `transport.Wrapper`, built via an injected + `ClientFactory` returning a `capturingRoundTripper` (see any `providers/*/*_test.go` for the + pattern) — no real network calls. +- **Config validation error-path tests** — e.g. a missing required field returns an error before any + dispatch is attempted. +- **≥85% coverage per package**, per this repo's `CLAUDE.md` coverage bar. +- **A registration round-trip test** (in `register_test.go`) asserting `notify.New("", + validConfig)` succeeds and returns a working `Sender` (behaviorally equivalent to the typed + constructor). +- **A missing-required-key test** asserting a config missing `"transport"` (or, for email, + `"mailer"`) returns an `error`, not a panic. + +### 3.9 Config validation/error handling conventions + +The registry's factory layer validates *structurally* (is the right type present under each +expected key?) and returns a `fmt.Errorf`-wrapped error naming exactly which key/type was expected. +The underlying `Config`-consuming `New`/`Send` still perform their own **semantic** validation +exactly as they do today (e.g. Discord's webhook-host allowlist, Slack's URL-shape regex, Pushover's +required user key/API token). The registry layer is a validation step *in front of*, not *instead +of*, each provider's existing validation. + +## 4. The registry internals + +`Register`, `New`, and `RegisteredTypes` live at the module root (`factory.go`, `package notify`) — +not a subpackage — because every provider package already imports the root `notify` package (for +`Sender`/`Message`), so this is the only placement with zero new import edges. Behavior: + +- **`Register(name string, factory Factory)`** stores `factory` under `strings.ToLower(name)` in a + `sync.RWMutex`-guarded map. It **panics** if `factory` is nil, if `name` is empty, or if `name` is + already registered — mirroring `database/sql.Register` exactly. This is a programmer error caught + at `init()` time (a build-time-discoverable defect, e.g. two packages both registering + `"webhook"`), not a runtime condition — panicking fails the program immediately and loudly rather + than silently shadowing one provider with another. +- **`New(name string, config map[string]any) (Sender, error)`** looks up the registered factory + (case-insensitively) and invokes it. Returns an error — **never panics** — if `name` isn't + registered; the opposite discipline from `Register`, because "provider type X isn't registered" is + a legitimate runtime condition (a host forgot to blank-import the package, or a config references + a typo'd/future type) that calling code must be able to handle gracefully. +- **`RegisteredTypes() []string`** returns the sorted list of currently registered names — + introspection for a host that wants to validate a config value or populate a UI dropdown against + exactly what's compiled in. +- The `sync.RWMutex` exists because registrations happen at `init()` time (effectively + single-threaded, before `main` runs) but `New`/`RegisteredTypes` may be called concurrently from a + host application's request-handling goroutines. + +**The registry is an additive convenience/discovery layer — not a replacement for the typed +constructors.** `discord.New(discord.Config{...}, wrapper)` remains fully supported, fully +type-safe, and is the recommended path for a caller that doesn't need runtime discovery. `notify.New` +trades compile-time type safety at this one boundary (a caller can put a `string` under a key a +factory expects to be a `*transport.Wrapper` and won't find out until `New` returns an error) for a +single uniform mechanism that also accommodates providers like `email` whose `Config` can't +round-trip through a plain-data boundary (JSON) at all. + +## 5. Versioning note + +Under this module's semver policy: + +- **Adding a new provider package** (a new `providers/` with its own `Register` call) is a + `feat:`/**minor**-version change — it's additive; no existing provider's API surface changes. +- **Changing `Register`, `New`, `Factory`, or `RegisteredTypes`'s signature** is a **breaking/major** + -version change — every provider package's `register.go` and every host application calling + `notify.New` directly depends on these exact shapes. +- **Changing an existing provider's typed `Config`/`New`/`Send` signature** is also + breaking/major, unchanged from this module's policy before the registry existed — the registry + doesn't relax this. + +This is stated explicitly here so a contributor (or a coding agent) doesn't have to guess at PR/tag +time. diff --git a/CHANGELOG.md b/CHANGELOG.md index ca5748c..09faf98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.2.0] - 2026-08-17 + +Self-registering provider factory registry, matching the `database/sql` +driver idiom: + +- `notify.Register`, `notify.New`, `notify.RegisteredTypes` (`factory.go`): + a thread-safe, name-keyed factory registry at the module root. `Register` + panics on a nil factory or duplicate name (programmer error, caught at + `init()` time); `New` returns an error — never panics — for an + unregistered provider type. +- All eight existing provider packages (`discord`, `slack`, `gotify`, + `pushover`, `ntfy`, `telegram`, `webhook`, `email`) now self-register via + `init()` in a new `register.go`, adapting a generic `map[string]any` + config into each package's typed `Config`. `email`'s factory takes its + non-serializable `Mailer`/`TemplateRenderer`/`TemplateName` values + directly out of the map rather than JSON-decoding them. +- `providers/all`: a new blank-import bundle package + (`import _ ".../providers/all"`) that registers all eight built-in + providers in one line, for consumers wanting zero-touch discovery. +- `ARCHITECTURE.md` (new), a new README "Provider registry" section, and + `docs/INTEGRATION.md` (new): documentation for adding a provider to the + registry and for integrating this module into another project. + +**Behavior change, not just an addition**: importing any provider package +now has an `init()`-time side effect (registering into the global +`notify` registry) it didn't have before, and two packages registering the +same provider name in one binary now panics at startup. No existing +exported signature (`Config`, `New(cfg, w)`, `Send`) changed — this is why +the bump is `v0.2.0`, a minor rather than a patch release, despite being +purely additive at the Go-API level. + +## [0.1.0] - 2026-08-14 + Initial extraction of the notification delivery engine into a standalone module: diff --git a/README.md b/README.md index 40b5d98..ad04e66 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,38 @@ Every HTTP-based provider's `Config.Template` selects the JSON payload shape: `" `"detailed"`, or `"custom"` (uses `Config.CustomTemplate`, a Go `text/template` string with a `toJSON` helper function available, e.g. `{{toJSON .Message}}`). +## Provider registry + +Every provider package self-registers with a small, generic factory registry at the module root, +mirroring the `database/sql` driver pattern: `notify.Register(name, factory)` is called from each +package's `init()`, and `notify.New(name, config)` looks up and constructs a `Sender` by name at +runtime — useful when the provider type is only known at runtime (e.g. loaded from a database row +or config file), not hardcoded at compile time. + +```go +import ( + notify "github.com/Wikid82/go_notify_yourself" + _ "github.com/Wikid82/go_notify_yourself/providers/all" // registers all 8 built-in providers +) + +wrapper := transport.NewWrapper() +sender, err := notify.New("discord", map[string]any{ + "transport": wrapper, + "webhook_url": "https://discord.com/api/webhooks/123456789/abcDEF", +}) +``` + +`notify.RegisteredTypes()` returns the sorted list of provider names currently linked into your +binary — handy for populating a UI dropdown or validating a config value against exactly what's +compiled in, without hardcoding your own list. + +The registry is an **additive convenience/discovery layer**, not a replacement for the typed +constructors — `discord.New(discord.Config{...}, wrapper)` remains the recommended path when you +don't need runtime discovery, since it keeps full compile-time type safety. See +[ARCHITECTURE.md](./ARCHITECTURE.md) for how to add a new provider to the registry, and +[docs/INTEGRATION.md](./docs/INTEGRATION.md) for a full integration walkthrough into your own +project. + ## Transport: SSRF-safe dispatch with retries `transport.Wrapper` is the shared delivery primitive every HTTP-based provider package dispatches diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md new file mode 100644 index 0000000..f216a62 --- /dev/null +++ b/docs/INTEGRATION.md @@ -0,0 +1,189 @@ +# Integrating `go_notify_yourself` into your project + +This guide is for someone bringing this module into their **own, unrelated** project — not for +working on this module itself (see [ARCHITECTURE.md](../ARCHITECTURE.md) for that) and not +specific to Charon, the project this module was originally extracted from. + +## Who this is for + +A Go developer building a self-hosted or small-team application who needs to fire outbound alerts +to chat/push/email destinations and doesn't want to hand-roll SSRF-safe HTTP dispatch, retries, or +per-service payload quirks. As the README puts it: most projects that need this end up +re-implementing the same things badly. + +**This is explicitly not for you if:** + +- You need an Apprise-style URL-scheme dispatcher (`discord://...`) today — not yet implemented + (see the README's "Project status" section for the long-term direction). +- You need inbound or two-way messaging — this module is send-only. + +## What it does / doesn't do + +**Does:** + +- SSRF-safe outbound HTTP dispatch with retry/backoff (`transport.Wrapper`) — destination + validation, redirect re-validation, request/response size caps. +- A uniform `Sender` interface across eight built-in provider types: Discord, Slack, Gotify, + Pushover, Ntfy, Telegram, generic webhook, and email. +- JSON payload templating with a shared `text/template` engine plus a `toJSON` helper. +- A self-registering factory/discovery layer (`notify.Register`/`notify.New`/ + `notify.RegisteredTypes`) for constructing a `Sender` by name at runtime. + +**Doesn't:** + +- Own any database, config file format, or HTTP framework. +- Provide inbound webhook receiving. +- Persist retries across a process restart — retries are in-process, in-request only. +- Provide a URL-scheme dispatch convention (yet). + +## When to reach for it vs. rolling your own + +**Reach for it if:** + +- You need two or more of {Discord, Slack, Gotify, Pushover, Ntfy, Telegram, generic webhook, + email} dispatch. +- You want retry/backoff and SSRF hardening without writing it yourself. +- You're fine supplying your own HTTP client factory / SSRF policy / SMTP mailer via the module's + dependency-injection seams (see "Why it's built this way" below). + +**Roll your own if:** + +- You need exactly one destination type with a very custom payload shape and don't want any of the + shared machinery. +- You need transport types this module doesn't have (SMS, inbound webhooks, message queues). + +## Where it fits in a typical app's architecture + +``` + domain event startup (once) + │ │ + ▼ ▼ + build a notify.Message build one shared *transport.Wrapper + │ (+ Mailer/TemplateRenderer if using email) + │ │ + └──────────────┬──────────────────┘ + ▼ + construct Sender(s): typed New(cfg, wrapper) directly, + OR notify.New(providerType, config) via providers/all + │ + ▼ + sender.Send(ctx, msg) + │ + ▼ + called from wherever your app fires alerts today + (a notification service, an error handler, a monitor loop) +``` + +This module is a **dispatch layer your service layer calls into** — it does not own your request +lifecycle, your background job scheduler, or your config file format. + +## Why it's built this way + +The module has zero database/framework/HTTP-server knowledge by design: every environment-specific +concern is a constructor-injected interface (`transport.ClientFactory`, `transport.URLValidator`, +`email.Mailer`, `email.TemplateRenderer`). This is what makes it equally usable from a Gin+GORM web +app, a CLI tool, or a serverless function — nothing in the module assumes any of those. It's also +why the provider registry (`notify.New`) accepts a generic `map[string]any` rather than forcing a +specific config-file format, struct tag convention, or framework binding: the registry boundary has +to work for both plain-data providers (Discord's `WebhookURL` string) and providers whose config +carries actual Go values a host constructs at startup (email's `Mailer`/`TemplateRenderer` +interfaces) — see [ARCHITECTURE.md](../ARCHITECTURE.md) for the full rationale. + +## How to integrate — end-to-end walkthrough + +**1. Get the module:** + +```sh +go get github.com/Wikid82/go_notify_yourself@v0.2.0 +``` + +**2. Register the providers you want.** Either blank-import everything for zero-touch discovery: + +```go +import _ "github.com/Wikid82/go_notify_yourself/providers/all" +``` + +or hand-pick individual providers if you'd rather control exactly what's linked into your binary +(smaller binary, no unused transitive dependencies): + +```go +import ( + _ "github.com/Wikid82/go_notify_yourself/providers/discord" + _ "github.com/Wikid82/go_notify_yourself/providers/email" +) +``` + +Both are equally supported; neither requires a different `notify.Register`/`New` API. Pick +`providers/all` if you want a new provider to become available automatically after a `go get` +version bump with no code change on your side; hand-pick if you'd rather that be a deliberate, +reviewed decision. + +**3. Build a shared `transport.Wrapper` once, at startup:** + +```go +wrapper := transport.NewWrapper( + transport.WithAllowHTTP(false), + // transport.WithClientFactory / transport.WithURLValidator: supply your own + // SSRF-hardened HTTP client and destination-validation policy in production — + // see the README's "Bringing your own SSRF policy" section. +) +``` + +**4. Construct a sender.** Via `notify.New` for a runtime-determined provider type (e.g. one row of +provider config loaded from your database): + +```go +sender, err := notify.New("discord", map[string]any{ + "transport": wrapper, + "webhook_url": "https://discord.com/api/webhooks/123456789/abcDEF", + "template": "minimal", +}) +if err != nil { + log.Fatalf("unsupported or misconfigured provider: %v", err) +} +``` + +Email needs its `Mailer` (your own SMTP wrapper) constructed and passed directly — it isn't +plain data: + +```go +sender, err := notify.New("email", map[string]any{ + "mailer": myMailer, // implements email.Mailer + "recipients": []string{"ops@example.com"}, + "subject_prefix": "[MyApp] ", +}) +``` + +Or skip the registry and call the typed constructor directly when you already know the provider +type at compile time — this keeps full compile-time type safety and is the recommended path when +you don't need runtime discovery: + +```go +sender := discord.New(discord.Config{WebhookURL: "https://discord.com/api/webhooks/..."}, wrapper) +``` + +**5. Dispatch a message:** + +```go +err = sender.Send(ctx, notify.Message{ + Title: "Disk usage high", + Body: "Volume /data is at 92% capacity.", + EventType: "disk_usage", + Data: map[string]any{"Host": "db-01", "Percent": 92}, +}) +``` + +**6. Handle/log the error.** `Send` never panics; a failure (bad destination, provider rejected the +payload, network error after retries) is always a returned `error`: + +```go +if err != nil { + log.Printf("notify: failed to send to discord: %v", err) + // decide your own policy: retry later, alert on a fallback channel, just log — this + // module makes no decision for you beyond in-request retry/backoff. +} +``` + +**Testing your integration:** see the README's ["Testing your own +integration"](../README.md#testing-your-own-integration) section for how to inject a fake HTTP +round-tripper and a passthrough URL validator so your tests never touch the network. diff --git a/factory.go b/factory.go new file mode 100644 index 0000000..2931be4 --- /dev/null +++ b/factory.go @@ -0,0 +1,84 @@ +package notify + +import ( + "fmt" + "sort" + "strings" + "sync" +) + +// Factory constructs a Sender from a generic configuration map. Each +// provider package's factory type-asserts the keys/types it expects out of +// config and returns a descriptive error for anything missing or +// wrong-typed — Factory implementations must never panic on bad input from +// a caller (panicking is reserved for Register's own misuse-by-programmer +// checks, per the database/sql convention — see below). +// +// Well-known convention (documented per-package in each provider's doc +// comment and in ARCHITECTURE.md): HTTP-based providers expect a +// "transport" key holding the shared *transport.Wrapper; provider-specific +// Config fields are expected under their lowercase snake_case field name +// (e.g. discord's WebhookURL -> config["webhook_url"]). This module makes +// no attempt to enforce these conventions structurally. +type Factory func(config map[string]any) (Sender, error) + +var ( + registryMu sync.RWMutex + registry = map[string]Factory{} +) + +// Register makes a provider Factory available under name (case-insensitive; +// stored lowercased). Intended to be called from a provider package's +// init(), mirroring database/sql.Register and image.RegisterFormat. +// +// Register panics if factory is nil or if name is already registered — +// exactly like sql.Register — because a duplicate/nil registration is +// always a programmer error discoverable at package-init time (e.g. two +// packages both claiming "webhook"), never a legitimate runtime condition +// a caller should have to handle. +func Register(name string, factory Factory) { + if factory == nil { + panic("notify: Register called with nil Factory for " + name) + } + key := strings.ToLower(strings.TrimSpace(name)) + if key == "" { + panic("notify: Register called with empty name") + } + registryMu.Lock() + defer registryMu.Unlock() + if _, exists := registry[key]; exists { + panic("notify: Register called twice for provider " + key) + } + registry[key] = factory +} + +// New looks up the Factory registered under name (case-insensitive) and +// invokes it with config. Returns an error — never panics — if name is not +// registered or if the factory itself returns an error (e.g. a missing +// required config key). +func New(name string, config map[string]any) (Sender, error) { + key := strings.ToLower(strings.TrimSpace(name)) + registryMu.RLock() + factory, ok := registry[key] + registryMu.RUnlock() + if !ok { + return nil, fmt.Errorf("notify: no provider registered for type %q (registered types: %s)", + name, strings.Join(RegisteredTypes(), ", ")) + } + return factory(config) +} + +// RegisteredTypes returns the sorted list of currently registered provider +// type names. Useful for a host application that wants to validate a +// config value or populate a UI dropdown against exactly what's compiled +// in, without hardcoding its own list. +func RegisteredTypes() []string { + registryMu.RLock() + defer registryMu.RUnlock() + names := make([]string, 0, len(registry)) + for name := range registry { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/factory_test.go b/factory_test.go new file mode 100644 index 0000000..f1ecb6b --- /dev/null +++ b/factory_test.go @@ -0,0 +1,168 @@ +package notify + +import ( + "context" + "fmt" + "strings" + "sync" + "testing" +) + +// fakeSender is a minimal notify.Sender used only by factory_test.go to +// exercise the registry without depending on any providers/* package (this +// package must not import its own consumers). +type fakeSender struct{ name string } + +func (f *fakeSender) Send(context.Context, Message) error { return nil } + +var _ Sender = (*fakeSender)(nil) + +func TestRegister_PanicsOnNilFactory(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected Register(nil factory) to panic, it did not") + } + }() + Register("factory-test-nil", nil) +} + +func TestRegister_PanicsOnEmptyName(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected Register(\"\") to panic, it did not") + } + }() + Register(" ", func(map[string]any) (Sender, error) { return &fakeSender{}, nil }) +} + +func TestRegister_PanicsOnDuplicate(t *testing.T) { + Register("factory-test-dup", func(map[string]any) (Sender, error) { return &fakeSender{}, nil }) + + defer func() { + if r := recover(); r == nil { + t.Fatal("expected second Register call for the same name to panic, it did not") + } + }() + Register("factory-test-dup", func(map[string]any) (Sender, error) { return &fakeSender{}, nil }) +} + +func TestRegister_CaseInsensitiveLookup(t *testing.T) { + Register("Factory-Test-Case", func(config map[string]any) (Sender, error) { + return &fakeSender{name: "case"}, nil + }) + + sender, err := New("FACTORY-TEST-CASE", nil) + if err != nil { + t.Fatalf("New with different case returned error: %v", err) + } + if sender == nil { + t.Fatal("expected a non-nil Sender") + } +} + +func TestNew_UnregisteredNameReturnsErrorNotPanic(t *testing.T) { + sender, err := New("factory-test-does-not-exist", map[string]any{"foo": "bar"}) + if err == nil { + t.Fatal("expected an error for an unregistered provider name, got nil") + } + if sender != nil { + t.Fatalf("expected a nil Sender on error, got %#v", sender) + } + if !strings.Contains(err.Error(), "factory-test-does-not-exist") { + t.Errorf("expected error to mention the requested name, got: %v", err) + } +} + +func TestNew_UnregisteredNameErrorListsRegisteredTypes(t *testing.T) { + Register("factory-test-listed", func(map[string]any) (Sender, error) { return &fakeSender{}, nil }) + + _, err := New("factory-test-not-listed", nil) + if err == nil { + t.Fatal("expected an error") + } + if !strings.Contains(err.Error(), "factory-test-listed") { + t.Errorf("expected error to list currently-registered types, got: %v", err) + } +} + +func TestNew_CallsFactoryAndReturnsSender(t *testing.T) { + var receivedConfig map[string]any + Register("factory-test-calls", func(config map[string]any) (Sender, error) { + receivedConfig = config + return &fakeSender{name: "calls"}, nil + }) + + cfg := map[string]any{"webhook_url": "https://example.com"} + sender, err := New("factory-test-calls", cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + fs, ok := sender.(*fakeSender) + if !ok || fs.name != "calls" { + t.Fatalf("expected the registered factory's Sender to be returned, got %#v", sender) + } + if receivedConfig["webhook_url"] != "https://example.com" { + t.Errorf("expected config to be passed through to the factory unchanged, got %#v", receivedConfig) + } +} + +func TestNew_FactoryErrorIsPropagated(t *testing.T) { + Register("factory-test-errors", func(map[string]any) (Sender, error) { + return nil, fmt.Errorf("factory-test: missing required key") + }) + + sender, err := New("factory-test-errors", nil) + if err == nil { + t.Fatal("expected the factory's error to propagate") + } + if sender != nil { + t.Fatalf("expected a nil Sender when the factory errors, got %#v", sender) + } +} + +func TestRegisteredTypes_SortedAndReflectsRegistrations(t *testing.T) { + Register("factory-test-zzz", func(map[string]any) (Sender, error) { return &fakeSender{}, nil }) + Register("factory-test-aaa", func(map[string]any) (Sender, error) { return &fakeSender{}, nil }) + + types := RegisteredTypes() + + var zIdx, aIdx = -1, -1 + for i, name := range types { + switch name { + case "factory-test-zzz": + zIdx = i + case "factory-test-aaa": + aIdx = i + } + } + if aIdx == -1 || zIdx == -1 { + t.Fatalf("expected both registered names present in %v", types) + } + if aIdx > zIdx { + t.Errorf("expected RegisteredTypes to be sorted ascending, got %v", types) + } + + for i := 1; i < len(types); i++ { + if types[i-1] > types[i] { + t.Fatalf("RegisteredTypes not fully sorted: %v", types) + } + } +} + +func TestRegistry_ConcurrentReadsAreSafe(t *testing.T) { + Register("factory-test-concurrent", func(map[string]any) (Sender, error) { return &fakeSender{}, nil }) + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(2) + go func() { + defer wg.Done() + _, _ = New("factory-test-concurrent", nil) + }() + go func() { + defer wg.Done() + _ = RegisteredTypes() + }() + } + wg.Wait() +} diff --git a/providers/all/all.go b/providers/all/all.go new file mode 100644 index 0000000..9dc3732 --- /dev/null +++ b/providers/all/all.go @@ -0,0 +1,33 @@ +// Package all blank-imports every provider package shipped in this module, +// registering all of them into the root notify package's registry as a +// side effect. Import this package for its side effects only — +// +// import _ "github.com/Wikid82/go_notify_yourself/providers/all" +// +// — when you want every built-in provider type available to notify.New +// without importing each provider package individually. This is the +// closest equivalent Go offers to true runtime auto-discovery: Go has no +// mechanism to discover and load packages that were not compiled into the +// binary, so *some* single import is unavoidable — providers/all exists so +// that import is exactly one line, added once, rather than one line per +// provider that must be kept in sync by hand as the provider list grows. +// +// Tradeoff: importing this package links every provider package's +// transitive dependencies into your binary, even ones you never configure. +// A consumer that wants tighter control over what's linked should +// hand-pick individual provider imports instead (e.g. +// import _ "github.com/Wikid82/go_notify_yourself/providers/discord") — +// both styles are equally supported by the registry; neither requires a +// different notify.Register/New API. +package all + +import ( + _ "github.com/Wikid82/go_notify_yourself/providers/discord" + _ "github.com/Wikid82/go_notify_yourself/providers/email" + _ "github.com/Wikid82/go_notify_yourself/providers/gotify" + _ "github.com/Wikid82/go_notify_yourself/providers/ntfy" + _ "github.com/Wikid82/go_notify_yourself/providers/pushover" + _ "github.com/Wikid82/go_notify_yourself/providers/slack" + _ "github.com/Wikid82/go_notify_yourself/providers/telegram" + _ "github.com/Wikid82/go_notify_yourself/providers/webhook" +) diff --git a/providers/all/all_test.go b/providers/all/all_test.go new file mode 100644 index 0000000..6627200 --- /dev/null +++ b/providers/all/all_test.go @@ -0,0 +1,35 @@ +package all + +import ( + "testing" + + notify "github.com/Wikid82/go_notify_yourself" +) + +// wantProviderCount is the number of built-in provider packages this +// module ships. Bump this constant (and the corresponding blank import in +// all.go) whenever a new provider package is added — this test is the +// safety net for the one step in adding a provider that has no compiler +// enforcement (a provider that registers itself but isn't added to +// providers/all silently isn't part of the "one import gets everything" +// bundle). +const wantProviderCount = 8 + +func TestAll_RegistersEveryBuiltInProvider(t *testing.T) { + types := notify.RegisteredTypes() + if len(types) != wantProviderCount { + t.Fatalf("expected %d registered provider types via providers/all, got %d: %v", + wantProviderCount, len(types), types) + } + + want := []string{"discord", "email", "gotify", "ntfy", "pushover", "slack", "telegram", "webhook"} + registered := make(map[string]bool, len(types)) + for _, name := range types { + registered[name] = true + } + for _, name := range want { + if !registered[name] { + t.Errorf("expected %q to be registered after importing providers/all, got %v", name, types) + } + } +} diff --git a/providers/discord/register.go b/providers/discord/register.go new file mode 100644 index 0000000..f5462fb --- /dev/null +++ b/providers/discord/register.go @@ -0,0 +1,36 @@ +package discord + +import ( + "fmt" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/providers/internal/regconfig" + "github.com/Wikid82/go_notify_yourself/transport" +) + +// init registers this package's Factory under the name "discord" with the +// notify package's registry. Callers that want discord available via +// notify.New without importing this package's typed constructor directly +// can either blank-import this package or providers/all. +// +// Expected config keys: +// - "transport" (required): *transport.Wrapper — the shared dispatch +// primitive; see transport.NewWrapper. +// - "webhook_url" (string): the Discord webhook destination. +// - "template" (string, optional): "minimal" (default), "detailed", or +// "custom". +// - "custom_template" (string, optional): used when template is "custom". +func init() { + notify.Register("discord", func(config map[string]any) (notify.Sender, error) { + w, ok := config["transport"].(*transport.Wrapper) + if !ok || w == nil { + return nil, fmt.Errorf(`discord: config["transport"] must be a non-nil *transport.Wrapper`) + } + cfg := Config{ + WebhookURL: regconfig.StringField(config, "webhook_url"), + Template: regconfig.StringField(config, "template"), + CustomTemplate: regconfig.StringField(config, "custom_template"), + } + return New(cfg, w), nil + }) +} diff --git a/providers/discord/register_test.go b/providers/discord/register_test.go new file mode 100644 index 0000000..84fc159 --- /dev/null +++ b/providers/discord/register_test.go @@ -0,0 +1,64 @@ +package discord + +import ( + "testing" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/transport" +) + +func TestRegister_NewReturnsWorkingSender(t *testing.T) { + w := transport.NewWrapper() + + sender, err := notify.New("discord", map[string]any{ + "transport": w, + "webhook_url": "https://discord.com/api/webhooks/123456789/abcDEF", + "template": "minimal", + }) + if err != nil { + t.Fatalf("notify.New(\"discord\", ...) returned error: %v", err) + } + + client, ok := sender.(*Client) + if !ok { + t.Fatalf("expected *discord.Client, got %T", sender) + } + if client.cfg.WebhookURL != "https://discord.com/api/webhooks/123456789/abcDEF" { + t.Errorf("expected WebhookURL to be threaded through from config, got %q", client.cfg.WebhookURL) + } +} + +func TestRegister_MissingTransportReturnsErrorNotPanic(t *testing.T) { + sender, err := notify.New("discord", map[string]any{ + "webhook_url": "https://discord.com/api/webhooks/123456789/abcDEF", + }) + if err == nil { + t.Fatal("expected an error when config[\"transport\"] is missing") + } + if sender != nil { + t.Fatalf("expected a nil Sender on error, got %#v", sender) + } +} + +func TestRegister_WrongTypedTransportReturnsError(t *testing.T) { + _, err := notify.New("discord", map[string]any{ + "transport": "not-a-wrapper", + "webhook_url": "https://discord.com/api/webhooks/123456789/abcDEF", + }) + if err == nil { + t.Fatal("expected an error when config[\"transport\"] is the wrong type") + } +} + +func TestRegister_RegisteredUnderExpectedName(t *testing.T) { + found := false + for _, name := range notify.RegisteredTypes() { + if name == "discord" { + found = true + break + } + } + if !found { + t.Errorf("expected %q registered in notify.RegisteredTypes(), got %v", "discord", notify.RegisteredTypes()) + } +} diff --git a/providers/email/register.go b/providers/email/register.go new file mode 100644 index 0000000..852f610 --- /dev/null +++ b/providers/email/register.go @@ -0,0 +1,48 @@ +package email + +import ( + "fmt" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/providers/internal/regconfig" +) + +// init registers this package's Factory under the name "email" with the +// notify package's registry. +// +// Unlike the seven HTTP-based providers, email's Config carries +// non-JSON-serializable Go values (Mailer/TemplateRenderer are behavioral +// interfaces; TemplateName is a closure) — a host application constructs +// these at startup and puts the actual values directly into config under +// the well-known keys below, rather than plain data. There is no +// "transport" key: email never dials HTTP directly (see email.go's +// New(cfg Config) *Client, which takes no *transport.Wrapper). +// +// Expected config keys: +// - "mailer" (required): Mailer — transports the composed email. +// - "recipients" (optional): []string — destination addresses. +// - "subject_prefix" (string, optional): prepended to the message title. +// - "renderer" (optional): TemplateRenderer — renders the HTML body; nil +// uses the package's built-in neutral template. +// - "template_name" (optional): func(notify.Message) string — maps a +// Message to a host-defined template name. +func init() { + notify.Register("email", func(config map[string]any) (notify.Sender, error) { + mailer, ok := config["mailer"].(Mailer) + if !ok || mailer == nil { + return nil, fmt.Errorf(`email: config["mailer"] must be a non-nil Mailer`) + } + cfg := Config{ + Mailer: mailer, + SubjectPrefix: regconfig.StringField(config, "subject_prefix"), + Recipients: regconfig.StringSliceField(config, "recipients"), + } + if r, ok := config["renderer"].(TemplateRenderer); ok { + cfg.Renderer = r + } + if tn, ok := config["template_name"].(func(notify.Message) string); ok { + cfg.TemplateName = tn + } + return New(cfg), nil + }) +} diff --git a/providers/email/register_test.go b/providers/email/register_test.go new file mode 100644 index 0000000..953ff7d --- /dev/null +++ b/providers/email/register_test.go @@ -0,0 +1,96 @@ +package email + +import ( + "reflect" + "testing" + + notify "github.com/Wikid82/go_notify_yourself" +) + +func TestRegister_NewReturnsWorkingSender(t *testing.T) { + mailer := &fakeMailer{} + + sender, err := notify.New("email", map[string]any{ + "mailer": mailer, + "recipients": []string{"ops@example.com"}, + "subject_prefix": "[MyApp] ", + }) + if err != nil { + t.Fatalf("notify.New(\"email\", ...) returned error: %v", err) + } + + client, ok := sender.(*Client) + if !ok { + t.Fatalf("expected *email.Client, got %T", sender) + } + if client.cfg.Mailer != mailer { + t.Errorf("expected Mailer to be threaded through from config") + } + if !reflect.DeepEqual(client.cfg.Recipients, []string{"ops@example.com"}) { + t.Errorf("expected Recipients to be threaded through, got %#v", client.cfg.Recipients) + } + if client.cfg.SubjectPrefix != "[MyApp] " { + t.Errorf("expected SubjectPrefix to be threaded through, got %q", client.cfg.SubjectPrefix) + } +} + +func TestRegister_MissingMailerReturnsErrorNotPanic(t *testing.T) { + sender, err := notify.New("email", map[string]any{ + "recipients": []string{"ops@example.com"}, + }) + if err == nil { + t.Fatal("expected an error when config[\"mailer\"] is missing") + } + if sender != nil { + t.Fatalf("expected a nil Sender on error, got %#v", sender) + } +} + +func TestRegister_WrongTypedMailerReturnsError(t *testing.T) { + _, err := notify.New("email", map[string]any{ + "mailer": "not-a-mailer", + }) + if err == nil { + t.Fatal("expected an error when config[\"mailer\"] is the wrong type") + } +} + +func TestRegister_OptionalRendererAndTemplateNamePassThrough(t *testing.T) { + mailer := &fakeMailer{} + renderer := &fakeRenderer{htmlBody: "

hi

"} + templateNameFn := func(notify.Message) string { return "custom-template" } + + sender, err := notify.New("email", map[string]any{ + "mailer": mailer, + "recipients": []string{"ops@example.com"}, + "renderer": renderer, + "template_name": templateNameFn, + }) + if err != nil { + t.Fatalf("notify.New(\"email\", ...) returned error: %v", err) + } + + client, ok := sender.(*Client) + if !ok { + t.Fatalf("expected *email.Client, got %T", sender) + } + if client.cfg.Renderer != renderer { + t.Errorf("expected Renderer to be threaded through from config") + } + if client.cfg.TemplateName == nil || client.cfg.TemplateName(notify.Message{}) != "custom-template" { + t.Errorf("expected TemplateName closure to be threaded through from config") + } +} + +func TestRegister_RegisteredUnderExpectedName(t *testing.T) { + found := false + for _, name := range notify.RegisteredTypes() { + if name == "email" { + found = true + break + } + } + if !found { + t.Errorf("expected %q registered in notify.RegisteredTypes(), got %v", "email", notify.RegisteredTypes()) + } +} diff --git a/providers/gotify/register.go b/providers/gotify/register.go new file mode 100644 index 0000000..1893323 --- /dev/null +++ b/providers/gotify/register.go @@ -0,0 +1,36 @@ +package gotify + +import ( + "fmt" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/providers/internal/regconfig" + "github.com/Wikid82/go_notify_yourself/transport" +) + +// init registers this package's Factory under the name "gotify" with the +// notify package's registry. +// +// Expected config keys: +// - "transport" (required): *transport.Wrapper — the shared dispatch +// primitive; see transport.NewWrapper. +// - "url" (string): the Gotify server's message push endpoint. +// - "token" (string, optional): the Gotify application token. +// - "template" (string, optional): "minimal" (default), "detailed", or +// "custom". +// - "custom_template" (string, optional): used when template is "custom". +func init() { + notify.Register("gotify", func(config map[string]any) (notify.Sender, error) { + w, ok := config["transport"].(*transport.Wrapper) + if !ok || w == nil { + return nil, fmt.Errorf(`gotify: config["transport"] must be a non-nil *transport.Wrapper`) + } + cfg := Config{ + URL: regconfig.StringField(config, "url"), + Token: regconfig.StringField(config, "token"), + Template: regconfig.StringField(config, "template"), + CustomTemplate: regconfig.StringField(config, "custom_template"), + } + return New(cfg, w), nil + }) +} diff --git a/providers/gotify/register_test.go b/providers/gotify/register_test.go new file mode 100644 index 0000000..959cac9 --- /dev/null +++ b/providers/gotify/register_test.go @@ -0,0 +1,54 @@ +package gotify + +import ( + "testing" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/transport" +) + +func TestRegister_NewReturnsWorkingSender(t *testing.T) { + w := transport.NewWrapper() + + sender, err := notify.New("gotify", map[string]any{ + "transport": w, + "url": "https://gotify.example.com/message", + "token": "abc123", + }) + if err != nil { + t.Fatalf("notify.New(\"gotify\", ...) returned error: %v", err) + } + + client, ok := sender.(*Client) + if !ok { + t.Fatalf("expected *gotify.Client, got %T", sender) + } + if client.cfg.URL != "https://gotify.example.com/message" || client.cfg.Token != "abc123" { + t.Errorf("expected config to be threaded through, got %#v", client.cfg) + } +} + +func TestRegister_MissingTransportReturnsErrorNotPanic(t *testing.T) { + sender, err := notify.New("gotify", map[string]any{ + "url": "https://gotify.example.com/message", + }) + if err == nil { + t.Fatal("expected an error when config[\"transport\"] is missing") + } + if sender != nil { + t.Fatalf("expected a nil Sender on error, got %#v", sender) + } +} + +func TestRegister_RegisteredUnderExpectedName(t *testing.T) { + found := false + for _, name := range notify.RegisteredTypes() { + if name == "gotify" { + found = true + break + } + } + if !found { + t.Errorf("expected %q registered in notify.RegisteredTypes(), got %v", "gotify", notify.RegisteredTypes()) + } +} diff --git a/providers/internal/regconfig/regconfig.go b/providers/internal/regconfig/regconfig.go new file mode 100644 index 0000000..0854f63 --- /dev/null +++ b/providers/internal/regconfig/regconfig.go @@ -0,0 +1,62 @@ +// Package regconfig provides small, shared helpers for decoding a +// notify.Factory's generic map[string]any config into the plain string/ +// string-slice fields most provider Config structs need. It is unexported +// (internal) because it is registration-layer plumbing shared across +// sibling providers/* packages, not part of this module's public API. +// +// Every helper here is deliberately lenient: a missing or wrong-typed key +// yields the zero value rather than an error. Each provider's register.go +// is responsible for deciding which of those zero values are actually +// required (e.g. an empty WebhookURL) and returning a descriptive error at +// that point — regconfig only extracts, it never validates provider +// semantics. +package regconfig + +// StringField returns config[key] as a string, or "" if the key is absent +// or not a string. +func StringField(config map[string]any, key string) string { + if config == nil { + return "" + } + v, ok := config[key] + if !ok { + return "" + } + s, ok := v.(string) + if !ok { + return "" + } + return s +} + +// StringSliceField returns config[key] as a []string, or nil if the key is +// absent or not a recognized slice-of-string shape. Both []string (the +// natural Go-side shape) and []any of strings (the natural shape after a +// generic decode, e.g. from encoding/json into map[string]any) are +// accepted, so callers that construct config maps either by hand or via +// JSON-style decoding both work without extra conversion. +func StringSliceField(config map[string]any, key string) []string { + if config == nil { + return nil + } + v, ok := config[key] + if !ok { + return nil + } + switch vals := v.(type) { + case []string: + return vals + case []any: + out := make([]string, 0, len(vals)) + for _, item := range vals { + s, ok := item.(string) + if !ok { + return nil + } + out = append(out, s) + } + return out + default: + return nil + } +} diff --git a/providers/internal/regconfig/regconfig_test.go b/providers/internal/regconfig/regconfig_test.go new file mode 100644 index 0000000..68884e5 --- /dev/null +++ b/providers/internal/regconfig/regconfig_test.go @@ -0,0 +1,55 @@ +package regconfig + +import ( + "reflect" + "testing" +) + +func TestStringField(t *testing.T) { + tests := []struct { + name string + config map[string]any + key string + want string + }{ + {"nil config", nil, "webhook_url", ""}, + {"missing key", map[string]any{}, "webhook_url", ""}, + {"present string", map[string]any{"webhook_url": "https://example.com"}, "webhook_url", "https://example.com"}, + {"wrong type int", map[string]any{"webhook_url": 42}, "webhook_url", ""}, + {"wrong type nil value", map[string]any{"webhook_url": nil}, "webhook_url", ""}, + {"empty string value", map[string]any{"webhook_url": ""}, "webhook_url", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := StringField(tt.config, tt.key) + if got != tt.want { + t.Errorf("StringField(%v, %q) = %q, want %q", tt.config, tt.key, got, tt.want) + } + }) + } +} + +func TestStringSliceField(t *testing.T) { + tests := []struct { + name string + config map[string]any + key string + want []string + }{ + {"nil config", nil, "recipients", nil}, + {"missing key", map[string]any{}, "recipients", nil}, + {"native string slice", map[string]any{"recipients": []string{"a@example.com", "b@example.com"}}, "recipients", []string{"a@example.com", "b@example.com"}}, + {"any slice of strings", map[string]any{"recipients": []any{"a@example.com", "b@example.com"}}, "recipients", []string{"a@example.com", "b@example.com"}}, + {"empty any slice", map[string]any{"recipients": []any{}}, "recipients", []string{}}, + {"any slice with non-string element", map[string]any{"recipients": []any{"a@example.com", 42}}, "recipients", nil}, + {"wrong type entirely", map[string]any{"recipients": "not-a-slice"}, "recipients", nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := StringSliceField(tt.config, tt.key) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("StringSliceField(%v, %q) = %#v, want %#v", tt.config, tt.key, got, tt.want) + } + }) + } +} diff --git a/providers/ntfy/register.go b/providers/ntfy/register.go new file mode 100644 index 0000000..e46a9e9 --- /dev/null +++ b/providers/ntfy/register.go @@ -0,0 +1,36 @@ +package ntfy + +import ( + "fmt" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/providers/internal/regconfig" + "github.com/Wikid82/go_notify_yourself/transport" +) + +// init registers this package's Factory under the name "ntfy" with the +// notify package's registry. +// +// Expected config keys: +// - "transport" (required): *transport.Wrapper — the shared dispatch +// primitive; see transport.NewWrapper. +// - "url" (string): the ntfy topic push endpoint. +// - "token" (string, optional): an ntfy access token. +// - "template" (string, optional): "minimal" (default), "detailed", or +// "custom". +// - "custom_template" (string, optional): used when template is "custom". +func init() { + notify.Register("ntfy", func(config map[string]any) (notify.Sender, error) { + w, ok := config["transport"].(*transport.Wrapper) + if !ok || w == nil { + return nil, fmt.Errorf(`ntfy: config["transport"] must be a non-nil *transport.Wrapper`) + } + cfg := Config{ + URL: regconfig.StringField(config, "url"), + Token: regconfig.StringField(config, "token"), + Template: regconfig.StringField(config, "template"), + CustomTemplate: regconfig.StringField(config, "custom_template"), + } + return New(cfg, w), nil + }) +} diff --git a/providers/ntfy/register_test.go b/providers/ntfy/register_test.go new file mode 100644 index 0000000..65a3663 --- /dev/null +++ b/providers/ntfy/register_test.go @@ -0,0 +1,54 @@ +package ntfy + +import ( + "testing" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/transport" +) + +func TestRegister_NewReturnsWorkingSender(t *testing.T) { + w := transport.NewWrapper() + + sender, err := notify.New("ntfy", map[string]any{ + "transport": w, + "url": "https://ntfy.sh/my-topic", + "token": "tk_abc", + }) + if err != nil { + t.Fatalf("notify.New(\"ntfy\", ...) returned error: %v", err) + } + + client, ok := sender.(*Client) + if !ok { + t.Fatalf("expected *ntfy.Client, got %T", sender) + } + if client.cfg.URL != "https://ntfy.sh/my-topic" || client.cfg.Token != "tk_abc" { + t.Errorf("expected config to be threaded through, got %#v", client.cfg) + } +} + +func TestRegister_MissingTransportReturnsErrorNotPanic(t *testing.T) { + sender, err := notify.New("ntfy", map[string]any{ + "url": "https://ntfy.sh/my-topic", + }) + if err == nil { + t.Fatal("expected an error when config[\"transport\"] is missing") + } + if sender != nil { + t.Fatalf("expected a nil Sender on error, got %#v", sender) + } +} + +func TestRegister_RegisteredUnderExpectedName(t *testing.T) { + found := false + for _, name := range notify.RegisteredTypes() { + if name == "ntfy" { + found = true + break + } + } + if !found { + t.Errorf("expected %q registered in notify.RegisteredTypes(), got %v", "ntfy", notify.RegisteredTypes()) + } +} diff --git a/providers/pushover/register.go b/providers/pushover/register.go new file mode 100644 index 0000000..8aa6e5f --- /dev/null +++ b/providers/pushover/register.go @@ -0,0 +1,39 @@ +package pushover + +import ( + "fmt" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/providers/internal/regconfig" + "github.com/Wikid82/go_notify_yourself/transport" +) + +// init registers this package's Factory under the name "pushover" with the +// notify package's registry. +// +// Expected config keys: +// - "transport" (required): *transport.Wrapper — the shared dispatch +// primitive; see transport.NewWrapper. +// - "user_key" (string): the Pushover user/group key. +// - "api_token" (string): the Pushover application API token. +// - "base_url" (string, optional): overrides Pushover's API base; +// intended for tests. +// - "template" (string, optional): "minimal" (default), "detailed", or +// "custom". +// - "custom_template" (string, optional): used when template is "custom". +func init() { + notify.Register("pushover", func(config map[string]any) (notify.Sender, error) { + w, ok := config["transport"].(*transport.Wrapper) + if !ok || w == nil { + return nil, fmt.Errorf(`pushover: config["transport"] must be a non-nil *transport.Wrapper`) + } + cfg := Config{ + UserKey: regconfig.StringField(config, "user_key"), + APIToken: regconfig.StringField(config, "api_token"), + BaseURL: regconfig.StringField(config, "base_url"), + Template: regconfig.StringField(config, "template"), + CustomTemplate: regconfig.StringField(config, "custom_template"), + } + return New(cfg, w), nil + }) +} diff --git a/providers/pushover/register_test.go b/providers/pushover/register_test.go new file mode 100644 index 0000000..be6447b --- /dev/null +++ b/providers/pushover/register_test.go @@ -0,0 +1,55 @@ +package pushover + +import ( + "testing" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/transport" +) + +func TestRegister_NewReturnsWorkingSender(t *testing.T) { + w := transport.NewWrapper() + + sender, err := notify.New("pushover", map[string]any{ + "transport": w, + "user_key": "u123", + "api_token": "a456", + }) + if err != nil { + t.Fatalf("notify.New(\"pushover\", ...) returned error: %v", err) + } + + client, ok := sender.(*Client) + if !ok { + t.Fatalf("expected *pushover.Client, got %T", sender) + } + if client.cfg.UserKey != "u123" || client.cfg.APIToken != "a456" { + t.Errorf("expected config to be threaded through, got %#v", client.cfg) + } +} + +func TestRegister_MissingTransportReturnsErrorNotPanic(t *testing.T) { + sender, err := notify.New("pushover", map[string]any{ + "user_key": "u123", + "api_token": "a456", + }) + if err == nil { + t.Fatal("expected an error when config[\"transport\"] is missing") + } + if sender != nil { + t.Fatalf("expected a nil Sender on error, got %#v", sender) + } +} + +func TestRegister_RegisteredUnderExpectedName(t *testing.T) { + found := false + for _, name := range notify.RegisteredTypes() { + if name == "pushover" { + found = true + break + } + } + if !found { + t.Errorf("expected %q registered in notify.RegisteredTypes(), got %v", "pushover", notify.RegisteredTypes()) + } +} diff --git a/providers/slack/register.go b/providers/slack/register.go new file mode 100644 index 0000000..48d0b68 --- /dev/null +++ b/providers/slack/register.go @@ -0,0 +1,34 @@ +package slack + +import ( + "fmt" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/providers/internal/regconfig" + "github.com/Wikid82/go_notify_yourself/transport" +) + +// init registers this package's Factory under the name "slack" with the +// notify package's registry. +// +// Expected config keys: +// - "transport" (required): *transport.Wrapper — the shared dispatch +// primitive; see transport.NewWrapper. +// - "webhook_url" (string): the Slack Incoming Webhook URL. +// - "template" (string, optional): "minimal" (default), "detailed", or +// "custom". +// - "custom_template" (string, optional): used when template is "custom". +func init() { + notify.Register("slack", func(config map[string]any) (notify.Sender, error) { + w, ok := config["transport"].(*transport.Wrapper) + if !ok || w == nil { + return nil, fmt.Errorf(`slack: config["transport"] must be a non-nil *transport.Wrapper`) + } + cfg := Config{ + WebhookURL: regconfig.StringField(config, "webhook_url"), + Template: regconfig.StringField(config, "template"), + CustomTemplate: regconfig.StringField(config, "custom_template"), + } + return New(cfg, w), nil + }) +} diff --git a/providers/slack/register_test.go b/providers/slack/register_test.go new file mode 100644 index 0000000..8da4ba4 --- /dev/null +++ b/providers/slack/register_test.go @@ -0,0 +1,64 @@ +package slack + +import ( + "testing" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/transport" +) + +func TestRegister_NewReturnsWorkingSender(t *testing.T) { + w := transport.NewWrapper() + + sender, err := notify.New("slack", map[string]any{ + "transport": w, + "webhook_url": "https://hooks.slack.com/services/T000/B000/xxxxxxxxxxxxxxxxxxxxxxxx", + "template": "minimal", + }) + if err != nil { + t.Fatalf("notify.New(\"slack\", ...) returned error: %v", err) + } + + client, ok := sender.(*Client) + if !ok { + t.Fatalf("expected *slack.Client, got %T", sender) + } + if client.cfg.WebhookURL != "https://hooks.slack.com/services/T000/B000/xxxxxxxxxxxxxxxxxxxxxxxx" { + t.Errorf("expected WebhookURL to be threaded through from config, got %q", client.cfg.WebhookURL) + } +} + +func TestRegister_MissingTransportReturnsErrorNotPanic(t *testing.T) { + sender, err := notify.New("slack", map[string]any{ + "webhook_url": "https://hooks.slack.com/services/T000/B000/xxxxxxxxxxxxxxxxxxxxxxxx", + }) + if err == nil { + t.Fatal("expected an error when config[\"transport\"] is missing") + } + if sender != nil { + t.Fatalf("expected a nil Sender on error, got %#v", sender) + } +} + +func TestRegister_WrongTypedTransportReturnsError(t *testing.T) { + _, err := notify.New("slack", map[string]any{ + "transport": "not-a-wrapper", + "webhook_url": "https://hooks.slack.com/services/T000/B000/xxxxxxxxxxxxxxxxxxxxxxxx", + }) + if err == nil { + t.Fatal("expected an error when config[\"transport\"] is the wrong type") + } +} + +func TestRegister_RegisteredUnderExpectedName(t *testing.T) { + found := false + for _, name := range notify.RegisteredTypes() { + if name == "slack" { + found = true + break + } + } + if !found { + t.Errorf("expected %q registered in notify.RegisteredTypes(), got %v", "slack", notify.RegisteredTypes()) + } +} diff --git a/providers/telegram/register.go b/providers/telegram/register.go new file mode 100644 index 0000000..276f5a8 --- /dev/null +++ b/providers/telegram/register.go @@ -0,0 +1,39 @@ +package telegram + +import ( + "fmt" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/providers/internal/regconfig" + "github.com/Wikid82/go_notify_yourself/transport" +) + +// init registers this package's Factory under the name "telegram" with the +// notify package's registry. +// +// Expected config keys: +// - "transport" (required): *transport.Wrapper — the shared dispatch +// primitive; see transport.NewWrapper. +// - "bot_token" (string): the Telegram bot token. +// - "chat_id" (string): the destination chat ID. +// - "base_url" (string, optional): overrides the Telegram Bot API base; +// intended for tests. +// - "template" (string, optional): "minimal" (default), "detailed", or +// "custom". +// - "custom_template" (string, optional): used when template is "custom". +func init() { + notify.Register("telegram", func(config map[string]any) (notify.Sender, error) { + w, ok := config["transport"].(*transport.Wrapper) + if !ok || w == nil { + return nil, fmt.Errorf(`telegram: config["transport"] must be a non-nil *transport.Wrapper`) + } + cfg := Config{ + BotToken: regconfig.StringField(config, "bot_token"), + ChatID: regconfig.StringField(config, "chat_id"), + BaseURL: regconfig.StringField(config, "base_url"), + Template: regconfig.StringField(config, "template"), + CustomTemplate: regconfig.StringField(config, "custom_template"), + } + return New(cfg, w), nil + }) +} diff --git a/providers/telegram/register_test.go b/providers/telegram/register_test.go new file mode 100644 index 0000000..822fe2b --- /dev/null +++ b/providers/telegram/register_test.go @@ -0,0 +1,55 @@ +package telegram + +import ( + "testing" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/transport" +) + +func TestRegister_NewReturnsWorkingSender(t *testing.T) { + w := transport.NewWrapper() + + sender, err := notify.New("telegram", map[string]any{ + "transport": w, + "bot_token": "bot123:abc", + "chat_id": "-100200300", + }) + if err != nil { + t.Fatalf("notify.New(\"telegram\", ...) returned error: %v", err) + } + + client, ok := sender.(*Client) + if !ok { + t.Fatalf("expected *telegram.Client, got %T", sender) + } + if client.cfg.BotToken != "bot123:abc" || client.cfg.ChatID != "-100200300" { + t.Errorf("expected config to be threaded through, got %#v", client.cfg) + } +} + +func TestRegister_MissingTransportReturnsErrorNotPanic(t *testing.T) { + sender, err := notify.New("telegram", map[string]any{ + "bot_token": "bot123:abc", + "chat_id": "-100200300", + }) + if err == nil { + t.Fatal("expected an error when config[\"transport\"] is missing") + } + if sender != nil { + t.Fatalf("expected a nil Sender on error, got %#v", sender) + } +} + +func TestRegister_RegisteredUnderExpectedName(t *testing.T) { + found := false + for _, name := range notify.RegisteredTypes() { + if name == "telegram" { + found = true + break + } + } + if !found { + t.Errorf("expected %q registered in notify.RegisteredTypes(), got %v", "telegram", notify.RegisteredTypes()) + } +} diff --git a/providers/webhook/register.go b/providers/webhook/register.go new file mode 100644 index 0000000..25b3ef9 --- /dev/null +++ b/providers/webhook/register.go @@ -0,0 +1,34 @@ +package webhook + +import ( + "fmt" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/providers/internal/regconfig" + "github.com/Wikid82/go_notify_yourself/transport" +) + +// init registers this package's Factory under the name "webhook" with the +// notify package's registry. +// +// Expected config keys: +// - "transport" (required): *transport.Wrapper — the shared dispatch +// primitive; see transport.NewWrapper. +// - "url" (string): the arbitrary destination to POST to. +// - "template" (string, optional): "minimal" (default), "detailed", or +// "custom". +// - "custom_template" (string, optional): used when template is "custom". +func init() { + notify.Register("webhook", func(config map[string]any) (notify.Sender, error) { + w, ok := config["transport"].(*transport.Wrapper) + if !ok || w == nil { + return nil, fmt.Errorf(`webhook: config["transport"] must be a non-nil *transport.Wrapper`) + } + cfg := Config{ + URL: regconfig.StringField(config, "url"), + Template: regconfig.StringField(config, "template"), + CustomTemplate: regconfig.StringField(config, "custom_template"), + } + return New(cfg, w), nil + }) +} diff --git a/providers/webhook/register_test.go b/providers/webhook/register_test.go new file mode 100644 index 0000000..006a112 --- /dev/null +++ b/providers/webhook/register_test.go @@ -0,0 +1,53 @@ +package webhook + +import ( + "testing" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/transport" +) + +func TestRegister_NewReturnsWorkingSender(t *testing.T) { + w := transport.NewWrapper() + + sender, err := notify.New("webhook", map[string]any{ + "transport": w, + "url": "https://example.com/hook", + }) + if err != nil { + t.Fatalf("notify.New(\"webhook\", ...) returned error: %v", err) + } + + client, ok := sender.(*Client) + if !ok { + t.Fatalf("expected *webhook.Client, got %T", sender) + } + if client.cfg.URL != "https://example.com/hook" { + t.Errorf("expected config to be threaded through, got %#v", client.cfg) + } +} + +func TestRegister_MissingTransportReturnsErrorNotPanic(t *testing.T) { + sender, err := notify.New("webhook", map[string]any{ + "url": "https://example.com/hook", + }) + if err == nil { + t.Fatal("expected an error when config[\"transport\"] is missing") + } + if sender != nil { + t.Fatalf("expected a nil Sender on error, got %#v", sender) + } +} + +func TestRegister_RegisteredUnderExpectedName(t *testing.T) { + found := false + for _, name := range notify.RegisteredTypes() { + if name == "webhook" { + found = true + break + } + } + if !found { + t.Errorf("expected %q registered in notify.RegisteredTypes(), got %v", "webhook", notify.RegisteredTypes()) + } +}