Initial commit

This commit is contained in:
wpetit 2018-12-06 15:18:05 +01:00
commit 2cb1bf1881
23 changed files with 827 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/coverage

24
Makefile Normal file
View File

@ -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

13
README.md Normal file
View File

@ -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

32
middleware/debug.go Normal file
View File

@ -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)
}

22
middleware/debug_test.go Normal file
View File

@ -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")
}
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)
}
}

9
middleware/type.go Normal file
View File

@ -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

25
script/coverage Executable file
View File

@ -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

43
service/container.go Normal file
View File

@ -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),
}
}

43
service/session/flash.go Normal file
View File

@ -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}
}

View File

@ -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
}

View File

@ -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
}

19
service/template/data.go Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -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}
}

22
static/static.go Normal file
View File

@ -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
}

40
template/html/data.go Normal file
View File

@ -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
}
}

71
template/html/helper.go Normal file
View File

@ -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()
}

58
template/html/option.go Normal file
View File

@ -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
}

89
template/html/service.go Normal file
View File

@ -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,
}
}