Files
PilotVault/API Server/internal/plugins/builtin/openweather/openweather.go
T
tajniak81andClaude Opus 4.8 6231b43076 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>
2026-07-14 11:21:48 +02:00

268 lines
8.3 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", 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 }