monorepo/cloud/maplefile-backend/pkg/httperror/rfc9457.go

289 lines
9 KiB
Go

// Package httperror provides RFC 9457 compliant error handling for HTTP APIs.
// RFC 9457: Problem Details for HTTP APIs
// https://www.rfc-editor.org/rfc/rfc9457.html
package httperror
import (
"encoding/json"
"net/http"
"time"
)
// ProblemDetail represents an RFC 9457 problem detail response.
// It provides a standardized way to carry machine-readable details of errors
// in HTTP response content.
type ProblemDetail struct {
// Standard RFC 9457 fields
// Type is a URI reference that identifies the problem type.
// When dereferenced, it should provide human-readable documentation.
// Defaults to "about:blank" if not provided.
Type string `json:"type"`
// Status is the HTTP status code for this occurrence of the problem.
Status int `json:"status"`
// Title is a short, human-readable summary of the problem type.
Title string `json:"title"`
// Detail is a human-readable explanation specific to this occurrence.
Detail string `json:"detail,omitempty"`
// Instance is a URI reference that identifies this specific occurrence.
Instance string `json:"instance,omitempty"`
// MapleFile-specific extensions
// Errors contains field-specific validation errors.
// Key is the field name, value is the error message.
Errors map[string]string `json:"errors,omitempty"`
// Timestamp is the ISO 8601 timestamp when the error occurred.
Timestamp string `json:"timestamp"`
// TraceID is the request trace ID for debugging.
TraceID string `json:"trace_id,omitempty"`
}
// Problem type URIs - these identify categories of errors
const (
TypeValidationError = "https://api.maplefile.com/problems/validation-error"
TypeBadRequest = "https://api.maplefile.com/problems/bad-request"
TypeUnauthorized = "https://api.maplefile.com/problems/unauthorized"
TypeForbidden = "https://api.maplefile.com/problems/forbidden"
TypeNotFound = "https://api.maplefile.com/problems/not-found"
TypeConflict = "https://api.maplefile.com/problems/conflict"
TypeTooManyRequests = "https://api.maplefile.com/problems/too-many-requests"
TypeInternalError = "https://api.maplefile.com/problems/internal-error"
TypeServiceUnavailable = "https://api.maplefile.com/problems/service-unavailable"
)
// NewProblemDetail creates a new RFC 9457 problem detail.
func NewProblemDetail(status int, problemType, title, detail string) *ProblemDetail {
return &ProblemDetail{
Type: problemType,
Status: status,
Title: title,
Detail: detail,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// NewValidationError creates a validation error problem detail.
// Use this when one or more fields fail validation.
func NewValidationError(fieldErrors map[string]string) *ProblemDetail {
detail := "One or more fields failed validation. Please check the errors and try again."
if len(fieldErrors) == 0 {
detail = "Validation failed."
}
return &ProblemDetail{
Type: TypeValidationError,
Status: http.StatusBadRequest,
Title: "Validation Failed",
Detail: detail,
Errors: fieldErrors,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// NewBadRequestError creates a generic bad request error.
// Use this for malformed requests or invalid input.
func NewBadRequestError(detail string) *ProblemDetail {
return &ProblemDetail{
Type: TypeBadRequest,
Status: http.StatusBadRequest,
Title: "Bad Request",
Detail: detail,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// NewUnauthorizedError creates an unauthorized error.
// Use this when authentication is required but missing or invalid.
func NewUnauthorizedError(detail string) *ProblemDetail {
if detail == "" {
detail = "Authentication is required to access this resource."
}
return &ProblemDetail{
Type: TypeUnauthorized,
Status: http.StatusUnauthorized,
Title: "Unauthorized",
Detail: detail,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// NewForbiddenError creates a forbidden error.
// Use this when the user is authenticated but lacks permission.
func NewForbiddenError(detail string) *ProblemDetail {
if detail == "" {
detail = "You do not have permission to access this resource."
}
return &ProblemDetail{
Type: TypeForbidden,
Status: http.StatusForbidden,
Title: "Forbidden",
Detail: detail,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// NewNotFoundError creates a not found error.
// Use this when a requested resource does not exist.
func NewNotFoundError(resourceType string) *ProblemDetail {
detail := "The requested resource was not found."
if resourceType != "" {
detail = resourceType + " not found."
}
return &ProblemDetail{
Type: TypeNotFound,
Status: http.StatusNotFound,
Title: "Not Found",
Detail: detail,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// NewConflictError creates a conflict error.
// Use this when the request conflicts with the current state.
func NewConflictError(detail string) *ProblemDetail {
if detail == "" {
detail = "The request conflicts with the current state of the resource."
}
return &ProblemDetail{
Type: TypeConflict,
Status: http.StatusConflict,
Title: "Conflict",
Detail: detail,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// NewTooManyRequestsError creates a rate limit exceeded error.
// Use this when the client has exceeded the allowed request rate.
// CWE-307: Used to prevent brute force attacks by limiting request frequency.
func NewTooManyRequestsError(detail string) *ProblemDetail {
if detail == "" {
detail = "Too many requests. Please try again later."
}
return &ProblemDetail{
Type: TypeTooManyRequests,
Status: http.StatusTooManyRequests,
Title: "Too Many Requests",
Detail: detail,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// NewInternalServerError creates an internal server error.
// Use this for unexpected errors that are not the client's fault.
func NewInternalServerError(detail string) *ProblemDetail {
if detail == "" {
detail = "An unexpected error occurred. Please try again later."
}
return &ProblemDetail{
Type: TypeInternalError,
Status: http.StatusInternalServerError,
Title: "Internal Server Error",
Detail: detail,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// NewServiceUnavailableError creates a service unavailable error.
// Use this when the service is temporarily unavailable.
func NewServiceUnavailableError(detail string) *ProblemDetail {
if detail == "" {
detail = "The service is temporarily unavailable. Please try again later."
}
return &ProblemDetail{
Type: TypeServiceUnavailable,
Status: http.StatusServiceUnavailable,
Title: "Service Unavailable",
Detail: detail,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// WithInstance adds the request path as the instance identifier.
func (p *ProblemDetail) WithInstance(instance string) *ProblemDetail {
p.Instance = instance
return p
}
// WithTraceID adds the request trace ID for debugging.
func (p *ProblemDetail) WithTraceID(traceID string) *ProblemDetail {
p.TraceID = traceID
return p
}
// WithError adds a single field error to the problem detail.
func (p *ProblemDetail) WithError(field, message string) *ProblemDetail {
if p.Errors == nil {
p.Errors = make(map[string]string)
}
p.Errors[field] = message
return p
}
// Error implements the error interface.
func (p *ProblemDetail) Error() string {
if p.Detail != "" {
return p.Detail
}
return p.Title
}
// ExtractRequestID gets the request ID from the request context or headers.
// This uses the existing request ID middleware.
func ExtractRequestID(r *http.Request) string {
// Try to get from context first (preferred)
if requestID := r.Context().Value("request_id"); requestID != nil {
if id, ok := requestID.(string); ok {
return id
}
}
// Fallback to header
if requestID := r.Header.Get("X-Request-ID"); requestID != "" {
return requestID
}
// No request ID found
return ""
}
// RespondWithProblem writes the RFC 9457 problem detail to the HTTP response.
// It sets the appropriate Content-Type header and status code.
func RespondWithProblem(w http.ResponseWriter, problem *ProblemDetail) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(problem.Status)
json.NewEncoder(w).Encode(problem)
}
// RespondWithError is a convenience function that handles both ProblemDetail
// and standard Go errors. If the error is a ProblemDetail, it writes it directly.
// Otherwise, it wraps it in an internal server error.
func RespondWithError(w http.ResponseWriter, r *http.Request, err error) {
requestID := ExtractRequestID(r)
// Check if error is already a ProblemDetail
if problem, ok := err.(*ProblemDetail); ok {
problem.WithInstance(r.URL.Path).WithTraceID(requestID)
RespondWithProblem(w, problem)
return
}
// Wrap standard error in internal server error
problem := NewInternalServerError(err.Error())
problem.WithInstance(r.URL.Path).WithTraceID(requestID)
RespondWithProblem(w, problem)
}