Add OpenWeather plugin with full Web App settings cascade

Builtin API Server connector for the OpenWeather API (current weather +
5-day forecast for a point, API-key auth, stdlib-only), surfaced to end
users through the same three-layer settings cascade as OpenSky/WebDAV.

- New builtin plugin (internal/plugins/builtin/openweather) with
  health probe, weather.current/forecast.5day capabilities, and tests.
- resolveOpenWeather cascade (global -> org -> user) with per-field
  independent resolution and API-key masking; GET/PUT/health endpoints.
- Web App BFF relays, api.js client, and a Settings card under
  APIs - External (scope switch, enable toggles, test connection).
- Resolver unit tests + rebuilt embedded frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-14 11:21:48 +02:00
co-authored by Claude Opus 4.8
parent 816db9b42f
commit 6231b43076
14 changed files with 1379 additions and 25 deletions
@@ -7,5 +7,6 @@ import (
_ "pilotvault/apiserver/internal/plugins/builtin/filetransfer"
_ "pilotvault/apiserver/internal/plugins/builtin/localstorage"
_ "pilotvault/apiserver/internal/plugins/builtin/opensky"
_ "pilotvault/apiserver/internal/plugins/builtin/openweather"
_ "pilotvault/apiserver/internal/plugins/builtin/webdav"
)
@@ -0,0 +1,267 @@
// 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", Default: "metric",
Options: []plugins.SelectOption{
{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."},
{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 }
@@ -0,0 +1,190 @@
package openweather
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"pilotvault/apiserver/internal/plugins"
)
func TestDescriptor(t *testing.T) {
p := &Plugin{}
d := p.Descriptor()
if d.Name != "openweather" {
t.Fatalf("name = %q, want openweather", d.Name)
}
if d.Kind != plugins.KindBuiltin {
t.Fatalf("kind = %q, want builtin", d.Kind)
}
if d.Category != plugins.CategoryAPIsExternal {
t.Fatalf("category = %q, want %q", d.Category, plugins.CategoryAPIsExternal)
}
if d.AuthType != plugins.AuthAPIKey {
t.Fatalf("authType = %q, want %q", d.AuthType, plugins.AuthAPIKey)
}
if len(d.Capabilities) == 0 {
t.Fatal("expected capabilities")
}
// The apiKey field must be flagged so the manager masks it, and no field may
// be Required (so the plugin can be enabled as an empty master switch).
for _, f := range d.ConfigFields {
if f.Key == "apiKey" && !f.Secret {
t.Errorf("config field %q must be Secret", f.Key)
}
if f.Required {
t.Errorf("config field %q must not be Required", f.Key)
}
}
}
func TestInitDefaults(t *testing.T) {
p := &Plugin{}
if err := p.Init(context.Background(), nil); err != nil {
t.Fatal(err)
}
if p.units != "metric" {
t.Errorf("default units = %q, want metric", p.units)
}
if p.lat != defaultLat || p.lon != defaultLon {
t.Errorf("default location = %q,%q, want %q,%q", p.lat, p.lon, defaultLat, defaultLon)
}
if p.client == nil {
t.Error("Init must build an http client")
}
}
func TestRequestURL(t *testing.T) {
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{
"apiKey": "secret", "units": "imperial", "lang": "pl",
})
got := p.requestURL("/data/2.5/weather", p.pointQuery("10", "20"))
for _, want := range []string{"lat=10", "lon=20", "units=imperial", "lang=pl", "appid=secret", "/data/2.5/weather?"} {
if !strings.Contains(got, want) {
t.Errorf("requestURL %q missing %q", got, want)
}
}
}
// TestRequestURLNoKey confirms the appid param is omitted when no key is set, so
// a key is never leaked as an empty value.
func TestRequestURLNoKey(t *testing.T) {
p := &Plugin{}
_ = p.Init(context.Background(), nil)
if got := p.requestURL("/data/2.5/weather", p.pointQuery("", "")); strings.Contains(got, "appid") {
t.Errorf("requestURL %q must not contain appid when key is unset", got)
}
}
// TestHealthCheckNoKey confirms a missing key is reported as down, not a panic.
func TestHealthCheckNoKey(t *testing.T) {
p := &Plugin{}
_ = p.Init(context.Background(), nil)
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown {
t.Errorf("status = %q, want down (detail=%q)", h.Status, h.Detail)
}
}
func TestRegistered(t *testing.T) {
m := plugins.NewManager(t.TempDir() + "/plugins.json")
if _, ok := m.Get("openweather"); !ok {
t.Fatal("openweather not registered in the plugin manager")
}
}
// fakeOW is a minimal OpenWeather stand-in: it validates the appid and echoes a
// small current-weather body, and returns 401 for a wrong key.
func newFakeOW(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("appid") != "good-key" {
w.WriteHeader(http.StatusUnauthorized)
return
}
switch r.URL.Path {
case "/data/2.5/weather":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"Warsaw","main":{"temp":12.5},"weather":[{"description":"clear sky"}]}`))
case "/data/2.5/forecast":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"cnt":40,"list":[]}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
func TestHealthCheckOK(t *testing.T) {
srv := newFakeOW(t)
defer srv.Close()
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"apiKey": "good-key"})
p.base = srv.URL
h := p.HealthCheck(context.Background())
if h.Status != plugins.StatusOK {
t.Fatalf("status = %q, want ok (detail=%q)", h.Status, h.Detail)
}
if !strings.Contains(h.Detail, "Warsaw") || !strings.Contains(h.Detail, "clear sky") {
t.Errorf("detail = %q, want it to summarise the current conditions", h.Detail)
}
}
func TestHealthCheckAuthFailure(t *testing.T) {
srv := newFakeOW(t)
defer srv.Close()
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"apiKey": "wrong"})
p.base = srv.URL
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDegraded {
t.Errorf("status = %q, want degraded on 401 (detail=%q)", h.Status, h.Detail)
}
}
func TestInvoke(t *testing.T) {
srv := newFakeOW(t)
defer srv.Close()
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"apiKey": "good-key"})
p.base = srv.URL
ctx := context.Background()
// current weather with an explicit location override
raw, err := p.Invoke(ctx, "weather.current", json.RawMessage(`{"lat":"51.5","lon":"-0.12"}`))
if err != nil {
t.Fatalf("weather.current: %v", err)
}
var cur struct {
Name string `json:"name"`
}
if err := json.Unmarshal(raw, &cur); err != nil || cur.Name != "Warsaw" {
t.Fatalf("weather.current body = %s (err=%v)", raw, err)
}
// 5-day forecast using the default location
if _, err := p.Invoke(ctx, "forecast.5day", nil); err != nil {
t.Fatalf("forecast.5day: %v", err)
}
// unknown action
if _, err := p.Invoke(ctx, "nope", nil); err == nil {
t.Error("expected error for unknown action")
}
}
// TestInvokeNoKey confirms Invoke refuses to call upstream without a key.
func TestInvokeNoKey(t *testing.T) {
p := &Plugin{}
_ = p.Init(context.Background(), nil)
if _, err := p.Invoke(context.Background(), "weather.current", nil); err == nil {
t.Error("expected errNoKey when no API key is configured")
}
}