├── .github └── workflows │ ├── go-test.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── README.md ├── cmd └── hookah │ └── main.go ├── deploy ├── config.json ├── docker-compose.yaml └── templates │ ├── discord.tmpl │ └── discord_simple.tmpl ├── go.mod └── internal ├── auth ├── auth.go └── auth_test.go ├── condition ├── evaluator.go ├── evaluator_test.go └── funcs.go ├── config ├── config.go └── config_test.go ├── flow ├── flow.go └── flow_test.go ├── render ├── funcs.go ├── render.go └── render_test.go ├── resolver ├── benchmark_test.go ├── resolver.go └── resolver_test.go ├── server ├── routes.go ├── routes_test.go ├── server.go └── util.go └── types ├── event.go └── types.go /.github/workflows/go-test.yml: -------------------------------------------------------------------------------- 1 | name: Go-test 2 | on: [ push, pull_request ] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Setup Go 11 | uses: actions/setup-go@v5 12 | with: 13 | go-version: '1.24.x' 14 | 15 | - name: Build 16 | run: go build -v ./... 17 | - name: Test with the Go CLI 18 | run: go test ./... 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: '1.24.x' 20 | 21 | - name: Run GoReleaser 22 | uses: goreleaser/goreleaser-action@v6 23 | with: 24 | distribution: goreleaser 25 | version: ${{ env.GITHUB_REF_NAME }} 26 | args: release --clean 27 | workdir: ./ 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with "go test -c" 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Go workspace file 18 | go.work 19 | tmp/ 20 | 21 | # IDE specific files 22 | .vscode 23 | .idea 24 | 25 | # .env file 26 | .env 27 | 28 | # Project build 29 | main 30 | *templ.go 31 | 32 | # OS X generated file 33 | .DS_Store 34 | 35 | .gen -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | before: 3 | hooks: 4 | - go mod tidy 5 | 6 | env: 7 | - PACKAGE_PATH=github.com/AdamShannag/hookah/cmd 8 | 9 | builds: 10 | - binary: "{{ .ProjectName }}" 11 | main: ./cmd/hookah 12 | goos: 13 | - darwin 14 | - linux 15 | - windows 16 | goarch: 17 | - amd64 18 | - arm64 19 | env: 20 | - CGO_ENABLED=0 21 | ldflags: 22 | - -s -w -X {{.Env.PACKAGE_PATH}}={{.Version}} 23 | release: 24 | prerelease: auto 25 | 26 | universal_binaries: 27 | - replace: true 28 | 29 | archives: 30 | - name_template: > 31 | {{- .ProjectName }}_{{- .Version }}_{{- title .Os }}_{{- if eq .Arch "amd64" }}x86_64{{- else if eq .Arch "386" }}i386{{- else }}{{ .Arch }}{{ end }}{{- if .Arm }}v{{ .Arm }}{{ end -}} 32 | format_overrides: 33 | - goos: windows 34 | format: zip 35 | builds_info: 36 | group: root 37 | owner: root 38 | files: 39 | - README.md 40 | 41 | checksum: 42 | name_template: 'checksums.txt' 43 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine AS build 2 | 3 | WORKDIR /app 4 | COPY . . 5 | 6 | RUN go mod tidy 7 | RUN go build -o hookah cmd/hookah/main.go 8 | 9 | FROM alpine:3.21.3 AS prod 10 | WORKDIR /app 11 | 12 | ENV PORT=3000 13 | ENV CONFIG_PATH=/etc/hookah/config.json 14 | ENV TEMPLATES_PATH=/etc/hookah/templates 15 | 16 | COPY --from=build /app/hookah /app/hookah 17 | 18 | EXPOSE ${PORT} 19 | 20 | CMD ["./hookah"] 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Adam Shannag 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hookah - literally passes the hook around 2 | 3 | **Hookah** is a lightweight, stateless, and zero-dependency webhook router built in Go. It serves as an intermediary 4 | between webhook sources (like GitLab, GitHub, etc.) and target destinations (such as Discord), forwarding events only 5 | when they match predefined conditions. 6 | 7 | Features 8 | ------ 9 | 10 | - **Webhook Receiver:** Accepts incoming webhooks from various platforms. 11 | - **Rule Engine:** Applies filters based on request headers/url query params and body content. 12 | - **Conditional Forwarding:** Sends a message to a target webhook only if the rules match. 13 | - **Reusable Templates:** Define multiple templates and reuse them across different configurations and webhook 14 | scenarios. 15 | - **Template Support:** Allows dynamic message generation using data from the incoming webhook payload. 16 | - **Lightweight & Extensible:** Simple design with future support for multiple rules, formats, and targets. 17 | 18 | Docs and Support 19 | ----- 20 | The documentation for using hookah is available [here](https://adamshannag.github.io/hookah-docs/) 21 | 22 | Why “Hookah”? 23 | ------ 24 | 25 | Much like a real hookah, this tool filters input before releasing output—except in this case, it's webhooks instead of 26 | smoke. 27 | 28 | 29 | Contributing 30 | ------ 31 | 32 | Contributions are welcome! If you’d like to help improve **Hookah**, feel free to submit an issue or open a pull 33 | request. 34 | 35 | License 36 | ------ 37 | **Hookah** is released under the [MIT License](LICENSE). 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /cmd/hookah/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "github.com/AdamShannag/hookah/internal/auth" 9 | "github.com/AdamShannag/hookah/internal/condition" 10 | "github.com/AdamShannag/hookah/internal/config" 11 | "github.com/AdamShannag/hookah/internal/resolver" 12 | "github.com/AdamShannag/hookah/internal/server" 13 | "github.com/AdamShannag/hookah/internal/types" 14 | "log" 15 | "net/http" 16 | "os" 17 | "os/signal" 18 | "path/filepath" 19 | "syscall" 20 | "time" 21 | ) 22 | 23 | func main() { 24 | templateConfigs, err := parseConfigFile(os.Getenv("CONFIG_PATH")) 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | 29 | templates, err := parseTemplates(os.Getenv("TEMPLATES_PATH")) 30 | if err != nil { 31 | log.Fatal(err) 32 | } 33 | 34 | conf := config.New(templateConfigs, templates, auth.NewDefault()) 35 | 36 | srv := server.NewServer(conf, condition.NewDefaultEvaluator(resolver.NewPathResolver())) 37 | done := make(chan bool, 1) 38 | go gracefulShutdown(srv, done) 39 | 40 | err = srv.ListenAndServe() 41 | if err != nil && !errors.Is(err, http.ErrServerClosed) { 42 | panic(fmt.Sprintf("http server error: %s", err)) 43 | } 44 | 45 | <-done 46 | log.Println("Graceful shutdown complete.") 47 | } 48 | 49 | func gracefulShutdown(apiServer *http.Server, done chan bool) { 50 | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 51 | defer stop() 52 | 53 | <-ctx.Done() 54 | 55 | log.Println("shutting down gracefully, press Ctrl+C again to force") 56 | 57 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 58 | defer cancel() 59 | if err := apiServer.Shutdown(ctx); err != nil { 60 | log.Printf("Server forced to shutdown with error: %v", err) 61 | } 62 | 63 | log.Println("Server exiting") 64 | 65 | done <- true 66 | } 67 | 68 | func parseConfigFile(filePath string) ([]types.Template, error) { 69 | data, err := os.ReadFile(filePath) 70 | if err != nil { 71 | return nil, fmt.Errorf("failed to read file: %w", err) 72 | } 73 | 74 | var result []types.Template 75 | if err = json.Unmarshal(data, &result); err != nil { 76 | return nil, fmt.Errorf("failed to unmarshal JSON: %w", err) 77 | } 78 | 79 | return result, nil 80 | } 81 | 82 | func parseTemplates(dirPath string) (map[string]string, error) { 83 | dir, err := os.ReadDir(dirPath) 84 | if err != nil { 85 | return nil, fmt.Errorf("failed to read templates directory: %w", err) 86 | } 87 | 88 | templates := make(map[string]string) 89 | for _, file := range dir { 90 | bytes, readErr := os.ReadFile(filepath.Join(dirPath, file.Name())) 91 | if readErr != nil { 92 | return nil, fmt.Errorf("failed to read file: %w", readErr) 93 | } 94 | templates[file.Name()] = string(bytes) 95 | } 96 | 97 | return templates, nil 98 | } 99 | -------------------------------------------------------------------------------- /deploy/config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "receiver": "gitlab", 4 | "auth": { 5 | "flow": "none" 6 | }, 7 | "event_type_in": "body", 8 | "event_type_key": "event_type", 9 | "events": [ 10 | { 11 | "event": "merge_request", 12 | "conditions": [ 13 | "{Header.x-gitlab-label} {in} {Body.object_attributes.labels[].title}" 14 | ], 15 | "hooks": [ 16 | { 17 | "name": "discord", 18 | "endpoint_key": "discord-url", 19 | "body": "discord.tmpl" 20 | }, 21 | { 22 | "name": "discord_simple", 23 | "endpoint_key": "discord-url", 24 | "body": "discord_simple.tmpl" 25 | } 26 | ] 27 | } 28 | ] 29 | } 30 | ] 31 | -------------------------------------------------------------------------------- /deploy/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | hookah: 3 | build: 4 | context: .. 5 | dockerfile: Dockerfile 6 | ports: 7 | - "3000:3000" 8 | environment: 9 | PORT: 3000 10 | CONFIG_PATH: /etc/hookah/config.json 11 | TEMPLATES_PATH: /etc/hookah/templates 12 | volumes: 13 | - ./config.json:/etc/hookah/config.json:ro 14 | - ./templates:/etc/hookah/templates:ro 15 | restart: unless-stopped 16 | -------------------------------------------------------------------------------- /deploy/templates/discord.tmpl: -------------------------------------------------------------------------------- 1 | { 2 | "username": "{{title .user.username }}", 3 | "avatar_url": "{{ .user.avatar_url }}", 4 | "content": "today is {{format now "2006-01-02"}}", 5 | "embeds": [ 6 | { 7 | "author": { 8 | "name": "{{upper .user.username }}", 9 | "icon_url": "{{ .user.avatar_url }}" 10 | }, 11 | "title": "{{ .object_attributes.title }}", 12 | "description": "{{ .user.name }} {{pastTense .object_attributes.action }} a merge request in [{{ .project.path_with_namespace }}]({{ .project.web_url }})", 13 | "color": 15258703, 14 | "footer": { 15 | "text": "{{format (parseTime .object_attributes.updated_at "2006-01-02T15:04:05Z07:00") "2006-01-02"}}" 16 | } 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /deploy/templates/discord_simple.tmpl: -------------------------------------------------------------------------------- 1 | { 2 | "username": "{{title .user.username }}", 3 | "avatar_url": "{{ .user.avatar_url }}", 4 | "content": "{{lower "SIMPLE CONTENT"}}" 5 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/AdamShannag/hookah 2 | 3 | go 1.24.2 4 | -------------------------------------------------------------------------------- /internal/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "github.com/AdamShannag/hookah/internal/flow" 5 | "github.com/AdamShannag/hookah/internal/types" 6 | "net/http" 7 | ) 8 | 9 | type Auth interface { 10 | RegisterFlow(flow string, flowFunc flow.Func) Auth 11 | ApplyFlow(auth types.Auth, r *http.Request, payload []byte) bool 12 | } 13 | 14 | type auth struct { 15 | flows map[string]flow.Func 16 | } 17 | 18 | func New() Auth { 19 | return &auth{make(map[string]flow.Func)} 20 | } 21 | 22 | func NewDefault() Auth { 23 | return New(). 24 | RegisterFlow("none", flow.None). 25 | RegisterFlow("plain secret", flow.PlainSecret). 26 | RegisterFlow("basic auth", flow.BasicAuth). 27 | RegisterFlow("gitlab", flow.Gitlab). 28 | RegisterFlow("github", flow.Github) 29 | } 30 | 31 | func (a *auth) RegisterFlow(flow string, flowFunc flow.Func) Auth { 32 | a.flows[flow] = flowFunc 33 | return a 34 | } 35 | 36 | func (a *auth) ApplyFlow(auth types.Auth, r *http.Request, payload []byte) bool { 37 | flowFunc, ok := a.flows[auth.Flow] 38 | if !ok { 39 | 40 | return false 41 | } 42 | 43 | return flowFunc(auth, r, payload) 44 | } 45 | -------------------------------------------------------------------------------- /internal/auth/auth_test.go: -------------------------------------------------------------------------------- 1 | package auth_test 2 | 3 | import ( 4 | "bytes" 5 | "github.com/AdamShannag/hookah/internal/auth" 6 | "github.com/AdamShannag/hookah/internal/types" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | ) 11 | 12 | func TestRegisterAndApplyFlow(t *testing.T) { 13 | a := auth.New() 14 | 15 | a.RegisterFlow("mock-flow-pass", mockFlow(true)) 16 | a.RegisterFlow("mock-flow-fail", mockFlow(false)) 17 | 18 | req := httptest.NewRequest("POST", "/", bytes.NewBuffer([]byte(`{"data":"test"}`))) 19 | 20 | tests := []struct { 21 | name string 22 | auth types.Auth 23 | expected bool 24 | }{ 25 | {"FlowPasses", types.Auth{Flow: "mock-flow-pass"}, true}, 26 | {"FlowFails", types.Auth{Flow: "mock-flow-fail"}, false}, 27 | {"FlowMissing", types.Auth{Flow: "unregistered"}, false}, 28 | } 29 | 30 | for _, tt := range tests { 31 | t.Run(tt.name, func(t *testing.T) { 32 | result := a.ApplyFlow(tt.auth, req, []byte("test")) 33 | if result != tt.expected { 34 | t.Errorf("expected %v, got %v", tt.expected, result) 35 | } 36 | }) 37 | } 38 | } 39 | 40 | func mockFlow(expected bool) func(types.Auth, *http.Request, []byte) bool { 41 | return func(a types.Auth, r *http.Request, b []byte) bool { 42 | return expected 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /internal/condition/evaluator.go: -------------------------------------------------------------------------------- 1 | package condition 2 | 3 | import ( 4 | "fmt" 5 | "github.com/AdamShannag/hookah/internal/resolver" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | type Evaluator interface { 11 | Register(op string, fn OperatorFunc) Evaluator 12 | EvaluateAll(conditions []string, headers http.Header, body map[string]any) (bool, error) 13 | } 14 | type OperatorFunc func(left, right any) (bool, error) 15 | 16 | type evaluator struct { 17 | operators map[string]OperatorFunc 18 | resolver resolver.Resolver 19 | } 20 | 21 | func NewEvaluator(resolver resolver.Resolver) Evaluator { 22 | return &evaluator{operators: map[string]OperatorFunc{}, resolver: resolver} 23 | } 24 | 25 | func NewDefaultEvaluator(resolver resolver.Resolver) Evaluator { 26 | return NewEvaluator(resolver). 27 | Register("{eq}", equals). 28 | Register("{ne}", notEquals). 29 | Register("{in}", in). 30 | Register("{notIn}", notIn). 31 | Register("{contains}", contains). 32 | Register("{startsWith}", startsWith). 33 | Register("{endsWith}", endsWith) 34 | } 35 | 36 | func (e *evaluator) Register(op string, fn OperatorFunc) Evaluator { 37 | e.operators[op] = fn 38 | return e 39 | } 40 | 41 | func (e *evaluator) EvaluateAll(conditions []string, headers http.Header, body map[string]any) (bool, error) { 42 | for _, cond := range conditions { 43 | match, err := e.evaluateOne(cond, headers, body) 44 | if err != nil { 45 | return false, err 46 | } 47 | if !match { 48 | return false, nil 49 | } 50 | } 51 | return true, nil 52 | } 53 | 54 | func (e *evaluator) evaluateOne(condition string, headers http.Header, body map[string]any) (bool, error) { 55 | op, leftRaw, rightRaw, err := e.extractParts(condition) 56 | if err != nil { 57 | return false, err 58 | } 59 | 60 | leftVal, err := e.resolveValue(leftRaw, headers, body) 61 | if err != nil { 62 | return false, fmt.Errorf("left value: %w", err) 63 | } 64 | 65 | rightVal, err := e.resolveValue(rightRaw, headers, body) 66 | if err != nil { 67 | return false, fmt.Errorf("right value: %w", err) 68 | } 69 | 70 | fn, ok := e.operators[op] 71 | if !ok { 72 | return false, fmt.Errorf("unknown operator: %s", op) 73 | } 74 | 75 | return fn(leftVal, rightVal) 76 | } 77 | 78 | func (e *evaluator) extractParts(condition string) (op, left, right string, err error) { 79 | for opr := range e.operators { 80 | if strings.Contains(condition, opr) { 81 | parts := strings.SplitN(condition, opr, 2) 82 | if len(parts) != 2 { 83 | return "", "", "", fmt.Errorf("invalid condition: %s", condition) 84 | } 85 | return opr, strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), nil 86 | } 87 | } 88 | return "", "", "", fmt.Errorf("unsupported operator in: %s", condition) 89 | } 90 | 91 | func (e *evaluator) resolveValue(expr string, headers http.Header, body map[string]any) (any, error) { 92 | expr = strings.Trim(expr, "{}") 93 | switch { 94 | case strings.HasPrefix(expr, "Header."): 95 | key := strings.TrimPrefix(expr, "Header.") 96 | return headers.Get(key), nil 97 | case strings.HasPrefix(expr, "Body."): 98 | return e.resolver.Resolve(strings.TrimPrefix(expr, "Body."), body) 99 | default: 100 | return expr, nil 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /internal/condition/evaluator_test.go: -------------------------------------------------------------------------------- 1 | package condition_test 2 | 3 | import ( 4 | "github.com/AdamShannag/hookah/internal/condition" 5 | "github.com/AdamShannag/hookah/internal/resolver" 6 | "net/http" 7 | "testing" 8 | ) 9 | 10 | func TestEvaluator_EvaluateAll(t *testing.T) { 11 | res := resolver.NewPathResolver() 12 | eval := condition.NewDefaultEvaluator(res) 13 | 14 | tests := []struct { 15 | name string 16 | conditions []string 17 | headers http.Header 18 | body map[string]any 19 | want bool 20 | wantErr bool 21 | }{ 22 | { 23 | name: "Equal condition true", 24 | conditions: []string{"{Body.user.name} {eq} {Jane}"}, 25 | body: map[string]any{ 26 | "user": map[string]any{"name": "Jane"}, 27 | }, 28 | want: true, 29 | }, 30 | { 31 | name: "Equal condition false", 32 | conditions: []string{"{Body.user.name} {eq} {Bob}"}, 33 | body: map[string]any{ 34 | "user": map[string]any{"name": "Alice"}, 35 | }, 36 | want: false, 37 | }, 38 | { 39 | name: "Not equal true", 40 | conditions: []string{"{Body.user.name} {ne} {Bob}"}, 41 | body: map[string]any{ 42 | "user": map[string]any{"name": "Alice"}, 43 | }, 44 | want: true, 45 | }, 46 | { 47 | name: "Header value match", 48 | conditions: []string{"{Header.X-Token} {eq} {abc123}"}, 49 | headers: http.Header{"X-Token": []string{"abc123"}}, 50 | want: true, 51 | }, 52 | { 53 | name: "Unsupported operator", 54 | conditions: []string{"{Body.user.name} {gt} {Jane}"}, 55 | body: map[string]any{"user": map[string]any{"name": "Jane"}}, 56 | wantErr: true, 57 | }, 58 | { 59 | name: "Malformed condition", 60 | conditions: []string{"{Body.user.name} [eq] {Jane}"}, 61 | body: map[string]any{"user": map[string]any{"name": "Jane"}}, 62 | wantErr: true, 63 | }, 64 | { 65 | name: "Missing field", 66 | conditions: []string{"{Body.user.age} {eq} {30}"}, 67 | body: map[string]any{"user": map[string]any{}}, 68 | want: false, 69 | wantErr: true, 70 | }, 71 | } 72 | 73 | for _, tt := range tests { 74 | t.Run(tt.name, func(t *testing.T) { 75 | got, err := eval.EvaluateAll(tt.conditions, tt.headers, tt.body) 76 | if (err != nil) != tt.wantErr { 77 | t.Errorf("EvaluateAll() error = %v, wantErr %v", err, tt.wantErr) 78 | return 79 | } 80 | if got != tt.want { 81 | t.Errorf("EvaluateAll() = %v, want %v", got, tt.want) 82 | } 83 | }) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /internal/condition/funcs.go: -------------------------------------------------------------------------------- 1 | package condition 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func equals(a, b any) (bool, error) { 9 | return a == b, nil 10 | } 11 | 12 | func notEquals(a, b any) (bool, error) { 13 | return a != b, nil 14 | } 15 | 16 | func in(a any, b any) (bool, error) { 17 | list, ok := b.([]any) 18 | if !ok { 19 | return false, fmt.Errorf("right side must be a list") 20 | } 21 | for _, item := range list { 22 | if item == a { 23 | return true, nil 24 | } 25 | } 26 | return false, nil 27 | } 28 | 29 | func contains(a, b any) (bool, error) { 30 | as, aok := a.(string) 31 | bs, bok := b.(string) 32 | if !aok || !bok { 33 | return false, fmt.Errorf("both sides must be strings") 34 | } 35 | return strings.Contains(as, bs), nil 36 | } 37 | 38 | func notIn(a any, b any) (bool, error) { 39 | list, ok := b.([]any) 40 | if !ok { 41 | return false, fmt.Errorf("right side must be a list") 42 | } 43 | for _, item := range list { 44 | if item == a { 45 | return false, nil 46 | } 47 | } 48 | return true, nil 49 | } 50 | 51 | func startsWith(a, b any) (bool, error) { 52 | as, aok := a.(string) 53 | bs, bok := b.(string) 54 | if !aok || !bok { 55 | return false, fmt.Errorf("both sides must be strings") 56 | } 57 | return strings.HasPrefix(as, bs), nil 58 | } 59 | 60 | func endsWith(a, b any) (bool, error) { 61 | as, aok := a.(string) 62 | bs, bok := b.(string) 63 | if !aok || !bok { 64 | return false, fmt.Errorf("both sides must be strings") 65 | } 66 | return strings.HasSuffix(as, bs), nil 67 | } 68 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/AdamShannag/hookah/internal/auth" 5 | "github.com/AdamShannag/hookah/internal/types" 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | type Config struct { 11 | templateConfigs []types.Template 12 | templates map[string]string 13 | auth auth.Auth 14 | } 15 | 16 | func New(templateConfigs []types.Template, templates map[string]string, auth auth.Auth) *Config { 17 | return &Config{ 18 | templateConfigs: templateConfigs, 19 | templates: templates, 20 | auth: auth, 21 | } 22 | } 23 | 24 | func (c *Config) GetTemplate(template string) string { 25 | body, ok := c.templates[template] 26 | if !ok { 27 | return "{}" 28 | } 29 | return body 30 | } 31 | 32 | func (c *Config) GetConfigTemplates(receiver string, r *http.Request, payload []byte) (templates []types.Template) { 33 | for _, template := range c.templateConfigs { 34 | if template.Receiver != receiver { 35 | continue 36 | } 37 | 38 | if !c.auth.ApplyFlow(template.Auth, r, payload) { 39 | log.Printf("[AUTH] failed for receiver: %s with flow: %s", receiver, template.Auth.Flow) 40 | continue 41 | } 42 | 43 | templates = append(templates, template) 44 | } 45 | return 46 | } 47 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config_test 2 | 3 | import ( 4 | "bytes" 5 | "github.com/AdamShannag/hookah/internal/auth" 6 | "github.com/AdamShannag/hookah/internal/config" 7 | "github.com/AdamShannag/hookah/internal/types" 8 | "net/http/httptest" 9 | "testing" 10 | ) 11 | 12 | func TestNewConfig(t *testing.T) { 13 | tmpls := []types.Template{{Receiver: "discord", Auth: types.Auth{Flow: "none"}}} 14 | tmplMap := map[string]string{"discord": `{ "msg": "hello" }`} 15 | 16 | cfg := config.New(tmpls, tmplMap, auth.NewDefault()) 17 | 18 | if len(cfg.GetConfigTemplates("discord", httptest.NewRequest("POST", "/", nil), nil)) != 1 { 19 | t.Error("expected one template to match") 20 | } 21 | } 22 | 23 | func TestGetTemplate(t *testing.T) { 24 | cfg := config.New(nil, map[string]string{ 25 | "discord": `{ "msg": "hello" }`, 26 | }, auth.NewDefault()) 27 | 28 | val := cfg.GetTemplate("discord") 29 | if val != `{ "msg": "hello" }` { 30 | t.Errorf("expected template body, got %s", val) 31 | } 32 | 33 | val = cfg.GetTemplate("unknown") 34 | if val != "{}" { 35 | t.Errorf("expected fallback '{}', got %s", val) 36 | } 37 | } 38 | 39 | func TestGetConfigTemplates_AuthFilter(t *testing.T) { 40 | templates := []types.Template{ 41 | {Receiver: "slack", Auth: types.Auth{Flow: "plain secret"}}, 42 | {Receiver: "slack", Auth: types.Auth{Flow: "none"}}, 43 | } 44 | 45 | req := httptest.NewRequest("POST", "/", bytes.NewBuffer([]byte("test"))) 46 | 47 | t.Run("auth passes", func(t *testing.T) { 48 | cfg := config.New(templates, nil, auth.NewDefault()) 49 | result := cfg.GetConfigTemplates("slack", req, []byte("test")) 50 | 51 | if len(result) != 2 { 52 | t.Errorf("expected 2 templates to pass auth, got %d", len(result)) 53 | } 54 | }) 55 | 56 | t.Run("auth fails", func(t *testing.T) { 57 | cfg := config.New([]types.Template{ 58 | {Receiver: "slack", Auth: types.Auth{Flow: "no flow"}}, 59 | }, nil, auth.NewDefault()) 60 | 61 | result := cfg.GetConfigTemplates("slack", req, []byte("test")) 62 | 63 | if len(result) != 0 { 64 | t.Errorf("expected 0 templates to pass auth, got %d", len(result)) 65 | } 66 | }) 67 | 68 | t.Run("receiver mismatch", func(t *testing.T) { 69 | cfg := config.New(templates, nil, auth.NewDefault()) 70 | result := cfg.GetConfigTemplates("discord", req, []byte("test")) 71 | 72 | if len(result) != 0 { 73 | t.Errorf("expected 0 templates for mismatched receiver, got %d", len(result)) 74 | } 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /internal/flow/flow.go: -------------------------------------------------------------------------------- 1 | package flow 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha256" 6 | "crypto/sha512" 7 | "crypto/subtle" 8 | "encoding/hex" 9 | "fmt" 10 | "github.com/AdamShannag/hookah/internal/types" 11 | "net/http" 12 | "strings" 13 | ) 14 | 15 | type Func func(auth types.Auth, r *http.Request, payload []byte) bool 16 | 17 | func None(_ types.Auth, _ *http.Request, _ []byte) bool { 18 | return true 19 | } 20 | 21 | func BasicAuth(auth types.Auth, r *http.Request, _ []byte) bool { 22 | username, password, ok := r.BasicAuth() 23 | return ok && auth.Secret == fmt.Sprintf("%s:%s", username, password) 24 | } 25 | 26 | func PlainSecret(auth types.Auth, r *http.Request, _ []byte) bool { 27 | return auth.Secret == r.Header.Get(auth.HeaderSecretKey) 28 | } 29 | 30 | func Gitlab(auth types.Auth, r *http.Request, _ []byte) bool { 31 | expected := sha512.Sum512([]byte(auth.Secret)) 32 | actual := sha512.Sum512([]byte(r.Header.Get(auth.HeaderSecretKey))) 33 | return subtle.ConstantTimeCompare(actual[:], expected[:]) == 1 34 | } 35 | 36 | func Github(auth types.Auth, r *http.Request, payload []byte) bool { 37 | signature := r.Header.Get(auth.HeaderSecretKey) 38 | if signature == "" { 39 | return false 40 | } 41 | signature = strings.TrimPrefix(signature, "sha256=") 42 | 43 | mac := hmac.New(sha256.New, []byte(auth.Secret)) 44 | _, _ = mac.Write(payload) 45 | expectedMAC := hex.EncodeToString(mac.Sum(nil)) 46 | 47 | return hmac.Equal([]byte(signature), []byte(expectedMAC)) 48 | } 49 | -------------------------------------------------------------------------------- /internal/flow/flow_test.go: -------------------------------------------------------------------------------- 1 | package flow_test 2 | 3 | import ( 4 | "bytes" 5 | "crypto/hmac" 6 | "crypto/sha256" 7 | "encoding/hex" 8 | "github.com/AdamShannag/hookah/internal/flow" 9 | "github.com/AdamShannag/hookah/internal/types" 10 | "net/http/httptest" 11 | "testing" 12 | ) 13 | 14 | func TestNone(t *testing.T) { 15 | auth := types.Auth{} 16 | req := httptest.NewRequest("POST", "/", nil) 17 | if !flow.None(auth, req, nil) { 18 | t.Error("None should always return true") 19 | } 20 | } 21 | 22 | func TestBasicAuth(t *testing.T) { 23 | auth := types.Auth{Secret: "user:pass"} 24 | req := httptest.NewRequest("POST", "/", nil) 25 | req.SetBasicAuth("user", "pass") 26 | 27 | if !flow.BasicAuth(auth, req, nil) { 28 | t.Error("BasicAuth should succeed with matching credentials") 29 | } 30 | 31 | req.SetBasicAuth("user", "wrong") 32 | if flow.BasicAuth(auth, req, nil) { 33 | t.Error("BasicAuth should fail with incorrect credentials") 34 | } 35 | } 36 | 37 | func TestPlainSecret(t *testing.T) { 38 | auth := types.Auth{ 39 | Secret: "my-secret", 40 | HeaderSecretKey: "X-Secret-Key", 41 | } 42 | req := httptest.NewRequest("POST", "/", nil) 43 | req.Header.Set("X-Secret-Key", "my-secret") 44 | 45 | if !flow.PlainSecret(auth, req, nil) { 46 | t.Error("PlainSecret should succeed with correct secret") 47 | } 48 | 49 | req.Header.Set("X-Secret-Key", "wrong-secret") 50 | if flow.PlainSecret(auth, req, nil) { 51 | t.Error("PlainSecret should fail with incorrect secret") 52 | } 53 | } 54 | 55 | func TestGitlab(t *testing.T) { 56 | secret := "top-secret" 57 | auth := types.Auth{ 58 | Secret: secret, 59 | HeaderSecretKey: "X-Gitlab-Token", 60 | } 61 | req := httptest.NewRequest("POST", "/", nil) 62 | req.Header.Set("X-Gitlab-Token", secret) 63 | 64 | if !flow.Gitlab(auth, req, nil) { 65 | t.Error("Gitlab should succeed with correct hash") 66 | } 67 | 68 | req.Header.Set("X-Gitlab-Token", "invalid") 69 | if flow.Gitlab(auth, req, nil) { 70 | t.Error("Gitlab should fail with incorrect hash") 71 | } 72 | } 73 | 74 | func TestGithub(t *testing.T) { 75 | secret := "github-secret" 76 | auth := types.Auth{ 77 | Secret: secret, 78 | HeaderSecretKey: "X-Hub-Signature-256", 79 | } 80 | payload := []byte(`{"key":"value"}`) 81 | mac := hmac.New(sha256.New, []byte(secret)) 82 | mac.Write(payload) 83 | signature := hex.EncodeToString(mac.Sum(nil)) 84 | req := httptest.NewRequest("POST", "/", bytes.NewReader(payload)) 85 | req.Header.Set("X-Hub-Signature-256", "sha256="+signature) 86 | 87 | if !flow.Github(auth, req, payload) { 88 | t.Error("Github should succeed with correct signature") 89 | } 90 | 91 | req.Header.Set("X-Hub-Signature-256", "sha256=invalidsignature") 92 | if flow.Github(auth, req, payload) { 93 | t.Error("Github should fail with incorrect signature") 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /internal/render/funcs.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | "text/template" 7 | "time" 8 | ) 9 | 10 | var funcMap = template.FuncMap{ 11 | "now": now, 12 | "format": format, 13 | "parseTime": parseTime, 14 | "pastTense": pastTense, 15 | "lower": strings.ToLower, 16 | "upper": strings.ToUpper, 17 | "title": strings.ToTitle, 18 | "trim": strings.TrimSpace, 19 | "contains": strings.Contains, 20 | "replace": strings.ReplaceAll, 21 | "default": defaultValue, 22 | } 23 | 24 | func now() time.Time { return time.Now() } 25 | 26 | func format(t time.Time, format string) string { 27 | return t.Format(format) 28 | } 29 | 30 | func parseTime(tm string, layout string) time.Time { 31 | t, err := time.Parse(layout, tm) 32 | if err != nil { 33 | log.Printf("[Template] %v", err) 34 | return time.Time{} 35 | } 36 | 37 | return t 38 | } 39 | 40 | func pastTense(word string) string { 41 | if len(word) > 0 && word[len(word)-1] == 'e' { 42 | return word + "d" 43 | } 44 | 45 | return word + "ed" 46 | } 47 | 48 | func defaultValue(val, fallback any) any { 49 | if val == nil || val == "" { 50 | return fallback 51 | } 52 | return val 53 | } 54 | -------------------------------------------------------------------------------- /internal/render/render.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "text/template" 8 | ) 9 | 10 | func ToMap(tmplStr string, dataSource map[string]any) (map[string]any, error) { 11 | tmpl, err := template.New("map-template").Funcs(funcMap).Parse(tmplStr) 12 | if err != nil { 13 | return nil, fmt.Errorf("error parsing template: %w", err) 14 | } 15 | 16 | var buf bytes.Buffer 17 | if err = tmpl.Execute(&buf, dataSource); err != nil { 18 | return nil, fmt.Errorf("error executing template: %w", err) 19 | } 20 | 21 | var result map[string]any 22 | if err = json.Unmarshal(buf.Bytes(), &result); err != nil { 23 | return nil, fmt.Errorf("error unmarshaling rendered template: %w", err) 24 | } 25 | 26 | return result, nil 27 | } 28 | -------------------------------------------------------------------------------- /internal/render/render_test.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestToMap_Success(t *testing.T) { 9 | tmpl := `{"msg": "hello {{.name}}", "active": {{.active}}}` 10 | data := map[string]any{ 11 | "name": "world", 12 | "active": true, 13 | } 14 | 15 | result, err := ToMap(tmpl, data) 16 | if err != nil { 17 | t.Fatalf("expected no error, got: %v", err) 18 | } 19 | 20 | if result["msg"] != "hello world" { 21 | t.Errorf("unexpected msg: %v", result["msg"]) 22 | } 23 | if result["active"] != true { 24 | t.Errorf("unexpected active: %v", result["active"]) 25 | } 26 | } 27 | 28 | func TestToMap_InvalidTemplate(t *testing.T) { 29 | _, err := ToMap(`{{.name`, map[string]any{"name": "fail"}) 30 | if err == nil || !strings.Contains(err.Error(), "parsing template") { 31 | t.Fatalf("expected template parsing error, got: %v", err) 32 | } 33 | } 34 | 35 | func TestToMap_TemplateExecError(t *testing.T) { 36 | tmpl := `{{call .badFunc}}` 37 | data := map[string]any{} 38 | 39 | _, err := ToMap(tmpl, data) 40 | if err == nil { 41 | t.Fatal("expected template execution error, got nil") 42 | } 43 | } 44 | 45 | func TestToMap_InvalidJSON(t *testing.T) { 46 | tmpl := `{{.name}}` 47 | data := map[string]any{"name": "no-quotes"} 48 | 49 | _, err := ToMap(tmpl, data) 50 | if err == nil || !strings.Contains(err.Error(), "unmarshaling") { 51 | t.Fatalf("expected JSON unmarshal error, got: %v", err) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /internal/resolver/benchmark_test.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | var benchmarkData = map[string]any{ 8 | "user": map[string]any{ 9 | "name": "Alice", 10 | "age": 30, 11 | "address": map[string]any{ 12 | "city": "Wonderland", 13 | "state": "Imagination", 14 | }, 15 | "tags": []any{"admin", "user"}, 16 | "friends": []any{ 17 | map[string]any{"name": "Bob"}, 18 | map[string]any{"name": "Charlie"}, 19 | }, 20 | "projects": []any{ 21 | map[string]any{ 22 | "name": "ProjectX", 23 | "tasks": []any{"Design", "Develop", "Test"}, 24 | }, 25 | map[string]any{ 26 | "name": "ProjectY", 27 | "tasks": []any{"Plan", "Execute"}, 28 | }, 29 | }, 30 | }, 31 | } 32 | 33 | func BenchmarkResolveSimplePath(b *testing.B) { 34 | resolver := NewPathResolver() 35 | for i := 0; i < b.N; i++ { 36 | _, _ = resolver.Resolve("user.name", benchmarkData) 37 | } 38 | } 39 | 40 | func BenchmarkResolveNestedPath(b *testing.B) { 41 | resolver := NewPathResolver() 42 | for i := 0; i < b.N; i++ { 43 | _, _ = resolver.Resolve("user.address.city", benchmarkData) 44 | } 45 | } 46 | 47 | func BenchmarkResolveIndexedPath(b *testing.B) { 48 | resolver := NewPathResolver() 49 | for i := 0; i < b.N; i++ { 50 | _, _ = resolver.Resolve("user.projects[1].tasks[1]", benchmarkData) 51 | } 52 | } 53 | 54 | func BenchmarkResolveProjectedPath(b *testing.B) { 55 | resolver := NewPathResolver() 56 | for i := 0; i < b.N; i++ { 57 | _, _ = resolver.Resolve("user.projects[].tasks", benchmarkData) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/resolver/resolver.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // Resolver defines the interface for any value resolver. 10 | type Resolver interface { 11 | Resolve(path string, data any) (any, error) 12 | } 13 | 14 | // pathResolver resolves dotted paths and supports array access and projection. 15 | type pathResolver struct{} 16 | 17 | func NewPathResolver() Resolver { 18 | return &pathResolver{} 19 | } 20 | 21 | // Resolve a value by navigating the provided path (e.g. "users[0].name" or "users[].name"). 22 | func (r *pathResolver) Resolve(path string, data any) (any, error) { 23 | parts := strings.Split(path, ".") 24 | return resolvePath(data, parts) 25 | } 26 | 27 | // resolvePath resolves the nested path on a given data structure. 28 | func resolvePath(current any, parts []string) (any, error) { 29 | for i, part := range parts { 30 | key, index, isIndexed := parseIndexedKey(part) 31 | 32 | objMap, ok := current.(map[string]any) 33 | if !ok { 34 | return nil, fmt.Errorf("expected map at '%s'", part) 35 | } 36 | 37 | value, exists := objMap[key] 38 | if !exists { 39 | return nil, fmt.Errorf("key '%s' not found", key) 40 | } 41 | 42 | if !isIndexed { 43 | current = value 44 | continue 45 | } 46 | 47 | array, ok := value.([]any) 48 | if !ok { 49 | return nil, fmt.Errorf("expected array at '%s'", key) 50 | } 51 | 52 | if index == nil { 53 | return projectArray(array, parts[i+1:]) 54 | } 55 | 56 | if *index < 0 || *index >= len(array) { 57 | return nil, fmt.Errorf("index out of bounds at '%s[%d]'", key, *index) 58 | } 59 | 60 | current = array[*index] 61 | } 62 | 63 | return current, nil 64 | } 65 | 66 | // projectArray handles projection through an array of maps using the remaining path parts. 67 | func projectArray(array []any, remainingParts []string) ([]any, error) { 68 | results := make([]any, 0, len(array)) 69 | for _, item := range array { 70 | val, err := resolvePath(item, remainingParts) 71 | if err != nil { 72 | continue 73 | } 74 | if list, ok := val.([]any); ok { 75 | results = append(results, list...) 76 | } else { 77 | results = append(results, val) 78 | } 79 | } 80 | return results, nil 81 | } 82 | 83 | // parseIndexedKey parses keys like "users[0]" or "users[]" and returns the base key, index (if any), and a flag. 84 | func parseIndexedKey(part string) (key string, index *int, isIndexed bool) { 85 | start := strings.Index(part, "[") 86 | end := strings.Index(part, "]") 87 | 88 | // No brackets 89 | if start == -1 || end == -1 || end < start { 90 | return part, nil, false 91 | } 92 | 93 | key = part[:start] 94 | idxStr := part[start+1 : end] 95 | 96 | if idxStr == "" { 97 | return key, nil, true 98 | } 99 | 100 | i, err := strconv.Atoi(idxStr) 101 | if err != nil { 102 | return part, nil, false 103 | } 104 | 105 | return key, &i, true 106 | } 107 | -------------------------------------------------------------------------------- /internal/resolver/resolver_test.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestPathResolver_Resolve(t *testing.T) { 9 | resolver := NewPathResolver() 10 | 11 | data := map[string]any{ 12 | "user": map[string]any{ 13 | "name": "Alice", 14 | "age": 30, 15 | "address": map[string]any{ 16 | "city": "Wonderland", 17 | "state": "Imagination", 18 | }, 19 | "tags": []any{"admin", "user"}, 20 | "friends": []any{ 21 | map[string]any{"name": "Bob"}, 22 | map[string]any{"name": "Charlie"}, 23 | }, 24 | "projects": []any{ 25 | map[string]any{ 26 | "name": "ProjectX", 27 | "tasks": []any{"Design", "Develop", "Test"}, 28 | }, 29 | map[string]any{ 30 | "name": "ProjectY", 31 | "tasks": []any{"Plan", "Execute"}, 32 | }, 33 | }, 34 | }, 35 | "meta": map[string]any{ 36 | "active": true, 37 | }, 38 | } 39 | 40 | tests := []struct { 41 | name string 42 | path string 43 | want any 44 | wantErr bool 45 | }{ 46 | // Basic and nested access 47 | {"Simple value", "user.name", "Alice", false}, 48 | {"Nested value", "user.address.city", "Wonderland", false}, 49 | {"Missing key", "user.nonexistent", nil, true}, 50 | {"Meta boolean", "meta.active", true, false}, 51 | 52 | // Indexed array access 53 | {"Array access", "user.tags[1]", "user", false}, 54 | {"Array out of bounds", "user.tags[5]", nil, true}, 55 | 56 | // Projections 57 | {"Array projection", "user.friends[].name", []any{"Bob", "Charlie"}, false}, 58 | {"Projection with nested array", "user.projects[].tasks[0]", []any{"Design", "Plan"}, false}, 59 | {"Projection full task lists", "user.projects[].tasks", []any{"Design", "Develop", "Test", "Plan", "Execute"}, false}, 60 | 61 | // Mixed nested indexing 62 | {"Deep access with index", "user.projects[1].tasks[1]", "Execute", false}, 63 | {"Invalid nested array", "user.projects[1].unknown[0]", nil, true}, 64 | 65 | // Invalid path formats 66 | {"Non-array projection", "user.name[]", nil, true}, 67 | {"Index on non-array", "user.name[0]", nil, true}, 68 | } 69 | 70 | for _, tt := range tests { 71 | t.Run(tt.name, func(t *testing.T) { 72 | got, err := resolver.Resolve(tt.path, data) 73 | if (err != nil) != tt.wantErr { 74 | t.Errorf("Resolve() error = %v, wantErr %v", err, tt.wantErr) 75 | return 76 | } 77 | 78 | if !reflect.DeepEqual(got, tt.want) { 79 | t.Errorf("Resolve() = %#v, want %#v", got, tt.want) 80 | } 81 | }) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /internal/server/routes.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | func (s *Server) RegisterRoutes() http.Handler { 10 | mux := http.NewServeMux() 11 | mux.HandleFunc("POST /webhooks/{receiver}", s.WebhookHandler) 12 | return mux 13 | } 14 | 15 | func (s *Server) WebhookHandler(w http.ResponseWriter, r *http.Request) { 16 | receiver := r.PathValue("receiver") 17 | 18 | for key, _ := range r.URL.Query() { 19 | r.Header.Set(key, r.URL.Query().Get(key)) 20 | } 21 | 22 | payload, err := io.ReadAll(r.Body) 23 | if err != nil || len(payload) == 0 { 24 | http.Error(w, "Invalid JSON body", http.StatusBadRequest) 25 | return 26 | } 27 | 28 | templates := s.config.GetConfigTemplates(receiver, r, payload) 29 | 30 | if len(templates) == 0 { 31 | w.WriteHeader(http.StatusOK) 32 | return 33 | } 34 | 35 | var request map[string]interface{} 36 | if err = json.Unmarshal(payload, &request); err != nil { 37 | http.Error(w, "Invalid JSON body", http.StatusBadRequest) 38 | return 39 | } 40 | 41 | for _, tmpl := range templates { 42 | go s.handleTemplate(tmpl, r.Header, request) 43 | } 44 | 45 | w.WriteHeader(http.StatusOK) 46 | } 47 | -------------------------------------------------------------------------------- /internal/server/routes_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "github.com/AdamShannag/hookah/internal/auth" 7 | "github.com/AdamShannag/hookah/internal/condition" 8 | "github.com/AdamShannag/hookah/internal/config" 9 | "github.com/AdamShannag/hookah/internal/resolver" 10 | "github.com/AdamShannag/hookah/internal/types" 11 | "io" 12 | "net/http" 13 | "net/http/httptest" 14 | "net/url" 15 | "sync" 16 | "testing" 17 | "time" 18 | ) 19 | 20 | func TestWebhookHandler_DispatchesToSimulatedEndpoint(t *testing.T) { 21 | var ( 22 | receivedPayload map[string]any 23 | mu sync.Mutex 24 | wg sync.WaitGroup 25 | ) 26 | 27 | wg.Add(1) 28 | mockDiscord := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 29 | defer r.Body.Close() 30 | body, _ := io.ReadAll(r.Body) 31 | 32 | mu.Lock() 33 | defer mu.Unlock() 34 | _ = json.Unmarshal(body, &receivedPayload) 35 | wg.Done() 36 | })) 37 | defer mockDiscord.Close() 38 | 39 | testServer := &Server{ 40 | evaluator: condition.NewDefaultEvaluator(resolver.NewPathResolver()), 41 | config: config.New([]types.Template{ 42 | { 43 | Receiver: "gitlab", 44 | Auth: types.Auth{Flow: "none"}, 45 | EventTypeKey: "event_name", 46 | EventTypeIn: "body", 47 | Events: types.Events{ 48 | { 49 | Event: "issue", 50 | Conditions: []string{"{Header.X-Custom} {eq} {active}"}, 51 | Hooks: []types.Hook{ 52 | { 53 | Name: "MockDiscord", 54 | EndpointKey: "Webhook-URL", 55 | Body: "discord.tmpl", 56 | }, 57 | }, 58 | }, 59 | }, 60 | }, 61 | }, map[string]string{ 62 | "discord.tmpl": getBodyTemplate("Issue received"), 63 | }, auth.NewDefault()), 64 | } 65 | 66 | reqBody := map[string]any{ 67 | "event_name": "issue", 68 | "status": "active", 69 | } 70 | bodyBuf := new(bytes.Buffer) 71 | _ = json.NewEncoder(bodyBuf).Encode(reqBody) 72 | 73 | req := httptest.NewRequest(http.MethodPost, "/webhooks/gitlab", bodyBuf) 74 | req.Header.Set("X-Custom", "active") 75 | req.Header.Set("Webhook-URL", mockDiscord.URL) 76 | 77 | rr := httptest.NewRecorder() 78 | testServer.RegisterRoutes().ServeHTTP(rr, req) 79 | 80 | if rr.Code != http.StatusOK { 81 | t.Fatalf("expected status 200, got %d", rr.Code) 82 | } 83 | 84 | wg.Wait() 85 | 86 | mu.Lock() 87 | defer mu.Unlock() 88 | if receivedPayload["content"] != "Issue received" { 89 | t.Fatalf("expected payload 'Issue received', got: %v", receivedPayload) 90 | } 91 | } 92 | 93 | func TestWebhookHandler_DoesNotDispatchWhenConditionFails(t *testing.T) { 94 | var wasCalled bool 95 | wg := sync.WaitGroup{} 96 | 97 | mockDiscord := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 98 | wasCalled = true 99 | wg.Done() 100 | })) 101 | defer mockDiscord.Close() 102 | 103 | testServer := &Server{ 104 | evaluator: condition.NewDefaultEvaluator(resolver.NewPathResolver()), 105 | config: config.New([]types.Template{ 106 | { 107 | Receiver: "gitlab", 108 | Auth: types.Auth{Flow: "none"}, 109 | EventTypeKey: "event_name", 110 | EventTypeIn: "body", 111 | Events: types.Events{ 112 | { 113 | Event: "issue", 114 | Conditions: []string{"{Header.X-Custom} {eq} {Body.status}"}, 115 | Hooks: []types.Hook{ 116 | { 117 | Name: "MockDiscord", 118 | EndpointKey: "Webhook-URL", 119 | Body: "discord.tmpl", 120 | }, 121 | }, 122 | }, 123 | }, 124 | }, 125 | }, map[string]string{ 126 | "discord.tmpl": getBodyTemplate("Should not be triggered"), 127 | }, auth.NewDefault()), 128 | } 129 | 130 | reqBody := map[string]any{ 131 | "event_name": "issue", 132 | "status": "inactive", 133 | } 134 | bodyBuf := new(bytes.Buffer) 135 | _ = json.NewEncoder(bodyBuf).Encode(reqBody) 136 | 137 | req := httptest.NewRequest(http.MethodPost, "/webhooks/gitlab", bodyBuf) 138 | req.Header.Set("X-Custom", "active") 139 | req.Header.Set("Webhook-URL", mockDiscord.URL) 140 | 141 | rr := httptest.NewRecorder() 142 | testServer.RegisterRoutes().ServeHTTP(rr, req) 143 | 144 | if rr.Code != http.StatusOK { 145 | t.Fatalf("expected status 200, got %d", rr.Code) 146 | } 147 | 148 | time.Sleep(100 * time.Millisecond) 149 | 150 | if wasCalled { 151 | t.Fatalf("expected no webhook call, but it was triggered") 152 | } 153 | } 154 | 155 | func TestWebhookHandler_UsesQueryParamsAsHeaders(t *testing.T) { 156 | var ( 157 | receivedPayload map[string]any 158 | mu sync.Mutex 159 | wg sync.WaitGroup 160 | ) 161 | 162 | wg.Add(1) 163 | mockDiscord := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 164 | defer r.Body.Close() 165 | body, _ := io.ReadAll(r.Body) 166 | 167 | mu.Lock() 168 | defer mu.Unlock() 169 | _ = json.Unmarshal(body, &receivedPayload) 170 | wg.Done() 171 | })) 172 | defer mockDiscord.Close() 173 | 174 | testServer := &Server{ 175 | evaluator: condition.NewDefaultEvaluator(resolver.NewPathResolver()), 176 | config: config.New([]types.Template{ 177 | { 178 | Receiver: "gitlab", 179 | Auth: types.Auth{Flow: "none"}, 180 | EventTypeKey: "event_name", 181 | EventTypeIn: "body", 182 | Events: types.Events{ 183 | { 184 | Event: "issue", 185 | Conditions: []string{"{Header.X-Custom} {eq} {active}"}, 186 | Hooks: []types.Hook{ 187 | { 188 | Name: "MockDiscord", 189 | EndpointKey: "Webhook-URL", 190 | Body: "discord.tmpl", 191 | }, 192 | }, 193 | }, 194 | }, 195 | }, 196 | }, map[string]string{ 197 | "discord.tmpl": getBodyTemplate("Query param test passed"), 198 | }, auth.NewDefault()), 199 | } 200 | 201 | reqBody := map[string]any{ 202 | "event_name": "issue", 203 | } 204 | bodyBuf := new(bytes.Buffer) 205 | _ = json.NewEncoder(bodyBuf).Encode(reqBody) 206 | 207 | req := httptest.NewRequest(http.MethodPost, "/webhooks/gitlab?X-Custom=active&Webhook-URL="+url.QueryEscape(mockDiscord.URL), bodyBuf) 208 | 209 | rr := httptest.NewRecorder() 210 | testServer.RegisterRoutes().ServeHTTP(rr, req) 211 | 212 | if rr.Code != http.StatusOK { 213 | t.Fatalf("expected status 200, got %d", rr.Code) 214 | } 215 | 216 | wg.Wait() 217 | 218 | mu.Lock() 219 | defer mu.Unlock() 220 | if receivedPayload["content"] != "Query param test passed" { 221 | t.Fatalf("expected payload 'Query param test passed', got: %v", receivedPayload) 222 | } 223 | } 224 | 225 | func getBodyTemplate(content string) string { 226 | marshal, _ := json.Marshal(map[string]string{ 227 | "content": content, 228 | }) 229 | 230 | return string(marshal) 231 | } 232 | -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "github.com/AdamShannag/hookah/internal/condition" 6 | "github.com/AdamShannag/hookah/internal/config" 7 | "net/http" 8 | "os" 9 | "strconv" 10 | "time" 11 | ) 12 | 13 | type Server struct { 14 | port int 15 | config *config.Config 16 | evaluator condition.Evaluator 17 | } 18 | 19 | func NewServer(config *config.Config, evaluator condition.Evaluator) *http.Server { 20 | port, _ := strconv.Atoi(os.Getenv("PORT")) 21 | newServer := &Server{ 22 | port: port, 23 | config: config, 24 | evaluator: evaluator, 25 | } 26 | 27 | server := &http.Server{ 28 | Addr: fmt.Sprintf(":%d", newServer.port), 29 | Handler: newServer.RegisterRoutes(), 30 | IdleTimeout: time.Minute, 31 | ReadTimeout: 10 * time.Second, 32 | WriteTimeout: 30 * time.Second, 33 | } 34 | 35 | return server 36 | } 37 | -------------------------------------------------------------------------------- /internal/server/util.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/AdamShannag/hookah/internal/render" 8 | "github.com/AdamShannag/hookah/internal/types" 9 | "log" 10 | "net/http" 11 | ) 12 | 13 | func (s *Server) handleTemplate(tmpl types.Template, headers http.Header, body map[string]any) { 14 | eventType, err := extractEventType(tmpl, headers, body) 15 | if err != nil { 16 | log.Printf("[Template] %v", err) 17 | return 18 | } 19 | 20 | events := tmpl.Events.GetEvents(eventType) 21 | if len(events) == 0 { 22 | log.Printf("[Template] No matching events found for type: %v", eventType) 23 | return 24 | } 25 | 26 | for _, evt := range events { 27 | go s.processEvent(evt, headers, body) 28 | } 29 | } 30 | 31 | func (s *Server) processEvent(evt types.Event, headers http.Header, body map[string]any) { 32 | ok, err := s.evaluator.EvaluateAll(evt.Conditions, headers, body) 33 | if err != nil { 34 | log.Printf("[Condition] Evaluation error: %v", err) 35 | return 36 | } 37 | if !ok { 38 | log.Println("[Condition] Not met, skipping event") 39 | return 40 | } 41 | 42 | for _, hook := range evt.Hooks { 43 | go s.triggerHook(hook, body, headers) 44 | } 45 | } 46 | 47 | func (s *Server) triggerHook(hook types.Hook, body map[string]any, headers http.Header) { 48 | templateStr := s.config.GetTemplate(hook.Body) 49 | 50 | payload, err := render.ToMap(templateStr, body) 51 | if err != nil { 52 | log.Printf("[Render] Failed to parse rendered template to map (%s): %v", hook.Name, err) 53 | return 54 | } 55 | 56 | url := headers.Get(hook.EndpointKey) 57 | if url == "" { 58 | log.Printf("[Webhook] URL not found in header for key: %s", hook.EndpointKey) 59 | return 60 | } 61 | 62 | log.Printf("[Webhook] Triggering: %s", hook.Name) 63 | if err = postJSON(url, payload); err != nil { 64 | log.Printf("[Webhook] Failed to send request (%s): %v", hook.Name, err) 65 | } 66 | } 67 | 68 | func extractEventType(tmpl types.Template, headers http.Header, body map[string]any) (string, error) { 69 | switch tmpl.EventTypeIn { 70 | case "header": 71 | eventType := headers.Get(tmpl.EventTypeKey) 72 | if eventType == "" { 73 | return "", fmt.Errorf("event key '%s' not found in headers", tmpl.EventTypeKey) 74 | } 75 | return eventType, nil 76 | case "body": 77 | rawEvent, ok := body[tmpl.EventTypeKey] 78 | if !ok { 79 | return "", fmt.Errorf("event key '%s' not found in body", tmpl.EventTypeKey) 80 | } 81 | eventType, ok := rawEvent.(string) 82 | if !ok { 83 | return "", fmt.Errorf("event key '%s' is not a string in body", tmpl.EventTypeKey) 84 | } 85 | return eventType, nil 86 | default: 87 | return "", fmt.Errorf("unknown EventTypeIn value: '%s'", tmpl.EventTypeIn) 88 | } 89 | } 90 | 91 | func postJSON(url string, data map[string]any) error { 92 | jsonData, err := json.Marshal(data) 93 | if err != nil { 94 | return err 95 | } 96 | _, err = http.Post(url, "application/json", bytes.NewBuffer(jsonData)) 97 | return err 98 | } 99 | -------------------------------------------------------------------------------- /internal/types/event.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type Events []Event 4 | 5 | type Event struct { 6 | Event string `json:"event,omitempty"` 7 | Conditions []string `json:"conditions,omitempty"` 8 | Hooks []Hook `json:"hooks,omitempty"` 9 | } 10 | 11 | func (e Events) GetEvents(event string) (events []Event) { 12 | for _, evt := range e { 13 | if evt.Event == event { 14 | events = append(events, evt) 15 | } 16 | } 17 | 18 | return 19 | } 20 | -------------------------------------------------------------------------------- /internal/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type Template struct { 4 | Receiver string `json:"receiver"` 5 | Auth Auth `json:"auth"` 6 | EventTypeIn string `json:"event_type_in"` 7 | EventTypeKey string `json:"event_type_key"` 8 | Events Events `json:"events,omitempty"` 9 | } 10 | 11 | type Hook struct { 12 | Name string `json:"name"` 13 | EndpointKey string `json:"endpoint_key"` 14 | Body string `json:"body,omitempty"` 15 | } 16 | 17 | type Auth struct { 18 | Flow string `json:"flow"` 19 | HeaderSecretKey string `json:"header_secret_key,omitempty"` 20 | Secret string `json:"secret"` 21 | } 22 | --------------------------------------------------------------------------------