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