feat: SpaceAPI v15 support

Delete types/v14.go, replace with v15.go.

Rename internal types to not include message version.
I don't currently see the need to support multiple message
versions that have incompatible layouts.
v15 is backwards compatible with v14, so the config example now
specifies both.

Rename the "response" field in the config file to "static" as
dynamic/static makes more sense from the user's perspective.
(Internally it's still SpaceapidConfig.{Dynamic,Response}
to align with type names and usage.)
This commit is contained in:
Bendodroid 2026-07-25 22:35:17 +02:00
commit a53a5c3517
10 changed files with 293 additions and 196 deletions

View file

@ -6,7 +6,8 @@
$ curl http://localhost:8080 | jq $ curl http://localhost:8080 | jq
{ {
"api_compatibility": [ "api_compatibility": [
"14" "14",
"15"
], ],
"space": "CCCHH", "space": "CCCHH",
... ...
@ -22,9 +23,9 @@ The config consists of three parts:
- `"credentials"` - `"credentials"`
- List of Username/Password credentials for HTTP BasicAuth - List of Username/Password credentials for HTTP BasicAuth
- `"dynamic"` - `"dynamic"`
- The configuration for the dynamic parts of the message - The dynamic parts of the message
- `"response"` - `"static"`
- The static (pre-filled) parts of the response - The static (pre-filled) parts of the message
See [Running](#Running) for details. See [Running](#Running) for details.

View file

@ -66,9 +66,10 @@
} }
} }
}, },
"response": { "static": {
"api_compatibility": [ "api_compatibility": [
"14" "14",
"15"
], ],
"space": "CCCHH", "space": "CCCHH",
"logo": "https://hamburg.ccc.de/images/logo.svg", "logo": "https://hamburg.ccc.de/images/logo.svg",

View file

@ -86,9 +86,9 @@ func ParseConfiguration() (conf SpaceapidConfig) {
} }
} }
// Check if compatible with v14 // Sanity check: Config should declare compatibility with current SpaceAPI message version
if !slices.Contains(conf.Response.APICompatibility, "14") { if !slices.Contains(conf.Response.APICompatibility, types.CurrentMessageVersion) {
log.Fatalln("Provided file doesn't specify compatibility with API version 14") log.Fatalln("Provided config doesn't specify compatibility with current API version", types.CurrentMessageVersion)
} }
// Initialize fields for environment sensors // Initialize fields for environment sensors

View file

@ -34,5 +34,5 @@ type SpaceapidConfig struct {
// The dynamic part of the spaceapi JSON // The dynamic part of the spaceapi JSON
Dynamic DynamicStateConfig `json:"dynamic"` Dynamic DynamicStateConfig `json:"dynamic"`
// The static part of the spaceapi JSON // The static part of the spaceapi JSON
Response types.SpaceAPIResponseV14 `json:"response"` Response types.SpaceAPIResponse `json:"static"`
} }

View file

