Skip to content

Commit ce9fa18

Browse files
FEATURE (webhook): Add webhook customization
1 parent 281e185 commit ce9fa18

File tree

8 files changed

+411
-102
lines changed

8 files changed

+411
-102
lines changed

backend/internal/features/notifiers/models/webhook/model.go

Lines changed: 154 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,57 @@ import (
1010
"net/http"
1111
"net/url"
1212
"postgresus-backend/internal/util/encryption"
13+
"strings"
1314

1415
"github.com/google/uuid"
16+
"gorm.io/gorm"
1517
)
1618

19+
type WebhookHeader struct {
20+
Key string `json:"key"`
21+
Value string `json:"value"`
22+
}
23+
1724
type WebhookNotifier struct {
1825
NotifierID uuid.UUID `json:"notifierId" gorm:"primaryKey;column:notifier_id"`
1926
WebhookURL string `json:"webhookUrl" gorm:"not null;column:webhook_url"`
2027
WebhookMethod WebhookMethod `json:"webhookMethod" gorm:"not null;column:webhook_method"`
28+
BodyTemplate *string `json:"bodyTemplate" gorm:"column:body_template;type:text"`
29+
HeadersJSON string `json:"-" gorm:"column:headers;type:text"`
30+
31+
Headers []WebhookHeader `json:"headers" gorm:"-"`
2132
}
2233

2334
func (t *WebhookNotifier) TableName() string {
2435
return "webhook_notifiers"
2536
}
2637

