Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
289
cloud/maplefile-backend/pkg/httperror/rfc9457.go
Normal file
289
cloud/maplefile-backend/pkg/httperror/rfc9457.go
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
// 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue