1
0
Fork 0

Big change

- Reorganised request template handling and error handling
- Added Content-Type response
- Implemented templating data
- Break out router
main
Ambrose Chua 2020-11-10 17:17:11 +08:00
parent b18b3c0035
commit 5e9d1ab6cb
20 changed files with 425 additions and 100 deletions

View File

@ -16,7 +16,7 @@ clean:
.PHONY: build .PHONY: build
build: datetime build: datetime
datetime: *.go datetime: *.go data/*.go
$(GO) build -tags "$(TAGS)" -v -o datetime $(GO) build -tags "$(TAGS)" -v -o datetime
.PHONY: test .PHONY: test

55
app.go
View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"errors"
"html/template" "html/template"
"net/http" "net/http"
@ -8,6 +9,9 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
// ErrNoTemplate is returned when a template was not found on the server
var ErrNoTemplate = errors.New("missing template")
// Datetime is the main application server // Datetime is the main application server
type Datetime struct { type Datetime struct {
*http.ServeMux *http.ServeMux
@ -19,7 +23,7 @@ type Datetime struct {
// like templates and data exist // like templates and data exist
func NewDatetime() (*Datetime, error) { func NewDatetime() (*Datetime, error) {
// Data // Data
tmpl, err := template.ParseGlob("templates/*") tmpl, err := template.New("templates").Funcs(templateFuncs).ParseGlob("templates/*")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -42,6 +46,11 @@ func NewDatetime() (*Datetime, error) {
return app, nil return app, nil
} }
type appRequest struct {
App Datetime
Req Request
}
// index handles all incoming page requests // index handles all incoming page requests
func (app Datetime) index(w http.ResponseWriter, req *http.Request) { func (app Datetime) index(w http.ResponseWriter, req *http.Request) {
var err error var err error
@ -51,45 +60,29 @@ func (app Datetime) index(w http.ResponseWriter, req *http.Request) {
return return
} }
accept := req.Header.Get("Accept") tmpl := app.loadTemplate("index", w, req)
tmpl, acceptable := app.chooseTemplate(accept)
if !acceptable {
w.WriteHeader(http.StatusNotAcceptable)
return
}
if tmpl == nil { if tmpl == nil {
l.Error("unable to find template", zap.String("accept", accept))
w.WriteHeader(http.StatusInternalServerError)
return return
} }
if req.Method == http.MethodHead { if req.Method == http.MethodHead {
return return
} }
l.Debug("", zap.Reflect("url", req.URL)) request := Request{}
err = tmpl.Execute(w, nil) if req.URL.Path != "/" {
request, err = ParseRequest(req.URL)
if err != nil {
l.Debug("parse failed", zap.Error(err))
app.error(HTTPError{http.StatusBadRequest, err}, w, req)
return
}
}
l.Debug("rendering template", zap.Reflect("request", request))
err = tmpl.Execute(w, appRequest{app, request})
if err != nil { if err != nil {
l.Error("templating failed", zap.Error(err)) l.Error("templating failed", zap.Error(err))
w.WriteHeader(http.StatusInternalServerError) // Usually this will fail app.templateError(HTTPError{http.StatusInternalServerError, err}, w, req)
return return
} }
} }
// chooseTemplate returns a template based on the accepted mime types from the
// client, and if a template cannot be found it returns a nil template.
func (app Datetime) chooseTemplate(accept string) (t *template.Template, acceptable bool) {
responseType := chooseResponseType(accept)
templateName := ""
switch responseType {
case responsePlain:
templateName = "index.txt"
case responseHTML:
templateName = "index.html"
case responseAny:
templateName = "index.txt"
case responseUnknown:
return nil, false
}
return app.tmpl.Lookup(templateName), true
}

View File

@ -7,27 +7,31 @@ import (
) )
func TestChooseTemplate(t *testing.T) { func TestChooseTemplate(t *testing.T) {
tmpl, err := template.ParseGlob("templates/*") tmpl, err := template.New("templates").Funcs(templateFuncs).ParseGlob("templates/*")
if err != nil { if err != nil {
panic(err) panic(err)
} }
app := &Datetime{tmpl: tmpl} app := &Datetime{tmpl: tmpl}
type chooseTest struct { type chooseTest struct {
accept string accept string
acceptable bool acceptable bool
template string contentType string
template string
} }
tests := []chooseTest{ tests := []chooseTest{
{"text/html", true, "index.html"}, {"text/html", true, "text/html", "index.html"},
{"text/html;q=0.9,text/plain", true, "index.txt"}, {"text/html;q=0.9,text/plain", true, "text/plain", "index.txt"},
{"image/png", false, ""}, {"image/png", false, "", ""},
{"*/*", true, "index.txt"}, {"*/*", true, "text/plain", "index.txt"},
} }
for _, test := range tests { for _, test := range tests {
tmpl, acceptable := app.chooseTemplate(test.accept) tmpl, contentType, acceptable := app.chooseTemplate(test.accept, "index")
fn := fmt.Sprintf("chooseTemplate(\"%s\")", test.accept) fn := fmt.Sprintf("chooseTemplate(\"%s\")", test.accept)
if contentType != test.contentType {
t.Errorf("%s; contentType = %v; wanted %v", fn, contentType, test.contentType)
}
if acceptable != test.acceptable { if acceptable != test.acceptable {
t.Errorf("%s; acceptable = %v; wanted %v", fn, acceptable, test.acceptable) t.Errorf("%s; acceptable = %v; wanted %v", fn, acceptable, test.acceptable)
} }

View File

@ -3,6 +3,7 @@ package data
import ( import (
"encoding/json" "encoding/json"
"io/ioutil" "io/ioutil"
"strings"
) )
// ReadCities opens the file "data/cities.json" and reads it into a map // ReadCities opens the file "data/cities.json" and reads it into a map
@ -20,3 +21,15 @@ func ReadCities() (map[string]*City, error) {
return cities, nil return cities, nil
} }
func extendName(names ...string) string {
return strings.Join(names, ", ")
}
// FullName returns a fully qualified human readable name
func (c City) FullName() string {
if len(c.Admin1.Name) > 0 {
return extendName(c.Name, c.Admin1.Name, c.Country.Name)
}
return extendName(c.Name, c.Country.Name)
}

View File

@ -1,6 +1,6 @@
package data package data
// city represents a city that belongs inside an administrative division level 1 // City represents a city that belongs inside an administrative division level 1
// and a country // and a country
type City struct { type City struct {
// Ref is the ASCII name of the city // Ref is the ASCII name of the city

60
error.go Normal file
View File

@ -0,0 +1,60 @@
package main
import (
"net/http"
"go.uber.org/zap"
)
// HTTPError is an error that can be rendered into a HTML page to follow HTTP
// status semantics
type HTTPError struct {
Status int
Error error
}
func (app Datetime) error(httpErr HTTPError, w http.ResponseWriter, req *http.Request) {
tmpl := app.loadTemplate("error", w, req)
if tmpl == nil {
return
}
w.WriteHeader(httpErr.Status)
if req.Method == http.MethodHead {
return
}
err := tmpl.Execute(w, httpErr)
if err != nil {
l.Error("template failed", zap.Error(err))
app.templateError(HTTPError{http.StatusInternalServerError, err}, w, req)
return
}
}
func (app Datetime) simpleError(httpErr HTTPError, w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(httpErr.Status)
if req.Method == http.MethodHead {
return
}
if httpErr.Error != nil {
w.Write([]byte(http.StatusText(httpErr.Status) + ": " + httpErr.Error.Error()))
} else {
w.Write([]byte(http.StatusText(httpErr.Status)))
}
}
func (app Datetime) templateError(httpErr HTTPError, w http.ResponseWriter, req *http.Request) {
// Sadly, we probably already sent out the header
//w.Header().Set("Content-Type", "text/plain")
//w.WriteHeader(httpErr.Status)
if httpErr.Error != nil {
w.Write([]byte("\n" + http.StatusText(httpErr.Status) + ": " + httpErr.Error.Error()))
} else {
w.Write([]byte("\n" + http.StatusText(httpErr.Status)))
}
}

View File

@ -4,8 +4,11 @@ package main
import ( import (
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zapcore"
) )
func zapConfig() zap.Config { func zapConfig() zap.Config {
return zap.NewDevelopmentConfig() dev := zap.NewDevelopmentConfig()
dev.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
return dev
} }

View File

@ -27,7 +27,7 @@ import (
"github.com/serverwentdown/datetime.link/data" "github.com/serverwentdown/datetime.link/data"
) )
var regexName = regexp.MustCompile(`[^a-zA-Z1-9]+`) var regexName = regexp.MustCompile(`[^a-zA-Z1-9']+`)
func extendRef(refs ...string) string { func extendRef(refs ...string) string {
return strings.Join(refs, "-") return strings.Join(refs, "-")

81
template.go Normal file
View File

@ -0,0 +1,81 @@
package main
import (
"html/template"
"net/http"
"go.uber.org/zap"
)
var templateFuncs = map[string]interface{}{
"statusText": templateFuncStatusText,
"thisIsSafe": templateFuncThisIsSafe,
// Formatting
"formatOffset": templateFuncFormatOffset,
// Logic
"resolveZone": templateFuncResolveZone,
}
func templateFuncStatusText(s int) string {
return http.StatusText(s)
}
func templateFuncThisIsSafe(s string) template.HTML {
return template.HTML(s)
}
func templateFuncFormatOffset(offset int) string {
return FormatZoneOffset(offset)
}
// ResolvedZone holds a resolved zone or an error
type ResolvedZone struct {
Zone
Error error
}
func templateFuncResolveZone(app Datetime, zone string) ResolvedZone {
z, err := ResolveZone(app.cities, zone)
if err != nil {
l.Debug("unable to resolve zone", zap.Reflect("zone", zone), zap.Error(err))
}
return ResolvedZone{z, err}
}
// loadTemplate returns a matching template for the request. It also causes an
// error if the template is not found or the Accept parameters are incorrect.
func (app Datetime) loadTemplate(name string, w http.ResponseWriter, req *http.Request) *template.Template {
accept := req.Header.Get("Accept")
tmpl, contentType, acceptable := app.chooseTemplate(accept, name)
if !acceptable {
app.simpleError(HTTPError{http.StatusNotAcceptable, nil}, w, req)
return nil
}
if tmpl == nil {
l.Error("unable to find template", zap.String("name", name), zap.String("accept", accept))
app.simpleError(HTTPError{http.StatusInternalServerError, ErrNoTemplate}, w, req)
return nil
}
w.Header().Set("Content-Type", contentType)
return tmpl
}
// chooseTemplate returns a template based on the accepted mime types from the
// client, and if a template cannot be found it returns a nil template.
func (app Datetime) chooseTemplate(accept string, name string) (t *template.Template, contentType string, acceptable bool) {
acceptable = true
switch chooseResponseType(accept) {
case responsePlain:
t = app.tmpl.Lookup(name + ".txt")
contentType = "text/plain"
case responseHTML:
t = app.tmpl.Lookup(name + ".html")
contentType = "text/html"
case responseAny:
t = app.tmpl.Lookup(name + ".txt")
contentType = "text/plain"
case responseUnknown:
acceptable = false
return
}
return
}

27
template_test.go Normal file
View File

@ -0,0 +1,27 @@
package main
import (
"testing"
)
func TestTemplateFuncFormatOffset(t *testing.T) {
want, got := "+06:06", templateFuncFormatOffset(6*60*60+6*60)
if want != got {
t.Fatalf("got offset %v, want offset %v", got, want)
}
want, got = "-12:15", templateFuncFormatOffset(-(12*60*60 + 15*60))
if want != got {
t.Fatalf("got offset %v, want offset %v", got, want)
}
want, got = "\u00B100:00", templateFuncFormatOffset(-(0*60*60 + 0*60))
if want != got {
t.Fatalf("got offset %v, want offset %v", got, want)
}
want, got = "+00:01", templateFuncFormatOffset(0*60*60+1*60)
if want != got {
t.Fatalf("got offset %v, want offset %v", got, want)
}
}

25
templates/error.html Normal file
View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{.Status | statusText}} - datetime.link</title>
<meta name="viewport" content="width=device-width">
{{template "resources.html"}}
</head>
<body>
{{template "resources-body.html"}}
<main id="app">
<h1 title="Status Code: {{.Status}}">{{.Status | statusText}}</h1>
{{if eq .Status 400}}
<p title="Error: {{.Error.Error}}">The URL path is not valid</p>
{{else}}
<p>{{.Error.Error}}</p>
{{end}}
</main>
{{template "footer.html"}}
</body>
</html>

2
templates/error.txt Normal file
View File

@ -0,0 +1,2 @@
{{.Status}} {{.Status | statusText | thisIsSafe}}
{{.Error | thisIsSafe}}

11
templates/footer.html Normal file
View File

@ -0,0 +1,11 @@
<footer>
<ul class="list-inline">
<li><a href="https://github.com/serverwentdown/datetime.link" target="_blank">About datetime.link</a></li><!--
--><li><a href="https://github.com/serverwentdown/datetime.link/issues/new" target="_blank">Found a bug?</a></li><!--
--><li onclick="toggleTheme()"><!--
--><span class="icon theme-toggle-system">{{template "icon_solid_adjust.svg"}}</span><span class="theme-toggle-name theme-toggle-system">&nbsp;System theme</span><!--
--><span class="icon theme-toggle-dark">{{template "icon_solid_moon.svg"}}</span><span class="theme-toggle-name theme-toggle-dark">&nbsp;Dark theme</span><!--
--><span class="icon theme-toggle-light">{{template "icon_solid_sun.svg"}}</span><span class="theme-toggle-name theme-toggle-light">&nbsp;Light theme</span><!--
--></li>
</ul>
</footer>

View File

@ -4,13 +4,38 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>datetime.link</title> <title>datetime.link</title>
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="https://rsms.me/inter/inter.css"> {{template "resources.html"}}
<link rel="stylesheet" href="/css/styles.css">
</head> </head>
<body> <body>
<script src="/js/theme.js"></script> {{template "resources-body.html"}}
<main id="app"> <main id="app">
{{$app := .App}}
{{$t := .Req.Time}}
{{range .Req.Zones}}
<d-zone zone="{{.}}">
{{with resolveZone $app .}}
{{if .Error}}
<d-zoneerror>
Unable to load zone. The URL path might not be valid
</d-zoneerror>
{{else}}
{{$zt := $t.In .Location}}
<d-zoneinfo>
<d-zonename>{{.Name}}</d-zonename>
{{if not .IsOffset}}
<d-zoneoffset>{{.TimeOffset $t | formatOffset}}</d-zoneoffset>
{{end}}
<d-date date="{{$zt.Format "2006-01-02"}}">{{$zt.Format "2006-01-02"}}</d-date>
</d-zoneinfo>
<d-zonefigure>
<d-time time="{{$zt.Format "15:04"}}">{{$zt.Format "15:04"}}</d-time>
</d-zonefigure>
{{end}}
{{end}}
</d-zone>
{{end}}
<!-- <!--
<d-zone> <d-zone>
<d-zoneinfo> <d-zoneinfo>
@ -45,17 +70,7 @@
--> -->
</main> </main>
<footer> {{template "footer.html"}}
<ul class="list-inline">
<li><a href="https://github.com/serverwentdown/datetime.link" target="_blank">About datetime.link</a></li><!--
--><li><a href="https://github.com/serverwentdown/datetime.link/issues/new" target="_blank">Found a bug?</a></li><!--
--><li onclick="toggleTheme()"><!--
--><span class="icon theme-toggle-system">{{template "icon_solid_adjust.svg"}}</span><span class="theme-toggle-name theme-toggle-system">&nbsp;System theme</span><!--
--><span class="icon theme-toggle-dark">{{template "icon_solid_moon.svg"}}</span><span class="theme-toggle-name theme-toggle-dark">&nbsp;Dark theme</span><!--
--><span class="icon theme-toggle-light">{{template "icon_solid_sun.svg"}}</span><span class="theme-toggle-name theme-toggle-light">&nbsp;Light theme</span><!--
--></li>
</ul>
</footer>
<!-- Work in progress... <!-- Work in progress...
<script src="/js/third-party/luxon.min.js"></script> <script src="/js/third-party/luxon.min.js"></script>

View File

@ -0,0 +1 @@
<script src="/js/theme.js"></script>

2
templates/resources.html Normal file
View File

@ -0,0 +1,2 @@
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<link rel="stylesheet" href="/css/styles.css">

10
url.go
View File

@ -2,16 +2,20 @@ package main
import ( import (
"errors" "errors"
"fmt"
"net/url" "net/url"
"strings" "strings"
"time" "time"
) )
// ErrMissingComponent is thrown when the URL has empty or missing components // ErrMissingComponent is thrown when the URL has empty or missing components
var ErrMissingComponent = errors.New("missing URL component") var ErrMissingComponent = errors.New("missing path component")
// ErrTooManyComponent is thrown when there are more than 2 components // ErrTooManyComponent is thrown when there are more than 2 components
var ErrTooManyComponent = errors.New("too many components") var ErrTooManyComponent = errors.New("too many path components")
// ErrInvalidTime is thrown in a time.ParseError
var ErrInvalidTime = errors.New("invalid ISO 8601 time")
var timeRFC3339NoSec = "2006-01-02T15:04Z07:00" var timeRFC3339NoSec = "2006-01-02T15:04Z07:00"
var timeFormats = []string{time.RFC3339, timeRFC3339NoSec} var timeFormats = []string{time.RFC3339, timeRFC3339NoSec}
@ -47,7 +51,7 @@ func ParseRequest(u *url.URL) (Request, error) {
} }
} }
if err != nil { if err != nil {
return Request{}, err return Request{}, fmt.Errorf("%w: %v", ErrInvalidTime, err)
} }
// Split zones // Split zones

View File

@ -25,7 +25,7 @@ func TestURLParse(t *testing.T) {
return return
} }
want := Request{ want := Request{
time.Date(2020, 6, 2, 14, 0, 0, 0, time.FixedZone("UTC +8", 8*60*60)), time.Date(2020, time.June, 2, 14, 0, 0, 0, time.FixedZone("UTC +8", 8*60*60)),
[]string{"Singapore", "Malaysia"}, []string{"Singapore", "Malaysia"},
} }
if !cmp.Equal(got, want) { if !cmp.Equal(got, want) {
@ -39,7 +39,7 @@ func TestURLParse(t *testing.T) {
return return
} }
want = Request{ want = Request{
time.Date(2019, 4, 30, 18, 0, 0, 0, time.FixedZone("UTC", 0)), time.Date(2019, time.April, 30, 18, 0, 0, 0, time.FixedZone("UTC", 0)),
[]string{"Nowhere"}, []string{"Nowhere"},
} }
if !cmp.Equal(got, want) { if !cmp.Equal(got, want) {
@ -74,15 +74,13 @@ func TestURLParseFail(t *testing.T) {
u = mustURLParse("http://test/2000-01-13T00:00Z08:00/hi") u = mustURLParse("http://test/2000-01-13T00:00Z08:00/hi")
_, err = ParseRequest(u) _, err = ParseRequest(u)
_, isParseError := err.(*time.ParseError) if !errors.Is(err, ErrInvalidTime) {
if !isParseError { t.Errorf("got error %v, want error %v", err, ErrInvalidTime)
t.Errorf("got error %v, want time.ParseError", err)
} }
u = mustURLParse("http://test/2000-01-13 00:00+08:00/hi") u = mustURLParse("http://test/2000-01-13 00:00+08:00/hi")
_, err = ParseRequest(u) _, err = ParseRequest(u)
_, isParseError = err.(*time.ParseError) if !errors.Is(err, ErrInvalidTime) {
if !isParseError { t.Errorf("got error %v, want error %v", err, ErrInvalidTime)
t.Errorf("got error %v, want time.ParseError", err)
} }
} }

73
zone.go
View File

@ -2,11 +2,13 @@ package main
import ( import (
"errors" "errors"
"fmt"
"regexp" "regexp"
"strconv" "strconv"
"time" "time"
"github.com/serverwentdown/datetime.link/data" "github.com/serverwentdown/datetime.link/data"
"go.uber.org/zap"
) )
// ErrZoneNotFound is thrown when a zone string has no match // ErrZoneNotFound is thrown when a zone string has no match
@ -15,6 +17,9 @@ var ErrZoneNotFound = errors.New("zone not found")
// ErrZoneOffsetInvalid is thrown when a zone string is an invalid zone offset // ErrZoneOffsetInvalid is thrown when a zone string is an invalid zone offset
var ErrZoneOffsetInvalid = errors.New("offset zone invalid") var ErrZoneOffsetInvalid = errors.New("offset zone invalid")
const zoneHour = 60 * 60
const zoneMinute = 60
var zoneOffsetRegexp = regexp.MustCompile(`^[+-][0-9]{2}:[0-9]{2}$`) var zoneOffsetRegexp = regexp.MustCompile(`^[+-][0-9]{2}:[0-9]{2}$`)
// SearchCities looks up a city by it's reference // SearchCities looks up a city by it's reference
@ -27,9 +32,9 @@ func SearchCities(cities map[string]*data.City, city string) (*data.City, error)
} }
// ParseZoneOffset parses a zone string into a time.Location // ParseZoneOffset parses a zone string into a time.Location
func ParseZoneOffset(zone string) (*time.Location, error) { func ParseZoneOffset(zone string) (int, error) {
if !zoneOffsetRegexp.MatchString(zone) { if !zoneOffsetRegexp.MatchString(zone) {
return nil, ErrZoneOffsetInvalid return 0, ErrZoneOffsetInvalid
} }
// Assume that if it satisfies the regex, it satisfies the length and won't // Assume that if it satisfies the regex, it satisfies the length and won't
// fail to parse // fail to parse
@ -44,9 +49,26 @@ func ParseZoneOffset(zone string) (*time.Location, error) {
// Allow hour offsets greater that 24 // Allow hour offsets greater that 24
m, _ := strconv.ParseUint(zone[1+3:1+3+2], 10, 64) m, _ := strconv.ParseUint(zone[1+3:1+3+2], 10, 64)
if m >= 60 { if m >= 60 {
return nil, ErrZoneOffsetInvalid return 0, ErrZoneOffsetInvalid
} }
return time.FixedZone("UTC"+zone, d*(int(h)*60*60+int(m)*60)), nil offset := d * (int(h)*zoneHour + int(m)*zoneMinute)
return offset, nil
}
// FormatZoneOffset formats an offset into a string
func FormatZoneOffset(offset int) string {
neg := offset < 0
s := '+'
if neg {
s = '-'
offset = -offset
}
if offset == 0 {
return "\u00B100:00"
}
h := offset / zoneHour
m := (offset % zoneHour) / zoneMinute
return fmt.Sprintf("%c%02d:%02d", s, h, m)
} }
// Zone represents any form of zone offset: this could be a city or a fixed // Zone represents any form of zone offset: this could be a city or a fixed
@ -58,13 +80,54 @@ type Zone struct {
Offset *time.Location Offset *time.Location
} }
// IsOffset is true when the Zone is an offset instead of a city
func (z Zone) IsOffset() bool {
return z.Offset != nil
}
// Name returns the name of the zone
func (z Zone) Name() string {
if z.IsOffset() {
return z.Offset.String()
} else if z.City != nil {
return z.City.FullName()
}
return ""
}
// Location returns the time.Location of the zone. Useful for other functions
func (z Zone) Location() *time.Location {
if z.IsOffset() {
return z.Offset
} else if z.City != nil {
loc, err := time.LoadLocation(z.City.Timezone)
if err != nil {
// This is a really bad situation
// TODO: validate this at boot time
l.Error("unable to find timezone", zap.String("timezone", z.City.Timezone))
}
return loc
}
return nil
}
// TimeOffset returns the timezone offset at a specific time
func (z Zone) TimeOffset(t time.Time) int {
if l := z.Location(); l != nil {
_, offset := t.In(l).Zone()
return offset
}
return -1 // TODO: better invalid handling
}
// ResolveZone resolves a zone string into a Zone // ResolveZone resolves a zone string into a Zone
func ResolveZone(cities map[string]*data.City, zone string) (Zone, error) { func ResolveZone(cities map[string]*data.City, zone string) (Zone, error) {
// Try parsing as a zone offset // Try parsing as a zone offset
offset, err := ParseZoneOffset(zone) offset, err := ParseZoneOffset(zone)
if err == nil { if err == nil {
offsetZone := time.FixedZone("UTC "+FormatZoneOffset(offset), offset)
return Zone{ return Zone{
Offset: offset, Offset: offsetZone,
}, nil }, nil
} }
// Parse as a city // Parse as a city

View File

@ -8,10 +8,51 @@ import (
) )
func TestParseZoneOffset(t *testing.T) { func TestParseZoneOffset(t *testing.T) {
loc, err := ParseZoneOffset("+08:00") offset, err := ParseZoneOffset("+08:00")
if err != nil { if err != nil {
t.Errorf("want error %v, got error %v", nil, err) t.Errorf("want error %v, got error %v", nil, err)
} else { } else {
want := 8*60*60 + 0*60
if offset != want {
t.Errorf("got %d, want %d", offset, want)
}
}
offset, err = ParseZoneOffset("-01:30")
if err != nil {
t.Errorf("want error %v, got error %v", nil, err)
} else {
want := -(1*60*60 + 30*60)
if offset != want {
t.Errorf("got %d, want %d", offset, want)
}
}
_, err = ParseZoneOffset("-0030")
if err != ErrZoneOffsetInvalid {
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
}
_, err = ParseZoneOffset("00:30")
if err != ErrZoneOffsetInvalid {
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
}
_, err = ParseZoneOffset("+08:60")
if err != ErrZoneOffsetInvalid {
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
}
_, err = ParseZoneOffset("+08:-6")
if err != ErrZoneOffsetInvalid {
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
}
offset, err = ParseZoneOffset("+08:00")
if err != nil {
t.Errorf("want error %v, got error %v", nil, err)
} else {
loc := time.FixedZone("UTC "+FormatZoneOffset(offset), offset)
time := time.Date(2020, time.November, 8, 23, 9, 0, 0, loc).Unix() time := time.Date(2020, time.November, 8, 23, 9, 0, 0, loc).Unix()
want := int64(1604848140) want := int64(1604848140)
if time != want { if time != want {
@ -19,36 +60,17 @@ func TestParseZoneOffset(t *testing.T) {
} }
} }
loc, err = ParseZoneOffset("-00:30") offset, err = ParseZoneOffset("-00:30")
if err != nil { if err != nil {
t.Errorf("want error %v, got error %v", nil, err) t.Errorf("want error %v, got error %v", nil, err)
} else { } else {
loc := time.FixedZone("UTC "+FormatZoneOffset(offset), offset)
time := time.Date(2020, time.November, 8, 14, 39, 0, 0, loc).Unix() time := time.Date(2020, time.November, 8, 14, 39, 0, 0, loc).Unix()
want := int64(1604848140) want := int64(1604848140)
if time != want { if time != want {
t.Errorf("got %d, want %d", time, want) t.Errorf("got %d, want %d", time, want)
} }
} }
loc, err = ParseZoneOffset("-0030")
if err != ErrZoneOffsetInvalid {
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
}
loc, err = ParseZoneOffset("00:30")
if err != ErrZoneOffsetInvalid {
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
}
loc, err = ParseZoneOffset("+08:60")
if err != ErrZoneOffsetInvalid {
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
}
loc, err = ParseZoneOffset("+08:-6")
if err != ErrZoneOffsetInvalid {
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
}
} }
func TestSearchCities(t *testing.T) { func TestSearchCities(t *testing.T) {
@ -109,9 +131,10 @@ func TestResolveZone(t *testing.T) {
if zone.City != nil { if zone.City != nil {
t.Errorf("want City %v, got City %v", nil, zone.City) t.Errorf("want City %v, got City %v", nil, zone.City)
} }
wantOffset, _ := ParseZoneOffset("+04:00") gotTime := time.Date(2020, time.January, 1, 0, 0, 0, 0, zone.Offset)
if zone.Offset.String() != wantOffset.String() { wantTime := time.Date(2019, time.December, 31, 20, 0, 0, 0, time.UTC)
t.Errorf("want Offset %v, got Offset %v", wantOffset, zone.Offset) if !gotTime.Equal(wantTime) {
t.Errorf("want time %v, got time %v", wantTime, gotTime)
} }
zone, err = ResolveZone(cities, "+04:80") zone, err = ResolveZone(cities, "+04:80")