commit 2cb1bf18814332f0a7e44106adefcba5350981f8 Author: William Petit Date: Thu Dec 6 15:18:05 2018 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0dd639e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/coverage \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c8340e5 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +test: + go clean -testcache + go test -v ./... + +deps: + go get -u golang.org/x/tools/cmd/godoc + go get -u github.com/cortesi/modd/cmd/modd + go get -u github.com/golangci/golangci-lint/cmd/golangci-lint + +coverage: + @script/coverage + +lint: + golangci-lint run --tests=false --enable-all + +clean: + rm -rf ./bin ./release ./coverage + +doc: + @echo "open your browser to http://localhost:6060/pkg/forge.cadoles.com/wpetit/goweb to see the documentation" + godoc -http=:6060 + + +.PHONY: test clean lint coverage doc \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1e8b65 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# goweb + +Librairie d'aide à la création d'applications web pour Go + +**Librairie en cours de développement.** Des changements d'API peuvent intervenir à tout moment. + +## Documentation + +https://godoc.org/forge.cadoles.com/wpetit/goweb + +## License + +AGPL-3.0 \ No newline at end of file diff --git a/middleware/debug.go b/middleware/debug.go new file mode 100644 index 0000000..7e89bfc --- /dev/null +++ b/middleware/debug.go @@ -0,0 +1,32 @@ +package middleware + +import ( + "context" + "errors" + + "github.com/go-chi/chi/middleware" +) + +const ( + // KeyDebug is the context key associated with the debug value + KeyDebug ContextKey = "debug" +) + +// ErrInvalidDebug is returned when no debug value +// could be found on the given context +var ErrInvalidDebug = errors.New("invalid debug") + +// GetDebug retrieves the debug value from the given context +func GetDebug(ctx context.Context) (bool, error) { + debug, ok := ctx.Value(KeyDebug).(bool) + if !ok { + return false, ErrInvalidDebug + } + return debug, nil +} + +// Debug expose the given debug flag as a context value +// on the HTTP requests +func Debug(debug bool) Middleware { + return middleware.WithValue(KeyDebug, debug) +} diff --git a/middleware/debug_test.go b/middleware/debug_test.go new file mode 100644 index 0000000..ae09718 --- /dev/null +++ b/middleware/debug_test.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "context" + "testing" +) + +func TestContextDebug(t *testing.T) { + + debug := false + ctx := context.WithValue(context.Background(), KeyDebug, debug) + + dbg, err := GetDebug(ctx) + if err != nil { + t.Fatal(err) + } + + if dbg { + t.Fatal("debug should be false") + } + +} diff --git a/middleware/invalid_host.go b/middleware/invalid_host.go new file mode 100644 index 0000000..fba05ba --- /dev/null +++ b/middleware/invalid_host.go @@ -0,0 +1,28 @@ +package middleware + +import "net/http" + +// InvalidHostRedirect returns a middleware that redirects incoming +// requests that do not use the expected host/schemes +func InvalidHostRedirect(expectedHost string, useTLS bool) Middleware { + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + invalidScheme := (useTLS && r.TLS == nil) || (!useTLS && r.TLS != nil) + invalidHost := expectedHost != r.Host + if invalidHost || invalidScheme { + if expectedHost != "" { + r.URL.Host = expectedHost + } + if useTLS && r.TLS == nil { + r.URL.Scheme = "https" + } else if !useTLS && r.TLS != nil { + r.URL.Scheme = "http" + } + http.Redirect(w, r, r.URL.String(), http.StatusTemporaryRedirect) + return + } + next.ServeHTTP(w, r) + } + return http.HandlerFunc(fn) + } +} diff --git a/middleware/service_container.go b/middleware/service_container.go new file mode 100644 index 0000000..07623b7 --- /dev/null +++ b/middleware/service_container.go @@ -0,0 +1,33 @@ +package middleware + +import ( + "context" + "errors" + + "forge.cadoles.com/wpetit/goweb/service" + "github.com/go-chi/chi/middleware" +) + +const ( + // KeyServiceContainer is the context key associated with the ServiceContainer value + KeyServiceContainer ContextKey = "serviceContainer" +) + +// ErrInvalidServiceContainer is returned when no service container +// could be found on the given context +var ErrInvalidServiceContainer = errors.New("invalid service container") + +// GetServiceContainer retrieves the service container from the given context +func GetServiceContainer(ctx context.Context) (*service.Container, error) { + container, ok := ctx.Value(KeyServiceContainer).(*service.Container) + if !ok { + return nil, ErrInvalidServiceContainer + } + return container, nil +} + +// ServiceContainer expose the given service container as a context value +// on the HTTP requests +func ServiceContainer(container *service.Container) Middleware { + return middleware.WithValue(KeyServiceContainer, container) +} diff --git a/middleware/service_container_test.go b/middleware/service_container_test.go new file mode 100644 index 0000000..9c92cf3 --- /dev/null +++ b/middleware/service_container_test.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "context" + "testing" + + "forge.cadoles.com/wpetit/goweb/service" +) + +func TestContextServiceContainer(t *testing.T) { + + container := service.NewContainer() + ctx := context.WithValue(context.Background(), KeyServiceContainer, container) + + ctn, err := GetServiceContainer(ctx) + if err != nil { + t.Fatal(err) + } + + if ctn == nil { + t.Fatal("container should not be nil") + } + +} + +func TestContextInvalidServiceContainer(t *testing.T) { + + invalidContainer := struct{}{} + ctx := context.WithValue(context.Background(), KeyServiceContainer, invalidContainer) + + container, err := GetServiceContainer(ctx) + + if g, e := err, ErrInvalidServiceContainer; g != e { + t.Errorf("err: got '%v', expected '%v'", g, e) + } + + if container != nil { + t.Errorf("container: got '%v', expected '%v'", container, nil) + } + +} diff --git a/middleware/type.go b/middleware/type.go new file mode 100644 index 0000000..f753ace --- /dev/null +++ b/middleware/type.go @@ -0,0 +1,9 @@ +package middleware + +import "net/http" + +// ContextKey are values exposed on the request context +type ContextKey string + +// Middleware An HTTP middleware +type Middleware func(http.Handler) http.Handler diff --git a/script/coverage b/script/coverage new file mode 100755 index 0000000..4d6b61a --- /dev/null +++ b/script/coverage @@ -0,0 +1,25 @@ +#!/bin/bash + +set -eo pipefail + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" +GO_PACKAGE_DIRS=$(find . -not -path './vendor/*' -name '*.go' -printf '%h\n' | uniq) +COVERAGE_DIR="$DIR/../coverage" + +rm -rf "$COVERAGE_DIR" +mkdir -p "$COVERAGE_DIR" + +for dir in $GO_PACKAGE_DIRS; do + pushd $dir > /dev/null + go test -coverprofile="$DIR/../coverage/${dir//\//_}.out" > /dev/null + popd > /dev/null +done + +OUTPUT_FILES=$(find "$COVERAGE_DIR" -name '*.out') +for out in $OUTPUT_FILES; do + package=$(realpath --relative-to="$DIR/../coverage" "${out}" | sed -e 's/\.out$//' -e 's/^\._//') + echo "-- Package '${package//_/\/}' --" + echo + go tool cover -func="$out" + echo +done \ No newline at end of file diff --git a/service/container.go b/service/container.go new file mode 100644 index 0000000..719da08 --- /dev/null +++ b/service/container.go @@ -0,0 +1,43 @@ +package service + +import ( + "errors" +) + +var ( + // ErrNotImplemented is the error when a service is accessed + // and no implementation was provided + ErrNotImplemented = errors.New("service not implemented") +) + +// Provider is a provider for a service +type Provider func(ctn *Container) (interface{}, error) + +// Container is a simple service container for dependency injection +type Container struct { + providers map[Name]Provider +} + +// Name is a name of a service +type Name string + +// Provide registers a provider for the survey.Service +func (c *Container) Provide(name Name, provider Provider) { + c.providers[name] = provider +} + +// Service retrieves a service implementation based on its name +func (c *Container) Service(name Name) (interface{}, error) { + provider, exists := c.providers[name] + if !exists { + return nil, ErrNotImplemented + } + return provider(c) +} + +// NewContainer returns a new empty service container +func NewContainer() *Container { + return &Container{ + providers: make(map[Name]Provider), + } +} diff --git a/service/session/flash.go b/service/session/flash.go new file mode 100644 index 0000000..20566db --- /dev/null +++ b/service/session/flash.go @@ -0,0 +1,43 @@ +package session + +const ( + // FlashError defines an "error" flash message + FlashError FlashType = "error" + // FlashWarn defines an "warning" flash message + FlashWarn FlashType = "warn" + // FlashSuccess defines an "success" flash message + FlashSuccess FlashType = "success" + // FlashInfo defines an "info" flash message + FlashInfo FlashType = "info" +) + +// FlashType defines the type of a flash message +type FlashType string + +// Flash is a ephemeral message that lives in a session +// until it's read +type Flash interface { + Type() FlashType + Message() string +} + +// BaseFlash is a base implementation of a flash message +type BaseFlash struct { + flashType FlashType + message string +} + +// Type returns the type of the flash +func (f *BaseFlash) Type() FlashType { + return f.flashType +} + +// Message returns the message of the flash +func (f *BaseFlash) Message() string { + return f.message +} + +// NewBaseFlash returns a new BaseFlash +func NewBaseFlash(flashType FlashType, message string) *BaseFlash { + return &BaseFlash{flashType, message} +} diff --git a/service/session/service.go b/service/session/service.go new file mode 100644 index 0000000..bdfd079 --- /dev/null +++ b/service/session/service.go @@ -0,0 +1,38 @@ +package session + +import ( + "net/http" + + "forge.cadoles.com/wpetit/goweb/service" + "github.com/pkg/errors" +) + +// ServiceName defines the Session service name +const ServiceName service.Name = "session" + +// Service defines the API of a "http session" service +type Service interface { + Get(http.ResponseWriter, *http.Request) (Session, error) +} + +// From retrieves the session service in the given container +func From(container *service.Container) (Service, error) { + service, err := container.Service(ServiceName) + if err != nil { + return nil, errors.Wrapf(err, "error while retrieving '%s' service", ServiceName) + } + sessionService, ok := service.(Service) + if !ok { + return nil, errors.Errorf("retrieved service is not a valid '%s' service", ServiceName) + } + return sessionService, nil +} + +// Must retrieves the session service in the given container or panic otherwise +func Must(container *service.Container) Service { + service, err := From(container) + if err != nil { + panic(err) + } + return service +} diff --git a/service/session/session.go b/service/session/session.go new file mode 100644 index 0000000..2e3b767 --- /dev/null +++ b/service/session/session.go @@ -0,0 +1,16 @@ +package session + +import ( + "net/http" +) + +// Session defines the API of a Session +type Session interface { + Set(string, interface{}) + Unset(string) + Get(string) interface{} + AddFlash(flashType FlashType, message string) + Flashes(flashTypes ...FlashType) []Flash + Save(http.ResponseWriter, *http.Request) error + Delete(http.ResponseWriter, *http.Request) error +} diff --git a/service/template/data.go b/service/template/data.go new file mode 100644 index 0000000..45ae18b --- /dev/null +++ b/service/template/data.go @@ -0,0 +1,19 @@ +package template + +// Data is some data to inject into the template +type Data map[string]interface{} + +// DataExtFunc is some extensions to a template's data +type DataExtFunc func(data Data) (Data, error) + +// Extend returns a template's data with the given extensions +func Extend(data Data, extensions ...DataExtFunc) (Data, error) { + var err error + for _, ext := range extensions { + data, err = ext(data) + if err != nil { + return nil, err + } + } + return data, nil +} diff --git a/service/template/service.go b/service/template/service.go new file mode 100644 index 0000000..e8bffe4 --- /dev/null +++ b/service/template/service.go @@ -0,0 +1,38 @@ +package template + +import ( + "net/http" + + "forge.cadoles.com/wpetit/goweb/service" + "github.com/pkg/errors" +) + +// ServiceName defines the Tempate service name +const ServiceName service.Name = "template" + +// Service is a templating service +type Service interface { + RenderPage(w http.ResponseWriter, templateName string, data interface{}) error +} + +// From retrieves the template service in the given container +func From(container *service.Container) (Service, error) { + service, err := container.Service(ServiceName) + if err != nil { + return nil, errors.Wrapf(err, "error while retrieving '%s' service", ServiceName) + } + templateService, ok := service.(Service) + if !ok { + return nil, errors.Errorf("retrieved service is not a valid '%s' service", ServiceName) + } + return templateService, nil +} + +// Must retrieves the template service in the given container or panic otherwise +func Must(container *service.Container) Service { + service, err := From(container) + if err != nil { + panic(err) + } + return service +} diff --git a/session/gorilla/service.go b/session/gorilla/service.go new file mode 100644 index 0000000..496525b --- /dev/null +++ b/session/gorilla/service.go @@ -0,0 +1,52 @@ +package gorilla + +import ( + "net/http" + + "github.com/gorilla/securecookie" + + "forge.cadoles.com/wpetit/goweb/service/session" + "github.com/gorilla/sessions" + "github.com/pkg/errors" +) + +// SessionService is an implementation of service.Session +// based on the github.com/gorilla/sessions +type SessionService struct { + sessionName string + store sessions.Store + defaultOptions *sessions.Options +} + +// Get returns a Session associated with the given HTTP request +func (s *SessionService) Get(w http.ResponseWriter, r *http.Request) (session.Session, error) { + sess, err := s.store.Get(r, s.sessionName) + if err != nil { + multiErr, ok := err.(securecookie.MultiError) + if !ok || multiErr.Error() != securecookie.ErrMacInvalid.Error() { + return nil, errors.Wrap(err, "error while retrieving the session from the request") + } + } + if err != nil { + defaultOptions := s.defaultOptionsCopy() + sess.Options = &defaultOptions + if err := sess.Save(r, w); err != nil { + return nil, errors.Wrap(err, "error while saving session") + } + } + return NewSession(sess), nil +} + +func (s *SessionService) defaultOptionsCopy() sessions.Options { + return *s.defaultOptions +} + +// NewSessionService returns a new SessionService backed +// by the given Store +func NewSessionService(sessionName string, store sessions.Store, defaultOptions *sessions.Options) *SessionService { + return &SessionService{ + sessionName: sessionName, + store: store, + defaultOptions: defaultOptions, + } +} diff --git a/session/gorilla/session.go b/session/gorilla/session.go new file mode 100644 index 0000000..c986dd8 --- /dev/null +++ b/session/gorilla/session.go @@ -0,0 +1,70 @@ +package gorilla + +import ( + "fmt" + "net/http" + + "forge.cadoles.com/wpetit/goweb/service/session" + "github.com/gorilla/sessions" +) + +// Session is an implementation of the session.Session service +// backed by github.com/gorilla/sessions Session +type Session struct { + sess *sessions.Session +} + +// Get retrieve a value from the session +func (s *Session) Get(key string) interface{} { + return s.sess.Values[key] +} + +// Set updates a value in the session +func (s *Session) Set(key string, value interface{}) { + s.sess.Values[key] = value +} + +// Unset remove a value in the session +func (s *Session) Unset(key string) { + delete(s.sess.Values, key) +} + +// AddFlash adds the given flash message to the stack +func (s *Session) AddFlash(flashType session.FlashType, message string) { + s.sess.AddFlash(message, s.flashKey(flashType)) +} + +// Flashes retrieves the flash messages of the given types in the stack +func (s *Session) Flashes(flashTypes ...session.FlashType) []session.Flash { + flashes := make([]session.Flash, 0) + for _, ft := range flashTypes { + rawFlashes := s.sess.Flashes(s.flashKey(ft)) + for _, f := range rawFlashes { + message := fmt.Sprintf("%v", f) + flashes = append(flashes, session.NewBaseFlash(ft, message)) + } + } + return flashes +} + +func (s *Session) flashKey(flashType session.FlashType) string { + return fmt.Sprintf("_%s_flash", flashType) +} + +// Save saves the session with its current values +func (s *Session) Save(w http.ResponseWriter, r *http.Request) error { + return s.sess.Save(r, w) +} + +// Delete deletes the session +func (s *Session) Delete(w http.ResponseWriter, r *http.Request) error { + s.sess.Options = &sessions.Options{ + MaxAge: -1, + } + return s.sess.Save(r, w) +} + +// NewSession returns a new Session +func NewSession(sess *sessions.Session) *Session { + return &Session{sess} +} diff --git a/static/static.go b/static/static.go new file mode 100644 index 0000000..8ba8a17 --- /dev/null +++ b/static/static.go @@ -0,0 +1,22 @@ +package static + +import ( + "net/http" + "os" +) + +// Dir serves the files in the given directory or +// uses the given handler to handles missing files +func Dir(dirPath string, stripPrefix string, notFoundHandler http.Handler) http.Handler { + root := http.Dir(dirPath) + fs := http.FileServer(root) + fn := func(w http.ResponseWriter, r *http.Request) { + if _, err := os.Stat(dirPath + r.RequestURI); os.IsNotExist(err) { + notFoundHandler.ServeHTTP(w, r) + } else { + fs.ServeHTTP(w, r) + } + } + handler := http.StripPrefix(stripPrefix, http.HandlerFunc(fn)) + return handler +} diff --git a/template/html/data.go b/template/html/data.go new file mode 100644 index 0000000..f0d835d --- /dev/null +++ b/template/html/data.go @@ -0,0 +1,40 @@ +package html + +import ( + "net/http" + + "forge.cadoles.com/wpetit/goweb/service" + "forge.cadoles.com/wpetit/goweb/service/session" + "forge.cadoles.com/wpetit/goweb/service/template" + "github.com/pkg/errors" +) + +// WithFlashes extends the template's data with session's flash messages +func WithFlashes(w http.ResponseWriter, r *http.Request, container *service.Container) template.DataExtFunc { + return func(data template.Data) (template.Data, error) { + + sessionService, err := session.From(container) + if err != nil { + return nil, errors.Wrap(err, "error while retrieving session service") + } + + sess, err := sessionService.Get(w, r) + if err != nil { + return nil, errors.Wrap(err, "error while retrieving session") + } + + flashes := sess.Flashes( + session.FlashError, session.FlashWarn, + session.FlashSuccess, session.FlashInfo, + ) + + data["Flashes"] = flashes + + if err := sess.Save(w, r); err != nil { + return nil, errors.Wrap(err, "error while saving session") + } + + return data, nil + + } +} diff --git a/template/html/helper.go b/template/html/helper.go new file mode 100644 index 0000000..e762862 --- /dev/null +++ b/template/html/helper.go @@ -0,0 +1,71 @@ +package html + +import ( + "encoding/json" + "errors" + "reflect" + "unicode/utf8" +) + +func dump(args ...interface{}) (string, error) { + out := "" + for _, a := range args { + str, err := json.MarshalIndent(a, "", " ") + if err != nil { + return "", err + } + out += string(str) + "\n" + } + return out, nil +} + +func increment(value int) int { + return value + 1 +} + +func ellipsis(str string, max int) string { + if utf8.RuneCountInString(str) > max { + return string([]rune(str)[0:(max-3)]) + "..." + } + return str +} + +func customMap(values ...interface{}) (map[string]interface{}, error) { + if len(values)%2 != 0 { + return nil, errors.New("invalid custoMmap call") + } + customMap := make(map[string]interface{}, len(values)/2) + for i := 0; i < len(values); i += 2 { + key, ok := values[i].(string) + if !ok { + return nil, errors.New("map keys must be strings") + } + customMap[key] = values[i+1] + } + return customMap, nil +} + +func defaultVal(src interface{}, defaultValue interface{}) interface{} { + switch typ := src.(type) { + case string: + if typ == "" { + return defaultValue + } + default: + if src == nil { + return defaultValue + } + } + return src +} + +func has(v interface{}, name string) bool { + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Ptr { + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return false + } + return rv.FieldByName(name).IsValid() +} diff --git a/template/html/option.go b/template/html/option.go new file mode 100644 index 0000000..31df3e3 --- /dev/null +++ b/template/html/option.go @@ -0,0 +1,58 @@ +package html + +import "html/template" + +// Options are configuration options for the template service +type Options struct { + Helpers template.FuncMap + PoolSize int +} + +// OptionFunc configures options for the template service +type OptionFunc func(*Options) + +// WithDefaultHelpers configures the template service +// to expose the default helpers +func WithDefaultHelpers() OptionFunc { + return func(opts *Options) { + helpers := template.FuncMap{ + "dump": dump, + "ellipsis": ellipsis, + "map": customMap, + "default": defaultVal, + "inc": increment, + "has": has, + } + for name, fn := range helpers { + WithHelper(name, fn)(opts) + } + } +} + +// WithHelper configures the template service +// to expose the default helpers +func WithHelper(name string, fn interface{}) OptionFunc { + return func(opts *Options) { + opts.Helpers[name] = fn + } +} + +// WithPoolSize configures the template service +// to use the given pool size +func WithPoolSize(size int) OptionFunc { + return func(opts *Options) { + opts.PoolSize = size + } +} + +func defaultOptions() *Options { + options := &Options{} + funcs := []OptionFunc{ + WithPoolSize(64), + WithDefaultHelpers(), + } + for _, f := range funcs { + f(options) + } + return options +} diff --git a/template/html/service.go b/template/html/service.go new file mode 100644 index 0000000..71b7211 --- /dev/null +++ b/template/html/service.go @@ -0,0 +1,89 @@ +package html + +import ( + "fmt" + "html/template" + "net/http" + "path/filepath" + + "github.com/oxtoacart/bpool" +) + +// TemplateService is a template/html based templating service +type TemplateService struct { + templates map[string]*template.Template + pool *bpool.BufferPool + helpers template.FuncMap +} + +// LoadTemplates loads the templates used by the service +func (t *TemplateService) LoadTemplates(templatesDir string) error { + + layouts, err := filepath.Glob(filepath.Join(templatesDir, "layouts", "*.tmpl")) + if err != nil { + return err + } + + blocks, err := filepath.Glob(filepath.Join(templatesDir, "blocks", "*.tmpl")) + if err != nil { + return err + } + + // Generate our templates map from our layouts/ and blocks/ directories + for _, layout := range layouts { + + var err error + files := append(blocks, layout) + + tmpl := template.New("") + tmpl.Funcs(t.helpers) + + tmpl, err = tmpl.ParseFiles(files...) + if err != nil { + return err + } + + t.templates[filepath.Base(layout)] = tmpl + + } + + return nil + +} + +// RenderPage renders a template to the given http.ResponseWriter +func (t *TemplateService) RenderPage(w http.ResponseWriter, templateName string, data interface{}) error { + + // Ensure the template exists in the map. + tmpl, ok := t.templates[templateName] + if !ok { + return fmt.Errorf("the template '%s' does not exist", templateName) + } + + // Create a buffer to temporarily write to and check if any errors were encountered. + buf := t.pool.Get() + defer t.pool.Put(buf) + + if err := tmpl.ExecuteTemplate(buf, templateName, data); err != nil { + return err + } + + // Set the header and write the buffer to the http.ResponseWriter + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, err := buf.WriteTo(w) + return err + +} + +// NewTemplateService returns a new Service +func NewTemplateService(funcs ...OptionFunc) *TemplateService { + options := defaultOptions() + for _, f := range funcs { + f(options) + } + return &TemplateService{ + templates: make(map[string]*template.Template), + pool: bpool.NewBufferPool(options.PoolSize), + helpers: options.Helpers, + } +}