├── .gitignore ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── Makefile ├── README.md ├── api ├── api.go ├── config.go ├── todo.go └── user.go ├── app ├── app.go ├── config.go ├── context.go ├── todo.go └── user.go ├── cmd ├── migrate.go ├── root.go ├── serve.go └── version.go ├── config.example.yaml ├── db ├── config.go ├── db.go ├── todo.go └── user.go ├── main.go ├── migrations ├── 0001_add_user.go ├── 0002_add_todo.go └── migration.go └── model ├── model.go ├── todo.go └── user.go /.gitignore: -------------------------------------------------------------------------------- 1 | ### Go template 2 | # Binaries for programs and plugins 3 | *.exe 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | 9 | # Test binary, build with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | /vendor 16 | /todos 17 | /config.yaml 18 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | digest = "1:af6e785bedb62fc2abb81977c58a7a44e5cf9f7e41b8d3c8dd4d872edea0ce08" 6 | name = "github.com/NYTimes/gziphandler" 7 | packages = ["."] 8 | pruneopts = "UT" 9 | revision = "dd0439581c7657cb652dfe5c71d7d48baf39541d" 10 | version = "v1.1.1" 11 | 12 | [[projects]] 13 | digest = "1:abeb38ade3f32a92943e5be54f55ed6d6e3b6602761d74b4aab4c9dd45c18abd" 14 | name = "github.com/fsnotify/fsnotify" 15 | packages = ["."] 16 | pruneopts = "UT" 17 | revision = "c2828203cd70a50dcccfb2761f8b1f8ceef9a8e9" 18 | version = "v1.4.7" 19 | 20 | [[projects]] 21 | digest = "1:664d37ea261f0fc73dd17f4a1f5f46d01fbb0b0d75f6375af064824424109b7d" 22 | name = "github.com/gorilla/handlers" 23 | packages = ["."] 24 | pruneopts = "UT" 25 | revision = "7e0847f9db758cdebd26c149d0ae9d5d0b9c98ce" 26 | version = "v1.4.0" 27 | 28 | [[projects]] 29 | digest = "1:ca59b1175189b3f0e9f1793d2c350114be36eaabbe5b9f554b35edee1de50aea" 30 | name = "github.com/gorilla/mux" 31 | packages = ["."] 32 | pruneopts = "UT" 33 | revision = "a7962380ca08b5a188038c69871b8d3fbdf31e89" 34 | version = "v1.7.0" 35 | 36 | [[projects]] 37 | digest = "1:c0d19ab64b32ce9fe5cf4ddceba78d5bc9807f0016db6b1183599da3dcc24d10" 38 | name = "github.com/hashicorp/hcl" 39 | packages = [ 40 | ".", 41 | "hcl/ast", 42 | "hcl/parser", 43 | "hcl/printer", 44 | "hcl/scanner", 45 | "hcl/strconv", 46 | "hcl/token", 47 | "json/parser", 48 | "json/scanner", 49 | "json/token", 50 | ] 51 | pruneopts = "UT" 52 | revision = "8cb6e5b959231cc1119e43259c4a608f9c51a241" 53 | version = "v1.0.0" 54 | 55 | [[projects]] 56 | digest = "1:870d441fe217b8e689d7949fef6e43efbc787e50f200cb1e70dbca9204a1d6be" 57 | name = "github.com/inconshreveable/mousetrap" 58 | packages = ["."] 59 | pruneopts = "UT" 60 | revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75" 61 | version = "v1.0" 62 | 63 | [[projects]] 64 | digest = "1:7e63b12cdd7bef2c411d736cb67e28134d39f5d236aa1bfb8f3d55e3ad709eca" 65 | name = "github.com/jinzhu/gorm" 66 | packages = [ 67 | ".", 68 | "dialects/postgres", 69 | ] 70 | pruneopts = "UT" 71 | revision = "472c70caa40267cb89fd8facb07fe6454b578626" 72 | version = "v1.9.2" 73 | 74 | [[projects]] 75 | branch = "master" 76 | digest = "1:fd97437fbb6b7dce04132cf06775bd258cce305c44add58eb55ca86c6c325160" 77 | name = "github.com/jinzhu/inflection" 78 | packages = ["."] 79 | pruneopts = "UT" 80 | revision = "04140366298a54a039076d798123ffa108fff46c" 81 | 82 | [[projects]] 83 | digest = "1:31e761d97c76151dde79e9d28964a812c46efc5baee4085b86f68f0c654450de" 84 | name = "github.com/konsorten/go-windows-terminal-sequences" 85 | packages = ["."] 86 | pruneopts = "UT" 87 | revision = "f55edac94c9bbba5d6182a4be46d86a2c9b5b50e" 88 | version = "v1.0.2" 89 | 90 | [[projects]] 91 | digest = "1:b18ffc558326ebaed3b4a175617f1e12ed4e3f53d6ebfe5ba372a3de16d22278" 92 | name = "github.com/lib/pq" 93 | packages = [ 94 | ".", 95 | "hstore", 96 | "oid", 97 | ] 98 | pruneopts = "UT" 99 | revision = "4ded0e9383f75c197b3a2aaa6d590ac52df6fd79" 100 | version = "v1.0.0" 101 | 102 | [[projects]] 103 | digest = "1:c568d7727aa262c32bdf8a3f7db83614f7af0ed661474b24588de635c20024c7" 104 | name = "github.com/magiconair/properties" 105 | packages = ["."] 106 | pruneopts = "UT" 107 | revision = "c2353362d570a7bfa228149c62842019201cfb71" 108 | version = "v1.8.0" 109 | 110 | [[projects]] 111 | digest = "1:53bc4cd4914cd7cd52139990d5170d6dc99067ae31c56530621b18b35fc30318" 112 | name = "github.com/mitchellh/mapstructure" 113 | packages = ["."] 114 | pruneopts = "UT" 115 | revision = "3536a929edddb9a5b34bd6861dc4a9647cb459fe" 116 | version = "v1.1.2" 117 | 118 | [[projects]] 119 | digest = "1:95741de3af260a92cc5c7f3f3061e85273f5a81b5db20d4bd68da74bd521675e" 120 | name = "github.com/pelletier/go-toml" 121 | packages = ["."] 122 | pruneopts = "UT" 123 | revision = "c01d1270ff3e442a8a57cddc1c92dc1138598194" 124 | version = "v1.2.0" 125 | 126 | [[projects]] 127 | digest = "1:cf31692c14422fa27c83a05292eb5cbe0fb2775972e8f1f8446a71549bd8980b" 128 | name = "github.com/pkg/errors" 129 | packages = ["."] 130 | pruneopts = "UT" 131 | revision = "ba968bfe8b2f7e042a574c888954fccecfa385b4" 132 | version = "v0.8.1" 133 | 134 | [[projects]] 135 | digest = "1:87c2e02fb01c27060ccc5ba7c5a407cc91147726f8f40b70cceeedbc52b1f3a8" 136 | name = "github.com/sirupsen/logrus" 137 | packages = ["."] 138 | pruneopts = "UT" 139 | revision = "e1e72e9de974bd926e5c56f83753fba2df402ce5" 140 | version = "v1.3.0" 141 | 142 | [[projects]] 143 | digest = "1:6a4a11ba764a56d2758899ec6f3848d24698d48442ebce85ee7a3f63284526cd" 144 | name = "github.com/spf13/afero" 145 | packages = [ 146 | ".", 147 | "mem", 148 | ] 149 | pruneopts = "UT" 150 | revision = "d40851caa0d747393da1ffb28f7f9d8b4eeffebd" 151 | version = "v1.1.2" 152 | 153 | [[projects]] 154 | digest = "1:08d65904057412fc0270fc4812a1c90c594186819243160dc779a402d4b6d0bc" 155 | name = "github.com/spf13/cast" 156 | packages = ["."] 157 | pruneopts = "UT" 158 | revision = "8c9545af88b134710ab1cd196795e7f2388358d7" 159 | version = "v1.3.0" 160 | 161 | [[projects]] 162 | digest = "1:645cabccbb4fa8aab25a956cbcbdf6a6845ca736b2c64e197ca7cbb9d210b939" 163 | name = "github.com/spf13/cobra" 164 | packages = ["."] 165 | pruneopts = "UT" 166 | revision = "ef82de70bb3f60c65fb8eebacbb2d122ef517385" 167 | version = "v0.0.3" 168 | 169 | [[projects]] 170 | digest = "1:68ea4e23713989dc20b1bded5d9da2c5f9be14ff9885beef481848edd18c26cb" 171 | name = "github.com/spf13/jwalterweatherman" 172 | packages = ["."] 173 | pruneopts = "UT" 174 | revision = "4a4406e478ca629068e7768fc33f3f044173c0a6" 175 | version = "v1.0.0" 176 | 177 | [[projects]] 178 | digest = "1:c1b1102241e7f645bc8e0c22ae352e8f0dc6484b6cb4d132fa9f24174e0119e2" 179 | name = "github.com/spf13/pflag" 180 | packages = ["."] 181 | pruneopts = "UT" 182 | revision = "298182f68c66c05229eb03ac171abe6e309ee79a" 183 | version = "v1.0.3" 184 | 185 | [[projects]] 186 | digest = "1:214775c11fd26da94a100111a62daa25339198a4f9c57cb4aab352da889f5b93" 187 | name = "github.com/spf13/viper" 188 | packages = ["."] 189 | pruneopts = "UT" 190 | revision = "2c12c60302a5a0e62ee102ca9bc996277c2f64f5" 191 | version = "v1.2.1" 192 | 193 | [[projects]] 194 | branch = "master" 195 | digest = "1:0c75a1e88c2b1e0361102c133f2690fd1fd5ab5da829f60a9c2dd20b9c224c71" 196 | name = "golang.org/x/crypto" 197 | packages = [ 198 | "bcrypt", 199 | "blowfish", 200 | "ssh/terminal", 201 | ] 202 | pruneopts = "UT" 203 | revision = "8dd112bcdc25174059e45e07517d9fc663123347" 204 | 205 | [[projects]] 206 | branch = "master" 207 | digest = "1:6a875550c3b582f6c2d7e2ce44aba792511f00016d7c46b0a4fb26f730ef3058" 208 | name = "golang.org/x/sys" 209 | packages = [ 210 | "unix", 211 | "windows", 212 | ] 213 | pruneopts = "UT" 214 | revision = "66b7b1311ac80bbafcd2daeef9a5e6e2cd1e2399" 215 | 216 | [[projects]] 217 | digest = "1:8029e9743749d4be5bc9f7d42ea1659471767860f0cdc34d37c3111bd308a295" 218 | name = "golang.org/x/text" 219 | packages = [ 220 | "internal/gen", 221 | "internal/triegen", 222 | "internal/ucd", 223 | "transform", 224 | "unicode/cldr", 225 | "unicode/norm", 226 | ] 227 | pruneopts = "UT" 228 | revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" 229 | version = "v0.3.0" 230 | 231 | [[projects]] 232 | digest = "1:342378ac4dcb378a5448dd723f0784ae519383532f5e70ade24132c4c8693202" 233 | name = "gopkg.in/yaml.v2" 234 | packages = ["."] 235 | pruneopts = "UT" 236 | revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" 237 | version = "v2.2.1" 238 | 239 | [solve-meta] 240 | analyzer-name = "dep" 241 | analyzer-version = 1 242 | input-imports = [ 243 | "github.com/NYTimes/gziphandler", 244 | "github.com/gorilla/handlers", 245 | "github.com/gorilla/mux", 246 | "github.com/jinzhu/gorm", 247 | "github.com/jinzhu/gorm/dialects/postgres", 248 | "github.com/pkg/errors", 249 | "github.com/sirupsen/logrus", 250 | "github.com/spf13/cobra", 251 | "github.com/spf13/viper", 252 | "golang.org/x/crypto/bcrypt", 253 | "golang.org/x/crypto/ssh/terminal", 254 | "golang.org/x/sys/unix", 255 | ] 256 | solver-name = "gps-cdcl" 257 | solver-version = 1 258 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Refer to https://golang.github.io/dep/docs/Gopkg.toml.html 2 | # for detailed Gopkg.toml documentation. 3 | 4 | [prune] 5 | go-tests = true 6 | unused-packages = true 7 | 8 | [[constraint]] 9 | name = "github.com/spf13/viper" 10 | version = "v1.2.1" 11 | 12 | [[constraint]] 13 | name = "github.com/spf13/cobra" 14 | version = "v0.0.3" 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alliance of American Football 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: dep build fmt 2 | 3 | dep: 4 | dep ensure 5 | 6 | build: dep 7 | go build -o todos . 8 | 9 | fmt: dep 10 | go fmt ./... 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple todo application in Go 2 | 3 | ### Related blog posts 4 | - Part 1: https://aaf.engineering/go-web-application-structure-pt-1 5 | - Part 2: https://aaf.engineering/go-web-application-structure-part-2 6 | - Part 3: https://aaf.engineering/go-web-application-structure-part-3 7 | - Part 4: https://aaf.engineering/go-web-application-structure-part-4 8 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "runtime/debug" 9 | "strings" 10 | "time" 11 | 12 | "github.com/gorilla/mux" 13 | "github.com/sirupsen/logrus" 14 | 15 | "github.com/theaaf/todos/app" 16 | "github.com/theaaf/todos/model" 17 | ) 18 | 19 | type statusCodeRecorder struct { 20 | http.ResponseWriter 21 | http.Hijacker 22 | StatusCode int 23 | } 24 | 25 | func (r *statusCodeRecorder) WriteHeader(statusCode int) { 26 | r.StatusCode = statusCode 27 | r.ResponseWriter.WriteHeader(statusCode) 28 | } 29 | 30 | type API struct { 31 | App *app.App 32 | Config *Config 33 | } 34 | 35 | func New(a *app.App) (api *API, err error) { 36 | api = &API{App: a} 37 | api.Config, err = InitConfig() 38 | if err != nil { 39 | return nil, err 40 | } 41 | return api, nil 42 | } 43 | 44 | func (a *API) Init(r *mux.Router) { 45 | // user methods 46 | r.Handle("/users/", a.handler(a.CreateUser)).Methods("POST") 47 | 48 | // todo methods 49 | todosRouter := r.PathPrefix("/todos").Subrouter() 50 | todosRouter.Handle("/", a.handler(a.GetTodos)).Methods("GET") 51 | todosRouter.Handle("/", a.handler(a.CreateTodo)).Methods("POST") 52 | todosRouter.Handle("/{id:[0-9]+}/", a.handler(a.GetTodoById)).Methods("GET") 53 | todosRouter.Handle("/{id:[0-9]+}/", a.handler(a.UpdateTodoById)).Methods("PATCH") 54 | todosRouter.Handle("/{id:[0-9]+}/", a.handler(a.DeleteTodoById)).Methods("DELETE") 55 | } 56 | 57 | func (a *API) handler(f func(*app.Context, http.ResponseWriter, *http.Request) error) http.Handler { 58 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 59 | r.Body = http.MaxBytesReader(w, r.Body, 100*1024*1024) 60 | 61 | beginTime := time.Now() 62 | 63 | hijacker, _ := w.(http.Hijacker) 64 | w = &statusCodeRecorder{ 65 | ResponseWriter: w, 66 | Hijacker: hijacker, 67 | } 68 | 69 | ctx := a.App.NewContext().WithRemoteAddress(a.IPAddressForRequest(r)) 70 | ctx = ctx.WithLogger(ctx.Logger.WithField("request_id", base64.RawURLEncoding.EncodeToString(model.NewId()))) 71 | 72 | if username, password, ok := r.BasicAuth(); ok { 73 | user, err := a.App.GetUserByEmail(username) 74 | 75 | if user == nil || err != nil { 76 | if err != nil { 77 | ctx.Logger.WithError(err).Error("unable to get user") 78 | } 79 | http.Error(w, "invalid credentials", http.StatusForbidden) 80 | return 81 | } 82 | 83 | if ok := user.CheckPassword(password); !ok { 84 | http.Error(w, "invalid credentials", http.StatusForbidden) 85 | return 86 | } 87 | 88 | ctx = ctx.WithUser(user) 89 | } 90 | 91 | defer func() { 92 | statusCode := w.(*statusCodeRecorder).StatusCode 93 | if statusCode == 0 { 94 | statusCode = 200 95 | } 96 | duration := time.Since(beginTime) 97 | 98 | logger := ctx.Logger.WithFields(logrus.Fields{ 99 | "duration": duration, 100 | "status_code": statusCode, 101 | "remote": ctx.RemoteAddress, 102 | }) 103 | logger.Info(r.Method + " " + r.URL.RequestURI()) 104 | }() 105 | 106 | defer func() { 107 | if r := recover(); r != nil { 108 | ctx.Logger.Error(fmt.Errorf("%v: %s", r, debug.Stack())) 109 | http.Error(w, "internal server error", http.StatusInternalServerError) 110 | } 111 | }() 112 | 113 | w.Header().Set("Content-Type", "application/json") 114 | 115 | if err := f(ctx, w, r); err != nil { 116 | if verr, ok := err.(*app.ValidationError); ok { 117 | data, err := json.Marshal(verr) 118 | if err == nil { 119 | w.WriteHeader(http.StatusBadRequest) 120 | _, err = w.Write(data) 121 | } 122 | 123 | if err != nil { 124 | ctx.Logger.Error(err) 125 | http.Error(w, "internal server error", http.StatusInternalServerError) 126 | } 127 | } else if uerr, ok := err.(*app.UserError); ok { 128 | data, err := json.Marshal(uerr) 129 | if err == nil { 130 | w.WriteHeader(uerr.StatusCode) 131 | _, err = w.Write(data) 132 | } 133 | 134 | if err != nil { 135 | ctx.Logger.Error(err) 136 | http.Error(w, "internal server error", http.StatusInternalServerError) 137 | } 138 | } else { 139 | ctx.Logger.Error(err) 140 | http.Error(w, "internal server error", http.StatusInternalServerError) 141 | } 142 | } 143 | }) 144 | } 145 | 146 | func (a *API) IPAddressForRequest(r *http.Request) string { 147 | addr := r.RemoteAddr 148 | if a.Config.ProxyCount > 0 { 149 | h := r.Header.Get("X-Forwarded-For") 150 | if h != "" { 151 | clients := strings.Split(h, ",") 152 | if a.Config.ProxyCount > len(clients) { 153 | addr = clients[0] 154 | } else { 155 | addr = clients[len(clients)-a.Config.ProxyCount] 156 | } 157 | } 158 | } 159 | return strings.Split(strings.TrimSpace(addr), ":")[0] 160 | } 161 | -------------------------------------------------------------------------------- /api/config.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "github.com/spf13/viper" 4 | 5 | type Config struct { 6 | // The port to bind the web application server to 7 | Port int 8 | 9 | // The number of proxies positioned in front of the API. This is used to interpret 10 | // X-Forwarded-For headers. 11 | ProxyCount int 12 | } 13 | 14 | func InitConfig() (*Config, error) { 15 | config := &Config{ 16 | Port: viper.GetInt("Port"), 17 | ProxyCount: viper.GetInt("ProxyCount"), 18 | } 19 | if config.Port == 0 { 20 | config.Port = 9092 21 | } 22 | return config, nil 23 | } 24 | -------------------------------------------------------------------------------- /api/todo.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http" 7 | "strconv" 8 | 9 | "github.com/gorilla/mux" 10 | 11 | "github.com/theaaf/todos/model" 12 | 13 | "github.com/theaaf/todos/app" 14 | ) 15 | 16 | func (a *API) GetTodos(ctx *app.Context, w http.ResponseWriter, r *http.Request) error { 17 | todos, err := ctx.GetUserTodos() 18 | if err != nil { 19 | return err 20 | } 21 | 22 | data, err := json.Marshal(todos) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | _, err = w.Write(data) 28 | return err 29 | } 30 | 31 | type CreateTodoInput struct { 32 | Name string `json:"name"` 33 | Done bool `json:"done"` 34 | } 35 | 36 | type CreateTodoResponse struct { 37 | Id uint `json:"id"` 38 | } 39 | 40 | func (a *API) CreateTodo(ctx *app.Context, w http.ResponseWriter, r *http.Request) error { 41 | var input CreateTodoInput 42 | 43 | defer r.Body.Close() 44 | body, err := ioutil.ReadAll(r.Body) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | if err := json.Unmarshal(body, &input); err != nil { 50 | return err 51 | } 52 | 53 | todo := &model.Todo{Name: input.Name, Done: input.Done} 54 | 55 | if err := ctx.CreateTodo(todo); err != nil { 56 | return err 57 | } 58 | 59 | data, err := json.Marshal(&CreateTodoResponse{Id: todo.ID}) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | _, err = w.Write(data) 65 | return err 66 | } 67 | 68 | func (a *API) GetTodoById(ctx *app.Context, w http.ResponseWriter, r *http.Request) error { 69 | id := getIdFromRequest(r) 70 | todo, err := ctx.GetTodoById(id) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | data, err := json.Marshal(todo) 76 | if err != nil { 77 | return err 78 | } 79 | 80 | _, err = w.Write(data) 81 | return err 82 | } 83 | 84 | type UpdateTodoInput struct { 85 | Name *string `json:"name"` 86 | Done *bool `json:"done"` 87 | } 88 | 89 | func (a *API) UpdateTodoById(ctx *app.Context, w http.ResponseWriter, r *http.Request) error { 90 | id := getIdFromRequest(r) 91 | 92 | var input UpdateTodoInput 93 | 94 | defer r.Body.Close() 95 | body, err := ioutil.ReadAll(r.Body) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | if err := json.Unmarshal(body, &input); err != nil { 101 | return err 102 | } 103 | 104 | existingTodo, err := ctx.GetTodoById(id) 105 | if err != nil || existingTodo == nil { 106 | return err 107 | } 108 | 109 | if input.Name != nil { 110 | existingTodo.Name = *input.Name 111 | } 112 | if input.Done != nil { 113 | existingTodo.Done = *input.Done 114 | } 115 | 116 | err = ctx.UpdateTodo(existingTodo) 117 | if err != nil { 118 | return err 119 | } 120 | 121 | data, err := json.Marshal(existingTodo) 122 | if err != nil { 123 | return err 124 | } 125 | 126 | _, err = w.Write(data) 127 | return err 128 | } 129 | 130 | func (a *API) DeleteTodoById(ctx *app.Context, w http.ResponseWriter, r *http.Request) error { 131 | id := getIdFromRequest(r) 132 | err := ctx.DeleteTodoById(id) 133 | 134 | if err != nil { 135 | return err 136 | } 137 | 138 | return &app.UserError{StatusCode: http.StatusOK, Message: "removed"} 139 | } 140 | 141 | func getIdFromRequest(r *http.Request) uint { 142 | vars := mux.Vars(r) 143 | id := vars["id"] 144 | 145 | intId, err := strconv.ParseInt(id, 10, 0) 146 | if err != nil { 147 | return 0 148 | } 149 | 150 | return uint(intId) 151 | } 152 | -------------------------------------------------------------------------------- /api/user.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http" 7 | 8 | "github.com/theaaf/todos/app" 9 | "github.com/theaaf/todos/model" 10 | ) 11 | 12 | type UserInput struct { 13 | Email string `json:"email"` 14 | Password string `json:"password"` 15 | } 16 | 17 | type UserResponse struct { 18 | Id uint `json:"id"` 19 | } 20 | 21 | func (a *API) CreateUser(ctx *app.Context, w http.ResponseWriter, r *http.Request) error { 22 | var input UserInput 23 | 24 | defer r.Body.Close() 25 | body, err := ioutil.ReadAll(r.Body) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | if err := json.Unmarshal(body, &input); err != nil { 31 | return err 32 | } 33 | 34 | user := &model.User{Email: input.Email} 35 | 36 | if err := ctx.CreateUser(user, input.Password); err != nil { 37 | return err 38 | } 39 | 40 | data, err := json.Marshal(&UserResponse{Id: user.ID}) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | _, err = w.Write(data) 46 | return err 47 | } 48 | -------------------------------------------------------------------------------- /app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/sirupsen/logrus" 5 | 6 | "github.com/theaaf/todos/db" 7 | ) 8 | 9 | type App struct { 10 | Config *Config 11 | Database *db.Database 12 | } 13 | 14 | func (a *App) NewContext() *Context { 15 | return &Context{ 16 | Logger: logrus.StandardLogger(), 17 | Database: a.Database, 18 | } 19 | } 20 | 21 | func New() (app *App, err error) { 22 | app = &App{} 23 | app.Config, err = InitConfig() 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | dbConfig, err := db.InitConfig() 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | app.Database, err = db.New(dbConfig) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | return app, err 39 | } 40 | 41 | func (a *App) Close() error { 42 | return a.Database.Close() 43 | } 44 | 45 | type ValidationError struct { 46 | Message string `json:"message"` 47 | } 48 | 49 | func (e *ValidationError) Error() string { 50 | return e.Message 51 | } 52 | 53 | type UserError struct { 54 | Message string `json:"message"` 55 | StatusCode int `json:"-"` 56 | } 57 | 58 | func (e *UserError) Error() string { 59 | return e.Message 60 | } 61 | -------------------------------------------------------------------------------- /app/config.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/viper" 7 | ) 8 | 9 | type Config struct { 10 | // A secret string used for session cookies, passwords, etc. 11 | SecretKey []byte 12 | } 13 | 14 | func InitConfig() (*Config, error) { 15 | config := &Config{ 16 | SecretKey: []byte(viper.GetString("SecretKey")), 17 | } 18 | if len(config.SecretKey) == 0 { 19 | return nil, fmt.Errorf("SecretKey must be set") 20 | } 21 | return config, nil 22 | } 23 | -------------------------------------------------------------------------------- /app/context.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/sirupsen/logrus" 7 | 8 | "github.com/theaaf/todos/db" 9 | "github.com/theaaf/todos/model" 10 | ) 11 | 12 | type Context struct { 13 | Logger logrus.FieldLogger 14 | RemoteAddress string 15 | Database *db.Database 16 | User *model.User 17 | } 18 | 19 | func (ctx *Context) WithLogger(logger logrus.FieldLogger) *Context { 20 | ret := *ctx 21 | ret.Logger = logger 22 | return &ret 23 | } 24 | 25 | func (ctx *Context) WithRemoteAddress(address string) *Context { 26 | ret := *ctx 27 | ret.RemoteAddress = address 28 | return &ret 29 | } 30 | 31 | func (ctx *Context) WithUser(user *model.User) *Context { 32 | ret := *ctx 33 | ret.User = user 34 | return &ret 35 | } 36 | 37 | func (ctx *Context) AuthorizationError() *UserError { 38 | return &UserError{Message: "unauthorized", StatusCode: http.StatusForbidden} 39 | } 40 | -------------------------------------------------------------------------------- /app/todo.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import "github.com/theaaf/todos/model" 4 | 5 | func (ctx *Context) GetTodoById(id uint) (*model.Todo, error) { 6 | if ctx.User == nil { 7 | return nil, ctx.AuthorizationError() 8 | } 9 | 10 | todo, err := ctx.Database.GetTodoById(id) 11 | if err != nil { 12 | return nil, err 13 | } 14 | 15 | if todo.UserID != ctx.User.ID { 16 | return nil, ctx.AuthorizationError() 17 | } 18 | 19 | return todo, nil 20 | } 21 | 22 | func (ctx *Context) getTodosByUserId(userId uint) ([]*model.Todo, error) { 23 | return ctx.Database.GetTodosByUserId(userId) 24 | } 25 | 26 | func (ctx *Context) GetUserTodos() ([]*model.Todo, error) { 27 | if ctx.User == nil { 28 | return nil, ctx.AuthorizationError() 29 | } 30 | 31 | return ctx.getTodosByUserId(ctx.User.ID) 32 | } 33 | 34 | func (ctx *Context) CreateTodo(todo *model.Todo) error { 35 | if ctx.User == nil { 36 | return ctx.AuthorizationError() 37 | } 38 | 39 | todo.UserID = ctx.User.ID 40 | 41 | if err := ctx.validateTodo(todo); err != nil { 42 | return err 43 | } 44 | 45 | return ctx.Database.CreateTodo(todo) 46 | } 47 | 48 | const maxTodoNameLength = 100 49 | 50 | func (ctx *Context) validateTodo(todo *model.Todo) *ValidationError { 51 | if len(todo.Name) > maxTodoNameLength { 52 | return &ValidationError{"name is too long"} 53 | } 54 | 55 | return nil 56 | } 57 | 58 | func (ctx *Context) UpdateTodo(todo *model.Todo) error { 59 | if ctx.User == nil { 60 | return ctx.AuthorizationError() 61 | } 62 | 63 | if todo.UserID != ctx.User.ID { 64 | return ctx.AuthorizationError() 65 | } 66 | 67 | if todo.ID == 0 { 68 | return &ValidationError{"cannot update"} 69 | } 70 | 71 | if err := ctx.validateTodo(todo); err != nil { 72 | return nil 73 | } 74 | 75 | return ctx.Database.UpdateTodo(todo) 76 | } 77 | 78 | func (ctx *Context) DeleteTodoById(id uint) error { 79 | if ctx.User == nil { 80 | return ctx.AuthorizationError() 81 | } 82 | 83 | todo, err := ctx.GetTodoById(id) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | if todo.UserID != ctx.User.ID { 89 | return ctx.AuthorizationError() 90 | } 91 | 92 | return ctx.Database.DeleteTodoById(id) 93 | } 94 | -------------------------------------------------------------------------------- /app/user.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/pkg/errors" 7 | 8 | "github.com/theaaf/todos/model" 9 | ) 10 | 11 | func (a *App) GetUserByEmail(email string) (*model.User, error) { 12 | return a.Database.GetUserByEmail(email) 13 | } 14 | 15 | func (ctx *Context) CreateUser(user *model.User, password string) error { 16 | if err := ctx.validateUser(user, password); err != nil { 17 | return err 18 | } 19 | 20 | if err := user.SetPassword(password); err != nil { 21 | return errors.Wrap(err, "unable to set user password") 22 | } 23 | 24 | return ctx.Database.CreateUser(user) 25 | } 26 | 27 | func (ctx *Context) validateUser(user *model.User, password string) *ValidationError { 28 | // naive email validation 29 | if !strings.Contains(user.Email, "@") { 30 | return &ValidationError{"invalid email"} 31 | } 32 | 33 | if password == "" { 34 | return &ValidationError{"password is required"} 35 | } 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /cmd/migrate.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/jinzhu/gorm" 7 | "github.com/pkg/errors" 8 | "github.com/sirupsen/logrus" 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/theaaf/todos/app" 12 | "github.com/theaaf/todos/migrations" 13 | ) 14 | 15 | var migrateCmd = &cobra.Command{ 16 | Use: "migrate", 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | number, _ := cmd.Flags().GetInt("number") 19 | dryRun, _ := cmd.Flags().GetBool("dry-run") 20 | 21 | if dryRun { 22 | logrus.Info("=== DRY RUN ===") 23 | } 24 | 25 | sort.Slice(migrations.Migrations, func(i, j int) bool { 26 | return migrations.Migrations[i].Number < migrations.Migrations[j].Number 27 | }) 28 | 29 | a, err := app.New() 30 | if err != nil { 31 | return err 32 | } 33 | defer a.Close() 34 | 35 | // Make sure Migration table is there 36 | logrus.Debug("ensuring migrations table is present") 37 | if err := a.Database.AutoMigrate(&migrations.Migration{}).Error; err != nil { 38 | return errors.Wrap(err, "unable to automatically migrate migrations table") 39 | } 40 | 41 | var latest migrations.Migration 42 | if err := a.Database.Order("number desc").First(&latest).Error; err != nil && !gorm.IsRecordNotFoundError(err) { 43 | return errors.Wrap(err, "unable to find latest migration") 44 | } 45 | 46 | noMigrationsApplied := latest.Number == 0 47 | 48 | if noMigrationsApplied && len(migrations.Migrations) == 0 { 49 | logrus.Info("no migrations to apply") 50 | return nil 51 | } 52 | 53 | if latest.Number >= migrations.Migrations[len(migrations.Migrations)-1].Number { 54 | logrus.Info("no migrations to apply") 55 | return nil 56 | } 57 | 58 | if number == -1 { 59 | number = int(migrations.Migrations[len(migrations.Migrations)-1].Number) 60 | } 61 | 62 | if uint(number) <= latest.Number && latest.Number > 0 { 63 | logrus.Info("no migrations to apply, specified number is less than or equal to latest migration; backwards migrations are not supported") 64 | return nil 65 | } 66 | 67 | for _, migration := range migrations.Migrations { 68 | if migration.Number > uint(number) { 69 | break 70 | } 71 | 72 | logger := logrus.WithField("migration_number", migration.Number) 73 | logger.Infof("applying migration %q", migration.Name) 74 | 75 | if dryRun { 76 | continue 77 | } 78 | 79 | tx := a.Database.Begin() 80 | 81 | if err := migration.Forwards(tx); err != nil { 82 | logger.WithError(err).Error("unable to apply migration, rolling back") 83 | if err := tx.Rollback().Error; err != nil { 84 | logger.WithError(err).Error("unable to rollback...") 85 | } 86 | break 87 | } 88 | 89 | if err := tx.Commit().Error; err != nil { 90 | logger.WithError(err).Error("unable to commit transaction...") 91 | break 92 | } 93 | 94 | if err := a.Database.Create(migration).Error; err != nil { 95 | logger.WithError(err).Error("unable to create migration record") 96 | break 97 | } 98 | } 99 | 100 | return nil 101 | }, 102 | } 103 | 104 | func init() { 105 | rootCmd.AddCommand(migrateCmd) 106 | 107 | migrateCmd.Flags().Int("number", -1, "the migration to run forwards until; if not set, will run all migrations") 108 | migrateCmd.Flags().Bool("dry-run", false, "print out migrations to be applied without running them") 109 | } 110 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/spf13/cobra" 8 | "github.com/spf13/viper" 9 | "golang.org/x/crypto/ssh/terminal" 10 | "golang.org/x/sys/unix" 11 | ) 12 | 13 | var rootCmd = &cobra.Command{ 14 | Use: "todos", 15 | Short: "Todo Web Application", 16 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 17 | if !terminal.IsTerminal(unix.Stdout) { 18 | logrus.SetFormatter(&logrus.JSONFormatter{}) 19 | } else { 20 | logrus.SetFormatter(&logrus.TextFormatter{ 21 | FullTimestamp: true, 22 | TimestampFormat: time.RFC3339Nano, 23 | }) 24 | } 25 | 26 | if verbose, _ := cmd.Flags().GetBool("verbose"); verbose { 27 | logrus.SetLevel(logrus.DebugLevel) 28 | } 29 | }, 30 | } 31 | 32 | func Execute() { 33 | if err := rootCmd.Execute(); err != nil { 34 | logrus.Fatal(err) 35 | } 36 | } 37 | 38 | var configFile string 39 | 40 | func init() { 41 | cobra.OnInitialize(initConfig) 42 | rootCmd.PersistentFlags().BoolP("verbose", "v", false, "make output more verbose") 43 | rootCmd.PersistentFlags().StringVar(&configFile, "config", "", "config file (default is config.yaml)") 44 | } 45 | 46 | func initConfig() { 47 | if configFile != "" { 48 | viper.SetConfigFile(configFile) 49 | } else { 50 | viper.SetConfigName("config") 51 | viper.AddConfigPath(".") 52 | viper.AddConfigPath("/etc/todos") 53 | viper.AddConfigPath("$HOME/.todos") 54 | } 55 | 56 | viper.AutomaticEnv() 57 | 58 | if err := viper.ReadInConfig(); err != nil { 59 | logrus.WithError(err).Warnf("unable to read config from file") 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /cmd/serve.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "sync" 10 | "time" 11 | 12 | "github.com/gorilla/handlers" 13 | "github.com/gorilla/mux" 14 | "github.com/sirupsen/logrus" 15 | "github.com/spf13/cobra" 16 | 17 | "github.com/theaaf/todos/api" 18 | "github.com/theaaf/todos/app" 19 | ) 20 | 21 | func serveAPI(ctx context.Context, api *api.API) { 22 | cors := handlers.CORS( 23 | handlers.AllowedOrigins([]string{"*"}), 24 | handlers.AllowedMethods([]string{"GET", "HEAD", "POST", "OPTIONS"}), 25 | handlers.AllowedHeaders([]string{"Content-Type", "Authorization"}), 26 | ) 27 | 28 | router := mux.NewRouter() 29 | api.Init(router.PathPrefix("/api").Subrouter()) 30 | 31 | s := &http.Server{ 32 | Addr: fmt.Sprintf(":%d", api.Config.Port), 33 | Handler: cors(router), 34 | ReadTimeout: 2 * time.Minute, 35 | } 36 | 37 | done := make(chan struct{}) 38 | go func() { 39 | <-ctx.Done() 40 | if err := s.Shutdown(context.Background()); err != nil { 41 | logrus.Error(err) 42 | } 43 | close(done) 44 | }() 45 | 46 | logrus.Infof("serving api at http://127.0.0.1:%d", api.Config.Port) 47 | if err := s.ListenAndServe(); err != http.ErrServerClosed { 48 | logrus.Error(err) 49 | } 50 | <-done 51 | } 52 | 53 | var serveCmd = &cobra.Command{ 54 | Use: "serve", 55 | Short: "serves the api", 56 | RunE: func(cmd *cobra.Command, args []string) error { 57 | a, err := app.New() 58 | if err != nil { 59 | return err 60 | } 61 | defer a.Close() 62 | 63 | api, err := api.New(a) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | ctx, cancel := context.WithCancel(context.Background()) 69 | 70 | go func() { 71 | ch := make(chan os.Signal, 1) 72 | signal.Notify(ch, os.Interrupt) 73 | <-ch 74 | logrus.Info("signal caught. shutting down...") 75 | cancel() 76 | }() 77 | 78 | var wg sync.WaitGroup 79 | 80 | wg.Add(1) 81 | go func() { 82 | defer wg.Done() 83 | defer cancel() 84 | serveAPI(ctx, api) 85 | }() 86 | 87 | wg.Wait() 88 | return nil 89 | }, 90 | } 91 | 92 | func init() { 93 | rootCmd.AddCommand(serveCmd) 94 | } 95 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func init() { 10 | rootCmd.AddCommand(versionCmd) 11 | } 12 | 13 | var versionCmd = &cobra.Command{ 14 | Use: "version", 15 | Short: "Print the version number", 16 | Run: func(cmd *cobra.Command, args []string) { 17 | fmt.Println("Todos v1.0") 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /config.example.yaml: -------------------------------------------------------------------------------- 1 | SecretKey: 123 2 | DatabaseURI: host=localhost port=5432 user=todos_user dbname=todos_db password=123 3 | -------------------------------------------------------------------------------- /db/config.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/viper" 7 | ) 8 | 9 | type Config struct { 10 | DatabaseURI string 11 | } 12 | 13 | func InitConfig() (*Config, error) { 14 | config := &Config{ 15 | DatabaseURI: viper.GetString("DatabaseURI"), 16 | } 17 | if config.DatabaseURI == "" { 18 | return nil, fmt.Errorf("DatabaseURI must be set") 19 | } 20 | return config, nil 21 | } 22 | -------------------------------------------------------------------------------- /db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | _ "github.com/jinzhu/gorm/dialects/postgres" 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | type Database struct { 10 | *gorm.DB 11 | } 12 | 13 | func New(config *Config) (*Database, error) { 14 | db, err := gorm.Open("postgres", config.DatabaseURI) 15 | if err != nil { 16 | return nil, errors.Wrap(err, "unable to connect to database") 17 | } 18 | return &Database{db}, nil 19 | } 20 | -------------------------------------------------------------------------------- /db/todo.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | "github.com/pkg/errors" 6 | 7 | "github.com/theaaf/todos/model" 8 | ) 9 | 10 | func (db *Database) GetTodoById(id uint) (*model.Todo, error) { 11 | var todo model.Todo 12 | 13 | if err := db.First(&todo, id).Error; err != nil { 14 | if gorm.IsRecordNotFoundError(err) { 15 | return nil, nil 16 | } 17 | 18 | return nil, errors.Wrap(err, "unable to get todo") 19 | } 20 | 21 | return &todo, nil 22 | } 23 | 24 | func (db *Database) GetTodosByUserId(userId uint) ([]*model.Todo, error) { 25 | var todos []*model.Todo 26 | return todos, errors.Wrap(db.Find(&todos, model.Todo{UserID: userId}).Error, "unable to get todos") 27 | } 28 | 29 | func (db *Database) CreateTodo(todo *model.Todo) error { 30 | return errors.Wrap(db.Create(todo).Error, "unable to create todo") 31 | } 32 | 33 | func (db *Database) UpdateTodo(todo *model.Todo) error { 34 | return errors.Wrap(db.Save(todo).Error, "unable to update todo") 35 | } 36 | 37 | func (db *Database) DeleteTodoById(id uint) error { 38 | return errors.Wrap(db.Delete(&model.Todo{}, id).Error, "unable to delete todo") 39 | } 40 | -------------------------------------------------------------------------------- /db/user.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | "github.com/pkg/errors" 6 | 7 | "github.com/theaaf/todos/model" 8 | ) 9 | 10 | func (db *Database) GetUserByEmail(email string) (*model.User, error) { 11 | var user model.User 12 | 13 | if err := db.First(&user, model.User{Email: email}).Error; err != nil { 14 | if gorm.IsRecordNotFoundError(err) { 15 | return nil, nil 16 | } 17 | return nil, errors.Wrap(err, "unable to get user") 18 | } 19 | 20 | return &user, nil 21 | } 22 | 23 | func (db *Database) CreateUser(user *model.User) error { 24 | return db.Create(user).Error 25 | } 26 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/theaaf/todos/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /migrations/0001_add_user.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | "github.com/pkg/errors" 6 | ) 7 | 8 | var addUserMigration_0001 = &Migration{ 9 | Number: 1, 10 | Name: "Add user", 11 | Forwards: func(db *gorm.DB) error { 12 | const addUserSQL = ` 13 | CREATE TABLE users( 14 | id serial PRIMARY KEY, 15 | email text UNIQUE NOT NULL, 16 | hashed_password bytea NOT NULL, 17 | created_at TIMESTAMP NOT NULL, 18 | updated_at TIMESTAMP NOT NULL, 19 | deleted_at TIMESTAMP 20 | ); 21 | ` 22 | 23 | err := db.Exec(addUserSQL).Error 24 | return errors.Wrap(err, "unable to create users table") 25 | }, 26 | } 27 | 28 | func init() { 29 | Migrations = append(Migrations, addUserMigration_0001) 30 | } 31 | -------------------------------------------------------------------------------- /migrations/0002_add_todo.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | "github.com/pkg/errors" 6 | ) 7 | 8 | var addTodoMigration_0002 = &Migration{ 9 | Number: 2, 10 | Name: "Add todo", 11 | Forwards: func(db *gorm.DB) error { 12 | const addUserSQL = ` 13 | CREATE TABLE todos( 14 | id serial PRIMARY KEY, 15 | name text NOT NULL, 16 | done boolean NOT NULL, 17 | user_id int not null, 18 | created_at TIMESTAMP NOT NULL, 19 | updated_at TIMESTAMP NOT NULL, 20 | deleted_at TIMESTAMP, 21 | 22 | CONSTRAINT todos_user_id_fkey FOREIGN KEY (user_id) 23 | REFERENCES users (id) MATCH SIMPLE 24 | ON UPDATE NO ACTION ON DELETE CASCADE 25 | ); 26 | ` 27 | 28 | err := db.Exec(addUserSQL).Error 29 | return errors.Wrap(err, "unable to create todos table") 30 | }, 31 | } 32 | 33 | func init() { 34 | Migrations = append(Migrations, addTodoMigration_0002) 35 | } 36 | -------------------------------------------------------------------------------- /migrations/migration.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | ) 6 | 7 | type Migration struct { 8 | Number uint `gorm:"primary_key"` 9 | Name string 10 | 11 | Forwards func(db *gorm.DB) error `gorm:"-"` 12 | } 13 | 14 | var Migrations []*Migration 15 | -------------------------------------------------------------------------------- /model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "crypto/rand" 5 | "time" 6 | ) 7 | 8 | type Id []byte 9 | 10 | func NewId() Id { 11 | ret := make(Id, 20) 12 | if _, err := rand.Read(ret); err != nil { 13 | panic(err) 14 | } 15 | return ret 16 | } 17 | 18 | type Model struct { 19 | ID uint `gorm:"primary_key" json:"id"` 20 | CreatedAt time.Time `json:"created_at"` 21 | UpdatedAt time.Time `json:"updated_at"` 22 | DeletedAt *time.Time `sql:"index" json:"deleted_at"` 23 | } 24 | -------------------------------------------------------------------------------- /model/todo.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Todo struct { 4 | Model 5 | 6 | Name string `json:"name"` 7 | Done bool `json:"done"` 8 | 9 | User User `json:"-"` 10 | UserID uint `json:"-"` 11 | } 12 | -------------------------------------------------------------------------------- /model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "golang.org/x/crypto/bcrypt" 5 | ) 6 | 7 | func GeneratePasswordHash(password []byte) ([]byte, error) { 8 | return bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost) 9 | } 10 | 11 | func ComparePasswordHash(hashedPassword, givenPassword []byte) bool { 12 | err := bcrypt.CompareHashAndPassword(hashedPassword, givenPassword) 13 | return err == nil 14 | } 15 | 16 | type User struct { 17 | Model 18 | 19 | Email string 20 | HashedPassword []byte 21 | } 22 | 23 | func (u *User) SetPassword(password string) error { 24 | hashed, err := GeneratePasswordHash([]byte(password)) 25 | if err != nil { 26 | return err 27 | } 28 | u.HashedPassword = hashed 29 | return nil 30 | } 31 | 32 | func (u *User) CheckPassword(password string) bool { 33 | return ComparePasswordHash(u.HashedPassword, []byte(password)) 34 | } 35 | --------------------------------------------------------------------------------