Files

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)
}