Big change
- Reorganised request template handling and error handling - Added Content-Type response - Implemented templating data - Break out routermain
parent
b18b3c0035
commit
5e9d1ab6cb
2
Makefile
2
Makefile
|
@ -16,7 +16,7 @@ clean:
|
|||
.PHONY: build
|
||||
build: datetime
|
||||
|
||||
datetime: *.go
|
||||
datetime: *.go data/*.go
|
||||
$(GO) build -tags "$(TAGS)" -v -o datetime
|
||||
|
||||
.PHONY: test
|
||||
|
|
55
app.go
55
app.go
|
@ -1,6 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
|
@ -8,6 +9,9 @@ import (
|
|||
"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
|
||||
type Datetime struct {
|
||||
*http.ServeMux
|
||||
|
@ -19,7 +23,7 @@ type Datetime struct {
|
|||
// like templates and data exist
|
||||
func NewDatetime() (*Datetime, error) {
|
||||
// Data
|
||||
tmpl, err := template.ParseGlob("templates/*")
|
||||
tmpl, err := template.New("templates").Funcs(templateFuncs).ParseGlob("templates/*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -42,6 +46,11 @@ func NewDatetime() (*Datetime, error) {
|
|||
return app, nil
|
||||
}
|
||||
|
||||
type appRequest struct {
|
||||
App Datetime
|
||||
Req Request
|
||||
}
|
||||
|
||||
// index handles all incoming page requests
|
||||
func (app Datetime) index(w http.ResponseWriter, req *http.Request) {
|
||||
var err error
|
||||
|
@ -51,45 +60,29 @@ func (app Datetime) index(w http.ResponseWriter, req *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
accept := req.Header.Get("Accept")
|
||||
tmpl, acceptable := app.chooseTemplate(accept)
|
||||
if !acceptable {
|
||||
w.WriteHeader(http.StatusNotAcceptable)
|
||||
return
|
||||
}
|
||||
tmpl := app.loadTemplate("index", w, req)
|
||||
if tmpl == nil {
|
||||
l.Error("unable to find template", zap.String("accept", accept))
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
|
||||
l.Debug("", zap.Reflect("url", req.URL))
|
||||
err = tmpl.Execute(w, nil)
|
||||
request := Request{}
|
||||
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 {
|
||||
l.Error("templating failed", zap.Error(err))
|
||||
w.WriteHeader(http.StatusInternalServerError) // Usually this will fail
|
||||
app.templateError(HTTPError{http.StatusInternalServerError, err}, w, req)
|
||||
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
|
||||
}
|
||||
|
|
22
app_test.go
22
app_test.go
|
@ -7,27 +7,31 @@ import (
|
|||
)
|
||||
|
||||
func TestChooseTemplate(t *testing.T) {
|
||||
tmpl, err := template.ParseGlob("templates/*")
|
||||
tmpl, err := template.New("templates").Funcs(templateFuncs).ParseGlob("templates/*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
app := &Datetime{tmpl: tmpl}
|
||||
|
||||
type chooseTest struct {
|
||||
accept string
|
||||
acceptable bool
|
||||
template string
|
||||
accept string
|
||||
acceptable bool
|
||||
contentType string
|
||||
template string
|
||||
}
|
||||
tests := []chooseTest{
|
||||
{"text/html", true, "index.html"},
|
||||
{"text/html;q=0.9,text/plain", true, "index.txt"},
|
||||
{"image/png", false, ""},
|
||||
{"*/*", true, "index.txt"},
|
||||
{"text/html", true, "text/html", "index.html"},
|
||||
{"text/html;q=0.9,text/plain", true, "text/plain", "index.txt"},
|
||||
{"image/png", false, "", ""},
|
||||
{"*/*", true, "text/plain", "index.txt"},
|
||||
}
|
||||
|
||||
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)
|
||||
if contentType != test.contentType {
|
||||
t.Errorf("%s; contentType = %v; wanted %v", fn, contentType, test.contentType)
|
||||
}
|
||||
if acceptable != test.acceptable {
|
||||
t.Errorf("%s; acceptable = %v; wanted %v", fn, acceptable, test.acceptable)
|
||||
}
|
||||
|
|
13
data/data.go
13
data/data.go
|
@ -3,6 +3,7 @@ package data
|
|||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
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
|
||||
type City struct {
|
||||
// Ref is the ASCII name of the city
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
}
|
|
@ -4,8 +4,11 @@ package main
|
|||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
func zapConfig() zap.Config {
|
||||
return zap.NewDevelopmentConfig()
|
||||
dev := zap.NewDevelopmentConfig()
|
||||
dev.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||
return dev
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ import (
|
|||
"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 {
|
||||
return strings.Join(refs, "-")
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
{{.Status}} {{.Status | statusText | thisIsSafe}}
|
||||
{{.Error | thisIsSafe}}
|
|
@ -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"> System theme</span><!--
|
||||
--><span class="icon theme-toggle-dark">{{template "icon_solid_moon.svg"}}</span><span class="theme-toggle-name theme-toggle-dark"> Dark theme</span><!--
|
||||
--><span class="icon theme-toggle-light">{{template "icon_solid_sun.svg"}}</span><span class="theme-toggle-name theme-toggle-light"> Light theme</span><!--
|
||||
--></li>
|
||||
</ul>
|
||||
</footer>
|
|
@ -4,13 +4,38 @@
|
|||
<meta charset="utf-8">
|
||||
<title>datetime.link</title>
|
||||
<meta name="viewport" content="width=device-width">
|
||||
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
|
||||
{{template "resources.html"}}
|
||||
</head>
|
||||
<body>
|
||||
<script src="/js/theme.js"></script>
|
||||
{{template "resources-body.html"}}
|
||||
|
||||
<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-zoneinfo>
|
||||
|
@ -45,17 +70,7 @@
|
|||
-->
|
||||
</main>
|
||||
|
||||
<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"> System theme</span><!--
|
||||
--><span class="icon theme-toggle-dark">{{template "icon_solid_moon.svg"}}</span><span class="theme-toggle-name theme-toggle-dark"> Dark theme</span><!--
|
||||
--><span class="icon theme-toggle-light">{{template "icon_solid_sun.svg"}}</span><span class="theme-toggle-name theme-toggle-light"> Light theme</span><!--
|
||||
--></li>
|
||||
</ul>
|
||||
</footer>
|
||||
{{template "footer.html"}}
|
||||
|
||||
<!-- Work in progress...
|
||||
<script src="/js/third-party/luxon.min.js"></script>
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<script src="/js/theme.js"></script>
|
|
@ -0,0 +1,2 @@
|
|||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
10
url.go
10
url.go
|
@ -2,16 +2,20 @@ package main
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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
|
||||
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 timeFormats = []string{time.RFC3339, timeRFC3339NoSec}
|
||||
|
@ -47,7 +51,7 @@ func ParseRequest(u *url.URL) (Request, error) {
|
|||
}
|
||||
}
|
||||
if err != nil {
|
||||
return Request{}, err
|
||||
return Request{}, fmt.Errorf("%w: %v", ErrInvalidTime, err)
|
||||
}
|
||||
|
||||
// Split zones
|
||||
|
|
14
url_test.go
14
url_test.go
|
@ -25,7 +25,7 @@ func TestURLParse(t *testing.T) {
|
|||
return
|
||||
}
|
||||
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"},
|
||||
}
|
||||
if !cmp.Equal(got, want) {
|
||||
|
@ -39,7 +39,7 @@ func TestURLParse(t *testing.T) {
|
|||
return
|
||||
}
|
||||
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"},
|
||||
}
|
||||
if !cmp.Equal(got, want) {
|
||||
|
@ -74,15 +74,13 @@ func TestURLParseFail(t *testing.T) {
|
|||
|
||||
u = mustURLParse("http://test/2000-01-13T00:00Z08:00/hi")
|
||||
_, err = ParseRequest(u)
|
||||
_, isParseError := err.(*time.ParseError)
|
||||
if !isParseError {
|
||||
t.Errorf("got error %v, want time.ParseError", err)
|
||||
if !errors.Is(err, ErrInvalidTime) {
|
||||
t.Errorf("got error %v, want error %v", err, ErrInvalidTime)
|
||||
}
|
||||
|
||||
u = mustURLParse("http://test/2000-01-13 00:00+08:00/hi")
|
||||
_, err = ParseRequest(u)
|
||||
_, isParseError = err.(*time.ParseError)
|
||||
if !isParseError {
|
||||
t.Errorf("got error %v, want time.ParseError", err)
|
||||
if !errors.Is(err, ErrInvalidTime) {
|
||||
t.Errorf("got error %v, want error %v", err, ErrInvalidTime)
|
||||
}
|
||||
}
|
||||
|
|
73
zone.go
73
zone.go
|
@ -2,11 +2,13 @@ package main
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/serverwentdown/datetime.link/data"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// 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
|
||||
var ErrZoneOffsetInvalid = errors.New("offset zone invalid")
|
||||
|
||||
const zoneHour = 60 * 60
|
||||
const zoneMinute = 60
|
||||
|
||||
var zoneOffsetRegexp = regexp.MustCompile(`^[+-][0-9]{2}:[0-9]{2}$`)
|
||||
|
||||
// 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
|
||||
func ParseZoneOffset(zone string) (*time.Location, error) {
|
||||
func ParseZoneOffset(zone string) (int, error) {
|
||||
if !zoneOffsetRegexp.MatchString(zone) {
|
||||
return nil, ErrZoneOffsetInvalid
|
||||
return 0, ErrZoneOffsetInvalid
|
||||
}
|
||||
// Assume that if it satisfies the regex, it satisfies the length and won't
|
||||
// fail to parse
|
||||
|
@ -44,9 +49,26 @@ func ParseZoneOffset(zone string) (*time.Location, error) {
|
|||
// Allow hour offsets greater that 24
|
||||
m, _ := strconv.ParseUint(zone[1+3:1+3+2], 10, 64)
|
||||
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
|
||||
|
@ -58,13 +80,54 @@ type Zone struct {
|
|||
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
|
||||
func ResolveZone(cities map[string]*data.City, zone string) (Zone, error) {
|
||||
// Try parsing as a zone offset
|
||||
offset, err := ParseZoneOffset(zone)
|
||||
if err == nil {
|
||||
offsetZone := time.FixedZone("UTC "+FormatZoneOffset(offset), offset)
|
||||
return Zone{
|
||||
Offset: offset,
|
||||
Offset: offsetZone,
|
||||
}, nil
|
||||
}
|
||||
// Parse as a city
|
||||
|
|
73
zone_test.go
73
zone_test.go
|
@ -8,10 +8,51 @@ import (
|
|||
)
|
||||
|
||||
func TestParseZoneOffset(t *testing.T) {
|
||||
loc, err := ParseZoneOffset("+08:00")
|
||||
offset, err := ParseZoneOffset("+08:00")
|
||||
if err != nil {
|
||||
t.Errorf("want error %v, got error %v", nil, err)
|
||||
} 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()
|
||||
want := int64(1604848140)
|
||||
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 {
|
||||
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, 14, 39, 0, 0, loc).Unix()
|
||||
want := int64(1604848140)
|
||||
if 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) {
|
||||
|
@ -109,9 +131,10 @@ func TestResolveZone(t *testing.T) {
|
|||
if zone.City != nil {
|
||||
t.Errorf("want City %v, got City %v", nil, zone.City)
|
||||
}
|
||||
wantOffset, _ := ParseZoneOffset("+04:00")
|
||||
if zone.Offset.String() != wantOffset.String() {
|
||||
t.Errorf("want Offset %v, got Offset %v", wantOffset, zone.Offset)
|
||||
gotTime := time.Date(2020, time.January, 1, 0, 0, 0, 0, zone.Offset)
|
||||
wantTime := time.Date(2019, time.December, 31, 20, 0, 0, 0, time.UTC)
|
||||
if !gotTime.Equal(wantTime) {
|
||||
t.Errorf("want time %v, got time %v", wantTime, gotTime)
|
||||
}
|
||||
|
||||
zone, err = ResolveZone(cities, "+04:80")
|
||||
|
|
Loading…
Reference in New Issue