- Introduced AggregateConfig to manage aggregation settings in the gateway configuration. - Added validation for reserved alias 'agg' and included tests for aggregate alias handling. - Updated config.example.yaml to demonstrate aggregate configuration options. - Enhanced README.md to include information about the new aggregation endpoint and its usage. - Modified gateway.go to integrate the new aggregate handler for processing aggregation requests.
190 lines
4.9 KiB
Go
190 lines
4.9 KiB
Go
package aggregate
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/telemt/telemt-api/internal/config"
|
|
)
|
|
|
|
const pathPrefix = "/api/agg"
|
|
|
|
// Handler serves GET /api/agg/* aggregate endpoints.
|
|
type Handler struct {
|
|
Parsed *config.Parsed
|
|
Client *http.Client
|
|
}
|
|
|
|
// NewHandler builds an aggregate handler; client must use a non-nil Transport (e.g. gateway shared transport).
|
|
func NewHandler(p *config.Parsed, client *http.Client) *Handler {
|
|
return &Handler{Parsed: p, Client: client}
|
|
}
|
|
|
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
_ = json.NewEncoder(w).Encode(errEnvelope("method_not_allowed", "only GET is allowed"))
|
|
return
|
|
}
|
|
sub := strings.TrimPrefix(r.URL.Path, pathPrefix)
|
|
sub = strings.TrimPrefix(sub, "/")
|
|
switch sub {
|
|
case "summary":
|
|
h.handleSummary(w, r)
|
|
case "traffic":
|
|
h.handleTraffic(w, r)
|
|
case "unique-ips":
|
|
h.handleUniqueIPs(w, r)
|
|
case "users":
|
|
h.handleUsers(w, r)
|
|
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"))
|
|
}
|
|
}
|
|
|
|
func errEnvelope(code, msg string) map[string]any {
|
|
return map[string]any{
|
|
"ok": false,
|
|
"error": map[string]string{
|
|
"code": code,
|
|
"message": msg,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (h *Handler) resolveAliases(r *http.Request) ([]string, error) {
|
|
q := r.URL.Query().Get("aliases")
|
|
if strings.TrimSpace(q) != "" {
|
|
var out []string
|
|
for _, p := range strings.Split(q, ",") {
|
|
a := strings.TrimSpace(p)
|
|
if a == "" {
|
|
continue
|
|
}
|
|
if h.Parsed.ByAlias[a] == nil {
|
|
return nil, &resolveError{msg: "unknown alias: " + a}
|
|
}
|
|
out = append(out, a)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, &resolveError{msg: "aliases query produced empty list"}
|
|
}
|
|
return out, nil
|
|
}
|
|
cfg := h.Parsed.Config.Aggregate
|
|
if cfg != nil && len(cfg.IncludeAliases) > 0 {
|
|
for _, a := range cfg.IncludeAliases {
|
|
a = strings.TrimSpace(a)
|
|
if a == "" {
|
|
continue
|
|
}
|
|
if h.Parsed.ByAlias[a] == nil {
|
|
return nil, &resolveError{msg: "aggregate.include_aliases: unknown alias: " + a}
|
|
}
|
|
}
|
|
out := make([]string, 0, len(cfg.IncludeAliases))
|
|
for _, a := range cfg.IncludeAliases {
|
|
a = strings.TrimSpace(a)
|
|
if a == "" {
|
|
continue
|
|
}
|
|
out = append(out, a)
|
|
}
|
|
return out, nil
|
|
}
|
|
out := make([]string, 0, len(h.Parsed.Config.Servers))
|
|
for i := range h.Parsed.Config.Servers {
|
|
out = append(out, h.Parsed.Config.Servers[i].Alias)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type resolveError struct {
|
|
msg string
|
|
}
|
|
|
|
func (e *resolveError) Error() string { return e.msg }
|
|
|
|
func (h *Handler) handleSummary(w http.ResponseWriter, r *http.Request) {
|
|
aliases, err := h.resolveAliases(r)
|
|
if err != nil {
|
|
writeBadRequest(w, err)
|
|
return
|
|
}
|
|
topN := 10
|
|
if v := r.URL.Query().Get("top_n"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
topN = n
|
|
}
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
|
defer cancel()
|
|
results := FetchStatsUsers(ctx, h.Client, h.Parsed, aliases)
|
|
data := BuildSummary(results, topN)
|
|
writeOK(w, data)
|
|
}
|
|
|
|
func (h *Handler) handleTraffic(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()
|
|
results := FetchStatsUsers(ctx, h.Client, h.Parsed, aliases)
|
|
data := BuildTraffic(results)
|
|
writeOK(w, data)
|
|
}
|
|
|
|
func (h *Handler) handleUniqueIPs(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()
|
|
results := FetchStatsUsers(ctx, h.Client, h.Parsed, aliases)
|
|
data := BuildUniqueIPs(results)
|
|
writeOK(w, data)
|
|
}
|
|
|
|
func (h *Handler) handleUsers(w http.ResponseWriter, r *http.Request) {
|
|
aliases, err := h.resolveAliases(r)
|
|
if err != nil {
|
|
writeBadRequest(w, err)
|
|
return
|
|
}
|
|
includeLinks := strings.EqualFold(r.URL.Query().Get("include_links"), "true")
|
|
minOct := uint64(0)
|
|
if v := r.URL.Query().Get("min_total_octets"); v != "" {
|
|
if n, err := strconv.ParseUint(v, 10, 64); err == nil {
|
|
minOct = n
|
|
}
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
|
defer cancel()
|
|
results := FetchStatsUsers(ctx, h.Client, h.Parsed, aliases)
|
|
data := BuildUsers(results, includeLinks, minOct)
|
|
writeOK(w, data)
|
|
}
|
|
|
|
func writeBadRequest(w http.ResponseWriter, err error) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_ = json.NewEncoder(w).Encode(errEnvelope("bad_request", err.Error()))
|
|
}
|
|
|
|
func writeOK(w http.ResponseWriter, data any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "data": data})
|
|
}
|