diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index fa66709..5385993 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ```bash # 빌드 (CGO 필수 — go-sqlite3) -CGO_ENABLED=1 go build -o webhook-relay ./cmd/server/ +CGO_ENABLED=1 go build -o relaybox ./cmd/server/ # 전체 테스트 (race detector 포함) go test -race ./... -timeout 60s @@ -16,7 +16,7 @@ go test -race ./internal/adapter/output/sqlite/... go test -race ./test/e2e/... # 특정 테스트 함수 하나만 실행 -go test -race -run TestDeliveryWorker_DeliverSuccess ./internal/application/service/ +go test -race -run TestRelayWorker_DeliverSuccess ./internal/application/service/ # 정적 분석 go vet ./... @@ -47,11 +47,11 @@ cmd/server/main.go ← DI 조립, cobra CLI | 경로 | 역할 | |------|------| -| `internal/domain/` | 엔티티(`Alert`, `Channel`), 열거형(`SourceType`, `AlertStatus`, `ChannelType`), 센티넬 에러, 템플릿 렌더링 | -| `internal/application/port/input/` | `ReceiveAlertUseCase` 인터페이스 | -| `internal/application/port/output/` | `AlertRepository`, `AlertQueue`, `AlertSender`, `SenderRegistry`, `RouteConfigReader` 인터페이스 | -| `internal/application/service/` | `AlertService`(Receive), `DeliveryWorker`(Start) | -| `internal/config/` | Viper 기반 YAML 로더, `InMemoryRouteConfigReader`, hot-reload(`Watch`) | +| `internal/domain/` | 엔티티(`Message`, `Output`), 열거형(`InputType`, `MessageStatus`, `OutputType`), 센티넬 에러, 템플릿 렌더링 | +| `internal/application/port/input/` | `ReceiveMessageUseCase` 인터페이스 | +| `internal/application/port/output/` | `MessageRepository`, `MessageQueue`, `OutputSender`, `OutputRegistry`, `RuleConfigReader` 인터페이스 | +| `internal/application/service/` | `MessageService`(Receive), `RelayWorker`(Start) | +| `internal/config/` | Viper 기반 YAML 로더, `InMemoryRuleConfigReader`, hot-reload(`Watch`) | | `internal/adapter/input/http/` | chi 라우터, RFC 7807 에러, `X-API-Version` 헤더 미들웨어 | | `internal/adapter/input/websocket/` | gorilla/websocket 인바운드 핸들러 | | `internal/adapter/output/sqlite/` | sqlc 기반 SQLite 저장소 | @@ -66,23 +66,23 @@ cmd/server/main.go ← DI 조립, cobra CLI 모든 열거형은 `type X string` + 대문자 상수(`"BESZEL"`, `"PENDING"` 등). 별도 `MarshalJSON` 불필요. ### 라우팅 키 -`InMemoryRouteConfigReader`는 routes를 **source type**(e.g. `"BESZEL"`)으로 인덱싱한다. `Update()`에서 `sourceID → sourceType` 변환을 수행하므로, `GetChannels`는 반드시 `string(alert.Source)`(타입 값)를 넘겨야 한다. +`InMemoryRuleConfigReader`는 rules를 **input type**(e.g. `"BESZEL"`)으로 인덱싱한다. `Update()`에서 `inputID → inputType` 변환을 수행하므로, `GetOutputs`는 반드시 `string(msg.Input)`(타입 값)를 넘겨야 한다. ### 큐 at-least-once 보장 파일 큐는 `Dequeue` 시 `.json` → `.json.processing` 으로 rename. `Ack`는 `.processing` 삭제, `Nack`는 원래 이름으로 rename 복구. ### AckFunc / NackFunc -`AlertQueue.Dequeue`는 `(domain.Alert, AckFunc, NackFunc, error)`를 반환한다. `AckFunc`와 `NackFunc`는 `output` 패키지에 정의된 함수 타입. +`MessageQueue.Dequeue`는 `(domain.Message, AckFunc, NackFunc, error)`를 반환한다. `AckFunc`와 `NackFunc`는 `output` 패키지에 정의된 함수 타입. ### HTTP API - URL versioning 없이 `X-API-Version` 응답 헤더 사용 - 에러 포맷: RFC 7807 (`application/problem+json`) -- `POST /sources/{sourceId}/alerts` → 201 + `Location: /sources/{sourceId}/alerts/{alertId}` +- `POST /inputs/{inputId}/messages` → 201 + `Location: /inputs/{inputId}/messages/{messageId}` - Bearer token 인증 (`Authorization: Bearer `) -- WebSocket: `GET /sources/{sourceId}/alerts/ws` +- WebSocket: `GET /inputs/{inputId}/messages/ws` ### sqlc `internal/adapter/output/sqlite/db/` 는 자동 생성 코드. `query.sql` / `schema.sql` 수정 후 `sqlc generate` 재실행. 직접 편집 금지. ### DI 조립 -`cmd/server/main.go`의 `runServer()` 함수가 전체 어댑터를 조립한다. `configSourceResolver`는 config 파일 기반으로 `SourceResolver` 인터페이스를 구현한다. +`cmd/server/main.go`의 `runServer()` 함수가 전체 어댑터를 조립한다. `configInputResolver`는 config 파일 기반으로 `InputResolver` 인터페이스를 구현한다. diff --git a/README.md b/README.md index bc4c033..a1d0c99 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,47 @@ -# webhook-relay +# relaybox -모니터링 알람을 수신하여 외부 채널로 전달하는 경량 웹훅 릴레이 허브. +Generic relay hub: receives any inbound protocol/format and delivers to outbound channels (CEL/Expr expression filter/transform/route rules planned). ``` -beszel / dozzle → webhook-relay → Slack / Discord / 커스텀 Webhook +any inbound (HTTP REST / WebSocket / TCP / ...) + ↓ + CEL / Expr expression filter + transform + route + ↓ +any outbound (Webhook / Slack / Discord / ...) ``` -## 주요 기능 +## Features -- **다중 소스 수신** — HTTP REST + WebSocket 인바운드 -- **YAML 템플릿 변환** — Go `text/template` 기반 페이로드 변환 -- **at-least-once 전달** — 파일 큐 기반, 재시작 후에도 메시지 유실 없음 -- **지수 백오프 재시도** — 채널별 `retryCount` / `retryDelayMs` 설정 -- **설정 핫리로드** — 재시작 없이 channels / routes 변경 반영 -- **Bearer token 인증** — 소스별 독립 시크릿 +- **Multi-protocol inbound** — HTTP REST + WebSocket (TCP planned) +- **Expression-based routing** — CEL/Expr filter and transform rules per route (planned) +- **Template transformation** — Go `text/template` payload rendering +- **at-least-once delivery** — file-queue backed, survives restarts +- **Exponential backoff retry** — per-channel `retryCount` / `retryDelayMs` +- **Hot config reload** — change outputs / rules without restart +- **Bearer token auth** — per-input independent secrets -## 빠른 시작 +## Quick Start -### 사전 요구사항 +### Prerequisites - Go 1.25+ -- GCC (go-sqlite3 CGO 빌드 필요) +- GCC (required for go-sqlite3 CGO build) ```bash -# 빌드 -CGO_ENABLED=1 go build -o webhook-relay ./cmd/server/ +# Build +CGO_ENABLED=1 go build -o relaybox ./cmd/server/ -# 설정 파일 준비 +# Prepare config cp internal/config/config.example.yaml config.yaml -# config.yaml 편집 후 +# Edit config.yaml, then: -# 서버 시작 -./webhook-relay start --config config.yaml +# Start server +./relaybox start --config config.yaml ``` -## 설정 +## Configuration -`config.yaml` 예시: +`config.yaml` example: ```yaml server: @@ -46,12 +51,12 @@ log: level: info # debug | info | warn | error format: json # json | text -sources: +inputs: - id: beszel type: BESZEL secret: "your-secret" -channels: +outputs: - id: ops-webhook type: WEBHOOK url: "https://hooks.example.com/xyz" @@ -59,13 +64,13 @@ channels: retryCount: 3 retryDelayMs: 1000 -routes: - - sourceId: beszel - channelIds: [ops-webhook] +rules: + - inputId: beszel + outputIds: [ops-webhook] storage: type: SQLITE - path: "./data/webhook-relay.db" + path: "./data/relaybox.db" queue: type: FILE @@ -73,71 +78,75 @@ queue: workerCount: 2 ``` -### 템플릿 변수 +### Template Variables -| 변수 | 설명 | -|------|------| -| `{{ .ID }}` | 알람 ULID | -| `{{ .Source }}` | 소스 타입 (`BESZEL`, `DOZZLE` 등) | -| `{{ .Payload }}` | 원본 JSON 페이로드 (문자열) | -| `{{ .CreatedAt }}` | 수신 시각 (`time.Time`) | +| Variable | Description | +|----------|-------------| +| `{{ .ID }}` | Alert ULID | +| `{{ .Source }}` | Source type (`BESZEL`, `DOZZLE`, etc.) | +| `{{ .Payload }}` | Raw JSON payload (string) | +| `{{ .CreatedAt }}` | Receive time (`time.Time`) | ## API -### 알람 수신 +### Receive Message ``` -POST /sources/{sourceId}/alerts +POST /inputs/{inputId}/messages Authorization: Bearer Content-Type: application/json {"host": "server1", "status": "down"} ``` -응답 `201 Created`: +Response `201 Created`: ```json {"id": "01J...", "status": "PENDING"} ``` -### WebSocket 수신 +### WebSocket Inbound ``` -GET /sources/{sourceId}/alerts/ws +GET /inputs/{inputId}/messages/ws Authorization: Bearer ``` -연결 후 JSON 메시지 전송 시 HTTP POST와 동일하게 처리. +JSON messages sent after connect are processed identically to HTTP POST. -### 헬스체크 +### Health Check ``` GET /healthz → 200 OK ``` -모든 응답에 `X-API-Version` 헤더가 포함된다. +All responses include an `X-API-Version` header. -## 아키텍처 +## Architecture -헥사고날 아키텍처(Ports & Adapters). 의존성은 항상 안쪽(domain)으로만 흐른다. +Hexagonal architecture (Ports & Adapters). Dependencies always flow inward toward domain. ``` -[ HTTP / WebSocket ] - ↓ - application/service - ↓ -[ SQLite repo ] [ File queue ] [ Webhook sender ] +domain (0 deps) + ↑ +application/port/{input,output} ← interface definitions + ↑ +application/service ← business logic + ↑ +adapter/{input,output} ← external world connections + ↑ +cmd/server/main.go ← DI wiring, cobra CLI ``` -## 개발 +## Development ```bash -# 전체 테스트 (race detector) +# Full test suite (race detector) go test -race ./... -timeout 60s -# 정적 분석 +# Static analysis go vet ./... -# sqlc 재생성 (SQL 변경 시) +# Regenerate sqlc (after SQL changes) cd internal/adapter/output/sqlite && sqlc generate ``` diff --git a/cmd/server/main.go b/cmd/server/main.go index 0490145..1caa64a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -11,15 +11,18 @@ import ( "time" "github.com/spf13/cobra" - httpadapter "webhook-relay/internal/adapter/input/http" - wsadapter "webhook-relay/internal/adapter/input/websocket" - "webhook-relay/internal/adapter/output/filequeue" - sqliteadapter "webhook-relay/internal/adapter/output/sqlite" - webhookadapter "webhook-relay/internal/adapter/output/webhook" - "webhook-relay/internal/application/port/output" - "webhook-relay/internal/application/service" - cfgpkg "webhook-relay/internal/config" - "webhook-relay/internal/domain" + httpadapter "relaybox/internal/adapter/input/http" + "relaybox/internal/adapter/input/parser" + tcpadapter "relaybox/internal/adapter/input/tcp" + wsadapter "relaybox/internal/adapter/input/websocket" + "relaybox/internal/adapter/output/expression" + "relaybox/internal/adapter/output/filequeue" + sqliteadapter "relaybox/internal/adapter/output/sqlite" + webhookadapter "relaybox/internal/adapter/output/webhook" + "relaybox/internal/application/port/output" + "relaybox/internal/application/service" + cfgpkg "relaybox/internal/config" + "relaybox/internal/domain" ) func main() { @@ -30,7 +33,7 @@ func main() { func rootCmd() *cobra.Command { var cfgPath string - root := &cobra.Command{Use: "webhook-relay", Short: "Monitoring alert relay hub"} + root := &cobra.Command{Use: "relaybox", Short: "Generic relay hub — receives any inbound message, routes to outbound outputs"} start := &cobra.Command{ Use: "start", Short: "Start server", @@ -40,7 +43,7 @@ func rootCmd() *cobra.Command { root.AddCommand(start) root.AddCommand(&cobra.Command{ Use: "version", Short: "Print version", - Run: func(_ *cobra.Command, _ []string) { fmt.Println("webhook-relay v0.1.0") }, + Run: func(_ *cobra.Command, _ []string) { fmt.Println("relaybox v0.2.0") }, }) return root } @@ -52,7 +55,7 @@ func runServer(cfgPath string) error { } setupLogger(cfg) - // 아웃바운드 어댑터 + // Outbound adapters repo, err := sqliteadapter.New(cfg.Storage.Path) if err != nil { return fmt.Errorf("init sqlite: %w", err) @@ -65,37 +68,95 @@ func runServer(cfgPath string) error { } sender := webhookadapter.NewSender() - registry := webhookadapter.NewRegistry(map[domain.ChannelType]output.AlertSender{ - domain.ChannelTypeWebhook: sender, + registry := webhookadapter.NewRegistry(map[domain.OutputType]output.OutputSender{ + domain.OutputTypeWebhook: sender, }) - // 설정 기반 라우팅 (핫리로드 지원) - routeReader := cfgpkg.NewInMemoryRouteConfigReader(cfg) + // Expression engine registry + exprRegistry := expression.NewInMemoryExpressionEngineRegistry() + celEngine, err := expression.NewCELEngine() + if err != nil { + return fmt.Errorf("init CEL engine: %w", err) + } + exprEngine := expression.NewExprEngine() + exprRegistry.Register(celEngine) + exprRegistry.Register(exprEngine) + if cfg.Expression.DefaultEngine != "" { + if err := exprRegistry.SetDefault(cfg.Expression.DefaultEngine); err != nil { + return fmt.Errorf("set default expression engine: %w", err) + } + } - // Viper WatchConfig → 핫리로드 + // Config-based routing (hot-reload support) + ruleReader := cfgpkg.NewInMemoryRuleConfigReader(cfg) + + // Viper WatchConfig -> hot-reload v, err := cfgpkg.NewViper(cfgPath) if err != nil { return fmt.Errorf("init viper: %w", err) } cfgpkg.Watch(v, func(newCfg *cfgpkg.Config) { - routeReader.Update(newCfg) + ruleReader.Update(newCfg) slog.Info("config reloaded") }) - // 애플리케이션 서비스 - alertSvc := service.NewAlertService(repo, queue) - worker := service.NewDeliveryWorker(queue, repo, routeReader, registry) + // Parser registry + parserRegistry := parser.NewInMemoryParserRegistry() + parserRegistry.Register(parser.NewJSONParser()) + parserRegistry.Register(parser.NewFormParser()) + parserRegistry.Register(parser.NewXMLParser()) + parserRegistry.Register(parser.NewLogfmtParser()) + + parserTypes := make(map[domain.InputType]string) + for _, inp := range cfg.Inputs { + if inp.Parser == "" { + continue + } + parserKey := inp.Parser + if inp.Parser == "regex" { + regexParser, err := parser.NewRegexParser(inp.Pattern) + if err != nil { + return fmt.Errorf("input %q: %w", inp.ID, err) + } + parserKey = "regex:" + inp.ID + parserRegistry.RegisterWithKey(parserKey, regexParser) + } + parserTypes[domain.InputType(inp.Type)] = parserKey + } + + // Application services + msgSvc := service.NewMessageService(repo, queue, parserTypes, parserRegistry) + worker := service.NewRelayWorker(queue, repo, ruleReader, registry, exprRegistry) - // HTTP + WebSocket 어댑터 조립 - resolver := newConfigSourceResolver(cfg) - wsHandler := wsadapter.NewHandler(alertSvc) - router := httpadapter.NewRouter(alertSvc, resolver, wsHandler) + // HTTP + WebSocket adapter assembly + resolver := newConfigInputResolver(cfg) + wsHandler := wsadapter.NewHandler(msgSvc) + router := httpadapter.NewRouter(msgSvc, resolver, wsHandler) ctx, cancel := context.WithCancel(context.Background()) defer cancel() worker.Start(ctx, cfg.Queue.WorkerCount) + // TCP listeners (per InputConfig with Address set) + for _, inp := range cfg.Inputs { + if inp.Address == "" { + continue + } + delimiter := byte('\n') + if inp.Delimiter != "" { + delimiter = inp.Delimiter[0] + } + contentType := parserToContentType(inp.Parser) + tcpL := tcpadapter.NewListener(msgSvc, domain.InputType(inp.Type), inp.Address, delimiter, contentType) + go func() { + slog.Info("tcp listener starting", "address", inp.Address, "inputType", inp.Type) + if err := tcpL.Start(ctx); err != nil { + slog.Error("tcp listener error", "address", inp.Address, "err", err) + } + }() + } + srv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Server.Port), Handler: router, @@ -129,32 +190,50 @@ func runServer(cfgPath string) error { return srv.Shutdown(shutCtx) } -// configSourceResolver Config 기반 SourceResolver 구현 -type configSourceResolver struct { - sources map[string]domain.SourceType +// configInputResolver Config-based InputResolver implementation +type configInputResolver struct { + inputs map[string]domain.InputType secrets map[string]string } -func newConfigSourceResolver(cfg *cfgpkg.Config) *configSourceResolver { - sources := make(map[string]domain.SourceType, len(cfg.Sources)) - secrets := make(map[string]string, len(cfg.Sources)) - for _, s := range cfg.Sources { - sources[s.ID] = domain.SourceType(s.Type) +func newConfigInputResolver(cfg *cfgpkg.Config) *configInputResolver { + inputs := make(map[string]domain.InputType, len(cfg.Inputs)) + secrets := make(map[string]string, len(cfg.Inputs)) + for _, s := range cfg.Inputs { + inputs[s.ID] = domain.InputType(s.Type) secrets[s.ID] = s.Secret } - return &configSourceResolver{sources: sources, secrets: secrets} + return &configInputResolver{inputs: inputs, secrets: secrets} } -func (r *configSourceResolver) Resolve(sourceID string) (domain.SourceType, error) { - st, ok := r.sources[sourceID] +func (r *configInputResolver) Resolve(inputID string) (domain.InputType, error) { + st, ok := r.inputs[inputID] if !ok { - return "", fmt.Errorf("resolve %q: %w", sourceID, domain.ErrSourceNotFound) + return "", fmt.Errorf("resolve %q: %w", inputID, domain.ErrInputNotFound) } return st, nil } -func (r *configSourceResolver) ValidateToken(sourceID, token string) bool { - return r.secrets[sourceID] == token +func (r *configInputResolver) ValidateToken(inputID, token string) bool { + return r.secrets[inputID] == token +} + +func parserToContentType(parserType string) string { + switch parserType { + case "json": + return "application/json" + case "xml": + return "application/xml" + case "form": + return "application/x-www-form-urlencoded" + case "logfmt": + return "text/plain" + case "regex", "": + return "application/octet-stream" + default: + slog.Warn("unknown parser type for content-type mapping", "parser", parserType) + return "application/octet-stream" + } } func setupLogger(cfg *cfgpkg.Config) { diff --git a/go.mod b/go.mod index 85509bc..a9a645c 100644 --- a/go.mod +++ b/go.mod @@ -1,25 +1,37 @@ -module webhook-relay +module relaybox go 1.25 require ( - github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-chi/chi/v5 v5.2.5 // indirect + github.com/expr-lang/expr v1.17.8 + github.com/fsnotify/fsnotify v1.9.0 + github.com/go-chi/chi/v5 v5.2.5 + github.com/google/cel-go v0.27.0 + github.com/gorilla/websocket v1.5.3 + github.com/kr/logfmt v0.0.0-20210122060352-19f9bcb100e6 + github.com/mattn/go-sqlite3 v1.14.37 + github.com/oklog/ulid/v2 v2.1.1 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 +) + +require ( + cel.dev/expr v0.25.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-sqlite3 v1.14.37 // indirect - github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/spf13/viper v1.21.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.28.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect + google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/go.sum b/go.sum index 994bd76..52600f4 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,34 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= +github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= +github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/logfmt v0.0.0-20210122060352-19f9bcb100e6 h1:ZK1mH67KVyVW/zOLu0xLva+f6xJ8vt+LGrkQq5FJYLY= +github.com/kr/logfmt v0.0.0-20210122060352-19f9bcb100e6/go.mod h1:JIiJcj9TX57tEvCXjm6eaHd2ce4pZZf9wzYuThq45u8= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= @@ -16,6 +36,10 @@ github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= @@ -32,12 +56,26 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw= +google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 h1:2035KHhUv+EpyB+hWgJnaWKJOdX1E95w2S8Rr4uWKTs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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/internal/adapter/input/http/handler.go b/internal/adapter/input/http/handler.go index 0606ea1..2afaecb 100644 --- a/internal/adapter/input/http/handler.go +++ b/internal/adapter/input/http/handler.go @@ -9,21 +9,21 @@ import ( "time" "github.com/go-chi/chi/v5" - "webhook-relay/internal/application/port/input" - "webhook-relay/internal/domain" + "relaybox/internal/application/port/input" + "relaybox/internal/domain" ) type Handler struct { - uc input.ReceiveAlertUseCase - resolver SourceResolver + uc input.ReceiveMessageUseCase + resolver InputResolver } -func NewHandler(uc input.ReceiveAlertUseCase, resolver SourceResolver) *Handler { +func NewHandler(uc input.ReceiveMessageUseCase, resolver InputResolver) *Handler { return &Handler{uc: uc, resolver: resolver} } -func (h *Handler) PostAlert(w http.ResponseWriter, r *http.Request) { - sourceID := chi.URLParam(r, "sourceId") +func (h *Handler) PostMessage(w http.ResponseWriter, r *http.Request) { + inputID := chi.URLParam(r, "inputId") token := tokenFromHeader(r) if token == "" { @@ -31,13 +31,13 @@ func (h *Handler) PostAlert(w http.ResponseWriter, r *http.Request) { return } - if !h.resolver.ValidateToken(sourceID, token) { + if !h.resolver.ValidateToken(inputID, token) { writeError(w, r, http.StatusUnauthorized, "Unauthorized", - fmt.Sprintf("invalid or missing token for source: %s", sourceID)) + fmt.Sprintf("invalid or missing token for input: %s", inputID)) return } - sourceType, err := h.resolver.Resolve(sourceID) + inputType, err := h.resolver.Resolve(inputID) if err != nil { mapError(w, r, err) return @@ -55,20 +55,20 @@ func (h *Handler) PostAlert(w http.ResponseWriter, r *http.Request) { return } - alertID, err := h.uc.Receive(r.Context(), sourceType, body) + messageID, err := h.uc.Receive(r.Context(), inputType, r.Header.Get("Content-Type"), body) if err != nil { mapError(w, r, err) return } resp := map[string]any{ - "id": alertID, - "sourceId": sourceID, - "status": string(domain.AlertStatusPending), + "id": messageID, + "inputId": inputID, + "status": string(domain.MessageStatusPending), "createdAt": time.Now().UTC().Format(time.RFC3339), } w.Header().Set("Content-Type", "application/json") - w.Header().Set("Location", fmt.Sprintf("/sources/%s/alerts/%s", sourceID, alertID)) + w.Header().Set("Location", fmt.Sprintf("/inputs/%s/messages/%s", inputID, messageID)) w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(resp) } diff --git a/internal/adapter/input/http/handler_test.go b/internal/adapter/input/http/handler_test.go index 36fa857..d7cde43 100644 --- a/internal/adapter/input/http/handler_test.go +++ b/internal/adapter/input/http/handler_test.go @@ -7,49 +7,49 @@ import ( "strings" "testing" - httpadapter "webhook-relay/internal/adapter/input/http" - "webhook-relay/internal/domain" + httpadapter "relaybox/internal/adapter/input/http" + "relaybox/internal/domain" ) type mockUseCase struct { - receiveFn func(context.Context, domain.SourceType, []byte) (string, error) + receiveFn func(context.Context, domain.InputType, string, []byte) (string, error) } -func (m *mockUseCase) Receive(ctx context.Context, s domain.SourceType, p []byte) (string, error) { - return m.receiveFn(ctx, s, p) +func (m *mockUseCase) Receive(ctx context.Context, s domain.InputType, contentType string, p []byte) (string, error) { + return m.receiveFn(ctx, s, contentType, p) } type mockResolver struct { - sources map[string]domain.SourceType + inputs map[string]domain.InputType secrets map[string]string } -func (m *mockResolver) Resolve(sourceID string) (domain.SourceType, error) { - st, ok := m.sources[sourceID] +func (m *mockResolver) Resolve(inputID string) (domain.InputType, error) { + st, ok := m.inputs[inputID] if !ok { - return "", domain.ErrSourceNotFound + return "", domain.ErrInputNotFound } return st, nil } -func (m *mockResolver) ValidateToken(sourceID, token string) bool { - return m.secrets[sourceID] == token +func (m *mockResolver) ValidateToken(inputID, token string) bool { + return m.secrets[inputID] == token } -func newTestRouter(receiveFn func(context.Context, domain.SourceType, []byte) (string, error)) http.Handler { +func newTestRouter(receiveFn func(context.Context, domain.InputType, string, []byte) (string, error)) http.Handler { uc := &mockUseCase{receiveFn: receiveFn} resolver := &mockResolver{ - sources: map[string]domain.SourceType{"beszel": domain.SourceTypeBeszel}, + inputs: map[string]domain.InputType{"beszel": domain.InputTypeBeszel}, secrets: map[string]string{"beszel": "test-token"}, } return httpadapter.NewRouter(uc, resolver, nil) } -func TestHandler_PostAlert_Success(t *testing.T) { - router := newTestRouter(func(_ context.Context, _ domain.SourceType, _ []byte) (string, error) { +func TestHandler_PostMessage_Success(t *testing.T) { + router := newTestRouter(func(_ context.Context, _ domain.InputType, _ string, _ []byte) (string, error) { return "01JTEST00000000000000000", nil }) - req := httptest.NewRequest(http.MethodPost, "/sources/beszel/alerts", strings.NewReader(`{"level":"critical"}`)) + req := httptest.NewRequest(http.MethodPost, "/inputs/beszel/messages", strings.NewReader(`{"level":"critical"}`)) req.Header.Set("Authorization", "Bearer test-token") req.Header.Set("Content-Type", "application/json") @@ -63,20 +63,20 @@ func TestHandler_PostAlert_Success(t *testing.T) { if loc == "" { t.Error("Location header missing") } - // Location must point to the specific alert, not the collection - if !strings.Contains(loc, "/alerts/01JTEST00000000000000000") { - t.Errorf("Location = %q, want path containing specific alertId", loc) + // Location must point to the specific message, not the collection + if !strings.Contains(loc, "/messages/01JTEST00000000000000000") { + t.Errorf("Location = %q, want path containing specific messageId", loc) } if v := w.Header().Get("X-API-Version"); v == "" { t.Error("X-API-Version header missing") } } -func TestHandler_PostAlert_InvalidToken(t *testing.T) { - router := newTestRouter(func(_ context.Context, _ domain.SourceType, _ []byte) (string, error) { +func TestHandler_PostMessage_InvalidToken(t *testing.T) { + router := newTestRouter(func(_ context.Context, _ domain.InputType, _ string, _ []byte) (string, error) { return "", nil }) - req := httptest.NewRequest(http.MethodPost, "/sources/beszel/alerts", strings.NewReader(`{}`)) + req := httptest.NewRequest(http.MethodPost, "/inputs/beszel/messages", strings.NewReader(`{}`)) req.Header.Set("Authorization", "Bearer wrong-token") w := httptest.NewRecorder() @@ -91,7 +91,7 @@ func TestHandler_PostAlert_InvalidToken(t *testing.T) { } func TestHandler_Healthz(t *testing.T) { - router := newTestRouter(func(_ context.Context, _ domain.SourceType, _ []byte) (string, error) { + router := newTestRouter(func(_ context.Context, _ domain.InputType, _ string, _ []byte) (string, error) { return "", nil }) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) @@ -104,24 +104,24 @@ func TestHandler_Healthz(t *testing.T) { // allowAllResolver는 ValidateToken이 항상 true를 반환하는 취약한 구현을 모사한다. // handler가 ValidateToken에 의존하지 않고 빈 토큰을 직접 거부하는지 검증하기 위해 사용한다. -type allowAllResolver struct{ sources map[string]domain.SourceType } +type allowAllResolver struct{ inputs map[string]domain.InputType } -func (a *allowAllResolver) Resolve(id string) (domain.SourceType, error) { - st, ok := a.sources[id] +func (a *allowAllResolver) Resolve(id string) (domain.InputType, error) { + st, ok := a.inputs[id] if !ok { - return "", domain.ErrSourceNotFound + return "", domain.ErrInputNotFound } return st, nil } func (a *allowAllResolver) ValidateToken(_, _ string) bool { return true } -func TestHandler_PostAlert_EmptyToken(t *testing.T) { +func TestHandler_PostMessage_EmptyToken(t *testing.T) { // ValidateToken이 항상 true인 resolver를 사용하여, // handler 레이어에서 빈 토큰을 명시적으로 거부해야 함을 검증한다. - uc := &mockUseCase{receiveFn: func(_ context.Context, _ domain.SourceType, _ []byte) (string, error) { + uc := &mockUseCase{receiveFn: func(_ context.Context, _ domain.InputType, _ string, _ []byte) (string, error) { return "id", nil }} - resolver := &allowAllResolver{sources: map[string]domain.SourceType{"beszel": domain.SourceTypeBeszel}} + resolver := &allowAllResolver{inputs: map[string]domain.InputType{"beszel": domain.InputTypeBeszel}} router := httpadapter.NewRouter(uc, resolver, nil) tests := []struct { @@ -133,7 +133,7 @@ func TestHandler_PostAlert_EmptyToken(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/sources/beszel/alerts", strings.NewReader(`{}`)) + req := httptest.NewRequest(http.MethodPost, "/inputs/beszel/messages", strings.NewReader(`{}`)) if tc.auth != "" { req.Header.Set("Authorization", tc.auth) } @@ -146,13 +146,13 @@ func TestHandler_PostAlert_EmptyToken(t *testing.T) { } } -func TestHandler_PostAlert_BodyTooLarge(t *testing.T) { - router := newTestRouter(func(_ context.Context, _ domain.SourceType, _ []byte) (string, error) { +func TestHandler_PostMessage_BodyTooLarge(t *testing.T) { + router := newTestRouter(func(_ context.Context, _ domain.InputType, _ string, _ []byte) (string, error) { return "id", nil }) // 1MB + 1byte 초과 요청 oversized := strings.Repeat("x", 1<<20+1) - req := httptest.NewRequest(http.MethodPost, "/sources/beszel/alerts", strings.NewReader(oversized)) + req := httptest.NewRequest(http.MethodPost, "/inputs/beszel/messages", strings.NewReader(oversized)) req.Header.Set("Authorization", "Bearer test-token") req.Header.Set("Content-Type", "application/json") @@ -164,22 +164,22 @@ func TestHandler_PostAlert_BodyTooLarge(t *testing.T) { } } -func TestHandler_SourceNotFound(t *testing.T) { - router := newTestRouter(func(_ context.Context, _ domain.SourceType, _ []byte) (string, error) { - return "", domain.ErrSourceNotFound +func TestHandler_InputNotFound(t *testing.T) { + router := newTestRouter(func(_ context.Context, _ domain.InputType, _ string, _ []byte) (string, error) { + return "", domain.ErrInputNotFound }) - req := httptest.NewRequest(http.MethodPost, "/sources/unknown/alerts", strings.NewReader(`{}`)) + req := httptest.NewRequest(http.MethodPost, "/inputs/unknown/messages", strings.NewReader(`{}`)) req.Header.Set("Authorization", "Bearer test-token") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { - // unknown source → token check fails first → 401 - t.Logf("status = %d (expected 401 since unknown source has no registered token)", w.Code) + // unknown input → token check fails first → 401 + t.Logf("status = %d (expected 401 since unknown input has no registered token)", w.Code) } } func TestWebSocketEndpoint_NoToken_Returns401(t *testing.T) { - router := newTestRouter(func(_ context.Context, _ domain.SourceType, _ []byte) (string, error) { + router := newTestRouter(func(_ context.Context, _ domain.InputType, _ string, _ []byte) (string, error) { return "id", nil }) @@ -192,7 +192,7 @@ func TestWebSocketEndpoint_NoToken_Returns401(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/sources/beszel/alerts/ws", nil) + req := httptest.NewRequest(http.MethodGet, "/inputs/beszel/messages/ws", nil) if tc.auth != "" { req.Header.Set("Authorization", tc.auth) } @@ -206,7 +206,7 @@ func TestWebSocketEndpoint_NoToken_Returns401(t *testing.T) { } func TestDocs_OpenAPI(t *testing.T) { - router := newTestRouter(func(_ context.Context, _ domain.SourceType, _ []byte) (string, error) { + router := newTestRouter(func(_ context.Context, _ domain.InputType, _ string, _ []byte) (string, error) { return "", nil }) req := httptest.NewRequest(http.MethodGet, "/docs/openapi", nil) @@ -225,7 +225,7 @@ func TestDocs_OpenAPI(t *testing.T) { } func TestDocs_AsyncAPI(t *testing.T) { - router := newTestRouter(func(_ context.Context, _ domain.SourceType, _ []byte) (string, error) { + router := newTestRouter(func(_ context.Context, _ domain.InputType, _ string, _ []byte) (string, error) { return "", nil }) req := httptest.NewRequest(http.MethodGet, "/docs/asyncapi", nil) @@ -244,7 +244,7 @@ func TestDocs_AsyncAPI(t *testing.T) { } func TestDocs_HTML(t *testing.T) { - router := newTestRouter(func(_ context.Context, _ domain.SourceType, _ []byte) (string, error) { + router := newTestRouter(func(_ context.Context, _ domain.InputType, _ string, _ []byte) (string, error) { return "", nil }) req := httptest.NewRequest(http.MethodGet, "/docs", nil) diff --git a/internal/adapter/input/http/input_resolver.go b/internal/adapter/input/http/input_resolver.go new file mode 100644 index 0000000..b63f1c1 --- /dev/null +++ b/internal/adapter/input/http/input_resolver.go @@ -0,0 +1,9 @@ +package http + +import "relaybox/internal/domain" + +// InputResolver URL inputID를 domain.InputType으로 변환하고 토큰을 검증한다. +type InputResolver interface { + Resolve(inputID string) (domain.InputType, error) + ValidateToken(inputID, token string) bool +} diff --git a/internal/adapter/input/http/middleware.go b/internal/adapter/input/http/middleware.go index 59ef51a..dd5f367 100644 --- a/internal/adapter/input/http/middleware.go +++ b/internal/adapter/input/http/middleware.go @@ -6,7 +6,7 @@ import ( "errors" "net/http" - "webhook-relay/internal/domain" + "relaybox/internal/domain" ) type contextKey string @@ -35,9 +35,9 @@ func mapError(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, domain.ErrInvalidToken): writeError(w, r, http.StatusUnauthorized, "Unauthorized", err.Error()) - case errors.Is(err, domain.ErrSourceNotFound): + case errors.Is(err, domain.ErrInputNotFound): writeError(w, r, http.StatusNotFound, "Not Found", err.Error()) - case errors.Is(err, domain.ErrAlertNotFound): + case errors.Is(err, domain.ErrMessageNotFound): writeError(w, r, http.StatusNotFound, "Not Found", err.Error()) case errors.Is(err, domain.ErrInvalidTransition): writeError(w, r, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error()) diff --git a/internal/adapter/input/http/router.go b/internal/adapter/input/http/router.go index 4cbd13e..9411d30 100644 --- a/internal/adapter/input/http/router.go +++ b/internal/adapter/input/http/router.go @@ -5,22 +5,22 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" - "webhook-relay/internal/adapter/input/websocket" - "webhook-relay/internal/apidocs" - "webhook-relay/internal/application/port/input" - "webhook-relay/internal/domain" + "relaybox/internal/adapter/input/websocket" + "relaybox/internal/apidocs" + "relaybox/internal/application/port/input" + "relaybox/internal/domain" ) // API 버전 — X-API-Version 헤더로 반환 const APIVersion = "2026-03-20" // WSHandler is the subset of websocket.Handler used by the router. -// nil is allowed for tests that don't exercise the /alerts/ws path. +// nil is allowed for tests that don't exercise the /messages/ws path. type WSHandler interface { - ServeWS(w http.ResponseWriter, r *http.Request, source domain.SourceType) + ServeWS(w http.ResponseWriter, r *http.Request, inputType domain.InputType) } -func NewRouter(uc input.ReceiveAlertUseCase, resolver SourceResolver, ws WSHandler) *chi.Mux { +func NewRouter(uc input.ReceiveMessageUseCase, resolver InputResolver, ws WSHandler) *chi.Mux { r := chi.NewRouter() r.Use(middleware.Recoverer) r.Use(middleware.RequestID) @@ -33,28 +33,28 @@ func NewRouter(uc input.ReceiveAlertUseCase, resolver SourceResolver, ws WSHandl r.Get("/docs/openapi", apidocs.OpenAPIHandler) r.Get("/docs/asyncapi", apidocs.AsyncAPIHandler) - r.Route("/sources/{sourceId}", func(r chi.Router) { - // 리터럴 /alerts/ws를 와일드카드 /alerts/{alertId}보다 먼저 등록 - r.Get("/alerts/ws", func(w http.ResponseWriter, req *http.Request) { - sourceID := chi.URLParam(req, "sourceId") + r.Route("/inputs/{inputId}", func(r chi.Router) { + // 리터럴 /messages/ws를 와일드카드 /messages/{messageId}보다 먼저 등록 + r.Get("/messages/ws", func(w http.ResponseWriter, req *http.Request) { + inputID := chi.URLParam(req, "inputId") token := tokenFromHeader(req) - if token == "" || !resolver.ValidateToken(sourceID, token) { + if token == "" || !resolver.ValidateToken(inputID, token) { writeError(w, req, http.StatusUnauthorized, "Unauthorized", "invalid or missing token") return } - sourceType, err := resolver.Resolve(sourceID) + inputType, err := resolver.Resolve(inputID) if err != nil { - writeError(w, req, http.StatusUnauthorized, "Unauthorized", "unknown source") + writeError(w, req, http.StatusUnauthorized, "Unauthorized", "unknown input") return } if ws == nil { writeError(w, req, http.StatusNotImplemented, "Not Implemented", "websocket not configured") return } - ws.ServeWS(w, req, sourceType) + ws.ServeWS(w, req, inputType) }) - r.Post("/alerts", h.PostAlert) - r.Get("/alerts/{alertId}", h.Healthz) // placeholder + r.Post("/messages", h.PostMessage) + r.Get("/messages/{messageId}", h.Healthz) // placeholder }) return r diff --git a/internal/adapter/input/http/source_resolver.go b/internal/adapter/input/http/source_resolver.go deleted file mode 100644 index 09d0c39..0000000 --- a/internal/adapter/input/http/source_resolver.go +++ /dev/null @@ -1,9 +0,0 @@ -package http - -import "webhook-relay/internal/domain" - -// SourceResolver URL sourceID를 domain.SourceType으로 변환하고 토큰을 검증한다. -type SourceResolver interface { - Resolve(sourceID string) (domain.SourceType, error) - ValidateToken(sourceID, token string) bool -} diff --git a/internal/adapter/input/parser/form_parser.go b/internal/adapter/input/parser/form_parser.go new file mode 100644 index 0000000..0a32221 --- /dev/null +++ b/internal/adapter/input/parser/form_parser.go @@ -0,0 +1,32 @@ +package parser + +import ( + "fmt" + "net/url" +) + +// FormParser parses URL-encoded form bodies into map[string]any. +type FormParser struct{} + +func NewFormParser() *FormParser { return &FormParser{} } + +func (p *FormParser) Type() string { return "form" } + +func (p *FormParser) Parse(_ string, body []byte) (map[string]any, error) { + if len(body) == 0 { + return nil, fmt.Errorf("form parser: empty body") + } + values, err := url.ParseQuery(string(body)) + if err != nil { + return nil, fmt.Errorf("form parser: %w", err) + } + result := make(map[string]any, len(values)) + for k, v := range values { + if len(v) == 1 { + result[k] = v[0] + } else { + result[k] = v + } + } + return result, nil +} diff --git a/internal/adapter/input/parser/form_parser_test.go b/internal/adapter/input/parser/form_parser_test.go new file mode 100644 index 0000000..d613692 --- /dev/null +++ b/internal/adapter/input/parser/form_parser_test.go @@ -0,0 +1,72 @@ +package parser_test + +import ( + "testing" + + "relaybox/internal/adapter/input/parser" +) + +func TestFormParser_Type(t *testing.T) { + p := parser.NewFormParser() + if got := p.Type(); got != "form" { + t.Errorf("Type() = %q, want %q", got, "form") + } +} + +func TestFormParser_Parse(t *testing.T) { + tests := []struct { + name string + body []byte + wantErr bool + check func(t *testing.T, result map[string]any) + }{ + { + name: "single values", + body: []byte("name=alice&age=30"), + check: func(t *testing.T, result map[string]any) { + if result["name"] != "alice" { + t.Errorf("name = %v, want alice", result["name"]) + } + if result["age"] != "30" { + t.Errorf("age = %v, want 30", result["age"]) + } + }, + }, + { + name: "multi-values", + body: []byte("color=red&color=blue&color=green"), + check: func(t *testing.T, result map[string]any) { + colors, ok := result["color"].([]string) + if !ok { + t.Fatalf("color should be []string, got %T", result["color"]) + } + if len(colors) != 3 { + t.Errorf("len(colors) = %d, want 3", len(colors)) + } + }, + }, + { + name: "empty body", + body: []byte{}, + wantErr: true, + }, + { + name: "nil body", + body: nil, + wantErr: true, + }, + } + + p := parser.NewFormParser() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := p.Parse("application/x-www-form-urlencoded", tt.body) + if (err != nil) != tt.wantErr { + t.Fatalf("Parse() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.check != nil { + tt.check(t, result) + } + }) + } +} diff --git a/internal/adapter/input/parser/json_parser.go b/internal/adapter/input/parser/json_parser.go new file mode 100644 index 0000000..a5a36dc --- /dev/null +++ b/internal/adapter/input/parser/json_parser.go @@ -0,0 +1,24 @@ +package parser + +import ( + "encoding/json" + "fmt" +) + +// JSONParser parses JSON message bodies into map[string]any. +type JSONParser struct{} + +func NewJSONParser() *JSONParser { return &JSONParser{} } + +func (p *JSONParser) Type() string { return "json" } + +func (p *JSONParser) Parse(_ string, body []byte) (map[string]any, error) { + if len(body) == 0 { + return nil, fmt.Errorf("json parser: empty body") + } + var result map[string]any + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("json parser: %w", err) + } + return result, nil +} diff --git a/internal/adapter/input/parser/json_parser_test.go b/internal/adapter/input/parser/json_parser_test.go new file mode 100644 index 0000000..1993eef --- /dev/null +++ b/internal/adapter/input/parser/json_parser_test.go @@ -0,0 +1,72 @@ +package parser_test + +import ( + "testing" + + "relaybox/internal/adapter/input/parser" +) + +func TestJSONParser_Type(t *testing.T) { + p := parser.NewJSONParser() + if got := p.Type(); got != "json" { + t.Errorf("Type() = %q, want %q", got, "json") + } +} + +func TestJSONParser_Parse(t *testing.T) { + tests := []struct { + name string + body []byte + wantErr bool + wantKey string + wantVal any + }{ + { + name: "valid JSON object", + body: []byte(`{"host":"server1","port":8080}`), + wantKey: "host", + wantVal: "server1", + }, + { + name: "nested JSON", + body: []byte(`{"data":{"nested":true}}`), + wantKey: "data", + }, + { + name: "invalid JSON", + body: []byte(`{invalid`), + wantErr: true, + }, + { + name: "empty body", + body: []byte{}, + wantErr: true, + }, + { + name: "nil body", + body: nil, + wantErr: true, + }, + } + + p := parser.NewJSONParser() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := p.Parse("application/json", tt.body) + if (err != nil) != tt.wantErr { + t.Fatalf("Parse() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if tt.wantVal != nil { + if got, ok := result[tt.wantKey]; !ok || got != tt.wantVal { + t.Errorf("result[%q] = %v, want %v", tt.wantKey, got, tt.wantVal) + } + } + if _, ok := result[tt.wantKey]; !ok { + t.Errorf("expected key %q in result", tt.wantKey) + } + }) + } +} diff --git a/internal/adapter/input/parser/logfmt_parser.go b/internal/adapter/input/parser/logfmt_parser.go new file mode 100644 index 0000000..488341a --- /dev/null +++ b/internal/adapter/input/parser/logfmt_parser.go @@ -0,0 +1,34 @@ +package parser + +import ( + "fmt" + + "github.com/kr/logfmt" +) + +// LogfmtParser parses logfmt-encoded bodies into map[string]any. +type LogfmtParser struct{} + +func NewLogfmtParser() *LogfmtParser { return &LogfmtParser{} } + +func (p *LogfmtParser) Type() string { return "logfmt" } + +func (p *LogfmtParser) Parse(_ string, body []byte) (map[string]any, error) { + if len(body) == 0 { + return nil, fmt.Errorf("logfmt parser: empty body") + } + + result := make(map[string]any) + handler := logfmt.HandlerFunc(func(key, val []byte) error { + result[string(key)] = string(val) + return nil + }) + + if err := logfmt.Unmarshal(body, handler); err != nil { + return nil, fmt.Errorf("logfmt parser: %w", err) + } + if len(result) == 0 { + return nil, fmt.Errorf("logfmt parser: no key-value pairs found") + } + return result, nil +} diff --git a/internal/adapter/input/parser/logfmt_parser_test.go b/internal/adapter/input/parser/logfmt_parser_test.go new file mode 100644 index 0000000..b824d6e --- /dev/null +++ b/internal/adapter/input/parser/logfmt_parser_test.go @@ -0,0 +1,71 @@ +package parser_test + +import ( + "testing" + + "relaybox/internal/adapter/input/parser" +) + +func TestLogfmtParser_Type(t *testing.T) { + p := parser.NewLogfmtParser() + if got := p.Type(); got != "logfmt" { + t.Errorf("Type() = %q, want %q", got, "logfmt") + } +} + +func TestLogfmtParser_Parse(t *testing.T) { + tests := []struct { + name string + body []byte + wantErr bool + check func(t *testing.T, result map[string]any) + }{ + { + name: "basic key=value pairs", + body: []byte(`level=info msg=hello ts=12345`), + check: func(t *testing.T, result map[string]any) { + if result["level"] != "info" { + t.Errorf("level = %v, want info", result["level"]) + } + if result["msg"] != "hello" { + t.Errorf("msg = %v, want hello", result["msg"]) + } + }, + }, + { + name: "quoted values", + body: []byte(`key="value with spaces" other=simple`), + check: func(t *testing.T, result map[string]any) { + if result["key"] != "value with spaces" { + t.Errorf("key = %v, want 'value with spaces'", result["key"]) + } + if result["other"] != "simple" { + t.Errorf("other = %v, want simple", result["other"]) + } + }, + }, + { + name: "empty body", + body: []byte{}, + wantErr: true, + }, + { + name: "nil body", + body: nil, + wantErr: true, + }, + } + + p := parser.NewLogfmtParser() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := p.Parse("text/plain", tt.body) + if (err != nil) != tt.wantErr { + t.Fatalf("Parse() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.check != nil { + tt.check(t, result) + } + }) + } +} diff --git a/internal/adapter/input/parser/regex_parser.go b/internal/adapter/input/parser/regex_parser.go new file mode 100644 index 0000000..fe0ccad --- /dev/null +++ b/internal/adapter/input/parser/regex_parser.go @@ -0,0 +1,44 @@ +package parser + +import ( + "fmt" + "regexp" +) + +// RegexParser parses message bodies using a compiled regex with named capture groups. +type RegexParser struct { + pattern *regexp.Regexp +} + +func NewRegexParser(pattern string) (*RegexParser, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("regex parser: compile: %w", err) + } + return &RegexParser{pattern: re}, nil +} + +func (p *RegexParser) Type() string { return "regex" } + +func (p *RegexParser) Parse(_ string, body []byte) (map[string]any, error) { + if len(body) == 0 { + return nil, fmt.Errorf("regex parser: empty body") + } + + match := p.pattern.FindSubmatch(body) + if match == nil { + return nil, fmt.Errorf("regex parser: no match") + } + + result := make(map[string]any) + for i, name := range p.pattern.SubexpNames() { + if i != 0 && name != "" && match[i] != nil { + result[name] = string(match[i]) + } + } + + if len(result) == 0 { + return nil, fmt.Errorf("regex parser: no named capture groups in pattern") + } + return result, nil +} diff --git a/internal/adapter/input/parser/regex_parser_test.go b/internal/adapter/input/parser/regex_parser_test.go new file mode 100644 index 0000000..57940c3 --- /dev/null +++ b/internal/adapter/input/parser/regex_parser_test.go @@ -0,0 +1,82 @@ +package parser_test + +import ( + "testing" + + "relaybox/internal/adapter/input/parser" +) + +func TestRegexParser_Type(t *testing.T) { + p, err := parser.NewRegexParser(`(?P\w+)`) + if err != nil { + t.Fatalf("NewRegexParser error: %v", err) + } + if got := p.Type(); got != "regex" { + t.Errorf("Type() = %q, want %q", got, "regex") + } +} + +func TestRegexParser_InvalidPattern(t *testing.T) { + _, err := parser.NewRegexParser(`[invalid`) + if err == nil { + t.Fatal("expected error for invalid pattern") + } +} + +func TestRegexParser_Parse(t *testing.T) { + tests := []struct { + name string + pattern string + body []byte + wantErr bool + check func(t *testing.T, result map[string]any) + }{ + { + name: "match with named groups", + pattern: `(?P\w+):(?P\d+)`, + body: []byte("server1:8080"), + check: func(t *testing.T, result map[string]any) { + if result["host"] != "server1" { + t.Errorf("host = %v, want server1", result["host"]) + } + if result["port"] != "8080" { + t.Errorf("port = %v, want 8080", result["port"]) + } + }, + }, + { + name: "no match", + pattern: `(?P\d+)`, + body: []byte("no-digits-here"), + wantErr: true, + }, + { + name: "no named groups", + pattern: `(\w+):(\d+)`, + body: []byte("server1:8080"), + wantErr: true, + }, + { + name: "empty body", + pattern: `(?P\w+)`, + body: []byte{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p, err := parser.NewRegexParser(tt.pattern) + if err != nil { + t.Fatalf("NewRegexParser error: %v", err) + } + result, err := p.Parse("text/plain", tt.body) + if (err != nil) != tt.wantErr { + t.Fatalf("Parse() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.check != nil { + tt.check(t, result) + } + }) + } +} diff --git a/internal/adapter/input/parser/registry.go b/internal/adapter/input/parser/registry.go new file mode 100644 index 0000000..0c2c467 --- /dev/null +++ b/internal/adapter/input/parser/registry.go @@ -0,0 +1,37 @@ +package parser + +import ( + "errors" + "fmt" + + "relaybox/internal/application/port/input" +) + +var ErrParserNotFound = errors.New("parser not found") + +// InMemoryParserRegistry holds parsers in memory indexed by type name. +type InMemoryParserRegistry struct { + parsers map[string]input.Parser +} + +func NewInMemoryParserRegistry() *InMemoryParserRegistry { + return &InMemoryParserRegistry{parsers: make(map[string]input.Parser)} +} + +func (r *InMemoryParserRegistry) Register(p input.Parser) { + r.parsers[p.Type()] = p +} + +// RegisterWithKey registers a parser under an explicit key rather than its Type(). +// Useful for parsers that need per-instance keys, such as regex parsers with unique patterns. +func (r *InMemoryParserRegistry) RegisterWithKey(key string, p input.Parser) { + r.parsers[key] = p +} + +func (r *InMemoryParserRegistry) Get(parserType string) (input.Parser, error) { + p, ok := r.parsers[parserType] + if !ok { + return nil, fmt.Errorf("%w: %s", ErrParserNotFound, parserType) + } + return p, nil +} diff --git a/internal/adapter/input/parser/registry_test.go b/internal/adapter/input/parser/registry_test.go new file mode 100644 index 0000000..93699d0 --- /dev/null +++ b/internal/adapter/input/parser/registry_test.go @@ -0,0 +1,47 @@ +package parser_test + +import ( + "errors" + "testing" + + "relaybox/internal/adapter/input/parser" +) + +func TestRegistry_GetExisting(t *testing.T) { + reg := parser.NewInMemoryParserRegistry() + reg.Register(parser.NewJSONParser()) + + p, err := reg.Get("json") + if err != nil { + t.Fatalf("Get() error: %v", err) + } + if p.Type() != "json" { + t.Errorf("Type() = %q, want %q", p.Type(), "json") + } +} + +func TestRegistry_GetMissing(t *testing.T) { + reg := parser.NewInMemoryParserRegistry() + + _, err := reg.Get("nonexistent") + if err == nil { + t.Fatal("expected error for missing parser") + } + if !errors.Is(err, parser.ErrParserNotFound) { + t.Errorf("error = %v, want ErrParserNotFound", err) + } +} + +func TestRegistry_RegisterOverwrite(t *testing.T) { + reg := parser.NewInMemoryParserRegistry() + reg.Register(parser.NewJSONParser()) + reg.Register(parser.NewJSONParser()) // should not panic + + p, err := reg.Get("json") + if err != nil { + t.Fatalf("Get() error: %v", err) + } + if p.Type() != "json" { + t.Errorf("Type() = %q, want %q", p.Type(), "json") + } +} diff --git a/internal/adapter/input/parser/xml_parser.go b/internal/adapter/input/parser/xml_parser.go new file mode 100644 index 0000000..b6a9c79 --- /dev/null +++ b/internal/adapter/input/parser/xml_parser.go @@ -0,0 +1,51 @@ +package parser + +import ( + "bytes" + "encoding/xml" + "fmt" +) + +// XMLParser parses XML bodies into a flat map of element names to text content. +type XMLParser struct{} + +func NewXMLParser() *XMLParser { return &XMLParser{} } + +func (p *XMLParser) Type() string { return "xml" } + +// Parse parses XML body into a flat map of element names to text content. +// Limitations: XML attributes are not captured; for repeated element names, last value wins. +func (p *XMLParser) Parse(_ string, body []byte) (map[string]any, error) { + if len(body) == 0 { + return nil, fmt.Errorf("xml parser: empty body") + } + + decoder := xml.NewDecoder(bytes.NewReader(body)) + result := make(map[string]any) + var currentElement string + + for { + tok, err := decoder.Token() + if err != nil { + break + } + switch t := tok.(type) { + case xml.StartElement: + currentElement = t.Name.Local + case xml.CharData: + if currentElement != "" { + text := string(bytes.TrimSpace([]byte(t))) + if text != "" { + result[currentElement] = text + } + } + case xml.EndElement: + currentElement = "" + } + } + + if len(result) == 0 { + return nil, fmt.Errorf("xml parser: no elements found") + } + return result, nil +} diff --git a/internal/adapter/input/parser/xml_parser_test.go b/internal/adapter/input/parser/xml_parser_test.go new file mode 100644 index 0000000..628dfda --- /dev/null +++ b/internal/adapter/input/parser/xml_parser_test.go @@ -0,0 +1,68 @@ +package parser_test + +import ( + "testing" + + "relaybox/internal/adapter/input/parser" +) + +func TestXMLParser_Type(t *testing.T) { + p := parser.NewXMLParser() + if got := p.Type(); got != "xml" { + t.Errorf("Type() = %q, want %q", got, "xml") + } +} + +func TestXMLParser_Parse(t *testing.T) { + tests := []struct { + name string + body []byte + wantErr bool + check func(t *testing.T, result map[string]any) + }{ + { + name: "simple XML", + body: []byte(`server18080`), + check: func(t *testing.T, result map[string]any) { + if result["host"] != "server1" { + t.Errorf("host = %v, want server1", result["host"]) + } + if result["port"] != "8080" { + t.Errorf("port = %v, want 8080", result["port"]) + } + }, + }, + { + name: "nested elements last value wins", + body: []byte(`test`), + check: func(t *testing.T, result map[string]any) { + if result["name"] != "test" { + t.Errorf("name = %v, want test", result["name"]) + } + }, + }, + { + name: "empty body", + body: []byte{}, + wantErr: true, + }, + { + name: "no elements", + body: []byte(``), + wantErr: true, + }, + } + + p := parser.NewXMLParser() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := p.Parse("application/xml", tt.body) + if (err != nil) != tt.wantErr { + t.Fatalf("Parse() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.check != nil { + tt.check(t, result) + } + }) + } +} diff --git a/internal/adapter/input/tcp/listener.go b/internal/adapter/input/tcp/listener.go new file mode 100644 index 0000000..56f8cd6 --- /dev/null +++ b/internal/adapter/input/tcp/listener.go @@ -0,0 +1,130 @@ +package tcp + +import ( + "bufio" + "context" + "fmt" + "log/slog" + "net" + + "relaybox/internal/application/port/input" + "relaybox/internal/domain" +) + +// Listener accepts TCP connections and delivers delimiter-framed messages +// to the ReceiveMessageUseCase. +type Listener struct { + uc input.ReceiveMessageUseCase + inputType domain.InputType + addr string + delimiter byte + contentType string +} + +// NewListener creates a new TCP listener. +// contentType maps from the parser config (e.g. "application/json" for json parser). +func NewListener( + uc input.ReceiveMessageUseCase, + inputType domain.InputType, + addr string, + delimiter byte, + contentType string, +) *Listener { + return &Listener{ + uc: uc, + inputType: inputType, + addr: addr, + delimiter: delimiter, + contentType: contentType, + } +} + +// Start binds to the configured TCP address and accepts connections. +// Blocks until ctx is cancelled. +func (l *Listener) Start(ctx context.Context) error { + ln, err := net.Listen("tcp", l.addr) + if err != nil { + return fmt.Errorf("tcp listen %s: %w", l.addr, err) + } + return l.StartWithListener(ctx, ln) +} + +// StartWithListener accepts connections on the provided net.Listener. +// This is useful for testing with a pre-bound listener. +// Blocks until ctx is cancelled. +func (l *Listener) StartWithListener(ctx context.Context, ln net.Listener) error { + go func() { + <-ctx.Done() + ln.Close() + }() + + for { + conn, err := ln.Accept() + if err != nil { + select { + case <-ctx.Done(): + return nil // graceful shutdown + default: + slog.Error("tcp accept error", "addr", l.addr, "err", err) + return fmt.Errorf("tcp accept: %w", err) + } + } + go l.handleConn(ctx, conn) + } +} + +const maxMessageBytes = 1 << 20 // 1 MiB + +func (l *Listener) handleConn(ctx context.Context, conn net.Conn) { + connCtx, connCancel := context.WithCancel(ctx) + defer connCancel() // exits the watcher goroutine when connection ends + defer conn.Close() + + go func() { + <-connCtx.Done() + conn.Close() + }() + + scanner := bufio.NewScanner(conn) + scanner.Split(splitFunc(l.delimiter)) + scanner.Buffer(make([]byte, 4096), maxMessageBytes) + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + // Copy the bytes since scanner reuses the buffer + msg := make([]byte, len(line)) + copy(msg, line) + if _, err := l.uc.Receive(ctx, l.inputType, l.contentType, msg); err != nil { + slog.Warn("tcp receive error", "inputType", l.inputType, "err", err) + } + } + if err := scanner.Err(); err != nil { + select { + case <-ctx.Done(): + // expected on shutdown + default: + slog.Warn("tcp scanner error", "inputType", l.inputType, "err", err) + } + } +} + +// splitFunc returns a bufio.SplitFunc that splits on the given delimiter byte. +func splitFunc(delim byte) bufio.SplitFunc { + return func(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + for i, b := range data { + if b == delim { + return i + 1, data[:i], nil + } + } + if atEOF { + return len(data), data, nil + } + return 0, nil, nil + } +} diff --git a/internal/adapter/input/tcp/listener_test.go b/internal/adapter/input/tcp/listener_test.go new file mode 100644 index 0000000..e79aa80 --- /dev/null +++ b/internal/adapter/input/tcp/listener_test.go @@ -0,0 +1,239 @@ +package tcp + +import ( + "context" + "fmt" + "net" + "sync" + "testing" + "time" + + "relaybox/internal/domain" +) + +// mockReceiveUseCase records calls to Receive. +type mockReceiveUseCase struct { + mu sync.Mutex + calls []receiveCall + returnID string + returnErr error +} + +type receiveCall struct { + inputType domain.InputType + contentType string + body []byte +} + +func (m *mockReceiveUseCase) Receive(_ context.Context, inputType domain.InputType, contentType string, body []byte) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + cp := make([]byte, len(body)) + copy(cp, body) + m.calls = append(m.calls, receiveCall{inputType: inputType, contentType: contentType, body: cp}) + return m.returnID, m.returnErr +} + +func (m *mockReceiveUseCase) getCalls() []receiveCall { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]receiveCall, len(m.calls)) + copy(out, m.calls) + return out +} + +func startTestListener(t *testing.T, delimiter byte, contentType string, mock *mockReceiveUseCase) (addr string, cancel context.CancelFunc) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr = ln.Addr().String() + + ctx, cancel := context.WithCancel(context.Background()) + listener := NewListener(mock, domain.InputTypeGeneric, ":0", delimiter, contentType) + + errCh := make(chan error, 1) + go func() { + errCh <- listener.StartWithListener(ctx, ln) + }() + + t.Cleanup(func() { + cancel() + select { + case err := <-errCh: + if err != nil { + t.Errorf("listener returned error: %v", err) + } + case <-time.After(5 * time.Second): + t.Error("listener did not stop in time") + } + }) + + return addr, cancel +} + +func waitForCalls(mock *mockReceiveUseCase, expected int, timeout time.Duration) ([]receiveCall, error) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + calls := mock.getCalls() + if len(calls) >= expected { + return calls, nil + } + time.Sleep(10 * time.Millisecond) + } + calls := mock.getCalls() + return calls, fmt.Errorf("expected %d calls, got %d", expected, len(calls)) +} + +func TestListener_BasicMessageDelivery(t *testing.T) { + mock := &mockReceiveUseCase{returnID: "msg-1"} + addr, _ := startTestListener(t, '\n', "application/json", mock) + + conn, err := net.Dial("tcp", addr) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + _, err = conn.Write([]byte("hello\n")) + if err != nil { + t.Fatalf("write: %v", err) + } + + calls, err := waitForCalls(mock, 1, 2*time.Second) + if err != nil { + t.Fatal(err) + } + if string(calls[0].body) != "hello" { + t.Errorf("body = %q, want %q", calls[0].body, "hello") + } + if calls[0].inputType != domain.InputTypeGeneric { + t.Errorf("inputType = %q, want %q", calls[0].inputType, domain.InputTypeGeneric) + } + if calls[0].contentType != "application/json" { + t.Errorf("contentType = %q, want %q", calls[0].contentType, "application/json") + } +} + +func TestListener_MultipleMessages(t *testing.T) { + mock := &mockReceiveUseCase{returnID: "msg-1"} + addr, _ := startTestListener(t, '\n', "application/json", mock) + + conn, err := net.Dial("tcp", addr) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + _, err = conn.Write([]byte("msg1\nmsg2\nmsg3\n")) + if err != nil { + t.Fatalf("write: %v", err) + } + + calls, err := waitForCalls(mock, 3, 2*time.Second) + if err != nil { + t.Fatal(err) + } + + expected := []string{"msg1", "msg2", "msg3"} + for i, want := range expected { + if string(calls[i].body) != want { + t.Errorf("call[%d] body = %q, want %q", i, calls[i].body, want) + } + } +} + +func TestListener_CustomDelimiter(t *testing.T) { + mock := &mockReceiveUseCase{returnID: "msg-1"} + addr, _ := startTestListener(t, '|', "application/json", mock) + + conn, err := net.Dial("tcp", addr) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + _, err = conn.Write([]byte("msg1|msg2|")) + if err != nil { + t.Fatalf("write: %v", err) + } + + calls, err := waitForCalls(mock, 2, 2*time.Second) + if err != nil { + t.Fatal(err) + } + if string(calls[0].body) != "msg1" { + t.Errorf("call[0] body = %q, want %q", calls[0].body, "msg1") + } + if string(calls[1].body) != "msg2" { + t.Errorf("call[1] body = %q, want %q", calls[1].body, "msg2") + } +} + +func TestListener_GracefulShutdown(t *testing.T) { + mock := &mockReceiveUseCase{returnID: "msg-1"} + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + listener := NewListener(mock, domain.InputTypeGeneric, ":0", '\n', "application/json") + + errCh := make(chan error, 1) + go func() { + errCh <- listener.StartWithListener(ctx, ln) + }() + + // Connect a client to verify listener is running + conn, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("dial: %v", err) + } + conn.Close() + + // Cancel context for graceful shutdown + cancel() + + select { + case err := <-errCh: + if err != nil { + t.Errorf("expected nil error on graceful shutdown, got: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("listener did not stop in time") + } +} + +func TestListener_EmptyMessageSkip(t *testing.T) { + mock := &mockReceiveUseCase{returnID: "msg-1"} + addr, _ := startTestListener(t, '\n', "application/json", mock) + + conn, err := net.Dial("tcp", addr) + if err != nil { + t.Fatalf("dial: %v", err) + } + + // Send empty lines followed by a real message + _, err = conn.Write([]byte("\n\nactual\n")) + if err != nil { + t.Fatalf("write: %v", err) + } + + calls, err := waitForCalls(mock, 1, 2*time.Second) + if err != nil { + t.Fatal(err) + } + if string(calls[0].body) != "actual" { + t.Errorf("body = %q, want %q", calls[0].body, "actual") + } + + // Wait a bit more and verify no extra calls from empty messages + time.Sleep(200 * time.Millisecond) + finalCalls := mock.getCalls() + if len(finalCalls) != 1 { + t.Errorf("expected exactly 1 call, got %d", len(finalCalls)) + } + conn.Close() +} diff --git a/internal/adapter/input/websocket/handler.go b/internal/adapter/input/websocket/handler.go index d1326d7..24c3acc 100644 --- a/internal/adapter/input/websocket/handler.go +++ b/internal/adapter/input/websocket/handler.go @@ -6,16 +6,16 @@ import ( "net/url" "github.com/gorilla/websocket" - "webhook-relay/internal/application/port/input" - "webhook-relay/internal/domain" + "relaybox/internal/application/port/input" + "relaybox/internal/domain" ) type Handler struct { - uc input.ReceiveAlertUseCase + uc input.ReceiveMessageUseCase upgrader websocket.Upgrader } -func NewHandler(uc input.ReceiveAlertUseCase) *Handler { +func NewHandler(uc input.ReceiveMessageUseCase) *Handler { return &Handler{ uc: uc, upgrader: websocket.Upgrader{CheckOrigin: sameHostOrigin}, @@ -36,7 +36,7 @@ func sameHostOrigin(r *http.Request) bool { return u.Host == r.Host } -func (h *Handler) ServeWS(w http.ResponseWriter, r *http.Request, source domain.SourceType) { +func (h *Handler) ServeWS(w http.ResponseWriter, r *http.Request, inputType domain.InputType) { conn, err := h.upgrader.Upgrade(w, r, nil) if err != nil { slog.Warn("ws upgrade failed", "err", err) @@ -48,12 +48,12 @@ func (h *Handler) ServeWS(w http.ResponseWriter, r *http.Request, source domain. _, msg, err := conn.ReadMessage() if err != nil { if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { - slog.Warn("ws read error", "source", source, "err", err) + slog.Warn("ws read error", "input", inputType, "err", err) } return } - if _, err := h.uc.Receive(r.Context(), source, msg); err != nil { - slog.Warn("receive via ws failed", "source", source, "err", err) + if _, err := h.uc.Receive(r.Context(), inputType, "application/json", msg); err != nil { + slog.Warn("receive via ws failed", "input", inputType, "err", err) } } } diff --git a/internal/adapter/input/websocket/handler_test.go b/internal/adapter/input/websocket/handler_test.go index af05969..734e68a 100644 --- a/internal/adapter/input/websocket/handler_test.go +++ b/internal/adapter/input/websocket/handler_test.go @@ -9,13 +9,13 @@ import ( "testing" gws "github.com/gorilla/websocket" - wsadapter "webhook-relay/internal/adapter/input/websocket" - "webhook-relay/internal/domain" + wsadapter "relaybox/internal/adapter/input/websocket" + "relaybox/internal/domain" ) type mockUseCase struct{ count atomic.Int32 } -func (m *mockUseCase) Receive(_ context.Context, _ domain.SourceType, _ []byte) (string, error) { +func (m *mockUseCase) Receive(_ context.Context, _ domain.InputType, _ string, _ []byte) (string, error) { m.count.Add(1) return "test-id", nil } @@ -25,7 +25,7 @@ func TestWebSocketHandler_ReceiveMessage(t *testing.T) { handler := wsadapter.NewHandler(uc) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - handler.ServeWS(w, r, domain.SourceTypeBeszel) + handler.ServeWS(w, r, domain.InputTypeBeszel) })) defer srv.Close() @@ -47,7 +47,7 @@ func TestWebSocketHandler_CrossOriginRejected(t *testing.T) { handler := wsadapter.NewHandler(uc) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - handler.ServeWS(w, r, domain.SourceTypeBeszel) + handler.ServeWS(w, r, domain.InputTypeBeszel) })) defer srv.Close() diff --git a/internal/adapter/output/expression/cel_engine.go b/internal/adapter/output/expression/cel_engine.go new file mode 100644 index 0000000..40f4cdd --- /dev/null +++ b/internal/adapter/output/expression/cel_engine.go @@ -0,0 +1,91 @@ +package expression + +import ( + "fmt" + "sync" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" +) + +// CELEngine implements ExpressionEngine using google/cel-go. +// A single cel.Env is built at construction time with a "data" variable of type map(string, dyn). +// All expressions access input data via the "data" prefix (e.g., data.id, data["key"]). +// This avoids cache collisions caused by different runtime data schemas sharing the same expression key. +type CELEngine struct { + env *cel.Env // built once at construction + cache sync.Map // expression string -> cel.Program +} + +// NewCELEngine creates a new CEL expression engine. +// Returns an error if the CEL environment cannot be created. +func NewCELEngine() (*CELEngine, error) { + env, err := cel.NewEnv( + cel.Variable("data", cel.MapType(cel.StringType, cel.DynType)), + ) + if err != nil { + return nil, fmt.Errorf("create CEL env: %w", err) + } + return &CELEngine{env: env}, nil +} + +func (e *CELEngine) Type() string { return "cel" } + +func (e *CELEngine) Evaluate(expression string, data map[string]any) (any, error) { + val, err := e.eval(expression, data) + if err != nil { + return nil, err + } + return val.Value(), nil +} + +func (e *CELEngine) EvaluateBool(expression string, data map[string]any) (bool, error) { + val, err := e.eval(expression, data) + if err != nil { + return false, err + } + if val.Type() != types.BoolType { + return false, fmt.Errorf("cel: expected bool, got %s", val.Type()) + } + return val.Value().(bool), nil +} + +func (e *CELEngine) EvaluateString(expression string, data map[string]any) (string, error) { + val, err := e.eval(expression, data) + if err != nil { + return "", err + } + if val.Type() != types.StringType { + return "", fmt.Errorf("cel: expected string, got %s", val.Type()) + } + return val.Value().(string), nil +} + +func (e *CELEngine) eval(expression string, data map[string]any) (ref.Val, error) { + prog, err := e.getOrCompile(expression) + if err != nil { + return nil, err + } + out, _, err := prog.Eval(map[string]any{"data": data}) + if err != nil { + return nil, fmt.Errorf("cel eval: %w", err) + } + return out, nil +} + +func (e *CELEngine) getOrCompile(expression string) (cel.Program, error) { + if v, ok := e.cache.Load(expression); ok { + return v.(cel.Program), nil + } + ast, iss := e.env.Compile(expression) + if iss != nil && iss.Err() != nil { + return nil, fmt.Errorf("cel compile %q: %w", expression, iss.Err()) + } + prog, err := e.env.Program(ast) + if err != nil { + return nil, fmt.Errorf("cel program %q: %w", expression, err) + } + actual, _ := e.cache.LoadOrStore(expression, prog) + return actual.(cel.Program), nil +} diff --git a/internal/adapter/output/expression/cel_engine_test.go b/internal/adapter/output/expression/cel_engine_test.go new file mode 100644 index 0000000..aabbcb7 --- /dev/null +++ b/internal/adapter/output/expression/cel_engine_test.go @@ -0,0 +1,148 @@ +package expression_test + +import ( + "testing" + + "relaybox/internal/adapter/output/expression" + "relaybox/internal/application/port/output" +) + +func newCELEngine(t *testing.T) *expression.CELEngine { + t.Helper() + e, err := expression.NewCELEngine() + if err != nil { + t.Fatalf("NewCELEngine() error: %v", err) + } + return e +} + +var _ output.ExpressionEngine = (*expression.CELEngine)(nil) + +func TestCELEngine_Type(t *testing.T) { + e := newCELEngine(t) + if e.Type() != "cel" { + t.Errorf("Type() = %q, want cel", e.Type()) + } +} + +func TestCELEngine_EvaluateString(t *testing.T) { + e := newCELEngine(t) + data := map[string]any{"name": "world"} + got, err := e.EvaluateString(`"hello " + data.name`, data) + if err != nil { + t.Fatalf("EvaluateString error: %v", err) + } + if got != "hello world" { + t.Errorf("got %q, want %q", got, "hello world") + } +} + +func TestCELEngine_EvaluateBool(t *testing.T) { + e := newCELEngine(t) + data := map[string]any{"status": "CRITICAL"} + + got, err := e.EvaluateBool(`data.status == "CRITICAL"`, data) + if err != nil { + t.Fatalf("EvaluateBool error: %v", err) + } + if !got { + t.Error("expected true") + } + + got, err = e.EvaluateBool(`data.status == "OK"`, data) + if err != nil { + t.Fatalf("EvaluateBool error: %v", err) + } + if got { + t.Error("expected false") + } +} + +func TestCELEngine_EvaluateNumeric(t *testing.T) { + e := newCELEngine(t) + data := map[string]any{"a": int64(10), "b": int64(20)} + got, err := e.Evaluate(`data.a + data.b`, data) + if err != nil { + t.Fatalf("Evaluate error: %v", err) + } + if got != int64(30) { + t.Errorf("got %v (%T), want 30", got, got) + } +} + +func TestCELEngine_CacheHit(t *testing.T) { + e := newCELEngine(t) + data := map[string]any{"x": "a"} + // First call compiles; second should use cache + for i := range 3 { + got, err := e.EvaluateString(`data.x`, data) + if err != nil { + t.Fatalf("iteration %d: %v", i, err) + } + if got != "a" { + t.Errorf("iteration %d: got %q", i, got) + } + } +} + +func TestCELEngine_ErrorInvalidExpr(t *testing.T) { + e := newCELEngine(t) + _, err := e.Evaluate(`!!!`, map[string]any{"x": "a"}) + if err == nil { + t.Error("expected error for invalid expression") + } +} + +func TestCELEngine_EvaluateBool_TypeMismatch(t *testing.T) { + e := newCELEngine(t) + _, err := e.EvaluateBool(`"hello"`, map[string]any{}) + if err == nil { + t.Error("expected error when result is not bool") + } +} + +func TestCELEngine_EvaluateString_TypeMismatch(t *testing.T) { + e := newCELEngine(t) + _, err := e.EvaluateString(`true`, map[string]any{}) + if err == nil { + t.Error("expected error when result is not string") + } +} + +func TestCELEngine_MapData(t *testing.T) { + e := newCELEngine(t) + data := map[string]any{ + "payload": `{"host":"server1"}`, + "input": "BESZEL", + } + got, err := e.EvaluateString(`data.input + ": " + data.payload`, data) + if err != nil { + t.Fatalf("error: %v", err) + } + want := `BESZEL: {"host":"server1"}` + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestCELEngine_DifferentSchemas_SameExpression(t *testing.T) { + e := newCELEngine(t) + // First call with one schema + data1 := map[string]any{"name": "alice"} + got1, err := e.EvaluateString(`data.name`, data1) + if err != nil { + t.Fatalf("first eval error: %v", err) + } + if got1 != "alice" { + t.Errorf("got %q, want alice", got1) + } + // Second call with a different schema but same expression - should work + data2 := map[string]any{"name": "bob", "extra": 42} + got2, err := e.EvaluateString(`data.name`, data2) + if err != nil { + t.Fatalf("second eval error: %v", err) + } + if got2 != "bob" { + t.Errorf("got %q, want bob", got2) + } +} diff --git a/internal/adapter/output/expression/expr_engine.go b/internal/adapter/output/expression/expr_engine.go new file mode 100644 index 0000000..6d0baca --- /dev/null +++ b/internal/adapter/output/expression/expr_engine.go @@ -0,0 +1,70 @@ +package expression + +import ( + "fmt" + "sync" + + "github.com/expr-lang/expr" + "github.com/expr-lang/expr/vm" +) + +// ExprEngine implements ExpressionEngine using expr-lang/expr. +// All expressions access input data via the "data" prefix (e.g., data.id, data["key"]). +type ExprEngine struct { + cache sync.Map // expression string -> *vm.Program +} + +// NewExprEngine creates a new Expr expression engine. +func NewExprEngine() *ExprEngine { + return &ExprEngine{} +} + +func (e *ExprEngine) Type() string { return "expr" } + +func (e *ExprEngine) Evaluate(expression string, data map[string]any) (any, error) { + prg, err := e.getOrCompile(expression) + if err != nil { + return nil, err + } + out, err := expr.Run(prg, map[string]any{"data": data}) + if err != nil { + return nil, fmt.Errorf("expr eval: %w", err) + } + return out, nil +} + +func (e *ExprEngine) EvaluateBool(expression string, data map[string]any) (bool, error) { + val, err := e.Evaluate(expression, data) + if err != nil { + return false, err + } + b, ok := val.(bool) + if !ok { + return false, fmt.Errorf("expr: expected bool, got %T", val) + } + return b, nil +} + +func (e *ExprEngine) EvaluateString(expression string, data map[string]any) (string, error) { + val, err := e.Evaluate(expression, data) + if err != nil { + return "", err + } + s, ok := val.(string) + if !ok { + return "", fmt.Errorf("expr: expected string, got %T", val) + } + return s, nil +} + +func (e *ExprEngine) getOrCompile(expression string) (*vm.Program, error) { + if v, ok := e.cache.Load(expression); ok { + return v.(*vm.Program), nil + } + prg, err := expr.Compile(expression, expr.AllowUndefinedVariables()) + if err != nil { + return nil, fmt.Errorf("expr compile: %w", err) + } + actual, _ := e.cache.LoadOrStore(expression, prg) + return actual.(*vm.Program), nil +} diff --git a/internal/adapter/output/expression/expr_engine_test.go b/internal/adapter/output/expression/expr_engine_test.go new file mode 100644 index 0000000..a7c5a4d --- /dev/null +++ b/internal/adapter/output/expression/expr_engine_test.go @@ -0,0 +1,116 @@ +package expression_test + +import ( + "testing" + + "relaybox/internal/adapter/output/expression" + "relaybox/internal/application/port/output" +) + +var _ output.ExpressionEngine = (*expression.ExprEngine)(nil) + +func TestExprEngine_Type(t *testing.T) { + e := expression.NewExprEngine() + if e.Type() != "expr" { + t.Errorf("Type() = %q, want expr", e.Type()) + } +} + +func TestExprEngine_EvaluateString(t *testing.T) { + e := expression.NewExprEngine() + data := map[string]any{"name": "world"} + got, err := e.EvaluateString(`"hello " + data.name`, data) + if err != nil { + t.Fatalf("EvaluateString error: %v", err) + } + if got != "hello world" { + t.Errorf("got %q, want %q", got, "hello world") + } +} + +func TestExprEngine_EvaluateBool(t *testing.T) { + e := expression.NewExprEngine() + data := map[string]any{"status": "CRITICAL"} + + got, err := e.EvaluateBool(`data.status == "CRITICAL"`, data) + if err != nil { + t.Fatalf("EvaluateBool error: %v", err) + } + if !got { + t.Error("expected true") + } + + got, err = e.EvaluateBool(`data.status == "OK"`, data) + if err != nil { + t.Fatalf("EvaluateBool error: %v", err) + } + if got { + t.Error("expected false") + } +} + +func TestExprEngine_EvaluateNumeric(t *testing.T) { + e := expression.NewExprEngine() + data := map[string]any{"a": 10, "b": 20} + got, err := e.Evaluate(`data.a + data.b`, data) + if err != nil { + t.Fatalf("Evaluate error: %v", err) + } + if got != 30 { + t.Errorf("got %v, want 30", got) + } +} + +func TestExprEngine_CacheHit(t *testing.T) { + e := expression.NewExprEngine() + data := map[string]any{"x": "a"} + for i := range 3 { + got, err := e.EvaluateString(`data.x`, data) + if err != nil { + t.Fatalf("iteration %d: %v", i, err) + } + if got != "a" { + t.Errorf("iteration %d: got %q", i, got) + } + } +} + +func TestExprEngine_ErrorInvalidExpr(t *testing.T) { + e := expression.NewExprEngine() + _, err := e.Evaluate(`!!!`, map[string]any{"x": "a"}) + if err == nil { + t.Error("expected error for invalid expression") + } +} + +func TestExprEngine_EvaluateBool_TypeMismatch(t *testing.T) { + e := expression.NewExprEngine() + _, err := e.EvaluateBool(`"hello"`, map[string]any{}) + if err == nil { + t.Error("expected error when result is not bool") + } +} + +func TestExprEngine_EvaluateString_TypeMismatch(t *testing.T) { + e := expression.NewExprEngine() + _, err := e.EvaluateString(`true`, map[string]any{}) + if err == nil { + t.Error("expected error when result is not string") + } +} + +func TestExprEngine_MapData(t *testing.T) { + e := expression.NewExprEngine() + data := map[string]any{ + "payload": `{"host":"server1"}`, + "input": "BESZEL", + } + got, err := e.EvaluateString(`data.input + ": " + data.payload`, data) + if err != nil { + t.Fatalf("error: %v", err) + } + want := `BESZEL: {"host":"server1"}` + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} diff --git a/internal/adapter/output/expression/registry.go b/internal/adapter/output/expression/registry.go new file mode 100644 index 0000000..523b5d4 --- /dev/null +++ b/internal/adapter/output/expression/registry.go @@ -0,0 +1,52 @@ +package expression + +import ( + "fmt" + + "relaybox/internal/application/port/output" +) + +// InMemoryExpressionEngineRegistry stores expression engines by type. +type InMemoryExpressionEngineRegistry struct { + engines map[string]output.ExpressionEngine + defEngine output.ExpressionEngine +} + +// NewInMemoryExpressionEngineRegistry creates an empty registry. +func NewInMemoryExpressionEngineRegistry() *InMemoryExpressionEngineRegistry { + return &InMemoryExpressionEngineRegistry{ + engines: make(map[string]output.ExpressionEngine), + } +} + +// Register adds an engine. The first registered engine becomes the default. +func (r *InMemoryExpressionEngineRegistry) Register(engine output.ExpressionEngine) { + r.engines[engine.Type()] = engine + if r.defEngine == nil { + r.defEngine = engine + } +} + +// SetDefault explicitly sets the default engine. +func (r *InMemoryExpressionEngineRegistry) SetDefault(engineType string) error { + e, ok := r.engines[engineType] + if !ok { + return fmt.Errorf("expression engine %q not registered", engineType) + } + r.defEngine = e + return nil +} + +// Get returns the engine with the given type. +func (r *InMemoryExpressionEngineRegistry) Get(engineType string) (output.ExpressionEngine, error) { + e, ok := r.engines[engineType] + if !ok { + return nil, fmt.Errorf("expression engine %q not registered", engineType) + } + return e, nil +} + +// Default returns the default engine. +func (r *InMemoryExpressionEngineRegistry) Default() output.ExpressionEngine { + return r.defEngine +} diff --git a/internal/adapter/output/expression/registry_test.go b/internal/adapter/output/expression/registry_test.go new file mode 100644 index 0000000..3f3881f --- /dev/null +++ b/internal/adapter/output/expression/registry_test.go @@ -0,0 +1,61 @@ +package expression_test + +import ( + "testing" + + "relaybox/internal/adapter/output/expression" + "relaybox/internal/application/port/output" +) + +var _ output.ExpressionEngineRegistry = (*expression.InMemoryExpressionEngineRegistry)(nil) + +func TestRegistry_GetAndDefault(t *testing.T) { + reg := expression.NewInMemoryExpressionEngineRegistry() + celEng := newCELEngine(t) + exprEng := expression.NewExprEngine() + + reg.Register(celEng) + reg.Register(exprEng) + + // Default is first registered + if reg.Default().Type() != "cel" { + t.Errorf("Default() = %q, want cel", reg.Default().Type()) + } + + got, err := reg.Get("expr") + if err != nil { + t.Fatalf("Get(expr) error: %v", err) + } + if got.Type() != "expr" { + t.Errorf("Get(expr).Type() = %q", got.Type()) + } + + _, err = reg.Get("unknown") + if err == nil { + t.Error("expected error for unknown engine") + } +} + +func TestRegistry_SetDefault(t *testing.T) { + reg := expression.NewInMemoryExpressionEngineRegistry() + reg.Register(newCELEngine(t)) + reg.Register(expression.NewExprEngine()) + + if err := reg.SetDefault("expr"); err != nil { + t.Fatalf("SetDefault error: %v", err) + } + if reg.Default().Type() != "expr" { + t.Errorf("Default() = %q, want expr", reg.Default().Type()) + } + + if err := reg.SetDefault("nonexistent"); err == nil { + t.Error("expected error for nonexistent engine") + } +} + +func TestRegistry_EmptyDefault(t *testing.T) { + reg := expression.NewInMemoryExpressionEngineRegistry() + if reg.Default() != nil { + t.Error("Default() on empty registry should be nil") + } +} diff --git a/internal/adapter/output/filequeue/queue.go b/internal/adapter/output/filequeue/queue.go index add3a7f..4b8e955 100644 --- a/internal/adapter/output/filequeue/queue.go +++ b/internal/adapter/output/filequeue/queue.go @@ -11,12 +11,12 @@ import ( "strings" "time" - "webhook-relay/internal/application/port/output" - "webhook-relay/internal/domain" + "relaybox/internal/application/port/output" + "relaybox/internal/domain" ) // 컴파일 타임 인터페이스 검증 -var _ output.AlertQueue = (*Queue)(nil) +var _ output.MessageQueue = (*Queue)(nil) type Queue struct{ dir string } @@ -43,19 +43,19 @@ func recoverOrphans(dir string) { } } -func (q *Queue) Enqueue(_ context.Context, alert domain.Alert) error { - b, err := json.Marshal(alert) +func (q *Queue) Enqueue(_ context.Context, msg domain.Message) error { + b, err := json.Marshal(msg) if err != nil { return fmt.Errorf("marshal: %w", err) } - name := fmt.Sprintf("%d-%s.json", time.Now().UnixNano(), alert.ID) + name := fmt.Sprintf("%d-%s.json", time.Now().UnixNano(), msg.ID) if err := os.WriteFile(filepath.Join(q.dir, name), b, 0644); err != nil { return fmt.Errorf("write: %w", err) } return nil } -func (q *Queue) Dequeue(_ context.Context) (domain.Alert, output.AckFunc, output.NackFunc, error) { +func (q *Queue) Dequeue(_ context.Context) (domain.Message, output.AckFunc, output.NackFunc, error) { entries, _ := os.ReadDir(q.dir) var files []string for _, e := range entries { @@ -65,26 +65,26 @@ func (q *Queue) Dequeue(_ context.Context) (domain.Alert, output.AckFunc, output } sort.Strings(files) if len(files) == 0 { - return domain.Alert{}, nil, nil, fmt.Errorf("queue empty") + return domain.Message{}, nil, nil, fmt.Errorf("queue empty") } src := filepath.Join(q.dir, files[0]) proc := src + ".processing" if err := os.Rename(src, proc); err != nil { - return domain.Alert{}, nil, nil, fmt.Errorf("lock: %w", err) + return domain.Message{}, nil, nil, fmt.Errorf("lock: %w", err) } b, err := os.ReadFile(proc) if err != nil { os.Rename(proc, src) - return domain.Alert{}, nil, nil, fmt.Errorf("read: %w", err) + return domain.Message{}, nil, nil, fmt.Errorf("read: %w", err) } - var alert domain.Alert - if err := json.Unmarshal(b, &alert); err != nil { + var msg domain.Message + if err := json.Unmarshal(b, &msg); err != nil { os.Rename(proc, src) - return domain.Alert{}, nil, nil, fmt.Errorf("unmarshal: %w", err) + return domain.Message{}, nil, nil, fmt.Errorf("unmarshal: %w", err) } ack := output.AckFunc(func() error { return os.Remove(proc) }) nack := output.NackFunc(func() error { return os.Rename(proc, src) }) - return alert, ack, nack, nil + return msg, ack, nack, nil } diff --git a/internal/adapter/output/filequeue/queue_test.go b/internal/adapter/output/filequeue/queue_test.go index d804d29..700b60c 100644 --- a/internal/adapter/output/filequeue/queue_test.go +++ b/internal/adapter/output/filequeue/queue_test.go @@ -6,20 +6,20 @@ import ( "strings" "testing" - "webhook-relay/internal/adapter/output/filequeue" - "webhook-relay/internal/application/port/output" - "webhook-relay/internal/domain" + "relaybox/internal/adapter/output/filequeue" + "relaybox/internal/application/port/output" + "relaybox/internal/domain" ) // 컴파일 타임 인터페이스 검증 -var _ output.AlertQueue = (*filequeue.Queue)(nil) +var _ output.MessageQueue = (*filequeue.Queue)(nil) func TestQueue_EnqueueDequeueAck(t *testing.T) { q, _ := filequeue.New(t.TempDir()) ctx := context.Background() - alert := domain.Alert{ID: "q-001", Source: domain.SourceTypeBeszel, Payload: domain.RawPayload(`{"x":1}`), Status: domain.AlertStatusPending, Version: 1} - if err := q.Enqueue(ctx, alert); err != nil { + msg := domain.Message{ID: "q-001", Input: domain.InputTypeBeszel, Payload: domain.RawPayload(`{"x":1}`), Status: domain.MessageStatusPending, Version: 1} + if err := q.Enqueue(ctx, msg); err != nil { t.Fatalf("Enqueue: %v", err) } @@ -27,8 +27,8 @@ func TestQueue_EnqueueDequeueAck(t *testing.T) { if err != nil { t.Fatalf("Dequeue: %v", err) } - if got.ID != alert.ID { - t.Errorf("ID mismatch: got %q, want %q", got.ID, alert.ID) + if got.ID != msg.ID { + t.Errorf("ID mismatch: got %q, want %q", got.ID, msg.ID) } if err := ack(); err != nil { t.Fatalf("ack: %v", err) @@ -39,10 +39,10 @@ func TestQueue_New_RecoverOrphans(t *testing.T) { dir := t.TempDir() // 크래시를 시뮬레이션: .json.processing 고아 파일을 직접 생성 - alert := domain.Alert{ID: "orphan-001", Source: domain.SourceTypeBeszel, Payload: domain.RawPayload(`{}`), Status: domain.AlertStatusPending, Version: 1} + msg := domain.Message{ID: "orphan-001", Input: domain.InputTypeBeszel, Payload: domain.RawPayload(`{}`), Status: domain.MessageStatusPending, Version: 1} tmp, _ := filequeue.New(dir) ctx := context.Background() - tmp.Enqueue(ctx, alert) + tmp.Enqueue(ctx, msg) // Dequeue로 .processing 상태로 전환 후 ack/nack 없이 종료 (크래시 시뮬) _, _, _, _ = tmp.Dequeue(ctx) @@ -69,8 +69,8 @@ func TestQueue_New_RecoverOrphans(t *testing.T) { if err != nil { t.Fatalf("Dequeue after recovery: %v", err) } - if got.ID != alert.ID { - t.Errorf("ID = %q, want %q", got.ID, alert.ID) + if got.ID != msg.ID { + t.Errorf("ID = %q, want %q", got.ID, msg.ID) } ack() } @@ -79,8 +79,8 @@ func TestQueue_Nack_Requeues(t *testing.T) { q, _ := filequeue.New(t.TempDir()) ctx := context.Background() - alert := domain.Alert{ID: "q-002", Source: domain.SourceTypeDozzle, Payload: domain.RawPayload(`{}`), Status: domain.AlertStatusPending, Version: 1} - q.Enqueue(ctx, alert) + msg := domain.Message{ID: "q-002", Input: domain.InputTypeDozzle, Payload: domain.RawPayload(`{}`), Status: domain.MessageStatusPending, Version: 1} + q.Enqueue(ctx, msg) _, _, nack, _ := q.Dequeue(ctx) if err := nack(); err != nil { @@ -91,8 +91,8 @@ func TestQueue_Nack_Requeues(t *testing.T) { if err != nil { t.Fatalf("re-Dequeue: %v", err) } - if got.ID != alert.ID { - t.Errorf("re-dequeue ID: got %q, want %q", got.ID, alert.ID) + if got.ID != msg.ID { + t.Errorf("re-dequeue ID: got %q, want %q", got.ID, msg.ID) } ack() } diff --git a/internal/adapter/output/sqlite/db/models.go b/internal/adapter/output/sqlite/db/models.go index 85ba005..96559ca 100644 --- a/internal/adapter/output/sqlite/db/models.go +++ b/internal/adapter/output/sqlite/db/models.go @@ -9,10 +9,10 @@ import ( "time" ) -type Alert struct { +type Message struct { ID string `json:"id"` Version int64 `json:"version"` - Source string `json:"source"` + Input string `json:"input"` Payload []byte `json:"payload"` CreatedAt time.Time `json:"created_at"` Status string `json:"status"` diff --git a/internal/adapter/output/sqlite/db/query.sql.go b/internal/adapter/output/sqlite/db/query.sql.go index 57acfea..78aa60b 100644 --- a/internal/adapter/output/sqlite/db/query.sql.go +++ b/internal/adapter/output/sqlite/db/query.sql.go @@ -11,18 +11,18 @@ import ( "time" ) -const getAlertByID = `-- name: GetAlertByID :one -SELECT id, version, source, payload, created_at, status, retry_count, last_attempt_at -FROM alerts WHERE id=? +const getMessageByID = `-- name: GetMessageByID :one +SELECT id, version, input, payload, created_at, status, retry_count, last_attempt_at +FROM messages WHERE id=? ` -func (q *Queries) GetAlertByID(ctx context.Context, id string) (Alert, error) { - row := q.db.QueryRowContext(ctx, getAlertByID, id) - var i Alert +func (q *Queries) GetMessageByID(ctx context.Context, id string) (Message, error) { + row := q.db.QueryRowContext(ctx, getMessageByID, id) + var i Message err := row.Scan( &i.ID, &i.Version, - &i.Source, + &i.Input, &i.Payload, &i.CreatedAt, &i.Status, @@ -32,26 +32,26 @@ func (q *Queries) GetAlertByID(ctx context.Context, id string) (Alert, error) { return i, err } -const insertAlert = `-- name: InsertAlert :exec -INSERT INTO alerts (id, version, source, payload, created_at, status, retry_count) +const insertMessage = `-- name: InsertMessage :exec +INSERT INTO messages (id, version, input, payload, created_at, status, retry_count) VALUES (?, ?, ?, ?, ?, ?, ?) ` -type InsertAlertParams struct { +type InsertMessageParams struct { ID string `json:"id"` Version int64 `json:"version"` - Source string `json:"source"` + Input string `json:"input"` Payload []byte `json:"payload"` CreatedAt time.Time `json:"created_at"` Status string `json:"status"` RetryCount int64 `json:"retry_count"` } -func (q *Queries) InsertAlert(ctx context.Context, arg InsertAlertParams) error { - _, err := q.db.ExecContext(ctx, insertAlert, +func (q *Queries) InsertMessage(ctx context.Context, arg InsertMessageParams) error { + _, err := q.db.ExecContext(ctx, insertMessage, arg.ID, arg.Version, - arg.Source, + arg.Input, arg.Payload, arg.CreatedAt, arg.Status, @@ -60,30 +60,30 @@ func (q *Queries) InsertAlert(ctx context.Context, arg InsertAlertParams) error return err } -const listAlertsBySource = `-- name: ListAlertsBySource :many -SELECT id, version, source, payload, created_at, status, retry_count, last_attempt_at -FROM alerts WHERE source=? ORDER BY created_at DESC LIMIT ? OFFSET ? +const listMessagesByInput = `-- name: ListMessagesByInput :many +SELECT id, version, input, payload, created_at, status, retry_count, last_attempt_at +FROM messages WHERE input=? ORDER BY created_at DESC LIMIT ? OFFSET ? ` -type ListAlertsBySourceParams struct { - Source string `json:"source"` +type ListMessagesByInputParams struct { + Input string `json:"input"` Limit int64 `json:"limit"` Offset int64 `json:"offset"` } -func (q *Queries) ListAlertsBySource(ctx context.Context, arg ListAlertsBySourceParams) ([]Alert, error) { - rows, err := q.db.QueryContext(ctx, listAlertsBySource, arg.Source, arg.Limit, arg.Offset) +func (q *Queries) ListMessagesByInput(ctx context.Context, arg ListMessagesByInputParams) ([]Message, error) { + rows, err := q.db.QueryContext(ctx, listMessagesByInput, arg.Input, arg.Limit, arg.Offset) if err != nil { return nil, err } defer rows.Close() - var items []Alert + var items []Message for rows.Next() { - var i Alert + var i Message if err := rows.Scan( &i.ID, &i.Version, - &i.Source, + &i.Input, &i.Payload, &i.CreatedAt, &i.Status, @@ -104,7 +104,7 @@ func (q *Queries) ListAlertsBySource(ctx context.Context, arg ListAlertsBySource } const updateDeliveryState = `-- name: UpdateDeliveryState :exec -UPDATE alerts SET status=?, retry_count=?, last_attempt_at=? WHERE id=? +UPDATE messages SET status=?, retry_count=?, last_attempt_at=? WHERE id=? ` type UpdateDeliveryStateParams struct { diff --git a/internal/adapter/output/sqlite/query.sql b/internal/adapter/output/sqlite/query.sql index afd1489..d68e15b 100644 --- a/internal/adapter/output/sqlite/query.sql +++ b/internal/adapter/output/sqlite/query.sql @@ -1,14 +1,14 @@ --- name: InsertAlert :exec -INSERT INTO alerts (id, version, source, payload, created_at, status, retry_count) +-- name: InsertMessage :exec +INSERT INTO messages (id, version, input, payload, created_at, status, retry_count) VALUES (?, ?, ?, ?, ?, ?, ?); -- name: UpdateDeliveryState :exec -UPDATE alerts SET status=?, retry_count=?, last_attempt_at=? WHERE id=?; +UPDATE messages SET status=?, retry_count=?, last_attempt_at=? WHERE id=?; --- name: GetAlertByID :one -SELECT id, version, source, payload, created_at, status, retry_count, last_attempt_at -FROM alerts WHERE id=?; +-- name: GetMessageByID :one +SELECT id, version, input, payload, created_at, status, retry_count, last_attempt_at +FROM messages WHERE id=?; --- name: ListAlertsBySource :many -SELECT id, version, source, payload, created_at, status, retry_count, last_attempt_at -FROM alerts WHERE source=? ORDER BY created_at DESC LIMIT ? OFFSET ?; +-- name: ListMessagesByInput :many +SELECT id, version, input, payload, created_at, status, retry_count, last_attempt_at +FROM messages WHERE input=? ORDER BY created_at DESC LIMIT ? OFFSET ?; diff --git a/internal/adapter/output/sqlite/repository.go b/internal/adapter/output/sqlite/repository.go index cbf9bdf..d797d9d 100644 --- a/internal/adapter/output/sqlite/repository.go +++ b/internal/adapter/output/sqlite/repository.go @@ -7,13 +7,13 @@ import ( "time" _ "github.com/mattn/go-sqlite3" - "webhook-relay/internal/adapter/output/sqlite/db" - "webhook-relay/internal/domain" + "relaybox/internal/adapter/output/sqlite/db" + "relaybox/internal/domain" ) // 컴파일 타임 인터페이스 검증 var _ interface { - Save(context.Context, domain.Alert) error + Save(context.Context, domain.Message) error } = (*Repository)(nil) type Repository struct { @@ -34,23 +34,23 @@ func New(dsn string) (*Repository, error) { func (r *Repository) Close() error { return r.sqlDB.Close() } -func (r *Repository) Save(ctx context.Context, a domain.Alert) error { - err := r.queries.InsertAlert(ctx, db.InsertAlertParams{ - ID: a.ID, - Version: int64(a.Version), - Source: string(a.Source), - Payload: []byte(a.Payload), - CreatedAt: a.CreatedAt.UTC(), - Status: string(a.Status), - RetryCount: int64(a.RetryCount), +func (r *Repository) Save(ctx context.Context, m domain.Message) error { + err := r.queries.InsertMessage(ctx, db.InsertMessageParams{ + ID: m.ID, + Version: int64(m.Version), + Input: string(m.Input), + Payload: []byte(m.Payload), + CreatedAt: m.CreatedAt.UTC(), + Status: string(m.Status), + RetryCount: int64(m.RetryCount), }) if err != nil { - return fmt.Errorf("save alert: %w", err) + return fmt.Errorf("save message: %w", err) } return nil } -func (r *Repository) UpdateDeliveryState(ctx context.Context, id string, status domain.AlertStatus, retryCount int, lastAttemptAt time.Time) error { +func (r *Repository) UpdateDeliveryState(ctx context.Context, id string, status domain.MessageStatus, retryCount int, lastAttemptAt time.Time) error { t := lastAttemptAt.UTC() err := r.queries.UpdateDeliveryState(ctx, db.UpdateDeliveryStateParams{ Status: string(status), @@ -64,50 +64,50 @@ func (r *Repository) UpdateDeliveryState(ctx context.Context, id string, status return nil } -func (r *Repository) FindByID(ctx context.Context, id string) (domain.Alert, error) { - row, err := r.queries.GetAlertByID(ctx, id) +func (r *Repository) FindByID(ctx context.Context, id string) (domain.Message, error) { + row, err := r.queries.GetMessageByID(ctx, id) if err != nil { - return domain.Alert{}, fmt.Errorf("find alert %q: %w", id, err) + return domain.Message{}, fmt.Errorf("find message %q: %w", id, err) } - return toAlert(row), nil + return toMessage(row), nil } -func (r *Repository) FindBySource(ctx context.Context, sourceID string, limit, offset int) ([]domain.Alert, error) { - rows, err := r.queries.ListAlertsBySource(ctx, db.ListAlertsBySourceParams{ - Source: sourceID, Limit: int64(limit), Offset: int64(offset), +func (r *Repository) FindByInput(ctx context.Context, inputID string, limit, offset int) ([]domain.Message, error) { + rows, err := r.queries.ListMessagesByInput(ctx, db.ListMessagesByInputParams{ + Input: inputID, Limit: int64(limit), Offset: int64(offset), }) if err != nil { - return nil, fmt.Errorf("list alerts: %w", err) + return nil, fmt.Errorf("list messages: %w", err) } - alerts := make([]domain.Alert, 0, len(rows)) + messages := make([]domain.Message, 0, len(rows)) for _, row := range rows { - alerts = append(alerts, toAlert(row)) + messages = append(messages, toMessage(row)) } - return alerts, nil + return messages, nil } -func toAlert(row db.Alert) domain.Alert { - a := domain.Alert{ +func toMessage(row db.Message) domain.Message { + m := domain.Message{ ID: row.ID, Version: int(row.Version), - Source: domain.SourceType(row.Source), + Input: domain.InputType(row.Input), Payload: domain.RawPayload(row.Payload), CreatedAt: row.CreatedAt, - Status: domain.AlertStatus(row.Status), + Status: domain.MessageStatus(row.Status), RetryCount: int(row.RetryCount), } if row.LastAttemptAt.Valid { t := row.LastAttemptAt.Time - a.LastAttemptAt = &t + m.LastAttemptAt = &t } - return a + return m } const schemaSQL = ` -CREATE TABLE IF NOT EXISTS alerts ( +CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 1, - source TEXT NOT NULL, + input TEXT NOT NULL, payload BLOB NOT NULL, created_at DATETIME NOT NULL, status TEXT NOT NULL DEFAULT 'PENDING', diff --git a/internal/adapter/output/sqlite/repository_test.go b/internal/adapter/output/sqlite/repository_test.go index d2faf90..78354df 100644 --- a/internal/adapter/output/sqlite/repository_test.go +++ b/internal/adapter/output/sqlite/repository_test.go @@ -5,14 +5,14 @@ import ( "testing" "time" - sqliteadapter "webhook-relay/internal/adapter/output/sqlite" - "webhook-relay/internal/domain" + sqliteadapter "relaybox/internal/adapter/output/sqlite" + "relaybox/internal/domain" ) // 컴파일 타임 인터페이스 검증 var _ interface { - Save(context.Context, domain.Alert) error - FindByID(context.Context, string) (domain.Alert, error) + Save(context.Context, domain.Message) error + FindByID(context.Context, string) (domain.Message, error) } = (*sqliteadapter.Repository)(nil) func newTestRepo(t *testing.T) *sqliteadapter.Repository { @@ -29,20 +29,20 @@ func TestRepository_SaveAndFindByID(t *testing.T) { repo := newTestRepo(t) ctx := context.Background() - alert := domain.Alert{ - ID: "test-001", Version: 1, Source: domain.SourceTypeBeszel, + msg := domain.Message{ + ID: "test-001", Version: 1, Input: domain.InputTypeBeszel, Payload: domain.RawPayload(`{"host":"srv1"}`), CreatedAt: time.Now().UTC().Truncate(time.Second), - Status: domain.AlertStatusPending, + Status: domain.MessageStatusPending, } - if err := repo.Save(ctx, alert); err != nil { + if err := repo.Save(ctx, msg); err != nil { t.Fatalf("Save() error: %v", err) } - got, err := repo.FindByID(ctx, alert.ID) + got, err := repo.FindByID(ctx, msg.ID) if err != nil { t.Fatalf("FindByID() error: %v", err) } - if got.ID != alert.ID || string(got.Payload) != string(alert.Payload) { + if got.ID != msg.ID || string(got.Payload) != string(msg.Payload) { t.Errorf("mismatch: got %+v", got) } } @@ -50,30 +50,30 @@ func TestRepository_SaveAndFindByID(t *testing.T) { func TestRepository_UpdateDeliveryState(t *testing.T) { repo := newTestRepo(t) ctx := context.Background() - alert := domain.Alert{ID: "test-002", Version: 1, Source: domain.SourceTypeDozzle, Payload: domain.RawPayload(`{}`), Status: domain.AlertStatusPending} - repo.Save(ctx, alert) + msg := domain.Message{ID: "test-002", Version: 1, Input: domain.InputTypeDozzle, Payload: domain.RawPayload(`{}`), Status: domain.MessageStatusPending} + repo.Save(ctx, msg) now := time.Now().UTC() - if err := repo.UpdateDeliveryState(ctx, alert.ID, domain.AlertStatusDelivered, 1, now); err != nil { + if err := repo.UpdateDeliveryState(ctx, msg.ID, domain.MessageStatusDelivered, 1, now); err != nil { t.Fatalf("UpdateDeliveryState() error: %v", err) } - got, _ := repo.FindByID(ctx, alert.ID) - if got.Status != domain.AlertStatusDelivered || got.RetryCount != 1 { + got, _ := repo.FindByID(ctx, msg.ID) + if got.Status != domain.MessageStatusDelivered || got.RetryCount != 1 { t.Errorf("unexpected state: %+v", got) } } -func TestRepository_FindBySource(t *testing.T) { +func TestRepository_FindByInput(t *testing.T) { repo := newTestRepo(t) ctx := context.Background() for _, id := range []string{"a1", "a2", "a3"} { - repo.Save(ctx, domain.Alert{ID: id, Source: domain.SourceTypeBeszel, Payload: domain.RawPayload(`{}`), Status: domain.AlertStatusPending, Version: 1}) + repo.Save(ctx, domain.Message{ID: id, Input: domain.InputTypeBeszel, Payload: domain.RawPayload(`{}`), Status: domain.MessageStatusPending, Version: 1}) } - alerts, err := repo.FindBySource(ctx, string(domain.SourceTypeBeszel), 10, 0) + messages, err := repo.FindByInput(ctx, string(domain.InputTypeBeszel), 10, 0) if err != nil { - t.Fatalf("FindBySource() error: %v", err) + t.Fatalf("FindByInput() error: %v", err) } - if len(alerts) != 3 { - t.Errorf("got %d, want 3", len(alerts)) + if len(messages) != 3 { + t.Errorf("got %d, want 3", len(messages)) } } diff --git a/internal/adapter/output/sqlite/schema.sql b/internal/adapter/output/sqlite/schema.sql index b32d4a9..8dd742e 100644 --- a/internal/adapter/output/sqlite/schema.sql +++ b/internal/adapter/output/sqlite/schema.sql @@ -1,7 +1,7 @@ -CREATE TABLE IF NOT EXISTS alerts ( +CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 1, - source TEXT NOT NULL, + input TEXT NOT NULL, payload BLOB NOT NULL, created_at DATETIME NOT NULL, status TEXT NOT NULL DEFAULT 'PENDING', diff --git a/internal/adapter/output/webhook/registry.go b/internal/adapter/output/webhook/registry.go index 8c8e820..d84fd25 100644 --- a/internal/adapter/output/webhook/registry.go +++ b/internal/adapter/output/webhook/registry.go @@ -3,22 +3,22 @@ package webhook import ( "fmt" - "webhook-relay/internal/application/port/output" - "webhook-relay/internal/domain" + "relaybox/internal/application/port/output" + "relaybox/internal/domain" ) type Registry struct { - senders map[domain.ChannelType]output.AlertSender + senders map[domain.OutputType]output.OutputSender } -func NewRegistry(senders map[domain.ChannelType]output.AlertSender) *Registry { +func NewRegistry(senders map[domain.OutputType]output.OutputSender) *Registry { return &Registry{senders: senders} } -func (r *Registry) Get(t domain.ChannelType) (output.AlertSender, error) { +func (r *Registry) Get(t domain.OutputType) (output.OutputSender, error) { s, ok := r.senders[t] if !ok { - return nil, fmt.Errorf("get sender %q: %w", t, domain.ErrSenderNotFound) + return nil, fmt.Errorf("get sender %q: %w", t, domain.ErrOutputSenderNotFound) } return s, nil } diff --git a/internal/adapter/output/webhook/sender.go b/internal/adapter/output/webhook/sender.go index ccad83d..22331dd 100644 --- a/internal/adapter/output/webhook/sender.go +++ b/internal/adapter/output/webhook/sender.go @@ -8,7 +8,7 @@ import ( "net/http" "time" - "webhook-relay/internal/domain" + "relaybox/internal/domain" ) const defaultTimeoutSec = 10 @@ -17,26 +17,22 @@ type Sender struct{} func NewSender() *Sender { return &Sender{} } -func (s *Sender) Send(ctx context.Context, ch domain.Channel, alert domain.Alert) error { - body, err := domain.RenderTemplate(ch.Template, alert) - if err != nil { - return fmt.Errorf("render: %w", err) - } - timeoutSec := ch.TimeoutSec +func (s *Sender) Send(ctx context.Context, out domain.Output, payload []byte) error { + timeoutSec := out.TimeoutSec if timeoutSec <= 0 { timeoutSec = defaultTimeoutSec } client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second} - if ch.SkipTLSVerify { + if out.SkipTLSVerify { client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, ch.URL, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, out.URL, bytes.NewReader(payload)) if err != nil { return fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") - if ch.Secret != "" { - req.Header.Set("Authorization", "Bearer "+ch.Secret) + if out.Secret != "" { + req.Header.Set("Authorization", "Bearer "+out.Secret) } resp, err := client.Do(req) if err != nil { diff --git a/internal/adapter/output/webhook/sender_test.go b/internal/adapter/output/webhook/sender_test.go index a8af6c2..7b12ad3 100644 --- a/internal/adapter/output/webhook/sender_test.go +++ b/internal/adapter/output/webhook/sender_test.go @@ -8,17 +8,16 @@ import ( "testing" "time" - "webhook-relay/internal/adapter/output/webhook" - "webhook-relay/internal/application/port/output" - "webhook-relay/internal/domain" + "relaybox/internal/adapter/output/webhook" + "relaybox/internal/application/port/output" + "relaybox/internal/domain" ) -// 컴파일 타임 인터페이스 검증 -var _ output.AlertSender = (*webhook.Sender)(nil) -var _ output.SenderRegistry = (*webhook.Registry)(nil) +// compile-time interface check +var _ output.OutputSender = (*webhook.Sender)(nil) +var _ output.OutputRegistry = (*webhook.Registry)(nil) func TestSender_Timeout(t *testing.T) { - // 응답이 매우 늦은 서버 — 클라이언트 타임아웃이 없으면 무한 대기 quit := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { select { @@ -33,11 +32,10 @@ func TestSender_Timeout(t *testing.T) { }) sender := webhook.NewSender() - channel := domain.Channel{URL: srv.URL, Template: `{}`, TimeoutSec: 1} - alert := domain.Alert{ID: "t1", Source: domain.SourceTypeBeszel, Payload: domain.RawPayload(`{}`), Version: 1} + out := domain.Output{URL: srv.URL, TimeoutSec: 1} start := time.Now() - err := sender.Send(context.Background(), channel, alert) + err := sender.Send(context.Background(), out, []byte(`{}`)) if err == nil { t.Fatal("expected timeout error") } @@ -55,10 +53,10 @@ func TestSender_Send(t *testing.T) { defer srv.Close() sender := webhook.NewSender() - channel := domain.Channel{URL: srv.URL, Template: `{"text":"{{ .Source }}"}`} - alert := domain.Alert{ID: "a1", Source: domain.SourceTypeBeszel, Payload: domain.RawPayload(`{}`), Version: 1} + out := domain.Output{URL: srv.URL} + payload := []byte(`{"text":"BESZEL"}`) - if err := sender.Send(context.Background(), channel, alert); err != nil { + if err := sender.Send(context.Background(), out, payload); err != nil { t.Fatalf("Send() error: %v", err) } if string(received) != `{"text":"BESZEL"}` { @@ -67,14 +65,14 @@ func TestSender_Send(t *testing.T) { } func TestRegistry_Get(t *testing.T) { - reg := webhook.NewRegistry(map[domain.ChannelType]output.AlertSender{ - domain.ChannelTypeWebhook: webhook.NewSender(), + reg := webhook.NewRegistry(map[domain.OutputType]output.OutputSender{ + domain.OutputTypeWebhook: webhook.NewSender(), }) - got, err := reg.Get(domain.ChannelTypeWebhook) + got, err := reg.Get(domain.OutputTypeWebhook) if err != nil || got == nil { t.Errorf("Get(WEBHOOK): err=%v", err) } - _, err = reg.Get(domain.ChannelTypeSlack) + _, err = reg.Get(domain.OutputTypeSlack) if err == nil { t.Error("expected error for unregistered type") } diff --git a/internal/apidocs/asyncapi.yaml b/internal/apidocs/asyncapi.yaml index d6e1e6f..98d3adb 100644 --- a/internal/apidocs/asyncapi.yaml +++ b/internal/apidocs/asyncapi.yaml @@ -1,12 +1,12 @@ asyncapi: "3.0.0" info: - title: webhook-relay WebSocket API + title: relaybox WebSocket API version: "2026-03-20" description: | - Inbound WebSocket API for webhook-relay. - Monitoring apps (Beszel, Dozzle, etc.) connect and push alert payloads to the server. - The server processes each message via ReceiveAlertUseCase. + Inbound WebSocket API for relaybox. + Monitoring apps (Beszel, Dozzle, etc.) connect and push message payloads to the server. + The server processes each message via ReceiveMessageUseCase. servers: local: @@ -17,49 +17,49 @@ servers: - $ref: "#/components/securitySchemes/bearerAuth" channels: - alerts: - address: /sources/{sourceId}/alerts/ws + messages: + address: /inputs/{inputId}/messages/ws description: | - WebSocket channel for inbound alert delivery. + WebSocket channel for inbound message delivery. Authenticate with Authorization: Bearer on connect. parameters: - sourceId: - description: Source identifier (must match a configured source) + inputId: + description: Input identifier (must match a configured input) messages: - alertPayload: - $ref: "#/components/messages/AlertPayload" + messagePayload: + $ref: "#/components/messages/MessagePayload" operations: - receiveAlert: + receiveMessage: action: receive channel: - $ref: "#/channels/alerts" - summary: Receive alert from monitoring app + $ref: "#/channels/messages" + summary: Receive message from monitoring app description: | - The server receives JSON alert payloads pushed by the connected monitoring app. - Each message triggers ReceiveAlertUseCase.Receive(), which: - 1. Creates an Alert entity (status=PENDING) + The server receives JSON message payloads pushed by the connected monitoring app. + Each message triggers ReceiveMessageUseCase.Receive(), which: + 1. Creates a Message entity (status=PENDING) 2. Persists to SQLite - 3. Enqueues for async delivery to configured channels + 3. Enqueues for async delivery to configured outputs messages: - - $ref: "#/channels/alerts/messages/alertPayload" + - $ref: "#/channels/messages/messages/messagePayload" components: messages: - AlertPayload: - name: AlertPayload - title: Alert Payload + MessagePayload: + name: MessagePayload + title: Message Payload summary: Raw JSON payload from monitoring app contentType: application/json payload: type: object description: | - Source-specific JSON payload. Structure varies by source type. + Input-specific JSON payload. Structure varies by input type. The relay stores and forwards this payload as-is, applying the - channel's Go text/template for transformation on delivery. + output's Go text/template for transformation on delivery. additionalProperties: true examples: - - summary: Beszel alert example + - summary: Beszel message example value: level: critical message: CPU usage exceeded 90% @@ -69,4 +69,4 @@ components: bearerAuth: type: http scheme: bearer - description: Source-specific bearer token. Same token used for HTTP POST endpoint. + description: Input-specific bearer token. Same token used for HTTP POST endpoint. diff --git a/internal/apidocs/embed.go b/internal/apidocs/embed.go index ab36ab5..7a13242 100644 --- a/internal/apidocs/embed.go +++ b/internal/apidocs/embed.go @@ -29,7 +29,7 @@ func AsyncAPIHandler(w http.ResponseWriter, r *http.Request) { func RedocHTMLHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) - w.Write([]byte(`webhook-relay API`)) //nolint:errcheck } diff --git a/internal/apidocs/embed_test.go b/internal/apidocs/embed_test.go index 52ba00d..38d01c9 100644 --- a/internal/apidocs/embed_test.go +++ b/internal/apidocs/embed_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "webhook-relay/internal/apidocs" + "relaybox/internal/apidocs" ) func TestOpenAPIHandler(t *testing.T) { @@ -24,7 +24,7 @@ func TestOpenAPIHandler(t *testing.T) { t.Error("body is empty") } // placeholder는 version "0.0.0" — 실제 스펙은 "2026-03-20" 이어야 한다 - if !strings.Contains(w.Body.String(), "webhook-relay API") { + if !strings.Contains(w.Body.String(), "relaybox API") { t.Error("openapi.yaml does not contain expected title") } } @@ -43,7 +43,7 @@ func TestAsyncAPIHandler(t *testing.T) { if w.Body.Len() == 0 { t.Error("body is empty") } - if !strings.Contains(w.Body.String(), "webhook-relay WebSocket API") { + if !strings.Contains(w.Body.String(), "relaybox WebSocket API") { t.Error("asyncapi.yaml does not contain expected title") } } diff --git a/internal/apidocs/openapi.yaml b/internal/apidocs/openapi.yaml index 7eeaf8f..5389cc0 100644 --- a/internal/apidocs/openapi.yaml +++ b/internal/apidocs/openapi.yaml @@ -1,11 +1,11 @@ openapi: "3.1.0" info: - title: webhook-relay API + title: relaybox API version: "2026-03-20" description: | - Webhook relay hub. Receives alerts from monitoring apps (Beszel, Dozzle, etc.) - and forwards them to external channels via templates. + relaybox — generic relay hub. Receives messages from monitoring apps (Beszel, Dozzle, etc.) + and forwards them to external outputs via templates. servers: - url: http://localhost:8080 @@ -15,12 +15,12 @@ security: - bearerAuth: [] tags: - - name: alerts - description: Alert management - - name: sources - description: Source management - - name: channels - description: Channel management + - name: messages + description: Message management + - name: inputs + description: Input management + - name: outputs + description: Output management - name: health description: Health check @@ -44,34 +44,34 @@ paths: example: ok required: [status] - /sources/{sourceId}/alerts: + /inputs/{inputId}/messages: post: - summary: Receive alert - operationId: postAlert - tags: [alerts] + summary: Receive message + operationId: postMessage + tags: [messages] parameters: - - $ref: "#/components/parameters/sourceId" + - $ref: "#/components/parameters/inputId" requestBody: required: true content: application/json: schema: type: object - description: Source-specific JSON payload + description: Input-specific JSON payload additionalProperties: true responses: "201": - description: Alert created + description: Message created headers: Location: - description: URL of the created alert + description: URL of the created message schema: type: string - example: /sources/beszel/alerts/01J... + example: /inputs/beszel/messages/01J... content: application/json: schema: - $ref: "#/components/schemas/AlertCreatedResponse" + $ref: "#/components/schemas/MessageCreatedResponse" "401": $ref: "#/components/responses/Unauthorized" "413": @@ -80,12 +80,12 @@ paths: $ref: "#/components/responses/InternalServerError" get: - summary: List alerts for a source - operationId: listAlerts + summary: List messages for an input + operationId: listMessages x-status: planned - tags: [alerts] + tags: [messages] parameters: - - $ref: "#/components/parameters/sourceId" + - $ref: "#/components/parameters/inputId" - name: limit in: query schema: @@ -98,47 +98,47 @@ paths: default: 0 responses: "200": - description: Alert list + description: Message list content: application/json: schema: type: array items: - $ref: "#/components/schemas/Alert" + $ref: "#/components/schemas/Message" "401": $ref: "#/components/responses/Unauthorized" - /sources/{sourceId}/alerts/{alertId}: + /inputs/{inputId}/messages/{messageId}: get: - summary: Get alert by ID - operationId: getAlert - tags: [alerts] + summary: Get message by ID + operationId: getMessage + tags: [messages] parameters: - - $ref: "#/components/parameters/sourceId" - - $ref: "#/components/parameters/alertId" + - $ref: "#/components/parameters/inputId" + - $ref: "#/components/parameters/messageId" responses: "200": - description: Alert found + description: Message found content: application/json: schema: - $ref: "#/components/schemas/Alert" + $ref: "#/components/schemas/Message" "401": $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" patch: - summary: Update alert status - operationId: patchAlert + summary: Update message status + operationId: patchMessage x-status: planned - tags: [alerts] + tags: [messages] description: | Allowed transition: FAILED → PENDING (manual re-queue). All other transitions return 422. parameters: - - $ref: "#/components/parameters/sourceId" - - $ref: "#/components/parameters/alertId" + - $ref: "#/components/parameters/inputId" + - $ref: "#/components/parameters/messageId" requestBody: required: true content: @@ -152,11 +152,11 @@ paths: required: [status] responses: "200": - description: Alert updated + description: Message updated content: application/json: schema: - $ref: "#/components/schemas/Alert" + $ref: "#/components/schemas/Message" "401": $ref: "#/components/responses/Unauthorized" "404": @@ -164,71 +164,71 @@ paths: "422": $ref: "#/components/responses/UnprocessableEntity" - /sources: + /inputs: get: - summary: List sources - operationId: listSources + summary: List inputs + operationId: listInputs x-status: planned - tags: [sources] + tags: [inputs] responses: "200": - description: Source list + description: Input list content: application/json: schema: type: array items: - $ref: "#/components/schemas/Source" + $ref: "#/components/schemas/Input" - /sources/{sourceId}: + /inputs/{inputId}: get: - summary: Get source by ID - operationId: getSource + summary: Get input by ID + operationId: getInput x-status: planned - tags: [sources] + tags: [inputs] parameters: - - $ref: "#/components/parameters/sourceId" + - $ref: "#/components/parameters/inputId" responses: "200": - description: Source found + description: Input found content: application/json: schema: - $ref: "#/components/schemas/Source" + $ref: "#/components/schemas/Input" "404": $ref: "#/components/responses/NotFound" - /channels: + /outputs: get: - summary: List channels - operationId: listChannels + summary: List outputs + operationId: listOutputs x-status: planned - tags: [channels] + tags: [outputs] responses: "200": - description: Channel list + description: Output list content: application/json: schema: type: array items: - $ref: "#/components/schemas/Channel" + $ref: "#/components/schemas/Output" - /channels/{channelId}: + /outputs/{outputId}: get: - summary: Get channel by ID - operationId: getChannel + summary: Get output by ID + operationId: getOutput x-status: planned - tags: [channels] + tags: [outputs] parameters: - - $ref: "#/components/parameters/channelId" + - $ref: "#/components/parameters/outputId" responses: "200": - description: Channel found + description: Output found content: application/json: schema: - $ref: "#/components/schemas/Channel" + $ref: "#/components/schemas/Output" "404": $ref: "#/components/responses/NotFound" @@ -237,25 +237,25 @@ components: bearerAuth: type: http scheme: bearer - description: Source-specific bearer token from config + description: Input-specific bearer token from config parameters: - sourceId: - name: sourceId + inputId: + name: inputId in: path required: true schema: type: string example: beszel - alertId: - name: alertId + messageId: + name: messageId in: path required: true schema: type: string example: 01J... - channelId: - name: channelId + outputId: + name: outputId in: path required: true schema: @@ -263,24 +263,24 @@ components: example: ops-webhook schemas: - AlertStatus: + MessageStatus: type: string enum: [PENDING, DELIVERED, FAILED] description: | - Alert delivery status. + Message delivery status. Transitions: PENDING → DELIVERED (success), PENDING → FAILED (retries exhausted), FAILED → PENDING (manual re-queue via PATCH). - Alert: + Message: type: object properties: id: type: string description: ULID - sourceId: + inputId: type: string status: - $ref: "#/components/schemas/AlertStatus" + $ref: "#/components/schemas/MessageStatus" createdAt: type: string format: date-time @@ -290,23 +290,23 @@ components: lastAttemptAt: type: ["string", "null"] format: date-time - required: [id, sourceId, status, createdAt, retryCount] + required: [id, inputId, status, createdAt, retryCount] - AlertCreatedResponse: + MessageCreatedResponse: type: object properties: id: type: string - sourceId: + inputId: type: string status: - $ref: "#/components/schemas/AlertStatus" + $ref: "#/components/schemas/MessageStatus" createdAt: type: string format: date-time - required: [id, sourceId, status, createdAt] + required: [id, inputId, status, createdAt] - Source: + Input: type: object properties: id: @@ -316,7 +316,7 @@ components: enum: [BESZEL, DOZZLE, GENERIC] required: [id, type] - Channel: + Output: type: object properties: id: @@ -347,7 +347,7 @@ components: example: 401 detail: type: string - example: "invalid or missing token for source: beszel" + example: "invalid or missing token for input: beszel" traceId: type: string required: [type, title, status, detail] diff --git a/internal/application/port/input/parser.go b/internal/application/port/input/parser.go new file mode 100644 index 0000000..d700133 --- /dev/null +++ b/internal/application/port/input/parser.go @@ -0,0 +1,12 @@ +package input + +// Parser parses raw message body into structured data. +type Parser interface { + Parse(contentType string, body []byte) (map[string]any, error) + Type() string +} + +// ParserRegistry provides lookup of parsers by type name. +type ParserRegistry interface { + Get(parserType string) (Parser, error) +} diff --git a/internal/application/port/input/receive_alert.go b/internal/application/port/input/receive_alert.go deleted file mode 100644 index c365e1a..0000000 --- a/internal/application/port/input/receive_alert.go +++ /dev/null @@ -1,14 +0,0 @@ -package input - -import ( - "context" - - "webhook-relay/internal/domain" -) - -// ReceiveAlertUseCase 알람 수신 유스케이스. -// source는 반드시 domain.SourceType 값 (예: "BESZEL")으로 전달된다. -// 성공 시 생성된 alert ID를 반환한다. -type ReceiveAlertUseCase interface { - Receive(ctx context.Context, source domain.SourceType, payload []byte) (string, error) -} diff --git a/internal/application/port/input/receive_message.go b/internal/application/port/input/receive_message.go new file mode 100644 index 0000000..f90aac5 --- /dev/null +++ b/internal/application/port/input/receive_message.go @@ -0,0 +1,15 @@ +package input + +import ( + "context" + + "relaybox/internal/domain" +) + +// ReceiveMessageUseCase 메시지 수신 유스케이스. +// input은 반드시 domain.InputType 값 (예: "BESZEL")으로 전달된다. +// contentType은 HTTP Content-Type 헤더 값이다 (파서 선택에 사용). +// 성공 시 생성된 message ID를 반환한다. +type ReceiveMessageUseCase interface { + Receive(ctx context.Context, input domain.InputType, contentType string, body []byte) (string, error) +} diff --git a/internal/application/port/output/alert_repository.go b/internal/application/port/output/alert_repository.go deleted file mode 100644 index 152b6dc..0000000 --- a/internal/application/port/output/alert_repository.go +++ /dev/null @@ -1,15 +0,0 @@ -package output - -import ( - "context" - "time" - - "webhook-relay/internal/domain" -) - -type AlertRepository interface { - Save(ctx context.Context, alert domain.Alert) error - UpdateDeliveryState(ctx context.Context, id string, status domain.AlertStatus, retryCount int, lastAttemptAt time.Time) error - FindByID(ctx context.Context, id string) (domain.Alert, error) - FindBySource(ctx context.Context, sourceID string, limit, offset int) ([]domain.Alert, error) -} diff --git a/internal/application/port/output/alert_sender.go b/internal/application/port/output/alert_sender.go deleted file mode 100644 index 48023c3..0000000 --- a/internal/application/port/output/alert_sender.go +++ /dev/null @@ -1,11 +0,0 @@ -package output - -import ( - "context" - - "webhook-relay/internal/domain" -) - -type AlertSender interface { - Send(ctx context.Context, channel domain.Channel, alert domain.Alert) error -} diff --git a/internal/application/port/output/expression.go b/internal/application/port/output/expression.go new file mode 100644 index 0000000..c55bdc8 --- /dev/null +++ b/internal/application/port/output/expression.go @@ -0,0 +1,15 @@ +package output + +// ExpressionEngine evaluates expressions against a data map. +type ExpressionEngine interface { + Evaluate(expression string, data map[string]any) (any, error) + EvaluateBool(expression string, data map[string]any) (bool, error) + EvaluateString(expression string, data map[string]any) (string, error) + Type() string +} + +// ExpressionEngineRegistry manages available expression engines. +type ExpressionEngineRegistry interface { + Get(engineType string) (ExpressionEngine, error) + Default() ExpressionEngine +} diff --git a/internal/application/port/output/alert_queue.go b/internal/application/port/output/message_queue.go similarity index 54% rename from internal/application/port/output/alert_queue.go rename to internal/application/port/output/message_queue.go index 5683df3..0e3f1b5 100644 --- a/internal/application/port/output/alert_queue.go +++ b/internal/application/port/output/message_queue.go @@ -3,7 +3,7 @@ package output import ( "context" - "webhook-relay/internal/domain" + "relaybox/internal/domain" ) // AckFunc 전달 성공 후 호출 — 큐에서 영구 삭제 @@ -12,7 +12,7 @@ type AckFunc func() error // NackFunc 전달 실패 후 호출 — 큐에 메시지 반환 type NackFunc func() error -type AlertQueue interface { - Enqueue(ctx context.Context, alert domain.Alert) error - Dequeue(ctx context.Context) (domain.Alert, AckFunc, NackFunc, error) +type MessageQueue interface { + Enqueue(ctx context.Context, msg domain.Message) error + Dequeue(ctx context.Context) (domain.Message, AckFunc, NackFunc, error) } diff --git a/internal/application/port/output/message_repository.go b/internal/application/port/output/message_repository.go new file mode 100644 index 0000000..b5e4f86 --- /dev/null +++ b/internal/application/port/output/message_repository.go @@ -0,0 +1,15 @@ +package output + +import ( + "context" + "time" + + "relaybox/internal/domain" +) + +type MessageRepository interface { + Save(ctx context.Context, msg domain.Message) error + UpdateDeliveryState(ctx context.Context, id string, status domain.MessageStatus, retryCount int, lastAttemptAt time.Time) error + FindByID(ctx context.Context, id string) (domain.Message, error) + FindByInput(ctx context.Context, inputID string, limit, offset int) ([]domain.Message, error) +} diff --git a/internal/application/port/output/output_registry.go b/internal/application/port/output/output_registry.go new file mode 100644 index 0000000..aa36e8b --- /dev/null +++ b/internal/application/port/output/output_registry.go @@ -0,0 +1,8 @@ +package output + +import "relaybox/internal/domain" + +type OutputRegistry interface { + // Get 은 OutputSender(named interface)를 반환한다. + Get(outputType domain.OutputType) (OutputSender, error) +} diff --git a/internal/application/port/output/output_sender.go b/internal/application/port/output/output_sender.go new file mode 100644 index 0000000..280254e --- /dev/null +++ b/internal/application/port/output/output_sender.go @@ -0,0 +1,12 @@ +package output + +import ( + "context" + + "relaybox/internal/domain" +) + +// OutputSender sends a pre-rendered payload to an output destination. +type OutputSender interface { + Send(ctx context.Context, out domain.Output, payload []byte) error +} diff --git a/internal/application/port/output/route_config_reader.go b/internal/application/port/output/route_config_reader.go deleted file mode 100644 index 3d9b252..0000000 --- a/internal/application/port/output/route_config_reader.go +++ /dev/null @@ -1,11 +0,0 @@ -package output - -import ( - "context" - - "webhook-relay/internal/domain" -) - -type RouteConfigReader interface { - GetChannels(ctx context.Context, sourceID string) ([]domain.Channel, error) -} diff --git a/internal/application/port/output/rule_config_reader.go b/internal/application/port/output/rule_config_reader.go new file mode 100644 index 0000000..2ccef87 --- /dev/null +++ b/internal/application/port/output/rule_config_reader.go @@ -0,0 +1,12 @@ +package output + +import ( + "context" + + "relaybox/internal/domain" +) + +// RuleConfigReader returns the rule and associated outputs for a given input type. +type RuleConfigReader interface { + GetRule(ctx context.Context, inputType string) (domain.Rule, []domain.Output, error) +} diff --git a/internal/application/port/output/sender_registry.go b/internal/application/port/output/sender_registry.go deleted file mode 100644 index 9db2f89..0000000 --- a/internal/application/port/output/sender_registry.go +++ /dev/null @@ -1,8 +0,0 @@ -package output - -import "webhook-relay/internal/domain" - -type SenderRegistry interface { - // Get 은 AlertSender(named interface)를 반환한다. - Get(channelType domain.ChannelType) (AlertSender, error) -} diff --git a/internal/application/service/alert_service.go b/internal/application/service/alert_service.go deleted file mode 100644 index b43fcfe..0000000 --- a/internal/application/service/alert_service.go +++ /dev/null @@ -1,40 +0,0 @@ -package service - -import ( - "context" - "crypto/rand" - "fmt" - "time" - - "github.com/oklog/ulid/v2" - "webhook-relay/internal/application/port/output" - "webhook-relay/internal/domain" -) - -type AlertService struct { - repo output.AlertRepository - queue output.AlertQueue -} - -func NewAlertService(repo output.AlertRepository, queue output.AlertQueue) *AlertService { - return &AlertService{repo: repo, queue: queue} -} - -func (s *AlertService) Receive(ctx context.Context, source domain.SourceType, payload []byte) (string, error) { - id := ulid.MustNew(ulid.Timestamp(time.Now()), rand.Reader).String() - alert := domain.Alert{ - ID: id, - Version: 1, - Source: source, - Payload: domain.RawPayload(payload), - CreatedAt: time.Now().UTC(), - Status: domain.AlertStatusPending, - } - if err := s.repo.Save(ctx, alert); err != nil { - return "", fmt.Errorf("receive: save: %w", err) - } - if err := s.queue.Enqueue(ctx, alert); err != nil { - return "", fmt.Errorf("receive: enqueue: %w", err) - } - return id, nil -} diff --git a/internal/application/service/alert_service_test.go b/internal/application/service/alert_service_test.go deleted file mode 100644 index c4e0d64..0000000 --- a/internal/application/service/alert_service_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package service_test - -import ( - "context" - "errors" - "testing" - "time" - - "webhook-relay/internal/application/port/output" - "webhook-relay/internal/application/service" - "webhook-relay/internal/domain" -) - -type mockRepo struct { - saveFn func(context.Context, domain.Alert) error - updateFn func(context.Context, string, domain.AlertStatus, int, time.Time) error -} - -func (m *mockRepo) Save(ctx context.Context, a domain.Alert) error { return m.saveFn(ctx, a) } -func (m *mockRepo) UpdateDeliveryState(ctx context.Context, id string, s domain.AlertStatus, retry int, t time.Time) error { - if m.updateFn != nil { - return m.updateFn(ctx, id, s, retry, t) - } - return nil -} -func (m *mockRepo) FindByID(_ context.Context, _ string) (domain.Alert, error) { - return domain.Alert{}, nil -} -func (m *mockRepo) FindBySource(_ context.Context, _ string, _, _ int) ([]domain.Alert, error) { - return nil, nil -} - -type mockQueue struct { - enqueueFn func(context.Context, domain.Alert) error -} - -func (m *mockQueue) Enqueue(ctx context.Context, a domain.Alert) error { return m.enqueueFn(ctx, a) } -func (m *mockQueue) Dequeue(_ context.Context) (domain.Alert, output.AckFunc, output.NackFunc, error) { - return domain.Alert{}, nil, nil, nil -} - -func TestAlertService_Receive_Success(t *testing.T) { - var saved domain.Alert - repo := &mockRepo{saveFn: func(_ context.Context, a domain.Alert) error { saved = a; return nil }} - var enqueued domain.Alert - queue := &mockQueue{enqueueFn: func(_ context.Context, a domain.Alert) error { enqueued = a; return nil }} - - svc := service.NewAlertService(repo, queue) - id, err := svc.Receive(context.Background(), domain.SourceTypeBeszel, []byte(`{"host":"srv1"}`)) - if err != nil { - t.Fatalf("Receive() error: %v", err) - } - if id == "" { - t.Error("returned ID should not be empty") - } - if saved.Source != domain.SourceTypeBeszel { - t.Errorf("source = %q, want BESZEL", saved.Source) - } - if saved.Status != domain.AlertStatusPending { - t.Errorf("status = %q, want PENDING", saved.Status) - } - if saved.ID != id { - t.Errorf("saved.ID = %q, want returned ID %q", saved.ID, id) - } - if enqueued.ID != saved.ID { - t.Errorf("enqueued ID != saved ID") - } -} - -func TestAlertService_Receive_SaveError(t *testing.T) { - repo := &mockRepo{saveFn: func(_ context.Context, _ domain.Alert) error { return errors.New("db err") }} - queue := &mockQueue{enqueueFn: func(_ context.Context, _ domain.Alert) error { return nil }} - - svc := service.NewAlertService(repo, queue) - if _, err := svc.Receive(context.Background(), domain.SourceTypeBeszel, []byte(`{}`)); err == nil { - t.Fatal("expected error") - } -} diff --git a/internal/application/service/delivery_worker.go b/internal/application/service/delivery_worker.go deleted file mode 100644 index 1388ba1..0000000 --- a/internal/application/service/delivery_worker.go +++ /dev/null @@ -1,127 +0,0 @@ -package service - -import ( - "context" - "fmt" - "log/slog" - "sync" - "time" - - "webhook-relay/internal/application/port/output" - "webhook-relay/internal/domain" -) - -type DeliveryWorker struct { - queue output.AlertQueue - repo output.AlertRepository - routeReader output.RouteConfigReader - registry output.SenderRegistry - wg sync.WaitGroup -} - -func NewDeliveryWorker( - queue output.AlertQueue, - repo output.AlertRepository, - routeReader output.RouteConfigReader, - registry output.SenderRegistry, -) *DeliveryWorker { - return &DeliveryWorker{queue: queue, repo: repo, routeReader: routeReader, registry: registry} -} - -func (w *DeliveryWorker) Start(ctx context.Context, workerCount int) { - w.wg.Add(workerCount) - for range workerCount { - go w.loop(ctx) - } -} - -// Wait blocks until all workers finish. Call after cancelling the context. -func (w *DeliveryWorker) Wait() { - w.wg.Wait() -} - -func (w *DeliveryWorker) loop(ctx context.Context) { - defer w.wg.Done() - for { - select { - case <-ctx.Done(): - return - default: - if err := w.processOne(ctx); err != nil { - select { - case <-ctx.Done(): - return - case <-time.After(500 * time.Millisecond): - } - } - } - } -} - -func (w *DeliveryWorker) processOne(ctx context.Context) error { - alert, ack, nack, err := w.queue.Dequeue(ctx) - if err != nil { - return err - } - - channels, err := w.routeReader.GetChannels(ctx, string(alert.Source)) - if err != nil { - _ = nack() - return fmt.Errorf("get channels: %w", err) - } - - success := true - for _, ch := range channels { - if err := w.deliver(ctx, ch, alert); err != nil { - slog.Warn("delivery failed", "alertID", alert.ID, "channel", ch.ID, "err", err) - success = false - } - } - - now := time.Now().UTC() - if success { - if err := ack(); err != nil { - slog.Warn("ack failed", "alertID", alert.ID, "err", err) - } - if err := w.repo.UpdateDeliveryState(ctx, alert.ID, domain.AlertStatusDelivered, alert.RetryCount, now); err != nil { - slog.Error("failed to update delivery state to delivered", "alertID", alert.ID, "err", err) - } - } else { - if err := nack(); err != nil { - slog.Warn("nack failed", "alertID", alert.ID, "err", err) - } - if err := w.repo.UpdateDeliveryState(ctx, alert.ID, domain.AlertStatusFailed, alert.RetryCount+1, now); err != nil { - slog.Error("failed to update delivery state to failed", "alertID", alert.ID, "err", err) - } - } - return nil -} - -func (w *DeliveryWorker) deliver(ctx context.Context, ch domain.Channel, alert domain.Alert) error { - sender, err := w.registry.Get(ch.Type) - if err != nil { - return fmt.Errorf("get sender: %w", err) - } - retryCount, delayMs := ch.RetryCount, ch.RetryDelayMs - if retryCount <= 0 { - retryCount = 3 - } - if delayMs <= 0 { - delayMs = 1000 - } - var lastErr error - for i := range retryCount { - if err := sender.Send(ctx, ch, alert); err == nil { - return nil - } else { - lastErr = err - } - backoff := time.Duration(delayMs*(1<= len(m.alerts) { - time.Sleep(10 * time.Millisecond) - return domain.Alert{}, nil, nil, errors.New("empty") - } - a := m.alerts[m.idx] - m.idx++ - return a, func() error { return nil }, func() error { return nil }, nil -} - -type mockRouteReader struct{ channels []domain.Channel } - -func (m *mockRouteReader) GetChannels(_ context.Context, _ string) ([]domain.Channel, error) { - return m.channels, nil -} - -type mockSender struct{ count atomic.Int32 } - -func (m *mockSender) Send(_ context.Context, _ domain.Channel, _ domain.Alert) error { - m.count.Add(1) - return nil -} - -type mockRegistry struct{ sender *mockSender } - -func (m *mockRegistry) Get(_ domain.ChannelType) (output.AlertSender, error) { - return m.sender, nil -} - -type mockRegistryFn struct{ senderFn func() output.AlertSender } - -func (m *mockRegistryFn) Get(_ domain.ChannelType) (output.AlertSender, error) { - return m.senderFn(), nil -} - -func TestDeliveryWorker_UpdateDeliveryState_ErrorDoesNotBreakWorker(t *testing.T) { - // UpdateDeliveryState가 에러를 반환해도 워커가 정상 동작(send 완료, 패닉 없음)해야 한다. - alert := domain.Alert{ID: "w-err", Source: domain.SourceTypeBeszel, Payload: domain.RawPayload(`{}`), Status: domain.AlertStatusPending, Version: 1} - queue := &mockAlertQueue{alerts: []domain.Alert{alert}} - repo := &mockRepo{ - saveFn: func(_ context.Context, _ domain.Alert) error { return nil }, - updateFn: func(_ context.Context, _ string, _ domain.AlertStatus, _ int, _ time.Time) error { - return errors.New("db error") - }, - } - sender := &mockSender{} - routeReader := &mockRouteReader{channels: []domain.Channel{{ID: "c1", Type: domain.ChannelTypeWebhook, Template: `{{ .Source }}`}}} - registry := &mockRegistry{sender: sender} - - ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) - defer cancel() - - worker := service.NewDeliveryWorker(queue, repo, routeReader, registry) - worker.Start(ctx, 1) - - time.Sleep(150 * time.Millisecond) - // DB 에러에도 불구하고 send는 수행되어야 한다 - if sender.count.Load() == 0 { - t.Error("expected send to be called despite UpdateDeliveryState error") - } -} - -func TestDeliveryWorker_DeliverSuccess(t *testing.T) { - alert := domain.Alert{ID: "w1", Source: domain.SourceTypeBeszel, Payload: domain.RawPayload(`{}`), Status: domain.AlertStatusPending, Version: 1} - queue := &mockAlertQueue{alerts: []domain.Alert{alert}} - repo := &mockRepo{saveFn: func(_ context.Context, _ domain.Alert) error { return nil }} - sender := &mockSender{} - routeReader := &mockRouteReader{channels: []domain.Channel{{ID: "c1", Type: domain.ChannelTypeWebhook, Template: `{{ .Source }}`}}} - registry := &mockRegistry{sender: sender} - - ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) - defer cancel() - - worker := service.NewDeliveryWorker(queue, repo, routeReader, registry) - worker.Start(ctx, 1) - - time.Sleep(150 * time.Millisecond) - if sender.count.Load() == 0 { - t.Error("expected at least one send call") - } -} - -type mockRouteReaderWithError struct{ err error } - -func (m *mockRouteReaderWithError) GetChannels(_ context.Context, _ string) ([]domain.Channel, error) { - return nil, m.err -} - -func TestDeliveryWorker_NoRoute_Nacks(t *testing.T) { - // source에 매핑된 route가 없을 때 alert을 nack 처리해야 한다 - alert := domain.Alert{ID: "no-route", Source: domain.SourceTypeBeszel, Payload: domain.RawPayload(`{}`), Status: domain.AlertStatusPending, Version: 1} - var nackCalled atomic.Bool - queue := &mockAlertQueueWithNack{alert: alert, nackFn: func() error { nackCalled.Store(true); return nil }} - repo := &mockRepo{saveFn: func(_ context.Context, _ domain.Alert) error { return nil }} - registry := &mockRegistry{sender: &mockSender{}} - routeReader := &mockRouteReaderWithError{err: errors.New("no route")} - - ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) - defer cancel() - - worker := service.NewDeliveryWorker(queue, repo, routeReader, registry) - worker.Start(ctx, 1) - time.Sleep(150 * time.Millisecond) - - if !nackCalled.Load() { - t.Error("expected nack to be called when no route found") - } -} - -type mockAlertQueueWithNack struct { - alert domain.Alert - nackFn func() error - called atomic.Bool -} - -func (m *mockAlertQueueWithNack) Enqueue(_ context.Context, _ domain.Alert) error { return nil } -func (m *mockAlertQueueWithNack) Dequeue(_ context.Context) (domain.Alert, output.AckFunc, output.NackFunc, error) { - if m.called.Swap(true) { - time.Sleep(10 * time.Millisecond) - return domain.Alert{}, nil, nil, errors.New("empty") - } - return m.alert, func() error { return nil }, m.nackFn, nil -} - -type mockSenderError struct{} - -func (m *mockSenderError) Send(_ context.Context, _ domain.Channel, _ domain.Alert) error { - return errors.New("send failed: render error") -} - -func TestDeliveryWorker_SendError_MarksAsFailed(t *testing.T) { - // Send가 에러를 반환하면 nack 처리되고 FAILED 상태로 기록되어야 한다 - alert := domain.Alert{ID: "send-err", Source: domain.SourceTypeBeszel, Payload: domain.RawPayload(`{}`), Status: domain.AlertStatusPending, Version: 1} - var nackCalled atomic.Bool - queue := &mockAlertQueueWithNack{alert: alert, nackFn: func() error { nackCalled.Store(true); return nil }} - - var mu sync.Mutex - var updatedStatus domain.AlertStatus - repo := &mockRepo{ - saveFn: func(_ context.Context, _ domain.Alert) error { return nil }, - updateFn: func(_ context.Context, _ string, s domain.AlertStatus, _ int, _ time.Time) error { - mu.Lock() - updatedStatus = s - mu.Unlock() - return nil - }, - } - routeReader := &mockRouteReader{channels: []domain.Channel{ - {ID: "c1", Type: domain.ChannelTypeWebhook, Template: `{}`, RetryCount: 1, RetryDelayMs: 10}, - }} - registry := &mockRegistryFn{senderFn: func() output.AlertSender { return &mockSenderError{} }} - - ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) - defer cancel() - - worker := service.NewDeliveryWorker(queue, repo, routeReader, registry) - worker.Start(ctx, 1) - time.Sleep(150 * time.Millisecond) - - if !nackCalled.Load() { - t.Error("expected nack when send fails") - } - mu.Lock() - status := updatedStatus - mu.Unlock() - if status != domain.AlertStatusFailed { - t.Errorf("status = %q, want FAILED", status) - } -} - -func TestDeliveryWorker_GracefulShutdown(t *testing.T) { - // ctx 취소 후 Wait()이 반환되어야 한다 (타임아웃 없이) - queue := &mockAlertQueue{} - repo := &mockRepo{saveFn: func(_ context.Context, _ domain.Alert) error { return nil }} - routeReader := &mockRouteReader{} - registry := &mockRegistry{sender: &mockSender{}} - - ctx, cancel := context.WithCancel(context.Background()) - worker := service.NewDeliveryWorker(queue, repo, routeReader, registry) - worker.Start(ctx, 2) - - cancel() - - done := make(chan struct{}) - go func() { - worker.Wait() - close(done) - }() - select { - case <-done: - // 정상 종료 - case <-time.After(2 * time.Second): - t.Fatal("Wait() did not return after context cancellation") - } -} diff --git a/internal/application/service/message_service.go b/internal/application/service/message_service.go new file mode 100644 index 0000000..81db5d9 --- /dev/null +++ b/internal/application/service/message_service.go @@ -0,0 +1,72 @@ +package service + +import ( + "context" + "crypto/rand" + "fmt" + "log/slog" + "time" + + "github.com/oklog/ulid/v2" + "relaybox/internal/application/port/input" + "relaybox/internal/application/port/output" + "relaybox/internal/domain" +) + +type MessageService struct { + repo output.MessageRepository + queue output.MessageQueue + parserTypes map[domain.InputType]string + registry input.ParserRegistry +} + +func NewMessageService( + repo output.MessageRepository, + queue output.MessageQueue, + parserTypes map[domain.InputType]string, + registry input.ParserRegistry, +) *MessageService { + return &MessageService{ + repo: repo, + queue: queue, + parserTypes: parserTypes, + registry: registry, + } +} + +func (s *MessageService) Receive(ctx context.Context, inputType domain.InputType, contentType string, body []byte) (string, error) { + id := ulid.MustNew(ulid.Timestamp(time.Now()), rand.Reader).String() + msg := domain.Message{ + ID: id, + Version: 1, + Input: inputType, + Payload: domain.RawPayload(body), + CreatedAt: time.Now().UTC(), + Status: domain.MessageStatusPending, + } + + // Parse body if a parser is configured for this input type + if s.parserTypes != nil && s.registry != nil { + if parserType, ok := s.parserTypes[inputType]; ok && parserType != "" { + if parser, err := s.registry.Get(parserType); err == nil { + if parsed, err := parser.Parse(contentType, body); err == nil { + msg.ParsedData = parsed + } else { + slog.Warn("parser failed, storing raw payload only", + "input", inputType, "parser", parserType, "err", err) + } + } else { + slog.Warn("parser not found, storing raw payload only", + "input", inputType, "parser", parserType, "err", err) + } + } + } + + if err := s.repo.Save(ctx, msg); err != nil { + return "", fmt.Errorf("receive: save: %w", err) + } + if err := s.queue.Enqueue(ctx, msg); err != nil { + return "", fmt.Errorf("receive: enqueue: %w", err) + } + return id, nil +} diff --git a/internal/application/service/message_service_test.go b/internal/application/service/message_service_test.go new file mode 100644 index 0000000..8408b40 --- /dev/null +++ b/internal/application/service/message_service_test.go @@ -0,0 +1,181 @@ +package service_test + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "relaybox/internal/application/port/input" + "relaybox/internal/application/port/output" + "relaybox/internal/application/service" + "relaybox/internal/domain" +) + +type mockRepo struct { + saveFn func(context.Context, domain.Message) error + updateFn func(context.Context, string, domain.MessageStatus, int, time.Time) error +} + +func (m *mockRepo) Save(ctx context.Context, a domain.Message) error { return m.saveFn(ctx, a) } +func (m *mockRepo) UpdateDeliveryState(ctx context.Context, id string, s domain.MessageStatus, retry int, t time.Time) error { + if m.updateFn != nil { + return m.updateFn(ctx, id, s, retry, t) + } + return nil +} +func (m *mockRepo) FindByID(_ context.Context, _ string) (domain.Message, error) { + return domain.Message{}, nil +} +func (m *mockRepo) FindByInput(_ context.Context, _ string, _, _ int) ([]domain.Message, error) { + return nil, nil +} + +type mockQueue struct { + enqueueFn func(context.Context, domain.Message) error +} + +func (m *mockQueue) Enqueue(ctx context.Context, a domain.Message) error { return m.enqueueFn(ctx, a) } +func (m *mockQueue) Dequeue(_ context.Context) (domain.Message, output.AckFunc, output.NackFunc, error) { + return domain.Message{}, nil, nil, nil +} + +type mockParser struct { + result map[string]any + err error + typ string +} + +func (m *mockParser) Parse(_ string, _ []byte) (map[string]any, error) { return m.result, m.err } +func (m *mockParser) Type() string { return m.typ } + +type mockParserRegistry struct { + parsers map[string]input.Parser +} + +func (m *mockParserRegistry) Get(t string) (input.Parser, error) { + p, ok := m.parsers[t] + if !ok { + return nil, fmt.Errorf("not found") + } + return p, nil +} + +func TestMessageService_Receive_Success(t *testing.T) { + var saved domain.Message + repo := &mockRepo{saveFn: func(_ context.Context, a domain.Message) error { saved = a; return nil }} + var enqueued domain.Message + queue := &mockQueue{enqueueFn: func(_ context.Context, a domain.Message) error { enqueued = a; return nil }} + + svc := service.NewMessageService(repo, queue, nil, nil) + id, err := svc.Receive(context.Background(), domain.InputTypeBeszel, "application/json", []byte(`{"host":"srv1"}`)) + if err != nil { + t.Fatalf("Receive() error: %v", err) + } + if id == "" { + t.Error("returned ID should not be empty") + } + if saved.Input != domain.InputTypeBeszel { + t.Errorf("input = %q, want BESZEL", saved.Input) + } + if saved.Status != domain.MessageStatusPending { + t.Errorf("status = %q, want PENDING", saved.Status) + } + if saved.ID != id { + t.Errorf("saved.ID = %q, want returned ID %q", saved.ID, id) + } + if enqueued.ID != saved.ID { + t.Errorf("enqueued ID != saved ID") + } +} + +func TestMessageService_Receive_SaveError(t *testing.T) { + repo := &mockRepo{saveFn: func(_ context.Context, _ domain.Message) error { return errors.New("db err") }} + queue := &mockQueue{enqueueFn: func(_ context.Context, _ domain.Message) error { return nil }} + + svc := service.NewMessageService(repo, queue, nil, nil) + if _, err := svc.Receive(context.Background(), domain.InputTypeBeszel, "application/json", []byte(`{}`)); err == nil { + t.Fatal("expected error") + } +} + +func TestMessageService_Receive_WithParser(t *testing.T) { + var saved domain.Message + repo := &mockRepo{saveFn: func(_ context.Context, a domain.Message) error { saved = a; return nil }} + queue := &mockQueue{enqueueFn: func(_ context.Context, _ domain.Message) error { return nil }} + + registry := &mockParserRegistry{ + parsers: map[string]input.Parser{ + "json": &mockParser{ + result: map[string]any{"host": "srv1", "port": float64(8080)}, + typ: "json", + }, + }, + } + + parserTypes := map[domain.InputType]string{ + domain.InputTypeBeszel: "json", + } + + svc := service.NewMessageService(repo, queue, parserTypes, registry) + _, err := svc.Receive(context.Background(), domain.InputTypeBeszel, "application/json", []byte(`{"host":"srv1","port":8080}`)) + if err != nil { + t.Fatalf("Receive() error: %v", err) + } + + if saved.ParsedData == nil { + t.Fatal("ParsedData should not be nil when parser is configured") + } + if saved.ParsedData["host"] != "srv1" { + t.Errorf("ParsedData[host] = %v, want srv1", saved.ParsedData["host"]) + } +} + +func TestMessageService_Receive_WithoutParser(t *testing.T) { + var saved domain.Message + repo := &mockRepo{saveFn: func(_ context.Context, a domain.Message) error { saved = a; return nil }} + queue := &mockQueue{enqueueFn: func(_ context.Context, _ domain.Message) error { return nil }} + + svc := service.NewMessageService(repo, queue, nil, nil) + _, err := svc.Receive(context.Background(), domain.InputTypeBeszel, "application/json", []byte(`{"host":"srv1"}`)) + if err != nil { + t.Fatalf("Receive() error: %v", err) + } + + if saved.ParsedData != nil { + t.Errorf("ParsedData should be nil when no parser is configured, got %v", saved.ParsedData) + } +} + +func TestMessageService_Receive_ParserFailsGracefully(t *testing.T) { + var saved domain.Message + repo := &mockRepo{saveFn: func(_ context.Context, a domain.Message) error { saved = a; return nil }} + queue := &mockQueue{enqueueFn: func(_ context.Context, _ domain.Message) error { return nil }} + + registry := &mockParserRegistry{ + parsers: map[string]input.Parser{ + "json": &mockParser{ + err: errors.New("parse error"), + typ: "json", + }, + }, + } + + parserTypes := map[domain.InputType]string{ + domain.InputTypeBeszel: "json", + } + + svc := service.NewMessageService(repo, queue, parserTypes, registry) + _, err := svc.Receive(context.Background(), domain.InputTypeBeszel, "application/json", []byte(`not-json`)) + if err != nil { + t.Fatalf("Receive() should succeed even when parser fails, got: %v", err) + } + + if saved.ParsedData != nil { + t.Errorf("ParsedData should be nil when parser fails, got %v", saved.ParsedData) + } + if saved.Payload == nil { + t.Error("Payload should still be stored even when parser fails") + } +} diff --git a/internal/application/service/relay_worker.go b/internal/application/service/relay_worker.go new file mode 100644 index 0000000..2fce972 --- /dev/null +++ b/internal/application/service/relay_worker.go @@ -0,0 +1,266 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "sync" + "time" + + "relaybox/internal/application/port/output" + "relaybox/internal/domain" +) + +type RelayWorker struct { + queue output.MessageQueue + repo output.MessageRepository + ruleReader output.RuleConfigReader + registry output.OutputRegistry + exprRegistry output.ExpressionEngineRegistry + wg sync.WaitGroup +} + +func NewRelayWorker( + queue output.MessageQueue, + repo output.MessageRepository, + ruleReader output.RuleConfigReader, + registry output.OutputRegistry, + exprRegistry output.ExpressionEngineRegistry, +) *RelayWorker { + return &RelayWorker{ + queue: queue, repo: repo, ruleReader: ruleReader, + registry: registry, exprRegistry: exprRegistry, + } +} + +func (w *RelayWorker) Start(ctx context.Context, workerCount int) { + w.wg.Add(workerCount) + for range workerCount { + go w.loop(ctx) + } +} + +// Wait blocks until all workers finish. Call after cancelling the context. +func (w *RelayWorker) Wait() { + w.wg.Wait() +} + +func (w *RelayWorker) loop(ctx context.Context) { + defer w.wg.Done() + for { + select { + case <-ctx.Done(): + return + default: + if err := w.processOne(ctx); err != nil { + select { + case <-ctx.Done(): + return + case <-time.After(500 * time.Millisecond): + } + } + } + } +} + +func (w *RelayWorker) processOne(ctx context.Context) error { + msg, ack, nack, err := w.queue.Dequeue(ctx) + if err != nil { + return err + } + + rule, outputs, err := w.ruleReader.GetRule(ctx, string(msg.Input)) + if err != nil { + _ = nack() + return fmt.Errorf("get rule: %w", err) + } + + // Build evaluation data from message + data := buildEvalData(msg) + + // Get expression engine + engine, err := w.getEngine(rule.Engine) + if err != nil { + _ = nack() + return fmt.Errorf("get engine: %w", err) + } + + // 1. Filter: skip if filter expression evaluates to false + if rule.Filter != "" { + pass, err := engine.EvaluateBool(rule.Filter, data) + if err != nil { + slog.Warn("filter evaluation failed", "messageID", msg.ID, "err", err) + _ = nack() + return err + } + if !pass { + _ = ack() // message processed but filtered out + return nil + } + } + + // 2. Mapping: evaluate all mapping expressions against the original data (parallel semantics). + // All expressions see the same pre-mapping state; cross-key dependencies are not supported. + mappedData := copyMap(data) + for key, expr := range rule.Mapping { + val, err := engine.Evaluate(expr, data) + if err != nil { + slog.Warn("mapping evaluation failed", "messageID", msg.ID, "key", key, "err", err) + continue + } + mappedData[key] = val + } + + // 3. Routing: evaluate conditions to determine which outputs to use + var routedOutputs []domain.Output + if len(rule.Routing) == 0 { + // No routing conditions = send to all outputs + routedOutputs = outputs + } else { + outputsByID := make(map[string]domain.Output, len(outputs)) + for _, o := range outputs { + outputsByID[o.ID] = o + } + for _, rc := range rule.Routing { + match, err := engine.EvaluateBool(rc.Condition, mappedData) + if err != nil { + slog.Warn("routing condition failed", "messageID", msg.ID, "condition", rc.Condition, "err", err) + continue + } + if match { + for _, oid := range rc.OutputIDs { + if o, ok := outputsByID[oid]; ok { + routedOutputs = append(routedOutputs, o) + } + } + } + } + } + + // Deduplicate outputs to prevent double-sending when multiple routing conditions match the same output + seen := make(map[string]struct{}, len(routedOutputs)) + deduped := routedOutputs[:0] + for _, o := range routedOutputs { + if _, ok := seen[o.ID]; !ok { + seen[o.ID] = struct{}{} + deduped = append(deduped, o) + } + } + routedOutputs = deduped + + // 4. Deliver to each routed output + success := true + for _, out := range routedOutputs { + payload, err := w.buildPayload(engine, out.Template, mappedData) + if err != nil { + slog.Warn("payload build failed", "messageID", msg.ID, "output", out.ID, "err", err) + success = false + continue + } + if err := w.deliver(ctx, out, payload); err != nil { + slog.Warn("delivery failed", "messageID", msg.ID, "output", out.ID, "err", err) + success = false + } + } + + now := time.Now().UTC() + if success { + if err := ack(); err != nil { + slog.Warn("ack failed", "messageID", msg.ID, "err", err) + } + if err := w.repo.UpdateDeliveryState(ctx, msg.ID, domain.MessageStatusDelivered, msg.RetryCount, now); err != nil { + slog.Error("failed to update delivery state to delivered", "messageID", msg.ID, "err", err) + } + } else { + if err := nack(); err != nil { + slog.Warn("nack failed", "messageID", msg.ID, "err", err) + } + if err := w.repo.UpdateDeliveryState(ctx, msg.ID, domain.MessageStatusFailed, msg.RetryCount+1, now); err != nil { + slog.Error("failed to update delivery state to failed", "messageID", msg.ID, "err", err) + } + } + return nil +} + +func (w *RelayWorker) deliver(ctx context.Context, out domain.Output, payload []byte) error { + sender, err := w.registry.Get(out.Type) + if err != nil { + return fmt.Errorf("get sender: %w", err) + } + retryCount, delayMs := out.RetryCount, out.RetryDelayMs + if retryCount <= 0 { + retryCount = 3 + } + if delayMs <= 0 { + delayMs = 1000 + } + var lastErr error + for i := range retryCount { + if err := sender.Send(ctx, out, payload); err == nil { + return nil + } else { + lastErr = err + } + backoff := time.Duration(delayMs*(1<= len(m.messages) { + time.Sleep(10 * time.Millisecond) + return domain.Message{}, nil, nil, errors.New("empty") + } + a := m.messages[m.idx] + m.idx++ + return a, func() error { return nil }, func() error { return nil }, nil +} + +type mockRuleReader struct { + rule domain.Rule + outputs []domain.Output +} + +func (m *mockRuleReader) GetRule(_ context.Context, _ string) (domain.Rule, []domain.Output, error) { + return m.rule, m.outputs, nil +} + +type mockSender struct { + count atomic.Int32 + payloads [][]byte + mu sync.Mutex +} + +func (m *mockSender) Send(_ context.Context, _ domain.Output, payload []byte) error { + m.count.Add(1) + m.mu.Lock() + m.payloads = append(m.payloads, payload) + m.mu.Unlock() + return nil +} + +type mockRegistry struct{ sender *mockSender } + +func (m *mockRegistry) Get(_ domain.OutputType) (output.OutputSender, error) { + return m.sender, nil +} + +type mockRegistryFn struct{ senderFn func() output.OutputSender } + +func (m *mockRegistryFn) Get(_ domain.OutputType) (output.OutputSender, error) { + return m.senderFn(), nil +} + +func newExprRegistry() output.ExpressionEngineRegistry { + reg := expression.NewInMemoryExpressionEngineRegistry() + celEng, err := expression.NewCELEngine() + if err != nil { + panic("NewCELEngine: " + err.Error()) + } + reg.Register(celEng) + reg.Register(expression.NewExprEngine()) + return reg +} + +func TestRelayWorker_UpdateDeliveryState_ErrorDoesNotBreakWorker(t *testing.T) { + msg := domain.Message{ID: "w-err", Input: domain.InputTypeBeszel, Payload: domain.RawPayload(`{}`), Status: domain.MessageStatusPending, Version: 1} + queue := &mockMessageQueue{messages: []domain.Message{msg}} + repo := &mockRepo{ + saveFn: func(_ context.Context, _ domain.Message) error { return nil }, + updateFn: func(_ context.Context, _ string, _ domain.MessageStatus, _ int, _ time.Time) error { + return errors.New("db error") + }, + } + sender := &mockSender{} + ruleReader := &mockRuleReader{ + rule: domain.Rule{InputID: "beszel"}, + outputs: []domain.Output{{ID: "c1", Type: domain.OutputTypeWebhook}}, + } + registry := &mockRegistry{sender: sender} + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + worker := service.NewRelayWorker(queue, repo, ruleReader, registry, newExprRegistry()) + worker.Start(ctx, 1) + + time.Sleep(150 * time.Millisecond) + if sender.count.Load() == 0 { + t.Error("expected send to be called despite UpdateDeliveryState error") + } +} + +func TestRelayWorker_DeliverSuccess(t *testing.T) { + msg := domain.Message{ID: "w1", Input: domain.InputTypeBeszel, Payload: domain.RawPayload(`{"host":"server1"}`), Status: domain.MessageStatusPending, Version: 1} + queue := &mockMessageQueue{messages: []domain.Message{msg}} + repo := &mockRepo{saveFn: func(_ context.Context, _ domain.Message) error { return nil }} + sender := &mockSender{} + ruleReader := &mockRuleReader{ + rule: domain.Rule{InputID: "beszel"}, + outputs: []domain.Output{{ID: "c1", Type: domain.OutputTypeWebhook}}, + } + registry := &mockRegistry{sender: sender} + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + worker := service.NewRelayWorker(queue, repo, ruleReader, registry, newExprRegistry()) + worker.Start(ctx, 1) + + time.Sleep(150 * time.Millisecond) + if sender.count.Load() == 0 { + t.Error("expected at least one send call") + } +} + +type mockRuleReaderWithError struct{ err error } + +func (m *mockRuleReaderWithError) GetRule(_ context.Context, _ string) (domain.Rule, []domain.Output, error) { + return domain.Rule{}, nil, m.err +} + +func TestRelayWorker_NoRule_Nacks(t *testing.T) { + msg := domain.Message{ID: "no-rule", Input: domain.InputTypeBeszel, Payload: domain.RawPayload(`{}`), Status: domain.MessageStatusPending, Version: 1} + var nackCalled atomic.Bool + queue := &mockMessageQueueWithNack{msg: msg, nackFn: func() error { nackCalled.Store(true); return nil }} + repo := &mockRepo{saveFn: func(_ context.Context, _ domain.Message) error { return nil }} + registry := &mockRegistry{sender: &mockSender{}} + ruleReader := &mockRuleReaderWithError{err: errors.New("no rule")} + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + worker := service.NewRelayWorker(queue, repo, ruleReader, registry, newExprRegistry()) + worker.Start(ctx, 1) + time.Sleep(150 * time.Millisecond) + + if !nackCalled.Load() { + t.Error("expected nack to be called when no rule found") + } +} + +type mockMessageQueueWithNack struct { + msg domain.Message + nackFn func() error + called atomic.Bool +} + +func (m *mockMessageQueueWithNack) Enqueue(_ context.Context, _ domain.Message) error { return nil } +func (m *mockMessageQueueWithNack) Dequeue(_ context.Context) (domain.Message, output.AckFunc, output.NackFunc, error) { + if m.called.Swap(true) { + time.Sleep(10 * time.Millisecond) + return domain.Message{}, nil, nil, errors.New("empty") + } + return m.msg, func() error { return nil }, m.nackFn, nil +} + +type mockSenderError struct{} + +func (m *mockSenderError) Send(_ context.Context, _ domain.Output, _ []byte) error { + return errors.New("send failed: render error") +} + +func TestRelayWorker_SendError_MarksAsFailed(t *testing.T) { + msg := domain.Message{ID: "send-err", Input: domain.InputTypeBeszel, Payload: domain.RawPayload(`{}`), Status: domain.MessageStatusPending, Version: 1} + var nackCalled atomic.Bool + queue := &mockMessageQueueWithNack{msg: msg, nackFn: func() error { nackCalled.Store(true); return nil }} + + var mu sync.Mutex + var updatedStatus domain.MessageStatus + repo := &mockRepo{ + saveFn: func(_ context.Context, _ domain.Message) error { return nil }, + updateFn: func(_ context.Context, _ string, s domain.MessageStatus, _ int, _ time.Time) error { + mu.Lock() + updatedStatus = s + mu.Unlock() + return nil + }, + } + ruleReader := &mockRuleReader{ + rule: domain.Rule{InputID: "beszel"}, + outputs: []domain.Output{{ID: "c1", Type: domain.OutputTypeWebhook, RetryCount: 1, RetryDelayMs: 10}}, + } + registry := &mockRegistryFn{senderFn: func() output.OutputSender { return &mockSenderError{} }} + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + worker := service.NewRelayWorker(queue, repo, ruleReader, registry, newExprRegistry()) + worker.Start(ctx, 1) + time.Sleep(150 * time.Millisecond) + + if !nackCalled.Load() { + t.Error("expected nack when send fails") + } + mu.Lock() + status := updatedStatus + mu.Unlock() + if status != domain.MessageStatusFailed { + t.Errorf("status = %q, want FAILED", status) + } +} + +func TestRelayWorker_GracefulShutdown(t *testing.T) { + queue := &mockMessageQueue{} + repo := &mockRepo{saveFn: func(_ context.Context, _ domain.Message) error { return nil }} + ruleReader := &mockRuleReader{} + registry := &mockRegistry{sender: &mockSender{}} + + ctx, cancel := context.WithCancel(context.Background()) + worker := service.NewRelayWorker(queue, repo, ruleReader, registry, newExprRegistry()) + worker.Start(ctx, 2) + + cancel() + + done := make(chan struct{}) + go func() { + worker.Wait() + close(done) + }() + select { + case <-done: + // normal shutdown + case <-time.After(2 * time.Second): + t.Fatal("Wait() did not return after context cancellation") + } +} + +// --- New expression engine tests --- + +func TestRelayWorker_FilterTrue_Passes(t *testing.T) { + msg := domain.Message{ID: "f-true", Input: domain.InputTypeBeszel, Payload: domain.RawPayload(`{}`), Status: domain.MessageStatusPending, Version: 1} + queue := &mockMessageQueue{messages: []domain.Message{msg}} + repo := &mockRepo{saveFn: func(_ context.Context, _ domain.Message) error { return nil }} + sender := &mockSender{} + ruleReader := &mockRuleReader{ + rule: domain.Rule{InputID: "beszel", Filter: `data.input == "BESZEL"`}, + outputs: []domain.Output{{ID: "c1", Type: domain.OutputTypeWebhook}}, + } + registry := &mockRegistry{sender: sender} + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + worker := service.NewRelayWorker(queue, repo, ruleReader, registry, newExprRegistry()) + worker.Start(ctx, 1) + time.Sleep(150 * time.Millisecond) + + if sender.count.Load() == 0 { + t.Error("expected send when filter passes") + } +} + +func TestRelayWorker_FilterFalse_Skips(t *testing.T) { + msg := domain.Message{ID: "f-false", Input: domain.InputTypeBeszel, Payload: domain.RawPayload(`{}`), Status: domain.MessageStatusPending, Version: 1} + var ackCalled atomic.Bool + queue := &mockMessageQueueWithAckNack{ + msg: msg, + ackFn: func() error { ackCalled.Store(true); return nil }, + nackFn: func() error { return nil }, + } + repo := &mockRepo{saveFn: func(_ context.Context, _ domain.Message) error { return nil }} + sender := &mockSender{} + ruleReader := &mockRuleReader{ + rule: domain.Rule{InputID: "beszel", Filter: `data.input == "NONEXISTENT"`}, + outputs: []domain.Output{{ID: "c1", Type: domain.OutputTypeWebhook}}, + } + registry := &mockRegistry{sender: sender} + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + worker := service.NewRelayWorker(queue, repo, ruleReader, registry, newExprRegistry()) + worker.Start(ctx, 1) + time.Sleep(150 * time.Millisecond) + + if sender.count.Load() != 0 { + t.Error("expected no send when filter rejects") + } + if !ackCalled.Load() { + t.Error("expected ack when filter rejects (message processed)") + } +} + +type mockMessageQueueWithAckNack struct { + msg domain.Message + ackFn func() error + nackFn func() error + called atomic.Bool +} + +func (m *mockMessageQueueWithAckNack) Enqueue(_ context.Context, _ domain.Message) error { return nil } +func (m *mockMessageQueueWithAckNack) Dequeue(_ context.Context) (domain.Message, output.AckFunc, output.NackFunc, error) { + if m.called.Swap(true) { + time.Sleep(10 * time.Millisecond) + return domain.Message{}, nil, nil, errors.New("empty") + } + return m.msg, m.ackFn, m.nackFn, nil +} + +func TestRelayWorker_EmptyFilter_PassesAll(t *testing.T) { + msg := domain.Message{ID: "no-filter", Input: domain.InputTypeBeszel, Payload: domain.RawPayload(`{}`), Status: domain.MessageStatusPending, Version: 1} + queue := &mockMessageQueue{messages: []domain.Message{msg}} + repo := &mockRepo{saveFn: func(_ context.Context, _ domain.Message) error { return nil }} + sender := &mockSender{} + ruleReader := &mockRuleReader{ + rule: domain.Rule{InputID: "beszel", Filter: ""}, + outputs: []domain.Output{{ID: "c1", Type: domain.OutputTypeWebhook}}, + } + registry := &mockRegistry{sender: sender} + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + worker := service.NewRelayWorker(queue, repo, ruleReader, registry, newExprRegistry()) + worker.Start(ctx, 1) + time.Sleep(150 * time.Millisecond) + + if sender.count.Load() == 0 { + t.Error("expected send when no filter set") + } +} + +func TestRelayWorker_EmptyRouting_AllOutputs(t *testing.T) { + msg := domain.Message{ID: "all-out", Input: domain.InputTypeBeszel, Payload: domain.RawPayload(`{}`), Status: domain.MessageStatusPending, Version: 1} + queue := &mockMessageQueue{messages: []domain.Message{msg}} + repo := &mockRepo{saveFn: func(_ context.Context, _ domain.Message) error { return nil }} + sender := &mockSender{} + ruleReader := &mockRuleReader{ + rule: domain.Rule{InputID: "beszel"}, + outputs: []domain.Output{ + {ID: "c1", Type: domain.OutputTypeWebhook}, + {ID: "c2", Type: domain.OutputTypeWebhook}, + }, + } + registry := &mockRegistry{sender: sender} + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + worker := service.NewRelayWorker(queue, repo, ruleReader, registry, newExprRegistry()) + worker.Start(ctx, 1) + time.Sleep(150 * time.Millisecond) + + if sender.count.Load() != 2 { + t.Errorf("expected 2 sends (all outputs), got %d", sender.count.Load()) + } +} + +func TestRelayWorker_Routing_MatchesCorrectOutputs(t *testing.T) { + msg := domain.Message{ID: "route-msg", Input: domain.InputTypeBeszel, Payload: domain.RawPayload(`{}`), Status: domain.MessageStatusPending, Version: 1} + queue := &mockMessageQueue{messages: []domain.Message{msg}} + repo := &mockRepo{saveFn: func(_ context.Context, _ domain.Message) error { return nil }} + sender := &mockSender{} + ruleReader := &mockRuleReader{ + rule: domain.Rule{ + InputID: "beszel", + Routing: []domain.RouteCondition{ + {Condition: `data.input == "BESZEL"`, OutputIDs: []string{"c1"}}, + {Condition: `data.input == "DOZZLE"`, OutputIDs: []string{"c2"}}, + }, + }, + outputs: []domain.Output{ + {ID: "c1", Type: domain.OutputTypeWebhook}, + {ID: "c2", Type: domain.OutputTypeWebhook}, + }, + } + registry := &mockRegistry{sender: sender} + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + worker := service.NewRelayWorker(queue, repo, ruleReader, registry, newExprRegistry()) + worker.Start(ctx, 1) + time.Sleep(150 * time.Millisecond) + + if sender.count.Load() != 1 { + t.Errorf("expected 1 send (only c1 matched), got %d", sender.count.Load()) + } +} + +func TestRelayWorker_TemplateExpressions_BuildPayload(t *testing.T) { + msg := domain.Message{ + ID: "tmpl-msg", Input: domain.InputTypeBeszel, + Payload: domain.RawPayload(`{"host":"server1"}`), + Status: domain.MessageStatusPending, Version: 1, + } + queue := &mockMessageQueue{messages: []domain.Message{msg}} + repo := &mockRepo{saveFn: func(_ context.Context, _ domain.Message) error { return nil }} + sender := &mockSender{} + ruleReader := &mockRuleReader{ + rule: domain.Rule{InputID: "beszel"}, + outputs: []domain.Output{{ + ID: "c1", Type: domain.OutputTypeWebhook, + Template: map[string]string{ + "src": `data.input`, + "msg": `"alert from " + data.input`, + }, + }}, + } + registry := &mockRegistry{sender: sender} + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + worker := service.NewRelayWorker(queue, repo, ruleReader, registry, newExprRegistry()) + worker.Start(ctx, 1) + time.Sleep(150 * time.Millisecond) + + if sender.count.Load() == 0 { + t.Fatal("expected send call") + } + + sender.mu.Lock() + payload := sender.payloads[0] + sender.mu.Unlock() + + var result map[string]any + if err := json.Unmarshal(payload, &result); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if result["src"] != "BESZEL" { + t.Errorf("src = %v, want BESZEL", result["src"]) + } + if result["msg"] != "alert from BESZEL" { + t.Errorf("msg = %v, want 'alert from BESZEL'", result["msg"]) + } +} + +func TestRelayWorker_NoTemplate_UsesRawPayload(t *testing.T) { + msg := domain.Message{ + ID: "raw-msg", Input: domain.InputTypeBeszel, + Payload: domain.RawPayload(`{"host":"server1"}`), + Status: domain.MessageStatusPending, Version: 1, + } + queue := &mockMessageQueue{messages: []domain.Message{msg}} + repo := &mockRepo{saveFn: func(_ context.Context, _ domain.Message) error { return nil }} + sender := &mockSender{} + ruleReader := &mockRuleReader{ + rule: domain.Rule{InputID: "beszel"}, + outputs: []domain.Output{{ID: "c1", Type: domain.OutputTypeWebhook}}, + } + registry := &mockRegistry{sender: sender} + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + worker := service.NewRelayWorker(queue, repo, ruleReader, registry, newExprRegistry()) + worker.Start(ctx, 1) + time.Sleep(150 * time.Millisecond) + + if sender.count.Load() == 0 { + t.Fatal("expected send call") + } + + sender.mu.Lock() + payload := sender.payloads[0] + sender.mu.Unlock() + + if string(payload) != `{"host":"server1"}` { + t.Errorf("payload = %q, want raw payload", payload) + } +} + +func TestRelayWorker_Mapping_EnrichesData(t *testing.T) { + msg := domain.Message{ + ID: "map-msg", Input: domain.InputTypeBeszel, + Payload: domain.RawPayload(`{}`), + Status: domain.MessageStatusPending, Version: 1, + } + queue := &mockMessageQueue{messages: []domain.Message{msg}} + repo := &mockRepo{saveFn: func(_ context.Context, _ domain.Message) error { return nil }} + sender := &mockSender{} + ruleReader := &mockRuleReader{ + rule: domain.Rule{ + InputID: "beszel", + Mapping: map[string]string{ + "upperInput": `"[" + data.input + "]"`, + }, + }, + outputs: []domain.Output{{ + ID: "c1", Type: domain.OutputTypeWebhook, + Template: map[string]string{ + "tag": `data.upperInput`, + }, + }}, + } + registry := &mockRegistry{sender: sender} + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + worker := service.NewRelayWorker(queue, repo, ruleReader, registry, newExprRegistry()) + worker.Start(ctx, 1) + time.Sleep(150 * time.Millisecond) + + if sender.count.Load() == 0 { + t.Fatal("expected send call") + } + + sender.mu.Lock() + payload := sender.payloads[0] + sender.mu.Unlock() + + var result map[string]any + if err := json.Unmarshal(payload, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if result["tag"] != "[BESZEL]" { + t.Errorf("tag = %v, want [BESZEL]", result["tag"]) + } +} diff --git a/internal/config/config.example.yaml b/internal/config/config.example.yaml index 9f21f52..e243caa 100644 --- a/internal/config/config.example.yaml +++ b/internal/config/config.example.yaml @@ -11,33 +11,50 @@ log: level: info # debug, info, warn, error format: json # json, text -sources: +expression: + defaultEngine: cel # cel or expr + +inputs: - id: beszel type: BESZEL + parser: json # json, form, xml, logfmt, regex secret: "change-me" - id: dozzle type: DOZZLE + parser: json secret: "change-me" + - id: tcp-input + type: GENERIC + address: ":9001" + delimiter: "\n" + parser: json + secret: "" # secret unused for TCP inputs (no token auth) -channels: +outputs: - id: ops-webhook type: WEBHOOK url: "https://hooks.example.com/xyz" - template: | - {"text": "{{ .Source }}: {{ .Payload }}"} + template: + text: 'data.input + ": " + data.payload' retryCount: 3 retryDelayMs: 1000 skipTLSVerify: false -routes: - - sourceId: beszel - channelIds: [ops-webhook] - - sourceId: dozzle - channelIds: [ops-webhook] +rules: + - inputId: beszel + engine: cel # override default engine per rule + filter: 'data.input == "BESZEL"' + mapping: + severity: '"HIGH"' + routing: + - condition: 'data.severity == "HIGH"' + outputIds: [ops-webhook] + - inputId: dozzle + outputIds: [ops-webhook] # simple: no filter/routing, send to all storage: type: SQLITE - path: "./data/webhook-relay.db" + path: "./data/relaybox.db" queue: type: FILE diff --git a/internal/config/config.go b/internal/config/config.go index ca83ef6..0324dce 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,10 +3,10 @@ package config import ( "fmt" "log/slog" + "net" "github.com/fsnotify/fsnotify" "github.com/spf13/viper" - "webhook-relay/internal/domain" ) type ServerConfig struct { @@ -27,26 +27,40 @@ type LogConfig struct { Format string `mapstructure:"format"` } -type SourceConfig struct { - ID string `mapstructure:"id"` - Type string `mapstructure:"type"` - Secret string `mapstructure:"secret"` +type InputConfig struct { + ID string `mapstructure:"id"` + Type string `mapstructure:"type"` + Parser string `mapstructure:"parser"` + Secret string `mapstructure:"secret"` + Pattern string `mapstructure:"pattern"` // for regex parser + Address string `mapstructure:"address"` // TCP: bind address, e.g. ":9000" + Delimiter string `mapstructure:"delimiter"` // TCP: message delimiter, default "\n" } -type ChannelConfig struct { - ID string `mapstructure:"id"` - Type string `mapstructure:"type"` - URL string `mapstructure:"url"` - Template string `mapstructure:"template"` - RetryCount int `mapstructure:"retryCount"` - RetryDelayMs int `mapstructure:"retryDelayMs"` - TimeoutSec int `mapstructure:"timeoutSec"` - SkipTLSVerify bool `mapstructure:"skipTLSVerify"` +type OutputConfig struct { + ID string `mapstructure:"id"` + Type string `mapstructure:"type"` + URL string `mapstructure:"url"` + Template map[string]string `mapstructure:"template"` + Secret string `mapstructure:"secret"` + RetryCount int `mapstructure:"retryCount"` + RetryDelayMs int `mapstructure:"retryDelayMs"` + TimeoutSec int `mapstructure:"timeoutSec"` + SkipTLSVerify bool `mapstructure:"skipTLSVerify"` } -type RouteConfig struct { - SourceID string `mapstructure:"sourceId"` - ChannelIDs []string `mapstructure:"channelIds"` +type RouteConditionConfig struct { + Condition string `mapstructure:"condition"` + OutputIDs []string `mapstructure:"outputIds"` +} + +type RuleConfig struct { + InputID string `mapstructure:"inputId"` + OutputIDs []string `mapstructure:"outputIds"` + Engine string `mapstructure:"engine"` + Filter string `mapstructure:"filter"` + Mapping map[string]string `mapstructure:"mapping"` + Routing []RouteConditionConfig `mapstructure:"routing"` } type StorageConfig struct { @@ -60,17 +74,22 @@ type QueueConfig struct { WorkerCount int `mapstructure:"workerCount"` } +type ExpressionConfig struct { + DefaultEngine string `mapstructure:"defaultEngine"` // "cel" or "expr" +} + type Config struct { - Server ServerConfig `mapstructure:"server"` - Log LogConfig `mapstructure:"log"` - Sources []SourceConfig `mapstructure:"sources"` - Channels []ChannelConfig `mapstructure:"channels"` - Routes []RouteConfig `mapstructure:"routes"` - Storage StorageConfig `mapstructure:"storage"` - Queue QueueConfig `mapstructure:"queue"` + Server ServerConfig `mapstructure:"server"` + Log LogConfig `mapstructure:"log"` + Inputs []InputConfig `mapstructure:"inputs"` + Outputs []OutputConfig `mapstructure:"outputs"` + Rules []RuleConfig `mapstructure:"rules"` + Storage StorageConfig `mapstructure:"storage"` + Queue QueueConfig `mapstructure:"queue"` + Expression ExpressionConfig `mapstructure:"expression"` } -// Load 설정 파일을 읽어 Config를 반환한다. 템플릿 검증 포함. +// Load reads and validates the config file. func Load(path string) (*Config, error) { v := viper.New() v.SetConfigFile(path) @@ -81,7 +100,7 @@ func Load(path string) (*Config, error) { return unmarshalAndValidate(v) } -// Watch 설정 파일 변경 감지. channels/routes만 핫리로드. 유효하지 않으면 기존 유지. +// Watch detects config file changes. Only outputs/rules are hot-reloaded. func Watch(v *viper.Viper, onChange func(cfg *Config)) { v.WatchConfig() v.OnConfigChange(func(_ fsnotify.Event) { @@ -94,7 +113,7 @@ func Watch(v *viper.Viper, onChange func(cfg *Config)) { }) } -// NewViper path에서 viper 인스턴스를 반환한다 (Watch용). +// NewViper returns a viper instance for Watch. func NewViper(path string) (*viper.Viper, error) { v := viper.New() v.SetConfigFile(path) @@ -122,44 +141,55 @@ func unmarshalAndValidate(v *viper.Viper) (*Config, error) { if err := validateIDs(&cfg); err != nil { return nil, err } - for _, ch := range cfg.Channels { - if ch.Template == "" { - continue - } - if err := domain.ValidateTemplate(ch.Template); err != nil { - return nil, fmt.Errorf("channel %q: %w", ch.ID, err) - } - } return &cfg, nil } func validateIDs(cfg *Config) error { - seenSources := make(map[string]struct{}, len(cfg.Sources)) - for _, s := range cfg.Sources { + seenInputs := make(map[string]struct{}, len(cfg.Inputs)) + for _, s := range cfg.Inputs { if s.ID == "" { - return fmt.Errorf("source ID must not be empty") + return fmt.Errorf("input ID must not be empty") } - if _, dup := seenSources[s.ID]; dup { - return fmt.Errorf("duplicate source ID %q", s.ID) + if _, dup := seenInputs[s.ID]; dup { + return fmt.Errorf("duplicate input ID %q", s.ID) } - seenSources[s.ID] = struct{}{} + seenInputs[s.ID] = struct{}{} } - seenChannels := make(map[string]struct{}, len(cfg.Channels)) - for _, c := range cfg.Channels { + seenOutputs := make(map[string]struct{}, len(cfg.Outputs)) + for _, c := range cfg.Outputs { if c.ID == "" { - return fmt.Errorf("channel ID must not be empty") + return fmt.Errorf("output ID must not be empty") } - if _, dup := seenChannels[c.ID]; dup { - return fmt.Errorf("duplicate channel ID %q", c.ID) + if _, dup := seenOutputs[c.ID]; dup { + return fmt.Errorf("duplicate output ID %q", c.ID) } - seenChannels[c.ID] = struct{}{} + seenOutputs[c.ID] = struct{}{} } - for _, rt := range cfg.Routes { - for _, chID := range rt.ChannelIDs { - if _, ok := seenChannels[chID]; !ok { - return fmt.Errorf("route for source %q references unknown channel %q", rt.SourceID, chID) + for _, inp := range cfg.Inputs { + if inp.Address != "" { + if _, err := net.ResolveTCPAddr("tcp", inp.Address); err != nil { + return fmt.Errorf("input %q: invalid TCP address %q: %w", inp.ID, inp.Address, err) + } + } + if inp.Address != "" && inp.Delimiter != "" && len(inp.Delimiter) > 1 { + return fmt.Errorf("input %q: delimiter must be a single character, got %q", inp.ID, inp.Delimiter) + } + } + + for _, rt := range cfg.Rules { + for _, outID := range rt.OutputIDs { + if _, ok := seenOutputs[outID]; !ok { + return fmt.Errorf("rule for input %q references unknown output %q", rt.InputID, outID) + } + } + // Validate routing condition output IDs + for _, rc := range rt.Routing { + for _, outID := range rc.OutputIDs { + if _, ok := seenOutputs[outID]; !ok { + return fmt.Errorf("rule for input %q routing condition references unknown output %q", rt.InputID, outID) + } } } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7d4eb96..7b32af1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -5,7 +5,7 @@ import ( "path/filepath" "testing" - "webhook-relay/internal/config" + "relaybox/internal/config" ) const testYAML = ` @@ -14,20 +14,21 @@ server: log: level: debug format: text -sources: +inputs: - id: beszel type: BESZEL secret: test-secret -channels: +outputs: - id: ops-webhook type: WEBHOOK url: https://hooks.example.com/test - template: '{"text":"{{ .Source }}"}' + template: + text: 'data.input + ": " + data.payload' retryCount: 3 retryDelayMs: 500 -routes: - - sourceId: beszel - channelIds: +rules: + - inputId: beszel + outputIds: - ops-webhook storage: type: SQLITE @@ -56,46 +57,33 @@ func TestLoad(t *testing.T) { if cfg.Server.Port != 9090 { t.Errorf("port = %d, want 9090", cfg.Server.Port) } - if len(cfg.Sources) != 1 || cfg.Sources[0].ID != "beszel" { - t.Errorf("sources = %+v", cfg.Sources) + if len(cfg.Inputs) != 1 || cfg.Inputs[0].ID != "beszel" { + t.Errorf("inputs = %+v", cfg.Inputs) } - if len(cfg.Routes) != 1 { - t.Errorf("routes = %+v", cfg.Routes) + if len(cfg.Rules) != 1 { + t.Errorf("rules = %+v", cfg.Rules) } -} - -func TestLoad_InvalidTemplate(t *testing.T) { - yaml := ` -server: - port: 8080 -channels: - - id: bad - type: WEBHOOK - url: https://example.com - template: '{{ .Source' -` - _, err := config.Load(writeConfig(t, yaml)) - if err == nil { - t.Fatal("expected error for invalid template") + if cfg.Outputs[0].Template["text"] != `data.input + ": " + data.payload` { + t.Errorf("template = %+v", cfg.Outputs[0].Template) } } -func TestLoad_EmptySourceID(t *testing.T) { +func TestLoad_EmptyInputID(t *testing.T) { yaml := ` -sources: +inputs: - id: "" type: BESZEL secret: s ` _, err := config.Load(writeConfig(t, yaml)) if err == nil { - t.Fatal("expected error for empty source ID") + t.Fatal("expected error for empty input ID") } } -func TestLoad_DuplicateSourceID(t *testing.T) { +func TestLoad_DuplicateInputID(t *testing.T) { yaml := ` -sources: +inputs: - id: beszel type: BESZEL secret: s1 @@ -105,41 +93,107 @@ sources: ` _, err := config.Load(writeConfig(t, yaml)) if err == nil { - t.Fatal("expected error for duplicate source ID") + t.Fatal("expected error for duplicate input ID") } } -func TestLoad_RouteReferencesUnknownChannel(t *testing.T) { +func TestLoad_RuleReferencesUnknownOutput(t *testing.T) { yaml := ` -sources: +inputs: - id: beszel type: BESZEL secret: s -channels: +outputs: - id: ch1 type: WEBHOOK url: https://example.com - template: '{}' -routes: - - sourceId: beszel - channelIds: - - nonexistent-channel +rules: + - inputId: beszel + outputIds: + - nonexistent-output ` _, err := config.Load(writeConfig(t, yaml)) if err == nil { - t.Fatal("expected error for route referencing unknown channel") + t.Fatal("expected error for rule referencing unknown output") } } -func TestInMemoryRouteConfigReader(t *testing.T) { +func TestInMemoryRuleConfigReader(t *testing.T) { cfg, _ := config.Load(writeConfig(t, testYAML)) - reader := config.NewInMemoryRouteConfigReader(cfg) + reader := config.NewInMemoryRuleConfigReader(cfg) + + rule, outputs, err := reader.GetRule(nil, "BESZEL") + if err != nil { + t.Fatalf("GetRule error: %v", err) + } + if len(outputs) != 1 { + t.Errorf("got %d outputs, want 1", len(outputs)) + } + if rule.InputID != "beszel" { + t.Errorf("rule.InputID = %q, want beszel", rule.InputID) + } +} + +func TestLoad_WithExpressionConfig(t *testing.T) { + yaml := ` +expression: + defaultEngine: expr +inputs: + - id: beszel + type: BESZEL + secret: s +outputs: + - id: ch1 + type: WEBHOOK + url: https://example.com +rules: + - inputId: beszel + engine: expr + filter: 'data.status == "CRITICAL"' + mapping: + severity: '"HIGH"' + routing: + - condition: 'data.severity == "HIGH"' + outputIds: [ch1] +storage: + type: SQLITE + path: ./data/test.db +queue: + type: FILE + path: ./data/queue +` + cfg, err := config.Load(writeConfig(t, yaml)) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Expression.DefaultEngine != "expr" { + t.Errorf("defaultEngine = %q, want expr", cfg.Expression.DefaultEngine) + } + if cfg.Rules[0].Engine != "expr" { + t.Errorf("rule engine = %q, want expr", cfg.Rules[0].Engine) + } + if cfg.Rules[0].Filter != `data.status == "CRITICAL"` { + t.Errorf("filter = %q", cfg.Rules[0].Filter) + } + if len(cfg.Rules[0].Routing) != 1 { + t.Errorf("routing len = %d, want 1", len(cfg.Rules[0].Routing)) + } - channels, err := reader.GetChannels(nil, "BESZEL") // query by source type, not source ID + reader := config.NewInMemoryRuleConfigReader(cfg) + rule, outputs, err := reader.GetRule(nil, "BESZEL") if err != nil { - t.Fatalf("GetChannels error: %v", err) + t.Fatalf("GetRule error: %v", err) + } + if rule.Engine != "expr" { + t.Errorf("rule.Engine = %q, want expr", rule.Engine) + } + if rule.Filter != `data.status == "CRITICAL"` { + t.Errorf("rule.Filter = %q", rule.Filter) + } + if len(rule.Routing) != 1 { + t.Errorf("rule.Routing len = %d", len(rule.Routing)) } - if len(channels) != 1 { - t.Errorf("got %d channels, want 1", len(channels)) + if len(outputs) != 1 { + t.Errorf("outputs len = %d, want 1", len(outputs)) } } diff --git a/internal/config/route_config_reader.go b/internal/config/route_config_reader.go deleted file mode 100644 index 44b4f10..0000000 --- a/internal/config/route_config_reader.go +++ /dev/null @@ -1,67 +0,0 @@ -package config - -import ( - "context" - "fmt" - "sync" - - "webhook-relay/internal/domain" -) - -type InMemoryRouteConfigReader struct { - mu sync.RWMutex - channels map[string]domain.Channel - routes map[string][]string -} - -func NewInMemoryRouteConfigReader(cfg *Config) *InMemoryRouteConfigReader { - r := &InMemoryRouteConfigReader{} - r.Update(cfg) - return r -} - -func (r *InMemoryRouteConfigReader) Update(cfg *Config) { - channels := make(map[string]domain.Channel, len(cfg.Channels)) - for _, c := range cfg.Channels { - channels[c.ID] = domain.Channel{ - ID: c.ID, Type: domain.ChannelType(c.Type), URL: c.URL, - Template: c.Template, RetryCount: c.RetryCount, - RetryDelayMs: c.RetryDelayMs, TimeoutSec: c.TimeoutSec, - SkipTLSVerify: c.SkipTLSVerify, - } - } - // sourceID → sourceType mapping (e.g. "beszel" → "BESZEL") - sourceTypeByID := make(map[string]string, len(cfg.Sources)) - for _, s := range cfg.Sources { - sourceTypeByID[s.ID] = s.Type - } - // Routes keyed by source type so delivery worker can query with alert.Source - routes := make(map[string][]string, len(cfg.Routes)) - for _, rt := range cfg.Routes { - key := sourceTypeByID[rt.SourceID] - if key == "" { - key = rt.SourceID // fallback - } - routes[key] = rt.ChannelIDs - } - r.mu.Lock() - r.channels = channels - r.routes = routes - r.mu.Unlock() -} - -func (r *InMemoryRouteConfigReader) GetChannels(_ context.Context, sourceID string) ([]domain.Channel, error) { - r.mu.RLock() - defer r.mu.RUnlock() - ids, ok := r.routes[sourceID] - if !ok { - return nil, fmt.Errorf("route for %q: %w", sourceID, domain.ErrSourceNotFound) - } - result := make([]domain.Channel, 0, len(ids)) - for _, id := range ids { - if ch, ok := r.channels[id]; ok { - result = append(result, ch) - } - } - return result, nil -} diff --git a/internal/config/rule_config_reader.go b/internal/config/rule_config_reader.go new file mode 100644 index 0000000..18b9835 --- /dev/null +++ b/internal/config/rule_config_reader.go @@ -0,0 +1,104 @@ +package config + +import ( + "context" + "fmt" + "sync" + + "relaybox/internal/domain" +) + +type ruleEntry struct { + rule domain.Rule + outputs []domain.Output +} + +// InMemoryRuleConfigReader implements output.RuleConfigReader. +type InMemoryRuleConfigReader struct { + mu sync.RWMutex + rules map[string]ruleEntry // keyed by input type (e.g. "BESZEL") +} + +func NewInMemoryRuleConfigReader(cfg *Config) *InMemoryRuleConfigReader { + r := &InMemoryRuleConfigReader{} + r.Update(cfg) + return r +} + +func (r *InMemoryRuleConfigReader) Update(cfg *Config) { + outputsByID := make(map[string]domain.Output, len(cfg.Outputs)) + for _, c := range cfg.Outputs { + outputsByID[c.ID] = domain.Output{ + ID: c.ID, Type: domain.OutputType(c.Type), URL: c.URL, + Template: c.Template, Secret: c.Secret, + RetryCount: c.RetryCount, RetryDelayMs: c.RetryDelayMs, + TimeoutSec: c.TimeoutSec, SkipTLSVerify: c.SkipTLSVerify, + } + } + + // inputID -> inputType mapping (e.g. "beszel" -> "BESZEL") + inputTypeByID := make(map[string]string, len(cfg.Inputs)) + for _, s := range cfg.Inputs { + inputTypeByID[s.ID] = s.Type + } + + rules := make(map[string]ruleEntry, len(cfg.Rules)) + for _, rt := range cfg.Rules { + key := inputTypeByID[rt.InputID] + if key == "" { + key = rt.InputID // fallback + } + + // Build routing conditions + var routing []domain.RouteCondition + for _, rc := range rt.Routing { + routing = append(routing, domain.RouteCondition{ + Condition: rc.Condition, + OutputIDs: rc.OutputIDs, + }) + } + + rule := domain.Rule{ + InputID: rt.InputID, + Engine: rt.Engine, + Filter: rt.Filter, + Mapping: rt.Mapping, + Routing: routing, + } + + // Collect outputs from outputIds (backward compat) and routing + outputIDSet := make(map[string]struct{}) + for _, id := range rt.OutputIDs { + outputIDSet[id] = struct{}{} + } + for _, rc := range rt.Routing { + for _, id := range rc.OutputIDs { + outputIDSet[id] = struct{}{} + } + } + + var outputs []domain.Output + for id := range outputIDSet { + if out, ok := outputsByID[id]; ok { + outputs = append(outputs, out) + } + } + + rules[key] = ruleEntry{rule: rule, outputs: outputs} + } + + r.mu.Lock() + r.rules = rules + r.mu.Unlock() +} + +// GetRule returns the rule and associated outputs for a given input type. +func (r *InMemoryRuleConfigReader) GetRule(_ context.Context, inputType string) (domain.Rule, []domain.Output, error) { + r.mu.RLock() + defer r.mu.RUnlock() + entry, ok := r.rules[inputType] + if !ok { + return domain.Rule{}, nil, fmt.Errorf("rule for %q: %w", inputType, domain.ErrInputNotFound) + } + return entry.rule, entry.outputs, nil +} diff --git a/internal/domain/alert.go b/internal/domain/alert.go deleted file mode 100644 index 0300d3c..0000000 --- a/internal/domain/alert.go +++ /dev/null @@ -1,32 +0,0 @@ -package domain - -import ( - "time" -) - -type RawPayload []byte - -func (r RawPayload) MarshalJSON() ([]byte, error) { - if r == nil { - return []byte("null"), nil - } - return r, nil -} - -func (r *RawPayload) UnmarshalJSON(b []byte) error { - *r = make(RawPayload, len(b)) - copy(*r, b) - return nil -} - -type Alert struct { - ID string `json:"id"` - Version int `json:"version"` - Source SourceType `json:"source"` - Payload RawPayload `json:"payload"` - CreatedAt time.Time `json:"createdAt"` - Status AlertStatus `json:"status"` - RetryCount int `json:"retryCount"` - LastAttemptAt *time.Time `json:"lastAttemptAt,omitempty"` -} - diff --git a/internal/domain/alert_status.go b/internal/domain/alert_status.go deleted file mode 100644 index 9eb6b33..0000000 --- a/internal/domain/alert_status.go +++ /dev/null @@ -1,27 +0,0 @@ -package domain - -type AlertStatus string - -const ( - AlertStatusPending AlertStatus = "PENDING" - AlertStatusDelivered AlertStatus = "DELIVERED" - AlertStatusFailed AlertStatus = "FAILED" -) - -func (s AlertStatus) IsValid() bool { - switch s { - case AlertStatusPending, AlertStatusDelivered, AlertStatusFailed: - return true - } - return false -} - -func (s AlertStatus) CanTransitionTo(next AlertStatus) bool { - switch s { - case AlertStatusPending: - return next == AlertStatusDelivered || next == AlertStatusFailed - case AlertStatusFailed: - return next == AlertStatusPending - } - return false -} diff --git a/internal/domain/channel_type.go b/internal/domain/channel_type.go deleted file mode 100644 index 2f13c1e..0000000 --- a/internal/domain/channel_type.go +++ /dev/null @@ -1,17 +0,0 @@ -package domain - -type ChannelType string - -const ( - ChannelTypeWebhook ChannelType = "WEBHOOK" - ChannelTypeSlack ChannelType = "SLACK" - ChannelTypeDiscord ChannelType = "DISCORD" -) - -func (c ChannelType) IsValid() bool { - switch c { - case ChannelTypeWebhook, ChannelTypeSlack, ChannelTypeDiscord: - return true - } - return false -} diff --git a/internal/domain/errors.go b/internal/domain/errors.go index ee1cbc4..2890cfe 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -3,9 +3,9 @@ package domain import "errors" var ( - ErrSourceNotFound = errors.New("source not found") - ErrInvalidToken = errors.New("invalid token") - ErrAlertNotFound = errors.New("alert not found") - ErrInvalidTransition = errors.New("invalid status transition") - ErrSenderNotFound = errors.New("sender not registered for channel type") + ErrInputNotFound = errors.New("input not found") + ErrInvalidToken = errors.New("invalid token") + ErrMessageNotFound = errors.New("message not found") + ErrInvalidTransition = errors.New("invalid status transition") + ErrOutputSenderNotFound = errors.New("sender not registered for output type") ) diff --git a/internal/domain/input_type.go b/internal/domain/input_type.go new file mode 100644 index 0000000..f9a26a5 --- /dev/null +++ b/internal/domain/input_type.go @@ -0,0 +1,17 @@ +package domain + +type InputType string + +const ( + InputTypeBeszel InputType = "BESZEL" + InputTypeDozzle InputType = "DOZZLE" + InputTypeGeneric InputType = "GENERIC" +) + +func (s InputType) IsValid() bool { + switch s { + case InputTypeBeszel, InputTypeDozzle, InputTypeGeneric: + return true + } + return false +} diff --git a/internal/domain/message.go b/internal/domain/message.go new file mode 100644 index 0000000..bd63ff2 --- /dev/null +++ b/internal/domain/message.go @@ -0,0 +1,35 @@ +package domain + +import ( + "time" +) + +type RawPayload []byte + +func (r RawPayload) MarshalJSON() ([]byte, error) { + if r == nil { + return []byte("null"), nil + } + return r, nil +} + +func (r *RawPayload) UnmarshalJSON(b []byte) error { + *r = make(RawPayload, len(b)) + copy(*r, b) + return nil +} + +type Message struct { + ID string `json:"id"` + Version int `json:"version"` + Input InputType `json:"input"` + Payload RawPayload `json:"payload"` + // ParsedData is populated at parse time from the raw Payload. + // It is intentionally not persisted to SQLite — only stored in the file queue via JSON. + // Available to the relay worker for expression evaluation. + ParsedData map[string]any `json:"parsedData,omitempty"` + CreatedAt time.Time `json:"createdAt"` + Status MessageStatus `json:"status"` + RetryCount int `json:"retryCount"` + LastAttemptAt *time.Time `json:"lastAttemptAt,omitempty"` +} diff --git a/internal/domain/message_status.go b/internal/domain/message_status.go new file mode 100644 index 0000000..5681512 --- /dev/null +++ b/internal/domain/message_status.go @@ -0,0 +1,27 @@ +package domain + +type MessageStatus string + +const ( + MessageStatusPending MessageStatus = "PENDING" + MessageStatusDelivered MessageStatus = "DELIVERED" + MessageStatusFailed MessageStatus = "FAILED" +) + +func (s MessageStatus) IsValid() bool { + switch s { + case MessageStatusPending, MessageStatusDelivered, MessageStatusFailed: + return true + } + return false +} + +func (s MessageStatus) CanTransitionTo(next MessageStatus) bool { + switch s { + case MessageStatusPending: + return next == MessageStatusDelivered || next == MessageStatusFailed + case MessageStatusFailed: + return next == MessageStatusPending + } + return false +} diff --git a/internal/domain/alert_test.go b/internal/domain/message_test.go similarity index 61% rename from internal/domain/alert_test.go rename to internal/domain/message_test.go index 59eb175..3577e87 100644 --- a/internal/domain/alert_test.go +++ b/internal/domain/message_test.go @@ -5,20 +5,20 @@ import ( "testing" "time" - "webhook-relay/internal/domain" + "relaybox/internal/domain" ) -func TestAlertStatus_IsValid(t *testing.T) { +func TestMessageStatus_IsValid(t *testing.T) { tests := []struct { name string - input domain.AlertStatus + input domain.MessageStatus want bool }{ - {"pending", domain.AlertStatusPending, true}, - {"delivered", domain.AlertStatusDelivered, true}, - {"failed", domain.AlertStatusFailed, true}, - {"unknown", domain.AlertStatus("UNKNOWN"), false}, - {"empty", domain.AlertStatus(""), false}, + {"pending", domain.MessageStatusPending, true}, + {"delivered", domain.MessageStatusDelivered, true}, + {"failed", domain.MessageStatusFailed, true}, + {"unknown", domain.MessageStatus("UNKNOWN"), false}, + {"empty", domain.MessageStatus(""), false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -29,16 +29,16 @@ func TestAlertStatus_IsValid(t *testing.T) { } } -func TestAlertStatus_CanTransitionTo(t *testing.T) { +func TestMessageStatus_CanTransitionTo(t *testing.T) { tests := []struct { - from domain.AlertStatus - to domain.AlertStatus + from domain.MessageStatus + to domain.MessageStatus want bool }{ - {domain.AlertStatusPending, domain.AlertStatusDelivered, true}, - {domain.AlertStatusPending, domain.AlertStatusFailed, true}, - {domain.AlertStatusFailed, domain.AlertStatusPending, true}, - {domain.AlertStatusDelivered, domain.AlertStatusPending, false}, + {domain.MessageStatusPending, domain.MessageStatusDelivered, true}, + {domain.MessageStatusPending, domain.MessageStatusFailed, true}, + {domain.MessageStatusFailed, domain.MessageStatusPending, true}, + {domain.MessageStatusDelivered, domain.MessageStatusPending, false}, } for _, tt := range tests { t.Run(string(tt.from)+"->"+string(tt.to), func(t *testing.T) { @@ -64,21 +64,21 @@ func TestRawPayload_MarshalJSON(t *testing.T) { } } -func TestAlert_JSON_RoundTrip(t *testing.T) { +func TestMessage_JSON_RoundTrip(t *testing.T) { now := time.Now().UTC().Truncate(time.Second) - a := domain.Alert{ + a := domain.Message{ ID: "01J...", Version: 1, - Source: domain.SourceTypeBeszel, + Input: domain.InputTypeBeszel, Payload: domain.RawPayload(`{"host":"server1"}`), CreatedAt: now, - Status: domain.AlertStatusPending, + Status: domain.MessageStatusPending, } b, err := json.Marshal(a) if err != nil { t.Fatalf("marshal: %v", err) } - var got domain.Alert + var got domain.Message if err := json.Unmarshal(b, &got); err != nil { t.Fatalf("unmarshal: %v", err) } diff --git a/internal/domain/channel.go b/internal/domain/output.go similarity index 59% rename from internal/domain/channel.go rename to internal/domain/output.go index 6706110..69d87fb 100644 --- a/internal/domain/channel.go +++ b/internal/domain/output.go @@ -1,10 +1,10 @@ package domain -type Channel struct { +type Output struct { ID string - Type ChannelType + Type OutputType URL string - Template string + Template map[string]string // key -> CEL/Expr expression Secret string RetryCount int RetryDelayMs int diff --git a/internal/domain/output_type.go b/internal/domain/output_type.go new file mode 100644 index 0000000..f2c9d69 --- /dev/null +++ b/internal/domain/output_type.go @@ -0,0 +1,17 @@ +package domain + +type OutputType string + +const ( + OutputTypeWebhook OutputType = "WEBHOOK" + OutputTypeSlack OutputType = "SLACK" + OutputTypeDiscord OutputType = "DISCORD" +) + +func (c OutputType) IsValid() bool { + switch c { + case OutputTypeWebhook, OutputTypeSlack, OutputTypeDiscord: + return true + } + return false +} diff --git a/internal/domain/route.go b/internal/domain/route.go deleted file mode 100644 index 5cf5ccd..0000000 --- a/internal/domain/route.go +++ /dev/null @@ -1,6 +0,0 @@ -package domain - -type Route struct { - SourceID string - ChannelIDs []string -} diff --git a/internal/domain/rule.go b/internal/domain/rule.go new file mode 100644 index 0000000..efdb369 --- /dev/null +++ b/internal/domain/rule.go @@ -0,0 +1,16 @@ +package domain + +// RouteCondition maps a boolean expression to a set of output IDs. +type RouteCondition struct { + Condition string + OutputIDs []string +} + +// Rule describes how to process messages from a given input. +type Rule struct { + InputID string + Engine string // "cel" or "expr"; empty means use default + Filter string // expression; empty means pass all + Mapping map[string]string // key -> expression + Routing []RouteCondition +} diff --git a/internal/domain/source_type.go b/internal/domain/source_type.go deleted file mode 100644 index 3df780e..0000000 --- a/internal/domain/source_type.go +++ /dev/null @@ -1,17 +0,0 @@ -package domain - -type SourceType string - -const ( - SourceTypeBeszel SourceType = "BESZEL" - SourceTypeDozzle SourceType = "DOZZLE" - SourceTypeGeneric SourceType = "GENERIC" -) - -func (s SourceType) IsValid() bool { - switch s { - case SourceTypeBeszel, SourceTypeDozzle, SourceTypeGeneric: - return true - } - return false -} diff --git a/internal/domain/template.go b/internal/domain/template.go deleted file mode 100644 index f2bcfd8..0000000 --- a/internal/domain/template.go +++ /dev/null @@ -1,40 +0,0 @@ -package domain - -import ( - "bytes" - "fmt" - "text/template" - "time" -) - -type TemplateData struct { - ID string - Source string - Payload string - CreatedAt time.Time -} - -func RenderTemplate(tmpl string, alert Alert) ([]byte, error) { - t, err := template.New("").Parse(tmpl) - if err != nil { - return nil, fmt.Errorf("parse template: %w", err) - } - data := TemplateData{ - ID: alert.ID, - Source: string(alert.Source), - Payload: string(alert.Payload), - CreatedAt: alert.CreatedAt, - } - var buf bytes.Buffer - if err := t.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("execute template: %w", err) - } - return buf.Bytes(), nil -} - -func ValidateTemplate(tmpl string) error { - if _, err := template.New("").Parse(tmpl); err != nil { - return fmt.Errorf("invalid template: %w", err) - } - return nil -} diff --git a/internal/domain/template_test.go b/internal/domain/template_test.go deleted file mode 100644 index f81b1d0..0000000 --- a/internal/domain/template_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package domain_test - -import ( - "testing" - "time" - - "webhook-relay/internal/domain" -) - -func TestRenderTemplate(t *testing.T) { - alert := domain.Alert{ - ID: "abc123", - Source: domain.SourceTypeBeszel, - Payload: domain.RawPayload(`{"host":"server1"}`), - CreatedAt: time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC), - Status: domain.AlertStatusPending, - } - - tests := []struct { - name string - tmpl string - want string - wantErr bool - }{ - { - name: "source and id", - tmpl: `{"text":"{{ .Source }}: {{ .ID }}"}`, - want: `{"text":"BESZEL: abc123"}`, - }, - { - name: "invalid syntax", - tmpl: `{{ .Source`, - wantErr: true, - }, - { - name: "payload field", - tmpl: `{{ .Payload }}`, - want: `{"host":"server1"}`, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := domain.RenderTemplate(tt.tmpl, alert) - if (err != nil) != tt.wantErr { - t.Fatalf("error = %v, wantErr %v", err, tt.wantErr) - } - if !tt.wantErr && string(got) != tt.want { - t.Errorf("got %q, want %q", got, tt.want) - } - }) - } -} - -func TestValidateTemplate(t *testing.T) { - if err := domain.ValidateTemplate(`{{ .Source }}`); err != nil { - t.Errorf("valid template failed: %v", err) - } - if err := domain.ValidateTemplate(`{{ .Source`); err == nil { - t.Error("invalid template should return error") - } -} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index c8825fc..09da35c 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -3,7 +3,6 @@ package e2e_test import ( "context" "encoding/json" - "fmt" "io" "net/http" "net/http/httptest" @@ -12,36 +11,48 @@ import ( "testing" "time" - httpadapter "webhook-relay/internal/adapter/input/http" - "webhook-relay/internal/adapter/output/filequeue" - sqliteadapter "webhook-relay/internal/adapter/output/sqlite" - webhookadapter "webhook-relay/internal/adapter/output/webhook" - "webhook-relay/internal/application/port/output" - "webhook-relay/internal/application/service" - cfgpkg "webhook-relay/internal/config" - "webhook-relay/internal/domain" + httpadapter "relaybox/internal/adapter/input/http" + "relaybox/internal/adapter/output/expression" + "relaybox/internal/adapter/output/filequeue" + sqliteadapter "relaybox/internal/adapter/output/sqlite" + webhookadapter "relaybox/internal/adapter/output/webhook" + "relaybox/internal/application/port/output" + "relaybox/internal/application/service" + cfgpkg "relaybox/internal/config" + "relaybox/internal/domain" ) -// configSourceResolver는 cmd/server/main.go와 동일한 로직을 E2E에서 재구현 (DI 검증용) -type configSourceResolver struct { - sources map[string]domain.SourceType +// configInputResolver replicates cmd/server/main.go logic for E2E DI +type configInputResolver struct { + inputs map[string]domain.InputType secrets map[string]string } -func (r *configSourceResolver) Resolve(id string) (domain.SourceType, error) { - st, ok := r.sources[id] +func (r *configInputResolver) Resolve(id string) (domain.InputType, error) { + st, ok := r.inputs[id] if !ok { - return "", domain.ErrSourceNotFound + return "", domain.ErrInputNotFound } return st, nil } -func (r *configSourceResolver) ValidateToken(id, token string) bool { +func (r *configInputResolver) ValidateToken(id, token string) bool { return r.secrets[id] == token } -func TestE2E_PostAlert_Returns201(t *testing.T) { - // 아웃바운드 웹훅 수신 서버 +func newExprRegistry() output.ExpressionEngineRegistry { + reg := expression.NewInMemoryExpressionEngineRegistry() + celEng, err := expression.NewCELEngine() + if err != nil { + panic("NewCELEngine: " + err.Error()) + } + reg.Register(celEng) + reg.Register(expression.NewExprEngine()) + return reg +} + +func TestE2E_PostMessage_Returns201(t *testing.T) { + // Outbound webhook receiver var mu sync.Mutex var deliveredPayload []byte targetSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -54,28 +65,34 @@ func TestE2E_PostAlert_Returns201(t *testing.T) { defer targetSrv.Close() cfg := &cfgpkg.Config{ - Sources: []cfgpkg.SourceConfig{{ID: "beszel", Type: "BESZEL", Secret: "tok"}}, - Channels: []cfgpkg.ChannelConfig{{ID: "ch1", Type: "WEBHOOK", URL: targetSrv.URL, Template: `{"src":"{{ .Source }}"}`, RetryCount: 1, RetryDelayMs: 10}}, - Routes: []cfgpkg.RouteConfig{{SourceID: "beszel", ChannelIDs: []string{"ch1"}}}, - Queue: cfgpkg.QueueConfig{WorkerCount: 1}, + Inputs: []cfgpkg.InputConfig{{ID: "beszel", Type: "BESZEL", Secret: "tok"}}, + Outputs: []cfgpkg.OutputConfig{{ + ID: "ch1", Type: "WEBHOOK", URL: targetSrv.URL, + Template: map[string]string{ + "src": `data.input`, + }, + RetryCount: 1, RetryDelayMs: 10, + }}, + Rules: []cfgpkg.RuleConfig{{InputID: "beszel", OutputIDs: []string{"ch1"}}}, + Queue: cfgpkg.QueueConfig{WorkerCount: 1}, } repo, _ := sqliteadapter.New(":memory:") defer repo.Close() queue, _ := filequeue.New(t.TempDir()) sender := webhookadapter.NewSender() - registry := webhookadapter.NewRegistry(map[domain.ChannelType]output.AlertSender{ - domain.ChannelTypeWebhook: sender, + registry := webhookadapter.NewRegistry(map[domain.OutputType]output.OutputSender{ + domain.OutputTypeWebhook: sender, }) - routeReader := cfgpkg.NewInMemoryRouteConfigReader(cfg) - alertSvc := service.NewAlertService(repo, queue) - worker := service.NewDeliveryWorker(queue, repo, routeReader, registry) + ruleReader := cfgpkg.NewInMemoryRuleConfigReader(cfg) + msgSvc := service.NewMessageService(repo, queue, nil, nil) + worker := service.NewRelayWorker(queue, repo, ruleReader, registry, newExprRegistry()) - resolver := &configSourceResolver{ - sources: map[string]domain.SourceType{"beszel": domain.SourceTypeBeszel}, + resolver := &configInputResolver{ + inputs: map[string]domain.InputType{"beszel": domain.InputTypeBeszel}, secrets: map[string]string{"beszel": "tok"}, } - router := httpadapter.NewRouter(alertSvc, resolver, nil) + router := httpadapter.NewRouter(msgSvc, resolver, nil) srv := httptest.NewServer(router) defer srv.Close() @@ -83,8 +100,8 @@ func TestE2E_PostAlert_Returns201(t *testing.T) { defer cancel() worker.Start(ctx, 1) - // POST 알람 - req, _ := http.NewRequest(http.MethodPost, srv.URL+"/sources/beszel/alerts", + // POST message + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/inputs/beszel/messages", strings.NewReader(`{"host":"server1"}`)) req.Header.Set("Authorization", "Bearer tok") req.Header.Set("Content-Type", "application/json") @@ -108,7 +125,7 @@ func TestE2E_PostAlert_Returns201(t *testing.T) { t.Errorf("body status = %v, want PENDING", body["status"]) } - // DeliveryWorker가 전달 완료할 때까지 대기 + // Wait for relay worker to deliver deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { mu.Lock() @@ -126,11 +143,15 @@ func TestE2E_PostAlert_Returns201(t *testing.T) { mu.Unlock() if len(got) == 0 { - t.Error("delivery worker did not deliver the alert") + t.Error("relay worker did not deliver the message") + } + // Template is {"src": input} which evaluates to {"src":"BESZEL"} + var result map[string]any + if err := json.Unmarshal(got, &result); err != nil { + t.Fatalf("unmarshal delivered payload: %v", err) } - want := fmt.Sprintf(`{"src":"%s"}`, string(domain.SourceTypeBeszel)) - if string(got) != want { - t.Errorf("delivered payload = %q, want %q", got, want) + if result["src"] != string(domain.InputTypeBeszel) { + t.Errorf("delivered src = %v, want %s", result["src"], domain.InputTypeBeszel) } } @@ -138,9 +159,9 @@ func TestE2E_Healthz(t *testing.T) { repo, _ := sqliteadapter.New(":memory:") defer repo.Close() queue, _ := filequeue.New(t.TempDir()) - alertSvc := service.NewAlertService(repo, queue) - resolver := &configSourceResolver{} - router := httpadapter.NewRouter(alertSvc, resolver, nil) + msgSvc := service.NewMessageService(repo, queue, nil, nil) + resolver := &configInputResolver{} + router := httpadapter.NewRouter(msgSvc, resolver, nil) srv := httptest.NewServer(router) defer srv.Close()