Add a weather card to the Web App Overview, wired like the OpenSky live-map
overlay and backed by the OpenWeather plugin.
- API Server: GET /api/integrations/openweather/current resolves the
caller's cascade, gates it (master/org/personal opt-in/key), and returns
trimmed current conditions for the configured or a supplied ?lat=&lon=
point; off/keyless returns {unavailable, detail} so the card degrades.
- BFF relay (forwards the location override) + route; api.js client.
- Dashboard: a Weather card stacked above Schedule showing an emoji
condition, temperature in the resolved unit, description, location, and a
feels-like/wind/humidity/cloud grid. Location follows the same cascade as
the map (drone -> phone -> browser -> default) without prompting for geo;
refreshes every 10 min while on Overview.
- validLatLon test; rebuilt embedded frontend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
553 lines
18 KiB
Go
553 lines
18 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// This file exposes the "openweather" plugin's settings to end users through the
|
|
// same three-layer cascade OpenSky uses (superadmin/global → organization →
|
|
// user); see integrations.go for the shared helpers (lockedFor, layerRank,
|
|
// maskPresent, callerFromRecord) and integrations_webdav.go for the endpoint shape.
|
|
//
|
|
// - global (L1): the plugin's config in plugins.json, set in the API Server panel.
|
|
// - org (L2): pluginSettings.openweather on the caller's organization record.
|
|
// - user (L3): pluginSettings.openweather on the caller's own user record.
|
|
//
|
|
// Unlike WebDAV (a connection group), every OpenWeather field resolves
|
|
// *independently* — top layer wins, a blank field falls through. There is a
|
|
// single secret (the API key) with no paired half, so no field group is needed.
|
|
//
|
|
// The API key is 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 openWeatherPlugin = "openweather"
|
|
|
|
// owFields are the plugin's ConfigField keys, in display order. Kept in sync with
|
|
// the plugin descriptor so the cascade covers every setting.
|
|
var owFields = []string{"apiKey", "units", "lat", "lon", "lang", "callsPerMinute"}
|
|
|
|
// owSecretKeys are masked in every view and preserved on save when left at the mask.
|
|
var owSecretKeys = map[string]bool{"apiKey": true}
|
|
|
|
// owConfig is one layer's openweather settings. Values are strings to match the
|
|
// plugin's ConfigField keys 1:1 (they are handed straight to plugins.Init).
|
|
type owConfig struct {
|
|
APIKey string `json:"apiKey"`
|
|
Units string `json:"units"`
|
|
Lat string `json:"lat"`
|
|
Lon string `json:"lon"`
|
|
Lang string `json:"lang"`
|
|
CallsPerMinute string `json:"callsPerMinute"`
|
|
}
|
|
|
|
// owStored is what we persist per user/org under pluginSettings.openweather.
|
|
type owStored struct {
|
|
Config owConfig `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 (mirrors OpenSky). Only meaningful on the org record.
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
}
|
|
|
|
// owSettingsDoc is the openweather slice of the shared pluginSettings JSON.
|
|
type owSettingsDoc struct {
|
|
OpenWeather owStored `json:"openweather"`
|
|
}
|
|
|
|
// owResolution is the fully-resolved openweather state for one caller.
|
|
type owResolution struct {
|
|
eff owConfig // effective (unmasked) — used only server-side (probes)
|
|
userOwn owConfig // caller's personal (L3) values (unmasked)
|
|
orgOwn owConfig // 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 (plugin enabled in the panel)
|
|
orgEnabled bool // org master switch (default true; gates the org's users)
|
|
enabled bool // caller's personal enable flag
|
|
}
|
|
|
|
// owConfigFromMap builds an owConfig from a flat string map (global plugin config).
|
|
func owConfigFromMap(m map[string]string) owConfig {
|
|
return owConfig{APIKey: m["apiKey"], Units: m["units"], Lat: m["lat"], Lon: m["lon"], Lang: m["lang"], CallsPerMinute: m["callsPerMinute"]}
|
|
}
|
|
|
|
// owGet returns a config field by the plugin's key name.
|
|
func owGet(c owConfig, key string) string {
|
|
switch key {
|
|
case "apiKey":
|
|
return c.APIKey
|
|
case "units":
|
|
return c.Units
|
|
case "lat":
|
|
return c.Lat
|
|
case "lon":
|
|
return c.Lon
|
|
case "lang":
|
|
return c.Lang
|
|
case "callsPerMinute":
|
|
return c.CallsPerMinute
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// owSet writes a config field by the plugin's key name.
|
|
func owSet(c *owConfig, key, v string) {
|
|
switch key {
|
|
case "apiKey":
|
|
c.APIKey = v
|
|
case "units":
|
|
c.Units = v
|
|
case "lat":
|
|
c.Lat = v
|
|
case "lon":
|
|
c.Lon = v
|
|
case "lang":
|
|
c.Lang = v
|
|
case "callsPerMinute":
|
|
c.CallsPerMinute = v
|
|
}
|
|
}
|
|
|
|
// resolveOpenWeather computes the cascade for a caller. userRaw is the caller's
|
|
// pluginSettings blob (from their auth-refresh record).
|
|
func (s *Server) resolveOpenWeather(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) owResolution {
|
|
g, masterEnabled, _ := s.plugins.RawConfig(openWeatherPlugin)
|
|
gc := owConfigFromMap(g)
|
|
|
|
var oStored owStored
|
|
if who.OrgID != "" {
|
|
oStored, _ = s.orgOpenWeather(ctx, who.OrgID)
|
|
}
|
|
oc := oStored.Config
|
|
|
|
var uStored owStored
|
|
if len(userRaw) > 0 {
|
|
var d owSettingsDoc
|
|
_ = json.Unmarshal(userRaw, &d)
|
|
uStored = d.OpenWeather
|
|
}
|
|
uc := uStored.Config
|
|
|
|
res := owResolution{
|
|
source: map[string]string{},
|
|
userOwn: uc,
|
|
orgOwn: oc,
|
|
isSuper: who.isSuperadmin(),
|
|
// An org admin may edit the organization layer in addition to their own.
|
|
// Requires the service account (org writes go through it).
|
|
canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.admin.configured(),
|
|
available: masterEnabled,
|
|
orgEnabled: !oStored.Disabled,
|
|
enabled: uStored.Enabled,
|
|
}
|
|
|
|
// Ordered layers, top (highest priority) first.
|
|
type layer struct {
|
|
name string
|
|
c owConfig
|
|
}
|
|
layers := []layer{{"global", gc}}
|
|
if who.OrgID != "" {
|
|
layers = append(layers, layer{"org", oc})
|
|
}
|
|
layers = append(layers, layer{"user", uc})
|
|
|
|
// Every field cascades independently: top wins, blanks fall through.
|
|
for _, key := range owFields {
|
|
src := "unset"
|
|
for _, l := range layers {
|
|
if v := strings.TrimSpace(owGet(l.c, key)); v != "" {
|
|
owSet(&res.eff, key, v)
|
|
src = l.name
|
|
break
|
|
}
|
|
}
|
|
res.source[key] = src
|
|
}
|
|
return res
|
|
}
|
|
|
|
// orgOpenWeather reads an organization's stored openweather settings (config + the
|
|
// org gate) and its raw pluginSettings blob via the service account. Best effort:
|
|
// zero values on any miss so callers proceed as if the org layer were empty.
|
|
func (s *Server) orgOpenWeather(ctx context.Context, orgID string) (owStored, json.RawMessage) {
|
|
if orgID == "" || !s.admin.configured() {
|
|
return owStored{}, 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 owStored{}, nil
|
|
}
|
|
var rec struct {
|
|
PluginSettings json.RawMessage `json:"pluginSettings"`
|
|
}
|
|
_ = json.Unmarshal(data, &rec)
|
|
var doc owSettingsDoc
|
|
if len(rec.PluginSettings) > 0 {
|
|
_ = json.Unmarshal(rec.PluginSettings, &doc)
|
|
}
|
|
return doc.OpenWeather, rec.PluginSettings
|
|
}
|
|
|
|
// mergeOpenWeather applies a mutation to the openweather entry of a pluginSettings
|
|
// blob, preserving any other plugin keys (e.g. opensky, webdav), and returns the
|
|
// new blob.
|
|
func mergeOpenWeather(existing json.RawMessage, apply func(*owStored)) 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 ow owStored
|
|
if raw, ok := doc["openweather"]; ok {
|
|
_ = json.Unmarshal(raw, &ow)
|
|
}
|
|
apply(&ow)
|
|
b, _ := json.Marshal(ow)
|
|
doc["openweather"] = b
|
|
out, _ := json.Marshal(doc)
|
|
return out
|
|
}
|
|
|
|
// GET /api/integrations/openweather — resolved view for the caller.
|
|
func (s *Server) handleGetOpenWeather(w http.ResponseWriter, r *http.Request) {
|
|
who, userRaw, ok := s.integrationCaller(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
res := s.resolveOpenWeather(r.Context(), who, userRaw)
|
|
writeJSON(w, http.StatusOK, s.openWeatherView(who, res))
|
|
}
|
|
|
|
// owScopeView builds the masked field set for one editable scope. editable is the
|
|
// layer the caller edits ("user" | "org" | "none"); a field is locked when its
|
|
// effective value is set above that layer.
|
|
func (s *Server) owScopeView(res owResolution, editable string) map[string]any {
|
|
own := res.userOwn
|
|
if editable == "org" {
|
|
own = res.orgOwn
|
|
}
|
|
fields := map[string]osFieldView{}
|
|
for _, key := range owFields {
|
|
src := res.source[key]
|
|
fv := osFieldView{Source: src, Locked: lockedFor(src, editable)}
|
|
if owSecretKeys[key] {
|
|
fv.Effective, fv.Own = maskPresent(owGet(res.eff, key)), maskPresent(owGet(own, key))
|
|
} else {
|
|
fv.Effective, fv.Own = owGet(res.eff, key), owGet(own, key)
|
|
}
|
|
fields[key] = fv
|
|
}
|
|
return map[string]any{"editableLayer": editable, "fields": fields}
|
|
}
|
|
|
|
// openWeatherView builds the masked, client-safe response body. It exposes a
|
|
// "user" scope for everyone plus, for org admins, an "org" scope.
|
|
func (s *Server) openWeatherView(who *callerIdentity, res owResolution) map[string]any {
|
|
out := map[string]any{
|
|
"available": res.available,
|
|
"orgEnabled": res.orgEnabled,
|
|
"enabled": res.enabled,
|
|
"role": who.Role,
|
|
"orgId": who.OrgID,
|
|
"canEditOrg": res.canOrg,
|
|
"isSuperadmin": res.isSuper,
|
|
}
|
|
if res.isSuper {
|
|
out["editableLayer"] = "none"
|
|
out["scopes"] = map[string]any{"user": s.owScopeView(res, "none")}
|
|
return out
|
|
}
|
|
scopes := map[string]any{"user": s.owScopeView(res, "user")}
|
|
if res.canOrg {
|
|
scopes["org"] = s.owScopeView(res, "org")
|
|
}
|
|
out["scopes"] = scopes
|
|
return out
|
|
}
|
|
|
|
// PUT /api/integrations/openweather — save the caller's editable layer. Body:
|
|
// {enabled?, scope?, config?}. Fields locked above the caller are ignored; the
|
|
// API key left at the mask is preserved.
|
|
func (s *Server) handlePutOpenWeather(w http.ResponseWriter, r *http.Request) {
|
|
token := r.Header.Get("Authorization")
|
|
var body struct {
|
|
Enabled *bool `json:"enabled"`
|
|
Scope string `json:"scope"`
|
|
Config map[string]string `json:"config"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
who, userRaw, ok := s.integrationCaller(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
res := s.resolveOpenWeather(r.Context(), who, userRaw)
|
|
|
|
// Resolve which layer this write targets.
|
|
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"
|
|
}
|
|
|
|
// Overlay the fields the caller may change in this scope onto its own values.
|
|
newOwn := res.userOwn
|
|
if editable == "org" {
|
|
newOwn = res.orgOwn
|
|
}
|
|
for _, key := range owFields {
|
|
v, present := body.Config[key]
|
|
if !present || lockedFor(res.source[key], editable) {
|
|
continue
|
|
}
|
|
if owSecretKeys[key] && v == openSkySecretMask {
|
|
continue // keep current secret
|
|
}
|
|
owSet(&newOwn, key, strings.TrimSpace(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.orgOpenWeather(r.Context(), who.OrgID)
|
|
newDoc := mergeOpenWeather(orgRaw, func(ow *owStored) {
|
|
ow.Config = newOwn
|
|
if body.Enabled != nil {
|
|
ow.Disabled = !*body.Enabled // org master switch, stored inverted
|
|
}
|
|
})
|
|
_, 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), and so does the personal config layer when this write targets user.
|
|
personalEnable := body.Enabled != nil && editable != "org"
|
|
if personalEnable || editable == "user" {
|
|
newDoc := mergeOpenWeather(userRaw, func(ow *owStored) {
|
|
if personalEnable {
|
|
ow.Enabled = *body.Enabled
|
|
}
|
|
if editable == "user" {
|
|
ow.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 {
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
return
|
|
}
|
|
res2 := s.resolveOpenWeather(r.Context(), who, fresh.Record["pluginSettings"])
|
|
writeJSON(w, http.StatusOK, s.openWeatherView(who, res2))
|
|
}
|
|
|
|
// POST /api/integrations/openweather/health — live probe using the caller's
|
|
// resolved config. Never returns the API key.
|
|
func (s *Server) handleOpenWeatherHealth(w http.ResponseWriter, r *http.Request) {
|
|
who, userRaw, ok := s.integrationCaller(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !who.isSuperadmin() {
|
|
if _, _, ok := s.plugins.RawConfig(openWeatherPlugin); !ok {
|
|
writeError(w, http.StatusNotFound, "unknown plugin")
|
|
return
|
|
}
|
|
}
|
|
res := s.resolveOpenWeather(r.Context(), who, userRaw)
|
|
if !res.available {
|
|
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
|
"status": "down", "detail": "OpenWeather is disabled by the administrator"}})
|
|
return
|
|
}
|
|
if !res.orgEnabled {
|
|
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
|
"status": "down", "detail": "OpenWeather is disabled for your organization"}})
|
|
return
|
|
}
|
|
if strings.TrimSpace(res.eff.APIKey) == "" {
|
|
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
|
"status": "down", "detail": "No API key configured — add one to connect"}})
|
|
return
|
|
}
|
|
cfg := map[string]string{}
|
|
for _, k := range owFields {
|
|
cfg[k] = owGet(res.eff, k)
|
|
}
|
|
h, err := s.plugins.HealthCheckWith(r.Context(), openWeatherPlugin, cfg)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"health": h})
|
|
}
|
|
|
|
// owWeather is the trimmed current-conditions shape the Overview weather card needs,
|
|
// flattened out of OpenWeather's richer /data/2.5/weather payload.
|
|
type owWeather struct {
|
|
Location string `json:"location"`
|
|
Country string `json:"country"`
|
|
Temp *float64 `json:"temp"`
|
|
FeelsLike *float64 `json:"feelsLike"`
|
|
Description string `json:"description"`
|
|
Icon string `json:"icon"` // OpenWeather icon code, e.g. "01d"
|
|
Humidity *int `json:"humidity"`
|
|
WindSpeed *float64 `json:"windSpeed"`
|
|
WindDeg *int `json:"windDeg"`
|
|
Clouds *int `json:"clouds"`
|
|
Dt int64 `json:"dt"` // observation time (unix seconds)
|
|
}
|
|
|
|
// validLatLon reports whether lat/lon are well-formed geographic coordinates.
|
|
func validLatLon(lat, lon string) bool {
|
|
la, e1 := strconv.ParseFloat(lat, 64)
|
|
lo, e2 := strconv.ParseFloat(lon, 64)
|
|
return e1 == nil && e2 == nil && la >= -90 && la <= 90 && lo >= -180 && lo <= 180
|
|
}
|
|
|
|
// GET /api/integrations/openweather/current — current conditions for the caller's
|
|
// resolved location (or a supplied ?lat=&lon= point), for the Overview weather card.
|
|
// Runs server-side against the resolved cascade config (never returns the API key).
|
|
// 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 (or no key resolves) it returns 200
|
|
// with {unavailable:true, detail} so the card can degrade quietly rather than error.
|
|
func (s *Server) handleOpenWeatherCurrent(w http.ResponseWriter, r *http.Request) {
|
|
who, userRaw, ok := s.integrationCaller(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
res := s.resolveOpenWeather(r.Context(), who, userRaw)
|
|
|
|
units := res.eff.Units
|
|
if units == "" {
|
|
units = "metric" // matches the plugin's runtime fallback
|
|
}
|
|
unavailable := func(detail string) {
|
|
writeJSON(w, http.StatusOK, map[string]any{"unavailable": true, "detail": detail, "units": units})
|
|
}
|
|
switch {
|
|
case !res.available:
|
|
unavailable("OpenWeather is disabled by the administrator")
|
|
return
|
|
case !res.orgEnabled:
|
|
unavailable("OpenWeather is disabled for your organization")
|
|
return
|
|
case !res.enabled:
|
|
unavailable("Enable OpenWeather in Settings → Integrations to show weather")
|
|
return
|
|
case strings.TrimSpace(res.eff.APIKey) == "":
|
|
unavailable("No API key configured for OpenWeather")
|
|
return
|
|
}
|
|
|
|
// Optional point override (drone/device/browser location the Overview resolves).
|
|
// Malformed input is ignored so the plugin falls back to the configured default.
|
|
var payload json.RawMessage
|
|
lat := strings.TrimSpace(r.URL.Query().Get("lat"))
|
|
lon := strings.TrimSpace(r.URL.Query().Get("lon"))
|
|
if validLatLon(lat, lon) {
|
|
payload, _ = json.Marshal(map[string]string{"lat": lat, "lon": lon})
|
|
}
|
|
|
|
cfg := map[string]string{}
|
|
for _, k := range owFields {
|
|
cfg[k] = owGet(res.eff, k)
|
|
}
|
|
raw, err := s.plugins.InvokeWith(r.Context(), openWeatherPlugin, cfg, "weather.current", payload)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Flatten OpenWeather's /data/2.5/weather response into the card model.
|
|
var owResp struct {
|
|
Weather []struct {
|
|
Description string `json:"description"`
|
|
Icon string `json:"icon"`
|
|
} `json:"weather"`
|
|
Main struct {
|
|
Temp *float64 `json:"temp"`
|
|
FeelsLike *float64 `json:"feels_like"`
|
|
Humidity *int `json:"humidity"`
|
|
} `json:"main"`
|
|
Wind struct {
|
|
Speed *float64 `json:"speed"`
|
|
Deg *int `json:"deg"`
|
|
} `json:"wind"`
|
|
Clouds struct {
|
|
All *int `json:"all"`
|
|
} `json:"clouds"`
|
|
Dt int64 `json:"dt"`
|
|
Name string `json:"name"`
|
|
Sys struct {
|
|
Country string `json:"country"`
|
|
} `json:"sys"`
|
|
}
|
|
if err := json.Unmarshal(raw, &owResp); err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "unexpected OpenWeather response"})
|
|
return
|
|
}
|
|
weather := owWeather{
|
|
Location: owResp.Name,
|
|
Country: owResp.Sys.Country,
|
|
Temp: owResp.Main.Temp,
|
|
FeelsLike: owResp.Main.FeelsLike,
|
|
Humidity: owResp.Main.Humidity,
|
|
WindSpeed: owResp.Wind.Speed,
|
|
WindDeg: owResp.Wind.Deg,
|
|
Clouds: owResp.Clouds.All,
|
|
Dt: owResp.Dt,
|
|
}
|
|
if len(owResp.Weather) > 0 {
|
|
weather.Description = owResp.Weather[0].Description
|
|
weather.Icon = owResp.Weather[0].Icon
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"weather": weather, "units": units})
|
|
}
|