├── .circleci └── config.yml ├── .gitignore ├── Gopkg.lock ├── Gopkg.toml ├── README.md ├── bin ├── ci │ └── deploy └── test ├── config ├── app.yaml ├── di.go ├── fixtures │ └── users.yml └── server.go └── src └── app ├── application └── user.go ├── domain └── user │ ├── errors.go │ ├── factory.go │ ├── repository.go │ ├── user.go │ └── value.go ├── infrastructure ├── config │ └── router.go ├── context │ └── transaction.go ├── datastore │ ├── transaction.go │ └── transaction_test.go ├── db │ ├── db.go │ └── user │ │ ├── repository.go │ │ ├── repository_test.go │ │ ├── user_email.go │ │ └── user_screen_name.go ├── environments │ └── environments.go └── fixture │ └── fixture.go ├── interfaces ├── handler │ ├── handler.go │ └── user.go ├── middleware │ └── context_setter.go └── response │ ├── error.go │ ├── response.go │ └── user.go ├── internal ├── mock_environment.go └── mock_environment_test.go └── test ├── di_test.go ├── init_test.go └── users_test.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | workflows: 4 | version: 2 5 | build: 6 | jobs: 7 | - test 8 | - deploy: 9 | requires: 10 | - test 11 | filters: 12 | branches: 13 | only: master 14 | 15 | job_defaults: &job_defaults 16 | working_directory: /go/src/github.com/hamakn/go_ddd_webapp 17 | docker: 18 | - image: mercari/appengine-go:1.9 19 | 20 | jobs: 21 | test: 22 | <<: *job_defaults 23 | steps: 24 | - checkout 25 | - run: go get -u github.com/golang/dep/cmd/dep 26 | - run: go get -u golang.org/x/lint/golint 27 | - run: go get -u honnef.co/go/tools/cmd/gosimple 28 | - run: go get -u honnef.co/go/tools/cmd/staticcheck 29 | - run: go get -u github.com/daisuzu/gsc 30 | - run: dep ensure -vendor-only 31 | - type: cache-save 32 | key: dep-cache-{{ checksum "Gopkg.lock" }} 33 | paths: 34 | - vendor 35 | - run: golint -set_exit_status ./config... && golint -set_exit_status ./src/... 36 | - run: RESULT=$(gofmt -d -s ./config/ ./src/) && echo "${RESULT}"; ! [ -n "${RESULT}" ] 37 | - run: go vet ./config/... ./src/... 38 | - run: gsc -tests=false ./config/... ./src/... 39 | - run: goapp test ./config/... ./src/... 40 | 41 | deploy: 42 | <<: *job_defaults 43 | steps: 44 | - checkout 45 | - type: cache-restore 46 | key: dep-cache-{{ checksum "Gopkg.lock" }} 47 | - run: ./bin/ci/deploy 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | 3 | **/ignored 4 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | name = "github.com/davecgh/go-spew" 6 | packages = ["spew"] 7 | revision = "346938d642f2ec3594ed81d874461961cd0faa76" 8 | version = "v1.1.0" 9 | 10 | [[projects]] 11 | name = "github.com/go-playground/locales" 12 | packages = [".","currency"] 13 | revision = "f63010822830b6fe52288ee52d5a1151088ce039" 14 | version = "v0.12.1" 15 | 16 | [[projects]] 17 | name = "github.com/go-playground/universal-translator" 18 | packages = ["."] 19 | revision = "b32fa301c9fe55953584134cb6853a13c87ec0a1" 20 | version = "v0.16.0" 21 | 22 | [[projects]] 23 | name = "github.com/golang/protobuf" 24 | packages = ["proto"] 25 | revision = "925541529c1fa6821df4e44ce2723319eb2be768" 26 | version = "v1.0.0" 27 | 28 | [[projects]] 29 | name = "github.com/gorilla/context" 30 | packages = ["."] 31 | revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a" 32 | version = "v1.1" 33 | 34 | [[projects]] 35 | name = "github.com/gorilla/mux" 36 | packages = ["."] 37 | revision = "53c1911da2b537f792e7cafcb446b05ffe33b996" 38 | version = "v1.6.1" 39 | 40 | [[projects]] 41 | name = "github.com/kelseyhightower/envconfig" 42 | packages = ["."] 43 | revision = "f611eb38b3875cc3bd991ca91c51d06446afa14c" 44 | version = "v1.3.0" 45 | 46 | [[projects]] 47 | branch = "master" 48 | name = "github.com/mjibson/goon" 49 | packages = ["."] 50 | revision = "0cd9745d6b9c43c1de754769708fb2ac349d84b2" 51 | 52 | [[projects]] 53 | name = "github.com/pkg/errors" 54 | packages = ["."] 55 | revision = "645ef00459ed84a119197bfb8d8205042c6df63d" 56 | version = "v0.8.0" 57 | 58 | [[projects]] 59 | name = "github.com/pmezard/go-difflib" 60 | packages = ["difflib"] 61 | revision = "792786c7400a136282c1664665ae0a8db921c6c2" 62 | version = "v1.0.0" 63 | 64 | [[projects]] 65 | name = "github.com/stretchr/testify" 66 | packages = ["assert","require"] 67 | revision = "12b6f73e6084dad08a7c6e575284b177ecafbc71" 68 | version = "v1.2.1" 69 | 70 | [[projects]] 71 | name = "github.com/urfave/negroni" 72 | packages = ["."] 73 | revision = "5dbbc83f748fc3ad38585842b0aedab546d0ea1e" 74 | version = "v0.3.0" 75 | 76 | [[projects]] 77 | branch = "master" 78 | name = "golang.org/x/net" 79 | packages = ["context"] 80 | revision = "f5dfe339be1d06f81b22525fe34671ee7d2c8904" 81 | 82 | [[projects]] 83 | branch = "master" 84 | name = "golang.org/x/sync" 85 | packages = ["errgroup"] 86 | revision = "1d60e4601c6fd243af51cc01ddf169918a5407ca" 87 | 88 | [[projects]] 89 | name = "google.golang.org/appengine" 90 | packages = [".","aetest","datastore","internal","internal/app_identity","internal/base","internal/datastore","internal/log","internal/memcache","internal/modules","internal/remote_api","internal/user","log","memcache","user"] 91 | revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a" 92 | version = "v1.0.0" 93 | 94 | [[projects]] 95 | name = "gopkg.in/go-playground/validator.v9" 96 | packages = ["."] 97 | revision = "8ce234ff024d85b3848e485decba806385d6e276" 98 | version = "v9.13.0" 99 | 100 | [[projects]] 101 | name = "gopkg.in/yaml.v2" 102 | packages = ["."] 103 | revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" 104 | version = "v2.2.1" 105 | 106 | [solve-meta] 107 | analyzer-name = "dep" 108 | analyzer-version = 1 109 | inputs-digest = "30e94ff4ed35397221986588fb1c8888d9244445dd0d3b1eccb79c5dfc2dea01" 110 | solver-name = "gps-cdcl" 111 | solver-version = 1 112 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Gopkg.toml example 2 | # 3 | # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md 4 | # for detailed Gopkg.toml documentation. 5 | # 6 | # required = ["github.com/user/thing/cmd/thing"] 7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 8 | # 9 | # [[constraint]] 10 | # name = "github.com/user/project" 11 | # version = "1.0.0" 12 | # 13 | # [[constraint]] 14 | # name = "github.com/user/project2" 15 | # branch = "dev" 16 | # source = "github.com/myfork/project2" 17 | # 18 | # [[override]] 19 | # name = "github.com/x/y" 20 | # version = "2.4.0" 21 | 22 | 23 | [[constraint]] 24 | name = "gopkg.in/yaml.v2" 25 | version = "v2.2.1" 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CircleCI](https://circleci.com/gh/hamakn/go_ddd_webapp/tree/master.svg?style=svg)](https://circleci.com/gh/hamakn/go_ddd_webapp/tree/master) 2 | 3 | # my Golang DDD webapp example 4 | 5 | ## Features 6 | 7 | * GAE/datastore 8 | * Multiple unique constaints on datastore 9 | * Layered Architecture 10 | * No framework library 11 | * Use [negroni](https://github.com/urfave/negroni) to handle HTTP 12 | * Use [goon](https://github.com/mjibson/goon) for autocaching 13 | 14 | ## Boot 15 | ```` 16 | % dev_appserver.py config/app.yaml 17 | ```` 18 | 19 | ## Request examples 20 | ```` 21 | % curl -X POST -d '{"email": "foo@foo.test", "screen_name": "foo", "age": 17}' http://localhost:8080/users/ 22 | {"id":5629499534213120,"email":"foo@foo.test","screen_name":"foo","age":17,"created_at":"2018-04-08T10:21:07.617449Z","updated_at":"2018-04-08T10:21:07.617449Z"} 23 | 24 | % curl http://localhost:8080/users/ 25 | [{"id":5629499534213120,"email":"foo@foo.test","screen_name":"foo","age":17,"created_at":"2018-04-08T10:21:07.617449Z","updated_at":"2018-04-08T10:21:07.617449Z"}] 26 | 27 | % curl http://localhost:8080/users/5629499534213120 28 | {"id":5629499534213120,"email":"foo@foo.test","screen_name":"foo","age":17,"created_at":"2018-04-08T10:21:07.617449Z","updated_at":"2018-04-08T10:21:07.617449Z"} 29 | 30 | % curl -X POST -d '{"email": "foo@foo.test", "screen_name": "new", "age": 17}' http://localhost:8080/users/ 31 | {"error":"Unprocessable Entity"} 32 | 33 | % curl -X POST -d '{"email": "new@foo.test", "screen_name": "foo", "age": 17}' http://localhost:8080/users/ 34 | {"error":"Unprocessable Entity"} 35 | 36 | % curl -X PUT -d '{"screen_name": "new"}' http://localhost:8080/users/5629499534213120 37 | {"id":5629499534213120,"email":"foo@foo.test","screen_name":"new","age":17,"created_at":"2018-04-08T10:21:07.617449Z","updated_at":"2018-04-08T10:22:59.279485Z"} 38 | 39 | % curl -X DELETE http://localhost:8080/users/5629499534213120 40 | 41 | % curl http://localhost:8080/users/ 42 | [] 43 | ```` 44 | -------------------------------------------------------------------------------- /bin/ci/deploy: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | # get gcloud access token 6 | echo $PROD_GCLOUD_SERVICE_KEY | base64 -d -i > ${HOME}/gcloud-service-key.json 7 | gcloud auth activate-service-account --key-file ${HOME}/gcloud-service-key.json 8 | gcloud config set project $PROD_PROJECT_NAME 9 | access_token=$(gcloud auth print-access-token 2> /dev/null) 10 | 11 | # deploy 12 | appcfg.py update ./config/app.yaml --application=$PROD_PROJECT_NAME --version=master --oauth2_access_token=${access_token} 13 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -u 4 | 5 | echo -e "### GO LINT ###\n" 6 | 7 | golint -set_exit_status ./config... 8 | GO_LINT_RESULT=$? 9 | 10 | golint -set_exit_status ./src/... 11 | GO_LINT_SRC_RESULT=$? 12 | 13 | echo -e "\n### GO FMT ###\n" 14 | 15 | RESULT=$(gofmt -d -s ./config/ ./src/) && echo "${RESULT}"; ! [ -n "${RESULT}" ] 16 | GO_FMT_RESULT=$? 17 | 18 | echo -e "### GO VET ###\n" 19 | 20 | go vet ./config/... ./src/... 21 | GO_VET_RESULT=$? 22 | 23 | echo -e "\n### GO SIMPLE ###\n" 24 | 25 | gosimple ./config/... ./src/... 26 | GO_SIMPLE_RESULT=$? 27 | 28 | echo -e "\n### STATICCHECK ###\n" 29 | 30 | staticcheck ./config/... ./src/... 31 | STATICCHECK_RESULT=$? 32 | 33 | echo -e "\n### GSC ###\n" 34 | 35 | gsc -tests=false ./config/... ./src/... 36 | GSC_RESULT=$? 37 | 38 | echo -e "\n### GO TEST ###\n" 39 | 40 | goapp test ./config/... ./src/... 41 | GO_TEST_RESULT=$? 42 | 43 | EXIT_RESULT=`expr $GO_LINT_RESULT + $GO_LINT_SRC_RESULT + $GO_FMT_RESULT + $GO_VET_RESULT + $GO_SIMPLE_RESULT + $STATICCHECK_RESULT + $GSC_RESULT + $GO_TEST_RESULT` 44 | exit $EXIT_RESULT 45 | -------------------------------------------------------------------------------- /config/app.yaml: -------------------------------------------------------------------------------- 1 | runtime: go 2 | api_version: go1.9 3 | instance_class: B1 4 | manual_scaling: 5 | instances: 1 6 | 7 | handlers: 8 | - url: /.* 9 | script: _go_app 10 | -------------------------------------------------------------------------------- /config/di.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/hamakn/go_ddd_webapp/src/app/domain/user" 5 | dbUser "github.com/hamakn/go_ddd_webapp/src/app/infrastructure/db/user" 6 | ) 7 | 8 | func injectDependencies() { 9 | injectRepositoryDependencies() 10 | } 11 | 12 | func injectRepositoryDependencies() { 13 | user.NewRepository = dbUser.NewRepository 14 | } 15 | -------------------------------------------------------------------------------- /config/fixtures/users.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - id: 1 3 | email: foo@hamakn.test 4 | screen_name: foo 5 | age: 17 6 | created_at: "2018-02-17T20:00:00+09:00" 7 | updated_at: "2018-02-17T20:00:00+09:00" 8 | - id: 2 9 | email: bar@hamakn.test 10 | screen_name: bar 11 | age: 37 12 | created_at: "2018-02-17T20:00:00+09:00" 13 | updated_at: "2018-02-17T20:00:00+09:00" 14 | -------------------------------------------------------------------------------- /config/server.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/hamakn/go_ddd_webapp/src/app/infrastructure/config" 7 | ) 8 | 9 | func init() { 10 | injectDependencies() 11 | 12 | http.Handle("/", config.NewRouter()) 13 | } 14 | -------------------------------------------------------------------------------- /src/app/application/user.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/hamakn/go_ddd_webapp/src/app/domain/user" 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | var ( 11 | // ErrGetUsers is error on GetUsers 12 | ErrGetUsers = errors.New("app-application-user: GetUsers failed") 13 | // ErrGetUserByID is error on GetUser 14 | ErrGetUserByID = errors.New("app-application-user: GetUserByID failed") 15 | // ErrCreateUser is error on CreateUser 16 | ErrCreateUser = errors.New("app-application-user: CreateUser failed") 17 | // ErrUpdateUser is error on CreateUser 18 | ErrUpdateUser = errors.New("app-application-user: UpdateUser failed") 19 | // ErrDeleteUser is error on DeleteUser 20 | ErrDeleteUser = errors.New("app-application-user: DeleteUser failed") 21 | ) 22 | 23 | // GetUsers returns users 24 | func GetUsers(ctx context.Context) ([]*user.User, error) { 25 | users, err := user.NewRepository().GetAll(ctx) 26 | if err != nil { 27 | return nil, errors.Wrap(err, ErrGetUsers.Error()) 28 | } 29 | 30 | return users, nil 31 | } 32 | 33 | // GetUserByID returns user specified by id 34 | func GetUserByID(ctx context.Context, id int64) (*user.User, error) { 35 | u, err := user.NewRepository().GetByID(ctx, id) 36 | if err != nil { 37 | return nil, errors.Wrap(err, ErrGetUserByID.Error()) 38 | } 39 | 40 | return u, nil 41 | } 42 | 43 | // CreateUser creates user from request 44 | func CreateUser(ctx context.Context, req user.CreateUserValue) (*user.User, error) { 45 | u := user.NewFactory().Create(*req.Email, *req.ScreenName, *req.Age) 46 | 47 | err := user.NewRepository().Create(ctx, u) 48 | if err != nil { 49 | return nil, errors.Wrap(err, ErrCreateUser.Error()) 50 | } 51 | 52 | return u, nil 53 | } 54 | 55 | // UpdateUser updates user from request 56 | func UpdateUser(ctx context.Context, id int64, req user.UpdateUserValue) (*user.User, error) { 57 | r := user.NewRepository() 58 | u, err := r.GetByID(ctx, id) 59 | if err != nil { 60 | return nil, errors.Wrap(err, ErrUpdateUser.Error()) 61 | } 62 | 63 | hasUpdate := req.UpdateUser(u) 64 | if !hasUpdate { 65 | return nil, user.ErrNothingToUpdate 66 | } 67 | 68 | err = r.Update(ctx, u) 69 | if err != nil { 70 | return nil, errors.Wrap(err, ErrUpdateUser.Error()) 71 | } 72 | 73 | return u, nil 74 | } 75 | 76 | // DeleteUser deletes user specified by id 77 | func DeleteUser(ctx context.Context, id int64) error { 78 | r := user.NewRepository() 79 | u, err := r.GetByID(ctx, id) 80 | if err != nil { 81 | return errors.Wrap(err, ErrDeleteUser.Error()) 82 | } 83 | 84 | err = r.Delete(ctx, u) 85 | if err != nil { 86 | return errors.Wrap(err, ErrUpdateUser.Error()) 87 | } 88 | 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /src/app/domain/user/errors.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import "errors" 4 | 5 | var ( 6 | // ErrNoSuchEntity is entity not found error 7 | ErrNoSuchEntity = errors.New("app-domain-user: No such entity") 8 | // ErrEmailCannotTake is email cannot take error 9 | ErrEmailCannotTake = errors.New("app-domain-user: Email cannot take") 10 | // ErrScreenNameCannotTake is screen_name cannot take error 11 | ErrScreenNameCannotTake = errors.New("app-domain-user: ScreenName cannot take") 12 | // ErrNothingToUpdate is nothing to update error 13 | ErrNothingToUpdate = errors.New("app-domain-user: Nothing to update") 14 | // ErrValidationFailed is user validation failed error 15 | ErrValidationFailed = errors.New("app-domain-user: Validation failed") 16 | ) 17 | -------------------------------------------------------------------------------- /src/app/domain/user/factory.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import "time" 4 | 5 | // Factory is interface of user factory 6 | type Factory interface { 7 | Create(email string, screenName string, age int) *User 8 | } 9 | 10 | type factory struct { 11 | } 12 | 13 | // NewFactory returns Factory 14 | func NewFactory() Factory { 15 | return &factory{} 16 | } 17 | 18 | func (f *factory) Create(email string, screenName string, age int) *User { 19 | now := time.Now() 20 | return &User{ 21 | Email: email, 22 | ScreenName: screenName, 23 | Age: age, 24 | CreatedAt: now, 25 | UpdatedAt: now, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/domain/user/repository.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import "context" 4 | 5 | // Repository is interface of user repository 6 | type Repository interface { 7 | GetAll(context.Context) ([]*User, error) 8 | GetByID(context.Context, int64) (*User, error) 9 | Create(context.Context, *User) error 10 | Update(context.Context, *User) error 11 | Delete(context.Context, *User) error 12 | CreateFixture(context.Context) ([]*User, error) 13 | } 14 | 15 | // NewRepository returns Repository 16 | // DI from infrastructure 17 | var NewRepository func() Repository 18 | -------------------------------------------------------------------------------- /src/app/domain/user/user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "time" 5 | 6 | validator "gopkg.in/go-playground/validator.v9" 7 | ) 8 | 9 | // User Entity 10 | type User struct { 11 | ID int64 `json:"id" yaml:"id" datastore:"-" goon:"id"` 12 | Email string `json:"email" yaml:"email" validate:"required,email"` 13 | ScreenName string `json:"screen_name" yaml:"screen_name" validate:"required,printascii,min=3,max=16"` 14 | Age int `json:"age" yaml:"age" validate:"required,min=0,max=120"` 15 | CreatedAt time.Time `json:"created_at" yaml:"created_at" validate:"required"` 16 | UpdatedAt time.Time `json:"updated_at" yaml:"updated_at" validate:"required"` 17 | } 18 | 19 | // Validate validates User 20 | func (u *User) Validate() error { 21 | v := validator.New() 22 | return v.Struct(u) 23 | } 24 | -------------------------------------------------------------------------------- /src/app/domain/user/value.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | // CreateUserValue is request for CreateUser 4 | type CreateUserValue struct { 5 | Email *string `json:"email" validate:"required,email"` 6 | ScreenName *string `json:"screen_name" validate:"required,printascii,min=3,max=16"` 7 | Age *int `json:"age" validate:"required,min=0,max=120"` 8 | } 9 | 10 | // UpdateUserValue is request for UpdateUser 11 | type UpdateUserValue struct { 12 | Email *string `json:"email" validate:"omitempty,email"` 13 | ScreenName *string `json:"screen_name" validate:"omitempty,printascii"` 14 | Age *int `json:"age" validate:"omitempty,min=0,max=120"` 15 | } 16 | 17 | // UpdateUser updates user by update request value 18 | func (r *UpdateUserValue) UpdateUser(u *User) bool { 19 | hasUpdate := false 20 | if r.Email != nil && u.Email != *r.Email { 21 | u.Email = *r.Email 22 | hasUpdate = true 23 | } 24 | if r.ScreenName != nil && u.ScreenName != *r.ScreenName { 25 | u.ScreenName = *r.ScreenName 26 | hasUpdate = true 27 | } 28 | if r.Age != nil && u.Age != *r.Age { 29 | u.Age = *r.Age 30 | hasUpdate = true 31 | } 32 | return hasUpdate 33 | } 34 | -------------------------------------------------------------------------------- /src/app/infrastructure/config/router.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/gorilla/mux" 5 | "github.com/hamakn/go_ddd_webapp/src/app/interfaces/handler" 6 | "github.com/hamakn/go_ddd_webapp/src/app/interfaces/middleware" 7 | "github.com/urfave/negroni" 8 | ) 9 | 10 | // NewRouter returns Negroni router to handle http request 11 | func NewRouter() *negroni.Negroni { 12 | router := mux.NewRouter() 13 | 14 | // User 15 | userRouter := router.PathPrefix("/users").Subrouter() 16 | userRouter.HandleFunc("/", handler.GetUsers()).Methods("GET") 17 | userRouter.HandleFunc("/{id}", handler.GetUser()).Methods("GET") 18 | userRouter.HandleFunc("/", handler.CreateUser()).Methods("POST") 19 | userRouter.HandleFunc("/{id}", handler.UpdateUser()).Methods("PUT") 20 | userRouter.HandleFunc("/{id}", handler.DeleteUser()).Methods("DELETE") 21 | 22 | n := negroni.New( 23 | middleware.NewContextSetter(), 24 | ) 25 | n.UseHandler(router) 26 | 27 | return n 28 | } 29 | -------------------------------------------------------------------------------- /src/app/infrastructure/context/transaction.go: -------------------------------------------------------------------------------- 1 | package context 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type contextKey string 8 | 9 | const ( 10 | transactionKey = contextKey("appTransaction") 11 | ) 12 | 13 | // IsInTransaction returns in txn or not 14 | func IsInTransaction(ctx context.Context) bool { 15 | value := ctx.Value(transactionKey) 16 | 17 | if value == nil { 18 | return false 19 | } 20 | 21 | return value.(bool) 22 | } 23 | 24 | // WithTransaction returns ctx marked in txn 25 | func WithTransaction(ctx context.Context) context.Context { 26 | return context.WithValue(ctx, transactionKey, true) 27 | } 28 | -------------------------------------------------------------------------------- /src/app/infrastructure/datastore/transaction.go: -------------------------------------------------------------------------------- 1 | package datastore 2 | 3 | import ( 4 | "context" 5 | 6 | appContext "github.com/hamakn/go_ddd_webapp/src/app/infrastructure/context" 7 | netContext "golang.org/x/net/context" // datastore lib doesn't support modern context yet, so use old one 8 | "google.golang.org/appengine/datastore" 9 | ) 10 | 11 | // RunInTransaction is wraptter for datastore.RunInTransaction 12 | // with resolving nested transaction and set attempts: 1 13 | func RunInTransaction(ctx context.Context, f func(context.Context) error, xg bool) error { 14 | if !appContext.IsInTransaction(ctx) { 15 | // mark ctx as in txn 16 | ctx = appContext.WithTransaction(ctx) 17 | 18 | return datastore.RunInTransaction(ctx, func(tctx netContext.Context) error { 19 | // cast: old context => modern context 20 | mctx := context.Context(tctx) // for gsc 21 | return f(mctx) 22 | }, &datastore.TransactionOptions{ 23 | XG: xg, 24 | Attempts: 1, 25 | }) 26 | } 27 | 28 | return f(ctx) 29 | } 30 | -------------------------------------------------------------------------------- /src/app/infrastructure/datastore/transaction_test.go: -------------------------------------------------------------------------------- 1 | package datastore 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | "google.golang.org/appengine/aetest" 9 | "google.golang.org/appengine/datastore" 10 | ) 11 | 12 | type Item struct { 13 | Message string 14 | } 15 | 16 | func TestTransactionNested(t *testing.T) { 17 | // setup ctx 18 | ctx, done, err := aetest.NewContext() 19 | defer done() 20 | require.Nil(t, err) 21 | 22 | id := int64(42) 23 | message := "hey" 24 | key := datastore.NewKey(ctx, "Item", "", id, nil) 25 | 26 | err = RunInTransaction(ctx, func(tctx context.Context) error { 27 | return RunInTransaction(tctx, func(ttctx context.Context) error { 28 | i := Item{message} 29 | _, err := datastore.Put(ttctx, key, &i) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | return nil 35 | }, true) 36 | }, true) 37 | 38 | require.Nil(t, err) 39 | 40 | i := Item{} 41 | datastore.Get(ctx, key, &i) 42 | require.Equal(t, message, i.Message) 43 | } 44 | -------------------------------------------------------------------------------- /src/app/infrastructure/db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/mjibson/goon" 7 | "google.golang.org/appengine/datastore" 8 | ) 9 | 10 | // GetAll get objects by query 11 | func GetAll(ctx context.Context, q *datastore.Query, dst interface{}) ([]*datastore.Key, error) { 12 | g := goon.FromContext(ctx) 13 | return g.GetAll(q, dst) 14 | } 15 | 16 | // Get get the entity based on dst's key 17 | func Get(ctx context.Context, dst interface{}) error { 18 | g := goon.FromContext(ctx) 19 | return g.Get(dst) 20 | } 21 | 22 | // Put puts src object to db 23 | func Put(ctx context.Context, src interface{}) error { 24 | g := goon.FromContext(ctx) 25 | _, err := g.Put(src) 26 | return err 27 | } 28 | 29 | // PutMulti puts src objects to db 30 | func PutMulti(ctx context.Context, src interface{}) ([]*datastore.Key, error) { 31 | g := goon.FromContext(ctx) 32 | return g.PutMulti(src) 33 | } 34 | 35 | // Delete delets object from db 36 | func Delete(ctx context.Context, src interface{}) error { 37 | g := goon.FromContext(ctx) 38 | return g.Delete(g.Key(src)) 39 | } 40 | -------------------------------------------------------------------------------- /src/app/infrastructure/db/user/repository.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/hamakn/go_ddd_webapp/src/app/domain/user" 8 | appDatastore "github.com/hamakn/go_ddd_webapp/src/app/infrastructure/datastore" 9 | "github.com/hamakn/go_ddd_webapp/src/app/infrastructure/db" 10 | "github.com/hamakn/go_ddd_webapp/src/app/infrastructure/fixture" 11 | "golang.org/x/sync/errgroup" 12 | "google.golang.org/appengine/datastore" 13 | ) 14 | 15 | const ( 16 | kind = "User" 17 | ) 18 | 19 | type repository struct { 20 | } 21 | 22 | // NewRepository returns user.Repository 23 | func NewRepository() user.Repository { 24 | return &repository{} 25 | } 26 | 27 | func (r *repository) GetAll(ctx context.Context) ([]*user.User, error) { 28 | users := []*user.User{} 29 | q := datastore.NewQuery(kind) 30 | 31 | _, err := db.GetAll(ctx, q, &users) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return users, nil 37 | } 38 | 39 | func (r *repository) GetByID(ctx context.Context, id int64) (*user.User, error) { 40 | u := &user.User{ID: id} 41 | err := db.Get(ctx, u) 42 | if err != nil { 43 | if err == datastore.ErrNoSuchEntity { 44 | return nil, user.ErrNoSuchEntity 45 | } 46 | return nil, err 47 | } 48 | 49 | return u, nil 50 | } 51 | 52 | func (r *repository) Create(ctx context.Context, u *user.User) error { 53 | err := u.Validate() 54 | if err != nil { 55 | return user.ErrValidationFailed 56 | } 57 | 58 | return appDatastore.RunInTransaction(ctx, func(tctx context.Context) error { 59 | var eg errgroup.Group 60 | 61 | eg.Go(func() error { 62 | // check email uniqueness 63 | tctx := tctx 64 | if !canTakeUserEmail(tctx, u.Email) { 65 | return user.ErrEmailCannotTake 66 | } 67 | return nil 68 | }) 69 | eg.Go(func() error { 70 | // check screen_name uniquness 71 | tctx := tctx 72 | if !canTakeUserScreenName(tctx, u.ScreenName) { 73 | return user.ErrScreenNameCannotTake 74 | } 75 | return nil 76 | }) 77 | eg.Go(func() error { 78 | // Put user 79 | tctx := tctx 80 | return db.Put(tctx, u) 81 | }) 82 | if err := eg.Wait(); err != nil { 83 | return err 84 | } 85 | 86 | // create userEmail and userScreenName after u.ID assigned 87 | userEmail := newUserEmail(u) 88 | userScreenName := newUserScreenName(u) 89 | 90 | eg.Go(func() error { 91 | // take email 92 | tctx := tctx 93 | err = takeUserEmail(tctx, userEmail) 94 | if err != nil { 95 | return user.ErrEmailCannotTake 96 | } 97 | return nil 98 | }) 99 | eg.Go(func() error { 100 | // take screen_name 101 | tctx := tctx 102 | err = takeUserScreenName(tctx, userScreenName) 103 | if err != nil { 104 | return user.ErrScreenNameCannotTake 105 | } 106 | return nil 107 | }) 108 | return eg.Wait() 109 | }, 110 | true, // XG 111 | ) 112 | } 113 | 114 | func (r *repository) Update(ctx context.Context, u *user.User) error { 115 | err := u.Validate() 116 | if err != nil { 117 | return user.ErrValidationFailed 118 | } 119 | 120 | return appDatastore.RunInTransaction(ctx, func(tctx context.Context) error { 121 | // get oldUser to get old email and screen name 122 | oldUser, err := r.GetByID(tctx, u.ID) 123 | if err != nil { 124 | return err 125 | } 126 | 127 | var eg errgroup.Group 128 | 129 | eg.Go(func() error { 130 | // userEmail 131 | tctx := tctx 132 | if oldUser.Email != u.Email { 133 | return updateUserEmail(tctx, u, oldUser.Email) 134 | } 135 | return nil 136 | }) 137 | eg.Go(func() error { 138 | // userScreenName 139 | tctx := tctx 140 | if oldUser.ScreenName != u.ScreenName { 141 | return updateUserScreenName(tctx, u, oldUser.ScreenName) 142 | } 143 | return nil 144 | }) 145 | eg.Go(func() error { 146 | // Update user 147 | tctx := tctx 148 | 149 | // TODO: rollback if error occurred 150 | u.UpdatedAt = time.Now() 151 | 152 | return db.Put(tctx, u) 153 | }) 154 | return eg.Wait() 155 | }, 156 | true, // XG 157 | ) 158 | } 159 | 160 | func (r *repository) Delete(ctx context.Context, u *user.User) error { 161 | return appDatastore.RunInTransaction(ctx, func(tctx context.Context) error { 162 | var eg errgroup.Group 163 | 164 | eg.Go(func() error { 165 | // userEmail 166 | tctx := tctx 167 | return deleteUserEmail(tctx, u.Email, u.ID) 168 | }) 169 | eg.Go(func() error { 170 | // userScreenName 171 | tctx := tctx 172 | return deleteUserScreenName(tctx, u.ScreenName, u.ID) 173 | }) 174 | eg.Go(func() error { 175 | // delete user 176 | tctx := tctx 177 | // lock user 178 | txu, err := r.GetByID(tctx, u.ID) 179 | if err != nil { 180 | return err 181 | } 182 | return db.Delete(tctx, txu) 183 | }) 184 | return eg.Wait() 185 | }, 186 | true, // XG 187 | ) 188 | } 189 | 190 | func (r *repository) CreateFixture(ctx context.Context) ([]*user.User, error) { 191 | users := []*user.User{} 192 | 193 | err := fixture.Load("users", &users) 194 | if err != nil { 195 | return nil, err 196 | } 197 | 198 | // NOTE: run in out of txn by datastore (25 entities) limit 199 | for _, u := range users { 200 | r.Create(ctx, u) 201 | } 202 | 203 | return users, nil 204 | } 205 | -------------------------------------------------------------------------------- /src/app/infrastructure/db/user/repository_test.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/hamakn/go_ddd_webapp/src/app/domain/user" 8 | "github.com/hamakn/go_ddd_webapp/src/app/infrastructure/environments" 9 | "github.com/hamakn/go_ddd_webapp/src/app/internal" 10 | "github.com/stretchr/testify/require" 11 | "google.golang.org/appengine/aetest" 12 | ) 13 | 14 | func TestCreate(t *testing.T) { 15 | ctx, done, err := aetest.NewContext() 16 | defer done() 17 | require.Nil(t, err) 18 | 19 | f := user.NewFactory() 20 | r := NewRepository() 21 | _, err = r.CreateFixture(ctx) 22 | require.Nil(t, err) 23 | 24 | testCases := []struct { 25 | email string 26 | screenName string 27 | age int 28 | hasError bool 29 | err error 30 | }{ 31 | { 32 | // taken email (other case) 33 | "FOO@hamakn.test", 34 | "new_name", 35 | 25, 36 | true, 37 | user.ErrEmailCannotTake, 38 | }, 39 | { 40 | // taken screen name (other case) 41 | "new@hamakn.test", 42 | "FOO", 43 | 26, 44 | true, 45 | user.ErrScreenNameCannotTake, 46 | }, 47 | { 48 | // validation failed 49 | "bad_email", 50 | "new_name", 51 | 27, 52 | true, 53 | user.ErrValidationFailed, 54 | }, 55 | { 56 | // ok 57 | "new@hamakn.test", 58 | "new", 59 | 17, 60 | false, 61 | nil, 62 | }, 63 | } 64 | 65 | for _, testCase := range testCases { 66 | u := f.Create(testCase.email, testCase.screenName, testCase.age) 67 | err := r.Create(ctx, u) 68 | 69 | if testCase.hasError { 70 | require.NotNil(t, err) 71 | require.Equal(t, testCase.err, err) 72 | 73 | } else { 74 | require.Nil(t, err) 75 | } 76 | } 77 | } 78 | 79 | func TestUpdate(t *testing.T) { 80 | ctx, done, err := aetest.NewContext() 81 | defer done() 82 | require.Nil(t, err) 83 | 84 | r := NewRepository() 85 | _, err = r.CreateFixture(ctx) 86 | require.Nil(t, err) 87 | 88 | now := time.Now() 89 | 90 | testCases := []struct { 91 | userID int64 92 | email string 93 | screenName string 94 | hasError bool 95 | err error 96 | }{ 97 | { 98 | // NG1 99 | 1, 100 | "BAR@hamakn.test", 101 | "new", 102 | true, 103 | user.ErrEmailCannotTake, 104 | }, 105 | { 106 | // NG2 107 | 1, 108 | "new@hamakn.test", 109 | "BAR", 110 | true, 111 | user.ErrScreenNameCannotTake, 112 | }, 113 | { 114 | // NG3: validation failed 115 | 1, 116 | "new@hamakn.test", 117 | "badname", 118 | true, 119 | user.ErrValidationFailed, 120 | }, 121 | { 122 | // OK1 123 | 1, 124 | "new@hamakn.test", 125 | "new", 126 | false, 127 | nil, 128 | }, 129 | { 130 | // OK2 131 | // depends previous test case 132 | 2, 133 | "foo@hamakn.test", 134 | "foo", 135 | false, 136 | nil, 137 | }, 138 | } 139 | 140 | for _, testCase := range testCases { 141 | u, err := r.GetByID(ctx, testCase.userID) 142 | require.Nil(t, err) 143 | 144 | oldEmail := u.Email 145 | oldScreenName := u.ScreenName 146 | newAge := 99 147 | u.Email = testCase.email 148 | u.ScreenName = testCase.screenName 149 | u.Age = newAge 150 | err = r.Update(ctx, u) 151 | 152 | if testCase.hasError { 153 | require.NotNil(t, err) 154 | require.Equal(t, testCase.err, err) 155 | 156 | } else { 157 | require.Nil(t, err) 158 | 159 | u, err := r.GetByID(ctx, testCase.userID) 160 | require.Nil(t, err) 161 | require.Equal(t, testCase.email, u.Email) 162 | require.Equal(t, testCase.screenName, u.ScreenName) 163 | require.Equal(t, newAge, u.Age) 164 | require.Equal(t, true, u.UpdatedAt.After(now)) 165 | 166 | require.Equal(t, true, canTakeUserEmail(ctx, oldEmail)) 167 | require.Equal(t, false, canTakeUserEmail(ctx, testCase.email)) 168 | 169 | require.Equal(t, true, canTakeUserScreenName(ctx, oldScreenName)) 170 | require.Equal(t, false, canTakeUserScreenName(ctx, testCase.screenName)) 171 | } 172 | } 173 | } 174 | 175 | func TestDelete(t *testing.T) { 176 | ctx, done, err := aetest.NewContext() 177 | defer done() 178 | require.Nil(t, err) 179 | 180 | r := NewRepository() 181 | _, err = r.CreateFixture(ctx) 182 | require.Nil(t, err) 183 | 184 | testCases := []struct { 185 | userID int64 186 | hasError bool 187 | }{ 188 | { 189 | // OK 190 | 1, 191 | false, 192 | }, 193 | } 194 | 195 | for _, testCase := range testCases { 196 | u, err := r.GetByID(ctx, testCase.userID) 197 | require.Nil(t, err) 198 | 199 | deleteEmail := u.Email 200 | deleteScreenName := u.ScreenName 201 | err = r.Delete(ctx, u) 202 | 203 | if testCase.hasError { 204 | require.NotNil(t, err) 205 | 206 | } else { 207 | require.Nil(t, err) 208 | 209 | _, err := r.GetByID(ctx, testCase.userID) 210 | require.Equal(t, user.ErrNoSuchEntity, err) 211 | 212 | require.Equal(t, true, canTakeUserEmail(ctx, deleteEmail)) 213 | require.Equal(t, true, canTakeUserScreenName(ctx, deleteScreenName)) 214 | } 215 | } 216 | } 217 | 218 | func TestCreateFixture(t *testing.T) { 219 | ctx, done, err := aetest.NewContext() 220 | defer done() 221 | require.Nil(t, err) 222 | 223 | r := NewRepository() 224 | users, err := r.CreateFixture(ctx) 225 | 226 | require.Nil(t, err) 227 | require.Equal(t, 2, len(users)) 228 | } 229 | 230 | func init() { 231 | internal.MockEnvironments(&environments.Environments{}) 232 | } 233 | -------------------------------------------------------------------------------- /src/app/infrastructure/db/user/user_email.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strings" 7 | "time" 8 | 9 | "github.com/hamakn/go_ddd_webapp/src/app/domain/user" 10 | appDatastore "github.com/hamakn/go_ddd_webapp/src/app/infrastructure/datastore" 11 | "golang.org/x/sync/errgroup" 12 | "google.golang.org/appengine/datastore" 13 | ) 14 | 15 | // userEmail Entity for email uniqueness constraints 16 | type userEmail struct { 17 | Email string 18 | UserID int64 19 | CreatedAt time.Time 20 | } 21 | 22 | func newUserEmail(u *user.User) *userEmail { 23 | now := time.Now() 24 | return &userEmail{ 25 | Email: u.Email, 26 | UserID: u.ID, 27 | CreatedAt: now, 28 | } 29 | } 30 | 31 | func userEmailKey(ctx context.Context, email string) *datastore.Key { 32 | return datastore.NewKey(ctx, "UserEmail", userEmailKeyString(email), 0, nil) 33 | } 34 | 35 | // userEmailKeyString is downcased email 36 | func userEmailKeyString(email string) string { 37 | return strings.ToLower(email) 38 | } 39 | 40 | func canTakeUserEmail(ctx context.Context, email string) bool { 41 | k := userEmailKey(ctx, email) 42 | err := datastore.Get(ctx, k, &userEmail{}) 43 | if err != nil && err == datastore.ErrNoSuchEntity { 44 | return true 45 | } 46 | return false 47 | } 48 | 49 | func takeUserEmail(ctx context.Context, u *userEmail) error { 50 | k := userEmailKey(ctx, u.Email) 51 | _, err := datastore.Put(ctx, k, u) 52 | return err 53 | } 54 | 55 | func updateUserEmail(ctx context.Context, u *user.User, oldEmail string) error { 56 | return appDatastore.RunInTransaction(ctx, func(tctx context.Context) error { 57 | // lock new userEmail 58 | if !canTakeUserEmail(tctx, u.Email) { 59 | return user.ErrEmailCannotTake 60 | } 61 | 62 | var eg errgroup.Group 63 | eg.Go(func() error { 64 | tctx := tctx 65 | return deleteUserEmail(tctx, oldEmail, u.ID) 66 | }) 67 | eg.Go(func() error { 68 | tctx := tctx 69 | return takeUserEmail(tctx, newUserEmail(u)) 70 | }) 71 | return eg.Wait() 72 | }, 73 | true, // XG 74 | ) 75 | } 76 | 77 | func deleteUserEmail(ctx context.Context, email string, userID int64) error { 78 | return appDatastore.RunInTransaction(ctx, func(tctx context.Context) error { 79 | // lock old userEmail 80 | old := userEmail{} 81 | err := datastore.Get(tctx, userEmailKey(tctx, email), &old) 82 | if err != nil { 83 | return err 84 | } 85 | // checking owner of old userEmail 86 | if old.UserID != userID { 87 | return errors.New("app-infra-db-user-email: oldEmail is not specified user's") 88 | } 89 | 90 | err = datastore.Delete(tctx, userEmailKey(tctx, email)) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | return nil 96 | }, 97 | false, 98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /src/app/infrastructure/db/user/user_screen_name.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strings" 7 | "time" 8 | 9 | "github.com/hamakn/go_ddd_webapp/src/app/domain/user" 10 | appDatastore "github.com/hamakn/go_ddd_webapp/src/app/infrastructure/datastore" 11 | "golang.org/x/sync/errgroup" 12 | "google.golang.org/appengine/datastore" 13 | ) 14 | 15 | // userScreenName Entity for screen_name uniqueness constraints 16 | type userScreenName struct { 17 | ScreenName string 18 | UserID int64 19 | CreatedAt time.Time 20 | } 21 | 22 | func newUserScreenName(u *user.User) *userScreenName { 23 | now := time.Now() 24 | return &userScreenName{ 25 | ScreenName: u.ScreenName, 26 | UserID: u.ID, 27 | CreatedAt: now, 28 | } 29 | } 30 | 31 | func userScreenNameKey(ctx context.Context, screenName string) *datastore.Key { 32 | return datastore.NewKey(ctx, "UserScreenName", userScreenNameKeyString(screenName), 0, nil) 33 | } 34 | 35 | // userScreenNameKeyString is downcased email 36 | func userScreenNameKeyString(screenName string) string { 37 | return strings.ToLower(screenName) 38 | } 39 | 40 | func canTakeUserScreenName(ctx context.Context, screenName string) bool { 41 | k := userScreenNameKey(ctx, screenName) 42 | err := datastore.Get(ctx, k, &userScreenName{}) 43 | if err != nil && err == datastore.ErrNoSuchEntity { 44 | return true 45 | } 46 | return false 47 | } 48 | 49 | func takeUserScreenName(ctx context.Context, u *userScreenName) error { 50 | k := userScreenNameKey(ctx, u.ScreenName) 51 | _, err := datastore.Put(ctx, k, u) 52 | return err 53 | } 54 | 55 | func updateUserScreenName(ctx context.Context, u *user.User, oldScreenName string) error { 56 | return appDatastore.RunInTransaction(ctx, func(tctx context.Context) error { 57 | // lock new userScreenName 58 | if !canTakeUserScreenName(tctx, u.ScreenName) { 59 | return user.ErrScreenNameCannotTake 60 | } 61 | 62 | var eg errgroup.Group 63 | eg.Go(func() error { 64 | tctx := tctx 65 | return deleteUserScreenName(tctx, oldScreenName, u.ID) 66 | }) 67 | eg.Go(func() error { 68 | tctx := tctx 69 | return takeUserScreenName(tctx, newUserScreenName(u)) 70 | }) 71 | return eg.Wait() 72 | }, 73 | true, // XG 74 | ) 75 | } 76 | 77 | func deleteUserScreenName(ctx context.Context, screenName string, userID int64) error { 78 | return appDatastore.RunInTransaction(ctx, func(tctx context.Context) error { 79 | // lock old userScreenName 80 | old := userScreenName{} 81 | err := datastore.Get(tctx, userScreenNameKey(tctx, screenName), &old) 82 | if err != nil { 83 | return err 84 | } 85 | // checking owner of old userScreenName 86 | if old.UserID != userID { 87 | return errors.New("app-infra-db-user-screen_name: oldScreenName is not specified user's") 88 | } 89 | 90 | err = datastore.Delete(tctx, userScreenNameKey(tctx, screenName)) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | return nil 96 | }, 97 | false, 98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /src/app/infrastructure/environments/environments.go: -------------------------------------------------------------------------------- 1 | package environments 2 | 3 | import ( 4 | "github.com/kelseyhightower/envconfig" 5 | ) 6 | 7 | // Environments holds application environments 8 | type Environments struct { 9 | AppBaseDir string `split_words:"true"` 10 | } 11 | 12 | // for singleton Environments 13 | var environments *Environments 14 | 15 | // GetEnvironments is func to return Environments 16 | // DI enable for test 17 | var GetEnvironments = getEnvironmentsDefault 18 | 19 | func getEnvironmentsDefault() *Environments { 20 | if environments != nil { 21 | return environments 22 | } 23 | 24 | environments = &Environments{} 25 | envconfig.Process("myapp", environments) 26 | 27 | return environments 28 | } 29 | -------------------------------------------------------------------------------- /src/app/infrastructure/fixture/fixture.go: -------------------------------------------------------------------------------- 1 | package fixture 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/hamakn/go_ddd_webapp/src/app/infrastructure/environments" 8 | yaml "gopkg.in/yaml.v2" 9 | ) 10 | 11 | // Load loads fixtures to dst, fixture name is specified by fixtureName 12 | func Load(fixtureName string, dst interface{}) error { 13 | f, err := os.Open(fixtureFilepath(fixtureName)) 14 | if err != nil { 15 | return err 16 | } 17 | defer f.Close() 18 | 19 | err = yaml.NewDecoder(f).Decode(dst) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | return nil 25 | } 26 | 27 | func fixtureFilepath(fixtureName string) string { 28 | return filepath.Join(environments.GetEnvironments().AppBaseDir, "fixtures", fixtureName+".yml") 29 | } 30 | -------------------------------------------------------------------------------- /src/app/interfaces/handler/handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | 8 | "github.com/pkg/errors" 9 | 10 | "github.com/hamakn/go_ddd_webapp/src/app/interfaces/response" 11 | "google.golang.org/appengine/log" 12 | validator "gopkg.in/go-playground/validator.v9" 13 | ) 14 | 15 | type appError struct { 16 | Error error 17 | Message string 18 | Code int 19 | } 20 | 21 | func createAppHandler(f func(context.Context, http.ResponseWriter, *http.Request) (*response.Response, *appError)) func(http.ResponseWriter, *http.Request) { 22 | return func(w http.ResponseWriter, r *http.Request) { 23 | ctx := r.Context() 24 | res, apperr := f(ctx, w, r) 25 | 26 | w.Header().Set("Content-Type", "application/json") 27 | 28 | if apperr != nil { 29 | log.Errorf(ctx, "%#v", apperr.Error) 30 | 31 | if err := WriteErrorResponse(w, apperr.Code, apperr.Message); err != nil { 32 | log.Errorf(ctx, "%#v", errors.Wrap(apperr.Error, err.Error())) 33 | } 34 | 35 | return 36 | } 37 | 38 | if isEmptyResponse(res) { 39 | w.WriteHeader(http.StatusNoContent) 40 | return 41 | } 42 | 43 | _, err := w.Write(res.Body) 44 | if err != nil { 45 | log.Errorf(ctx, "%#v", err) 46 | } 47 | } 48 | } 49 | 50 | // WriteErrorResponse is writing error response 51 | func WriteErrorResponse(w http.ResponseWriter, code int, message string) error { 52 | w.WriteHeader(code) 53 | 54 | res, err := response.NewErrorResponse(message) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | _, err = w.Write(res.Body) 60 | return err 61 | } 62 | 63 | func isEmptyResponse(res *response.Response) bool { 64 | return len(res.Body) == 0 65 | } 66 | 67 | func parseRequest(r *http.Request, request interface{}) error { 68 | err := json.NewDecoder(r.Body).Decode(request) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | v := validator.New() 74 | return v.Struct(request) 75 | } 76 | -------------------------------------------------------------------------------- /src/app/interfaces/handler/user.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/gorilla/mux" 9 | "github.com/hamakn/go_ddd_webapp/src/app/application" 10 | "github.com/hamakn/go_ddd_webapp/src/app/domain/user" 11 | "github.com/hamakn/go_ddd_webapp/src/app/interfaces/response" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | var ( 16 | // ErrGetUsers is error on GetUsers 17 | ErrGetUsers = errors.New("app-interface-handler-get-users: GetUsers failed") 18 | // ErrGetUser is error on GetUser 19 | ErrGetUser = errors.New("app-interface-handler-get-user: GetUser failed") 20 | // ErrCreateUser is error on CreateUser 21 | ErrCreateUser = errors.New("app-interface-handler-create-user: CreateUser failed") 22 | // ErrUpdateUser is error on UpdateUser 23 | ErrUpdateUser = errors.New("app-interface-handler-update-user: UpdateUser failed") 24 | // ErrDeleteUser is error on DeleteUser 25 | ErrDeleteUser = errors.New("app-interface-handler-delete-user: DeleteUser failed") 26 | ) 27 | 28 | // GetUsers is handler to handle getting users request 29 | func GetUsers() func(http.ResponseWriter, *http.Request) { 30 | return createAppHandler(func(ctx context.Context, w http.ResponseWriter, r *http.Request) (*response.Response, *appError) { 31 | users, err := application.GetUsers(ctx) 32 | if err != nil { 33 | return nil, &appError{errors.Wrap(err, ErrGetUsers.Error()), "internal server error", http.StatusInternalServerError} 34 | } 35 | 36 | res, err := response.UsersResponse(users) 37 | if err != nil { 38 | return nil, &appError{errors.Wrap(err, ErrGetUsers.Error()), "internal server error", http.StatusInternalServerError} 39 | } 40 | 41 | return res, nil 42 | }) 43 | } 44 | 45 | // GetUser is handler to handle getting user request 46 | func GetUser() func(http.ResponseWriter, *http.Request) { 47 | return createAppHandler(func(ctx context.Context, w http.ResponseWriter, r *http.Request) (*response.Response, *appError) { 48 | vars := mux.Vars(r) 49 | id, err := strconv.ParseInt(vars["id"], 10, 64) 50 | if err != nil { 51 | return nil, &appError{err, "Bad Request", http.StatusBadRequest} 52 | } 53 | 54 | u, err := application.GetUserByID(ctx, id) 55 | if err != nil { 56 | switch errors.Cause(err) { 57 | case user.ErrNoSuchEntity: 58 | return nil, &appError{errors.Wrap(err, ErrGetUser.Error()), "Not Found", http.StatusNotFound} 59 | default: 60 | return nil, &appError{errors.Wrap(err, ErrGetUser.Error()), "internal server error", http.StatusInternalServerError} 61 | } 62 | } 63 | 64 | res, err := response.UserResponse(u) 65 | if err != nil { 66 | return nil, &appError{errors.Wrap(err, ErrGetUser.Error()), "internal server error", http.StatusInternalServerError} 67 | } 68 | 69 | return res, nil 70 | }) 71 | } 72 | 73 | // CreateUser is handler to handle create user request 74 | func CreateUser() func(http.ResponseWriter, *http.Request) { 75 | return createAppHandler(func(ctx context.Context, w http.ResponseWriter, r *http.Request) (*response.Response, *appError) { 76 | req := user.CreateUserValue{} 77 | err := parseRequest(r, &req) 78 | if err != nil { 79 | return nil, &appError{errors.Wrap(err, ErrCreateUser.Error()), "Bad Request", http.StatusBadRequest} 80 | } 81 | 82 | u, err := application.CreateUser(ctx, req) 83 | if err != nil { 84 | switch errors.Cause(err) { 85 | case user.ErrValidationFailed: 86 | return nil, &appError{errors.Wrap(err, ErrCreateUser.Error()), "Bad Request", http.StatusBadRequest} 87 | case user.ErrEmailCannotTake, user.ErrScreenNameCannotTake: 88 | return nil, &appError{errors.Wrap(err, ErrCreateUser.Error()), "Unprocessable Entity", http.StatusUnprocessableEntity} 89 | default: 90 | return nil, &appError{errors.Wrap(err, ErrCreateUser.Error()), "internal server error", http.StatusInternalServerError} 91 | } 92 | } 93 | 94 | res, err := response.UserResponse(u) 95 | if err != nil { 96 | return nil, &appError{errors.Wrap(err, ErrCreateUser.Error()), "internal server error", http.StatusInternalServerError} 97 | } 98 | 99 | return res, nil 100 | }) 101 | } 102 | 103 | // UpdateUser is hanlder to handle update user request 104 | func UpdateUser() func(http.ResponseWriter, *http.Request) { 105 | return createAppHandler(func(ctx context.Context, w http.ResponseWriter, r *http.Request) (*response.Response, *appError) { 106 | vars := mux.Vars(r) 107 | id, err := strconv.ParseInt(vars["id"], 10, 64) 108 | if err != nil { 109 | return nil, &appError{errors.Wrap(err, ErrUpdateUser.Error()), "Bad Request", http.StatusBadRequest} 110 | } 111 | 112 | req := user.UpdateUserValue{} 113 | err = parseRequest(r, &req) 114 | if err != nil { 115 | return nil, &appError{errors.Wrap(err, ErrUpdateUser.Error()), "Bad Request", http.StatusBadRequest} 116 | } 117 | 118 | u, err := application.UpdateUser(ctx, id, req) 119 | if err != nil { 120 | switch errors.Cause(err) { 121 | case user.ErrNoSuchEntity: 122 | return nil, &appError{errors.Wrap(err, ErrUpdateUser.Error()), "Not Found", http.StatusNotFound} 123 | case user.ErrNothingToUpdate, user.ErrValidationFailed: 124 | return nil, &appError{errors.Wrap(err, ErrUpdateUser.Error()), "Bad Request", http.StatusBadRequest} 125 | case user.ErrEmailCannotTake, user.ErrScreenNameCannotTake: 126 | return nil, &appError{errors.Wrap(err, ErrUpdateUser.Error()), "Unprocessable Entity", http.StatusUnprocessableEntity} 127 | default: 128 | return nil, &appError{errors.Wrap(err, ErrUpdateUser.Error()), "internal server error", http.StatusInternalServerError} 129 | } 130 | } 131 | 132 | res, err := response.UserResponse(u) 133 | if err != nil { 134 | return nil, &appError{errors.Wrap(err, ErrUpdateUser.Error()), "internal server error", http.StatusInternalServerError} 135 | } 136 | 137 | return res, nil 138 | }) 139 | } 140 | 141 | // DeleteUser is handler to handle delete user request 142 | func DeleteUser() func(http.ResponseWriter, *http.Request) { 143 | return createAppHandler(func(ctx context.Context, w http.ResponseWriter, r *http.Request) (*response.Response, *appError) { 144 | vars := mux.Vars(r) 145 | id, err := strconv.ParseInt(vars["id"], 10, 64) 146 | if err != nil { 147 | return nil, &appError{errors.Wrap(err, ErrDeleteUser.Error()), "Bad Request", http.StatusBadRequest} 148 | } 149 | 150 | err = application.DeleteUser(ctx, id) 151 | if err != nil { 152 | switch errors.Cause(err) { 153 | case user.ErrNoSuchEntity: 154 | return nil, &appError{errors.Wrap(err, ErrDeleteUser.Error()), "Not Found", http.StatusNotFound} 155 | default: 156 | return nil, &appError{errors.Wrap(err, ErrDeleteUser.Error()), "internal server error", http.StatusInternalServerError} 157 | } 158 | } 159 | 160 | // return empty response 161 | return &response.Response{}, nil 162 | }) 163 | } 164 | -------------------------------------------------------------------------------- /src/app/interfaces/middleware/context_setter.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | "google.golang.org/appengine" 7 | ) 8 | 9 | // ContextSetter is middleware to set appengine context 10 | type ContextSetter struct { 11 | Namespace *string 12 | } 13 | 14 | // NewContextSetter returns new ContextSetter 15 | func NewContextSetter() *ContextSetter { 16 | return &ContextSetter{} 17 | } 18 | 19 | func (c *ContextSetter) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { 20 | // generate appengine context to avoid: 21 | // appengine: NewContext passed an unknown http.Request 22 | // refs: 23 | // https://groups.google.com/forum/#!topic/google-appengine-go/Av7Lg956D6Y 24 | // https://qiita.com/tenntenn/items/0b92fc089f8826fabaf1 25 | ctx := r.Context() // for gsc 26 | ctx = appengine.WithContext(ctx, r) // for gsc 27 | next(w, r.WithContext(ctx)) 28 | } 29 | -------------------------------------------------------------------------------- /src/app/interfaces/response/error.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | // NewErrorResponse returns error response 4 | func NewErrorResponse(message string) (*Response, error) { 5 | src := struct { 6 | Error string `json:"error"` 7 | }{message} 8 | 9 | return newResponse(src) 10 | } 11 | -------------------------------------------------------------------------------- /src/app/interfaces/response/response.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | var ( 10 | // ErrNewResponse is error on newResponse 11 | ErrNewResponse = errors.New("app-interface-response: newResponse failed") 12 | ) 13 | 14 | // Response is struct to represent HTTP Response 15 | type Response struct { 16 | Body []byte 17 | } 18 | 19 | func newResponse(src interface{}) (*Response, error) { 20 | j, err := json.Marshal(src) 21 | 22 | if err != nil { 23 | return nil, errors.Wrap(err, ErrNewResponse.Error()) 24 | } 25 | 26 | r := Response{Body: j} 27 | 28 | return &r, nil 29 | } 30 | -------------------------------------------------------------------------------- /src/app/interfaces/response/user.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "github.com/hamakn/go_ddd_webapp/src/app/domain/user" 5 | ) 6 | 7 | // UsersResponse returns response of users 8 | func UsersResponse(users []*user.User) (*Response, error) { 9 | return newResponse(users) 10 | } 11 | 12 | // UserResponse returns response of user 13 | func UserResponse(user *user.User) (*Response, error) { 14 | return newResponse(user) 15 | } 16 | -------------------------------------------------------------------------------- /src/app/internal/mock_environment.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/hamakn/go_ddd_webapp/src/app/infrastructure/environments" 8 | ) 9 | 10 | const ( 11 | // AppRootFile is the file on the app root 12 | AppRootFile = "Gopkg.lock" 13 | ) 14 | 15 | // MockEnvironments is mocking environments.GetEnvironments 16 | func MockEnvironments(env *environments.Environments) { 17 | environments.GetEnvironments = func() *environments.Environments { 18 | env.AppBaseDir = appBaseDir() 19 | return env 20 | } 21 | } 22 | 23 | func appBaseDir() string { 24 | dir := filepath.Join(".") 25 | for { 26 | if isFileExist(filepath.Join(dir, AppRootFile)) { 27 | return filepath.Join(dir, "config") 28 | } 29 | dir = filepath.Join(dir, "..") 30 | } 31 | } 32 | 33 | // isFileExist returns file exist or not 34 | // refs: https://qiita.com/hnakamur/items/848097aad846d40ae84b 35 | func isFileExist(name string) bool { 36 | _, err := os.Stat(name) 37 | return err == nil 38 | } 39 | -------------------------------------------------------------------------------- /src/app/internal/mock_environment_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/hamakn/go_ddd_webapp/src/app/infrastructure/environments" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestMockEnvironments(t *testing.T) { 11 | MockEnvironments(&environments.Environments{}) 12 | env := environments.GetEnvironments() 13 | 14 | require.Equal(t, env.AppBaseDir, "../../../config") 15 | } 16 | -------------------------------------------------------------------------------- /src/app/test/di_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "github.com/hamakn/go_ddd_webapp/src/app/domain/user" 5 | dbUser "github.com/hamakn/go_ddd_webapp/src/app/infrastructure/db/user" 6 | ) 7 | 8 | func injectDependencies() { 9 | injectRepositoryDependencies() 10 | } 11 | 12 | func injectRepositoryDependencies() { 13 | user.NewRepository = dbUser.NewRepository 14 | } 15 | -------------------------------------------------------------------------------- /src/app/test/init_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "github.com/hamakn/go_ddd_webapp/src/app/infrastructure/environments" 5 | "github.com/hamakn/go_ddd_webapp/src/app/internal" 6 | ) 7 | 8 | func init() { 9 | internal.MockEnvironments(&environments.Environments{}) 10 | injectDependencies() 11 | } 12 | -------------------------------------------------------------------------------- /src/app/test/users_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/http/httptest" 9 | "strconv" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/hamakn/go_ddd_webapp/src/app/domain/user" 14 | 15 | "github.com/hamakn/go_ddd_webapp/src/app/infrastructure/config" 16 | "github.com/stretchr/testify/require" 17 | "google.golang.org/appengine" 18 | "google.golang.org/appengine/aetest" 19 | ) 20 | 21 | func TestGetUsers(t *testing.T) { 22 | inst, err := aetest.NewInstance(&aetest.Options{ 23 | StronglyConsistentDatastore: true, 24 | }) 25 | require.Nil(t, err) 26 | defer inst.Close() 27 | 28 | req, err := inst.NewRequest("GET", "/users/", nil) 29 | require.Nil(t, err) 30 | 31 | err = loadUserFixture(appengine.NewContext(req)) 32 | require.Nil(t, err) 33 | 34 | res := httptest.NewRecorder() 35 | config.NewRouter().ServeHTTP(res, req) 36 | require.Equal(t, http.StatusOK, res.Code) 37 | 38 | users := []*user.User{} 39 | err = json.NewDecoder(res.Body).Decode(&users) 40 | require.Nil(t, err) 41 | 42 | require.Equal(t, 2, len(users)) 43 | } 44 | 45 | func TestGetUser(t *testing.T) { 46 | inst, err := aetest.NewInstance(&aetest.Options{ 47 | StronglyConsistentDatastore: true, 48 | }) 49 | require.Nil(t, err) 50 | defer inst.Close() 51 | 52 | req, err := inst.NewRequest("GET", "/dummy", nil) 53 | require.Nil(t, err) 54 | 55 | err = loadUserFixture(appengine.NewContext(req)) 56 | require.Nil(t, err) 57 | 58 | testCases := []struct { 59 | UserID string 60 | HasError bool 61 | ResponseCode int 62 | }{ 63 | {"1", false, http.StatusOK}, 64 | {"42", true, http.StatusNotFound}, 65 | {"bad", true, http.StatusBadRequest}, 66 | } 67 | 68 | for _, testCase := range testCases { 69 | req, err := inst.NewRequest("GET", "/users/"+testCase.UserID, nil) 70 | require.Nil(t, err) 71 | 72 | res := httptest.NewRecorder() 73 | config.NewRouter().ServeHTTP(res, req) 74 | 75 | require.Equal(t, testCase.ResponseCode, res.Code) 76 | if !testCase.HasError { 77 | u := &user.User{} 78 | json.NewDecoder(res.Body).Decode(u) 79 | require.Equal(t, testCase.UserID, fmt.Sprint(u.ID)) 80 | } 81 | } 82 | } 83 | 84 | func TestCreateUser(t *testing.T) { 85 | inst, err := aetest.NewInstance(&aetest.Options{ 86 | StronglyConsistentDatastore: true, 87 | }) 88 | require.Nil(t, err) 89 | defer inst.Close() 90 | 91 | req, err := inst.NewRequest("GET", "/dummy", nil) 92 | require.Nil(t, err) 93 | 94 | err = loadUserFixture(appengine.NewContext(req)) 95 | require.Nil(t, err) 96 | 97 | testCases := []struct { 98 | PostJSON string 99 | HasError bool 100 | ResponseCode int 101 | }{ 102 | { 103 | // OK json 104 | `{"email":"new@hamakn.test","screen_name":"new_name","age":17}`, 105 | false, 106 | http.StatusOK, 107 | }, 108 | { 109 | // NG json: broken json 110 | "{", 111 | true, 112 | http.StatusBadRequest, 113 | }, 114 | { 115 | // NG json: no required field 116 | "{}", 117 | true, 118 | http.StatusBadRequest, 119 | }, 120 | { 121 | // NG json: validation failed 122 | `{"email":"new@hamakn.test","screen_name":"badname","age":17}`, 123 | true, 124 | http.StatusBadRequest, 125 | }, 126 | { 127 | // NG json: email taken user 128 | `{"email":"foo@hamakn.test","screen_name":"new_foo","age":17}`, 129 | true, 130 | http.StatusUnprocessableEntity, 131 | }, 132 | } 133 | 134 | for _, testCase := range testCases { 135 | req, err := inst.NewRequest("POST", "/users/", strings.NewReader(testCase.PostJSON)) 136 | require.Nil(t, err) 137 | 138 | res := httptest.NewRecorder() 139 | config.NewRouter().ServeHTTP(res, req) 140 | 141 | require.Equal(t, testCase.ResponseCode, res.Code) 142 | 143 | if !testCase.HasError { 144 | ctx := appengine.NewContext(req) 145 | 146 | u := &user.User{} 147 | json.NewDecoder(res.Body).Decode(&u) 148 | 149 | r := user.CreateUserValue{} 150 | json.NewDecoder(strings.NewReader(testCase.PostJSON)).Decode(&r) 151 | 152 | dbu, err := user.NewRepository().GetByID(ctx, u.ID) 153 | require.Nil(t, err) 154 | 155 | require.Equal(t, *r.Email, dbu.Email) 156 | require.Equal(t, *r.ScreenName, dbu.ScreenName) 157 | require.Equal(t, *r.Age, dbu.Age) 158 | } 159 | } 160 | } 161 | 162 | func TestUpdateUser(t *testing.T) { 163 | inst, err := aetest.NewInstance(&aetest.Options{ 164 | StronglyConsistentDatastore: true, 165 | }) 166 | require.Nil(t, err) 167 | defer inst.Close() 168 | 169 | req, err := inst.NewRequest("GET", "/dummy", nil) 170 | require.Nil(t, err) 171 | 172 | err = loadUserFixture(appengine.NewContext(req)) 173 | require.Nil(t, err) 174 | 175 | testCases := []struct { 176 | UserID string 177 | PostJSON string 178 | HasError bool 179 | ResponseCode int 180 | }{ 181 | { 182 | // NG1: bad user ID 183 | "bad", 184 | `{"email":"new@hamakn.test"}`, 185 | true, 186 | http.StatusBadRequest, 187 | }, 188 | { 189 | // NG2: broken JSON 190 | "1", 191 | `{`, 192 | true, 193 | http.StatusBadRequest, 194 | }, 195 | { 196 | // NG3: validation error (bad email) 197 | "1", 198 | `{"email":"bad_email"}`, 199 | true, 200 | http.StatusBadRequest, 201 | }, 202 | { 203 | // NG4: nothing to update 204 | "1", 205 | `{"extra":"aaa"}`, 206 | true, 207 | http.StatusBadRequest, 208 | }, 209 | { 210 | // NG5: nothing to update (same old screen_name and new screen_name) 211 | "1", 212 | `{"screen_name":"foo"}`, 213 | true, 214 | http.StatusBadRequest, 215 | }, 216 | { 217 | // NG6: no entity 218 | "42", 219 | `{"email":"new@hamakn.test"}`, 220 | true, 221 | http.StatusNotFound, 222 | }, 223 | { 224 | // NG7: email taken 225 | "1", 226 | `{"email":"bar@hamakn.test"}`, 227 | true, 228 | http.StatusUnprocessableEntity, 229 | }, 230 | { 231 | // OK 232 | "1", 233 | `{"email":"new@hamakn.test"}`, 234 | false, 235 | http.StatusOK, 236 | }, 237 | } 238 | 239 | for _, testCase := range testCases { 240 | req, err := inst.NewRequest("PUT", "/users/"+testCase.UserID, strings.NewReader(testCase.PostJSON)) 241 | require.Nil(t, err) 242 | 243 | res := httptest.NewRecorder() 244 | config.NewRouter().ServeHTTP(res, req) 245 | 246 | require.Equal(t, testCase.ResponseCode, res.Code) 247 | 248 | if !testCase.HasError { 249 | ctx := appengine.NewContext(req) 250 | 251 | u := &user.User{} 252 | json.NewDecoder(res.Body).Decode(&u) 253 | 254 | r := user.UpdateUserValue{} 255 | json.NewDecoder(strings.NewReader(testCase.PostJSON)).Decode(&r) 256 | 257 | dbu, err := user.NewRepository().GetByID(ctx, u.ID) 258 | require.Nil(t, err) 259 | 260 | if r.Email != nil { 261 | require.Equal(t, *r.Email, dbu.Email) 262 | } 263 | if r.ScreenName != nil { 264 | require.Equal(t, *r.ScreenName, dbu.ScreenName) 265 | } 266 | if r.Age != nil { 267 | require.Equal(t, *r.Age, dbu.Age) 268 | } 269 | } 270 | } 271 | } 272 | 273 | func TestDeleteUser(t *testing.T) { 274 | inst, err := aetest.NewInstance(&aetest.Options{ 275 | StronglyConsistentDatastore: true, 276 | }) 277 | require.Nil(t, err) 278 | defer inst.Close() 279 | 280 | req, err := inst.NewRequest("GET", "/dummy", nil) 281 | require.Nil(t, err) 282 | 283 | err = loadUserFixture(appengine.NewContext(req)) 284 | require.Nil(t, err) 285 | 286 | testCases := []struct { 287 | UserID string 288 | HasError bool 289 | ResponseCode int 290 | }{ 291 | { 292 | // NG1: bad user id 293 | "bad", 294 | true, 295 | http.StatusBadRequest, 296 | }, 297 | { 298 | // NG2: not found 299 | "42", 300 | true, 301 | http.StatusNotFound, 302 | }, 303 | { 304 | // OK 305 | "1", 306 | false, 307 | http.StatusNoContent, 308 | }, 309 | } 310 | 311 | for _, testCase := range testCases { 312 | req, err := inst.NewRequest("DELETE", "/users/"+testCase.UserID, nil) 313 | require.Nil(t, err) 314 | 315 | res := httptest.NewRecorder() 316 | config.NewRouter().ServeHTTP(res, req) 317 | 318 | require.Equal(t, testCase.ResponseCode, res.Code) 319 | 320 | if !testCase.HasError { 321 | ctx := appengine.NewContext(req) 322 | 323 | id, err := strconv.ParseInt(testCase.UserID, 10, 64) 324 | require.Nil(t, err) 325 | 326 | _, err = user.NewRepository().GetByID(ctx, id) 327 | require.Equal(t, user.ErrNoSuchEntity, err) 328 | } 329 | } 330 | } 331 | 332 | func loadUserFixture(ctx context.Context) error { 333 | r := user.NewRepository() 334 | _, err := r.CreateFixture(ctx) 335 | return err 336 | } 337 | --------------------------------------------------------------------------------