All checks were successful
Implement a stateless Go service that routes /api/v1/send_push and /api/v1/ack by platform prefix to configured backends, enabling mixed official and custom mobile clients on a single PushNotificationServer URL. Co-authored-by: Cursor <cursoragent@cursor.com>
159 lines
3.2 KiB
Go
159 lines
3.2 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
)
|
|
|
|
const defaultPort = "8066"
|
|
const defaultRequestTimeoutSec = 60
|
|
|
|
type Route struct {
|
|
Prefixes []string
|
|
URL string
|
|
}
|
|
|
|
type Config struct {
|
|
Port string
|
|
Routes []Route
|
|
RequestTimeout time.Duration
|
|
}
|
|
|
|
type fileConfig struct {
|
|
Listen string `toml:"listen"`
|
|
RequestTimeoutSec int `toml:"request_timeout_sec"`
|
|
Routes []routeConfig `toml:"routes"`
|
|
}
|
|
|
|
type routeConfig struct {
|
|
Prefixes []string `toml:"prefixes"`
|
|
URL string `toml:"url"`
|
|
}
|
|
|
|
func LoadConfig() (Config, error) {
|
|
configFile := os.Getenv("CONFIG_FILE")
|
|
if configFile == "" {
|
|
return Config{}, fmt.Errorf("CONFIG_FILE is required")
|
|
}
|
|
|
|
data, err := os.ReadFile(configFile)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("read config file: %w", err)
|
|
}
|
|
|
|
var fc fileConfig
|
|
if err := toml.Unmarshal(data, &fc); err != nil {
|
|
return Config{}, fmt.Errorf("invalid config file: %w", err)
|
|
}
|
|
|
|
cfg := Config{
|
|
Port: envOr("PORT", listenPort(fc.Listen)),
|
|
RequestTimeout: time.Duration(envIntOr("REQUEST_TIMEOUT_SEC", timeoutSec(fc.RequestTimeoutSec))) * time.Second,
|
|
}
|
|
|
|
cfg.Routes, err = normalizeRoutes(fc.Routes)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
func listenPort(listen string) string {
|
|
listen = strings.TrimSpace(listen)
|
|
if listen == "" {
|
|
return defaultPort
|
|
}
|
|
if strings.HasPrefix(listen, ":") {
|
|
return strings.TrimPrefix(listen, ":")
|
|
}
|
|
if idx := strings.LastIndex(listen, ":"); idx >= 0 && idx < len(listen)-1 {
|
|
return listen[idx+1:]
|
|
}
|
|
return listen
|
|
}
|
|
|
|
func timeoutSec(sec int) int {
|
|
if sec <= 0 {
|
|
return defaultRequestTimeoutSec
|
|
}
|
|
return sec
|
|
}
|
|
|
|
func normalizeRoutes(raw []routeConfig) ([]Route, error) {
|
|
if len(raw) == 0 {
|
|
return nil, fmt.Errorf("at least one route is required")
|
|
}
|
|
|
|
routes := make([]Route, 0, len(raw))
|
|
seen := make(map[string]string)
|
|
|
|
for i, r := range raw {
|
|
url := strings.TrimRight(strings.TrimSpace(r.URL), "/")
|
|
if url == "" {
|
|
return nil, fmt.Errorf("route %d: url is required", i)
|
|
}
|
|
|
|
prefixes := make([]string, 0, len(r.Prefixes))
|
|
for _, prefix := range r.Prefixes {
|
|
prefix = strings.TrimSpace(prefix)
|
|
if prefix == "" {
|
|
continue
|
|
}
|
|
if otherURL, ok := seen[prefix]; ok {
|
|
return nil, fmt.Errorf("duplicate prefix %q in routes for %q and %q", prefix, otherURL, url)
|
|
}
|
|
seen[prefix] = url
|
|
prefixes = append(prefixes, prefix)
|
|
}
|
|
|
|
if len(prefixes) == 0 {
|
|
return nil, fmt.Errorf("route %d: at least one prefix is required", i)
|
|
}
|
|
|
|
routes = append(routes, Route{Prefixes: prefixes, URL: url})
|
|
}
|
|
|
|
return routes, nil
|
|
}
|
|
|
|
func (c Config) BackendForPlatform(platform string) (string, bool) {
|
|
platform = stripVersionSuffix(platform)
|
|
if platform == "" {
|
|
return "", false
|
|
}
|
|
|
|
for _, route := range c.Routes {
|
|
for _, prefix := range route.Prefixes {
|
|
if platform == prefix {
|
|
return route.URL, true
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
func envOr(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func envIntOr(key string, fallback int) int {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return fallback
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return n
|
|
}
|