├── web ├── static │ ├── main.css │ └── favicon.ico └── templates │ └── index.tpl ├── interfaces ├── web │ ├── doc.go │ ├── app.go │ ├── fs.go │ └── web.go ├── rest │ ├── doc.go │ ├── utils.go │ ├── rest.go │ ├── users.go │ └── posts.go └── mongo │ ├── doc.go │ ├── mongo.go │ ├── posts.go │ └── users.go ├── domain ├── doc.go ├── meta_test.go ├── meta.go ├── user.go ├── user_test.go ├── post_test.go └── post.go ├── usecases ├── posts │ ├── doc.go │ ├── store.go │ ├── retrieval.go │ └── publish.go └── users │ ├── doc.go │ ├── store.go │ ├── registration.go │ └── retrieval.go ├── pkg ├── render │ ├── doc.go │ └── render.go ├── middlewares │ ├── doc.go │ ├── utils.go │ ├── recovery.go │ ├── logging.go │ └── authn.go ├── graceful │ ├── doc.go │ └── graceful.go ├── doc.go ├── errors │ ├── doc.go │ ├── authorization.go │ ├── resource.go │ ├── validation.go │ ├── errors.go │ ├── error.go │ └── stack.go └── logger │ ├── doc.go │ └── logrus.go ├── Dockerfile ├── .gitignore ├── docker-compose.yml ├── Makefile ├── go.mod ├── LICENSE ├── README.md ├── CONTRIBUTING.md ├── main.go ├── docs ├── organization.md └── interfaces.md └── go.sum /web/static/main.css: -------------------------------------------------------------------------------- 1 | html { 2 | } -------------------------------------------------------------------------------- /interfaces/web/doc.go: -------------------------------------------------------------------------------- 1 | // Package web contains MVC style web app. 2 | package web 3 | -------------------------------------------------------------------------------- /web/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spy16/droplets/HEAD/web/static/favicon.ico -------------------------------------------------------------------------------- /domain/doc.go: -------------------------------------------------------------------------------- 1 | // Package domain contains domain entities and core validation rules. 2 | package domain 3 | -------------------------------------------------------------------------------- /interfaces/rest/doc.go: -------------------------------------------------------------------------------- 1 | // Package rest exposes the features of droplets as REST API. This 2 | // package uses gorilla/mux for routing. 3 | package rest 4 | -------------------------------------------------------------------------------- /usecases/posts/doc.go: -------------------------------------------------------------------------------- 1 | // Package posts has usecases around Post domain entity. This includes 2 | // publishing and management of posts. 3 | package posts 4 | -------------------------------------------------------------------------------- /usecases/users/doc.go: -------------------------------------------------------------------------------- 1 | // Package users has usecases around User domain entity. This includes 2 | // user registration, retrieval etc. 3 | package users 4 | -------------------------------------------------------------------------------- /pkg/render/doc.go: -------------------------------------------------------------------------------- 1 | // Package render provides simple and generic functions for rendering 2 | // data structures using different encoding formats. 3 | package render 4 | -------------------------------------------------------------------------------- /interfaces/mongo/doc.go: -------------------------------------------------------------------------------- 1 | // Package mongo contains any component in the entire project which interfaces 2 | // with MongoDB (e.g. different store implementations). 3 | package mongo 4 | -------------------------------------------------------------------------------- /pkg/middlewares/doc.go: -------------------------------------------------------------------------------- 1 | // Package middlewares contains re-usable middleware functions. Middleware 2 | // functions in this package follow standard http.HandlerFunc signature and 3 | // hence are compatible with all standard http library functions. 4 | package middlewares 5 | -------------------------------------------------------------------------------- /pkg/graceful/doc.go: -------------------------------------------------------------------------------- 1 | // Package graceful provides a simple wrapper for http.Handler which 2 | // handles graceful shutdown based on registered signals. Server in 3 | // this package closely follows the http.Server struct but can not be 4 | // used as a drop-in replacement. 5 | package graceful 6 | -------------------------------------------------------------------------------- /pkg/doc.go: -------------------------------------------------------------------------------- 1 | // Package pkg is the root for re-usable packages. This package should not 2 | // contain any entities (exported or otherwise) since, a package named `pkg` 3 | // does not express anything about its purpose and could become a catch all 4 | // package like `utils`, `misc` etc. which should be avoided. 5 | package pkg 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.11 as builder 2 | RUN mkdir /droplets-src 3 | WORKDIR /droplets-src 4 | COPY ./ . 5 | RUN CGO_ENABLED=0 make setup all 6 | 7 | FROM alpine:latest 8 | RUN mkdir /app 9 | WORKDIR /app 10 | COPY --from=builder /droplets-src/bin/droplets ./ 11 | COPY --from=builder /droplets-src/web ./web 12 | EXPOSE 8080 13 | CMD ["./droplets"] 14 | -------------------------------------------------------------------------------- /interfaces/web/app.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "html/template" 5 | "net/http" 6 | ) 7 | 8 | type app struct { 9 | render func(wr http.ResponseWriter, tpl string, data interface{}) 10 | tpl template.Template 11 | } 12 | 13 | func (app app) indexHandler(wr http.ResponseWriter, req *http.Request) { 14 | app.render(wr, "index.tpl", nil) 15 | } 16 | -------------------------------------------------------------------------------- /pkg/errors/doc.go: -------------------------------------------------------------------------------- 1 | // Package errors provides common error definitions and tools for dealing 2 | // with errors. The API of this package is drop-in replacement for standard 3 | // errors package except for one significant difference: 4 | // Since Error type used in this package embeds map inside, errors created 5 | // by this package are not comparable and hence cannot be used to create 6 | // Sentinel Errors. 7 | package errors 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | vendor/ 3 | 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, build with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | 18 | .DS_Store 19 | 20 | # temporarily do not track this 21 | PRACTICES.md 22 | 23 | # custom ignores 24 | expt/ 25 | test.db 26 | .vscode/ 27 | .idea/ 28 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.2' 2 | 3 | services: 4 | droplets: 5 | build: ./ 6 | environment: 7 | - MONGO_URI=mongodb://mongo 8 | - LOG_LEVEL=info 9 | ports: 10 | - "8080:8080" 11 | links: 12 | - mongo 13 | networks: 14 | - droplets_net 15 | mongo: 16 | image: mongo:3-stretch 17 | ports: 18 | - "27017:27017" 19 | networks: 20 | - droplets_net 21 | 22 | networks: 23 | droplets_net: 24 | -------------------------------------------------------------------------------- /usecases/users/store.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spy16/droplets/domain" 7 | ) 8 | 9 | // Store implementation is responsible for managing persistence of 10 | // users. 11 | type Store interface { 12 | Exists(ctx context.Context, name string) bool 13 | Save(ctx context.Context, user domain.User) (*domain.User, error) 14 | FindByName(ctx context.Context, name string) (*domain.User, error) 15 | FindAll(ctx context.Context, tags []string, limit int) ([]domain.User, error) 16 | } 17 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | @echo "Building droplets at './bin/droplets' ..." 3 | @go build -o bin/droplets 4 | 5 | clean: 6 | rm -rf ./bin 7 | 8 | all: lint vet cyclo test build 9 | 10 | test: 11 | @echo "Running unit tests..." 12 | @go test -cover ./... 13 | 14 | cyclo: 15 | @echo "Checking cyclomatic complexity..." 16 | @gocyclo -over 7 ./ 17 | 18 | vet: 19 | @echo "Running vet..." 20 | @go vet ./... 21 | 22 | lint: 23 | @echo "Running golint..." 24 | @golint ./... 25 | 26 | setup: 27 | @go get -u golang.org/x/lint/golint 28 | @go get -u github.com/fzipp/gocyclo -------------------------------------------------------------------------------- /interfaces/mongo/mongo.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "gopkg.in/mgo.v2" 5 | ) 6 | 7 | // Connect to a MongoDB instance located by mongo-uri using the `mgo` 8 | // driver. 9 | func Connect(uri string, failFast bool) (*mgo.Database, func(), error) { 10 | di, err := mgo.ParseURL(uri) 11 | if err != nil { 12 | return nil, doNothing, err 13 | } 14 | 15 | di.FailFast = failFast 16 | session, err := mgo.DialWithInfo(di) 17 | if err != nil { 18 | return nil, doNothing, err 19 | } 20 | 21 | return session.DB(di.Database), session.Close, nil 22 | } 23 | 24 | func doNothing() {} 25 | -------------------------------------------------------------------------------- /pkg/errors/authorization.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import "net/http" 4 | 5 | // Common authorization related errors 6 | const ( 7 | TypeUnauthorized = "Unauthorized" 8 | ) 9 | 10 | // Unauthorized can be used to generate an error that represents an unauthorized 11 | // request. 12 | func Unauthorized(reason string) error { 13 | return WithStack(&Error{ 14 | Code: http.StatusUnauthorized, 15 | Type: TypeUnauthorized, 16 | Message: "You are not authorized to perform the requested action", 17 | Context: map[string]interface{}{ 18 | "reason": reason, 19 | }, 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /usecases/posts/store.go: -------------------------------------------------------------------------------- 1 | package posts 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spy16/droplets/domain" 7 | ) 8 | 9 | // Store implementation is responsible for managing persistance of posts. 10 | type Store interface { 11 | Get(ctx context.Context, name string) (*domain.Post, error) 12 | Exists(ctx context.Context, name string) bool 13 | Save(ctx context.Context, post domain.Post) (*domain.Post, error) 14 | Delete(ctx context.Context, name string) (*domain.Post, error) 15 | } 16 | 17 | // userVerifier is responsible for verifying existence of a user. 18 | type userVerifier interface { 19 | Exists(ctx context.Context, name string) bool 20 | } 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/spy16/droplets 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/BurntSushi/toml v0.3.1 // indirect 7 | github.com/gorilla/context v1.1.1 // indirect 8 | github.com/gorilla/mux v1.6.2 9 | github.com/kr/pretty v0.1.0 // indirect 10 | github.com/mitchellh/mapstructure v1.1.2 // indirect 11 | github.com/pelletier/go-toml v1.2.0 12 | github.com/sirupsen/logrus v1.2.0 13 | github.com/spf13/cast v1.3.0 // indirect 14 | github.com/spf13/pflag v1.0.3 // indirect 15 | github.com/spf13/viper v1.2.1 16 | github.com/unrolled/render v0.0.0-20180914162206-b9786414de4d 17 | golang.org/x/crypto v0.0.0-20181106171534-e4dc69e5b2fd 18 | golang.org/x/sys v0.0.0-20181116161606-93218def8b18 // indirect 19 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 20 | gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce 21 | ) 22 | -------------------------------------------------------------------------------- /pkg/render/render.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | const contentTypeJSON = "application/json; charset=utf-8" 10 | 11 | // JSON encodes the given val using the standard json package and writes 12 | // the encoding output to the given writer. If the writer implements the 13 | // http.ResponseWriter interface, then this function will also set the 14 | // proper JSON content-type header with charset as UTF-8. Status will be 15 | // considered only when wr is http.ResponseWriter and in that case, status 16 | // must be a valid status code. 17 | func JSON(wr io.Writer, status int, val interface{}) error { 18 | if hw, ok := wr.(http.ResponseWriter); ok { 19 | hw.Header().Set("Content-type", contentTypeJSON) 20 | hw.WriteHeader(status) 21 | } 22 | 23 | return json.NewEncoder(wr).Encode(val) 24 | } 25 | -------------------------------------------------------------------------------- /pkg/middlewares/utils.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/spy16/droplets/pkg/logger" 7 | ) 8 | 9 | func requestInfo(req *http.Request) map[string]interface{} { 10 | return map[string]interface{}{ 11 | "path": req.URL.Path, 12 | "query": req.URL.RawQuery, 13 | "method": req.Method, 14 | "client": req.RemoteAddr, 15 | } 16 | } 17 | 18 | func wrap(wr http.ResponseWriter, logger logger.Logger) *wrappedWriter { 19 | return &wrappedWriter{ 20 | ResponseWriter: wr, 21 | Logger: logger, 22 | wroteStatus: http.StatusOK, 23 | } 24 | } 25 | 26 | type wrappedWriter struct { 27 | http.ResponseWriter 28 | logger.Logger 29 | 30 | wroteStatus int 31 | } 32 | 33 | func (wr *wrappedWriter) WriteHeader(statusCode int) { 34 | wr.wroteStatus = statusCode 35 | wr.ResponseWriter.WriteHeader(statusCode) 36 | } 37 | -------------------------------------------------------------------------------- /interfaces/rest/utils.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/spy16/droplets/pkg/errors" 8 | "github.com/spy16/droplets/pkg/render" 9 | ) 10 | 11 | func respond(wr http.ResponseWriter, status int, v interface{}) { 12 | if err := render.JSON(wr, status, v); err != nil { 13 | if loggable, ok := wr.(errorLogger); ok { 14 | loggable.Errorf("failed to write data to http ResponseWriter: %s", err) 15 | } 16 | } 17 | } 18 | 19 | func respondErr(wr http.ResponseWriter, err error) { 20 | if e, ok := err.(*errors.Error); ok { 21 | respond(wr, e.Code, e) 22 | return 23 | } 24 | respond(wr, http.StatusInternalServerError, err) 25 | } 26 | 27 | func readRequest(req *http.Request, v interface{}) error { 28 | if err := json.NewDecoder(req.Body).Decode(v); err != nil { 29 | return errors.Validation("Failed to read request body") 30 | } 31 | 32 | return nil 33 | } 34 | 35 | type errorLogger interface { 36 | Errorf(msg string, args ...interface{}) 37 | } 38 | -------------------------------------------------------------------------------- /pkg/logger/doc.go: -------------------------------------------------------------------------------- 1 | // Package logger provides logging functions. The loggers implemented in this 2 | // package will have the API defined by the Logger interface. The interface 3 | // is defined here (instead of where it is being used which is the right place), 4 | // is because Logger interface is a common thing that gets used across the code 5 | // base while being fairly constant in terms of its API. 6 | package logger 7 | 8 | // Logger implementation is responsible for providing structured and levled 9 | // logging functions. 10 | type Logger interface { 11 | Debugf(msg string, args ...interface{}) 12 | Infof(msg string, args ...interface{}) 13 | Warnf(msg string, args ...interface{}) 14 | Errorf(msg string, args ...interface{}) 15 | Fatalf(msg string, args ...interface{}) 16 | 17 | // WithFields should return a logger which is annotated with the given 18 | // fields. These fields should be added to every logging call on the 19 | // returned logger. 20 | WithFields(m map[string]interface{}) Logger 21 | } 22 | -------------------------------------------------------------------------------- /interfaces/web/fs.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | 7 | "github.com/spy16/droplets/pkg/logger" 8 | ) 9 | 10 | func newSafeFileSystemServer(lg logger.Logger, root string) http.Handler { 11 | sfs := &safeFileSystem{ 12 | fs: http.Dir(root), 13 | Logger: lg, 14 | } 15 | return http.FileServer(sfs) 16 | } 17 | 18 | // safeFileSystem implements http.FileSystem. It is used to prevent directory 19 | // listing of static assets. 20 | type safeFileSystem struct { 21 | logger.Logger 22 | 23 | fs http.FileSystem 24 | } 25 | 26 | func (sfs safeFileSystem) Open(path string) (http.File, error) { 27 | f, err := sfs.fs.Open(path) 28 | if err != nil { 29 | sfs.Warnf("failed to open file '%s': %v", path, err) 30 | return nil, err 31 | } 32 | 33 | stat, err := f.Stat() 34 | if err != nil { 35 | return nil, err 36 | } 37 | if stat.IsDir() { 38 | sfs.Warnf("path '%s' is a directory, rejecting static path request", path) 39 | return nil, os.ErrNotExist 40 | } 41 | 42 | return f, nil 43 | } 44 | -------------------------------------------------------------------------------- /pkg/errors/resource.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import "net/http" 4 | 5 | // Common resource related error codes. 6 | const ( 7 | TypeResourceNotFound = "ResourceNotFound" 8 | TypeResourceConflict = "ResourceConflict" 9 | ) 10 | 11 | // ResourceNotFound returns an error that represents an attempt to access a 12 | // non-existent resource. 13 | func ResourceNotFound(rType, rID string) error { 14 | return WithStack(&Error{ 15 | Code: http.StatusNotFound, 16 | Type: TypeResourceNotFound, 17 | Message: "Resource you are requesting does not exist", 18 | Context: map[string]interface{}{ 19 | "resource_type": rType, 20 | "resource_id": rID, 21 | }, 22 | }) 23 | } 24 | 25 | // Conflict returns an error that represents a resource identifier conflict. 26 | func Conflict(rType, rID string) error { 27 | return WithStack(&Error{ 28 | Code: http.StatusConflict, 29 | Type: TypeResourceConflict, 30 | Message: "A resource with same name already exists", 31 | Context: map[string]interface{}{ 32 | "resource_type": rType, 33 | "resource_id": rID, 34 | }, 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /pkg/logger/logrus.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "io" 5 | "os" 6 | 7 | "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // New returns a logger implemented using the logrus package. 11 | func New(wr io.Writer, level string, format string) Logger { 12 | if wr == nil { 13 | wr = os.Stderr 14 | } 15 | 16 | lr := logrus.New() 17 | lr.SetOutput(wr) 18 | lr.SetFormatter(&logrus.TextFormatter{}) 19 | if format == "json" { 20 | lr.SetFormatter(&logrus.JSONFormatter{}) 21 | } 22 | 23 | lvl, err := logrus.ParseLevel(level) 24 | if err != nil { 25 | lvl = logrus.WarnLevel 26 | lr.Warnf("failed to parse log-level '%s', defaulting to 'warning'", level) 27 | } 28 | lr.SetLevel(lvl) 29 | 30 | return &logrusLogger{ 31 | Entry: logrus.NewEntry(lr), 32 | } 33 | } 34 | 35 | // logrusLogger provides functions for structured logging. 36 | type logrusLogger struct { 37 | *logrus.Entry 38 | } 39 | 40 | func (ll *logrusLogger) WithFields(fields map[string]interface{}) Logger { 41 | annotatedEntry := ll.Entry.WithFields(logrus.Fields(fields)) 42 | return &logrusLogger{ 43 | Entry: annotatedEntry, 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Shivaprasad Bhat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /domain/meta_test.go: -------------------------------------------------------------------------------- 1 | package domain_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/spy16/droplets/domain" 8 | "github.com/spy16/droplets/pkg/errors" 9 | ) 10 | 11 | func TestMeta_Validate(suite *testing.T) { 12 | suite.Parallel() 13 | 14 | cases := []struct { 15 | meta domain.Meta 16 | expectErr bool 17 | errType string 18 | }{} 19 | 20 | for id, cs := range cases { 21 | suite.Run(fmt.Sprintf("Case#%d", id), func(t *testing.T) { 22 | testValidation(t, cs.meta, cs.expectErr, cs.errType) 23 | }) 24 | } 25 | 26 | } 27 | 28 | func testValidation(t *testing.T, validator validatable, expectErr bool, errType string) { 29 | err := validator.Validate() 30 | if err != nil { 31 | if !expectErr { 32 | t.Errorf("unexpected error: %s", err) 33 | return 34 | } 35 | 36 | if actualType := errors.Type(err); actualType != errType { 37 | t.Errorf("expecting error type '%s', got '%s'", errType, actualType) 38 | } 39 | return 40 | } 41 | 42 | if expectErr { 43 | t.Errorf("was expecting an error of type '%s', got nil", errType) 44 | return 45 | } 46 | } 47 | 48 | type validatable interface { 49 | Validate() error 50 | } 51 | -------------------------------------------------------------------------------- /pkg/middlewares/recovery.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/spy16/droplets/pkg/logger" 8 | ) 9 | 10 | // WithRecovery recovers from any panics and logs them appropriately. 11 | func WithRecovery(logger logger.Logger, next http.Handler) http.Handler { 12 | return http.HandlerFunc(func(wr http.ResponseWriter, req *http.Request) { 13 | ri := recoveryInfo{} 14 | safeHandler(next, &ri).ServeHTTP(wr, req) 15 | 16 | if ri.panicked { 17 | logger.Errorf("recovered from panic: %+v", ri.val) 18 | 19 | wr.WriteHeader(http.StatusInternalServerError) 20 | json.NewEncoder(wr).Encode(map[string]interface{}{ 21 | "error": "Something went wrong", 22 | }) 23 | } 24 | }) 25 | } 26 | 27 | func safeHandler(next http.Handler, ri *recoveryInfo) http.Handler { 28 | return http.HandlerFunc(func(wr http.ResponseWriter, req *http.Request) { 29 | defer func() { 30 | if val := recover(); val != nil { 31 | ri.panicked = true 32 | ri.val = val 33 | } 34 | }() 35 | 36 | next.ServeHTTP(wr, req) 37 | }) 38 | } 39 | 40 | type recoveryInfo struct { 41 | panicked bool 42 | val interface{} 43 | } 44 | -------------------------------------------------------------------------------- /interfaces/rest/rest.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/spy16/droplets/pkg/render" 7 | 8 | "github.com/gorilla/mux" 9 | "github.com/spy16/droplets/pkg/errors" 10 | "github.com/spy16/droplets/pkg/logger" 11 | ) 12 | 13 | // New initializes the server with routes exposing the given usecases. 14 | func New(logger logger.Logger, reg registration, ret retriever, postsRet postRetriever, postPub postPublication) http.Handler { 15 | // setup router with default handlers 16 | router := mux.NewRouter() 17 | router.NotFoundHandler = http.HandlerFunc(notFoundHandler) 18 | router.MethodNotAllowedHandler = http.HandlerFunc(methodNotAllowedHandler) 19 | 20 | // setup api endpoints 21 | addUsersAPI(router, reg, ret, logger) 22 | addPostsAPI(router, postPub, postsRet, logger) 23 | 24 | return router 25 | } 26 | 27 | func notFoundHandler(wr http.ResponseWriter, req *http.Request) { 28 | render.JSON(wr, http.StatusNotFound, errors.ResourceNotFound("path", req.URL.Path)) 29 | } 30 | 31 | func methodNotAllowedHandler(wr http.ResponseWriter, req *http.Request) { 32 | render.JSON(wr, http.StatusMethodNotAllowed, errors.ResourceNotFound("path", req.URL.Path)) 33 | } 34 | -------------------------------------------------------------------------------- /usecases/posts/retrieval.go: -------------------------------------------------------------------------------- 1 | package posts 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/spy16/droplets/domain" 8 | "github.com/spy16/droplets/pkg/logger" 9 | ) 10 | 11 | // NewRetriever initializes the retrieval usecase with given store. 12 | func NewRetriever(lg logger.Logger, store Store) *Retriever { 13 | return &Retriever{ 14 | Logger: lg, 15 | store: store, 16 | } 17 | } 18 | 19 | // Retriever provides retrieval related usecases. 20 | type Retriever struct { 21 | logger.Logger 22 | 23 | store Store 24 | } 25 | 26 | // Get finds a post by its name. 27 | func (ret *Retriever) Get(ctx context.Context, name string) (*domain.Post, error) { 28 | return ret.store.Get(ctx, name) 29 | } 30 | 31 | // Search finds all the posts matching the parameters in the query. 32 | func (ret *Retriever) Search(ctx context.Context, query Query) ([]domain.Post, error) { 33 | return nil, errors.New("not implemented") 34 | } 35 | 36 | // Query represents parameters for executing a search. Zero valued fields 37 | // in the query will be ignored. 38 | type Query struct { 39 | Name string `json:"name,omitempty"` 40 | Owner string `json:"owner,omitempty"` 41 | Tags []string `json:"tags,omitempty"` 42 | } 43 | -------------------------------------------------------------------------------- /domain/meta.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/spy16/droplets/pkg/errors" 8 | ) 9 | 10 | // Meta represents metadata about different entities. 11 | type Meta struct { 12 | // Name represents a unique name/identifier for the object. 13 | Name string `json:"name" bson:"name"` 14 | 15 | // Tags can contain additional metadata about the object. 16 | Tags []string `json:"tags,omitempty" bson:"tags"` 17 | 18 | // CreateAt represents the time at which this object was created. 19 | CreatedAt time.Time `json:"created_at,omitempty" bson:"created_at"` 20 | 21 | // UpdatedAt represents the time at which this object was last 22 | // modified. 23 | UpdatedAt time.Time `json:"updated_at,omitempty" bson:"updated_at"` 24 | } 25 | 26 | // SetDefaults sets sensible defaults on meta. 27 | func (meta *Meta) SetDefaults() { 28 | if meta.CreatedAt.IsZero() { 29 | meta.CreatedAt = time.Now() 30 | meta.UpdatedAt = time.Now() 31 | } 32 | } 33 | 34 | // Validate performs basic validation of the metadata. 35 | func (meta Meta) Validate() error { 36 | switch { 37 | case empty(meta.Name): 38 | return errors.MissingField("Name") 39 | } 40 | return nil 41 | } 42 | 43 | func empty(str string) bool { 44 | return len(strings.TrimSpace(str)) == 0 45 | } 46 | -------------------------------------------------------------------------------- /pkg/middlewares/logging.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/spy16/droplets/pkg/logger" 8 | ) 9 | 10 | // WithRequestLogging adds logging to the given handler. Every request handled by 11 | // 'next' will be logged with request information such as path, method, latency, 12 | // client-ip, response status code etc. Logging will be done at info level only. 13 | // Also, injects a logger into the ResponseWriter which can be later used by the 14 | // handlers to perform additional logging. 15 | func WithRequestLogging(logger logger.Logger, next http.Handler) http.Handler { 16 | return http.HandlerFunc(func(wr http.ResponseWriter, req *http.Request) { 17 | wrappedWr := wrap(wr, logger) 18 | 19 | start := time.Now() 20 | defer logRequest(logger, start, wrappedWr, req) 21 | 22 | next.ServeHTTP(wrappedWr, req) 23 | 24 | }) 25 | } 26 | 27 | func logRequest(logger logger.Logger, startedAt time.Time, wr *wrappedWriter, req *http.Request) { 28 | duration := time.Now().Sub(startedAt) 29 | 30 | info := map[string]interface{}{ 31 | "latency": duration, 32 | "status": wr.wroteStatus, 33 | } 34 | 35 | logger. 36 | WithFields(requestInfo(req)). 37 | WithFields(info). 38 | Infof("request completed with code %d", wr.wroteStatus) 39 | } 40 | -------------------------------------------------------------------------------- /usecases/users/registration.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spy16/droplets/domain" 7 | "github.com/spy16/droplets/pkg/errors" 8 | "github.com/spy16/droplets/pkg/logger" 9 | ) 10 | 11 | // NewRegistrar initializes a Registration service object. 12 | func NewRegistrar(lg logger.Logger, store Store) *Registrar { 13 | return &Registrar{ 14 | Logger: lg, 15 | store: store, 16 | } 17 | } 18 | 19 | // Registrar provides functions for user registration. 20 | type Registrar struct { 21 | logger.Logger 22 | 23 | store Store 24 | } 25 | 26 | // Register creates a new user in the system using the given user object. 27 | func (reg *Registrar) Register(ctx context.Context, user domain.User) (*domain.User, error) { 28 | if err := user.Validate(); err != nil { 29 | return nil, err 30 | } 31 | if len(user.Secret) < 8 { 32 | return nil, errors.InvalidValue("Secret", "secret must have 8 or more characters") 33 | } 34 | 35 | if reg.store.Exists(ctx, user.Name) { 36 | return nil, errors.Conflict("User", user.Name) 37 | } 38 | 39 | if err := user.HashSecret(); err != nil { 40 | return nil, err 41 | } 42 | 43 | saved, err := reg.store.Save(ctx, user) 44 | if err != nil { 45 | reg.Logger.Warnf("failed to save user object: %v", err) 46 | return nil, err 47 | } 48 | 49 | saved.Secret = "" 50 | return saved, nil 51 | } 52 | -------------------------------------------------------------------------------- /domain/user.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "net/mail" 5 | 6 | "github.com/spy16/droplets/pkg/errors" 7 | "golang.org/x/crypto/bcrypt" 8 | ) 9 | 10 | // User represents information about registered users. 11 | type User struct { 12 | Meta `json:",inline,omitempty" bson:",inline"` 13 | 14 | // Email should contain a valid email of the user. 15 | Email string `json:"email,omitempty" bson:"email"` 16 | 17 | // Secret represents the user secret. 18 | Secret string `json:"secret,omitempty" bson:"secret"` 19 | } 20 | 21 | // Validate performs basic validation of user information. 22 | func (user User) Validate() error { 23 | if err := user.Meta.Validate(); err != nil { 24 | return err 25 | } 26 | 27 | _, err := mail.ParseAddress(user.Email) 28 | if err != nil { 29 | return errors.InvalidValue("Email", err.Error()) 30 | } 31 | 32 | return nil 33 | } 34 | 35 | // HashSecret creates bcrypt hash of the password. 36 | func (user *User) HashSecret() error { 37 | bytes, err := bcrypt.GenerateFromPassword([]byte(user.Secret), 4) 38 | if err != nil { 39 | return err 40 | } 41 | user.Secret = string(bytes) 42 | return nil 43 | } 44 | 45 | // CheckSecret compares the cleartext password with the hash. 46 | func (user User) CheckSecret(password string) bool { 47 | err := bcrypt.CompareHashAndPassword([]byte(user.Secret), []byte(password)) 48 | return err == nil 49 | } 50 | -------------------------------------------------------------------------------- /domain/user_test.go: -------------------------------------------------------------------------------- 1 | package domain_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/spy16/droplets/domain" 8 | "github.com/spy16/droplets/pkg/errors" 9 | ) 10 | 11 | func TestUser_CheckSecret(t *testing.T) { 12 | password := "hello@world!" 13 | 14 | user := domain.User{} 15 | user.Secret = password 16 | err := user.HashSecret() 17 | if err != nil { 18 | t.Errorf("was not expecting error, got '%s'", err) 19 | } 20 | 21 | if !user.CheckSecret(password) { 22 | t.Errorf("CheckSecret expected to return true, but got false") 23 | } 24 | } 25 | 26 | func TestUser_Validate(suite *testing.T) { 27 | suite.Parallel() 28 | 29 | cases := []struct { 30 | user domain.User 31 | expectErr bool 32 | errType string 33 | }{ 34 | { 35 | user: domain.User{}, 36 | expectErr: true, 37 | errType: errors.TypeMissingField, 38 | }, 39 | { 40 | user: domain.User{ 41 | Meta: domain.Meta{ 42 | Name: "spy16", 43 | }, 44 | Email: "blah.com", 45 | }, 46 | expectErr: true, 47 | errType: errors.TypeInvalidValue, 48 | }, 49 | { 50 | user: domain.User{ 51 | Meta: domain.Meta{ 52 | Name: "spy16", 53 | }, 54 | Email: "spy16 ", 55 | }, 56 | expectErr: false, 57 | }, 58 | } 59 | 60 | for id, cs := range cases { 61 | suite.Run(fmt.Sprintf("Case#%d", id), func(t *testing.T) { 62 | testValidation(t, cs.user, cs.expectErr, cs.errType) 63 | }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /pkg/errors/validation.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import "net/http" 4 | 5 | // Common validation error type codes. 6 | const ( 7 | TypeInvalidRequest = "InvalidRequest" 8 | TypeMissingField = "MissingField" 9 | TypeInvalidValue = "InvalidValue" 10 | ) 11 | 12 | // Validation returns an error that can be used to represent an invalid request. 13 | func Validation(reason string) error { 14 | return WithStack(&Error{ 15 | Code: http.StatusBadRequest, 16 | Type: TypeInvalidRequest, 17 | Message: reason, 18 | Context: map[string]interface{}{}, 19 | }) 20 | } 21 | 22 | // InvalidValue can be used to generate an error that represents an invalid 23 | // value for the 'field'. reason should be used to add detail describing why 24 | // the value is invalid. 25 | func InvalidValue(field string, reason string) error { 26 | return WithStack(&Error{ 27 | Code: http.StatusBadRequest, 28 | Type: TypeInvalidValue, 29 | Message: "A parameter has invalid value", 30 | Context: map[string]interface{}{ 31 | "field": field, 32 | "reason": reason, 33 | }, 34 | }) 35 | } 36 | 37 | // MissingField can be used to generate an error that represents 38 | // a empty value for a required field. 39 | func MissingField(field string) error { 40 | return WithStack(&Error{ 41 | Code: http.StatusBadRequest, 42 | Type: TypeMissingField, 43 | Message: "A required field is missing", 44 | Context: map[string]interface{}{ 45 | "field": field, 46 | }, 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /usecases/posts/publish.go: -------------------------------------------------------------------------------- 1 | package posts 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/spy16/droplets/domain" 8 | "github.com/spy16/droplets/pkg/errors" 9 | "github.com/spy16/droplets/pkg/logger" 10 | ) 11 | 12 | // NewPublication initializes the publication usecase. 13 | func NewPublication(lg logger.Logger, store Store, verifier userVerifier) *Publication { 14 | return &Publication{ 15 | Logger: lg, 16 | store: store, 17 | verifier: verifier, 18 | } 19 | } 20 | 21 | // Publication implements the publishing usecases. 22 | type Publication struct { 23 | logger.Logger 24 | 25 | store Store 26 | verifier userVerifier 27 | } 28 | 29 | // Publish validates and persists the post into the store. 30 | func (pub *Publication) Publish(ctx context.Context, post domain.Post) (*domain.Post, error) { 31 | if err := post.Validate(); err != nil { 32 | return nil, err 33 | } 34 | 35 | if !pub.verifier.Exists(ctx, post.Owner) { 36 | return nil, errors.Unauthorized(fmt.Sprintf("user '%s' not found", post.Owner)) 37 | } 38 | 39 | if pub.store.Exists(ctx, post.Name) { 40 | return nil, errors.Conflict("Post", post.Name) 41 | } 42 | 43 | saved, err := pub.store.Save(ctx, post) 44 | if err != nil { 45 | pub.Warnf("failed to save post to the store: %+v", err) 46 | } 47 | 48 | return saved, nil 49 | } 50 | 51 | // Delete removes the post from the store. 52 | func (pub *Publication) Delete(ctx context.Context, name string) (*domain.Post, error) { 53 | return pub.store.Delete(ctx, name) 54 | } 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > WIP 2 | 3 | # droplets 4 | 5 | [![GoDoc](https://godoc.org/github.com/spy16/droplets?status.svg)](https://godoc.org/github.com/spy16/droplets) [![Go Report Card](https://goreportcard.com/badge/github.com/spy16/droplets)](https://goreportcard.com/report/github.com/spy16/droplets) 6 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fspy16%2Fdroplets.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Fspy16%2Fdroplets?ref=badge_shield) 7 | 8 | A platform for Gophers similar to the awesome [Golang News](http://golangnews.com). 9 | 10 | ## Why? 11 | 12 | Droplets is built to showcase: 13 | 14 | 1. Application of [CodeReviewComments](https://github.com/golang/go/wiki/CodeReviewComments) and [EffectiveGo](https://golang.org/doc/effective_go.html) 15 | 2. Usage of [Clean Architecture](http://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) 16 | 3. Testing practices such as [Table-driven tests](https://github.com/golang/go/wiki/TableDrivenTests) 17 | 18 | Follow the links to understand best practices and conventions used: 19 | 1. [Directory Structure](./docs/organization.md) 20 | 2. [Interfaces](./docs/interfaces.md) 21 | 22 | ## Building 23 | 24 | Droplets uses `go mod` (available from `go 1.11`) for dependency management. 25 | 26 | To test and build, run `make all`. 27 | 28 | ## License 29 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fspy16%2Fdroplets.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fspy16%2Fdroplets?ref=badge_large) 30 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Droplets 2 | 3 | Thanks for taking the time to contribute. You are Awesome! :heart: 4 | 5 | Droplets is built to showcase ideas, patterns and best practices which can be 6 | applied while building cool stuff with Go. 7 | 8 | ## What can I contribute and How ? 9 | 10 | A project with realistic development cycle is required to be able to demonstrate 11 | different ideas as applicable in real-world scenarios. This means, any type of 12 | contribution that a typical open-source project would go through are welcome here. 13 | 14 | Some possible contributions: 15 | 16 | - Suggest features to add 17 | - Suggest improvements to code 18 | - Suggest improvements to documentation 19 | - Open an issue and discuss a practice used 20 | - Try the project and report bugs 21 | - Designs for the web app 22 | 23 | Or any other contribution you can think of (And be sure to send a PR to add it to 24 | this document, which itself is another contribution!) 25 | 26 | You can simply follow the guidelines from [opensource.guide](https://opensource.guide/how-to-contribute/). 27 | 28 | ## Responsibilities 29 | 30 | * No platform specific code 31 | * Ensure that any code you add follows [CodeReviewComments](https://github.com/golang/go/wiki/CodeReviewComments), [EffectiveGo](https://golang.org/doc/effective_go.html) and [Clean Architecture](http://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) 32 | * Keep PRs as small as possible to make it easy to review and discuss. 33 | * Be welcoming to newcomers and encourage diverse new contributors from all backgrounds. 34 | * Code change PRs must have unit tests 35 | 36 | -------------------------------------------------------------------------------- /domain/post_test.go: -------------------------------------------------------------------------------- 1 | package domain_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/spy16/droplets/domain" 8 | ) 9 | 10 | func TestPost_Validate(suite *testing.T) { 11 | suite.Parallel() 12 | 13 | validMeta := domain.Meta{ 14 | Name: "hello", 15 | } 16 | 17 | cases := []struct { 18 | post domain.Post 19 | expectErr bool 20 | }{ 21 | { 22 | post: domain.Post{}, 23 | expectErr: true, 24 | }, 25 | { 26 | post: domain.Post{ 27 | Meta: validMeta, 28 | }, 29 | expectErr: true, 30 | }, 31 | { 32 | post: domain.Post{ 33 | Meta: validMeta, 34 | Body: "hello world post!", 35 | }, 36 | expectErr: true, 37 | }, 38 | { 39 | post: domain.Post{ 40 | Meta: validMeta, 41 | Type: "blah", 42 | Owner: "spy16", 43 | Body: "hello world post!", 44 | }, 45 | expectErr: true, 46 | }, 47 | { 48 | post: domain.Post{ 49 | Meta: validMeta, 50 | Type: domain.ContentLibrary, 51 | Body: "hello world post!", 52 | }, 53 | expectErr: true, 54 | }, 55 | { 56 | post: domain.Post{ 57 | Meta: validMeta, 58 | Type: domain.ContentLibrary, 59 | Body: "hello world post!", 60 | Owner: "spy16", 61 | }, 62 | expectErr: false, 63 | }, 64 | } 65 | 66 | for id, cs := range cases { 67 | suite.Run(fmt.Sprintf("#%d", id), func(t *testing.T) { 68 | err := cs.post.Validate() 69 | if err != nil { 70 | if !cs.expectErr { 71 | t.Errorf("was not expecting error, got '%s'", err) 72 | } 73 | return 74 | } 75 | 76 | if cs.expectErr { 77 | t.Errorf("was expecting error, got nil") 78 | } 79 | }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /domain/post.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/spy16/droplets/pkg/errors" 8 | ) 9 | 10 | // Common content types. 11 | const ( 12 | ContentLibrary = "library" 13 | ContentLink = "link" 14 | ContentVideo = "video" 15 | ) 16 | 17 | var validTypes = []string{ContentLibrary, ContentLink, ContentVideo} 18 | 19 | // Post represents an article, link, video etc. 20 | type Post struct { 21 | Meta `json:",inline" bson:",inline"` 22 | 23 | // Type should state the type of the content. (e.g., library, 24 | // video, link etc.) 25 | Type string `json:"type" bson:"type"` 26 | 27 | // Body should contain the actual content according to the Type 28 | // specified. (e.g. github.com/spy16/parens when Type=link) 29 | Body string `json:"body" bson:"body"` 30 | 31 | // Owner represents the name of the user who created the post. 32 | Owner string `json:"owner" bson:"owner"` 33 | } 34 | 35 | // Validate performs validation of the post. 36 | func (post Post) Validate() error { 37 | if err := post.Meta.Validate(); err != nil { 38 | return err 39 | } 40 | 41 | if len(strings.TrimSpace(post.Body)) == 0 { 42 | return errors.MissingField("Body") 43 | } 44 | 45 | if len(strings.TrimSpace(post.Owner)) == 0 { 46 | return errors.MissingField("Owner") 47 | } 48 | 49 | if !contains(post.Type, validTypes) { 50 | return errors.InvalidValue("Type", fmt.Sprintf("type must be one of: %s", strings.Join(validTypes, ","))) 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func contains(val string, vals []string) bool { 57 | for _, item := range vals { 58 | if val == item { 59 | return true 60 | } 61 | } 62 | return false 63 | } 64 | -------------------------------------------------------------------------------- /interfaces/web/web.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "html/template" 5 | "io/ioutil" 6 | "net/http" 7 | "path/filepath" 8 | 9 | "github.com/gorilla/mux" 10 | "github.com/spy16/droplets/pkg/logger" 11 | ) 12 | 13 | // New initializes a new webapp server. 14 | func New(lg logger.Logger, cfg Config) (http.Handler, error) { 15 | tpl, err := initTemplate(lg, "", cfg.TemplateDir) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | app := &app{ 21 | render: func(wr http.ResponseWriter, tplName string, data interface{}) { 22 | if err := tpl.ExecuteTemplate(wr, tplName, data); err != nil { 23 | lg.Errorf("failed to render template '%s': %+v", tplName, err) 24 | } 25 | }, 26 | } 27 | 28 | fsServer := newSafeFileSystemServer(lg, cfg.StaticDir) 29 | 30 | router := mux.NewRouter() 31 | router.PathPrefix("/static").Handler(http.StripPrefix("/static", fsServer)) 32 | router.Handle("/favicon.ico", fsServer) 33 | 34 | // web app routes 35 | router.HandleFunc("/", app.indexHandler) 36 | 37 | return router, nil 38 | } 39 | 40 | // Config represents server configuration. 41 | type Config struct { 42 | TemplateDir string 43 | StaticDir string 44 | } 45 | 46 | func initTemplate(lg logger.Logger, name, path string) (*template.Template, error) { 47 | apath, err := filepath.Abs(path) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | files, err := ioutil.ReadDir(apath) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | lg.Infof("loading templates from '%s'...", path) 58 | tpl := template.New(name) 59 | for _, f := range files { 60 | if f.IsDir() { 61 | continue 62 | } 63 | fp := filepath.Join(apath, f.Name()) 64 | lg.Debugf("parsing template file '%s'", f.Name()) 65 | tpl.New(f.Name()).ParseFiles(fp) 66 | } 67 | 68 | return tpl, nil 69 | } 70 | -------------------------------------------------------------------------------- /usecases/users/retrieval.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spy16/droplets/domain" 7 | "github.com/spy16/droplets/pkg/logger" 8 | ) 9 | 10 | // NewRetriever initializes an instance of Retriever with given store. 11 | func NewRetriever(lg logger.Logger, store Store) *Retriever { 12 | return &Retriever{ 13 | Logger: lg, 14 | store: store, 15 | } 16 | } 17 | 18 | // Retriever provides functions for retrieving user and user info. 19 | type Retriever struct { 20 | logger.Logger 21 | 22 | store Store 23 | } 24 | 25 | // Search finds all users matching the tags. 26 | func (ret *Retriever) Search(ctx context.Context, tags []string, limit int) ([]domain.User, error) { 27 | users, err := ret.store.FindAll(ctx, tags, limit) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | for i := range users { 33 | users[i].Secret = "" 34 | } 35 | 36 | return users, nil 37 | } 38 | 39 | // Get finds a user by name. 40 | func (ret *Retriever) Get(ctx context.Context, name string) (*domain.User, error) { 41 | return ret.findUser(ctx, name, true) 42 | } 43 | 44 | // VerifySecret finds the user by name and verifies the secret against the has found 45 | // in the store. 46 | func (ret *Retriever) VerifySecret(ctx context.Context, name, secret string) bool { 47 | user, err := ret.findUser(ctx, name, false) 48 | if err != nil { 49 | return false 50 | } 51 | 52 | return user.CheckSecret(secret) 53 | } 54 | 55 | func (ret *Retriever) findUser(ctx context.Context, name string, stripSecret bool) (*domain.User, error) { 56 | user, err := ret.store.FindByName(ctx, name) 57 | if err != nil { 58 | ret.Debugf("failed to find user with name '%s': %v", name, err) 59 | return nil, err 60 | } 61 | 62 | if stripSecret { 63 | user.Secret = "" 64 | } 65 | return user, nil 66 | } 67 | -------------------------------------------------------------------------------- /pkg/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | // TypeUnknown represents unknown error type. 9 | const TypeUnknown = "Unknown" 10 | 11 | // New returns an error object with formatted error message generated using 12 | // the arguments. 13 | func New(msg string, args ...interface{}) error { 14 | return &Error{ 15 | Code: http.StatusInternalServerError, 16 | Type: TypeUnknown, 17 | Message: fmt.Sprintf(msg, args...), 18 | stack: callStack(3), 19 | } 20 | } 21 | 22 | // Type attempts converting the err to Error type and extracts error Type. 23 | // If conversion not possible, returns TypeUnknown. 24 | func Type(err error) string { 25 | if e, ok := err.(*Error); ok { 26 | return e.Type 27 | } 28 | return TypeUnknown 29 | } 30 | 31 | // Wrapf wraps the given err with formatted message and returns a new error. 32 | func Wrapf(err error, msg string, args ...interface{}) error { 33 | return WithStack(&Error{ 34 | Code: http.StatusInternalServerError, 35 | Type: TypeUnknown, 36 | Message: fmt.Sprintf(msg, args...), 37 | original: err, 38 | }) 39 | } 40 | 41 | // WithStack annotates the given error with stack trace and returns the wrapped 42 | // error. 43 | func WithStack(err error) error { 44 | var wrappedErr Error 45 | if e, ok := err.(*Error); ok { 46 | wrappedErr = *e 47 | } else { 48 | wrappedErr.Type = TypeUnknown 49 | wrappedErr.Message = "Something went wrong" 50 | wrappedErr.original = err 51 | } 52 | 53 | wrappedErr.stack = callStack(3) 54 | return &wrappedErr 55 | } 56 | 57 | // Cause returns the underlying error if the given error is wrapping another error. 58 | func Cause(err error) error { 59 | if err == nil { 60 | return nil 61 | } 62 | 63 | if e, ok := err.(*Error); ok { 64 | return e 65 | } 66 | 67 | return err 68 | } 69 | -------------------------------------------------------------------------------- /web/templates/index.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | 13 | Droplets 14 | 15 | 16 | 17 |
18 | 28 | 29 |
30 |

