Repository: spy16/droplets Branch: master Commit: b94dee34e852 Files: 61 Total size: 70.1 KB Directory structure: gitextract_s7jwt71_/ ├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── docker-compose.yml ├── docs/ │ ├── interfaces.md │ └── organization.md ├── domain/ │ ├── doc.go │ ├── meta.go │ ├── meta_test.go │ ├── post.go │ ├── post_test.go │ ├── user.go │ └── user_test.go ├── go.mod ├── go.sum ├── interfaces/ │ ├── mongo/ │ │ ├── doc.go │ │ ├── mongo.go │ │ ├── posts.go │ │ └── users.go │ ├── rest/ │ │ ├── doc.go │ │ ├── posts.go │ │ ├── rest.go │ │ ├── users.go │ │ └── utils.go │ └── web/ │ ├── app.go │ ├── doc.go │ ├── fs.go │ └── web.go ├── main.go ├── pkg/ │ ├── doc.go │ ├── errors/ │ │ ├── authorization.go │ │ ├── doc.go │ │ ├── error.go │ │ ├── errors.go │ │ ├── resource.go │ │ ├── stack.go │ │ └── validation.go │ ├── graceful/ │ │ ├── doc.go │ │ └── graceful.go │ ├── logger/ │ │ ├── doc.go │ │ └── logrus.go │ ├── middlewares/ │ │ ├── authn.go │ │ ├── doc.go │ │ ├── logging.go │ │ ├── recovery.go │ │ └── utils.go │ └── render/ │ ├── doc.go │ └── render.go ├── usecases/ │ ├── posts/ │ │ ├── doc.go │ │ ├── publish.go │ │ ├── retrieval.go │ │ └── store.go │ └── users/ │ ├── doc.go │ ├── registration.go │ ├── retrieval.go │ └── store.go └── web/ ├── static/ │ └── main.css └── templates/ └── index.tpl ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ bin/ vendor/ # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, build with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out .DS_Store # temporarily do not track this PRACTICES.md # custom ignores expt/ test.db .vscode/ .idea/ ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Droplets Thanks for taking the time to contribute. You are Awesome! :heart: Droplets is built to showcase ideas, patterns and best practices which can be applied while building cool stuff with Go. ## What can I contribute and How ? A project with realistic development cycle is required to be able to demonstrate different ideas as applicable in real-world scenarios. This means, any type of contribution that a typical open-source project would go through are welcome here. Some possible contributions: - Suggest features to add - Suggest improvements to code - Suggest improvements to documentation - Open an issue and discuss a practice used - Try the project and report bugs - Designs for the web app Or any other contribution you can think of (And be sure to send a PR to add it to this document, which itself is another contribution!) You can simply follow the guidelines from [opensource.guide](https://opensource.guide/how-to-contribute/). ## Responsibilities * No platform specific code * 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) * Keep PRs as small as possible to make it easy to review and discuss. * Be welcoming to newcomers and encourage diverse new contributors from all backgrounds. * Code change PRs must have unit tests ================================================ FILE: Dockerfile ================================================ FROM golang:1.11 as builder RUN mkdir /droplets-src WORKDIR /droplets-src COPY ./ . RUN CGO_ENABLED=0 make setup all FROM alpine:latest RUN mkdir /app WORKDIR /app COPY --from=builder /droplets-src/bin/droplets ./ COPY --from=builder /droplets-src/web ./web EXPOSE 8080 CMD ["./droplets"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 Shivaprasad Bhat Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ build: @echo "Building droplets at './bin/droplets' ..." @go build -o bin/droplets clean: rm -rf ./bin all: lint vet cyclo test build test: @echo "Running unit tests..." @go test -cover ./... cyclo: @echo "Checking cyclomatic complexity..." @gocyclo -over 7 ./ vet: @echo "Running vet..." @go vet ./... lint: @echo "Running golint..." @golint ./... setup: @go get -u golang.org/x/lint/golint @go get -u github.com/fzipp/gocyclo ================================================ FILE: README.md ================================================ > WIP # droplets [![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) [![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) A platform for Gophers similar to the awesome [Golang News](http://golangnews.com). ## Why? Droplets is built to showcase: 1. Application of [CodeReviewComments](https://github.com/golang/go/wiki/CodeReviewComments) and [EffectiveGo](https://golang.org/doc/effective_go.html) 2. Usage of [Clean Architecture](http://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) 3. Testing practices such as [Table-driven tests](https://github.com/golang/go/wiki/TableDrivenTests) Follow the links to understand best practices and conventions used: 1. [Directory Structure](./docs/organization.md) 2. [Interfaces](./docs/interfaces.md) ## Building Droplets uses `go mod` (available from `go 1.11`) for dependency management. To test and build, run `make all`. ## License [![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) ================================================ FILE: docker-compose.yml ================================================ version: '3.2' services: droplets: build: ./ environment: - MONGO_URI=mongodb://mongo - LOG_LEVEL=info ports: - "8080:8080" links: - mongo networks: - droplets_net mongo: image: mongo:3-stretch ports: - "27017:27017" networks: - droplets_net networks: droplets_net: ================================================ FILE: docs/interfaces.md ================================================ # Interfaces Following are some best practices for using interfaces: 1. Define small interfaces with well defined scope - Single-method interfaces are ideal (e.g. `io.Reader`, `io.Writer` etc.) - [Bigger the interface, weaker the abstraction - Go Proverbs by Rob Pike](https://www.youtube.com/watch?v=PAAkCSZUG1c&t=5m17s) 2. Accept interfaces, return structs - Interfaces should be defined where they are used [Read More](#where-should-i-define-the-interface-) ## Where should I define the interface ? From [CodeReviewComments](https://github.com/golang/go/wiki/CodeReviewComments#interfaces): > Go interfaces generally belong in the package that uses values of the interface type, not the > package that implements those values. Interfaces are contracts that should be used to define the minimal requirement of a client to execute its functionality. In other words, client defines what it needs and not the implementor. So, interfaces should generally be defined on the client side. This is also inline with the [Interface Segregation Principle](https://en.wikipedia.org/wiki/Interface_segregation_principle) from [SOLID](https://en.wikipedia.org/wiki/SOLID) principles. A **bad** pattern that shows up quite a lot: ```go package producer func NewThinger() Thinger { return defaultThinger{ … } } type Thinger interface { Thing() bool } type defaultThinger struct{ … } func (t defaultThinger) Thing() bool { … } ``` ### Why is this bad? Go uses [Structural Type System](https://en.wikipedia.org/wiki/Structural_type_system) as opposed to [Nominal Type System](https://en.wikipedia.org/wiki/Nominal_type_system) used in other static languages like `Java`, `C#` etc. This simply means that a type `MyType` does not need to add `implements Doer` clause to be compatible with an interface `Doer`. `MyType` is compatible with `Doer` interface if it has all the methods defined in `Doer`. Read following articles for more information: 1. https://medium.com/@cep21/preemptive-interface-anti-pattern-in-go-54c18ac0668a 2. https://medium.com/@cep21/what-accept-interfaces-return-structs-means-in-go-2fe879e25ee8 This also provides an interesting power to Go interfaces. Clients are truly free to define interfaces when they need to. For example consider the following function: ```go func writeData(f *os.File, data string) { f.Write([]byte(data)) } ``` Let's assume after sometime a new feature requirement which requires us to write to a tcp connection. One thing we could do is define a new function: ```go func writeDataToTCPCon(con *net.TCPConn, data string) { con.Write([]byte(data)) } ``` But this approach is tedious and will grow out of control quickly as new requirements are added. Also, different writers cannot be injected into other entities easily. But instead, you can simply refactor the `writeData` function as below: ```go type writer interface { Write([]byte) (int, error) } func writeData(wr writer, data string) { wr.Write([]byte(data)) } ``` Refactored `writeData` will continue to work with our existing code that is passing `*os.File` since it implements `writer`. In addition, `writeData` function can now accept anything that implements `writer` which includes `os.File`, `net.TCPConn`, `http.ResponseWriter` etc. (And every single Go entity in the **entire world** that has a method `Write([]byte) (int, error)`) Note that, this pattern is *not possible in other languages*. Because, after refactoring `writeData` to accept a new interface `writer`, you need to refactor all the classes you want to use with `writeData` to have `implements writer` in their declarations. Another advantage is that client is free to define the subset of features it requires instead of accepting more than it needs. ================================================ FILE: docs/organization.md ================================================ # Directory Structure Directory structure is based on [Clean Architecture](http://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html). ![Clean Architecture](http://blog.cleancoder.com/uncle-bob/images/2012-08-13-the-clean-architecture/CleanArchitecture.jpg) Most important part of Clean Architecture is the **Dependency Rule**: > **source code dependencies can only point inwards** ### 1. `domain/` Package `domain` represents the `entities` layer from the Clean Architecture. Entities in this layer are *least likely to change when something external changes*. For example, the rules inside `Validate` methods are designed in such a way that the these are *absolute requirement* for the entity to belong to the domain. For example, a `User` object without `Name` does not belong to `domain` of Droplets. In other words, any feature or business requirement that comes in later, these definitions would still not change unless the requirement is leading to a domain change. This package **strictly cannot** have direct dependency on external packages. It can use built-in types, functions and standard library types/functions. > `domain` package makes one exception and imports `github.com/spy16/droplets/pkg/errors`. This is because > of errors are values and are a basic requirement across the application. In other words, the `errors` package > is used in place of `errors` package from the standard library. ### 2. `usecases/` Directory (not a package) `usecases` represents the `Use Cases` layer from the Clean Architecture. It encapsulates and implements all of the use cases of the system by directing the `entities` layer. This layer is expected to change when a new use case or business requirement is presented. In Droplets, Use cases are separated as packages based on the entity they primarily operate on. (e.g. `usecases/users` etc.) Any real use case would also need external entities such as persistence, external service integration etc. But this layer also **strictly** cannot have direct dependency on external packages. This crossing of boundaries is done through interfaces. For example, `users.Registrar` provides functions for registering users which requires storage functionality. But this cannot directly import a `mongo` or `sql` driver and implement storage functions. It can also not import an adapter from the `interfaces` package directly both of which would violate `Dependency Rule`. So instead, an interface `users.Store` is defined which is expected to injected when calling `NewRegistrar`. **Why is `Store` interface defined in `users` package?** See [Interfaces](interfaces.md) for conventions around interfaces. ### 3. `interfaces/` > Should not be confused with Go `interface` keyword. - Represents the `interface-adapter` layer from the Clean Architecture - This is the layer that cares about the external world (i.e, external dependencies). - Interfacing includes: - Exposing `usecases` as API (e.g., RPC, GraphQL, REST etc.) - Presenting `usecases` to end-user (e.g., GUI, WebApp etc.) - Persistence logic (e.g., cache, datastores etc.) - Integrating an external service required by `usecases` - Packages inside this are organized in 2 ways: 1. Based on the medium they use (e.g., `rest`, `web` etc.) 2. Based on the external dependency they use (e.g., `mongo`, `redis` etc.) ### 4. `pkg/` - Contains re-usable packages that is safe to be imported in other projects - This package should not import anything from `domain`, `interfaces`, `usecases` or their sub-packages ### 5. `web/` - `web/` is **NOT** a Go package - Contains web assets such as css, images, templates etc. ================================================ FILE: domain/doc.go ================================================ // Package domain contains domain entities and core validation rules. package domain ================================================ FILE: domain/meta.go ================================================ package domain import ( "strings" "time" "github.com/spy16/droplets/pkg/errors" ) // Meta represents metadata about different entities. type Meta struct { // Name represents a unique name/identifier for the object. Name string `json:"name" bson:"name"` // Tags can contain additional metadata about the object. Tags []string `json:"tags,omitempty" bson:"tags"` // CreateAt represents the time at which this object was created. CreatedAt time.Time `json:"created_at,omitempty" bson:"created_at"` // UpdatedAt represents the time at which this object was last // modified. UpdatedAt time.Time `json:"updated_at,omitempty" bson:"updated_at"` } // SetDefaults sets sensible defaults on meta. func (meta *Meta) SetDefaults() { if meta.CreatedAt.IsZero() { meta.CreatedAt = time.Now() meta.UpdatedAt = time.Now() } } // Validate performs basic validation of the metadata. func (meta Meta) Validate() error { switch { case empty(meta.Name): return errors.MissingField("Name") } return nil } func empty(str string) bool { return len(strings.TrimSpace(str)) == 0 } ================================================ FILE: domain/meta_test.go ================================================ package domain_test import ( "fmt" "testing" "github.com/spy16/droplets/domain" "github.com/spy16/droplets/pkg/errors" ) func TestMeta_Validate(suite *testing.T) { suite.Parallel() cases := []struct { meta domain.Meta expectErr bool errType string }{} for id, cs := range cases { suite.Run(fmt.Sprintf("Case#%d", id), func(t *testing.T) { testValidation(t, cs.meta, cs.expectErr, cs.errType) }) } } func testValidation(t *testing.T, validator validatable, expectErr bool, errType string) { err := validator.Validate() if err != nil { if !expectErr { t.Errorf("unexpected error: %s", err) return } if actualType := errors.Type(err); actualType != errType { t.Errorf("expecting error type '%s', got '%s'", errType, actualType) } return } if expectErr { t.Errorf("was expecting an error of type '%s', got nil", errType) return } } type validatable interface { Validate() error } ================================================ FILE: domain/post.go ================================================ package domain import ( "fmt" "strings" "github.com/spy16/droplets/pkg/errors" ) // Common content types. const ( ContentLibrary = "library" ContentLink = "link" ContentVideo = "video" ) var validTypes = []string{ContentLibrary, ContentLink, ContentVideo} // Post represents an article, link, video etc. type Post struct { Meta `json:",inline" bson:",inline"` // Type should state the type of the content. (e.g., library, // video, link etc.) Type string `json:"type" bson:"type"` // Body should contain the actual content according to the Type // specified. (e.g. github.com/spy16/parens when Type=link) Body string `json:"body" bson:"body"` // Owner represents the name of the user who created the post. Owner string `json:"owner" bson:"owner"` } // Validate performs validation of the post. func (post Post) Validate() error { if err := post.Meta.Validate(); err != nil { return err } if len(strings.TrimSpace(post.Body)) == 0 { return errors.MissingField("Body") } if len(strings.TrimSpace(post.Owner)) == 0 { return errors.MissingField("Owner") } if !contains(post.Type, validTypes) { return errors.InvalidValue("Type", fmt.Sprintf("type must be one of: %s", strings.Join(validTypes, ","))) } return nil } func contains(val string, vals []string) bool { for _, item := range vals { if val == item { return true } } return false } ================================================ FILE: domain/post_test.go ================================================ package domain_test import ( "fmt" "testing" "github.com/spy16/droplets/domain" ) func TestPost_Validate(suite *testing.T) { suite.Parallel() validMeta := domain.Meta{ Name: "hello", } cases := []struct { post domain.Post expectErr bool }{ { post: domain.Post{}, expectErr: true, }, { post: domain.Post{ Meta: validMeta, }, expectErr: true, }, { post: domain.Post{ Meta: validMeta, Body: "hello world post!", }, expectErr: true, }, { post: domain.Post{ Meta: validMeta, Type: "blah", Owner: "spy16", Body: "hello world post!", }, expectErr: true, }, { post: domain.Post{ Meta: validMeta, Type: domain.ContentLibrary, Body: "hello world post!", }, expectErr: true, }, { post: domain.Post{ Meta: validMeta, Type: domain.ContentLibrary, Body: "hello world post!", Owner: "spy16", }, expectErr: false, }, } for id, cs := range cases { suite.Run(fmt.Sprintf("#%d", id), func(t *testing.T) { err := cs.post.Validate() if err != nil { if !cs.expectErr { t.Errorf("was not expecting error, got '%s'", err) } return } if cs.expectErr { t.Errorf("was expecting error, got nil") } }) } } ================================================ FILE: domain/user.go ================================================ package domain import ( "net/mail" "github.com/spy16/droplets/pkg/errors" "golang.org/x/crypto/bcrypt" ) // User represents information about registered users. type User struct { Meta `json:",inline,omitempty" bson:",inline"` // Email should contain a valid email of the user. Email string `json:"email,omitempty" bson:"email"` // Secret represents the user secret. Secret string `json:"secret,omitempty" bson:"secret"` } // Validate performs basic validation of user information. func (user User) Validate() error { if err := user.Meta.Validate(); err != nil { return err } _, err := mail.ParseAddress(user.Email) if err != nil { return errors.InvalidValue("Email", err.Error()) } return nil } // HashSecret creates bcrypt hash of the password. func (user *User) HashSecret() error { bytes, err := bcrypt.GenerateFromPassword([]byte(user.Secret), 4) if err != nil { return err } user.Secret = string(bytes) return nil } // CheckSecret compares the cleartext password with the hash. func (user User) CheckSecret(password string) bool { err := bcrypt.CompareHashAndPassword([]byte(user.Secret), []byte(password)) return err == nil } ================================================ FILE: domain/user_test.go ================================================ package domain_test import ( "fmt" "testing" "github.com/spy16/droplets/domain" "github.com/spy16/droplets/pkg/errors" ) func TestUser_CheckSecret(t *testing.T) { password := "hello@world!" user := domain.User{} user.Secret = password err := user.HashSecret() if err != nil { t.Errorf("was not expecting error, got '%s'", err) } if !user.CheckSecret(password) { t.Errorf("CheckSecret expected to return true, but got false") } } func TestUser_Validate(suite *testing.T) { suite.Parallel() cases := []struct { user domain.User expectErr bool errType string }{ { user: domain.User{}, expectErr: true, errType: errors.TypeMissingField, }, { user: domain.User{ Meta: domain.Meta{ Name: "spy16", }, Email: "blah.com", }, expectErr: true, errType: errors.TypeInvalidValue, }, { user: domain.User{ Meta: domain.Meta{ Name: "spy16", }, Email: "spy16 ", }, expectErr: false, }, } for id, cs := range cases { suite.Run(fmt.Sprintf("Case#%d", id), func(t *testing.T) { testValidation(t, cs.user, cs.expectErr, cs.errType) }) } } ================================================ FILE: go.mod ================================================ module github.com/spy16/droplets go 1.15 require ( github.com/BurntSushi/toml v0.3.1 // indirect github.com/gorilla/context v1.1.1 // indirect github.com/gorilla/mux v1.6.2 github.com/kr/pretty v0.1.0 // indirect github.com/mitchellh/mapstructure v1.1.2 // indirect github.com/pelletier/go-toml v1.2.0 github.com/sirupsen/logrus v1.2.0 github.com/spf13/cast v1.3.0 // indirect github.com/spf13/pflag v1.0.3 // indirect github.com/spf13/viper v1.2.1 github.com/unrolled/render v0.0.0-20180914162206-b9786414de4d golang.org/x/crypto v0.0.0-20181106171534-e4dc69e5b2fd golang.org/x/sys v0.0.0-20181116161606-93218def8b18 // indirect gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce ) ================================================ FILE: go.sum ================================================ github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mitchellh/mapstructure v1.0.0/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sirupsen/logrus v1.2.0 h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/cast v1.2.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg= github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/viper v1.2.1 h1:bIcUwXqLseLF3BDAZduuNfekWG87ibtFxi59Bq+oI9M= github.com/spf13/viper v1.2.1/go.mod h1:P4AexN0a+C9tGAnUFNwDMYYZv3pjFuvmeiMyKRaNVlI= github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/unrolled/render v0.0.0-20180914162206-b9786414de4d h1:ggUgChAeyge4NZ4QUw6lhHsVymzwSDJOZcE0s2X8S20= github.com/unrolled/render v0.0.0-20180914162206-b9786414de4d/go.mod h1:tu82oB5W2ykJRVioYsB+IQKcft7ryBr7w12qMBUPyXg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793 h1:u+LnwYTOOW7Ukr/fppxEb1Nwz0AtPflrblfvUudpo+I= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181106171534-e4dc69e5b2fd h1:VtIkGDhk0ph3t+THbvXHfMZ8QHgsBO39Nh52+74pq7w= golang.org/x/crypto v0.0.0-20181106171534-e4dc69e5b2fd/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33 h1:I6FyU15t786LL7oL/hn43zqTuEGr4PN7F4XJ1p4E3Y8= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180906133057-8cf3aee42992 h1:BH3eQWeGbwRU2+wxxuuPOdFBmaiBH81O8BugSjHeTFg= golang.org/x/sys v0.0.0-20180906133057-8cf3aee42992/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116161606-93218def8b18 h1:Wh+XCfg3kNpjhdq2LXrsiOProjtQZKme5XUx7VcxwAw= golang.org/x/sys v0.0.0-20181116161606-93218def8b18/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU= gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= ================================================ FILE: interfaces/mongo/doc.go ================================================ // Package mongo contains any component in the entire project which interfaces // with MongoDB (e.g. different store implementations). package mongo ================================================ FILE: interfaces/mongo/mongo.go ================================================ package mongo import ( "gopkg.in/mgo.v2" ) // Connect to a MongoDB instance located by mongo-uri using the `mgo` // driver. func Connect(uri string, failFast bool) (*mgo.Database, func(), error) { di, err := mgo.ParseURL(uri) if err != nil { return nil, doNothing, err } di.FailFast = failFast session, err := mgo.DialWithInfo(di) if err != nil { return nil, doNothing, err } return session.DB(di.Database), session.Close, nil } func doNothing() {} ================================================ FILE: interfaces/mongo/posts.go ================================================ package mongo import ( "context" "time" "github.com/spy16/droplets/domain" "github.com/spy16/droplets/pkg/errors" "gopkg.in/mgo.v2" "gopkg.in/mgo.v2/bson" ) const colPosts = "posts" // NewPostStore initializes the Posts store with given mongo db handle. func NewPostStore(db *mgo.Database) *PostStore { return &PostStore{ db: db, } } // PostStore manages persistence and retrieval of posts. type PostStore struct { db *mgo.Database } // Exists checks if a post exists by name. func (posts *PostStore) Exists(ctx context.Context, name string) bool { col := posts.db.C(colPosts) count, err := col.Find(bson.M{"name": name}).Count() if err != nil { return false } return count > 0 } // Get finds a post by name. func (posts *PostStore) Get(ctx context.Context, name string) (*domain.Post, error) { col := posts.db.C(colPosts) post := domain.Post{} if err := col.Find(bson.M{"name": name}).One(&post); err != nil { if err == mgo.ErrNotFound { return nil, errors.ResourceNotFound("Post", name) } return nil, errors.Wrapf(err, "failed to fetch post") } post.SetDefaults() return &post, nil } // Save validates and persists the post. func (posts *PostStore) Save(ctx context.Context, post domain.Post) (*domain.Post, error) { post.SetDefaults() if err := post.Validate(); err != nil { return nil, err } post.CreatedAt = time.Now() post.UpdatedAt = time.Now() col := posts.db.C(colPosts) if err := col.Insert(post); err != nil { return nil, err } return &post, nil } // Delete removes one post identified by the name. func (posts *PostStore) Delete(ctx context.Context, name string) (*domain.Post, error) { col := posts.db.C(colPosts) ch := mgo.Change{ Remove: true, ReturnNew: true, Upsert: false, } post := domain.Post{} _, err := col.Find(bson.M{"name": name}).Apply(ch, &post) if err != nil { if err == mgo.ErrNotFound { return nil, errors.ResourceNotFound("Post", name) } return nil, err } return &post, nil } ================================================ FILE: interfaces/mongo/users.go ================================================ package mongo import ( "context" "time" "github.com/spy16/droplets/domain" "github.com/spy16/droplets/pkg/errors" "gopkg.in/mgo.v2" "gopkg.in/mgo.v2/bson" ) const colUsers = "users" // NewUserStore initializes a users store with the given db handle. func NewUserStore(db *mgo.Database) *UserStore { return &UserStore{ db: db, } } // UserStore provides functions for persisting User entities in MongoDB. type UserStore struct { db *mgo.Database } // Exists checks if the user identified by the given username already // exists. Will return false in case of any error. func (users *UserStore) Exists(ctx context.Context, name string) bool { col := users.db.C(colUsers) count, err := col.Find(bson.M{"name": name}).Count() if err != nil { return false } return count > 0 } // Save validates and persists the user. func (users *UserStore) Save(ctx context.Context, user domain.User) (*domain.User, error) { user.SetDefaults() if err := user.Validate(); err != nil { return nil, err } user.CreatedAt = time.Now() user.UpdatedAt = time.Now() col := users.db.C(colUsers) if err := col.Insert(user); err != nil { return nil, err } return &user, nil } // FindByName finds a user by name. If not found, returns ResourceNotFound error. func (users *UserStore) FindByName(ctx context.Context, name string) (*domain.User, error) { col := users.db.C(colUsers) user := domain.User{} if err := col.Find(bson.M{"name": name}).One(&user); err != nil { if err == mgo.ErrNotFound { return nil, errors.ResourceNotFound("User", name) } return nil, errors.Wrapf(err, "failed to fetch user") } user.SetDefaults() return &user, nil } // FindAll finds all users matching the tags. func (users *UserStore) FindAll(ctx context.Context, tags []string, limit int) ([]domain.User, error) { col := users.db.C(colUsers) filter := bson.M{} if len(tags) > 0 { filter["tags"] = bson.M{ "$in": tags, } } matches := []domain.User{} if err := col.Find(filter).Limit(limit).All(&matches); err != nil { return nil, errors.Wrapf(err, "failed to query for users") } return matches, nil } // Delete removes one user identified by the name. func (users *UserStore) Delete(ctx context.Context, name string) (*domain.User, error) { col := users.db.C(colUsers) ch := mgo.Change{ Remove: true, ReturnNew: true, Upsert: false, } user := domain.User{} _, err := col.Find(bson.M{"name": name}).Apply(ch, &user) if err != nil { if err == mgo.ErrNotFound { return nil, errors.ResourceNotFound("User", name) } return nil, err } return &user, nil } ================================================ FILE: interfaces/rest/doc.go ================================================ // Package rest exposes the features of droplets as REST API. This // package uses gorilla/mux for routing. package rest ================================================ FILE: interfaces/rest/posts.go ================================================ package rest import ( "context" "net/http" "github.com/gorilla/mux" "github.com/spy16/droplets/domain" "github.com/spy16/droplets/pkg/logger" "github.com/spy16/droplets/pkg/middlewares" "github.com/spy16/droplets/usecases/posts" ) func addPostsAPI(router *mux.Router, pub postPublication, ret postRetriever, lg logger.Logger) { pc := &postController{} pc.ret = ret pc.pub = pub pc.Logger = lg router.HandleFunc("/v1/posts", pc.search).Methods(http.MethodGet) router.HandleFunc("/v1/posts/{name}", pc.get).Methods(http.MethodGet) router.HandleFunc("/v1/posts/{name}", pc.delete).Methods(http.MethodDelete) router.HandleFunc("/v1/posts", pc.post).Methods(http.MethodPost) } type postController struct { logger.Logger pub postPublication ret postRetriever } func (pc *postController) search(wr http.ResponseWriter, req *http.Request) { posts, err := pc.ret.Search(req.Context(), posts.Query{}) if err != nil { respondErr(wr, err) return } respond(wr, http.StatusOK, posts) } func (pc *postController) get(wr http.ResponseWriter, req *http.Request) { name := mux.Vars(req)["name"] post, err := pc.ret.Get(req.Context(), name) if err != nil { respondErr(wr, err) return } respond(wr, http.StatusOK, post) } func (pc *postController) post(wr http.ResponseWriter, req *http.Request) { post := domain.Post{} if err := readRequest(req, &post); err != nil { pc.Warnf("failed to read user request: %s", err) respond(wr, http.StatusBadRequest, err) return } user, _ := middlewares.User(req) post.Owner = user published, err := pc.pub.Publish(req.Context(), post) if err != nil { respondErr(wr, err) return } respond(wr, http.StatusCreated, published) } func (pc *postController) delete(wr http.ResponseWriter, req *http.Request) { name := mux.Vars(req)["name"] post, err := pc.pub.Delete(req.Context(), name) if err != nil { respondErr(wr, err) return } respond(wr, http.StatusOK, post) } type postRetriever interface { Get(ctx context.Context, name string) (*domain.Post, error) Search(ctx context.Context, query posts.Query) ([]domain.Post, error) } type postPublication interface { Publish(ctx context.Context, post domain.Post) (*domain.Post, error) Delete(ctx context.Context, name string) (*domain.Post, error) } ================================================ FILE: interfaces/rest/rest.go ================================================ package rest import ( "net/http" "github.com/spy16/droplets/pkg/render" "github.com/gorilla/mux" "github.com/spy16/droplets/pkg/errors" "github.com/spy16/droplets/pkg/logger" ) // New initializes the server with routes exposing the given usecases. func New(logger logger.Logger, reg registration, ret retriever, postsRet postRetriever, postPub postPublication) http.Handler { // setup router with default handlers router := mux.NewRouter() router.NotFoundHandler = http.HandlerFunc(notFoundHandler) router.MethodNotAllowedHandler = http.HandlerFunc(methodNotAllowedHandler) // setup api endpoints addUsersAPI(router, reg, ret, logger) addPostsAPI(router, postPub, postsRet, logger) return router } func notFoundHandler(wr http.ResponseWriter, req *http.Request) { render.JSON(wr, http.StatusNotFound, errors.ResourceNotFound("path", req.URL.Path)) } func methodNotAllowedHandler(wr http.ResponseWriter, req *http.Request) { render.JSON(wr, http.StatusMethodNotAllowed, errors.ResourceNotFound("path", req.URL.Path)) } ================================================ FILE: interfaces/rest/users.go ================================================ package rest import ( "context" "net/http" "github.com/gorilla/mux" "github.com/spy16/droplets/domain" "github.com/spy16/droplets/pkg/logger" ) func addUsersAPI(router *mux.Router, reg registration, ret retriever, logger logger.Logger) { uc := &userController{ Logger: logger, reg: reg, ret: ret, } router.HandleFunc("/v1/users/{name}", uc.get).Methods(http.MethodGet) router.HandleFunc("/v1/users/", uc.search).Methods(http.MethodGet) router.HandleFunc("/v1/users/", uc.post).Methods(http.MethodPost) } type userController struct { logger.Logger reg registration ret retriever } func (uc *userController) get(wr http.ResponseWriter, req *http.Request) { vars := mux.Vars(req) user, err := uc.ret.Get(req.Context(), vars["name"]) if err != nil { respondErr(wr, err) return } respond(wr, http.StatusOK, user) } func (uc *userController) search(wr http.ResponseWriter, req *http.Request) { vals := req.URL.Query()["t"] users, err := uc.ret.Search(req.Context(), vals, 10) if err != nil { respondErr(wr, err) return } respond(wr, http.StatusOK, users) } func (uc *userController) post(wr http.ResponseWriter, req *http.Request) { user := domain.User{} if err := readRequest(req, &user); err != nil { uc.Warnf("failed to read user request: %s", err) respond(wr, http.StatusBadRequest, err) return } registered, err := uc.reg.Register(req.Context(), user) if err != nil { uc.Warnf("failed to register user: %s", err) respondErr(wr, err) return } uc.Infof("new user registered with id '%s'", registered.Name) respond(wr, http.StatusCreated, registered) } type registration interface { Register(ctx context.Context, user domain.User) (*domain.User, error) } type retriever interface { Get(ctx context.Context, name string) (*domain.User, error) Search(ctx context.Context, tags []string, limit int) ([]domain.User, error) VerifySecret(ctx context.Context, name, secret string) bool } ================================================ FILE: interfaces/rest/utils.go ================================================ package rest import ( "encoding/json" "net/http" "github.com/spy16/droplets/pkg/errors" "github.com/spy16/droplets/pkg/render" ) func respond(wr http.ResponseWriter, status int, v interface{}) { if err := render.JSON(wr, status, v); err != nil { if loggable, ok := wr.(errorLogger); ok { loggable.Errorf("failed to write data to http ResponseWriter: %s", err) } } } func respondErr(wr http.ResponseWriter, err error) { if e, ok := err.(*errors.Error); ok { respond(wr, e.Code, e) return } respond(wr, http.StatusInternalServerError, err) } func readRequest(req *http.Request, v interface{}) error { if err := json.NewDecoder(req.Body).Decode(v); err != nil { return errors.Validation("Failed to read request body") } return nil } type errorLogger interface { Errorf(msg string, args ...interface{}) } ================================================ FILE: interfaces/web/app.go ================================================ package web import ( "html/template" "net/http" ) type app struct { render func(wr http.ResponseWriter, tpl string, data interface{}) tpl template.Template } func (app app) indexHandler(wr http.ResponseWriter, req *http.Request) { app.render(wr, "index.tpl", nil) } ================================================ FILE: interfaces/web/doc.go ================================================ // Package web contains MVC style web app. package web ================================================ FILE: interfaces/web/fs.go ================================================ package web import ( "net/http" "os" "github.com/spy16/droplets/pkg/logger" ) func newSafeFileSystemServer(lg logger.Logger, root string) http.Handler { sfs := &safeFileSystem{ fs: http.Dir(root), Logger: lg, } return http.FileServer(sfs) } // safeFileSystem implements http.FileSystem. It is used to prevent directory // listing of static assets. type safeFileSystem struct { logger.Logger fs http.FileSystem } func (sfs safeFileSystem) Open(path string) (http.File, error) { f, err := sfs.fs.Open(path) if err != nil { sfs.Warnf("failed to open file '%s': %v", path, err) return nil, err } stat, err := f.Stat() if err != nil { return nil, err } if stat.IsDir() { sfs.Warnf("path '%s' is a directory, rejecting static path request", path) return nil, os.ErrNotExist } return f, nil } ================================================ FILE: interfaces/web/web.go ================================================ package web import ( "html/template" "io/ioutil" "net/http" "path/filepath" "github.com/gorilla/mux" "github.com/spy16/droplets/pkg/logger" ) // New initializes a new webapp server. func New(lg logger.Logger, cfg Config) (http.Handler, error) { tpl, err := initTemplate(lg, "", cfg.TemplateDir) if err != nil { return nil, err } app := &app{ render: func(wr http.ResponseWriter, tplName string, data interface{}) { if err := tpl.ExecuteTemplate(wr, tplName, data); err != nil { lg.Errorf("failed to render template '%s': %+v", tplName, err) } }, } fsServer := newSafeFileSystemServer(lg, cfg.StaticDir) router := mux.NewRouter() router.PathPrefix("/static").Handler(http.StripPrefix("/static", fsServer)) router.Handle("/favicon.ico", fsServer) // web app routes router.HandleFunc("/", app.indexHandler) return router, nil } // Config represents server configuration. type Config struct { TemplateDir string StaticDir string } func initTemplate(lg logger.Logger, name, path string) (*template.Template, error) { apath, err := filepath.Abs(path) if err != nil { return nil, err } files, err := ioutil.ReadDir(apath) if err != nil { return nil, err } lg.Infof("loading templates from '%s'...", path) tpl := template.New(name) for _, f := range files { if f.IsDir() { continue } fp := filepath.Join(apath, f.Name()) lg.Debugf("parsing template file '%s'", f.Name()) tpl.New(f.Name()).ParseFiles(fp) } return tpl, nil } ================================================ FILE: main.go ================================================ package main import ( "context" "net/http" "os" "time" "github.com/gorilla/mux" "github.com/spf13/viper" "github.com/spy16/droplets/interfaces/mongo" "github.com/spy16/droplets/interfaces/rest" "github.com/spy16/droplets/interfaces/web" "github.com/spy16/droplets/pkg/graceful" "github.com/spy16/droplets/pkg/logger" "github.com/spy16/droplets/pkg/middlewares" "github.com/spy16/droplets/usecases/posts" "github.com/spy16/droplets/usecases/users" ) func main() { cfg := loadConfig() lg := logger.New(os.Stderr, cfg.LogLevel, cfg.LogFormat) db, closeSession, err := mongo.Connect(cfg.MongoURI, true) if err != nil { lg.Fatalf("failed to connect to mongodb: %v", err) } defer closeSession() lg.Debugf("setting up rest api service") userStore := mongo.NewUserStore(db) postStore := mongo.NewPostStore(db) userRegistration := users.NewRegistrar(lg, userStore) userRetriever := users.NewRetriever(lg, userStore) postPub := posts.NewPublication(lg, postStore, userStore) postRet := posts.NewRetriever(lg, postStore) restHandler := rest.New(lg, userRegistration, userRetriever, postRet, postPub) webHandler, err := web.New(lg, web.Config{ TemplateDir: cfg.TemplateDir, StaticDir: cfg.StaticDir, }) if err != nil { lg.Fatalf("failed to setup web handler: %v", err) } srv := setupServer(cfg, lg, webHandler, restHandler) lg.Infof("listening for requests on :8080...") if err := srv.ListenAndServe(); err != nil { lg.Fatalf("http server exited: %s", err) } } func setupServer(cfg config, lg logger.Logger, web http.Handler, rest http.Handler) *graceful.Server { rest = middlewares.WithBasicAuth(lg, rest, middlewares.UserVerifierFunc(func(ctx context.Context, name, secret string) bool { return secret == "secret@123" }), ) router := mux.NewRouter() router.PathPrefix("/api").Handler(http.StripPrefix("/api", rest)) router.PathPrefix("/").Handler(web) handler := middlewares.WithRequestLogging(lg, router) handler = middlewares.WithRecovery(lg, handler) srv := graceful.NewServer(handler, cfg.GracefulTimeout, os.Interrupt) srv.Log = lg.Errorf srv.Addr = cfg.Addr return srv } type config struct { Addr string LogLevel string LogFormat string StaticDir string TemplateDir string GracefulTimeout time.Duration MongoURI string } func loadConfig() config { viper.SetDefault("MONGO_URI", "mongodb://localhost/droplets") viper.SetDefault("LOG_LEVEL", "debug") viper.SetDefault("LOG_FORMAT", "text") viper.SetDefault("ADDR", ":8080") viper.SetDefault("STATIC_DIR", "./web/static/") viper.SetDefault("TEMPLATE_DIR", "./web/templates/") viper.SetDefault("GRACEFUL_TIMEOUT", 20*time.Second) viper.ReadInConfig() viper.AutomaticEnv() return config{ // application configuration Addr: viper.GetString("ADDR"), StaticDir: viper.GetString("STATIC_DIR"), TemplateDir: viper.GetString("TEMPLATE_DIR"), LogLevel: viper.GetString("LOG_LEVEL"), LogFormat: viper.GetString("LOG_FORMAT"), GracefulTimeout: viper.GetDuration("GRACEFUL_TIMEOUT"), // store configuration MongoURI: viper.GetString("MONGO_URI"), } } ================================================ FILE: pkg/doc.go ================================================ // Package pkg is the root for re-usable packages. This package should not // contain any entities (exported or otherwise) since, a package named `pkg` // does not express anything about its purpose and could become a catch all // package like `utils`, `misc` etc. which should be avoided. package pkg ================================================ FILE: pkg/errors/authorization.go ================================================ package errors import "net/http" // Common authorization related errors const ( TypeUnauthorized = "Unauthorized" ) // Unauthorized can be used to generate an error that represents an unauthorized // request. func Unauthorized(reason string) error { return WithStack(&Error{ Code: http.StatusUnauthorized, Type: TypeUnauthorized, Message: "You are not authorized to perform the requested action", Context: map[string]interface{}{ "reason": reason, }, }) } ================================================ FILE: pkg/errors/doc.go ================================================ // Package errors provides common error definitions and tools for dealing // with errors. The API of this package is drop-in replacement for standard // errors package except for one significant difference: // Since Error type used in this package embeds map inside, errors created // by this package are not comparable and hence cannot be used to create // Sentinel Errors. package errors ================================================ FILE: pkg/errors/error.go ================================================ package errors import ( "fmt" "io" ) // Error is a generic error representation with some fields to provide additional // context around the error. type Error struct { // Code can represent an http error code. Code int `json:"-"` // Type should be an error code to identify the error. Type and Context together // should provide enough context for robust error handling on client side. Type string `json:"type,omitempty"` // Context can contain additional information describing the error. Context will // be exposed only in API endpoints so that clients be integrated effectively. Context map[string]interface{} `json:"context,omitempty"` // Message should be a user-friendly error message which can be shown to the // end user without further modifications. However, clients are free to modify // this (e.g., for enabling localization), or augment this message with the // information available in the context before rendering a message to the end // user. Message string `json:"message,omitempty"` // original can contain an underlying error if any. This value will be returned // by the Cause() method. original error // stack will contain a minimal stack trace which can be used for logging and // debugging. stack should not be examined to handle errors. stack stack } // Cause returns the underlying error if any. func (err Error) Cause() error { return err.original } func (err Error) Error() string { if origin := err.Cause(); origin != nil { return fmt.Sprintf("%s: %s: %s", origin, err.Type, err.Message) } return fmt.Sprintf("%s: %s", err.Type, err.Message) } // Format implements fmt.Formatter interface. func (err Error) Format(st fmt.State, verb rune) { switch verb { case 'v': if st.Flag('+') { io.WriteString(st, err.Error()) err.stack.Format(st, verb) } else { fmt.Fprintf(st, "%s: ", err.Type) for key, val := range err.Context { fmt.Fprintf(st, "%s='%s' ", key, val) } } case 's': io.WriteString(st, err.Error()) case 'q': fmt.Fprintf(st, "%q", err.Error()) } } ================================================ FILE: pkg/errors/errors.go ================================================ package errors import ( "fmt" "net/http" ) // TypeUnknown represents unknown error type. const TypeUnknown = "Unknown" // New returns an error object with formatted error message generated using // the arguments. func New(msg string, args ...interface{}) error { return &Error{ Code: http.StatusInternalServerError, Type: TypeUnknown, Message: fmt.Sprintf(msg, args...), stack: callStack(3), } } // Type attempts converting the err to Error type and extracts error Type. // If conversion not possible, returns TypeUnknown. func Type(err error) string { if e, ok := err.(*Error); ok { return e.Type } return TypeUnknown } // Wrapf wraps the given err with formatted message and returns a new error. func Wrapf(err error, msg string, args ...interface{}) error { return WithStack(&Error{ Code: http.StatusInternalServerError, Type: TypeUnknown, Message: fmt.Sprintf(msg, args...), original: err, }) } // WithStack annotates the given error with stack trace and returns the wrapped // error. func WithStack(err error) error { var wrappedErr Error if e, ok := err.(*Error); ok { wrappedErr = *e } else { wrappedErr.Type = TypeUnknown wrappedErr.Message = "Something went wrong" wrappedErr.original = err } wrappedErr.stack = callStack(3) return &wrappedErr } // Cause returns the underlying error if the given error is wrapping another error. func Cause(err error) error { if err == nil { return nil } if e, ok := err.(*Error); ok { return e } return err } ================================================ FILE: pkg/errors/resource.go ================================================ package errors import "net/http" // Common resource related error codes. const ( TypeResourceNotFound = "ResourceNotFound" TypeResourceConflict = "ResourceConflict" ) // ResourceNotFound returns an error that represents an attempt to access a // non-existent resource. func ResourceNotFound(rType, rID string) error { return WithStack(&Error{ Code: http.StatusNotFound, Type: TypeResourceNotFound, Message: "Resource you are requesting does not exist", Context: map[string]interface{}{ "resource_type": rType, "resource_id": rID, }, }) } // Conflict returns an error that represents a resource identifier conflict. func Conflict(rType, rID string) error { return WithStack(&Error{ Code: http.StatusConflict, Type: TypeResourceConflict, Message: "A resource with same name already exists", Context: map[string]interface{}{ "resource_type": rType, "resource_id": rID, }, }) } ================================================ FILE: pkg/errors/stack.go ================================================ package errors import ( "fmt" "io" "path" "runtime" ) const depth = 32 type stack []frame // Format formats the stack of Frames according to the fmt.Formatter interface. // // %s lists source files for each Frame in the stack // %v lists the source file and line number for each Frame in the stack // // Format accepts flags that alter the printing of some verbs, as follows: // // %+v Prints filename, function, and line number for each Frame in the stack. func (st stack) Format(s fmt.State, verb rune) { switch verb { case 'v': switch { case s.Flag('+'): for _, f := range st { fmt.Fprintf(s, "\n%+v", f) } case s.Flag('#'): fmt.Fprintf(s, "%#v", []frame(st)) default: fmt.Fprintf(s, "%v", []frame(st)) } case 's': fmt.Fprintf(s, "%s", []frame(st)) } } type frame struct { fn string file string line int } // Format formats the frame according to the fmt.Formatter interface. // // %s source file // %d source line // %n function name // %v equivalent to %s:%d // // Format accepts flags that alter the printing of some verbs, as follows: // // %+s function name and path of source file relative to the compile time // GOPATH separated by \n\t (\n\t) // %+v equivalent to %+s:%d func (f frame) Format(s fmt.State, verb rune) { switch verb { case 's': switch { case s.Flag('+'): fmt.Fprintf(s, "%s\n\t%s", f.fn, f.file) default: io.WriteString(s, path.Base(f.file)) } case 'd': fmt.Fprintf(s, "%d", f.line) case 'n': io.WriteString(s, f.fn) case 'v': f.Format(s, 's') io.WriteString(s, ":") f.Format(s, 'd') } } func callStack(skip int) stack { var frames []frame var pcs [depth]uintptr n := runtime.Callers(skip, pcs[:]) for i := 0; i < n; i++ { pc := pcs[i] frame := frameFromPC(pc) frames = append(frames, frame) } return stack(frames) } func frameFromPC(pc uintptr) frame { fr := frame{} fn := runtime.FuncForPC(pc) if fn == nil { fr.fn = "unknown" } else { fr.fn = fn.Name() } fr.file, fr.line = fn.FileLine(pc) return fr } ================================================ FILE: pkg/errors/validation.go ================================================ package errors import "net/http" // Common validation error type codes. const ( TypeInvalidRequest = "InvalidRequest" TypeMissingField = "MissingField" TypeInvalidValue = "InvalidValue" ) // Validation returns an error that can be used to represent an invalid request. func Validation(reason string) error { return WithStack(&Error{ Code: http.StatusBadRequest, Type: TypeInvalidRequest, Message: reason, Context: map[string]interface{}{}, }) } // InvalidValue can be used to generate an error that represents an invalid // value for the 'field'. reason should be used to add detail describing why // the value is invalid. func InvalidValue(field string, reason string) error { return WithStack(&Error{ Code: http.StatusBadRequest, Type: TypeInvalidValue, Message: "A parameter has invalid value", Context: map[string]interface{}{ "field": field, "reason": reason, }, }) } // MissingField can be used to generate an error that represents // a empty value for a required field. func MissingField(field string) error { return WithStack(&Error{ Code: http.StatusBadRequest, Type: TypeMissingField, Message: "A required field is missing", Context: map[string]interface{}{ "field": field, }, }) } ================================================ FILE: pkg/graceful/doc.go ================================================ // Package graceful provides a simple wrapper for http.Handler which // handles graceful shutdown based on registered signals. Server in // this package closely follows the http.Server struct but can not be // used as a drop-in replacement. package graceful ================================================ FILE: pkg/graceful/graceful.go ================================================ package graceful import ( "context" "log" "net" "net/http" "os" "os/signal" "time" ) // LogFunc can be set on the server to customize the message printed when the // server is shutting down. type LogFunc func(msg string, args ...interface{}) // NewServer creates a wrapper around the given handler. func NewServer(handler http.Handler, timeout time.Duration, signals ...os.Signal) *Server { gss := &Server{} gss.server = &http.Server{Handler: handler} gss.signals = signals gss.Log = log.Printf gss.timeout = timeout return gss } // Server is a wrapper around an http handler. It provides methods // to start the server with graceful-shutdown enabled. type Server struct { Addr string Log LogFunc server *http.Server signals []os.Signal timeout time.Duration startErr error } // Serve starts the http listener with the registered http.Handler and // then blocks until a interrupt signal is received. func (gss *Server) Serve(l net.Listener) error { ctx, cancel := context.WithCancel(context.Background()) go func() { if err := gss.server.Serve(l); err != nil { gss.startErr = err cancel() } }() return gss.waitForInterrupt(ctx) } // ServeTLS starts the http listener with the registered http.Handler and // then blocks until a interrupt signal is received. func (gss *Server) ServeTLS(l net.Listener, certFile, keyFile string) error { ctx, cancel := context.WithCancel(context.Background()) go func() { if err := gss.server.ServeTLS(l, certFile, keyFile); err != nil { gss.startErr = err cancel() } }() return gss.waitForInterrupt(ctx) } // ListenAndServe serves the requests on a listener bound to interface // specified by Addr func (gss *Server) ListenAndServe() error { ctx, cancel := context.WithCancel(context.Background()) go func() { gss.server.Addr = gss.Addr if err := gss.server.ListenAndServe(); err != http.ErrServerClosed { gss.startErr = err cancel() } }() return gss.waitForInterrupt(ctx) } // ListenAndServeTLS serves the requests on a listener bound to interface // specified by Addr func (gss *Server) ListenAndServeTLS(certFile, keyFile string) error { ctx, cancel := context.WithCancel(context.Background()) go func() { if err := gss.server.ListenAndServeTLS(certFile, keyFile); err != http.ErrServerClosed { gss.startErr = err cancel() } }() return gss.waitForInterrupt(ctx) } func (gss *Server) waitForInterrupt(ctx context.Context) error { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, gss.signals...) select { case sig := <-sigCh: if gss.Log != nil { gss.Log("shutting down (signal=%s)...", sig) } break case <-ctx.Done(): return gss.startErr } return gss.shutdown() } func (gss *Server) shutdown() error { ctx, cancel := context.WithTimeout(context.Background(), gss.timeout) defer cancel() return gss.server.Shutdown(ctx) } ================================================ FILE: pkg/logger/doc.go ================================================ // Package logger provides logging functions. The loggers implemented in this // package will have the API defined by the Logger interface. The interface // is defined here (instead of where it is being used which is the right place), // is because Logger interface is a common thing that gets used across the code // base while being fairly constant in terms of its API. package logger // Logger implementation is responsible for providing structured and levled // logging functions. type Logger interface { Debugf(msg string, args ...interface{}) Infof(msg string, args ...interface{}) Warnf(msg string, args ...interface{}) Errorf(msg string, args ...interface{}) Fatalf(msg string, args ...interface{}) // WithFields should return a logger which is annotated with the given // fields. These fields should be added to every logging call on the // returned logger. WithFields(m map[string]interface{}) Logger } ================================================ FILE: pkg/logger/logrus.go ================================================ package logger import ( "io" "os" "github.com/sirupsen/logrus" ) // New returns a logger implemented using the logrus package. func New(wr io.Writer, level string, format string) Logger { if wr == nil { wr = os.Stderr } lr := logrus.New() lr.SetOutput(wr) lr.SetFormatter(&logrus.TextFormatter{}) if format == "json" { lr.SetFormatter(&logrus.JSONFormatter{}) } lvl, err := logrus.ParseLevel(level) if err != nil { lvl = logrus.WarnLevel lr.Warnf("failed to parse log-level '%s', defaulting to 'warning'", level) } lr.SetLevel(lvl) return &logrusLogger{ Entry: logrus.NewEntry(lr), } } // logrusLogger provides functions for structured logging. type logrusLogger struct { *logrus.Entry } func (ll *logrusLogger) WithFields(fields map[string]interface{}) Logger { annotatedEntry := ll.Entry.WithFields(logrus.Fields(fields)) return &logrusLogger{ Entry: annotatedEntry, } } ================================================ FILE: pkg/middlewares/authn.go ================================================ package middlewares import ( "context" "net/http" "github.com/spy16/droplets/pkg/errors" "github.com/spy16/droplets/pkg/logger" "github.com/spy16/droplets/pkg/render" ) var authUser = ctxKey("user") // WithBasicAuth adds Basic authentication checks to the handler. Basic Auth header // will be extracted from the request and verified using the verifier. func WithBasicAuth(lg logger.Logger, next http.Handler, verifier UserVerifier) http.Handler { return http.HandlerFunc(func(wr http.ResponseWriter, req *http.Request) { name, secret, ok := req.BasicAuth() if !ok { render.JSON(wr, http.StatusUnauthorized, errors.Unauthorized("Basic auth header is not present")) return } verified := verifier.VerifySecret(req.Context(), name, secret) if !verified { wr.WriteHeader(http.StatusUnauthorized) render.JSON(wr, http.StatusUnauthorized, errors.Unauthorized("Invalid username or secret")) return } req = req.WithContext(context.WithValue(req.Context(), authUser, name)) next.ServeHTTP(wr, req) }) } // User extracts the username injected into the context by the auth middleware. func User(req *http.Request) (string, bool) { val := req.Context().Value(authUser) if userName, ok := val.(string); ok { return userName, true } return "", false } type ctxKey string // UserVerifier implementation is responsible for verifying the name-secret pair. type UserVerifier interface { VerifySecret(ctx context.Context, name, secret string) bool } // UserVerifierFunc implements UserVerifier. type UserVerifierFunc func(ctx context.Context, name, secret string) bool // VerifySecret delegates call to the wrapped function. func (uvf UserVerifierFunc) VerifySecret(ctx context.Context, name, secret string) bool { return uvf(ctx, name, secret) } ================================================ FILE: pkg/middlewares/doc.go ================================================ // Package middlewares contains re-usable middleware functions. Middleware // functions in this package follow standard http.HandlerFunc signature and // hence are compatible with all standard http library functions. package middlewares ================================================ FILE: pkg/middlewares/logging.go ================================================ package middlewares import ( "net/http" "time" "github.com/spy16/droplets/pkg/logger" ) // WithRequestLogging adds logging to the given handler. Every request handled by // 'next' will be logged with request information such as path, method, latency, // client-ip, response status code etc. Logging will be done at info level only. // Also, injects a logger into the ResponseWriter which can be later used by the // handlers to perform additional logging. func WithRequestLogging(logger logger.Logger, next http.Handler) http.Handler { return http.HandlerFunc(func(wr http.ResponseWriter, req *http.Request) { wrappedWr := wrap(wr, logger) start := time.Now() defer logRequest(logger, start, wrappedWr, req) next.ServeHTTP(wrappedWr, req) }) } func logRequest(logger logger.Logger, startedAt time.Time, wr *wrappedWriter, req *http.Request) { duration := time.Now().Sub(startedAt) info := map[string]interface{}{ "latency": duration, "status": wr.wroteStatus, } logger. WithFields(requestInfo(req)). WithFields(info). Infof("request completed with code %d", wr.wroteStatus) } ================================================ FILE: pkg/middlewares/recovery.go ================================================ package middlewares import ( "encoding/json" "net/http" "github.com/spy16/droplets/pkg/logger" ) // WithRecovery recovers from any panics and logs them appropriately. func WithRecovery(logger logger.Logger, next http.Handler) http.Handler { return http.HandlerFunc(func(wr http.ResponseWriter, req *http.Request) { ri := recoveryInfo{} safeHandler(next, &ri).ServeHTTP(wr, req) if ri.panicked { logger.Errorf("recovered from panic: %+v", ri.val) wr.WriteHeader(http.StatusInternalServerError) json.NewEncoder(wr).Encode(map[string]interface{}{ "error": "Something went wrong", }) } }) } func safeHandler(next http.Handler, ri *recoveryInfo) http.Handler { return http.HandlerFunc(func(wr http.ResponseWriter, req *http.Request) { defer func() { if val := recover(); val != nil { ri.panicked = true ri.val = val } }() next.ServeHTTP(wr, req) }) } type recoveryInfo struct { panicked bool val interface{} } ================================================ FILE: pkg/middlewares/utils.go ================================================ package middlewares import ( "net/http" "github.com/spy16/droplets/pkg/logger" ) func requestInfo(req *http.Request) map[string]interface{} { return map[string]interface{}{ "path": req.URL.Path, "query": req.URL.RawQuery, "method": req.Method, "client": req.RemoteAddr, } } func wrap(wr http.ResponseWriter, logger logger.Logger) *wrappedWriter { return &wrappedWriter{ ResponseWriter: wr, Logger: logger, wroteStatus: http.StatusOK, } } type wrappedWriter struct { http.ResponseWriter logger.Logger wroteStatus int } func (wr *wrappedWriter) WriteHeader(statusCode int) { wr.wroteStatus = statusCode wr.ResponseWriter.WriteHeader(statusCode) } ================================================ FILE: pkg/render/doc.go ================================================ // Package render provides simple and generic functions for rendering // data structures using different encoding formats. package render ================================================ FILE: pkg/render/render.go ================================================ package render import ( "encoding/json" "io" "net/http" ) const contentTypeJSON = "application/json; charset=utf-8" // JSON encodes the given val using the standard json package and writes // the encoding output to the given writer. If the writer implements the // http.ResponseWriter interface, then this function will also set the // proper JSON content-type header with charset as UTF-8. Status will be // considered only when wr is http.ResponseWriter and in that case, status // must be a valid status code. func JSON(wr io.Writer, status int, val interface{}) error { if hw, ok := wr.(http.ResponseWriter); ok { hw.Header().Set("Content-type", contentTypeJSON) hw.WriteHeader(status) } return json.NewEncoder(wr).Encode(val) } ================================================ FILE: usecases/posts/doc.go ================================================ // Package posts has usecases around Post domain entity. This includes // publishing and management of posts. package posts ================================================ FILE: usecases/posts/publish.go ================================================ package posts import ( "context" "fmt" "github.com/spy16/droplets/domain" "github.com/spy16/droplets/pkg/errors" "github.com/spy16/droplets/pkg/logger" ) // NewPublication initializes the publication usecase. func NewPublication(lg logger.Logger, store Store, verifier userVerifier) *Publication { return &Publication{ Logger: lg, store: store, verifier: verifier, } } // Publication implements the publishing usecases. type Publication struct { logger.Logger store Store verifier userVerifier } // Publish validates and persists the post into the store. func (pub *Publication) Publish(ctx context.Context, post domain.Post) (*domain.Post, error) { if err := post.Validate(); err != nil { return nil, err } if !pub.verifier.Exists(ctx, post.Owner) { return nil, errors.Unauthorized(fmt.Sprintf("user '%s' not found", post.Owner)) } if pub.store.Exists(ctx, post.Name) { return nil, errors.Conflict("Post", post.Name) } saved, err := pub.store.Save(ctx, post) if err != nil { pub.Warnf("failed to save post to the store: %+v", err) } return saved, nil } // Delete removes the post from the store. func (pub *Publication) Delete(ctx context.Context, name string) (*domain.Post, error) { return pub.store.Delete(ctx, name) } ================================================ FILE: usecases/posts/retrieval.go ================================================ package posts import ( "context" "errors" "github.com/spy16/droplets/domain" "github.com/spy16/droplets/pkg/logger" ) // NewRetriever initializes the retrieval usecase with given store. func NewRetriever(lg logger.Logger, store Store) *Retriever { return &Retriever{ Logger: lg, store: store, } } // Retriever provides retrieval related usecases. type Retriever struct { logger.Logger store Store } // Get finds a post by its name. func (ret *Retriever) Get(ctx context.Context, name string) (*domain.Post, error) { return ret.store.Get(ctx, name) } // Search finds all the posts matching the parameters in the query. func (ret *Retriever) Search(ctx context.Context, query Query) ([]domain.Post, error) { return nil, errors.New("not implemented") } // Query represents parameters for executing a search. Zero valued fields // in the query will be ignored. type Query struct { Name string `json:"name,omitempty"` Owner string `json:"owner,omitempty"` Tags []string `json:"tags,omitempty"` } ================================================ FILE: usecases/posts/store.go ================================================ package posts import ( "context" "github.com/spy16/droplets/domain" ) // Store implementation is responsible for managing persistance of posts. type Store interface { Get(ctx context.Context, name string) (*domain.Post, error) Exists(ctx context.Context, name string) bool Save(ctx context.Context, post domain.Post) (*domain.Post, error) Delete(ctx context.Context, name string) (*domain.Post, error) } // userVerifier is responsible for verifying existence of a user. type userVerifier interface { Exists(ctx context.Context, name string) bool } ================================================ FILE: usecases/users/doc.go ================================================ // Package users has usecases around User domain entity. This includes // user registration, retrieval etc. package users ================================================ FILE: usecases/users/registration.go ================================================ package users import ( "context" "github.com/spy16/droplets/domain" "github.com/spy16/droplets/pkg/errors" "github.com/spy16/droplets/pkg/logger" ) // NewRegistrar initializes a Registration service object. func NewRegistrar(lg logger.Logger, store Store) *Registrar { return &Registrar{ Logger: lg, store: store, } } // Registrar provides functions for user registration. type Registrar struct { logger.Logger store Store } // Register creates a new user in the system using the given user object. func (reg *Registrar) Register(ctx context.Context, user domain.User) (*domain.User, error) { if err := user.Validate(); err != nil { return nil, err } if len(user.Secret) < 8 { return nil, errors.InvalidValue("Secret", "secret must have 8 or more characters") } if reg.store.Exists(ctx, user.Name) { return nil, errors.Conflict("User", user.Name) } if err := user.HashSecret(); err != nil { return nil, err } saved, err := reg.store.Save(ctx, user) if err != nil { reg.Logger.Warnf("failed to save user object: %v", err) return nil, err } saved.Secret = "" return saved, nil } ================================================ FILE: usecases/users/retrieval.go ================================================ package users import ( "context" "github.com/spy16/droplets/domain" "github.com/spy16/droplets/pkg/logger" ) // NewRetriever initializes an instance of Retriever with given store. func NewRetriever(lg logger.Logger, store Store) *Retriever { return &Retriever{ Logger: lg, store: store, } } // Retriever provides functions for retrieving user and user info. type Retriever struct { logger.Logger store Store } // Search finds all users matching the tags. func (ret *Retriever) Search(ctx context.Context, tags []string, limit int) ([]domain.User, error) { users, err := ret.store.FindAll(ctx, tags, limit) if err != nil { return nil, err } for i := range users { users[i].Secret = "" } return users, nil } // Get finds a user by name. func (ret *Retriever) Get(ctx context.Context, name string) (*domain.User, error) { return ret.findUser(ctx, name, true) } // VerifySecret finds the user by name and verifies the secret against the has found // in the store. func (ret *Retriever) VerifySecret(ctx context.Context, name, secret string) bool { user, err := ret.findUser(ctx, name, false) if err != nil { return false } return user.CheckSecret(secret) } func (ret *Retriever) findUser(ctx context.Context, name string, stripSecret bool) (*domain.User, error) { user, err := ret.store.FindByName(ctx, name) if err != nil { ret.Debugf("failed to find user with name '%s': %v", name, err) return nil, err } if stripSecret { user.Secret = "" } return user, nil } ================================================ FILE: usecases/users/store.go ================================================ package users import ( "context" "github.com/spy16/droplets/domain" ) // Store implementation is responsible for managing persistence of // users. type Store interface { Exists(ctx context.Context, name string) bool Save(ctx context.Context, user domain.User) (*domain.User, error) FindByName(ctx context.Context, name string) (*domain.User, error) FindAll(ctx context.Context, tags []string, limit int) ([]domain.User, error) } ================================================ FILE: web/static/main.css ================================================ html { } ================================================ FILE: web/templates/index.tpl ================================================ Droplets

Welcome