341 lines
9.2 KiB
Go
341 lines
9.2 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"math"
|
|
mathrand "math/rand/v2"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
|
"go.opentelemetry.io/otel/trace"
|
|
)
|
|
|
|
type api struct {
|
|
store store
|
|
metrics *metrics
|
|
logger *slog.Logger
|
|
version string
|
|
chaosRate float64
|
|
migrated *atomic.Bool
|
|
prometheus prometheus.Gatherer
|
|
}
|
|
|
|
func (a *api) routes() http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Use(a.versionHeader)
|
|
r.Use(a.observeRequest)
|
|
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
})
|
|
r.Get("/readyz", a.ready)
|
|
r.Handle("/metrics", promhttp.HandlerFor(a.prometheus, promhttp.HandlerOpts{}))
|
|
r.Route("/api/v1", func(r chi.Router) {
|
|
r.Use(a.chaos)
|
|
r.Get("/buoys", a.listBuoys)
|
|
r.Post("/buoys", a.createBuoy)
|
|
r.Get("/buoys/{id}", a.getBuoy)
|
|
r.Delete("/buoys/{id}", a.deleteBuoy)
|
|
r.Get("/readings", a.listReadings)
|
|
r.Post("/readings", a.createReading)
|
|
r.Get("/readings/aggregate", a.aggregateReadings)
|
|
})
|
|
return otelhttp.NewHandler(r, "http.request")
|
|
}
|
|
|
|
func (a *api) versionHeader(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Nereus-Version", a.version)
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
type responseRecorder struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
func (w *responseRecorder) WriteHeader(status int) {
|
|
w.status = status
|
|
w.ResponseWriter.WriteHeader(status)
|
|
}
|
|
|
|
func (a *api) observeRequest(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
started := time.Now()
|
|
recorder := &responseRecorder{ResponseWriter: w, status: http.StatusOK}
|
|
next.ServeHTTP(recorder, r)
|
|
path := chi.RouteContext(r.Context()).RoutePattern()
|
|
if path == "" {
|
|
path = "unmatched"
|
|
}
|
|
status := strconv.Itoa(recorder.status)
|
|
a.metrics.requests.WithLabelValues(r.Method, path, status, a.version).Inc()
|
|
a.metrics.duration.WithLabelValues(r.Method, path, a.version).Observe(time.Since(started).Seconds())
|
|
span := trace.SpanFromContext(r.Context()).SpanContext()
|
|
a.logger.InfoContext(r.Context(), "request", "method", r.Method, "path", path, "status", recorder.status, "duration_ms", time.Since(started).Milliseconds(), "trace_id", span.TraceID().String())
|
|
})
|
|
}
|
|
|
|
func (a *api) chaos(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if a.chaosRate > 0 && mathrand.Float64() < a.chaosRate {
|
|
writeError(w, http.StatusInternalServerError, "injected failure")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (a *api) ready(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := contextWithTimeout(r, 2*time.Second)
|
|
defer cancel()
|
|
if !a.migrated.Load() || a.store.Ping(ctx) != nil {
|
|
writeError(w, http.StatusServiceUnavailable, "database unavailable")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
|
}
|
|
|
|
func (a *api) listBuoys(w http.ResponseWriter, r *http.Request) {
|
|
items, err := a.store.ListBuoys(r.Context())
|
|
if err != nil {
|
|
a.internalError(w, r, err)
|
|
return
|
|
}
|
|
if items == nil {
|
|
items = []buoy{}
|
|
}
|
|
a.metrics.active.Set(float64(len(items)))
|
|
writeJSON(w, http.StatusOK, items)
|
|
}
|
|
|
|
func (a *api) createBuoy(w http.ResponseWriter, r *http.Request) {
|
|
var input buoy
|
|
if !decodeJSON(w, r, &input) {
|
|
return
|
|
}
|
|
input.Name = strings.TrimSpace(input.Name)
|
|
if input.Name == "" || len(input.Name) > 120 || input.Latitude < -90 || input.Latitude > 90 || input.Longitude < -180 || input.Longitude > 180 {
|
|
writeError(w, http.StatusBadRequest, "invalid buoy")
|
|
return
|
|
}
|
|
id, err := newUUID()
|
|
if err != nil {
|
|
a.internalError(w, r, err)
|
|
return
|
|
}
|
|
input.ID = id
|
|
created, err := a.store.CreateBuoy(r.Context(), input)
|
|
if err != nil {
|
|
a.internalError(w, r, err)
|
|
return
|
|
}
|
|
a.metrics.active.Inc()
|
|
writeJSON(w, http.StatusCreated, created)
|
|
}
|
|
|
|
func (a *api) getBuoy(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if !validUUID(id) {
|
|
writeError(w, http.StatusBadRequest, "invalid buoy ID")
|
|
return
|
|
}
|
|
item, err := a.store.GetBuoy(r.Context(), id)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeError(w, http.StatusNotFound, "buoy not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
a.internalError(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, item)
|
|
}
|
|
|
|
func (a *api) deleteBuoy(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if !validUUID(id) {
|
|
writeError(w, http.StatusBadRequest, "invalid buoy ID")
|
|
return
|
|
}
|
|
err := a.store.DeleteBuoy(r.Context(), id)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeError(w, http.StatusNotFound, "buoy not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
a.internalError(w, r, err)
|
|
return
|
|
}
|
|
a.metrics.active.Dec()
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (a *api) listReadings(w http.ResponseWriter, r *http.Request) {
|
|
query := r.URL.Query()
|
|
from, ok := optionalTime(w, query.Get("from"))
|
|
if !ok {
|
|
return
|
|
}
|
|
to, ok := optionalTime(w, query.Get("to"))
|
|
if !ok {
|
|
return
|
|
}
|
|
if from != nil && to != nil && from.After(*to) {
|
|
writeError(w, http.StatusBadRequest, "from must not be after to")
|
|
return
|
|
}
|
|
if id := query.Get("buoy_id"); id != "" && !validUUID(id) {
|
|
writeError(w, http.StatusBadRequest, "invalid buoy ID")
|
|
return
|
|
}
|
|
limit, ok := boundedInt(query.Get("limit"), 100, 1, 1000)
|
|
if !ok {
|
|
writeError(w, http.StatusBadRequest, "invalid limit")
|
|
return
|
|
}
|
|
offset, ok := boundedInt(query.Get("offset"), 0, 0, math.MaxInt)
|
|
if !ok {
|
|
writeError(w, http.StatusBadRequest, "invalid offset")
|
|
return
|
|
}
|
|
items, err := a.store.ListReadings(r.Context(), query.Get("buoy_id"), from, to, limit, offset)
|
|
if err != nil {
|
|
a.internalError(w, r, err)
|
|
return
|
|
}
|
|
if items == nil {
|
|
items = []reading{}
|
|
}
|
|
writeJSON(w, http.StatusOK, items)
|
|
}
|
|
|
|
func (a *api) createReading(w http.ResponseWriter, r *http.Request) {
|
|
var input reading
|
|
if !decodeJSON(w, r, &input) {
|
|
return
|
|
}
|
|
if !validUUID(input.BuoyID) || input.WaveHeight < 0 || input.Salinity != nil && *input.Salinity < 0 {
|
|
writeError(w, http.StatusBadRequest, "invalid reading")
|
|
return
|
|
}
|
|
id, err := newUUID()
|
|
if err != nil {
|
|
a.internalError(w, r, err)
|
|
return
|
|
}
|
|
input.ID = id
|
|
created, err := a.store.CreateReading(r.Context(), input)
|
|
if err != nil {
|
|
a.internalError(w, r, err)
|
|
return
|
|
}
|
|
a.metrics.ingested.Inc()
|
|
writeJSON(w, http.StatusCreated, created)
|
|
}
|
|
|
|
func (a *api) aggregateReadings(w http.ResponseWriter, r *http.Request) {
|
|
window, err := time.ParseDuration(r.URL.Query().Get("window"))
|
|
if err != nil || window < time.Minute || window > 24*time.Hour {
|
|
writeError(w, http.StatusBadRequest, "window must be between 1m and 24h")
|
|
return
|
|
}
|
|
items, err := a.store.AggregateReadings(r.Context(), window)
|
|
if err != nil {
|
|
a.internalError(w, r, err)
|
|
return
|
|
}
|
|
if items == nil {
|
|
items = []aggregate{}
|
|
}
|
|
writeJSON(w, http.StatusOK, items)
|
|
}
|
|
|
|
func (a *api) internalError(w http.ResponseWriter, r *http.Request, err error) {
|
|
a.logger.ErrorContext(r.Context(), "request failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
}
|
|
func writeError(w http.ResponseWriter, status int, message string) {
|
|
writeJSON(w, status, map[string]string{"error": message})
|
|
}
|
|
func writeJSON(w http.ResponseWriter, status int, value any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(value)
|
|
}
|
|
|
|
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(dst); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
|
return false
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
|
writeError(w, http.StatusBadRequest, "request body must contain one JSON object")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func optionalTime(w http.ResponseWriter, value string) (*time.Time, bool) {
|
|
if value == "" {
|
|
return nil, true
|
|
}
|
|
parsed, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid timestamp")
|
|
return nil, false
|
|
}
|
|
return &parsed, true
|
|
}
|
|
func boundedInt(value string, fallback, min, max int) (int, bool) {
|
|
if value == "" {
|
|
return fallback, true
|
|
}
|
|
parsed, err := strconv.Atoi(value)
|
|
return parsed, err == nil && parsed >= min && parsed <= max
|
|
}
|
|
|
|
func validUUID(value string) bool {
|
|
if len(value) != 36 || value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-' {
|
|
return false
|
|
}
|
|
compact := strings.ReplaceAll(value, "-", "")
|
|
_, err := hex.DecodeString(compact)
|
|
return err == nil
|
|
}
|
|
|
|
func newUUID() (string, error) {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return "", fmt.Errorf("generate UUID: %w", err)
|
|
}
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
|
encoded := make([]byte, 36)
|
|
hex.Encode(encoded[0:8], b[0:4])
|
|
encoded[8] = '-'
|
|
hex.Encode(encoded[9:13], b[4:6])
|
|
encoded[13] = '-'
|
|
hex.Encode(encoded[14:18], b[6:8])
|
|
encoded[18] = '-'
|
|
hex.Encode(encoded[19:23], b[8:10])
|
|
encoded[23] = '-'
|
|
hex.Encode(encoded[24:36], b[10:16])
|
|
return string(encoded), nil
|
|
}
|