Files
telemt-api/internal/aggregate/handlers.go
T
Denozordec 54306ec6c4
Publish telemt-api gateway Docker image / test (push) Failing after 27s
Publish telemt-api gateway Docker image / build-and-push (push) Has been skipped
Add aggregate configuration support and update documentation
- 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.
2026-03-30 00:51:19 +07:00

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})
}