// 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 }