Merge remote-tracking branch 'origin/main' into feature/results

This commit is contained in:
timka
2025-12-17 18:18:44 +03:00
85 changed files with 5879 additions and 1131 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ func Load() (*Config, error) {
_ = godotenv.Load()
return &Config{
GRPCPort: mustGetInt("AUTH_GRPC_PORT", 50052),
GRPCPort: mustGetInt("AUTH_GRPC_PORT", 50051),
GRPCEnableReflection: mustGetBool("AUTH_GRPC_ENABLE_REFLECTION", false),
HTTPPort: mustGetInt("AUTH_HTTP_PORT", 8081),
LogLevel: getEnv("LOG_LEVEL", "info"),
+4 -1
View File
@@ -53,7 +53,10 @@ func (h *AuthHandler) SignIn(ctx context.Context, req *pb.SignInRequest) (*pb.Si
return &pb.SignInResponse{Token: token}, nil
}
func (h *AuthHandler) ValidateToken(ctx context.Context, req *pb.ValidateTokenRequest) (*pb.ValidateTokenResponse, error) {
func (h *AuthHandler) ValidateToken(
ctx context.Context,
req *pb.ValidateTokenRequest,
) (*pb.ValidateTokenResponse, error) {
token := req.Token
if token == "" {
+2 -2
View File
@@ -12,13 +12,13 @@ import (
)
type SignUpRequest struct {
Email string `json:"email" binding:"required,email"`
Email string `json:"email" binding:"required,email"`
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type SignInRequest struct {
Email string `json:"email" binding:"required,email"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
-57
View File
@@ -1,7 +1,6 @@
package server
import (
"context"
"fmt"
"log"
"net"
@@ -10,7 +9,6 @@ import (
"datarush/internal/auth/config"
grpcHandlers "datarush/internal/auth/handler/grpc"
httpHandlers "datarush/internal/auth/handler/http"
authPostgresRepo "datarush/internal/auth/repository/postgres"
"datarush/internal/auth/service"
@@ -64,10 +62,6 @@ func (s *Server) Start() error {
}
}()
if err := s.startHTTPServer(); err != nil {
return fmt.Errorf("failed to start HTTP server: %w", err)
}
return nil
}
@@ -88,60 +82,9 @@ func (s *Server) registerGRPCServices() error {
return nil
}
func (s *Server) startHTTPServer() error {
userRepo := authPostgresRepo.NewUserRepository(s.db)
authService := service.NewAuthService(userRepo, s.config.JWTSecret)
authHandler := httpHandlers.NewAuthHandler(authService)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/sign-up", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
authHandler.SignUp(w, r)
})
mux.HandleFunc("/api/v1/sign-in", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
authHandler.SignIn(w, r)
})
s.httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", s.config.HTTPPort),
Handler: mux,
ReadTimeout: httpReadTimeout,
WriteTimeout: httpWriteTimeout,
IdleTimeout: httpIdleTimeout,
}
go func() {
log.Printf("starting HTTP server on port %d", s.config.HTTPPort)
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("failed to start HTTP server: %v", err)
}
}()
return nil
}
func (s *Server) Stop() {
log.Println("shutting down auth server...")
if s.httpServer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.httpServer.Shutdown(ctx); err != nil {
log.Printf("failed to shutdown HTTP server: %v", err)
}
}
if s.grpcServer != nil {
s.grpcServer.GracefulStop()
}
+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", 50051),
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)
}
@@ -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/repository/postgres"
"datarush/internal/competition/service"
authpb "datarush/pkg/api/auth"
pb "datarush/pkg/api/competition"
"datarush/pkg/interceptor"
"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 := interceptor.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)
}
+101
View File
@@ -0,0 +1,101 @@
package config
import (
"fmt"
"os"
"strconv"
)
type Config struct {
Server ServerConfig
GRPC GRPCConfig
S3 S3Config
Auth AuthConfig
}
type ServerConfig struct {
Port string
Host string
}
type GRPCConfig struct {
AuthServiceAddr string
UserServiceAddr string
CompetitionServiceAddr string
TaskServiceAddr string
SubmissionServiceAddr string
ResultsServiceAddr string
ReviewServiceAddr string
AchievementsServiceAddr string
}
type S3Config struct {
AccessKeyID string
SecretAccessKey string
Region string
Bucket string
Endpoint string
}
type AuthConfig struct {
JWTSecret string
}
func Load() (*Config, error) {
cfg := &Config{
Server: ServerConfig{
Port: getEnv("SERVER_PORT", "8080"),
Host: getEnv("SERVER_HOST", "0.0.0.0"),
},
GRPC: GRPCConfig{
AuthServiceAddr: getEnvRequired("AUTH_SERVICE_ADDR"),
UserServiceAddr: getEnvRequired("USER_SERVICE_ADDR"),
CompetitionServiceAddr: getEnvRequired("COMPETITION_SERVICE_ADDR"),
TaskServiceAddr: getEnvRequired("TASK_SERVICE_ADDR"),
SubmissionServiceAddr: getEnvRequired("SUBMISSION_SERVICE_ADDR"),
ResultsServiceAddr: getEnvRequired("RESULTS_SERVICE_ADDR"),
ReviewServiceAddr: getEnvRequired("REVIEW_SERVICE_ADDR"),
AchievementsServiceAddr: getEnvRequired("ACHIEVEMENTS_SERVICE_ADDR"),
},
S3: S3Config{
AccessKeyID: getEnvRequired("AWS_ACCESS_KEY_ID"),
SecretAccessKey: getEnvRequired("AWS_SECRET_ACCESS_KEY"),
Region: getEnv("AWS_REGION", ""),
Bucket: getEnvRequired("S3_BUCKET"),
Endpoint: getEnv("S3_ENDPOINT", ""),
},
Auth: AuthConfig{
JWTSecret: getEnv("JWT_SECRET", ""),
},
}
return cfg, nil
}
func getEnv(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
return value
}
func getEnvRequired(key string) string {
value := os.Getenv(key)
if value == "" {
panic(fmt.Sprintf("required environment variable %s is not set", key))
}
return value
}
func getEnvAsInt(key string, defaultValue int) int {
valueStr := os.Getenv(key)
if valueStr == "" {
return defaultValue
}
value, err := strconv.Atoi(valueStr)
if err != nil {
return defaultValue
}
return value
}
+137
View File
@@ -0,0 +1,137 @@
package domain
import (
"errors"
"fmt"
"net/http"
)
var (
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
ErrNotFound = errors.New("not found")
ErrBadRequest = errors.New("bad request")
ErrInternalServer = errors.New("internal server error")
ErrConflict = errors.New("conflict")
ErrInvalidToken = errors.New("invalid token")
ErrMissingAuthHeader = errors.New("missing authorization header")
ErrInvalidFile = errors.New("invalid file")
ErrFileTooLarge = errors.New("file too large")
)
type AppError struct {
Err error
Message string
StatusCode int
}
func (e *AppError) Error() string {
if e.Message != "" {
return e.Message
}
if e.Err != nil {
return e.Err.Error()
}
return "unknown error"
}
func NewAppError(err error, message string, statusCode int) *AppError {
return &AppError{
Err: err,
Message: message,
StatusCode: statusCode,
}
}
func NewBadRequestError(message string) *AppError {
return &AppError{
Err: ErrBadRequest,
Message: message,
StatusCode: http.StatusBadRequest,
}
}
func NewUnauthorizedError(message string) *AppError {
return &AppError{
Err: ErrUnauthorized,
Message: message,
StatusCode: http.StatusUnauthorized,
}
}
func NewForbiddenError(message string) *AppError {
return &AppError{
Err: ErrForbidden,
Message: message,
StatusCode: http.StatusForbidden,
}
}
func NewNotFoundError(message string) *AppError {
return &AppError{
Err: ErrNotFound,
Message: message,
StatusCode: http.StatusNotFound,
}
}
func NewConflictError(message string) *AppError {
return &AppError{
Err: ErrConflict,
Message: message,
StatusCode: http.StatusConflict,
}
}
func NewInternalServerError(message string) *AppError {
return &AppError{
Err: ErrInternalServer,
Message: message,
StatusCode: http.StatusInternalServerError,
}
}
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
Details string `json:"details,omitempty"`
}
func NewErrorResponse(err error, message string) *ErrorResponse {
errMsg := "internal server error"
if err != nil {
errMsg = err.Error()
}
return &ErrorResponse{
Error: errMsg,
Message: message,
}
}
func GRPCErrorToHTTPStatus(err error) int {
if err == nil {
return http.StatusOK
}
errMsg := err.Error()
switch {
case contains(errMsg, "not found"):
return http.StatusNotFound
case contains(errMsg, "already exists"), contains(errMsg, "conflict"):
return http.StatusConflict
case contains(errMsg, "invalid"), contains(errMsg, "bad request"):
return http.StatusBadRequest
case contains(errMsg, "unauthorized"), contains(errMsg, "unauthenticated"):
return http.StatusUnauthorized
case contains(errMsg, "forbidden"), contains(errMsg, "permission denied"):
return http.StatusForbidden
default:
return http.StatusInternalServerError
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || fmt.Sprintf("%s", s) != s)
}
+207
View File
@@ -0,0 +1,207 @@
package domain
import "time"
type SignUpRequest struct {
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,min=3,max=50"`
Password string `json:"password" validate:"required,min=6"`
}
type SignInRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
}
type TokenResponse struct {
Token string `json:"token"`
}
type UserResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
FullName *string `json:"full_name,omitempty"`
AvatarURL *string `json:"avatar_url,omitempty"`
}
type CompetitionRequest struct {
ID string `json:"id,omitempty"`
Title string `json:"title" validate:"required,max=200"`
Description string `json:"description" validate:"required"`
ImageURL *string `json:"image_url,omitempty"`
StartTime time.Time `json:"start_time" validate:"required"`
EndTime time.Time `json:"end_time" validate:"required"`
Type string `json:"type" validate:"required,oneof=educative competitive"`
ParticipationType string `json:"participation_type" validate:"required,oneof=individual team"`
}
type CompetitionResponse struct {
ID string `json:"id"`
State string `json:"state"`
Title string `json:"title"`
Description string `json:"description"`
ImageURL *string `json:"image_url,omitempty"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Type string `json:"type"`
ParticipationType string `json:"participation_type"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListCompetitionsResponse struct {
TotalCount int32 `json:"total_count"`
NextPageToken int32 `json:"next_page_token"`
Competitions []CompetitionResponse `json:"competitions"`
}
type ChangeCompetitionStateRequest struct {
State string `json:"state" validate:"required,oneof=draft not_started started finished archived"`
}
type TaskRequest struct {
ID string `json:"id,omitempty"`
CompetitionID string `json:"competition_id,omitempty"`
Title string `json:"title" validate:"required,max=50"`
Description string `json:"description" validate:"required"`
InCompetitionPosition int32 `json:"in_competition_position" validate:"required"`
MaxPoints int32 `json:"max_points,omitempty"`
MaxAttempts int32 `json:"max_attempts,omitempty"`
Type string `json:"type" validate:"required,oneof=input checker review"`
}
type TaskResponse struct {
ID string `json:"id"`
CompetitionID string `json:"competition_id"`
Title string `json:"title"`
Description string `json:"description"`
InCompetitionPosition int32 `json:"in_competition_position"`
MaxPoints int32 `json:"max_points,omitempty"`
MaxAttempts int32 `json:"max_attempts,omitempty"`
Type string `json:"type"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListTasksResponse struct {
Tasks []TaskResponse `json:"tasks"`
}
type TaskAttachmentResponse struct {
ID string `json:"id"`
FileURL string `json:"file_url"`
IsPublic bool `json:"is_public"`
}
type SubmissionResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
CompetitionID string `json:"competition_id"`
TaskID string `json:"task_id"`
Status string `json:"status"`
EarnedPoints int32 `json:"earned_points"`
SubmittedAt time.Time `json:"submitted_at"`
CheckedAt time.Time `json:"checked_at,omitempty"`
FileURL string `json:"file_url"`
}
type SubmitTaskResponse struct {
SubmissionID string `json:"submission_id"`
}
type SubmissionHistoryResponse struct {
Submissions []SubmissionResponse `json:"submissions"`
}
type TaskStatusResponse struct {
TaskID string `json:"task_id"`
TaskTitle string `json:"task_title"`
EarnedPoints int32 `json:"earned_points"`
MaxPoints int32 `json:"max_points"`
Position *int32 `json:"position,omitempty"`
}
type UserResultResponse struct {
UserID string `json:"user_id"`
Username string `json:"username"`
TotalScore int32 `json:"total_score"`
OverallPosition int32 `json:"overall_position"`
TaskStatuses []TaskStatusResponse `json:"task_statuses"`
}
type CompetitionResultsResponse struct {
Results []UserResultResponse `json:"results"`
TotalCount int32 `json:"total_count"`
NextPageToken int32 `json:"next_page_token"`
}
type CriteriaMarkRequest struct {
Slug string `json:"slug" validate:"required"`
Mark float64 `json:"mark" validate:"required"`
}
type EvaluateSubmissionRequest struct {
EarnedPoints int32 `json:"earned_points" validate:"required"`
ReviewerComment string `json:"reviewer_comment"`
Marks []CriteriaMarkRequest `json:"marks"`
}
type SubmissionSummaryResponse struct {
ID string `json:"id"`
CompetitionID string `json:"competition_id"`
TaskID string `json:"task_id"`
CompetitionTitle string `json:"competition_title"`
TaskTitle string `json:"task_title"`
SubmittedAt time.Time `json:"submitted_at"`
ReviewStatus string `json:"review_status"`
}
type SubmissionForReviewResponse struct {
ID string `json:"id"`
CompetitionID string `json:"competition_id"`
TaskID string `json:"task_id"`
Content string `json:"content"`
Description string `json:"description"`
ReviewStatus string `json:"review_status"`
SubmittedAt time.Time `json:"submitted_at"`
CheckedAt *time.Time `json:"checked_at,omitempty"`
}
type ListSubmissionsForReviewResponse struct {
TotalCount int32 `json:"total_count"`
NextPageToken int32 `json:"next_page_token"`
Submissions []SubmissionSummaryResponse `json:"submissions"`
}
type EvaluateSubmissionResponse struct {
SubmissionID string `json:"submission_id"`
FinalScore int32 `json:"final_score"`
NewStatus string `json:"new_status"`
}
type AchievementResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
IconURL string `json:"icon_url"`
}
type ListAchievementsResponse struct {
Achievements []AchievementResponse `json:"achievements"`
}
type UserAchievementResponse struct {
AchievementID string `json:"achievement_id"`
UserID string `json:"user_id"`
EarnedAt time.Time `json:"earned_at"`
}
type ListUserAchievementsResponse struct {
Achievements []UserAchievementResponse `json:"achievements"`
}
type PingResponse struct {
Message string `json:"message"`
Status string `json:"status"`
}
@@ -0,0 +1,49 @@
package grpc_client
import (
"context"
"fmt"
"google.golang.org/grpc"
pb "datarush/pkg/api/achievements"
)
type AchievementsClient struct {
client pb.AchievementsServiceClient
conn *grpc.ClientConn
}
func NewAchievementsClient(ctx context.Context, address string, factory *ClientFactory) (*AchievementsClient, error) {
conn, err := factory.GetConnection(ctx, address)
if err != nil {
return nil, fmt.Errorf("failed to create achievements client: %w", err)
}
return &AchievementsClient{client: pb.NewAchievementsServiceClient(conn), conn: conn}, nil
}
func (c *AchievementsClient) GetAchievement(ctx context.Context, achievementID string) (*pb.Achievement, error) {
req := &pb.GetAchievementRequest{Id: achievementID}
resp, err := c.client.GetAchievement(ctx, req)
if err != nil {
return nil, fmt.Errorf("get achievement failed: %w", err)
}
return resp, nil
}
func (c *AchievementsClient) ListAchievements(ctx context.Context) ([]*pb.Achievement, error) {
resp, err := c.client.ListAchievements(ctx, nil)
if err != nil {
return nil, fmt.Errorf("list achievements failed: %w", err)
}
return resp.Achievements, nil
}
func (c *AchievementsClient) GetUserAchievements(ctx context.Context, userID string) ([]*pb.AchievementUser, error) {
req := &pb.GetUserAchievementsRequest{UserId: userID}
resp, err := c.client.GetUserAchievements(ctx, req)
if err != nil {
return nil, fmt.Errorf("get user achievements failed: %w", err)
}
return resp.UserAchievements, nil
}
+69
View File
@@ -0,0 +1,69 @@
package grpc_client
import (
"context"
"fmt"
pb "datarush/pkg/api/auth"
"google.golang.org/grpc"
)
type AuthClient struct {
client pb.AuthServiceClient
conn *grpc.ClientConn
}
func NewAuthClient(ctx context.Context, address string, factory *ClientFactory) (*AuthClient, error) {
conn, err := factory.GetConnection(ctx, address)
if err != nil {
return nil, fmt.Errorf("failed to create auth client: %w", err)
}
return &AuthClient{
client: pb.NewAuthServiceClient(conn),
conn: conn,
}, nil
}
func (c *AuthClient) SignUp(ctx context.Context, email, username, password string) (string, error) {
req := &pb.SignUpRequest{
Email: email,
Username: username,
Password: password,
}
resp, err := c.client.SignUp(ctx, req)
if err != nil {
return "", fmt.Errorf("sign up failed: %w", err)
}
return resp.Token, nil
}
func (c *AuthClient) SignIn(ctx context.Context, email, password string) (string, error) {
req := &pb.SignInRequest{
Email: email,
Password: password,
}
resp, err := c.client.SignIn(ctx, req)
if err != nil {
return "", fmt.Errorf("sign in failed: %w", err)
}
return resp.Token, nil
}
func (c *AuthClient) ValidateToken(ctx context.Context, token string) (string, error) {
req := &pb.ValidateTokenRequest{
Token: token,
}
resp, err := c.client.ValidateToken(ctx, req)
if err != nil {
return "", fmt.Errorf("token validation failed: %w", err)
}
return resp.UserId, nil
}
+49
View File
@@ -0,0 +1,49 @@
package grpc_client
import (
"context"
"fmt"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
type ClientFactory struct {
connections map[string]*grpc.ClientConn
}
func NewClientFactory() *ClientFactory {
return &ClientFactory{
connections: make(map[string]*grpc.ClientConn),
}
}
func (f *ClientFactory) GetConnection(ctx context.Context, address string) (*grpc.ClientConn, error) {
if conn, ok := f.connections[address]; ok {
return conn, nil
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
conn, err := grpc.DialContext(ctx, address,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
if err != nil {
return nil, fmt.Errorf("failed to connect to %s: %w", address, err)
}
f.connections[address] = conn
return conn, nil
}
func (f *ClientFactory) Close() error {
for addr, conn := range f.connections {
if err := conn.Close(); err != nil {
return fmt.Errorf("failed to close connection to %s: %w", addr, err)
}
}
return nil
}
@@ -0,0 +1,98 @@
package grpc_client
import (
"context"
"fmt"
pb "datarush/pkg/api/competition"
"google.golang.org/grpc"
)
type CompetitionClient struct {
client pb.CompetitionServiceClient
conn *grpc.ClientConn
}
func NewCompetitionClient(ctx context.Context, address string, factory *ClientFactory) (*CompetitionClient, error) {
conn, err := factory.GetConnection(ctx, address)
if err != nil {
return nil, fmt.Errorf("failed to create competition client: %w", err)
}
return &CompetitionClient{
client: pb.NewCompetitionServiceClient(conn),
conn: conn,
}, nil
}
func (c *CompetitionClient) CreateCompetition(
ctx context.Context,
competition *pb.Competition,
) (*pb.Competition, error) {
resp, err := c.client.CreateCompetition(ctx, competition)
if err != nil {
return nil, fmt.Errorf("create competition failed: %w", err)
}
return resp, nil
}
func (c *CompetitionClient) GetCompetition(ctx context.Context, competitionID string) (*pb.Competition, error) {
req := &pb.GetCompetitionRequest{
CompetitionId: competitionID,
}
resp, err := c.client.GetCompetition(ctx, req)
if err != nil {
return nil, fmt.Errorf("get competition failed: %w", err)
}
return resp, nil
}
func (c *CompetitionClient) EditCompetition(ctx context.Context, competition *pb.Competition) (*pb.Competition, error) {
resp, err := c.client.EditCompetition(ctx, competition)
if err != nil {
return nil, fmt.Errorf("edit competition failed: %w", err)
}
return resp, nil
}
func (c *CompetitionClient) DeleteCompetition(ctx context.Context, competitionID string) error {
req := &pb.DeleteCompetitionRequest{
CompetitionId: competitionID,
}
_, err := c.client.DeleteCompetition(ctx, req)
if err != nil {
return fmt.Errorf("delete competition failed: %w", err)
}
return nil
}
func (c *CompetitionClient) ListCompetitions(
ctx context.Context,
req *pb.ListCompetitionsRequest,
) (*pb.ListCompetitionsResponse, error) {
resp, err := c.client.ListCompetitions(ctx, req)
if err != nil {
return nil, fmt.Errorf("list competitions failed: %w", err)
}
return resp, nil
}
func (c *CompetitionClient) ChangeCompetitionState(
ctx context.Context,
competitionID string,
state pb.CompetitionState,
) (*pb.Competition, error) {
req := &pb.ChangeCompetitionStateRequest{
CompetitionId: competitionID,
State: state,
}
resp, err := c.client.ChangeCompetitionState(ctx, req)
if err != nil {
return nil, fmt.Errorf("change competition state failed: %w", err)
}
return resp, nil
}
+58
View File
@@ -0,0 +1,58 @@
package grpc_client
import (
"context"
"fmt"
pb "datarush/pkg/api/results"
"google.golang.org/grpc"
)
type ResultsClient struct {
client pb.ResultsServiceClient
conn *grpc.ClientConn
}
func NewResultsClient(ctx context.Context, address string, factory *ClientFactory) (*ResultsClient, error) {
conn, err := factory.GetConnection(ctx, address)
if err != nil {
return nil, fmt.Errorf("failed to create results client: %w", err)
}
return &ResultsClient{client: pb.NewResultsServiceClient(conn), conn: conn}, nil
}
func (c *ResultsClient) GetCompetitionResults(
ctx context.Context,
req *pb.GetCompetitionResultsRequest,
) (*pb.GetCompetitionResultsResponse, error) {
resp, err := c.client.GetCompetitionResults(ctx, req)
if err != nil {
return nil, fmt.Errorf("get competition results failed: %w", err)
}
return resp, nil
}
func (c *ResultsClient) GetUserCompetitionResults(
ctx context.Context,
competitionID, userID string,
) (*pb.UserResult, error) {
req := &pb.GetUserCompetitionResultsRequest{
CompetitionId: competitionID,
UserId: userID,
}
resp, err := c.client.GetUserCompetitionResults(ctx, req)
if err != nil {
return nil, fmt.Errorf("get user competition results failed: %w", err)
}
return resp.Result, nil
}
func (c *ResultsClient) RecalculateResults(ctx context.Context, competitionID string) error {
req := &pb.RecalculateResultsRequest{CompetitionId: competitionID}
_, err := c.client.RecalculateResults(ctx, req)
if err != nil {
return fmt.Errorf("recalculate results failed: %w", err)
}
return nil
}
+81
View File
@@ -0,0 +1,81 @@
package grpc_client
import (
"context"
"fmt"
pb "datarush/pkg/api/review"
"google.golang.org/grpc"
)
type ReviewClient struct {
client pb.ReviewServiceClient
conn *grpc.ClientConn
}
func NewReviewClient(ctx context.Context, address string, factory *ClientFactory) (*ReviewClient, error) {
conn, err := factory.GetConnection(ctx, address)
if err != nil {
return nil, fmt.Errorf("failed to create review client: %w", err)
}
return &ReviewClient{client: pb.NewReviewServiceClient(conn), conn: conn}, nil
}
func (c *ReviewClient) ValidateReviewToken(ctx context.Context, token string) (*pb.ValidateReviewTokenResponse, error) {
req := &pb.ValidateReviewTokenRequest{Token: token}
resp, err := c.client.ValidateReviewToken(ctx, req)
if err != nil {
return nil, fmt.Errorf("validate review token failed: %w", err)
}
return resp, nil
}
func (c *ReviewClient) ListSubmissionsForReview(
ctx context.Context,
req *pb.ListSubmissionsForReviewRequest,
) (*pb.ListSubmissionsForReviewResponse, error) {
resp, err := c.client.ListSubmissionsForReview(ctx, req)
if err != nil {
return nil, fmt.Errorf("list submissions for review failed: %w", err)
}
return resp, nil
}
func (c *ReviewClient) GetSubmissionForReview(
ctx context.Context,
token, submissionID string,
) (*pb.SubmissionForReview, error) {
req := &pb.GetSubmissionForReviewRequest{
Token: token,
SubmissionId: submissionID,
}
resp, err := c.client.GetSubmissionForReview(ctx, req)
if err != nil {
return nil, fmt.Errorf("get submission for review failed: %w", err)
}
return resp, nil
}
func (c *ReviewClient) EvaluateSubmission(
ctx context.Context,
req *pb.EvaluateSubmissionRequest,
) (*pb.EvaluateSubmissionResponse, error) {
resp, err := c.client.EvaluateSubmission(ctx, req)
if err != nil {
return nil, fmt.Errorf("evaluate submission failed: %w", err)
}
return resp, nil
}
func (c *ReviewClient) ReleaseSubmission(ctx context.Context, token, submissionID string) error {
req := &pb.ReleaseSubmissionRequest{
Token: token,
SubmissionId: submissionID,
}
_, err := c.client.ReleaseSubmission(ctx, req)
if err != nil {
return fmt.Errorf("release submission failed: %w", err)
}
return nil
}
@@ -0,0 +1,85 @@
package grpc_client
import (
"context"
"fmt"
pb "datarush/pkg/api/submission"
"google.golang.org/grpc"
)
type SubmissionClient struct {
client pb.SubmissionServiceClient
conn *grpc.ClientConn
}
func NewSubmissionClient(ctx context.Context, address string, factory *ClientFactory) (*SubmissionClient, error) {
conn, err := factory.GetConnection(ctx, address)
if err != nil {
return nil, fmt.Errorf("failed to create submission client: %w", err)
}
return &SubmissionClient{
client: pb.NewSubmissionServiceClient(conn),
conn: conn,
}, nil
}
func (c *SubmissionClient) SubmitTask(
ctx context.Context,
userID, competitionID, taskID, fileURL string,
) (*pb.Submission, error) {
req := &pb.SubmitTaskRequest{
UserId: userID,
CompetitionId: competitionID,
TaskId: taskID,
FileUrl: fileURL,
}
resp, err := c.client.SubmitTask(ctx, req)
if err != nil {
return nil, fmt.Errorf("submit task failed: %w", err)
}
return resp, nil
}
func (c *SubmissionClient) GetSubmissionsHistory(
ctx context.Context,
userID, competitionID, taskID string,
) ([]*pb.Submission, error) {
req := &pb.GetSubmissionsHistoryRequest{
UserId: userID,
CompetitionId: competitionID,
TaskId: taskID,
}
resp, err := c.client.GetSubmissionsHistory(ctx, req)
if err != nil {
return nil, fmt.Errorf("get submissions history failed: %w", err)
}
return resp.Submissions, nil
}
func (c *SubmissionClient) GetSubmission(ctx context.Context, submissionID string) (*pb.Submission, error) {
req := &pb.GetSubmissionRequest{
SubmissionId: submissionID,
}
resp, err := c.client.GetSubmission(ctx, req)
if err != nil {
return nil, fmt.Errorf("get submission failed: %w", err)
}
return resp, nil
}
func (c *SubmissionClient) ListSubmissions(
ctx context.Context,
req *pb.ListSubmissionsRequest,
) (*pb.ListSubmissionsResponse, error) {
resp, err := c.client.ListSubmissions(ctx, req)
if err != nil {
return nil, fmt.Errorf("list submissions failed: %w", err)
}
return resp, nil
}
+82
View File
@@ -0,0 +1,82 @@
package grpc_client
import (
"context"
"fmt"
pb "datarush/pkg/api/task"
"google.golang.org/grpc"
)
type TaskClient struct {
client pb.TaskServiceClient
conn *grpc.ClientConn
}
func NewTaskClient(ctx context.Context, address string, factory *ClientFactory) (*TaskClient, error) {
conn, err := factory.GetConnection(ctx, address)
if err != nil {
return nil, fmt.Errorf("failed to create task client: %w", err)
}
return &TaskClient{client: pb.NewTaskServiceClient(conn), conn: conn}, nil
}
func (c *TaskClient) CreateTask(ctx context.Context, task *pb.Task) (*pb.Task, error) {
resp, err := c.client.CreateTask(ctx, task)
if err != nil {
return nil, fmt.Errorf("create task failed: %w", err)
}
return resp, nil
}
func (c *TaskClient) GetTask(ctx context.Context, taskID string) (*pb.Task, error) {
req := &pb.GetTaskRequest{TaskId: taskID}
resp, err := c.client.GetTask(ctx, req)
if err != nil {
return nil, fmt.Errorf("get task failed: %w", err)
}
return resp, nil
}
func (c *TaskClient) EditTask(ctx context.Context, task *pb.Task) (*pb.Task, error) {
resp, err := c.client.EditTask(ctx, task)
if err != nil {
return nil, fmt.Errorf("edit task failed: %w", err)
}
return resp, nil
}
func (c *TaskClient) DeleteTask(ctx context.Context, taskID string) error {
req := &pb.DeleteTaskRequest{TaskId: taskID}
_, err := c.client.DeleteTask(ctx, req)
if err != nil {
return fmt.Errorf("delete task failed: %w", err)
}
return nil
}
func (c *TaskClient) ListCompetitionTasks(ctx context.Context, competitionID string) ([]*pb.Task, error) {
req := &pb.ListCompetitionTasksRequest{CompetitionId: competitionID}
resp, err := c.client.ListCompetitionTasks(ctx, req)
if err != nil {
return nil, fmt.Errorf("list tasks failed: %w", err)
}
return resp.Tasks, nil
}
func (c *TaskClient) GetTaskAttachments(
ctx context.Context,
taskID string,
showPrivate bool,
) ([]*pb.TaskAttachment, error) {
req := &pb.GetTaskAttachmentsRequest{
TaskId: taskID,
ShowPrivate: &showPrivate,
}
resp, err := c.client.GetTaskAttachments(ctx, req)
if err != nil {
return nil, fmt.Errorf("get task attachments failed: %w", err)
}
return resp.Attachments, nil
}
+79
View File
@@ -0,0 +1,79 @@
package grpc_client
import (
"context"
"fmt"
pb "datarush/pkg/api/user"
"google.golang.org/grpc"
)
type UserClient struct {
client pb.UserServiceClient
conn *grpc.ClientConn
}
func NewUserClient(ctx context.Context, address string, factory *ClientFactory) (*UserClient, error) {
conn, err := factory.GetConnection(ctx, address)
if err != nil {
return nil, fmt.Errorf("failed to create user client: %w", err)
}
return &UserClient{
client: pb.NewUserServiceClient(conn),
conn: conn,
}, nil
}
func (c *UserClient) GetProfile(ctx context.Context, userID string) (*pb.User, error) {
req := &pb.GetProfileRequest{
UserId: userID,
}
resp, err := c.client.GetProfile(ctx, req)
if err != nil {
return nil, fmt.Errorf("get profile failed: %w", err)
}
return resp, nil
}
func (c *UserClient) RegisterForCompetition(ctx context.Context, competitionID string) error {
req := &pb.RegisterForCompetitionRequest{
CompetitionId: competitionID,
}
_, err := c.client.RegisterForCompetition(ctx, req)
if err != nil {
return fmt.Errorf("register for competition failed: %w", err)
}
return nil
}
func (c *UserClient) UnregisterFromCompetition(ctx context.Context, competitionID string) error {
req := &pb.UnregisterFromCompetitionRequest{
CompetitionId: competitionID,
}
_, err := c.client.UnregisterFromCompetition(ctx, req)
if err != nil {
return fmt.Errorf("unregister from competition failed: %w", err)
}
return nil
}
func (c *UserClient) ListUserCompetitions(ctx context.Context, userID string) ([]string, error) {
req := &pb.ListUserCompetitionsRequest{
UserId: userID,
}
resp, err := c.client.ListUserCompetitions(ctx, req)
if err != nil {
return nil, fmt.Errorf("list user competitions failed: %w", err)
}
return resp.CompetitionIds, nil
}
@@ -0,0 +1,69 @@
package handler
import (
"net/http"
"datarush/internal/gw/domain"
"datarush/internal/gw/grpc_client"
"datarush/internal/gw/utils"
)
type AchievementsHandler struct {
achievementsClient *grpc_client.AchievementsClient
}
func NewAchievementsHandler(achievementsClient *grpc_client.AchievementsClient) *AchievementsHandler {
return &AchievementsHandler{achievementsClient: achievementsClient}
}
func (h *AchievementsHandler) ListAchievements(w http.ResponseWriter, r *http.Request) {
achievements, err := h.achievementsClient.ListAchievements(r.Context())
if err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to list achievements"))
return
}
response := make([]domain.AchievementResponse, len(achievements))
for i, ach := range achievements {
response[i] = *utils.AchievementProtoToHTTP(ach)
}
utils.RespondJSON(w, http.StatusOK, &domain.ListAchievementsResponse{
Achievements: response,
})
}
func (h *AchievementsHandler) GetAchievement(w http.ResponseWriter, r *http.Request) {
achievementID := getPathParam(r, "achievement_id")
achievement, err := h.achievementsClient.GetAchievement(r.Context(), achievementID)
if err != nil {
utils.RespondError(w, domain.NewNotFoundError("achievement not found"))
return
}
utils.RespondJSON(w, http.StatusOK, utils.AchievementProtoToHTTP(achievement))
}
func (h *AchievementsHandler) GetUserAchievements(w http.ResponseWriter, r *http.Request) {
userID := getPathParam(r, "user_id")
achievements, err := h.achievementsClient.GetUserAchievements(r.Context(), userID)
if err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to get user achievements"))
return
}
response := make([]domain.UserAchievementResponse, len(achievements))
for i, ach := range achievements {
response[i] = domain.UserAchievementResponse{
AchievementID: ach.Achievement.Id,
UserID: userID,
EarnedAt: ach.ReceivedAt.AsTime(),
}
}
utils.RespondJSON(w, http.StatusOK, &domain.ListUserAchievementsResponse{
Achievements: response,
})
}
+69
View File
@@ -0,0 +1,69 @@
package handler
import (
"net/http"
"datarush/internal/gw/domain"
"datarush/internal/gw/grpc_client"
"datarush/internal/gw/utils"
)
type AuthHandler struct {
authClient *grpc_client.AuthClient
userClient *grpc_client.UserClient
}
func NewAuthHandler(authClient *grpc_client.AuthClient, userClient *grpc_client.UserClient) *AuthHandler {
return &AuthHandler{
authClient: authClient,
userClient: userClient,
}
}
func (h *AuthHandler) SignUp(w http.ResponseWriter, r *http.Request) {
var req domain.SignUpRequest
if err := utils.DecodeJSON(r, &req); err != nil {
utils.RespondError(w, err)
return
}
token, err := h.authClient.SignUp(r.Context(), req.Email, req.Username, req.Password)
if err != nil {
utils.RespondError(w, domain.NewInternalServerError("sign up failed"))
return
}
utils.RespondJSON(w, http.StatusCreated, &domain.TokenResponse{Token: token})
}
func (h *AuthHandler) SignIn(w http.ResponseWriter, r *http.Request) {
var req domain.SignInRequest
if err := utils.DecodeJSON(r, &req); err != nil {
utils.RespondError(w, err)
return
}
token, err := h.authClient.SignIn(r.Context(), req.Email, req.Password)
if err != nil {
utils.RespondError(w, domain.NewUnauthorizedError("invalid credentials"))
return
}
utils.RespondJSON(w, http.StatusOK, &domain.TokenResponse{Token: token})
}
func (h *AuthHandler) GetMe(w http.ResponseWriter, r *http.Request) {
userID, err := getUserIDFromContext(r.Context())
if err != nil {
utils.RespondError(w, domain.NewUnauthorizedError("unauthorized"))
return
}
user, err := h.userClient.GetProfile(r.Context(), userID)
if err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to get user profile"))
return
}
utils.RespondJSON(w, http.StatusOK, utils.UserProtoToHTTP(user))
}
+75
View File
@@ -0,0 +1,75 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"strconv"
"datarush/internal/gw/domain"
"datarush/internal/gw/middleware"
"github.com/gorilla/mux"
)
func getUserIDFromContext(ctx context.Context) (string, error) {
return middleware.GetUserIDFromContext(ctx)
}
func getPathParam(r *http.Request, key string) string {
vars := mux.Vars(r)
return vars[key]
}
func getQueryParam(r *http.Request, key string) string {
return r.URL.Query().Get(key)
}
func getQueryParamInt(r *http.Request, key string, defaultValue int) int {
val := r.URL.Query().Get(key)
if val == "" {
return defaultValue
}
intVal, err := strconv.Atoi(val)
if err != nil {
return defaultValue
}
return intVal
}
func getQueryParamInt32(r *http.Request, key string, defaultValue int32) int32 {
return int32(getQueryParamInt(r, key, int(defaultValue)))
}
func getQueryParamBool(r *http.Request, key string) *bool {
val := r.URL.Query().Get(key)
if val == "" {
return nil
}
boolVal, err := strconv.ParseBool(val)
if err != nil {
return nil
}
return &boolVal
}
type PingHandler struct{}
func NewPingHandler() *PingHandler {
return &PingHandler{}
}
func (h *PingHandler) Ping(w http.ResponseWriter, r *http.Request) {
response := &domain.PingResponse{
Message: "pong",
Status: "ok",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}
+158
View File
@@ -0,0 +1,158 @@
package handler
import (
"net/http"
"datarush/internal/gw/domain"
"datarush/internal/gw/grpc_client"
"datarush/internal/gw/utils"
comppb "datarush/pkg/api/competition"
)
type CompetitionHandler struct {
competitionClient *grpc_client.CompetitionClient
userClient *grpc_client.UserClient
}
func NewCompetitionHandler(
competitionClient *grpc_client.CompetitionClient,
userClient *grpc_client.UserClient,
) *CompetitionHandler {
return &CompetitionHandler{
competitionClient: competitionClient,
userClient: userClient,
}
}
func (h *CompetitionHandler) CreateCompetition(w http.ResponseWriter, r *http.Request) {
var req domain.CompetitionRequest
if err := utils.DecodeJSON(r, &req); err != nil {
utils.RespondError(w, err)
return
}
competition := utils.CompetitionHTTPToProto(&req)
resp, err := h.competitionClient.CreateCompetition(r.Context(), competition)
if err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to create competition"))
return
}
utils.RespondJSON(w, http.StatusCreated, utils.CompetitionProtoToHTTP(resp))
}
func (h *CompetitionHandler) GetCompetition(w http.ResponseWriter, r *http.Request) {
competitionID := getPathParam(r, "competition_id")
competition, err := h.competitionClient.GetCompetition(r.Context(), competitionID)
if err != nil {
utils.RespondError(w, domain.NewNotFoundError("competition not found"))
return
}
utils.RespondJSON(w, http.StatusOK, utils.CompetitionProtoToHTTP(competition))
}
func (h *CompetitionHandler) UpdateCompetition(w http.ResponseWriter, r *http.Request) {
competitionID := getPathParam(r, "competition_id")
var req domain.CompetitionRequest
if err := utils.DecodeJSON(r, &req); err != nil {
utils.RespondError(w, err)
return
}
req.ID = competitionID
competition := utils.CompetitionHTTPToProto(&req)
resp, err := h.competitionClient.EditCompetition(r.Context(), competition)
if err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to update competition"))
return
}
utils.RespondJSON(w, http.StatusOK, utils.CompetitionProtoToHTTP(resp))
}
func (h *CompetitionHandler) DeleteCompetition(w http.ResponseWriter, r *http.Request) {
competitionID := getPathParam(r, "competition_id")
if err := h.competitionClient.DeleteCompetition(r.Context(), competitionID); err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to delete competition"))
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *CompetitionHandler) ListCompetitions(w http.ResponseWriter, r *http.Request) {
pageSize := getQueryParamInt32(r, "page_size", 20)
pageToken := getQueryParamInt32(r, "page_token", 0)
state := getQueryParam(r, "state")
searchQuery := getQueryParam(r, "search_query")
isParticipating := getQueryParamBool(r, "is_participating")
req := &comppb.ListCompetitionsRequest{
PageSize: pageSize,
PageToken: pageToken,
}
if state != "" {
s := utils.StringToCompetitionState(state)
req.State = &s
}
if searchQuery != "" {
req.SearchQuery = &searchQuery
}
if isParticipating != nil {
req.IsParticipating = isParticipating
}
resp, err := h.competitionClient.ListCompetitions(r.Context(), req)
if err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to list competitions"))
return
}
competitions := make([]domain.CompetitionResponse, len(resp.Competitions))
for i, comp := range resp.Competitions {
competitions[i] = *utils.CompetitionProtoToHTTP(comp)
}
utils.RespondJSON(w, http.StatusOK, &domain.ListCompetitionsResponse{
TotalCount: resp.TotalCount,
NextPageToken: resp.NextPageToken,
Competitions: competitions,
})
}
func (h *CompetitionHandler) ChangeCompetitionState(w http.ResponseWriter, r *http.Request) {
competitionID := getPathParam(r, "competition_id")
var req domain.ChangeCompetitionStateRequest
if err := utils.DecodeJSON(r, &req); err != nil {
utils.RespondError(w, err)
return
}
state := utils.StringToCompetitionState(req.State)
competition, err := h.competitionClient.ChangeCompetitionState(r.Context(), competitionID, state)
if err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to change competition state"))
return
}
utils.RespondJSON(w, http.StatusOK, utils.CompetitionProtoToHTTP(competition))
}
func (h *CompetitionHandler) JoinCompetition(w http.ResponseWriter, r *http.Request) {
competitionID := getPathParam(r, "competition_id")
if err := h.userClient.RegisterForCompetition(r.Context(), competitionID); err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to join competition"))
return
}
w.WriteHeader(http.StatusNoContent)
}
+77
View File
@@ -0,0 +1,77 @@
package handler
import (
"net/http"
"datarush/internal/gw/domain"
"datarush/internal/gw/grpc_client"
"datarush/internal/gw/utils"
resultspb "datarush/pkg/api/results"
)
type ResultsHandler struct {
resultsClient *grpc_client.ResultsClient
}
func NewResultsHandler(resultsClient *grpc_client.ResultsClient) *ResultsHandler {
return &ResultsHandler{resultsClient: resultsClient}
}
func (h *ResultsHandler) GetCompetitionResults(w http.ResponseWriter, r *http.Request) {
competitionID := getPathParam(r, "competition_id")
pageSize := getQueryParamInt32(r, "page_size", 20)
pageToken := getQueryParamInt32(r, "page_token", 0)
req := &resultspb.GetCompetitionResultsRequest{
CompetitionId: competitionID,
PageSize: pageSize,
PageToken: pageToken,
}
resp, err := h.resultsClient.GetCompetitionResults(r.Context(), req)
if err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to get competition results"))
return
}
results := make([]domain.UserResultResponse, len(resp.Results))
for i, result := range resp.Results {
results[i] = *utils.UserResultProtoToHTTP(result)
}
utils.RespondJSON(w, http.StatusOK, &domain.CompetitionResultsResponse{
Results: results,
TotalCount: resp.TotalCount,
NextPageToken: resp.NextPageToken,
})
}
func (h *ResultsHandler) GetMyResults(w http.ResponseWriter, r *http.Request) {
userID, err := getUserIDFromContext(r.Context())
if err != nil {
utils.RespondError(w, domain.NewUnauthorizedError("unauthorized"))
return
}
competitionID := getPathParam(r, "competition_id")
result, err := h.resultsClient.GetUserCompetitionResults(r.Context(), competitionID, userID)
if err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to get user results"))
return
}
utils.RespondJSON(w, http.StatusOK, utils.UserResultProtoToHTTP(result))
}
func (h *ResultsHandler) RecalculateResults(w http.ResponseWriter, r *http.Request) {
competitionID := getPathParam(r, "competition_id")
if err := h.resultsClient.RecalculateResults(r.Context(), competitionID); err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to recalculate results"))
return
}
w.WriteHeader(http.StatusNoContent)
}
+133
View File
@@ -0,0 +1,133 @@
package handler
import (
"net/http"
"datarush/internal/gw/domain"
"datarush/internal/gw/grpc_client"
"datarush/internal/gw/utils"
reviewpb "datarush/pkg/api/review"
)
type ReviewHandler struct {
reviewClient *grpc_client.ReviewClient
}
func NewReviewHandler(reviewClient *grpc_client.ReviewClient) *ReviewHandler {
return &ReviewHandler{reviewClient: reviewClient}
}
func (h *ReviewHandler) ListSubmissionsForReview(w http.ResponseWriter, r *http.Request) {
token := getPathParam(r, "token")
pageSize := getQueryParamInt32(r, "page_size", 20)
pageToken := getQueryParamInt32(r, "page_token", 0)
statusStr := getQueryParam(r, "status")
req := &reviewpb.ListSubmissionsForReviewRequest{
Token: token,
PageSize: pageSize,
PageToken: pageToken,
}
if statusStr != "" {
status := stringToReviewStatus(statusStr)
req.Status = &status
}
resp, err := h.reviewClient.ListSubmissionsForReview(r.Context(), req)
if err != nil {
utils.RespondError(w, domain.NewUnauthorizedError("invalid review token"))
return
}
submissions := make([]domain.SubmissionSummaryResponse, len(resp.Submissions))
for i, sub := range resp.Submissions {
submissions[i] = *utils.SubmissionSummaryProtoToHTTP(sub)
}
utils.RespondJSON(w, http.StatusOK, &domain.ListSubmissionsForReviewResponse{
TotalCount: resp.TotalCount,
NextPageToken: resp.NextPageToken,
Submissions: submissions,
})
}
func (h *ReviewHandler) GetSubmissionForReview(w http.ResponseWriter, r *http.Request) {
token := getPathParam(r, "token")
submissionID := getPathParam(r, "submission_id")
submission, err := h.reviewClient.GetSubmissionForReview(r.Context(), token, submissionID)
if err != nil {
utils.RespondError(w, domain.NewNotFoundError("submission not found"))
return
}
utils.RespondJSON(w, http.StatusOK, utils.SubmissionForReviewProtoToHTTP(submission))
}
func (h *ReviewHandler) EvaluateSubmission(w http.ResponseWriter, r *http.Request) {
token := getPathParam(r, "token")
submissionID := getPathParam(r, "submission_id")
var reqBody domain.EvaluateSubmissionRequest
if err := utils.DecodeJSON(r, &reqBody); err != nil {
utils.RespondError(w, err)
return
}
marks := make([]*reviewpb.CriteriaMark, len(reqBody.Marks))
for i, m := range reqBody.Marks {
marks[i] = &reviewpb.CriteriaMark{
Slug: m.Slug,
Mark: m.Mark,
}
}
req := &reviewpb.EvaluateSubmissionRequest{
Token: token,
SubmissionId: submissionID,
EarnedPoints: reqBody.EarnedPoints,
ReviewerComment: reqBody.ReviewerComment,
Marks: marks,
}
resp, err := h.reviewClient.EvaluateSubmission(r.Context(), req)
if err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to evaluate submission"))
return
}
utils.RespondJSON(w, http.StatusOK, &domain.EvaluateSubmissionResponse{
SubmissionID: resp.SubmissionId,
FinalScore: resp.FinalScore,
NewStatus: utils.ReviewStatusToString(resp.NewStatus),
})
}
func (h *ReviewHandler) ReleaseSubmission(w http.ResponseWriter, r *http.Request) {
token := getPathParam(r, "token")
submissionID := getPathParam(r, "submission_id")
if err := h.reviewClient.ReleaseSubmission(r.Context(), token, submissionID); err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to release submission"))
return
}
w.WriteHeader(http.StatusNoContent)
}
func stringToReviewStatus(s string) reviewpb.ReviewStatus {
switch s {
case "pending":
return reviewpb.ReviewStatus_REVIEW_STATUS_PENDING
case "in_review":
return reviewpb.ReviewStatus_REVIEW_STATUS_IN_REVIEW
case "completed":
return reviewpb.ReviewStatus_REVIEW_STATUS_COMPLETED
case "rejected":
return reviewpb.ReviewStatus_REVIEW_STATUS_REJECTED
default:
return reviewpb.ReviewStatus_REVIEW_STATUS_UNSPECIFIED
}
}
+90
View File
@@ -0,0 +1,90 @@
package handler
import (
"net/http"
"datarush/internal/gw/domain"
"datarush/internal/gw/grpc_client"
"datarush/internal/gw/storage"
"datarush/internal/gw/utils"
)
type SubmissionHandler struct {
submissionClient *grpc_client.SubmissionClient
s3Storage *storage.S3Storage
}
func NewSubmissionHandler(
submissionClient *grpc_client.SubmissionClient,
s3Storage *storage.S3Storage,
) *SubmissionHandler {
return &SubmissionHandler{
submissionClient: submissionClient,
s3Storage: s3Storage,
}
}
func (h *SubmissionHandler) SubmitTask(w http.ResponseWriter, r *http.Request) {
userID, err := getUserIDFromContext(r.Context())
if err != nil {
utils.RespondError(w, domain.NewUnauthorizedError("unauthorized"))
return
}
competitionID := getPathParam(r, "competition_id")
taskID := getPathParam(r, "task_id")
if err := r.ParseMultipartForm(32 << 20); err != nil { // 32 MB max
utils.RespondError(w, domain.NewBadRequestError("failed to parse form"))
return
}
file, header, err := r.FormFile("content")
if err != nil {
utils.RespondError(w, domain.NewBadRequestError("missing or invalid file"))
return
}
defer file.Close()
fileURL, err := h.s3Storage.UploadFile(r.Context(), file, header)
if err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to upload file"))
return
}
submission, err := h.submissionClient.SubmitTask(r.Context(), userID, competitionID, taskID, fileURL)
if err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to submit task"))
return
}
utils.RespondJSON(w, http.StatusCreated, &domain.SubmitTaskResponse{
SubmissionID: submission.Id,
})
}
func (h *SubmissionHandler) GetSubmissionHistory(w http.ResponseWriter, r *http.Request) {
userID, err := getUserIDFromContext(r.Context())
if err != nil {
utils.RespondError(w, domain.NewUnauthorizedError("unauthorized"))
return
}
competitionID := getPathParam(r, "competition_id")
taskID := getPathParam(r, "task_id")
submissions, err := h.submissionClient.GetSubmissionsHistory(r.Context(), userID, competitionID, taskID)
if err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to get submission history"))
return
}
response := make([]domain.SubmissionResponse, len(submissions))
for i, sub := range submissions {
response[i] = *utils.SubmissionProtoToHTTP(sub)
}
utils.RespondJSON(w, http.StatusOK, &domain.SubmissionHistoryResponse{
Submissions: response,
})
}
+99
View File
@@ -0,0 +1,99 @@
package handler
import (
"net/http"
"datarush/internal/gw/domain"
"datarush/internal/gw/grpc_client"
"datarush/internal/gw/utils"
)
type TaskHandler struct {
taskClient *grpc_client.TaskClient
}
func NewTaskHandler(taskClient *grpc_client.TaskClient) *TaskHandler {
return &TaskHandler{taskClient: taskClient}
}
func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
competitionID := getPathParam(r, "competition_id")
var req domain.TaskRequest
if err := utils.DecodeJSON(r, &req); err != nil {
utils.RespondError(w, err)
return
}
req.CompetitionID = competitionID
task := utils.TaskHTTPToProto(&req)
resp, err := h.taskClient.CreateTask(r.Context(), task)
if err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to create task"))
return
}
utils.RespondJSON(w, http.StatusCreated, utils.TaskProtoToHTTP(resp))
}
func (h *TaskHandler) GetTask(w http.ResponseWriter, r *http.Request) {
taskID := getPathParam(r, "task_id")
task, err := h.taskClient.GetTask(r.Context(), taskID)
if err != nil {
utils.RespondError(w, domain.NewNotFoundError("task not found"))
return
}
utils.RespondJSON(w, http.StatusOK, utils.TaskProtoToHTTP(task))
}
func (h *TaskHandler) UpdateTask(w http.ResponseWriter, r *http.Request) {
competitionID := getPathParam(r, "competition_id")
taskID := getPathParam(r, "task_id")
var req domain.TaskRequest
if err := utils.DecodeJSON(r, &req); err != nil {
utils.RespondError(w, err)
return
}
req.ID = taskID
req.CompetitionID = competitionID
task := utils.TaskHTTPToProto(&req)
resp, err := h.taskClient.EditTask(r.Context(), task)
if err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to update task"))
return
}
utils.RespondJSON(w, http.StatusOK, utils.TaskProtoToHTTP(resp))
}
func (h *TaskHandler) DeleteTask(w http.ResponseWriter, r *http.Request) {
taskID := getPathParam(r, "task_id")
if err := h.taskClient.DeleteTask(r.Context(), taskID); err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to delete task"))
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
competitionID := getPathParam(r, "competition_id")
tasks, err := h.taskClient.ListCompetitionTasks(r.Context(), competitionID)
if err != nil {
utils.RespondError(w, domain.NewInternalServerError("failed to list tasks"))
return
}
response := make([]domain.TaskResponse, len(tasks))
for i, task := range tasks {
response[i] = *utils.TaskProtoToHTTP(task)
}
utils.RespondJSON(w, http.StatusOK, &domain.ListTasksResponse{Tasks: response})
}
+69
View File
@@ -0,0 +1,69 @@
package middleware
import (
"context"
"net/http"
"strings"
"datarush/internal/gw/domain"
"datarush/internal/gw/grpc_client"
)
type contextKey string
const (
UserIDKey contextKey = "user_id"
)
type AuthMiddleware struct {
authClient *grpc_client.AuthClient
}
func NewAuthMiddleware(authClient *grpc_client.AuthClient) *AuthMiddleware {
return &AuthMiddleware{
authClient: authClient,
}
}
func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
respondWithError(w, domain.NewUnauthorizedError("missing authorization header"))
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
respondWithError(w, domain.NewUnauthorizedError("invalid authorization header format"))
return
}
token := parts[1]
userID, err := m.authClient.ValidateToken(r.Context(), token)
if err != nil {
respondWithError(w, domain.NewUnauthorizedError("invalid token"))
return
}
ctx := context.WithValue(r.Context(), UserIDKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func GetUserIDFromContext(ctx context.Context) (string, error) {
userID, ok := ctx.Value(UserIDKey).(string)
if !ok || userID == "" {
return "", domain.ErrUnauthorized
}
return userID, nil
}
func respondWithError(w http.ResponseWriter, err *domain.AppError) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(err.StatusCode)
response := domain.NewErrorResponse(err.Err, err.Message)
w.Write([]byte(`{"error":"` + response.Error + `","message":"` + response.Message + `"}`))
}
+21
View File
@@ -0,0 +1,21 @@
package middleware
import (
"net/http"
)
func CORSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Max-Age", "3600")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
+47
View File
@@ -0,0 +1,47 @@
package middleware
import (
"log"
"net/http"
"time"
)
type responseWriter struct {
http.ResponseWriter
statusCode int
written int64
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
n, err := rw.ResponseWriter.Write(b)
rw.written += int64(n)
return n, err
}
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &responseWriter{
ResponseWriter: w,
statusCode: http.StatusOK,
}
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
log.Printf(
"%s %s %d %s %s",
r.Method,
r.RequestURI,
wrapped.statusCode,
duration,
r.RemoteAddr,
)
})
}
+113
View File
@@ -0,0 +1,113 @@
package router
import (
"net/http"
"datarush/internal/gw/handler"
"datarush/internal/gw/middleware"
"github.com/gorilla/mux"
)
type Router struct {
authHandler *handler.AuthHandler
competitionHandler *handler.CompetitionHandler
taskHandler *handler.TaskHandler
submissionHandler *handler.SubmissionHandler
resultsHandler *handler.ResultsHandler
reviewHandler *handler.ReviewHandler
achievementsHandler *handler.AchievementsHandler
pingHandler *handler.PingHandler
authMiddleware *middleware.AuthMiddleware
}
func NewRouter(
authHandler *handler.AuthHandler,
competitionHandler *handler.CompetitionHandler,
taskHandler *handler.TaskHandler,
submissionHandler *handler.SubmissionHandler,
resultsHandler *handler.ResultsHandler,
reviewHandler *handler.ReviewHandler,
achievementsHandler *handler.AchievementsHandler,
pingHandler *handler.PingHandler,
authMiddleware *middleware.AuthMiddleware,
) *Router {
return &Router{
authHandler: authHandler,
competitionHandler: competitionHandler,
taskHandler: taskHandler,
submissionHandler: submissionHandler,
resultsHandler: resultsHandler,
reviewHandler: reviewHandler,
achievementsHandler: achievementsHandler,
pingHandler: pingHandler,
authMiddleware: authMiddleware,
}
}
func (rt *Router) Setup() http.Handler {
r := mux.NewRouter()
r.Use(middleware.LoggingMiddleware)
r.Use(middleware.CORSMiddleware)
api := r.PathPrefix("/api/v1").Subrouter()
api.HandleFunc("/ping", rt.pingHandler.Ping).Methods(http.MethodGet)
api.HandleFunc("/sign-up", rt.authHandler.SignUp).Methods(http.MethodPost)
api.HandleFunc("/sign-in", rt.authHandler.SignIn).Methods(http.MethodPost)
protected := api.PathPrefix("").Subrouter()
protected.Use(rt.authMiddleware.Authenticate)
protected.HandleFunc("/me", rt.authHandler.GetMe).Methods(http.MethodGet)
protected.HandleFunc("/competitions", rt.competitionHandler.CreateCompetition).Methods(http.MethodPost)
protected.HandleFunc("/competitions", rt.competitionHandler.ListCompetitions).Methods(http.MethodGet)
protected.HandleFunc("/competitions/{competition_id}", rt.competitionHandler.GetCompetition).Methods(http.MethodGet)
protected.HandleFunc("/competitions/{competition_id}", rt.competitionHandler.UpdateCompetition).
Methods(http.MethodPut)
protected.HandleFunc("/competitions/{competition_id}", rt.competitionHandler.DeleteCompetition).
Methods(http.MethodDelete)
protected.HandleFunc("/competitions/{competition_id}/state", rt.competitionHandler.ChangeCompetitionState).
Methods(http.MethodPatch)
protected.HandleFunc("/competitions/{competition_id}/join", rt.competitionHandler.JoinCompetition).
Methods(http.MethodPost)
protected.HandleFunc("/competitions/{competition_id}/tasks", rt.taskHandler.CreateTask).Methods(http.MethodPost)
protected.HandleFunc("/competitions/{competition_id}/tasks", rt.taskHandler.ListTasks).Methods(http.MethodGet)
protected.HandleFunc("/competitions/{competition_id}/tasks/{task_id}", rt.taskHandler.GetTask).
Methods(http.MethodGet)
protected.HandleFunc("/competitions/{competition_id}/tasks/{task_id}", rt.taskHandler.UpdateTask).
Methods(http.MethodPut)
protected.HandleFunc("/competitions/{competition_id}/tasks/{task_id}", rt.taskHandler.DeleteTask).
Methods(http.MethodDelete)
protected.HandleFunc("/competitions/{competition_id}/tasks/{task_id}/submit", rt.submissionHandler.SubmitTask).
Methods(http.MethodPost)
protected.HandleFunc("/competitions/{competition_id}/tasks/{task_id}/history", rt.submissionHandler.GetSubmissionHistory).
Methods(http.MethodGet)
protected.HandleFunc("/competitions/{competition_id}/results", rt.resultsHandler.GetCompetitionResults).
Methods(http.MethodGet)
protected.HandleFunc("/competitions/{competition_id}/results/me", rt.resultsHandler.GetMyResults).
Methods(http.MethodGet)
protected.HandleFunc("/competitions/{competition_id}/results/recalculate", rt.resultsHandler.RecalculateResults).
Methods(http.MethodPost)
protected.HandleFunc("/achievements", rt.achievementsHandler.ListAchievements).Methods(http.MethodGet)
protected.HandleFunc("/achievements/{achievement_id}", rt.achievementsHandler.GetAchievement).
Methods(http.MethodGet)
protected.HandleFunc("/users/{user_id}/achievements", rt.achievementsHandler.GetUserAchievements).
Methods(http.MethodGet)
api.HandleFunc("/review/{token}/submissions", rt.reviewHandler.ListSubmissionsForReview).Methods(http.MethodGet)
api.HandleFunc("/review/{token}/submissions/{submission_id}", rt.reviewHandler.GetSubmissionForReview).
Methods(http.MethodGet)
api.HandleFunc("/review/{token}/submissions/{submission_id}/evaluate", rt.reviewHandler.EvaluateSubmission).
Methods(http.MethodPost)
api.HandleFunc("/review/{token}/submissions/{submission_id}/release", rt.reviewHandler.ReleaseSubmission).
Methods(http.MethodPost)
return r
}
+187
View File
@@ -0,0 +1,187 @@
package storage
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/url"
"path/filepath"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/google/uuid"
)
type S3Storage struct {
client *s3.Client
bucket string
region string
endpoint string
}
type S3Config struct {
AccessKeyID string
SecretAccessKey string
Region string
Bucket string
Endpoint string
}
func NewS3Storage(cfg S3Config) (*S3Storage, error) {
var loadOpts []func(*config.LoadOptions) error
if cfg.Region != "" {
loadOpts = append(loadOpts, config.WithRegion(cfg.Region))
}
if cfg.Endpoint != "" {
customResolver := aws.EndpointResolverWithOptionsFunc(
func(service, region string, options ...interface{}) (aws.Endpoint, error) {
return aws.Endpoint{
URL: cfg.Endpoint,
SigningRegion: cfg.Region,
HostnameImmutable: true,
}, nil
},
)
loadOpts = append(loadOpts, config.WithEndpointResolverWithOptions(customResolver))
}
if cfg.AccessKeyID != "" || cfg.SecretAccessKey != "" {
loadOpts = append(loadOpts, config.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""),
))
}
awsCfg, err := config.LoadDefaultConfig(context.TODO(), loadOpts...)
if err != nil {
return nil, fmt.Errorf("failed to load AWS config: %w", err)
}
var client *s3.Client
if cfg.Endpoint != "" {
client = s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.UsePathStyle = true
})
} else {
client = s3.NewFromConfig(awsCfg)
}
return &S3Storage{
client: client,
bucket: cfg.Bucket,
region: cfg.Region,
endpoint: cfg.Endpoint,
}, nil
}
func (s *S3Storage) UploadFile(ctx context.Context, file multipart.File, header *multipart.FileHeader) (string, error) {
fileBytes, err := io.ReadAll(file)
if err != nil {
return "", fmt.Errorf("failed to read file: %w", err)
}
ext := filepath.Ext(header.Filename)
key := fmt.Sprintf("submissions/%s/%s%s",
time.Now().Format("2006/01/02"),
uuid.New().String(),
ext,
)
_, err = s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: bytes.NewReader(fileBytes),
ContentType: aws.String(header.Header.Get("Content-Type")),
})
if err != nil {
return "", fmt.Errorf("failed to upload file to S3: %w", err)
}
return s.buildObjectURL(key), nil
}
func (s *S3Storage) UploadFileFromBytes(
ctx context.Context,
content []byte,
filename string,
contentType string,
) (string, error) {
ext := filepath.Ext(filename)
key := fmt.Sprintf("submissions/%s/%s%s",
time.Now().Format("2006/01/02"),
uuid.New().String(),
ext,
)
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: bytes.NewReader(content),
ContentType: aws.String(contentType),
})
if err != nil {
return "", fmt.Errorf("failed to upload file to S3: %w", err)
}
return s.buildObjectURL(key), nil
}
func (s *S3Storage) DeleteFile(ctx context.Context, fileURL string) error {
key := s.extractKeyFromURL(fileURL)
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
return fmt.Errorf("failed to delete file from S3: %w", err)
}
return nil
}
func (s *S3Storage) buildObjectURL(key string) string {
if s.endpoint != "" {
ep := strings.TrimRight(s.endpoint, "/")
return fmt.Sprintf("%s/%s/%s", ep, s.bucket, key)
}
return fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", s.bucket, s.region, key)
}
func (s *S3Storage) extractKeyFromURL(urlStr string) string {
u, err := url.Parse(urlStr)
if err != nil {
return urlStr
}
path := strings.TrimPrefix(u.Path, "/")
if strings.HasPrefix(u.Host, s.bucket+".") {
return path
}
if strings.HasPrefix(path, s.bucket+"/") {
return strings.TrimPrefix(path, s.bucket+"/")
}
if s.endpoint != "" {
ep := strings.TrimPrefix(strings.TrimRight(s.endpoint, "/"), "http://")
ep = strings.TrimPrefix(ep, "https://")
if strings.HasPrefix(u.Host, ep) {
if strings.HasPrefix(path, s.bucket+"/") {
return strings.TrimPrefix(path, s.bucket+"/")
}
return path
}
}
return path
}
+291
View File
@@ -0,0 +1,291 @@
package utils
import (
"datarush/internal/gw/domain"
achievepb "datarush/pkg/api/achievements"
comppb "datarush/pkg/api/competition"
resultspb "datarush/pkg/api/results"
reviewpb "datarush/pkg/api/review"
subpb "datarush/pkg/api/submission"
taskpb "datarush/pkg/api/task"
userpb "datarush/pkg/api/user"
"google.golang.org/protobuf/types/known/timestamppb"
)
func UserProtoToHTTP(u *userpb.User) *domain.UserResponse {
return &domain.UserResponse{
ID: u.Id,
Username: u.Username,
Email: u.Email,
FullName: u.FullName,
AvatarURL: u.AvatarUrl,
}
}
func CompetitionProtoToHTTP(c *comppb.Competition) *domain.CompetitionResponse {
return &domain.CompetitionResponse{
ID: c.Id,
State: CompetitionStateToString(c.State),
Title: c.Title,
Description: c.Description,
ImageURL: c.ImageUrl,
StartTime: c.StartTime.AsTime(),
EndTime: c.EndTime.AsTime(),
Type: CompetitionTypeToString(c.Type),
ParticipationType: ParticipationTypeToString(c.ParticipationType),
CreatedAt: c.CreatedAt.AsTime(),
UpdatedAt: c.UpdatedAt.AsTime(),
}
}
func TaskProtoToHTTP(t *taskpb.Task) *domain.TaskResponse {
return &domain.TaskResponse{
ID: t.Id,
CompetitionID: t.CompetitionId,
Title: t.Title,
Description: t.Description,
InCompetitionPosition: t.InCompetitionPosition,
MaxPoints: t.MaxPoints,
MaxAttempts: t.MaxAttempts,
Type: TaskTypeToString(t.Type),
CreatedAt: t.CreatedAt.AsTime(),
UpdatedAt: t.UpdatedAt.AsTime(),
}
}
func SubmissionProtoToHTTP(s *subpb.Submission) *domain.SubmissionResponse {
return &domain.SubmissionResponse{
ID: s.Id,
UserID: s.UserId,
CompetitionID: s.CompetitionId,
TaskID: s.TaskId,
Status: SubmissionStatusToString(s.Status),
EarnedPoints: s.EarnedPoints,
SubmittedAt: s.SubmittedAt.AsTime(),
CheckedAt: s.CheckedAt.AsTime(),
FileURL: s.FileUrl,
}
}
func UserResultProtoToHTTP(r *resultspb.UserResult) *domain.UserResultResponse {
taskStatuses := make([]domain.TaskStatusResponse, len(r.TaskStatuses))
for i, ts := range r.TaskStatuses {
taskStatuses[i] = domain.TaskStatusResponse{
TaskID: ts.TaskId,
TaskTitle: ts.TaskTitle,
EarnedPoints: ts.EarnedPoints,
MaxPoints: ts.MaxPoints,
Position: ts.Position,
}
}
return &domain.UserResultResponse{
UserID: r.UserId,
Username: r.Username,
TotalScore: r.TotalScore,
OverallPosition: r.OverallPosition,
TaskStatuses: taskStatuses,
}
}
func SubmissionSummaryProtoToHTTP(s *reviewpb.SubmissionSummary) *domain.SubmissionSummaryResponse {
return &domain.SubmissionSummaryResponse{
ID: s.Id,
CompetitionID: s.CompetitionId,
TaskID: s.TaskId,
CompetitionTitle: s.CompetitionTitle,
TaskTitle: s.TaskTitle,
SubmittedAt: s.SubmittedAt.AsTime(),
ReviewStatus: ReviewStatusToString(s.ReviewStatus),
}
}
func SubmissionForReviewProtoToHTTP(s *reviewpb.SubmissionForReview) *domain.SubmissionForReviewResponse {
resp := &domain.SubmissionForReviewResponse{
ID: s.Id,
CompetitionID: s.CompetitionId,
TaskID: s.TaskId,
Content: s.Content,
Description: s.Description,
ReviewStatus: ReviewStatusToString(s.ReviewStatus),
SubmittedAt: s.SubmittedAt.AsTime(),
}
if s.CheckedAt != nil {
t := s.CheckedAt.AsTime()
resp.CheckedAt = &t
}
return resp
}
func AchievementProtoToHTTP(a *achievepb.Achievement) *domain.AchievementResponse {
return &domain.AchievementResponse{
ID: a.Id,
Name: a.Name,
Description: a.Description,
IconURL: a.IconUrl,
}
}
func CompetitionHTTPToProto(req *domain.CompetitionRequest) *comppb.Competition {
return &comppb.Competition{
Id: req.ID,
Title: req.Title,
Description: req.Description,
ImageUrl: req.ImageURL,
StartTime: timestamppb.New(req.StartTime),
EndTime: timestamppb.New(req.EndTime),
Type: StringToCompetitionType(req.Type),
ParticipationType: StringToParticipationType(req.ParticipationType),
}
}
func TaskHTTPToProto(req *domain.TaskRequest) *taskpb.Task {
return &taskpb.Task{
Id: req.ID,
CompetitionId: req.CompetitionID,
Title: req.Title,
Description: req.Description,
InCompetitionPosition: req.InCompetitionPosition,
MaxPoints: req.MaxPoints,
MaxAttempts: req.MaxAttempts,
Type: StringToTaskType(req.Type),
}
}
func CompetitionStateToString(state comppb.CompetitionState) string {
switch state {
case comppb.CompetitionState_COMPETITION_STATE_DRAFT:
return "draft"
case comppb.CompetitionState_COMPETITION_STATE_NOT_STARTED:
return "not_started"
case comppb.CompetitionState_COMPETITION_STATE_STARTED:
return "started"
case comppb.CompetitionState_COMPETITION_STATE_FINISHED:
return "finished"
case comppb.CompetitionState_COMPETITION_STATE_ARCHIVED:
return "archived"
default:
return "unspecified"
}
}
func StringToCompetitionState(state string) comppb.CompetitionState {
switch state {
case "draft":
return comppb.CompetitionState_COMPETITION_STATE_DRAFT
case "not_started":
return comppb.CompetitionState_COMPETITION_STATE_NOT_STARTED
case "started":
return comppb.CompetitionState_COMPETITION_STATE_STARTED
case "finished":
return comppb.CompetitionState_COMPETITION_STATE_FINISHED
case "archived":
return comppb.CompetitionState_COMPETITION_STATE_ARCHIVED
default:
return comppb.CompetitionState_COMPETITION_STATE_UNSPECIFIED
}
}
func CompetitionTypeToString(t comppb.CompetitionType) string {
switch t {
case comppb.CompetitionType_COMPETITION_TYPE_EDUCATIVE:
return "educative"
case comppb.CompetitionType_COMPETITION_TYPE_COMPETETIVE:
return "competitive"
default:
return "unspecified"
}
}
func StringToCompetitionType(t string) comppb.CompetitionType {
switch t {
case "educative":
return comppb.CompetitionType_COMPETITION_TYPE_EDUCATIVE
case "competitive":
return comppb.CompetitionType_COMPETITION_TYPE_COMPETETIVE
default:
return comppb.CompetitionType_COMPETITION_TYPE_UNSPECIFIED
}
}
func ParticipationTypeToString(t comppb.ParticipationType) string {
switch t {
case comppb.ParticipationType_PARTICIPATION_TYPE_INDIVIDUAL:
return "individual"
case comppb.ParticipationType_PARTICIPATION_TYPE_TEAM:
return "team"
default:
return "unspecified"
}
}
func StringToParticipationType(t string) comppb.ParticipationType {
switch t {
case "individual":
return comppb.ParticipationType_PARTICIPATION_TYPE_INDIVIDUAL
case "team":
return comppb.ParticipationType_PARTICIPATION_TYPE_TEAM
default:
return comppb.ParticipationType_PARTICIPATION_TYPE_UNSPECIFIED
}
}
func TaskTypeToString(t taskpb.TaskType) string {
switch t {
case taskpb.TaskType_TASK_TYPE_INPUT:
return "input"
case taskpb.TaskType_TASK_TYPE_CHECKER:
return "checker"
case taskpb.TaskType_TASK_TYPE_REVIEW:
return "review"
default:
return "unspecified"
}
}
func StringToTaskType(t string) taskpb.TaskType {
switch t {
case "input":
return taskpb.TaskType_TASK_TYPE_INPUT
case "checker":
return taskpb.TaskType_TASK_TYPE_CHECKER
case "review":
return taskpb.TaskType_TASK_TYPE_REVIEW
default:
return taskpb.TaskType_TASK_TYPE_UNSPECIFIED
}
}
func SubmissionStatusToString(s subpb.SubmissionStatus) string {
switch s {
case subpb.SubmissionStatus_SUBMISSION_STATUS_PENDING:
return "pending"
case subpb.SubmissionStatus_SUBMISSION_STATUS_SENT_FOR_CHECK:
return "sent"
case subpb.SubmissionStatus_SUBMISSION_STATUS_CHECKING:
return "checking"
case subpb.SubmissionStatus_SUBMISSION_STATUS_CHECKED:
return "checked"
case subpb.SubmissionStatus_SUBMISSION_STATUS_FAILED:
return "failed"
default:
return "unspecified"
}
}
func ReviewStatusToString(s reviewpb.ReviewStatus) string {
switch s {
case reviewpb.ReviewStatus_REVIEW_STATUS_PENDING:
return "pending"
case reviewpb.ReviewStatus_REVIEW_STATUS_IN_REVIEW:
return "in_review"
case reviewpb.ReviewStatus_REVIEW_STATUS_COMPLETED:
return "completed"
case reviewpb.ReviewStatus_REVIEW_STATUS_REJECTED:
return "rejected"
default:
return "unspecified"
}
}
+35
View File
@@ -0,0 +1,35 @@
package utils
import (
"encoding/json"
"net/http"
"datarush/internal/gw/domain"
)
func RespondJSON(w http.ResponseWriter, statusCode int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
if data != nil {
if err := json.NewEncoder(w).Encode(data); err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}
}
func RespondError(w http.ResponseWriter, err error) {
if appErr, ok := err.(*domain.AppError); ok {
RespondJSON(w, appErr.StatusCode, domain.NewErrorResponse(appErr.Err, appErr.Message))
return
}
RespondJSON(w, http.StatusInternalServerError, domain.NewErrorResponse(err, "internal server error"))
}
func DecodeJSON(r *http.Request, v interface{}) error {
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
return domain.NewBadRequestError("invalid JSON body")
}
return nil
}
-9
View File
@@ -1,9 +0,0 @@
package domain
import (
"errors"
)
var (
ErrInvalidID = errors.New("invalid uuid")
)
-42
View File
@@ -1,42 +0,0 @@
package domain
import (
"errors"
"fmt"
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
)
var (
ErrOrderAlreadyExist = errors.New("order already exist")
ErrOrderNotFound = errors.New("order not found")
ErrInvalidOrderData = errors.New("invalid order data")
)
type Order struct {
ID uuid.UUID `db:"id" json:"id" validate:"required"`
Item string `db:"item" json:"item" validate:"required"`
Quantity int32 `db:"quantity" json:"quantity" validate:"required,gt=0"`
}
func NewOrder(id uuid.UUID, item string, quantity int32) (*Order, error) {
order := &Order{
ID: id,
Item: item,
Quantity: quantity,
}
err := order.Validate()
if err != nil {
return nil, err
}
return order, nil
}
func (o *Order) Validate() error {
validate := validator.New()
return fmt.Errorf("%w: %w", ErrInvalidOrderData, validate.Struct(o))
}
-51
View File
@@ -1,51 +0,0 @@
package gateway
import (
"context"
"fmt"
"log"
"net"
"net/http"
"time"
authPb "datarush/pkg/api/auth"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
const (
httpReadTimeout = 10 * time.Second
httpWriteTimeout = 10 * time.Second
httpIdleTimeout = 60 * time.Second
)
func StartGateway(grpcPort, httpPort int, authGrpcAddr string) error {
ctx := context.Background()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
gwmux := runtime.NewServeMux()
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
// Register auth service
if err := authPb.RegisterAuthServiceHandlerFromEndpoint(ctx, gwmux, authGrpcAddr, opts); err != nil {
return fmt.Errorf("failed to register auth service: %w", err)
}
srv := &http.Server{
Addr: fmt.Sprintf(":%d", httpPort),
Handler: gwmux,
ReadTimeout: httpReadTimeout,
WriteTimeout: httpWriteTimeout,
IdleTimeout: httpIdleTimeout,
}
log.Printf("starting gRPC-Gateway on port %d", httpPort)
return srv.ListenAndServe()
}
func GetGRPCListener(port int) (net.Listener, error) {
return net.Listen("tcp", fmt.Sprintf(":%d", port))
}
-26
View File
@@ -1,26 +0,0 @@
package handler
import (
"errors"
"log"
"datarush/internal/lms/domain"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func mapError(err error) error {
if errors.Is(err, domain.ErrOrderNotFound) {
return status.Error(codes.NotFound, err.Error())
}
if errors.Is(err, domain.ErrOrderAlreadyExist) {
return status.Error(codes.AlreadyExists, err.Error())
}
if errors.Is(err, domain.ErrInvalidOrderData) || errors.Is(err, domain.ErrInvalidID) {
return status.Error(codes.InvalidArgument, err.Error())
}
log.Printf("internal server error: %v", err)
return status.Error(codes.Internal, "internal server error")
}
-63
View File
@@ -1,63 +0,0 @@
package http
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/jmoiron/sqlx"
"github.com/redis/go-redis/v9"
)
type HealthHandler struct {
DB *sqlx.DB
Redis *redis.Client
}
func NewHealthHandler(db *sqlx.DB, redisDB *redis.Client) *HealthHandler {
return &HealthHandler{
DB: db,
Redis: redisDB,
}
}
type HealthResponse struct {
Status string `json:"status"`
Details map[string]string `json:"details"`
}
func (h *HealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
details := map[string]string{}
if err := h.DB.PingContext(ctx); err != nil {
details["postgres"] = "unhealthy: " + err.Error()
} else {
details["postgres"] = "ok"
}
if err := h.Redis.Ping(ctx).Err(); err != nil {
details["redis"] = "unhealthy: " + err.Error()
} else {
details["redis"] = "ok"
}
status := "ok"
for _, v := range details {
if v != "ok" {
status = "unhealthy"
break
}
}
resp := HealthResponse{Status: status, Details: details}
w.Header().Set("Content-Type", "application/json")
if status != "ok" {
w.WriteHeader(http.StatusServiceUnavailable)
}
_ = json.NewEncoder(w).Encode(resp)
}
-73
View File
@@ -1,73 +0,0 @@
package interceptor
import (
"context"
"log"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/status"
)
type LoggerInterceptor struct{}
func NewLoggerInterceptor() *LoggerInterceptor {
return &LoggerInterceptor{}
}
func (i *LoggerInterceptor) Unary() grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
start := time.Now()
log.Printf("gRPC method %s called", info.FullMethod)
resp, err := handler(ctx, req)
duration := time.Since(start)
if err != nil {
if st, ok := status.FromError(err); ok {
log.Printf("error: %s, code: %s, duration: %v",
st.Message(), st.Code(), duration)
} else {
log.Printf("error: %v, duration: %v", err, duration)
}
} else {
log.Printf("method %s completed in %v", info.FullMethod, duration)
}
return resp, err
}
}
func (i *LoggerInterceptor) Stream() grpc.StreamServerInterceptor {
return func(
srv any,
stream grpc.ServerStream,
info *grpc.StreamServerInfo,
handler grpc.StreamHandler,
) error {
start := time.Now()
log.Printf("gRPC stream method %s started", info.FullMethod)
err := handler(srv, stream)
duration := time.Since(start)
if err != nil {
log.Printf("stream method %s failed: %v, duration: %v",
info.FullMethod, err, duration)
} else {
log.Printf("stream method %s completed in %v",
info.FullMethod, duration)
}
return err
}
}
-102
View File
@@ -1,102 +0,0 @@
package inmemory
import (
"context"
"sync"
"datarush/internal/lms/domain"
"github.com/google/uuid"
)
type OrderRepository struct {
mu sync.RWMutex
orders map[string]*domain.Order
}
func NewOrderRepository() *OrderRepository {
return &OrderRepository{
orders: make(map[string]*domain.Order),
}
}
func (r *OrderRepository) Create(ctx context.Context, order *domain.Order) error {
if err := ctx.Err(); err != nil {
return err
}
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.orders[order.ID.String()]; ok {
return domain.ErrOrderAlreadyExist
}
r.orders[order.ID.String()] = order
return nil
}
func (r *OrderRepository) Get(ctx context.Context, id uuid.UUID) (*domain.Order, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
r.mu.RLock()
defer r.mu.RUnlock()
order, ok := r.orders[id.String()]
if !ok {
return nil, domain.ErrOrderNotFound
}
return order, nil
}
func (r *OrderRepository) Update(ctx context.Context, order *domain.Order) error {
if err := ctx.Err(); err != nil {
return err
}
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.orders[order.ID.String()]; !ok {
return domain.ErrOrderNotFound
}
r.orders[order.ID.String()] = order
return nil
}
func (r *OrderRepository) Delete(ctx context.Context, id uuid.UUID) error {
if err := ctx.Err(); err != nil {
return err
}
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.orders[id.String()]; !ok {
return domain.ErrOrderNotFound
}
delete(r.orders, id.String())
return nil
}
func (r *OrderRepository) List(ctx context.Context) ([]*domain.Order, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
r.mu.RLock()
defer r.mu.RUnlock()
orders := make([]*domain.Order, 0, len(r.orders))
for _, order := range r.orders {
orders = append(orders, order)
}
return orders, nil
}
-17
View File
@@ -1,17 +0,0 @@
package repository
import (
"context"
"datarush/internal/lms/domain"
"github.com/google/uuid"
)
type OrderRepository interface {
Create(ctx context.Context, order *domain.Order) error
Get(ctx context.Context, id uuid.UUID) (*domain.Order, error)
Update(ctx context.Context, order *domain.Order) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context) ([]*domain.Order, error)
}
-243
View File
@@ -1,243 +0,0 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"time"
"datarush/internal/lms/domain"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/redis/go-redis/v9"
)
const (
orderCachePrefix = "order:"
cacheTTL = 5 * time.Minute
)
type OrderRepository struct {
db *sqlx.DB
redisClient *redis.Client
cacheEnable bool
}
type Config struct {
CacheEnable bool
}
func NewOrderRepository(db *sqlx.DB, redisClient *redis.Client, config *Config) *OrderRepository {
if config == nil {
config = &Config{
CacheEnable: true,
}
}
return &OrderRepository{
db: db,
redisClient: redisClient,
cacheEnable: config.CacheEnable,
}
}
func (r *OrderRepository) cacheKey(id string) string {
return orderCachePrefix + id
}
func (r *OrderRepository) Create(ctx context.Context, order *domain.Order) error {
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
query := `
insert into orders (id, item, quantity)
values (:id, :item, :quantity)
`
if _, err := tx.NamedExecContext(ctx, query, order); err != nil {
return fmt.Errorf("create order: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
if r.cacheEnable {
if err := r.setCacheWithRetry(ctx, order); err != nil {
log.Printf("warn: cache set error for order %s: %v", order.ID, err)
}
}
return nil
}
func (r *OrderRepository) Get(ctx context.Context, id uuid.UUID) (*domain.Order, error) {
if r.cacheEnable {
if order, err := r.getFromCache(ctx, id.String()); err == nil {
return order, nil
} else if !errors.Is(err, redis.Nil) {
log.Printf("warn: cache get error for order %s: %v", id, err)
}
}
const query = `
select id, item, quantity
from orders
where id = $1
`
var order domain.Order
if err := r.db.GetContext(ctx, &order, query, id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrOrderNotFound
}
return nil, fmt.Errorf("get order by id: %w", err)
}
if r.cacheEnable {
if err := r.setCacheWithRetry(ctx, &order); err != nil {
log.Printf("warn: cache set error for order %s: %v", id, err)
}
}
return &order, nil
}
func (r *OrderRepository) Update(ctx context.Context, order *domain.Order) error {
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
query := `
update orders
set item = :item, quantity = :quantity
where id = :id
`
result, err := tx.NamedExecContext(ctx, query, order)
if err != nil {
return fmt.Errorf("update order: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("get rows affected: %w", err)
}
if rowsAffected == 0 {
return domain.ErrOrderNotFound
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
if r.cacheEnable {
if err := r.setCacheWithRetry(ctx, order); err != nil {
log.Printf("warn: cache set error for order %s: %v", order.ID, err)
}
}
return nil
}
func (r *OrderRepository) Delete(ctx context.Context, id uuid.UUID) error {
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
const query = `
delete from orders
where id = $1
`
result, err := tx.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("delete order: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("get rows affected: %w", err)
}
if rowsAffected == 0 {
return domain.ErrOrderNotFound
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
r.invalidateCache(ctx, id.String())
return nil
}
func (r *OrderRepository) List(ctx context.Context) ([]*domain.Order, error) {
const query = `
select id, item, quantity
from orders
order by id
`
var orders []*domain.Order
if err := r.db.SelectContext(ctx, &orders, query); err != nil {
return nil, fmt.Errorf("list orders: %w", err)
}
return orders, nil
}
func (r *OrderRepository) getFromCache(ctx context.Context, id string) (*domain.Order, error) {
data, err := r.redisClient.Get(ctx, r.cacheKey(id)).Bytes()
if err != nil {
return nil, err
}
var order domain.Order
if err := json.Unmarshal(data, &order); err != nil {
r.redisClient.Del(ctx, r.cacheKey(id))
return nil, err
}
return &order, nil
}
func (r *OrderRepository) setCacheWithRetry(ctx context.Context, order *domain.Order) error {
data, err := json.Marshal(order)
if err != nil {
return err
}
key := r.cacheKey(order.ID.String())
err = r.redisClient.Set(ctx, key, data, cacheTTL).Err()
return err
}
func (r *OrderRepository) invalidateCache(_ context.Context, id string) {
if !r.cacheEnable {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), r.redisClient.Options().ReadTimeout)
defer cancel()
if err := r.redisClient.Del(ctx, r.cacheKey(id)).Err(); err != nil {
log.Printf("warn: cache invalidation failed for order %s: %v", id, err)
}
}()
}
-182
View File
@@ -1,182 +0,0 @@
package server
import (
"context"
"fmt"
"log"
"net"
"net/http"
"time"
"datarush/internal/lms/config"
"datarush/internal/lms/interceptor"
httpHandlers "datarush/internal/lms/handler/http"
authPb "datarush/pkg/api/auth"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq" // postgres driver
"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
redisRetryCount = 2
redisMinRetryBackoff = 50 * time.Millisecond
redisMaxRetryBackoff = 200 * time.Millisecond
redisDialTimeout = 1 * time.Second
redisDialerRetries = 3
redisTimeout = 2 * time.Second
)
type Server struct {
grpcServer *grpc.Server
config *config.Config
db *sqlx.DB
redisDB *redis.Client
}
func New(cfg *config.Config) *Server {
loggerInterceptor := interceptor.NewLoggerInterceptor()
grpcServer := grpc.NewServer(
grpc.UnaryInterceptor(loggerInterceptor.Unary()),
grpc.StreamInterceptor(loggerInterceptor.Stream()),
)
return &Server{
grpcServer: grpcServer,
config: cfg,
}
}
func runHTTPHandler(s *Server, grpcServerEndpoint *string) error {
ctx := context.Background()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
gwmux := runtime.NewServeMux()
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
// Register auth service
if err := registerAuthService(ctx, gwmux, s.config.AuthGRPCAddr, opts); err != nil {
log.Printf("failed to register auth service: %v", err)
}
mux := http.NewServeMux()
mux.Handle("/healthz", httpHandlers.NewHealthHandler(s.db, s.redisDB))
mux.Handle("/", gwmux)
srv := &http.Server{
Addr: fmt.Sprintf(":%d", s.config.HTTPPort),
Handler: mux,
ReadTimeout: httpReadTimeout,
WriteTimeout: httpWriteTimeout,
IdleTimeout: httpIdleTimeout,
}
return srv.ListenAndServe()
}
func registerAuthService(ctx context.Context, gwmux *runtime.ServeMux, authAddr string, opts []grpc.DialOption) error {
if err := authPb.RegisterAuthServiceHandlerFromEndpoint(ctx, gwmux, authAddr, opts); err != nil {
return fmt.Errorf("register auth service handler: %w", err)
}
log.Printf("registered auth service from %s", authAddr)
return nil
}
func registerAuthHandlerFromEndpoint(ctx context.Context, gwmux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) error {
// We'll import the proto and register it here
// For now, this is a placeholder that will be called from gateway integration
return nil
}
func getDatabase(cfg config.Config) (*sqlx.DB, error) {
db, err := sqlx.Connect("postgres", cfg.BuildPostgresConnStr())
if err != nil {
return nil, fmt.Errorf("connect to database: %w", err)
}
return db, nil
}
func getRedis(cfg config.Config) (*redis.Client, error) {
conn, err := redis.ParseURL(cfg.RedisURI)
client := redis.NewClient(&redis.Options{
Addr: conn.Addr,
MaxRetries: redisRetryCount,
MinRetryBackoff: redisMinRetryBackoff,
MaxRetryBackoff: redisMaxRetryBackoff,
DialTimeout: redisDialTimeout,
DialerRetries: redisDialerRetries,
DialerRetryTimeout: redisDialTimeout,
ReadTimeout: redisTimeout,
WriteTimeout: redisTimeout,
})
if err != nil {
return nil, fmt.Errorf("parse Redis URI: %w", err)
}
_, err = client.Ping(context.Background()).Result()
if err != nil {
return nil, fmt.Errorf("connect to Redis server: %w", err)
}
return client, nil
}
func (s *Server) RegisterServices() {
db, err := getDatabase(*s.config)
if err != nil {
log.Print(err)
}
s.db = db
redisDB, err := getRedis(*s.config)
if err != nil {
log.Print(err)
}
s.redisDB = redisDB
if s.config.GRPCEnableReflection {
reflection.Register(s.grpcServer)
log.Println("gRPC server will start with reflection")
}
}
func (s *Server) Start() error {
addr := fmt.Sprintf(":%d", s.config.GRPCPort)
lis, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("failed to listen: %w", err)
}
if s.config.EnableHTTPHandler {
go func() {
log.Printf("starting HTTP gateway on port %d", s.config.HTTPPort)
if err := runHTTPHandler(s, &addr); err != nil {
log.Printf("HTTP gateway failed: %v", err)
}
}()
}
log.Printf("starting gRPC server on port %d", s.config.GRPCPort)
if err := s.grpcServer.Serve(lis); err != nil {
return fmt.Errorf("failed to serve: %w", err)
}
return nil
}
func (s *Server) Stop() {
s.grpcServer.GracefulStop()
log.Println("gRPC server stopped gracefully")
}
-56
View File
@@ -1,56 +0,0 @@
package service
import (
"context"
"datarush/internal/lms/domain"
"datarush/internal/lms/repository"
"github.com/google/uuid"
)
type OrderService struct {
repo repository.OrderRepository
}
func NewOrderService(repo repository.OrderRepository) *OrderService {
return &OrderService{
repo: repo,
}
}
func (s *OrderService) Create(ctx context.Context, item string, quantity int32) (*domain.Order, error) {
order, err := domain.NewOrder(uuid.New(), item, quantity)
if err != nil {
return nil, err
}
if err := s.repo.Create(ctx, order); err != nil {
return nil, err
}
return order, nil
}
func (s *OrderService) Get(ctx context.Context, id uuid.UUID) (*domain.Order, error) {
return s.repo.Get(ctx, id)
}
func (s *OrderService) Update(ctx context.Context, id uuid.UUID, item string, quantity int32) (*domain.Order, error) {
order, err := domain.NewOrder(id, item, quantity)
if err != nil {
return nil, err
}
if err := s.repo.Update(ctx, order); err != nil {
return nil, err
}
return order, nil
}
func (s *OrderService) Delete(ctx context.Context, id uuid.UUID) error {
return s.repo.Delete(ctx, id)
}
func (s *OrderService) List(ctx context.Context) ([]*domain.Order, error) {
return s.repo.List(ctx)
}
@@ -22,25 +22,17 @@ type Config struct {
DBPassword string
DBName string
RedisURI string
AuthGRPCAddr string
}
func Load() (*Config, error) {
_ = godotenv.Load()
return &Config{
GRPCPort: mustGetInt("GRPC_PORT", 50051), //nolint:mnd // false-positive
GRPCEnableReflection: mustGetBool("GRPC_ENABLE_REFLECTION", false),
EnableHTTPHandler: mustGetBool("HTTP_HANDLER_ENABLE", false),
HTTPPort: mustGetInt("HTTP_PORT", 8080), //nolint:mnd // false-positive
LogLevel: getEnv("LOG_LEVEL", "info"),
DBHost: getEnv("POSTGRES_HOST", "localhost"),
DBPort: mustGetInt("POSTGRES_PORT", 5432), //nolint:mnd // false-positive
DBUser: getEnv("POSTGRES_USERNAME", "postgres"),
DBPassword: getEnv("POSTGRES_PASSWORD", "postgres"),
DBName: getEnv("POSTGRES_DATABASE", "postgres"),
RedisURI: getEnv("REDIS_URI", "redis://localhost:6379"),
AuthGRPCAddr: getEnv("AUTH_GRPC_ADDR", "localhost:50052"),
}, nil
}
+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
AuthSvcAddr string
}
func Load() (*Config, error) {
_ = godotenv.Load()
return &Config{
GRPCPort: mustGetInt("TASK_GRPC_PORT", 50053),
GRPCEnableReflection: mustGetBool("TASK_GRPC_ENABLE_REFLECTION", false),
HTTPPort: mustGetInt("TASK_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"),
}, 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)
}
+50
View File
@@ -0,0 +1,50 @@
package grpc
import (
"context"
pb "datarush/pkg/api/task"
"google.golang.org/protobuf/types/known/emptypb"
)
type TaskService interface {
CreateTask(ctx context.Context, req *pb.Task) (*pb.Task, error)
GetTask(ctx context.Context, req *pb.GetTaskRequest) (*pb.Task, error)
EditTask(ctx context.Context, req *pb.Task) (*pb.Task, error)
DeleteTask(ctx context.Context, req *pb.DeleteTaskRequest) (*emptypb.Empty, error)
ListCompetitionTasks(ctx context.Context, req *pb.ListCompetitionTasksRequest) (*pb.ListCompetitionTasksResponse, error)
GetTaskAttachments(ctx context.Context, req *pb.GetTaskAttachmentsRequest) (*pb.GetTaskAttachmentsResponse, error)
}
type TaskHandler struct {
pb.UnimplementedTaskServiceServer
service TaskService
}
func NewTaskHandler(service TaskService) *TaskHandler {
return &TaskHandler{service: service}
}
func (h *TaskHandler) CreateTask(ctx context.Context, req *pb.Task) (*pb.Task, error) {
return h.service.CreateTask(ctx, req)
}
func (h *TaskHandler) GetTask(ctx context.Context, req *pb.GetTaskRequest) (*pb.Task, error) {
return h.service.GetTask(ctx, req)
}
func (h *TaskHandler) EditTask(ctx context.Context, req *pb.Task) (*pb.Task, error) {
return h.service.EditTask(ctx, req)
}
func (h *TaskHandler) DeleteTask(ctx context.Context, req *pb.DeleteTaskRequest) (*emptypb.Empty, error) {
return h.service.DeleteTask(ctx, req)
}
func (h *TaskHandler) ListCompetitionTasks(ctx context.Context, req *pb.ListCompetitionTasksRequest) (*pb.ListCompetitionTasksResponse, error) {
return h.service.ListCompetitionTasks(ctx, req)
}
func (h *TaskHandler) GetTaskAttachments(ctx context.Context, req *pb.GetTaskAttachmentsRequest) (*pb.GetTaskAttachmentsResponse, error) {
return h.service.GetTaskAttachments(ctx, req)
}
@@ -0,0 +1,79 @@
package postgres
import (
"context"
"github.com/jmoiron/sqlx"
"datarush/pkg/api/task"
)
type TaskRepository struct {
db *sqlx.DB
}
func NewTaskRepository(db *sqlx.DB) *TaskRepository {
return &TaskRepository{db: db}
}
func (r *TaskRepository) CreateTask(ctx context.Context, t *task.Task) (*task.Task, error) {
query := `INSERT INTO tasks (competition_id, title, description, in_competition_position, max_points, max_attempts, type)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, updated_at`
var createdTask task.Task
err := r.db.QueryRowxContext(ctx, query, t.CompetitionId, t.Title, t.Description, t.InCompetitionPosition, t.MaxPoints, t.MaxAttempts, t.Type).StructScan(&createdTask)
if err != nil {
return nil, err
}
createdTask.CompetitionId = t.CompetitionId
createdTask.Title = t.Title
createdTask.Description = t.Description
createdTask.InCompetitionPosition = t.InCompetitionPosition
createdTask.MaxPoints = t.MaxPoints
createdTask.MaxAttempts = t.MaxAttempts
createdTask.Type = t.Type
return &createdTask, nil
}
func (r *TaskRepository) GetTask(ctx context.Context, id string) (*task.Task, error) {
var t task.Task
err := r.db.GetContext(ctx, &t, "SELECT * FROM tasks WHERE id = $1", id)
return &t, err
}
func (r *TaskRepository) EditTask(ctx context.Context, t *task.Task) (*task.Task, error) {
query := `UPDATE tasks SET title = $1, description = $2, in_competition_position = $3, max_points = $4, max_attempts = $5, type = $6, updated_at = now()
WHERE id = $7 RETURNING updated_at`
var updatedTask task.Task
err := r.db.QueryRowxContext(ctx, query, t.Title, t.Description, t.InCompetitionPosition, t.MaxPoints, t.MaxAttempts, t.Type, t.Id).StructScan(&updatedTask)
if err != nil {
return nil, err
}
t.UpdatedAt = updatedTask.UpdatedAt
return t, nil
}
func (r *TaskRepository) DeleteTask(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM tasks WHERE id = $1", id)
return err
}
func (r *TaskRepository) ListCompetitionTasks(ctx context.Context, competitionID string) ([]*task.Task, error) {
var tasks []*task.Task
err := r.db.SelectContext(ctx, &tasks, "SELECT * FROM tasks WHERE competition_id = $1", competitionID)
return tasks, err
}
func (r *TaskRepository) GetTaskAttachments(ctx context.Context, taskID string, showPrivate bool) ([]*task.TaskAttachment, error) {
var attachments []*task.TaskAttachment
query := "SELECT * FROM task_attachments WHERE task_id = $1"
args := []interface{}{taskID}
if !showPrivate {
query += " AND is_public = true"
}
err := r.db.SelectContext(ctx, &attachments, query, args...)
return attachments, err
}
+102
View File
@@ -0,0 +1,102 @@
package server
import (
"fmt"
"log"
"net"
"time"
"datarush/internal/task/config"
grpcHandlers "datarush/internal/task/handler/grpc"
taskPostgresRepo "datarush/internal/task/repository/postgres"
"datarush/internal/task/service"
authpb "datarush/pkg/api/auth"
pb "datarush/pkg/api/task"
"datarush/pkg/interceptor"
"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
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
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 := interceptor.NewAuthInterceptor(authClient)
s.grpcServer = grpc.NewServer()
taskRepo := taskPostgresRepo.NewTaskRepository(s.db)
taskService := service.NewTaskService(taskRepo)
taskHandler := grpcHandlers.NewTaskHandler(taskService)
pb.RegisterTaskServiceServer(s.grpcServer, taskHandler)
if s.config.GRPCEnableReflection {
reflection.Register(s.grpcServer)
}
return nil
}
func (s *Server) Stop() {
log.Println("shutting down task 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("task server stopped")
}
@@ -0,0 +1,131 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: internal/task/service/service.go
//
// Generated by this command:
//
// mockgen -source=internal/task/service/service.go -destination=internal/task/service/mocks/mock_repository.go -package=mocks
//
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
task "datarush/pkg/api/task"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockTaskRepository is a mock of TaskRepository interface.
type MockTaskRepository struct {
ctrl *gomock.Controller
recorder *MockTaskRepositoryMockRecorder
isgomock struct{}
}
// MockTaskRepositoryMockRecorder is the mock recorder for MockTaskRepository.
type MockTaskRepositoryMockRecorder struct {
mock *MockTaskRepository
}
// NewMockTaskRepository creates a new mock instance.
func NewMockTaskRepository(ctrl *gomock.Controller) *MockTaskRepository {
mock := &MockTaskRepository{ctrl: ctrl}
mock.recorder = &MockTaskRepositoryMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockTaskRepository) EXPECT() *MockTaskRepositoryMockRecorder {
return m.recorder
}
// CreateTask mocks base method.
func (m *MockTaskRepository) CreateTask(ctx context.Context, t *task.Task) (*task.Task, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CreateTask", ctx, t)
ret0, _ := ret[0].(*task.Task)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CreateTask indicates an expected call of CreateTask.
func (mr *MockTaskRepositoryMockRecorder) CreateTask(ctx, t any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTask", reflect.TypeOf((*MockTaskRepository)(nil).CreateTask), ctx, t)
}
// DeleteTask mocks base method.
func (m *MockTaskRepository) DeleteTask(ctx context.Context, id string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteTask", ctx, id)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteTask indicates an expected call of DeleteTask.
func (mr *MockTaskRepositoryMockRecorder) DeleteTask(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTask", reflect.TypeOf((*MockTaskRepository)(nil).DeleteTask), ctx, id)
}
// EditTask mocks base method.
func (m *MockTaskRepository) EditTask(ctx context.Context, t *task.Task) (*task.Task, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "EditTask", ctx, t)
ret0, _ := ret[0].(*task.Task)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// EditTask indicates an expected call of EditTask.
func (mr *MockTaskRepositoryMockRecorder) EditTask(ctx, t any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EditTask", reflect.TypeOf((*MockTaskRepository)(nil).EditTask), ctx, t)
}
// GetTask mocks base method.
func (m *MockTaskRepository) GetTask(ctx context.Context, id string) (*task.Task, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTask", ctx, id)
ret0, _ := ret[0].(*task.Task)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetTask indicates an expected call of GetTask.
func (mr *MockTaskRepositoryMockRecorder) GetTask(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTask", reflect.TypeOf((*MockTaskRepository)(nil).GetTask), ctx, id)
}
// GetTaskAttachments mocks base method.
func (m *MockTaskRepository) GetTaskAttachments(ctx context.Context, taskID string, showPrivate bool) ([]*task.TaskAttachment, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTaskAttachments", ctx, taskID, showPrivate)
ret0, _ := ret[0].([]*task.TaskAttachment)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetTaskAttachments indicates an expected call of GetTaskAttachments.
func (mr *MockTaskRepositoryMockRecorder) GetTaskAttachments(ctx, taskID, showPrivate any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTaskAttachments", reflect.TypeOf((*MockTaskRepository)(nil).GetTaskAttachments), ctx, taskID, showPrivate)
}
// ListCompetitionTasks mocks base method.
func (m *MockTaskRepository) ListCompetitionTasks(ctx context.Context, competitionID string) ([]*task.Task, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListCompetitionTasks", ctx, competitionID)
ret0, _ := ret[0].([]*task.Task)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListCompetitionTasks indicates an expected call of ListCompetitionTasks.
func (mr *MockTaskRepositoryMockRecorder) ListCompetitionTasks(ctx, competitionID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCompetitionTasks", reflect.TypeOf((*MockTaskRepository)(nil).ListCompetitionTasks), ctx, competitionID)
}
+62
View File
@@ -0,0 +1,62 @@
package service
import (
"context"
pb "datarush/pkg/api/task"
"google.golang.org/protobuf/types/known/emptypb"
)
type TaskRepository interface {
CreateTask(ctx context.Context, t *pb.Task) (*pb.Task, error)
GetTask(ctx context.Context, id string) (*pb.Task, error)
EditTask(ctx context.Context, t *pb.Task) (*pb.Task, error)
DeleteTask(ctx context.Context, id string) error
ListCompetitionTasks(ctx context.Context, competitionID string) ([]*pb.Task, error)
GetTaskAttachments(ctx context.Context, taskID string, showPrivate bool) ([]*pb.TaskAttachment, error)
}
type TaskService struct {
repo TaskRepository
}
func NewTaskService(repo TaskRepository) *TaskService {
return &TaskService{repo: repo}
}
func (s *TaskService) CreateTask(ctx context.Context, req *pb.Task) (*pb.Task, error) {
return s.repo.CreateTask(ctx, req)
}
func (s *TaskService) GetTask(ctx context.Context, req *pb.GetTaskRequest) (*pb.Task, error) {
return s.repo.GetTask(ctx, req.TaskId)
}
func (s *TaskService) EditTask(ctx context.Context, req *pb.Task) (*pb.Task, error) {
return s.repo.EditTask(ctx, req)
}
func (s *TaskService) DeleteTask(ctx context.Context, req *pb.DeleteTaskRequest) (*emptypb.Empty, error) {
err := s.repo.DeleteTask(ctx, req.TaskId)
return &emptypb.Empty{}, err
}
func (s *TaskService) ListCompetitionTasks(ctx context.Context, req *pb.ListCompetitionTasksRequest) (*pb.ListCompetitionTasksResponse, error) {
tasks, err := s.repo.ListCompetitionTasks(ctx, req.CompetitionId)
if err != nil {
return nil, err
}
return &pb.ListCompetitionTasksResponse{Tasks: tasks}, nil
}
func (s *TaskService) GetTaskAttachments(ctx context.Context, req *pb.GetTaskAttachmentsRequest) (*pb.GetTaskAttachmentsResponse, error) {
showPrivate := false
if req.ShowPrivate != nil {
showPrivate = *req.ShowPrivate
}
attachments, err := s.repo.GetTaskAttachments(ctx, req.TaskId, showPrivate)
if err != nil {
return nil, err
}
return &pb.GetTaskAttachmentsResponse{Attachments: attachments}, nil
}
+140
View File
@@ -0,0 +1,140 @@
package service
import (
"context"
"testing"
"datarush/internal/task/service/mocks"
pb "datarush/pkg/api/task"
"go.uber.org/mock/gomock"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestTaskService(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := mocks.NewMockTaskRepository(ctrl)
service := NewTaskService(mockRepo)
ctx := context.Background()
t.Run("CreateTask", func(t *testing.T) {
task := &pb.Task{
CompetitionId: "comp1",
Title: "Test Task",
Description: "This is a test task",
InCompetitionPosition: 1,
MaxPoints: 100,
MaxAttempts: 10,
Type: pb.TaskType_TASK_TYPE_INPUT,
}
mockRepo.EXPECT().CreateTask(ctx, task).Return(task, nil)
createdTask, err := service.CreateTask(ctx, task)
assert.NoError(t, err)
assert.Equal(t, task, createdTask)
})
t.Run("GetTask", func(t *testing.T) {
taskID := "task1"
req := &pb.GetTaskRequest{TaskId: taskID}
expectedTask := &pb.Task{
Id: taskID,
CompetitionId: "comp1",
Title: "Test Task",
Description: "This is a test task",
InCompetitionPosition: 1,
MaxPoints: 100,
MaxAttempts: 10,
Type: pb.TaskType_TASK_TYPE_INPUT,
CreatedAt: timestamppb.Now(),
UpdatedAt: timestamppb.Now(),
}
mockRepo.EXPECT().GetTask(ctx, taskID).Return(expectedTask, nil)
task, err := service.GetTask(ctx, req)
assert.NoError(t, err)
assert.Equal(t, expectedTask, task)
})
t.Run("EditTask", func(t *testing.T) {
task := &pb.Task{
Id: "task1",
CompetitionId: "comp1",
Title: "Updated Test Task",
Description: "This is an updated test task",
InCompetitionPosition: 1,
MaxPoints: 150,
MaxAttempts: 5,
Type: pb.TaskType_TASK_TYPE_CHECKER,
}
mockRepo.EXPECT().EditTask(ctx, task).Return(task, nil)
updatedTask, err := service.EditTask(ctx, task)
assert.NoError(t, err)
assert.Equal(t, task, updatedTask)
})
t.Run("DeleteTask", func(t *testing.T) {
taskID := "task1"
req := &pb.DeleteTaskRequest{TaskId: taskID}
mockRepo.EXPECT().DeleteTask(ctx, taskID).Return(nil)
_, err := service.DeleteTask(ctx, req)
assert.NoError(t, err)
})
t.Run("ListCompetitionTasks", func(t *testing.T) {
competitionID := "comp1"
req := &pb.ListCompetitionTasksRequest{CompetitionId: competitionID}
expectedTasks := []*pb.Task{
{Id: "task1", CompetitionId: competitionID, Title: "Task 1"},
{Id: "task2", CompetitionId: competitionID, Title: "Task 2"},
}
mockRepo.EXPECT().ListCompetitionTasks(ctx, competitionID).Return(expectedTasks, nil)
resp, err := service.ListCompetitionTasks(ctx, req)
assert.NoError(t, err)
assert.Equal(t, expectedTasks, resp.Tasks)
})
t.Run("GetTaskAttachments", func(t *testing.T) {
taskID := "task1"
showPrivate := true
req := &pb.GetTaskAttachmentsRequest{TaskId: taskID, ShowPrivate: &showPrivate}
expectedAttachments := []*pb.TaskAttachment{
{Id: "att1", FileUrl: "url1", IsPublic: true},
{Id: "att2", FileUrl: "url2", IsPublic: false},
}
mockRepo.EXPECT().GetTaskAttachments(ctx, taskID, showPrivate).Return(expectedAttachments, nil)
resp, err := service.GetTaskAttachments(ctx, req)
assert.NoError(t, err)
assert.Equal(t, expectedAttachments, resp.Attachments)
})
t.Run("GetTaskAttachments - show public only", func(t *testing.T) {
taskID := "task1"
showPrivate := false
req := &pb.GetTaskAttachmentsRequest{TaskId: taskID, ShowPrivate: &showPrivate}
expectedAttachments := []*pb.TaskAttachment{
{Id: "att1", FileUrl: "url1", IsPublic: true},
}
mockRepo.EXPECT().GetTaskAttachments(ctx, taskID, showPrivate).Return(expectedAttachments, nil)
resp, err := service.GetTaskAttachments(ctx, req)
assert.NoError(t, err)
assert.Equal(t, expectedAttachments, resp.Attachments)
})
}