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>
269 lines
8.5 KiB
Go
269 lines
8.5 KiB
Go
// 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 }
|