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