PATCH /api/v1/maps/{id} з оптимістичним блокуванням за revision, плюс
створення, видалення й автопобудова з виявленої топології.
Усі скалярні поля патча — вказівники: перетягування шле лише x/y, і якби
відсутні поля означали порожні, кожен рух миші стирав би стиль, розмір і
прив'язку до пристрою.
Ребро може посилатися на вузол, створений тим же патчем, за client_id.
Той, хто спізнився з ревізією, отримує 409, а не тихо затирає чужу правку.
Знімок пишеться тією ж транзакцією, що й зміна, — інакше в історії лишався
б крок, якого в мапі немає.
Автопобудова ідемпотентна: повторний запуск не дублює вузлів і не скидає
ручну розкладку.
Тестами знайдено: revision <= $2 - $3 з двома нетипізованими параметрами
дає "operator is not unique: unknown - unknown" — потрібні явні касти.
Перевірено: 23 інтеграційні тести API (-race), плюс живий прогін проти
даних, зібраних агентом.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
277 lines
9.4 KiB
Go
277 lines
9.4 KiB
Go
// Package httpapi — REST і WebSocket для фронтенду.
|
||
//
|
||
// Окремий процес від AgentService навмисно: зонди й браузери мають різні
|
||
// профілі навантаження, різні мережеві периметри й різні цикли релізів.
|
||
// Спільним лишається лише шар store, тому обидва бачать однакові дані.
|
||
package httpapi
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/netpulse/netpulse/server/internal/store"
|
||
)
|
||
|
||
type ctxKey string
|
||
|
||
const tokenCtxKey ctxKey = "netpulse.token"
|
||
|
||
type Server struct {
|
||
store *store.Store
|
||
log *slog.Logger
|
||
hub *Hub
|
||
}
|
||
|
||
func New(st *store.Store, log *slog.Logger) *Server {
|
||
if log == nil {
|
||
log = slog.Default()
|
||
}
|
||
s := &Server{store: st, log: log}
|
||
s.hub = NewHub(st, log)
|
||
return s
|
||
}
|
||
|
||
// Hub — доступ до трансляції для зовнішнього коду (тести, метрики).
|
||
func (s *Server) Hub() *Hub { return s.hub }
|
||
|
||
// Handler збирає маршрути.
|
||
//
|
||
// Роутер стандартної бібліотеки: Go 1.22 вміє шаблони з методом і
|
||
// параметрами шляху, і цього тут вистачає. Зовнішній роутер додав би
|
||
// залежність заради синтаксису.
|
||
func (s *Server) Handler() http.Handler {
|
||
mux := http.NewServeMux()
|
||
|
||
mux.HandleFunc("GET /healthz", s.handleHealth)
|
||
|
||
mux.Handle("GET /api/v1/maps", s.authenticated(s.handleListMaps))
|
||
mux.Handle("POST /api/v1/maps", s.authenticated(s.handleCreateMap))
|
||
mux.Handle("GET /api/v1/maps/{id}", s.authenticated(s.handleGetMap))
|
||
mux.Handle("PATCH /api/v1/maps/{id}", s.authenticated(s.handlePatchMap))
|
||
mux.Handle("DELETE /api/v1/maps/{id}", s.authenticated(s.handleDeleteMap))
|
||
mux.Handle("POST /api/v1/maps/{id}/build", s.authenticated(s.handleBuildMap))
|
||
mux.Handle("GET /api/v1/devices", s.authenticated(s.handleListDevices))
|
||
mux.Handle("GET /api/v1/agents", s.authenticated(s.handleListAgents))
|
||
|
||
// WebSocket теж під автентифікацією: браузер шле токен у
|
||
// заголовку через підпротокол — див. ws.go.
|
||
mux.Handle("GET /api/v1/ws", s.authenticated(s.handleWS))
|
||
|
||
return s.withRecovery(s.withLogging(mux))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Проміжні шари
|
||
// ---------------------------------------------------------------------
|
||
|
||
func (s *Server) authenticated(next func(http.ResponseWriter, *http.Request, *store.APIToken)) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
raw := bearerToken(r)
|
||
if raw == "" {
|
||
writeError(w, http.StatusUnauthorized, "no_token", "потрібен Bearer-токен")
|
||
return
|
||
}
|
||
|
||
tok, err := s.store.AuthenticateAPIToken(r.Context(), raw)
|
||
if errors.Is(err, store.ErrTokenInvalid) {
|
||
writeError(w, http.StatusUnauthorized, "invalid_token", "токен недійсний")
|
||
return
|
||
}
|
||
if err != nil {
|
||
s.log.Error("автентифікація", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
|
||
next(w, r, tok)
|
||
})
|
||
}
|
||
|
||
// bearerToken дістає токен із заголовка або з підпротоколу WebSocket.
|
||
//
|
||
// Другий шлях потрібен тому, що браузерний WebSocket API не дозволяє
|
||
// задати довільні заголовки — токен передається як підпротокол
|
||
// "netpulse.token.<value>". Це загальноприйнятий обхід; сам токен при
|
||
// цьому не потрапляє в URL, а отже і в логи проксі.
|
||
func bearerToken(r *http.Request) string {
|
||
if v := r.Header.Get("Authorization"); v != "" {
|
||
if after, ok := strings.CutPrefix(v, "Bearer "); ok {
|
||
return strings.TrimSpace(after)
|
||
}
|
||
}
|
||
for _, proto := range websocketProtocols(r) {
|
||
if after, ok := strings.CutPrefix(proto, "netpulse.token."); ok {
|
||
return after
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func websocketProtocols(r *http.Request) []string {
|
||
raw := r.Header.Get("Sec-WebSocket-Protocol")
|
||
if raw == "" {
|
||
return nil
|
||
}
|
||
parts := strings.Split(raw, ",")
|
||
for i := range parts {
|
||
parts[i] = strings.TrimSpace(parts[i])
|
||
}
|
||
return parts
|
||
}
|
||
|
||
func (s *Server) withLogging(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
start := time.Now()
|
||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||
next.ServeHTTP(rec, r)
|
||
|
||
level := slog.LevelDebug
|
||
if rec.status >= 500 {
|
||
level = slog.LevelError
|
||
}
|
||
s.log.Log(r.Context(), level, "http",
|
||
"method", r.Method, "path", r.URL.Path,
|
||
"status", rec.status, "ms", time.Since(start).Milliseconds())
|
||
})
|
||
}
|
||
|
||
// withRecovery не дає паніці в одному запиті вбити весь процес разом
|
||
// із живими WebSocket-підписниками.
|
||
func (s *Server) withRecovery(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
defer func() {
|
||
if v := recover(); v != nil {
|
||
s.log.Error("паніка в обробнику", "path", r.URL.Path, "panic", v)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
}
|
||
}()
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
type statusRecorder struct {
|
||
http.ResponseWriter
|
||
status int
|
||
written bool
|
||
}
|
||
|
||
func (r *statusRecorder) WriteHeader(code int) {
|
||
if r.written {
|
||
return
|
||
}
|
||
r.written = true
|
||
r.status = code
|
||
r.ResponseWriter.WriteHeader(code)
|
||
}
|
||
|
||
// Hijack потрібен, бо WebSocket перехоплює з'єднання, а обгортка
|
||
// логування стоїть у ланцюжку вище.
|
||
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Обробники
|
||
// ---------------------------------------------------------------------
|
||
|
||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"status": "ok",
|
||
"subscribers": s.hub.SubscriberCount(),
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleListMaps(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
|
||
if !tok.Can("maps:read") {
|
||
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:read")
|
||
return
|
||
}
|
||
|
||
maps, err := s.store.ListMaps(r.Context(), tok.TenantID)
|
||
if err != nil {
|
||
s.log.Error("перелік мап", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
if maps == nil {
|
||
maps = []store.MapSummary{}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"maps": maps})
|
||
}
|
||
|
||
func (s *Server) handleGetMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
|
||
if !tok.Can("maps:read") {
|
||
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:read")
|
||
return
|
||
}
|
||
|
||
state, err := s.store.GetMapState(r.Context(), tok.TenantID, r.PathValue("id"))
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "мапу не знайдено")
|
||
return
|
||
}
|
||
if err != nil {
|
||
// Невалідний uuid у шляху доходить сюди помилкою розбору —
|
||
// для клієнта це 400, а не 500.
|
||
if strings.Contains(err.Error(), "invalid input syntax for type uuid") {
|
||
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор мапи")
|
||
return
|
||
}
|
||
s.log.Error("стан мапи", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, state)
|
||
}
|
||
|
||
func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
|
||
if !tok.Can("devices:read") {
|
||
writeError(w, http.StatusForbidden, "forbidden", "немає права devices:read")
|
||
return
|
||
}
|
||
|
||
devices, err := s.store.ListDevices(r.Context(), tok.TenantID)
|
||
if err != nil {
|
||
s.log.Error("перелік пристроїв", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
if devices == nil {
|
||
devices = []store.DeviceSummary{}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"devices": devices})
|
||
}
|
||
|
||
func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
|
||
if !tok.Can("agents:read") {
|
||
writeError(w, http.StatusForbidden, "forbidden", "немає права agents:read")
|
||
return
|
||
}
|
||
|
||
agents, err := s.store.ListAgents(r.Context(), tok.TenantID)
|
||
if err != nil {
|
||
s.log.Error("перелік зондів", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
if agents == nil {
|
||
agents = []store.AgentSummary{}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"agents": agents})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
|
||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(v)
|
||
}
|
||
|
||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||
writeJSON(w, status, map[string]any{
|
||
"error": map[string]string{"code": code, "message": message},
|
||
})
|
||
}
|