Compare commits

...
Author SHA1 Message Date
98d22b9f72 feat: Add flags to set listening address/port 2026-10-09 00:30:39 +02:00
11046cb501 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
2026-10-09 00:30:26 +02:00
7 changed files with 118 additions and 41 deletions

View file

@ -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)
}

2
go.mod
View file

@ -1,3 +1,3 @@
module git.hamburg.ccc.de/ccchh/spaceapid
go 1.22
go 1.27

View file

@ -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)

View file

@ -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()
}
}

12
main.go
View file

@ -2,6 +2,7 @@ package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
@ -17,10 +18,14 @@ import (
var (
flagHelp bool
listenAddress string
listenPort string
version string
)
func init() {
flag.StringVar(&listenAddress, "i", "", "IP address the HTTP server should bind to (all available addresses by default)")
flag.StringVar(&listenPort, "p", "8080", "Port the HTTP server should listen on")
flag.BoolVar(&flagHelp, "h", false, "Print help output")
}
@ -59,7 +64,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",
@ -91,5 +99,5 @@ func main() {
// Start webserver
log.Println("Starting HTTP server...")
log.Fatalln(http.ListenAndServe(":8080", nil))
log.Fatalln(http.ListenAndServe(fmt.Sprintf("%s:%s", listenAddress, listenPort), nil))
}

View file

@ -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)
}

View file

@ -1,5 +1,9 @@
package types
import (
"encoding/json/v2"
)
const CurrentMessageVersion = "15"
// SpaceAPIResponse represents the contents of the SpaceAPI message
@ -114,8 +118,31 @@ 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"`
Open SpaceAPIStateOpen `json:"open,omitzero"`
LastChange int64 `json:"lastchange,omitempty"`
TriggerPerson string `json:"trigger_person,omitempty"`
Message string `json:"message,omitempty"`
@ -125,8 +152,16 @@ type SpaceAPIState struct {
} `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,13 +182,16 @@ type EnvironmentSensor struct {
LastChange int64 `json:"lastchange,omitempty"`
}
type PersistentState struct {
State PersistentSpaceState `json:"state"`
Sensors map[string][]PersistentEnvironmentSensor `json:"sensors,omitempty"`
}
type PersistentSpaceState struct {
State struct {
Open bool `json:"open"`
IsUnknown bool `json:"is_unknown"`
LastChange int64 `json:"lastchange"`
Message string `json:"message,omitempty"`
} `json:"state"`
Sensors map[string][]PersistentEnvironmentSensor `json:"sensors,omitempty"`
}
type PersistentEnvironmentSensor struct {