From 5bb8ee87370c7a088c0bc5b612422824eaf69c8e Mon Sep 17 00:00:00 2001 From: AdamMagued Date: Sat, 10 Oct 2026 04:10:13 +0300 Subject: [PATCH] feat(backends): support reading API keys from external key files Signed-off-by: AdamMagued --- README.md | 2 + backends/apikey.go | 26 +++++ backends/apikey_test.go | 177 +++++++++++++++++++++++++++++ backends/caiyun.go | 14 ++- backends/open-meteo.com.go | 12 +- backends/openweathermap.org.go | 15 ++- backends/pirateweather.net.go | 13 ++- backends/weatherapi.com.go | 15 ++- backends/worldweatheronline.com.go | 15 ++- 9 files changed, 269 insertions(+), 20 deletions(-) create mode 100644 backends/apikey.go create mode 100644 backends/apikey_test.go diff --git a/README.md b/README.md index 05a14cf..982554f 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ The config file is resolved in the following order: ### Backends +For backends requiring an API key, you can specify the key directly with `*-api-key` or provide a path to a key file using `*-api-key-file` (e.g. `owm-api-key-file=/path/to/key.txt`). When both are specified, `*-api-key` takes precedence. + __[Open-Meteo](https://open-meteo.com/)__ — free, no API key required: ``` backend=openmeteo diff --git a/backends/apikey.go b/backends/apikey.go new file mode 100644 index 0000000..c971b9a --- /dev/null +++ b/backends/apikey.go @@ -0,0 +1,26 @@ +package backends + +import ( + "fmt" + "os" + "strings" +) + +// ResolveAPIKey determines the API key to use based on key and keyFile. +// If key is non-empty, it takes precedence and is returned directly. +// If key is empty and keyFile is non-empty, the contents of keyFile are read +// with any trailing whitespace and newlines trimmed. +// If both key and keyFile are empty, an empty string and nil error are returned. +func ResolveAPIKey(key, keyFile string) (string, error) { + if key != "" { + return key, nil + } + if keyFile == "" { + return "", nil + } + data, err := os.ReadFile(keyFile) + if err != nil { + return "", fmt.Errorf("unable to read api key file %s: %w", keyFile, err) + } + return strings.TrimRight(string(data), "\r\n \t"), nil +} diff --git a/backends/apikey_test.go b/backends/apikey_test.go new file mode 100644 index 0000000..06a5a77 --- /dev/null +++ b/backends/apikey_test.go @@ -0,0 +1,177 @@ +package backends + +import ( + "flag" + "os" + "path/filepath" + "testing" +) + +func TestResolveAPIKey_InlineKey(t *testing.T) { + key, err := ResolveAPIKey("inline-secret", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if key != "inline-secret" { + t.Fatalf("expected 'inline-secret', got '%s'", key) + } +} + +func TestResolveAPIKey_KeyFromFile(t *testing.T) { + tmpDir := t.TempDir() + keyPath := filepath.Join(tmpDir, "key.txt") + expectedKey := "test-secret-key-12345" + + if err := os.WriteFile(keyPath, []byte(expectedKey), 0600); err != nil { + t.Fatalf("failed to create temp key file: %v", err) + } + + key, err := ResolveAPIKey("", keyPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if key != expectedKey { + t.Fatalf("expected '%s', got '%s'", expectedKey, key) + } +} + +func TestResolveAPIKey_TrailingWhitespaceAndNewlines(t *testing.T) { + tmpDir := t.TempDir() + + testCases := []struct { + name string + content string + expected string + }{ + { + name: "trailing newline", + content: "my-secret-key\n", + expected: "my-secret-key", + }, + { + name: "trailing CRLF", + content: "my-secret-key\r\n", + expected: "my-secret-key", + }, + { + name: "trailing spaces and tabs", + content: "my-secret-key \t ", + expected: "my-secret-key", + }, + { + name: "multiple trailing newlines and spaces", + content: "my-secret-key \r\n\n\t \n", + expected: "my-secret-key", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + filePath := filepath.Join(tmpDir, tc.name+".txt") + if err := os.WriteFile(filePath, []byte(tc.content), 0600); err != nil { + t.Fatalf("failed to write test key file: %v", err) + } + + key, err := ResolveAPIKey("", filePath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if key != tc.expected { + t.Fatalf("expected '%s', got '%s'", tc.expected, key) + } + }) + } +} + +func TestResolveAPIKey_Precedence(t *testing.T) { + tmpDir := t.TempDir() + keyPath := filepath.Join(tmpDir, "file_key.txt") + + if err := os.WriteFile(keyPath, []byte("file-key-value"), 0600); err != nil { + t.Fatalf("failed to write temp key file: %v", err) + } + + // When both inline key and key file are present, inline key must take precedence + key, err := ResolveAPIKey("inline-key-value", keyPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if key != "inline-key-value" { + t.Fatalf("expected 'inline-key-value', got '%s'", key) + } + + // Even if keyFile points to a non-existent file, inline key must still succeed without error + nonExistentPath := filepath.Join(tmpDir, "non_existent.txt") + key, err = ResolveAPIKey("inline-key-value", nonExistentPath) + if err != nil { + t.Fatalf("unexpected error when inline key takes precedence over missing file: %v", err) + } + if key != "inline-key-value" { + t.Fatalf("expected 'inline-key-value', got '%s'", key) + } +} + +func TestResolveAPIKey_MissingFile(t *testing.T) { + nonExistentPath := filepath.Join(t.TempDir(), "does_not_exist.txt") + + key, err := ResolveAPIKey("", nonExistentPath) + if err == nil { + t.Fatalf("expected error for missing key file, got nil with key '%s'", key) + } + if key != "" { + t.Fatalf("expected empty key on error, got '%s'", key) + } +} + +func TestResolveAPIKey_BothEmpty(t *testing.T) { + key, err := ResolveAPIKey("", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if key != "" { + t.Fatalf("expected empty key, got '%s'", key) + } +} + +func TestBackendFlagsRegistered(t *testing.T) { + // Call Setup on all backend configurations to ensure flags are registered + owm := &openWeatherConfig{} + owm.Setup() + + caiyun := &CaiyunConfig{} + caiyun.Setup() + + openmeteo := &openmeteoConfig{} + openmeteo.Setup() + + pirate := &pirateweatherConfig{} + pirate.Setup() + + weather := &weatherApiConfig{} + weather.Setup() + + wwo := &wwoConfig{} + wwo.Setup() + + expectedFlags := []string{ + "owm-api-key", + "owm-api-key-file", + "caiyun-api-key", + "caiyun-api-key-file", + "openmeteo-api-key", + "openmeteo-api-key-file", + "pirateweather-api-key", + "pirateweather-api-key-file", + "weather-api-key", + "weather-api-key-file", + "wwo-api-key", + "wwo-api-key-file", + } + + for _, flagName := range expectedFlags { + f := flag.Lookup(flagName) + if f == nil { + t.Errorf("expected flag %s to be registered, but was nil", flagName) + } + } +} diff --git a/backends/caiyun.go b/backends/caiyun.go index 5ab3550..3c5a23a 100644 --- a/backends/caiyun.go +++ b/backends/caiyun.go @@ -20,13 +20,15 @@ const ( ) type CaiyunConfig struct { - apiKey string - lang string - debug bool + apiKey string + apiKeyFile string + lang string + debug bool } func (c *CaiyunConfig) Setup() { flag.StringVar(&c.apiKey, "caiyun-api-key", "", "caiyun backend: the api `KEY` to use") + flag.StringVar(&c.apiKeyFile, "caiyun-api-key-file", "", "caiyun backend: the path to a `FILE` containing the api key") flag.StringVar(&c.lang, "caiyun-lang", "en", "caiyun backend: the `LANGUAGE` to request from caiyunapp.com/") flag.BoolVar(&c.debug, "caiyun-debug", true, "caiyun backend: print raw requests and responses") } @@ -143,6 +145,12 @@ func (c *CaiyunConfig) GetWeatherDataFromLocalBegin(lng float64, lat float64, nu } func (c *CaiyunConfig) Fetch(location string, numdays int) iface.Data { + apiKey, err := ResolveAPIKey(c.apiKey, c.apiKeyFile) + if err != nil { + log.Fatalf("Failed to read caiyun API key file: %v\n", err) + } + c.apiKey = apiKey + if c.debug { log.Printf("caiyun location %v", location) } diff --git a/backends/open-meteo.com.go b/backends/open-meteo.com.go index c33a54d..052fd1a 100644 --- a/backends/open-meteo.com.go +++ b/backends/open-meteo.com.go @@ -15,8 +15,9 @@ import ( ) type openmeteoConfig struct { - apiKey string - debug bool + apiKey string + apiKeyFile string + debug bool } type curCond struct { @@ -108,6 +109,7 @@ var ( func (opmeteo *openmeteoConfig) Setup() { flag.StringVar(&opmeteo.apiKey, "openmeteo-api-key", "", "openmeteo backend: the api `KEY` to use if commercial usage") + flag.StringVar(&opmeteo.apiKeyFile, "openmeteo-api-key-file", "", "openmeteo backend: the path to a `FILE` containing the api key if commercial usage") flag.BoolVar(&opmeteo.debug, "openmeteo-debug", false, "openmeteo backend: print raw requests and responses") } @@ -165,6 +167,12 @@ func (opmeteo *openmeteoConfig) Fetch(location string, numdays int) iface.Data { var params []string var loc string + apiKey, err := ResolveAPIKey(opmeteo.apiKey, opmeteo.apiKeyFile) + if err != nil { + log.Fatalf("Failed to read openmeteo API key file: %v\n", err) + } + opmeteo.apiKey = apiKey + if numdays <= 0 { log.Fatal("Number of days less than 1 ") } diff --git a/backends/openweathermap.org.go b/backends/openweathermap.org.go index 840b260..3ac03e4 100644 --- a/backends/openweathermap.org.go +++ b/backends/openweathermap.org.go @@ -16,9 +16,10 @@ import ( ) type openWeatherConfig struct { - apiKey string - lang string - debug bool + apiKey string + apiKeyFile string + lang string + debug bool } type openWeatherResponse struct { @@ -72,6 +73,7 @@ const ( func (c *openWeatherConfig) Setup() { flag.StringVar(&c.apiKey, "owm-api-key", "", "openweathermap backend: the api `KEY` to use") + flag.StringVar(&c.apiKeyFile, "owm-api-key-file", "", "openweathermap backend: the path to a `FILE` containing the api key") flag.StringVar(&c.lang, "owm-lang", "en", "openweathermap backend: the `LANGUAGE` to request from openweathermap") flag.BoolVar(&c.debug, "owm-debug", false, "openweathermap backend: print raw requests and responses") } @@ -280,9 +282,14 @@ func (c *openWeatherConfig) Fetch(location string, numdays int) iface.Data { var ret iface.Data loc := "" - if len(c.apiKey) == 0 { + apiKey, err := ResolveAPIKey(c.apiKey, c.apiKeyFile) + if err != nil { + log.Fatalf("Failed to read openweathermap.org API key file: %v\n", err) + } + if len(apiKey) == 0 { log.Fatal("No openweathermap.org API key specified.\nYou have to register for one at https://home.openweathermap.org/users/sign_up") } + c.apiKey = apiKey if matched, err := regexp.MatchString(`^-?[0-9]*(\.[0-9]+)?,-?[0-9]*(\.[0-9]+)?$`, location); matched && err == nil { s := strings.Split(location, ",") loc = fmt.Sprintf("lat=%s&lon=%s", s[0], s[1]) diff --git a/backends/pirateweather.net.go b/backends/pirateweather.net.go index 9ea8311..8f7e6d2 100644 --- a/backends/pirateweather.net.go +++ b/backends/pirateweather.net.go @@ -19,19 +19,26 @@ const ( ) type pirateweatherConfig struct { - apiKey string - debug bool + apiKey string + apiKeyFile string + debug bool } func (c *pirateweatherConfig) Setup() { flag.StringVar(&c.apiKey, "pirateweather-api-key", "", "pirateweather backend: the api `KEY` to use") + flag.StringVar(&c.apiKeyFile, "pirateweather-api-key-file", "", "pirateweather backend: the path to a `FILE` containing the api key") flag.BoolVar(&c.debug, "pirateweather-debug", false, "pirateweather backend: print raw req and res") } func (c *pirateweatherConfig) Fetch(location string, numdays int) iface.Data { - if c.apiKey == "" { + apiKey, err := ResolveAPIKey(c.apiKey, c.apiKeyFile) + if err != nil { + log.Fatalf("Failed to read pirateweather.net API key file: %v\n", err) + } + if apiKey == "" { log.Fatal("No pirateweather.net API key specified.\nYou have to register for one at https://pirateweather.net/") } + c.apiKey = apiKey if c.debug { log.Printf("pirateweather location %v", location) diff --git a/backends/weatherapi.com.go b/backends/weatherapi.com.go index 0b3639c..638ddb5 100644 --- a/backends/weatherapi.com.go +++ b/backends/weatherapi.com.go @@ -57,9 +57,10 @@ type hourlyWeather struct { } type weatherApiConfig struct { - apiKey string - debug bool - lang string + apiKey string + apiKeyFile string + debug bool + lang string } const ( @@ -122,6 +123,7 @@ var ( func (c *weatherApiConfig) Setup() { flag.StringVar(&c.apiKey, "weather-api-key", "", "weatherapi backend: the api `Key` to use") + flag.StringVar(&c.apiKeyFile, "weather-api-key-file", "", "weatherapi backend: the path to a `FILE` containing the api key") flag.StringVar(&c.lang, "weather-api-lang", "en", "weatherapi backend: the `LANGUAGE` to request from weatherapi") flag.BoolVar(&c.debug, "weather-api-debug", false, "weatherapi backend: print raw requests and responses") } @@ -218,9 +220,14 @@ func (c *weatherApiConfig) parseCurCond(forecastInfo currentCond) (iface.Cond, e func (c *weatherApiConfig) Fetch(location string, numdays int) iface.Data { var ret iface.Data - if len(c.apiKey) == 0 { + apiKey, err := ResolveAPIKey(c.apiKey, c.apiKeyFile) + if err != nil { + log.Fatalf("Failed to read weatherapi.com API key file: %v\n", err) + } + if len(apiKey) == 0 { log.Fatal("No weatherapi.com API key specified.\nYou have to register for one at https://weatherapi.com/signup.aspx") } + c.apiKey = apiKey resp, err := c.fetch( fmt.Sprintf(weatherApiURI, c.apiKey, location, strconv.Itoa(numdays), c.lang), diff --git a/backends/worldweatheronline.com.go b/backends/worldweatheronline.com.go index cefca47..678925e 100644 --- a/backends/worldweatheronline.com.go +++ b/backends/worldweatheronline.com.go @@ -67,9 +67,10 @@ type wwoCoordinateResp struct { } type wwoConfig struct { - apiKey string - language string - debug bool + apiKey string + apiKeyFile string + language string + debug bool } const ( @@ -249,6 +250,7 @@ func wwoUnmarshalLang(body []byte, r *wwoResponse, lang string) error { func (c *wwoConfig) Setup() { flag.StringVar(&c.apiKey, "wwo-api-key", "", "worldweatheronline backend: the api `KEY` to use") + flag.StringVar(&c.apiKeyFile, "wwo-api-key-file", "", "worldweatheronline backend: the path to a `FILE` containing the api key") flag.StringVar(&c.language, "wwo-lang", "en", "worldweatheronline backend: the `LANGUAGE` to request from worldweatheronline") flag.BoolVar(&c.debug, "wwo-debug", false, "worldweatheronline backend: print raw requests and responses") } @@ -307,9 +309,14 @@ func (c *wwoConfig) Fetch(loc string, numdays int) iface.Data { var ret iface.Data coordChan := make(chan *iface.LatLon) - if len(c.apiKey) == 0 { + apiKey, err := ResolveAPIKey(c.apiKey, c.apiKeyFile) + if err != nil { + log.Fatalf("Failed to read worldweatheronline API key file: %v\n", err) + } + if len(apiKey) == 0 { log.Fatal("No API key specified. Setup instructions are in the README.") } + c.apiKey = apiKey params = append(params, "key="+c.apiKey) if len(loc) > 0 {