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-charts is 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 driver package with database, weather, chart, and session helpers
  • the extra dependencies added to go.mod and go.sum

Next: Country Selector