Merge branch 'feature/competition'

* feature/competition:
  add competition service to compose
  remove generated shit from competitions
  lint competition service
  use auth service for validating user in competitions service
  use auth service for validating user in competitions service
  add redis cache to competitions
  rewrite competition service: remove http related things
  rewrite competition service: remove http related things
  fix proto files
  fix issues
  add competition service (it`s broken, I will fix it later)
This commit is contained in:
ITQ
2025-12-17 15:40:46 +03:00
14 changed files with 816 additions and 8 deletions
+84
View File
@@ -0,0 +1,84 @@
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
AuthSvcAddr string
RedisAddr string
RedisPassword string
RedisDB int
CacheEnabled bool
}
func Load() (*Config, error) {
_ = godotenv.Load()
return &Config{
GRPCPort: mustGetInt("COMPETITION_GRPC_PORT", 50053),
GRPCEnableReflection: mustGetBool("COMPETITION_GRPC_ENABLE_REFLECTION", false),
HTTPPort: mustGetInt("COMPETITION_HTTP_PORT", 8082),
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"),
AuthSvcAddr: getEnv("AUTH_SVC_ADDR", "localhost:50051"),
RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"),
RedisPassword: getEnv("REDIS_PASSWORD", ""),
RedisDB: mustGetInt("REDIS_DB", 0),
CacheEnabled: mustGetBool("CACHE_ENABLED", true),
}, 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)
}
@@ -0,0 +1,63 @@
package grpc
import (
"context"
"google.golang.org/protobuf/types/known/emptypb"
pb "datarush/pkg/api/competition"
)
type CompetitionService interface {
CreateCompetition(ctx context.Context, req *pb.Competition) (*pb.Competition, error)
GetCompetition(ctx context.Context, req *pb.GetCompetitionRequest) (*pb.Competition, error)
EditCompetition(ctx context.Context, req *pb.Competition) (*pb.Competition, error)
DeleteCompetition(ctx context.Context, req *pb.DeleteCompetitionRequest) (*emptypb.Empty, error)
ListCompetitions(ctx context.Context, req *pb.ListCompetitionsRequest) (*pb.ListCompetitionsResponse, error)
ChangeCompetitionState(ctx context.Context, req *pb.ChangeCompetitionStateRequest) (*pb.Competition, error)
}
type CompetitionHandler struct {
pb.UnimplementedCompetitionServiceServer
service CompetitionService
}
func NewCompetitionHandler(service CompetitionService) *CompetitionHandler {
return &CompetitionHandler{service: service}
}
func (h *CompetitionHandler) CreateCompetition(ctx context.Context, req *pb.Competition) (*pb.Competition, error) {
return h.service.CreateCompetition(ctx, req)
}
func (h *CompetitionHandler) GetCompetition(
ctx context.Context,
req *pb.GetCompetitionRequest,
) (*pb.Competition, error) {
return h.service.GetCompetition(ctx, req)
}
func (h *CompetitionHandler) EditCompetition(ctx context.Context, req *pb.Competition) (*pb.Competition, error) {
return h.service.EditCompetition(ctx, req)
}
func (h *CompetitionHandler) DeleteCompetition(
ctx context.Context,
req *pb.DeleteCompetitionRequest,
) (*emptypb.Empty, error) {
return h.service.DeleteCompetition(ctx, req)
}
func (h *CompetitionHandler) ListCompetitions(
ctx context.Context,
req *pb.ListCompetitionsRequest,
) (*pb.ListCompetitionsResponse, error) {
return h.service.ListCompetitions(ctx, req)
}
func (h *CompetitionHandler) ChangeCompetitionState(
ctx context.Context,
req *pb.ChangeCompetitionStateRequest,
) (*pb.Competition, error) {
return h.service.ChangeCompetitionState(ctx, req)
}
+67
View File
@@ -0,0 +1,67 @@
package middleware
import (
"context"
"strings"
authpb "datarush/pkg/api/auth"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
type contextKey string
const (
UserIDKey contextKey = "user_id"
authHeader = "authorization"
bearerScheme = "bearer"
)
type AuthInterceptor struct {
authClient authpb.AuthServiceClient
}
func NewAuthInterceptor(authClient authpb.AuthServiceClient) *AuthInterceptor {
return &AuthInterceptor{authClient: authClient}
}
func (i *AuthInterceptor) Unary() 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.Error(codes.Unauthenticated, "metadata is not provided")
}
authHeaders := md.Get(authHeader)
if len(authHeaders) == 0 {
return nil, status.Error(codes.Unauthenticated, "authorization token is not provided")
}
header := authHeaders[0]
parts := strings.Split(header, " ")
if len(parts) != 2 || !strings.EqualFold(parts[0], bearerScheme) {
return nil, status.Errorf(codes.Unauthenticated, "invalid authorization header format")
}
token := parts[1]
validateResp, err := i.authClient.ValidateToken(ctx, &authpb.ValidateTokenRequest{
Token: token,
})
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "failed to validate token: %v", err)
}
newCtx := context.WithValue(ctx, UserIDKey, validateResp.GetUserId())
return handler(newCtx, req)
}
}
@@ -0,0 +1,26 @@
package repository
import (
"context"
"github.com/google/uuid"
pb "datarush/pkg/api/competition"
)
type ListCompetitionsOptions struct {
Page int
PageSize int
State *pb.CompetitionState
IsParticipating *bool
SearchQuery *string
}
type CompetitionRepository interface {
Create(ctx context.Context, competition *pb.Competition) (*pb.Competition, error)
Get(ctx context.Context, id uuid.UUID) (*pb.Competition, error)
Update(ctx context.Context, competition *pb.Competition) (*pb.Competition, error)
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, opts ListCompetitionsOptions) ([]*pb.Competition, int, error)
ChangeState(ctx context.Context, id uuid.UUID, state pb.CompetitionState) (*pb.Competition, error)
}
@@ -0,0 +1,230 @@
package postgres
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"datarush/internal/competition/repository"
pb "datarush/pkg/api/competition"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/redis/go-redis/v9"
)
const (
competitionCachePrefix = "competition:"
)
type CompetitionRepository struct {
db *sqlx.DB
redisClient *redis.Client
cacheEnabled bool
}
func NewCompetitionRepository(
db *sqlx.DB,
redisClient *redis.Client,
cacheEnabled bool,
) repository.CompetitionRepository {
return &CompetitionRepository{
db: db,
redisClient: redisClient,
cacheEnabled: cacheEnabled,
}
}
func (r *CompetitionRepository) cacheKey(id string) string {
return competitionCachePrefix + id
}
func (r *CompetitionRepository) Create(ctx context.Context, c *pb.Competition) (*pb.Competition, error) {
query := `INSERT INTO competitions (state, title, description, image_url, start_time, end_time, type, participation_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at, updated_at`
var createdCompetition pb.Competition
err := r.db.QueryRowxContext(ctx, query,
c.State,
c.Title,
c.Description,
c.ImageUrl,
c.StartTime.AsTime(),
c.EndTime.AsTime(),
c.Type,
c.ParticipationType,
).StructScan(&createdCompetition)
if err != nil {
return nil, err
}
c.Id = createdCompetition.Id
c.CreatedAt = createdCompetition.CreatedAt
c.UpdatedAt = createdCompetition.UpdatedAt
if r.cacheEnabled {
data, err := json.Marshal(c)
if err == nil {
r.redisClient.Set(ctx, r.cacheKey(c.Id), data, 10*time.Minute).Err()
}
}
return c, nil
}
func (r *CompetitionRepository) Get(ctx context.Context, id uuid.UUID) (*pb.Competition, error) {
if r.cacheEnabled {
val, err := r.redisClient.Get(ctx, r.cacheKey(id.String())).Result()
if err == nil {
var competition pb.Competition
if json.Unmarshal([]byte(val), &competition) == nil {
return &competition, nil
}
}
}
query := `SELECT id, state, title, description, image_url, start_time, end_time, type, participation_type, created_at, updated_at FROM competitions WHERE id = $1`
var competition pb.Competition
err := r.db.GetContext(ctx, &competition, query, id)
if err != nil {
return nil, err
}
if r.cacheEnabled {
data, err := json.Marshal(&competition)
if err == nil {
r.redisClient.Set(ctx, r.cacheKey(id.String()), data, 10*time.Minute).Err()
}
}
return &competition, nil
}
func (r *CompetitionRepository) Update(ctx context.Context, c *pb.Competition) (*pb.Competition, error) {
query := `UPDATE competitions SET
state = $2, title = $3, description = $4, image_url = $5, start_time = $6, end_time = $7, type = $8, participation_type = $9, updated_at = now()
WHERE id = $1 RETURNING updated_at`
var updatedCompetition pb.Competition
err := r.db.QueryRowxContext(ctx, query,
c.Id,
c.State,
c.Title,
c.Description,
c.ImageUrl,
c.StartTime.AsTime(),
c.EndTime.AsTime(),
c.Type,
c.ParticipationType,
).StructScan(&updatedCompetition)
if err != nil {
return nil, err
}
c.UpdatedAt = updatedCompetition.UpdatedAt
if r.cacheEnabled {
r.redisClient.Del(ctx, r.cacheKey(c.Id)).Err()
}
return c, nil
}
func (r *CompetitionRepository) Delete(ctx context.Context, id uuid.UUID) error {
query := `DELETE FROM competitions WHERE id = $1`
_, err := r.db.ExecContext(ctx, query, id)
if err != nil {
return err
}
if r.cacheEnabled {
r.redisClient.Del(ctx, r.cacheKey(id.String())).Err()
}
return nil
}
func (r *CompetitionRepository) List(
ctx context.Context,
opts repository.ListCompetitionsOptions,
) ([]*pb.Competition, int, error) {
var args []interface{}
var whereClauses []string
argId := 1
if opts.State != nil {
whereClauses = append(whereClauses, fmt.Sprintf("state = $%d", argId))
args = append(args, *opts.State)
argId++
}
if opts.SearchQuery != nil {
whereClauses = append(whereClauses, fmt.Sprintf("title ILIKE $%d", argId))
args = append(args, "%"+*opts.SearchQuery+"%")
argId++
}
if opts.IsParticipating != nil {
userID, ok := ctx.Value("user_id").(string)
if !ok {
return nil, 0, fmt.Errorf("user not authenticated or user_id not in context")
}
if *opts.IsParticipating {
whereClauses = append(
whereClauses,
fmt.Sprintf("id IN (SELECT competition_id FROM user_competitions WHERE user_id = $%d)", argId),
)
} else {
whereClauses = append(whereClauses, fmt.Sprintf("id NOT IN (SELECT competition_id FROM user_competitions WHERE user_id = $%d)", argId))
}
args = append(args, userID)
argId++
}
where := ""
if len(whereClauses) > 0 {
where = "WHERE " + strings.Join(whereClauses, " AND ")
}
countQuery := "SELECT COUNT(*) FROM competitions " + where
var total int
if err := r.db.GetContext(ctx, &total, countQuery, args...); err != nil {
return nil, 0, err
}
query := fmt.Sprintf(
`SELECT id, state, title, description, image_url, start_time, end_time, type, participation_type, created_at, updated_at FROM competitions %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d`,
where,
argId,
argId+1,
)
args = append(args, opts.PageSize, (opts.Page-1)*opts.PageSize)
var competitions []*pb.Competition
err := r.db.SelectContext(ctx, &competitions, query, args...)
if err != nil {
return nil, 0, err
}
return competitions, total, nil
}
func (r *CompetitionRepository) ChangeState(
ctx context.Context,
id uuid.UUID,
state pb.CompetitionState,
) (*pb.Competition, error) {
query := `UPDATE competitions SET state = $1, updated_at = $2 WHERE id = $3 RETURNING updated_at`
now := time.Now()
var competition pb.Competition
err := r.db.QueryRowxContext(ctx, query, state, now, id).StructScan(&competition)
if err != nil {
return nil, err
}
if r.cacheEnabled {
r.redisClient.Del(ctx, r.cacheKey(id.String())).Err()
}
return r.Get(ctx, id)
}
+130
View File
@@ -0,0 +1,130 @@
package server
import (
"fmt"
"log"
"net"
"time"
"datarush/internal/competition/config"
grpcHandlers "datarush/internal/competition/handler/grpc"
"datarush/internal/competition/middleware"
"datarush/internal/competition/repository/postgres"
"datarush/internal/competition/service"
authpb "datarush/pkg/api/auth"
pb "datarush/pkg/api/competition"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"github.com/redis/go-redis/v9"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"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
redisClient *redis.Client
authConn *grpc.ClientConn
}
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
s.redisClient = redis.NewClient(&redis.Options{
Addr: s.config.RedisAddr,
Password: s.config.RedisPassword,
DB: s.config.RedisDB,
})
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)
}
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 {
authClient := authpb.NewAuthServiceClient(s.authConn)
authInterceptor := middleware.NewAuthInterceptor(authClient)
s.grpcServer = grpc.NewServer(
grpc.UnaryInterceptor(authInterceptor.Unary()),
)
compRepo := postgres.NewCompetitionRepository(s.db, s.redisClient, s.config.CacheEnabled)
compService := service.NewCompetitionService(compRepo)
compHandler := grpcHandlers.NewCompetitionHandler(compService)
pb.RegisterCompetitionServiceServer(s.grpcServer, compHandler)
if s.config.GRPCEnableReflection {
reflection.Register(s.grpcServer)
}
return nil
}
func (s *Server) Stop() {
log.Println("shutting down competition server...")
if s.grpcServer != nil {
s.grpcServer.GracefulStop()
}
if s.authConn != nil {
if err := s.authConn.Close(); err != nil {
log.Printf("failed to close auth service connection: %v", err)
}
}
if s.db != nil {
if err := s.db.Close(); err != nil {
log.Printf("failed to close database: %v", err)
}
}
if s.redisClient != nil {
if err := s.redisClient.Close(); err != nil {
log.Printf("failed to close redis client: %v", err)
}
}
log.Println("competition server stopped")
}
+90
View File
@@ -0,0 +1,90 @@
package service
import (
"context"
"datarush/internal/competition/repository"
pb "datarush/pkg/api/competition"
"github.com/google/uuid"
"google.golang.org/protobuf/types/known/emptypb"
)
type CompetitionService struct {
repo repository.CompetitionRepository
}
func NewCompetitionService(repo repository.CompetitionRepository) *CompetitionService {
return &CompetitionService{repo: repo}
}
func (s *CompetitionService) CreateCompetition(ctx context.Context, req *pb.Competition) (*pb.Competition, error) {
return s.repo.Create(ctx, req)
}
func (s *CompetitionService) GetCompetition(
ctx context.Context,
req *pb.GetCompetitionRequest,
) (*pb.Competition, error) {
id, err := uuid.Parse(req.CompetitionId)
if err != nil {
return nil, err
}
return s.repo.Get(ctx, id)
}
func (s *CompetitionService) EditCompetition(ctx context.Context, req *pb.Competition) (*pb.Competition, error) {
return s.repo.Update(ctx, req)
}
func (s *CompetitionService) DeleteCompetition(
ctx context.Context,
req *pb.DeleteCompetitionRequest,
) (*emptypb.Empty, error) {
id, err := uuid.Parse(req.CompetitionId)
if err != nil {
return nil, err
}
err = s.repo.Delete(ctx, id)
return &emptypb.Empty{}, err
}
func (s *CompetitionService) ListCompetitions(
ctx context.Context,
req *pb.ListCompetitionsRequest,
) (*pb.ListCompetitionsResponse, error) {
opts := repository.ListCompetitionsOptions{
Page: int(req.PageToken),
PageSize: int(req.PageSize),
State: req.State,
IsParticipating: req.IsParticipating,
SearchQuery: req.SearchQuery,
}
competitions, total, err := s.repo.List(ctx, opts)
if err != nil {
return nil, err
}
var nextPageToken int32
if (opts.Page+1)*opts.PageSize < total {
nextPageToken = int32(opts.Page + 1)
}
return &pb.ListCompetitionsResponse{
Competitions: competitions,
TotalCount: int32(total),
NextPageToken: nextPageToken,
}, nil
}
func (s *CompetitionService) ChangeCompetitionState(
ctx context.Context,
req *pb.ChangeCompetitionStateRequest,
) (*pb.Competition, error) {
id, err := uuid.Parse(req.CompetitionId)
if err != nil {
return nil, err
}
return s.repo.ChangeState(ctx, id, req.State)
}
+2
View File
@@ -23,6 +23,7 @@ type Config struct {
DBName string
RedisURI string
AuthGRPCAddr string
CacheEnabled bool
}
func Load() (*Config, error) {
@@ -41,6 +42,7 @@ func Load() (*Config, error) {
DBName: getEnv("POSTGRES_DATABASE", "postgres"),
RedisURI: getEnv("REDIS_URI", "redis://localhost:6379"),
AuthGRPCAddr: getEnv("AUTH_GRPC_ADDR", "localhost:50052"),
CacheEnabled: mustGetBool("CACHE_ENABLED", true),
}, nil
}