From 6f21e895f0ca1f4aee89641c48733787ae3432b9 Mon Sep 17 00:00:00 2001 From: "Timur Kh." Date: Wed, 17 Dec 2025 12:09:25 +0300 Subject: [PATCH 01/10] add user service --- cmd/user/main.go | 32 ++++++ internal/user/config/config.go | 76 +++++++++++++ internal/user/handler/grpc/handler.go | 40 +++++++ internal/user/middleware/auth.go | 52 +++++++++ .../user/repository/postgres/repository.go | 38 +++++++ internal/user/server/server.go | 99 +++++++++++++++++ .../user/service/mocks/mock_repository.go | 100 +++++++++++++++++ internal/user/service/service.go | 57 ++++++++++ internal/user/service/service_test.go | 102 ++++++++++++++++++ 9 files changed, 596 insertions(+) create mode 100644 cmd/user/main.go create mode 100644 internal/user/config/config.go create mode 100644 internal/user/handler/grpc/handler.go create mode 100644 internal/user/middleware/auth.go create mode 100644 internal/user/repository/postgres/repository.go create mode 100644 internal/user/server/server.go create mode 100644 internal/user/service/mocks/mock_repository.go create mode 100644 internal/user/service/service.go create mode 100644 internal/user/service/service_test.go diff --git a/cmd/user/main.go b/cmd/user/main.go new file mode 100644 index 0000000..4f3ac4a --- /dev/null +++ b/cmd/user/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "datarush/internal/user/config" + "datarush/internal/user/server" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("failed to load config: %v", err) + } + + srv := server.New(cfg) + + if err := srv.Start(); err != nil { + log.Fatalf("failed to start server: %v", err) + } + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("shutting down user server...") + srv.Stop() + log.Println("user server stopped") +} \ No newline at end of file diff --git a/internal/user/config/config.go b/internal/user/config/config.go new file mode 100644 index 0000000..6b7ea01 --- /dev/null +++ b/internal/user/config/config.go @@ -0,0 +1,76 @@ +package config + +import ( + "fmt" + "log" + "net" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +type Config struct { + GRPCPort int + GRPCEnableReflection bool + HTTPPort int + LogLevel string + DBHost string + DBPort int + DBUser string + DBPassword string + DBName string + JWTSecret string +} + +func Load() (*Config, error) { + _ = godotenv.Load() + + return &Config{ + GRPCPort: mustGetInt("USER_GRPC_PORT", 50054), + GRPCEnableReflection: mustGetBool("USER_GRPC_ENABLE_REFLECTION", false), + HTTPPort: mustGetInt("USER_HTTP_PORT", 8083), + LogLevel: getEnv("LOG_LEVEL", "info"), + DBHost: getEnv("POSTGRES_HOST", "localhost"), + DBPort: mustGetInt("POSTGRES_PORT", 5432), + DBUser: getEnv("POSTGRES_USERNAME", "postgres"), + DBPassword: getEnv("POSTGRES_PASSWORD", "postgres"), + DBName: getEnv("POSTGRES_DATABASE", "postgres"), + JWTSecret: getEnv("JWT_SECRET", "your-secret-key-change-in-production"), + }, nil +} + +func getEnv(key, def string) string { + if val := os.Getenv(key); val != "" { + return val + } + return def +} + +func mustGetInt(key string, def int) int { + val := getEnv(key, strconv.Itoa(def)) + n, err := strconv.Atoi(val) + if err != nil { + log.Fatalf("invalid int for %s: %v", key, err) + } + return n +} + +func mustGetBool(key string, def bool) bool { + val := getEnv(key, strconv.FormatBool(def)) + b, err := strconv.ParseBool(val) + if err != nil { + log.Fatalf("invalid bool for %s: %v", key, err) + } + return b +} + +func (c Config) BuildPostgresConnStr() string { + return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + c.DBHost, c.DBPort, c.DBUser, c.DBPassword, c.DBName) +} + +func (c Config) BuildPostgresDSN() string { + return fmt.Sprintf("postgresql://%s:%s@%s/%s?sslmode=disable", + c.DBUser, c.DBPassword, net.JoinHostPort(c.DBHost, strconv.Itoa(c.DBPort)), c.DBName) +} \ No newline at end of file diff --git a/internal/user/handler/grpc/handler.go b/internal/user/handler/grpc/handler.go new file mode 100644 index 0000000..7b4a915 --- /dev/null +++ b/internal/user/handler/grpc/handler.go @@ -0,0 +1,40 @@ +package grpc + +import ( + "context" + + pb "datarush/pkg/api/user" + "google.golang.org/protobuf/types/known/emptypb" +) + +type UserService interface { + GetProfile(ctx context.Context, req *pb.GetProfileRequest) (*pb.User, error) + RegisterForCompetition(ctx context.Context, req *pb.RegisterForCompetitionRequest) (*emptypb.Empty, error) + UnregisterFromCompetition(ctx context.Context, req *pb.UnregisterFromCompetitionRequest) (*emptypb.Empty, error) + ListUserCompetitions(ctx context.Context, req *pb.ListUserCompetitionsRequest) (*pb.ListUserCompetitionsResponse, error) +} + +type UserHandler struct { + pb.UnimplementedUserServiceServer + service UserService +} + +func NewUserHandler(service UserService) *UserHandler { + return &UserHandler{service: service} +} + +func (h *UserHandler) GetProfile(ctx context.Context, req *pb.GetProfileRequest) (*pb.User, error) { + return h.service.GetProfile(ctx, req) +} + +func (h *UserHandler) RegisterForCompetition(ctx context.Context, req *pb.RegisterForCompetitionRequest) (*emptypb.Empty, error) { + return h.service.RegisterForCompetition(ctx, req) +} + +func (h *UserHandler) UnregisterFromCompetition(ctx context.Context, req *pb.UnregisterFromCompetitionRequest) (*emptypb.Empty, error) { + return h.service.UnregisterFromCompetition(ctx, req) +} + +func (h *UserHandler) ListUserCompetitions(ctx context.Context, req *pb.ListUserCompetitionsRequest) (*pb.ListUserCompetitionsResponse, error) { + return h.service.ListUserCompetitions(ctx, req) +} \ No newline at end of file diff --git a/internal/user/middleware/auth.go b/internal/user/middleware/auth.go new file mode 100644 index 0000000..e86be98 --- /dev/null +++ b/internal/user/middleware/auth.go @@ -0,0 +1,52 @@ +package middleware + +import ( + "context" + "strings" + + "github.com/golang-jwt/jwt/v5" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +type UserIDKey struct{} + +func AuthInterceptor(jwtSecret string) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return nil, status.Errorf(codes.Unauthenticated, "metadata is not provided") + } + + authHeader, ok := md["authorization"] + if !ok || len(authHeader) == 0 { + return nil, status.Errorf(codes.Unauthenticated, "authorization token is not provided") + } + + tokenString := strings.TrimPrefix(authHeader[0], "Bearer ") + + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, status.Errorf(codes.Unauthenticated, "unexpected signing method: %v", token.Header["alg"]) + } + return []byte(jwtSecret), nil + }) + if err != nil { + return nil, status.Errorf(codes.Unauthenticated, "invalid token: %v", err) + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + userID, ok := claims["user_id"].(string) + if !ok { + return nil, status.Errorf(codes.Unauthenticated, "invalid token: user_id is not a string") + } + + newCtx := context.WithValue(ctx, UserIDKey{}, userID) + return handler(newCtx, req) + } + + return nil, status.Errorf(codes.Unauthenticated, "invalid token") + } +} \ No newline at end of file diff --git a/internal/user/repository/postgres/repository.go b/internal/user/repository/postgres/repository.go new file mode 100644 index 0000000..a47a956 --- /dev/null +++ b/internal/user/repository/postgres/repository.go @@ -0,0 +1,38 @@ +package postgres + +import ( + "context" + + "datarush/pkg/api/user" + "github.com/jmoiron/sqlx" +) + +type UserRepository struct { + db *sqlx.DB +} + +func NewUserRepository(db *sqlx.DB) *UserRepository { + return &UserRepository{db: db} +} + +func (r *UserRepository) GetProfile(ctx context.Context, userID string) (*user.User, error) { + var u user.User + err := r.db.GetContext(ctx, &u, "SELECT id, username, email, full_name, avatar_url FROM users WHERE id = $1", userID) + return &u, err +} + +func (r *UserRepository) RegisterForCompetition(ctx context.Context, userID, competitionID string) error { + _, err := r.db.ExecContext(ctx, "INSERT INTO user_competitions (user_id, competition_id) VALUES ($1, $2)", userID, competitionID) + return err +} + +func (r *UserRepository) UnregisterFromCompetition(ctx context.Context, userID, competitionID string) error { + _, err := r.db.ExecContext(ctx, "DELETE FROM user_competitions WHERE user_id = $1 AND competition_id = $2", userID, competitionID) + return err +} + +func (r *UserRepository) ListUserCompetitions(ctx context.Context, userID string) ([]string, error) { + var competitionIDs []string + err := r.db.SelectContext(ctx, &competitionIDs, "SELECT competition_id FROM user_competitions WHERE user_id = $1", userID) + return competitionIDs, err +} \ No newline at end of file diff --git a/internal/user/server/server.go b/internal/user/server/server.go new file mode 100644 index 0000000..c945f7b --- /dev/null +++ b/internal/user/server/server.go @@ -0,0 +1,99 @@ +package server + +import ( + "fmt" + "log" + "net" + "time" + + "datarush/internal/user/config" + grpcHandlers "datarush/internal/user/handler/grpc" + "datarush/internal/user/middleware" + userPostgresRepo "datarush/internal/user/repository/postgres" + "datarush/internal/user/service" + pb "datarush/pkg/api/user" + + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" +) + +const ( + httpReadTimeout = 10 * time.Second + httpWriteTimeout = 10 * time.Second + httpIdleTimeout = 60 * time.Second +) + +type Server struct { + grpcServer *grpc.Server + config *config.Config + db *sqlx.DB +} + +func New(cfg *config.Config) *Server { + return &Server{ + config: cfg, + } +} + +func (s *Server) Start() error { + db, err := sqlx.Connect("postgres", s.config.BuildPostgresConnStr()) + if err != nil { + return fmt.Errorf("failed to connect to postgres: %w", err) + } + s.db = db + + if err := s.registerGRPCServices(); err != nil { + return fmt.Errorf("failed to register gRPC services: %w", err) + } + + go func() { + lis, err := net.Listen("tcp", fmt.Sprintf(":%d", s.config.GRPCPort)) + if err != nil { + log.Fatalf("failed to listen on grpc port: %v", err) + } + + log.Printf("starting gRPC server on port %d", s.config.GRPCPort) + if err := s.grpcServer.Serve(lis); err != nil { + log.Fatalf("failed to serve gRPC: %v", err) + } + }() + + return nil +} + +func (s *Server) registerGRPCServices() error { + s.grpcServer = grpc.NewServer( + grpc.UnaryInterceptor(middleware.AuthInterceptor(s.config.JWTSecret)), + ) + + userRepo := userPostgresRepo.NewUserRepository(s.db) + + userService := service.NewUserService(userRepo) + + userHandler := grpcHandlers.NewUserHandler(userService) + pb.RegisterUserServiceServer(s.grpcServer, userHandler) + + if s.config.GRPCEnableReflection { + reflection.Register(s.grpcServer) + } + + return nil +} + +func (s *Server) Stop() { + log.Println("shutting down user server...") + + if s.grpcServer != nil { + s.grpcServer.GracefulStop() + } + + if s.db != nil { + if err := s.db.Close(); err != nil { + log.Printf("failed to close database: %v", err) + } + } + + log.Println("user server stopped") +} \ No newline at end of file diff --git a/internal/user/service/mocks/mock_repository.go b/internal/user/service/mocks/mock_repository.go new file mode 100644 index 0000000..4ca4183 --- /dev/null +++ b/internal/user/service/mocks/mock_repository.go @@ -0,0 +1,100 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: internal/user/service/service.go +// +// Generated by this command: +// +// mockgen -source=internal/user/service/service.go -destination=internal/user/service/mocks/mock_repository.go -package=mocks +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + user "datarush/pkg/api/user" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockUserRepository is a mock of UserRepository interface. +type MockUserRepository struct { + ctrl *gomock.Controller + recorder *MockUserRepositoryMockRecorder + isgomock struct{} +} + +// MockUserRepositoryMockRecorder is the mock recorder for MockUserRepository. +type MockUserRepositoryMockRecorder struct { + mock *MockUserRepository +} + +// NewMockUserRepository creates a new mock instance. +func NewMockUserRepository(ctrl *gomock.Controller) *MockUserRepository { + mock := &MockUserRepository{ctrl: ctrl} + mock.recorder = &MockUserRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockUserRepository) EXPECT() *MockUserRepositoryMockRecorder { + return m.recorder +} + +// GetProfile mocks base method. +func (m *MockUserRepository) GetProfile(ctx context.Context, userID string) (*user.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetProfile", ctx, userID) + ret0, _ := ret[0].(*user.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetProfile indicates an expected call of GetProfile. +func (mr *MockUserRepositoryMockRecorder) GetProfile(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProfile", reflect.TypeOf((*MockUserRepository)(nil).GetProfile), ctx, userID) +} + +// ListUserCompetitions mocks base method. +func (m *MockUserRepository) ListUserCompetitions(ctx context.Context, userID string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListUserCompetitions", ctx, userID) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListUserCompetitions indicates an expected call of ListUserCompetitions. +func (mr *MockUserRepositoryMockRecorder) ListUserCompetitions(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserCompetitions", reflect.TypeOf((*MockUserRepository)(nil).ListUserCompetitions), ctx, userID) +} + +// RegisterForCompetition mocks base method. +func (m *MockUserRepository) RegisterForCompetition(ctx context.Context, userID, competitionID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RegisterForCompetition", ctx, userID, competitionID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RegisterForCompetition indicates an expected call of RegisterForCompetition. +func (mr *MockUserRepositoryMockRecorder) RegisterForCompetition(ctx, userID, competitionID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterForCompetition", reflect.TypeOf((*MockUserRepository)(nil).RegisterForCompetition), ctx, userID, competitionID) +} + +// UnregisterFromCompetition mocks base method. +func (m *MockUserRepository) UnregisterFromCompetition(ctx context.Context, userID, competitionID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UnregisterFromCompetition", ctx, userID, competitionID) + ret0, _ := ret[0].(error) + return ret0 +} + +// UnregisterFromCompetition indicates an expected call of UnregisterFromCompetition. +func (mr *MockUserRepositoryMockRecorder) UnregisterFromCompetition(ctx, userID, competitionID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnregisterFromCompetition", reflect.TypeOf((*MockUserRepository)(nil).UnregisterFromCompetition), ctx, userID, competitionID) +} diff --git a/internal/user/service/service.go b/internal/user/service/service.go new file mode 100644 index 0000000..5c2db7e --- /dev/null +++ b/internal/user/service/service.go @@ -0,0 +1,57 @@ +package service + +import ( + "context" + "errors" + + "datarush/internal/user/middleware" + pb "datarush/pkg/api/user" + "google.golang.org/protobuf/types/known/emptypb" +) + +type UserRepository interface { + GetProfile(ctx context.Context, userID string) (*pb.User, error) + RegisterForCompetition(ctx context.Context, userID, competitionID string) error + UnregisterFromCompetition(ctx context.Context, userID, competitionID string) error + ListUserCompetitions(ctx context.Context, userID string) ([]string, error) +} + +type UserService struct { + repo UserRepository +} + +func NewUserService(repo UserRepository) *UserService { + return &UserService{repo: repo} +} + +func (s *UserService) GetProfile(ctx context.Context, req *pb.GetProfileRequest) (*pb.User, error) { + return s.repo.GetProfile(ctx, req.UserId) +} + +func (s *UserService) RegisterForCompetition(ctx context.Context, req *pb.RegisterForCompetitionRequest) (*emptypb.Empty, error) { + userID, ok := ctx.Value(middleware.UserIDKey{}).(string) + if !ok { + return nil, errors.New("user ID not found in context") + } + + err := s.repo.RegisterForCompetition(ctx, userID, req.CompetitionId) + return &emptypb.Empty{}, err +} + +func (s *UserService) UnregisterFromCompetition(ctx context.Context, req *pb.UnregisterFromCompetitionRequest) (*emptypb.Empty, error) { + userID, ok := ctx.Value(middleware.UserIDKey{}).(string) + if !ok { + return nil, errors.New("user ID not found in context") + } + + err := s.repo.UnregisterFromCompetition(ctx, userID, req.CompetitionId) + return &emptypb.Empty{}, err +} + +func (s *UserService) ListUserCompetitions(ctx context.Context, req *pb.ListUserCompetitionsRequest) (*pb.ListUserCompetitionsResponse, error) { + competitionIDs, err := s.repo.ListUserCompetitions(ctx, req.UserId) + if err != nil { + return nil, err + } + return &pb.ListUserCompetitionsResponse{CompetitionIds: competitionIDs}, nil +} \ No newline at end of file diff --git a/internal/user/service/service_test.go b/internal/user/service/service_test.go new file mode 100644 index 0000000..85d4477 --- /dev/null +++ b/internal/user/service/service_test.go @@ -0,0 +1,102 @@ +package service + +import ( + "context" + "errors" + "testing" + + "datarush/internal/user/middleware" + "datarush/internal/user/service/mocks" + pb "datarush/pkg/api/user" + + "go.uber.org/mock/gomock" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/emptypb" +) + +func TestUserService(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRepo := mocks.NewMockUserRepository(ctrl) + service := NewUserService(mockRepo) + + ctx := context.Background() + userID := "test-user-id" + ctx = context.WithValue(ctx, middleware.UserIDKey{}, userID) + + t.Run("GetProfile", func(t *testing.T) { + req := &pb.GetProfileRequest{UserId: userID} + expectedUser := &pb.User{ + Id: userID, + Username: "testuser", + Email: "test@example.com", + } + + mockRepo.EXPECT().GetProfile(ctx, userID).Return(expectedUser, nil) + + user, err := service.GetProfile(ctx, req) + assert.NoError(t, err) + assert.Equal(t, expectedUser, user) + }) + + t.Run("RegisterForCompetition", func(t *testing.T) { + competitionID := "comp1" + req := &pb.RegisterForCompetitionRequest{CompetitionId: competitionID} + + mockRepo.EXPECT().RegisterForCompetition(ctx, userID, competitionID).Return(nil) + + _, err := service.RegisterForCompetition(ctx, req) + assert.NoError(t, err) + }) + + t.Run("RegisterForCompetition - No UserID in context", func(t *testing.T) { + competitionID := "comp1" + req := &pb.RegisterForCompetitionRequest{CompetitionId: competitionID} + + _, err := service.RegisterForCompetition(context.Background(), req) + assert.Error(t, err) + assert.Equal(t, "user ID not found in context", err.Error()) + }) + + t.Run("UnregisterFromCompetition", func(t *testing.T) { + competitionID := "comp1" + req := &pb.UnregisterFromCompetitionRequest{CompetitionId: competitionID} + + mockRepo.EXPECT().UnregisterFromCompetition(ctx, userID, competitionID).Return(nil) + + _, err := service.UnregisterFromCompetition(ctx, req) + assert.NoError(t, err) + }) + + t.Run("UnregisterFromCompetition - No UserID in context", func(t *testing.T) { + competitionID := "comp1" + req := &pb.UnregisterFromCompetitionRequest{CompetitionId: competitionID} + + _, err := service.UnregisterFromCompetition(context.Background(), req) + assert.Error(t, err) + assert.Equal(t, "user ID not found in context", err.Error()) + }) + + t.Run("ListUserCompetitions", func(t *testing.T) { + req := &pb.ListUserCompetitionsRequest{UserId: userID} + expectedCompetitionIDs := []string{"comp1", "comp2"} + + mockRepo.EXPECT().ListUserCompetitions(ctx, userID).Return(expectedCompetitionIDs, nil) + + resp, err := service.ListUserCompetitions(ctx, req) + assert.NoError(t, err) + assert.Equal(t, expectedCompetitionIDs, resp.CompetitionIds) + }) + + t.Run("ListUserCompetitions - Error", func(t *testing.T) { + req := &pb.ListUserCompetitionsRequest{UserId: userID} + expectedError := errors.New("repository error") + + mockRepo.EXPECT().ListUserCompetitions(ctx, userID).Return(nil, expectedError) + + _, err := service.ListUserCompetitions(ctx, req) + assert.Error(t, err) + assert.Equal(t, expectedError, err) + }) +} \ No newline at end of file From d843cafbfb0140351413df2fe4f329d265f3e54a Mon Sep 17 00:00:00 2001 From: "Timur Kh." Date: Wed, 17 Dec 2025 12:10:20 +0300 Subject: [PATCH 02/10] add user tests --- go.mod | 6 +++++- go.sum | 4 ++-- internal/user/service/service_test.go | 1 - 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 9d96342..6e7ff9c 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ toolchain go1.24.9 require ( github.com/Masterminds/squirrel v1.5.4 - github.com/gin-gonic/gin v1.10.0 github.com/go-playground/validator/v10 v10.28.0 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-migrate/migrate/v4 v4.19.0 @@ -16,6 +15,8 @@ require ( github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 github.com/redis/go-redis/v9 v9.16.0 + github.com/stretchr/testify v1.10.0 + go.uber.org/mock v0.6.0 golang.org/x/crypto v0.42.0 google.golang.org/grpc v1.76.0 google.golang.org/protobuf v1.36.10 @@ -23,6 +24,7 @@ require ( require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -33,6 +35,7 @@ require ( github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.43.0 // indirect @@ -41,6 +44,7 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 // indirect google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) tool ( diff --git a/go.sum b/go.sum index af41754..fde49b9 100644 --- a/go.sum +++ b/go.sum @@ -35,7 +35,6 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -52,7 +51,6 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE= @@ -123,6 +121,8 @@ go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFh go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= diff --git a/internal/user/service/service_test.go b/internal/user/service/service_test.go index 85d4477..7d4fb5c 100644 --- a/internal/user/service/service_test.go +++ b/internal/user/service/service_test.go @@ -11,7 +11,6 @@ import ( "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" - "google.golang.org/protobuf/types/known/emptypb" ) func TestUserService(t *testing.T) { From b14371036a235e91ca817d31f8b52e4804a63cea Mon Sep 17 00:00:00 2001 From: timka Date: Wed, 17 Dec 2025 17:54:26 +0300 Subject: [PATCH 03/10] some go.mod changes --- go.mod | 5 +---- go.sum | 2 -- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 07caaad..4e9d854 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ toolchain go1.24.9 require ( github.com/Masterminds/squirrel v1.5.4 - github.com/go-playground/validator/v10 v10.28.0 github.com/aws/aws-sdk-go-v2 v1.41.0 github.com/aws/aws-sdk-go-v2/config v1.32.6 github.com/aws/aws-sdk-go-v2/credentials v1.19.6 @@ -19,10 +18,9 @@ require ( github.com/jmoiron/sqlx v1.4.0 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 - github.com/redis/go-redis/v9 v9.16.0 + github.com/redis/go-redis/v9 v9.17.2 github.com/stretchr/testify v1.10.0 go.uber.org/mock v0.6.0 - github.com/redis/go-redis/v9 v9.17.2 golang.org/x/crypto v0.42.0 google.golang.org/grpc v1.76.0 google.golang.org/protobuf v1.36.10 @@ -52,7 +50,6 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect - github.com/leodido/go-urn v1.4.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect diff --git a/go.sum b/go.sum index b473dde..c55eade 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,6 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= -github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= From 6fd31915b9a02d5c8cb37c60785993780e561bdc Mon Sep 17 00:00:00 2001 From: timka Date: Wed, 17 Dec 2025 17:58:58 +0300 Subject: [PATCH 04/10] add interceptors to task and competition services --- go.sum | 5 ----- internal/task/server/server.go | 4 +++- internal/user/server/server.go | 11 ++++++++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/go.sum b/go.sum index b25dc5c..c55eade 100644 --- a/go.sum +++ b/go.sum @@ -138,13 +138,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -<<<<<<< HEAD -github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4= -github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= -======= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= ->>>>>>> origin/feature/task github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/internal/task/server/server.go b/internal/task/server/server.go index b2f94bf..bcce31a 100644 --- a/internal/task/server/server.go +++ b/internal/task/server/server.go @@ -69,7 +69,9 @@ func (s *Server) registerGRPCServices() error { authClient := authpb.NewAuthServiceClient(s.authConn) authInterceptor := interceptor.NewAuthInterceptor(authClient) - s.grpcServer = grpc.NewServer() + s.grpcServer = grpc.NewServer( + grpc.UnaryInterceptor(authInterceptor.Unary()), + ) taskRepo := taskPostgresRepo.NewTaskRepository(s.db) diff --git a/internal/user/server/server.go b/internal/user/server/server.go index c945f7b..1eeb22c 100644 --- a/internal/user/server/server.go +++ b/internal/user/server/server.go @@ -8,10 +8,11 @@ import ( "datarush/internal/user/config" grpcHandlers "datarush/internal/user/handler/grpc" - "datarush/internal/user/middleware" userPostgresRepo "datarush/internal/user/repository/postgres" "datarush/internal/user/service" + authpb "datarush/pkg/api/auth" pb "datarush/pkg/api/user" + "datarush/pkg/interceptor" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" @@ -29,6 +30,7 @@ type Server struct { grpcServer *grpc.Server config *config.Config db *sqlx.DB + authConn *grpc.ClientConn } func New(cfg *config.Config) *Server { @@ -64,8 +66,11 @@ func (s *Server) Start() error { } func (s *Server) registerGRPCServices() error { + authClient := authpb.NewAuthServiceClient(s.authConn) + authInterceptor := interceptor.NewAuthInterceptor(authClient) + s.grpcServer = grpc.NewServer( - grpc.UnaryInterceptor(middleware.AuthInterceptor(s.config.JWTSecret)), + grpc.UnaryInterceptor(authInterceptor.Unary()), ) userRepo := userPostgresRepo.NewUserRepository(s.db) @@ -96,4 +101,4 @@ func (s *Server) Stop() { } log.Println("user server stopped") -} \ No newline at end of file +} From 400e26cfff6178f6e92f3e05ea68b8259dbe087b Mon Sep 17 00:00:00 2001 From: timka Date: Wed, 17 Dec 2025 18:04:49 +0300 Subject: [PATCH 05/10] fix auth interceptors in task and user services; --- compose.yaml | 29 +++++++++++++++++++++++++++++ internal/task/server/server.go | 7 +++++++ internal/user/config/config.go | 4 +++- internal/user/server/server.go | 13 +++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/compose.yaml b/compose.yaml index 48956cb..837b49c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -142,6 +142,35 @@ services: restart: unless-stopped shm_size: 4mb + user: + build: + context: . + dockerfile: Containerfile + args: + SERVICE: user + depends_on: + postgres: + restart: false + condition: service_healthy + required: true + redis: + restart: false + condition: service_healthy + required: true + auth: + restart: false + condition: service_started + required: true + env_file: + - path: ./infrastructure/user/.env.template + required: true + - path: ./infrastructure/user/.env + required: false + networks: + - default + restart: unless-stopped + shm_size: 4mb + nginx: image: docker.io/nginx:1.29-alpine configs: diff --git a/internal/task/server/server.go b/internal/task/server/server.go index bcce31a..d567570 100644 --- a/internal/task/server/server.go +++ b/internal/task/server/server.go @@ -17,6 +17,7 @@ import ( "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/reflection" ) @@ -46,6 +47,12 @@ func (s *Server) Start() error { } s.db = db + authConn, err := grpc.Dial(s.config.AuthSvcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return fmt.Errorf("failed to connect to auth service: %w", err) + } + s.authConn = authConn + if err := s.registerGRPCServices(); err != nil { return fmt.Errorf("failed to register gRPC services: %w", err) } diff --git a/internal/user/config/config.go b/internal/user/config/config.go index 6b7ea01..bb40b72 100644 --- a/internal/user/config/config.go +++ b/internal/user/config/config.go @@ -21,6 +21,7 @@ type Config struct { DBPassword string DBName string JWTSecret string + AuthSvcAddr string } func Load() (*Config, error) { @@ -37,6 +38,7 @@ func Load() (*Config, error) { DBPassword: getEnv("POSTGRES_PASSWORD", "postgres"), DBName: getEnv("POSTGRES_DATABASE", "postgres"), JWTSecret: getEnv("JWT_SECRET", "your-secret-key-change-in-production"), + AuthSvcAddr: getEnv("AUTH_SVC_ADDR", "localhost:50051"), }, nil } @@ -73,4 +75,4 @@ func (c Config) BuildPostgresConnStr() string { func (c Config) BuildPostgresDSN() string { return fmt.Sprintf("postgresql://%s:%s@%s/%s?sslmode=disable", c.DBUser, c.DBPassword, net.JoinHostPort(c.DBHost, strconv.Itoa(c.DBPort)), c.DBName) -} \ No newline at end of file +} diff --git a/internal/user/server/server.go b/internal/user/server/server.go index 1eeb22c..f039941 100644 --- a/internal/user/server/server.go +++ b/internal/user/server/server.go @@ -17,6 +17,7 @@ import ( "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/reflection" ) @@ -46,6 +47,12 @@ func (s *Server) Start() error { } s.db = db + authConn, err := grpc.Dial(s.config.AuthSvcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return fmt.Errorf("failed to connect to auth service: %w", err) + } + s.authConn = authConn + if err := s.registerGRPCServices(); err != nil { return fmt.Errorf("failed to register gRPC services: %w", err) } @@ -100,5 +107,11 @@ func (s *Server) Stop() { } } + if s.authConn != nil { + if err := s.authConn.Close(); err != nil { + log.Printf("failed to close auth connection: %v", err) + } + } + log.Println("user server stopped") } From 5e73575faa499b7a8c8f259b4863c8de3090d943 Mon Sep 17 00:00:00 2001 From: timka Date: Wed, 17 Dec 2025 18:06:47 +0300 Subject: [PATCH 06/10] add .env.example for user service --- compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose.yaml b/compose.yaml index 837b49c..16fc2cd 100644 --- a/compose.yaml +++ b/compose.yaml @@ -164,7 +164,7 @@ services: env_file: - path: ./infrastructure/user/.env.template required: true - - path: ./infrastructure/user/.env + - path: ./infrastructure/use/.env required: false networks: - default From 154e0b0779d77b43894abb4ee76ee4676f83a22c Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 17 Dec 2025 17:36:36 +0300 Subject: [PATCH 07/10] ci: added jobs for new service --- .gitlab-ci.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 92686ce..8efce09 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -209,6 +209,14 @@ build-auth: BUILDTARGET: runtime SERVICE_NAME: auth +build-competition: + <<: *build-config + variables: + IMAGE_NAME: $BASE_IMAGE_NAME/competition + CONTAINERFILE: Containerfile + BUILDTARGET: runtime + SERVICE_NAME: competition + golangci-lint: stage: lint image: docker.io/golangci/golangci-lint:latest-alpine @@ -281,6 +289,14 @@ sast-image-auth: dependencies: - build-auth +sast-image-competition: + <<: *trivy-image-scan + variables: + IMAGE_NAME: $BASE_IMAGE_NAME/competition + IMAGE_TYPE: competition + dependencies: + - build-competition + tag-migrate: <<: *tag-config variables: @@ -296,6 +312,11 @@ tag-auth: variables: IMAGE_NAME: $BASE_IMAGE_NAME/auth +tag-competition: + <<: *tag-config + variables: + IMAGE_NAME: $BASE_IMAGE_NAME/competition + # webhook-backend-deploy: # <<: *webhook-config # stage: deploy From 383f67893eefc53943c25413100a6bb6dd6f952e Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 17 Dec 2025 17:57:16 +0300 Subject: [PATCH 08/10] feat(gw): added connection with retry --- internal/gw/grpc_client/auth_client.go | 2 +- internal/gw/grpc_client/client.go | 29 +++++++++++++++++++ internal/gw/grpc_client/competition_client.go | 2 +- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/internal/gw/grpc_client/auth_client.go b/internal/gw/grpc_client/auth_client.go index 63383e9..32ab32b 100644 --- a/internal/gw/grpc_client/auth_client.go +++ b/internal/gw/grpc_client/auth_client.go @@ -15,7 +15,7 @@ type AuthClient struct { } func NewAuthClient(ctx context.Context, address string, factory *ClientFactory) (*AuthClient, error) { - conn, err := factory.GetConnection(ctx, address) + conn, err := factory.GetConnectionWithRetry(ctx, address) if err != nil { return nil, fmt.Errorf("failed to create auth client: %w", err) } diff --git a/internal/gw/grpc_client/client.go b/internal/gw/grpc_client/client.go index 195d4d6..c8d2da6 100644 --- a/internal/gw/grpc_client/client.go +++ b/internal/gw/grpc_client/client.go @@ -6,7 +6,9 @@ import ( "time" "google.golang.org/grpc" + "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" ) type ClientFactory struct { @@ -19,6 +21,11 @@ func NewClientFactory() *ClientFactory { } } +const ( + maxRetries = 3 + retryDelay = 500 * time.Millisecond +) + func (f *ClientFactory) GetConnection(ctx context.Context, address string) (*grpc.ClientConn, error) { if conn, ok := f.connections[address]; ok { return conn, nil @@ -39,6 +46,28 @@ func (f *ClientFactory) GetConnection(ctx context.Context, address string) (*grp return conn, nil } +func (f *ClientFactory) GetConnectionWithRetry(ctx context.Context, address string) (*grpc.ClientConn, error) { + var conn *grpc.ClientConn + var err error + + for i := 0; i < maxRetries; i++ { + conn, err = f.GetConnection(ctx, address) + if err == nil { + return conn, nil + } + + st, ok := status.FromError(err) + if ok && (st.Code() == codes.Unavailable || st.Code() == codes.ResourceExhausted) { + time.Sleep(retryDelay) + continue + } + + break + } + + return nil, fmt.Errorf("failed to connect to %s after %d retries: %w", address, maxRetries, err) +} + func (f *ClientFactory) Close() error { for addr, conn := range f.connections { if err := conn.Close(); err != nil { diff --git a/internal/gw/grpc_client/competition_client.go b/internal/gw/grpc_client/competition_client.go index 4476597..c23b248 100644 --- a/internal/gw/grpc_client/competition_client.go +++ b/internal/gw/grpc_client/competition_client.go @@ -15,7 +15,7 @@ type CompetitionClient struct { } func NewCompetitionClient(ctx context.Context, address string, factory *ClientFactory) (*CompetitionClient, error) { - conn, err := factory.GetConnection(ctx, address) + conn, err := factory.GetConnectionWithRetry(ctx, address) if err != nil { return nil, fmt.Errorf("failed to create competition client: %w", err) } From 4dca8de5c806c6169c0a5cc71595a6f9151da8b3 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 17 Dec 2025 18:27:57 +0300 Subject: [PATCH 09/10] docs: added basic docs --- Makefile | 14 +++++- README.md | 140 +++++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 130 insertions(+), 24 deletions(-) diff --git a/Makefile b/Makefile index 633f5cd..99bdbac 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,8 @@ BASE_BINARY_NAME=datarush GW_BINARY_NAME=$(BASE_BINARY_NAME)-gw MIGRATE_BINARY_NAME=$(BASE_BINARY_NAME)-migrate AUTH_BINARY_NAME=$(BASE_BINARY_NAME)-auth +COMPETITION_BINARY_NAME=$(BASE_BINARY_NAME)-competition +TASK_BINARY_NAME=$(BASE_BINARY_NAME)-task BINARY_DIR=bin @@ -49,10 +51,20 @@ build-auth: $(GOBUILD) -o ./$(BINARY_DIR)/$(AUTH_BINARY_NAME) ./cmd/auth chmod +x ./$(BINARY_DIR)/$(AUTH_BINARY_NAME) -build: build-gw build-migrate build-auth +build-competition: + $(GOBUILD) -o ./$(BINARY_DIR)/$(COMPETITION_BINARY_NAME) ./cmd/competition + chmod +x ./$(BINARY_DIR)/$(COMPETITION_BINARY_NAME) + +build-task: + $(GOBUILD) -o ./$(BINARY_DIR)/$(TASK_BINARY_NAME) ./cmd/task + chmod +x ./$(BINARY_DIR)/$(TASK_BINARY_NAME) + +build: build-gw build-migrate build-auth build-competition build-task run: ./$(BINARY_DIR)/$(AUTH_BINARY_NAME) & + ./$(BINARY_DIR)/$(COMPETITION_BINARY_NAME) & + ./$(BINARY_DIR)/$(TASK_BINARY_NAME) & ./$(BINARY_DIR)/$(GW_BINARY_NAME) migrate: build-migrate diff --git a/README.md b/README.md index 001dd76..27d316b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Datarush +# DataRush API -Data analysis contest management system +Data analysis contest management system. ## Prerequisites @@ -10,52 +10,146 @@ Ensure you have the following installed on your system: - protoc (Protocol Buffers compiler) - make (latest version recommended) -## Installation +## Environment Variables + +See `infrastructure//.env.template` for example usage. + +## Setup ### 1. Clone the project ### 2. Go to the project directory -### 3. Install dependencies +### 3. Install Dependencies ```bash make i ``` -### 4. Customize environment +### 4. Build ```bash -cp .env.example .env +make build- ``` -And setup env vars according to your needs. +### 3. Set Environment Variables -## Configuration +Create a `.env` file or export variables: + +### 4. Run the service ```bash -GRPC_PORT=50051 # gRPC server port -GRPC_ENABLE_REFLECTION=false # whether to enable gRPC reflection or not -HTTP_HANDLER_ENABLE=false # whether to enable HTTP gateway or not -HTTP_PORT=8080 # HTTP gateway port -LOG_LEVEL=info # logging severity (debug, info, warn, error) +./bin/ ``` -## Running +## API Endpoints -### Build + run +### Authentication +- `POST /api/v1/sign-up` - Register new user +- `POST /api/v1/sign-in` - Authenticate user +- `GET /api/v1/me` - Get current user profile (requires auth) + +### Competitions +- `POST /api/v1/competitions` - Create competition (requires auth) +- `GET /api/v1/competitions` - List competitions (requires auth) +- `GET /api/v1/competitions/{id}` - Get competition details (requires auth) +- `PUT /api/v1/competitions/{id}` - Update competition (requires auth) +- `DELETE /api/v1/competitions/{id}` - Delete competition (requires auth) +- `PATCH /api/v1/competitions/{id}/state` - Change competition state (requires auth) +- `POST /api/v1/competitions/{id}/join` - Join competition (requires auth) + +### Tasks +- `POST /api/v1/competitions/{comp_id}/tasks` - Create task (requires auth) +- `GET /api/v1/competitions/{comp_id}/tasks` - List tasks (requires auth) +- `GET /api/v1/competitions/{comp_id}/tasks/{task_id}` - Get task (requires auth) +- `PUT /api/v1/competitions/{comp_id}/tasks/{task_id}` - Update task (requires auth) +- `DELETE /api/v1/competitions/{comp_id}/tasks/{task_id}` - Delete task (requires auth) + +### Submissions +- `POST /api/v1/competitions/{comp_id}/tasks/{task_id}/submit` - Submit task with file upload (requires auth) +- `GET /api/v1/competitions/{comp_id}/tasks/{task_id}/history` - Get submission history (requires auth) + +### Results +- `GET /api/v1/competitions/{id}/results` - Get competition leaderboard (requires auth) +- `GET /api/v1/competitions/{id}/results/me` - Get my results (requires auth) +- `POST /api/v1/competitions/{id}/results/recalculate` - Recalculate results (requires auth) + +### Review (Token-based) +- `GET /api/v1/review/{token}/submissions` - List submissions for review +- `GET /api/v1/review/{token}/submissions/{id}` - Get submission details +- `POST /api/v1/review/{token}/submissions/{id}/evaluate` - Evaluate submission +- `POST /api/v1/review/{token}/submissions/{id}/release` - Release submission + +### Achievements +- `GET /api/v1/achievements` - List all achievements (requires auth) +- `GET /api/v1/achievements/{id}` - Get achievement details (requires auth) +- `GET /api/v1/users/{user_id}/achievements` - Get user achievements (requires auth) + +### Health Check +- `GET /api/v1/ping` - Health check endpoint + +## Authentication + +Most endpoints require JWT authentication. Include the token in the Authorization header: ```bash -make run +Authorization: Bearer YOUR_JWT_TOKEN ``` -### Build +The gateway validates tokens by calling `AuthService.ValidateToken` and extracts the user ID for subsequent requests. + +## Error Handling + +The API returns consistent error responses: + +```json +{ + "error": "error_type", + "message": "Human-readable error message" +} +``` + +HTTP status codes: + +- `200` - Success +- `201` - Created +- `204` - No Content +- `400` - Bad Request +- `401` - Unauthorized +- `403` - Forbidden +- `404` - Not Found +- `409` - Conflict +- `500` - Internal Server Error + +## Development + +### Gateway Service Structure + +- **cmd/**: Entry point with dependency injection +- **config/**: Environment variable configuration +- **domain/**: HTTP request/response models and errors +- **handler/**: HTTP handlers (one per resource) +- **middleware/**: Reusable middleware +- **grpc_client/**: gRPC client wrappers (one per service) +- **storage/**: S3 integration +- **router/**: Route definitions +- **utils/**: Converters and helpers + +### Adding New Endpoints + +1. Add the endpoint to the OpenAPI spec +2. Update proto files if needed +3. Regenerate proto stubs +4. Add converter functions in `utils/converter.go` +5. Add handler method in appropriate handler file +6. Register route in `router/router.go` + +### Testing ```bash -make build -``` +# Run tests +go test ./... -### gRPC code generation - -```bash -make generate +# Run with coverage +go test -cover ./... ``` From dc23bf7c79ad954aa283dc0700aa6d52b0bdfc8d Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 17 Dec 2025 18:36:12 +0300 Subject: [PATCH 10/10] docs: added docker compose instructions --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 27d316b..54570d7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ Ensure you have the following installed on your system: See `infrastructure//.env.template` for example usage. +## Setup with Compose + +```bash +docker compose up -d --build --force-recreate --remove-orphans +``` + ## Setup ### 1. Clone the project