diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..a39eb6ff1 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +APP_HOST= +APP_PORT=8080 +APP_SECRET=8c6c19688d9f94b3900042923aa94a99 + +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=manabie +DB_USERNAME=manabie +DB_PASSWORD=12345 \ No newline at end of file diff --git a/.gitignore b/.gitignore index a512c8b38..ad300f4f3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ *.out # Dependency directories (remove the comment below to include it) -# vendor/ +vendor/ out/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..70c72eac9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM golang:1.18.4 +WORKDIR /go/src +COPY . /go/src +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -o start cmd/main.go + +FROM alpine:latest +RUN apk --no-cache add ca-certificates +WORKDIR /root/ +COPY --from=0 /go/src/start . +CMD ["./start"] \ No newline at end of file diff --git a/README.md b/README.md index 6398b5c09..a8f4bd9ad 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,262 @@ -### Requirements +### About the app +This app is built in Golang and PostgreSQL (for storing data). Gorilla Mux is the default router which is using in the app and for integration test too. -- Implement one single API which accepts a todo task and records it - - There is a maximum **limit of N tasks per user** that can be added **per day**. - - Different users can have **different** maximum daily limit. -- Write integration (functional) tests -- Write unit tests -- Choose a suitable architecture to make your code simple, organizable, and maintainable -- Using Docker to run locally - - Using Docker for database (if used) is mandatory. -- Write a concise README - - How to run your code locally? - - A sample “curl” command to call your API - - How to run your unit tests locally? - - What do you love about your solution? - - What else do you want us to know about however you do not have enough time to complete? +The app is inspired heavily by Laravel, so you would feel familiar if you had developed with Laravel. I also update JWT Authentication and full CRUD for this app. -### Notes +### How to run the app? +```shell +docker-compose up +``` -- We're using Golang at Manabie. **However**, we encourage you to use the programming language that you are most comfortable with because we want you to **shine** with all your skills and knowledge. +Default app port is `8080`, if you your `8080` port is not available, do not hesitate to change to another one in `docker-compose.yml` -### How to submit your solution? +### How to use the app? +1. #### Register new account -- Fork this repo and show us your development progress via a PR +Method: `POST` -### Interesting facts about Manabie +URL: `/api/register` -- Monthly there are about 2 million lines of code changes (inserted/updated/deleted) committed into our GitHub repositories. To avoid **regression bugs**, we write different kinds of **automated tests** (unit/integration (functionality)/end2end) as parts of the definition of done of our assigned tasks. -- We nurture the cultural values: **knowledge sharing** and **good communication**, therefore good written documents and readable, organizable, and maintainable code are in our blood when we build any features to grow our products. -- We have **collaborative** culture at Manabie. Feel free to ask trieu@manabie.com any questions. We are very happy to answer all of them. +cURL: +```shell +curl --location --request POST 'http://localhost:8080/api/register' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "email": "huuthuan.nguyen@hotmail.com", + "password": "123@pass!word" +}' +``` -Thank you for spending time to read and attempt our take-home assessment. We are looking forward to your submission. +Payload: +```json +{ + "email": "huuthuan.nguyen@hotmail.com", + "password": "123@pass!word" +} +``` + +Response: +```json +{ + "status": 1, + "messages": [ + "Successful." + ], + "data": { + "id": 1, + "email": "huuthuan.nguyen@hotmail.com", + "is_active": true, + "daily_limit": 3, + "create_at": "2022-07-25T18:02:49.291850383Z", + "updated_at": "2022-07-25T18:02:49.291852258Z" + } +} +``` + +Default quota for your tasks is 3 per day, you can not change this. + +2. #### Login with your email and password + +Method: `POST` + +URL: `/api/auth/login` + +cURL: +```shell +curl --location --request POST 'http://localhost:8080/api/auth/login' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "email": "huuthuan.nguyen@hotmail.com", + "password": "123@pass!word" +}' +``` + +Payload: +```json +{ + "email": "huuthuan.nguyen@hotmail.com", + "password": "123@pass!word" +} +``` + +Response: +```json +{ + "status": 1, + "messages": [ + "Successful." + ], + "data": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Imh1dXRodWFuLm5ndXllbkBob3RtYWlsLmNvbSIsImV4cCI6MTY1ODg1ODY0NH0.vfglN4fOZ7NDat1hxznivNk9T4znCgw4l8K6XFemAYQ", + "expired_at": "2022-07-26T18:04:04.060592793Z" + } +} +``` + +3. #### Add new task + +Method: `POST` + +URL: `/api/tasks` + +cURL: +```shell +curl --location --request POST 'http://localhost:8080/api/tasks' \ +--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Imh1dXRodWFuLm5ndXllbkBob3RtYWlsLmNvbSIsImV4cCI6MTY1ODg1ODY0NH0.vfglN4fOZ7NDat1hxznivNk9T4znCgw4l8K6XFemAYQ' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "content": "Do homework" +}' +``` + +Payload: +```json +{ + "content": "Do homework" +} +``` + +Response +```json +{ + "status": 1, + "messages": [ + "Successful." + ], + "data": { + "id": 1, + "content": "Do homework", + "published_date": "2022-07-25", + "status": 0, + "created_by": 1, + "create_at": "2022-07-25T18:04:30.128784138Z", + "updated_at": "2022-07-25T18:04:30.128785013Z" + } +} +``` + +4. #### Update your task by ID + +Method: `PUT` + +URL: `/api/tasks/:id` + +cURL: +```shell +curl --location --request PUT 'http://localhost:8080/api/tasks/1' \ +--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Imh1dXRodWFuLm5ndXllbkBob3RtYWlsLmNvbSIsImV4cCI6MTY1ODg1ODY0NH0.vfglN4fOZ7NDat1hxznivNk9T4znCgw4l8K6XFemAYQ' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "content": "Do homework later" +}' +``` + +Payload: +```json +{ + "content": "Do homework later" +} +``` + +Response: +```json +{ + "status": 1, + "messages": [ + "Successful." + ], + "data": { + "id": 1, + "content": "Do homework later", + "published_date": "2022-07-25", + "status": 0, + "created_by": 1, + "create_at": "2022-07-25T18:04:30.128784138Z", + "updated_at": "2022-07-25T18:07:45.866218Z" + } +} +``` + +5. #### Remove your task by ID + +Method: `DELETE` + +URL: `/api/tasks/:id` + +cURL: +```shell +curl --location --request DELETE 'http://localhost:8080/api/tasks/1' \ +--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Imh1dXRodWFuLm5ndXllbkBob3RtYWlsLmNvbSIsImV4cCI6MTY1ODg1ODY0NH0.vfglN4fOZ7NDat1hxznivNk9T4znCgw4l8K6XFemAYQ' +``` + +Payload: +```json + +``` +Response +```json +``` + +6. #### List your tasks + +Method: `GET` + +URL: `/api/tasks` + +cURL: +```shell +curl --location --request GET 'http://localhost:8080/api/tasks' \ +--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Imh1dXRodWFuLm5ndXllbkBob3RtYWlsLmNvbSIsImV4cCI6MTY1ODg1ODY0NH0.vfglN4fOZ7NDat1hxznivNk9T4znCgw4l8K6XFemAYQ' +``` + +Payload: +```json + +``` + +Response: +```json +{ + "status": 1, + "messages": [ + "Successful." + ], + "data": { + "items": [ + { + "id": 1, + "content": "Do homework later", + "published_date": "2022-07-25", + "status": 0, + "created_by": 1, + "create_at": "2022-07-25T18:04:30.128784138Z", + "updated_at": "2022-07-25T18:07:45.866218Z" + }, + { + "id": 1, + "content": "Do laundry", + "published_date": "2022-07-25", + "status": 0, + "created_by": 1, + "create_at": "2022-07-25T18:04:30.128784138Z", + "updated_at": "2022-07-25T18:07:45.866218Z" + } + ] + } +} +``` + +### How to run test locally? + +Requisite: start your app with docker +```shell +APP_HOST= APP_PORT=8080 APP_SECRET=8c6c19688d9f94b3900042923aa94a99 DB_HOST=localhost DB_PORT=5432 DB_NAME=manabie DB_USERNAME=manabie DB_PASSWORD=12345 go test -v -count=1 ./test +``` + +### Final + +- This app need to be optimized the database, use the aggregated column for checking exceeded daily limit instead of `count` query, because `count` query is so expensive when the records grow up. +- We need to move the testing bash script to docker-compose.yml too +- Add swagger instead of using Postman + +Postman Collection: `https://www.getpostman.com/collections/2cdc08395f26e9047e4f` \ No newline at end of file diff --git a/app/app.go b/app/app.go new file mode 100644 index 000000000..e66253000 --- /dev/null +++ b/app/app.go @@ -0,0 +1,60 @@ +package app + +import ( + "context" + "fmt" + "github.com/gorilla/mux" + "github.com/huuthuan-nguyen/manabie/app/handler" + "github.com/huuthuan-nguyen/manabie/app/router" + "github.com/huuthuan-nguyen/manabie/config" + "github.com/huuthuan-nguyen/manabie/migration" + "log" + "net/http" + "time" +) + +type App struct { + router *mux.Router + handler *handler.Handler + ctx context.Context + server *http.Server + config *config.Config +} + +func (app *App) Run() { + + if err := app.Migrate(); err != nil { + log.Fatalf("Migrating fail:%s\n", err) + return + } + + app.server = &http.Server{ + Addr: fmt.Sprintf("%s:%s", app.config.Server.Host, app.config.Server.Port), + Handler: app.router, + WriteTimeout: 30 * time.Second, + ReadTimeout: 30 * time.Second, + } + defer app.server.Close() + + log.Printf("Listening on %s:%v...\n", app.config.Server.Host, app.config.Server.Port) + log.Fatalln(app.server.ListenAndServe()) +} + +func NewApp(config *config.Config) *App { + c := context.Background() + h := handler.New(c, config) + r := router.NewRouter(config, h) + + return &App{ + router: r, + handler: h, + ctx: c, + config: config, + } +} + +// Migrate /** +func (app *App) Migrate() error { + migrateEngine := migration.NewEngine(app.config) + return migrateEngine.Migrate() +} diff --git a/app/handler/handler.go b/app/handler/handler.go new file mode 100644 index 000000000..d359a313b --- /dev/null +++ b/app/handler/handler.go @@ -0,0 +1,34 @@ +package handler + +import ( + "context" + "github.com/huuthuan-nguyen/manabie/app/model" + "github.com/huuthuan-nguyen/manabie/config" + "github.com/uptrace/bun" + "log" +) + +type Handler struct { + db *bun.DB + ctx context.Context + config *config.Config +} + +func (handler *Handler) GetDB() *bun.DB { + return handler.db +} + +// New /** +func New(ctx context.Context, config *config.Config) *Handler { + + connection, err := model.NewConnection(config) + if err != nil { + log.Fatalf("Error connecting to database...") + } + + return &Handler{ + db: connection, + ctx: ctx, + config: config, + } +} diff --git a/app/handler/task.go b/app/handler/task.go new file mode 100644 index 000000000..788749806 --- /dev/null +++ b/app/handler/task.go @@ -0,0 +1,156 @@ +package handler + +import ( + "github.com/gorilla/mux" + "github.com/huuthuan-nguyen/manabie/app/model" + "github.com/huuthuan-nguyen/manabie/app/render" + "github.com/huuthuan-nguyen/manabie/app/request" + "github.com/huuthuan-nguyen/manabie/app/transformer" + "github.com/huuthuan-nguyen/manabie/app/utils" + "log" + "net/http" + "strconv" +) + +// TaskStore /** +func (handler *Handler) TaskStore(w http.ResponseWriter, r *http.Request) { + taskRequest := request.Task{} + taskModel := model.Task{} + + if err := taskRequest.Bind(r, &taskModel); err != nil { + render.Error(w, r, err) + return + } + + if err := taskModel.Create(r.Context(), handler.db); err != nil { + log.Println(err) + if _, ok := err.(*model.ErrExceedDailyLimit); ok { + utils.PanicBadRequest(err) + return + } + utils.PanicInternalServerError(err) + return + } + + taskTransformer := &transformer.TaskTransformer{} + taskItem := transformer.NewItem(taskModel, taskTransformer) + + // render task item to JSON + render.JSON(w, r, taskItem) +} + +// TaskUpdate /** +func (handler *Handler) TaskUpdate(w http.ResponseWriter, r *http.Request) { + taskRequest := request.Task{} + taskModel := model.Task{} + vars := mux.Vars(r) + + id, ok := vars["id"] + if !ok { + utils.PanicNotFound() + } + + taskID, err := strconv.Atoi(id) + if err != nil { + utils.PanicInternalServerError(err) + return + } + + _, err = model.FindOneTaskByID(r.Context(), taskID, handler.db) + if err != nil { + utils.PanicNotFound() + return + } + + taskModel.ID = taskID + if err := taskRequest.Bind(r, &taskModel); err != nil { + render.Error(w, r, err) + return + } + + if err := taskModel.Update(r.Context(), handler.db); err != nil { + utils.PanicInternalServerError(err) + return + } + + taskTransformer := &transformer.TaskTransformer{} + taskItem := transformer.NewItem(taskModel, taskTransformer) + + // render task item to JSON + render.JSON(w, r, taskItem) +} + +// TaskDestroy /** +func (handler *Handler) TaskDestroy(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + + id, ok := vars["id"] + if !ok { + utils.PanicNotFound() + } + + taskID, err := strconv.Atoi(id) + if err != nil { + utils.PanicInternalServerError(err) + return + } + + _, err = model.FindOneTaskByID(r.Context(), taskID, handler.db) + if err != nil { + utils.PanicNotFound() + return + } + + taskModel := model.Task{} + taskModel.ID = taskID + + if err := taskModel.Delete(r.Context(), handler.db); err != nil { + utils.PanicInternalServerError(err) + return + } + + render.NoContent(w, r) +} + +// TaskIndex /** +func (handler *Handler) TaskIndex(w http.ResponseWriter, r *http.Request) { + // prepare data response + tasks, err := model.FindTasks(r.Context(), handler.db, r.URL.Query()) + if err != nil { + render.Error(w, r, err) + return + } + + taskTransformer := &transformer.TaskTransformer{} + taskCollection := transformer.NewCollection(tasks, taskTransformer) + + // render Collection to JSON + render.JSON(w, r, taskCollection) +} + +// TaskShow /** +func (handler *Handler) TaskShow(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id, ok := vars["id"] + if !ok { + utils.PanicNotFound() + } + + taskID, err := strconv.Atoi(id) + if err != nil { + utils.PanicInternalServerError(err) + return + } + + task, err := model.FindOneTaskByID(r.Context(), taskID, handler.db) + if err != nil { + utils.PanicNotFound() + return + } + + taskTransformer := &transformer.TaskTransformer{} + taskItem := transformer.NewItem(task, taskTransformer) + + // render task item to JSON + render.JSON(w, r, taskItem) +} diff --git a/app/handler/user.go b/app/handler/user.go new file mode 100644 index 000000000..2ddd7001f --- /dev/null +++ b/app/handler/user.go @@ -0,0 +1,88 @@ +package handler + +import ( + "github.com/golang-jwt/jwt" + "github.com/huuthuan-nguyen/manabie/app/middleware" + "github.com/huuthuan-nguyen/manabie/app/model" + "github.com/huuthuan-nguyen/manabie/app/render" + "github.com/huuthuan-nguyen/manabie/app/request" + "github.com/huuthuan-nguyen/manabie/app/transformer" + "github.com/huuthuan-nguyen/manabie/app/utils" + "net/http" + "time" +) + +// UserRegister /** +func (handler *Handler) UserRegister(w http.ResponseWriter, r *http.Request) { + credentialsRequest := request.Credentials{} + userModel := model.User{} + + if err := credentialsRequest.Bind(r, &userModel); err != nil { + render.Error(w, r, err) + return + } + + if err := userModel.Create(r.Context(), handler.db); err != nil { + utils.PanicInternalServerError(err) + return + } + + userTransformer := &transformer.UserTransformer{} + userItem := transformer.NewItem(userModel, userTransformer) + + // render user item to JSON + render.JSON(w, r, userItem) +} + +// UserLogin /** +func (handler *Handler) UserLogin(w http.ResponseWriter, r *http.Request) { + credentialsRequest := request.Credentials{} + userModel := model.User{} + + if err := credentialsRequest.Bind(r, &userModel); err != nil { + render.Error(w, r, err) + return + } + + // find user by email in database + currentUser, err := model.FindOneUserByEmail(r.Context(), userModel.Email, handler.db) + if err != nil { + render.Error(w, r, err) + } + + // compare password + if !model.CheckPasswordHash(userModel.Password, currentUser.Password) { + // throw error + utils.PanicUnauthorized() + return + } + + // expired at + expirationTime := time.Now().Add(1440 * time.Minute) // one day expiration + // create JWT claims with expiration and email + claims := &middleware.Payload{ + Email: userModel.Email, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: expirationTime.Unix(), // expired at in unix milliseconds format + }, + } + // create token with HS256 algorithm and claims + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + // token string + tokenString, err := token.SignedString([]byte(handler.config.Server.Secret)) + if err != nil { + utils.PanicInternalServerError(err) + return + } + + // return token + tokenModel := model.Token{ + AccessToken: tokenString, + ExpiredAt: expirationTime, + } + tokenTransformer := &transformer.TokenTransformer{} + tokenItem := transformer.NewItem(tokenModel, tokenTransformer) + + // render token item to JSON + render.JSON(w, r, tokenItem) +} diff --git a/app/middleware/cors_middleware.go b/app/middleware/cors_middleware.go new file mode 100644 index 000000000..63c80550d --- /dev/null +++ b/app/middleware/cors_middleware.go @@ -0,0 +1,21 @@ +package middleware + +import ( + "github.com/gorilla/mux" + "net/http" +) + +// CORSMiddleware wrap the gorilla Logging Handler +func CORSMiddleware() mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE, HEAD") + if r.Method == http.MethodOptions { + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/app/middleware/jwt_authentication.go b/app/middleware/jwt_authentication.go new file mode 100644 index 000000000..d54c3fdfc --- /dev/null +++ b/app/middleware/jwt_authentication.go @@ -0,0 +1,65 @@ +package middleware + +import ( + "context" + "fmt" + "github.com/golang-jwt/jwt" + "github.com/gorilla/mux" + "github.com/huuthuan-nguyen/manabie/app/model" + "github.com/huuthuan-nguyen/manabie/app/utils" + "github.com/huuthuan-nguyen/manabie/config" + "github.com/uptrace/bun" + "log" + "net/http" + "strings" +) + +// JWTAuthenticateMiddleware protect resources +func JWTAuthenticateMiddleware(config *config.Config, db bun.IDB) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + bearerToken := r.Header.Get("Authorization") + if len(bearerToken) > 0 { + bearerToken = strings.TrimPrefix(bearerToken, "Bearer ") + } else { + utils.PanicUnauthorized() + return + } + + payload := &Payload{} + tkn, err := jwt.ParseWithClaims(bearerToken, payload, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method %v", token.Header["alg"]) + } + return []byte(config.Server.Secret), nil + }) + + if err != nil || tkn == nil || !tkn.Valid { + log.Println(err) + utils.PanicUnauthorized() + return + } + + payload, ok := tkn.Claims.(*Payload) + if !ok { + utils.PanicUnauthorized() + return + } + + // prepare user + user, err := model.FindOneUserByEmail(r.Context(), payload.Email, db) + if err != nil { + utils.PanicUnauthorized() + return + } + + ctx := context.WithValue(r.Context(), "user", &user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +type Payload struct { + Email string `json:"email,omitempty"` + jwt.StandardClaims +} diff --git a/app/middleware/logging_middleware.go b/app/middleware/logging_middleware.go new file mode 100644 index 000000000..d15205a55 --- /dev/null +++ b/app/middleware/logging_middleware.go @@ -0,0 +1,18 @@ +package middleware + +import ( + "github.com/gorilla/handlers" + "github.com/gorilla/mux" + "net/http" + "os" +) + +// LoggingMiddleware wrap the gorilla Logging Handler +func LoggingMiddleware() mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next = handlers.LoggingHandler(os.Stdout, next) // run logger before execute the logic + next.ServeHTTP(w, r) + }) + } +} diff --git a/app/middleware/prometheus_middleware.go b/app/middleware/prometheus_middleware.go new file mode 100644 index 000000000..4fd0dd879 --- /dev/null +++ b/app/middleware/prometheus_middleware.go @@ -0,0 +1,49 @@ +package middleware + +import ( + "github.com/gorilla/mux" + "github.com/prometheus/client_golang/prometheus" + "net/http" +) + +// PrometheusMiddleware /** +func PrometheusMiddleware() mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + timer := prometheus.NewTimer(httpDuration.WithLabelValues(r.URL.Path)) + totalRequests.WithLabelValues(r.URL.Path).Inc() + totalHTTPMethods.WithLabelValues(r.Method).Inc() + next.ServeHTTP(w, r) + timer.ObserveDuration() + }) + } +} + +func init() { + _ = prometheus.Register(totalRequests) + _ = prometheus.Register(totalHTTPMethods) + _ = prometheus.Register(httpDuration) +} + +var totalRequests = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "http_requests_total", + Help: "Number of incoming requests", + }, + []string{"path"}, +) + +var totalHTTPMethods = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "http_methods_total", + Help: "Number of requests per HTTP method", + }, []string{"method"}, +) + +var httpDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "http_response_time_seconds", + Help: "Duration of HTTP requests", + }, + []string{"path"}, +) diff --git a/app/middleware/rate_limiting_middleware.go b/app/middleware/rate_limiting_middleware.go new file mode 100644 index 000000000..b0b1d981a --- /dev/null +++ b/app/middleware/rate_limiting_middleware.go @@ -0,0 +1,136 @@ +package middleware + +import ( + "github.com/gorilla/mux" + "github.com/huuthuan-nguyen/manabie/app/utils" + "golang.org/x/time/rate" + "net" + "net/http" + "strings" + "sync" + "time" +) + +var limiter = NewIPRateLimiter(1, 500) // rate is 1, token bucket size is 500 + +func init() { + go limiter.CleanUpVisitors() +} + +// RateLimitingMiddleware /** +func RateLimitingMiddleware() mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + visitor := limiter.GetVisitor(GetIP(r)) + if !visitor.limiter.Allow() { + utils.PanicTooManyRequests() + } + next.ServeHTTP(w, r) + }) + } +} + +// Visitor struct +type Visitor struct { + limiter *rate.Limiter + lastSeen time.Time +} + +// IPRateLimiter /** +type IPRateLimiter struct { + visitors map[string]*Visitor + mu *sync.RWMutex + r rate.Limit + b int +} + +// NewIPRateLimiter /** +func NewIPRateLimiter(r rate.Limit, b int) *IPRateLimiter { + return &IPRateLimiter{ + visitors: make(map[string]*Visitor), + mu: &sync.RWMutex{}, + r: r, + b: b, + } +} + +// AddVisitor creates a new rate limiter and adds it to the ips map, +// using the IP address as the key +func (i *IPRateLimiter) AddVisitor(ip string) *Visitor { + i.mu.Lock() + defer i.mu.Unlock() + + limiter := rate.NewLimiter(i.r, i.b) + + i.visitors[ip] = &Visitor{ + limiter: limiter, + lastSeen: time.Now(), + } + return i.visitors[ip] +} + +// GetVisitor /** +// GetLimiter returns the rate limiter for the provided IP address if it exists +// Otherwise call AddVisitor to add IP address to the map +func (i *IPRateLimiter) GetVisitor(ip string) *Visitor { + i.mu.Lock() + visitor, ok := i.visitors[ip] + + if !ok { + i.mu.Unlock() + return i.AddVisitor(ip) + } + + i.mu.Unlock() + return visitor +} + +// CleanUpVisitors /** +// Every minute check the map for IPs that haven't been seen for +// more than 3 minutes and delete the entries +func (i *IPRateLimiter) CleanUpVisitors() { + for { + time.Sleep(time.Minute) + + i.mu.Lock() + + for ip, v := range i.visitors { + if time.Since(v.lastSeen) > 3*time.Minute { + delete(i.visitors, ip) + } + } + + i.mu.Unlock() + } +} + +// GetIP /** +func GetIP(r *http.Request) string { + // get IP from the X-REAL-IP header + ip := r.Header.Get("X-REAL-IP") + netIP := net.ParseIP(ip) + if netIP != nil { + return ip + } + // get IP from X-FORWARDED-FOR header + ips := r.Header.Get("X-FORWARDED-FOR") + splitIps := strings.Split(ips, ",") + for _, ip := range splitIps { + netIP := net.ParseIP(ip) + if netIP != nil { + return ip + } + } + + // get IP from RemoteAddr + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return "" + } + netIP = net.ParseIP(ip) + if netIP != nil { + return ip + } + + return "" +} diff --git a/app/middleware/recovery_middleware.go b/app/middleware/recovery_middleware.go new file mode 100644 index 000000000..15bbe6c79 --- /dev/null +++ b/app/middleware/recovery_middleware.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "github.com/gorilla/mux" + "github.com/huuthuan-nguyen/manabie/app/utils" + "net/http" +) + +// RecoveringMiddleware wrap the gorilla Recovery handler +func RecoveringMiddleware() mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // recover when panic happening + defer func() { + if err := recover(); err != nil { + // Handling the error + if e, ok := err.(utils.Error); ok { + statusCode := e.StatusCode + w.WriteHeader(statusCode) + _, _ = w.Write([]byte(e.Error())) + } else { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("Something went wrong.")) + } + } + }() + + next.ServeHTTP(w, r) + }) + } +} diff --git a/app/middleware/validate_middleware.go b/app/middleware/validate_middleware.go new file mode 100644 index 000000000..3536726a9 --- /dev/null +++ b/app/middleware/validate_middleware.go @@ -0,0 +1,19 @@ +package middleware + +import ( + "context" + "github.com/gorilla/mux" + "github.com/huuthuan-nguyen/manabie/app/validator" + "net/http" +) + +// ValidateMiddleware add validator to request context +func ValidateMiddleware() mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + validate, _ := validator.NewValidator() + ctx := context.WithValue(r.Context(), "validate", validate) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/app/model/model.go b/app/model/model.go new file mode 100644 index 000000000..48741f5d5 --- /dev/null +++ b/app/model/model.go @@ -0,0 +1,58 @@ +package model + +import ( + "context" + "database/sql" + "fmt" + "github.com/huuthuan-nguyen/manabie/config" + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect/pgdialect" + "github.com/uptrace/bun/driver/pgdriver" +) + +// A TxFn is a function that will be called with an initialized `Transaction` object +// that can be used for executing statements and queries against a database. +type TxFn func(db bun.Tx) error + +// WithTransaction creates a new transaction and handles rollback/commit based on the +// error object returned by the `TxFn` +func WithTransaction(ctx context.Context, db *bun.DB, fn TxFn) (err error) { + tx, err := db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return err + } + + defer func() { + if p := recover(); p != nil { + // a panic occurred, rollback and re panic + _ = tx.Rollback() + panic(p) + } else if err != nil { + // something went wrong, rollback + _ = tx.Rollback() + } else { + // all good, commit + err = tx.Commit() + } + }() + + err = fn(tx) + return err +} + +// NewConnection /** +func NewConnection(c *config.Config) (*bun.DB, error) { + dns := "postgres://%s:%s@%s:%s/%s?sslmode=disable" + + dnsConnectionString := fmt.Sprintf(dns, + c.Database.Username, + c.Database.Password, + c.Database.Host, + c.Database.Port, + c.Database.Name, + ) + + sqlDB := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dnsConnectionString))) + + return bun.NewDB(sqlDB, pgdialect.New()), nil +} diff --git a/app/model/task.go b/app/model/task.go new file mode 100644 index 000000000..d3cb0b49d --- /dev/null +++ b/app/model/task.go @@ -0,0 +1,127 @@ +package model + +import ( + "context" + "github.com/uptrace/bun" + "net/url" + "time" +) + +type Task struct { + bun.BaseModel `bun:"table:tasks,alias:task"` + ID int `bun:"id,pk,autoincrement"` + Content string `bun:"content"` + PublishedDate time.Time `bun:"published_date"` + Status int `bun:"status"` + CreatedBy int `bun:"created_by,pk"` + Owner *User `bun:"rel:belongs-to,join:created_by=id"` + CreatedAt time.Time `bun:"created_at,default:current_timestamp"` + UpdatedAt time.Time `bun:"updated_at,default:current_timestamp"` +} + +type ErrExceedDailyLimit struct{} + +func (e ErrExceedDailyLimit) Error() string { + return "user is exceeded daily limit of tasks" +} + +type ErrInvalidUser struct{} + +func (e ErrInvalidUser) Error() string { + return "invalid user" +} + +// FindTasks /** +func FindTasks(ctx context.Context, db bun.IDB, params url.Values) ([]Task, error) { + var tasks = make([]Task, 0) + user, ok := ctx.Value("user").(*User) + if !ok { + return nil, &ErrInvalidUser{} + } + query := db.NewSelect().Model(&tasks) + + for k := range params { + v := params.Get(k) + + if k == "published_date" { + query.Where("published_date=?", v) + continue + } + } + + query.Where("created_by=?", user.ID) + + err := query.Scan(ctx) + + return tasks, err +} + +// FindOneTaskByID /** +func FindOneTaskByID(ctx context.Context, id int, db bun.IDB) (Task, error) { + user, ok := ctx.Value("user").(*User) + if !ok { + return Task{}, &ErrInvalidUser{} + } + + task := Task{ + ID: id, + } + + err := db.NewSelect(). + Model(&task). + Where("id=?", id). + Where("created_by=?", user.ID). + Scan(ctx) + return task, err +} + +// Create /** +func (task *Task) Create(ctx context.Context, db bun.IDB) error { + user, ok := ctx.Value("user").(*User) + if !ok { + return &ErrInvalidUser{} + } + + if user.IsExceededDailyLimit(ctx, db) { + return &ErrExceedDailyLimit{} + } + + task.PublishedDate = time.Now().UTC() + task.CreatedAt = time.Now().UTC() + task.UpdatedAt = time.Now().UTC() + task.CreatedBy = user.ID + + if _, err := db.NewInsert().Model(task).Exec(ctx); err != nil { + return err + } + return nil + +} + +// Update /** +func (task *Task) Update(ctx context.Context, db bun.IDB) error { + // force current time on updated_at column + task.UpdatedAt = time.Now().UTC() + if _, err := db.NewUpdate(). + Model(task). + ExcludeColumn("created_at", "id", "created_by", "published_date"). + Where("id=?", task.ID). + Returning("*"). + Exec(ctx); err != nil { + return err + } + + return nil +} + +// Delete /** +func (task *Task) Delete(ctx context.Context, db bun.IDB) error { + + if _, err := db.NewDelete(). + Model(task). + Where("id=?", task.ID). + Exec(ctx); err != nil { + return err + } + return nil +} diff --git a/app/model/token.go b/app/model/token.go new file mode 100644 index 000000000..42d43739d --- /dev/null +++ b/app/model/token.go @@ -0,0 +1,8 @@ +package model + +import "time" + +type Token struct { + AccessToken string + ExpiredAt time.Time +} diff --git a/app/model/user.go b/app/model/user.go new file mode 100644 index 000000000..14c3ccd32 --- /dev/null +++ b/app/model/user.go @@ -0,0 +1,101 @@ +package model + +import ( + "context" + "github.com/uptrace/bun" + "golang.org/x/crypto/bcrypt" + "log" + "time" +) + +type User struct { + bun.BaseModel `bun:"table:users,alias:user"` + ID int `bun:"id,pk,autoincrement"` + Email string `bun:"email"` + Password string `bun:"password"` + IsActive bool `bun:"is_active,default:false"` + DailyLimit int `bun:"daily_limit,default:3"` + Tasks []*Task `bun:"rel:has-many,join:id=created_by"` + CreatedAt time.Time `bun:"created_at,default:current_timestamp"` + UpdatedAt time.Time `bun:"updated_at,default:current_timestamp"` +} + +// FindOneUserByID /** +func FindOneUserByID(ctx context.Context, id int, db bun.IDB) (User, error) { + user := User{ + ID: id, + } + + err := db.NewSelect().Model(&user).WherePK().Scan(ctx) + return user, err +} + +// FindOneUserByEmail /** +func FindOneUserByEmail(ctx context.Context, email string, db bun.IDB) (User, error) { + user := User{ + Email: email, + } + + err := db.NewSelect().Model(&user).Where("email=?", user.Email).Scan(ctx) + return user, err +} + +// Create /** +func (user *User) Create(ctx context.Context, db bun.IDB) error { + // hash password using bcrypt before saving + if hashedPassword, err := HashPassword(user.Password); err != nil { + return err + } else { + user.Password = hashedPassword + } + user.IsActive = true // default is true when user register + user.DailyLimit = 3 // default daily limit is 3 + user.CreatedAt = time.Now().UTC() + user.UpdatedAt = time.Now().UTC() + if _, err := db.NewInsert().Model(user).Exec(ctx); err != nil { + return err + } + return nil +} + +// HashPassword /** +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), 10) + return string(bytes), err +} + +// CheckPasswordHash /** +func CheckPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// IsExceededDailyLimit /** +func (user *User) IsExceededDailyLimit(ctx context.Context, db bun.IDB) bool { + if user.countTasksCurrentDate(ctx, db) < user.DailyLimit { + return false + } + return true +} + +// countTasksByDate /** +func (user *User) countTasksByDate(date string, ctx context.Context, db bun.IDB) int { + count, err := db.NewSelect(). + Model((*Task)(nil)). + Where("published_date=?", date). + Where("created_by=?", user.ID). + Count(ctx) + if err != nil { + log.Println(err) + return 0 + } + + return count +} + +// countTasksCurrentDate /** +func (user *User) countTasksCurrentDate(ctx context.Context, db bun.IDB) int { + currentTime := time.Now().UTC() + currentDate := currentTime.Format("2006-01-02") + return user.countTasksByDate(currentDate, ctx, db) +} diff --git a/app/render/error.go b/app/render/error.go new file mode 100644 index 000000000..9784e4b2b --- /dev/null +++ b/app/render/error.go @@ -0,0 +1,53 @@ +package render + +import ( + "encoding/json" + "github.com/go-playground/validator/v10" + "github.com/huuthuan-nguyen/manabie/app/transformer" + "github.com/huuthuan-nguyen/manabie/app/utils" + validation "github.com/huuthuan-nguyen/manabie/app/validator" + "net/http" +) + +// Error /** +func Error(w http.ResponseWriter, r *http.Request, payload any) { + + var statusCode = http.StatusOK + var data any + jsonSerializer := transformer.NewJSONSerializer() + + switch e := payload.(type) { + case validator.ValidationErrors: // bad request + statusCode = http.StatusBadRequest + data = validation.Translate(e) + jsonSerializer.Messages = []string{http.StatusText(http.StatusNotFound)} + break + case utils.Error: // common error + statusCode = http.StatusBadRequest + jsonSerializer.Messages = []string{http.StatusText(e.StatusCode)} + break + case error: // internal error + statusCode = http.StatusInternalServerError + jsonSerializer.Messages = []string{e.Error()} + break + default: // default + statusCode = http.StatusInternalServerError + jsonSerializer.Messages = []string{http.StatusText(http.StatusInternalServerError)} + } + + transformerManager := transformer.Manager{ + Serializer: jsonSerializer, + } + errorItem := transformer.NewError(data) + + payloadStruct := transformerManager.CreateData(errorItem) + payloadResponse, err := json.Marshal(payloadStruct) + if err != nil { + utils.PanicInternalServerError(err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _, _ = w.Write(payloadResponse) + return +} diff --git a/app/render/json.go b/app/render/json.go new file mode 100644 index 000000000..74f19fc4e --- /dev/null +++ b/app/render/json.go @@ -0,0 +1,22 @@ +package render + +import ( + "encoding/json" + "github.com/huuthuan-nguyen/manabie/app/transformer" + "github.com/huuthuan-nguyen/manabie/app/utils" + "net/http" +) + +// JSON /** +func JSON(w http.ResponseWriter, r *http.Request, payload interface{}) { + transformerManager := transformer.NewManager() + + payloadStruct := transformerManager.CreateData(payload) + payloadResponse, err := json.Marshal(payloadStruct) + if err != nil { + utils.PanicInternalServerError(err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(payloadResponse) +} diff --git a/app/render/no_content.go b/app/render/no_content.go new file mode 100644 index 000000000..c749997d2 --- /dev/null +++ b/app/render/no_content.go @@ -0,0 +1,9 @@ +package render + +import "net/http" + +// NoContent /** +func NoContent(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNoContent) +} diff --git a/app/request/auth.go b/app/request/auth.go new file mode 100644 index 000000000..81fc324d5 --- /dev/null +++ b/app/request/auth.go @@ -0,0 +1,39 @@ +package request + +import ( + "encoding/json" + "github.com/go-playground/validator/v10" + "github.com/huuthuan-nguyen/manabie/app/model" + "net/http" +) + +type Credentials struct { + Email string `json:"email" validate:"required"` + Password string `json:"password" validate:"required"` +} + +// Validate /** +func (credentials *Credentials) Validate(r *http.Request) error { + if validate, ok := r.Context().Value("validate").(*validator.Validate); ok { + return validate.Struct(credentials) + } + + return nil +} + +// Bind /** +func (credentials *Credentials) Bind(r *http.Request, userModel *model.User) error { + defer r.Body.Close() + + if err := json.NewDecoder(r.Body).Decode(credentials); err != nil { + return err + } + + if err := credentials.Validate(r); err != nil { + return err + } + + userModel.Email = credentials.Email + userModel.Password = credentials.Password + return nil +} diff --git a/app/request/task.go b/app/request/task.go new file mode 100644 index 000000000..99a2d0baa --- /dev/null +++ b/app/request/task.go @@ -0,0 +1,39 @@ +package request + +import ( + "encoding/json" + "github.com/go-playground/validator/v10" + "github.com/huuthuan-nguyen/manabie/app/model" + "net/http" +) + +type Task struct { + Content string `json:"content" validate:"required"` + Status int `json:"status" validate:"omitempty"` +} + +// Validate /** +func (task *Task) Validate(r *http.Request) error { + if validate, ok := r.Context().Value("validate").(*validator.Validate); ok { + return validate.Struct(task) + } + + return nil +} + +// Bind /** +func (task *Task) Bind(r *http.Request, taskModel *model.Task) error { + defer r.Body.Close() + + if err := json.NewDecoder(r.Body).Decode(task); err != nil { + return err + } + + if err := task.Validate(r); err != nil { + return err + } + + taskModel.Content = task.Content + taskModel.Status = task.Status + return nil +} diff --git a/app/router/api.go b/app/router/api.go new file mode 100644 index 000000000..87776753d --- /dev/null +++ b/app/router/api.go @@ -0,0 +1,30 @@ +package router + +import ( + "github.com/gorilla/mux" + "github.com/huuthuan-nguyen/manabie/app/handler" + "github.com/huuthuan-nguyen/manabie/app/middleware" + "github.com/huuthuan-nguyen/manabie/config" + "net/http" +) + +func SetAPIRoutes(router *mux.Router, config *config.Config, handler *handler.Handler) *mux.Router { + apiRouter := router.PathPrefix("/api").Subrouter() + + // authentication + apiRouter.HandleFunc("/register", handler.UserRegister).Methods(http.MethodPost, http.MethodOptions) + apiRouter.HandleFunc("/auth/login", handler.UserLogin).Methods(http.MethodPost, http.MethodOptions) + + // tasks + productRouter := apiRouter.PathPrefix("/").Subrouter() + productRouter.HandleFunc("/tasks", handler.TaskStore).Methods(http.MethodPost, http.MethodOptions) + productRouter.HandleFunc("/tasks/{id:[0-9]+}", handler.TaskUpdate).Methods(http.MethodPut, http.MethodOptions) + productRouter.HandleFunc("/tasks/{id:[0-9]+}", handler.TaskDestroy).Methods(http.MethodDelete, http.MethodOptions) + productRouter.HandleFunc("/tasks", handler.TaskIndex).Methods(http.MethodGet, http.MethodOptions) + productRouter.HandleFunc("/tasks/{id:[0-9]+}", handler.TaskShow).Methods(http.MethodGet, http.MethodOptions) + + jwtAuthenticationMiddleware := middleware.JWTAuthenticateMiddleware(config, handler.GetDB()) + productRouter.Use(jwtAuthenticationMiddleware) + + return router +} diff --git a/app/router/router.go b/app/router/router.go new file mode 100644 index 000000000..505d76308 --- /dev/null +++ b/app/router/router.go @@ -0,0 +1,35 @@ +package router + +import ( + "github.com/gorilla/handlers" + "github.com/gorilla/mux" + "github.com/huuthuan-nguyen/manabie/app/handler" + "github.com/huuthuan-nguyen/manabie/app/middleware" + "github.com/huuthuan-nguyen/manabie/config" +) + +func NewRouter(config *config.Config, handler *handler.Handler) *mux.Router { + router := mux.NewRouter().StrictSlash(false) + + // Routes for api + router = SetAPIRoutes(router, config, handler) + + recoveryMiddleware := middleware.RecoveringMiddleware() + loggingMiddleware := middleware.LoggingMiddleware() + compressMiddleware := handlers.CompressHandler + rateLimitingMiddleware := middleware.RateLimitingMiddleware() + corsMiddleware := middleware.CORSMiddleware() + prometheusMiddleware := middleware.PrometheusMiddleware() + validateMiddleware := middleware.ValidateMiddleware() + router.Use( + compressMiddleware, + loggingMiddleware, + recoveryMiddleware, + prometheusMiddleware, + corsMiddleware, + rateLimitingMiddleware, + validateMiddleware, + ) + + return router +} diff --git a/app/transformer/resource.go b/app/transformer/resource.go new file mode 100644 index 000000000..f889f79fa --- /dev/null +++ b/app/transformer/resource.go @@ -0,0 +1,125 @@ +package transformer + +import ( + "errors" + "github.com/huuthuan-nguyen/manabie/app/utils" + "reflect" +) + +type Resource struct { + Transformer Transformer +} + +type Resourcer interface { + GetTransformer() any + SetTransformer(transformer Transformer) +} + +func (r *Resource) GetTransformer() any { + return r.Transformer +} + +func (r *Resource) SetTransformer(transformer Transformer) { + r.Transformer = transformer +} + +type Item struct { + Resource + Data any +} + +type Collection struct { + Resource + Total int + Page int + ItemsPerPage int + Data []any +} + +type Error struct { + Resource + Data any +} + +func (i *Item) GetData() any { + return i.Data +} + +func (i *Item) SetData(data any) { + i.Data = data +} + +func (c *Collection) GetData() []any { + return c.Data +} + +func (c *Collection) SetData(data []any) { + c.Data = data +} + +func (c *Collection) GetTotal() int { + return c.Total +} + +func (c *Collection) SetTotal(total int) { + c.Total = total +} + +func (c *Collection) GetPage() int { + return c.Page +} + +func (c *Collection) SetPage(page int) { + c.Page = page +} + +func (c *Collection) GetItemsPerPage() int { + return c.ItemsPerPage +} + +func (c *Collection) SetItemsPerPage(itemsPerPage int) { + c.ItemsPerPage = itemsPerPage +} + +func (e *Error) GetData() any { + return e.Data +} + +func (e *Error) SetData(data any) { + e.Data = data +} + +// NewItem /** +func NewItem(data any, transformer Transformer) Item { + i := Item{} + i.SetData(data) + i.SetTransformer(transformer) + return i +} + +// NewCollection /** +func NewCollection(data any, transformer Transformer) Collection { + c := Collection{} + var r = make([]any, 0) + switch reflect.TypeOf(data).Kind() { + case reflect.Slice, reflect.Array: + s := reflect.ValueOf(data) + for i := 0; i < s.Len(); i++ { + e := s.Index(i).Interface() + r = append(r, e) + } + default: + utils.PanicInternalServerError(errors.New("can not reflect value type")) + } + c.SetData(r) + c.SetTransformer(transformer) + return c +} + +// NewError /** +func NewError(data any) Error { + e := Error{} + e.SetData(data) + e.SetTransformer(nil) + return e +} diff --git a/app/transformer/serializer.go b/app/transformer/serializer.go new file mode 100644 index 000000000..cca9479c6 --- /dev/null +++ b/app/transformer/serializer.go @@ -0,0 +1,90 @@ +package transformer + +import "math" + +type Serializer interface { + SerializeItem(resource Item) any + SerializeCollection(resource Collection) any + SerializeError(resource Error) any + SerializeNil() any +} + +// NewJSONSerializer /** +func NewJSONSerializer() *JSONSerializer { + return &JSONSerializer{ + Status: 1, + Messages: []string{"Successful."}, + Data: nil, + } +} + +type JSONSerializer struct { + Status int `json:"status"` + Messages []string `json:"messages"` + Data any `json:"data"` +} + +type CollectionJson struct { + Items []any `json:"items"` + Pagination PaginationJson `json:"-"` +} + +type PaginationJson struct { + Current int `json:"current"` + Prev int `json:"prev"` + Next int `json:"next"` + TotalItems int `json:"total_items"` + First int `json:"first"` + Last int `json:"last"` + ItemsPerPage int `json:"items_per_page"` +} + +func (j JSONSerializer) SerializeItem(resource Item) interface{} { + formattedItem := resource.Transformer.Transform(resource.Data) + j.Data = formattedItem + + return j +} + +func (j JSONSerializer) SerializeCollection(resource Collection) interface{} { + var formattedCollection = make([]any, 0) + for _, v := range resource.GetData() { + formattedCollection = append(formattedCollection, resource.Transformer.Transform(v)) + } + + collection := CollectionJson{} + total := resource.GetTotal() // total + page := resource.GetPage() // page from request + itemsPerPage := resource.GetItemsPerPage() // items_per_page from request + last := 1 // last default is 1 + if itemsPerPage != 0 { + last = int(math.Ceil(float64(total / itemsPerPage))) // calculate the last + } else { + itemsPerPage = total + } + collection.Items = formattedCollection + collection.Pagination = PaginationJson{ + Current: page, + Prev: 1, + Next: 1, + First: 1, + Last: last, + TotalItems: total, + ItemsPerPage: itemsPerPage, + } + + j.Data = collection + + return j +} + +func (j JSONSerializer) SerializeNil() any { + return j +} + +func (j JSONSerializer) SerializeError(resource Error) any { + payload := resource.GetData() + j.Status = 0 // default 0 for all error + j.Data = payload + return j +} diff --git a/app/transformer/task.go b/app/transformer/task.go new file mode 100644 index 000000000..eec6d9bed --- /dev/null +++ b/app/transformer/task.go @@ -0,0 +1,33 @@ +package transformer + +import ( + "github.com/huuthuan-nguyen/manabie/app/model" + "time" +) + +type TaskTransformer struct { + ID int `json:"id"` + Content string `json:"content"` + PublishedDate string `json:"published_date"` + Status int `json:"status"` + CreatedBy int `json:"created_by"` + CreateAt time.Time `json:"create_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Transform /** +func (task *TaskTransformer) Transform(e any) any { + taskModel, ok := e.(model.Task) + if !ok { + return e + } + + task.ID = taskModel.ID + task.Content = taskModel.Content + task.PublishedDate = taskModel.PublishedDate.Format("2006-01-02") + task.Status = taskModel.Status + task.CreatedBy = taskModel.CreatedBy + task.CreateAt = taskModel.CreatedAt + task.UpdatedAt = taskModel.UpdatedAt + return *task +} diff --git a/app/transformer/token.go b/app/transformer/token.go new file mode 100644 index 000000000..0be95ae23 --- /dev/null +++ b/app/transformer/token.go @@ -0,0 +1,23 @@ +package transformer + +import ( + "github.com/huuthuan-nguyen/manabie/app/model" + "time" +) + +type TokenTransformer struct { + AccessToken string `json:"access_token"` + ExpiredAt time.Time `json:"expired_at"` +} + +// Transform /** +func (token *TokenTransformer) Transform(e any) any { + tokenModel, ok := e.(model.Token) + if !ok { + return e + } + + token.AccessToken = tokenModel.AccessToken + token.ExpiredAt = tokenModel.ExpiredAt + return *token +} diff --git a/app/transformer/transformer.go b/app/transformer/transformer.go new file mode 100644 index 000000000..7e04e5f68 --- /dev/null +++ b/app/transformer/transformer.go @@ -0,0 +1,35 @@ +package transformer + +type Transformer interface { + Transform(model any) any +} + +type Manager struct { + Serializer Serializer +} + +// NewManager /** +func NewManager() *Manager { + return &Manager{ + Serializer: NewJSONSerializer(), + } +} + +// CreateData /** +func (m *Manager) CreateData(data any) any { + return m.serializeResource(data) +} + +// SerializeResource /** +func (m *Manager) serializeResource(resource any) any { + switch v := resource.(type) { + case Collection: + return m.Serializer.SerializeCollection(v) + case Item: + return m.Serializer.SerializeItem(v) + case Error: + return m.Serializer.SerializeError(v) + default: + return m.Serializer.SerializeNil() + } +} diff --git a/app/transformer/user.go b/app/transformer/user.go new file mode 100644 index 000000000..cccf6aecc --- /dev/null +++ b/app/transformer/user.go @@ -0,0 +1,31 @@ +package transformer + +import ( + "github.com/huuthuan-nguyen/manabie/app/model" + "time" +) + +type UserTransformer struct { + ID int `json:"id"` + Email string `json:"email"` + IsActive bool `json:"is_active"` + DailyLimit int `json:"daily_limit"` + CreateAt time.Time `json:"create_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Transform /** +func (user *UserTransformer) Transform(e any) any { + userModel, ok := e.(model.User) + if !ok { + return e + } + + user.ID = userModel.ID + user.Email = userModel.Email + user.IsActive = userModel.IsActive + user.DailyLimit = userModel.DailyLimit + user.CreateAt = userModel.CreatedAt + user.UpdatedAt = userModel.UpdatedAt + return *user +} diff --git a/app/utils/common.go b/app/utils/common.go new file mode 100644 index 000000000..945d91190 --- /dev/null +++ b/app/utils/common.go @@ -0,0 +1,24 @@ +package utils + +import ( + "github.com/huuthuan-nguyen/manabie/config" + "log" +) + +// ReadConfig /** +func ReadConfig() *config.Config { + c := &config.Config{} + // get the config path from command arguments + configPath, err := config.ParseConfigFlags() + if err != nil { + log.Printf("Config File path is can not be loaded: %v\n", err) + } + + // parse config from yml + err = config.ReadENVFile(c, configPath) + if err != nil { + log.Fatalf("Fail to load config from env: %v", err) + } + + return c +} diff --git a/app/utils/error.go b/app/utils/error.go new file mode 100644 index 000000000..bb5e1b585 --- /dev/null +++ b/app/utils/error.go @@ -0,0 +1,51 @@ +package utils + +import "net/http" + +type Error struct { + StatusCode int + Message string +} + +// Custom error +func (e *Error) Error() string { + return e.Message +} + +// NewError /** +func NewError(c int, s string) Error { + return Error{c, s} +} + +// PanicInternalServerError /** +func PanicInternalServerError(err ...error) { + message := "Something went wrong." + if len(err) > 0 { + message = err[0].Error() + } + panic(NewError(http.StatusInternalServerError, message)) +} + +// PanicBadRequest /** +func PanicBadRequest(err ...error) { + message := "Bad request." + if len(err) > 0 { + message = err[0].Error() + } + panic(NewError(http.StatusBadRequest, message)) +} + +// PanicTooManyRequests /** +func PanicTooManyRequests() { + panic(NewError(http.StatusTooManyRequests, "Too many requests.")) +} + +// PanicNotFound /** +func PanicNotFound() { + panic(NewError(http.StatusNotFound, "Not found.")) +} + +// PanicUnauthorized /** +func PanicUnauthorized() { + panic(NewError(http.StatusUnauthorized, "Unauthorized.")) +} diff --git a/app/validator/date_validation.go b/app/validator/date_validation.go new file mode 100644 index 000000000..e39b50726 --- /dev/null +++ b/app/validator/date_validation.go @@ -0,0 +1,15 @@ +package validator + +import ( + "github.com/go-playground/validator/v10" + "time" +) + +// DateValidation /** +func DateValidation(fl validator.FieldLevel) bool { + _, err := time.Parse("2006-01-02", fl.Field().String()) + if err != nil { + return false + } + return true +} diff --git a/app/validator/validator.go b/app/validator/validator.go new file mode 100644 index 000000000..c05fe48e2 --- /dev/null +++ b/app/validator/validator.go @@ -0,0 +1,109 @@ +package validator + +import ( + "github.com/go-playground/locales/en" + ut "github.com/go-playground/universal-translator" + "github.com/go-playground/validator/v10" + enTranslations "github.com/go-playground/validator/v10/translations/en" + "reflect" + "strings" +) + +var ( + translator ut.Translator + translations = map[string]string{ + "required_with": "{0} is required with {1}", + } + validations = map[string]func(fl validator.FieldLevel) bool{ + "date": DateValidation, + } +) + +// NewValidator /** +func NewValidator() (*validator.Validate, error) { + + v := validator.New() + // name func registration + registerTagNameFunc(v) + // translations registration + if err := registerTranslations(v); err != nil { + return nil, err + } + // validations registration + if err := registerValidations(v); err != nil { + return nil, err + } + + return v, nil +} + +// Translate /** +func Translate(err error) map[string][]string { + result := make(map[string][]string) + + errs := err.(validator.ValidationErrors) + + for _, err := range errs { + result[err.Field()] = append(result[err.Field()], err.Translate(translator)) + } + + return result +} + +// registerTranslations /** +func registerTranslations(v *validator.Validate) error { + + // default local is english + enLocal := en.New() + uni := ut.New(enLocal) + translator, _ = uni.GetTranslator("en") + // register translations + if err := enTranslations.RegisterDefaultTranslations(v, translator); err != nil { + return err + } + + // register custom translations + for tag, message := range translations { + registerFn := func(ut ut.Translator) error { + return ut.Add(tag, message, false) + } + translateFn := func(ut ut.Translator, fe validator.FieldError) string { + param := fe.Param() + tag := fe.Tag() + t, err := ut.T(tag, fe.Field(), param) + if err != nil { + return fe.(error).Error() + } + return t + } + if err := v.RegisterTranslation(tag, translator, registerFn, translateFn); err != nil { + return err + } + } + + return nil +} + +// registerTagNameFunc /** +func registerTagNameFunc(v *validator.Validate) { + // register tag name func + v.RegisterTagNameFunc(func(fld reflect.StructField) string { + name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0] + if name == "-" { + return "" + } + return name + }) +} + +// registerValidations /** +func registerValidations(v *validator.Validate) error { + // register custom validations + for tag, customFunc := range validations { + if err := v.RegisterValidation(tag, customFunc); err != nil { + return err + } + } + + return nil +} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 000000000..496ad7a71 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "github.com/huuthuan-nguyen/manabie/app" + "github.com/huuthuan-nguyen/manabie/app/utils" +) + +func main() { + config := utils.ReadConfig() // read config from env + application := app.NewApp(config) + application.Run() +} diff --git a/cmd/migrate.go b/cmd/migrate.go new file mode 100644 index 000000000..b0d1f81d9 --- /dev/null +++ b/cmd/migrate.go @@ -0,0 +1,31 @@ +package main + +import ( + "flag" + "github.com/huuthuan-nguyen/manabie/app/utils" + "github.com/huuthuan-nguyen/manabie/migration" + "log" +) + +var rollback = flag.Bool("rollback", false, "Rollback to previous migration.") +var reset = flag.Bool("reset", false, "Reset the database and run all migrations.") + +func main() { + flag.Parse() + var err error + + config := utils.ReadConfig() + engine := migration.NewEngine(config) + // if rollback + if *rollback { + err = engine.Rollback() + } else if *reset { + err = engine.Reset() + } else { // default is migrate + err = engine.Migrate() + } + + if err != nil { + log.Fatalf("Error migrating database:%v\n", err) + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 000000000..3b91402af --- /dev/null +++ b/config/config.go @@ -0,0 +1,118 @@ +package config + +import ( + "flag" + "fmt" + "github.com/joho/godotenv" + "os" + "reflect" + "strconv" +) + +// Config server configuration +type Config struct { + Server struct { + // Host is the local machine IP Address to bind the HTTP Server to + Host string `yaml:"host" env:"APP_HOST"` + // Port is the local machine TCP Port to bind the HTTP Server to + Port string `yaml:"port" env:"APP_PORT"` + // Secret for JWT + Secret string `yaml:"secret" env:"APP_SECRET"` + } + Database struct { + Host string `env:"DB_HOST"` // DB Host + Port string `env:"DB_PORT"` // DB Port + Name string `env:"DB_NAME"` // DB Name + Username string `env:"DB_USERNAME"` // DB UserName + Password string `env:"DB_PASSWORD"` // DB Password + } +} + +// ReadENVFile /** +func ReadENVFile(c *Config, configPath string) error { + if len(configPath) > 0 { + err := godotenv.Load() + if err != nil { + return err + } + } + + Decode(c) + return nil +} + +// ValidateConfigPath /** +func ValidateConfigPath(configPath string) error { + s, err := os.Stat(configPath) + if err != nil { + return err + } + + if s.IsDir() { + return fmt.Errorf("'%s' is a directory, not a file", configPath) + } + + return nil +} + +// ParseConfigFlags /** +func ParseConfigFlags() (configPath string, err error) { + + flag.StringVar(&configPath, "config", ".env", "Path to the config file") + + flag.Parse() + // parse the flags + if err := ValidateConfigPath(configPath); err != nil { + return "", err + } + + // return the config path + return configPath, nil +} + +// Decode /** +func Decode(i interface{}) { + var t reflect.Type + var v reflect.Value + var ok bool + + if v, ok = i.(reflect.Value); !ok { + v = reflect.ValueOf(i).Elem() + } + + t = v.Type() + + for i := 0; i < v.NumField(); i++ { + if v.CanInterface() { + if v.Field(i).Kind() != reflect.Struct { + if eTag, ok := t.Field(i).Tag.Lookup("env"); ok { + var eRVal interface{} + eString, exist := os.LookupEnv(eTag) // get string value from environment + // try to cast to real value + if t.Field(i).Type.String() == "int" { + if v, err := strconv.Atoi(eString); err == nil { + eRVal = v + } else { + if exist { + panic(eTag + " must be int type") + } else { + eRVal = 0 + } + } + } else { + eRVal = eString + } + + eVal := reflect.ValueOf(eRVal) + if eVal.IsValid() { + v.Field(i).Set(eVal.Convert(t.Field(i).Type)) + } + } + } else if v.Field(i).Kind() == reflect.Struct { + Decode(v.Field(i)) + } else { + panic("can not reflect value:" + v.Field(i).Kind().String()) + } + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..e1f5060d1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +version: '3.5' + +services: + # App Service + app: + # Configuration for building the docker image for the service + build: + context: ./ # Use an image built from the specified dockerfile in the current directory. + dockerfile: Dockerfile + ports: + - "8080:8080" # Forward the exposed port 8080 on the container to port 8080 on the host machine + depends_on: + - db + restart: unless-stopped + networks: # Networks to join (Services on the same network can communicate with each other using their name) + - manabie + environment: + APP_HOST: 0.0.0.0 + APP_PORT: 8080 + APP_SECRET: 8c6c19688d9f94b3900042923aa94a99 + DB_HOST: db + DB_PORT: 5432 + DB_NAME: manabie + DB_USERNAME: manabie + DB_PASSWORD: 12345 + db: + image: postgres:14.2 + environment: + POSTGRES_USER: manabie + POSTGRES_PASSWORD: 12345 + POSTGRES_DB: manabie + PGDATA: /data/postgres + volumes: + - db:/data/postgres + ports: + - "5432:5432" + networks: + - manabie # Join manabie network + restart: unless-stopped + +networks: + manabie: + driver: bridge + +volumes: + db: + driver: local \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..7f3690ea8 --- /dev/null +++ b/go.mod @@ -0,0 +1,39 @@ +module github.com/huuthuan-nguyen/manabie + +go 1.18 + +require ( + github.com/go-playground/locales v0.14.0 + github.com/go-playground/universal-translator v0.18.0 + github.com/go-playground/validator/v10 v10.11.0 + github.com/golang-jwt/jwt v3.2.2+incompatible + github.com/gorilla/handlers v1.5.1 + github.com/gorilla/mux v1.8.0 + github.com/joho/godotenv v1.4.0 + github.com/prometheus/client_golang v1.12.2 + github.com/uptrace/bun v1.1.6 + github.com/uptrace/bun/dialect/pgdialect v1.1.6 + github.com/uptrace/bun/driver/pgdriver v1.1.6 + golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa + golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/felixge/httpsnoop v1.0.1 // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/leodido/go-urn v1.2.1 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect + github.com/prometheus/client_model v0.2.0 // indirect + github.com/prometheus/common v0.32.1 // indirect + github.com/prometheus/procfs v0.7.3 // indirect + github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect + github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + golang.org/x/sys v0.0.0-20220708085239-5a0f0661e09d // indirect + golang.org/x/text v0.3.7 // indirect + google.golang.org/protobuf v1.26.0 // indirect + mellium.im/sasl v0.2.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..a72ccc1af --- /dev/null +++ b/go.sum @@ -0,0 +1,527 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= +github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU= +github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= +github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= +github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= +github.com/go-playground/validator/v10 v10.11.0 h1:0W+xRM511GY47Yy3bZUbJVitCNg2BOGlCyvTqsp/xIw= +github.com/go-playground/validator/v10 v10.11.0/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= +github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= +github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_golang v1.12.2 h1:51L9cDoUHVrXx4zWYlcLQIZ+d+VXHgqnYKkIuq4g/34= +github.com/prometheus/client_golang v1.12.2/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4= +github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= +github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= +github.com/uptrace/bun v1.1.6 h1:vDJ1Qs6fXock5+q/PSOZZ7vZVZABmWkGlgZDUkJwbfc= +github.com/uptrace/bun v1.1.6/go.mod h1:Z2Pd3cRvNKbrYuL6Gp1XGjA9QEYz+rDz5KkEi9MZLnQ= +github.com/uptrace/bun/dialect/pgdialect v1.1.6 h1:mDGOPm9FkWaM2kQLddzcRW6juUdITJ1XPUr1vt2anYw= +github.com/uptrace/bun/dialect/pgdialect v1.1.6/go.mod h1:vSyJq5nr4EP/QcVZEOctDnbHQ4OKebZbXGRd1bTBIFI= +github.com/uptrace/bun/driver/pgdriver v1.1.6 h1:t5fzjXasWt3uwXS5h5m0BhfdJEZp1Z9rzTgIO3mnq80= +github.com/uptrace/bun/driver/pgdriver v1.1.6/go.mod h1:Qb/QgyqAWYQPGk/o6eA6A8EIkOyl0c007NK8//wKJGw= +github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= +github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20180910181607-0e37d006457b/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220708085239-5a0f0661e09d h1:/m5NbqQelATgoSPVC2Z23sR4kVNokFwDDyWh/3rGY+I= +golang.org/x/sys v0.0.0-20220708085239-5a0f0661e09d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 h1:ftMN5LMiBFjbzleLqtoBZk7KdJwhuybIU+FckUHgoyQ= +golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +mellium.im/sasl v0.2.1 h1:nspKSRg7/SyO0cRGY71OkfHab8tf9kCts6a6oTDut0w= +mellium.im/sasl v0.2.1/go.mod h1:ROaEDLQNuf9vjKqE1SrAfnsobm2YKXT1gnN1uDp1PjQ= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/migration/20220724000001_init_schema.go b/migration/20220724000001_init_schema.go new file mode 100644 index 000000000..2252a4edb --- /dev/null +++ b/migration/20220724000001_init_schema.go @@ -0,0 +1,66 @@ +package migration + +import ( + "context" + "github.com/huuthuan-nguyen/manabie/app/model" + "github.com/uptrace/bun" +) + +type InitSchema struct { + Migration + Version string +} + +func (m *InitSchema) Up() (err error) { + db := m.GetDB() + ctx := context.Background() + return model.WithTransaction(ctx, db, func(tx bun.Tx) error { + + // create "users" table + const users = `CREATE TABLE IF NOT EXISTS users( + id SERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password TEXT NOT NULL, + is_active BOOLEAN DEFAULT false NOT NULL, + daily_limit INT NOT NULL DEFAULT 3, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + )` + if _, err = tx.Exec(users); err != nil { + return err + } + + // create "tasks" table + const tasks = `CREATE TABLE IF NOT EXISTS tasks( + id SERIAL PRIMARY KEY, + content TEXT NOT NULL, + published_date DATE NOT NULL, + status SMALLINT NOT NULL DEFAULT 0, + created_by INT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_owner + FOREIGN KEY(created_by) + REFERENCES users(id) + ON DELETE RESTRICT + )` + if _, err = tx.Exec(tasks); err != nil { + return err + } + + return nil + }) +} + +func (m *InitSchema) Down() (err error) { + db := m.GetDB() + ctx := context.Background() + return model.WithTransaction(ctx, db, func(tx bun.Tx) error { + _, err := tx.Exec(`DROP TABLE IF EXISTS tasks, users`) + return err + }) +} + +func (m *InitSchema) GetVersion() string { + return "20220724000001" +} diff --git a/migration/migration.go b/migration/migration.go new file mode 100644 index 000000000..b7d88b76c --- /dev/null +++ b/migration/migration.go @@ -0,0 +1,270 @@ +package migration + +import ( + "context" + "fmt" + "github.com/huuthuan-nguyen/manabie/app/model" + "github.com/huuthuan-nguyen/manabie/config" + "github.com/uptrace/bun" + "log" + "strings" +) + +type Migrator interface { + Up() (err error) + Down() (err error) + GetVersion() string + SetDB(db *bun.DB) + GetDB() *bun.DB +} + +var Migrators = []Migrator{ + &InitSchema{}, +} + +// Migration /** +type Migration struct { + bun.BaseModel `bun:"table:migrations,alias:migration"` + ID int `bun:"id,pk,autoincrement"` + Migration string `bun:"migration"` + Batch int `bun:"batch"` + DB *bun.DB `bun:"-"` +} + +// SetDB /** +func (m *Migration) SetDB(db *bun.DB) { + m.DB = db +} + +// GetDB /** +func (m *Migration) GetDB() *bun.DB { + return m.DB +} + +// Engine /** +type Engine struct { + DB *bun.DB +} + +// NewEngine /** +func NewEngine(c *config.Config) *Engine { + const migration = `CREATE TABLE IF NOT EXISTS migrations( + id SERIAL PRIMARY KEY, + migration VARCHAR(255) NOT NULL, + batch INT NOT NULL)` + + db, err := model.NewConnection(c) + if err != nil { + log.Fatalln("Error connecting to database...") + } + + // init the "migration" table + _, err = db.Exec(migration) + + if err != nil { + log.Fatalf("Error while creating migrations table:%v\n", err) + } + + return &Engine{ + DB: db, + } +} + +// Migrate /** +func (engine *Engine) Migrate() (err error) { + lastBatch, err := engine.getLastBatch() + + if err != nil { + return err + } + + successMigration := 0 + + log.Println("Begin migrating...") + + for _, migrator := range Migrators { + migrator.SetDB(engine.DB) + + version := migrator.GetVersion() + + migration, err := engine.getMigrationByVersion(version) + if err != nil { // not migrated yet + migration = Migration{ + Migration: version, + Batch: lastBatch + 1, + DB: engine.DB, + } + } else { // already migrated + continue + } + + // "up" the migration changes + log.Println("Migrating ", migrator.GetVersion()) + err = migrator.Up() + + if err != nil { + log.Printf("Error when migrating %s: %v\n", migrator.GetVersion(), err) + panic(err) + } + + // insert a new record to migration table + err = migration.Create() + if err != nil { + panic(err) + } + successMigration++ + log.Println("Migrated ", migrator.GetVersion()) + } + + if successMigration > 0 { + log.Println("Migrate Done.") + } else { + log.Println("Nothing to migrate.") + } + + return err +} + +// Rollback /** +func (engine *Engine) Rollback() (err error) { + lastBatch, err := engine.getLastBatch() + migrations, err := engine.getMigrationByBatch(lastBatch) + + successRollback := 0 + + log.Println("Begin rolling back...") + for _, m := range migrations { + m.SetDB(engine.DB) + log.Println("Rolling back ", m.Migration) + err = m.Rollback() + if err != nil { + return err + } + err = m.Delete() + if err != nil { + return err + } + log.Println("Rolled back ", m.Migration) + successRollback++ + } + + if successRollback > 0 { + log.Println("Rollback Done.") + } else { + log.Println("Nothing to rollback.") + } + return +} + +/** +Get the latest batch on migration table. +*/ +func (engine *Engine) getLastBatch() (maxBatch int, err error) { + statement := "SELECT COALESCE(MAX(batch), 0) AS b FROM migrations" + err = engine.DB.QueryRow(statement).Scan(&maxBatch) + return maxBatch, err +} + +// Create /** +func (m *Migration) Create() (err error) { + statement := "INSERT INTO migrations(migration, batch) VALUES(?, ?)" + + _, err = m.DB.Exec(statement, m.Migration, m.Batch) + return err +} + +// Delete /** +func (m *Migration) Delete() (err error) { + _, err = m.DB.Exec("DELETE FROM migrations WHERE id=?", m.ID) + return err +} + +// Rollback /** +func (m *Migration) Rollback() (err error) { + for _, migrator := range Migrators { + migrator.SetDB(m.DB) + if m.Migration == migrator.GetVersion() { + err = migrator.Down() + } + } + return err +} + +/** +Get migrations by batch number +*/ +func (engine *Engine) getMigrationByBatch(batch int) (migrations []Migration, err error) { + err = engine.DB.NewSelect(). + Model(&migrations). + Column("id", "migration", "batch"). + Where("batch=?", batch). + Order("id DESC"). + Scan(context.Background()) + return migrations, err +} + +/** +Get specific migration by version +*/ +func (engine *Engine) getMigrationByVersion(version string) (migration Migration, err error) { + err = engine.DB.NewSelect(). + Model(&migration). + Column("id", "migration", "batch"). + Where("migration=?", version). + Scan(context.Background()) + migration.SetDB(engine.DB) + return migration, err +} + +// Reset /** +func (engine *Engine) Reset() (err error) { + ctx := context.Background() + err = model.WithTransaction(ctx, engine.DB, func(tx bun.Tx) error { + log.Println("Begin drop all tables...") + if _, err := tx.ExecContext(ctx, "SET session_replication_role = 'replica'"); err != nil { + return err + } + + rows, err := tx.Query(`SELECT tablename from pg_tables WHERE schemaname = 'public' AND tablename <> 'migrations'`) + if err != nil { + return err + } + + defer rows.Close() + + tableNames := make([]string, 0) + for rows.Next() { + var tableName string + if err := rows.Scan(&tableName); err != nil { + return err + } else { + tableNames = append(tableNames, tableName) + } + } + if len(tableNames) > 0 { + if _, err := tx.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s CASCADE", strings.Join(tableNames, ", "))); err != nil { + return err + } + } + + log.Println("Drop all tables done.") + + // reset the "migrations" table + if _, err := tx.Exec("TRUNCATE migrations"); err != nil { + return err + } + + if _, err := engine.DB.Exec("SET session_replication_role = 'origin'"); err != nil { + return err + } + + return nil + }) + + if err != nil { + return err + } + + err = engine.Migrate() + return err +} diff --git a/test/task_test.go b/test/task_test.go new file mode 100644 index 000000000..e21684450 --- /dev/null +++ b/test/task_test.go @@ -0,0 +1,469 @@ +package test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "github.com/gorilla/mux" + "github.com/huuthuan-nguyen/manabie/app/handler" + "github.com/huuthuan-nguyen/manabie/app/model" + "github.com/huuthuan-nguyen/manabie/app/router" + "github.com/huuthuan-nguyen/manabie/app/utils" + "github.com/uptrace/bun" + "log" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" +) + +var token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Imh1dXRodWFuLm5ndXllbkBob3RtYWlsLmNvbSIsImV4cCI6MTY1ODg0NTYwOX0.2dCHBNe0A1kbAriNUG-vevC71Sj6PKY9qthIh6DAR9Y" +var server *mux.Router +var db bun.IDB + +// metaResponse /** +type metaResponse struct { + Status int `json:"status"` + Messages []string `json:"messages"` +} + +// taskResponse /** +type taskResponse struct { + metaResponse + Data struct { + ID int `json:"id"` + Content string `json:"content"` + PublishedDate string `json:"published_date"` + Status int `json:"status"` + CreatedBy int `json:"created_by"` + CreateAt time.Time `json:"create_at"` + UpdatedAt time.Time `json:"updated_at"` + } `json:"data"` +} + +// listResponse /** +type listResponse struct { + metaResponse + Data struct { + Items []struct { + ID int `json:"id"` + Content string `json:"content"` + PublishedDate string `json:"published_date"` + Status int `json:"status"` + CreatedBy int `json:"created_by"` + CreateAt time.Time `json:"create_at"` + UpdatedAt time.Time `json:"updated_at"` + } `json:"items"` + } `json:"data"` +} + +func truncateDB(ctx context.Context) error { + _, err := db.ExecContext(ctx, "TRUNCATE TABLE users CASCADE") + return err +} + +func truncateTaskRecords(ctx context.Context) error { + _, err := db.ExecContext(ctx, "TRUNCATE TABLE tasks") + return err +} + +func populateTasks(ctx context.Context) error { + tasks := []struct { + id int + content string + publishedDate time.Time + status int + createdBy int + createdAt time.Time + updatedAt time.Time + }{ + { + id: 1, + content: "Do homework", + publishedDate: time.Now().UTC(), + status: 0, + createdBy: 1, + createdAt: time.Now().UTC(), + updatedAt: time.Now().UTC(), + }, + { + id: 2, + content: "Do laundry", + publishedDate: time.Now().UTC(), + status: 0, + createdBy: 1, + createdAt: time.Now().UTC(), + updatedAt: time.Now().UTC(), + }, + { + id: 3, + content: "Do housework", + publishedDate: time.Now().UTC(), + status: 0, + createdBy: 1, + createdAt: time.Now().UTC(), + updatedAt: time.Now().UTC(), + }, + } + for _, task := range tasks { + if _, err := db.ExecContext(ctx, "INSERT INTO tasks(id, content, published_date, status, created_by, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?)", + task.id, + task.content, + task.publishedDate, + task.status, + task.createdBy, + task.createdAt, + task.updatedAt, + ); err != nil { + return err + } + } + return nil +} + +func initUser(ctx context.Context) error { + password, err := model.HashPassword("123@pass!word") + if err != nil { + return err + } + _, err = db.ExecContext(ctx, + "INSERT INTO users(id, email, password, is_active, daily_limit, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?)", + 1, + "huuthuan.nguyen@hotmail.com", + password, + true, + 3, + time.Now().UTC(), + time.Now().UTC(), + ) + return err +} + +func TestMain(m *testing.M) { // life cycle testing + + config := utils.ReadConfig() // read config from env + c := context.Background() + h := handler.New(c, config) + server = router.NewRouter(config, h) + + ctx := context.Background() + db = h.GetDB() + // truncate + if err := truncateDB(ctx); err != nil { + log.Printf("truncate DB failed:%v\n", err) + return + } + // init user + if err := initUser(ctx); err != nil { + log.Printf("init user failed:%v\n", err) + return + } + + code := m.Run() + os.Exit(code) +} + +// TestCreateTask /** +func TestCreateTask(t *testing.T) { + + ctx := context.Background() + if err := truncateTaskRecords(ctx); err != nil { + t.Errorf("Unable to truncate task records\n") + } + + var jsonStr = []byte(`{"content":"Do homework"}`) + request, _ := http.NewRequest("POST", "/api/tasks", bytes.NewBuffer(jsonStr)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + responseRecorder := httptest.NewRecorder() + server.ServeHTTP(responseRecorder, request) + + // assert status code + if http.StatusOK != responseRecorder.Code { + t.Errorf("Expected response code %d. Got %d\n", http.StatusOK, responseRecorder.Code) + } + + var data taskResponse + err := json.Unmarshal(responseRecorder.Body.Bytes(), &data) + if err != nil { + t.Errorf("Unable to unmarshal json response\n") + } + + // assert Status on json body + if data.Status != 1 { + t.Errorf("Expected status is '%d'. Got '%d'\n", 1, data.Status) + } + + // assert ID on json body + if data.Data.ID == 0 { + t.Errorf("Expected ID of task is greater than '0'. Got '%v'\n", data.Data.ID) + } + + // assert Content on json body + if data.Data.Content != "Do homework" { + t.Errorf("Expected Content of Task to be 'Do homework'. Got '%v'\n", data.Data.Content) + } +} + +// TestCreateTaskReturnFailWhenUserExceededDailyLimit /** +func TestCreateTaskReturnFailWhenUserExceededDailyLimit(t *testing.T) { + + ctx := context.Background() + if err := truncateTaskRecords(ctx); err != nil { + t.Errorf("Unable to truncate task records\n") + } + + testData := []struct { + input string + statusCode int + }{ + { + input: `{"content":"Do homework"}`, + statusCode: http.StatusOK, + }, + { + input: `{"content":"Do laundry"}`, + statusCode: http.StatusOK, + }, + { + input: `{"content":"Do housework"}`, + statusCode: http.StatusOK, + }, + { + input: `{"content":"Get sleeping"}`, + statusCode: http.StatusBadRequest, + }, + } + + for _, data := range testData { + request, _ := http.NewRequest("POST", "/api/tasks", bytes.NewBuffer([]byte(data.input))) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + responseRecorder := httptest.NewRecorder() + server.ServeHTTP(responseRecorder, request) + + // assert status code + if data.statusCode != responseRecorder.Code { + t.Errorf("Expected response code %d. Got %d\n", data.statusCode, responseRecorder.Code) + } + } +} + +// TestListTasks /** +func TestListTasks(t *testing.T) { + + ctx := context.Background() + // truncate all tasks + if err := truncateTaskRecords(ctx); err != nil { + t.Errorf("Unable to truncate task records\n") + } + // populate test tasks + if err := populateTasks(ctx); err != nil { + t.Errorf("Unable to populate task records\n") + } + + request, _ := http.NewRequest("GET", "/api/tasks", nil) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + responseRecorder := httptest.NewRecorder() + server.ServeHTTP(responseRecorder, request) + + // assert status code + if http.StatusOK != responseRecorder.Code { + t.Errorf("Expected response code %d. Got %d\n", http.StatusOK, responseRecorder.Code) + } + + var data listResponse + err := json.Unmarshal(responseRecorder.Body.Bytes(), &data) + if err != nil { + t.Errorf("Unable to unmarshal json response\n") + } + + // count number of tasks + if len(data.Data.Items) != 3 { + t.Errorf("Expected number of tasks to be '3'. Got '%d'\n", len(data.Data.Items)) + } +} + +// TestListTasks /** +func TestShowSpecificTask(t *testing.T) { + + ctx := context.Background() + // truncate all tasks + if err := truncateTaskRecords(ctx); err != nil { + t.Errorf("Unable to truncate task records\n") + } + // populate test tasks + if err := populateTasks(ctx); err != nil { + t.Errorf("Unable to populate task records\n") + } + + request, _ := http.NewRequest("GET", "/api/tasks/1", nil) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + responseRecorder := httptest.NewRecorder() + server.ServeHTTP(responseRecorder, request) + + // assert status code + if http.StatusOK != responseRecorder.Code { + t.Errorf("Expected response code %d. Got %d\n", http.StatusOK, responseRecorder.Code) + } + + var data taskResponse + err := json.Unmarshal(responseRecorder.Body.Bytes(), &data) + if err != nil { + t.Errorf("Unable to unmarshal json response\n") + } + + // count number of tasks + if data.Data.Content != "Do homework" { + t.Errorf("Expected content of task to be 'Do homework'. Got '%v'\n", data.Data.Content) + } +} + +// TestShowSpecificTaskReturnNotFoundWhenTaskDoesNotExist /** +func TestShowSpecificTaskReturnNotFoundWhenTaskDoesNotExist(t *testing.T) { + + ctx := context.Background() + // truncate all tasks + if err := truncateTaskRecords(ctx); err != nil { + t.Errorf("Unable to truncate task records\n") + } + // populate test tasks + if err := populateTasks(ctx); err != nil { + t.Errorf("Unable to populate task records\n") + } + + request, _ := http.NewRequest("GET", "/api/tasks/4", nil) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + responseRecorder := httptest.NewRecorder() + server.ServeHTTP(responseRecorder, request) + + // assert status code + if http.StatusNotFound != responseRecorder.Code { + t.Errorf("Expected response code %d. Got %d\n", http.StatusOK, responseRecorder.Code) + } +} + +// TestUpdateSpecificTask /** +func TestUpdateSpecificTask(t *testing.T) { + + ctx := context.Background() + // truncate all tasks + if err := truncateTaskRecords(ctx); err != nil { + t.Errorf("Unable to truncate task records\n") + } + // populate test tasks + if err := populateTasks(ctx); err != nil { + t.Errorf("Unable to populate task records\n") + } + + request, _ := http.NewRequest("PUT", "/api/tasks/1", bytes.NewBuffer([]byte(`{"content":"Do homework later"}`))) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + responseRecorder := httptest.NewRecorder() + server.ServeHTTP(responseRecorder, request) + + // assert status code + if http.StatusOK != responseRecorder.Code { + t.Errorf("Expected response code %d. Got %d\n", http.StatusOK, responseRecorder.Code) + } + + var data taskResponse + err := json.Unmarshal(responseRecorder.Body.Bytes(), &data) + if err != nil { + t.Errorf("Unable to unmarshal json response\n") + } + + // count number of tasks + if data.Data.Content != "Do homework later" { + t.Errorf("Expected content of task to be 'Do homework later'. Got '%v'\n", data.Data.Content) + } +} + +// TestUpdateSpecificTaskReturnNotFoundWhenTaskDoesNotExist /** +func TestUpdateSpecificTaskReturnNotFoundWhenTaskDoesNotExist(t *testing.T) { + + ctx := context.Background() + // truncate all tasks + if err := truncateTaskRecords(ctx); err != nil { + t.Errorf("Unable to truncate task records\n") + } + // populate test tasks + if err := populateTasks(ctx); err != nil { + t.Errorf("Unable to populate task records\n") + } + + request, _ := http.NewRequest("PUT", "/api/tasks/4", bytes.NewBuffer([]byte(`{"content":"Do homework later"}`))) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + responseRecorder := httptest.NewRecorder() + server.ServeHTTP(responseRecorder, request) + + // assert status code + if http.StatusNotFound != responseRecorder.Code { + t.Errorf("Expected response code %d. Got %d\n", http.StatusOK, responseRecorder.Code) + } +} + +// TestDeleteSpecificTask /** +func TestDeleteSpecificTask(t *testing.T) { + + ctx := context.Background() + // truncate all tasks + if err := truncateTaskRecords(ctx); err != nil { + t.Errorf("Unable to truncate task records\n") + } + // populate test tasks + if err := populateTasks(ctx); err != nil { + t.Errorf("Unable to populate task records\n") + } + + request, _ := http.NewRequest("DELETE", "/api/tasks/1", bytes.NewBuffer([]byte(`{"content":"Do homework later"}`))) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + responseRecorder := httptest.NewRecorder() + server.ServeHTTP(responseRecorder, request) + + // assert status code + if http.StatusNoContent != responseRecorder.Code { + t.Errorf("Expected response code %d. Got %d\n", http.StatusOK, responseRecorder.Code) + } +} + +// TestDeleteSpecificTaskReturnNotFoundWhenTaskDoesNotExist /** +func TestDeleteSpecificTaskReturnNotFoundWhenTaskDoesNotExist(t *testing.T) { + + ctx := context.Background() + // truncate all tasks + if err := truncateTaskRecords(ctx); err != nil { + t.Errorf("Unable to truncate task records\n") + } + // populate test tasks + if err := populateTasks(ctx); err != nil { + t.Errorf("Unable to populate task records\n") + } + + request, _ := http.NewRequest("DELETE", "/api/tasks/4", nil) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + responseRecorder := httptest.NewRecorder() + server.ServeHTTP(responseRecorder, request) + + // assert status code + if http.StatusNotFound != responseRecorder.Code { + t.Errorf("Expected response code %d. Got %d\n", http.StatusOK, responseRecorder.Code) + } +}