feat: Gotify CRUD + internal handler dispatch for notification service

- Add internal handler support to ServiceAdapter (Handler field)
- Dispatcher routes to internal handler when set (no reverse proxy)
- GotifyClient: full CRUD (send/list/delete messages, CRUD applications)
- Refactor notification handler: route by X-Resource header, not body format
- Register notification as ServiceAdapter with auth required
- Resources: send-email, send-message, list-messages, delete-message,
  delete-all-messages, list-applications, create-application, delete-application
- Update examples: sendmsg-email.sh, gotify-crud.sh
- Env vars: GOTIFY_URL, GOTIFY_APP_TOKEN, GOTIFY_CLIENT_TOKEN
This commit is contained in:
Admin Bot
2026-09-15 15:14:13 +09:00
parent 743148f576
commit 18e2031ab9
7 changed files with 605 additions and 207 deletions
+24
View File
@@ -11,6 +11,7 @@ import (
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth" "forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config" "forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/notification"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/proxy" "forgejo.riotpiao.com/rock/homelab-frontend/internal/proxy"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/server" "forgejo.riotpiao.com/rock/homelab-frontend/internal/server"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter" "forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
@@ -82,6 +83,29 @@ func main() {
} }
_ = registry.Add(workflowAdapter) _ = registry.Add(workflowAdapter)
// Add notification service adapter (internal handler, no upstream proxy)
notifHandler := notification.NewHandler()
notifAdapter := &serviceadapter.ServiceAdapter{
Namespace: "notification",
ServiceName: "notification",
Handler: notifHandler,
Spec: serviceadapter.Spec{
ServiceName: "notification",
Auth: serviceadapter.Auth{Required: true},
Resources: []serviceadapter.Resource{
{Name: "send-email", Methods: []serviceadapter.Method{{Verb: "POST", UpstreamPath: "/send-email"}}},
{Name: "send-message", Methods: []serviceadapter.Method{{Verb: "POST", UpstreamPath: "/send-message"}}},
{Name: "list-messages", Methods: []serviceadapter.Method{{Verb: "GET", UpstreamPath: "/list-messages"}}},
{Name: "delete-message", Methods: []serviceadapter.Method{{Verb: "DELETE", UpstreamPath: "/delete-message"}}},
{Name: "delete-all-messages", Methods: []serviceadapter.Method{{Verb: "DELETE", UpstreamPath: "/delete-all-messages"}}},
{Name: "list-applications", Methods: []serviceadapter.Method{{Verb: "GET", UpstreamPath: "/list-applications"}}},
{Name: "create-application", Methods: []serviceadapter.Method{{Verb: "POST", UpstreamPath: "/create-application"}}},
{Name: "delete-application", Methods: []serviceadapter.Method{{Verb: "DELETE", UpstreamPath: "/delete-application"}}},
},
},
}
_ = registry.Add(notifAdapter)
// Add other adapters from config // Add other adapters from config
for _, a := range cfg.Adapters { for _, a := range cfg.Adapters {
_ = registry.Add(a) _ = registry.Add(a)
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
# Example: Gotify CRUD operations via notification service (X-Service routing)
BASE_URL="${1:-https://api.riotpiao.com}"
AUTH_TOKEN="${2:-}"
AUTH="-H \"Authorization: Bearer $AUTH_TOKEN\""
echo "=== Send Gotify Message ==="
curl -s -X POST "$BASE_URL" \
-H "X-Service: notification" \
-H "X-Resource: send-message" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-d '{
"title": "Deployment Complete",
"message": "homelab-frontend v1.2.0 deployed to production",
"priority": 5
}' | jq .
echo ""
echo "=== List Messages ==="
curl -s -X GET "$BASE_URL?limit=10" \
-H "X-Service: notification" \
-H "X-Resource: list-messages" \
-H "Authorization: Bearer $AUTH_TOKEN" | jq .
echo ""
echo "=== List Applications ==="
curl -s -X GET "$BASE_URL" \
-H "X-Service: notification" \
-H "X-Resource: list-applications" \
-H "Authorization: Bearer $AUTH_TOKEN" | jq .
echo ""
echo "=== Create Application ==="
curl -s -X POST "$BASE_URL" \
-H "X-Service: notification" \
-H "X-Resource: create-application" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-d '{
"name": "my-monitor",
"description": "Monitoring alerts"
}' | jq .
echo ""
echo "=== Delete Message (by ID) ==="
curl -s -X DELETE "$BASE_URL" \
-H "X-Service: notification" \
-H "X-Resource: delete-message" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-d '{"id": 1}' | jq .
echo ""
echo "=== Delete Application (by ID) ==="
curl -s -X DELETE "$BASE_URL" \
-H "X-Service: notification" \
-H "X-Resource: delete-application" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-d '{"id": 1}' | jq .
+11 -27
View File
@@ -1,36 +1,20 @@
#!/bin/bash #!/bin/bash
# Example: Send email via notification/sendMsg endpoint # Example: Send email via notification service (X-Service routing)
BASE_URL="${1:-https://api.riotpiao.com}" BASE_URL="${1:-https://api.riotpiao.com}"
AUTH_TOKEN="${2:-}" # Optional JWT token if auth required AUTH_TOKEN="${2:-}"
PAYLOAD=$(cat <<'EOF' # Send email
{ curl -s -X POST "$BASE_URL" \
"format": "smtp",
"title": "System Alert",
"message": "CPU usage exceeded 90% threshold",
"priority": 7,
"extras": {
"to_email": "[email protected]",
"cc": "[email protected]"
}
}
EOF
)
if [ -n "$AUTH_TOKEN" ]; then
curl -X POST "$BASE_URL" \
-H "X-Service: notification" \ -H "X-Service: notification" \
-H "X-Resource: sendMsg" \ -H "X-Resource: send-email" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "Authorization: Bearer $AUTH_TOKEN" \ -H "Authorization: Bearer $AUTH_TOKEN" \
-d "$PAYLOAD" -d '{
else "to": "[email protected]",
curl -X POST "$BASE_URL" \ "cc": "[email protected]",
-H "X-Service: notification" \ "subject": "System Alert",
-H "X-Resource: sendMsg" \ "body": "CPU usage exceeded 90% threshold"
-H "Content-Type: application/json" \ }' | jq .
-d "$PAYLOAD"
fi
echo "" echo ""
+257
View File
@@ -0,0 +1,257 @@
package notification
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// GotifyClient is a CRUD client for the Gotify API.
type GotifyClient struct {
baseURL string
appToken string // token for sending messages (application token)
clientToken string // token for reading/managing (client token)
httpClient *http.Client
}
// NewGotifyClient creates a Gotify API client.
// appToken is used for sending messages.
// clientToken is used for listing/deleting messages and managing applications.
func NewGotifyClient(baseURL, appToken, clientToken string) *GotifyClient {
return &GotifyClient{
baseURL: baseURL,
appToken: appToken,
clientToken: clientToken,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// --- Message Types ---
// GotifyMessage represents a Gotify message.
type GotifyMessage struct {
ID int `json:"id,omitempty"`
AppID int `json:"appid,omitempty"`
Title string `json:"title"`
Message string `json:"message"`
Priority int `json:"priority,omitempty"`
Date string `json:"date,omitempty"`
Extras map[string]interface{} `json:"extras,omitempty"`
}
// GotifyMessageList is a paginated list of messages.
type GotifyMessageList struct {
Messages []GotifyMessage `json:"messages"`
Paging GotifyPaging `json:"paging"`
}
// GotifyPaging represents pagination info.
type GotifyPaging struct {
Size int `json:"size"`
Since int `json:"since"`
Limit int `json:"limit"`
Next string `json:"next,omitempty"`
}
// --- Application Types ---
// GotifyApplication represents a Gotify application.
type GotifyApplication struct {
ID int `json:"id,omitempty"`
Token string `json:"token,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Image string `json:"image,omitempty"`
Internal bool `json:"internal,omitempty"`
}
// --- Message CRUD ---
// SendMessage sends a message via Gotify (uses app token).
func (c *GotifyClient) SendMessage(msg GotifyMessage) (*GotifyMessage, error) {
body, err := json.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("marshal message: %w", err)
}
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/message", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Gotify-Key", c.appToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("send message: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, c.readError(resp)
}
var result GotifyMessage
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &result, nil
}
// ListMessages lists messages (uses client token).
func (c *GotifyClient) ListMessages(limit int) (*GotifyMessageList, error) {
url := fmt.Sprintf("%s/message?limit=%d", c.baseURL, limit)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("X-Gotify-Key", c.clientToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("list messages: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, c.readError(resp)
}
var result GotifyMessageList
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &result, nil
}
// DeleteMessage deletes a message by ID (uses client token).
func (c *GotifyClient) DeleteMessage(id int) error {
url := fmt.Sprintf("%s/message/%d", c.baseURL, id)
req, err := http.NewRequest(http.MethodDelete, url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("X-Gotify-Key", c.clientToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("delete message: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
return c.readError(resp)
}
return nil
}
// DeleteAllMessages deletes all messages (uses client token).
func (c *GotifyClient) DeleteAllMessages() error {
req, err := http.NewRequest(http.MethodDelete, c.baseURL+"/message", nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("X-Gotify-Key", c.clientToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("delete all messages: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
return c.readError(resp)
}
return nil
}
// --- Application CRUD ---
// ListApplications lists all applications (uses client token).
func (c *GotifyClient) ListApplications() ([]GotifyApplication, error) {
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/application", nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("X-Gotify-Key", c.clientToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("list applications: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, c.readError(resp)
}
var result []GotifyApplication
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return result, nil
}
// CreateApplication creates a new application (uses client token).
func (c *GotifyClient) CreateApplication(app GotifyApplication) (*GotifyApplication, error) {
body, err := json.Marshal(app)
if err != nil {
return nil, fmt.Errorf("marshal application: %w", err)
}
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/application", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Gotify-Key", c.clientToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("create application: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, c.readError(resp)
}
var result GotifyApplication
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &result, nil
}
// DeleteApplication deletes an application by ID (uses client token).
func (c *GotifyClient) DeleteApplication(id int) error {
url := fmt.Sprintf("%s/application/%d", c.baseURL, id)
req, err := http.NewRequest(http.MethodDelete, url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("X-Gotify-Key", c.clientToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("delete application: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
return c.readError(resp)
}
return nil
}
// --- Helpers ---
func (c *GotifyClient) readError(resp *http.Response) error {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("gotify API error (HTTP %d): %s", resp.StatusCode, string(body))
}
+219 -160
View File
@@ -7,226 +7,285 @@ import (
"net/http" "net/http"
"net/smtp" "net/smtp"
"os" "os"
"strings" "strconv"
) )
// SendMsgRequest represents a sendMsg API request. // Handler handles notification requests routed via X-Resource header.
type SendMsgRequest struct { // Supports: send-email, send-gotify, list-messages, delete-message,
Format string `json:"format"` // "smtp" or "sms" // delete-all-messages, list-applications, create-application, delete-application.
Title string `json:"title"`
Message string `json:"message"`
Priority int `json:"priority,omitempty"`
Extras map[string]string `json:"extras,omitempty"` // e.g., {"to_email": "[email protected]", "phone": "+1234567890"}
}
// SendMsgResponse represents a sendMsg API response.
type SendMsgResponse struct {
Status string `json:"status"`
MessageID string `json:"messageId,omitempty"`
Error string `json:"error,omitempty"`
}
// Handler handles sendMsg requests and forwards to appropriate channel (email, SMS, or Gotify push).
type Handler struct { type Handler struct {
smtpHost string smtpHost string
smtpPort string smtpPort string
smtpFrom string smtpFrom string
smtpUser string smtpUser string
smtpPass string smtpPass string
smsAPIURL string gotify *GotifyClient
smsAPIKey string
gotifyURL string
gotifyToken string
} }
// NewHandler creates a new notification handler from environment variables. // NewHandler creates a notification handler from environment variables.
func NewHandler() *Handler { func NewHandler() *Handler {
var gotify *GotifyClient
gotifyURL := os.Getenv("GOTIFY_URL")
if gotifyURL != "" {
gotify = NewGotifyClient(
gotifyURL,
os.Getenv("GOTIFY_APP_TOKEN"),
os.Getenv("GOTIFY_CLIENT_TOKEN"),
)
}
return &Handler{ return &Handler{
smtpHost: os.Getenv("SMTP_HOST"), smtpHost: os.Getenv("SMTP_HOST"),
smtpPort: os.Getenv("SMTP_PORT"), smtpPort: os.Getenv("SMTP_PORT"),
smtpFrom: os.Getenv("SMTP_FROM"), smtpFrom: os.Getenv("SMTP_FROM"),
smtpUser: os.Getenv("SMTP_USER"), smtpUser: os.Getenv("SMTP_USER"),
smtpPass: os.Getenv("SMTP_PASS"), smtpPass: os.Getenv("SMTP_PASS"),
smsAPIURL: os.Getenv("SMS_API_URL"), gotify: gotify,
smsAPIKey: os.Getenv("SMS_API_KEY"),
gotifyURL: os.Getenv("GOTIFY_URL"),
gotifyToken: os.Getenv("GOTIFY_TOKEN"),
} }
} }
// ServeHTTP handles sendMsg requests. // ServeHTTP routes requests by X-Upstream-Path (set by dispatcher after resource matching).
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { resource := r.Header.Get("X-Resource")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req SendMsgRequest switch resource {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { // --- Email ---
w.Header().Set("Content-Type", "application/json") case "send-email":
w.WriteHeader(http.StatusBadRequest) h.handleSendEmail(w, r)
json.NewEncoder(w).Encode(SendMsgResponse{
Status: "error", // --- Gotify Messages ---
Error: "invalid request: " + err.Error(), case "send-message":
}) h.handleSendGotify(w, r)
return case "list-messages":
} h.handleListMessages(w, r)
case "delete-message":
h.handleDeleteMessage(w, r)
case "delete-all-messages":
h.handleDeleteAllMessages(w, r)
// --- Gotify Applications ---
case "list-applications":
h.handleListApplications(w, r)
case "create-application":
h.handleCreateApplication(w, r)
case "delete-application":
h.handleDeleteApplication(w, r)
// Route based on format
var resp SendMsgResponse
switch req.Format {
case "smtp":
resp = h.sendEmail(req)
case "sms":
resp = h.sendSMS(req)
case "gotify":
resp = h.sendGotify(req)
default: default:
resp = SendMsgResponse{ h.writeJSON(w, http.StatusNotFound, map[string]string{
Status: "error", "error": fmt.Sprintf("unknown resource: %s", resource),
Error: "unsupported format: " + req.Format, })
} }
} }
w.Header().Set("Content-Type", "application/json") // --- Email ---
if resp.Error != "" {
w.WriteHeader(http.StatusInternalServerError) type SendEmailRequest struct {
} else { To string `json:"to"`
w.WriteHeader(http.StatusOK) CC string `json:"cc,omitempty"`
} Subject string `json:"subject"`
json.NewEncoder(w).Encode(resp) Body string `json:"body"`
} }
// sendEmail sends an email via SMTP. func (h *Handler) handleSendEmail(w http.ResponseWriter, r *http.Request) {
func (h *Handler) sendEmail(req SendMsgRequest) SendMsgResponse { var req SendEmailRequest
toEmail := req.Extras["to_email"] if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if toEmail == "" { h.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request: " + err.Error()})
return SendMsgResponse{ return
Status: "error",
Error: "missing to_email in extras",
}
} }
subject := req.Title if req.To == "" {
h.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing 'to' field"})
return
}
subject := req.Subject
if subject == "" { if subject == "" {
subject = "Notification" subject = "Notification"
} }
// Construct email body
body := req.Message
if req.Extras != nil {
if cc := req.Extras["cc"]; cc != "" {
body = fmt.Sprintf("CC: %s\n\n%s", cc, body)
}
}
msg := fmt.Sprintf( msg := fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", "From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
h.smtpFrom, toEmail, subject, body, h.smtpFrom, req.To, subject, req.Body,
) )
// Send via SMTP
smtpAddr := fmt.Sprintf("%s:%s", h.smtpHost, h.smtpPort) smtpAddr := fmt.Sprintf("%s:%s", h.smtpHost, h.smtpPort)
auth := smtp.PlainAuth("", h.smtpUser, h.smtpPass, h.smtpHost) auth := smtp.PlainAuth("", h.smtpUser, h.smtpPass, h.smtpHost)
if err := smtp.SendMail(smtpAddr, auth, h.smtpFrom, []string{toEmail}, []byte(msg)); err != nil { if err := smtp.SendMail(smtpAddr, auth, h.smtpFrom, []string{req.To}, []byte(msg)); err != nil {
log.Printf("error sending email to %s: %v", toEmail, err) log.Printf("error sending email to %s: %v", req.To, err)
return SendMsgResponse{ h.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to send email: " + err.Error()})
Status: "error", return
Error: "failed to send email: " + err.Error(),
}
} }
return SendMsgResponse{ h.writeJSON(w, http.StatusOK, map[string]string{
Status: "success", "status": "success",
MessageID: fmt.Sprintf("email-%s", toEmail), "messageId": fmt.Sprintf("email-%s", req.To),
} })
} }
// sendSMS sends an SMS via configured provider. // --- Gotify Messages ---
// Placeholder: integrate with Twilio, AWS SNS, or similar.
func (h *Handler) sendSMS(req SendMsgRequest) SendMsgResponse { func (h *Handler) handleSendGotify(w http.ResponseWriter, r *http.Request) {
phone := req.Extras["phone"] if h.gotify == nil {
if phone == "" { h.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Gotify not configured"})
return SendMsgResponse{ return
Status: "error",
Error: "missing phone in extras",
}
} }
// TODO: Implement SMS provider integration (Twilio, AWS SNS, etc.) var msg GotifyMessage
// For now, return error if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return SendMsgResponse{ h.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request: " + err.Error()})
Status: "error", return
Error: "SMS not implemented yet",
}
} }
// sendGotify sends a notification via Gotify server. result, err := h.gotify.SendMessage(msg)
func (h *Handler) sendGotify(req SendMsgRequest) SendMsgResponse {
if h.gotifyURL == "" || h.gotifyToken == "" {
return SendMsgResponse{
Status: "error",
Error: "Gotify not configured (missing GOTIFY_URL or GOTIFY_TOKEN)",
}
}
// Construct Gotify message
gotifyReq := map[string]interface{}{
"title": req.Title,
"message": req.Message,
"priority": req.Priority,
}
// Marshal to JSON
body, err := json.Marshal(gotifyReq)
if err != nil { if err != nil {
log.Printf("error marshaling Gotify request: %v", err) log.Printf("error sending gotify message: %v", err)
return SendMsgResponse{ h.writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
Status: "error", return
Error: "failed to marshal request: " + err.Error(), }
h.writeJSON(w, http.StatusOK, result)
}
func (h *Handler) handleListMessages(w http.ResponseWriter, r *http.Request) {
if h.gotify == nil {
h.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Gotify not configured"})
return
}
limit := 50
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
} }
} }
// POST to Gotify result, err := h.gotify.ListMessages(limit)
gotifyEndpoint := fmt.Sprintf("%s/message?token=%s", h.gotifyURL, h.gotifyToken)
resp, err := http.Post(gotifyEndpoint, "application/json", strings.NewReader(string(body)))
if err != nil { if err != nil {
log.Printf("error sending to Gotify: %v", err) log.Printf("error listing gotify messages: %v", err)
return SendMsgResponse{ h.writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
Status: "error", return
Error: "failed to send to Gotify: " + err.Error(),
}
}
defer resp.Body.Close()
// Check response status
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
log.Printf("Gotify returned status %d", resp.StatusCode)
return SendMsgResponse{
Status: "error",
Error: fmt.Sprintf("Gotify returned status %d", resp.StatusCode),
}
} }
// Parse response h.writeJSON(w, http.StatusOK, result)
var gotifyResp map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&gotifyResp); err != nil {
log.Printf("error decoding Gotify response: %v", err)
return SendMsgResponse{
Status: "success",
MessageID: "gotify-sent",
}
} }
// Extract message ID if available func (h *Handler) handleDeleteMessage(w http.ResponseWriter, r *http.Request) {
msgID := "gotify-sent" if h.gotify == nil {
if id, ok := gotifyResp["id"]; ok { h.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Gotify not configured"})
msgID = fmt.Sprintf("gotify-%v", id) return
} }
return SendMsgResponse{ var req struct {
Status: "success", ID int `json:"id"`
MessageID: msgID,
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request: " + err.Error()})
return
}
if req.ID == 0 {
h.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing 'id' field"})
return
}
if err := h.gotify.DeleteMessage(req.ID); err != nil {
log.Printf("error deleting gotify message %d: %v", req.ID, err)
h.writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
h.writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (h *Handler) handleDeleteAllMessages(w http.ResponseWriter, r *http.Request) {
if h.gotify == nil {
h.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Gotify not configured"})
return
}
if err := h.gotify.DeleteAllMessages(); err != nil {
log.Printf("error deleting all gotify messages: %v", err)
h.writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
h.writeJSON(w, http.StatusOK, map[string]string{"status": "all messages deleted"})
}
// --- Gotify Applications ---
func (h *Handler) handleListApplications(w http.ResponseWriter, r *http.Request) {
if h.gotify == nil {
h.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Gotify not configured"})
return
}
result, err := h.gotify.ListApplications()
if err != nil {
log.Printf("error listing gotify applications: %v", err)
h.writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
h.writeJSON(w, http.StatusOK, result)
}
func (h *Handler) handleCreateApplication(w http.ResponseWriter, r *http.Request) {
if h.gotify == nil {
h.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Gotify not configured"})
return
}
var app GotifyApplication
if err := json.NewDecoder(r.Body).Decode(&app); err != nil {
h.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request: " + err.Error()})
return
}
result, err := h.gotify.CreateApplication(app)
if err != nil {
log.Printf("error creating gotify application: %v", err)
h.writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
h.writeJSON(w, http.StatusCreated, result)
}
func (h *Handler) handleDeleteApplication(w http.ResponseWriter, r *http.Request) {
if h.gotify == nil {
h.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Gotify not configured"})
return
}
var req struct {
ID int `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request: " + err.Error()})
return
}
if req.ID == 0 {
h.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing 'id' field"})
return
}
if err := h.gotify.DeleteApplication(req.ID); err != nil {
log.Printf("error deleting gotify application %d: %v", req.ID, err)
h.writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
h.writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// --- Helpers ---
func (h *Handler) writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
} }
+8
View File
@@ -80,6 +80,14 @@ func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
} }
} }
// Internal handler: dispatch directly without reverse proxy
if adapter.Handler != nil {
// Set X-Upstream-Path so the handler knows which method was matched
r.Header.Set("X-Upstream-Path", method.UpstreamPath)
adapter.Handler.ServeHTTP(w, r)
return
}
upstreamURL := adapter.Spec.Upstream.URL upstreamURL := adapter.Spec.Upstream.URL
if strings.HasPrefix(upstreamURL, "grpc://") { if strings.HasPrefix(upstreamURL, "grpc://") {
d.dispatchGRPC(w, r, upstreamURL, method, adapter) d.dispatchGRPC(w, r, upstreamURL, method, adapter)
+4
View File
@@ -1,6 +1,7 @@
package serviceadapter package serviceadapter
import ( import (
"net/http"
"time" "time"
) )
@@ -49,6 +50,8 @@ type Status struct {
} }
// ServiceAdapter is a gateway service adapter. // ServiceAdapter is a gateway service adapter.
// When Handler is set, the dispatcher routes directly to the internal handler
// instead of reverse-proxying to Spec.Upstream.URL.
type ServiceAdapter struct { type ServiceAdapter struct {
Name string // namespace/name Name string // namespace/name
Namespace string Namespace string
@@ -56,4 +59,5 @@ type ServiceAdapter struct {
Spec Spec Spec Spec
Status Status Status Status
CreatedAt time.Time CreatedAt time.Time
Handler http.Handler `json:"-" yaml:"-"` // internal handler (skip serialization)
} }