Netpulse_SasS/server/internal/httpapi/maps_write.go
zotac 8bf2902522 Етап 3: запис у мапу — редактор полотна
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>
2026-08-14 15:58:16 +03:00

135 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"encoding/json"
"errors"
"net/http"
"github.com/netpulse/netpulse/server/internal/store"
)
// Обмеження тіла запиту. Полотно на 1000 вузлів у патчі — це ~1 МБ;
// 8 МБ лишає запас і водночас не дає одним запитом з'їсти пам'ять.
const maxPatchBody = 8 << 20
func (s *Server) handleCreateMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
if !tok.Can("maps:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
return
}
var in struct {
Name string `json:"name"`
Slug string `json:"slug"`
Kind string `json:"kind"`
}
if !decodeBody(w, r, &in) {
return
}
m, err := s.store.CreateMap(r.Context(), tok.TenantID, in.Name, in.Slug, in.Kind)
if err != nil {
s.writeStoreError(w, "створення мапи", err)
return
}
writeJSON(w, http.StatusCreated, m)
}
func (s *Server) handleDeleteMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
if !tok.Can("maps:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
return
}
if err := s.store.DeleteMap(r.Context(), tok.TenantID, r.PathValue("id")); err != nil {
s.writeStoreError(w, "видалення мапи", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handlePatchMap — редактор полотна.
func (s *Server) handlePatchMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
if !tok.Can("maps:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
return
}
var patch store.MapPatch
if !decodeBody(w, r, &patch) {
return
}
mapID := r.PathValue("id")
res, err := s.store.ApplyMapPatch(r.Context(), tok.TenantID, mapID, "", &patch)
if err != nil {
s.writeStoreError(w, "збереження мапи", err)
return
}
// Інші відкриті полотна мають дізнатись про зміну: у NOC над однією
// мапою часто працюють кілька людей, і мовчазне розходження станів
// закінчується взаємним затиранням.
s.hub.BroadcastMapUpdate(tok.TenantID, mapID, res.Revision)
writeJSON(w, http.StatusOK, res)
}
// handleBuildMap добудовує мапу з виявленої топології.
func (s *Server) handleBuildMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
if !tok.Can("maps:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
return
}
mapID := r.PathValue("id")
st, err := s.store.BuildFromTopology(r.Context(), tok.TenantID, mapID, "")
if err != nil {
s.writeStoreError(w, "автопобудова мапи", err)
return
}
if st.NodesAdded > 0 || st.EdgesAdded > 0 {
s.hub.BroadcastMapUpdate(tok.TenantID, mapID, st.Revision)
}
writeJSON(w, http.StatusOK, st)
}
// ---------------------------------------------------------------------
func decodeBody(w http.ResponseWriter, r *http.Request, v any) bool {
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxPatchBody))
// Невідоме поле — майже завжди друкарська помилка в клієнті.
// Мовчки її проковтнути означає, що правка «збереглась», але
// не застосувалась.
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
writeError(w, http.StatusBadRequest, "bad_body", "не вдалося розібрати тіло: "+err.Error())
return false
}
return true
}
func (s *Server) writeStoreError(w http.ResponseWriter, what string, err error) {
switch {
case errors.Is(err, store.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", "не знайдено")
case errors.Is(err, store.ErrConflict):
// 409 із поточною ревізією дозволяє клієнту перечитати мапу
// й повторити правку, а не втратити її.
writeError(w, http.StatusConflict, "revision_conflict", err.Error())
case errors.Is(err, store.ErrPlanLimit):
// 402 — саме те, що сталося: потрібен інший тариф.
writeError(w, http.StatusPaymentRequired, "plan_limit", err.Error())
case errors.Is(err, store.ErrInvalid):
writeError(w, http.StatusBadRequest, "invalid", err.Error())
default:
s.log.Error(what, "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
}
}