Files
galaxy-game/authsession/internal/api/publichttp/handler.go
T
2026-04-08 16:23:07 +02:00

243 lines
6.6 KiB
Go

package publichttp
import (
"context"
"errors"
"fmt"
"net/http"
"net/mail"
"strings"
"sync"
"time"
"galaxy/authsession/internal/service/confirmemailcode"
"galaxy/authsession/internal/service/sendemailcode"
"galaxy/authsession/internal/service/shared"
"galaxy/authsession/internal/telemetry"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
)
const jsonContentType = "application/json; charset=utf-8"
const publicHTTPServiceName = "galaxy-authsession-public"
type sendEmailCodeRequest struct {
Email string `json:"email"`
}
type sendEmailCodeResponse struct {
ChallengeID string `json:"challenge_id"`
}
type confirmEmailCodeRequest struct {
ChallengeID string `json:"challenge_id"`
Code string `json:"code"`
ClientPublicKey string `json:"client_public_key"`
}
type confirmEmailCodeResponse struct {
DeviceSessionID string `json:"device_session_id"`
}
type errorResponse struct {
Error errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
var configureGinModeOnce sync.Once
func newHandlerWithConfig(cfg Config, deps Dependencies) (http.Handler, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
normalizedDeps, err := normalizeDependencies(deps)
if err != nil {
return nil, err
}
configureGinModeOnce.Do(func() {
gin.SetMode(gin.ReleaseMode)
})
engine := gin.New()
engine.Use(newOTelMiddleware(normalizedDeps.Telemetry))
engine.Use(withPublicObservability(normalizedDeps.Logger, normalizedDeps.Telemetry))
engine.POST(
"/api/v1/public/auth/send-email-code",
handleSendEmailCode(normalizedDeps.SendEmailCode, cfg.RequestTimeout),
)
engine.POST(
"/api/v1/public/auth/confirm-email-code",
handleConfirmEmailCode(normalizedDeps.ConfirmEmailCode, cfg.RequestTimeout),
)
return engine, nil
}
func newOTelMiddleware(runtime *telemetry.Runtime) gin.HandlerFunc {
options := []otelgin.Option{}
if runtime != nil {
options = append(
options,
otelgin.WithTracerProvider(runtime.TracerProvider()),
otelgin.WithMeterProvider(runtime.MeterProvider()),
)
}
return otelgin.Middleware(publicHTTPServiceName, options...)
}
func handleSendEmailCode(useCase SendEmailCodeUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request sendEmailCodeRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, projectSendEmailCodeError(shared.InvalidRequest(err.Error())))
return
}
if err := validateSendEmailCodeRequest(&request); err != nil {
abortWithProjection(c, projectSendEmailCodeError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, sendemailcode.Input{Email: request.Email})
if err != nil {
abortWithProjection(c, projectSendEmailCodeError(err))
return
}
if err := validateSendEmailCodeResult(&result); err != nil {
abortWithProjection(c, unavailableProjection(fmt.Errorf("send email code response: %w", err)))
return
}
c.JSON(http.StatusOK, sendEmailCodeResponse{ChallengeID: result.ChallengeID})
}
}
func handleConfirmEmailCode(useCase ConfirmEmailCodeUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request confirmEmailCodeRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, projectConfirmEmailCodeError(shared.InvalidRequest(err.Error())))
return
}
if err := validateConfirmEmailCodeRequest(&request); err != nil {
abortWithProjection(c, projectConfirmEmailCodeError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, confirmemailcode.Input{
ChallengeID: request.ChallengeID,
Code: request.Code,
ClientPublicKey: request.ClientPublicKey,
})
if err != nil {
abortWithProjection(c, projectConfirmEmailCodeError(err))
return
}
if err := validateConfirmEmailCodeResult(&result); err != nil {
abortWithProjection(c, unavailableProjection(fmt.Errorf("confirm email code response: %w", err)))
return
}
c.JSON(http.StatusOK, confirmEmailCodeResponse{DeviceSessionID: result.DeviceSessionID})
}
}
func validateSendEmailCodeRequest(request *sendEmailCodeRequest) error {
request.Email = strings.TrimSpace(request.Email)
if request.Email == "" {
return errors.New("email must not be empty")
}
parsedAddress, err := mail.ParseAddress(request.Email)
if err != nil || parsedAddress.Name != "" || parsedAddress.Address != request.Email {
return errors.New("email must be a single valid email address")
}
return nil
}
func validateSendEmailCodeResult(result *sendemailcode.Result) error {
result.ChallengeID = strings.TrimSpace(result.ChallengeID)
if result.ChallengeID == "" {
return errors.New("challenge_id must not be empty")
}
return nil
}
func validateConfirmEmailCodeRequest(request *confirmEmailCodeRequest) error {
request.ChallengeID = strings.TrimSpace(request.ChallengeID)
if request.ChallengeID == "" {
return errors.New("challenge_id must not be empty")
}
request.Code = strings.TrimSpace(request.Code)
if request.Code == "" {
return errors.New("code must not be empty")
}
request.ClientPublicKey = strings.TrimSpace(request.ClientPublicKey)
if request.ClientPublicKey == "" {
return errors.New("client_public_key must not be empty")
}
return nil
}
func validateConfirmEmailCodeResult(result *confirmemailcode.Result) error {
result.DeviceSessionID = strings.TrimSpace(result.DeviceSessionID)
if result.DeviceSessionID == "" {
return errors.New("device_session_id must not be empty")
}
return nil
}
func projectSendEmailCodeError(err error) shared.PublicErrorProjection {
if isTimeoutOrCanceled(err) {
return unavailableProjection(err)
}
projection := shared.ProjectPublicError(err)
if !shared.IsSendEmailCodePublicErrorCode(projection.Code) {
return unavailableProjection(err)
}
return projection
}
func projectConfirmEmailCodeError(err error) shared.PublicErrorProjection {
if isTimeoutOrCanceled(err) {
return unavailableProjection(err)
}
projection := shared.ProjectPublicError(err)
if !shared.IsConfirmEmailCodePublicErrorCode(projection.Code) {
return unavailableProjection(err)
}
return projection
}
func unavailableProjection(err error) shared.PublicErrorProjection {
return shared.ProjectPublicError(shared.ServiceUnavailable(err))
}
func isTimeoutOrCanceled(err error) bool {
return errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)
}