Server: rewrite in Go as an API-only service
Same endpoints, request formats, validation rules and InfluxDB layout as
the Next.js server, so the car needs no change: POST /v1/telemetry,
PUT /v1/consumption/daily, PUT /v1/charging-sessions/{id}, GET
/v1/consumption/history/{id}, GET /v1/health. The dashboard, login and
the /v1/history reads are gone; Grafana takes over the dashboards.
- Go 1.26 standard library (net/http routing, log/slog) plus the
official InfluxDB 2 client. Fails closed at startup on a missing
InfluxDB setting or an ingest token under 24 characters.
- ABRP forwarder ported: newest fresh sample, rate limited, never
queued, credentials only in the form body.
- 22 tests: validation, aggregation, forwarding, config, and the
handlers through a fake store. govulncheck clean (x/net 0.60.0,
toolchain go1.26.9).
- Image: static binary on distroless, about 11 MB, UID 65532, port
8080; the build runs vet and the tests.
- Dev shell: Go, gopls and govulncheck instead of Node.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
+157
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// API serves the car. Every endpoint but /v1/health needs the ingest token.
|
||||
//
|
||||
// POST /v1/telemetry live samples; stored, newest forwarded to ABRP
|
||||
// PUT /v1/consumption/daily one delta chunk of a day's consumption
|
||||
// PUT /v1/charging-sessions/{sessionId} one finished DC charging session
|
||||
// GET /v1/consumption/history/{installationId} the car reads its own totals back
|
||||
// GET /v1/health unauthenticated liveness probe
|
||||
type API struct {
|
||||
Store Store
|
||||
IngestToken string
|
||||
Forwarder *Forwarder
|
||||
}
|
||||
|
||||
const maxBodyBytes = 512 * 1024
|
||||
|
||||
func (a *API) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /v1/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
mux.HandleFunc("POST /v1/telemetry", a.ingest(a.telemetry))
|
||||
mux.HandleFunc("PUT /v1/consumption/daily", a.ingest(a.consumption))
|
||||
mux.HandleFunc("PUT /v1/charging-sessions/{sessionId}", a.ingest(a.chargingSession))
|
||||
mux.HandleFunc("GET /v1/consumption/history/{installationId}", a.ingest(a.history))
|
||||
return mux
|
||||
}
|
||||
|
||||
// hasIngestToken compares in constant time. main refuses to start with a token under 24
|
||||
// characters, so an empty or short configured token can never match here.
|
||||
func (a *API) hasIngestToken(r *http.Request) bool {
|
||||
header := r.Header.Get("Authorization")
|
||||
supplied, ok := strings.CutPrefix(header, "Bearer ")
|
||||
if !ok || len(a.IngestToken) < minIngestTokenLength {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(supplied), []byte(a.IngestToken)) == 1
|
||||
}
|
||||
|
||||
type handler func(w http.ResponseWriter, r *http.Request, body []byte) error
|
||||
|
||||
// ingest wraps a handler with the token check, the body limit and error mapping: a
|
||||
// validation failure is a 400 naming the field; anything else is a 500 whose detail
|
||||
// stays in the server log.
|
||||
func (a *API) ingest(h handler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.hasIngestToken(r) {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body could not be read"})
|
||||
return
|
||||
}
|
||||
if len(body) > maxBodyBytes {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body is too large"})
|
||||
return
|
||||
}
|
||||
if err := h(w, r, body); err != nil {
|
||||
var verr *ValidationError
|
||||
if errors.As(err, &verr) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": verr.Error()})
|
||||
return
|
||||
}
|
||||
slog.Error("request failed", "method", r.Method, "path", r.URL.Path, "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// telemetry stores the batch first; only once the write succeeded is the newest sample
|
||||
// handed to ABRP, so the car's retry of a failed request cannot leave ABRP with data the
|
||||
// database never got.
|
||||
func (a *API) telemetry(w http.ResponseWriter, r *http.Request, body []byte) error {
|
||||
batch, err := validateTelemetryBatch(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.Store.WriteTelemetry(r.Context(), batch.InstallationID, batch.Samples); err != nil {
|
||||
return err
|
||||
}
|
||||
a.Forwarder.Forward(batch.Samples)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// consumption stores one delta chunk at its recordedAt. Reads add a day's chunks up, so a
|
||||
// chunk the car re-sends from its offline cache replaces itself instead of counting twice.
|
||||
func (a *API) consumption(w http.ResponseWriter, r *http.Request, body []byte) error {
|
||||
chunk, err := validateConsumption(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.Store.WriteConsumptionChunk(r.Context(), chunk); err != nil {
|
||||
return err
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *API) chargingSession(w http.ResponseWriter, r *http.Request, body []byte) error {
|
||||
session, err := validateChargingSession(body, r.PathValue("sessionId"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.Store.WriteChargingSession(r.Context(), session); err != nil {
|
||||
return err
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// history is the car's read-back of its own uploaded totals, so an app reinstall does not
|
||||
// blank its Consumption tab. Up to 400 days, newest first, plus the lifetime sum of all.
|
||||
func (a *API) history(w http.ResponseWriter, r *http.Request, _ []byte) error {
|
||||
id, err := validUUID(r.PathValue("installationId"), "installationId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
chunks, err := a.Store.ReadConsumptionChunks(r.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
days := aggregateDays(chunks)
|
||||
var lifetime Totals
|
||||
if len(days) > 0 {
|
||||
lifetime = days[0].Lifetime
|
||||
}
|
||||
if len(days) > 400 {
|
||||
days = days[:400]
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Records []DailyConsumption `json:"records"`
|
||||
Lifetime Totals `json:"lifetime"`
|
||||
}{Records: days, Lifetime: lifetime})
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(value); err != nil {
|
||||
slog.Warn("response not written", "error", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user