@ -8,7 +8,7 @@ import (
"git.hamburg.ccc.de/ccchh/spaceapid/types" "git.hamburg.ccc.de/ccchh/spaceapid/types"
) )
func Root(resp *types.SpaceAPIResponseV14) func(http.ResponseWriter, *http.Request) { func Root(resp *types.SpaceAPIResponse) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// Check if GET method // Check if GET method
if r.Method != http.MethodGet { if r.Method != http.MethodGet {

View file

@ -12,7 +12,7 @@ import (
func StateOpen( func StateOpen(
authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID, authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID,
resp *types.SpaceState, resp *types.SpaceAPIState,
) func(http.ResponseWriter, *http.Request) { ) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
body, err := updateEndpointValidator(authDB, validCredentials, w, r) body, err := updateEndpointValidator(authDB, validCredentials, w, r)
@ -37,7 +37,7 @@ func StateOpen(
func StateMessagePUT( func StateMessagePUT(
authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID, authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID,
resp *types.SpaceState, resp *types.SpaceAPIState,
) func(http.ResponseWriter, *http.Request) { ) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
body, err := updateEndpointValidator(authDB, validCredentials, w, r) body, err := updateEndpointValidator(authDB, validCredentials, w, r)
@ -54,7 +54,7 @@ func StateMessagePUT(
func StateMessageDELETE( func StateMessageDELETE(
authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID, authDB config.HTTPBACredentials, validCredentials []config.HTTPBACredentialID,
resp *types.SpaceState, resp *types.SpaceAPIState,
) func(http.ResponseWriter, *http.Request) { ) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
_, err := updateEndpointValidator(authDB, validCredentials, w, r) _, err := updateEndpointValidator(authDB, validCredentials, w, r)

View file

@ -46,7 +46,7 @@ func main() {
// Register signal handler // Register signal handler
sc := make(chan os.Signal, 1) sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM)
go func(resp *types.SpaceAPIResponseV14) { go func(resp *types.SpaceAPIResponse) {
<-sc <-sc
log.Println("Saving state and shutting down...") log.Println("Saving state and shutting down...")
persistence.SaveCurrentState(*resp) persistence.SaveCurrentState(*resp)

View file

@ -16,11 +16,11 @@ const (
// MergeOldState merges a given SpaceAPIResponse with the state saved at the time of last program exit and then deletes // MergeOldState merges a given SpaceAPIResponse with the state saved at the time of last program exit and then deletes
// the file containing the old state. // the file containing the old state.
func MergeOldState(response *types.SpaceAPIResponseV14) { func MergeOldState(response *types.SpaceAPIResponse) {
var ( var (
err error err error
oldState []byte oldState []byte
persistedState types.PersistentStateV14 persistedState types.PersistentSpaceState
) )
log.Println("Merging old state from", persistentStatePath, "...") log.Println("Merging old state from", persistentStatePath, "...")
@ -48,7 +48,7 @@ func MergeOldState(response *types.SpaceAPIResponseV14) {
} }
// Merge state // Merge state
response.State = persistedState.State response.State.FromPersistentSpaceState(persistedState)
// Merge sensors // Merge sensors
for key, environmentSensors := range persistedState.Sensors { for key, environmentSensors := range persistedState.Sensors {
@ -72,7 +72,7 @@ removeOld:
} }
} }
func SaveCurrentState(response types.SpaceAPIResponseV14) { func SaveCurrentState(response types.SpaceAPIResponse) {
// Create state directory if not present // Create state directory if not present
err := os.MkdirAll(persistentStateDir, 0750) err := os.MkdirAll(persistentStateDir, 0750)
if err != nil { if err != nil {
@ -87,7 +87,7 @@ func SaveCurrentState(response types.SpaceAPIResponseV14) {
defer file.Close() defer file.Close()
// Create persistent state // Create persistent state
persistentStateV14 := types.PersistentStateV14{ persistentStateV14 := types.PersistentSpaceState{
State: struct { State: struct {
Open bool `json:"open"` Open bool `json:"open"`
LastChange int64 `json:"lastchange"` LastChange int64 `json:"lastchange"`

View file

@ -1,69 +0,0 @@
package types
type SpaceAPIResponseV14 struct {
APICompatibility []string `json:"api_compatibility"`
Space string `json:"space"`
Logo string `json:"logo"`
ExtCCC string `json:"ext_ccc"`
URL string `json:"url"`
Location struct {
Address string `json:"address"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
} `json:"location"`
State SpaceState `json:"state"`
Contact struct {
Phone string `json:"phone"`
IRC string `json:"irc"`
Mastodon string `json:"mastodon"`
Email string `json:"email"`
ML string `json:"ml"`
Matrix string `json:"matrix"`
} `json:"contact"`
Sensors map[string][]EnvironmentSensor `json:"sensors"`
Feeds struct {
Blog struct {
Type string `json:"type"`
URL string `json:"url"`
} `json:"blog"`
Calendar struct {
Type string `json:"type"`
URL string `json:"url"`
} `json:"calendar"`
} `json:"feeds"`
Links []struct {
Name string `json:"name"`
URL string `json:"url"`
} `json:"links"`
}
type SpaceState struct {
Open bool `json:"open"`
LastChange int64 `json:"lastchange"`
Message string `json:"message,omitempty"`
}
type EnvironmentSensor struct {
Value float64 `json:"value"`
Unit string `json:"unit"`
Location string `json:"location,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
LastChange int64 `json:"lastchange,omitempty"`
}
type PersistentStateV14 struct {
State struct {
Open bool `json:"open"`
LastChange int64 `json:"lastchange"`
Message string `json:"message,omitempty"`
} `json:"state"`
Sensors map[string][]PersistentEnvironmentSensor `json:"sensors,omitempty"`
}
type PersistentEnvironmentSensor struct {
Value float64 `json:"value"`
Location string `json:"location,omitempty"`
Name string `json:"name,omitempty"`
LastChange int64 `json:"lastchange"`
}

164
types/v15.go Normal file
View file

@ -0,0 +1,164 @@
package types
const CurrentMessageVersion = "15"
// SpaceAPIResponse represents the contents of the SpaceAPI message
//
// Currently implemented version: 15
type SpaceAPIResponse struct {
APICompatibility []string `json:"api_compatibility"` // REQUIRED
Space string `json:"space"` // REQUIRED
Logo string `json:"logo"` // REQUIRED
URL string `json:"url"` // REQUIRED
// Minimum number of properties: 1
Location struct {
Address string `json:"address,omitzero"`
Lat float64 `json:"lat,omitzero"`
Lon float64 `json:"lon,omitzero"`
Timezone string `json:"timezone,omitzero"`
CountryCode string `json:"country_code,omitzero"`
Hint string `json:"hint,omitzero"`
// Minimum number of items: 1
Areas []SpaceAPILocationAreas `json:"areas,omitempty"`
} `json:"location,omitzero"`
SpaceFed struct {
SpaceNet bool `json:"spacenet,omitzero"`
SpaceSAML bool `json:"spacesaml,omitzero"`
} `json:"spacefed,omitzero"`
Cam []string `json:"cam,omitempty"`
State SpaceAPIState `json:"state,omitzero"`
Events []SpaceAPIEvents `json:"events,omitempty"`
Contact struct {
Phone string `json:"phone,omitzero"`
Sip string `json:"sip,omitzero"`
// keymasters
Keymasters []struct {
Name string `json:"name,omitzero"`
IRCNick string `json:"irc_nick,omitzero"`
Phone string `json:"phone,omitzero"`
Email string `json:"email,omitzero"`
Twitter string `json:"twitter,omitzero"`
Xmpp string `json:"xmpp,omitzero"`
Mastodon string `json:"mastodon,omitzero"`
Matrix string `json:"matrix,omitzero"`
} `json:"keymasters,omitempty"`
IRC string `json:"irc,omitzero"`
Twitter string `json:"twitter,omitzero"`
Mastodon string `json:"mastodon,omitzero"`
Facebook string `json:"facebook,omitzero"`
Identica string `json:"identica,omitzero"`
Foursquare string `json:"foursquare,omitzero"`
Email string `json:"email,omitzero"`
ML string `json:"ml,omitzero"`
XMPP string `json:"xmpp,omitzero"`
IssueMail string `json:"issue_mail,omitzero"`
Gopher string `json:"gopher,omitzero"`
Matrix string `json:"matrix,omitzero"`
Mumble string `json:"mumble,omitzero"`
} `json:"contact"`
Sensors map[string][]EnvironmentSensor `json:"sensors,omitempty"`
Feeds struct {
Blog struct {
Type string `json:"type,omitzero"`
URL string `json:"url"` // REQUIRED
} `json:"blog,omitzero"`
Wiki struct {
Type string `json:"type,omitzero"`
URL string `json:"url"` // REQUIRED
} `json:"wiki,omitzero"`
Calendar struct {
Type string `json:"type,omitzero"`
URL string `json:"url"` // REQUIRED
} `json:"calendar,omitzero"`
Flickr struct {
Type string `json:"type,omitzero"`
URL string `json:"url"` // REQUIRED
} `json:"flickr,omitzero"`
} `json:"feeds,omitzero"`
Projects []string `json:"projects,omitempty"`
Links []struct {
Name string `json:"name"` // REQUIRED
URL string `json:"url"` // REQUIRED
Description string `json:"description,omitzero"`
} `json:"links,omitempty"`
MembershipPlans []struct {
Name string `json:"name"` // REQUIRED
Value int64 `json:"value"` // REQUIRED
Currency string `json:"currency"` // REQUIRED
BillingInterval string `json:"billing_interval"` // REQUIRED
Description string `json:"description,omitzero"`
} `json:"membership_plans,omitempty"`
LinkedSpaces []struct {
Endpoint string `json:"endpoint,omitzero"`
Website string `json:"website,omitzero"`
} `json:"linked_spaces,omitempty"`
ExtCCC string `json:"ext_ccc,omitzero"`
}
type SpaceAPILocationAreas struct {
Name string `json:"name,omitzero"`
Description string `json:"description,omitzero"`
SquareMeters float64 `json:"square_meters"` // REQUIRED
}
type SpaceAPIState struct {
Open bool `json:"open,omitempty"`
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
s.LastChange = ps.State.LastChange
s.Message = ps.State.Message
}
type SpaceAPIEvents struct {
Name string `json:"name"` // REQUIRED
Type string `json:"type"` // REQUIRED
Timestamp int64 `json:"timestamp"` // REQUIRED
Extra string `json:"extra,omitzero"`
}
type EnvironmentSensor struct {
Value float64 `json:"value"`
Unit string `json:"unit"`
Location string `json:"location,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
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"`
Sensors map[string][]PersistentEnvironmentSensor `json:"sensors,omitempty"`
}
type PersistentEnvironmentSensor struct {
Value float64 `json:"value"`
Location string `json:"location,omitempty"`
Name string `json:"name,omitempty"`
LastChange int64 `json:"lastchange"`
}