From 11046cb50132d0f3a16cca2266ae09e34e41a055 Mon Sep 17 00:00:00 2001 From: Bennett Wetters Date: Thu, 8 Oct 2026 23:58:10 +0200 Subject: [PATCH] feat: Allow setting state->open to unknown via DELETE The schema defines the absence of state->open to represent the unknown state. Previously it was not possible to explicitly set this state via the HTTP endpoints. types.SpaceAPIState.Open is now an enum which is either unknown, closed, or open. Custom IsZero() and MarshalJSON() methods on the type ensure marshaling the value yields a boolean for open/closed and an empty byte slice for unknown (resulting in the field being absent from the final response). The handlers.StateOpenDELETE() function has been added to expose this behavior. Switching to encoding/json/v2 required raising the minimum Go version in go.mod to 1.27. Closes: CCCHH/spaceapid!37 Fixes: CCCHH/spaceapid#38 --- config/config.go | 6 ++-- go.mod | 2 +- handlers/root.go | 4 +-- handlers/state.go | 27 +++++++++++++-- main.go | 5 ++- persistence/persistentState.go | 42 +++++++++++++++-------- types/v15.go | 62 +++++++++++++++++++++++++++------- 7 files changed, 110 insertions(+), 38 deletions(-) diff --git a/config/config.go b/config/config.go index 7ad632c..05727aa 100644 --- a/config/config.go +++ b/config/config.go @@ -2,7 +2,7 @@ package config import ( "bytes" - "encoding/json" + "encoding/json/v2" "flag" "fmt" "log" @@ -78,9 +78,7 @@ func ParseConfiguration() (conf SpaceapidConfig) { } // Parse JSON - dec := json.NewDecoder(bytes.NewReader(file)) - dec.DisallowUnknownFields() - err = dec.Decode(&conf) + err = json.UnmarshalRead(bytes.NewReader(file), &conf, json.RejectUnknownMembers(true)) if err != nil { log.Fatalln("Could not parse spaceapid config file:", err) } diff --git a/go.mod b/go.mod index 506613d..61b6d1f 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module git.hamburg.ccc.de/ccchh/spaceapid -go 1.22 +go 1.27 diff --git a/handlers/root.go b/handlers/root.go index 88cdc55..e79023a 100644 --- a/handlers/root.go +++ b/handlers/root.go @@ -1,7 +1,7 @@ package handlers import ( - "encoding/json" + "encoding/json/v2" "log" "net/http" @@ -19,7 +19,7 @@ func Root(resp *types.SpaceAPIResponse) func(http.ResponseWriter, *http.Request) } // Serialize response - response, err := json.Marshal(resp) + response, err := json.Marshal(resp, json.Deterministic(true)) if err != nil { log.Println("Failed to serialize JSON response:", err) w.WriteHeader(http.StatusInternalServerError) diff --git a/handlers/state.go b/handlers/state.go index d4962c1..47d7878 100644 --- a/handlers/state.go +++ b/handlers/state.go @@ -10,7 +10,7 @@ import ( "git.hamburg.ccc.de/ccchh/spaceapid/types" ) -func StateOpen( +func StateOpenPUT( authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID, resp *types.SpaceAPIState, ) func(http.ResponseWriter, *http.Request) { @@ -22,7 +22,7 @@ func StateOpen( } // Parse request body - newState, err := strconv.ParseBool(string(body)) + doorNowOpen, err := strconv.ParseBool(string(body)) if err != nil { log.Println("Failed to parse request body from", r.RemoteAddr, "with error:", err) http.Error(w, "HTTP request body should either be true or false", http.StatusBadRequest) @@ -30,7 +30,28 @@ func StateOpen( } // Set SpaceAPI response values - resp.Open = newState + if doorNowOpen { + resp.Open = types.SpaceAPIStateOpenIsOpen + } else { + resp.Open = types.SpaceAPIStateOpenIsClosed + } + resp.LastChange = time.Now().Unix() + } +} + +func StateOpenDELETE( + authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID, + resp *types.SpaceAPIState, +) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + _, err := updateEndpointValidator(authDB, validCredentials, w, r) + if err != nil { + log.Println(err) + return + } + + // Set SpaceAPI response values + resp.Open = types.SpaceAPIStateOpenIsUnknown resp.LastChange = time.Now().Unix() } } diff --git a/main.go b/main.go index 4b01eee..9ab02ef 100644 --- a/main.go +++ b/main.go @@ -59,7 +59,10 @@ func main() { ) // state->open http.HandleFunc("PUT /state/open", - handlers.StateOpen(conf.Credentials, conf.Dynamic.State.Open.AllowedCredentials, &conf.Response.State), + handlers.StateOpenPUT(conf.Credentials, conf.Dynamic.State.Open.AllowedCredentials, &conf.Response.State), + ) + http.HandleFunc("DELETE /state/open", + handlers.StateOpenDELETE(conf.Credentials, conf.Dynamic.State.Open.AllowedCredentials, &conf.Response.State), ) // state->message http.HandleFunc("PUT /state/message", diff --git a/persistence/persistentState.go b/persistence/persistentState.go index 87c3121..1f72394 100644 --- a/persistence/persistentState.go +++ b/persistence/persistentState.go @@ -1,7 +1,8 @@ package persistence import ( - "encoding/json" + "encoding/json/jsontext" + "encoding/json/v2" "errors" "log" "os" @@ -20,7 +21,7 @@ func MergeOldState(response *types.SpaceAPIResponse) { var ( err error oldState []byte - persistedState types.PersistentSpaceState + persistedState types.PersistentState ) log.Println("Merging old state from", persistentStatePath, "...") @@ -87,27 +88,38 @@ func SaveCurrentState(response types.SpaceAPIResponse) { defer file.Close() // Create persistent state - persistentStateV14 := types.PersistentSpaceState{ - State: struct { - Open bool `json:"open"` - LastChange int64 `json:"lastchange"` - Message string `json:"message,omitempty"` - }{Open: response.State.Open, LastChange: response.State.LastChange, Message: response.State.Message}, + var ( + isOpen bool + isUnknown bool + ) + switch response.State.Open { + case types.SpaceAPIStateOpenIsUnknown: + isUnknown = true + case types.SpaceAPIStateOpenIsClosed: + break + case types.SpaceAPIStateOpenIsOpen: + isOpen = true + } + persistentState := types.PersistentState{ + State: types.PersistentSpaceState{ + Open: isOpen, IsUnknown: isUnknown, + LastChange: response.State.LastChange, Message: response.State.Message, + }, + Sensors: make(map[string][]types.PersistentEnvironmentSensor), } // Save sensor state - persistentStateV14.Sensors = make(map[string][]types.PersistentEnvironmentSensor) for key, environmentSensors := range response.Sensors { - persistentStateV14.Sensors[key] = make([]types.PersistentEnvironmentSensor, len(environmentSensors)) + persistentState.Sensors[key] = make([]types.PersistentEnvironmentSensor, len(environmentSensors)) for i, sensor := range environmentSensors { - persistentStateV14.Sensors[key][i].Value = sensor.Value - persistentStateV14.Sensors[key][i].Location = sensor.Location - persistentStateV14.Sensors[key][i].Name = sensor.Name - persistentStateV14.Sensors[key][i].LastChange = sensor.LastChange + persistentState.Sensors[key][i].Value = sensor.Value + persistentState.Sensors[key][i].Location = sensor.Location + persistentState.Sensors[key][i].Name = sensor.Name + persistentState.Sensors[key][i].LastChange = sensor.LastChange } } // Serialize persistent state - marshal, err := json.MarshalIndent(persistentStateV14, "", "\t") + marshal, err := json.Marshal(persistentState, json.JoinOptions(jsontext.Multiline(true), jsontext.SpaceAfterComma(true))) if err != nil { log.Fatalln("Failed serializing persistent state... error:", err) } diff --git a/types/v15.go b/types/v15.go index 5180ba6..b844175 100644 --- a/types/v15.go +++ b/types/v15.go @@ -1,5 +1,9 @@ package types +import ( + "encoding/json/v2" +) + const CurrentMessageVersion = "15" // SpaceAPIResponse represents the contents of the SpaceAPI message @@ -114,19 +118,50 @@ type SpaceAPILocationAreas struct { SquareMeters float64 `json:"square_meters"` // REQUIRED } +type SpaceAPIStateOpen int + +const ( + SpaceAPIStateOpenIsUnknown = iota + SpaceAPIStateOpenIsClosed + SpaceAPIStateOpenIsOpen +) + +func (state SpaceAPIStateOpen) MarshalJSON() ([]byte, error) { + switch state { + case SpaceAPIStateOpenIsClosed: + return json.Marshal(false) + case SpaceAPIStateOpenIsOpen: + return json.Marshal(true) + default: + return []byte{}, nil + } +} + +func (state SpaceAPIStateOpen) IsZero() bool { + return state == SpaceAPIStateOpenIsUnknown +} + type SpaceAPIState struct { - Open bool `json:"open,omitempty"` - LastChange int64 `json:"lastchange,omitempty"` - TriggerPerson string `json:"trigger_person,omitempty"` - Message string `json:"message,omitempty"` + Open SpaceAPIStateOpen `json:"open,omitzero"` + LastChange int64 `json:"lastchange,omitempty"` + TriggerPerson string `json:"trigger_person,omitempty"` + Message string `json:"message,omitempty"` Icon struct { Open string `json:"open,omitempty"` // REQUIRED Closed string `json:"closed,omitempty"` // REQUIRED } `json:"icon,omitzero"` } -func (s *SpaceAPIState) FromPersistentSpaceState(ps PersistentSpaceState) { - s.Open = ps.State.Open +func (s *SpaceAPIState) FromPersistentSpaceState(ps PersistentState) { + if ps.State.IsUnknown { + s.Open = SpaceAPIStateOpenIsUnknown + } else { + if ps.State.Open { + s.Open = SpaceAPIStateOpenIsOpen + } else { + s.Open = SpaceAPIStateOpenIsClosed + } + } s.LastChange = ps.State.LastChange s.Message = ps.State.Message } @@ -147,15 +182,18 @@ type EnvironmentSensor struct { LastChange int64 `json:"lastchange,omitempty"` } -type PersistentSpaceState struct { - State struct { - Open bool `json:"open"` - LastChange int64 `json:"lastchange"` - Message string `json:"message,omitempty"` - } `json:"state"` +type PersistentState struct { + State PersistentSpaceState `json:"state"` Sensors map[string][]PersistentEnvironmentSensor `json:"sensors,omitempty"` } +type PersistentSpaceState struct { + Open bool `json:"open"` + IsUnknown bool `json:"is_unknown"` + LastChange int64 `json:"lastchange"` + Message string `json:"message,omitempty"` +} + type PersistentEnvironmentSensor struct { Value float64 `json:"value"` Location string `json:"location,omitempty"`