289 lines
9 KiB
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)
|
|
}
|