Files
PilotVault/API Server/internal/plugins/builtin/openweather/openweather.go
T
tajniak81andClaude Opus 4.8 3903428964 Let OpenWeather calls-per-minute limit cascade to orgs and users
Drop the descriptor Default of 60 so the global panel field can be left
blank. A non-blank global value sits at the top of the cascade and locks
every lower layer, so the fixed default made the per-minute limit
un-overridable by organizations and users. Blank now means "inherit";
the runtime still falls back to 60 when no layer sets a value, so the
usage gauge is unchanged. Update the descriptor help and the Settings
row copy to explain the inherit behavior.

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

392 lines
13 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"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"pilotvault/apiserver/internal/plugins"
)
// defaultCallsPerMinute is OpenWeather's free-tier per-minute limit, used to gauge
// usage when no plan limit is configured.
const defaultCallsPerMinute = 60
// callUsage tracks how many upstream OpenWeather calls this process has made for
// one API key, bucketed into the current minute and the current UTC day.
//
// OpenWeather does not report remaining quota, so this counts only the calls
// PilotVault itself makes (not any made outside it) and resets when the server
// restarts — an approximate gauge, not an authoritative balance.
type callUsage struct {
mu sync.Mutex
minuteStart time.Time
minuteCount int
dayStart time.Time
dayCount int
}
// roll resets the minute/day buckets that now has moved past. Caller holds u.mu.
func (u *callUsage) roll(now time.Time) {
if m := now.Truncate(time.Minute); !m.Equal(u.minuteStart) {
u.minuteStart, u.minuteCount = m, 0
}
if d := now.UTC().Truncate(24 * time.Hour); !d.Equal(u.dayStart) {
u.dayStart, u.dayCount = d, 0
}
}
func (u *callUsage) record(now time.Time) {
u.mu.Lock()
defer u.mu.Unlock()
u.roll(now)
u.minuteCount++
u.dayCount++
}
func (u *callUsage) snapshot(now time.Time) (minute, day int) {
u.mu.Lock()
defer u.mu.Unlock()
u.roll(now)
return u.minuteCount, u.dayCount
}
// usageStore holds per-API-key call counters for the whole process, so counts
// survive the transient plugin instances the settings cascade builds per request.
type usageStore struct {
mu sync.Mutex
m map[string]*callUsage
}
var usage = &usageStore{m: map[string]*callUsage{}}
func (s *usageStore) get(keyHash string) *callUsage {
s.mu.Lock()
defer s.mu.Unlock()
u := s.m[keyHash]
if u == nil {
u = &callUsage{}
s.m[keyHash] = u
}
return u
}
// keyFingerprint derives a short, non-reversible bucket id from an API key, so
// distinct keys (e.g. per-organization) get independent usage counters without
// the key itself ever being stored in the counter map.
func keyFingerprint(apiKey string) string {
sum := sha256.Sum256([]byte(apiKey))
return hex.EncodeToString(sum[:8])
}
// 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
callsPerMinute int
keyHash string // fingerprint of apiKey, keys this plugin's usage counter
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."},
{Key: "callsPerMinute", Label: "Calls per minute limit", Type: "number",
Help: "Your OpenWeather plan's per-minute call limit (the free tier is 60). Used only to gauge usage in the app — OpenWeather does not report remaining quota, so the app counts the calls it makes against this number. Leave blank to let each organization or user set their own limit; the app falls back to 60 when no layer sets one."},
},
}
}
// atoiDefault parses s as an int, returning def when it is blank or malformed.
func atoiDefault(s string, def int) int {
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && n > 0 {
return n
}
return def
}
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.callsPerMinute = atoiDefault(config["callsPerMinute"], defaultCallsPerMinute)
p.keyHash = keyFingerprint(p.apiKey)
p.base = apiBase
p.client = &http.Client{Timeout: 10 * time.Second}
return nil
}
// do performs an upstream request and records it against this key's usage gauge
// when the request actually reached OpenWeather (any HTTP response — a transport
// failure consumes no quota, so it is not counted).
func (p *Plugin) do(req *http.Request) (*http.Response, error) {
resp, err := p.client.Do(req)
if err == nil {
p.mu.Lock()
kh := p.keyHash
p.mu.Unlock()
usage.get(kh).record(time.Now())
}
return resp, err
}
// 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 == "" {
// No key at this (global) layer. This is a valid configuration: the plugin
// acts as a master switch and organizations/users supply their own key in
// the Web App. Report degraded rather than down — nothing is broken, the
// global layer just can't self-probe without a key.
return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(),
Detail: "no global API key — master switch only; organizations and users supply their own key"}
}
target := p.requestURL("/data/2.5/weather", p.pointQuery("", ""))
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
resp, err := p.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
}
// Attach the app-side usage gauge (this probe is included in the count) so the
// UI can show consumption against the plan's per-minute limit.
p.mu.Lock()
kh, limit := p.keyHash, p.callsPerMinute
p.mu.Unlock()
minute, day := usage.get(kh).snapshot(time.Now())
h.Usage = &plugins.HealthUsage{MinuteUsed: minute, MinuteLimit: limit, DayUsed: day}
h.Detail += " · " + usageText(minute, limit, day)
return h
}
// usageText renders a compact app-usage summary, e.g. "3/60 calls this min · 27 today".
func usageText(minute, limit, day int) string {
m := strconv.Itoa(minute)
if limit > 0 {
m += "/" + strconv.Itoa(limit)
}
return m + " calls this min · " + strconv.Itoa(day) + " today"
}
// 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.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 }