From 005871155c8a4ef828b6296b3e197d4ae2038c18 Mon Sep 17 00:00:00 2001 From: ppzxc Date: Sun, 22 Mar 2026 21:00:19 +0900 Subject: [PATCH 1/2] =?UTF-8?q?docs:=20text/template=20=EC=9E=94=EC=A1=B4?= =?UTF-8?q?=20=EC=B0=B8=EC=A1=B0=EB=A5=BC=20CEL/Expr=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20README=20=ED=95=9C=EA=B8=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 초기 설계의 Go text/template 참조가 문서에 남아 있었으나 실제 구현은 CEL/Expr 표현식 엔진을 사용한다. 문서 정합성을 맞추고 README를 한글로 재작성한다. --- .claude/CLAUDE.md | 2 +- README.md | 114 ++++++------- docs/superpowers/plans/2026-03-20-api-doc.md | 2 +- .../plans/2026-03-20-webhook-relay.md | 151 ++---------------- .../specs/2026-03-20-webhook-relay-design.md | 26 ++- internal/apidocs/asyncapi.yaml | 2 +- 6 files changed, 82 insertions(+), 215 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 5385993..468d5df 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -47,7 +47,7 @@ cmd/server/main.go ← DI 조립, cobra CLI | 경로 | 역할 | |------|------| -| `internal/domain/` | 엔티티(`Message`, `Output`), 열거형(`InputType`, `MessageStatus`, `OutputType`), 센티넬 에러, 템플릿 렌더링 | +| `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) | diff --git a/README.md b/README.md index 8222889..48cf064 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,49 @@ # relaybox -Generic relay hub: receives any inbound protocol/format and delivers to outbound channels via CEL/Expr expression filter, transform, and route rules. +범용 릴레이 허브: 어떤 인바운드 프로토콜/포맷도 수신하고, CEL/Expr 표현식 기반 필터·변환·라우팅 규칙을 통해 아웃바운드 채널로 전달한다. ``` -any inbound (HTTP REST / WebSocket / TCP / ...) +어떤 인바운드 (HTTP REST / WebSocket / TCP / ...) ↓ - parser pipeline (JSON / Form / XML / Logfmt / Regex) + 파서 파이프라인 (JSON / Form / XML / Logfmt / Regex) ↓ - CEL / Expr expression filter + transform + route + CEL / Expr 표현식 필터 + 변환 + 라우팅 ↓ -any outbound (Webhook / Slack / Discord / ...) +어떤 아웃바운드 (Webhook / Slack / Discord / ...) ``` -## Features +## 주요 기능 -- **Multi-protocol inbound** — HTTP REST + WebSocket + TCP -- **Parser pipeline** — JSON, Form, XML, Logfmt, Regex per input -- **Expression-based routing** — CEL/Expr filter, mapping, and routing conditions per rule -- **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 +- **멀티 프로토콜 인바운드** — HTTP REST + WebSocket + TCP +- **파서 파이프라인** — 입력별로 JSON, Form, XML, Logfmt, Regex 지원 +- **표현식 기반 라우팅** — 규칙별 CEL/Expr 필터, 매핑, 라우팅 조건 +- **at-least-once 전달** — 파일 큐 기반, 재시작 시에도 메시지 보존 +- **지수 백오프 재시도** — 채널별 `retryCount` / `retryDelayMs` 설정 +- **설정 핫리로드** — 재시작 없이 아웃풋 / 규칙 변경 가능 +- **Bearer 토큰 인증** — 입력별 독립 시크릿 -## Quick Start +## 빠른 시작 -### Prerequisites +### 사전 요구 사항 - Go 1.25+ -- GCC (required for go-sqlite3 CGO build) +- GCC (go-sqlite3 CGO 빌드에 필요) ```bash -# Build +# 빌드 CGO_ENABLED=1 go build -o relaybox ./cmd/server/ -# Prepare config -cp internal/config/config.example.yaml config.yaml -# Edit config.yaml, then: +# 설정 준비 +cp docs/config.example.yaml config.yaml +# config.yaml 수정 후: -# Start server +# 서버 시작 ./relaybox start --config config.yaml ``` -## Configuration +## 설정 -`config.yaml` example: +`config.yaml` 예시: ```yaml server: @@ -68,7 +68,7 @@ inputs: address: ":9001" delimiter: "\n" parser: json - secret: "" # secret unused for TCP inputs + secret: "" # TCP 입력은 시크릿 미사용 outputs: - id: ops-webhook @@ -81,7 +81,7 @@ outputs: rules: - inputId: beszel - engine: cel # override default engine per rule + engine: cel # 규칙별 엔진 오버라이드 filter: 'data.input == "BESZEL"' mapping: severity: '"HIGH"' @@ -89,7 +89,7 @@ rules: - condition: 'data.severity == "HIGH"' outputIds: [ops-webhook] - inputId: tcp-input - outputIds: [ops-webhook] # simple: no filter/routing, send to all + outputIds: [ops-webhook] # 단순: 필터/라우팅 없이 전체 전송 storage: type: SQLITE @@ -101,38 +101,38 @@ queue: workerCount: 2 ``` -### Expression Variables +### 표현식 변수 -All expressions (filter, mapping, routing, template) share the same `data` context: +모든 표현식(필터, 매핑, 라우팅, 템플릿)은 동일한 `data` 컨텍스트를 공유한다: -| Variable | Description | -|----------|-------------| -| `data.id` | Message ULID | -| `data.input` | Input type (`BESZEL`, `DOZZLE`, `GENERIC`, etc.) | -| `data.payload` | Raw payload string | -| `data.createdAt` | Receive timestamp (RFC3339) | -| `data.` | Any field added via `mapping` expressions | +| 변수 | 설명 | +|------|------| +| `data.id` | 메시지 ULID | +| `data.input` | 입력 타입 (`BESZEL`, `DOZZLE`, `GENERIC` 등) | +| `data.payload` | 원본 페이로드 문자열 | +| `data.createdAt` | 수신 타임스탬프 (RFC3339) | +| `data.` | `mapping` 표현식으로 추가된 필드 | -**Filter** — boolean expression; message is dropped if `false`: +**필터** — 불리언 표현식; `false`이면 메시지 드롭: ```yaml filter: 'data.input == "BESZEL"' ``` -**Mapping** — enrich `data` with computed fields: +**매핑** — 계산된 필드로 `data` 보강: ```yaml mapping: severity: '"HIGH"' label: 'data.input + "-alert"' ``` -**Routing** — conditional output selection (evaluated after mapping): +**라우팅** — 조건부 아웃풋 선택 (매핑 이후 평가): ```yaml routing: - condition: 'data.severity == "HIGH"' outputIds: [ops-webhook] ``` -**Template** — map of output fields rendered as expressions: +**템플릿** — 아웃풋 필드를 표현식으로 렌더링: ```yaml template: text: 'data.input + ": " + data.payload' @@ -140,7 +140,7 @@ template: ## API -### Receive Message +### 메시지 수신 ``` POST /inputs/{inputId}/messages @@ -150,58 +150,58 @@ Content-Type: application/json {"host": "server1", "status": "down"} ``` -Response `201 Created`: +응답 `201 Created`: ```json {"id": "01J...", "status": "PENDING"} ``` -### WebSocket Inbound +### WebSocket 인바운드 ``` GET /inputs/{inputId}/messages/ws Authorization: Bearer ``` -JSON messages sent after connect are processed identically to HTTP POST. +연결 후 JSON 메시지를 전송하면 HTTP POST와 동일하게 처리된다. -### TCP Inbound +### TCP 인바운드 -Connect to the configured `address` and send newline-delimited (or custom `delimiter`) messages. No token auth — secure via network policy. +설정한 `address`로 연결 후 개행(또는 커스텀 `delimiter`) 구분 메시지를 전송한다. 토큰 인증 없음 — 네트워크 정책으로 보안 적용. -### Health Check +### 헬스 체크 ``` GET /healthz → 200 OK ``` -All HTTP responses include an `X-API-Version` header. +모든 HTTP 응답에는 `X-API-Version` 헤더가 포함된다. -## Architecture +## 아키텍처 -Hexagonal architecture (Ports & Adapters). Dependencies always flow inward toward domain. +헥사고날 아키텍처(Ports & Adapters). 의존성 방향은 항상 도메인을 향해 안쪽으로만 흐른다. ``` domain (0 deps) ↑ -application/port/{input,output} ← interface definitions +application/port/{input,output} ← 인터페이스 정의 ↑ -application/service ← business logic +application/service ← 비즈니스 로직 ↑ -adapter/{input,output} ← external world connections +adapter/{input,output} ← 외부 세계와 연결 ↑ -cmd/server/main.go ← DI wiring, cobra CLI +cmd/server/main.go ← DI 조립, cobra CLI ``` -## Development +## 개발 ```bash -# Full test suite (race detector) +# 전체 테스트 (race detector 포함) go test -race ./... -timeout 60s -# Static analysis +# 정적 분석 go vet ./... -# Regenerate sqlc (after SQL changes) +# sqlc 코드 재생성 (SQL 변경 후) cd internal/adapter/output/sqlite && sqlc generate ``` diff --git a/docs/superpowers/plans/2026-03-20-api-doc.md b/docs/superpowers/plans/2026-03-20-api-doc.md index 3fc5fe2..4394a84 100644 --- a/docs/superpowers/plans/2026-03-20-api-doc.md +++ b/docs/superpowers/plans/2026-03-20-api-doc.md @@ -769,7 +769,7 @@ components: description: | Source-specific JSON payload. Structure varies by source type. The relay stores and forwards this payload as-is, applying the - channel's Go text/template for transformation on delivery. + channel's CEL/Expr expression template for transformation on delivery. additionalProperties: true examples: - summary: Beszel alert example diff --git a/docs/superpowers/plans/2026-03-20-webhook-relay.md b/docs/superpowers/plans/2026-03-20-webhook-relay.md index 3ae9419..0b00fc2 100644 --- a/docs/superpowers/plans/2026-03-20-webhook-relay.md +++ b/docs/superpowers/plans/2026-03-20-webhook-relay.md @@ -478,148 +478,21 @@ git commit -m "feat(domain): add entities, enums, and sentinel errors" --- -## Task 3: 도메인 — 템플릿 렌더링 +## Task 3: 페이로드 렌더링 — CEL/Expr 표현식 템플릿 -**Files:** -- Create: `internal/domain/template.go` -- Test: `internal/domain/template_test.go` - -- [ ] **Step 1: 테스트 작성** - -`internal/domain/template_test.go`: - -```go -package domain_test - -import ( - "testing" - "time" +> **Note:** 초기 설계에서 `text/template` 기반 도메인 헬퍼를 사용하는 방식을 검토했으나, +> 최종 구현에서는 CEL/Expr 표현식 엔진으로 전환되었다. `domain/template.go` 파일은 생성되지 않았으며, +> 페이로드 렌더링은 `RelayWorker.buildPayload()`에서 처리한다. - "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, - } +**현재 구현:** +- `Output.Template`: `map[string]string` — 각 value가 CEL/Expr 표현식 +- `RelayWorker.buildPayload(engine, template, data)`: 각 표현식 평가 후 JSON 마샬링 +- 템플릿이 비어 있으면 `data["payload"]` 원문을 그대로 전달 - 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") - } -} -``` - -- [ ] **Step 2: 테스트 실패 확인** - -```bash -go test ./internal/domain/... -run TestRenderTemplate -``` - -Expected: FAIL - -- [ ] **Step 3: 구현** - -`internal/domain/template.go`: - -```go -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 -} -``` - -- [ ] **Step 4: 전체 도메인 테스트 통과 확인** - -```bash -go test ./internal/domain/... -v -``` - -Expected: PASS - -- [ ] **Step 5: 커밋** - -```bash -git add internal/domain/template.go internal/domain/template_test.go -git commit -m "feat(domain): add template rendering helper" +```yaml +# 설정 예시 +template: + text: 'data.input + ": " + data.payload' ``` --- diff --git a/docs/superpowers/specs/2026-03-20-webhook-relay-design.md b/docs/superpowers/specs/2026-03-20-webhook-relay-design.md index 498ba0c..6265f38 100644 --- a/docs/superpowers/specs/2026-03-20-webhook-relay-design.md +++ b/docs/superpowers/specs/2026-03-20-webhook-relay-design.md @@ -36,7 +36,6 @@ internal/ │ ├── channel_type.go │ ├── route.go │ ├── source_type.go -│ ├── template.go # 템플릿 렌더링 도메인 헬퍼 │ └── errors.go │ ├── application/ # 헥사곤 핵심 @@ -141,7 +140,7 @@ type Channel struct { ID string Type ChannelType URL string - Template string // Go text/template 원문 문자열 (YAML에서 로드) + Template map[string]string // key -> CEL/Expr 표현식 (YAML에서 로드) Secret string RetryCount int // 기본값: 3 RetryDelayMs int // 기본값: 1000 @@ -157,24 +156,19 @@ type Route struct { } ``` -### 템플릿 렌더링 (도메인 헬퍼) +### 템플릿 렌더링 (RelayWorker) -`domain/template.go`에 `RenderTemplate(tmpl string, alert Alert) ([]byte, error)` 구현. -- `text/template`은 표준 라이브러리이므로 도메인에서 직접 사용 허용 -- 템플릿 데이터 모델 (`.` 값): +`Output.Template`은 `map[string]string`으로 각 value는 CEL/Expr 표현식이다. +`RelayWorker.buildPayload(engine, template, data)`에서 각 표현식을 평가해 JSON 페이로드를 생성한다. +- 템플릿이 비어 있으면 `data["payload"]` 원문을 그대로 전달 +- `data` 컨텍스트: `data.id`, `data.input`, `data.payload`, `data.createdAt`, mapping으로 추가된 필드 -```go -type TemplateData struct { - Source string // alert.Source의 string 값 - Payload string // alert.Payload의 JSON 문자열 - ID string - CreatedAt time.Time -} +예시: +```yaml +template: + text: 'data.input + ": " + data.payload' ``` -- 설정 로드 시 (`config.go`) 템플릿 문자열 파싱 검증 — 유효하지 않으면 로드 실패 -- 핫리로드 시에도 동일하게 파싱 검증 후 유효한 경우에만 반영 - --- ## 4. 포트 인터페이스 diff --git a/internal/apidocs/asyncapi.yaml b/internal/apidocs/asyncapi.yaml index 98d3adb..52cf943 100644 --- a/internal/apidocs/asyncapi.yaml +++ b/internal/apidocs/asyncapi.yaml @@ -56,7 +56,7 @@ components: description: | Input-specific JSON payload. Structure varies by input type. The relay stores and forwards this payload as-is, applying the - output's Go text/template for transformation on delivery. + output's CEL/Expr expression template for transformation on delivery. additionalProperties: true examples: - summary: Beszel message example From 3d0f7f26681cebcb3d1905588baa73b4849f59f6 Mon Sep 17 00:00:00 2001 From: ppzxc Date: Sun, 22 Mar 2026 21:00:25 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20config.example.yaml=EC=9D=84=20doc?= =?UTF-8?q?s/=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20=EB=B0=8F=20.gitignore=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=EB=AA=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config.example.yaml: internal/config/ → docs/ (접근성 개선) - .gitignore: webhook-relay → relaybox (리네이밍 반영) --- .gitignore | 2 +- {internal/config => docs}/config.example.yaml | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename {internal/config => docs}/config.example.yaml (100%) diff --git a/.gitignore b/.gitignore index d2b922d..c6f5ab8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Binaries -webhook-relay +relaybox /server *.exe *.out diff --git a/internal/config/config.example.yaml b/docs/config.example.yaml similarity index 100% rename from internal/config/config.example.yaml rename to docs/config.example.yaml