93 lines
3.7 KiB
Go
93 lines
3.7 KiB
Go
// Package handler — HTTP-обработчики IoT REST API (новый монолит).
|
||
//
|
||
// Отличие от старого кода: устройства — в PostgreSQL (таблица iot_devices),
|
||
// без k8s-клиента и CRD. Телеметрия — через iotpg, как раньше.
|
||
package handler
|
||
|
||
import (
|
||
"encoding/json"
|
||
"log/slog"
|
||
"net/http"
|
||
"time"
|
||
|
||
"github.com/aws/aws-sdk-go-v2/service/sqs"
|
||
"github.com/gorilla/mux"
|
||
|
||
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/config"
|
||
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/store"
|
||
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
|
||
)
|
||
|
||
// Handler — зависимости всех HTTP-обработчиков.
|
||
type Handler struct {
|
||
Devices *store.DeviceStore
|
||
IoTPG *iotpg.IoTPostgresStore
|
||
SQS *sqs.Client
|
||
Cfg *config.Config
|
||
Log *slog.Logger
|
||
|
||
// Version — версия бинарника (ldflags), StartedAt — для админ-статистики.
|
||
Version string
|
||
StartedAt time.Time
|
||
}
|
||
|
||
// writeJSON отправляет JSON-ответ с указанным статусом.
|
||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(v)
|
||
}
|
||
|
||
// maxRequestBodyBytes — максимум размера тела запроса (устройства/метаданные
|
||
// небольшие; 64 КиБ с запасом).
|
||
const maxRequestBodyBytes = 64 * 1024
|
||
|
||
// decodeJSON читает JSON-тело с лимитом размера и запретом неизвестных полей
|
||
// (ревью 2026-10-10). MaxBytesReader защищает от раздутых тел;
|
||
// DisallowUnknownFields ловит опечатки в полях — критично для Terraform (лучше
|
||
// 400 с понятной ошибкой, чем молча проигнорированное поле).
|
||
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) error {
|
||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
|
||
dec := json.NewDecoder(r.Body)
|
||
dec.DisallowUnknownFields()
|
||
return dec.Decode(v)
|
||
}
|
||
|
||
// errResp возвращает структуру ошибки для JSON.
|
||
func errResp(msg string) map[string]string {
|
||
return map[string]string{"error": msg}
|
||
}
|
||
|
||
// pathVar читает переменную пути из gorilla/mux.
|
||
func pathVar(r *http.Request, key string) string {
|
||
return mux.Vars(r)[key]
|
||
}
|
||
|
||
// defaultNamespace — namespace, когда в пути он не задан.
|
||
const defaultNamespace = "default"
|
||
|
||
// namespaceFromPath возвращает {namespace} из URL (пусто → "default").
|
||
func namespaceFromPath(r *http.Request) string {
|
||
ns := pathVar(r, "namespace")
|
||
if ns == "" {
|
||
ns = defaultNamespace
|
||
}
|
||
return ns
|
||
}
|
||
|
||
// requireNamespace — namespace из URL + его валидация. При невалидном значении
|
||
// отвечает 400 и возвращает ok=false (вызывающий обязан выйти).
|
||
//
|
||
// Зачем единая точка: раньше проверка была ТОЛЬКО в CreateIoTDevice, а
|
||
// GET/DELETE/PATCH/telemetry пропускали любой namespace дальше (тест 2026-10-11:
|
||
// `GET /v1/namespaces/bad_ns/iot/devices` → 200, а чтение телеметрии с таким ns
|
||
// падало уже в хранилище с 500). Валидируем на входе у всех — единообразный 400.
|
||
func requireNamespace(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||
ns := namespaceFromPath(r)
|
||
if msg := validateIdentifier(ns, "namespace", 56); msg != "" {
|
||
writeJSON(w, http.StatusBadRequest, errResp(msg))
|
||
return "", false
|
||
}
|
||
return ns, true
|
||
}
|