Files
PilotVault/API Server/internal/api/integrations.go
T
tajniak81andClaude Opus 4.8 125d0ccc7d Make air-traffic refresh interval configurable, resolved from plan
Add a refresh-interval control to the Map settings popover: Auto (follows
the OpenSky plan) or a fixed 15/30/60/120s. The states endpoint now
resolves a recommended interval from the resolved plan (anonymous 60s,
standard 30s, contributor 15s) and returns it alongside the aircraft, so
"Auto" tracks the plan's credit budget and update rate. Polling reschedules
live when the effective interval changes; the map caption reflects it. New
airTrafficInterval preference (default 'auto', persisted + synced).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:58:19 +02:00

705 lines
25 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
)
// Integrations exposes the OpenSky plugin's settings to end users under a
// three-layer cascade (superadmin/global → organization → user). Each of the
// four settings resolves independently, top wins, and a blank field falls
// through to the layer below:
//
// - global (L1): the plugin's config in plugins.json, set in the API Server panel.
// - org (L2): pluginSettings on the caller's organization record (org admins).
// - user (L3): pluginSettings on the caller's own user record.
//
// The OAuth2 client id + secret resolve as a *pair* from the highest layer that
// supplies a client id, so credential halves are never mixed across layers.
// Enablement is strictly per-user (L3) and gated by the global master switch.
//
// Secrets (and inherited client ids) are never returned to a lower-privileged
// client: the effective config is resolved server-side and only masked values
// leave the API. Live probes run server-side against the resolved config.
const (
openSkyPlugin = "opensky"
openSkySecretMask = "••••••••"
)
// osConfig is one layer's OpenSky settings.
type osConfig struct {
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
Plan string `json:"plan"`
Bbox string `json:"bbox"`
}
// osStored is what we persist per user/org under pluginSettings.opensky.
type osStored struct {
Config osConfig `json:"config"`
// Enabled is the personal per-user opt-in (user layer). Default false.
Enabled bool `json:"enabled"`
// Disabled is the organization layer's off switch, stored inverted so that
// absent == enabled: existing org records (which carry a legacy enabled:false
// from earlier config saves) therefore read as enabled, avoiding a regression.
// Only meaningful on the org record; ignored on user records.
Disabled bool `json:"disabled,omitempty"`
}
// osSettingsDoc is the pluginSettings JSON shape (only opensky today).
type osSettingsDoc struct {
OpenSky osStored `json:"opensky"`
}
// osFieldView is one field's resolved state for the UI.
type osFieldView struct {
Effective string `json:"effective"` // resolved value in force (secrets/inherited creds masked)
Own string `json:"own"` // the caller's own editable-layer value (secret masked)
Source string `json:"source"` // global | org | user | unset
Locked bool `json:"locked"` // set above the caller's editable layer
}
// osResolution is the fully-resolved OpenSky state for one caller. It captures the
// effective cascade plus both editable layers (personal + organization), so an org
// admin can manage each independently — their own settings as a user, and the
// organization-wide settings that override every user's.
type osResolution struct {
eff osConfig // effective (unmasked) — used only server-side (probes)
userOwn osConfig // caller's personal (L3) values (unmasked)
orgOwn osConfig // organization (L2) values (unmasked)
source map[string]string // field -> layer name (global|org|user|unset)
isSuper bool // superadmin: manages the global layer in the panel
canOrg bool // caller may edit the organization layer (org admin)
available bool // global master switch
orgEnabled bool // org master switch (default true; gates the org's users)
allowAnon bool // global anonymous policy
enabled bool // caller's personal enable flag
}
// resolveOpenSky computes the cascade for a caller. userRaw is the caller's
// pluginSettings blob (from their auth-refresh record).
func (s *Server) resolveOpenSky(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) osResolution {
g, masterEnabled, _ := s.plugins.RawConfig(openSkyPlugin)
gc := osConfig{ClientID: g["clientId"], ClientSecret: g["clientSecret"], Plan: g["plan"], Bbox: g["bbox"]}
allowAnon := !strings.EqualFold(strings.TrimSpace(g["allowAnonymous"]), "false")
var oStored osStored
if who.OrgID != "" {
oStored, _ = s.orgOpenSky(ctx, who.OrgID)
}
oc := oStored.Config
var uStored osStored
if len(userRaw) > 0 {
var d osSettingsDoc
_ = json.Unmarshal(userRaw, &d)
uStored = d.OpenSky
}
uc := uStored.Config
res := osResolution{
source: map[string]string{},
userOwn: uc,
orgOwn: oc,
isSuper: who.isSuperadmin(),
// An org admin may edit the organization layer in addition to their own
// personal layer. Requires the service account (org writes go through it);
// without it the org layer is invisible to the cascade anyway.
canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.admin.configured(),
available: masterEnabled,
// Org gate: enabled by default, off only when the org explicitly disabled it.
orgEnabled: !oStored.Disabled,
allowAnon: allowAnon,
enabled: uStored.Enabled,
}
// Ordered layers, top (highest priority) first.
type layer struct {
name string
c osConfig
}
layers := []layer{{"global", gc}}
if who.OrgID != "" {
layers = append(layers, layer{"org", oc})
}
layers = append(layers, layer{"user", uc})
pick := func(get func(osConfig) string) (val, src string) {
for _, l := range layers {
if v := strings.TrimSpace(get(l.c)); v != "" {
return v, l.name
}
}
return "", "unset"
}
res.eff.Plan, res.source["plan"] = pick(func(c osConfig) string { return c.Plan })
res.eff.Bbox, res.source["bbox"] = pick(func(c osConfig) string { return c.Bbox })
// Credentials resolve as a pair from the highest layer with a client id.
credSrc := "unset"
for _, l := range layers {
if id := strings.TrimSpace(l.c.ClientID); id != "" {
res.eff.ClientID, res.eff.ClientSecret, credSrc = id, l.c.ClientSecret, l.name
break
}
}
res.source["clientId"] = credSrc
res.source["clientSecret"] = credSrc
return res
}
// layerRank orders the cascade layers; a higher number is lower priority.
var layerRank = map[string]int{"global": 1, "org": 2, "user": 3}
// lockedFor reports whether a field whose value comes from source is locked for a
// caller whose editable layer is editable (i.e. the value is set above them).
func lockedFor(source, editable string) bool {
if editable == "none" {
return true // superadmin edits the global layer in the panel, not here
}
sr, ok := layerRank[source]
if !ok {
return false // unset — the caller may be the first to set it
}
return sr < layerRank[editable]
}
// maskPresent returns the secret mask when v is non-empty, else "".
func maskPresent(v string) string {
if strings.TrimSpace(v) != "" {
return openSkySecretMask
}
return ""
}
// orgOpenSky reads an organization's stored OpenSky settings (config + the org
// gate) and its raw pluginSettings blob via the service account. Best effort: zero
// values on any miss so callers can proceed as if the org layer were empty.
func (s *Server) orgOpenSky(ctx context.Context, orgID string) (osStored, json.RawMessage) {
if orgID == "" || !s.admin.configured() {
return osStored{}, nil
}
data, status, err := s.admin.do(ctx, http.MethodGet,
"/api/collections/organizations/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil)
if err != nil || status != http.StatusOK {
return osStored{}, nil
}
var rec struct {
PluginSettings json.RawMessage `json:"pluginSettings"`
}
_ = json.Unmarshal(data, &rec)
var doc osSettingsDoc
if len(rec.PluginSettings) > 0 {
_ = json.Unmarshal(rec.PluginSettings, &doc)
}
return doc.OpenSky, rec.PluginSettings
}
// mergeOpenSky applies a mutation to the opensky entry of a pluginSettings blob,
// preserving any other plugin keys, and returns the new blob.
func mergeOpenSky(existing json.RawMessage, apply func(*osStored)) json.RawMessage {
doc := map[string]json.RawMessage{}
if len(existing) > 0 {
_ = json.Unmarshal(existing, &doc)
}
if doc == nil {
doc = map[string]json.RawMessage{} // existing was JSON null
}
var os osStored
if raw, ok := doc["opensky"]; ok {
_ = json.Unmarshal(raw, &os)
}
apply(&os)
b, _ := json.Marshal(os)
doc["opensky"] = b
out, _ := json.Marshal(doc)
return out
}
// callerFromRecord builds an identity from an auth-refresh record.
func callerFromRecord(rec *pbAuthResp) *callerIdentity {
role := unquote(rec.Record["role"])
if role == "" {
role = roleUser
}
return &callerIdentity{
ID: unquote(rec.Record["id"]),
Email: unquote(rec.Record["email"]),
Role: role,
OrgID: unquote(rec.Record["organization"]),
}
}
// GET /api/integrations/opensky — resolved OpenSky view for the caller.
func (s *Server) handleGetOpenSky(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
writeError(w, http.StatusUnauthorized, "missing token")
return
}
rec, status, err := s.pbAuthRefresh(r.Context(), token)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
return
}
if status != http.StatusOK || rec == nil {
writeError(w, http.StatusUnauthorized, "invalid or expired token")
return
}
who := callerFromRecord(rec)
res := s.resolveOpenSky(r.Context(), who, rec.Record["pluginSettings"])
writeJSON(w, http.StatusOK, s.openSkyView(who, res))
}
// osScopeView builds the masked field set for one editable scope. editable is the
// layer the caller edits in this scope ("user" | "org" | "none"); a field is locked
// when its effective value is set above that layer.
func (s *Server) osScopeView(res osResolution, editable string) map[string]any {
own := res.userOwn
if editable == "org" {
own = res.orgOwn
}
field := func(key, eff, ownv string, secret bool) osFieldView {
src := res.source[key]
locked := lockedFor(src, editable)
fv := osFieldView{Source: src, Locked: locked}
switch {
case secret:
// Never expose a secret; show only presence.
fv.Effective, fv.Own = maskPresent(eff), maskPresent(ownv)
case key == "clientId" && locked:
// Inherited client id — hide the concrete value from a lower layer.
fv.Effective, fv.Own = maskPresent(eff), maskPresent(ownv)
default:
fv.Effective, fv.Own = eff, ownv
}
return fv
}
return map[string]any{
"editableLayer": editable,
"fields": map[string]osFieldView{
"clientId": field("clientId", res.eff.ClientID, own.ClientID, false),
"clientSecret": field("clientSecret", res.eff.ClientSecret, own.ClientSecret, true),
"plan": field("plan", res.eff.Plan, own.Plan, false),
"bbox": field("bbox", res.eff.Bbox, own.Bbox, false),
},
}
}
// openSkyView builds the masked, client-safe response body from a resolution. It
// exposes a "user" scope for everyone plus, for org admins, an "org" scope — each
// with its own locked-field state — so the UI can present the two independently.
func (s *Server) openSkyView(who *callerIdentity, res osResolution) map[string]any {
out := map[string]any{
"available": res.available,
"orgEnabled": res.orgEnabled,
"allowAnonymous": res.allowAnon,
"enabled": res.enabled,
"role": who.Role,
"orgId": who.OrgID,
"canEditOrg": res.canOrg,
"isSuperadmin": res.isSuper,
}
if res.isSuper {
// Superadmin manages the global layer in the panel; here it is read-only.
out["editableLayer"] = "none"
out["scopes"] = map[string]any{"user": s.osScopeView(res, "none")}
return out
}
scopes := map[string]any{"user": s.osScopeView(res, "user")}
if res.canOrg {
scopes["org"] = s.osScopeView(res, "org")
}
out["scopes"] = scopes
return out
}
// PUT /api/integrations/opensky — save the caller's editable layer. Body:
// {enabled?: bool, config?: {clientId, clientSecret, plan, bbox}}. Fields locked
// above the caller are ignored; a client secret left at the mask is preserved.
func (s *Server) handlePutOpenSky(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
writeError(w, http.StatusUnauthorized, "missing token")
return
}
var body struct {
Enabled *bool `json:"enabled"`
Scope string `json:"scope"` // "user" (default) | "org" (admins only)
Config map[string]string `json:"config"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
rec, status, err := s.pbAuthRefresh(r.Context(), token)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
return
}
if status != http.StatusOK || rec == nil {
writeError(w, http.StatusUnauthorized, "invalid or expired token")
return
}
who := callerFromRecord(rec)
userRaw := rec.Record["pluginSettings"]
res := s.resolveOpenSky(r.Context(), who, userRaw)
// Resolve which layer this write targets. Everyone edits their own personal
// (user) layer by default; an org admin may target the organization layer by
// asking for scope "org". Superadmins are read-only here (they manage global
// in the panel) and may only toggle their personal enable flag.
editable := "user"
switch {
case res.isSuper:
editable = "none"
case strings.EqualFold(strings.TrimSpace(body.Scope), "org"):
if !res.canOrg {
writeError(w, http.StatusForbidden, "only an organization admin can edit organization settings")
return
}
editable = "org"
}
// Build the new target-layer config from its current own values, overlaying
// only fields the caller is allowed to change in this scope.
newOwn := res.userOwn
if editable == "org" {
newOwn = res.orgOwn
}
applyField := func(key string, set func(*osConfig, string)) {
v, ok := body.Config[key]
if !ok || lockedFor(res.source[key], editable) {
return
}
if key == "clientSecret" && v == openSkySecretMask {
return // keep current secret
}
set(&newOwn, strings.TrimSpace(v))
}
applyField("plan", func(c *osConfig, v string) { c.Plan = v })
applyField("bbox", func(c *osConfig, v string) { c.Bbox = v })
applyField("clientId", func(c *osConfig, v string) { c.ClientID = v })
// Secret is not trimmed (may legitimately contain edge whitespace? no — trim
// for consistency with the panel's Upsert, which TrimSpaces all values).
applyField("clientSecret", func(c *osConfig, v string) { c.ClientSecret = v })
// Persist the organization layer (admins) via the service account.
if editable == "org" {
if who.OrgID == "" {
writeError(w, http.StatusForbidden, "your account is not attached to an organization")
return
}
if !s.admin.configured() {
writeError(w, http.StatusServiceUnavailable, "organization settings not configured on the server")
return
}
_, orgRaw := s.orgOpenSky(r.Context(), who.OrgID)
newDoc := mergeOpenSky(orgRaw, func(os *osStored) {
os.Config = newOwn
// In the org scope the enable flag is the org master switch, stored
// inverted (disabled) so absent means enabled.
if body.Enabled != nil {
os.Disabled = !*body.Enabled
}
})
_, st, err := s.admin.do(r.Context(), http.MethodPatch,
"/api/collections/organizations/records/"+url.PathEscape(who.OrgID),
map[string]json.RawMessage{"pluginSettings": newDoc})
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
return
}
if st != http.StatusOK {
writeError(w, http.StatusBadGateway, "could not save organization settings")
return
}
}
// Persist the user record: the personal enable flag lives here (user/superadmin
// scope — in the org scope it targets the org gate instead), and so does the
// personal config layer when this write targets the user scope.
personalEnable := body.Enabled != nil && editable != "org"
if personalEnable || editable == "user" {
newDoc := mergeOpenSky(userRaw, func(os *osStored) {
if personalEnable {
os.Enabled = *body.Enabled
}
if editable == "user" {
os.Config = newOwn
}
})
if code, err := s.patchUserPluginSettings(r.Context(), token, who.ID, newDoc); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
return
} else if code != http.StatusOK {
writeError(w, http.StatusBadGateway, "could not save user settings")
return
}
}
// Re-resolve and return the fresh view.
fresh, st, err := s.pbAuthRefresh(r.Context(), token)
if err != nil || st != http.StatusOK || fresh == nil {
// The writes succeeded; just report success minimally.
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
res2 := s.resolveOpenSky(r.Context(), who, fresh.Record["pluginSettings"])
writeJSON(w, http.StatusOK, s.openSkyView(who, res2))
}
// patchUserPluginSettings writes the pluginSettings blob onto the caller's own
// user record using their token (PocketBase authorises self-writes).
func (s *Server) patchUserPluginSettings(ctx context.Context, token, id string, doc json.RawMessage) (int, error) {
if id == "" {
return 0, io.EOF // treated as a transport-ish failure by the caller
}
patch, _ := json.Marshal(map[string]json.RawMessage{"pluginSettings": doc})
req, _ := http.NewRequestWithContext(ctx, http.MethodPatch,
s.auth.url()+"/api/collections/users/records/"+url.PathEscape(id), bytes.NewReader(patch))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
resp, err := s.auth.client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
return resp.StatusCode, nil
}
// POST /api/integrations/opensky/health — live probe using the caller's resolved
// config. Never returns secrets.
func (s *Server) handleOpenSkyHealth(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
writeError(w, http.StatusUnauthorized, "missing token")
return
}
rec, status, err := s.pbAuthRefresh(r.Context(), token)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
return
}
if status != http.StatusOK || rec == nil {
writeError(w, http.StatusUnauthorized, "invalid or expired token")
return
}
who := callerFromRecord(rec)
if !who.isSuperadmin() {
if _, _, ok := s.plugins.RawConfig(openSkyPlugin); !ok {
writeError(w, http.StatusNotFound, "unknown plugin")
return
}
}
res := s.resolveOpenSky(r.Context(), who, rec.Record["pluginSettings"])
if !res.available {
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
"status": "down", "detail": "OpenSky is disabled by the administrator"}})
return
}
if !res.orgEnabled {
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
"status": "down", "detail": "OpenSky is disabled for your organization"}})
return
}
cfg := map[string]string{
"clientId": res.eff.ClientID,
"clientSecret": res.eff.ClientSecret,
"plan": res.eff.Plan,
"bbox": res.eff.Bbox,
"allowAnonymous": boolStr(res.allowAnon),
}
h, err := s.plugins.HealthCheckWith(r.Context(), openSkyPlugin, cfg)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"health": h})
}
// osAircraft is one trimmed aircraft state vector for the Live map. It flattens the
// positional fields the UI actually plots out of OpenSky's raw index-addressed array.
type osAircraft struct {
Icao24 string `json:"icao24"`
Callsign string `json:"callsign"`
Country string `json:"country"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Heading *float64 `json:"heading,omitempty"` // true track, degrees
Velocity *float64 `json:"velocity,omitempty"` // m/s over ground
Altitude *float64 `json:"altitude,omitempty"` // barometric, metres
OnGround bool `json:"onGround"`
}
// GET /api/integrations/opensky/states — live aircraft positions for the caller's
// resolved bounding box, for plotting on the Web App Live map. Runs server-side
// against the resolved cascade config (never returns credentials). Gated by the
// same switches as the settings view: global master, org gate, and the caller's
// personal opt-in. When any gate is off it returns 200 with an empty list plus a
// reason, so the map can degrade quietly rather than error.
func (s *Server) handleOpenSkyStates(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
writeError(w, http.StatusUnauthorized, "missing token")
return
}
rec, status, err := s.pbAuthRefresh(r.Context(), token)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
return
}
if status != http.StatusOK || rec == nil {
writeError(w, http.StatusUnauthorized, "invalid or expired token")
return
}
who := callerFromRecord(rec)
res := s.resolveOpenSky(r.Context(), who, rec.Record["pluginSettings"])
// The plan drives the recommended refresh cadence (its daily credit budget and
// upstream update rate). Surfaced on every response so the "Auto" interval in the
// Map settings popover resolves even before the overlay is enabled.
plan := res.eff.Plan
recInterval := recommendedOpenSkyInterval(plan)
disabled := func(detail string) {
writeJSON(w, http.StatusOK, map[string]any{
"states": []osAircraft{}, "unavailable": true, "detail": detail,
"plan": plan, "recommendedInterval": recInterval})
}
switch {
case !res.available:
disabled("OpenSky is disabled by the administrator")
return
case !res.orgEnabled:
disabled("OpenSky is disabled for your organization")
return
case !res.enabled:
disabled("Enable OpenSky in Settings → Integrations to show live air traffic")
return
}
cfg := map[string]string{
"clientId": res.eff.ClientID,
"clientSecret": res.eff.ClientSecret,
"plan": res.eff.Plan,
"bbox": res.eff.Bbox,
"allowAnonymous": boolStr(res.allowAnon),
}
raw, err := s.plugins.InvokeWith(r.Context(), openSkyPlugin, cfg, "states.bbox", nil)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
// OpenSky /states/all shape: {time, states: [[icao24, callsign, country,
// time_position, last_contact, lon, lat, baro_altitude, on_ground, velocity,
// true_track, ...], ...]}. states may be null when nothing is in the box.
var osResp struct {
Time int64 `json:"time"`
States [][]json.RawMessage `json:"states"`
}
if err := json.Unmarshal(raw, &osResp); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "unexpected OpenSky response"})
return
}
aircraft := make([]osAircraft, 0, len(osResp.States))
for _, st := range osResp.States {
lng, okLng := rawFloat(st, 5)
lat, okLat := rawFloat(st, 6)
if !okLat || !okLng {
continue // no position fix — nothing to plot
}
a := osAircraft{
Icao24: strings.TrimSpace(rawString(st, 0)),
Callsign: strings.TrimSpace(rawString(st, 1)),
Country: strings.TrimSpace(rawString(st, 2)),
Lat: lat,
Lng: lng,
OnGround: rawBool(st, 8),
}
if v, ok := rawFloat(st, 7); ok {
a.Altitude = &v
}
if v, ok := rawFloat(st, 9); ok {
a.Velocity = &v
}
if v, ok := rawFloat(st, 10); ok {
a.Heading = &v
}
aircraft = append(aircraft, a)
}
writeJSON(w, http.StatusOK, map[string]any{
"time": osResp.Time, "states": aircraft,
"plan": plan, "recommendedInterval": recInterval})
}
// recommendedOpenSkyInterval maps a resolved plan to a sensible default poll
// interval (seconds) for the Live map. It balances the plan's daily credit budget
// (anonymous 400 · standard 4000 · contributor 8000) against how often OpenSky
// actually refreshes state vectors (~10s anonymous, ~5s authenticated), so the map
// stays fresh without draining credits. An unset plan resolves as standard (the
// runtime default). See planDailyCredits in the opensky plugin.
func recommendedOpenSkyInterval(plan string) int {
switch plan {
case "anonymous":
return 60
case "contributor":
return 15
default: // standard or unset
return 30
}
}
// rawFloat reads element i of an OpenSky state array as a float, reporting ok=false
// for a missing index or a JSON null (OpenSky uses null for unknown fields).
func rawFloat(st []json.RawMessage, i int) (float64, bool) {
if i >= len(st) {
return 0, false
}
var f float64
if err := json.Unmarshal(st[i], &f); err != nil {
return 0, false
}
return f, true
}
// rawString reads element i as a string ("" for missing/null/non-string).
func rawString(st []json.RawMessage, i int) string {
if i >= len(st) {
return ""
}
var s string
if err := json.Unmarshal(st[i], &s); err != nil {
return ""
}
return s
}
// rawBool reads element i as a bool (false for missing/null/non-bool).
func rawBool(st []json.RawMessage, i int) bool {
if i >= len(st) {
return false
}
var b bool
if err := json.Unmarshal(st[i], &b); err != nil {
return false
}
return b
}
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}