Merge branch 'main' into 'feature/results'
# Conflicts: # go.sum
This commit is contained in:
@@ -209,6 +209,14 @@ build-auth:
|
||||
BUILDTARGET: runtime
|
||||
SERVICE_NAME: auth
|
||||
|
||||
build-competition:
|
||||
<<: *build-config
|
||||
variables:
|
||||
IMAGE_NAME: $BASE_IMAGE_NAME/competition
|
||||
CONTAINERFILE: Containerfile
|
||||
BUILDTARGET: runtime
|
||||
SERVICE_NAME: competition
|
||||
|
||||
golangci-lint:
|
||||
stage: lint
|
||||
image: docker.io/golangci/golangci-lint:latest-alpine
|
||||
@@ -281,6 +289,14 @@ sast-image-auth:
|
||||
dependencies:
|
||||
- build-auth
|
||||
|
||||
sast-image-competition:
|
||||
<<: *trivy-image-scan
|
||||
variables:
|
||||
IMAGE_NAME: $BASE_IMAGE_NAME/competition
|
||||
IMAGE_TYPE: competition
|
||||
dependencies:
|
||||
- build-competition
|
||||
|
||||
tag-migrate:
|
||||
<<: *tag-config
|
||||
variables:
|
||||
@@ -296,6 +312,11 @@ tag-auth:
|
||||
variables:
|
||||
IMAGE_NAME: $BASE_IMAGE_NAME/auth
|
||||
|
||||
tag-competition:
|
||||
<<: *tag-config
|
||||
variables:
|
||||
IMAGE_NAME: $BASE_IMAGE_NAME/competition
|
||||
|
||||
# webhook-backend-deploy:
|
||||
# <<: *webhook-config
|
||||
# stage: deploy
|
||||
|
||||
@@ -6,6 +6,8 @@ BASE_BINARY_NAME=datarush
|
||||
GW_BINARY_NAME=$(BASE_BINARY_NAME)-gw
|
||||
MIGRATE_BINARY_NAME=$(BASE_BINARY_NAME)-migrate
|
||||
AUTH_BINARY_NAME=$(BASE_BINARY_NAME)-auth
|
||||
COMPETITION_BINARY_NAME=$(BASE_BINARY_NAME)-competition
|
||||
TASK_BINARY_NAME=$(BASE_BINARY_NAME)-task
|
||||
|
||||
BINARY_DIR=bin
|
||||
|
||||
@@ -49,10 +51,20 @@ build-auth:
|
||||
$(GOBUILD) -o ./$(BINARY_DIR)/$(AUTH_BINARY_NAME) ./cmd/auth
|
||||
chmod +x ./$(BINARY_DIR)/$(AUTH_BINARY_NAME)
|
||||
|
||||
build: build-gw build-migrate build-auth
|
||||
build-competition:
|
||||
$(GOBUILD) -o ./$(BINARY_DIR)/$(COMPETITION_BINARY_NAME) ./cmd/competition
|
||||
chmod +x ./$(BINARY_DIR)/$(COMPETITION_BINARY_NAME)
|
||||
|
||||
build-task:
|
||||
$(GOBUILD) -o ./$(BINARY_DIR)/$(TASK_BINARY_NAME) ./cmd/task
|
||||
chmod +x ./$(BINARY_DIR)/$(TASK_BINARY_NAME)
|
||||
|
||||
build: build-gw build-migrate build-auth build-competition build-task
|
||||
|
||||
run:
|
||||
./$(BINARY_DIR)/$(AUTH_BINARY_NAME) &
|
||||
./$(BINARY_DIR)/$(COMPETITION_BINARY_NAME) &
|
||||
./$(BINARY_DIR)/$(TASK_BINARY_NAME) &
|
||||
./$(BINARY_DIR)/$(GW_BINARY_NAME)
|
||||
|
||||
migrate: build-migrate
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Datarush
|
||||
# DataRush API
|
||||
|
||||
Data analysis contest management system
|
||||
Data analysis contest management system.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -10,52 +10,152 @@ Ensure you have the following installed on your system:
|
||||
- protoc (Protocol Buffers compiler)
|
||||
- make (latest version recommended)
|
||||
|
||||
## Installation
|
||||
## Environment Variables
|
||||
|
||||
See `infrastructure/<service>/.env.template` for example usage.
|
||||
|
||||
## Setup with Compose
|
||||
|
||||
```bash
|
||||
docker compose up -d --build --force-recreate --remove-orphans
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Clone the project
|
||||
|
||||
### 2. Go to the project directory
|
||||
|
||||
### 3. Install dependencies
|
||||
### 3. Install Dependencies
|
||||
|
||||
```bash
|
||||
make i
|
||||
```
|
||||
|
||||
### 4. Customize environment
|
||||
### 4. Build
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
make build-<service name>
|
||||
```
|
||||
|
||||
And setup env vars according to your needs.
|
||||
### 3. Set Environment Variables
|
||||
|
||||
## Configuration
|
||||
Create a `.env` file or export variables:
|
||||
|
||||
### 4. Run the service
|
||||
|
||||
```bash
|
||||
GRPC_PORT=50051 # gRPC server port
|
||||
GRPC_ENABLE_REFLECTION=false # whether to enable gRPC reflection or not
|
||||
HTTP_HANDLER_ENABLE=false # whether to enable HTTP gateway or not
|
||||
HTTP_PORT=8080 # HTTP gateway port
|
||||
LOG_LEVEL=info # logging severity (debug, info, warn, error)
|
||||
./bin/<binary_name>
|
||||
```
|
||||
|
||||
## Running
|
||||
## API Endpoints
|
||||
|
||||
### Build + run
|
||||
### Authentication
|
||||
- `POST /api/v1/sign-up` - Register new user
|
||||
- `POST /api/v1/sign-in` - Authenticate user
|
||||
- `GET /api/v1/me` - Get current user profile (requires auth)
|
||||
|
||||
### Competitions
|
||||
- `POST /api/v1/competitions` - Create competition (requires auth)
|
||||
- `GET /api/v1/competitions` - List competitions (requires auth)
|
||||
- `GET /api/v1/competitions/{id}` - Get competition details (requires auth)
|
||||
- `PUT /api/v1/competitions/{id}` - Update competition (requires auth)
|
||||
- `DELETE /api/v1/competitions/{id}` - Delete competition (requires auth)
|
||||
- `PATCH /api/v1/competitions/{id}/state` - Change competition state (requires auth)
|
||||
- `POST /api/v1/competitions/{id}/join` - Join competition (requires auth)
|
||||
|
||||
### Tasks
|
||||
- `POST /api/v1/competitions/{comp_id}/tasks` - Create task (requires auth)
|
||||
- `GET /api/v1/competitions/{comp_id}/tasks` - List tasks (requires auth)
|
||||
- `GET /api/v1/competitions/{comp_id}/tasks/{task_id}` - Get task (requires auth)
|
||||
- `PUT /api/v1/competitions/{comp_id}/tasks/{task_id}` - Update task (requires auth)
|
||||
- `DELETE /api/v1/competitions/{comp_id}/tasks/{task_id}` - Delete task (requires auth)
|
||||
|
||||
### Submissions
|
||||
- `POST /api/v1/competitions/{comp_id}/tasks/{task_id}/submit` - Submit task with file upload (requires auth)
|
||||
- `GET /api/v1/competitions/{comp_id}/tasks/{task_id}/history` - Get submission history (requires auth)
|
||||
|
||||
### Results
|
||||
- `GET /api/v1/competitions/{id}/results` - Get competition leaderboard (requires auth)
|
||||
- `GET /api/v1/competitions/{id}/results/me` - Get my results (requires auth)
|
||||
- `POST /api/v1/competitions/{id}/results/recalculate` - Recalculate results (requires auth)
|
||||
|
||||
### Review (Token-based)
|
||||
- `GET /api/v1/review/{token}/submissions` - List submissions for review
|
||||
- `GET /api/v1/review/{token}/submissions/{id}` - Get submission details
|
||||
- `POST /api/v1/review/{token}/submissions/{id}/evaluate` - Evaluate submission
|
||||
- `POST /api/v1/review/{token}/submissions/{id}/release` - Release submission
|
||||
|
||||
### Achievements
|
||||
- `GET /api/v1/achievements` - List all achievements (requires auth)
|
||||
- `GET /api/v1/achievements/{id}` - Get achievement details (requires auth)
|
||||
- `GET /api/v1/users/{user_id}/achievements` - Get user achievements (requires auth)
|
||||
|
||||
### Health Check
|
||||
- `GET /api/v1/ping` - Health check endpoint
|
||||
|
||||
## Authentication
|
||||
|
||||
Most endpoints require JWT authentication. Include the token in the Authorization header:
|
||||
|
||||
```bash
|
||||
make run
|
||||
Authorization: Bearer YOUR_JWT_TOKEN
|
||||
```
|
||||
|
||||
### Build
|
||||
The gateway validates tokens by calling `AuthService.ValidateToken` and extracts the user ID for subsequent requests.
|
||||
|
||||
## Error Handling
|
||||
|
||||
The API returns consistent error responses:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "error_type",
|
||||
"message": "Human-readable error message"
|
||||
}
|
||||
```
|
||||
|
||||
HTTP status codes:
|
||||
|
||||
- `200` - Success
|
||||
- `201` - Created
|
||||
- `204` - No Content
|
||||
- `400` - Bad Request
|
||||
- `401` - Unauthorized
|
||||
- `403` - Forbidden
|
||||
- `404` - Not Found
|
||||
- `409` - Conflict
|
||||
- `500` - Internal Server Error
|
||||
|
||||
## Development
|
||||
|
||||
### Gateway Service Structure
|
||||
|
||||
- **cmd/**: Entry point with dependency injection
|
||||
- **config/**: Environment variable configuration
|
||||
- **domain/**: HTTP request/response models and errors
|
||||
- **handler/**: HTTP handlers (one per resource)
|
||||
- **middleware/**: Reusable middleware
|
||||
- **grpc_client/**: gRPC client wrappers (one per service)
|
||||
- **storage/**: S3 integration
|
||||
- **router/**: Route definitions
|
||||
- **utils/**: Converters and helpers
|
||||
|
||||
### Adding New Endpoints
|
||||
|
||||
1. Add the endpoint to the OpenAPI spec
|
||||
2. Update proto files if needed
|
||||
3. Regenerate proto stubs
|
||||
4. Add converter functions in `utils/converter.go`
|
||||
5. Add handler method in appropriate handler file
|
||||
6. Register route in `router/router.go`
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
# Run tests
|
||||
go test ./...
|
||||
|
||||
### gRPC code generation
|
||||
|
||||
```bash
|
||||
make generate
|
||||
# Run with coverage
|
||||
go test -cover ./...
|
||||
```
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"datarush/internal/user/config"
|
||||
"datarush/internal/user/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
srv := server.New(cfg)
|
||||
|
||||
if err := srv.Start(); err != nil {
|
||||
log.Fatalf("failed to start server: %v", err)
|
||||
}
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("shutting down user server...")
|
||||
srv.Stop()
|
||||
log.Println("user server stopped")
|
||||
}
|
||||
@@ -142,6 +142,35 @@ services:
|
||||
restart: unless-stopped
|
||||
shm_size: 4mb
|
||||
|
||||
user:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Containerfile
|
||||
args:
|
||||
SERVICE: user
|
||||
depends_on:
|
||||
postgres:
|
||||
restart: false
|
||||
condition: service_healthy
|
||||
required: true
|
||||
redis:
|
||||
restart: false
|
||||
condition: service_healthy
|
||||
required: true
|
||||
auth:
|
||||
restart: false
|
||||
condition: service_started
|
||||
required: true
|
||||
env_file:
|
||||
- path: ./infrastructure/user/.env.template
|
||||
required: true
|
||||
- path: ./infrastructure/use/.env
|
||||
required: false
|
||||
networks:
|
||||
- default
|
||||
restart: unless-stopped
|
||||
shm_size: 4mb
|
||||
|
||||
nginx:
|
||||
image: docker.io/nginx:1.29-alpine
|
||||
configs:
|
||||
|
||||
@@ -18,7 +18,7 @@ require (
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/redis/go-redis/v9 v9.16.0
|
||||
github.com/redis/go-redis/v9 v9.17.2
|
||||
github.com/stretchr/testify v1.10.0
|
||||
go.uber.org/mock v0.6.0
|
||||
golang.org/x/crypto v0.42.0
|
||||
|
||||
@@ -138,8 +138,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
|
||||
github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
||||
@@ -15,7 +15,7 @@ type AuthClient struct {
|
||||
}
|
||||
|
||||
func NewAuthClient(ctx context.Context, address string, factory *ClientFactory) (*AuthClient, error) {
|
||||
conn, err := factory.GetConnection(ctx, address)
|
||||
conn, err := factory.GetConnectionWithRetry(ctx, address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create auth client: %w", err)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type ClientFactory struct {
|
||||
@@ -19,6 +21,11 @@ func NewClientFactory() *ClientFactory {
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
maxRetries = 3
|
||||
retryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
func (f *ClientFactory) GetConnection(ctx context.Context, address string) (*grpc.ClientConn, error) {
|
||||
if conn, ok := f.connections[address]; ok {
|
||||
return conn, nil
|
||||
@@ -39,6 +46,28 @@ func (f *ClientFactory) GetConnection(ctx context.Context, address string) (*grp
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (f *ClientFactory) GetConnectionWithRetry(ctx context.Context, address string) (*grpc.ClientConn, error) {
|
||||
var conn *grpc.ClientConn
|
||||
var err error
|
||||
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
conn, err = f.GetConnection(ctx, address)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
st, ok := status.FromError(err)
|
||||
if ok && (st.Code() == codes.Unavailable || st.Code() == codes.ResourceExhausted) {
|
||||
time.Sleep(retryDelay)
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to connect to %s after %d retries: %w", address, maxRetries, err)
|
||||
}
|
||||
|
||||
func (f *ClientFactory) Close() error {
|
||||
for addr, conn := range f.connections {
|
||||
if err := conn.Close(); err != nil {
|
||||
|
||||
@@ -15,7 +15,7 @@ type CompetitionClient struct {
|
||||
}
|
||||
|
||||
func NewCompetitionClient(ctx context.Context, address string, factory *ClientFactory) (*CompetitionClient, error) {
|
||||
conn, err := factory.GetConnection(ctx, address)
|
||||
conn, err := factory.GetConnectionWithRetry(ctx, address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create competition client: %w", err)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/reflection"
|
||||
)
|
||||
|
||||
@@ -46,6 +47,12 @@ func (s *Server) Start() error {
|
||||
}
|
||||
s.db = db
|
||||
|
||||
authConn, err := grpc.Dial(s.config.AuthSvcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to auth service: %w", err)
|
||||
}
|
||||
s.authConn = authConn
|
||||
|
||||
if err := s.registerGRPCServices(); err != nil {
|
||||
return fmt.Errorf("failed to register gRPC services: %w", err)
|
||||
}
|
||||
@@ -69,7 +76,9 @@ func (s *Server) registerGRPCServices() error {
|
||||
authClient := authpb.NewAuthServiceClient(s.authConn)
|
||||
authInterceptor := interceptor.NewAuthInterceptor(authClient)
|
||||
|
||||
s.grpcServer = grpc.NewServer()
|
||||
s.grpcServer = grpc.NewServer(
|
||||
grpc.UnaryInterceptor(authInterceptor.Unary()),
|
||||
)
|
||||
|
||||
taskRepo := taskPostgresRepo.NewTaskRepository(s.db)
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
GRPCPort int
|
||||
GRPCEnableReflection bool
|
||||
HTTPPort int
|
||||
LogLevel string
|
||||
DBHost string
|
||||
DBPort int
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
JWTSecret string
|
||||
AuthSvcAddr string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
_ = godotenv.Load()
|
||||
|
||||
return &Config{
|
||||
GRPCPort: mustGetInt("USER_GRPC_PORT", 50054),
|
||||
GRPCEnableReflection: mustGetBool("USER_GRPC_ENABLE_REFLECTION", false),
|
||||
HTTPPort: mustGetInt("USER_HTTP_PORT", 8083),
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
DBHost: getEnv("POSTGRES_HOST", "localhost"),
|
||||
DBPort: mustGetInt("POSTGRES_PORT", 5432),
|
||||
DBUser: getEnv("POSTGRES_USERNAME", "postgres"),
|
||||
DBPassword: getEnv("POSTGRES_PASSWORD", "postgres"),
|
||||
DBName: getEnv("POSTGRES_DATABASE", "postgres"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "your-secret-key-change-in-production"),
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
pb "datarush/pkg/api/user"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
type UserService interface {
|
||||
GetProfile(ctx context.Context, req *pb.GetProfileRequest) (*pb.User, error)
|
||||
RegisterForCompetition(ctx context.Context, req *pb.RegisterForCompetitionRequest) (*emptypb.Empty, error)
|
||||
UnregisterFromCompetition(ctx context.Context, req *pb.UnregisterFromCompetitionRequest) (*emptypb.Empty, error)
|
||||
ListUserCompetitions(ctx context.Context, req *pb.ListUserCompetitionsRequest) (*pb.ListUserCompetitionsResponse, error)
|
||||
}
|
||||
|
||||
type UserHandler struct {
|
||||
pb.UnimplementedUserServiceServer
|
||||
service UserService
|
||||
}
|
||||
|
||||
func NewUserHandler(service UserService) *UserHandler {
|
||||
return &UserHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *UserHandler) GetProfile(ctx context.Context, req *pb.GetProfileRequest) (*pb.User, error) {
|
||||
return h.service.GetProfile(ctx, req)
|
||||
}
|
||||
|
||||
func (h *UserHandler) RegisterForCompetition(ctx context.Context, req *pb.RegisterForCompetitionRequest) (*emptypb.Empty, error) {
|
||||
return h.service.RegisterForCompetition(ctx, req)
|
||||
}
|
||||
|
||||
func (h *UserHandler) UnregisterFromCompetition(ctx context.Context, req *pb.UnregisterFromCompetitionRequest) (*emptypb.Empty, error) {
|
||||
return h.service.UnregisterFromCompetition(ctx, req)
|
||||
}
|
||||
|
||||
func (h *UserHandler) ListUserCompetitions(ctx context.Context, req *pb.ListUserCompetitionsRequest) (*pb.ListUserCompetitionsResponse, error) {
|
||||
return h.service.ListUserCompetitions(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type UserIDKey struct{}
|
||||
|
||||
func AuthInterceptor(jwtSecret string) grpc.UnaryServerInterceptor {
|
||||
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "metadata is not provided")
|
||||
}
|
||||
|
||||
authHeader, ok := md["authorization"]
|
||||
if !ok || len(authHeader) == 0 {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "authorization token is not provided")
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader[0], "Bearer ")
|
||||
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(jwtSecret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid token: %v", err)
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
||||
userID, ok := claims["user_id"].(string)
|
||||
if !ok {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid token: user_id is not a string")
|
||||
}
|
||||
|
||||
newCtx := context.WithValue(ctx, UserIDKey{}, userID)
|
||||
return handler(newCtx, req)
|
||||
}
|
||||
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid token")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"datarush/pkg/api/user"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type UserRepository struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *sqlx.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetProfile(ctx context.Context, userID string) (*user.User, error) {
|
||||
var u user.User
|
||||
err := r.db.GetContext(ctx, &u, "SELECT id, username, email, full_name, avatar_url FROM users WHERE id = $1", userID)
|
||||
return &u, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) RegisterForCompetition(ctx context.Context, userID, competitionID string) error {
|
||||
_, err := r.db.ExecContext(ctx, "INSERT INTO user_competitions (user_id, competition_id) VALUES ($1, $2)", userID, competitionID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserRepository) UnregisterFromCompetition(ctx context.Context, userID, competitionID string) error {
|
||||
_, err := r.db.ExecContext(ctx, "DELETE FROM user_competitions WHERE user_id = $1 AND competition_id = $2", userID, competitionID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserRepository) ListUserCompetitions(ctx context.Context, userID string) ([]string, error) {
|
||||
var competitionIDs []string
|
||||
err := r.db.SelectContext(ctx, &competitionIDs, "SELECT competition_id FROM user_competitions WHERE user_id = $1", userID)
|
||||
return competitionIDs, err
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"datarush/internal/user/config"
|
||||
grpcHandlers "datarush/internal/user/handler/grpc"
|
||||
userPostgresRepo "datarush/internal/user/repository/postgres"
|
||||
"datarush/internal/user/service"
|
||||
authpb "datarush/pkg/api/auth"
|
||||
pb "datarush/pkg/api/user"
|
||||
"datarush/pkg/interceptor"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
"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
|
||||
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
|
||||
|
||||
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()),
|
||||
)
|
||||
|
||||
userRepo := userPostgresRepo.NewUserRepository(s.db)
|
||||
|
||||
userService := service.NewUserService(userRepo)
|
||||
|
||||
userHandler := grpcHandlers.NewUserHandler(userService)
|
||||
pb.RegisterUserServiceServer(s.grpcServer, userHandler)
|
||||
|
||||
if s.config.GRPCEnableReflection {
|
||||
reflection.Register(s.grpcServer)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Stop() {
|
||||
log.Println("shutting down user server...")
|
||||
|
||||
if s.grpcServer != nil {
|
||||
s.grpcServer.GracefulStop()
|
||||
}
|
||||
|
||||
if s.db != nil {
|
||||
if err := s.db.Close(); err != nil {
|
||||
log.Printf("failed to close database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if s.authConn != nil {
|
||||
if err := s.authConn.Close(); err != nil {
|
||||
log.Printf("failed to close auth connection: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("user server stopped")
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: internal/user/service/service.go
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -source=internal/user/service/service.go -destination=internal/user/service/mocks/mock_repository.go -package=mocks
|
||||
//
|
||||
|
||||
// Package mocks is a generated GoMock package.
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
user "datarush/pkg/api/user"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockUserRepository is a mock of UserRepository interface.
|
||||
type MockUserRepository struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockUserRepositoryMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockUserRepositoryMockRecorder is the mock recorder for MockUserRepository.
|
||||
type MockUserRepositoryMockRecorder struct {
|
||||
mock *MockUserRepository
|
||||
}
|
||||
|
||||
// NewMockUserRepository creates a new mock instance.
|
||||
func NewMockUserRepository(ctrl *gomock.Controller) *MockUserRepository {
|
||||
mock := &MockUserRepository{ctrl: ctrl}
|
||||
mock.recorder = &MockUserRepositoryMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockUserRepository) EXPECT() *MockUserRepositoryMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// GetProfile mocks base method.
|
||||
func (m *MockUserRepository) GetProfile(ctx context.Context, userID string) (*user.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetProfile", ctx, userID)
|
||||
ret0, _ := ret[0].(*user.User)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetProfile indicates an expected call of GetProfile.
|
||||
func (mr *MockUserRepositoryMockRecorder) GetProfile(ctx, userID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProfile", reflect.TypeOf((*MockUserRepository)(nil).GetProfile), ctx, userID)
|
||||
}
|
||||
|
||||
// ListUserCompetitions mocks base method.
|
||||
func (m *MockUserRepository) ListUserCompetitions(ctx context.Context, userID string) ([]string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ListUserCompetitions", ctx, userID)
|
||||
ret0, _ := ret[0].([]string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ListUserCompetitions indicates an expected call of ListUserCompetitions.
|
||||
func (mr *MockUserRepositoryMockRecorder) ListUserCompetitions(ctx, userID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserCompetitions", reflect.TypeOf((*MockUserRepository)(nil).ListUserCompetitions), ctx, userID)
|
||||
}
|
||||
|
||||
// RegisterForCompetition mocks base method.
|
||||
func (m *MockUserRepository) RegisterForCompetition(ctx context.Context, userID, competitionID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "RegisterForCompetition", ctx, userID, competitionID)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// RegisterForCompetition indicates an expected call of RegisterForCompetition.
|
||||
func (mr *MockUserRepositoryMockRecorder) RegisterForCompetition(ctx, userID, competitionID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterForCompetition", reflect.TypeOf((*MockUserRepository)(nil).RegisterForCompetition), ctx, userID, competitionID)
|
||||
}
|
||||
|
||||
// UnregisterFromCompetition mocks base method.
|
||||
func (m *MockUserRepository) UnregisterFromCompetition(ctx context.Context, userID, competitionID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UnregisterFromCompetition", ctx, userID, competitionID)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UnregisterFromCompetition indicates an expected call of UnregisterFromCompetition.
|
||||
func (mr *MockUserRepositoryMockRecorder) UnregisterFromCompetition(ctx, userID, competitionID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnregisterFromCompetition", reflect.TypeOf((*MockUserRepository)(nil).UnregisterFromCompetition), ctx, userID, competitionID)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"datarush/internal/user/middleware"
|
||||
pb "datarush/pkg/api/user"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
GetProfile(ctx context.Context, userID string) (*pb.User, error)
|
||||
RegisterForCompetition(ctx context.Context, userID, competitionID string) error
|
||||
UnregisterFromCompetition(ctx context.Context, userID, competitionID string) error
|
||||
ListUserCompetitions(ctx context.Context, userID string) ([]string, error)
|
||||
}
|
||||
|
||||
type UserService struct {
|
||||
repo UserRepository
|
||||
}
|
||||
|
||||
func NewUserService(repo UserRepository) *UserService {
|
||||
return &UserService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *UserService) GetProfile(ctx context.Context, req *pb.GetProfileRequest) (*pb.User, error) {
|
||||
return s.repo.GetProfile(ctx, req.UserId)
|
||||
}
|
||||
|
||||
func (s *UserService) RegisterForCompetition(ctx context.Context, req *pb.RegisterForCompetitionRequest) (*emptypb.Empty, error) {
|
||||
userID, ok := ctx.Value(middleware.UserIDKey{}).(string)
|
||||
if !ok {
|
||||
return nil, errors.New("user ID not found in context")
|
||||
}
|
||||
|
||||
err := s.repo.RegisterForCompetition(ctx, userID, req.CompetitionId)
|
||||
return &emptypb.Empty{}, err
|
||||
}
|
||||
|
||||
func (s *UserService) UnregisterFromCompetition(ctx context.Context, req *pb.UnregisterFromCompetitionRequest) (*emptypb.Empty, error) {
|
||||
userID, ok := ctx.Value(middleware.UserIDKey{}).(string)
|
||||
if !ok {
|
||||
return nil, errors.New("user ID not found in context")
|
||||
}
|
||||
|
||||
err := s.repo.UnregisterFromCompetition(ctx, userID, req.CompetitionId)
|
||||
return &emptypb.Empty{}, err
|
||||
}
|
||||
|
||||
func (s *UserService) ListUserCompetitions(ctx context.Context, req *pb.ListUserCompetitionsRequest) (*pb.ListUserCompetitionsResponse, error) {
|
||||
competitionIDs, err := s.repo.ListUserCompetitions(ctx, req.UserId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pb.ListUserCompetitionsResponse{CompetitionIds: competitionIDs}, nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"datarush/internal/user/middleware"
|
||||
"datarush/internal/user/service/mocks"
|
||||
pb "datarush/pkg/api/user"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUserService(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockRepo := mocks.NewMockUserRepository(ctrl)
|
||||
service := NewUserService(mockRepo)
|
||||
|
||||
ctx := context.Background()
|
||||
userID := "test-user-id"
|
||||
ctx = context.WithValue(ctx, middleware.UserIDKey{}, userID)
|
||||
|
||||
t.Run("GetProfile", func(t *testing.T) {
|
||||
req := &pb.GetProfileRequest{UserId: userID}
|
||||
expectedUser := &pb.User{
|
||||
Id: userID,
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
}
|
||||
|
||||
mockRepo.EXPECT().GetProfile(ctx, userID).Return(expectedUser, nil)
|
||||
|
||||
user, err := service.GetProfile(ctx, req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedUser, user)
|
||||
})
|
||||
|
||||
t.Run("RegisterForCompetition", func(t *testing.T) {
|
||||
competitionID := "comp1"
|
||||
req := &pb.RegisterForCompetitionRequest{CompetitionId: competitionID}
|
||||
|
||||
mockRepo.EXPECT().RegisterForCompetition(ctx, userID, competitionID).Return(nil)
|
||||
|
||||
_, err := service.RegisterForCompetition(ctx, req)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("RegisterForCompetition - No UserID in context", func(t *testing.T) {
|
||||
competitionID := "comp1"
|
||||
req := &pb.RegisterForCompetitionRequest{CompetitionId: competitionID}
|
||||
|
||||
_, err := service.RegisterForCompetition(context.Background(), req)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "user ID not found in context", err.Error())
|
||||
})
|
||||
|
||||
t.Run("UnregisterFromCompetition", func(t *testing.T) {
|
||||
competitionID := "comp1"
|
||||
req := &pb.UnregisterFromCompetitionRequest{CompetitionId: competitionID}
|
||||
|
||||
mockRepo.EXPECT().UnregisterFromCompetition(ctx, userID, competitionID).Return(nil)
|
||||
|
||||
_, err := service.UnregisterFromCompetition(ctx, req)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("UnregisterFromCompetition - No UserID in context", func(t *testing.T) {
|
||||
competitionID := "comp1"
|
||||
req := &pb.UnregisterFromCompetitionRequest{CompetitionId: competitionID}
|
||||
|
||||
_, err := service.UnregisterFromCompetition(context.Background(), req)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "user ID not found in context", err.Error())
|
||||
})
|
||||
|
||||
t.Run("ListUserCompetitions", func(t *testing.T) {
|
||||
req := &pb.ListUserCompetitionsRequest{UserId: userID}
|
||||
expectedCompetitionIDs := []string{"comp1", "comp2"}
|
||||
|
||||
mockRepo.EXPECT().ListUserCompetitions(ctx, userID).Return(expectedCompetitionIDs, nil)
|
||||
|
||||
resp, err := service.ListUserCompetitions(ctx, req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedCompetitionIDs, resp.CompetitionIds)
|
||||
})
|
||||
|
||||
t.Run("ListUserCompetitions - Error", func(t *testing.T) {
|
||||
req := &pb.ListUserCompetitionsRequest{UserId: userID}
|
||||
expectedError := errors.New("repository error")
|
||||
|
||||
mockRepo.EXPECT().ListUserCompetitions(ctx, userID).Return(nil, expectedError)
|
||||
|
||||
_, err := service.ListUserCompetitions(ctx, req)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, expectedError, err)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user