mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-19 19:36:24 +00:00
252 lines
6.0 KiB
Go
252 lines
6.0 KiB
Go
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 "<nil>"
|
|
}
|
|
|
|
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)
|
|
}
|