diff --git a/cmd/gw/main.go b/cmd/gw/main.go new file mode 100644 index 0000000..c1f2f83 --- /dev/null +++ b/cmd/gw/main.go @@ -0,0 +1,138 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "datarush/internal/gw/config" + "datarush/internal/gw/grpc_client" + "datarush/internal/gw/handler" + "datarush/internal/gw/middleware" + "datarush/internal/gw/router" + "datarush/internal/gw/storage" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + ctx := context.Background() + + clientFactory := grpc_client.NewClientFactory() + defer clientFactory.Close() + + authClient, err := grpc_client.NewAuthClient(ctx, cfg.GRPC.AuthServiceAddr, clientFactory) + if err != nil { + log.Fatalf("Failed to create auth client: %v", err) + } + + userClient, err := grpc_client.NewUserClient(ctx, cfg.GRPC.UserServiceAddr, clientFactory) + if err != nil { + log.Fatalf("Failed to create user client: %v", err) + } + + competitionClient, err := grpc_client.NewCompetitionClient(ctx, cfg.GRPC.CompetitionServiceAddr, clientFactory) + if err != nil { + log.Fatalf("Failed to create competition client: %v", err) + } + + taskClient, err := grpc_client.NewTaskClient(ctx, cfg.GRPC.TaskServiceAddr, clientFactory) + if err != nil { + log.Fatalf("Failed to create task client: %v", err) + } + + submissionClient, err := grpc_client.NewSubmissionClient(ctx, cfg.GRPC.SubmissionServiceAddr, clientFactory) + if err != nil { + log.Fatalf("Failed to create submission client: %v", err) + } + + resultsClient, err := grpc_client.NewResultsClient(ctx, cfg.GRPC.ResultsServiceAddr, clientFactory) + if err != nil { + log.Fatalf("Failed to create results client: %v", err) + } + + reviewClient, err := grpc_client.NewReviewClient(ctx, cfg.GRPC.ReviewServiceAddr, clientFactory) + if err != nil { + log.Fatalf("Failed to create review client: %v", err) + } + + achievementsClient, err := grpc_client.NewAchievementsClient(ctx, cfg.GRPC.AchievementsServiceAddr, clientFactory) + if err != nil { + log.Fatalf("Failed to create achievements client: %v", err) + } + + s3Storage, err := storage.NewS3Storage(storage.S3Config{ + AccessKeyID: cfg.S3.AccessKeyID, + SecretAccessKey: cfg.S3.SecretAccessKey, + Region: cfg.S3.Region, + Bucket: cfg.S3.Bucket, + Endpoint: cfg.S3.Endpoint, + }) + if err != nil { + log.Fatalf("Failed to create S3 storage: %v", err) + } + + authMiddleware := middleware.NewAuthMiddleware(authClient) + + authHandler := handler.NewAuthHandler(authClient, userClient) + competitionHandler := handler.NewCompetitionHandler(competitionClient, userClient) + taskHandler := handler.NewTaskHandler(taskClient) + submissionHandler := handler.NewSubmissionHandler(submissionClient, s3Storage) + resultsHandler := handler.NewResultsHandler(resultsClient) + reviewHandler := handler.NewReviewHandler(reviewClient) + achievementsHandler := handler.NewAchievementsHandler(achievementsClient) + pingHandler := handler.NewPingHandler() + + rt := router.NewRouter( + authHandler, + competitionHandler, + taskHandler, + submissionHandler, + resultsHandler, + reviewHandler, + achievementsHandler, + pingHandler, + authMiddleware, + ) + + httpHandler := rt.Setup() + + addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port) + server := &http.Server{ + Addr: addr, + Handler: httpHandler, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + log.Printf("Starting API Gateway on %s", addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + 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 server...") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatalf("Server forced to shutdown: %v", err) + } + + log.Println("Server exited") +} diff --git a/go.mod b/go.mod index 9d96342..8b1782b 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,15 @@ toolchain go1.24.9 require ( github.com/Masterminds/squirrel v1.5.4 - github.com/gin-gonic/gin v1.10.0 + github.com/aws/aws-sdk-go-v2 v1.41.0 + github.com/aws/aws-sdk-go-v2/config v1.32.6 + github.com/aws/aws-sdk-go-v2/credentials v1.19.6 + github.com/aws/aws-sdk-go-v2/service/s3 v1.27.11 github.com/go-playground/validator/v10 v10.28.0 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-migrate/migrate/v4 v4.19.0 github.com/google/uuid v1.6.0 + github.com/gorilla/mux v1.7.4 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 github.com/jmoiron/sqlx v1.4.0 github.com/joho/godotenv v1.5.1 @@ -22,6 +26,21 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.8 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.14 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.18 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect + github.com/aws/smithy-go v1.24.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect diff --git a/go.sum b/go.sum index af41754..7bd671f 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,50 @@ github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8 github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/aws/aws-sdk-go-v2 v1.16.16/go.mod h1:SwiyXi/1zTUZ6KIAmLK5V5ll8SiURNUYOqTerZPaF9k= +github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= +github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.8 h1:tcFliCWne+zOuUfKNRn8JdFBuWPDuISDH08wD2ULkhk= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.8/go.mod h1:JTnlBSot91steJeti4ryyu/tLd4Sk84O5W22L7O2EQU= +github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= +github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.23/go.mod h1:2DFxAQ9pfIRy0imBCJv+vZ2X6RKxves6fbnEuSry6b4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.17/go.mod h1:pRwaTYCJemADaqCbUAxltMoHKata7hmB5PjEXeu0kfg= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.14 h1:ZSIPAkAsCCjYrhqfw2+lNzWDzxzHXEckFkTePL5RSWQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.14/go.mod h1:AyGgqiKv9ECM6IZeNQtdT8NnMvUb3/2wokeq2Fgryto= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.9/go.mod h1:a9j48l6yL5XINLHLcOKInjdvknN+vWqPBxqeIDw7ktw= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.18 h1:BBYoNQt2kUZUUK4bIPsKrCcjVPUMNsgQpNAwhznK/zo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.18/go.mod h1:NS55eQ4YixUJPTC+INxi2/jCqe1y2Uw3rnh9wEOVJxY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.17/go.mod h1:4nYOrY41Lrbk2170/BGkcJKBhws9Pfn8MG3aGqjjeFI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.17 h1:HfVVR1vItaG6le+Bpw6P4midjBDMKnjMyZnw9MXYUcE= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.17/go.mod h1:YqMdV+gEKCQ59NrB7rzrJdALeBIsYiVi8Inj3+KcqHI= +github.com/aws/aws-sdk-go-v2/service/s3 v1.27.11 h1:3/gm/JTX9bX8CpzTgIlrtYpB3EVBDxyg/GY/QdcIEZw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.27.11/go.mod h1:fmgDANqTUCxciViKl9hb/zD5LFbvPINFRgWhDbR+vZo= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= +github.com/aws/smithy-go v1.13.3/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -17,6 +61,7 @@ github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= @@ -35,7 +80,6 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -52,17 +96,19 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE= github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= +github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -70,6 +116,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -106,6 +154,7 @@ github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERS github.com/redis/go-redis/v9 v9.16.0/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= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= @@ -148,5 +197,6 @@ google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/gw/config/config.go b/internal/gw/config/config.go new file mode 100644 index 0000000..ec66c0d --- /dev/null +++ b/internal/gw/config/config.go @@ -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: getEnvRequired("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 +} diff --git a/internal/gw/domain/errors.go b/internal/gw/domain/errors.go new file mode 100644 index 0000000..39016ed --- /dev/null +++ b/internal/gw/domain/errors.go @@ -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) +} diff --git a/internal/gw/domain/models.go b/internal/gw/domain/models.go new file mode 100644 index 0000000..406572a --- /dev/null +++ b/internal/gw/domain/models.go @@ -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"` +} diff --git a/internal/gw/grpc_client/achievements_client.go b/internal/gw/grpc_client/achievements_client.go new file mode 100644 index 0000000..4a4148d --- /dev/null +++ b/internal/gw/grpc_client/achievements_client.go @@ -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 +} diff --git a/internal/gw/grpc_client/auth_client.go b/internal/gw/grpc_client/auth_client.go new file mode 100644 index 0000000..63383e9 --- /dev/null +++ b/internal/gw/grpc_client/auth_client.go @@ -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 +} diff --git a/internal/gw/grpc_client/client.go b/internal/gw/grpc_client/client.go new file mode 100644 index 0000000..195d4d6 --- /dev/null +++ b/internal/gw/grpc_client/client.go @@ -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 +} diff --git a/internal/gw/grpc_client/competition_client.go b/internal/gw/grpc_client/competition_client.go new file mode 100644 index 0000000..4476597 --- /dev/null +++ b/internal/gw/grpc_client/competition_client.go @@ -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 +} diff --git a/internal/gw/grpc_client/results_client.go b/internal/gw/grpc_client/results_client.go new file mode 100644 index 0000000..1d5494e --- /dev/null +++ b/internal/gw/grpc_client/results_client.go @@ -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 +} diff --git a/internal/gw/grpc_client/review_client.go b/internal/gw/grpc_client/review_client.go new file mode 100644 index 0000000..4d0fb09 --- /dev/null +++ b/internal/gw/grpc_client/review_client.go @@ -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 +} diff --git a/internal/gw/grpc_client/submission_client.go b/internal/gw/grpc_client/submission_client.go new file mode 100644 index 0000000..fec6d10 --- /dev/null +++ b/internal/gw/grpc_client/submission_client.go @@ -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 +} diff --git a/internal/gw/grpc_client/task_client.go b/internal/gw/grpc_client/task_client.go new file mode 100644 index 0000000..6ccfb66 --- /dev/null +++ b/internal/gw/grpc_client/task_client.go @@ -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 +} diff --git a/internal/gw/grpc_client/user_client.go b/internal/gw/grpc_client/user_client.go new file mode 100644 index 0000000..e21eb0c --- /dev/null +++ b/internal/gw/grpc_client/user_client.go @@ -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 +} diff --git a/internal/gw/handler/achievements_handler.go b/internal/gw/handler/achievements_handler.go new file mode 100644 index 0000000..1a083f1 --- /dev/null +++ b/internal/gw/handler/achievements_handler.go @@ -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, + }) +} diff --git a/internal/gw/handler/auth_handler.go b/internal/gw/handler/auth_handler.go new file mode 100644 index 0000000..4a02bd2 --- /dev/null +++ b/internal/gw/handler/auth_handler.go @@ -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)) +} diff --git a/internal/gw/handler/common.go b/internal/gw/handler/common.go new file mode 100644 index 0000000..0d664f3 --- /dev/null +++ b/internal/gw/handler/common.go @@ -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) +} diff --git a/internal/gw/handler/competition_handler.go b/internal/gw/handler/competition_handler.go new file mode 100644 index 0000000..0ce6aff --- /dev/null +++ b/internal/gw/handler/competition_handler.go @@ -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) +} diff --git a/internal/gw/handler/results_handler.go b/internal/gw/handler/results_handler.go new file mode 100644 index 0000000..6226ef4 --- /dev/null +++ b/internal/gw/handler/results_handler.go @@ -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) +} diff --git a/internal/gw/handler/review_handler.go b/internal/gw/handler/review_handler.go new file mode 100644 index 0000000..f7eff59 --- /dev/null +++ b/internal/gw/handler/review_handler.go @@ -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 + } +} diff --git a/internal/gw/handler/submission_handler.go b/internal/gw/handler/submission_handler.go new file mode 100644 index 0000000..d416cc7 --- /dev/null +++ b/internal/gw/handler/submission_handler.go @@ -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, + }) +} diff --git a/internal/gw/handler/task_handler.go b/internal/gw/handler/task_handler.go new file mode 100644 index 0000000..c4c39c7 --- /dev/null +++ b/internal/gw/handler/task_handler.go @@ -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}) +} diff --git a/internal/gw/middleware/auth.go b/internal/gw/middleware/auth.go new file mode 100644 index 0000000..5022d27 --- /dev/null +++ b/internal/gw/middleware/auth.go @@ -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 + `"}`)) +} diff --git a/internal/gw/middleware/cors.go b/internal/gw/middleware/cors.go new file mode 100644 index 0000000..a16b26f --- /dev/null +++ b/internal/gw/middleware/cors.go @@ -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) + }) +} diff --git a/internal/gw/middleware/logging.go b/internal/gw/middleware/logging.go new file mode 100644 index 0000000..565b1ce --- /dev/null +++ b/internal/gw/middleware/logging.go @@ -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, + ) + }) +} diff --git a/internal/gw/router/router.go b/internal/gw/router/router.go new file mode 100644 index 0000000..73a3b80 --- /dev/null +++ b/internal/gw/router/router.go @@ -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 +} diff --git a/internal/gw/storage/s3.go b/internal/gw/storage/s3.go new file mode 100644 index 0000000..87f8004 --- /dev/null +++ b/internal/gw/storage/s3.go @@ -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 +} diff --git a/internal/gw/utils/converter.go b/internal/gw/utils/converter.go new file mode 100644 index 0000000..697aecd --- /dev/null +++ b/internal/gw/utils/converter.go @@ -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" + } +} diff --git a/internal/gw/utils/http.go b/internal/gw/utils/http.go new file mode 100644 index 0000000..a2fbee2 --- /dev/null +++ b/internal/gw/utils/http.go @@ -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 +}