Welcome

31 |
32 |
33 | 34 | 35 | 37 | 39 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /pkg/middlewares/authn.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/spy16/droplets/pkg/errors" 8 | "github.com/spy16/droplets/pkg/logger" 9 | "github.com/spy16/droplets/pkg/render" 10 | ) 11 | 12 | var authUser = ctxKey("user") 13 | 14 | // WithBasicAuth adds Basic authentication checks to the handler. Basic Auth header 15 | // will be extracted from the request and verified using the verifier. 16 | func WithBasicAuth(lg logger.Logger, next http.Handler, verifier UserVerifier) http.Handler { 17 | return http.HandlerFunc(func(wr http.ResponseWriter, req *http.Request) { 18 | name, secret, ok := req.BasicAuth() 19 | if !ok { 20 | render.JSON(wr, http.StatusUnauthorized, errors.Unauthorized("Basic auth header is not present")) 21 | return 22 | } 23 | 24 | verified := verifier.VerifySecret(req.Context(), name, secret) 25 | if !verified { 26 | wr.WriteHeader(http.StatusUnauthorized) 27 | render.JSON(wr, http.StatusUnauthorized, errors.Unauthorized("Invalid username or secret")) 28 | return 29 | } 30 | 31 | req = req.WithContext(context.WithValue(req.Context(), authUser, name)) 32 | next.ServeHTTP(wr, req) 33 | }) 34 | } 35 | 36 | // User extracts the username injected into the context by the auth middleware. 37 | func User(req *http.Request) (string, bool) { 38 | val := req.Context().Value(authUser) 39 | if userName, ok := val.(string); ok { 40 | return userName, true 41 | } 42 | 43 | return "", false 44 | } 45 | 46 | type ctxKey string 47 | 48 | // UserVerifier implementation is responsible for verifying the name-secret pair. 49 | type UserVerifier interface { 50 | VerifySecret(ctx context.Context, name, secret string) bool 51 | } 52 | 53 | // UserVerifierFunc implements UserVerifier. 54 | type UserVerifierFunc func(ctx context.Context, name, secret string) bool 55 | 56 | // VerifySecret delegates call to the wrapped function. 57 | func (uvf UserVerifierFunc) VerifySecret(ctx context.Context, name, secret string) bool { 58 | return uvf(ctx, name, secret) 59 | } 60 | -------------------------------------------------------------------------------- /interfaces/rest/users.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | "github.com/spy16/droplets/domain" 9 | "github.com/spy16/droplets/pkg/logger" 10 | ) 11 | 12 | func addUsersAPI(router *mux.Router, reg registration, ret retriever, logger logger.Logger) { 13 | uc := &userController{ 14 | Logger: logger, 15 | reg: reg, 16 | ret: ret, 17 | } 18 | 19 | router.HandleFunc("/v1/users/{name}", uc.get).Methods(http.MethodGet) 20 | router.HandleFunc("/v1/users/", uc.search).Methods(http.MethodGet) 21 | router.HandleFunc("/v1/users/", uc.post).Methods(http.MethodPost) 22 | } 23 | 24 | type userController struct { 25 | logger.Logger 26 | reg registration 27 | ret retriever 28 | } 29 | 30 | func (uc *userController) get(wr http.ResponseWriter, req *http.Request) { 31 | vars := mux.Vars(req) 32 | user, err := uc.ret.Get(req.Context(), vars["name"]) 33 | if err != nil { 34 | respondErr(wr, err) 35 | return 36 | } 37 | 38 | respond(wr, http.StatusOK, user) 39 | } 40 | 41 | func (uc *userController) search(wr http.ResponseWriter, req *http.Request) { 42 | vals := req.URL.Query()["t"] 43 | users, err := uc.ret.Search(req.Context(), vals, 10) 44 | if err != nil { 45 | respondErr(wr, err) 46 | return 47 | } 48 | 49 | respond(wr, http.StatusOK, users) 50 | } 51 | 52 | func (uc *userController) post(wr http.ResponseWriter, req *http.Request) { 53 | user := domain.User{} 54 | if err := readRequest(req, &user); err != nil { 55 | uc.Warnf("failed to read user request: %s", err) 56 | respond(wr, http.StatusBadRequest, err) 57 | return 58 | } 59 | 60 | registered, err := uc.reg.Register(req.Context(), user) 61 | if err != nil { 62 | uc.Warnf("failed to register user: %s", err) 63 | respondErr(wr, err) 64 | return 65 | } 66 | 67 | uc.Infof("new user registered with id '%s'", registered.Name) 68 | respond(wr, http.StatusCreated, registered) 69 | } 70 | 71 | type registration interface { 72 | Register(ctx context.Context, user domain.User) (*domain.User, error) 73 | } 74 | 75 | type retriever interface { 76 | Get(ctx context.Context, name string) (*domain.User, error) 77 | Search(ctx context.Context, tags []string, limit int) ([]domain.User, error) 78 | VerifySecret(ctx context.Context, name, secret string) bool 79 | } 80 | -------------------------------------------------------------------------------- /pkg/errors/error.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | ) 7 | 8 | // Error is a generic error representation with some fields to provide additional 9 | // context around the error. 10 | type Error struct { 11 | // Code can represent an http error code. 12 | Code int `json:"-"` 13 | 14 | // Type should be an error code to identify the error. Type and Context together 15 | // should provide enough context for robust error handling on client side. 16 | Type string `json:"type,omitempty"` 17 | 18 | // Context can contain additional information describing the error. Context will 19 | // be exposed only in API endpoints so that clients be integrated effectively. 20 | Context map[string]interface{} `json:"context,omitempty"` 21 | 22 | // Message should be a user-friendly error message which can be shown to the 23 | // end user without further modifications. However, clients are free to modify 24 | // this (e.g., for enabling localization), or augment this message with the 25 | // information available in the context before rendering a message to the end 26 | // user. 27 | Message string `json:"message,omitempty"` 28 | 29 | // original can contain an underlying error if any. This value will be returned 30 | // by the Cause() method. 31 | original error 32 | 33 | // stack will contain a minimal stack trace which can be used for logging and 34 | // debugging. stack should not be examined to handle errors. 35 | stack stack 36 | } 37 | 38 | // Cause returns the underlying error if any. 39 | func (err Error) Cause() error { 40 | return err.original 41 | } 42 | 43 | func (err Error) Error() string { 44 | if origin := err.Cause(); origin != nil { 45 | return fmt.Sprintf("%s: %s: %s", origin, err.Type, err.Message) 46 | } 47 | 48 | return fmt.Sprintf("%s: %s", err.Type, err.Message) 49 | } 50 | 51 | // Format implements fmt.Formatter interface. 52 | func (err Error) Format(st fmt.State, verb rune) { 53 | switch verb { 54 | case 'v': 55 | if st.Flag('+') { 56 | io.WriteString(st, err.Error()) 57 | err.stack.Format(st, verb) 58 | } else { 59 | fmt.Fprintf(st, "%s: ", err.Type) 60 | for key, val := range err.Context { 61 | fmt.Fprintf(st, "%s='%s' ", key, val) 62 | } 63 | } 64 | case 's': 65 | io.WriteString(st, err.Error()) 66 | case 'q': 67 | fmt.Fprintf(st, "%q", err.Error()) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /interfaces/mongo/posts.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/spy16/droplets/domain" 8 | "github.com/spy16/droplets/pkg/errors" 9 | "gopkg.in/mgo.v2" 10 | "gopkg.in/mgo.v2/bson" 11 | ) 12 | 13 | const colPosts = "posts" 14 | 15 | // NewPostStore initializes the Posts store with given mongo db handle. 16 | func NewPostStore(db *mgo.Database) *PostStore { 17 | return &PostStore{ 18 | db: db, 19 | } 20 | } 21 | 22 | // PostStore manages persistence and retrieval of posts. 23 | type PostStore struct { 24 | db *mgo.Database 25 | } 26 | 27 | // Exists checks if a post exists by name. 28 | func (posts *PostStore) Exists(ctx context.Context, name string) bool { 29 | col := posts.db.C(colPosts) 30 | 31 | count, err := col.Find(bson.M{"name": name}).Count() 32 | if err != nil { 33 | return false 34 | } 35 | return count > 0 36 | } 37 | 38 | // Get finds a post by name. 39 | func (posts *PostStore) Get(ctx context.Context, name string) (*domain.Post, error) { 40 | col := posts.db.C(colPosts) 41 | 42 | post := domain.Post{} 43 | if err := col.Find(bson.M{"name": name}).One(&post); err != nil { 44 | if err == mgo.ErrNotFound { 45 | return nil, errors.ResourceNotFound("Post", name) 46 | } 47 | return nil, errors.Wrapf(err, "failed to fetch post") 48 | } 49 | 50 | post.SetDefaults() 51 | return &post, nil 52 | } 53 | 54 | // Save validates and persists the post. 55 | func (posts *PostStore) Save(ctx context.Context, post domain.Post) (*domain.Post, error) { 56 | post.SetDefaults() 57 | if err := post.Validate(); err != nil { 58 | return nil, err 59 | } 60 | post.CreatedAt = time.Now() 61 | post.UpdatedAt = time.Now() 62 | 63 | col := posts.db.C(colPosts) 64 | if err := col.Insert(post); err != nil { 65 | return nil, err 66 | } 67 | return &post, nil 68 | } 69 | 70 | // Delete removes one post identified by the name. 71 | func (posts *PostStore) Delete(ctx context.Context, name string) (*domain.Post, error) { 72 | col := posts.db.C(colPosts) 73 | 74 | ch := mgo.Change{ 75 | Remove: true, 76 | ReturnNew: true, 77 | Upsert: false, 78 | } 79 | post := domain.Post{} 80 | _, err := col.Find(bson.M{"name": name}).Apply(ch, &post) 81 | if err != nil { 82 | if err == mgo.ErrNotFound { 83 | return nil, errors.ResourceNotFound("Post", name) 84 | } 85 | return nil, err 86 | } 87 | 88 | return &post, nil 89 | } 90 | -------------------------------------------------------------------------------- /pkg/errors/stack.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "path" 7 | "runtime" 8 | ) 9 | 10 | const depth = 32 11 | 12 | type stack []frame 13 | 14 | // Format formats the stack of Frames according to the fmt.Formatter interface. 15 | // 16 | // %s lists source files for each Frame in the stack 17 | // %v lists the source file and line number for each Frame in the stack 18 | // 19 | // Format accepts flags that alter the printing of some verbs, as follows: 20 | // 21 | // %+v Prints filename, function, and line number for each Frame in the stack. 22 | func (st stack) Format(s fmt.State, verb rune) { 23 | switch verb { 24 | case 'v': 25 | switch { 26 | case s.Flag('+'): 27 | for _, f := range st { 28 | fmt.Fprintf(s, "\n%+v", f) 29 | } 30 | case s.Flag('#'): 31 | fmt.Fprintf(s, "%#v", []frame(st)) 32 | default: 33 | fmt.Fprintf(s, "%v", []frame(st)) 34 | } 35 | case 's': 36 | fmt.Fprintf(s, "%s", []frame(st)) 37 | } 38 | } 39 | 40 | type frame struct { 41 | fn string 42 | file string 43 | line int 44 | } 45 | 46 | // Format formats the frame according to the fmt.Formatter interface. 47 | // 48 | // %s source file 49 | // %d source line 50 | // %n function name 51 | // %v equivalent to %s:%d 52 | // 53 | // Format accepts flags that alter the printing of some verbs, as follows: 54 | // 55 | // %+s function name and path of source file relative to the compile time 56 | // GOPATH separated by \n\t (\n\t) 57 | // %+v equivalent to %+s:%d 58 | func (f frame) Format(s fmt.State, verb rune) { 59 | switch verb { 60 | case 's': 61 | switch { 62 | case s.Flag('+'): 63 | fmt.Fprintf(s, "%s\n\t%s", f.fn, f.file) 64 | default: 65 | io.WriteString(s, path.Base(f.file)) 66 | } 67 | case 'd': 68 | fmt.Fprintf(s, "%d", f.line) 69 | case 'n': 70 | io.WriteString(s, f.fn) 71 | case 'v': 72 | f.Format(s, 's') 73 | io.WriteString(s, ":") 74 | f.Format(s, 'd') 75 | } 76 | } 77 | 78 | func callStack(skip int) stack { 79 | var frames []frame 80 | var pcs [depth]uintptr 81 | 82 | n := runtime.Callers(skip, pcs[:]) 83 | for i := 0; i < n; i++ { 84 | pc := pcs[i] 85 | frame := frameFromPC(pc) 86 | frames = append(frames, frame) 87 | } 88 | return stack(frames) 89 | } 90 | 91 | func frameFromPC(pc uintptr) frame { 92 | fr := frame{} 93 | 94 | fn := runtime.FuncForPC(pc) 95 | if fn == nil { 96 | fr.fn = "unknown" 97 | } else { 98 | fr.fn = fn.Name() 99 | } 100 | 101 | fr.file, fr.line = fn.FileLine(pc) 102 | return fr 103 | } 104 | -------------------------------------------------------------------------------- /interfaces/rest/posts.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | "github.com/spy16/droplets/domain" 9 | "github.com/spy16/droplets/pkg/logger" 10 | "github.com/spy16/droplets/pkg/middlewares" 11 | "github.com/spy16/droplets/usecases/posts" 12 | ) 13 | 14 | func addPostsAPI(router *mux.Router, pub postPublication, ret postRetriever, lg logger.Logger) { 15 | pc := &postController{} 16 | pc.ret = ret 17 | pc.pub = pub 18 | pc.Logger = lg 19 | 20 | router.HandleFunc("/v1/posts", pc.search).Methods(http.MethodGet) 21 | router.HandleFunc("/v1/posts/{name}", pc.get).Methods(http.MethodGet) 22 | router.HandleFunc("/v1/posts/{name}", pc.delete).Methods(http.MethodDelete) 23 | router.HandleFunc("/v1/posts", pc.post).Methods(http.MethodPost) 24 | } 25 | 26 | type postController struct { 27 | logger.Logger 28 | 29 | pub postPublication 30 | ret postRetriever 31 | } 32 | 33 | func (pc *postController) search(wr http.ResponseWriter, req *http.Request) { 34 | posts, err := pc.ret.Search(req.Context(), posts.Query{}) 35 | if err != nil { 36 | respondErr(wr, err) 37 | return 38 | } 39 | 40 | respond(wr, http.StatusOK, posts) 41 | } 42 | 43 | func (pc *postController) get(wr http.ResponseWriter, req *http.Request) { 44 | name := mux.Vars(req)["name"] 45 | post, err := pc.ret.Get(req.Context(), name) 46 | if err != nil { 47 | respondErr(wr, err) 48 | return 49 | } 50 | 51 | respond(wr, http.StatusOK, post) 52 | } 53 | 54 | func (pc *postController) post(wr http.ResponseWriter, req *http.Request) { 55 | post := domain.Post{} 56 | if err := readRequest(req, &post); err != nil { 57 | pc.Warnf("failed to read user request: %s", err) 58 | respond(wr, http.StatusBadRequest, err) 59 | return 60 | } 61 | user, _ := middlewares.User(req) 62 | post.Owner = user 63 | 64 | published, err := pc.pub.Publish(req.Context(), post) 65 | if err != nil { 66 | respondErr(wr, err) 67 | return 68 | } 69 | 70 | respond(wr, http.StatusCreated, published) 71 | } 72 | 73 | func (pc *postController) delete(wr http.ResponseWriter, req *http.Request) { 74 | name := mux.Vars(req)["name"] 75 | post, err := pc.pub.Delete(req.Context(), name) 76 | if err != nil { 77 | respondErr(wr, err) 78 | return 79 | } 80 | 81 | respond(wr, http.StatusOK, post) 82 | } 83 | 84 | type postRetriever interface { 85 | Get(ctx context.Context, name string) (*domain.Post, error) 86 | Search(ctx context.Context, query posts.Query) ([]domain.Post, error) 87 | } 88 | 89 | type postPublication interface { 90 | Publish(ctx context.Context, post domain.Post) (*domain.Post, error) 91 | Delete(ctx context.Context, name string) (*domain.Post, error) 92 | } 93 | -------------------------------------------------------------------------------- /interfaces/mongo/users.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/spy16/droplets/domain" 8 | "github.com/spy16/droplets/pkg/errors" 9 | "gopkg.in/mgo.v2" 10 | "gopkg.in/mgo.v2/bson" 11 | ) 12 | 13 | const colUsers = "users" 14 | 15 | // NewUserStore initializes a users store with the given db handle. 16 | func NewUserStore(db *mgo.Database) *UserStore { 17 | return &UserStore{ 18 | db: db, 19 | } 20 | } 21 | 22 | // UserStore provides functions for persisting User entities in MongoDB. 23 | type UserStore struct { 24 | db *mgo.Database 25 | } 26 | 27 | // Exists checks if the user identified by the given username already 28 | // exists. Will return false in case of any error. 29 | func (users *UserStore) Exists(ctx context.Context, name string) bool { 30 | col := users.db.C(colUsers) 31 | 32 | count, err := col.Find(bson.M{"name": name}).Count() 33 | if err != nil { 34 | return false 35 | } 36 | return count > 0 37 | } 38 | 39 | // Save validates and persists the user. 40 | func (users *UserStore) Save(ctx context.Context, user domain.User) (*domain.User, error) { 41 | user.SetDefaults() 42 | if err := user.Validate(); err != nil { 43 | return nil, err 44 | } 45 | user.CreatedAt = time.Now() 46 | user.UpdatedAt = time.Now() 47 | 48 | col := users.db.C(colUsers) 49 | if err := col.Insert(user); err != nil { 50 | return nil, err 51 | } 52 | return &user, nil 53 | } 54 | 55 | // FindByName finds a user by name. If not found, returns ResourceNotFound error. 56 | func (users *UserStore) FindByName(ctx context.Context, name string) (*domain.User, error) { 57 | col := users.db.C(colUsers) 58 | 59 | user := domain.User{} 60 | if err := col.Find(bson.M{"name": name}).One(&user); err != nil { 61 | if err == mgo.ErrNotFound { 62 | return nil, errors.ResourceNotFound("User", name) 63 | } 64 | return nil, errors.Wrapf(err, "failed to fetch user") 65 | } 66 | 67 | user.SetDefaults() 68 | return &user, nil 69 | } 70 | 71 | // FindAll finds all users matching the tags. 72 | func (users *UserStore) FindAll(ctx context.Context, tags []string, limit int) ([]domain.User, error) { 73 | col := users.db.C(colUsers) 74 | 75 | filter := bson.M{} 76 | if len(tags) > 0 { 77 | filter["tags"] = bson.M{ 78 | "$in": tags, 79 | } 80 | } 81 | 82 | matches := []domain.User{} 83 | if err := col.Find(filter).Limit(limit).All(&matches); err != nil { 84 | return nil, errors.Wrapf(err, "failed to query for users") 85 | } 86 | return matches, nil 87 | } 88 | 89 | // Delete removes one user identified by the name. 90 | func (users *UserStore) Delete(ctx context.Context, name string) (*domain.User, error) { 91 | col := users.db.C(colUsers) 92 | 93 | ch := mgo.Change{ 94 | Remove: true, 95 | ReturnNew: true, 96 | Upsert: false, 97 | } 98 | user := domain.User{} 99 | _, err := col.Find(bson.M{"name": name}).Apply(ch, &user) 100 | if err != nil { 101 | if err == mgo.ErrNotFound { 102 | return nil, errors.ResourceNotFound("User", name) 103 | } 104 | return nil, err 105 | } 106 | 107 | return &user, nil 108 | } 109 | -------------------------------------------------------------------------------- /pkg/graceful/graceful.go: -------------------------------------------------------------------------------- 1 | package graceful 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "time" 11 | ) 12 | 13 | // LogFunc can be set on the server to customize the message printed when the 14 | // server is shutting down. 15 | type LogFunc func(msg string, args ...interface{}) 16 | 17 | // NewServer creates a wrapper around the given handler. 18 | func NewServer(handler http.Handler, timeout time.Duration, signals ...os.Signal) *Server { 19 | gss := &Server{} 20 | gss.server = &http.Server{Handler: handler} 21 | gss.signals = signals 22 | gss.Log = log.Printf 23 | gss.timeout = timeout 24 | return gss 25 | } 26 | 27 | // Server is a wrapper around an http handler. It provides methods 28 | // to start the server with graceful-shutdown enabled. 29 | type Server struct { 30 | Addr string 31 | Log LogFunc 32 | 33 | server *http.Server 34 | signals []os.Signal 35 | timeout time.Duration 36 | startErr error 37 | } 38 | 39 | // Serve starts the http listener with the registered http.Handler and 40 | // then blocks until a interrupt signal is received. 41 | func (gss *Server) Serve(l net.Listener) error { 42 | ctx, cancel := context.WithCancel(context.Background()) 43 | go func() { 44 | if err := gss.server.Serve(l); err != nil { 45 | gss.startErr = err 46 | cancel() 47 | } 48 | }() 49 | return gss.waitForInterrupt(ctx) 50 | } 51 | 52 | // ServeTLS starts the http listener with the registered http.Handler and 53 | // then blocks until a interrupt signal is received. 54 | func (gss *Server) ServeTLS(l net.Listener, certFile, keyFile string) error { 55 | ctx, cancel := context.WithCancel(context.Background()) 56 | go func() { 57 | if err := gss.server.ServeTLS(l, certFile, keyFile); err != nil { 58 | gss.startErr = err 59 | cancel() 60 | } 61 | }() 62 | return gss.waitForInterrupt(ctx) 63 | } 64 | 65 | // ListenAndServe serves the requests on a listener bound to interface 66 | // specified by Addr 67 | func (gss *Server) ListenAndServe() error { 68 | ctx, cancel := context.WithCancel(context.Background()) 69 | go func() { 70 | gss.server.Addr = gss.Addr 71 | if err := gss.server.ListenAndServe(); err != http.ErrServerClosed { 72 | gss.startErr = err 73 | cancel() 74 | } 75 | }() 76 | return gss.waitForInterrupt(ctx) 77 | } 78 | 79 | // ListenAndServeTLS serves the requests on a listener bound to interface 80 | // specified by Addr 81 | func (gss *Server) ListenAndServeTLS(certFile, keyFile string) error { 82 | ctx, cancel := context.WithCancel(context.Background()) 83 | go func() { 84 | if err := gss.server.ListenAndServeTLS(certFile, keyFile); err != http.ErrServerClosed { 85 | gss.startErr = err 86 | cancel() 87 | } 88 | }() 89 | return gss.waitForInterrupt(ctx) 90 | } 91 | 92 | func (gss *Server) waitForInterrupt(ctx context.Context) error { 93 | sigCh := make(chan os.Signal, 1) 94 | signal.Notify(sigCh, gss.signals...) 95 | 96 | select { 97 | case sig := <-sigCh: 98 | if gss.Log != nil { 99 | gss.Log("shutting down (signal=%s)...", sig) 100 | } 101 | break 102 | 103 | case <-ctx.Done(): 104 | return gss.startErr 105 | } 106 | 107 | return gss.shutdown() 108 | } 109 | 110 | func (gss *Server) shutdown() error { 111 | ctx, cancel := context.WithTimeout(context.Background(), gss.timeout) 112 | defer cancel() 113 | return gss.server.Shutdown(ctx) 114 | } 115 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "os" 7 | "time" 8 | 9 | "github.com/gorilla/mux" 10 | "github.com/spf13/viper" 11 | "github.com/spy16/droplets/interfaces/mongo" 12 | "github.com/spy16/droplets/interfaces/rest" 13 | "github.com/spy16/droplets/interfaces/web" 14 | "github.com/spy16/droplets/pkg/graceful" 15 | "github.com/spy16/droplets/pkg/logger" 16 | "github.com/spy16/droplets/pkg/middlewares" 17 | "github.com/spy16/droplets/usecases/posts" 18 | "github.com/spy16/droplets/usecases/users" 19 | ) 20 | 21 | func main() { 22 | cfg := loadConfig() 23 | lg := logger.New(os.Stderr, cfg.LogLevel, cfg.LogFormat) 24 | 25 | db, closeSession, err := mongo.Connect(cfg.MongoURI, true) 26 | if err != nil { 27 | lg.Fatalf("failed to connect to mongodb: %v", err) 28 | } 29 | defer closeSession() 30 | 31 | lg.Debugf("setting up rest api service") 32 | userStore := mongo.NewUserStore(db) 33 | postStore := mongo.NewPostStore(db) 34 | 35 | userRegistration := users.NewRegistrar(lg, userStore) 36 | userRetriever := users.NewRetriever(lg, userStore) 37 | 38 | postPub := posts.NewPublication(lg, postStore, userStore) 39 | postRet := posts.NewRetriever(lg, postStore) 40 | 41 | restHandler := rest.New(lg, userRegistration, userRetriever, postRet, postPub) 42 | webHandler, err := web.New(lg, web.Config{ 43 | TemplateDir: cfg.TemplateDir, 44 | StaticDir: cfg.StaticDir, 45 | }) 46 | if err != nil { 47 | lg.Fatalf("failed to setup web handler: %v", err) 48 | } 49 | 50 | srv := setupServer(cfg, lg, webHandler, restHandler) 51 | lg.Infof("listening for requests on :8080...") 52 | if err := srv.ListenAndServe(); err != nil { 53 | lg.Fatalf("http server exited: %s", err) 54 | } 55 | } 56 | 57 | func setupServer(cfg config, lg logger.Logger, web http.Handler, rest http.Handler) *graceful.Server { 58 | rest = middlewares.WithBasicAuth(lg, rest, 59 | middlewares.UserVerifierFunc(func(ctx context.Context, name, secret string) bool { 60 | return secret == "secret@123" 61 | }), 62 | ) 63 | 64 | router := mux.NewRouter() 65 | router.PathPrefix("/api").Handler(http.StripPrefix("/api", rest)) 66 | router.PathPrefix("/").Handler(web) 67 | 68 | handler := middlewares.WithRequestLogging(lg, router) 69 | handler = middlewares.WithRecovery(lg, handler) 70 | 71 | srv := graceful.NewServer(handler, cfg.GracefulTimeout, os.Interrupt) 72 | srv.Log = lg.Errorf 73 | srv.Addr = cfg.Addr 74 | return srv 75 | } 76 | 77 | type config struct { 78 | Addr string 79 | LogLevel string 80 | LogFormat string 81 | StaticDir string 82 | TemplateDir string 83 | GracefulTimeout time.Duration 84 | MongoURI string 85 | } 86 | 87 | func loadConfig() config { 88 | viper.SetDefault("MONGO_URI", "mongodb://localhost/droplets") 89 | viper.SetDefault("LOG_LEVEL", "debug") 90 | viper.SetDefault("LOG_FORMAT", "text") 91 | viper.SetDefault("ADDR", ":8080") 92 | viper.SetDefault("STATIC_DIR", "./web/static/") 93 | viper.SetDefault("TEMPLATE_DIR", "./web/templates/") 94 | viper.SetDefault("GRACEFUL_TIMEOUT", 20*time.Second) 95 | 96 | viper.ReadInConfig() 97 | viper.AutomaticEnv() 98 | 99 | return config{ 100 | // application configuration 101 | Addr: viper.GetString("ADDR"), 102 | StaticDir: viper.GetString("STATIC_DIR"), 103 | TemplateDir: viper.GetString("TEMPLATE_DIR"), 104 | LogLevel: viper.GetString("LOG_LEVEL"), 105 | LogFormat: viper.GetString("LOG_FORMAT"), 106 | GracefulTimeout: viper.GetDuration("GRACEFUL_TIMEOUT"), 107 | 108 | // store configuration 109 | MongoURI: viper.GetString("MONGO_URI"), 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /docs/organization.md: -------------------------------------------------------------------------------- 1 | 2 | # Directory Structure 3 | 4 | Directory structure is based on [Clean Architecture](http://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html). 5 | 6 | ![Clean Architecture](http://blog.cleancoder.com/uncle-bob/images/2012-08-13-the-clean-architecture/CleanArchitecture.jpg) 7 | 8 | Most important part of Clean Architecture is the **Dependency Rule**: 9 | 10 | > **source code dependencies can only point inwards** 11 | 12 | ### 1. `domain/` 13 | 14 | Package `domain` represents the `entities` layer from the Clean Architecture. Entities in this layer are 15 | *least likely to change when something external changes*. For example, the rules inside `Validate` methods 16 | are designed in such a way that the these are *absolute requirement* for the entity to belong to the domain. 17 | For example, a `User` object without `Name` does not belong to `domain` of Droplets. 18 | 19 | In other words, any feature or business requirement that comes in later, these definitions would still not 20 | change unless the requirement is leading to a domain change. 21 | 22 | This package **strictly cannot** have direct dependency on external packages. It can use built-in types, 23 | functions and standard library types/functions. 24 | 25 | > `domain` package makes one exception and imports `github.com/spy16/droplets/pkg/errors`. This is because 26 | > of errors are values and are a basic requirement across the application. In other words, the `errors` package 27 | > is used in place of `errors` package from the standard library. 28 | 29 | ### 2. `usecases/` 30 | 31 | Directory (not a package) `usecases` represents the `Use Cases` layer from the Clean Architecture. It encapsulates 32 | and implements all of the use cases of the system by directing the `entities` layer. This layer is expected to change 33 | when a new use case or business requirement is presented. 34 | 35 | In Droplets, Use cases are separated as packages based on the entity they primarily operate on. (e.g. `usecases/users` etc.) 36 | 37 | Any real use case would also need external entities such as persistence, external service integration etc. 38 | But this layer also **strictly** cannot have direct dependency on external packages. This crossing of boundaries 39 | is done through interfaces. 40 | 41 | For example, `users.Registrar` provides functions for registering users which requires storage functionality. But 42 | this cannot directly import a `mongo` or `sql` driver and implement storage functions. It can also not import an 43 | adapter from the `interfaces` package directly both of which would violate `Dependency Rule`. So instead, an interface 44 | `users.Store` is defined which is expected to injected when calling `NewRegistrar`. 45 | 46 | **Why is `Store` interface defined in `users` package?** 47 | 48 | See [Interfaces](interfaces.md) for conventions around interfaces. 49 | 50 | 51 | ### 3. `interfaces/` 52 | 53 | > Should not be confused with Go `interface` keyword. 54 | 55 | - Represents the `interface-adapter` layer from the Clean Architecture 56 | - This is the layer that cares about the external world (i.e, external dependencies). 57 | - Interfacing includes: 58 | - Exposing `usecases` as API (e.g., RPC, GraphQL, REST etc.) 59 | - Presenting `usecases` to end-user (e.g., GUI, WebApp etc.) 60 | - Persistence logic (e.g., cache, datastores etc.) 61 | - Integrating an external service required by `usecases` 62 | - Packages inside this are organized in 2 ways: 63 | 1. Based on the medium they use (e.g., `rest`, `web` etc.) 64 | 2. Based on the external dependency they use (e.g., `mongo`, `redis` etc.) 65 | 66 | ### 4. `pkg/` 67 | 68 | - Contains re-usable packages that is safe to be imported in other projects 69 | - This package should not import anything from `domain`, `interfaces`, `usecases` or their sub-packages 70 | 71 | 72 | ### 5. `web/` 73 | 74 | - `web/` is **NOT** a Go package 75 | - Contains web assets such as css, images, templates etc. -------------------------------------------------------------------------------- /docs/interfaces.md: -------------------------------------------------------------------------------- 1 | 2 | # Interfaces 3 | 4 | Following are some best practices for using interfaces: 5 | 6 | 1. Define small interfaces with well defined scope 7 | - Single-method interfaces are ideal (e.g. `io.Reader`, `io.Writer` etc.) 8 | - [Bigger the interface, weaker the abstraction - Go Proverbs by Rob Pike](https://www.youtube.com/watch?v=PAAkCSZUG1c&t=5m17s) 9 | 2. Accept interfaces, return structs 10 | - Interfaces should be defined where they are used [Read More](#where-should-i-define-the-interface-) 11 | 12 | ## Where should I define the interface ? 13 | 14 | From [CodeReviewComments](https://github.com/golang/go/wiki/CodeReviewComments#interfaces): 15 | 16 | > Go interfaces generally belong in the package that uses values of the interface type, not the 17 | > package that implements those values. 18 | 19 | Interfaces are contracts that should be used to define the minimal requirement of a client 20 | to execute its functionality. In other words, client defines what it needs and not the 21 | implementor. So, interfaces should generally be defined on the client side. This is also inline 22 | with the [Interface Segregation Principle](https://en.wikipedia.org/wiki/Interface_segregation_principle) 23 | from [SOLID](https://en.wikipedia.org/wiki/SOLID) principles. 24 | 25 | A **bad** pattern that shows up quite a lot: 26 | 27 | ```go 28 | package producer 29 | 30 | func NewThinger() Thinger { 31 | return defaultThinger{ … } 32 | } 33 | 34 | type Thinger interface { 35 | Thing() bool 36 | } 37 | 38 | 39 | type defaultThinger struct{ … } 40 | func (t defaultThinger) Thing() bool { … } 41 | ``` 42 | 43 | ### Why is this bad? 44 | 45 | Go uses [Structural Type System](https://en.wikipedia.org/wiki/Structural_type_system) as opposed to 46 | [Nominal Type System](https://en.wikipedia.org/wiki/Nominal_type_system) used in other static languages 47 | like `Java`, `C#` etc. This simply means that a type `MyType` does not need to add `implements Doer` clause 48 | to be compatible with an interface `Doer`. `MyType` is compatible with `Doer` interface if it has all the 49 | methods defined in `Doer`. 50 | 51 | Read following articles for more information: 52 | 53 | 1. https://medium.com/@cep21/preemptive-interface-anti-pattern-in-go-54c18ac0668a 54 | 2. https://medium.com/@cep21/what-accept-interfaces-return-structs-means-in-go-2fe879e25ee8 55 | 56 | This also provides an interesting power to Go interfaces. Clients are truly free to define interfaces when they 57 | need to. For example consider the following function: 58 | 59 | ```go 60 | func writeData(f *os.File, data string) { 61 | f.Write([]byte(data)) 62 | } 63 | ``` 64 | 65 | Let's assume after sometime a new feature requirement which requires us to write to a tcp connection. One 66 | thing we could do is define a new function: 67 | 68 | ```go 69 | func writeDataToTCPCon(con *net.TCPConn, data string) { 70 | con.Write([]byte(data)) 71 | } 72 | ``` 73 | 74 | But this approach is tedious and will grow out of control quickly as new requirements are added. Also, different 75 | writers cannot be injected into other entities easily. But instead, you can simply refactor the `writeData` function 76 | as below: 77 | 78 | ```go 79 | type writer interface { 80 | Write([]byte) (int, error) 81 | } 82 | 83 | func writeData(wr writer, data string) { 84 | wr.Write([]byte(data)) 85 | } 86 | ``` 87 | 88 | Refactored `writeData` will continue to work with our existing code that is passing `*os.File` since it 89 | implements `writer`. In addition, `writeData` function can now accept anything that implements `writer` 90 | which includes `os.File`, `net.TCPConn`, `http.ResponseWriter` etc. (And every single Go entity in the 91 | **entire world** that has a method `Write([]byte) (int, error)`) 92 | 93 | Note that, this pattern is *not possible in other languages*. Because, after refactoring `writeData` to 94 | accept a new interface `writer`, you need to refactor all the classes you want to use with `writeData` to 95 | have `implements writer` in their declarations. 96 | 97 | Another advantage is that client is free to define the subset of features it requires instead of accepting 98 | more than it needs. 99 | 100 | 101 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 6 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 7 | github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= 8 | github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= 9 | github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk= 10 | github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 11 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 12 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 13 | github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= 14 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 15 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 16 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 17 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 18 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 19 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 20 | github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= 21 | github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 22 | github.com/mitchellh/mapstructure v1.0.0/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 23 | github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= 24 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 25 | github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= 26 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 27 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 28 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 29 | github.com/sirupsen/logrus v1.2.0 h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo= 30 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 31 | github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= 32 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 33 | github.com/spf13/cast v1.2.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg= 34 | github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= 35 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 36 | github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= 37 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 38 | github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 39 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 40 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 41 | github.com/spf13/viper v1.2.1 h1:bIcUwXqLseLF3BDAZduuNfekWG87ibtFxi59Bq+oI9M= 42 | github.com/spf13/viper v1.2.1/go.mod h1:P4AexN0a+C9tGAnUFNwDMYYZv3pjFuvmeiMyKRaNVlI= 43 | github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= 44 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 45 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 46 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 47 | github.com/unrolled/render v0.0.0-20180914162206-b9786414de4d h1:ggUgChAeyge4NZ4QUw6lhHsVymzwSDJOZcE0s2X8S20= 48 | github.com/unrolled/render v0.0.0-20180914162206-b9786414de4d/go.mod h1:tu82oB5W2ykJRVioYsB+IQKcft7ryBr7w12qMBUPyXg= 49 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793 h1:u+LnwYTOOW7Ukr/fppxEb1Nwz0AtPflrblfvUudpo+I= 50 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 51 | golang.org/x/crypto v0.0.0-20181106171534-e4dc69e5b2fd h1:VtIkGDhk0ph3t+THbvXHfMZ8QHgsBO39Nh52+74pq7w= 52 | golang.org/x/crypto v0.0.0-20181106171534-e4dc69e5b2fd/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 53 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33 h1:I6FyU15t786LL7oL/hn43zqTuEGr4PN7F4XJ1p4E3Y8= 54 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 55 | golang.org/x/sys v0.0.0-20180906133057-8cf3aee42992 h1:BH3eQWeGbwRU2+wxxuuPOdFBmaiBH81O8BugSjHeTFg= 56 | golang.org/x/sys v0.0.0-20180906133057-8cf3aee42992/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 57 | golang.org/x/sys v0.0.0-20181116161606-93218def8b18 h1:Wh+XCfg3kNpjhdq2LXrsiOProjtQZKme5XUx7VcxwAw= 58 | golang.org/x/sys v0.0.0-20181116161606-93218def8b18/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 59 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 60 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 61 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 62 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 63 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 64 | gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU= 65 | gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= 66 | gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= 67 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 68 | --------------------------------------------------------------------------------