Add CORS support and response caching to aggregate endpoints
- Introduced CORS configuration options in config.example.yaml, allowing specification of allowed origins for cross-origin requests. - Enhanced the aggregate handler to support response caching with a configurable TTL, improving performance for repeated requests. - Updated the aggregate API to return a structured response indicating whether any upstream requests failed, enhancing error handling and response clarity. - Modified documentation in AGGREGATE.md and README.md to reflect the new CORS and caching features. - Added tests to validate the new functionality in the aggregate handler.
This commit is contained in:
+157
-20
@@ -4,8 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/telemt/telemt-api/internal/config"
|
||||
@@ -14,17 +17,34 @@ import (
|
||||
|
||||
const pathPrefix = "/api/agg"
|
||||
|
||||
var aggUsernameRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
|
||||
|
||||
type cacheEntry struct {
|
||||
body []byte
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// Handler serves GET /api/agg/* aggregate endpoints.
|
||||
type Handler struct {
|
||||
Parsed *config.Parsed
|
||||
Client *http.Client
|
||||
Geo *geoip.Service
|
||||
Parsed *config.Parsed
|
||||
Client *http.Client
|
||||
Geo *geoip.Service
|
||||
CacheTTL time.Duration
|
||||
|
||||
cacheMu sync.Mutex
|
||||
cache map[string]cacheEntry
|
||||
}
|
||||
|
||||
// NewHandler builds an aggregate handler; client must use a non-nil Transport (e.g. gateway shared transport).
|
||||
// Geo may be nil (no GeoLite2 lookups).
|
||||
func NewHandler(p *config.Parsed, client *http.Client, geo *geoip.Service) *Handler {
|
||||
return &Handler{Parsed: p, Client: client, Geo: geo}
|
||||
// Geo may be nil (no GeoLite2 lookups). cacheTTL 0 disables response caching.
|
||||
func NewHandler(p *config.Parsed, client *http.Client, geo *geoip.Service, cacheTTL time.Duration) *Handler {
|
||||
return &Handler{
|
||||
Parsed: p,
|
||||
Client: client,
|
||||
Geo: geo,
|
||||
CacheTTL: cacheTTL,
|
||||
cache: make(map[string]cacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -36,19 +56,67 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
sub := strings.TrimPrefix(r.URL.Path, pathPrefix)
|
||||
sub = strings.TrimPrefix(sub, "/")
|
||||
switch sub {
|
||||
case "summary":
|
||||
|
||||
if h.CacheTTL > 0 {
|
||||
key := r.URL.Path + "\x00" + r.URL.RawQuery
|
||||
now := time.Now()
|
||||
h.cacheMu.Lock()
|
||||
ent, hit := h.cache[key]
|
||||
if hit && now.Before(ent.expires) {
|
||||
body := ent.body
|
||||
h.cacheMu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write(body)
|
||||
return
|
||||
}
|
||||
h.cacheMu.Unlock()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.dispatch(rec, r, sub)
|
||||
body := rec.Body.Bytes()
|
||||
if rec.Code == http.StatusOK {
|
||||
h.cacheMu.Lock()
|
||||
h.cache[key] = cacheEntry{body: append([]byte(nil), body...), expires: now.Add(h.CacheTTL)}
|
||||
h.cacheMu.Unlock()
|
||||
}
|
||||
copyRecorderToResponse(rec, w)
|
||||
return
|
||||
}
|
||||
|
||||
h.dispatch(w, r, sub)
|
||||
}
|
||||
|
||||
func copyRecorderToResponse(rec *httptest.ResponseRecorder, w http.ResponseWriter) {
|
||||
for k, vv := range rec.Header() {
|
||||
for _, v := range vv {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(rec.Code)
|
||||
_, _ = w.Write(rec.Body.Bytes())
|
||||
}
|
||||
|
||||
func (h *Handler) dispatch(w http.ResponseWriter, r *http.Request, sub string) {
|
||||
switch {
|
||||
case sub == "summary":
|
||||
h.handleSummary(w, r)
|
||||
case "traffic":
|
||||
case sub == "traffic":
|
||||
h.handleTraffic(w, r)
|
||||
case "unique-ips":
|
||||
case sub == "unique-ips":
|
||||
h.handleUniqueIPs(w, r)
|
||||
case "users":
|
||||
case sub == "users":
|
||||
h.handleUsers(w, r)
|
||||
case sub == "fleet-status":
|
||||
h.handleFleetStatus(w, r)
|
||||
case strings.HasPrefix(sub, "user/"):
|
||||
username := strings.TrimPrefix(sub, "user/")
|
||||
if username == "" {
|
||||
writeNotFound(w, "missing username")
|
||||
return
|
||||
}
|
||||
h.handleUserOne(w, r, username)
|
||||
default:
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_ = json.NewEncoder(w).Encode(errEnvelope("not_found", "unknown aggregate path"))
|
||||
writeNotFound(w, "unknown aggregate path")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +183,15 @@ type resolveError struct {
|
||||
|
||||
func (e *resolveError) Error() string { return e.msg }
|
||||
|
||||
func anyUpstreamFailed(results []ServerFetchResult) bool {
|
||||
for _, fr := range results {
|
||||
if !fr.OK {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Handler) handleSummary(w http.ResponseWriter, r *http.Request) {
|
||||
aliases, err := h.resolveAliases(r)
|
||||
if err != nil {
|
||||
@@ -131,7 +208,8 @@ func (h *Handler) handleSummary(w http.ResponseWriter, r *http.Request) {
|
||||
defer cancel()
|
||||
results := FetchStatsUsers(ctx, h.Client, h.Parsed, aliases)
|
||||
data := BuildSummary(results, topN)
|
||||
writeOK(w, data)
|
||||
partial := anyUpstreamFailed(results)
|
||||
writeAggOK(w, partial, data)
|
||||
}
|
||||
|
||||
func (h *Handler) handleTraffic(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -144,7 +222,8 @@ func (h *Handler) handleTraffic(w http.ResponseWriter, r *http.Request) {
|
||||
defer cancel()
|
||||
results := FetchStatsUsers(ctx, h.Client, h.Parsed, aliases)
|
||||
data := BuildTraffic(results)
|
||||
writeOK(w, data)
|
||||
partial := anyUpstreamFailed(results)
|
||||
writeAggOK(w, partial, data)
|
||||
}
|
||||
|
||||
func (h *Handler) handleUniqueIPs(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -160,7 +239,8 @@ func (h *Handler) handleUniqueIPs(w http.ResponseWriter, r *http.Request) {
|
||||
if h.Geo != nil && !strings.EqualFold(r.URL.Query().Get("geo"), "false") {
|
||||
EnrichUniqueIPsGeo(data, h.Geo)
|
||||
}
|
||||
writeOK(w, data)
|
||||
partial := anyUpstreamFailed(results)
|
||||
writeAggOK(w, partial, data)
|
||||
}
|
||||
|
||||
func (h *Handler) handleUsers(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -184,7 +264,44 @@ func (h *Handler) handleUsers(w http.ResponseWriter, r *http.Request) {
|
||||
defer cancel()
|
||||
results := FetchStatsUsers(ctx, h.Client, h.Parsed, aliases)
|
||||
data := BuildUsers(results, includeLinks, minOct)
|
||||
writeOK(w, data)
|
||||
partial := anyUpstreamFailed(results)
|
||||
writeAggOK(w, partial, data)
|
||||
}
|
||||
|
||||
func (h *Handler) handleFleetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
aliases, err := h.resolveAliases(r)
|
||||
if err != nil {
|
||||
writeBadRequest(w, err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
data := FetchFleetStatus(ctx, h.Client, h.Parsed, aliases)
|
||||
partial := data.ServersFailed > 0
|
||||
writeAggOK(w, partial, data)
|
||||
}
|
||||
|
||||
func (h *Handler) handleUserOne(w http.ResponseWriter, r *http.Request, username string) {
|
||||
if !aggUsernameRe.MatchString(username) {
|
||||
writeBadRequestString(w, "invalid username")
|
||||
return
|
||||
}
|
||||
aliases, err := h.resolveAliases(r)
|
||||
if err != nil {
|
||||
writeBadRequest(w, err)
|
||||
return
|
||||
}
|
||||
includeLinks := strings.EqualFold(r.URL.Query().Get("include_links"), "true")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
results := FetchStatsUsers(ctx, h.Client, h.Parsed, aliases)
|
||||
row := BuildSingleUser(results, username, includeLinks)
|
||||
if row == nil {
|
||||
writeNotFound(w, "user not found on any upstream")
|
||||
return
|
||||
}
|
||||
partial := anyUpstreamFailed(results)
|
||||
writeAggOK(w, partial, row)
|
||||
}
|
||||
|
||||
func writeBadRequest(w http.ResponseWriter, err error) {
|
||||
@@ -193,7 +310,27 @@ func writeBadRequest(w http.ResponseWriter, err error) {
|
||||
_ = json.NewEncoder(w).Encode(errEnvelope("bad_request", err.Error()))
|
||||
}
|
||||
|
||||
func writeOK(w http.ResponseWriter, data any) {
|
||||
func writeBadRequestString(w http.ResponseWriter, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "data": data})
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(errEnvelope("bad_request", msg))
|
||||
}
|
||||
|
||||
func writeNotFound(w http.ResponseWriter, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_ = json.NewEncoder(w).Encode(errEnvelope("not_found", msg))
|
||||
}
|
||||
|
||||
func writeAggOK(w http.ResponseWriter, partial bool, data any) {
|
||||
env := map[string]any{
|
||||
"ok": true,
|
||||
"data": data,
|
||||
"generated_at": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if partial {
|
||||
env["partial"] = true
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(env)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user