├── solution_example └── account-management-service │ ├── internal │ ├── entity │ │ ├── product.go │ │ ├── account.go │ │ ├── user.go │ │ ├── reservation.go │ │ └── operation.go │ ├── repo │ │ ├── repoerrs │ │ │ └── errors.go │ │ ├── pgdb │ │ │ ├── product.go │ │ │ ├── user.go │ │ │ ├── operation.go │ │ │ ├── account.go │ │ │ ├── reservation.go │ │ │ └── user_test.go │ │ └── repo.go │ ├── webapi │ │ ├── webapi.go │ │ └── gdrive │ │ │ └── gdrive.go │ ├── app │ │ ├── logger.go │ │ ├── migrate.go │ │ └── app.go │ ├── controller │ │ └── http │ │ │ └── v1 │ │ │ ├── error.go │ │ │ ├── middleware.go │ │ │ ├── router.go │ │ │ ├── product.go │ │ │ ├── auth.go │ │ │ ├── operation.go │ │ │ ├── reservation.go │ │ │ ├── account.go │ │ │ └── auth_test.go │ └── service │ │ ├── product.go │ │ ├── errors.go │ │ ├── reservation.go │ │ ├── account.go │ │ ├── operation.go │ │ ├── auth.go │ │ ├── service.go │ │ └── operation_test.go │ ├── cmd │ └── app │ │ └── main.go │ ├── config │ ├── config.yaml │ └── config.go │ ├── migrations │ ├── 20221018151330_account_management.down.sql │ └── 20221018151330_account_management.up.sql │ ├── pkg │ ├── postgres │ │ ├── options.go │ │ └── postgres.go │ ├── hasher │ │ └── password.go │ ├── httpserver │ │ ├── options.go │ │ └── server.go │ └── validator │ │ └── custom.go │ ├── .env.example │ ├── docker-compose.yaml │ ├── Dockerfile │ ├── Makefile │ ├── go.mod │ ├── README.md │ └── docs │ ├── swagger.yaml │ └── swagger.json └── README.md /solution_example/account-management-service/internal/entity/product.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | type Product struct { 4 | Id int `db:"id"` 5 | Name string `db:"name"` 6 | } 7 | -------------------------------------------------------------------------------- /solution_example/account-management-service/cmd/app/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "account-management-service/internal/app" 5 | ) 6 | 7 | const configPath = "config/config.yaml" 8 | 9 | func main() { 10 | app.Run(configPath) 11 | } 12 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/entity/account.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import "time" 4 | 5 | type Account struct { 6 | Id int `db:"id"` 7 | Balance int `db:"balance"` 8 | CreatedAt time.Time `db:"created_at"` 9 | } 10 | -------------------------------------------------------------------------------- /solution_example/account-management-service/config/config.yaml: -------------------------------------------------------------------------------- 1 | app: 2 | name: 'blog-backend' 3 | version: '1.0.0' 4 | 5 | http: 6 | port: 8080 7 | 8 | log: 9 | level: 'debug' 10 | 11 | postgres: 12 | max_pool_size: 20 13 | 14 | jwt: 15 | token_ttl: 120m 16 | -------------------------------------------------------------------------------- /solution_example/account-management-service/migrations/20221018151330_account_management.down.sql: -------------------------------------------------------------------------------- 1 | drop table if exists operations; 2 | 3 | drop table if exists reservations; 4 | 5 | drop table if exists products; 6 | 7 | drop table if exists accounts; 8 | 9 | drop table if exists users; 10 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/entity/user.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import "time" 4 | 5 | type User struct { 6 | Id int `db:"id"` 7 | Username string `db:"username"` 8 | Password string `db:"password"` 9 | CreatedAt time.Time `db:"created_at"` 10 | } 11 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/repo/repoerrs/errors.go: -------------------------------------------------------------------------------- 1 | package repoerrs 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrNotFound = errors.New("not found") 7 | ErrAlreadyExists = errors.New("already exists") 8 | 9 | ErrNotEnoughBalance = errors.New("not enough balance") 10 | ) 11 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/entity/reservation.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | type Reservation struct { 4 | Id int `db:"id"` 5 | AccountId int `db:"account_id"` 6 | ProductId int `db:"product_id"` 7 | OrderId int `db:"order_id"` 8 | Amount int `db:"amount"` 9 | CreatedAt int `db:"created_at"` 10 | } 11 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/webapi/webapi.go: -------------------------------------------------------------------------------- 1 | package webapi 2 | 3 | import "context" 4 | 5 | type GDrive interface { 6 | UploadCSVFile(ctx context.Context, name string, data []byte) (string, error) 7 | DeleteFile(ctx context.Context, name string) error 8 | GetAllFilenames(ctx context.Context) ([]string, error) 9 | IsAvailable() bool 10 | } 11 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/app/logger.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/sirupsen/logrus" 5 | "os" 6 | ) 7 | 8 | func SetLogrus(level string) { 9 | logrusLevel, err := logrus.ParseLevel(level) 10 | if err != nil { 11 | logrus.SetLevel(logrus.DebugLevel) 12 | } else { 13 | logrus.SetLevel(logrusLevel) 14 | } 15 | 16 | logrus.SetFormatter(&logrus.JSONFormatter{ 17 | TimestampFormat: "2006-01-02 15:04:05", 18 | }) 19 | 20 | logrus.SetOutput(os.Stdout) 21 | } 22 | -------------------------------------------------------------------------------- /solution_example/account-management-service/pkg/postgres/options.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import "time" 4 | 5 | type Option func(*Postgres) 6 | 7 | func MaxPoolSize(size int) Option { 8 | return func(c *Postgres) { 9 | c.maxPoolSize = size 10 | } 11 | } 12 | 13 | func ConnAttempts(attempts int) Option { 14 | return func(c *Postgres) { 15 | c.connAttempts = attempts 16 | } 17 | } 18 | 19 | func ConnTimeout(timeout time.Duration) Option { 20 | return func(c *Postgres) { 21 | c.connTimeout = timeout 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /solution_example/account-management-service/pkg/hasher/password.go: -------------------------------------------------------------------------------- 1 | package hasher 2 | 3 | import ( 4 | "crypto/sha1" 5 | "fmt" 6 | ) 7 | 8 | type PasswordHasher interface { 9 | Hash(password string) string 10 | } 11 | 12 | type SHA1Hasher struct { 13 | salt string 14 | } 15 | 16 | func NewSHA1Hasher(salt string) *SHA1Hasher { 17 | return &SHA1Hasher{salt: salt} 18 | } 19 | 20 | func (h *SHA1Hasher) Hash(password string) string { 21 | hash := sha1.New() 22 | hash.Write([]byte(password)) 23 | 24 | return fmt.Sprintf("%x", hash.Sum([]byte(h.salt))) 25 | } 26 | -------------------------------------------------------------------------------- /solution_example/account-management-service/.env.example: -------------------------------------------------------------------------------- 1 | # port for http server 2 | HTTP_PORT= 3 | 4 | # postgresql database 5 | POSTGRES_HOST= 6 | POSTGRES_PORT= 7 | POSTGRES_USER= 8 | POSTGRES_PASSWORD= 9 | POSTGRES_DB= 10 | 11 | # url to connect to postgresql database 12 | PG_URL=postgres://{user}:{password}@{host}:{port}/{database} 13 | PG_URL_LOCALHOST=postgres://{user}:{password}@localhost:{port}/{database} 14 | 15 | # secret key for jwt 16 | JWT_SIGN_KEY= 17 | 18 | # secret salt for password hashing 19 | HASHER_SALT= 20 | 21 | # path to Google Drive credentials file 22 | GOOGLE_DRIVE_JSON_FILE_PATH=secrets/your_credentials_file.json 23 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/controller/http/v1/error.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | var ( 10 | ErrInvalidAuthHeader = fmt.Errorf("invalid auth header") 11 | ErrCannotParseToken = fmt.Errorf("cannot parse token") 12 | ) 13 | 14 | func newErrorResponse(c echo.Context, errStatus int, message string) { 15 | err := errors.New(message) 16 | _, ok := err.(*echo.HTTPError) 17 | if !ok { 18 | report := echo.NewHTTPError(errStatus, err.Error()) 19 | _ = c.JSON(errStatus, report) 20 | } 21 | c.Error(errors.New("internal server error")) 22 | } 23 | -------------------------------------------------------------------------------- /solution_example/account-management-service/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | 5 | postgres: 6 | container_name: postgres 7 | image: postgres 8 | volumes: 9 | - pg-data:/var/lib/postgresql/data 10 | env_file: 11 | - .env 12 | ports: 13 | - "5432:5432" 14 | restart: unless-stopped 15 | 16 | app: 17 | container_name: app 18 | build: . 19 | volumes: 20 | - ./logs:/logs 21 | - ./secrets:/secrets 22 | env_file: 23 | - .env 24 | ports: 25 | - "${HTTP_PORT}:${HTTP_PORT}" 26 | depends_on: 27 | - postgres 28 | restart: unless-stopped 29 | 30 | volumes: 31 | pg-data: 32 | -------------------------------------------------------------------------------- /solution_example/account-management-service/Dockerfile: -------------------------------------------------------------------------------- 1 | # Step 1: Modules caching 2 | FROM golang:alpine as modules 3 | COPY go.mod go.sum /modules/ 4 | WORKDIR /modules 5 | RUN go mod download 6 | 7 | # Step 2: Builder 8 | FROM golang:alpine as builder 9 | COPY --from=modules /go/pkg /go/pkg 10 | COPY . /app 11 | WORKDIR /app 12 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ 13 | go build -tags migrate -o /bin/app ./cmd/app 14 | 15 | # Step 3: Final 16 | FROM scratch 17 | COPY --from=builder /app/config /config 18 | COPY --from=builder /app/migrations /migrations 19 | COPY --from=builder /bin/app /app 20 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 21 | CMD ["/app"] 22 | -------------------------------------------------------------------------------- /solution_example/account-management-service/pkg/httpserver/options.go: -------------------------------------------------------------------------------- 1 | package httpserver 2 | 3 | import ( 4 | "net" 5 | "time" 6 | ) 7 | 8 | type Option func(*Server) 9 | 10 | func Port(port string) Option { 11 | return func(s *Server) { 12 | s.server.Addr = net.JoinHostPort("", port) 13 | } 14 | } 15 | 16 | func ReadTimeout(timeout time.Duration) Option { 17 | return func(s *Server) { 18 | s.server.ReadTimeout = timeout 19 | } 20 | } 21 | 22 | func WriteTimeout(timeout time.Duration) Option { 23 | return func(s *Server) { 24 | s.server.WriteTimeout = timeout 25 | } 26 | } 27 | 28 | func ShutdownTimeout(timeout time.Duration) Option { 29 | return func(s *Server) { 30 | s.shutdownTimeout = timeout 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/service/product.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "account-management-service/internal/entity" 5 | "account-management-service/internal/repo" 6 | "context" 7 | ) 8 | 9 | type ProductService struct { 10 | productRepo repo.Product 11 | } 12 | 13 | func NewProductService(productRepo repo.Product) *ProductService { 14 | return &ProductService{productRepo: productRepo} 15 | } 16 | 17 | func (s *ProductService) CreateProduct(ctx context.Context, name string) (int, error) { 18 | return s.productRepo.CreateProduct(ctx, name) 19 | } 20 | 21 | func (s *ProductService) GetProductById(ctx context.Context, id int) (entity.Product, error) { 22 | return s.productRepo.GetProductById(ctx, id) 23 | } 24 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/service/errors.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import "fmt" 4 | 5 | var ( 6 | ErrCannotSignToken = fmt.Errorf("cannot sign token") 7 | ErrCannotParseToken = fmt.Errorf("cannot parse token") 8 | 9 | ErrUserAlreadyExists = fmt.Errorf("user already exists") 10 | ErrCannotCreateUser = fmt.Errorf("cannot create user") 11 | ErrUserNotFound = fmt.Errorf("user not found") 12 | ErrCannotGetUser = fmt.Errorf("cannot get user") 13 | 14 | ErrAccountAlreadyExists = fmt.Errorf("account already exists") 15 | ErrCannotCreateAccount = fmt.Errorf("cannot create account") 16 | ErrAccountNotFound = fmt.Errorf("account not found") 17 | ErrCannotGetAccount = fmt.Errorf("cannot get account") 18 | 19 | ErrCannotCreateReservation = fmt.Errorf("cannot create reservation") 20 | ) 21 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/entity/operation.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import "time" 4 | 5 | type Operation struct { 6 | Id int `db:"id"` 7 | AccountId int `db:"account_id"` 8 | Amount int `db:"amount"` 9 | OperationType string `db:"operation_type"` 10 | CreatedAt time.Time `db:"created_at"` 11 | 12 | ProductId *int `db:"product_id"` 13 | OrderId *int `db:"order_id"` 14 | Description string `db:"description"` 15 | } 16 | 17 | // TODO: make operation_type a distinct type with it's own table 18 | 19 | const ( 20 | OperationTypeDeposit = "deposit" 21 | OperationTypeWithdraw = "withdraw" 22 | OperationTypeTransferFrom = "transfer_from" 23 | OperationTypeTransferTo = "transfer_to" 24 | OperationTypeReservation = "reservation" 25 | OperationTypeRevenue = "revenue" 26 | OperationTypeRefund = "refund" 27 | ) 28 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/service/reservation.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "account-management-service/internal/entity" 5 | "account-management-service/internal/repo" 6 | "context" 7 | ) 8 | 9 | type ReservationService struct { 10 | reservationRepo repo.Reservation 11 | } 12 | 13 | func NewReservationService(reservationRepo repo.Reservation) *ReservationService { 14 | return &ReservationService{reservationRepo: reservationRepo} 15 | } 16 | 17 | func (s *ReservationService) CreateReservation(ctx context.Context, input ReservationCreateInput) (int, error) { 18 | reservation := entity.Reservation{ 19 | AccountId: input.AccountId, 20 | ProductId: input.ProductId, 21 | OrderId: input.OrderId, 22 | Amount: input.Amount, 23 | } 24 | 25 | id, err := s.reservationRepo.CreateReservation(ctx, reservation) 26 | if err != nil { 27 | return 0, ErrCannotCreateReservation 28 | } 29 | 30 | return id, nil 31 | } 32 | 33 | func (s *ReservationService) RefundReservationByOrderId(ctx context.Context, orderId int) error { 34 | return s.reservationRepo.RefundReservationByOrderId(ctx, orderId) 35 | } 36 | 37 | func (s *ReservationService) RevenueReservationByOrderId(ctx context.Context, orderId int) error { 38 | return s.reservationRepo.RevenueReservationByOrderId(ctx, orderId) 39 | } 40 | -------------------------------------------------------------------------------- /solution_example/account-management-service/pkg/httpserver/server.go: -------------------------------------------------------------------------------- 1 | package httpserver 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | const ( 10 | defaultReadTimeout = 5 * time.Second 11 | defaultWriteTimeout = 5 * time.Second 12 | defaultAddr = ":80" 13 | defaultShutdownTimeout = 3 * time.Second 14 | ) 15 | 16 | type Server struct { 17 | server *http.Server 18 | notify chan error 19 | shutdownTimeout time.Duration 20 | } 21 | 22 | func New(handler http.Handler, opts ...Option) *Server { 23 | httpServer := &http.Server{ 24 | Handler: handler, 25 | ReadTimeout: defaultReadTimeout, 26 | WriteTimeout: defaultWriteTimeout, 27 | Addr: defaultAddr, 28 | } 29 | 30 | s := &Server{ 31 | server: httpServer, 32 | notify: make(chan error, 1), 33 | shutdownTimeout: defaultShutdownTimeout, 34 | } 35 | 36 | for _, opt := range opts { 37 | opt(s) 38 | } 39 | 40 | s.start() 41 | 42 | return s 43 | } 44 | 45 | func (s *Server) start() { 46 | go func() { 47 | s.notify <- s.server.ListenAndServe() 48 | close(s.notify) 49 | }() 50 | } 51 | 52 | func (s *Server) Notify() <-chan error { 53 | return s.notify 54 | } 55 | 56 | func (s *Server) Shutdown() error { 57 | ctx, cancel := context.WithTimeout(context.Background(), s.shutdownTimeout) 58 | defer cancel() 59 | 60 | return s.server.Shutdown(ctx) 61 | } 62 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/service/account.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "account-management-service/internal/entity" 5 | "account-management-service/internal/repo" 6 | "account-management-service/internal/repo/repoerrs" 7 | "context" 8 | ) 9 | 10 | type AccountService struct { 11 | accountRepo repo.Account 12 | } 13 | 14 | func NewAccountService(accountRepo repo.Account) *AccountService { 15 | return &AccountService{accountRepo: accountRepo} 16 | } 17 | 18 | func (s *AccountService) CreateAccount(ctx context.Context) (int, error) { 19 | id, err := s.accountRepo.CreateAccount(ctx) 20 | if err != nil { 21 | if err == repoerrs.ErrAlreadyExists { 22 | return 0, ErrAccountAlreadyExists 23 | } 24 | return 0, ErrCannotCreateAccount 25 | } 26 | 27 | return id, nil 28 | } 29 | 30 | func (s *AccountService) GetAccountById(ctx context.Context, userId int) (entity.Account, error) { 31 | return s.accountRepo.GetAccountById(ctx, userId) 32 | } 33 | 34 | func (s *AccountService) Deposit(ctx context.Context, input AccountDepositInput) error { 35 | return s.accountRepo.Deposit(ctx, input.Id, input.Amount) 36 | } 37 | 38 | func (s *AccountService) Withdraw(ctx context.Context, input AccountWithdrawInput) error { 39 | return s.accountRepo.Withdraw(ctx, input.Id, input.Amount) 40 | } 41 | 42 | func (s *AccountService) Transfer(ctx context.Context, input AccountTransferInput) error { 43 | return s.accountRepo.Transfer(ctx, input.From, input.To, input.Amount) 44 | } 45 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/controller/http/v1/middleware.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "account-management-service/internal/service" 5 | "github.com/labstack/echo/v4" 6 | log "github.com/sirupsen/logrus" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | const ( 12 | userIdCtx = "userId" 13 | ) 14 | 15 | type AuthMiddleware struct { 16 | authService service.Auth 17 | } 18 | 19 | func (h *AuthMiddleware) UserIdentity(next echo.HandlerFunc) echo.HandlerFunc { 20 | return func(c echo.Context) error { 21 | token, ok := bearerToken(c.Request()) 22 | if !ok { 23 | log.Errorf("AuthMiddleware.UserIdentity: bearerToken: %v", ErrInvalidAuthHeader) 24 | newErrorResponse(c, http.StatusUnauthorized, ErrInvalidAuthHeader.Error()) 25 | return nil 26 | } 27 | 28 | userId, err := h.authService.ParseToken(token) 29 | if err != nil { 30 | log.Errorf("AuthMiddleware.UserIdentity: h.authService.ParseToken: %v", err) 31 | newErrorResponse(c, http.StatusUnauthorized, ErrCannotParseToken.Error()) 32 | return err 33 | } 34 | 35 | c.Set(userIdCtx, userId) 36 | 37 | return next(c) 38 | } 39 | } 40 | 41 | func bearerToken(r *http.Request) (string, bool) { 42 | const prefix = "Bearer " 43 | 44 | header := r.Header.Get(echo.HeaderAuthorization) 45 | if header == "" { 46 | return "", false 47 | } 48 | 49 | if len(header) > len(prefix) && strings.EqualFold(header[:len(prefix)], prefix) { 50 | return header[len(prefix):], true 51 | } 52 | 53 | return "", false 54 | } 55 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/app/migrate.go: -------------------------------------------------------------------------------- 1 | //go:build migrate 2 | 3 | package app 4 | 5 | import ( 6 | "errors" 7 | log "github.com/sirupsen/logrus" 8 | "os" 9 | "time" 10 | 11 | "github.com/golang-migrate/migrate/v4" 12 | // migrate tools 13 | _ "github.com/golang-migrate/migrate/v4/database/postgres" 14 | _ "github.com/golang-migrate/migrate/v4/source/file" 15 | ) 16 | 17 | const ( 18 | defaultAttempts = 20 19 | defaultTimeout = time.Second 20 | ) 21 | 22 | func init() { 23 | databaseURL, ok := os.LookupEnv("PG_URL") 24 | if !ok || len(databaseURL) == 0 { 25 | log.Fatalf("migrate: environment variable not declared: PG_URL") 26 | } 27 | 28 | databaseURL += "?sslmode=disable" 29 | 30 | var ( 31 | attempts = defaultAttempts 32 | err error 33 | m *migrate.Migrate 34 | ) 35 | 36 | for attempts > 0 { 37 | m, err = migrate.New("file://migrations", databaseURL) 38 | if err == nil { 39 | break 40 | } 41 | 42 | log.Printf("Migrate: pgdb is trying to connect, attempts left: %d", attempts) 43 | time.Sleep(defaultTimeout) 44 | attempts-- 45 | } 46 | 47 | if err != nil { 48 | log.Fatalf("Migrate: pgdb connect error: %s", err) 49 | } 50 | 51 | err = m.Up() 52 | defer func() { _, _ = m.Close() }() 53 | if err != nil && !errors.Is(err, migrate.ErrNoChange) { 54 | log.Fatalf("Migrate: up error: %s", err) 55 | } 56 | 57 | if errors.Is(err, migrate.ErrNoChange) { 58 | log.Printf("Migrate: no change") 59 | return 60 | } 61 | 62 | log.Printf("Migrate: up success") 63 | } 64 | -------------------------------------------------------------------------------- /solution_example/account-management-service/migrations/20221018151330_account_management.up.sql: -------------------------------------------------------------------------------- 1 | create table users 2 | ( 3 | id serial primary key, 4 | username varchar(255) not null unique, 5 | password varchar(255) not null, 6 | created_at timestamp not null default now() 7 | ); 8 | 9 | create table accounts 10 | ( 11 | id serial primary key, 12 | balance int not null default 0, 13 | created_at timestamp not null default now() 14 | ); 15 | 16 | create table products 17 | ( 18 | id serial primary key, 19 | name varchar(255) not null unique 20 | ); 21 | 22 | create table reservations 23 | ( 24 | id serial primary key, 25 | account_id int not null, 26 | product_id int not null, 27 | order_id int not null unique, 28 | amount int not null, 29 | created_at timestamp not null default now(), 30 | foreign key (account_id) references accounts (id), 31 | foreign key (product_id) references products (id) 32 | ); 33 | 34 | create table operations 35 | ( 36 | id serial primary key, 37 | account_id int not null, 38 | amount int not null, 39 | operation_type varchar(255) not null, 40 | created_at timestamp not null default now(), 41 | product_id int default null, 42 | order_id int default null, 43 | description varchar(255) default null, 44 | foreign key (account_id) references accounts (id) 45 | ); 46 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/controller/http/v1/router.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | _ "account-management-service/docs" 5 | "account-management-service/internal/service" 6 | "github.com/labstack/echo/v4" 7 | "github.com/labstack/echo/v4/middleware" 8 | log "github.com/sirupsen/logrus" 9 | echoSwagger "github.com/swaggo/echo-swagger" 10 | "os" 11 | ) 12 | 13 | func NewRouter(handler *echo.Echo, services *service.Services) { 14 | handler.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ 15 | Format: `{"time":"${time_rfc3339_nano}", "method":"${method}","uri":"${uri}", "status":${status},"error":"${error}"}` + "\n", 16 | Output: setLogsFile(), 17 | })) 18 | handler.Use(middleware.Recover()) 19 | 20 | handler.GET("/health", func(c echo.Context) error { return c.NoContent(200) }) 21 | handler.GET("/swagger/*", echoSwagger.WrapHandler) 22 | 23 | auth := handler.Group("/auth") 24 | { 25 | newAuthRoutes(auth, services.Auth) 26 | } 27 | 28 | authMiddleware := &AuthMiddleware{services.Auth} 29 | v1 := handler.Group("/api/v1", authMiddleware.UserIdentity) 30 | { 31 | newAccountRoutes(v1.Group("/accounts"), services.Account) 32 | newReservationRoutes(v1.Group("/reservations"), services.Reservation) 33 | newProductRoutes(v1.Group("/products"), services.Product) 34 | newOperationRoutes(v1.Group("/operations"), services.Operation) 35 | } 36 | } 37 | 38 | func setLogsFile() *os.File { 39 | file, err := os.OpenFile("/logs/requests.log", os.O_APPEND|os.O_CREATE|os.O_RDWR, 0666) 40 | if err != nil { 41 | log.Fatal(err) 42 | } 43 | return file 44 | } 45 | -------------------------------------------------------------------------------- /solution_example/account-management-service/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ilyakaznacheev/cleanenv" 6 | "path" 7 | "time" 8 | ) 9 | 10 | type ( 11 | Config struct { 12 | App `yaml:"app"` 13 | HTTP `yaml:"http"` 14 | Log `yaml:"log"` 15 | PG `yaml:"postgres"` 16 | JWT `yaml:"jwt"` 17 | Hasher `yaml:"hasher"` 18 | WebAPI `yaml:"webapi"` 19 | } 20 | 21 | App struct { 22 | Name string `env-required:"true" yaml:"name" env:"APP_NAME"` 23 | Version string `env-required:"true" yaml:"version" env:"APP_VERSION"` 24 | } 25 | 26 | HTTP struct { 27 | Port string `env-required:"true" yaml:"port" env:"HTTP_PORT"` 28 | } 29 | 30 | Log struct { 31 | Level string `env-required:"true" yaml:"level" env:"LOG_LEVEL"` 32 | } 33 | 34 | PG struct { 35 | MaxPoolSize int `env-required:"true" yaml:"max_pool_size" env:"PG_MAX_POOL_SIZE"` 36 | URL string `env-required:"true" env:"PG_URL"` 37 | } 38 | 39 | JWT struct { 40 | SignKey string `env-required:"true" env:"JWT_SIGN_KEY"` 41 | TokenTTL time.Duration `env-required:"true" yaml:"token_ttl" env:"JWT_TOKEN_TTL"` 42 | } 43 | 44 | Hasher struct { 45 | Salt string `env-required:"true" env:"HASHER_SALT"` 46 | } 47 | 48 | WebAPI struct { 49 | GDriveJSONFilePath string `env-required:"false" env:"GOOGLE_DRIVE_JSON_FILE_PATH"` 50 | } 51 | ) 52 | 53 | func NewConfig(configPath string) (*Config, error) { 54 | cfg := &Config{} 55 | 56 | err := cleanenv.ReadConfig(path.Join("./", configPath), cfg) 57 | if err != nil { 58 | return nil, fmt.Errorf("error reading config file: %w", err) 59 | } 60 | 61 | err = cleanenv.UpdateEnv(cfg) 62 | if err != nil { 63 | return nil, fmt.Errorf("error updating env: %w", err) 64 | } 65 | 66 | return cfg, nil 67 | } 68 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/repo/pgdb/product.go: -------------------------------------------------------------------------------- 1 | package pgdb 2 | 3 | import ( 4 | "account-management-service/internal/entity" 5 | "account-management-service/pkg/postgres" 6 | "context" 7 | "fmt" 8 | ) 9 | 10 | type ProductRepo struct { 11 | *postgres.Postgres 12 | } 13 | 14 | func NewProductRepo(pg *postgres.Postgres) *ProductRepo { 15 | return &ProductRepo{pg} 16 | } 17 | 18 | func (r *ProductRepo) CreateProduct(ctx context.Context, name string) (int, error) { 19 | sql, args, _ := r.Builder. 20 | Insert("products"). 21 | Columns("name"). 22 | Values(name). 23 | Suffix("RETURNING id"). 24 | ToSql() 25 | 26 | var id int 27 | err := r.Pool.QueryRow(ctx, sql, args...).Scan(&id) 28 | if err != nil { 29 | return 0, fmt.Errorf("ProductRepo.CreateProduct - r.Pool.QueryRow: %v", err) 30 | } 31 | 32 | return id, nil 33 | } 34 | 35 | func (r *ProductRepo) GetProductById(ctx context.Context, id int) (entity.Product, error) { 36 | sql, args, _ := r.Builder. 37 | Select("*"). 38 | From("products"). 39 | Where("id = ?", id). 40 | ToSql() 41 | 42 | var product entity.Product 43 | err := r.Pool.QueryRow(ctx, sql, args...).Scan( 44 | &product.Id, 45 | &product.Name, 46 | ) 47 | if err != nil { 48 | return entity.Product{}, fmt.Errorf("ProductRepo.GetProductById - r.Pool.QueryRow: %v", err) 49 | } 50 | 51 | return product, nil 52 | } 53 | 54 | func (r *ProductRepo) GetAllProducts(ctx context.Context) ([]entity.Product, error) { 55 | sql, args, _ := r.Builder. 56 | Select("*"). 57 | From("products"). 58 | ToSql() 59 | 60 | rows, err := r.Pool.Query(ctx, sql, args...) 61 | if err != nil { 62 | return nil, fmt.Errorf("ProductRepo.GetAllProducts - r.Pool.Query: %v", err) 63 | } 64 | defer rows.Close() 65 | 66 | var products []entity.Product 67 | for rows.Next() { 68 | var product entity.Product 69 | err := rows.Scan( 70 | &product.Id, 71 | &product.Name, 72 | ) 73 | if err != nil { 74 | return nil, fmt.Errorf("ProductRepo.GetAllProducts - rows.Scan: %v", err) 75 | } 76 | products = append(products, product) 77 | } 78 | 79 | return products, nil 80 | } 81 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/repo/repo.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "account-management-service/internal/entity" 5 | "account-management-service/internal/repo/pgdb" 6 | "account-management-service/pkg/postgres" 7 | "context" 8 | ) 9 | 10 | type User interface { 11 | CreateUser(ctx context.Context, user entity.User) (int, error) 12 | GetUserByUsernameAndPassword(ctx context.Context, username, password string) (entity.User, error) 13 | GetUserById(ctx context.Context, id int) (entity.User, error) 14 | GetUserByUsername(ctx context.Context, username string) (entity.User, error) 15 | } 16 | 17 | type Account interface { 18 | CreateAccount(ctx context.Context) (int, error) 19 | GetAccountById(ctx context.Context, id int) (entity.Account, error) 20 | Deposit(ctx context.Context, id, amount int) error 21 | Withdraw(ctx context.Context, id, amount int) error 22 | Transfer(ctx context.Context, from, to, amount int) error 23 | } 24 | 25 | type Product interface { 26 | CreateProduct(ctx context.Context, name string) (int, error) 27 | GetProductById(ctx context.Context, id int) (entity.Product, error) 28 | GetAllProducts(ctx context.Context) ([]entity.Product, error) 29 | } 30 | 31 | type Reservation interface { 32 | CreateReservation(ctx context.Context, reservation entity.Reservation) (int, error) 33 | GetReservationById(ctx context.Context, id int) (entity.Reservation, error) 34 | RefundReservationByOrderId(ctx context.Context, id int) error 35 | RevenueReservationByOrderId(ctx context.Context, orderId int) error 36 | } 37 | 38 | type Operation interface { 39 | GetAllRevenueOperationsGroupedByProduct(ctx context.Context, month, year int) ([]string, []int, error) 40 | OperationsPagination(ctx context.Context, accountId int, sortType string, offset int, limit int) ([]entity.Operation, []string, error) 41 | } 42 | 43 | type Repositories struct { 44 | User 45 | Account 46 | Product 47 | Reservation 48 | Operation 49 | } 50 | 51 | func NewRepositories(pg *postgres.Postgres) *Repositories { 52 | return &Repositories{ 53 | User: pgdb.NewUserRepo(pg), 54 | Account: pgdb.NewAccountRepo(pg), 55 | Product: pgdb.NewProductRepo(pg), 56 | Reservation: pgdb.NewReservationRepo(pg), 57 | Operation: pgdb.NewOperationRepo(pg), 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /solution_example/account-management-service/Makefile: -------------------------------------------------------------------------------- 1 | include .env 2 | export 3 | 4 | .PHONY: help 5 | help: ## Display this help screen 6 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 7 | 8 | compose-up: ### Run docker-compose 9 | docker-compose up --build -d && docker-compose logs -f 10 | .PHONY: compose-up 11 | 12 | compose-down: ### Down docker-compose 13 | docker-compose down --remove-orphans 14 | .PHONY: compose-down 15 | 16 | docker-rm-volume: ### remove docker volume 17 | docker volume rm pg-data 18 | .PHONY: docker-rm-volume 19 | 20 | linter-golangci: ### check by golangci linter 21 | golangci-lint run 22 | .PHONY: linter-golangci 23 | 24 | linter-hadolint: ### check by hadolint linter 25 | git ls-files --exclude='Dockerfile*' -c --ignored | xargs hadolint 26 | .PHONY: linter-hadolint 27 | 28 | migrate-create: ### create new migration 29 | migrate create -ext sql -dir migrations 'account_management' 30 | .PHONY: migrate-create 31 | 32 | migrate-up: ### migration up 33 | migrate -path migrations -database '$(PG_URL_LOCALHOST)?sslmode=disable' up 34 | .PHONY: migrate-up 35 | 36 | migrate-down: ### migration down 37 | echo "y" | migrate -path migrations -database '$(PG_URL_LOCALHOST)?sslmode=disable' down 38 | .PHONY: migrate-down 39 | 40 | test: ### run test 41 | go test -v ./... 42 | 43 | cover-html: ### run test with coverage and open html report 44 | go test -coverprofile=coverage.out ./... 45 | go tool cover -html=coverage.out 46 | rm coverage.out 47 | .PHONY: coverage-html 48 | 49 | cover: ### run test with coverage 50 | go test -coverprofile=coverage.out ./... 51 | go tool cover -func=coverage.out 52 | rm coverage.out 53 | .PHONY: coverage 54 | 55 | mockgen: ### generate mock 56 | mockgen -source=internal/service/service.go -destination=internal/mocks/servicemocks/service.go -package=servicemocks 57 | mockgen -source=internal/repo/repo.go -destination=internal/mocks/repomocks/repo.go -package=repomocks 58 | mockgen -source=internal/webapi/webapi.go -destination=internal/mocks/webapimocks/webapi.go -package=webapimocks 59 | .PHONY: mockgen 60 | 61 | swag: ### generate swagger docs 62 | swag init -g internal/app/app.go --parseInternal --parseDependency 63 | -------------------------------------------------------------------------------- /solution_example/account-management-service/pkg/postgres/postgres.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/Masterminds/squirrel" 7 | "github.com/jackc/pgx/v5" 8 | "github.com/jackc/pgx/v5/pgconn" 9 | "github.com/jackc/pgx/v5/pgxpool" 10 | "log" 11 | "time" 12 | ) 13 | 14 | const ( 15 | defaultMaxPoolSize = 1 16 | defaultConnAttempts = 10 17 | defaultConnTimeout = time.Second 18 | ) 19 | 20 | type PgxPool interface { 21 | Close() 22 | Acquire(ctx context.Context) (*pgxpool.Conn, error) 23 | Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) 24 | Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) 25 | QueryRow(ctx context.Context, sql string, args ...any) pgx.Row 26 | SendBatch(ctx context.Context, b *pgx.Batch) pgx.BatchResults 27 | Begin(ctx context.Context) (pgx.Tx, error) 28 | BeginTx(ctx context.Context, txOptions pgx.TxOptions) (pgx.Tx, error) 29 | CopyFrom(ctx context.Context, tableName pgx.Identifier, columnNames []string, rowSrc pgx.CopyFromSource) (int64, error) 30 | Ping(ctx context.Context) error 31 | } 32 | 33 | type Postgres struct { 34 | maxPoolSize int 35 | connAttempts int 36 | connTimeout time.Duration 37 | 38 | Builder squirrel.StatementBuilderType 39 | Pool PgxPool 40 | } 41 | 42 | func New(url string, opts ...Option) (*Postgres, error) { 43 | pg := &Postgres{ 44 | maxPoolSize: defaultMaxPoolSize, 45 | connAttempts: defaultConnAttempts, 46 | connTimeout: defaultConnTimeout, 47 | } 48 | 49 | for _, opt := range opts { 50 | opt(pg) 51 | } 52 | 53 | pg.Builder = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar) 54 | 55 | poolConfig, err := pgxpool.ParseConfig(url) 56 | if err != nil { 57 | return nil, fmt.Errorf("pgdb - New - pgxpool.ParseConfig: %w", err) 58 | } 59 | 60 | poolConfig.MaxConns = int32(pg.maxPoolSize) 61 | 62 | for pg.connAttempts > 0 { 63 | pg.Pool, err = pgxpool.NewWithConfig(context.Background(), poolConfig) 64 | if err == nil { 65 | break 66 | } 67 | 68 | log.Printf("Postgres is trying to connect, attempts left: %d", pg.connAttempts) 69 | time.Sleep(pg.connTimeout) 70 | pg.connAttempts-- 71 | } 72 | 73 | if err != nil { 74 | return nil, fmt.Errorf("pgdb - New - pgxpool.ConnectConfig: %w", err) 75 | } 76 | 77 | return pg, nil 78 | } 79 | 80 | func (p *Postgres) Close() { 81 | if p.Pool != nil { 82 | p.Pool.Close() 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/service/operation.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "account-management-service/internal/repo" 5 | "account-management-service/internal/webapi" 6 | "bytes" 7 | "context" 8 | "encoding/csv" 9 | "errors" 10 | "fmt" 11 | "strconv" 12 | ) 13 | 14 | type OperationService struct { 15 | operationRepo repo.Operation 16 | productRepo repo.Product 17 | gDrive webapi.GDrive 18 | } 19 | 20 | func NewOperationService(operationRepo repo.Operation, productRepo repo.Product, gDrive webapi.GDrive) *OperationService { 21 | return &OperationService{ 22 | operationRepo: operationRepo, 23 | productRepo: productRepo, 24 | gDrive: gDrive, 25 | } 26 | } 27 | 28 | func (s *OperationService) OperationHistory(ctx context.Context, input OperationHistoryInput) ([]OperationHistoryOutput, error) { 29 | operations, productNames, err := s.operationRepo.OperationsPagination(ctx, input.AccountId, input.SortType, input.Offset, input.Limit) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | output := make([]OperationHistoryOutput, 0, len(operations)) 35 | for i, operation := range operations { 36 | 37 | output = append(output, OperationHistoryOutput{ 38 | Amount: operation.Amount, 39 | Operation: operation.OperationType, 40 | Time: operation.CreatedAt, 41 | Product: productNames[i], 42 | Order: operation.OrderId, 43 | Description: operation.Description, 44 | }) 45 | } 46 | return output, nil 47 | } 48 | 49 | func (s *OperationService) MakeReportLink(ctx context.Context, month, year int) (string, error) { 50 | if !s.gDrive.IsAvailable() { 51 | return "", errors.New("google drive is not available") 52 | } 53 | 54 | file, err := s.MakeReportFile(ctx, month, year) 55 | if err != nil { 56 | return "", err 57 | } 58 | 59 | url, err := s.gDrive.UploadCSVFile(ctx, fmt.Sprintf("report_%d_%d.csv", month, year), file) 60 | if err != nil { 61 | return "", errors.New("failed to upload csv file") 62 | } 63 | 64 | return url, nil 65 | } 66 | 67 | func (s *OperationService) MakeReportFile(ctx context.Context, month, year int) ([]byte, error) { 68 | products, amounts, err := s.operationRepo.GetAllRevenueOperationsGroupedByProduct(ctx, month, year) 69 | if err != nil { 70 | return nil, errors.New("failed to get revenue operations") 71 | } 72 | 73 | b := bytes.Buffer{} 74 | w := csv.NewWriter(&b) 75 | 76 | for i := range products { 77 | err := w.Write([]string{products[i], strconv.Itoa(amounts[i])}) 78 | if err != nil { 79 | return nil, errors.New("failed to write csv") 80 | } 81 | } 82 | 83 | w.Flush() 84 | if err := w.Error(); err != nil { 85 | return nil, errors.New("failed to write csv") 86 | } 87 | 88 | return b.Bytes(), nil 89 | } 90 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/controller/http/v1/product.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "account-management-service/internal/entity" 5 | "account-management-service/internal/service" 6 | "github.com/labstack/echo/v4" 7 | "net/http" 8 | ) 9 | 10 | type productRoutes struct { 11 | productService service.Product 12 | } 13 | 14 | func newProductRoutes(g *echo.Group, productService service.Product) *productRoutes { 15 | r := &productRoutes{ 16 | productService: productService, 17 | } 18 | 19 | g.POST("/create", r.create) 20 | g.GET("/", r.getById) 21 | 22 | return r 23 | } 24 | 25 | type productCreateInput struct { 26 | Name string `json:"name" validate:"required"` 27 | } 28 | 29 | // @Summary Create product 30 | // @Description Create product 31 | // @Tags products 32 | // @Accept json 33 | // @Produce json 34 | // @Success 201 {object} v1.productRoutes.create.response 35 | // @Failure 400 {object} echo.HTTPError 36 | // @Failure 500 {object} echo.HTTPError 37 | // @Security JWT 38 | // @Router /api/v1/products/create [post] 39 | func (r *productRoutes) create(c echo.Context) error { 40 | var input productCreateInput 41 | 42 | if err := c.Bind(&input); err != nil { 43 | newErrorResponse(c, http.StatusBadRequest, "invalid request body") 44 | return err 45 | } 46 | 47 | if err := c.Validate(input); err != nil { 48 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 49 | return err 50 | } 51 | 52 | id, err := r.productService.CreateProduct(c.Request().Context(), input.Name) 53 | if err != nil { 54 | newErrorResponse(c, http.StatusInternalServerError, "internal server error") 55 | return err 56 | } 57 | 58 | type response struct { 59 | Id int `json:"id"` 60 | } 61 | 62 | return c.JSON(http.StatusCreated, response{ 63 | Id: id, 64 | }) 65 | } 66 | 67 | type getByIdInput struct { 68 | Id int `json:"id" validate:"required"` 69 | } 70 | 71 | // @Summary Get product by id 72 | // @Description Get product by id 73 | // @Tags products 74 | // @Accept json 75 | // @Produce json 76 | // @Success 200 {object} v1.productRoutes.getById.response 77 | // @Failure 400 {object} echo.HTTPError 78 | // @Failure 500 {object} echo.HTTPError 79 | // @Security JWT 80 | // @Router /api/v1/products/getById [get] 81 | func (r *productRoutes) getById(c echo.Context) error { 82 | var input getByIdInput 83 | 84 | if err := c.Bind(&input); err != nil { 85 | newErrorResponse(c, http.StatusBadRequest, "invalid request body") 86 | return err 87 | } 88 | 89 | if err := c.Validate(input); err != nil { 90 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 91 | return err 92 | } 93 | 94 | product, err := r.productService.GetProductById(c.Request().Context(), input.Id) 95 | if err != nil { 96 | newErrorResponse(c, http.StatusInternalServerError, "internal server error") 97 | return err 98 | } 99 | 100 | type response struct { 101 | Product entity.Product `json:"product"` 102 | } 103 | 104 | return c.JSON(http.StatusOK, response{ 105 | Product: product, 106 | }) 107 | } 108 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/service/auth.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "account-management-service/internal/entity" 5 | "account-management-service/internal/repo" 6 | "account-management-service/internal/repo/repoerrs" 7 | "account-management-service/pkg/hasher" 8 | "context" 9 | "errors" 10 | "fmt" 11 | "github.com/golang-jwt/jwt" 12 | log "github.com/sirupsen/logrus" 13 | "time" 14 | ) 15 | 16 | type TokenClaims struct { 17 | jwt.StandardClaims 18 | UserId int 19 | } 20 | 21 | type AuthService struct { 22 | userRepo repo.User 23 | passwordHasher hasher.PasswordHasher 24 | signKey string 25 | tokenTTL time.Duration 26 | } 27 | 28 | func NewAuthService(userRepo repo.User, passwordHasher hasher.PasswordHasher, signKey string, tokenTTL time.Duration) *AuthService { 29 | return &AuthService{ 30 | userRepo: userRepo, 31 | passwordHasher: passwordHasher, 32 | signKey: signKey, 33 | tokenTTL: tokenTTL, 34 | } 35 | } 36 | 37 | func (s *AuthService) CreateUser(ctx context.Context, input AuthCreateUserInput) (int, error) { 38 | user := entity.User{ 39 | Username: input.Username, 40 | Password: s.passwordHasher.Hash(input.Password), 41 | } 42 | 43 | userId, err := s.userRepo.CreateUser(ctx, user) 44 | if err != nil { 45 | if errors.Is(err, repoerrs.ErrAlreadyExists) { 46 | return 0, ErrUserAlreadyExists 47 | } 48 | log.Errorf("AuthService.CreateUser - c.userRepo.CreateUser: %v", err) 49 | return 0, ErrCannotCreateUser 50 | } 51 | return userId, nil 52 | } 53 | 54 | func (s *AuthService) GenerateToken(ctx context.Context, input AuthGenerateTokenInput) (string, error) { 55 | // get user from DB 56 | user, err := s.userRepo.GetUserByUsernameAndPassword(ctx, input.Username, s.passwordHasher.Hash(input.Password)) 57 | if err != nil { 58 | if errors.Is(err, repoerrs.ErrNotFound) { 59 | return "", ErrUserNotFound 60 | } 61 | log.Errorf("AuthService.GenerateToken: cannot get user: %v", err) 62 | return "", ErrCannotGetUser 63 | } 64 | 65 | // generate token 66 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, &TokenClaims{ 67 | StandardClaims: jwt.StandardClaims{ 68 | ExpiresAt: time.Now().Add(s.tokenTTL).Unix(), 69 | IssuedAt: time.Now().Unix(), 70 | }, 71 | UserId: user.Id, 72 | }) 73 | 74 | // sign token 75 | tokenString, err := token.SignedString([]byte(s.signKey)) 76 | if err != nil { 77 | log.Errorf("AuthService.GenerateToken: cannot sign token: %v", err) 78 | return "", ErrCannotSignToken 79 | } 80 | 81 | return tokenString, nil 82 | } 83 | 84 | func (s *AuthService) ParseToken(accessToken string) (int, error) { 85 | token, err := jwt.ParseWithClaims(accessToken, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) { 86 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 87 | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) 88 | } 89 | 90 | return []byte(s.signKey), nil 91 | }) 92 | 93 | if err != nil { 94 | return 0, ErrCannotParseToken 95 | } 96 | 97 | claims, ok := token.Claims.(*TokenClaims) 98 | if !ok { 99 | return 0, ErrCannotParseToken 100 | } 101 | 102 | return claims.UserId, nil 103 | } 104 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "account-management-service/config" 5 | v1 "account-management-service/internal/controller/http/v1" 6 | "account-management-service/internal/repo" 7 | "account-management-service/internal/service" 8 | "account-management-service/internal/webapi/gdrive" 9 | "account-management-service/pkg/hasher" 10 | "account-management-service/pkg/httpserver" 11 | "account-management-service/pkg/postgres" 12 | "account-management-service/pkg/validator" 13 | "fmt" 14 | "github.com/labstack/echo/v4" 15 | log "github.com/sirupsen/logrus" 16 | "os" 17 | "os/signal" 18 | "syscall" 19 | ) 20 | 21 | // @title Account Management Service 22 | // @version 1.0 23 | // @description This is a service for managing accounts, reservations, products and operations. 24 | 25 | // @contact.name Changaz Danial 26 | // @contact.email changaz.d@gmail.com 27 | 28 | // @host localhost:8089 29 | // @BasePath / 30 | 31 | // @securityDefinitions.apikey JWT 32 | // @in header 33 | // @name Authorization 34 | // @description JWT token 35 | 36 | func Run(configPath string) { 37 | // Configuration 38 | cfg, err := config.NewConfig(configPath) 39 | if err != nil { 40 | log.Fatalf("Config error: %s", err) 41 | } 42 | 43 | // Logger 44 | SetLogrus(cfg.Log.Level) 45 | 46 | // Repositories 47 | log.Info("Initializing postgres...") 48 | pg, err := postgres.New(cfg.PG.URL, postgres.MaxPoolSize(cfg.PG.MaxPoolSize)) 49 | if err != nil { 50 | log.Fatal(fmt.Errorf("app - Run - pgdb.NewServices: %w", err)) 51 | } 52 | defer pg.Close() 53 | 54 | // Repositories 55 | log.Info("Initializing repositories...") 56 | repositories := repo.NewRepositories(pg) 57 | 58 | // Services dependencies 59 | log.Info("Initializing services...") 60 | deps := service.ServicesDependencies{ 61 | Repos: repositories, 62 | GDrive: gdrive.New(cfg.WebAPI.GDriveJSONFilePath), 63 | Hasher: hasher.NewSHA1Hasher(cfg.Hasher.Salt), 64 | SignKey: cfg.JWT.SignKey, 65 | TokenTTL: cfg.JWT.TokenTTL, 66 | } 67 | services := service.NewServices(deps) 68 | 69 | // Echo handler 70 | log.Info("Initializing handlers and routes...") 71 | handler := echo.New() 72 | // setup handler validator as lib validator 73 | handler.Validator = validator.NewCustomValidator() 74 | v1.NewRouter(handler, services) 75 | 76 | // HTTP server 77 | log.Info("Starting http server...") 78 | log.Debugf("Server port: %s", cfg.HTTP.Port) 79 | httpServer := httpserver.New(handler, httpserver.Port(cfg.HTTP.Port)) 80 | 81 | // Waiting signal 82 | log.Info("Configuring graceful shutdown...") 83 | interrupt := make(chan os.Signal, 1) 84 | signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) 85 | 86 | select { 87 | case s := <-interrupt: 88 | log.Info("app - Run - signal: " + s.String()) 89 | case err = <-httpServer.Notify(): 90 | log.Error(fmt.Errorf("app - Run - httpServer.Notify: %w", err)) 91 | } 92 | 93 | // Graceful shutdown 94 | log.Info("Shutting down...") 95 | err = httpServer.Shutdown() 96 | if err != nil { 97 | log.Error(fmt.Errorf("app - Run - httpServer.Shutdown: %w", err)) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/repo/pgdb/user.go: -------------------------------------------------------------------------------- 1 | package pgdb 2 | 3 | import ( 4 | "account-management-service/internal/entity" 5 | "account-management-service/internal/repo/repoerrs" 6 | "account-management-service/pkg/postgres" 7 | "context" 8 | "errors" 9 | "fmt" 10 | "github.com/jackc/pgx/v5" 11 | "github.com/jackc/pgx/v5/pgconn" 12 | ) 13 | 14 | type UserRepo struct { 15 | *postgres.Postgres 16 | } 17 | 18 | func NewUserRepo(pg *postgres.Postgres) *UserRepo { 19 | return &UserRepo{pg} 20 | } 21 | 22 | func (r *UserRepo) CreateUser(ctx context.Context, user entity.User) (int, error) { 23 | sql, args, _ := r.Builder. 24 | Insert("users"). 25 | Columns("username", "password"). 26 | Values(user.Username, user.Password). 27 | Suffix("RETURNING id"). 28 | ToSql() 29 | 30 | var id int 31 | err := r.Pool.QueryRow(ctx, sql, args...).Scan(&id) 32 | if err != nil { 33 | var pgErr *pgconn.PgError 34 | if ok := errors.As(err, &pgErr); ok { 35 | if pgErr.Code == "23505" { 36 | return 0, repoerrs.ErrAlreadyExists 37 | } 38 | } 39 | return 0, fmt.Errorf("UserRepo.CreateUser - r.Pool.QueryRow: %v", err) 40 | } 41 | 42 | return id, nil 43 | } 44 | 45 | func (r *UserRepo) GetUserByUsernameAndPassword(ctx context.Context, username, password string) (entity.User, error) { 46 | sql, args, _ := r.Builder. 47 | Select("id, username, password, created_at"). 48 | From("users"). 49 | Where("username = ? AND password = ?", username, password). 50 | ToSql() 51 | 52 | var user entity.User 53 | err := r.Pool.QueryRow(ctx, sql, args...).Scan( 54 | &user.Id, 55 | &user.Username, 56 | &user.Password, 57 | &user.CreatedAt, 58 | ) 59 | if err != nil { 60 | if errors.Is(err, pgx.ErrNoRows) { 61 | return entity.User{}, repoerrs.ErrNotFound 62 | } 63 | return entity.User{}, fmt.Errorf("UserRepo.GetUserByUsernameAndPassword - r.Pool.QueryRow: %v", err) 64 | } 65 | 66 | return user, nil 67 | } 68 | 69 | func (r *UserRepo) GetUserById(ctx context.Context, id int) (entity.User, error) { 70 | sql, args, _ := r.Builder. 71 | Select("id, username, password, created_at"). 72 | From("users"). 73 | Where("id = ?", id). 74 | ToSql() 75 | 76 | var user entity.User 77 | err := r.Pool.QueryRow(ctx, sql, args...).Scan( 78 | &user.Id, 79 | &user.Username, 80 | &user.Password, 81 | &user.CreatedAt, 82 | ) 83 | if err != nil { 84 | if errors.Is(err, pgx.ErrNoRows) { 85 | return entity.User{}, repoerrs.ErrNotFound 86 | } 87 | return entity.User{}, fmt.Errorf("UserRepo.GetUserById - r.Pool.QueryRow: %v", err) 88 | } 89 | 90 | return user, nil 91 | } 92 | 93 | func (r *UserRepo) GetUserByUsername(ctx context.Context, username string) (entity.User, error) { 94 | sql, args, _ := r.Builder. 95 | Select("id, username, password, created_at"). 96 | From("users"). 97 | Where("username = ?", username). 98 | ToSql() 99 | 100 | var user entity.User 101 | err := r.Pool.QueryRow(ctx, sql, args...).Scan( 102 | &user.Id, 103 | &user.Username, 104 | &user.Password, 105 | &user.CreatedAt, 106 | ) 107 | if err != nil { 108 | if errors.Is(err, pgx.ErrNoRows) { 109 | return entity.User{}, repoerrs.ErrNotFound 110 | } 111 | return entity.User{}, fmt.Errorf("UserRepo.GetUserByUsername - r.Pool.QueryRow: %v", err) 112 | } 113 | 114 | return user, nil 115 | } 116 | -------------------------------------------------------------------------------- /solution_example/account-management-service/go.mod: -------------------------------------------------------------------------------- 1 | module account-management-service 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/Masterminds/squirrel v1.5.3 7 | github.com/go-playground/validator/v10 v10.11.1 8 | github.com/golang-jwt/jwt v3.2.2+incompatible 9 | github.com/golang-migrate/migrate/v4 v4.15.2 10 | github.com/golang/mock v1.6.0 11 | github.com/ilyakaznacheev/cleanenv v1.4.0 12 | github.com/jackc/pgx/v5 v5.0.3 13 | github.com/labstack/echo/v4 v4.9.1 14 | github.com/pashagolub/pgxmock/v2 v2.1.0 15 | github.com/sirupsen/logrus v1.9.0 16 | github.com/stretchr/testify v1.8.0 17 | github.com/swaggo/swag v1.8.7 18 | google.golang.org/api v0.100.0 19 | ) 20 | 21 | require ( 22 | cloud.google.com/go/compute v1.10.0 // indirect 23 | github.com/BurntSushi/toml v1.1.0 // indirect 24 | github.com/KyleBanks/depth v1.2.1 // indirect 25 | github.com/PuerkitoBio/purell v1.2.0 // indirect 26 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect 27 | github.com/davecgh/go-spew v1.1.1 // indirect 28 | github.com/go-openapi/jsonpointer v0.19.5 // indirect 29 | github.com/go-openapi/jsonreference v0.20.0 // indirect 30 | github.com/go-openapi/spec v0.20.7 // indirect 31 | github.com/go-openapi/swag v0.22.3 // indirect 32 | github.com/go-playground/locales v0.14.0 // indirect 33 | github.com/go-playground/universal-translator v0.18.0 // indirect 34 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 35 | github.com/golang/protobuf v1.5.2 // indirect 36 | github.com/google/uuid v1.3.0 // indirect 37 | github.com/googleapis/enterprise-certificate-proxy v0.2.0 // indirect 38 | github.com/googleapis/gax-go/v2 v2.6.0 // indirect 39 | github.com/hashicorp/errwrap v1.1.0 // indirect 40 | github.com/hashicorp/go-multierror v1.1.1 // indirect 41 | github.com/jackc/pgpassfile v1.0.0 // indirect 42 | github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect 43 | github.com/jackc/puddle/v2 v2.0.0 // indirect 44 | github.com/joho/godotenv v1.4.0 // indirect 45 | github.com/josharian/intern v1.0.0 // indirect 46 | github.com/labstack/gommon v0.4.0 // indirect 47 | github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect 48 | github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect 49 | github.com/leodido/go-urn v1.2.1 // indirect 50 | github.com/lib/pq v1.10.2 // indirect 51 | github.com/mailru/easyjson v0.7.7 // indirect 52 | github.com/mattn/go-colorable v0.1.13 // indirect 53 | github.com/mattn/go-isatty v0.0.16 // indirect 54 | github.com/pmezard/go-difflib v1.0.0 // indirect 55 | github.com/swaggo/echo-swagger v1.3.5 // indirect 56 | github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a // indirect 57 | github.com/valyala/bytebufferpool v1.0.0 // indirect 58 | github.com/valyala/fasttemplate v1.2.2 // indirect 59 | go.opencensus.io v0.23.0 // indirect 60 | go.uber.org/atomic v1.7.0 // indirect 61 | golang.org/x/crypto v0.1.0 // indirect 62 | golang.org/x/net v0.1.0 // indirect 63 | golang.org/x/oauth2 v0.1.0 // indirect 64 | golang.org/x/sys v0.1.0 // indirect 65 | golang.org/x/text v0.4.0 // indirect 66 | golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 // indirect 67 | golang.org/x/tools v0.2.0 // indirect 68 | google.golang.org/appengine v1.6.7 // indirect 69 | google.golang.org/genproto v0.0.0-20221014213838-99cd37c6964a // indirect 70 | google.golang.org/grpc v1.50.1 // indirect 71 | google.golang.org/protobuf v1.28.1 // indirect 72 | gopkg.in/yaml.v2 v2.4.0 // indirect 73 | gopkg.in/yaml.v3 v3.0.1 // indirect 74 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect 75 | ) 76 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/controller/http/v1/auth.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "account-management-service/internal/service" 5 | "github.com/labstack/echo/v4" 6 | "net/http" 7 | ) 8 | 9 | type authRoutes struct { 10 | authService service.Auth 11 | } 12 | 13 | func newAuthRoutes(g *echo.Group, authService service.Auth) { 14 | r := &authRoutes{ 15 | authService: authService, 16 | } 17 | 18 | g.POST("/sign-up", r.signUp) 19 | g.POST("/sign-in", r.signIn) 20 | } 21 | 22 | type signUpInput struct { 23 | Username string `json:"username" validate:"required,min=4,max=32"` 24 | Password string `json:"password" validate:"required,password"` 25 | } 26 | 27 | // @Summary Sign up 28 | // @Description Sign up 29 | // @Tags auth 30 | // @Accept json 31 | // @Produce json 32 | // @Param input body signUpInput true "input" 33 | // @Success 201 {object} v1.authRoutes.signUp.response 34 | // @Failure 400 {object} echo.HTTPError 35 | // @Failure 500 {object} echo.HTTPError 36 | // @Router /auth/sign-up [post] 37 | func (r *authRoutes) signUp(c echo.Context) error { 38 | var input signUpInput 39 | 40 | if err := c.Bind(&input); err != nil { 41 | newErrorResponse(c, http.StatusBadRequest, "invalid request body") 42 | return err 43 | } 44 | 45 | if err := c.Validate(input); err != nil { 46 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 47 | return err 48 | } 49 | 50 | id, err := r.authService.CreateUser(c.Request().Context(), service.AuthCreateUserInput{ 51 | Username: input.Username, 52 | Password: input.Password, 53 | }) 54 | if err != nil { 55 | if err == service.ErrUserAlreadyExists { 56 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 57 | return err 58 | } 59 | newErrorResponse(c, http.StatusInternalServerError, "internal server error") 60 | return err 61 | } 62 | 63 | type response struct { 64 | Id int `json:"id"` 65 | } 66 | 67 | return c.JSON(http.StatusCreated, response{ 68 | Id: id, 69 | }) 70 | } 71 | 72 | type signInInput struct { 73 | Username string `json:"username" validate:"required,min=4,max=32"` 74 | Password string `json:"password" validate:"required,password"` 75 | } 76 | 77 | // @Summary Sign in 78 | // @Description Sign in 79 | // @Tags auth 80 | // @Accept json 81 | // @Produce json 82 | // @Param input body signInInput true "input" 83 | // @Success 200 {object} v1.authRoutes.signIn.response 84 | // @Failure 400 {object} echo.HTTPError 85 | // @Failure 500 {object} echo.HTTPError 86 | // @Router /auth/sign-in [post] 87 | func (r *authRoutes) signIn(c echo.Context) error { 88 | var input signInInput 89 | 90 | if err := c.Bind(&input); err != nil { 91 | newErrorResponse(c, http.StatusBadRequest, "invalid request body") 92 | return err 93 | } 94 | 95 | if err := c.Validate(input); err != nil { 96 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 97 | return err 98 | } 99 | 100 | token, err := r.authService.GenerateToken(c.Request().Context(), service.AuthGenerateTokenInput{ 101 | Username: input.Username, 102 | Password: input.Password, 103 | }) 104 | if err != nil { 105 | if err == service.ErrUserNotFound { 106 | newErrorResponse(c, http.StatusBadRequest, "invalid username or password") 107 | return err 108 | } 109 | newErrorResponse(c, http.StatusInternalServerError, "internal server error") 110 | return err 111 | } 112 | 113 | type response struct { 114 | Token string `json:"token"` 115 | } 116 | 117 | return c.JSON(http.StatusOK, response{ 118 | Token: token, 119 | }) 120 | } 121 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "account-management-service/internal/entity" 5 | "account-management-service/internal/repo" 6 | "account-management-service/internal/webapi" 7 | "account-management-service/pkg/hasher" 8 | "context" 9 | "time" 10 | ) 11 | 12 | type AuthCreateUserInput struct { 13 | Username string 14 | Password string 15 | } 16 | 17 | type AuthGenerateTokenInput struct { 18 | Username string 19 | Password string 20 | } 21 | 22 | type Auth interface { 23 | CreateUser(ctx context.Context, input AuthCreateUserInput) (int, error) 24 | GenerateToken(ctx context.Context, input AuthGenerateTokenInput) (string, error) 25 | ParseToken(token string) (int, error) 26 | } 27 | 28 | type AccountDepositInput struct { 29 | Id int 30 | Amount int 31 | } 32 | 33 | type AccountWithdrawInput struct { 34 | Id int 35 | Amount int 36 | } 37 | 38 | type AccountTransferInput struct { 39 | From int 40 | To int 41 | Amount int 42 | } 43 | 44 | type Account interface { 45 | CreateAccount(ctx context.Context) (int, error) 46 | GetAccountById(ctx context.Context, userId int) (entity.Account, error) 47 | Deposit(ctx context.Context, input AccountDepositInput) error 48 | Withdraw(ctx context.Context, input AccountWithdrawInput) error 49 | Transfer(ctx context.Context, input AccountTransferInput) error 50 | } 51 | 52 | type Product interface { 53 | CreateProduct(ctx context.Context, name string) (int, error) 54 | GetProductById(ctx context.Context, id int) (entity.Product, error) 55 | } 56 | 57 | type ReservationCreateInput struct { 58 | AccountId int 59 | ProductId int 60 | OrderId int 61 | Amount int 62 | } 63 | 64 | type Reservation interface { 65 | CreateReservation(ctx context.Context, input ReservationCreateInput) (int, error) 66 | RefundReservationByOrderId(ctx context.Context, orderId int) error 67 | RevenueReservationByOrderId(ctx context.Context, orderId int) error 68 | } 69 | 70 | type OperationHistoryInput struct { 71 | AccountId int 72 | SortType string 73 | Offset int 74 | Limit int 75 | } 76 | 77 | type OperationHistoryOutput struct { 78 | Amount int `json:"amount"` 79 | Operation string `json:"operation"` 80 | Time time.Time `json:"time"` 81 | Product string `json:"product,omitempty"` 82 | Order *int `json:"order,omitempty"` 83 | Description string `json:"description,omitempty"` 84 | } 85 | 86 | type Operation interface { 87 | OperationHistory(ctx context.Context, input OperationHistoryInput) ([]OperationHistoryOutput, error) 88 | MakeReportLink(ctx context.Context, month, year int) (string, error) 89 | MakeReportFile(ctx context.Context, month, year int) ([]byte, error) 90 | } 91 | 92 | type Services struct { 93 | Auth Auth 94 | Account Account 95 | Product Product 96 | Reservation Reservation 97 | Operation Operation 98 | } 99 | 100 | type ServicesDependencies struct { 101 | Repos *repo.Repositories 102 | GDrive webapi.GDrive 103 | Hasher hasher.PasswordHasher 104 | 105 | SignKey string 106 | TokenTTL time.Duration 107 | } 108 | 109 | func NewServices(deps ServicesDependencies) *Services { 110 | return &Services{ 111 | Auth: NewAuthService(deps.Repos.User, deps.Hasher, deps.SignKey, deps.TokenTTL), 112 | Account: NewAccountService(deps.Repos.Account), 113 | Product: NewProductService(deps.Repos.Product), 114 | Reservation: NewReservationService(deps.Repos.Reservation), 115 | Operation: NewOperationService(deps.Repos.Operation, deps.Repos.Product, deps.GDrive), 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /solution_example/account-management-service/pkg/validator/custom.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "fmt" 5 | "github.com/go-playground/validator/v10" 6 | "reflect" 7 | "regexp" 8 | "strings" 9 | ) 10 | 11 | const ( 12 | passwordMinLength = 8 13 | passwordMaxLength = 32 14 | passwordMinLower = 1 15 | passwordMinUpper = 1 16 | passwordMinDigit = 1 17 | passwordMinSymbol = 1 18 | ) 19 | 20 | var ( 21 | lengthRegexp = regexp.MustCompile(fmt.Sprintf(`^.{%d,%d}$`, passwordMinLength, passwordMaxLength)) 22 | lowerCaseRegexp = regexp.MustCompile(fmt.Sprintf(`[a-z]{%d,}`, passwordMinLower)) 23 | upperCaseRegexp = regexp.MustCompile(fmt.Sprintf(`[A-Z]{%d,}`, passwordMinUpper)) 24 | digitRegexp = regexp.MustCompile(fmt.Sprintf(`[0-9]{%d,}`, passwordMinDigit)) 25 | symbolRegexp = regexp.MustCompile(fmt.Sprintf(`[!@#$%%^&*]{%d,}`, passwordMinSymbol)) 26 | ) 27 | 28 | type CustomValidator struct { 29 | v *validator.Validate 30 | passwdErr error 31 | } 32 | 33 | func NewCustomValidator() *CustomValidator { 34 | v := validator.New() 35 | cv := &CustomValidator{v: v} 36 | 37 | v.RegisterTagNameFunc(func(fld reflect.StructField) string { 38 | name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0] 39 | if name == "-" { 40 | return "" 41 | } 42 | return name 43 | }) 44 | 45 | err := v.RegisterValidation("password", cv.passwordValidate) 46 | if err != nil { 47 | panic(err) 48 | } 49 | 50 | return cv 51 | } 52 | 53 | func (cv *CustomValidator) Validate(i interface{}) error { 54 | err := cv.v.Struct(i) 55 | if err != nil { 56 | fieldErr := err.(validator.ValidationErrors)[0] 57 | 58 | return cv.newValidationError(fieldErr.Field(), fieldErr.Value(), fieldErr.Tag(), fieldErr.Param()) 59 | } 60 | return nil 61 | } 62 | 63 | func (cv *CustomValidator) newValidationError(field string, value interface{}, tag string, param string) error { 64 | switch tag { 65 | case "required": 66 | return fmt.Errorf("field %s is required", field) 67 | case "email": 68 | return fmt.Errorf("field %s must be a valid email address", field) 69 | case "password": 70 | return cv.passwdErr 71 | case "min": 72 | return fmt.Errorf("field %s must be at least %s characters", field, param) 73 | case "max": 74 | return fmt.Errorf("field %s must be at most %s characters", field, param) 75 | default: 76 | return fmt.Errorf("field %s is invalid", field) 77 | } 78 | } 79 | 80 | func (cv *CustomValidator) passwordValidate(fl validator.FieldLevel) bool { 81 | // check if the field is a string 82 | if fl.Field().Kind() != reflect.String { 83 | cv.passwdErr = fmt.Errorf("field %s must be a string", fl.FieldName()) 84 | return false 85 | } 86 | 87 | // get the value of the field 88 | fieldValue := fl.Field().String() 89 | 90 | // check regexp matching 91 | if ok := lengthRegexp.MatchString(fieldValue); !ok { 92 | cv.passwdErr = fmt.Errorf("field %s must be between %d and %d characters", fl.FieldName(), passwordMinLength, passwordMaxLength) 93 | return false 94 | } else if ok = lowerCaseRegexp.MatchString(fieldValue); !ok { 95 | cv.passwdErr = fmt.Errorf("field %s must contain at least %d lowercase letter(s)", fl.FieldName(), passwordMinLower) 96 | return false 97 | } else if ok = upperCaseRegexp.MatchString(fieldValue); !ok { 98 | cv.passwdErr = fmt.Errorf("field %s must contain at least %d uppercase letter(s)", fl.FieldName(), passwordMinUpper) 99 | return false 100 | } else if ok = digitRegexp.MatchString(fieldValue); !ok { 101 | cv.passwdErr = fmt.Errorf("field %s must contain at least %d digit(s)", fl.FieldName(), passwordMinDigit) 102 | return false 103 | } else if ok = symbolRegexp.MatchString(fieldValue); !ok { 104 | cv.passwdErr = fmt.Errorf("field %s must contain at least %d special character(s)", fl.FieldName(), passwordMinSymbol) 105 | return false 106 | } 107 | 108 | return true 109 | } 110 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/repo/pgdb/operation.go: -------------------------------------------------------------------------------- 1 | package pgdb 2 | 3 | import ( 4 | "account-management-service/internal/entity" 5 | "account-management-service/pkg/postgres" 6 | "context" 7 | "fmt" 8 | ) 9 | 10 | const ( 11 | maxPaginationLimit = 10 12 | defaultPaginationLimit = 10 13 | 14 | DateSortType string = "date" 15 | AmountSortType string = "amount" 16 | ) 17 | 18 | type OperationRepo struct { 19 | *postgres.Postgres 20 | } 21 | 22 | func NewOperationRepo(pg *postgres.Postgres) *OperationRepo { 23 | return &OperationRepo{pg} 24 | } 25 | 26 | func (r *OperationRepo) GetAllRevenueOperationsGroupedByProduct(ctx context.Context, month, year int) ([]string, []int, error) { 27 | sql, args, _ := r.Builder. 28 | Select("products.name", "sum(amount)"). 29 | From("operations"). 30 | InnerJoin("products on operations.product_id = products.id"). 31 | Where("operation_type = ? and extract(month from operations.created_at) = ? and extract(year from operations.created_at) = ?", entity.OperationTypeRevenue, month, year). 32 | GroupBy("products.name"). 33 | ToSql() 34 | 35 | rows, err := r.Pool.Query(ctx, sql, args...) 36 | if err != nil { 37 | return nil, nil, fmt.Errorf("OperationRepo.GetAllRevenueOperationsGroupedByProductId - r.Pool.Query: %v", err) 38 | } 39 | defer rows.Close() 40 | 41 | var productNames []string 42 | var amounts []int 43 | for rows.Next() { 44 | var productName string 45 | var amount int 46 | err = rows.Scan(&productName, &amount) 47 | if err != nil { 48 | return nil, nil, fmt.Errorf("OperationRepo.GetAllRevenueOperationsGroupedByProductId - rows.Scan: %v", err) 49 | } 50 | productNames = append(productNames, productName) 51 | amounts = append(amounts, amount) 52 | } 53 | 54 | return productNames, amounts, nil 55 | } 56 | 57 | func (r *OperationRepo) OperationsPagination(ctx context.Context, accountId int, sortType string, offset int, limit int) ([]entity.Operation, []string, error) { 58 | if limit > maxPaginationLimit { 59 | limit = maxPaginationLimit 60 | } 61 | if limit == 0 { 62 | limit = defaultPaginationLimit 63 | } 64 | 65 | var orderBySql string 66 | switch sortType { 67 | case "": 68 | orderBySql = "created_at DESC" 69 | case DateSortType: 70 | orderBySql = "created_at DESC" 71 | case AmountSortType: 72 | orderBySql = "amount DESC" 73 | default: 74 | return nil, nil, fmt.Errorf("OperationRepo.PaginationOperations: unknown sort type - %s", sortType) 75 | } 76 | 77 | sqlQuery, args, _ := r.Builder. 78 | Select("operations.id", "account_id", "amount", "operation_type", "created_at", "COALESCE((case when operations.product_id is null then null else products.name end), '') as product_name", "order_id", "COALESCE(description, '')"). 79 | From("operations"). 80 | InnerJoin("products on operations.product_id = products.id or operations.product_id is null"). 81 | Where("account_id = ?", accountId). 82 | OrderBy(orderBySql). 83 | Limit(uint64(limit)). 84 | Offset(uint64(offset)). 85 | ToSql() 86 | 87 | rows, err := r.Pool.Query(ctx, sqlQuery, args...) 88 | if err != nil { 89 | return nil, nil, fmt.Errorf("OperationRepo.paginationOperationsByDate - r.Pool.Query: %v", err) 90 | } 91 | defer rows.Close() 92 | 93 | var operations []entity.Operation 94 | var productNames []string 95 | for rows.Next() { 96 | var operation entity.Operation 97 | var productName string 98 | err = rows.Scan(&operation.Id, &operation.AccountId, &operation.Amount, &operation.OperationType, &operation.CreatedAt, &productName, &operation.OrderId, &operation.Description) 99 | if err != nil { 100 | return nil, nil, fmt.Errorf("OperationRepo.paginationOperationsByDate - rows.Scan: %v", err) 101 | } 102 | operations = append(operations, operation) 103 | productNames = append(productNames, productName) 104 | } 105 | 106 | return operations, productNames, nil 107 | } 108 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/webapi/gdrive/gdrive.go: -------------------------------------------------------------------------------- 1 | package gdrive 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "google.golang.org/api/drive/v3" 9 | "google.golang.org/api/option" 10 | ) 11 | 12 | type GDriveWebAPI struct { 13 | driveService *drive.Service 14 | isAvailable bool 15 | } 16 | 17 | var ( 18 | ErrFileNotFound = errors.New("file not found") 19 | ) 20 | 21 | func New(apiJSONFilePath string) *GDriveWebAPI { 22 | if apiJSONFilePath == "" { 23 | return &GDriveWebAPI{isAvailable: false} 24 | } 25 | 26 | driveService, err := drive.NewService(context.Background(), option.WithCredentialsFile(apiJSONFilePath)) 27 | if err != nil { 28 | panic(err) 29 | } 30 | 31 | return &GDriveWebAPI{ 32 | driveService: driveService, 33 | isAvailable: true, 34 | } 35 | } 36 | 37 | func (w *GDriveWebAPI) IsAvailable() bool { 38 | return w.isAvailable 39 | } 40 | 41 | func (w *GDriveWebAPI) UploadCSVFile(ctx context.Context, name string, data []byte) (string, error) { 42 | fileId, err := w.getFileIdByName(ctx, name) 43 | if err != nil { 44 | if !errors.Is(err, ErrFileNotFound) { 45 | return "", fmt.Errorf("GDriveWebAPI.UploadCSVFile: w.getFileIdByName: %w", err) 46 | } 47 | 48 | id, err := w.createFile(ctx, name, data) 49 | if err != nil { 50 | return "", fmt.Errorf("GDriveWebAPI.UploadCSVFile: w.createFile: %w", err) 51 | } 52 | 53 | return w.getFileURL(id), nil 54 | } 55 | 56 | err = w.updateFile(ctx, fileId, data) 57 | if err != nil { 58 | return "", fmt.Errorf("GDriveWebAPI.UploadCSVFile: w.updateFile: %w", err) 59 | } 60 | 61 | return w.getFileURL(fileId), nil 62 | } 63 | 64 | func (w *GDriveWebAPI) DeleteFile(ctx context.Context, name string) error { 65 | fileId, err := w.getFileIdByName(ctx, name) 66 | if err != nil { 67 | return fmt.Errorf("GDriveWebAPI.DeleteFile: w.getFileIdByName: %w", err) 68 | } 69 | 70 | err = w.driveService.Files.Delete(fileId).Context(ctx).Do() 71 | if err != nil { 72 | return fmt.Errorf("GDriveWebAPI.DeleteFile: w.driveService.Files.Delete: %w", err) 73 | } 74 | 75 | return nil 76 | } 77 | 78 | func (w *GDriveWebAPI) GetAllFilenames(ctx context.Context) ([]string, error) { 79 | files, err := w.getAllFiles(ctx) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | names := make([]string, 0, len(files)) 85 | for _, file := range files { 86 | names = append(names, file.Name) 87 | } 88 | 89 | return names, nil 90 | } 91 | 92 | // createFile creates a csv file in Google Drive with public read access and returns its ID and URL 93 | func (w *GDriveWebAPI) createFile(ctx context.Context, name string, content []byte) (string, error) { 94 | file := &drive.File{ 95 | Name: name, 96 | MimeType: "text/csv", 97 | } 98 | 99 | permissions := &drive.Permission{ 100 | Type: "anyone", 101 | Role: "reader", 102 | } 103 | 104 | _, err := w.driveService.Files.Create(file).Context(ctx).Media(bytes.NewReader(content)).Do() 105 | if err != nil { 106 | return "", err 107 | } 108 | 109 | fileId, err := w.getFileIdByName(ctx, name) 110 | if err != nil { 111 | return "", err 112 | } 113 | 114 | _, err = w.driveService.Permissions.Create(fileId, permissions).Context(ctx).Do() 115 | if err != nil { 116 | return "", err 117 | } 118 | 119 | return fileId, nil 120 | } 121 | 122 | func (w *GDriveWebAPI) updateFile(ctx context.Context, id string, content []byte) error { 123 | _, err := w.driveService.Files.Update(id, &drive.File{}).Context(ctx).Media(bytes.NewReader(content)).Do() 124 | 125 | return err 126 | } 127 | 128 | func (w *GDriveWebAPI) getFileURL(id string) string { 129 | return fmt.Sprintf("https://drive.google.com/file/d/%s/view?usp=sharing", id) 130 | } 131 | 132 | func (w *GDriveWebAPI) getAllFiles(ctx context.Context) ([]*drive.File, error) { 133 | r, err := w.driveService.Files.List().Context(ctx).Do() 134 | if err != nil { 135 | return nil, err 136 | } 137 | 138 | return r.Files, nil 139 | } 140 | 141 | func (w *GDriveWebAPI) getFileIdByName(ctx context.Context, name string) (string, error) { 142 | files, err := w.getAllFiles(ctx) 143 | if err != nil { 144 | return "", err 145 | } 146 | 147 | for _, file := range files { 148 | if file.Name == name { 149 | return file.Id, nil 150 | } 151 | } 152 | 153 | return "", ErrFileNotFound 154 | } 155 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/controller/http/v1/operation.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "account-management-service/internal/service" 5 | "github.com/labstack/echo/v4" 6 | log "github.com/sirupsen/logrus" 7 | "net/http" 8 | ) 9 | 10 | type operationRoutes struct { 11 | service.Operation 12 | } 13 | 14 | func newOperationRoutes(g *echo.Group, operationService service.Operation) *operationRoutes { 15 | r := &operationRoutes{ 16 | Operation: operationService, 17 | } 18 | 19 | g.GET("/history", r.getHistory) 20 | g.GET("/report-link", r.getReportLink) 21 | g.GET("/report-file", r.getReportFile) 22 | 23 | return r 24 | } 25 | 26 | type getHistoryInput struct { 27 | AccountId int `json:"account_id" validate:"required"` 28 | SortType string `json:"sort_type,omitempty"` 29 | Offset int `json:"offset,omitempty"` 30 | Limit int `json:"limit,omitempty"` 31 | } 32 | 33 | // @Summary Get history 34 | // @Description Get history of operations 35 | // @Tags operations 36 | // @Accept json 37 | // @Produce json 38 | // @Param input body getHistoryInput true "input" 39 | // @Success 200 {object} v1.operationRoutes.getHistory.response 40 | // @Failure 400 {object} echo.HTTPError 41 | // @Failure 500 {object} echo.HTTPError 42 | // @Security JWT 43 | // @Router /api/v1/operations/history [get] 44 | func (r *operationRoutes) getHistory(c echo.Context) error { 45 | var input getHistoryInput 46 | 47 | if err := c.Bind(&input); err != nil { 48 | newErrorResponse(c, http.StatusBadRequest, "invalid request body") 49 | return err 50 | } 51 | 52 | if err := c.Validate(input); err != nil { 53 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 54 | return err 55 | } 56 | 57 | operations, err := r.Operation.OperationHistory(c.Request().Context(), service.OperationHistoryInput{ 58 | AccountId: input.AccountId, 59 | SortType: input.SortType, 60 | Offset: input.Offset, 61 | Limit: input.Limit, 62 | }) 63 | if err != nil { 64 | log.Debugf("error while getting operation history: %s", err.Error()) 65 | newErrorResponse(c, http.StatusInternalServerError, "internal server error") 66 | return err 67 | } 68 | 69 | type response struct { 70 | Operations []service.OperationHistoryOutput `json:"operations"` 71 | } 72 | 73 | return c.JSON(http.StatusOK, response{ 74 | Operations: operations, 75 | }) 76 | } 77 | 78 | type getReportInput struct { 79 | Month int `json:"month" validate:"required"` 80 | Year int `json:"year" validate:"required"` 81 | } 82 | 83 | // @Summary Get report link 84 | // @Description Get link to report 85 | // @Tags operations 86 | // @Accept json 87 | // @Produce json 88 | // @Param input body getReportInput true "input" 89 | // @Success 200 {object} v1.operationRoutes.getReportLink.response 90 | // @Failure 400 {object} echo.HTTPError 91 | // @Failure 500 {object} echo.HTTPError 92 | // @Security JWT 93 | // @Router /api/v1/operations/report-link [get] 94 | func (r *operationRoutes) getReportLink(c echo.Context) error { 95 | var input getReportInput 96 | 97 | if err := c.Bind(&input); err != nil { 98 | newErrorResponse(c, http.StatusBadRequest, "invalid request body") 99 | return err 100 | } 101 | 102 | if err := c.Validate(input); err != nil { 103 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 104 | return err 105 | } 106 | 107 | link, err := r.Operation.MakeReportLink(c.Request().Context(), input.Month, input.Year) 108 | if err != nil { 109 | log.Debugf("error while getting report link: %s", err.Error()) 110 | newErrorResponse(c, http.StatusInternalServerError, "internal server error") 111 | return err 112 | } 113 | 114 | type response struct { 115 | Link string `json:"link"` 116 | } 117 | 118 | return c.JSON(http.StatusOK, response{ 119 | Link: link, 120 | }) 121 | } 122 | 123 | // @Summary Get report file 124 | // @Description Get report file 125 | // @Tags operations 126 | // @Accept json 127 | // @Produce text/csv 128 | // @Param input body getReportInput true "input" 129 | // @Success 200 130 | // @Failure 400 {object} echo.HTTPError 131 | // @Failure 500 {object} echo.HTTPError 132 | // @Security JWT 133 | // @Router /api/v1/operations/report-file [get] 134 | func (r *operationRoutes) getReportFile(c echo.Context) error { 135 | var input getReportInput 136 | 137 | if err := c.Bind(&input); err != nil { 138 | newErrorResponse(c, http.StatusBadRequest, "invalid request body") 139 | return err 140 | } 141 | 142 | if err := c.Validate(input); err != nil { 143 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 144 | return err 145 | } 146 | 147 | file, err := r.Operation.MakeReportFile(c.Request().Context(), input.Month, input.Year) 148 | if err != nil { 149 | log.Debugf("error while getting report file: %s", err.Error()) 150 | newErrorResponse(c, http.StatusInternalServerError, "internal server error") 151 | return err 152 | } 153 | 154 | return c.Blob(http.StatusOK, "text/csv", file) 155 | } 156 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/controller/http/v1/reservation.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "account-management-service/internal/service" 5 | "github.com/labstack/echo/v4" 6 | "net/http" 7 | ) 8 | 9 | type reservationRoutes struct { 10 | reservationService service.Reservation 11 | } 12 | 13 | func newReservationRoutes(g *echo.Group, reservationService service.Reservation) { 14 | r := &reservationRoutes{ 15 | reservationService: reservationService, 16 | } 17 | 18 | g.POST("/create", r.create) 19 | g.POST("/revenue", r.revenue) 20 | g.POST("/refund", r.refund) 21 | } 22 | 23 | type reservationCreateInput struct { 24 | AccountId int `json:"account_id" validate:"required"` 25 | ProductId int `json:"product_id" validate:"required"` 26 | OrderId int `json:"order_id" validate:"required"` 27 | Amount int `json:"amount" validate:"required"` 28 | } 29 | 30 | // @Summary Create reservation 31 | // @Description Create reservation 32 | // @Tags reservations 33 | // @Accept json 34 | // @Produce json 35 | // @Param input body reservationCreateInput true "input" 36 | // @Success 201 {object} v1.reservationRoutes.create.response 37 | // @Failure 400 {object} echo.HTTPError 38 | // @Failure 500 {object} echo.HTTPError 39 | // @Security JWT 40 | // @Router /api/v1/reservations/create [post] 41 | func (r *reservationRoutes) create(c echo.Context) error { 42 | var input reservationCreateInput 43 | 44 | if err := c.Bind(&input); err != nil { 45 | newErrorResponse(c, http.StatusBadRequest, "invalid request body") 46 | return err 47 | } 48 | 49 | if err := c.Validate(input); err != nil { 50 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 51 | return err 52 | } 53 | 54 | id, err := r.reservationService.CreateReservation(c.Request().Context(), service.ReservationCreateInput{ 55 | AccountId: input.AccountId, 56 | ProductId: input.ProductId, 57 | OrderId: input.OrderId, 58 | Amount: input.Amount, 59 | }) 60 | if err != nil { 61 | if err == service.ErrCannotCreateReservation { 62 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 63 | return err 64 | } 65 | newErrorResponse(c, http.StatusInternalServerError, "internal server error") 66 | return err 67 | } 68 | 69 | type response struct { 70 | Id int `json:"id"` 71 | } 72 | 73 | return c.JSON(http.StatusCreated, response{ 74 | Id: id, 75 | }) 76 | } 77 | 78 | type reservationRevenueInput struct { 79 | AccountId int `json:"account_id" validate:"required"` 80 | ProductId int `json:"product_id" validate:"required"` 81 | OrderId int `json:"order_id" validate:"required"` 82 | Amount int `json:"amount" validate:"required"` 83 | } 84 | 85 | // @Summary Revenue reservation 86 | // @Description Revenue reservation 87 | // @Tags reservations 88 | // @Accept json 89 | // @Produce json 90 | // @Param input body reservationRevenueInput true "input" 91 | // @Success 200 92 | // @Failure 400 {object} echo.HTTPError 93 | // @Failure 500 {object} echo.HTTPError 94 | // @Security JWT 95 | // @Router /api/v1/reservations/revenue [post] 96 | func (r *reservationRoutes) revenue(c echo.Context) error { 97 | var input reservationRevenueInput 98 | 99 | if err := c.Bind(&input); err != nil { 100 | newErrorResponse(c, http.StatusBadRequest, "invalid request body") 101 | return err 102 | } 103 | 104 | if err := c.Validate(input); err != nil { 105 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 106 | return err 107 | } 108 | 109 | err := r.reservationService.RevenueReservationByOrderId(c.Request().Context(), input.OrderId) 110 | if err != nil { 111 | newErrorResponse(c, http.StatusInternalServerError, "internal server error") 112 | return err 113 | } 114 | 115 | return c.JSON(http.StatusOK, map[string]interface{}{ 116 | "message": "success", 117 | }) 118 | } 119 | 120 | type reservationRefundInput struct { 121 | OrderId int `json:"order_id" validate:"required"` 122 | } 123 | 124 | // @Summary Refund reservation 125 | // @Description Refund reservation 126 | // @Tags reservations 127 | // @Accept json 128 | // @Produce json 129 | // @Param input body reservationRefundInput true "input" 130 | // @Success 200 131 | // @Failure 400 {object} echo.HTTPError 132 | // @Failure 500 {object} echo.HTTPError 133 | // @Security JWT 134 | // @Router /api/v1/reservations/refund [post] 135 | func (r *reservationRoutes) refund(c echo.Context) error { 136 | var input reservationRefundInput 137 | 138 | if err := c.Bind(&input); err != nil { 139 | newErrorResponse(c, http.StatusBadRequest, "invalid request body") 140 | return err 141 | } 142 | 143 | if err := c.Validate(input); err != nil { 144 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 145 | return err 146 | } 147 | 148 | err := r.reservationService.RefundReservationByOrderId(c.Request().Context(), input.OrderId) 149 | if err != nil { 150 | newErrorResponse(c, http.StatusInternalServerError, "internal server error") 151 | return err 152 | } 153 | 154 | return c.JSON(http.StatusOK, map[string]interface{}{ 155 | "message": "success", 156 | }) 157 | } 158 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Тестовое задание на позицию стажёра-бэкендера + разбор 2 | 3 | ## Микросервис для работы с балансом пользователей 4 | 5 | **Проблема:** 6 | 7 | В нашей компании есть много различных микросервисов. Многие из них так или иначе хотят взаимодействовать с балансом пользователя. На архитектурном комитете приняли решение централизовать работу с балансом пользователя в отдельный сервис. 8 | 9 | **Задача:** 10 | 11 | Необходимо реализовать микросервис для работы с балансом пользователей (зачисление средств, списание средств, перевод средств от пользователя к пользователю, а также метод получения баланса пользователя). Сервис должен предоставлять HTTP API и принимать/отдавать запросы/ответы в формате JSON. 12 | 13 | **Сценарии использования:** 14 | 15 | Далее описаны несколько упрощенных кейсов приближенных к реальности. 16 | 1. Сервис биллинга с помощью внешних мерчантов (аля через visa/mastercard) обработал зачисление денег на наш счет. Теперь биллингу нужно добавить эти деньги на баланс пользователя. 17 | 2. Пользователь хочет купить у нас какую-то услугу. Для этого у нас есть специальный сервис управления услугами, который перед применением услуги резервирует деньги на отдельном счете и потом списывает в доход компании. 18 | 3. Бухгалтерия раз в месяц хочет получить сводный отчет по всем пользователям в разрезе каждой услуги. 19 | 20 | 21 | **Требования к сервису:** 22 | 23 | 1. Сервис должен предоставлять HTTP API с форматом JSON как при отправке запроса, так и при получении результата. 24 | 2. Язык разработки: Golang. 25 | 2. Фреймворки и библиотеки можно использовать любые. 26 | 3. Реляционная СУБД: MySQL или PostgreSQL. 27 | 4. Использование docker и docker-compose для поднятия и развертывания dev-среды. 28 | 4. Весь код должен быть выложен на Github с Readme файлом с инструкцией по запуску и примерами запросов/ответов (можно просто описать в Readme методы, можно через Postman, можно в Readme curl запросы скопировать, и так далее). 29 | 5. Если есть потребность в асинхронных сценариях, то использование любых систем очередей - допускается. 30 | 6. При возникновении вопросов по ТЗ оставляем принятие решения за кандидатом (в таком случае Readme файле к проекту должен быть указан список вопросов с которыми кандидат столкнулся и каким образом он их решил). 31 | 7. Разработка интерфейса в браузере НЕ ТРЕБУЕТСЯ. Взаимодействие с API предполагается посредством запросов из кода другого сервиса. Для тестирования можно использовать любой удобный инструмент. Например: в терминале через curl или Postman. 32 | 33 | **Будет плюсом:** 34 | 35 | 1. Покрытие кода тестами. 36 | 2. [Swagger](https://swagger.io/solutions/api-design/) файл для вашего API. 37 | 3. Реализовать сценарий разрезервирования денег, если услугу применить не удалось. 38 | 39 | **Основное задание (минимум):** 40 | 41 | Метод начисления средств на баланс. Принимает id пользователя и сколько средств зачислить. 42 | Метод резервирования средств с основного баланса на отдельном счете. Принимает id пользователя, ИД услуги, ИД заказа, стоимость. 43 | Метод признания выручки – списывает из резерва деньги, добавляет данные в отчет для бухгалтерии. Принимает id пользователя, ИД услуги, ИД заказа, сумму. 44 | Метод получения баланса пользователя. Принимает id пользователя. 45 | 46 | **Детали по заданию:** 47 | 48 | 1. По умолчанию сервис не содержит в себе никаких данных о балансах (пустая табличка в БД). Данные о балансе появляются при первом зачислении денег. 49 | 2. Валидацию данных и обработку ошибок оставляем на усмотрение кандидата. 50 | 3. Список полей к методам не фиксированный. Перечислен лишь необходимый минимум. В рамках выполнения доп. заданий возможны дополнительные поля. 51 | 4. Механизм миграции не нужен. Достаточно предоставить конечный SQL файл с созданием всех необходимых таблиц в БД. 52 | 5. Баланс пользователя - очень важные данные в которых недопустимы ошибки (фактически мы работаем тут с реальными деньгами). Необходимо всегда держать баланс в актуальном состоянии и не допускать ситуаций когда баланс может уйти в минус. 53 | 6. Мультивалютность реализовывать не требуется. 54 | 55 | **Дополнительные задания** 56 | 57 | Далее перечислены дополнительные задания. Они не являются обязательными, но их выполнение даст существенный плюс перед другими кандидатами. 58 | 59 | *Доп. задание 1:* 60 | 61 | Бухгалтерия раз в месяц просит предоставить сводный отчет по всем пользователем, с указанием сумм выручки по каждой из предоставленной услуги для расчета и уплаты налогов. 62 | 63 | Задача: реализовать метод для получения месячного отчета. На вход: год-месяц. На выходе ссылка на CSV файл. 64 | 65 | Пример отчета: 66 | 67 | название услуги 1;общая сумма выручки за отчетный период 68 | 69 | название услуги 2;общая сумма выручки за отчетный период 70 | 71 | *Доп. задание 2:* 72 | 73 | Пользователи жалуются, что не понимают за что были списаны (или зачислены) средства. 74 | 75 | Задача: необходимо предоставить метод получения списка транзакций с комментариями откуда и зачем были начислены/списаны средства с баланса. Необходимо предусмотреть пагинацию и сортировку по сумме и дате. 76 | 77 | **Разбор задания:** 78 | 79 | Пример решения тестового задания можно найти в папке `solution_example` 80 | 81 | Что в этом решении хорошего: 82 | - Выполнены все дополнительные задания (тестирование не полностью) 83 | - Хорошая структура проекта 84 | - Понятный нейминг объектов 85 | - Конфигурация вынесена в отдельное место (`config/`) 86 | - Учтена транзакционная история работы с балансом 87 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/repo/pgdb/account.go: -------------------------------------------------------------------------------- 1 | package pgdb 2 | 3 | import ( 4 | "account-management-service/internal/entity" 5 | "account-management-service/internal/repo/repoerrs" 6 | "account-management-service/pkg/postgres" 7 | "context" 8 | "errors" 9 | "fmt" 10 | "github.com/Masterminds/squirrel" 11 | "github.com/jackc/pgx/v5" 12 | "github.com/jackc/pgx/v5/pgconn" 13 | log "github.com/sirupsen/logrus" 14 | ) 15 | 16 | type AccountRepo struct { 17 | *postgres.Postgres 18 | } 19 | 20 | func NewAccountRepo(pg *postgres.Postgres) *AccountRepo { 21 | return &AccountRepo{pg} 22 | } 23 | 24 | func (r *AccountRepo) CreateAccount(ctx context.Context) (int, error) { 25 | sql, args, _ := r.Builder. 26 | Insert("accounts"). 27 | Values(squirrel.Expr("DEFAULT")). 28 | Suffix("RETURNING id"). 29 | ToSql() 30 | 31 | var id int 32 | err := r.Pool.QueryRow(ctx, sql, args...).Scan(&id) 33 | if err != nil { 34 | log.Debugf("err: %v", err) 35 | var pgErr *pgconn.PgError 36 | if ok := errors.As(err, &pgErr); ok { 37 | if pgErr.Code == "23505" { 38 | return 0, repoerrs.ErrAlreadyExists 39 | } 40 | } 41 | return 0, fmt.Errorf("AccountRepo.CreateAccount - r.Pool.QueryRow: %v", err) 42 | } 43 | 44 | return id, nil 45 | } 46 | 47 | func (r *AccountRepo) GetAccountById(ctx context.Context, id int) (entity.Account, error) { 48 | sql, args, _ := r.Builder. 49 | Select("*"). 50 | From("accounts"). 51 | Where("id = ?", id). 52 | ToSql() 53 | 54 | var account entity.Account 55 | err := r.Pool.QueryRow(ctx, sql, args...).Scan( 56 | &account.Id, 57 | &account.Balance, 58 | &account.CreatedAt, 59 | ) 60 | if err != nil { 61 | if errors.Is(err, pgx.ErrNoRows) { 62 | return entity.Account{}, repoerrs.ErrNotFound 63 | } 64 | return entity.Account{}, fmt.Errorf("AccountRepo.GetAccountById - r.Pool.QueryRow: %v", err) 65 | } 66 | 67 | return account, nil 68 | } 69 | 70 | func (r *AccountRepo) Deposit(ctx context.Context, id, amount int) error { 71 | tx, err := r.Pool.Begin(ctx) 72 | if err != nil { 73 | return fmt.Errorf("AccountRepo.Deposit - r.Pool.Begin: %v", err) 74 | } 75 | defer func() { _ = tx.Rollback(ctx) }() 76 | 77 | sql, args, _ := r.Builder. 78 | Update("accounts"). 79 | Set("balance", squirrel.Expr("balance + ?", amount)). 80 | Where("id = ?", id). 81 | ToSql() 82 | 83 | _, err = tx.Exec(ctx, sql, args...) 84 | if err != nil { 85 | return fmt.Errorf("AccountRepo.Deposit - tx.Exec: %v", err) 86 | } 87 | 88 | sql, args, _ = r.Builder. 89 | Insert("operations"). 90 | Columns("account_id", "amount", "operation_type"). 91 | Values(id, amount, entity.OperationTypeDeposit). 92 | ToSql() 93 | 94 | _, err = tx.Exec(ctx, sql, args...) 95 | if err != nil { 96 | return fmt.Errorf("AccountRepo.Deposit - tx.Exec: %v", err) 97 | } 98 | 99 | err = tx.Commit(ctx) 100 | if err != nil { 101 | return fmt.Errorf("AccountRepo.Deposit - tx.Commit: %v", err) 102 | } 103 | 104 | return nil 105 | } 106 | 107 | func (r *AccountRepo) Withdraw(ctx context.Context, id, amount int) error { 108 | tx, err := r.Pool.Begin(ctx) 109 | if err != nil { 110 | return fmt.Errorf("AccountRepo.Withdraw - r.Pool.Begin: %v", err) 111 | } 112 | defer func() { _ = tx.Rollback(ctx) }() 113 | 114 | // check if account has enough balance to withdraw 115 | sql, args, _ := r.Builder. 116 | Select("balance"). 117 | From("accounts"). 118 | Where("id = ?", id). 119 | ToSql() 120 | 121 | var balance int 122 | err = tx.QueryRow(ctx, sql, args...).Scan(&balance) 123 | if err != nil { 124 | return fmt.Errorf("AccountRepo.Withdraw - tx.QueryRow: %v", err) 125 | } 126 | 127 | if balance < amount { 128 | return repoerrs.ErrNotEnoughBalance 129 | } 130 | 131 | sql, args, _ = r.Builder. 132 | Update("accounts"). 133 | Set("balance", squirrel.Expr("balance - ?", amount)). 134 | Where("id = ?", id). 135 | ToSql() 136 | 137 | _, err = tx.Exec(ctx, sql, args...) 138 | if err != nil { 139 | return fmt.Errorf("AccountRepo.Withdraw - tx.Exec: %v", err) 140 | } 141 | 142 | sql, args, _ = r.Builder. 143 | Insert("operations"). 144 | Columns("account_id", "amount", "operation_type"). 145 | Values(id, amount, entity.OperationTypeWithdraw). 146 | ToSql() 147 | 148 | _, err = tx.Exec(ctx, sql, args...) 149 | if err != nil { 150 | return fmt.Errorf("AccountRepo.Withdraw - tx.Exec: %v", err) 151 | } 152 | 153 | err = tx.Commit(ctx) 154 | if err != nil { 155 | return fmt.Errorf("AccountRepo.Withdraw - tx.Commit: %v", err) 156 | } 157 | 158 | return nil 159 | } 160 | 161 | func (r *AccountRepo) Transfer(ctx context.Context, from, to, amount int) error { 162 | tx, err := r.Pool.Begin(ctx) 163 | if err != nil { 164 | return fmt.Errorf("AccountRepo.Transfer - r.Pool.Begin: %v", err) 165 | } 166 | defer func() { _ = tx.Rollback(ctx) }() 167 | 168 | // check if account 'from' has enough balance to transfer 169 | sql, args, _ := r.Builder. 170 | Select("balance"). 171 | From("accounts"). 172 | Where("id = ?", from). 173 | ToSql() 174 | 175 | var balance int 176 | err = tx.QueryRow(ctx, sql, args...).Scan(&balance) 177 | if err != nil { 178 | return fmt.Errorf("AccountRepo.Transfer - tx.QueryRow: %v", err) 179 | } 180 | 181 | if balance < amount { 182 | return repoerrs.ErrNotEnoughBalance 183 | } 184 | 185 | sql, args, _ = r.Builder. 186 | Update("accounts"). 187 | Set("balance", squirrel.Expr("balance - ?", amount)). 188 | Where("id = ?", from). 189 | ToSql() 190 | 191 | _, err = tx.Exec(ctx, sql, args...) 192 | if err != nil { 193 | return fmt.Errorf("AccountRepo.Transfer - tx.Exec: %v", err) 194 | } 195 | 196 | sql, args, _ = r.Builder. 197 | Update("accounts"). 198 | Set("balance", squirrel.Expr("balance + ?", amount)). 199 | Where("id = ?", to). 200 | ToSql() 201 | 202 | _, err = tx.Exec(ctx, sql, args...) 203 | if err != nil { 204 | return fmt.Errorf("AccountRepo.Transfer - tx.Exec: %v", err) 205 | } 206 | 207 | sql, args, _ = r.Builder. 208 | Insert("operations"). 209 | Columns("account_id", "amount", "operation_type"). 210 | Values(from, amount, entity.OperationTypeTransferFrom). 211 | ToSql() 212 | 213 | _, err = tx.Exec(ctx, sql, args...) 214 | if err != nil { 215 | return fmt.Errorf("AccountRepo.Transfer - tx.Exec: %v", err) 216 | } 217 | 218 | sql, args, _ = r.Builder. 219 | Insert("operations"). 220 | Columns("account_id", "amount", "operation_type"). 221 | Values(to, amount, entity.OperationTypeTransferTo). 222 | ToSql() 223 | 224 | _, err = tx.Exec(ctx, sql, args...) 225 | if err != nil { 226 | return fmt.Errorf("AccountRepo.Transfer - tx.Exec: %v", err) 227 | } 228 | 229 | err = tx.Commit(ctx) 230 | if err != nil { 231 | return fmt.Errorf("AccountRepo.Transfer - tx.Commit: %v", err) 232 | } 233 | 234 | return nil 235 | } 236 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/controller/http/v1/account.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "account-management-service/internal/service" 5 | "github.com/labstack/echo/v4" 6 | "net/http" 7 | ) 8 | 9 | type accountRoutes struct { 10 | accountService service.Account 11 | } 12 | 13 | func newAccountRoutes(g *echo.Group, accountService service.Account) { 14 | r := &accountRoutes{ 15 | accountService: accountService, 16 | } 17 | 18 | g.POST("/create", r.create) 19 | g.POST("/deposit", r.deposit) // POST, а не PUT, потому что неидемпотентно 20 | g.POST("/withdraw", r.withdraw) 21 | g.POST("/transfer", r.transfer) 22 | g.GET("/", r.getBalance) 23 | } 24 | 25 | // @Summary Create account 26 | // @Description Create account 27 | // @Tags accounts 28 | // @Accept json 29 | // @Produce json 30 | // @Success 201 {object} v1.accountRoutes.create.response 31 | // @Failure 400 {object} echo.HTTPError 32 | // @Failure 500 {object} echo.HTTPError 33 | // @Security JWT 34 | // @Router /api/v1/accounts/create [post] 35 | func (r *accountRoutes) create(c echo.Context) error { 36 | id, err := r.accountService.CreateAccount(c.Request().Context()) 37 | if err != nil { 38 | if err == service.ErrAccountAlreadyExists { 39 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 40 | return err 41 | } 42 | newErrorResponse(c, http.StatusInternalServerError, "internal server error") 43 | return err 44 | } 45 | 46 | type response struct { 47 | Id int `json:"id"` 48 | } 49 | 50 | return c.JSON(http.StatusCreated, response{ 51 | Id: id, 52 | }) 53 | } 54 | 55 | type accountDepositInput struct { 56 | Id int `json:"id" validate:"required"` 57 | Amount int `json:"amount" validate:"required"` 58 | } 59 | 60 | // @Summary Deposit 61 | // @Description Deposit 62 | // @Tags accounts 63 | // @Accept json 64 | // @Produce json 65 | // @Param input body v1.accountDepositInput true "input" 66 | // @Success 200 67 | // @Failure 400 {object} echo.HTTPError 68 | // @Failure 500 {object} echo.HTTPError 69 | // @Security JWT 70 | // @Router /api/v1/accounts/deposit [post] 71 | func (r *accountRoutes) deposit(c echo.Context) error { 72 | var input accountDepositInput 73 | 74 | if err := c.Bind(&input); err != nil { 75 | newErrorResponse(c, http.StatusBadRequest, "invalid request body") 76 | return err 77 | } 78 | 79 | if err := c.Validate(input); err != nil { 80 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 81 | return err 82 | } 83 | 84 | err := r.accountService.Deposit(c.Request().Context(), service.AccountDepositInput{ 85 | Id: input.Id, 86 | Amount: input.Amount, 87 | }) 88 | if err != nil { 89 | if err == service.ErrAccountNotFound { 90 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 91 | return err 92 | } 93 | newErrorResponse(c, http.StatusInternalServerError, "internal server error") 94 | return err 95 | } 96 | 97 | return c.JSON(http.StatusOK, map[string]interface{}{ 98 | "message": "success", 99 | }) 100 | } 101 | 102 | type accountWithdrawInput struct { 103 | Id int `json:"id" validate:"required"` 104 | Amount int `json:"amount" validate:"required"` 105 | } 106 | 107 | // @Summary Withdraw 108 | // @Description Withdraw 109 | // @Tags accounts 110 | // @Accept json 111 | // @Produce json 112 | // @Param input body v1.accountWithdrawInput true "input" 113 | // @Success 200 114 | // @Failure 400 {object} echo.HTTPError 115 | // @Failure 500 {object} echo.HTTPError 116 | // @Security JWT 117 | // @Router /api/v1/accounts/withdraw [post] 118 | func (r *accountRoutes) withdraw(c echo.Context) error { 119 | var input accountWithdrawInput 120 | 121 | if err := c.Bind(&input); err != nil { 122 | newErrorResponse(c, http.StatusBadRequest, "invalid request body") 123 | return err 124 | } 125 | 126 | if err := c.Validate(input); err != nil { 127 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 128 | return err 129 | } 130 | 131 | err := r.accountService.Withdraw(c.Request().Context(), service.AccountWithdrawInput{ 132 | Id: input.Id, 133 | Amount: input.Amount, 134 | }) 135 | if err != nil { 136 | if err == service.ErrAccountNotFound { 137 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 138 | return err 139 | } 140 | newErrorResponse(c, http.StatusInternalServerError, "internal server error") 141 | return err 142 | } 143 | 144 | return c.JSON(http.StatusOK, map[string]interface{}{ 145 | "message": "success", 146 | }) 147 | } 148 | 149 | type accountTransferInput struct { 150 | From int `json:"from" validate:"required"` 151 | To int `json:"to" validate:"required"` 152 | Amount int `json:"amount" validate:"required"` 153 | } 154 | 155 | // @Summary Transfer 156 | // @Description Transfer 157 | // @Tags accounts 158 | // @Accept json 159 | // @Produce json 160 | // @Param input body v1.accountTransferInput true "input" 161 | // @Success 200 162 | // @Failure 400 {object} echo.HTTPError 163 | // @Failure 500 {object} echo.HTTPError 164 | // @Security JWT 165 | // @Router /api/v1/accounts/transfer [post] 166 | func (r *accountRoutes) transfer(c echo.Context) error { 167 | var input accountTransferInput 168 | 169 | if err := c.Bind(&input); err != nil { 170 | newErrorResponse(c, http.StatusBadRequest, "invalid request body") 171 | return err 172 | } 173 | 174 | if err := c.Validate(input); err != nil { 175 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 176 | return err 177 | } 178 | 179 | err := r.accountService.Transfer(c.Request().Context(), service.AccountTransferInput{ 180 | From: input.From, 181 | To: input.To, 182 | Amount: input.Amount, 183 | }) 184 | if err != nil { 185 | if err == service.ErrAccountNotFound { 186 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 187 | return err 188 | } 189 | newErrorResponse(c, http.StatusInternalServerError, "internal server error") 190 | return err 191 | } 192 | 193 | return c.JSON(http.StatusOK, map[string]interface{}{ 194 | "message": "success", 195 | }) 196 | } 197 | 198 | type getBalanceInput struct { 199 | Id int `json:"id" validate:"required"` 200 | } 201 | 202 | // @Summary Get balance 203 | // @Description Get balance 204 | // @Tags accounts 205 | // @Accept json 206 | // @Produce json 207 | // @Param input body v1.getBalanceInput true "input" 208 | // @Success 200 {object} v1.accountRoutes.getBalance.response 209 | // @Failure 400 {object} echo.HTTPError 210 | // @Failure 500 {object} echo.HTTPError 211 | // @Security JWT 212 | // @Router /api/v1/accounts/ [get] 213 | func (r *accountRoutes) getBalance(c echo.Context) error { 214 | var input getBalanceInput 215 | 216 | if err := c.Bind(&input); err != nil { 217 | newErrorResponse(c, http.StatusBadRequest, "invalid request body") 218 | return err 219 | } 220 | 221 | if err := c.Validate(input); err != nil { 222 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 223 | return err 224 | } 225 | 226 | account, err := r.accountService.GetAccountById(c.Request().Context(), input.Id) 227 | if err != nil { 228 | if err == service.ErrAccountNotFound { 229 | newErrorResponse(c, http.StatusBadRequest, err.Error()) 230 | return err 231 | } 232 | newErrorResponse(c, http.StatusInternalServerError, "internal server error") 233 | return err 234 | } 235 | 236 | type response struct { 237 | Id int `json:"id"` 238 | Balance int `json:"balance"` 239 | } 240 | 241 | return c.JSON(http.StatusOK, response{ 242 | Id: account.Id, 243 | Balance: account.Balance, 244 | }) 245 | } 246 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/service/operation_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "account-management-service/internal/entity" 5 | "account-management-service/internal/mocks/repomocks" 6 | "account-management-service/internal/mocks/webapimocks" 7 | "context" 8 | "errors" 9 | "github.com/golang/mock/gomock" 10 | "github.com/stretchr/testify/assert" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | func TestOperationService_OperationHistory(t *testing.T) { 16 | type args struct { 17 | ctx context.Context 18 | input OperationHistoryInput 19 | } 20 | 21 | type MockBehavior func(o *repomocks.MockOperation, p *repomocks.MockProduct, g *webapimocks.MockGDrive, args args) 22 | 23 | testCases := []struct { 24 | name string 25 | args args 26 | mockBehavior MockBehavior 27 | want []OperationHistoryOutput 28 | wantErr bool 29 | }{ 30 | { 31 | name: "OK", 32 | args: args{ 33 | ctx: context.Background(), 34 | input: OperationHistoryInput{ 35 | AccountId: 1, 36 | }, 37 | }, 38 | mockBehavior: func(o *repomocks.MockOperation, p *repomocks.MockProduct, g *webapimocks.MockGDrive, args args) { 39 | o.EXPECT().OperationsPagination(args.ctx, args.input.AccountId, args.input.SortType, args.input.Offset, args.input.Limit). 40 | Return([]entity.Operation{ 41 | { 42 | Id: 1, 43 | AccountId: 1, 44 | Amount: 100, 45 | OperationType: entity.OperationTypeDeposit, 46 | CreatedAt: time.UnixMilli(123456), 47 | ProductId: nil, 48 | OrderId: nil, 49 | Description: "", 50 | }, 51 | }, []string{ 52 | "some product name", 53 | }, nil) 54 | }, 55 | want: []OperationHistoryOutput{ 56 | { 57 | Amount: 100, 58 | Operation: "deposit", 59 | Time: time.UnixMilli(123456), 60 | Product: "some product name", 61 | Order: nil, 62 | Description: "", 63 | }, 64 | }, 65 | wantErr: false, 66 | }, 67 | { 68 | name: "operations pagination error", 69 | args: args{ 70 | ctx: context.Background(), 71 | input: OperationHistoryInput{ 72 | AccountId: 1, 73 | }, 74 | }, 75 | mockBehavior: func(o *repomocks.MockOperation, p *repomocks.MockProduct, g *webapimocks.MockGDrive, args args) { 76 | o.EXPECT().OperationsPagination(args.ctx, args.input.AccountId, args.input.SortType, args.input.Offset, args.input.Limit). 77 | Return(nil, nil, errors.New("some error")) 78 | }, 79 | want: nil, 80 | wantErr: true, 81 | }, 82 | } 83 | 84 | for _, tc := range testCases { 85 | t.Run(tc.name, func(t *testing.T) { 86 | // init deps 87 | ctrl := gomock.NewController(t) 88 | defer ctrl.Finish() 89 | 90 | // init mocks 91 | operationRepo := repomocks.NewMockOperation(ctrl) 92 | productRepo := repomocks.NewMockProduct(ctrl) 93 | gDrive := webapimocks.NewMockGDrive(ctrl) 94 | tc.mockBehavior(operationRepo, productRepo, gDrive, tc.args) 95 | 96 | // init service 97 | s := NewOperationService(operationRepo, productRepo, gDrive) 98 | 99 | // run test 100 | got, err := s.OperationHistory(tc.args.ctx, tc.args.input) 101 | if tc.wantErr { 102 | assert.Error(t, err) 103 | return 104 | } 105 | 106 | assert.NoError(t, err) 107 | assert.Equal(t, tc.want, got) 108 | }) 109 | } 110 | } 111 | 112 | func TestOperationService_MakeReportFile(t *testing.T) { 113 | type args struct { 114 | ctx context.Context 115 | month int 116 | year int 117 | } 118 | 119 | type MockBehavior func(o *repomocks.MockOperation, p *repomocks.MockProduct, g *webapimocks.MockGDrive, args args) 120 | 121 | testCases := []struct { 122 | name string 123 | args args 124 | mockBehavior MockBehavior 125 | want []byte 126 | wantErr bool 127 | }{ 128 | { 129 | name: "OK", 130 | args: args{ 131 | ctx: context.Background(), 132 | month: 1, 133 | year: 2021, 134 | }, 135 | mockBehavior: func(o *repomocks.MockOperation, p *repomocks.MockProduct, g *webapimocks.MockGDrive, args args) { 136 | o.EXPECT().GetAllRevenueOperationsGroupedByProduct(args.ctx, args.month, args.year). 137 | Return([]string{ 138 | "some product name", 139 | }, []int{ 140 | 100, 141 | }, nil) 142 | }, 143 | want: []byte("some product name,100\n"), 144 | wantErr: false, 145 | }, 146 | { 147 | name: "get all revenue operations grouped by product error", 148 | args: args{ 149 | ctx: context.Background(), 150 | month: 1, 151 | year: 2021, 152 | }, 153 | mockBehavior: func(o *repomocks.MockOperation, p *repomocks.MockProduct, g *webapimocks.MockGDrive, args args) { 154 | o.EXPECT().GetAllRevenueOperationsGroupedByProduct(args.ctx, args.month, args.year). 155 | Return(nil, nil, errors.New("some error")) 156 | }, 157 | want: nil, 158 | wantErr: true, 159 | }, 160 | } 161 | 162 | for _, tc := range testCases { 163 | t.Run(tc.name, func(t *testing.T) { 164 | // init deps 165 | ctrl := gomock.NewController(t) 166 | defer ctrl.Finish() 167 | 168 | // init mocks 169 | operationRepo := repomocks.NewMockOperation(ctrl) 170 | productRepo := repomocks.NewMockProduct(ctrl) 171 | gDrive := webapimocks.NewMockGDrive(ctrl) 172 | tc.mockBehavior(operationRepo, productRepo, gDrive, tc.args) 173 | 174 | // init service 175 | s := NewOperationService(operationRepo, productRepo, gDrive) 176 | 177 | // run test 178 | got, err := s.MakeReportFile(tc.args.ctx, tc.args.month, tc.args.year) 179 | if tc.wantErr { 180 | assert.Error(t, err) 181 | return 182 | } 183 | 184 | assert.NoError(t, err) 185 | assert.Equal(t, tc.want, got) 186 | }) 187 | } 188 | } 189 | 190 | func TestOperationService_MakeReportLink(t *testing.T) { 191 | type args struct { 192 | ctx context.Context 193 | month int 194 | year int 195 | } 196 | 197 | type MockBehavior func(o *repomocks.MockOperation, p *repomocks.MockProduct, g *webapimocks.MockGDrive, args args) 198 | 199 | testCases := []struct { 200 | name string 201 | args args 202 | mockBehavior MockBehavior 203 | want string 204 | wantErr bool 205 | }{ 206 | { 207 | name: "OK", 208 | args: args{ 209 | ctx: context.Background(), 210 | month: 1, 211 | year: 2021, 212 | }, 213 | mockBehavior: func(o *repomocks.MockOperation, p *repomocks.MockProduct, g *webapimocks.MockGDrive, args args) { 214 | g.EXPECT().IsAvailable().Return(true) 215 | 216 | o.EXPECT().GetAllRevenueOperationsGroupedByProduct(args.ctx, args.month, args.year). 217 | Return([]string{ 218 | "some product name", 219 | }, []int{ 220 | 100, 221 | }, nil) 222 | 223 | g.EXPECT().UploadCSVFile(args.ctx, "report_1_2021.csv", []byte("some product name,100\n")). 224 | Return("https://example.com", nil) 225 | }, 226 | want: "https://example.com", 227 | wantErr: false, 228 | }, 229 | { 230 | name: "gdrive is not available", 231 | args: args{ 232 | ctx: context.Background(), 233 | month: 1, 234 | year: 2021, 235 | }, 236 | mockBehavior: func(o *repomocks.MockOperation, p *repomocks.MockProduct, g *webapimocks.MockGDrive, args args) { 237 | g.EXPECT().IsAvailable().Return(false) 238 | }, 239 | want: "", 240 | wantErr: true, 241 | }, 242 | { 243 | name: "get all revenue operations grouped by product error", 244 | args: args{ 245 | ctx: context.Background(), 246 | month: 1, 247 | year: 2021, 248 | }, 249 | mockBehavior: func(o *repomocks.MockOperation, p *repomocks.MockProduct, g *webapimocks.MockGDrive, args args) { 250 | g.EXPECT().IsAvailable().Return(true) 251 | 252 | o.EXPECT().GetAllRevenueOperationsGroupedByProduct(args.ctx, args.month, args.year). 253 | Return(nil, nil, errors.New("some error")) 254 | }, 255 | want: "", 256 | wantErr: true, 257 | }, 258 | { 259 | name: "upload csv file error", 260 | args: args{ 261 | ctx: context.Background(), 262 | month: 1, 263 | year: 2021, 264 | }, 265 | mockBehavior: func(o *repomocks.MockOperation, p *repomocks.MockProduct, g *webapimocks.MockGDrive, args args) { 266 | g.EXPECT().IsAvailable().Return(true) 267 | 268 | o.EXPECT().GetAllRevenueOperationsGroupedByProduct(args.ctx, args.month, args.year). 269 | Return([]string{ 270 | "some product name", 271 | }, []int{ 272 | 100, 273 | }, nil) 274 | 275 | g.EXPECT().UploadCSVFile(args.ctx, "report_1_2021.csv", []byte("some product name,100\n")). 276 | Return("", errors.New("some error")) 277 | }, 278 | want: "", 279 | wantErr: true, 280 | }, 281 | } 282 | 283 | for _, tc := range testCases { 284 | t.Run(tc.name, func(t *testing.T) { 285 | // init deps 286 | ctrl := gomock.NewController(t) 287 | defer ctrl.Finish() 288 | 289 | // init mocks 290 | operationRepo := repomocks.NewMockOperation(ctrl) 291 | productRepo := repomocks.NewMockProduct(ctrl) 292 | gDrive := webapimocks.NewMockGDrive(ctrl) 293 | tc.mockBehavior(operationRepo, productRepo, gDrive, tc.args) 294 | 295 | // init service 296 | s := NewOperationService(operationRepo, productRepo, gDrive) 297 | 298 | // run test 299 | got, err := s.MakeReportLink(tc.args.ctx, tc.args.month, tc.args.year) 300 | if tc.wantErr { 301 | assert.Error(t, err) 302 | return 303 | } 304 | 305 | assert.NoError(t, err) 306 | assert.Equal(t, tc.want, got) 307 | }) 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/repo/pgdb/reservation.go: -------------------------------------------------------------------------------- 1 | package pgdb 2 | 3 | import ( 4 | "account-management-service/internal/entity" 5 | "account-management-service/internal/repo/repoerrs" 6 | "account-management-service/pkg/postgres" 7 | "context" 8 | "fmt" 9 | "github.com/Masterminds/squirrel" 10 | ) 11 | 12 | type ReservationRepo struct { 13 | *postgres.Postgres 14 | } 15 | 16 | func NewReservationRepo(pg *postgres.Postgres) *ReservationRepo { 17 | return &ReservationRepo{pg} 18 | } 19 | 20 | func (r *ReservationRepo) CreateReservation(ctx context.Context, reservation entity.Reservation) (int, error) { 21 | tx, err := r.Pool.Begin(ctx) 22 | if err != nil { 23 | return 0, fmt.Errorf("ReservationRepo.CreateReservation - r.Pool.Begin: %v", err) 24 | } 25 | defer func() { _ = tx.Rollback(ctx) }() 26 | 27 | // check if account has enough balance to create reservation 28 | sql, args, _ := r.Builder. 29 | Select("balance"). 30 | From("accounts"). 31 | Where("id = ?", reservation.AccountId). 32 | ToSql() 33 | 34 | var balance int 35 | err = tx.QueryRow(ctx, sql, args...).Scan(&balance) 36 | if err != nil { 37 | return 0, fmt.Errorf("ReservationRepo.CreateReservation - tx.QueryRow: %v", err) 38 | } 39 | 40 | if balance < reservation.Amount { 41 | return 0, repoerrs.ErrNotEnoughBalance 42 | } 43 | 44 | sql, args, _ = r.Builder. 45 | Update("accounts"). 46 | Set("balance", squirrel.Expr("balance - ?", reservation.Amount)). 47 | Where("id = ?", reservation.AccountId). 48 | ToSql() 49 | 50 | _, err = tx.Exec(ctx, sql, args...) 51 | if err != nil { 52 | return 0, fmt.Errorf("ReservationRepo.CreateReservation - tx.Exec: %v", err) 53 | } 54 | 55 | sql, args, _ = r.Builder. 56 | Insert("reservations"). 57 | Columns("account_id", "product_id", "order_id", "amount"). 58 | Values( 59 | reservation.AccountId, 60 | reservation.ProductId, 61 | reservation.OrderId, 62 | reservation.Amount, 63 | ). 64 | Suffix("RETURNING id"). 65 | ToSql() 66 | 67 | var id int 68 | err = tx.QueryRow(ctx, sql, args...).Scan(&id) 69 | if err != nil { 70 | return 0, fmt.Errorf("ReservationRepo.CreateReservation - tx.QueryRow: %v", err) 71 | } 72 | 73 | sql, args, _ = r.Builder. 74 | Insert("operations"). 75 | Columns("account_id", "amount", "operation_type", "product_id", "order_id"). 76 | Values( 77 | reservation.AccountId, 78 | reservation.Amount, 79 | entity.OperationTypeReservation, 80 | reservation.ProductId, 81 | reservation.OrderId, 82 | ). 83 | ToSql() 84 | 85 | _, err = tx.Exec(ctx, sql, args...) 86 | if err != nil { 87 | return 0, fmt.Errorf("ReservationRepo.CreateReservation - tx.Exec: %v", err) 88 | } 89 | 90 | err = tx.Commit(ctx) 91 | if err != nil { 92 | return 0, fmt.Errorf("ReservationRepo.CreateReservation - tx.Commit: %v", err) 93 | } 94 | 95 | return id, nil 96 | } 97 | 98 | func (r *ReservationRepo) GetReservationById(ctx context.Context, id int) (entity.Reservation, error) { 99 | sql, args, _ := r.Builder. 100 | Select("*"). 101 | From("reservations"). 102 | Where("id = ?", id). 103 | ToSql() 104 | 105 | var reservation entity.Reservation 106 | err := r.Pool.QueryRow(ctx, sql, args...).Scan( 107 | &reservation.Id, 108 | &reservation.AccountId, 109 | &reservation.ProductId, 110 | &reservation.OrderId, 111 | &reservation.Amount, 112 | &reservation.CreatedAt, 113 | ) 114 | if err != nil { 115 | return entity.Reservation{}, fmt.Errorf("ReservationRepo.GetReservationById - r.Pool.QueryRow: %v", err) 116 | } 117 | 118 | return reservation, nil 119 | } 120 | 121 | func (r *ReservationRepo) RefundReservationById(ctx context.Context, id int) error { 122 | tx, err := r.Pool.Begin(ctx) 123 | if err != nil { 124 | return fmt.Errorf("ReservationRepo.DeleteReservationById - r.Pool.Begin: %v", err) 125 | } 126 | defer func() { _ = tx.Rollback(ctx) }() 127 | 128 | sql, args, _ := r.Builder. 129 | Delete("reservations"). 130 | Where("id = ?", id). 131 | Suffix("RETURNING account_id, product_id, order_id, amount"). 132 | ToSql() 133 | 134 | var reservation entity.Reservation 135 | err = tx.QueryRow(ctx, sql, args...).Scan( 136 | &reservation.AccountId, 137 | &reservation.ProductId, 138 | &reservation.OrderId, 139 | &reservation.Amount, 140 | ) 141 | if err != nil { 142 | return fmt.Errorf("ReservationRepo.DeleteReservationById - tx.QueryRow: %v", err) 143 | } 144 | 145 | sql, args, _ = r.Builder. 146 | Update("accounts"). 147 | Set("balance", squirrel.Expr("balance + ?", reservation.Amount)). 148 | Where("id = ?", reservation.AccountId). 149 | ToSql() 150 | 151 | _, err = tx.Exec(ctx, sql, args...) 152 | if err != nil { 153 | return fmt.Errorf("ReservationRepo.DeleteReservationById - tx.Exec: %v", err) 154 | } 155 | 156 | sql, args, _ = r.Builder. 157 | Insert("operations"). 158 | Columns("account_id", "amount", "operation_type", "product_id", "order_id"). 159 | Values( 160 | reservation.AccountId, 161 | reservation.Amount, 162 | entity.OperationTypeRefund, 163 | reservation.ProductId, 164 | reservation.OrderId, 165 | ). 166 | ToSql() 167 | 168 | _, err = tx.Exec(ctx, sql, args...) 169 | if err != nil { 170 | return fmt.Errorf("ReservationRepo.DeleteReservationById - tx.Exec: %v", err) 171 | } 172 | 173 | err = tx.Commit(ctx) 174 | if err != nil { 175 | return fmt.Errorf("ReservationRepo.DeleteReservationById - tx.Commit: %v", err) 176 | } 177 | 178 | return nil 179 | } 180 | 181 | func (r *ReservationRepo) RefundReservationByOrderId(ctx context.Context, orderId int) error { 182 | tx, err := r.Pool.Begin(ctx) 183 | if err != nil { 184 | return fmt.Errorf("ReservationRepo.DeleteReservationByOrderId - r.Pool.Begin: %v", err) 185 | } 186 | defer func() { _ = tx.Rollback(ctx) }() 187 | 188 | sql, args, _ := r.Builder. 189 | Delete("reservations"). 190 | Where("order_id = ?", orderId). 191 | Suffix("RETURNING account_id, product_id, order_id, amount"). 192 | ToSql() 193 | 194 | var reservation entity.Reservation 195 | err = tx.QueryRow(ctx, sql, args...).Scan( 196 | &reservation.AccountId, 197 | &reservation.ProductId, 198 | &reservation.OrderId, 199 | &reservation.Amount, 200 | ) 201 | if err != nil { 202 | return fmt.Errorf("ReservationRepo.DeleteReservationByOrderId - tx.QueryRow: %v", err) 203 | } 204 | 205 | sql, args, _ = r.Builder. 206 | Update("accounts"). 207 | Set("balance", squirrel.Expr("balance + ?", reservation.Amount)). 208 | Where("id = ?", reservation.AccountId). 209 | ToSql() 210 | 211 | _, err = tx.Exec(ctx, sql, args...) 212 | if err != nil { 213 | return fmt.Errorf("ReservationRepo.DeleteReservationByOrderId - tx.Exec: %v", err) 214 | } 215 | 216 | sql, args, _ = r.Builder. 217 | Insert("operations"). 218 | Columns("account_id", "amount", "operation_type", "product_id", "order_id"). 219 | Values( 220 | reservation.AccountId, 221 | reservation.Amount, 222 | entity.OperationTypeRefund, 223 | reservation.ProductId, 224 | reservation.OrderId, 225 | ). 226 | ToSql() 227 | 228 | _, err = tx.Exec(ctx, sql, args...) 229 | if err != nil { 230 | return fmt.Errorf("ReservationRepo.DeleteReservationByOrderId - tx.Exec: %v", err) 231 | } 232 | 233 | err = tx.Commit(ctx) 234 | if err != nil { 235 | return fmt.Errorf("ReservationRepo.DeleteReservationByOrderId - tx.Commit: %v", err) 236 | } 237 | 238 | return nil 239 | } 240 | 241 | func (r *ReservationRepo) RevenueReservationById(ctx context.Context, id int) error { 242 | tx, err := r.Pool.Begin(ctx) 243 | if err != nil { 244 | return fmt.Errorf("ReservationRepo.RevenueReservationById - r.Pool.Begin: %v", err) 245 | } 246 | defer func() { _ = tx.Rollback(ctx) }() 247 | 248 | sql, args, _ := r.Builder. 249 | Delete("reservations"). 250 | Where("id = ?", id). 251 | Suffix("RETURNING account_id, product_id, order_id, amount"). 252 | ToSql() 253 | 254 | var reservation entity.Reservation 255 | err = tx.QueryRow(ctx, sql, args...).Scan( 256 | &reservation.AccountId, 257 | &reservation.ProductId, 258 | &reservation.OrderId, 259 | &reservation.Amount, 260 | ) 261 | if err != nil { 262 | return fmt.Errorf("ReservationRepo.RevenueReservationById - tx.QueryRow: %v", err) 263 | } 264 | 265 | sql, args, _ = r.Builder. 266 | Insert("operations"). 267 | Columns("account_id", "amount", "operation_type", "product_id", "order_id"). 268 | Values( 269 | reservation.AccountId, 270 | reservation.Amount, 271 | entity.OperationTypeRevenue, 272 | reservation.ProductId, 273 | reservation.OrderId, 274 | ). 275 | ToSql() 276 | 277 | _, err = tx.Exec(ctx, sql, args...) 278 | if err != nil { 279 | return fmt.Errorf("ReservationRepo.RevenueReservationById - tx.Exec: %v", err) 280 | } 281 | 282 | err = tx.Commit(ctx) 283 | if err != nil { 284 | return fmt.Errorf("ReservationRepo.RevenueReservationById - tx.Commit: %v", err) 285 | } 286 | 287 | return nil 288 | } 289 | 290 | func (r *ReservationRepo) RevenueReservationByOrderId(ctx context.Context, orderId int) error { 291 | tx, err := r.Pool.Begin(ctx) 292 | if err != nil { 293 | return fmt.Errorf("ReservationRepo.RevenueReservationByOrderId - r.Pool.Begin: %v", err) 294 | } 295 | defer func() { _ = tx.Rollback(ctx) }() 296 | 297 | sql, args, _ := r.Builder. 298 | Delete("reservations"). 299 | Where("order_id = ?", orderId). 300 | Suffix("RETURNING account_id, product_id, order_id, amount"). 301 | ToSql() 302 | 303 | var reservation entity.Reservation 304 | err = tx.QueryRow(ctx, sql, args...).Scan( 305 | &reservation.AccountId, 306 | &reservation.ProductId, 307 | &reservation.OrderId, 308 | &reservation.Amount, 309 | ) 310 | if err != nil { 311 | return fmt.Errorf("ReservationRepo.RevenueReservationByOrderId - tx.QueryRow: %v", err) 312 | } 313 | 314 | sql, args, _ = r.Builder. 315 | Insert("operations"). 316 | Columns("account_id", "amount", "operation_type", "product_id", "order_id"). 317 | Values( 318 | reservation.AccountId, 319 | reservation.Amount, 320 | entity.OperationTypeRevenue, 321 | reservation.ProductId, 322 | reservation.OrderId, 323 | ). 324 | ToSql() 325 | 326 | _, err = tx.Exec(ctx, sql, args...) 327 | if err != nil { 328 | return fmt.Errorf("ReservationRepo.RevenueReservationByOrderId - tx.Exec: %v", err) 329 | } 330 | 331 | err = tx.Commit(ctx) 332 | if err != nil { 333 | return fmt.Errorf("ReservationRepo.RevenueReservationByOrderId - tx.Commit: %v", err) 334 | } 335 | 336 | return nil 337 | } 338 | -------------------------------------------------------------------------------- /solution_example/account-management-service/README.md: -------------------------------------------------------------------------------- 1 | # Account management service 2 | 3 | [![forthebadge](https://forthebadge.com/images/badges/made-with-go.svg)](https://forthebadge.com) [![forthebadge](http://forthebadge.com/images/badges/built-with-love.svg)](http://forthebadge.com) 4 | 5 | Микросервис для работы с балансами пользователей, резервации средств на специальном счёте и последующем признании выручки компании или возврата денег. 6 | А также для получения данных для сводного отчёта по каждой услуге 7 | 8 | Используемые технологии: 9 | - PostgreSQL (в качестве хранилища данных) 10 | - Docker (для запуска сервиса) 11 | - Swagger (для документации API) 12 | - Echo (веб фреймворк) 13 | - golang-migrate/migrate (для миграций БД) 14 | - pgx (драйвер для работы с PostgreSQL) 15 | - golang/mock, testify (для тестирования) 16 | 17 | Сервис был написан с Clean Architecture, что позволяет легко расширять функционал сервиса и тестировать его. 18 | Также был реализован Graceful Shutdown для корректного завершения работы сервиса 19 | 20 | # Getting Started 21 | 22 | Для запуска сервиса с интеграцией с Google Drive необходимо предварительно: 23 | - Зарегистрировать приложение в Google Cloud Platform: [Документация](https://developers.google.com/workspace/guides/create-project) 24 | - Создать сервисный аккаунт и его секретный ключ: [Документация](https://developers.google.com/workspace/guides/create-credentials) 25 | - Добавить секретный ключ в директорию secrets 26 | - Добавить .env файл в директорию с проектом и заполнить его данными из .env.example, 27 | указав `GOOGLE_DRIVE_JSON_FILE_PATH=secrets/your_credentials_file.json` 28 | - Опционально, настроить `congig/config.yaml` под себя 29 | 30 | Для запуска сервиса без интеграции с Google Drive достаточно заполнить .env файл, 31 | оставив переменную `GOOGLE_DRIVE_JSON_FILE_PATH` пустой 32 | 33 | # Usage 34 | 35 | Запустить сервис можно с помощью команды `make compose-up` 36 | 37 | Документацию после завпуска сервиса можно посмотреть по адресу `http://localhost:8080/swagger/index.html` 38 | с портом 8080 по умолчанию 39 | 40 | Для запуска тестов необходимо выполнить команду `make test`, для запуска тестов с покрытием `make cover` и `make cover-html` для получения отчёта в html формате 41 | 42 | Для запуска линтера необходимо выполнить команду `make linter-golangci` 43 | 44 | ## Examples 45 | 46 | Некоторые примеры запросов 47 | - [Регистрация](#sign-up) 48 | - [Аутентификация](#sign-in) 49 | - [Пополнение счёта](#accounts-deposit) 50 | - [Резервирование средств](#reservations-create) 51 | - [Признание выручки](#reservations-revenue) 52 | - [Возврат средств](#reservations-refund) 53 | - [Получение истории операций пользователя](#operations-history) 54 | - [Сводный отчёт по услугам с экспортом в Google Drive](#operations-report-link) 55 | - [Сводный отчёт по услугам в формате csv файла](#operations-report-file) 56 | 57 | ### Регистрация 58 | 59 | Регистрация сервиса: 60 | ```curl 61 | curl --location --request POST 'http://localhost:8080/auth/sign-up' \ 62 | --header 'Content-Type: application/json' \ 63 | --data-raw '{ 64 | "username":"dannromm", 65 | "password":"Qwerty123!" 66 | }' 67 | ``` 68 | Пример ответа: 69 | ```json 70 | { 71 | "id": 1 72 | } 73 | ``` 74 | 75 | ### Аутентификация 76 | 77 | Аутентификация сервиса для получения токена доступа: 78 | ```curl 79 | curl --location --request POST 'http://localhost:8080/auth/sign-in' \ 80 | --header 'Content-Type: application/json' \ 81 | --data-raw '{ 82 | "username":"dannromm", 83 | "password":"Qwerty123!" 84 | }' 85 | ``` 86 | Пример ответа: 87 | ```json 88 | { 89 | "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY2MTUxMjEsImlhdCI6MTY2NjYwNzkyMSwiVXNlcklkIjoxfQ.c4jMWdmyXePtjTo_qrN6m9n-LQtHk_Q99OuzcpriYs4" 90 | } 91 | ``` 92 | 93 | ### Пополнение счёта 94 | 95 | Пополнение счёта пользователя на определённую сумму: 96 | ```curl 97 | curl --location --request POST 'http://localhost:8080/api/v1/accounts/deposit' \ 98 | --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY2MTUxMjEsImlhdCI6MTY2NjYwNzkyMSwiVXNlcklkIjoxfQ.c4jMWdmyXePtjTo_qrN6m9n-LQtHk_Q99OuzcpriYs4' \ 99 | --header 'Content-Type: application/json' \ 100 | --data-raw '{ 101 | "id": 1, 102 | "amount": 100 103 | }' 104 | ``` 105 | Пример ответа: 106 | ```json 107 | { 108 | "message": "success" 109 | } 110 | ``` 111 | 112 | ### Резервирование средств 113 | 114 | Резервирование средств по указанной услуге и номеру заказа: 115 | ```curl 116 | curl --location --request POST 'http://localhost:8080/api/v1/reservations/create' \ 117 | --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY2MTUxMjEsImlhdCI6MTY2NjYwNzkyMSwiVXNlcklkIjoxfQ.c4jMWdmyXePtjTo_qrN6m9n-LQtHk_Q99OuzcpriYs4' \ 118 | --header 'Content-Type: application/json' \ 119 | --data-raw '{ 120 | "account_id": 1, 121 | "product_id": 1, 122 | "order_id": 15, 123 | "amount": 10 124 | }' 125 | ``` 126 | Пример ответа, с указанием id резервирования: 127 | ```json 128 | { 129 | "id": 1 130 | } 131 | ``` 132 | 133 | ### Признание выручки 134 | 135 | Признание выручки по указанному резервированию: 136 | ```curl 137 | curl --location --request POST 'http://localhost:8080/api/v1/reservations/revenue' \ 138 | --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY2MTUxMjEsImlhdCI6MTY2NjYwNzkyMSwiVXNlcklkIjoxfQ.c4jMWdmyXePtjTo_qrN6m9n-LQtHk_Q99OuzcpriYs4' \ 139 | --header 'Content-Type: application/json' \ 140 | --data-raw '{ 141 | "account_id": 1, 142 | "product_id": 1, 143 | "order_id": 15, 144 | "amount": 10 145 | }' 146 | ``` 147 | Пример ответа: 148 | ```json 149 | { 150 | "message": "success" 151 | } 152 | ``` 153 | 154 | ### Возврат средств 155 | 156 | В случае отказа от услуги можно вернуть средства на счёт пользователя: 157 | ```curl 158 | curl --location --request POST 'http://localhost:8080/api/v1/reservations/refund' \ 159 | --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY2MTUxMjEsImlhdCI6MTY2NjYwNzkyMSwiVXNlcklkIjoxfQ.c4jMWdmyXePtjTo_qrN6m9n-LQtHk_Q99OuzcpriYs4' \ 160 | --header 'Content-Type: application/json' \ 161 | --data-raw '{ 162 | "order_id": 15 163 | }' 164 | ``` 165 | Пример ответа: 166 | ```json 167 | { 168 | "message": "success" 169 | } 170 | ``` 171 | 172 | ### Получение истории операций пользователя 173 | 174 | Используется пагинация, по умолчанию возвращается последние 10 записей отсортированные по дате создания. 175 | 176 | Для сортировке по сумме необходимо передать параметр `sort_type` со значением `amount`, 177 | также можно явно указать сортировку по дате создания, передав значение параметра `date` 178 | 179 | Для получения следующей страницы с данными необходимо передать параметр `offset` со значением `10` (по умолчанию 0) 180 | 181 | Также можно указать количество записей на странице, передав параметр `limit` (максимальное значение 10, по умолчанию 10) 182 | ```curl 183 | curl --location --request GET 'http://localhost:8080/api/v1/operations/history' \ 184 | --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY2MTUxMjEsImlhdCI6MTY2NjYwNzkyMSwiVXNlcklkIjoxfQ.c4jMWdmyXePtjTo_qrN6m9n-LQtHk_Q99OuzcpriYs4' \ 185 | --header 'Content-Type: application/json' \ 186 | --data-raw '{ 187 | "account_id": 1 188 | }' 189 | ``` 190 | Пример ответа: 191 | ```json 192 | { 193 | "operations": [ 194 | { 195 | "amount": 10, 196 | "operation": "refund", 197 | "time": "2022-10-24T11:06:06.896409Z", 198 | "product": "some product", 199 | "order": 15 200 | }, 201 | { 202 | "amount": 10, 203 | "operation": "reservation", 204 | "time": "2022-10-24T11:06:02.431726Z", 205 | "product": "some product", 206 | "order": 15 207 | } 208 | ] 209 | } 210 | ``` 211 | 212 | ### Сводный отчёт по услугам с экспортом в Google Drive 213 | 214 | Сервис формирует отчёт в разрезе каждой услуги, затем загружает его в Google Drive и возвращает ссылку на файл с открытым доступом на чтение: 215 | ```curl 216 | curl --location --request GET 'http://localhost:8080/api/v1/operations/report-link' \ 217 | --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY2MTUxMjEsImlhdCI6MTY2NjYwNzkyMSwiVXNlcklkIjoxfQ.c4jMWdmyXePtjTo_qrN6m9n-LQtHk_Q99OuzcpriYs4' \ 218 | --header 'Content-Type: application/json' \ 219 | --data-raw '{ 220 | "month": 10, 221 | "year": 2022 222 | }' 223 | ``` 224 | Пример ответа: 225 | ```json 226 | { 227 | "link": "https://drive.google.com/file/d/1rl91RS9n5l5kO9BDpHVQxpxYBegMzQC6/view?usp=sharing" 228 | } 229 | ``` 230 | 231 | ### Сводный отчёт по услугам в формате csv файла 232 | 233 | Сервис формирует отчёт и возвращает его в виде csv файла: 234 | ```curl 235 | curl --location --request GET 'http://localhost:8080/api/v1/operations/report-file' \ 236 | --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY2MTUxMjEsImlhdCI6MTY2NjYwNzkyMSwiVXNlcklkIjoxfQ.c4jMWdmyXePtjTo_qrN6m9n-LQtHk_Q99OuzcpriYs4' \ 237 | --header 'Content-Type: application/json' \ 238 | --data-raw '{ 239 | "month": 10, 240 | "year": 2022 241 | }' 242 | ``` 243 | Пример ответа: 244 | ```csv 245 | some product,30 246 | ``` 247 | 248 | # Decisions 249 | 250 | В ходе разработки был сомнения по тем или иным вопросам, которые были решены следующим образом: 251 | 252 | 1. При создании счёта стоит ли указывать id/uuid аккаунта в параметрах, 253 | чтобы сервис мог использовать свои собственные id/uuid для идентификации и не хранить внешние id сервиса управления балансом. 254 | > Решил, что не стоит, т.к. это увеличит время на разработку. Но возможно в будущем стоит добавить эту возможность 255 | 2. Как реализовать резервирование денег? 256 | > Сначала была идея сделать отдельную сущность под отдельный счёт пользователя, 257 | но потом решил, что достаточно хранить все резервирования в одной таблице и при необходимости делать по ней поиск 258 | 3. Как составлять отчёт? 259 | > В задании указано, что нужно вернуть ссылку на отчёт. Была идея развернуть ftp сервер и хранить отчёты in-memory. 260 | Всё-таки решил, что интеграция с Google Drive это интереснее, но в качестве альтернативы оставил возможность получить csv файл через http. 261 | К тому же, архитектура позволяет в будущем легко переехать на внутреннее решение 262 | 4. Какой использовать тип пагинации? 263 | > Каждый способ имеет свои преимущества и недостатки. Limit-Offset теряет в скорости работы и консистентности данных, 264 | так как если между получениями смежных страниц, будут добавлены данные, то это приведёт к дублированию и потере записи. 265 | От использования курсора пришлось отказаться, так как на одну дату может быть множество операций, 266 | тогда затруднительно получить отличные от первой страницы, нужно было бы увеличивать точность даты курсора, что усложнило бы разработку 267 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/repo/pgdb/user_test.go: -------------------------------------------------------------------------------- 1 | package pgdb 2 | 3 | import ( 4 | "account-management-service/internal/entity" 5 | "account-management-service/pkg/postgres" 6 | "context" 7 | "errors" 8 | "github.com/Masterminds/squirrel" 9 | "github.com/jackc/pgx/v5" 10 | "github.com/jackc/pgx/v5/pgconn" 11 | "github.com/pashagolub/pgxmock/v2" 12 | "github.com/stretchr/testify/assert" 13 | "testing" 14 | "time" 15 | ) 16 | 17 | func TestUserRepo_CreateUser(t *testing.T) { 18 | type args struct { 19 | ctx context.Context 20 | user entity.User 21 | } 22 | 23 | type MockBehavior func(m pgxmock.PgxPoolIface, args args) 24 | 25 | testCases := []struct { 26 | name string 27 | args args 28 | mockBehavior MockBehavior 29 | want int 30 | wantErr bool 31 | }{ 32 | { 33 | name: "OK", 34 | args: args{ 35 | ctx: context.Background(), 36 | user: entity.User{ 37 | Username: "test_user", 38 | Password: "Qwerty1!", 39 | }, 40 | }, 41 | mockBehavior: func(m pgxmock.PgxPoolIface, args args) { 42 | rows := pgxmock.NewRows([]string{"id"}). 43 | AddRow(1) 44 | 45 | m.ExpectQuery("INSERT INTO users"). 46 | WithArgs(args.user.Username, args.user.Password). 47 | WillReturnRows(rows) 48 | }, 49 | want: 1, 50 | wantErr: false, 51 | }, 52 | { 53 | name: "user already exists", 54 | args: args{ 55 | ctx: context.Background(), 56 | user: entity.User{ 57 | Username: "test_user", 58 | Password: "Qwerty1!", 59 | }, 60 | }, 61 | mockBehavior: func(m pgxmock.PgxPoolIface, args args) { 62 | m.ExpectQuery("INSERT INTO users"). 63 | WithArgs(args.user.Username, args.user.Password). 64 | WillReturnError(&pgconn.PgError{ 65 | Code: "23505", 66 | }) 67 | }, 68 | want: 0, 69 | wantErr: true, 70 | }, 71 | { 72 | name: "unexpected error", 73 | args: args{ 74 | ctx: context.Background(), 75 | user: entity.User{ 76 | Username: "test_user", 77 | Password: "Qwerty1!", 78 | }, 79 | }, 80 | mockBehavior: func(m pgxmock.PgxPoolIface, args args) { 81 | m.ExpectQuery("INSERT INTO users"). 82 | WithArgs(args.user.Username, args.user.Password). 83 | WillReturnError(errors.New("some error")) 84 | }, 85 | want: 0, 86 | wantErr: true, 87 | }, 88 | } 89 | 90 | for _, tc := range testCases { 91 | t.Run(tc.name, func(t *testing.T) { 92 | poolMock, _ := pgxmock.NewPool() 93 | defer poolMock.Close() 94 | tc.mockBehavior(poolMock, tc.args) 95 | 96 | postgresMock := &postgres.Postgres{ 97 | Builder: squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar), 98 | Pool: poolMock, 99 | } 100 | userRepoMock := NewUserRepo(postgresMock) 101 | 102 | got, err := userRepoMock.CreateUser(tc.args.ctx, tc.args.user) 103 | if tc.wantErr { 104 | assert.Error(t, err) 105 | return 106 | } 107 | assert.NoError(t, err) 108 | assert.Equal(t, tc.want, got) 109 | 110 | err = poolMock.ExpectationsWereMet() 111 | assert.NoError(t, err) 112 | }) 113 | } 114 | } 115 | 116 | func TestUserRepo_GetUserByUsernameAndPassword(t *testing.T) { 117 | type args struct { 118 | ctx context.Context 119 | username string 120 | password string 121 | } 122 | 123 | type MockBehavior func(m pgxmock.PgxPoolIface, args args) 124 | 125 | testCases := []struct { 126 | name string 127 | args args 128 | mockBehavior MockBehavior 129 | want entity.User 130 | wantErr bool 131 | }{ 132 | { 133 | name: "OK", 134 | args: args{ 135 | ctx: context.Background(), 136 | username: "test_user", 137 | password: "Qwerty1!", 138 | }, 139 | mockBehavior: func(m pgxmock.PgxPoolIface, args args) { 140 | rows := pgxmock.NewRows([]string{"id", "username", "password", "created_at"}). 141 | AddRow(1, args.username, args.password, time.UnixMilli(123456)) 142 | 143 | m.ExpectQuery("SELECT id, username, password, created_at FROM users"). 144 | WithArgs(args.username, args.password). 145 | WillReturnRows(rows) 146 | }, 147 | want: entity.User{ 148 | Id: 1, 149 | Username: "test_user", 150 | Password: "Qwerty1!", 151 | CreatedAt: time.UnixMilli(123456), 152 | }, 153 | wantErr: false, 154 | }, 155 | { 156 | name: "user not found", 157 | args: args{ 158 | ctx: context.Background(), 159 | username: "test_user", 160 | password: "Qwerty1!", 161 | }, 162 | mockBehavior: func(m pgxmock.PgxPoolIface, args args) { 163 | m.ExpectQuery("SELECT id, username, password, created_at FROM users"). 164 | WithArgs(args.username, args.password). 165 | WillReturnError(pgx.ErrNoRows) 166 | }, 167 | want: entity.User{}, 168 | wantErr: true, 169 | }, 170 | { 171 | name: "unexpected error", 172 | args: args{ 173 | ctx: context.Background(), 174 | username: "test_user", 175 | password: "Qwerty1!", 176 | }, 177 | mockBehavior: func(m pgxmock.PgxPoolIface, args args) { 178 | m.ExpectQuery("SELECT id, username, password, created_at FROM users"). 179 | WithArgs(args.username, args.password). 180 | WillReturnError(errors.New("some error")) 181 | }, 182 | want: entity.User{}, 183 | wantErr: true, 184 | }, 185 | } 186 | 187 | for _, tc := range testCases { 188 | t.Run(tc.name, func(t *testing.T) { 189 | poolMock, _ := pgxmock.NewPool() 190 | defer poolMock.Close() 191 | tc.mockBehavior(poolMock, tc.args) 192 | 193 | postgresMock := &postgres.Postgres{ 194 | Builder: squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar), 195 | Pool: poolMock, 196 | } 197 | userRepoMock := NewUserRepo(postgresMock) 198 | 199 | got, err := userRepoMock.GetUserByUsernameAndPassword(tc.args.ctx, tc.args.username, tc.args.password) 200 | if tc.wantErr { 201 | assert.Error(t, err) 202 | return 203 | } 204 | assert.NoError(t, err) 205 | assert.Equal(t, tc.want, got) 206 | 207 | err = poolMock.ExpectationsWereMet() 208 | assert.NoError(t, err) 209 | }) 210 | } 211 | } 212 | 213 | func TestUserRepo_GetUserById(t *testing.T) { 214 | type args struct { 215 | ctx context.Context 216 | id int 217 | } 218 | 219 | type MockBehavior func(m pgxmock.PgxPoolIface, args args) 220 | 221 | testCases := []struct { 222 | name string 223 | args args 224 | mockBehavior MockBehavior 225 | want entity.User 226 | wantErr bool 227 | }{ 228 | { 229 | name: "OK", 230 | args: args{ 231 | ctx: context.Background(), 232 | id: 1, 233 | }, 234 | mockBehavior: func(m pgxmock.PgxPoolIface, args args) { 235 | rows := pgxmock.NewRows([]string{"id", "username", "password", "created_at"}). 236 | AddRow(args.id, "test_user", "Qwerty1!", time.UnixMilli(123456)) 237 | 238 | m.ExpectQuery("SELECT id, username, password, created_at FROM users"). 239 | WithArgs(args.id). 240 | WillReturnRows(rows) 241 | }, 242 | want: entity.User{ 243 | Id: 1, 244 | Username: "test_user", 245 | Password: "Qwerty1!", 246 | CreatedAt: time.UnixMilli(123456), 247 | }, 248 | wantErr: false, 249 | }, 250 | { 251 | name: "user not found", 252 | args: args{ 253 | ctx: context.Background(), 254 | id: 1, 255 | }, 256 | mockBehavior: func(m pgxmock.PgxPoolIface, args args) { 257 | m.ExpectQuery("SELECT id, username, password, created_at FROM users"). 258 | WithArgs(args.id). 259 | WillReturnError(pgx.ErrNoRows) 260 | }, 261 | want: entity.User{}, 262 | wantErr: true, 263 | }, 264 | { 265 | name: "unexpected error", 266 | args: args{ 267 | ctx: context.Background(), 268 | id: 1, 269 | }, 270 | mockBehavior: func(m pgxmock.PgxPoolIface, args args) { 271 | m.ExpectQuery("SELECT id, username, password, created_at FROM users"). 272 | WithArgs(args.id). 273 | WillReturnError(errors.New("some error")) 274 | }, 275 | want: entity.User{}, 276 | wantErr: true, 277 | }, 278 | } 279 | 280 | for _, tc := range testCases { 281 | t.Run(tc.name, func(t *testing.T) { 282 | poolMock, _ := pgxmock.NewPool() 283 | defer poolMock.Close() 284 | tc.mockBehavior(poolMock, tc.args) 285 | 286 | postgresMock := &postgres.Postgres{ 287 | Builder: squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar), 288 | Pool: poolMock, 289 | } 290 | userRepoMock := NewUserRepo(postgresMock) 291 | 292 | got, err := userRepoMock.GetUserById(tc.args.ctx, tc.args.id) 293 | if tc.wantErr { 294 | assert.Error(t, err) 295 | return 296 | } 297 | assert.NoError(t, err) 298 | assert.Equal(t, tc.want, got) 299 | 300 | err = poolMock.ExpectationsWereMet() 301 | assert.NoError(t, err) 302 | }) 303 | } 304 | } 305 | 306 | func TestUserRepo_GetUserByUsername(t *testing.T) { 307 | type args struct { 308 | ctx context.Context 309 | username string 310 | } 311 | 312 | type MockBehavior func(m pgxmock.PgxPoolIface, args args) 313 | 314 | testCases := []struct { 315 | name string 316 | args args 317 | mockBehavior MockBehavior 318 | want entity.User 319 | wantErr bool 320 | }{ 321 | { 322 | name: "OK", 323 | args: args{ 324 | ctx: context.Background(), 325 | username: "test_user", 326 | }, 327 | mockBehavior: func(m pgxmock.PgxPoolIface, args args) { 328 | rows := pgxmock.NewRows([]string{"id", "username", "password", "created_at"}). 329 | AddRow(1, args.username, "Qwerty1!", time.UnixMilli(123456)) 330 | 331 | m.ExpectQuery("SELECT id, username, password, created_at FROM users"). 332 | WithArgs(args.username). 333 | WillReturnRows(rows) 334 | }, 335 | want: entity.User{ 336 | Id: 1, 337 | Username: "test_user", 338 | Password: "Qwerty1!", 339 | CreatedAt: time.UnixMilli(123456), 340 | }, 341 | wantErr: false, 342 | }, 343 | { 344 | name: "user not found", 345 | args: args{ 346 | ctx: context.Background(), 347 | username: "test_user", 348 | }, 349 | mockBehavior: func(m pgxmock.PgxPoolIface, args args) { 350 | m.ExpectQuery("SELECT id, username, password, created_at FROM users"). 351 | WithArgs(args.username). 352 | WillReturnError(pgx.ErrNoRows) 353 | }, 354 | want: entity.User{}, 355 | wantErr: true, 356 | }, 357 | { 358 | name: "unexpected error", 359 | args: args{ 360 | ctx: context.Background(), 361 | username: "test_user", 362 | }, 363 | mockBehavior: func(m pgxmock.PgxPoolIface, args args) { 364 | m.ExpectQuery("SELECT id, username, password, created_at FROM users"). 365 | WithArgs(args.username). 366 | WillReturnError(errors.New("some error")) 367 | }, 368 | want: entity.User{}, 369 | wantErr: true, 370 | }, 371 | } 372 | 373 | for _, tc := range testCases { 374 | t.Run(tc.name, func(t *testing.T) { 375 | poolMock, _ := pgxmock.NewPool() 376 | defer poolMock.Close() 377 | tc.mockBehavior(poolMock, tc.args) 378 | 379 | postgresMock := &postgres.Postgres{ 380 | Builder: squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar), 381 | Pool: poolMock, 382 | } 383 | userRepoMock := NewUserRepo(postgresMock) 384 | 385 | got, err := userRepoMock.GetUserByUsername(tc.args.ctx, tc.args.username) 386 | if tc.wantErr { 387 | assert.Error(t, err) 388 | return 389 | } 390 | assert.NoError(t, err) 391 | assert.Equal(t, tc.want, got) 392 | 393 | err = poolMock.ExpectationsWereMet() 394 | assert.NoError(t, err) 395 | }) 396 | } 397 | } 398 | -------------------------------------------------------------------------------- /solution_example/account-management-service/internal/controller/http/v1/auth_test.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "account-management-service/internal/mocks/servicemocks" 5 | "account-management-service/internal/service" 6 | "account-management-service/pkg/validator" 7 | "bytes" 8 | "context" 9 | "errors" 10 | "github.com/golang/mock/gomock" 11 | "github.com/labstack/echo/v4" 12 | "github.com/stretchr/testify/assert" 13 | "net/http" 14 | "net/http/httptest" 15 | "testing" 16 | ) 17 | 18 | func TestAuthRoutes_SignUp(t *testing.T) { 19 | type args struct { 20 | ctx context.Context 21 | input service.AuthCreateUserInput 22 | } 23 | 24 | type MockBehaviour func(m *servicemocks.MockAuth, args args) 25 | 26 | testCases := []struct { 27 | name string 28 | args args 29 | inputBody string 30 | mockBehaviour MockBehaviour 31 | wantStatusCode int 32 | wantRequestBody string 33 | }{ 34 | { 35 | name: "OK", 36 | args: args{ 37 | ctx: context.Background(), 38 | input: service.AuthCreateUserInput{ 39 | Username: "test", 40 | Password: "Qwerty!1", 41 | }, 42 | }, 43 | inputBody: `{"username":"test","password":"Qwerty!1"}`, 44 | mockBehaviour: func(m *servicemocks.MockAuth, args args) { 45 | m.EXPECT().CreateUser(args.ctx, args.input).Return(1, nil) 46 | }, 47 | wantStatusCode: 201, 48 | wantRequestBody: `{"id":1}` + "\n", 49 | }, 50 | { 51 | name: "Invalid password: not provided", 52 | args: args{}, 53 | inputBody: `{"username":"test"}`, 54 | mockBehaviour: func(m *servicemocks.MockAuth, args args) {}, 55 | wantStatusCode: 400, 56 | wantRequestBody: `{"message":"field password is required"}` + "\n", 57 | }, 58 | { 59 | name: "Invalid password: too short", 60 | args: args{}, 61 | inputBody: `{"username":"test","password":"Qw!1"}`, 62 | mockBehaviour: func(m *servicemocks.MockAuth, args args) {}, 63 | wantStatusCode: 400, 64 | wantRequestBody: `{"message":"field password must be between 8 and 32 characters"}` + "\n", 65 | }, 66 | { 67 | name: "Invalid password: too long", 68 | args: args{}, 69 | inputBody: `{"username":"test","password":"Qwerty!123456789012345678901234567890"}`, 70 | mockBehaviour: func(m *servicemocks.MockAuth, args args) {}, 71 | wantStatusCode: 400, 72 | wantRequestBody: `{"message":"field password must be between 8 and 32 characters"}` + "\n", 73 | }, 74 | { 75 | name: "Invalid password: no uppercase", 76 | args: args{}, 77 | inputBody: `{"username":"test","password":"qwerty!1"}`, 78 | mockBehaviour: func(m *servicemocks.MockAuth, args args) {}, 79 | wantStatusCode: 400, 80 | wantRequestBody: `{"message":"field password must contain at least 1 uppercase letter(s)"}` + "\n", 81 | }, 82 | { 83 | name: "Invalid password: no lowercase", 84 | args: args{}, 85 | inputBody: `{"username":"test","password":"QWERTY!1"}`, 86 | mockBehaviour: func(m *servicemocks.MockAuth, args args) {}, 87 | wantStatusCode: 400, 88 | wantRequestBody: `{"message":"field password must contain at least 1 lowercase letter(s)"}` + "\n", 89 | }, 90 | { 91 | name: "Invalid password: no digits", 92 | args: args{}, 93 | inputBody: `{"username":"test","password":"Qwerty!!"}`, 94 | mockBehaviour: func(m *servicemocks.MockAuth, args args) {}, 95 | wantStatusCode: 400, 96 | wantRequestBody: `{"message":"field password must contain at least 1 digit(s)"}` + "\n", 97 | }, 98 | { 99 | name: "Invalid password: no special characters", 100 | args: args{}, 101 | inputBody: `{"username":"test","password":"Qwerty11"}`, 102 | mockBehaviour: func(m *servicemocks.MockAuth, args args) {}, 103 | wantStatusCode: 400, 104 | wantRequestBody: `{"message":"field password must contain at least 1 special character(s)"}` + "\n", 105 | }, 106 | { 107 | name: "Invalid username: not provided", 108 | args: args{}, 109 | inputBody: `{"password":"Qwerty!1"}`, 110 | mockBehaviour: func(m *servicemocks.MockAuth, args args) {}, 111 | wantStatusCode: 400, 112 | wantRequestBody: `{"message":"field username is required"}` + "\n", 113 | }, 114 | { 115 | name: "Invalid username: too short", 116 | args: args{}, 117 | inputBody: `{"username":"t","password":"Qwerty!1"}`, 118 | mockBehaviour: func(m *servicemocks.MockAuth, args args) {}, 119 | wantStatusCode: 400, 120 | wantRequestBody: `{"message":"field username must be at least 4 characters"}` + "\n", 121 | }, 122 | { 123 | name: "Invalid username: too long", 124 | args: args{}, 125 | inputBody: `{"username":"testtesttesttesttesttesttesttesttest","password":"Qwerty!1"}`, 126 | mockBehaviour: func(m *servicemocks.MockAuth, args args) {}, 127 | wantStatusCode: 400, 128 | wantRequestBody: `{"message":"field username must be at most 32 characters"}` + "\n", 129 | }, 130 | { 131 | name: "Invalid request body", 132 | args: args{}, 133 | inputBody: `{"username" test","password":"Qwerty!1"`, 134 | mockBehaviour: func(m *servicemocks.MockAuth, args args) {}, 135 | wantStatusCode: 400, 136 | wantRequestBody: `{"message":"invalid request body"}` + "\n", 137 | }, 138 | { 139 | name: "Auth service error", 140 | args: args{ 141 | ctx: context.Background(), 142 | input: service.AuthCreateUserInput{ 143 | Username: "test", 144 | Password: "Qwerty!1", 145 | }, 146 | }, 147 | inputBody: `{"username":"test","password":"Qwerty!1"}`, 148 | mockBehaviour: func(m *servicemocks.MockAuth, args args) { 149 | m.EXPECT().CreateUser(args.ctx, args.input).Return(0, service.ErrUserAlreadyExists) 150 | }, 151 | wantStatusCode: 400, 152 | wantRequestBody: `{"message":"user already exists"}` + "\n", 153 | }, 154 | { 155 | name: "Internal server error", 156 | args: args{ 157 | ctx: context.Background(), 158 | input: service.AuthCreateUserInput{ 159 | Username: "test", 160 | Password: "Qwerty!1", 161 | }, 162 | }, 163 | inputBody: `{"username":"test","password":"Qwerty!1"}`, 164 | mockBehaviour: func(m *servicemocks.MockAuth, args args) { 165 | m.EXPECT().CreateUser(args.ctx, args.input).Return(0, errors.New("some error")) 166 | }, 167 | wantStatusCode: 500, 168 | wantRequestBody: `{"message":"internal server error"}` + "\n", 169 | }, 170 | } 171 | 172 | for _, tc := range testCases { 173 | t.Run(tc.name, func(t *testing.T) { 174 | // init deps 175 | ctrl := gomock.NewController(t) 176 | defer ctrl.Finish() 177 | 178 | // init service mock 179 | auth := servicemocks.NewMockAuth(ctrl) 180 | tc.mockBehaviour(auth, tc.args) 181 | services := &service.Services{Auth: auth} 182 | 183 | // create test server 184 | e := echo.New() 185 | e.Validator = validator.NewCustomValidator() 186 | g := e.Group("/auth") 187 | newAuthRoutes(g, services.Auth) 188 | 189 | // create request 190 | w := httptest.NewRecorder() 191 | req := httptest.NewRequest(http.MethodPost, "/auth/sign-up", bytes.NewBufferString(tc.inputBody)) 192 | req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) 193 | 194 | // execute request 195 | e.ServeHTTP(w, req) 196 | 197 | // check response 198 | assert.Equal(t, tc.wantStatusCode, w.Code) 199 | assert.Equal(t, tc.wantRequestBody, w.Body.String()) 200 | }) 201 | } 202 | } 203 | 204 | func TestAuthRoutes_SignIn(t *testing.T) { 205 | type args struct { 206 | ctx context.Context 207 | input service.AuthGenerateTokenInput 208 | } 209 | 210 | type mockBehaviour func(m *servicemocks.MockAuth, args args) 211 | 212 | testCases := []struct { 213 | name string 214 | args args 215 | inputBody string 216 | mockBehaviour mockBehaviour 217 | wantStatusCode int 218 | wantRequestBody string 219 | }{ 220 | { 221 | name: "OK", 222 | args: args{ 223 | ctx: context.Background(), 224 | input: service.AuthGenerateTokenInput{ 225 | Username: "test", 226 | Password: "Qwerty!1", 227 | }, 228 | }, 229 | inputBody: `{"username":"test","password":"Qwerty!1"}`, 230 | mockBehaviour: func(m *servicemocks.MockAuth, args args) { 231 | m.EXPECT().GenerateToken(args.ctx, args.input).Return("token", nil) 232 | }, 233 | wantStatusCode: 200, 234 | wantRequestBody: `{"token":"token"}` + "\n", 235 | }, 236 | { 237 | name: "Invalid username: not provided", 238 | args: args{}, 239 | inputBody: `{"password":"Qwerty!1"}`, 240 | mockBehaviour: func(m *servicemocks.MockAuth, args args) {}, 241 | wantStatusCode: 400, 242 | wantRequestBody: `{"message":"field username is required"}` + "\n", 243 | }, 244 | { 245 | name: "Invalid password: not provided", 246 | args: args{}, 247 | inputBody: `{"username":"test"}`, 248 | mockBehaviour: func(m *servicemocks.MockAuth, args args) {}, 249 | wantStatusCode: 400, 250 | wantRequestBody: `{"message":"field password is required"}` + "\n", 251 | }, 252 | { 253 | name: "Wrong username or password", 254 | args: args{ 255 | ctx: context.Background(), 256 | input: service.AuthGenerateTokenInput{ 257 | Username: "test", 258 | Password: "Qwerty!1", 259 | }, 260 | }, 261 | inputBody: `{"username":"test","password":"Qwerty!1"}`, 262 | mockBehaviour: func(m *servicemocks.MockAuth, args args) { 263 | m.EXPECT().GenerateToken(args.ctx, args.input).Return("", service.ErrUserNotFound) 264 | }, 265 | wantStatusCode: 400, 266 | wantRequestBody: `{"message":"invalid username or password"}` + "\n", 267 | }, 268 | { 269 | name: "Internal server error", 270 | args: args{ 271 | ctx: context.Background(), 272 | input: service.AuthGenerateTokenInput{ 273 | Username: "test", 274 | Password: "Qwerty!1", 275 | }, 276 | }, 277 | inputBody: `{"username":"test","password":"Qwerty!1"}`, 278 | mockBehaviour: func(m *servicemocks.MockAuth, args args) { 279 | m.EXPECT().GenerateToken(args.ctx, args.input).Return("", errors.New("some error")) 280 | }, 281 | wantStatusCode: 500, 282 | wantRequestBody: `{"message":"internal server error"}` + "\n", 283 | }, 284 | { 285 | name: "Invalid request body", 286 | args: args{}, 287 | inputBody: `{qw"qwdf)00)))`, 288 | mockBehaviour: func(m *servicemocks.MockAuth, args args) {}, 289 | wantStatusCode: 400, 290 | wantRequestBody: `{"message":"invalid request body"}` + "\n", 291 | }, 292 | } 293 | 294 | for _, tc := range testCases { 295 | t.Run(tc.name, func(t *testing.T) { 296 | // init deps 297 | ctrl := gomock.NewController(t) 298 | defer ctrl.Finish() 299 | 300 | // create service mock 301 | auth := servicemocks.NewMockAuth(ctrl) 302 | tc.mockBehaviour(auth, tc.args) 303 | services := &service.Services{Auth: auth} 304 | 305 | // create test server 306 | e := echo.New() 307 | e.Validator = validator.NewCustomValidator() 308 | g := e.Group("/auth") 309 | newAuthRoutes(g, services.Auth) 310 | 311 | // create request 312 | w := httptest.NewRecorder() 313 | req := httptest.NewRequest(http.MethodPost, "/auth/sign-in", bytes.NewBufferString(tc.inputBody)) 314 | req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) 315 | 316 | // execute request 317 | e.ServeHTTP(w, req) 318 | 319 | // check response 320 | assert.Equal(t, tc.wantStatusCode, w.Code) 321 | assert.Equal(t, tc.wantRequestBody, w.Body.String()) 322 | }) 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /solution_example/account-management-service/docs/swagger.yaml: -------------------------------------------------------------------------------- 1 | basePath: / 2 | definitions: 3 | account-management-service_internal_controller_http_v1.accountDepositInput: 4 | properties: 5 | amount: 6 | type: integer 7 | id: 8 | type: integer 9 | required: 10 | - amount 11 | - id 12 | type: object 13 | account-management-service_internal_controller_http_v1.accountRoutes: 14 | type: object 15 | account-management-service_internal_controller_http_v1.accountTransferInput: 16 | properties: 17 | amount: 18 | type: integer 19 | from: 20 | type: integer 21 | to: 22 | type: integer 23 | required: 24 | - amount 25 | - from 26 | - to 27 | type: object 28 | account-management-service_internal_controller_http_v1.accountWithdrawInput: 29 | properties: 30 | amount: 31 | type: integer 32 | id: 33 | type: integer 34 | required: 35 | - amount 36 | - id 37 | type: object 38 | account-management-service_internal_controller_http_v1.authRoutes: 39 | type: object 40 | account-management-service_internal_controller_http_v1.getBalanceInput: 41 | properties: 42 | id: 43 | type: integer 44 | required: 45 | - id 46 | type: object 47 | account-management-service_internal_controller_http_v1.getHistoryInput: 48 | properties: 49 | account_id: 50 | type: integer 51 | limit: 52 | type: integer 53 | offset: 54 | type: integer 55 | sort_type: 56 | type: string 57 | required: 58 | - account_id 59 | type: object 60 | account-management-service_internal_controller_http_v1.getReportInput: 61 | properties: 62 | month: 63 | type: integer 64 | year: 65 | type: integer 66 | required: 67 | - month 68 | - year 69 | type: object 70 | account-management-service_internal_controller_http_v1.operationRoutes: 71 | properties: 72 | service.Operation: {} 73 | type: object 74 | account-management-service_internal_controller_http_v1.productRoutes: 75 | type: object 76 | account-management-service_internal_controller_http_v1.reservationCreateInput: 77 | properties: 78 | account_id: 79 | type: integer 80 | amount: 81 | type: integer 82 | order_id: 83 | type: integer 84 | product_id: 85 | type: integer 86 | required: 87 | - account_id 88 | - amount 89 | - order_id 90 | - product_id 91 | type: object 92 | account-management-service_internal_controller_http_v1.reservationRefundInput: 93 | properties: 94 | order_id: 95 | type: integer 96 | required: 97 | - order_id 98 | type: object 99 | account-management-service_internal_controller_http_v1.reservationRevenueInput: 100 | properties: 101 | account_id: 102 | type: integer 103 | amount: 104 | type: integer 105 | order_id: 106 | type: integer 107 | product_id: 108 | type: integer 109 | required: 110 | - account_id 111 | - amount 112 | - order_id 113 | - product_id 114 | type: object 115 | account-management-service_internal_controller_http_v1.reservationRoutes: 116 | type: object 117 | account-management-service_internal_controller_http_v1.signInInput: 118 | properties: 119 | password: 120 | type: string 121 | username: 122 | maxLength: 32 123 | minLength: 4 124 | type: string 125 | required: 126 | - password 127 | - username 128 | type: object 129 | account-management-service_internal_controller_http_v1.signUpInput: 130 | properties: 131 | password: 132 | type: string 133 | username: 134 | maxLength: 32 135 | minLength: 4 136 | type: string 137 | required: 138 | - password 139 | - username 140 | type: object 141 | echo.HTTPError: 142 | properties: 143 | message: {} 144 | type: object 145 | internal_controller_http_v1.accountDepositInput: 146 | properties: 147 | amount: 148 | type: integer 149 | id: 150 | type: integer 151 | required: 152 | - amount 153 | - id 154 | type: object 155 | internal_controller_http_v1.accountRoutes: 156 | type: object 157 | internal_controller_http_v1.accountTransferInput: 158 | properties: 159 | amount: 160 | type: integer 161 | from: 162 | type: integer 163 | to: 164 | type: integer 165 | required: 166 | - amount 167 | - from 168 | - to 169 | type: object 170 | internal_controller_http_v1.accountWithdrawInput: 171 | properties: 172 | amount: 173 | type: integer 174 | id: 175 | type: integer 176 | required: 177 | - amount 178 | - id 179 | type: object 180 | internal_controller_http_v1.authRoutes: 181 | type: object 182 | internal_controller_http_v1.getBalanceInput: 183 | properties: 184 | id: 185 | type: integer 186 | required: 187 | - id 188 | type: object 189 | internal_controller_http_v1.getHistoryInput: 190 | properties: 191 | account_id: 192 | type: integer 193 | limit: 194 | type: integer 195 | offset: 196 | type: integer 197 | sort_type: 198 | type: string 199 | required: 200 | - account_id 201 | type: object 202 | internal_controller_http_v1.getReportInput: 203 | properties: 204 | month: 205 | type: integer 206 | year: 207 | type: integer 208 | required: 209 | - month 210 | - year 211 | type: object 212 | internal_controller_http_v1.operationRoutes: 213 | properties: 214 | service.Operation: {} 215 | type: object 216 | internal_controller_http_v1.productRoutes: 217 | type: object 218 | internal_controller_http_v1.reservationCreateInput: 219 | properties: 220 | account_id: 221 | type: integer 222 | amount: 223 | type: integer 224 | order_id: 225 | type: integer 226 | product_id: 227 | type: integer 228 | required: 229 | - account_id 230 | - amount 231 | - order_id 232 | - product_id 233 | type: object 234 | internal_controller_http_v1.reservationRefundInput: 235 | properties: 236 | order_id: 237 | type: integer 238 | required: 239 | - order_id 240 | type: object 241 | internal_controller_http_v1.reservationRevenueInput: 242 | properties: 243 | account_id: 244 | type: integer 245 | amount: 246 | type: integer 247 | order_id: 248 | type: integer 249 | product_id: 250 | type: integer 251 | required: 252 | - account_id 253 | - amount 254 | - order_id 255 | - product_id 256 | type: object 257 | internal_controller_http_v1.reservationRoutes: 258 | type: object 259 | internal_controller_http_v1.signInInput: 260 | properties: 261 | password: 262 | type: string 263 | username: 264 | maxLength: 32 265 | minLength: 4 266 | type: string 267 | required: 268 | - password 269 | - username 270 | type: object 271 | internal_controller_http_v1.signUpInput: 272 | properties: 273 | password: 274 | type: string 275 | username: 276 | maxLength: 32 277 | minLength: 4 278 | type: string 279 | required: 280 | - password 281 | - username 282 | type: object 283 | host: localhost:8089 284 | info: 285 | contact: 286 | email: changaz.d@gmail.com 287 | name: Changaz Danial 288 | description: This is a service for managing accounts, reservations, products and 289 | operations. 290 | title: Account Management Service 291 | version: "1.0" 292 | paths: 293 | /api/v1/accounts/: 294 | get: 295 | consumes: 296 | - application/json 297 | description: Get balance 298 | parameters: 299 | - description: input 300 | in: body 301 | name: input 302 | required: true 303 | schema: 304 | $ref: '#/definitions/account-management-service_internal_controller_http_v1.getBalanceInput' 305 | produces: 306 | - application/json 307 | responses: 308 | "200": 309 | description: OK 310 | schema: 311 | $ref: '#/definitions/account-management-service_internal_controller_http_v1.accountRoutes' 312 | "400": 313 | description: Bad Request 314 | schema: 315 | $ref: '#/definitions/echo.HTTPError' 316 | "500": 317 | description: Internal Server Error 318 | schema: 319 | $ref: '#/definitions/echo.HTTPError' 320 | security: 321 | - JWT: [] 322 | summary: Get balance 323 | tags: 324 | - accounts 325 | /api/v1/accounts/create: 326 | post: 327 | consumes: 328 | - application/json 329 | description: Create account 330 | produces: 331 | - application/json 332 | responses: 333 | "201": 334 | description: Created 335 | schema: 336 | $ref: '#/definitions/account-management-service_internal_controller_http_v1.accountRoutes' 337 | "400": 338 | description: Bad Request 339 | schema: 340 | $ref: '#/definitions/echo.HTTPError' 341 | "500": 342 | description: Internal Server Error 343 | schema: 344 | $ref: '#/definitions/echo.HTTPError' 345 | security: 346 | - JWT: [] 347 | summary: Create account 348 | tags: 349 | - accounts 350 | /api/v1/accounts/deposit: 351 | post: 352 | consumes: 353 | - application/json 354 | description: Deposit 355 | parameters: 356 | - description: input 357 | in: body 358 | name: input 359 | required: true 360 | schema: 361 | $ref: '#/definitions/account-management-service_internal_controller_http_v1.accountDepositInput' 362 | produces: 363 | - application/json 364 | responses: 365 | "200": 366 | description: OK 367 | "400": 368 | description: Bad Request 369 | schema: 370 | $ref: '#/definitions/echo.HTTPError' 371 | "500": 372 | description: Internal Server Error 373 | schema: 374 | $ref: '#/definitions/echo.HTTPError' 375 | security: 376 | - JWT: [] 377 | summary: Deposit 378 | tags: 379 | - accounts 380 | /api/v1/accounts/transfer: 381 | post: 382 | consumes: 383 | - application/json 384 | description: Transfer 385 | parameters: 386 | - description: input 387 | in: body 388 | name: input 389 | required: true 390 | schema: 391 | $ref: '#/definitions/account-management-service_internal_controller_http_v1.accountTransferInput' 392 | produces: 393 | - application/json 394 | responses: 395 | "200": 396 | description: OK 397 | "400": 398 | description: Bad Request 399 | schema: 400 | $ref: '#/definitions/echo.HTTPError' 401 | "500": 402 | description: Internal Server Error 403 | schema: 404 | $ref: '#/definitions/echo.HTTPError' 405 | security: 406 | - JWT: [] 407 | summary: Transfer 408 | tags: 409 | - accounts 410 | /api/v1/accounts/withdraw: 411 | post: 412 | consumes: 413 | - application/json 414 | description: Withdraw 415 | parameters: 416 | - description: input 417 | in: body 418 | name: input 419 | required: true 420 | schema: 421 | $ref: '#/definitions/account-management-service_internal_controller_http_v1.accountWithdrawInput' 422 | produces: 423 | - application/json 424 | responses: 425 | "200": 426 | description: OK 427 | "400": 428 | description: Bad Request 429 | schema: 430 | $ref: '#/definitions/echo.HTTPError' 431 | "500": 432 | description: Internal Server Error 433 | schema: 434 | $ref: '#/definitions/echo.HTTPError' 435 | security: 436 | - JWT: [] 437 | summary: Withdraw 438 | tags: 439 | - accounts 440 | /api/v1/operations/history: 441 | get: 442 | consumes: 443 | - application/json 444 | description: Get history of operations 445 | parameters: 446 | - description: input 447 | in: body 448 | name: input 449 | required: true 450 | schema: 451 | $ref: '#/definitions/internal_controller_http_v1.getHistoryInput' 452 | produces: 453 | - application/json 454 | responses: 455 | "200": 456 | description: OK 457 | schema: 458 | $ref: '#/definitions/internal_controller_http_v1.operationRoutes' 459 | "400": 460 | description: Bad Request 461 | schema: 462 | $ref: '#/definitions/echo.HTTPError' 463 | "500": 464 | description: Internal Server Error 465 | schema: 466 | $ref: '#/definitions/echo.HTTPError' 467 | security: 468 | - JWT: [] 469 | summary: Get history 470 | tags: 471 | - operations 472 | /api/v1/operations/report-file: 473 | get: 474 | consumes: 475 | - application/json 476 | description: Get report file 477 | parameters: 478 | - description: input 479 | in: body 480 | name: input 481 | required: true 482 | schema: 483 | $ref: '#/definitions/internal_controller_http_v1.getReportInput' 484 | produces: 485 | - text/csv 486 | responses: 487 | "200": 488 | description: OK 489 | "400": 490 | description: Bad Request 491 | schema: 492 | $ref: '#/definitions/echo.HTTPError' 493 | "500": 494 | description: Internal Server Error 495 | schema: 496 | $ref: '#/definitions/echo.HTTPError' 497 | security: 498 | - JWT: [] 499 | summary: Get report file 500 | tags: 501 | - operations 502 | /api/v1/operations/report-link: 503 | get: 504 | consumes: 505 | - application/json 506 | description: Get link to report 507 | parameters: 508 | - description: input 509 | in: body 510 | name: input 511 | required: true 512 | schema: 513 | $ref: '#/definitions/internal_controller_http_v1.getReportInput' 514 | produces: 515 | - application/json 516 | responses: 517 | "200": 518 | description: OK 519 | schema: 520 | $ref: '#/definitions/internal_controller_http_v1.operationRoutes' 521 | "400": 522 | description: Bad Request 523 | schema: 524 | $ref: '#/definitions/echo.HTTPError' 525 | "500": 526 | description: Internal Server Error 527 | schema: 528 | $ref: '#/definitions/echo.HTTPError' 529 | security: 530 | - JWT: [] 531 | summary: Get report link 532 | tags: 533 | - operations 534 | /api/v1/products/create: 535 | post: 536 | consumes: 537 | - application/json 538 | description: Create product 539 | produces: 540 | - application/json 541 | responses: 542 | "201": 543 | description: Created 544 | schema: 545 | $ref: '#/definitions/account-management-service_internal_controller_http_v1.productRoutes' 546 | "400": 547 | description: Bad Request 548 | schema: 549 | $ref: '#/definitions/echo.HTTPError' 550 | "500": 551 | description: Internal Server Error 552 | schema: 553 | $ref: '#/definitions/echo.HTTPError' 554 | security: 555 | - JWT: [] 556 | summary: Create product 557 | tags: 558 | - products 559 | /api/v1/products/getById: 560 | get: 561 | consumes: 562 | - application/json 563 | description: Get product by id 564 | produces: 565 | - application/json 566 | responses: 567 | "200": 568 | description: OK 569 | schema: 570 | $ref: '#/definitions/account-management-service_internal_controller_http_v1.productRoutes' 571 | "400": 572 | description: Bad Request 573 | schema: 574 | $ref: '#/definitions/echo.HTTPError' 575 | "500": 576 | description: Internal Server Error 577 | schema: 578 | $ref: '#/definitions/echo.HTTPError' 579 | security: 580 | - JWT: [] 581 | summary: Get product by id 582 | tags: 583 | - products 584 | /api/v1/reservations/create: 585 | post: 586 | consumes: 587 | - application/json 588 | description: Create reservation 589 | parameters: 590 | - description: input 591 | in: body 592 | name: input 593 | required: true 594 | schema: 595 | $ref: '#/definitions/account-management-service_internal_controller_http_v1.reservationCreateInput' 596 | produces: 597 | - application/json 598 | responses: 599 | "201": 600 | description: Created 601 | schema: 602 | $ref: '#/definitions/account-management-service_internal_controller_http_v1.reservationRoutes' 603 | "400": 604 | description: Bad Request 605 | schema: 606 | $ref: '#/definitions/echo.HTTPError' 607 | "500": 608 | description: Internal Server Error 609 | schema: 610 | $ref: '#/definitions/echo.HTTPError' 611 | security: 612 | - JWT: [] 613 | summary: Create reservation 614 | tags: 615 | - reservations 616 | /api/v1/reservations/refund: 617 | post: 618 | consumes: 619 | - application/json 620 | description: Refund reservation 621 | parameters: 622 | - description: input 623 | in: body 624 | name: input 625 | required: true 626 | schema: 627 | $ref: '#/definitions/account-management-service_internal_controller_http_v1.reservationRefundInput' 628 | produces: 629 | - application/json 630 | responses: 631 | "200": 632 | description: OK 633 | "400": 634 | description: Bad Request 635 | schema: 636 | $ref: '#/definitions/echo.HTTPError' 637 | "500": 638 | description: Internal Server Error 639 | schema: 640 | $ref: '#/definitions/echo.HTTPError' 641 | security: 642 | - JWT: [] 643 | summary: Refund reservation 644 | tags: 645 | - reservations 646 | /api/v1/reservations/revenue: 647 | post: 648 | consumes: 649 | - application/json 650 | description: Revenue reservation 651 | parameters: 652 | - description: input 653 | in: body 654 | name: input 655 | required: true 656 | schema: 657 | $ref: '#/definitions/account-management-service_internal_controller_http_v1.reservationRevenueInput' 658 | produces: 659 | - application/json 660 | responses: 661 | "200": 662 | description: OK 663 | "400": 664 | description: Bad Request 665 | schema: 666 | $ref: '#/definitions/echo.HTTPError' 667 | "500": 668 | description: Internal Server Error 669 | schema: 670 | $ref: '#/definitions/echo.HTTPError' 671 | security: 672 | - JWT: [] 673 | summary: Revenue reservation 674 | tags: 675 | - reservations 676 | /auth/sign-in: 677 | post: 678 | consumes: 679 | - application/json 680 | description: Sign in 681 | parameters: 682 | - description: input 683 | in: body 684 | name: input 685 | required: true 686 | schema: 687 | $ref: '#/definitions/account-management-service_internal_controller_http_v1.signInInput' 688 | produces: 689 | - application/json 690 | responses: 691 | "200": 692 | description: OK 693 | schema: 694 | $ref: '#/definitions/account-management-service_internal_controller_http_v1.authRoutes' 695 | "400": 696 | description: Bad Request 697 | schema: 698 | $ref: '#/definitions/echo.HTTPError' 699 | "500": 700 | description: Internal Server Error 701 | schema: 702 | $ref: '#/definitions/echo.HTTPError' 703 | summary: Sign in 704 | tags: 705 | - auth 706 | /auth/sign-up: 707 | post: 708 | consumes: 709 | - application/json 710 | description: Sign up 711 | parameters: 712 | - description: input 713 | in: body 714 | name: input 715 | required: true 716 | schema: 717 | $ref: '#/definitions/account-management-service_internal_controller_http_v1.signUpInput' 718 | produces: 719 | - application/json 720 | responses: 721 | "201": 722 | description: Created 723 | schema: 724 | $ref: '#/definitions/account-management-service_internal_controller_http_v1.authRoutes' 725 | "400": 726 | description: Bad Request 727 | schema: 728 | $ref: '#/definitions/echo.HTTPError' 729 | "500": 730 | description: Internal Server Error 731 | schema: 732 | $ref: '#/definitions/echo.HTTPError' 733 | summary: Sign up 734 | tags: 735 | - auth 736 | securityDefinitions: 737 | JWT: 738 | in: header 739 | name: Authorization 740 | type: apiKey 741 | swagger: "2.0" 742 | -------------------------------------------------------------------------------- /solution_example/account-management-service/docs/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "description": "This is a service for managing accounts, reservations, products and operations.", 5 | "title": "Account Management Service", 6 | "contact": { 7 | "name": "Changaz Danial", 8 | "email": "changaz.d@gmail.com" 9 | }, 10 | "version": "1.0" 11 | }, 12 | "host": "localhost:8089", 13 | "basePath": "/", 14 | "paths": { 15 | "/api/v1/accounts/": { 16 | "get": { 17 | "security": [ 18 | { 19 | "JWT": [] 20 | } 21 | ], 22 | "description": "Get balance", 23 | "consumes": [ 24 | "application/json" 25 | ], 26 | "produces": [ 27 | "application/json" 28 | ], 29 | "tags": [ 30 | "accounts" 31 | ], 32 | "summary": "Get balance", 33 | "parameters": [ 34 | { 35 | "description": "input", 36 | "name": "input", 37 | "in": "body", 38 | "required": true, 39 | "schema": { 40 | "$ref": "#/definitions/account-management-service_internal_controller_http_v1.getBalanceInput" 41 | } 42 | } 43 | ], 44 | "responses": { 45 | "200": { 46 | "description": "OK", 47 | "schema": { 48 | "$ref": "#/definitions/account-management-service_internal_controller_http_v1.accountRoutes" 49 | } 50 | }, 51 | "400": { 52 | "description": "Bad Request", 53 | "schema": { 54 | "$ref": "#/definitions/echo.HTTPError" 55 | } 56 | }, 57 | "500": { 58 | "description": "Internal Server Error", 59 | "schema": { 60 | "$ref": "#/definitions/echo.HTTPError" 61 | } 62 | } 63 | } 64 | } 65 | }, 66 | "/api/v1/accounts/create": { 67 | "post": { 68 | "security": [ 69 | { 70 | "JWT": [] 71 | } 72 | ], 73 | "description": "Create account", 74 | "consumes": [ 75 | "application/json" 76 | ], 77 | "produces": [ 78 | "application/json" 79 | ], 80 | "tags": [ 81 | "accounts" 82 | ], 83 | "summary": "Create account", 84 | "responses": { 85 | "201": { 86 | "description": "Created", 87 | "schema": { 88 | "$ref": "#/definitions/account-management-service_internal_controller_http_v1.accountRoutes" 89 | } 90 | }, 91 | "400": { 92 | "description": "Bad Request", 93 | "schema": { 94 | "$ref": "#/definitions/echo.HTTPError" 95 | } 96 | }, 97 | "500": { 98 | "description": "Internal Server Error", 99 | "schema": { 100 | "$ref": "#/definitions/echo.HTTPError" 101 | } 102 | } 103 | } 104 | } 105 | }, 106 | "/api/v1/accounts/deposit": { 107 | "post": { 108 | "security": [ 109 | { 110 | "JWT": [] 111 | } 112 | ], 113 | "description": "Deposit", 114 | "consumes": [ 115 | "application/json" 116 | ], 117 | "produces": [ 118 | "application/json" 119 | ], 120 | "tags": [ 121 | "accounts" 122 | ], 123 | "summary": "Deposit", 124 | "parameters": [ 125 | { 126 | "description": "input", 127 | "name": "input", 128 | "in": "body", 129 | "required": true, 130 | "schema": { 131 | "$ref": "#/definitions/account-management-service_internal_controller_http_v1.accountDepositInput" 132 | } 133 | } 134 | ], 135 | "responses": { 136 | "200": { 137 | "description": "OK" 138 | }, 139 | "400": { 140 | "description": "Bad Request", 141 | "schema": { 142 | "$ref": "#/definitions/echo.HTTPError" 143 | } 144 | }, 145 | "500": { 146 | "description": "Internal Server Error", 147 | "schema": { 148 | "$ref": "#/definitions/echo.HTTPError" 149 | } 150 | } 151 | } 152 | } 153 | }, 154 | "/api/v1/accounts/transfer": { 155 | "post": { 156 | "security": [ 157 | { 158 | "JWT": [] 159 | } 160 | ], 161 | "description": "Transfer", 162 | "consumes": [ 163 | "application/json" 164 | ], 165 | "produces": [ 166 | "application/json" 167 | ], 168 | "tags": [ 169 | "accounts" 170 | ], 171 | "summary": "Transfer", 172 | "parameters": [ 173 | { 174 | "description": "input", 175 | "name": "input", 176 | "in": "body", 177 | "required": true, 178 | "schema": { 179 | "$ref": "#/definitions/account-management-service_internal_controller_http_v1.accountTransferInput" 180 | } 181 | } 182 | ], 183 | "responses": { 184 | "200": { 185 | "description": "OK" 186 | }, 187 | "400": { 188 | "description": "Bad Request", 189 | "schema": { 190 | "$ref": "#/definitions/echo.HTTPError" 191 | } 192 | }, 193 | "500": { 194 | "description": "Internal Server Error", 195 | "schema": { 196 | "$ref": "#/definitions/echo.HTTPError" 197 | } 198 | } 199 | } 200 | } 201 | }, 202 | "/api/v1/accounts/withdraw": { 203 | "post": { 204 | "security": [ 205 | { 206 | "JWT": [] 207 | } 208 | ], 209 | "description": "Withdraw", 210 | "consumes": [ 211 | "application/json" 212 | ], 213 | "produces": [ 214 | "application/json" 215 | ], 216 | "tags": [ 217 | "accounts" 218 | ], 219 | "summary": "Withdraw", 220 | "parameters": [ 221 | { 222 | "description": "input", 223 | "name": "input", 224 | "in": "body", 225 | "required": true, 226 | "schema": { 227 | "$ref": "#/definitions/account-management-service_internal_controller_http_v1.accountWithdrawInput" 228 | } 229 | } 230 | ], 231 | "responses": { 232 | "200": { 233 | "description": "OK" 234 | }, 235 | "400": { 236 | "description": "Bad Request", 237 | "schema": { 238 | "$ref": "#/definitions/echo.HTTPError" 239 | } 240 | }, 241 | "500": { 242 | "description": "Internal Server Error", 243 | "schema": { 244 | "$ref": "#/definitions/echo.HTTPError" 245 | } 246 | } 247 | } 248 | } 249 | }, 250 | "/api/v1/operations/history": { 251 | "get": { 252 | "security": [ 253 | { 254 | "JWT": [] 255 | } 256 | ], 257 | "description": "Get history of operations", 258 | "consumes": [ 259 | "application/json" 260 | ], 261 | "produces": [ 262 | "application/json" 263 | ], 264 | "tags": [ 265 | "operations" 266 | ], 267 | "summary": "Get history", 268 | "parameters": [ 269 | { 270 | "description": "input", 271 | "name": "input", 272 | "in": "body", 273 | "required": true, 274 | "schema": { 275 | "$ref": "#/definitions/internal_controller_http_v1.getHistoryInput" 276 | } 277 | } 278 | ], 279 | "responses": { 280 | "200": { 281 | "description": "OK", 282 | "schema": { 283 | "$ref": "#/definitions/internal_controller_http_v1.operationRoutes" 284 | } 285 | }, 286 | "400": { 287 | "description": "Bad Request", 288 | "schema": { 289 | "$ref": "#/definitions/echo.HTTPError" 290 | } 291 | }, 292 | "500": { 293 | "description": "Internal Server Error", 294 | "schema": { 295 | "$ref": "#/definitions/echo.HTTPError" 296 | } 297 | } 298 | } 299 | } 300 | }, 301 | "/api/v1/operations/report-file": { 302 | "get": { 303 | "security": [ 304 | { 305 | "JWT": [] 306 | } 307 | ], 308 | "description": "Get report file", 309 | "consumes": [ 310 | "application/json" 311 | ], 312 | "produces": [ 313 | "text/csv" 314 | ], 315 | "tags": [ 316 | "operations" 317 | ], 318 | "summary": "Get report file", 319 | "parameters": [ 320 | { 321 | "description": "input", 322 | "name": "input", 323 | "in": "body", 324 | "required": true, 325 | "schema": { 326 | "$ref": "#/definitions/internal_controller_http_v1.getReportInput" 327 | } 328 | } 329 | ], 330 | "responses": { 331 | "200": { 332 | "description": "OK" 333 | }, 334 | "400": { 335 | "description": "Bad Request", 336 | "schema": { 337 | "$ref": "#/definitions/echo.HTTPError" 338 | } 339 | }, 340 | "500": { 341 | "description": "Internal Server Error", 342 | "schema": { 343 | "$ref": "#/definitions/echo.HTTPError" 344 | } 345 | } 346 | } 347 | } 348 | }, 349 | "/api/v1/operations/report-link": { 350 | "get": { 351 | "security": [ 352 | { 353 | "JWT": [] 354 | } 355 | ], 356 | "description": "Get link to report", 357 | "consumes": [ 358 | "application/json" 359 | ], 360 | "produces": [ 361 | "application/json" 362 | ], 363 | "tags": [ 364 | "operations" 365 | ], 366 | "summary": "Get report link", 367 | "parameters": [ 368 | { 369 | "description": "input", 370 | "name": "input", 371 | "in": "body", 372 | "required": true, 373 | "schema": { 374 | "$ref": "#/definitions/internal_controller_http_v1.getReportInput" 375 | } 376 | } 377 | ], 378 | "responses": { 379 | "200": { 380 | "description": "OK", 381 | "schema": { 382 | "$ref": "#/definitions/internal_controller_http_v1.operationRoutes" 383 | } 384 | }, 385 | "400": { 386 | "description": "Bad Request", 387 | "schema": { 388 | "$ref": "#/definitions/echo.HTTPError" 389 | } 390 | }, 391 | "500": { 392 | "description": "Internal Server Error", 393 | "schema": { 394 | "$ref": "#/definitions/echo.HTTPError" 395 | } 396 | } 397 | } 398 | } 399 | }, 400 | "/api/v1/products/create": { 401 | "post": { 402 | "security": [ 403 | { 404 | "JWT": [] 405 | } 406 | ], 407 | "description": "Create product", 408 | "consumes": [ 409 | "application/json" 410 | ], 411 | "produces": [ 412 | "application/json" 413 | ], 414 | "tags": [ 415 | "products" 416 | ], 417 | "summary": "Create product", 418 | "responses": { 419 | "201": { 420 | "description": "Created", 421 | "schema": { 422 | "$ref": "#/definitions/account-management-service_internal_controller_http_v1.productRoutes" 423 | } 424 | }, 425 | "400": { 426 | "description": "Bad Request", 427 | "schema": { 428 | "$ref": "#/definitions/echo.HTTPError" 429 | } 430 | }, 431 | "500": { 432 | "description": "Internal Server Error", 433 | "schema": { 434 | "$ref": "#/definitions/echo.HTTPError" 435 | } 436 | } 437 | } 438 | } 439 | }, 440 | "/api/v1/products/getById": { 441 | "get": { 442 | "security": [ 443 | { 444 | "JWT": [] 445 | } 446 | ], 447 | "description": "Get product by id", 448 | "consumes": [ 449 | "application/json" 450 | ], 451 | "produces": [ 452 | "application/json" 453 | ], 454 | "tags": [ 455 | "products" 456 | ], 457 | "summary": "Get product by id", 458 | "responses": { 459 | "200": { 460 | "description": "OK", 461 | "schema": { 462 | "$ref": "#/definitions/account-management-service_internal_controller_http_v1.productRoutes" 463 | } 464 | }, 465 | "400": { 466 | "description": "Bad Request", 467 | "schema": { 468 | "$ref": "#/definitions/echo.HTTPError" 469 | } 470 | }, 471 | "500": { 472 | "description": "Internal Server Error", 473 | "schema": { 474 | "$ref": "#/definitions/echo.HTTPError" 475 | } 476 | } 477 | } 478 | } 479 | }, 480 | "/api/v1/reservations/create": { 481 | "post": { 482 | "security": [ 483 | { 484 | "JWT": [] 485 | } 486 | ], 487 | "description": "Create reservation", 488 | "consumes": [ 489 | "application/json" 490 | ], 491 | "produces": [ 492 | "application/json" 493 | ], 494 | "tags": [ 495 | "reservations" 496 | ], 497 | "summary": "Create reservation", 498 | "parameters": [ 499 | { 500 | "description": "input", 501 | "name": "input", 502 | "in": "body", 503 | "required": true, 504 | "schema": { 505 | "$ref": "#/definitions/account-management-service_internal_controller_http_v1.reservationCreateInput" 506 | } 507 | } 508 | ], 509 | "responses": { 510 | "201": { 511 | "description": "Created", 512 | "schema": { 513 | "$ref": "#/definitions/account-management-service_internal_controller_http_v1.reservationRoutes" 514 | } 515 | }, 516 | "400": { 517 | "description": "Bad Request", 518 | "schema": { 519 | "$ref": "#/definitions/echo.HTTPError" 520 | } 521 | }, 522 | "500": { 523 | "description": "Internal Server Error", 524 | "schema": { 525 | "$ref": "#/definitions/echo.HTTPError" 526 | } 527 | } 528 | } 529 | } 530 | }, 531 | "/api/v1/reservations/refund": { 532 | "post": { 533 | "security": [ 534 | { 535 | "JWT": [] 536 | } 537 | ], 538 | "description": "Refund reservation", 539 | "consumes": [ 540 | "application/json" 541 | ], 542 | "produces": [ 543 | "application/json" 544 | ], 545 | "tags": [ 546 | "reservations" 547 | ], 548 | "summary": "Refund reservation", 549 | "parameters": [ 550 | { 551 | "description": "input", 552 | "name": "input", 553 | "in": "body", 554 | "required": true, 555 | "schema": { 556 | "$ref": "#/definitions/account-management-service_internal_controller_http_v1.reservationRefundInput" 557 | } 558 | } 559 | ], 560 | "responses": { 561 | "200": { 562 | "description": "OK" 563 | }, 564 | "400": { 565 | "description": "Bad Request", 566 | "schema": { 567 | "$ref": "#/definitions/echo.HTTPError" 568 | } 569 | }, 570 | "500": { 571 | "description": "Internal Server Error", 572 | "schema": { 573 | "$ref": "#/definitions/echo.HTTPError" 574 | } 575 | } 576 | } 577 | } 578 | }, 579 | "/api/v1/reservations/revenue": { 580 | "post": { 581 | "security": [ 582 | { 583 | "JWT": [] 584 | } 585 | ], 586 | "description": "Revenue reservation", 587 | "consumes": [ 588 | "application/json" 589 | ], 590 | "produces": [ 591 | "application/json" 592 | ], 593 | "tags": [ 594 | "reservations" 595 | ], 596 | "summary": "Revenue reservation", 597 | "parameters": [ 598 | { 599 | "description": "input", 600 | "name": "input", 601 | "in": "body", 602 | "required": true, 603 | "schema": { 604 | "$ref": "#/definitions/account-management-service_internal_controller_http_v1.reservationRevenueInput" 605 | } 606 | } 607 | ], 608 | "responses": { 609 | "200": { 610 | "description": "OK" 611 | }, 612 | "400": { 613 | "description": "Bad Request", 614 | "schema": { 615 | "$ref": "#/definitions/echo.HTTPError" 616 | } 617 | }, 618 | "500": { 619 | "description": "Internal Server Error", 620 | "schema": { 621 | "$ref": "#/definitions/echo.HTTPError" 622 | } 623 | } 624 | } 625 | } 626 | }, 627 | "/auth/sign-in": { 628 | "post": { 629 | "description": "Sign in", 630 | "consumes": [ 631 | "application/json" 632 | ], 633 | "produces": [ 634 | "application/json" 635 | ], 636 | "tags": [ 637 | "auth" 638 | ], 639 | "summary": "Sign in", 640 | "parameters": [ 641 | { 642 | "description": "input", 643 | "name": "input", 644 | "in": "body", 645 | "required": true, 646 | "schema": { 647 | "$ref": "#/definitions/account-management-service_internal_controller_http_v1.signInInput" 648 | } 649 | } 650 | ], 651 | "responses": { 652 | "200": { 653 | "description": "OK", 654 | "schema": { 655 | "$ref": "#/definitions/account-management-service_internal_controller_http_v1.authRoutes" 656 | } 657 | }, 658 | "400": { 659 | "description": "Bad Request", 660 | "schema": { 661 | "$ref": "#/definitions/echo.HTTPError" 662 | } 663 | }, 664 | "500": { 665 | "description": "Internal Server Error", 666 | "schema": { 667 | "$ref": "#/definitions/echo.HTTPError" 668 | } 669 | } 670 | } 671 | } 672 | }, 673 | "/auth/sign-up": { 674 | "post": { 675 | "description": "Sign up", 676 | "consumes": [ 677 | "application/json" 678 | ], 679 | "produces": [ 680 | "application/json" 681 | ], 682 | "tags": [ 683 | "auth" 684 | ], 685 | "summary": "Sign up", 686 | "parameters": [ 687 | { 688 | "description": "input", 689 | "name": "input", 690 | "in": "body", 691 | "required": true, 692 | "schema": { 693 | "$ref": "#/definitions/account-management-service_internal_controller_http_v1.signUpInput" 694 | } 695 | } 696 | ], 697 | "responses": { 698 | "201": { 699 | "description": "Created", 700 | "schema": { 701 | "$ref": "#/definitions/account-management-service_internal_controller_http_v1.authRoutes" 702 | } 703 | }, 704 | "400": { 705 | "description": "Bad Request", 706 | "schema": { 707 | "$ref": "#/definitions/echo.HTTPError" 708 | } 709 | }, 710 | "500": { 711 | "description": "Internal Server Error", 712 | "schema": { 713 | "$ref": "#/definitions/echo.HTTPError" 714 | } 715 | } 716 | } 717 | } 718 | } 719 | }, 720 | "definitions": { 721 | "account-management-service_internal_controller_http_v1.accountDepositInput": { 722 | "type": "object", 723 | "required": [ 724 | "amount", 725 | "id" 726 | ], 727 | "properties": { 728 | "amount": { 729 | "type": "integer" 730 | }, 731 | "id": { 732 | "type": "integer" 733 | } 734 | } 735 | }, 736 | "account-management-service_internal_controller_http_v1.accountRoutes": { 737 | "type": "object" 738 | }, 739 | "account-management-service_internal_controller_http_v1.accountTransferInput": { 740 | "type": "object", 741 | "required": [ 742 | "amount", 743 | "from", 744 | "to" 745 | ], 746 | "properties": { 747 | "amount": { 748 | "type": "integer" 749 | }, 750 | "from": { 751 | "type": "integer" 752 | }, 753 | "to": { 754 | "type": "integer" 755 | } 756 | } 757 | }, 758 | "account-management-service_internal_controller_http_v1.accountWithdrawInput": { 759 | "type": "object", 760 | "required": [ 761 | "amount", 762 | "id" 763 | ], 764 | "properties": { 765 | "amount": { 766 | "type": "integer" 767 | }, 768 | "id": { 769 | "type": "integer" 770 | } 771 | } 772 | }, 773 | "account-management-service_internal_controller_http_v1.authRoutes": { 774 | "type": "object" 775 | }, 776 | "account-management-service_internal_controller_http_v1.getBalanceInput": { 777 | "type": "object", 778 | "required": [ 779 | "id" 780 | ], 781 | "properties": { 782 | "id": { 783 | "type": "integer" 784 | } 785 | } 786 | }, 787 | "account-management-service_internal_controller_http_v1.getHistoryInput": { 788 | "type": "object", 789 | "required": [ 790 | "account_id" 791 | ], 792 | "properties": { 793 | "account_id": { 794 | "type": "integer" 795 | }, 796 | "limit": { 797 | "type": "integer" 798 | }, 799 | "offset": { 800 | "type": "integer" 801 | }, 802 | "sort_type": { 803 | "type": "string" 804 | } 805 | } 806 | }, 807 | "account-management-service_internal_controller_http_v1.getReportInput": { 808 | "type": "object", 809 | "required": [ 810 | "month", 811 | "year" 812 | ], 813 | "properties": { 814 | "month": { 815 | "type": "integer" 816 | }, 817 | "year": { 818 | "type": "integer" 819 | } 820 | } 821 | }, 822 | "account-management-service_internal_controller_http_v1.operationRoutes": { 823 | "type": "object", 824 | "properties": { 825 | "service.Operation": {} 826 | } 827 | }, 828 | "account-management-service_internal_controller_http_v1.productRoutes": { 829 | "type": "object" 830 | }, 831 | "account-management-service_internal_controller_http_v1.reservationCreateInput": { 832 | "type": "object", 833 | "required": [ 834 | "account_id", 835 | "amount", 836 | "order_id", 837 | "product_id" 838 | ], 839 | "properties": { 840 | "account_id": { 841 | "type": "integer" 842 | }, 843 | "amount": { 844 | "type": "integer" 845 | }, 846 | "order_id": { 847 | "type": "integer" 848 | }, 849 | "product_id": { 850 | "type": "integer" 851 | } 852 | } 853 | }, 854 | "account-management-service_internal_controller_http_v1.reservationRefundInput": { 855 | "type": "object", 856 | "required": [ 857 | "order_id" 858 | ], 859 | "properties": { 860 | "order_id": { 861 | "type": "integer" 862 | } 863 | } 864 | }, 865 | "account-management-service_internal_controller_http_v1.reservationRevenueInput": { 866 | "type": "object", 867 | "required": [ 868 | "account_id", 869 | "amount", 870 | "order_id", 871 | "product_id" 872 | ], 873 | "properties": { 874 | "account_id": { 875 | "type": "integer" 876 | }, 877 | "amount": { 878 | "type": "integer" 879 | }, 880 | "order_id": { 881 | "type": "integer" 882 | }, 883 | "product_id": { 884 | "type": "integer" 885 | } 886 | } 887 | }, 888 | "account-management-service_internal_controller_http_v1.reservationRoutes": { 889 | "type": "object" 890 | }, 891 | "account-management-service_internal_controller_http_v1.signInInput": { 892 | "type": "object", 893 | "required": [ 894 | "password", 895 | "username" 896 | ], 897 | "properties": { 898 | "password": { 899 | "type": "string" 900 | }, 901 | "username": { 902 | "type": "string", 903 | "maxLength": 32, 904 | "minLength": 4 905 | } 906 | } 907 | }, 908 | "account-management-service_internal_controller_http_v1.signUpInput": { 909 | "type": "object", 910 | "required": [ 911 | "password", 912 | "username" 913 | ], 914 | "properties": { 915 | "password": { 916 | "type": "string" 917 | }, 918 | "username": { 919 | "type": "string", 920 | "maxLength": 32, 921 | "minLength": 4 922 | } 923 | } 924 | }, 925 | "echo.HTTPError": { 926 | "type": "object", 927 | "properties": { 928 | "message": {} 929 | } 930 | }, 931 | "internal_controller_http_v1.accountDepositInput": { 932 | "type": "object", 933 | "required": [ 934 | "amount", 935 | "id" 936 | ], 937 | "properties": { 938 | "amount": { 939 | "type": "integer" 940 | }, 941 | "id": { 942 | "type": "integer" 943 | } 944 | } 945 | }, 946 | "internal_controller_http_v1.accountRoutes": { 947 | "type": "object" 948 | }, 949 | "internal_controller_http_v1.accountTransferInput": { 950 | "type": "object", 951 | "required": [ 952 | "amount", 953 | "from", 954 | "to" 955 | ], 956 | "properties": { 957 | "amount": { 958 | "type": "integer" 959 | }, 960 | "from": { 961 | "type": "integer" 962 | }, 963 | "to": { 964 | "type": "integer" 965 | } 966 | } 967 | }, 968 | "internal_controller_http_v1.accountWithdrawInput": { 969 | "type": "object", 970 | "required": [ 971 | "amount", 972 | "id" 973 | ], 974 | "properties": { 975 | "amount": { 976 | "type": "integer" 977 | }, 978 | "id": { 979 | "type": "integer" 980 | } 981 | } 982 | }, 983 | "internal_controller_http_v1.authRoutes": { 984 | "type": "object" 985 | }, 986 | "internal_controller_http_v1.getBalanceInput": { 987 | "type": "object", 988 | "required": [ 989 | "id" 990 | ], 991 | "properties": { 992 | "id": { 993 | "type": "integer" 994 | } 995 | } 996 | }, 997 | "internal_controller_http_v1.getHistoryInput": { 998 | "type": "object", 999 | "required": [ 1000 | "account_id" 1001 | ], 1002 | "properties": { 1003 | "account_id": { 1004 | "type": "integer" 1005 | }, 1006 | "limit": { 1007 | "type": "integer" 1008 | }, 1009 | "offset": { 1010 | "type": "integer" 1011 | }, 1012 | "sort_type": { 1013 | "type": "string" 1014 | } 1015 | } 1016 | }, 1017 | "internal_controller_http_v1.getReportInput": { 1018 | "type": "object", 1019 | "required": [ 1020 | "month", 1021 | "year" 1022 | ], 1023 | "properties": { 1024 | "month": { 1025 | "type": "integer" 1026 | }, 1027 | "year": { 1028 | "type": "integer" 1029 | } 1030 | } 1031 | }, 1032 | "internal_controller_http_v1.operationRoutes": { 1033 | "type": "object", 1034 | "properties": { 1035 | "service.Operation": {} 1036 | } 1037 | }, 1038 | "internal_controller_http_v1.productRoutes": { 1039 | "type": "object" 1040 | }, 1041 | "internal_controller_http_v1.reservationCreateInput": { 1042 | "type": "object", 1043 | "required": [ 1044 | "account_id", 1045 | "amount", 1046 | "order_id", 1047 | "product_id" 1048 | ], 1049 | "properties": { 1050 | "account_id": { 1051 | "type": "integer" 1052 | }, 1053 | "amount": { 1054 | "type": "integer" 1055 | }, 1056 | "order_id": { 1057 | "type": "integer" 1058 | }, 1059 | "product_id": { 1060 | "type": "integer" 1061 | } 1062 | } 1063 | }, 1064 | "internal_controller_http_v1.reservationRefundInput": { 1065 | "type": "object", 1066 | "required": [ 1067 | "order_id" 1068 | ], 1069 | "properties": { 1070 | "order_id": { 1071 | "type": "integer" 1072 | } 1073 | } 1074 | }, 1075 | "internal_controller_http_v1.reservationRevenueInput": { 1076 | "type": "object", 1077 | "required": [ 1078 | "account_id", 1079 | "amount", 1080 | "order_id", 1081 | "product_id" 1082 | ], 1083 | "properties": { 1084 | "account_id": { 1085 | "type": "integer" 1086 | }, 1087 | "amount": { 1088 | "type": "integer" 1089 | }, 1090 | "order_id": { 1091 | "type": "integer" 1092 | }, 1093 | "product_id": { 1094 | "type": "integer" 1095 | } 1096 | } 1097 | }, 1098 | "internal_controller_http_v1.reservationRoutes": { 1099 | "type": "object" 1100 | }, 1101 | "internal_controller_http_v1.signInInput": { 1102 | "type": "object", 1103 | "required": [ 1104 | "password", 1105 | "username" 1106 | ], 1107 | "properties": { 1108 | "password": { 1109 | "type": "string" 1110 | }, 1111 | "username": { 1112 | "type": "string", 1113 | "maxLength": 32, 1114 | "minLength": 4 1115 | } 1116 | } 1117 | }, 1118 | "internal_controller_http_v1.signUpInput": { 1119 | "type": "object", 1120 | "required": [ 1121 | "password", 1122 | "username" 1123 | ], 1124 | "properties": { 1125 | "password": { 1126 | "type": "string" 1127 | }, 1128 | "username": { 1129 | "type": "string", 1130 | "maxLength": 32, 1131 | "minLength": 4 1132 | } 1133 | } 1134 | } 1135 | }, 1136 | "securityDefinitions": { 1137 | "JWT": { 1138 | "type": "apiKey", 1139 | "name": "Authorization", 1140 | "in": "header" 1141 | } 1142 | } 1143 | } --------------------------------------------------------------------------------