diff --git a/Makefile b/Makefile index d2c4d95..528b5ab 100644 --- a/Makefile +++ b/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 diff --git a/app.go b/app.go index 47bdc68..c21b4d7 100644 --- a/app.go +++ b/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 -} diff --git a/app_test.go b/app_test.go index e53cf3e..dd14ff7 100644 --- a/app_test.go +++ b/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) } diff --git a/data/data.go b/data/data.go index d9a6856..6f9d555 100644 --- a/data/data.go +++ b/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) +} diff --git a/data/types.go b/data/types.go index 0ea4fb9..6e7fdea 100644 --- a/data/types.go +++ b/data/types.go @@ -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 diff --git a/error.go b/error.go new file mode 100644 index 0000000..32d7f51 --- /dev/null +++ b/error.go @@ -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))) + } +} diff --git a/logger_development.go b/logger_development.go index ae63f5c..1845f88 100644 --- a/logger_development.go +++ b/logger_development.go @@ -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 } diff --git a/scripts/data.go b/scripts/data.go index fadfb2e..b344e07 100644 --- a/scripts/data.go +++ b/scripts/data.go @@ -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, "-") diff --git a/template.go b/template.go new file mode 100644 index 0000000..29669aa --- /dev/null +++ b/template.go @@ -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 +} diff --git a/template_test.go b/template_test.go new file mode 100644 index 0000000..b606c7c --- /dev/null +++ b/template_test.go @@ -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) + } +} diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..ce79935 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,25 @@ + + + + + {{.Status | statusText}} - datetime.link + + + {{template "resources.html"}} + + + {{template "resources-body.html"}} + +
+

{{.Status | statusText}}

+ {{if eq .Status 400}} +

The URL path is not valid

+ {{else}} +

{{.Error.Error}}

+ {{end}} +
+ + {{template "footer.html"}} + + + diff --git a/templates/error.txt b/templates/error.txt new file mode 100644 index 0000000..db5c63c --- /dev/null +++ b/templates/error.txt @@ -0,0 +1,2 @@ +{{.Status}} {{.Status | statusText | thisIsSafe}} +{{.Error | thisIsSafe}} diff --git a/templates/footer.html b/templates/footer.html new file mode 100644 index 0000000..26a6673 --- /dev/null +++ b/templates/footer.html @@ -0,0 +1,11 @@ + diff --git a/templates/index.html b/templates/index.html index 3f0ba27..5387887 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4,13 +4,38 @@ datetime.link - - - + + {{template "resources.html"}} - + {{template "resources-body.html"}} +
+ {{$app := .App}} + {{$t := .Req.Time}} + {{range .Req.Zones}} + + {{with resolveZone $app .}} + {{if .Error}} + + Unable to load zone. The URL path might not be valid + + {{else}} + {{$zt := $t.In .Location}} + + {{.Name}} + {{if not .IsOffset}} + {{.TimeOffset $t | formatOffset}} + {{end}} + {{$zt.Format "2006-01-02"}} + + + {{$zt.Format "15:04"}} + + {{end}} + {{end}} + + {{end}}
- + {{template "footer.html"}}