Skip to content

Commit 077daf0

Browse files
authored
Refactor entain test task (#1)
2 parents fa2981f + f7da420 commit 077daf0

30 files changed

+1044
-605
lines changed

.env.example

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
POSTGRES_DB_HOST="localhost"
2-
POSTGRES_DB_PORT="5432"
3-
POSTGRES_DB_USER="user"
4-
POSTGRES_DB_PASS="12345"
5-
POSTGRES_DB_NAME="entain"
6-
POSTGRES_DATABASEURL="postgres://${POSTGRES_DB_USER}:${POSTGRES_DB_PASS}@${POSTGRES_DB_HOST}:${POSTGRES_DB_PORT}/${POSTGRES_DB_NAME}"
7-
CANCEL_ODD_RECORDS_MINUTES_INTERVAL=10
8-
SERVER_PORT=8080
1+
POSTGRES_HOST="localhost"
2+
POSTGRES_PORT="5432"
3+
POSTGRES_USER="user"
4+
POSTGRES_PASSWORD="12345"
5+
POSTGRES_DB="entain"
6+
CANCEL_ODD_RECORDS_MINUTES_INTERVAL=1
7+
NUMBER_OF_LATEST_RECORDS_FOR_CANCELLING=10
8+
SERVER_PORT="8080"

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
.env
2+
vendor

.golangci.yml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
run:
2+
# Default concurrency is an available CPU number
3+
concurrency: 4
4+
5+
# Include test files or not, default is true
6+
tests: true
7+
8+
# Settings of specific linters
9+
linters-settings:
10+
gocyclo:
11+
min-complexity: 15
12+
13+
linters:
14+
# do not use `enable-all`: it's deprecated and will be removed soon.
15+
disable-all: true
16+
enable:
17+
- asasalint
18+
- asciicheck
19+
- bidichk
20+
- bodyclose
21+
- dogsled
22+
- durationcheck
23+
- errcheck
24+
- errchkjson
25+
- execinquery
26+
- exportloopref
27+
- forbidigo
28+
- gocognit
29+
- gocritic
30+
- godox
31+
- gofmt
32+
- gofumpt
33+
- goimports
34+
- goprintffuncname
35+
- gosimple
36+
- govet
37+
- ineffassign
38+
- misspell
39+
- nakedret
40+
- nestif
41+
- nilerr
42+
- nonamedreturns
43+
- prealloc
44+
- predeclared
45+
- revive
46+
- staticcheck
47+
- stylecheck
48+
- tenv
49+
- typecheck
50+
- unconvert
51+
- unparam
52+
- unused
53+
- usestdlibvars
54+
- whitespace
55+
56+
issues:
57+
max-same-issues: 0

Dockerfile

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,16 @@
1-
FROM golang:1.19-alpine3.16 as builder
1+
FROM golang:1.22.4-alpine3.20
22

33
# Set the Current Working Directory inside the container
44
WORKDIR /app
55

66
# Copy the source from the current directory to the Working Directory inside the container
77
COPY . .
88

9-
# Install git.
10-
# Git is required for fetching the dependencies.
11-
RUN apk update && apk add --no-cache git && apk add --no-cache bash && apk add build-base
12-
13-
# Download all dependencies. Dependencies will be cached if the go.mod and the go.sum files are not changed
14-
RUN go mod download
15-
16-
# Build the Go app
17-
RUN go build -o main .
9+
# Install any needed packages specified in go.mod and build the go app
10+
RUN apk update && \
11+
apk add --no-cache git bash build-base && \
12+
go mod download && \
13+
go build -o main .
1814

1915
# This container exposes port 8080 to the outside world
2016
EXPOSE ${SERVER_PORT}

Makefile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@ include .env
33
run:
44
go run main.go
55

6+
mod-vendor:
7+
go mod vendor
8+
9+
linter:
10+
@golangci-lint run
11+
12+
gosec:
13+
@gosec -quiet ./...
14+
15+
validate: linter gosec
16+
617
create-migration:
718
@migrate create -ext sql -dir migrations -seq ${name}
819

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Process record request body example:
3838

3939
Transaction id is unique, so you can't process the same transaction twice, provide UUID v4 format.
4040
State can be `win` or `lose`.
41-
Amount is a number, can be positive or negative.
41+
Amount is a number should be positive but to have a negative balance you should provide a `lose` state.
4242

4343
### Required header for all endpoints:
4444

@@ -61,6 +61,6 @@ This user has 50 amount of his balance for testing.
6161
## To run application in docker container:
6262

6363
1. Create `.env` file in root folder and add all required variables from `.env.example` file
64-
2. To run docker container you should have `docker` and `docker-compose` tools installed (Tested on `docker version 20.10.22, build 3a2c30b` and `docker-compose version 1.26.2, build eefe0d31`)
64+
2. To run docker container you should have `docker` and `docker-compose` tools installed (Tested on `Docker version 26.1.3, build b72abbb` and `Docker Compose version v2.27.1`)
6565
3. `docker-compose up` - to run application in docker container
6666
4. `docker-compose down` - to stop application in docker container

configuration/config.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package configuration
2+
3+
import (
4+
"github.com/joho/godotenv"
5+
"github.com/spf13/viper"
6+
)
7+
8+
type Config struct {
9+
PostgresHost string
10+
PostgresPort string
11+
PostgresUser string
12+
PostgresPassword string
13+
PostgresName string
14+
CancelOddRecordsMinutesInterval int
15+
NumberOfLatestRecordsForCancelling int
16+
ServerPort string
17+
}
18+
19+
func Load() (*Config, error) {
20+
if err := godotenv.Load(); err != nil {
21+
return nil, err
22+
}
23+
24+
viper.AutomaticEnv()
25+
26+
return &Config{
27+
PostgresHost: viper.GetString("POSTGRES_HOST"),
28+
PostgresPort: viper.GetString("POSTGRES_PORT"),
29+
PostgresUser: viper.GetString("POSTGRES_USER"),
30+
PostgresPassword: viper.GetString("POSTGRES_PASSWORD"),
31+
PostgresName: viper.GetString("POSTGRES_DB"),
32+
CancelOddRecordsMinutesInterval: viper.GetInt("CANCEL_ODD_RECORDS_MINUTES_INTERVAL"),
33+
NumberOfLatestRecordsForCancelling: viper.GetInt("NUMBER_OF_LATEST_RECORDS_FOR_CANCELLING"),
34+
ServerPort: viper.GetString("SERVER_PORT"),
35+
}, nil
36+
}

controller/controllers.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package controller
2+
3+
import (
4+
"github.com/entain-test-task/configuration"
5+
"github.com/entain-test-task/repository"
6+
"github.com/entain-test-task/service"
7+
)
8+
9+
type Controllers struct {
10+
User *User
11+
Transaction *Transaction
12+
}
13+
14+
func NewControllers(
15+
cfg *configuration.Config,
16+
store *repository.Store,
17+
) *Controllers {
18+
userRepo := repository.NewUser(store)
19+
transactionRepo := repository.NewTransaction(store)
20+
21+
userService := service.NewUser(userRepo)
22+
transactionService := service.NewTransaction(transactionRepo, userRepo)
23+
24+
return &Controllers{
25+
User: NewUser(userService),
26+
Transaction: NewTransaction(cfg, transactionService),
27+
}
28+
}
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package middleware
1+
package controller
22

33
import (
44
"encoding/json"
@@ -8,50 +8,43 @@ import (
88
)
99

1010
func StatusOK(w http.ResponseWriter, response interface{}) {
11-
w.Header().Set("Content-Type", "application/json")
12-
w.WriteHeader(http.StatusOK)
13-
14-
json.NewEncoder(w).Encode(response)
11+
writeJSONResponse(w, http.StatusOK, response)
1512
}
1613

1714
func StatusInternalServerError(w http.ResponseWriter) {
18-
w.Header().Set("Content-Type", "application/json")
19-
w.WriteHeader(http.StatusInternalServerError)
20-
21-
json.NewEncoder(w).Encode(model.Error{
15+
writeJSONResponse(w, http.StatusInternalServerError, model.Error{
2216
Message: "Internal Server Error",
2317
})
2418
}
2519

2620
func StatusBadRequest(w http.ResponseWriter, message string) {
27-
w.Header().Set("Content-Type", "application/json")
28-
w.WriteHeader(http.StatusBadRequest)
29-
30-
json.NewEncoder(w).Encode(model.Error{
21+
writeJSONResponse(w, http.StatusBadRequest, model.Error{
3122
Message: message,
3223
})
3324
}
3425

3526
func StatusUnprocessableEntity(w http.ResponseWriter, message string) {
36-
w.Header().Set("Content-Type", "application/json")
37-
w.WriteHeader(http.StatusUnprocessableEntity)
38-
39-
json.NewEncoder(w).Encode(model.Error{
27+
writeJSONResponse(w, http.StatusUnprocessableEntity, model.Error{
4028
Message: message,
4129
})
4230
}
4331

4432
func StatusBadRequestWithErrors(w http.ResponseWriter, message string, validationErrors []error) {
45-
w.Header().Set("Content-Type", "application/json")
46-
w.WriteHeader(http.StatusBadRequest)
47-
4833
errorDetails := make([]string, 0)
4934
for _, validationError := range validationErrors {
5035
errorDetails = append(errorDetails, validationError.Error())
5136
}
5237

53-
json.NewEncoder(w).Encode(model.Error{
38+
writeJSONResponse(w, http.StatusBadRequest, model.Error{
5439
Message: message,
5540
Errors: errorDetails,
5641
})
5742
}
43+
44+
func writeJSONResponse(w http.ResponseWriter, statusCode int, response interface{}) {
45+
w.Header().Set("Content-Type", "application/json")
46+
w.WriteHeader(statusCode)
47+
if err := json.NewEncoder(w).Encode(response); err != nil {
48+
http.Error(w, err.Error(), http.StatusInternalServerError)
49+
}
50+
}

controller/transaction.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package controller
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"log"
7+
"net/http"
8+
9+
"github.com/entain-test-task/configuration"
10+
"github.com/entain-test-task/model"
11+
requestmodel "github.com/entain-test-task/model/request"
12+
"github.com/entain-test-task/service"
13+
"github.com/go-openapi/strfmt"
14+
"github.com/gorilla/mux"
15+
)
16+
17+
type Transaction struct {
18+
cfg *configuration.Config
19+
service *service.Transaction
20+
validator *model.Validator
21+
}
22+
23+
func NewTransaction(
24+
cfg *configuration.Config,
25+
service *service.Transaction,
26+
) *Transaction {
27+
return &Transaction{
28+
cfg: cfg,
29+
service: service,
30+
validator: model.NewValidator(),
31+
}
32+
}
33+
34+
func (controller *Transaction) GetAllTransactionsByUserID(w http.ResponseWriter, r *http.Request) {
35+
params := mux.Vars(r)
36+
userID := params["user_id"]
37+
38+
if !strfmt.IsUUID4(userID) {
39+
StatusBadRequest(w, "user_id is not a valid UUID4")
40+
return
41+
}
42+
43+
getAllTransactionsByUserIDResponse, err := controller.service.GetAllTransactionsByUserID(strfmt.UUID4(userID))
44+
if err != nil {
45+
log.Printf("getting all transactions by user ID failed. %v", err)
46+
StatusInternalServerError(w)
47+
return
48+
}
49+
50+
StatusOK(w, getAllTransactionsByUserIDResponse)
51+
}
52+
53+
func (controller *Transaction) ProcessRecord(w http.ResponseWriter, r *http.Request) {
54+
params := mux.Vars(r)
55+
userID := params["user_id"]
56+
57+
if !strfmt.IsUUID4(userID) {
58+
StatusBadRequest(w, "user_id is not a valid UUID4")
59+
return
60+
}
61+
62+
var processRecordRequest requestmodel.ProcessRecordRequest
63+
if err := json.NewDecoder(r.Body).Decode(&processRecordRequest); err != nil {
64+
handleDecodeError(err, w)
65+
return
66+
}
67+
68+
validationErrors := controller.validator.ValidateRequest(processRecordRequest)
69+
if validationErrors != nil {
70+
log.Printf("validating request failed. %v", validationErrors)
71+
StatusBadRequestWithErrors(w, "validation error", validationErrors)
72+
return
73+
}
74+
75+
processRecordResponse, err := controller.service.ProcessRecord(strfmt.UUID4(userID), processRecordRequest)
76+
if err != nil {
77+
handleProcessRecordError(w, err)
78+
return
79+
}
80+
81+
StatusOK(w, processRecordResponse)
82+
}
83+
84+
func (controller *Transaction) CancelLatestOddTransactionRecords() {
85+
controller.service.CancelLatestOddTransactionRecords(controller.cfg.NumberOfLatestRecordsForCancelling)
86+
}
87+
88+
func handleProcessRecordError(w http.ResponseWriter, err error) {
89+
switch err.Error() {
90+
case model.ErrUserNotFound().Error():
91+
StatusUnprocessableEntity(w, model.ErrUserNotFound().Error())
92+
case model.ErrInsufficientBalance().Error():
93+
StatusUnprocessableEntity(w, model.ErrInsufficientBalance().Error())
94+
case model.ErrTransactionAlreadyExists().Error():
95+
StatusUnprocessableEntity(w, model.ErrTransactionAlreadyExists().Error())
96+
default:
97+
log.Printf("%s. %v", "processing record failed", err)
98+
StatusInternalServerError(w)
99+
}
100+
}
101+
102+
func handleDecodeError(err error, w http.ResponseWriter) {
103+
if err == nil {
104+
return
105+
}
106+
107+
handleUnmarshalTypeError(err, w)
108+
109+
log.Printf("decoding request failed. %v", err)
110+
StatusInternalServerError(w)
111+
}
112+
113+
func handleUnmarshalTypeError(err error, w http.ResponseWriter) {
114+
typeErr, ok := err.(*json.UnmarshalTypeError)
115+
if !ok {
116+
return
117+
}
118+
119+
StatusBadRequestWithErrors(w, "validation error", []error{
120+
errors.New(typeErr.Field + " is not within allowed range"),
121+
})
122+
}

0 commit comments

Comments
 (0)