diff --git a/cmd/backend/app.go b/cmd/backend/app.go
index aa520d2..59c1b26 100644
--- a/cmd/backend/app.go
+++ b/cmd/backend/app.go
@@ -186,7 +186,7 @@ func (a *App) Run(p RunParams) {
}()
}
- srv := server.New(
+ srv := server.NewServer(
server.NewServerOpts{
DebugMode: debugMode,
Logger: logger,
diff --git a/cmd/backend/config_defaults/config.yaml b/cmd/backend/config.yaml
similarity index 100%
rename from cmd/backend/config_defaults/config.yaml
rename to cmd/backend/config.yaml
diff --git a/cmd/backend/config_defaults/jwt_signing_key b/cmd/backend/jwt_signing_key
similarity index 100%
rename from cmd/backend/config_defaults/jwt_signing_key
rename to cmd/backend/jwt_signing_key
diff --git a/cmd/backend/server/handlers/user_create_handler.go b/cmd/backend/server/handlers/user_create_handler.go
index 09610e0..392bbdb 100644
--- a/cmd/backend/server/handlers/user_create_handler.go
+++ b/cmd/backend/server/handlers/user_create_handler.go
@@ -2,16 +2,17 @@ package handlers
import (
"backend/internal/core/services"
+ httpserver "backend/internal/http_server"
"backend/pkg/logger"
- "encoding/json"
+ "context"
"github.com/gin-gonic/gin"
)
type createUserInput struct {
- Email string `json:"email"`
- Password string `json:"password"`
- Name string `json:"name"`
+ Email string `json:"email" validate:"required,email"`
+ Password string `json:"password" validate:"required"`
+ Name string `json:"name" validate:"required"`
}
type createUserOutput struct {
@@ -20,54 +21,27 @@ type createUserOutput struct {
Name string `json:"name"`
}
-func NewUserCreateHandler(logger logger.Logger, userService services.UserService) gin.HandlerFunc {
- return func(c *gin.Context) {
- ctxLogger := logger.WithContext(c)
+func NewUserCreateHandler(log logger.Logger, userService services.UserService) gin.HandlerFunc {
+ return httpserver.WrapGin(log,
+ func(ctx context.Context, input createUserInput) (createUserOutput, error) {
+ user, err := userService.CreateUser(ctx,
+ services.UserCreateParams{
+ Email: input.Email,
+ Password: input.Password,
+ Name: input.Name,
+ },
+ )
- params := createUserInput{}
- if err := c.ShouldBindJSON(¶ms); err != nil {
- ctxLogger.Error().Err(err).Msg("bad input body model")
- c.Data(400, "plain/text", []byte(err.Error()))
- return
- }
+ out := createUserOutput{}
+ if err != nil {
+ return out, err
+ }
- dto, err := userService.CreateUser(
- c,
- services.UserCreateParams{
- Email: params.Email,
- Password: params.Password,
- Name: params.Name,
- },
- )
- if err == services.ErrUserExists {
- ctxLogger.Error().Err(err).Msg("user already exists")
- c.Data(400, "plain/text", []byte(err.Error()))
- return
- }
- if err == services.ErrUserBadPassword {
- ctxLogger.Error().Err(err).Msg("password does not satisfy requirements")
- c.Data(400, "plain/text", []byte(err.Error()))
- return
- }
- if err != nil {
- ctxLogger.Error().Err(err).Msg("unexpected create user error")
- c.Data(500, "plain/text", []byte(err.Error()))
- return
- }
-
- resultBody, err := json.Marshal(
- createUserOutput{
- Id: dto.Id,
- Email: dto.Email,
- Name: dto.Name,
- },
- )
- if err != nil {
- ctxLogger.Error().Err(err).Msg("marshal user model error")
- c.Data(500, "plain/text", []byte(err.Error()))
- return
- }
-
- c.Data(200, "application/json", resultBody)
- }
+ return createUserOutput{
+ Id: user.Id,
+ Email: user.Email,
+ Name: user.Name,
+ }, nil
+ },
+ )
}
diff --git a/cmd/backend/server/handlers/user_login_handler.go b/cmd/backend/server/handlers/user_login_handler.go
index d1878d4..90193dd 100644
--- a/cmd/backend/server/handlers/user_login_handler.go
+++ b/cmd/backend/server/handlers/user_login_handler.go
@@ -2,14 +2,15 @@ package handlers
import (
"backend/internal/core/services"
+ httpserver "backend/internal/http_server"
"backend/pkg/logger"
- "encoding/json"
+ "context"
"github.com/gin-gonic/gin"
)
type loginUserInput struct {
- Login string `json:"email"`
+ Login string `json:"email" validate:"required,email"`
Password string `json:"password"`
}
@@ -17,43 +18,17 @@ type loginUserOutput struct {
Token string `json:"token"`
}
-func NewUserLoginHandler(logger logger.Logger, userService services.UserService) gin.HandlerFunc {
- return func(c *gin.Context) {
- ctxLogger := logger.WithContext(c).WithPrefix("NewUserLoginHandler")
+func NewUserLoginHandler(log logger.Logger, userService services.UserService) gin.HandlerFunc {
+ return httpserver.WrapGin(log,
+ func(ctx context.Context, input loginUserInput) (loginUserOutput, error) {
+ token, err := userService.AuthenticateUser(ctx, input.Login, input.Password)
+ if err != nil {
+ return loginUserOutput{}, err
+ }
- params := loginUserInput{}
- if err := c.ShouldBindJSON(¶ms); err != nil {
- ctxLogger.Error().Err(err).Msg("bad input body model")
- c.AbortWithError(400, err)
- return
- }
-
- token, err := userService.AuthenticateUser(c, params.Login, params.Password)
- if err == services.ErrUserNotExists {
- ctxLogger.Error().Err(err).Msg("user does not exist")
- c.AbortWithError(400, err)
- return
- }
- if err == services.ErrUserWrongPassword {
- ctxLogger.Error().Err(err).Msg("wrong password")
- c.AbortWithError(400, err)
- return
- }
- if err != nil {
- ctxLogger.Error().Err(err).Msg("AuthenticateUser internal error")
- c.AbortWithError(500, err)
- return
- }
-
- resultBody, err := json.Marshal(loginUserOutput{
- Token: token,
- })
- if err != nil {
- ctxLogger.Error().Err(err).Msg("marshal json internal error")
- c.AbortWithError(500, err)
- return
- }
-
- c.Data(200, "application/json", resultBody)
- }
+ return loginUserOutput{
+ Token: token,
+ }, nil
+ },
+ )
}
diff --git a/cmd/backend/server/handlers/user_verify_handler.go b/cmd/backend/server/handlers/user_verify_handler.go
new file mode 100644
index 0000000..b6a6163
--- /dev/null
+++ b/cmd/backend/server/handlers/user_verify_handler.go
@@ -0,0 +1,71 @@
+package handlers
+
+import (
+ "backend/internal/core/services"
+ "backend/pkg/logger"
+
+ "html/template"
+
+ "github.com/gin-gonic/gin"
+)
+
+type HtmlTemplate struct {
+ TabTitle string
+ Title string
+ Text string
+ Link string
+ LinkText string
+}
+
+const htmlTemplate = `
+
+
+ {{.TabTitle}}
+
+
+ {{if .Title}}
+ {{.Title}}
+ {{end}}
+
+ {{.Text}}
+
+ {{if .Link}}
+ {{.LinkText}}
+ {{end}}
+
+
+`
+
+func NewUserVerifyEmailHandler(log logger.Logger, userService services.UserService) gin.HandlerFunc {
+ template, err := template.New("verify-email").Parse(htmlTemplate)
+ if err != nil {
+ log.Fatal().Err(err).Msg("Error parsing template")
+ }
+
+ return func(c *gin.Context) {
+ tmp := HtmlTemplate{
+ TabTitle: "Verify Email",
+ Text: "Error verifying email",
+ }
+
+ token, ok := c.GetQuery("token")
+ if !ok || token == "" {
+ log.Error().Err(err).Msg("No token in query param")
+ template.Execute(c.Writer, tmp)
+ c.Status(400)
+ return
+ }
+
+ err := userService.VerifyEmail(c, token)
+ if err != nil {
+ log.Error().Err(err).Msg("Error verifying email")
+ template.Execute(c.Writer, tmp)
+ c.Status(400)
+ return
+ }
+
+ tmp.Text = "Email successfully verified"
+ template.Execute(c.Writer, tmp)
+ c.Status(200)
+ }
+}
diff --git a/cmd/backend/server/middleware/auth.go b/cmd/backend/server/middleware/auth.go
index 6d315ac..a2f6a6b 100644
--- a/cmd/backend/server/middleware/auth.go
+++ b/cmd/backend/server/middleware/auth.go
@@ -15,7 +15,7 @@ func NewAuthMiddleware(userService services.UserService) gin.HandlerFunc {
return
}
- user, err := userService.ValidateToken(ctx, token)
+ user, err := userService.ValidateAuthToken(ctx, token)
if err == services.ErrUserWrongToken || err == services.ErrUserNotExists {
ctx.AbortWithError(403, err)
return
diff --git a/cmd/backend/server/middleware/request_log.go b/cmd/backend/server/middleware/request_log.go
deleted file mode 100644
index 587abc6..0000000
--- a/cmd/backend/server/middleware/request_log.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package middleware
-
-import (
- "backend/internal/integrations"
- log "backend/pkg/logger"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- "go.opentelemetry.io/otel/trace"
-)
-
-func NewRequestLogMiddleware(logger log.Logger, tracer trace.Tracer, prometheus *integrations.Prometheus) gin.HandlerFunc {
- return func(c *gin.Context) {
- prometheus.RequestInc()
- defer prometheus.RequestDec()
-
- requestId := c.GetHeader("X-Request-Id")
- if requestId == "" {
- requestId = uuid.New().String()
- }
- c.Header("X-Request-Id", requestId)
- c.Header("Access-Control-Allow-Origin", "*")
-
- log.SetCtxRequestId(c, requestId)
-
- path := c.Request.URL.Path
- if c.Request.URL.RawQuery != "" {
- path = path + "?" + c.Request.URL.RawQuery
- }
-
- start := time.Now()
- c.Next()
- latency := time.Since(start)
-
- prometheus.AddRequestTime(float64(latency.Microseconds()))
-
- method := c.Request.Method
- statusCode := c.Writer.Status()
-
- if statusCode >= 200 && statusCode < 400 {
- return
- }
-
- ctxLogger := logger.WithContext(c)
-
- if statusCode >= 400 && statusCode < 500 {
- prometheus.Add4xxError()
- ctxLogger.Warning().Msgf("Request %s %s %d %v", method, path, statusCode, latency)
- return
- }
-
- prometheus.Add5xxError()
- ctxLogger.Error().Msgf("Request %s %s %d %v", method, path, statusCode, latency)
- }
-}
diff --git a/cmd/backend/server/server.go b/cmd/backend/server/server.go
index e968048..68e0ec0 100644
--- a/cmd/backend/server/server.go
+++ b/cmd/backend/server/server.go
@@ -5,21 +5,14 @@ import (
"backend/cmd/backend/server/middleware"
"backend/cmd/backend/server/utils"
"backend/internal/core/services"
+ httpserver "backend/internal/http_server"
"backend/internal/integrations"
"backend/pkg/logger"
- "context"
- "fmt"
- "net"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel/trace"
)
-type Server struct {
- logger logger.Logger
- ginEngine *gin.Engine
-}
-
type NewServerOpts struct {
DebugMode bool
Logger logger.Logger
@@ -28,7 +21,7 @@ type NewServerOpts struct {
Tracer trace.Tracer
}
-func New(opts NewServerOpts) *Server {
+func NewServer(opts NewServerOpts) *httpserver.Server {
if !opts.DebugMode {
gin.SetMode(gin.ReleaseMode)
}
@@ -36,53 +29,42 @@ func New(opts NewServerOpts) *Server {
r := gin.New()
r.ContextWithFallback = true // Use it to allow getting values from c.Request.Context()
- r.Static("/webapp", "./webapp")
+ // r.Static("/webapp", "./webapp")
r.GET("/health", handlers.NewDummyHandler())
prometheus := integrations.NewPrometheus()
r.Any("/metrics", gin.WrapH(prometheus.GetRequestHandler()))
- r.Use(middleware.NewRecoveryMiddleware(opts.Logger, prometheus, opts.DebugMode))
- r.Use(middleware.NewRequestLogMiddleware(opts.Logger, opts.Tracer, prometheus))
- r.Use(middleware.NewTracingMiddleware(opts.Tracer))
+ r.Use(httpserver.NewRecoveryMiddleware(opts.Logger, prometheus, opts.DebugMode))
+ r.Use(httpserver.NewRequestLogMiddleware(opts.Logger, opts.Tracer, prometheus))
+ r.Use(httpserver.NewTracingMiddleware(opts.Tracer))
- userGroup := r.Group("/user")
- userGroup.POST("/create", handlers.NewUserCreateHandler(opts.Logger, opts.UserService))
- userGroup.POST("/login", handlers.NewUserLoginHandler(opts.Logger, opts.UserService))
+ r.GET("/verify-user", handlers.NewUserVerifyEmailHandler(opts.Logger, opts.UserService))
- dummyGroup := r.Group("/dummy")
+ api := r.Group("/api")
+
+ v1 := api.Group("/v1")
+ userGroup := v1.Group("/user")
+ {
+ userGroup.POST("/create", handlers.NewUserCreateHandler(opts.Logger, opts.UserService))
+ userGroup.POST("/login", handlers.NewUserLoginHandler(opts.Logger, opts.UserService))
+
+ }
+
+ dummyGroup := v1.Group("/dummy")
+ dummyGroup.Use(middleware.NewAuthMiddleware(opts.UserService))
{
- dummyGroup.Use(middleware.NewAuthMiddleware(opts.UserService))
dummyGroup.GET("", handlers.NewDummyHandler())
dummyGroup.POST("/forgot-password", func(c *gin.Context) {
user := utils.GetUserFromRequest(c)
- opts.UserService.ForgotPassword(c, user.Id)
+ opts.UserService.SendEmailForgotPassword(c, user.Id)
})
}
- return &Server{
- logger: opts.Logger,
- ginEngine: r,
- }
-}
-
-func (s *Server) Run(ctx context.Context, port uint16) {
- listenAddr := fmt.Sprintf("0.0.0.0:%d", port)
- s.logger.Log().Msgf("server listening on %s", listenAddr)
-
- listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", listenAddr)
- if err != nil {
- s.logger.Fatal().Err(err).Msg("can not create network listener")
- }
-
- go func() {
- <-ctx.Done()
- s.logger.Log().Msg("stopping tcp listener...")
- listener.Close()
- }()
-
- err = s.ginEngine.RunListener(listener)
- if err != nil && err == net.ErrClosed {
- s.logger.Fatal().Err(err).Msg("server stopped with error")
- }
+ return httpserver.New(
+ httpserver.NewServerOpts{
+ Logger: opts.Logger,
+ HttpServer: r,
+ },
+ )
}
diff --git a/cmd/shortlinks/grpc.go b/cmd/shortlinks/grpc.go
new file mode 100644
index 0000000..c35bf71
--- /dev/null
+++ b/cmd/shortlinks/grpc.go
@@ -0,0 +1,31 @@
+package main
+
+import (
+ "backend/internal/core/services"
+ "backend/internal/grpc_server/shortlinks"
+ httpserver "backend/internal/http_server"
+ "backend/pkg/logger"
+ "context"
+)
+
+func NewShortlinksGrpc(log logger.Logger, shortlinkService services.ShortlinkService, host string) *ShortlinksGrpc {
+ return &ShortlinksGrpc{
+ handler: NewCreateHandler(log, shortlinkService, host),
+ }
+}
+
+type ShortlinksGrpc struct {
+ shortlinks.UnimplementedShortlinksServer
+ handler httpserver.Handler[shortlinkCreateInput, shortlinkCreateOutput]
+}
+
+func (s *ShortlinksGrpc) Create(ctx context.Context, req *shortlinks.CreateRequest) (*shortlinks.CreateResponse, error) {
+ output, err := s.handler(ctx, shortlinkCreateInput{req.Url})
+ if err != nil {
+ return nil, err
+ }
+
+ return &shortlinks.CreateResponse{
+ Link: output.Link,
+ }, nil
+}
diff --git a/cmd/shortlinks/handlers.go b/cmd/shortlinks/handlers.go
index 3fdc089..0af19af 100644
--- a/cmd/shortlinks/handlers.go
+++ b/cmd/shortlinks/handlers.go
@@ -2,93 +2,52 @@ package main
import (
"backend/internal/core/services"
- "backend/internal/grpc_server/shortlinks"
+ httpserver "backend/internal/http_server"
"backend/pkg/logger"
"context"
- "encoding/json"
"fmt"
"net/url"
"github.com/gin-gonic/gin"
)
+type shortlinkCreateInput struct {
+ Url string `json:"url"`
+}
+
type shortlinkCreateOutput struct {
Link string `json:"link"`
}
-type ShortlinksGrpc struct {
- shortlinks.UnimplementedShortlinksServer
- log logger.Logger
- host string
- shortlinkService services.ShortlinkService
-}
+func NewCreateHandler(
+ log logger.Logger,
+ shortlinkService services.ShortlinkService,
+ host string,
+) httpserver.Handler[shortlinkCreateInput, shortlinkCreateOutput] {
+ return func(ctx context.Context, input shortlinkCreateInput) (shortlinkCreateOutput, error) {
+ output := shortlinkCreateOutput{}
-func (s *ShortlinksGrpc) Create(ctx context.Context, req *shortlinks.CreateRequest) (*shortlinks.CreateResponse, error) {
- ctxLogger := s.log.WithContext(ctx)
-
- rawUrl := req.GetUrl()
- if rawUrl == "" {
- ctxLogger.Error().Msg("url query param missing")
- return nil, fmt.Errorf("url query param missing")
- }
-
- u, err := url.Parse(rawUrl)
- if err != nil {
- ctxLogger.Error().Err(err).Msg("error parsing url param")
- return nil, err
- }
- u.Scheme = "https"
-
- linkId, err := s.shortlinkService.CreateShortlink(ctx, u.String())
- if err != nil {
- ctxLogger.Error().Err(err).Msg("err creating shortlink")
- return nil, err
- }
-
- return &shortlinks.CreateResponse{
- Link: fmt.Sprintf("%s/s/%s", s.host, linkId),
- }, nil
-}
-
-func NewShortlinkCreateHandler(logger logger.Logger, shortlinkService services.ShortlinkService, host string) gin.HandlerFunc {
- return func(ctx *gin.Context) {
- ctxLogger := logger.WithContext(ctx)
-
- rawUrl := ctx.Query("url")
- if rawUrl == "" {
- ctxLogger.Error().Msg("url query param missing")
- ctx.AbortWithError(400, fmt.Errorf("url query param missing"))
- return
- }
-
- u, err := url.Parse(rawUrl)
+ u, err := url.Parse(input.Url)
if err != nil {
- ctxLogger.Error().Err(err).Msg("error parsing url param")
- ctx.Data(400, "plain/text", []byte(err.Error()))
- return
+ return output, err
}
u.Scheme = "https"
linkId, err := shortlinkService.CreateShortlink(ctx, u.String())
if err != nil {
- ctxLogger.Error().Err(err).Msg("err creating shortlink")
- ctx.Data(500, "plain/text", []byte(err.Error()))
- return
+ return output, err
}
- resultBody, err := json.Marshal(shortlinkCreateOutput{
+ return shortlinkCreateOutput{
Link: fmt.Sprintf("%s/s/%s", host, linkId),
- })
- if err != nil {
- ctxLogger.Error().Err(err).Msg("err marshalling shortlink")
- ctx.AbortWithError(500, err)
- return
- }
-
- ctx.Data(200, "application/json", resultBody)
+ }, nil
}
}
+func NewShortlinkCreateHandler(log logger.Logger, shortlinkService services.ShortlinkService, host string) gin.HandlerFunc {
+ return httpserver.WrapGin(log, NewCreateHandler(log, shortlinkService, host))
+}
+
func NewShortlinkResolveHandler(logger logger.Logger, shortlinkService services.ShortlinkService) gin.HandlerFunc {
return func(ctx *gin.Context) {
ctxLogger := logger.WithContext(ctx)
diff --git a/cmd/shortlinks/main.go b/cmd/shortlinks/main.go
index d6ef4ed..2adbf3d 100644
--- a/cmd/shortlinks/main.go
+++ b/cmd/shortlinks/main.go
@@ -6,7 +6,6 @@ import (
grpcserver "backend/internal/grpc_server"
"backend/internal/grpc_server/shortlinks"
httpserver "backend/internal/http_server"
- "backend/internal/http_server/middleware"
"backend/internal/integrations"
"backend/pkg/cache"
"backend/pkg/logger"
@@ -94,22 +93,19 @@ func RunServer(ctx context.Context, log logger.Logger, tracer trace.Tracer, conf
ctx.Status(200)
})
- r.Use(middleware.NewRecoveryMiddleware(log, prometheus, debugMode))
- r.Use(middleware.NewRequestLogMiddleware(log, tracer, prometheus))
- r.Use(middleware.NewTracingMiddleware(tracer))
+ r.Use(httpserver.NewRecoveryMiddleware(log, prometheus, debugMode))
+ r.Use(httpserver.NewRequestLogMiddleware(log, tracer, prometheus))
+ r.Use(httpserver.NewTracingMiddleware(tracer))
linkGroup := r.Group("/s")
linkGroup.POST("/new", NewShortlinkCreateHandler(log, shortlinkService, host))
linkGroup.GET("/:linkId", NewShortlinkResolveHandler(log, shortlinkService))
- grpcObj := &ShortlinksGrpc{
- log: log,
- host: host,
- shortlinkService: shortlinkService,
- }
-
grpcUnderlying := grpc.NewServer()
- shortlinks.RegisterShortlinksServer(grpcUnderlying, grpcObj)
+ shortlinks.RegisterShortlinksServer(
+ grpcUnderlying,
+ NewShortlinksGrpc(log, shortlinkService, host),
+ )
httpServer := httpserver.New(
httpserver.NewServerOpts{
diff --git a/deploy/prometheus.yml b/deploy/prometheus.yml
index 0d296d7..56d26b2 100644
--- a/deploy/prometheus.yml
+++ b/deploy/prometheus.yml
@@ -34,6 +34,6 @@ scrape_configs:
- job_name: 'machine'
scrape_interval: 2s
static_configs:
- - targets: ['host.docker.internal:9100']
+ - targets: ['node_exporter:9100']
labels:
group: 'backend'
\ No newline at end of file
diff --git a/docker-compose.yaml b/docker-compose.yaml
index cfbd7ea..0262ab0 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -56,15 +56,20 @@ services:
node_exporter:
image: quay.io/prometheus/node-exporter:latest
+ pid: host
command:
- - '--path.rootfs=/host'
- ports:
- - 9100:9100
+ - '--path.procfs=/host/proc'
+ - '--path.rootfs=/rootfs'
+ - '--path.sysfs=/host/sys'
+ - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
+ volumes:
+ - /proc:/host/proc:ro
+ - /sys:/host/sys:ro
+ - /:/rootfs:ro
extra_hosts:
- "host.docker.internal:host-gateway"
- pid: host
- volumes:
- - '/:/host:ro,rslave'
+ ports:
+ - 9100:9100
otel-collector:
image: otel/opentelemetry-collector-contrib:0.108.0
@@ -97,12 +102,17 @@ services:
- tempo-init
kafka:
- image: apache/kafka:3.8.0
+ image: &kafkaImage apache/kafka:3.8.0
+ healthcheck:
+ test: ["CMD-SHELL", "/opt/kafka/bin/kafka-cluster.sh cluster-id --bootstrap-server http://127.0.0.1:9092 || exit 1"]
+ interval: 1s
+ timeout: 30s
+ retries: 30
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
- KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
+ KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
@@ -113,18 +123,48 @@ services:
KAFKA_NUM_PARTITIONS: 3
ports:
- 9092:9092
- # - 9093:9093
- # backend:
- # build: .
- # # dockerfile: ./dockerfile
- # volumes:
- # - ./:/app
- # ports:
- # - 8080:8080
+ kafka-init:
+ image: *kafkaImage
+ depends_on:
+ kafka:
+ condition: service_healthy
+ entrypoint: >
+ /bin/bash -c "/opt/kafka/bin/kafka-topics.sh --bootstrap-server http://kafka:9092 --create --topic events --partitions 6"
+
+
+ minio:
+ image: quay.io/minio/minio:latest
+ command: ["server", "/data", "--console-address", ":9001"]
+ healthcheck:
+ test: 'mc ready local'
+ interval: 1s
+ environment:
+ MINIO_ROOT_USER: miniouser
+ MINIO_ROOT_PASSWORD: miniouser
+ MINIO_ACCESS_KEY: miniokey
+ MINIO_SECRET_KEY: miniokey
+ ports:
+ - 9000:9000
+ - 9001:9001
+ volumes:
+ - minio-volume:/data
+
+ minio-init:
+ image: quay.io/minio/mc:latest
+ depends_on:
+ - minio
+ entrypoint: >
+ /bin/sh -c "
+ /usr/bin/mc alias set myminio http://minio:9000 miniouser miniouser;
+ /usr/bin/mc mb minio/bucket;
+ /usr/bin/mc anonymous set public minio/bucket;
+ exit 0;
+ "
volumes:
postgres-volume:
grafana-volume:
tempo-volume:
- prometheus-volume:
\ No newline at end of file
+ prometheus-volume:
+ minio-volume:
\ No newline at end of file
diff --git a/internal/core/models/action_token.go b/internal/core/models/action_token.go
index 15d5991..c86b106 100644
--- a/internal/core/models/action_token.go
+++ b/internal/core/models/action_token.go
@@ -5,8 +5,10 @@ import "time"
type ActionTokenTarget int
const (
- ActionTokenTargetForgotPassword ActionTokenTarget = iota
+ _ ActionTokenTarget = iota
+ ActionTokenTargetForgotPassword
ActionTokenTargetLogin2FA
+ ActionTokenVerifyEmail
)
type ActionTokenDTO struct {
diff --git a/internal/core/models/user.go b/internal/core/models/user.go
index ced8300..dbd6e65 100644
--- a/internal/core/models/user.go
+++ b/internal/core/models/user.go
@@ -1,10 +1,11 @@
package models
type UserDTO struct {
- Id string
- Email string
- Secret string
- Name string
+ Id string
+ Email string
+ EmailVerified bool
+ Secret string
+ Name string
}
type UserUpdateDTO struct {
diff --git a/internal/core/repos/action_token.go b/internal/core/repos/action_token.go
index 4c3dcde..7eda5f0 100644
--- a/internal/core/repos/action_token.go
+++ b/internal/core/repos/action_token.go
@@ -9,7 +9,8 @@ import (
type ActionTokenRepo interface {
CreateActionToken(ctx context.Context, dto models.ActionTokenDTO) (*models.ActionTokenDTO, error)
- PopActionToken(ctx context.Context, userId, value string, target models.ActionTokenTarget) (*models.ActionTokenDTO, error)
+ GetActionToken(ctx context.Context, value string, target models.ActionTokenTarget) (*models.ActionTokenDTO, error)
+ DeleteActionToken(ctx context.Context, id string) error
}
func NewActionTokenRepo(db integrations.SqlDB) ActionTokenRepo {
@@ -43,18 +44,17 @@ func (a *actionTokenRepo) CreateActionToken(ctx context.Context, dto models.Acti
}, nil
}
-func (a *actionTokenRepo) PopActionToken(ctx context.Context, userId, value string, target models.ActionTokenTarget) (*models.ActionTokenDTO, error) {
- query := `
- delete
- from action_tokens
- where
- user_id=$1 and value=$2 and target=$3
- and CURRENT_TIMESTAMP < expiration
- returning id;`
- row := a.db.QueryRowContext(ctx, query, userId, value, target)
+func (a *actionTokenRepo) GetActionToken(ctx context.Context, value string, target models.ActionTokenTarget) (*models.ActionTokenDTO, error) {
+ dto := &models.ActionTokenDTO{Value: value, Target: target}
- id := ""
- err := row.Scan(&id)
+ query := `
+ select id, user_id from action_tokens
+ where
+ value=$1 and target=$2
+ and CURRENT_TIMESTAMP < expiration;`
+ row := a.db.QueryRowContext(ctx, query, value, target)
+
+ err := row.Scan(&dto.Id, &dto.UserId)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -62,10 +62,13 @@ func (a *actionTokenRepo) PopActionToken(ctx context.Context, userId, value stri
return nil, err
}
- return &models.ActionTokenDTO{
- Id: id,
- UserId: userId,
- Value: value,
- Target: target,
- }, nil
+ return dto, nil
+}
+
+func (a *actionTokenRepo) DeleteActionToken(ctx context.Context, id string) error {
+ query := `delete from action_tokens where id=$1;`
+ if _, err := a.db.ExecContext(ctx, query, id); err != nil {
+ return err
+ }
+ return nil
}
diff --git a/internal/core/repos/event_repo.go b/internal/core/repos/event_repo.go
index 57460b0..abba12e 100644
--- a/internal/core/repos/event_repo.go
+++ b/internal/core/repos/event_repo.go
@@ -16,7 +16,7 @@ type EventRepo struct {
kafka *integrations.Kafka
}
-func (e *EventRepo) SendEmailForgotPassword(ctx context.Context, email, actionToken string) error {
+func (e *EventRepo) sendEmail(ctx context.Context, email, actionToken, eventType string) error {
value := struct {
Email string `json:"email"`
Token string `json:"token"`
@@ -29,5 +29,13 @@ func (e *EventRepo) SendEmailForgotPassword(ctx context.Context, email, actionTo
return err
}
- return e.kafka.SendMessage(ctx, "email_forgot_password", valueBytes)
+ return e.kafka.SendMessage(ctx, eventType, valueBytes)
+}
+
+func (e *EventRepo) SendEmailForgotPassword(ctx context.Context, email, actionToken string) error {
+ return e.sendEmail(ctx, email, actionToken, "email_forgot_password")
+}
+
+func (e *EventRepo) SendEmailVerifyEmail(ctx context.Context, email, actionToken string) error {
+ return e.sendEmail(ctx, email, actionToken, "email_verify_email")
}
diff --git a/internal/core/repos/user_repo.go b/internal/core/repos/user_repo.go
index 2cc7461..fe4936e 100644
--- a/internal/core/repos/user_repo.go
+++ b/internal/core/repos/user_repo.go
@@ -20,6 +20,7 @@ import (
type UserRepo interface {
CreateUser(ctx context.Context, dto models.UserDTO) (*models.UserDTO, error)
UpdateUser(ctx context.Context, userId string, dto models.UserUpdateDTO) error
+ SetUserEmailVerified(ctx context.Context, userId string) error
GetUserById(ctx context.Context, id string) (*models.UserDTO, error)
GetUserByEmail(ctx context.Context, login string) (*models.UserDTO, error)
}
@@ -66,15 +67,28 @@ func (u *userRepo) UpdateUser(ctx context.Context, userId string, dto models.Use
return nil
}
+func (u *userRepo) SetUserEmailVerified(ctx context.Context, userId string) error {
+ _, span := u.tracer.Start(ctx, "postgres::SetUserEmailVerified")
+ defer span.End()
+
+ query := `update users set email_verified=true where id = $1;`
+ _, err := u.db.ExecContext(ctx, query, userId)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
func (u *userRepo) GetUserById(ctx context.Context, id string) (*models.UserDTO, error) {
_, span := u.tracer.Start(ctx, "postgres::GetUserById")
defer span.End()
- query := `select id, email, secret, name from users where id = $1;`
+ query := `select id, email, secret, name, email_verified from users where id = $1;`
row := u.db.QueryRowContext(ctx, query, id)
dto := &models.UserDTO{}
- err := row.Scan(&dto.Id, &dto.Email, &dto.Secret, &dto.Name)
+ err := row.Scan(&dto.Id, &dto.Email, &dto.Secret, &dto.Name, &dto.EmailVerified)
if err == nil {
return dto, nil
}
@@ -89,11 +103,11 @@ func (u *userRepo) GetUserByEmail(ctx context.Context, login string) (*models.Us
_, span := u.tracer.Start(ctx, "postgres::GetUserByEmail")
defer span.End()
- query := `select id, email, secret, name from users where email = $1;`
+ query := `select id, email, secret, name, email_verified from users where email = $1;`
row := u.db.QueryRowContext(ctx, query, login)
dto := &models.UserDTO{}
- err := row.Scan(&dto.Id, &dto.Email, &dto.Secret, &dto.Name)
+ err := row.Scan(&dto.Id, &dto.Email, &dto.Secret, &dto.Name, &dto.EmailVerified)
if err == nil {
return dto, nil
}
diff --git a/internal/core/services/user_service.go b/internal/core/services/user_service.go
index baca777..64cb446 100644
--- a/internal/core/services/user_service.go
+++ b/internal/core/services/user_service.go
@@ -13,11 +13,12 @@ import (
)
var (
- ErrUserNotExists = fmt.Errorf("no such user")
- ErrUserExists = fmt.Errorf("user with this login already exists")
- ErrUserWrongPassword = fmt.Errorf("wrong password")
- ErrUserWrongToken = fmt.Errorf("bad user token")
- ErrUserBadPassword = fmt.Errorf("password must contain at least 8 characters")
+ ErrUserNotExists = fmt.Errorf("no such user")
+ ErrUserExists = fmt.Errorf("user with this login already exists")
+ ErrUserWrongPassword = fmt.Errorf("wrong password")
+ ErrUserWrongToken = fmt.Errorf("bad user token")
+ ErrUserBadPassword = fmt.Errorf("password must contain at least 8 characters")
+ ErrUserEmailUnverified = fmt.Errorf("user has not verified email yet")
// ErrUserInternal = fmt.Errorf("unexpected error. contact tech support")
)
@@ -28,9 +29,12 @@ const (
type UserService interface {
CreateUser(ctx context.Context, params UserCreateParams) (*models.UserDTO, error)
AuthenticateUser(ctx context.Context, login, password string) (string, error)
- ValidateToken(ctx context.Context, tokenStr string) (*models.UserDTO, error)
+ ValidateAuthToken(ctx context.Context, tokenStr string) (*models.UserDTO, error)
+ VerifyEmail(ctx context.Context, actionToken string) error
+
+ SendEmailForgotPassword(ctx context.Context, userId string) error
+ SendEmailVerifyEmail(ctx context.Context, userId string) error
- ForgotPassword(ctx context.Context, userId string) error
ChangePassword(ctx context.Context, userId, oldPassword, newPassword string) error
ChangePasswordWithToken(ctx context.Context, userId, actionToken, newPassword string) error
}
@@ -87,6 +91,7 @@ func (u *userService) CreateUser(ctx context.Context, params UserCreateParams) (
if err != nil {
return nil, err
}
+ u.sendEmailVerifyEmail(ctx, result.Id, user.Email)
u.deps.UserCache.Set(result.Id, *result, cache.Expiration{Ttl: userCacheTtl})
@@ -106,6 +111,10 @@ func (u *userService) AuthenticateUser(ctx context.Context, email, password stri
return "", ErrUserWrongPassword
}
+ if !user.EmailVerified {
+ return "", ErrUserEmailUnverified
+ }
+
payload := utils.JwtPayload{UserId: user.Id}
jwt, err := u.deps.Jwt.Create(payload)
if err != nil {
@@ -117,8 +126,27 @@ func (u *userService) AuthenticateUser(ctx context.Context, email, password stri
return jwt, nil
}
-func (u *userService) ForgotPassword(ctx context.Context, userId string) error {
- user, err := u.getUserById(ctx, userId)
+func (u *userService) VerifyEmail(ctx context.Context, actionToken string) error {
+ token, err := u.deps.ActionTokenRepo.GetActionToken(ctx, actionToken, models.ActionTokenVerifyEmail)
+ if err != nil {
+ return err
+ }
+ if token == nil {
+ return fmt.Errorf("wrong action token")
+ }
+
+ if err := u.deps.UserRepo.SetUserEmailVerified(ctx, token.UserId); err != nil {
+ return nil
+ }
+
+ //TODO: log warnings somehow
+ u.deps.ActionTokenRepo.DeleteActionToken(ctx, token.Id)
+ return nil
+}
+
+func (u *userService) SendEmailForgotPassword(ctx context.Context, email string) error {
+ // user, err := u.getUserById(ctx, userId)
+ user, err := u.deps.UserRepo.GetUserByEmail(ctx, email)
if err != nil {
return err
}
@@ -139,21 +167,54 @@ func (u *userService) ForgotPassword(ctx context.Context, userId string) error {
return u.deps.EventRepo.SendEmailForgotPassword(ctx, user.Email, actionToken.Value)
}
+func (u *userService) sendEmailVerifyEmail(ctx context.Context, userId, email string) error {
+ actionToken, err := u.deps.ActionTokenRepo.CreateActionToken(
+ ctx,
+ models.ActionTokenDTO{
+ UserId: userId,
+ Value: uuid.New().String(),
+ Target: models.ActionTokenVerifyEmail,
+ Expiration: time.Now().Add(1 * time.Hour),
+ },
+ )
+ if err != nil {
+ return err
+ }
+
+ return u.deps.EventRepo.SendEmailVerifyEmail(ctx, email, actionToken.Value)
+}
+
+func (u *userService) SendEmailVerifyEmail(ctx context.Context, email string) error {
+ //user, err := u.getUserById(ctx, userId)
+ user, err := u.deps.UserRepo.GetUserByEmail(ctx, email)
+ if err != nil {
+ return err
+ }
+
+ return u.sendEmailVerifyEmail(ctx, user.Id, user.Email)
+}
+
func (u *userService) ChangePasswordWithToken(ctx context.Context, userId, actionToken, newPassword string) error {
user, err := u.getUserById(ctx, userId)
if err != nil {
return err
}
- code, err := u.deps.ActionTokenRepo.PopActionToken(ctx, userId, actionToken, models.ActionTokenTargetForgotPassword)
+ token, err := u.deps.ActionTokenRepo.GetActionToken(ctx, actionToken, models.ActionTokenTargetForgotPassword)
if err != nil {
return err
}
- if code == nil {
- return fmt.Errorf("wrong user access code")
+ if token == nil {
+ return fmt.Errorf("wrong action token")
}
- return u.updatePassword(ctx, *user, newPassword)
+ if err := u.updatePassword(ctx, *user, newPassword); err != nil {
+ return err
+ }
+
+ //TODO: log warnings somehow
+ u.deps.ActionTokenRepo.DeleteActionToken(ctx, token.Id)
+ return nil
}
func (u *userService) ChangePassword(ctx context.Context, userId, oldPassword, newPassword string) error {
@@ -205,7 +266,7 @@ func (u *userService) getUserById(ctx context.Context, userId string) (*models.U
return user, nil
}
-func (u *userService) ValidateToken(ctx context.Context, tokenStr string) (*models.UserDTO, error) {
+func (u *userService) ValidateAuthToken(ctx context.Context, tokenStr string) (*models.UserDTO, error) {
if userId, ok := u.deps.JwtCache.Get(tokenStr); ok {
return u.getUserById(ctx, userId)
}
diff --git a/internal/http_server/middleware/recovery.go b/internal/http_server/middleware/recovery.go
deleted file mode 100644
index 472e126..0000000
--- a/internal/http_server/middleware/recovery.go
+++ /dev/null
@@ -1,155 +0,0 @@
-package middleware
-
-// Modified recovery from gin, use own logger
-
-import (
- "backend/internal/integrations"
- "backend/pkg/logger"
- "bytes"
- "errors"
- "fmt"
- "net"
- "net/http"
- "net/http/httputil"
- "os"
- "runtime"
- "strings"
- "time"
-
- "github.com/gin-gonic/gin"
-)
-
-const (
- reset = "\033[0m"
-)
-
-var (
- dunno = []byte("???")
- centerDot = []byte("·")
- dot = []byte(".")
- slash = []byte("/")
-)
-
-func NewRecoveryMiddleware(logger logger.Logger, prometheus *integrations.Prometheus, debugMode bool) gin.HandlerFunc {
- handle := defaultHandleRecovery
- return func(c *gin.Context) {
- defer func() {
- if err := recover(); err != nil {
- prometheus.AddPanic()
-
- // Check for a broken connection, as it is not really a
- // condition that warrants a panic stack trace.
- var brokenPipe bool
- if ne, ok := err.(*net.OpError); ok {
- var se *os.SyscallError
- if errors.As(ne, &se) {
- seStr := strings.ToLower(se.Error())
- if strings.Contains(seStr, "broken pipe") ||
- strings.Contains(seStr, "connection reset by peer") {
- brokenPipe = true
- }
- }
- }
- if logger != nil {
- stack := stack(3)
- httpRequest, _ := httputil.DumpRequest(c.Request, false)
- headers := strings.Split(string(httpRequest), "\r\n")
- for idx, header := range headers {
- current := strings.Split(header, ":")
- if current[0] == "Authorization" {
- headers[idx] = current[0] + ": *"
- }
- }
- headersToStr := strings.Join(headers, "\r\n")
- if brokenPipe {
- logger.Printf("%s\n%s%s", err, headersToStr, reset)
- } else if debugMode {
- logger.Printf("[Recovery] %s panic recovered:\n%s\n%s\n%s%s",
- timeFormat(time.Now()), headersToStr, err, stack, reset)
- } else {
- logger.Printf("[Recovery] %s panic recovered:\n%s\n%s%s",
- timeFormat(time.Now()), err, stack, reset)
- }
- }
- if brokenPipe {
- // If the connection is dead, we can't write a status to it.
- c.Error(err.(error)) //nolint: errcheck
- c.Abort()
- } else {
- handle(c, err)
- }
- }
- }()
- c.Next()
- }
-}
-
-func defaultHandleRecovery(c *gin.Context, _ any) {
- c.AbortWithStatus(http.StatusInternalServerError)
-}
-
-// stack returns a nicely formatted stack frame, skipping skip frames.
-func stack(skip int) []byte {
- buf := new(bytes.Buffer) // the returned data
- // As we loop, we open files and read them. These variables record the currently
- // loaded file.
- var lines [][]byte
- var lastFile string
- for i := skip; ; i++ { // Skip the expected number of frames
- pc, file, line, ok := runtime.Caller(i)
- if !ok {
- break
- }
- // Print this much at least. If we can't find the source, it won't show.
- fmt.Fprintf(buf, "%s:%d (0x%x)\n", file, line, pc)
- if file != lastFile {
- data, err := os.ReadFile(file)
- if err != nil {
- continue
- }
- lines = bytes.Split(data, []byte{'\n'})
- lastFile = file
- }
- fmt.Fprintf(buf, "\t%s: %s\n", function(pc), source(lines, line))
- }
- return buf.Bytes()
-}
-
-// source returns a space-trimmed slice of the n'th line.
-func source(lines [][]byte, n int) []byte {
- n-- // in stack trace, lines are 1-indexed but our array is 0-indexed
- if n < 0 || n >= len(lines) {
- return dunno
- }
- return bytes.TrimSpace(lines[n])
-}
-
-// function returns, if possible, the name of the function containing the PC.
-func function(pc uintptr) []byte {
- fn := runtime.FuncForPC(pc)
- if fn == nil {
- return dunno
- }
- name := []byte(fn.Name())
- // The name includes the path name to the package, which is unnecessary
- // since the file name is already included. Plus, it has center dots.
- // That is, we see
- // runtime/debug.*T·ptrmethod
- // and want
- // *T.ptrmethod
- // Also the package path might contain dot (e.g. code.google.com/...),
- // so first eliminate the path prefix
- if lastSlash := bytes.LastIndex(name, slash); lastSlash >= 0 {
- name = name[lastSlash+1:]
- }
- if period := bytes.Index(name, dot); period >= 0 {
- name = name[period+1:]
- }
- name = bytes.ReplaceAll(name, centerDot, dot)
- return name
-}
-
-// timeFormat returns a customized time string for logger.
-func timeFormat(t time.Time) string {
- return t.Format("2006/01/02 - 15:04:05")
-}
diff --git a/internal/http_server/middleware/tracing.go b/internal/http_server/middleware/tracing.go
deleted file mode 100644
index a739c3c..0000000
--- a/internal/http_server/middleware/tracing.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package middleware
-
-import (
- "fmt"
-
- "github.com/gin-gonic/gin"
- "go.opentelemetry.io/otel"
- "go.opentelemetry.io/otel/propagation"
- "go.opentelemetry.io/otel/trace"
-)
-
-func NewTracingMiddleware(tracer trace.Tracer) gin.HandlerFunc {
- prop := otel.GetTextMapPropagator()
-
- return func(c *gin.Context) {
- savedCtx := c.Request.Context()
- defer func() {
- c.Request = c.Request.WithContext(savedCtx)
- }()
-
- ctx := prop.Extract(savedCtx, propagation.HeaderCarrier(c.Request.Header))
-
- ctx, span := tracer.Start(ctx, fmt.Sprintf("%s %s", c.Request.Method, c.Request.URL.Path))
- defer span.End()
-
- traceId := span.SpanContext().TraceID()
- c.Header("X-Trace-Id", traceId.String())
-
- c.Request = c.Request.WithContext(ctx)
-
- c.Next()
- }
-}
diff --git a/cmd/backend/server/middleware/recovery.go b/internal/http_server/recovery.go
similarity index 99%
rename from cmd/backend/server/middleware/recovery.go
rename to internal/http_server/recovery.go
index 472e126..4611dab 100644
--- a/cmd/backend/server/middleware/recovery.go
+++ b/internal/http_server/recovery.go
@@ -1,4 +1,4 @@
-package middleware
+package httpserver
// Modified recovery from gin, use own logger
diff --git a/internal/http_server/middleware/request_log.go b/internal/http_server/request_log.go
similarity index 98%
rename from internal/http_server/middleware/request_log.go
rename to internal/http_server/request_log.go
index 814d86e..45123ca 100644
--- a/internal/http_server/middleware/request_log.go
+++ b/internal/http_server/request_log.go
@@ -1,4 +1,4 @@
-package middleware
+package httpserver
import (
"backend/internal/integrations"
diff --git a/cmd/backend/server/middleware/tracing.go b/internal/http_server/tracing.go
similarity index 97%
rename from cmd/backend/server/middleware/tracing.go
rename to internal/http_server/tracing.go
index a739c3c..9781378 100644
--- a/cmd/backend/server/middleware/tracing.go
+++ b/internal/http_server/tracing.go
@@ -1,4 +1,4 @@
-package middleware
+package httpserver
import (
"fmt"
diff --git a/internal/http_server/wrapper.go b/internal/http_server/wrapper.go
new file mode 100644
index 0000000..f96d6a4
--- /dev/null
+++ b/internal/http_server/wrapper.go
@@ -0,0 +1,79 @@
+package httpserver
+
+import (
+ "backend/pkg/logger"
+ "context"
+ "encoding/json"
+
+ "github.com/gin-gonic/gin"
+)
+
+type Handler[Input, Output interface{}] func(ctx context.Context, input Input) (Output, error)
+
+type ResponseOk struct {
+ Status string `json:"status"`
+ Result interface{} `json:"result"`
+}
+
+type ResponseError struct {
+ Status string `json:"status"`
+ Error struct {
+ Id string `json:"id"`
+ Message string `json:"message"`
+ } `json:"error"`
+}
+
+func WrapGin[In, Out interface{}](log logger.Logger, handler Handler[In, Out]) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ log := log.WithContext(c)
+
+ var input In
+ if err := c.ShouldBindJSON(&input); err != nil {
+ response := ResponseError{
+ Status: "error",
+ Error: struct {
+ Id string `json:"id"`
+ Message string `json:"message"`
+ }{
+ Id: "WrongBody",
+ Message: err.Error(),
+ },
+ }
+
+ body, _ := json.Marshal(response)
+ c.Data(400, "application/json", body)
+ return
+ }
+
+ var response interface{}
+
+ output, err := handler(c, input)
+ if err != nil {
+ log.Error().Err(err).Msg("error in request handler")
+ response = ResponseError{
+ Status: "error",
+ Error: struct {
+ Id string `json:"id"`
+ Message string `json:"message"`
+ }{
+ Id: "-",
+ Message: err.Error(),
+ },
+ }
+ } else {
+ response = ResponseOk{
+ Status: "success",
+ Result: output,
+ }
+ }
+
+ body, err := json.Marshal(response)
+ if err != nil {
+ log.Error().Err(err).Msg("marshal response error")
+ c.Data(500, "plain/text", []byte(err.Error()))
+ return
+ }
+
+ c.Data(200, "application/json", body)
+ }
+}
diff --git a/internal/integrations/kafka.go b/internal/integrations/kafka.go
index ebd0b7c..69eef72 100644
--- a/internal/integrations/kafka.go
+++ b/internal/integrations/kafka.go
@@ -16,7 +16,7 @@ func NewKafka(addr, topic string) *Kafka {
Addr: kafka.TCP(addr),
Topic: topic,
Balancer: &kafka.RoundRobin{},
- AllowAutoTopicCreation: true,
+ AllowAutoTopicCreation: false,
BatchSize: 100,
BatchTimeout: 100 * time.Millisecond,
}
diff --git a/load_tests/api.py b/load_tests/api.py
deleted file mode 100644
index d41fc4c..0000000
--- a/load_tests/api.py
+++ /dev/null
@@ -1,84 +0,0 @@
-import random
-import string
-from locust import HttpUser, FastHttpUser
-
-class Auth():
- token: string
-
- def __init__(self, token):
- self.token = token
-
-class User():
- email: string
- name: string
- password: string
-
- def __init__(self, email, password, name, token = ""):
- self.email = email
- self.password = password
- self.name = name
- self.token = token
-
-
-class BackendApi():
- http: FastHttpUser
-
- def __init__(self, http: FastHttpUser):
- self.http = http
-
- def user_create(self) -> User:
- email = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10)) + '@test.test'
- name = ''.join(random.choices(string.ascii_letters, k=10))
- password = 'Abcdef1!!1'
-
- response = self.http.client.post(
- "/user/create",
- json={
- "email": email,
- "password": password,
- "name": name,
- },
- )
- if response.status_code != 200:
- raise AssertionError('can not create user')
-
- return User(email, password, name)
-
- def user_login(self, user: User) -> Auth:
- response = self.http.client.post(
- "/user/login",
- json={
- "email": user.email,
- "password": user.password,
- },
- )
- if response.status_code != 200:
- raise AssertionError('can not login user')
-
- token = response.json()['token']
- if token == '':
- raise AssertionError('empty user token')
-
- return Auth(token)
-
- def dummy_get(self, auth: Auth):
- headers = {"X-Auth": auth.token}
- response = self.http.client.get("/dummy", headers=headers)
- if response.status_code != 200:
- raise AssertionError('something wrong')
-
- def health_get(self):
- response = self.http.client.get("/health")
- if response.status_code != 200:
- raise AssertionError('something wrong')
-
- def shortlink_create(self, url: string) -> string:
- response = self.http.client.post("/s/new?url=" + url)
- if response.status_code != 200:
- raise AssertionError('can not login user')
-
- link = response.json()['link']
- if link == '':
- raise AssertionError('empty user token')
-
- return link
\ No newline at end of file
diff --git a/sql/01_user.sql b/sql/01_user.sql
index ad2bcb9..740201d 100644
--- a/sql/01_user.sql
+++ b/sql/01_user.sql
@@ -3,6 +3,7 @@ create table if not exists users (
email text unique not null,
secret text not null,
name text not null,
+ email_verified boolean not null default false,
primary key (id)
);
diff --git a/load_tests/.gitignore b/tests/.gitignore
similarity index 100%
rename from load_tests/.gitignore
rename to tests/.gitignore
diff --git a/load_tests/makefile b/tests/makefile
similarity index 69%
rename from load_tests/makefile
rename to tests/makefile
index efc9b49..36f1721 100644
--- a/load_tests/makefile
+++ b/tests/makefile
@@ -15,7 +15,10 @@ install:
requirements:
pip freeze > requirements.txt
-run-web:
- locust -f tests,loads --class-picker --host http://localhost:8080 --processes 16
+run-integration:
+ python3 -m pytest integration/
+
+run-performance-web:
+ locust -f performance --class-picker --host http://localhost:8080 --processes 16
diff --git a/load_tests/tests/dummy.py b/tests/performance/dummy.py
similarity index 65%
rename from load_tests/tests/dummy.py
rename to tests/performance/dummy.py
index bf84587..8d5f08d 100644
--- a/load_tests/tests/dummy.py
+++ b/tests/performance/dummy.py
@@ -3,15 +3,13 @@ from locust import FastHttpUser, task
from api import BackendApi, Auth
class DummyGet(FastHttpUser):
- api: BackendApi
- auth: Auth
+ def on_start(self):
+ self.api = BackendApi(self.client)
+ user = self.api.user_create()
+ self.auth = self.api.user_login(user)
@task
def dummy_test(self):
self.api.dummy_get(self.auth)
- def on_start(self):
- self.api = BackendApi(self)
-
- user = self.api.user_create()
- self.auth = self.api.user_login(user)
\ No newline at end of file
+
\ No newline at end of file
diff --git a/load_tests/tests/health.py b/tests/performance/health.py
similarity index 65%
rename from load_tests/tests/health.py
rename to tests/performance/health.py
index df5ddc5..8c05304 100644
--- a/load_tests/tests/health.py
+++ b/tests/performance/health.py
@@ -3,11 +3,9 @@ from locust import FastHttpUser, task
from api import BackendApi
class HealthGet(FastHttpUser):
- api: BackendApi
+ def on_start(self):
+ self.api = BackendApi(self.client)
@task
def user_create_test(self):
- self.api.health_get()
-
- def on_start(self):
- self.api = BackendApi(self)
\ No newline at end of file
+ self.api.health_get()
\ No newline at end of file
diff --git a/load_tests/loads/low_load.py b/tests/performance/low_load.py
similarity index 83%
rename from load_tests/loads/low_load.py
rename to tests/performance/low_load.py
index c84d101..722a030 100644
--- a/load_tests/loads/low_load.py
+++ b/tests/performance/low_load.py
@@ -1,9 +1,9 @@
from locust import LoadTestShape
class LowLoad(LoadTestShape):
- time_limit = 600
- spawn_rate = 5
- max_users = 100
+ time_limit = 60
+ spawn_rate = 2
+ max_users = 10
def tick(self) -> (tuple[float, int] | None):
user_count = self.spawn_rate * self.get_run_time()
diff --git a/load_tests/tests/shortlink.py b/tests/performance/shortlink.py
similarity index 61%
rename from load_tests/tests/shortlink.py
rename to tests/performance/shortlink.py
index 2f9072c..8fb0e46 100644
--- a/load_tests/tests/shortlink.py
+++ b/tests/performance/shortlink.py
@@ -3,11 +3,9 @@ from locust import FastHttpUser, task
from api import BackendApi
class ShortlinkCreate(FastHttpUser):
- api: BackendApi
+ def on_start(self):
+ self.api = BackendApi(self.client)
@task
def user_create_test(self):
- self.api.shortlink_create("https://ya.ru")
-
- def on_start(self):
- self.api = BackendApi(self)
\ No newline at end of file
+ self.api.shortlink_create("https://example.com")
\ No newline at end of file
diff --git a/load_tests/tests/user.py b/tests/performance/user.py
similarity index 66%
rename from load_tests/tests/user.py
rename to tests/performance/user.py
index d9f3881..e67b067 100644
--- a/load_tests/tests/user.py
+++ b/tests/performance/user.py
@@ -3,23 +3,18 @@ from locust import FastHttpUser, task
from api import BackendApi, User
class UserCreate(FastHttpUser):
- api: BackendApi
+ def on_start(self):
+ self.api = BackendApi(self.client)
@task
def user_create_test(self):
self.api.user_create()
- def on_start(self):
- self.api = BackendApi(self)
-
class UserLogin(FastHttpUser):
- api: BackendApi
- user: User
+ def on_start(self):
+ self.api = BackendApi(self)
+ self.user = self.api.user_create()
@task
def user_create_test(self):
- self.api.user_login(self.user)
-
- def on_start(self):
- self.api = BackendApi(self)
- self.user = self.api.user_create()
\ No newline at end of file
+ self.api.user_login(self.user)
\ No newline at end of file
diff --git a/load_tests/requirements.txt b/tests/requirements.txt
similarity index 84%
rename from load_tests/requirements.txt
rename to tests/requirements.txt
index 166fda7..ea6536c 100644
--- a/load_tests/requirements.txt
+++ b/tests/requirements.txt
@@ -4,6 +4,7 @@ certifi==2024.7.4
charset-normalizer==3.3.2
click==8.1.7
ConfigArgParse==1.7
+exceptiongroup==1.2.2
Flask==3.0.3
Flask-Cors==4.0.1
Flask-Login==0.6.3
@@ -11,12 +12,16 @@ gevent==24.2.1
geventhttpclient==2.3.1
greenlet==3.0.3
idna==3.7
+iniconfig==2.0.0
itsdangerous==2.2.0
Jinja2==3.1.4
locust==2.31.3
MarkupSafe==2.1.5
msgpack==1.0.8
+packaging==24.2
+pluggy==1.5.0
psutil==6.0.0
+pytest==8.3.4
pyzmq==26.1.0
requests==2.32.3
tomli==2.0.1