Files
PilotVault/API Server/internal/plugins/builtin/openweather/openweather.go
T
tajniak81andClaude Opus 4.8 285551fe8a Let OpenWeather Units fall through the settings cascade
The Units field carried a Default of "metric" and had no blank option, so
the global panel layer always set a value and locked organizations and
users out of changing it. Mirror the fix already applied to OpenSky's plan.

- Plugin descriptor: drop the "metric" Default and add a blank "Not set"
  option; runtime still falls back to metric when no layer sets it.
- Web App: add a "Not set" choice to the Units control and stop forcing
  metric back into the form on load.
- Test guards that the units field has no Default and offers a blank option.
- Rebuilt embedded frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:31:42 +02:00

269 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package openweather is a built-in plugin connecting the OpenWeather API
// (current conditions and 5-day/3-hour forecast for a point location). Like the
// opensky connector it demonstrates a real third-party integration behind the
// plugin contract, here using simple API-key auth.
//
// Docs: https://openweathermap.org/current https://openweathermap.org/forecast5
package openweather
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"pilotvault/apiserver/internal/plugins"
)
// apiBase is the OpenWeather REST root. It is copied into the plugin instance in
// Init so tests can point the connector at an httptest server.
const apiBase = "https://api.openweathermap.org"
// Default probe location — Warsaw, Poland. Used by the health probe and as the
// default point for weather.current / forecast.5day when a call supplies none.
const (
defaultLat = "52.2297"
defaultLon = "21.0122"
)
func init() {
plugins.Register("openweather", func() plugins.Plugin { return &Plugin{} })
}
// Plugin is the OpenWeather connector.
type Plugin struct {
mu sync.Mutex
apiKey string
units string
lang string
lat string
lon string
base string
client *http.Client
}
// errNoKey is returned when a probe/call has no resolved API key. OpenWeather has
// no anonymous tier, so a key is required for any live request.
var errNoKey = errors.New("no API key configured")
func (p *Plugin) Descriptor() plugins.Descriptor {
return plugins.Descriptor{
Name: "openweather",
Provider: "OpenWeather",
Version: "1.0.0",
Kind: plugins.KindBuiltin,
Category: plugins.CategoryAPIsExternal,
Capabilities: []plugins.Capability{
{ID: "weather.current", Method: "GET", Endpoint: "/data/2.5/weather?lat&lon",
Description: "Current weather for the configured (or a supplied) lat/lon."},
{ID: "forecast.5day", Method: "GET", Endpoint: "/data/2.5/forecast?lat&lon",
Description: "5-day / 3-hour forecast for the configured (or a supplied) lat/lon."},
},
AuthType: plugins.AuthAPIKey,
ConfigFields: []plugins.ConfigField{
{Key: "apiKey", Label: "API key", Type: "password", Secret: true,
Help: "Your OpenWeather API key (the appid query parameter). Required for any live request — OpenWeather has no anonymous tier. Leave blank to enable the plugin as a master switch and supply the key at another layer."},
{Key: "units", Label: "Units", Type: "select",
Options: []plugins.SelectOption{
{Value: "", Label: "Not set — let organizations and users choose"},
{Value: "standard", Label: "Standard — Kelvin, m/s"},
{Value: "metric", Label: "Metric — °C, m/s"},
{Value: "imperial", Label: "Imperial — °F, mph"},
},
Help: "Measurement system for temperatures and wind speed in responses. Leave it unset to let each organization or user pick their own; set a value only to force one for everyone. Runtime falls back to metric when no layer sets it."},
{Key: "lat", Label: "Default latitude", Type: "text", Default: defaultLat,
Help: "Latitude used by the health probe and by calls that supply no location (90…90)."},
{Key: "lon", Label: "Default longitude", Type: "text", Default: defaultLon,
Help: "Longitude used by the health probe and by calls that supply no location (180…180)."},
{Key: "lang", Label: "Language", Type: "text",
Help: "Optional ISO language code for human-readable weather descriptions (e.g. en, pl, de). Leave blank for the API default."},
},
}
}
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.apiKey = strings.TrimSpace(config["apiKey"])
p.units = strings.TrimSpace(config["units"])
if p.units == "" {
p.units = "metric"
}
p.lang = strings.TrimSpace(config["lang"])
p.lat = strings.TrimSpace(config["lat"])
if p.lat == "" {
p.lat = defaultLat
}
p.lon = strings.TrimSpace(config["lon"])
if p.lon == "" {
p.lon = defaultLon
}
p.base = apiBase
p.client = &http.Client{Timeout: 10 * time.Second}
return nil
}
// requestURL builds an absolute OpenWeather request URL for the given path,
// merging the caller's query with the configured units, language and API key.
// The API key is never included when unset (callers must guard on errNoKey).
func (p *Plugin) requestURL(path string, q url.Values) string {
p.mu.Lock()
base, key, units, lang := p.base, p.apiKey, p.units, p.lang
p.mu.Unlock()
if q == nil {
q = url.Values{}
}
if units != "" {
q.Set("units", units)
}
if lang != "" {
q.Set("lang", lang)
}
if key != "" {
q.Set("appid", key)
}
return strings.TrimRight(base, "/") + path + "?" + q.Encode()
}
// pointQuery returns a lat/lon query, preferring caller-supplied coordinates and
// falling back to the configured defaults.
func (p *Plugin) pointQuery(lat, lon string) url.Values {
p.mu.Lock()
dlat, dlon := p.lat, p.lon
p.mu.Unlock()
if strings.TrimSpace(lat) == "" {
lat = dlat
}
if strings.TrimSpace(lon) == "" {
lon = dlon
}
return url.Values{"lat": {strings.TrimSpace(lat)}, "lon": {strings.TrimSpace(lon)}}
}
// HealthCheck performs a live current-weather query at the configured location
// and classifies the outcome.
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
start := time.Now()
p.mu.Lock()
key := p.apiKey
p.mu.Unlock()
if key == "" {
return plugins.Health{Status: plugins.StatusDown, LatencyMs: time.Since(start).Milliseconds(),
Detail: errNoKey.Error()}
}
target := p.requestURL("/data/2.5/weather", p.pointQuery("", ""))
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
resp, err := p.client.Do(req)
lat := time.Since(start).Milliseconds()
if err != nil {
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: err.Error()}
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
h := plugins.Health{LatencyMs: lat}
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
h.Status, h.Detail = plugins.StatusOK, "OpenWeather reachable"
if summary := currentSummary(data); summary != "" {
h.Detail += " · " + summary
}
case resp.StatusCode == http.StatusUnauthorized:
h.Status, h.Detail = plugins.StatusDegraded, "API key rejected (HTTP 401)"
case resp.StatusCode == http.StatusTooManyRequests:
h.Status, h.Detail = plugins.StatusDegraded, "rate limited (HTTP 429)"
default:
h.Status, h.Detail = plugins.StatusDown, "HTTP "+resp.Status
}
return h
}
// currentSummary renders a short "place: 12°C, clear sky" line from a current
// weather payload. Returns "" when the body can't be parsed.
func currentSummary(data []byte) string {
var out struct {
Name string `json:"name"`
Main struct {
Temp float64 `json:"temp"`
} `json:"main"`
Weather []struct {
Description string `json:"description"`
} `json:"weather"`
}
if json.Unmarshal(data, &out) != nil {
return ""
}
parts := []string{}
if out.Name != "" {
parts = append(parts, out.Name)
}
temp := strconv.FormatFloat(out.Main.Temp, 'f', -1, 64) + "°"
if len(out.Weather) > 0 && out.Weather[0].Description != "" {
temp += ", " + out.Weather[0].Description
}
if len(parts) == 0 {
return temp
}
return parts[0] + ": " + temp
}
// invokeParams is the optional per-call override accepted by Invoke actions.
type invokeParams struct {
Lat string `json:"lat"`
Lon string `json:"lon"`
}
// Invoke exposes weather.current / forecast.5day. Both accept an optional
// {lat,lon} override and otherwise use the configured default location.
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
p.mu.Lock()
key := p.apiKey
p.mu.Unlock()
if key == "" {
return nil, errNoKey
}
var in invokeParams
if len(params) > 0 {
if err := json.Unmarshal(params, &in); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
}
var path string
switch action {
case "weather.current":
path = "/data/2.5/weather"
case "forecast.5day":
path = "/data/2.5/forecast"
default:
return nil, errors.New("unknown action: " + action)
}
target := p.requestURL(path, p.pointQuery(in.Lat, in.Lon))
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("openweather %s: HTTP %s", action, resp.Status)
}
return data, nil
}
func (p *Plugin) Shutdown(context.Context) error { return nil }