// Package httpapi exposes the same device commands as the gRPC server over // plain HTTP/JSON, for callers where a full gRPC client is impractical // (Laravel/PHP, in this platform's case — see the service README for why). // It's a thin transport: all the actual logic (Device Shadow patch + MQTT // publish) lives in internal/server and is reused as-is. package httpapi import ( "context" "encoding/json" "log/slog" "net/http" devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control" ) // Dispatcher is the subset of server.Server this package needs. type Dispatcher interface { TurnOn(ctx context.Context, req *devicecontrol.TurnOnRequest) (*devicecontrol.CommandResult, error) TurnOff(ctx context.Context, req *devicecontrol.TurnOffRequest) (*devicecontrol.CommandResult, error) SetLevel(ctx context.Context, req *devicecontrol.SetLevelRequest) (*devicecontrol.CommandResult, error) } type commandResult struct { Success bool `json:"success"` Error string `json:"error,omitempty"` } func NewRouter(dispatcher Dispatcher, logger *slog.Logger) http.Handler { mux := http.NewServeMux() mux.HandleFunc("POST /devices/{device_id}/turn-on", func(w http.ResponseWriter, r *http.Request) { deviceID := r.PathValue("device_id") res, err := dispatcher.TurnOn(r.Context(), &devicecontrol.TurnOnRequest{DeviceId: deviceID}) writeResult(w, logger, res, err) }) mux.HandleFunc("POST /devices/{device_id}/turn-off", func(w http.ResponseWriter, r *http.Request) { deviceID := r.PathValue("device_id") res, err := dispatcher.TurnOff(r.Context(), &devicecontrol.TurnOffRequest{DeviceId: deviceID}) writeResult(w, logger, res, err) }) mux.HandleFunc("POST /devices/{device_id}/set-level", func(w http.ResponseWriter, r *http.Request) { deviceID := r.PathValue("device_id") var body struct { Level float64 `json:"level"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(commandResult{Success: false, Error: "invalid JSON body: expected {\"level\": number}"}) return } res, err := dispatcher.SetLevel(r.Context(), &devicecontrol.SetLevelRequest{DeviceId: deviceID, Level: body.Level}) writeResult(w, logger, res, err) }) return mux } // writeResult always answers 200 with the command's success/error in the // body — the wire contract mirrors devicecontrol.CommandResult exactly, the // same as the gRPC transport. A non-nil err here is a transport-layer bug in // the dispatcher (it isn't supposed to return one), logged and surfaced as a // generic failure rather than leaking internals to the caller. func writeResult(w http.ResponseWriter, logger *slog.Logger, res *devicecontrol.CommandResult, err error) { w.Header().Set("Content-Type", "application/json") if err != nil { logger.Error("dispatcher returned unexpected error", "error", err) w.WriteHeader(http.StatusInternalServerError) _ = json.NewEncoder(w).Encode(commandResult{Success: false, Error: "internal error"}) return } _ = json.NewEncoder(w).Encode(commandResult{Success: res.GetSuccess(), Error: res.GetError()}) }