commit 084da1a39f29a54e16a5936fbe502280683c5e04 Author: EricNeid Date: Mon Dec 9 15:09:21 2019 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..575dc8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.key +debug.test +.vscode +*.exe \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..29c07f3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Eric Neidhardt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d2cef2d --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +build: + cd cmd/openweatherclient && go build + +test: + go test ./... \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..11a8c3d --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# go - openweatherapi + +This Repo contains golang library to query OpenWetherMaps () for weather information. + +* current weather: http://openweathermap.org/current +* 5 days forecast: http://openweathermap.org/forecast5 + +## Install + +```bash +go get github.com/EricNeid/openweather +``` + +## Documentation + +Is available on ``godoc``: + + + +## Examples + +Consuming the library: + +```go +import "github.com/EricNeid/openweather" + +// create a query +q := openweather.NewQueryForCity(readAPIKey(), "Berlin,de") + +// obtain data +resp, err := q.Weather() + +// enjoy +fmt.Println(resp.Name) // Berlin +fmt.Println(resp.Weather[0].Description) // misc +fmt.Println(resp.Main.Temp) // 1 +``` + +See the test files for more example. + +A simple client for testing is also included: + +```bash +go build cmd/openweatherclient +openweatherclient -key -city Berlin,de +``` diff --git a/api.go b/api.go new file mode 100644 index 0000000..a75ee78 --- /dev/null +++ b/api.go @@ -0,0 +1,131 @@ +// Package openweather contains helper functions to query +// OpenWeatherMaps (http://openweathermap.org/) for weather information. +// Currently the current weather API (http://openweathermap.org/current) and the +// 5 days forecast API (http://openweathermap.org/forecast5) are supported. +package openweather + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" +) + +// WeatherRaw downloads current weather data from openweathermap and return them as string. +func (query Query) WeatherRaw() (json string, err error) { + bytes, err := download(WeatherURL(query)) + if err != nil { + return "", err + } + return string(bytes), nil +} + +// Weather downloads current weather data from openweathermap and return them as WeatherData. +func (query Query) Weather() (*CurrentWeather, error) { + bytes, err := download(WeatherURL(query)) + if err != nil { + return nil, err + } + + dataPtr := &CurrentWeather{} + err = json.Unmarshal(bytes, dataPtr) + return dataPtr, err +} + +// DailyForecast5Raw downloads 5 days forecast data from openweathermap and return them as string. +func (query Query) DailyForecast5Raw() (json string, err error) { + bytes, err := download(DailyForecast5URL(query)) + if err != nil { + return "", err + } + return string(bytes), nil +} + +// DailyForecast5 downloads 5 days forecast data from openweathermap and return them as DailyForecast5. +func (query Query) DailyForecast5() (*DailyForecast5, error) { + bytes, err := download(DailyForecast5URL(query)) + if err != nil { + return nil, err + } + dataPtr := &DailyForecast5{} + err = json.Unmarshal(bytes, dataPtr) + return dataPtr, err +} + +// DailyForecast16Raw downloads 16 days forecast data from openweathermap and return them as string. +// Warning: the 16 days forecast requires a paid account. +func (query Query) DailyForecast16Raw() (json string, err error) { + bytes, err := download(DailyForecast16URL(query)) + if err != nil { + return "", err + } + return string(bytes), nil +} + +// DailyForecast16 downloads 16 days forecast data from openweathermap and return them as DailyForecast16. +// Warning: the 16 days forecast requires a paid account. +func (query Query) DailyForecast16() (*DailyForecast16, error) { + bytes, err := download(DailyForecast16URL(query)) + if err != nil { + return nil, err + } + dataPtr := &DailyForecast16{} + err = json.Unmarshal(bytes, dataPtr) + return dataPtr, err +} + +func download(url string) (res []byte, err error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return body, nil +} + +// WeatherIconURL returns an url to download matching icon for +// given weather id. +func WeatherIconURL(iconID string) (url string) { + return "http://openweathermap.org/img/w/" + iconID + ".png" +} + +// DailyForecast5URL returns a matching url for the given query which can be used to obtain the 5 days forecast +// from openweathermap.org. +func DailyForecast5URL(q Query) string { + return "http://api.openweathermap.org/data/2.5/forecast/daily" + formatURLQuery(q) +} + +// DailyForecast16URL returns a matching url for the given query which can be used to obtain the 16 days forecast +// from openweathermap.org. +func DailyForecast16URL(q Query) string { + return "http://api.openweathermap.org/data/2.5/forecast/daily" + formatURLQuery(q) + "&cnt=16" +} + +// WeatherURL returns a matching url for the given query which can be used to obtain the current weather information +// from openweathermap.org. +func WeatherURL(q Query) string { + return "http://api.openweathermap.org/data/2.5/weather" + formatURLQuery(q) +} + +func formatURLQuery(q Query) string { + queryType := q.queryType + queryValue := q.Query + var query string + + if queryType == queryTypeGeo { + params := strings.Split(queryValue, "|") // expected format is lat|long + lat := params[0] + lon := params[1] + query = fmt.Sprintf("?lat=%s&lon=%s", lat, lon) + } else { + query = fmt.Sprintf("?%s=%s", queryType, queryValue) + } + + query = query + fmt.Sprintf("&appid=%s&units=%s", q.APIKey, q.Unit) + return query +} diff --git a/api_test.go b/api_test.go new file mode 100644 index 0000000..fca2a32 --- /dev/null +++ b/api_test.go @@ -0,0 +1,64 @@ +package openweather + +import ( + "io/ioutil" + "testing" + + "github.com/EricNeid/openweather/internal/test" +) + +const apiKeyFile = "testdata/api.key" +const cityBerlin = "Berlin,de" + +func readAPIKey() string { + key, err := ioutil.ReadFile(apiKeyFile) + if err != nil { + panic(` + Cannot run test, you must provide openweathermap api key. + Expected: testdata/api.key + + See https://home.openweathermap.org/users/sign_up + for information how to obtain a key`) + } + return string(key) +} + +func TestForecastRaw(t *testing.T) { + // arrange + q := NewQueryForCity(readAPIKey(), cityBerlin) + // action + resp, err := q.DailyForecast5Raw() + // verify + test.Ok(t, err) + test.Assert(t, len(resp) > 0, "Received empty response") +} + +func TestWeatherRaw(t *testing.T) { + // arrange + q := NewQueryForCity(readAPIKey(), cityBerlin) + // action + resp, err := q.WeatherRaw() + // verify + test.Ok(t, err) + test.Assert(t, len(resp) > 0, "Received empty response") +} + +func TestWeather(t *testing.T) { + // arrange + q := NewQueryForCity(readAPIKey(), cityBerlin) + // action + data, err := q.Weather() + // verify + test.Ok(t, err) + test.Equals(t, "Berlin", data.Name) +} + +func TestDailyForecast(t *testing.T) { + // arrange + q := NewQueryForCity(readAPIKey(), cityBerlin) + // action + data, err := q.DailyForecast5() + // verify + test.Ok(t, err) + test.Equals(t, "Berlin", data.City.Name) +} diff --git a/cmd/openweatherclient/main.go b/cmd/openweatherclient/main.go new file mode 100644 index 0000000..7519ace --- /dev/null +++ b/cmd/openweatherclient/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "flag" + "fmt" + + "github.com/EricNeid/openweather" +) + +func main() { + keyPtr := flag.String("key", "", "Supply valid api key, see https://home.openweathermap.org/users/sign_up for details.") + cityPtr := flag.String("city", "", "City to look up") + flag.Parse() + + if len(*keyPtr) == 0 || len(*cityPtr) == 0 { + fmt.Println("Usage: simpleclient.exe -key -city ") + return + } + + query := openweather.NewQueryForCity(*keyPtr, *cityPtr, "metric") + weather, err := query.WeatherRaw() + + if err != nil { + fmt.Println("Error while retrieving data: " + err.Error()) + return + } + + fmt.Println(weather) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d28bcab --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/EricNeid/openweather + +go 1.13 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/internal/test/testing_utils.go b/internal/test/testing_utils.go new file mode 100644 index 0000000..3497485 --- /dev/null +++ b/internal/test/testing_utils.go @@ -0,0 +1,36 @@ +package test + +import ( + "fmt" + "path/filepath" + "reflect" + "runtime" + "testing" +) + +// Assert fails the test if the condition is false. +func Assert(t *testing.T, condition bool, msg string, v ...interface{}) { + if !condition { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d: "+msg+"\033[39m\n\n", append([]interface{}{filepath.Base(file), line}, v...)...) + t.FailNow() + } +} + +// Ok fails the test if an err is not nil. +func Ok(t *testing.T, err error) { + if err != nil { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d: unexpected error: %s\033[39m\n\n", filepath.Base(file), line, err.Error()) + t.FailNow() + } +} + +// Equals fails the test if exp is not equal to act. +func Equals(t *testing.T, exp, act interface{}) { + if !reflect.DeepEqual(exp, act) { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d:\n\n\texp: %#v\n\n\tgot: %#v\033[39m\n\n", filepath.Base(file), line, exp, act) + t.FailNow() + } +} diff --git a/model.go b/model.go new file mode 100644 index 0000000..53d2fb2 --- /dev/null +++ b/model.go @@ -0,0 +1,123 @@ +package openweather + +// CurrentWeather represents unmarshalled data from openweathermap +// for the current weather API (http://openweathermap.org/current). +type CurrentWeather struct { + Coord struct { + Lon float64 `json:"lon"` + Lat float64 `json:"lat"` + } `json:"coord"` + Weather []struct { + ID int `json:"id"` + Main string `json:"main"` + Description string `json:"description"` + Icon string `json:"icon"` + } `json:"weather"` + Base string `json:"base"` + Main struct { + Temp float64 `json:"temp"` + Pressure int `json:"pressure"` + Humidity int `json:"humidity"` + TempMin float64 `json:"temp_min"` + TempMax float64 `json:"temp_max"` + } `json:"main"` + Wind struct { + Speed float64 `json:"speed"` + Deg int `json:"deg"` + } `json:"wind"` + Clouds struct { + All int `json:"all"` + } `json:"clouds"` + Rain struct { + ThreeH int `json:"3h"` + } `json:"rain"` + Dt int `json:"dt"` + Sys struct { + Type int `json:"type"` + ID int `json:"id"` + Message float64 `json:"message"` + Country string `json:"country"` + Sunrise int `json:"sunrise"` + Sunset int `json:"sunset"` + } `json:"sys"` + ID int `json:"id"` + Name string `json:"name"` + Cod int `json:"cod"` +} + +// DailyForecast5 represents unmarshalled data from openweathermap +// for the 5 days forecast weather API (http://openweathermap.org/forecast5). +type DailyForecast5 struct { + Cod string `json:"cod"` + Message float64 `json:"message"` + City struct { + GeonameID int `json:"geoname_id"` + Name string `json:"name"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Country string `json:"country"` + Iso2 string `json:"iso2"` + Type string `json:"type"` + Population int `json:"population"` + } `json:"city"` + Cnt int `json:"cnt"` + List []struct { + Dt int `json:"dt"` + Temp struct { + Day float64 `json:"day"` + Min float64 `json:"min"` + Max float64 `json:"max"` + Night float64 `json:"night"` + Eve float64 `json:"eve"` + Morn float64 `json:"morn"` + } `json:"temp"` + Pressure float64 `json:"pressure"` + Humidity int `json:"humidity"` + Weather []struct { + ID int `json:"id"` + Main string `json:"main"` + Description string `json:"description"` + Icon string `json:"icon"` + } `json:"weather"` + Speed float64 `json:"speed"` + Deg int `json:"deg"` + Clouds int `json:"clouds"` + Snow float64 `json:"snow,omitempty"` + } `json:"list"` +} + +// DailyForecast16 represents unmarshalled data from openweathermap +// for the 16 days forecast weather API (http://openweathermap.org/forecast16). +type DailyForecast16 struct { + Cod string `json:"cod"` + Message float64 `json:"message"` + City struct { + ID int `json:"id"` + Name string `json:"name"` + Coord struct { + Lon float64 `json:"lon"` + Lat float64 `json:"lat"` + } `json:"coord"` + Country string `json:"country"` + } `json:"city"` + Cnt int `json:"cnt"` + List []struct { + Dt int `json:"dt"` + Temp struct { + Day float64 `json:"day"` + Min float64 `json:"min"` + Max float64 `json:"max"` + Night float64 `json:"night"` + Eve float64 `json:"eve"` + Morn float64 `json:"morn"` + } `json:"temp"` + Pressure float64 `json:"pressure"` + Humidity int `json:"humidity"` + Weather []struct { + ID int `json:"id"` + Main string `json:"main"` + Description string `json:"description"` + Icon string `json:"icon"` + } `json:"weather"` + } `json:"list"` +} diff --git a/query.go b/query.go new file mode 100644 index 0000000..db98585 --- /dev/null +++ b/query.go @@ -0,0 +1,74 @@ +package openweather + +// Query represents a pending request to openweathermap. +type Query struct { + APIKey string + Unit string + Query string + queryType string +} + +const queryTypeCity = "q" +const queryTypeZip = "zip" +const queryTypeID = "id" +const queryTypeGeo = "lat|lon" + +// NewQueryForCity creates a query for openweathermap from city name. +// The unit is optional and defaults to metric. +func NewQueryForCity(apiKey string, city string, unit ...string) Query { + u := "metric" + if len(unit) > 0 { + u = unit[0] + } + return Query{ + APIKey: apiKey, + Query: city, + queryType: queryTypeCity, + Unit: u, + } +} + +// NewQueryForZip creates a query for openweathermap from zip code. +// The unit is optional and defaults to metric. +func NewQueryForZip(apiKey string, zip string, unit ...string) Query { + u := "metric" + if len(unit) > 0 { + u = unit[0] + } + return Query{ + APIKey: apiKey, + Query: zip, + queryType: queryTypeZip, + Unit: u, + } +} + +// NewQueryForID creates a query for openweathermap from city id. +// The unit is optional and defaults to metric. +func NewQueryForID(apiKey string, id string, unit ...string) Query { + u := "metric" + if len(unit) > 0 { + u = unit[0] + } + return Query{ + APIKey: apiKey, + Query: id, + queryType: queryTypeID, + Unit: u, + } +} + +// NewQueryForLocation creates a query for openweathermap from latitude and longitude. +// The unit is optional and defaults to metric. +func NewQueryForLocation(apiKey string, lat string, lon string, unit ...string) Query { + u := "metric" + if len(unit) > 0 { + u = unit[0] + } + return Query{ + APIKey: apiKey, + Query: lat + "|" + lon, + queryType: queryTypeGeo, + Unit: u, + } +} diff --git a/query_test.go b/query_test.go new file mode 100644 index 0000000..8e10dcd --- /dev/null +++ b/query_test.go @@ -0,0 +1,64 @@ +package openweather + +import ( + "testing" + + "github.com/EricNeid/openweather/internal/test" +) + +func TestNewQueryForCity(t *testing.T) { + // arrange + apiKey := "testKey" + location := cityBerlin + // action + q := NewQueryForCity(apiKey, location) + // verify + test.Equals(t, apiKey, q.APIKey) + test.Equals(t, location, q.Query) + test.Equals(t, "metric", q.Unit) + test.Equals(t, queryTypeCity, q.queryType) + + // arrange + unit := "imperial" + // action + q = NewQueryForCity(apiKey, location, unit) + // verify + test.Equals(t, unit, q.Unit) +} + +func TestNewQueryForZip(t *testing.T) { + // arrange + apiKey := "testKey" + zip := "12345" + // action + q := NewQueryForZip(apiKey, zip) + // verify + test.Equals(t, apiKey, q.APIKey) + test.Equals(t, zip, q.Query) + test.Equals(t, queryTypeZip, q.queryType) +} + +func TestNewQueryForID(t *testing.T) { + // arrange + apiKey := "testKey" + id := "42" + // action + q := NewQueryForID(apiKey, id) + // verify + test.Equals(t, apiKey, q.APIKey) + test.Equals(t, id, q.Query) + test.Equals(t, queryTypeID, q.queryType) +} + +func TestNewQueryForLocation(t *testing.T) { + // arrange + apiKey := "testKey" + lat := "51" + lon := "13" + // action + q := NewQueryForLocation(apiKey, lat, lon) + // verify + test.Equals(t, apiKey, q.APIKey) + test.Equals(t, lat+"|"+lon, q.Query) + test.Equals(t, queryTypeGeo, q.queryType) +}