Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions backends/apikey.go
Original file line number Diff line number Diff line change
@@ -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
}
177 changes: 177 additions & 0 deletions backends/apikey_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
14 changes: 11 additions & 3 deletions backends/caiyun.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)
}
Expand Down
12 changes: 10 additions & 2 deletions backends/open-meteo.com.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ import (
)

type openmeteoConfig struct {
apiKey string
debug bool
apiKey string
apiKeyFile string
debug bool
}

type curCond struct {
Expand Down Expand Up @@ -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")
}

Expand Down Expand Up @@ -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 ")
}
Expand Down
15 changes: 11 additions & 4 deletions backends/openweathermap.org.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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])
Expand Down
13 changes: 10 additions & 3 deletions backends/pirateweather.net.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 11 additions & 4 deletions backends/weatherapi.com.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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),
Expand Down
Loading