add user service

This commit is contained in:
Timur Kh.
2025-12-17 12:09:25 +03:00
parent 843c50a103
commit 6f21e895f0
9 changed files with 596 additions and 0 deletions
+32
View File
@@ -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")
}
+76
View File
@@ -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)
}
+40
View File
@@ -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)
}
+52
View File
@@ -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")
}
}
@@ -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
}
+99
View File
@@ -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")
}
@@ -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)
}
+57
View File
@@ -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
}
+102
View File
@@ -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)
})
}