Integrations
Here, we add the support code for the app: location search, weather fetching, SVG charts, and a small session store.
This part is mostly setup. The tutorial focuses on Doors, so it is perfectly fine to copy these files as-is and move on.
Database
Start by adding the SQLite world database. You can download it from the releases in dr5hn/countries-states-cities-database.
Place it here:
./sqlite
└── sqlite-world.sqlite3
Then install the SQLite driver:
go get github.com/mattn/go-sqlite3
Create driver/locations.go:
package driver
import (
"database/sql"
"errors"
)
type Place struct {
Id int
Name string
}
func (p Place) IsValid() bool {
return p.Name != ""
}
type City struct {
Name string
Country Place
Id int
Lat float64
Long float64
}
func (c City) IsValid() bool {
return c.Name != ""
}
type locationsDB struct {
db *sql.DB
}
func (d locationsDB) CountriesGet(id int) (Place, error) {
var p Place
query := `
SELECT id, name
FROM countries
WHERE id = ?
LIMIT 1
`
row := d.db.QueryRow(query, id)
if err := row.Scan(&p.Id, &p.Name); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Place{}, nil
}
return Place{}, err
}
return p, nil
}
func (d locationsDB) CountriesSearch(term string) ([]Place, error) {
query := `
SELECT id, name
FROM countries
WHERE LOWER(name) LIKE LOWER(?)
LIMIT 7
`
rows, err := d.db.Query(query, term+"%")
if err != nil {
return nil, err
}
defer rows.Close()
var results []Place
for rows.Next() {
var c Place
if err := rows.Scan(&c.Id, &c.Name); err != nil {
return nil, err
}
results = append(results, c)
}
return results, nil
}
func (d locationsDB) CitiesGet(city int) (City, error) {
var c City
row := d.db.QueryRow(`
SELECT id, name, latitude, longitude, country_id
FROM cities
WHERE id = ?
LIMIT 1
`, city)
var countryId int
var err error
if err = row.Scan(&c.Id, &c.Name, &c.Lat, &c.Long, &countryId); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return City{}, nil
}
return City{}, err
}
c.Country, err = d.CountriesGet(countryId)
if err != nil {
return City{}, err
}
return c, nil
}
func (d locationsDB) CitiesSearch(country int, term string) ([]Place, error) {
query := `
SELECT id, name
FROM cities
WHERE country_id = ?
AND LOWER(name) LIKE LOWER(?)
LIMIT 7
`
rows, err := d.db.Query(query, country, term+"%")
if err != nil {
return nil, err
}
defer rows.Close()
var results []Place
for rows.Next() {
var c Place
if err := rows.Scan(&c.Id, &c.Name); err != nil {
return nil, err
}
results = append(results, c)
}
return results, nil
}
Create driver/session.go:
package driver
import (
"database/sql"
"time"
"github.com/doors-dev/doors"
)
func newSessionsDB(db *sql.DB) sessionsDB {
initQuery := `
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
login TEXT NOT NULL,
expire DATETIME NOT NULL
);
`
if _, err := db.Exec(initQuery); err != nil {
panic("Failed to create sessions table: " + err.Error())
}
s := sessionsDB{
db: db,
}
go s.cleanup()
return s
}
type Session struct {
Token string `json:"token"`
Login string `json:"login"`
Expire time.Time `json:"expire"`
}
func (s Session) IsValid() bool {
return s.Token != ""
}
type sessionsDB struct {
db *sql.DB
}
func (d sessionsDB) cleanup() {
for {
<-time.After(10 * time.Minute)
_, err := d.db.Exec("DELETE FROM sessions WHERE expire <= ?", time.Now())
if err != nil {
panic("Failed to cleanup expired sessions: " + err.Error())
}
}
}
func (d sessionsDB) Add(login string, dur time.Duration) Session {
token := doors.IDRand()
expire := time.Now().Add(dur)
_, err := d.db.Exec(
"INSERT INTO sessions (token, login, expire) VALUES (?, ?, ?)",
token, login, expire,
)
if err != nil {
panic("Failed to create session: " + err.Error())
}
return Session{
Token: token,
Login: login,
Expire: expire,
}
}
func (d sessionsDB) Get(token string) Session {
var session Session
err := d.db.QueryRow(
"SELECT token, login, expire FROM sessions WHERE token = ? AND expire > ?",
token, time.Now(),
).Scan(&session.Token, &session.Login, &session.Expire)
if err != nil {
if err == sql.ErrNoRows {
return Session{}
}
panic("Failed to get session: " + err.Error())
}
return session
}
func (d sessionsDB) Remove(token string) bool {
result, err := d.db.Exec("DELETE FROM sessions WHERE token = ?", token)
if err != nil {
panic("Failed to remove session: " + err.Error())
}
rowsAffected, err := result.RowsAffected()
if err != nil {
panic("Failed to get rows affected: " + err.Error())
}
return rowsAffected > 0
}
Weather
Next, add a small wrapper around the weather API we will use for the dashboard charts.
Create driver/weather.go:
package driver
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
type weatherAPI struct {
endpoint string
timeout time.Duration
}
type Response struct {
Hourly struct {
Time []string `json:"time"`
Temperature2m []float64 `json:"temperature_2m"`
RelativeHumidity2m []float64 `json:"relative_humidity_2m"`
WindSpeed10m []float64 `json:"wind_speed_10m"`
Rain []float64 `json:"rain"`
WeatherCode []int `json:"weather_code"`
} `json:"hourly"`
}
func (w weatherAPI) parseTime(str string) (time.Time, error) {
layout := "2006-01-02T15:04"
return time.Parse(layout, str)
}
func (w weatherAPI) request(ctx context.Context, city City, parameter parameter, units Units, days int) (Response, error) {
ctx, cancel := context.WithTimeout(ctx, w.timeout)
defer cancel()
url := fmt.Sprintf(
"%s?latitude=%.2f&longitude=%.2f%s%s&forecast_days=%d",
w.endpoint,
city.Lat, city.Long,
parameter.param(), units.param(),
days,
)
var r Response
for attempt := range 5 {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return r, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "doors-tutorial/1.0")
res, err := http.DefaultClient.Do(req)
if err != nil {
if ctx.Err() != nil {
return r, ctx.Err()
}
log.Printf("weather request failed: attempt=%d city=%q parameter=%q days=%d err=%v", attempt+1, city.Name, parameter, days, err)
if attempt < 2 && waitRetry(ctx, attempt) == nil {
continue
}
return r, err
}
err = decodeWeatherResponse(res, &r)
if err == nil {
break
}
log.Printf("weather request failed: attempt=%d city=%q parameter=%q days=%d err=%v", attempt+1, city.Name, parameter, days, err)
if attempt < 2 && waitRetry(ctx, attempt) == nil {
continue
}
return r, err
}
for i, v := range r.Hourly.Time {
t, err := w.parseTime(v)
if err != nil {
return r, err
}
if days < 3 {
r.Hourly.Time[i] = t.Format("15:04") + " "
} else {
r.Hourly.Time[i] = t.Format("02.01")
}
}
return r, nil
}
func decodeWeatherResponse(res *http.Response, out *Response) error {
defer res.Body.Close()
if res.StatusCode == http.StatusBadGateway ||
res.StatusCode == http.StatusServiceUnavailable ||
res.StatusCode == http.StatusGatewayTimeout ||
res.StatusCode == http.StatusTooManyRequests {
return fmt.Errorf("transient weather api status: %s", res.Status)
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("weather api status: %s", res.Status)
}
return json.NewDecoder(res.Body).Decode(out)
}
func waitRetry(ctx context.Context, attempt int) error {
delay := time.Duration(attempt+1) * 100 * time.Millisecond
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
type FloatSamples struct {
Labels []string `json:"labels"`
Values []float64 `json:"values"`
}
type StringSamples struct {
Labels []string `json:"labels"`
Values []string `json:"values"`
}
func (w weatherAPI) Humidity(ctx context.Context, city City, days int) (FloatSamples, error) {
r, err := w.request(ctx, city, humidity, noUnits, days)
if err != nil {
return FloatSamples{}, err
}
samples := FloatSamples{
Labels: make([]string, len(r.Hourly.Time)),
Values: make([]float64, len(r.Hourly.Time)),
}
for i := range r.Hourly.Time {
samples.Labels[i] = r.Hourly.Time[i]
samples.Values[i] = r.Hourly.RelativeHumidity2m[i]
}
return samples, nil
}
func (w weatherAPI) Temperature(ctx context.Context, city City, units Units, days int) (FloatSamples, error) {
r, err := w.request(ctx, city, temperature, units, days)
if err != nil {
return FloatSamples{}, err
}
samples := FloatSamples{
Labels: make([]string, len(r.Hourly.Time)),
Values: make([]float64, len(r.Hourly.Time)),
}
for i := range r.Hourly.Time {
samples.Labels[i] = r.Hourly.Time[i]
samples.Values[i] = r.Hourly.Temperature2m[i]
}
return samples, nil
}
func (w weatherAPI) WindSpeed(ctx context.Context, city City, units Units, days int) (FloatSamples, error) {
r, err := w.request(ctx, city, windSpeed, noUnits, days)
if err != nil {
return FloatSamples{}, err
}
samples := FloatSamples{
Labels: make([]string, len(r.Hourly.Time)),
Values: make([]float64, len(r.Hourly.Time)),
}
for i := range r.Hourly.Time {
samples.Labels[i] = r.Hourly.Time[i]
samples.Values[i] = r.Hourly.WindSpeed10m[i]
}
return samples, nil
}
func (w weatherAPI) Code(ctx context.Context, city City, days int) (StringSamples, error) {
r, err := w.request(ctx, city, weatherCode, Metric, days)
if err != nil {
return StringSamples{}, err
}
samples := StringSamples{
Labels: make([]string, len(r.Hourly.Time)),
Values: make([]string, len(r.Hourly.Time)),
}
for i := range r.Hourly.Time {
samples.Labels[i] = r.Hourly.Time[i]
str, ok := weatherCodeShort[r.Hourly.WeatherCode[i]]
if !ok {
str = "unknown"
}
samples.Values[i] = str
}
return samples, nil
}
var weatherCodeShort = map[int]string{
0: "Clear",
1: "Mainly clear",
2: "Partly cloudy",
3: "Overcast",
45: "Fog",
48: "Rime fog",
51: "Drizzle light",
53: "Drizzle mod",
55: "Drizzle dense",
56: "Frzg drizzle lgt",
57: "Frzg drizzle hvy",
61: "Rain light",
63: "Rain mod",
65: "Rain heavy",
66: "Frzg rain lgt",
67: "Frzg rain hvy",
71: "Snow light",
73: "Snow mod",
75: "Snow heavy",
77: "Snow grains",
80: "Shower rain lgt",
81: "Shower rain mod",
82: "Shower rain hvy",
85: "Snow shower lgt",
86: "Snow shower hvy",
95: "Thunderstorm",
96: "Storm + small hail",
99: "Storm + heavy hail",
}
type Units int
const (
Metric Units = iota
Imperial
noUnits
)
func (u Units) String() string {
if u == Imperial {
return "Imperial"
}
if u == Metric {
return "Metric"
}
return "unknown"
}
func (u Units) WindSpeed() string {
if u == Imperial {
return "KMH"
}
if u == Metric {
return "MPH"
}
return "unknown"
}
func (u Units) Temperature() string {
if u == Imperial {
return " °F"
}
if u == Metric {
return " °C"
}
return "unknown"
}
func (u Units) Ref() *Units {
return &u
}
func (u Units) param() string {
if u == Metric {
return "&wind_speed_unit=kmh&temperature_unit=celsius&precipitation_unit=mm"
}
if u == Imperial {
return "&wind_speed_unit=mph&temperature_unit=fahrenheit&precipitation_unit=inch"
}
return ""
}
type parameter string
const (
temperature parameter = "temperature_2m"
humidity parameter = "relative_humidity_2m"
windSpeed parameter = "wind_speed_10m"
weatherCode parameter = "weather_code"
)
func (u parameter) param() string {
return "&hourly=" + string(u)
}
Charts
We also need SVG chart helpers. For that, install go-charts:
go get github.com/vicanso/go-charts/v2
Create driver/charts.go:
package driver
import (
"sort"
"github.com/vicanso/go-charts/v2"
)
func chartDefaults(opt *charts.ChartOption) {
opt.Theme = "dark"
opt.Height = 400
opt.BackgroundColor = charts.Color{
R: 24,
G: 28,
B: 37,
A: 255,
}
}
func ChartLine(values []float64, labels []string, unit string) ([]byte, error) {
p, err := charts.LineRender(
[][]float64{values},
charts.SVGTypeOption(),
func(opt *charts.ChartOption) {
chartDefaults(opt)
opt.XAxis = charts.NewXAxisOption(labels)
opt.SymbolShow = charts.FalseFlag()
opt.Legend = charts.LegendOption{
Data: []string{unit},
}
opt.LineStrokeWidth = 2
},
)
if err != nil {
return nil, err
}
return p.Bytes()
}
func ChartPie(values []string) ([]byte, error) {
m := make(map[string]float64)
for _, v := range values {
c, _ := m[v]
m[v] = c + 1
}
counts := make([]float64, len(m))
labels := make([]string, len(m))
i := 0
for k := range m {
labels[i] = k
i += 1
}
sort.Strings(labels)
for i, label := range labels {
counts[i] = m[label]
}
p, err := charts.PieRender(
counts,
charts.SVGTypeOption(),
charts.PieSeriesShowLabel(),
func(opt *charts.ChartOption) {
chartDefaults(opt)
f := false
opt.Legend = charts.LegendOption{
Orient: charts.OrientVertical,
Data: labels,
Show: &f,
}
},
)
if err != nil {
return nil, err
}
return p.Bytes()
}
go-chartsis good enough for this tutorial, even though it is no longer maintained.
Init
Finally, wire these integrations together in one place.
Create driver/init.go:
package driver
import (
"database/sql"
"log"
"time"
_ "github.com/mattn/go-sqlite3"
)
var Locations locationsDB
var Weather weatherAPI
var Sessions sessionsDB
func init() {
locations, err := sql.Open("sqlite3", "./sqlite/sqlite-world.sqlite3")
if err != nil {
log.Fatal("Failed to open database:", err)
}
Locations = locationsDB{db: locations}
Weather = weatherAPI{
endpoint: "https://api.open-meteo.com/v1/forecast",
timeout: 10 * time.Second,
}
sessions, err := sql.Open("sqlite3", "./sqlite/sessions.sqlite3")
if err != nil {
log.Fatal("Failed to open database:", err)
}
Sessions = newSessionsDB(sessions)
}
After this step, your project should have:
- the SQLite world database in
sqlite/sqlite-world.sqlite3 - the
driverpackage with database, weather, chart, and session helpers - the extra dependencies added to
go.modandgo.sum
Next: Country Selector