38+
func (t *WebhookNotifier) BeforeSave(_ *gorm.DB) error {
39+
if len(t.Headers) > 0 {
40+
data, err := json.Marshal(t.Headers)
41+
42+
if err != nil {
43+
return err
44+
}
45+
46+
t.HeadersJSON = string(data)
47+
} else {
48+
t.HeadersJSON = "[]"
49+
}
50+
51+
return nil
52+
}
53+
54+
func (t *WebhookNotifier) AfterFind(_ *gorm.DB) error {
55+
if t.HeadersJSON != "" {
56+
if err := json.Unmarshal([]byte(t.HeadersJSON), &t.Headers); err != nil {
57+
return err
58+
}
59+
}
60+
61+
return nil
62+
}
63+
2764
func (t *WebhookNotifier) Validate(encryptor encryption.FieldEncryptor) error {
2865
if t.WebhookURL == "" {
2966
return errors.New("webhook URL is required")
@@ -49,66 +86,9 @@ func (t *WebhookNotifier) Send(
4986

5087
switch t.WebhookMethod {
5188
case WebhookMethodGET:
52-
reqURL := fmt.Sprintf("%s?heading=%s&message=%s",
53-
webhookURL,
54-
url.QueryEscape(heading),
55-
url.QueryEscape(message),
56-
)
57-
58-
resp, err := http.Get(reqURL)
59-
if err != nil {
60-
return fmt.Errorf("failed to send GET webhook: %w", err)
61-
}
62-
defer func() {
63-
if cerr := resp.Body.Close(); cerr != nil {
64-
logger.Error("failed to close response body", "error", cerr)
65-
}
66-
}()
67-
68-
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
69-
body, _ := io.ReadAll(resp.Body)
70-
return fmt.Errorf(
71-
"webhook GET returned status: %s, body: %s",
72-
resp.Status,
73-
string(body),
74-
)
75-
}
76-
77-
return nil
78-
89+
return t.sendGET(webhookURL, heading, message, logger)
7990
case WebhookMethodPOST:
80-
payload := map[string]string{
81-
"heading": heading,
82-
"message": message,
83-
}
84-
85-
body, err := json.Marshal(payload)
86-
if err != nil {
87-
return fmt.Errorf("failed to marshal webhook payload: %w", err)
88-
}
89-
90-
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
91-
if err != nil {
92-
return fmt.Errorf("failed to send POST webhook: %w", err)
93-
}
94-
95-
defer func() {
96-
if cerr := resp.Body.Close(); cerr != nil {
97-
logger.Error("failed to close response body", "error", cerr)
98-
}
99-
}()
100-
101-
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
102-
body, _ := io.ReadAll(resp.Body)
103-
return fmt.Errorf(
104-
"webhook POST returned status: %s, body: %s",
105-
resp.Status,
106-
string(body),
107-
)
108-
}
109-
110-
return nil
111-
91+
return t.sendPOST(webhookURL, heading, message, logger)
11292
default:
11393
return fmt.Errorf("unsupported webhook method: %s", t.WebhookMethod)
11494
}
@@ -120,15 +100,130 @@ func (t *WebhookNotifier) HideSensitiveData() {
120100
func (t *WebhookNotifier) Update(incoming *WebhookNotifier) {
121101
t.WebhookURL = incoming.WebhookURL
122102
t.WebhookMethod = incoming.WebhookMethod
103+
t.BodyTemplate = incoming.BodyTemplate
104+
t.Headers = incoming.Headers
123105
}
124106

125107
func (t *WebhookNotifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
126108
if t.WebhookURL != "" {
127109
encrypted, err := encryptor.Encrypt(t.NotifierID, t.WebhookURL)
110+
128111
if err != nil {
129112
return fmt.Errorf("failed to encrypt webhook URL: %w", err)
130113
}
114+
131115
t.WebhookURL = encrypted
132116
}
117+
133118
return nil
134119
}
120+
121+
func (t *WebhookNotifier) sendGET(webhookURL, heading, message string, logger *slog.Logger) error {
122+
reqURL := fmt.Sprintf("%s?heading=%s&message=%s",
123+
webhookURL,
124+
url.QueryEscape(heading),
125+
url.QueryEscape(message),
126+
)
127+
128+
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
129+
if err != nil {
130+
return fmt.Errorf("failed to create GET request: %w", err)
131+
}
132+
133+
t.applyHeaders(req)
134+
135+
client := &http.Client{}
136+
resp, err := client.Do(req)
137+
if err != nil {
138+
return fmt.Errorf("failed to send GET webhook: %w", err)
139+
}
140+
141+
defer func() {
142+
if cerr := resp.Body.Close(); cerr != nil {
143+
logger.Error("failed to close response body", "error", cerr)
144+
}
145+
}()
146+
147+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
148+
body, _ := io.ReadAll(resp.Body)
149+
return fmt.Errorf(
150+
"webhook GET returned status: %s, body: %s",
151+
resp.Status,
152+
string(body),
153+
)
154+
}
155+
156+
return nil
157+
}
158+
159+
func (t *WebhookNotifier) sendPOST(webhookURL, heading, message string, logger *slog.Logger) error {
160+
body := t.buildRequestBody(heading, message)
161+
162+
req, err := http.NewRequest(http.MethodPost, webhookURL, bytes.NewReader(body))
163+
if err != nil {
164+
return fmt.Errorf("failed to create POST request: %w", err)
165+
}
166+
167+
hasContentType := false
168+
169+
for _, h := range t.Headers {
170+
if strings.EqualFold(h.Key, "Content-Type") {
171+
hasContentType = true
172+
break
173+
}
174+
}
175+
176+
if !hasContentType {
177+
req.Header.Set("Content-Type", "application/json")
178+
}
179+
180+
t.applyHeaders(req)
181+
182+
client := &http.Client{}
183+
resp, err := client.Do(req)
184+
if err != nil {
185+
return fmt.Errorf("failed to send POST webhook: %w", err)
186+
}
187+
188+
defer func() {
189+
if cerr := resp.Body.Close(); cerr != nil {
190+
logger.Error("failed to close response body", "error", cerr)
191+
}
192+
}()
193+
194+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
195+
respBody, _ := io.ReadAll(resp.Body)
196+
return fmt.Errorf(
197+
"webhook POST returned status: %s, body: %s",
198+
resp.Status,
199+
string(respBody),
200+
)
201+
}
202+
203+
return nil
204+
}
205+
206+
func (t *WebhookNotifier) buildRequestBody(heading, message string) []byte {
207+
if t.BodyTemplate != nil && *t.BodyTemplate != "" {
208+
result := *t.BodyTemplate
209+
result = strings.ReplaceAll(result, "{{heading}}", heading)
210+
result = strings.ReplaceAll(result, "{{message}}", message)
211+
return []byte(result)
212+
}
213+
214+
payload := map[string]string{
215+
"heading": heading,
216+
"message": message,
217+
}
218+
body, _ := json.Marshal(payload)
219+
220+
return body
221+
}
222+
223+
func (t *WebhookNotifier) applyHeaders(req *http.Request) {
224+
for _, h := range t.Headers {
225+
if h.Key != "" {
226+
req.Header.Set(h.Key, h.Value)
227+
}
228+
}
229+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- +goose Up
2+
-- +goose StatementBegin
3+
4+
ALTER TABLE webhook_notifiers
5+
ADD COLUMN body_template TEXT,
6+
ADD COLUMN headers TEXT DEFAULT '[]';
7+
8+
-- +goose StatementEnd
9+
10+
-- +goose Down
11+
-- +goose StatementBegin
12+
13+
ALTER TABLE webhook_notifiers
14+
DROP COLUMN body_template,
15+
DROP COLUMN headers;
16+
17+
-- +goose StatementEnd
18+

frontend/src/entity/notifiers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type { TelegramNotifier } from './models/telegram/TelegramNotifier';
99
export { validateTelegramNotifier } from './models/telegram/validateTelegramNotifier';
1010

1111
export type { WebhookNotifier } from './models/webhook/WebhookNotifier';
12+
export type { WebhookHeader } from './models/webhook/WebhookHeader';
1213
export { validateWebhookNotifier } from './models/webhook/validateWebhookNotifier';
1314
export { WebhookMethod } from './models/webhook/WebhookMethod';
1415

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface WebhookHeader {
2+
key: string;
3+
value: string;
4+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import type { WebhookHeader } from './WebhookHeader';
12
import type { WebhookMethod } from './WebhookMethod';
23

34
export interface WebhookNotifier {
45
webhookUrl: string;
56
webhookMethod: WebhookMethod;
7+
bodyTemplate?: string;
8+
headers?: WebhookHeader[];
69
}

frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export function EditNotifierComponent({
119119
notifier.webhookNotifier = {
120120
webhookUrl: '',
121121
webhookMethod: WebhookMethod.POST,
122+
headers: [],
122123
};
123124
}
124125

0 commit comments

Comments
 (0)