Files
nmon/internal/api/api.go

165 lines
5.2 KiB
Go

// Package api is nmon's HTTP API: huma v2 on net/http, errors as RFC 9457
// problem details, OpenAPI generated from the handlers at /openapi.json.
package api
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/poller"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/selfmon"
"github.com/v10networks/nmon/internal/store"
)
// Limits.
const (
// QueryTimeout bounds one /query call.
QueryTimeout = 30 * time.Second
// MaxSeries is the most series a /query call may return; more is a 422.
// It is enforced on the result rather than with a store flag because the
// rollup job needs the store's own limit far higher (-search.maxUniqueTimeseries).
MaxSeries = 100_000
// DiscoveryTimeout bounds the seed discovery run by POST /devices.
DiscoveryTimeout = 30 * time.Second
)
// Querier runs read-only MetricsQL against the hot store. *vmsup.Querier implements it.
type Querier interface {
Query(ctx context.Context, store, q string, ts time.Time) ([]byte, error)
QueryRange(ctx context.Context, store, q string, from, to time.Time, step time.Duration) ([]byte, error)
}
// Refresher is told when the device list changed. *poller.Poller implements it.
type Refresher interface{ Refresh() }
// Deps are what the handlers need. Store, Querier and Poller are required.
type Deps struct {
Store store.Store
Querier Querier
Poller Refresher
Clock clock.Clock // default clock.Real()
Registry *selfmon.Registry // served at /metrics; default selfmon.Default
Logger *slog.Logger // default slog.Default()
SNMPPort int // dev/test override of UDP 161; 0 = 161
// Profiles select a device's profile at seed discovery. Nil: no profile matching.
Profiles *profile.Set
// PollerStatus feeds GET /poller/health. *poller.Poller's Status fits.
PollerStatus func() []poller.DeviceStatus
// AfterDiscover is called after POST /devices/{slug}/discover succeeded, e.g. to make the poller reload that device.
AfterDiscover func(deviceID int64)
// DiscoveryTimeout overrides the 30 s seed discovery limit (tests).
DiscoveryTimeout time.Duration
}
type server struct {
Deps
}
// New returns the API handler with every route registered.
func New(d Deps) http.Handler {
if d.Clock == nil {
d.Clock = clock.Real()
}
if d.Registry == nil {
d.Registry = selfmon.Default
}
if d.Logger == nil {
d.Logger = slog.Default()
}
if d.DiscoveryTimeout <= 0 {
d.DiscoveryTimeout = DiscoveryTimeout
}
s := &server{d}
mux := http.NewServeMux()
cfg := huma.DefaultConfig("nmon", "1.0.0")
cfg.Info.Description = "nmon network monitor API."
cfg.OpenAPIPath = "/openapi"
cfg.DocsPath = "/docs"
cfg.DocsRenderer = huma.DocsRendererSwaggerUI
cfg.CreateHooks = nil // no $schema fields or Link headers in responses
api := humago.New(mux, cfg)
s.registerDevices(api)
s.registerOps(api)
s.registerQuery(api)
s.registerMetrics(api)
return problemFallback(mux)
}
// storeErr maps store sentinels to problem responses; anything else is a 500
// whose detail does not leak internals.
func (s *server) storeErr(err error) error {
switch {
case errors.Is(err, store.ErrNotFound):
return huma.Error404NotFound("not found")
case errors.Is(err, store.ErrConflict):
return huma.Error409Conflict(trimSentinel(err, store.ErrConflict))
case errors.Is(err, store.ErrInvalid):
return huma.Error422UnprocessableEntity(trimSentinel(err, store.ErrInvalid))
}
s.Logger.Error("store error", "err", err)
return huma.Error500InternalServerError("internal error")
}
// trimSentinel returns err's text without the leading "store: x: " sentinel.
func trimSentinel(err, sentinel error) string {
msg := err.Error()
if rest, ok := strings.CutPrefix(msg, sentinel.Error()+": "); ok {
return rest
}
return strings.TrimPrefix(msg, "store: ")
}
// problemFallback answers requests that match no route (404, 405) with
// problem+json instead of the mux's plain text.
func problemFallback(mux *http.ServeMux) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h, pattern := mux.Handler(r)
if pattern != "" {
mux.ServeHTTP(w, r) // sets the path values
return
}
cw := &capture{h: http.Header{}}
h.ServeHTTP(cw, r)
status := cw.status
if status == 0 {
status = http.StatusNotFound
}
if allow := cw.h.Get("Allow"); allow != "" {
w.Header().Set("Allow", allow)
}
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{
"title": http.StatusText(status), "status": status,
"detail": r.Method + " " + r.URL.Path + ": " + strings.ToLower(http.StatusText(status)),
})
})
}
// capture records only the status and headers the mux would have written.
type capture struct {
h http.Header
status int
}
func (c *capture) Header() http.Header { return c.h }
func (c *capture) Write(b []byte) (int, error) { return len(b), nil }
func (c *capture) WriteHeader(s int) { c.status = s }
func itoa(n int) string { return strconv.Itoa(n) }