package middleware import ( "context" "errors" "fmt" "log/slog" "net/http" "reflect" "strings" "time" "unicode" "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/binding" "github.com/go-playground/validator/v10" "github.com/google/uuid" "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/dto" "go.opentelemetry.io/otel/trace" ) const requestIDHeader = "X-Request-ID" type requestIDContextKey struct{} type requestErrorCodeContextKey struct{} // RequestID returns the identifier assigned to the current request func RequestID(c *gin.Context) string { if c == nil { return "" } requestID, exists := c.Get(requestIDContextKey{}) if !exists { return "" } value, ok := requestID.(string) if !ok { return "" } return value } // RequestErrorCode returns the stable code of the error handled for the current request func RequestErrorCode(c *gin.Context) apperror.Code { if c == nil { return "" } code, exists := c.Get(requestErrorCodeContextKey{}) if !exists { return "" } value, ok := code.(apperror.Code) if !ok { return "" } return value } type ErrorHandlerMiddleware struct{} func NewErrorHandlerMiddleware() *ErrorHandlerMiddleware { return &ErrorHandlerMiddleware{} } type classifiedError struct { code apperror.Code status int message string details map[string]string fields []apperror.FieldError retryAfter time.Duration } // Add records a request ID before executing the request and serializes the first returned error afterward func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc { return func(c *gin.Context) { requestID := uuid.NewString() c.Set(requestIDContextKey{}, requestID) c.Header(requestIDHeader, requestID) c.Next() if len(c.Errors) == 0 { return } err := c.Errors[0].Err classified := classifyError(err) c.Set(requestErrorCodeContextKey{}, classified.code) logRequestError(c, err, classified, requestID) if c.Writer.Written() { return } if classified.retryAfter > 0 { c.Header("Retry-After", formatRetryAfter(classified.retryAfter)) } writeErrorResponse(c, classified, requestID) } } func classifyError(err error) classifiedError { var structuredErr *apperror.Error if errors.As(err, &structuredErr) && structuredErr != nil { return classifiedError{ code: structuredErr.Code(), status: normalizeStatus(structuredErr.HTTPStatus()), message: capitalizeFirst(structuredErr.ClientMessage()), details: structuredErr.Details(), fields: structuredErr.Fields(), retryAfter: structuredErr.RetryAfter(), } } if errors.Is(err, context.DeadlineExceeded) { return classifiedError{ code: apperror.CodeRequestTimeout, status: http.StatusGatewayTimeout, message: "Request timed out", } } var validationErrors validator.ValidationErrors if errors.As(err, &validationErrors) && len(validationErrors) > 0 { return classifiedValidationError(validationErrors) } var sliceValidationErrors binding.SliceValidationError if errors.As(err, &sliceValidationErrors) && len(sliceValidationErrors) > 0 { if errors.As(sliceValidationErrors[0], &validationErrors) { return classifiedValidationError(validationErrors) } } return classifiedError{ code: apperror.CodeInternal, status: http.StatusInternalServerError, message: "Something went wrong", } } func classifiedValidationError(validationErrors validator.ValidationErrors) classifiedError { fields := make([]apperror.FieldError, 0, len(validationErrors)) messages := make([]string, 0, len(validationErrors)) for _, validationError := range validationErrors { fieldName := validationError.Field() code, message := dto.ValidationErrorDetails(validationError) fields = append(fields, apperror.FieldError{ Field: fieldName, Code: code, Message: message, }) messages = append(messages, fieldName+" "+message) } return classifiedError{ code: apperror.CodeValidationFailed, status: http.StatusBadRequest, message: capitalizeFirst(strings.Join(messages, ", ")), fields: fields, } } func writeErrorResponse(c *gin.Context, classified classifiedError, requestID string) { details := make(map[string]any, len(classified.details)+1) for key, value := range classified.details { details[key] = value } if len(classified.fields) > 0 { details["fields"] = classified.fields } if len(details) == 0 { details = nil } response := dto.ErrorDto{ Error: classified.message, Code: classified.code, Details: details, RequestID: requestID, } c.JSON(classified.status, response) } func logRequestError(c *gin.Context, err error, classified classifiedError, requestID string) { if classified.status < http.StatusInternalServerError { return } attrs := []any{ slog.String("error_code", string(classified.code)), slog.String("error_type", errorTypeName(err)), slog.Int("http_status", classified.status), slog.String("request_id", requestID), slog.String("http_method", c.Request.Method), slog.String("http_path", c.Request.URL.Path), slog.Any("error", err), } if spanContext := trace.SpanFromContext(c.Request.Context()).SpanContext(); spanContext.IsValid() { attrs = append(attrs, slog.String("trace_id", spanContext.TraceID().String())) } slog.ErrorContext(c.Request.Context(), "Request failed", attrs...) } func errorTypeName(err error) string { if err == nil { return "" } return reflect.TypeOf(err).String() } func normalizeStatus(status int) int { if status < http.StatusBadRequest || status > 599 { return http.StatusInternalServerError } return status } func formatRetryAfter(retryAfter time.Duration) string { seconds := int(retryAfter / time.Second) if retryAfter%time.Second != 0 { seconds++ } if seconds < 1 { seconds = 1 } return fmt.Sprintf("%d", seconds) } func capitalizeFirst(message string) string { runes := []rune(message) if len(runes) == 0 { return message } runes[0] = unicode.ToUpper(runes[0]) return string(runes) }