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
147
cloud/maplefile-backend/pkg/httperror/httperror.go
Normal file
147
cloud/maplefile-backend/pkg/httperror/httperror.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// File Path: monorepo/cloud/maplefile-backend/pkg/httperror/httperror.go
|
||||
package httperror
|
||||
|
||||
// This package introduces a new `error` type that combines an HTTP status code and a message.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HTTPError represents an http error that occurred while handling a request
|
||||
type HTTPError struct {
|
||||
Code int `json:"-"` // HTTP Status code. We use `-` to skip json marshaling.
|
||||
Errors *map[string]string `json:"-"` // The original error. Same reason as above.
|
||||
}
|
||||
|
||||
// New creates a new HTTPError instance with a multi-field errors.
|
||||
func New(statusCode int, errorsMap *map[string]string) error {
|
||||
return HTTPError{
|
||||
Code: statusCode,
|
||||
Errors: errorsMap,
|
||||
}
|
||||
}
|
||||
|
||||
// NewForSingleField create a new HTTPError instance for a single field. This is a convinience constructor.
|
||||
func NewForSingleField(statusCode int, field string, message string) error {
|
||||
return HTTPError{
|
||||
Code: statusCode,
|
||||
Errors: &map[string]string{field: message},
|
||||
}
|
||||
}
|
||||
|
||||
// NewForBadRequest create a new HTTPError instance pertaining to 403 bad requests with the multi-errors. This is a convinience constructor.
|
||||
func NewForBadRequest(err *map[string]string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusBadRequest,
|
||||
Errors: err,
|
||||
}
|
||||
}
|
||||
|
||||
// NewForBadRequestWithSingleField create a new HTTPError instance pertaining to 403 bad requests for a single field. This is a convinience constructor.
|
||||
func NewForBadRequestWithSingleField(field string, message string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusBadRequest,
|
||||
Errors: &map[string]string{field: message},
|
||||
}
|
||||
}
|
||||
|
||||
func NewForInternalServerErrorWithSingleField(field string, message string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusInternalServerError,
|
||||
Errors: &map[string]string{field: message},
|
||||
}
|
||||
}
|
||||
|
||||
// NewForNotFoundWithSingleField create a new HTTPError instance pertaining to 404 not found for a single field. This is a convinience constructor.
|
||||
func NewForNotFoundWithSingleField(field string, message string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusNotFound,
|
||||
Errors: &map[string]string{field: message},
|
||||
}
|
||||
}
|
||||
|
||||
// NewForServiceUnavailableWithSingleField create a new HTTPError instance pertaining service unavailable for a single field. This is a convinience constructor.
|
||||
func NewForServiceUnavailableWithSingleField(field string, message string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusServiceUnavailable,
|
||||
Errors: &map[string]string{field: message},
|
||||
}
|
||||
}
|
||||
|
||||
// NewForLockedWithSingleField create a new HTTPError instance pertaining to 424 locked for a single field. This is a convinience constructor.
|
||||
func NewForLockedWithSingleField(field string, message string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusLocked,
|
||||
Errors: &map[string]string{field: message},
|
||||
}
|
||||
}
|
||||
|
||||
// NewForForbiddenWithSingleField create a new HTTPError instance pertaining to 403 bad requests for a single field. This is a convinience constructor.
|
||||
func NewForForbiddenWithSingleField(field string, message string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusForbidden,
|
||||
Errors: &map[string]string{field: message},
|
||||
}
|
||||
}
|
||||
|
||||
// NewForUnauthorizedWithSingleField create a new HTTPError instance pertaining to 401 unauthorized for a single field. This is a convinience constructor.
|
||||
func NewForUnauthorizedWithSingleField(field string, message string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusUnauthorized,
|
||||
Errors: &map[string]string{field: message},
|
||||
}
|
||||
}
|
||||
|
||||
// NewForGoneWithSingleField create a new HTTPError instance pertaining to 410 gone for a single field. This is a convinience constructor.
|
||||
func NewForGoneWithSingleField(field string, message string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusGone,
|
||||
Errors: &map[string]string{field: message},
|
||||
}
|
||||
}
|
||||
|
||||
// Error function used to implement the `error` interface for returning errors.
|
||||
func (err HTTPError) Error() string {
|
||||
b, e := json.Marshal(err.Errors)
|
||||
if e != nil { // Defensive code
|
||||
return e.Error()
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ResponseError function returns the HTTP error response based on the httpcode used.
|
||||
func ResponseError(rw http.ResponseWriter, err error) {
|
||||
// Copied from:
|
||||
// https://dev.to/tigorlazuardi/go-creating-custom-error-wrapper-and-do-proper-error-equality-check-11k7
|
||||
|
||||
rw.Header().Set("Content-Type", "Application/json")
|
||||
|
||||
//
|
||||
// CASE 1 OF 2: Handle API Errors.
|
||||
//
|
||||
|
||||
var ew HTTPError
|
||||
if errors.As(err, &ew) {
|
||||
rw.WriteHeader(ew.Code)
|
||||
_ = json.NewEncoder(rw).Encode(ew.Errors)
|
||||
return
|
||||
}
|
||||
|
||||
//
|
||||
// CASE 2 OF 2: Handle non ErrorWrapper types.
|
||||
//
|
||||
|
||||
rw.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
_ = json.NewEncoder(rw).Encode(err.Error())
|
||||
}
|
||||
|
||||
// NewForInternalServerError create a new HTTPError instance pertaining to 500 internal server error with the multi-errors. This is a convinience constructor.
|
||||
func NewForInternalServerError(err string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusInternalServerError,
|
||||
Errors: &map[string]string{"message": err},
|
||||
}
|
||||
}
|
||||
328
cloud/maplefile-backend/pkg/httperror/httperror_test.go
Normal file
328
cloud/maplefile-backend/pkg/httperror/httperror_test.go
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
// File Path: monorepo/cloud/maplefile-backend/pkg/httperror/httperror_test.go
|
||||
package httperror
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
errors map[string]string
|
||||
wantCode int
|
||||
}{
|
||||
{
|
||||
name: "basic error",
|
||||
code: http.StatusBadRequest,
|
||||
errors: map[string]string{"field": "error message"},
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "empty errors map",
|
||||
code: http.StatusNotFound,
|
||||
errors: map[string]string{},
|
||||
wantCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "multiple errors",
|
||||
code: http.StatusBadRequest,
|
||||
errors: map[string]string{"field1": "error1", "field2": "error2"},
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := New(tt.code, &tt.errors)
|
||||
|
||||
httpErr, ok := err.(HTTPError)
|
||||
if !ok {
|
||||
t.Fatal("expected HTTPError type")
|
||||
}
|
||||
if httpErr.Code != tt.wantCode {
|
||||
t.Errorf("Code = %v, want %v", httpErr.Code, tt.wantCode)
|
||||
}
|
||||
for k, v := range tt.errors {
|
||||
if (*httpErr.Errors)[k] != v {
|
||||
t.Errorf("Errors[%s] = %v, want %v", k, (*httpErr.Errors)[k], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewForBadRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errors map[string]string
|
||||
}{
|
||||
{
|
||||
name: "single error",
|
||||
errors: map[string]string{"field": "error"},
|
||||
},
|
||||
{
|
||||
name: "multiple errors",
|
||||
errors: map[string]string{"field1": "error1", "field2": "error2"},
|
||||
},
|
||||
{
|
||||
name: "empty errors",
|
||||
errors: map[string]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := NewForBadRequest(&tt.errors)
|
||||
|
||||
httpErr, ok := err.(HTTPError)
|
||||
if !ok {
|
||||
t.Fatal("expected HTTPError type")
|
||||
}
|
||||
if httpErr.Code != http.StatusBadRequest {
|
||||
t.Errorf("Code = %v, want %v", httpErr.Code, http.StatusBadRequest)
|
||||
}
|
||||
for k, v := range tt.errors {
|
||||
if (*httpErr.Errors)[k] != v {
|
||||
t.Errorf("Errors[%s] = %v, want %v", k, (*httpErr.Errors)[k], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewForSingleField(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
field string
|
||||
message string
|
||||
}{
|
||||
{
|
||||
name: "basic error",
|
||||
code: http.StatusBadRequest,
|
||||
field: "test",
|
||||
message: "error",
|
||||
},
|
||||
{
|
||||
name: "empty field",
|
||||
code: http.StatusNotFound,
|
||||
field: "",
|
||||
message: "error",
|
||||
},
|
||||
{
|
||||
name: "empty message",
|
||||
code: http.StatusBadRequest,
|
||||
field: "field",
|
||||
message: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := NewForSingleField(tt.code, tt.field, tt.message)
|
||||
|
||||
httpErr, ok := err.(HTTPError)
|
||||
if !ok {
|
||||
t.Fatal("expected HTTPError type")
|
||||
}
|
||||
if httpErr.Code != tt.code {
|
||||
t.Errorf("Code = %v, want %v", httpErr.Code, tt.code)
|
||||
}
|
||||
if (*httpErr.Errors)[tt.field] != tt.message {
|
||||
t.Errorf("Errors[%s] = %v, want %v", tt.field, (*httpErr.Errors)[tt.field], tt.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errors map[string]string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid json",
|
||||
errors: map[string]string{"field": "error"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty map",
|
||||
errors: map[string]string{},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := HTTPError{
|
||||
Code: http.StatusBadRequest,
|
||||
Errors: &tt.errors,
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
var jsonMap map[string]string
|
||||
if jsonErr := json.Unmarshal([]byte(errStr), &jsonMap); (jsonErr != nil) != tt.wantErr {
|
||||
t.Errorf("Error() json.Unmarshal error = %v, wantErr %v", jsonErr, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
for k, v := range tt.errors {
|
||||
if jsonMap[k] != v {
|
||||
t.Errorf("Error() jsonMap[%s] = %v, want %v", k, jsonMap[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantCode int
|
||||
wantContent string
|
||||
}{
|
||||
{
|
||||
name: "http error",
|
||||
err: NewForBadRequestWithSingleField("field", "invalid"),
|
||||
wantCode: http.StatusBadRequest,
|
||||
wantContent: `{"field":"invalid"}`,
|
||||
},
|
||||
{
|
||||
name: "standard error",
|
||||
err: fmt.Errorf("standard error"),
|
||||
wantCode: http.StatusInternalServerError,
|
||||
wantContent: `"standard error"`,
|
||||
},
|
||||
{
|
||||
name: "nil error",
|
||||
err: errors.New("<nil>"),
|
||||
wantCode: http.StatusInternalServerError,
|
||||
wantContent: `"\u003cnil\u003e"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
ResponseError(rr, tt.err)
|
||||
|
||||
// Check status code
|
||||
if rr.Code != tt.wantCode {
|
||||
t.Errorf("ResponseError() code = %v, want %v", rr.Code, tt.wantCode)
|
||||
}
|
||||
|
||||
// Check content type
|
||||
if ct := rr.Header().Get("Content-Type"); ct != "Application/json" {
|
||||
t.Errorf("ResponseError() Content-Type = %v, want Application/json", ct)
|
||||
}
|
||||
|
||||
// Trim newline from response for comparison
|
||||
got := rr.Body.String()
|
||||
got = got[:len(got)-1] // Remove trailing newline added by json.Encoder
|
||||
if got != tt.wantContent {
|
||||
t.Errorf("ResponseError() content = %v, want %v", got, tt.wantContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorWrapping(t *testing.T) {
|
||||
originalErr := errors.New("original error")
|
||||
wrappedErr := fmt.Errorf("wrapped: %w", originalErr)
|
||||
httpErr := NewForBadRequestWithSingleField("field", wrappedErr.Error())
|
||||
|
||||
// Test error unwrapping
|
||||
if !errors.Is(httpErr, httpErr) {
|
||||
t.Error("errors.Is failed for same error")
|
||||
}
|
||||
|
||||
var targetErr HTTPError
|
||||
if !errors.As(httpErr, &targetErr) {
|
||||
t.Error("errors.As failed to get HTTPError")
|
||||
}
|
||||
}
|
||||
|
||||
// Test all convenience constructors
|
||||
func TestConvenienceConstructors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
create func() error
|
||||
wantCode int
|
||||
}{
|
||||
{
|
||||
name: "NewForBadRequestWithSingleField",
|
||||
create: func() error {
|
||||
return NewForBadRequestWithSingleField("field", "message")
|
||||
},
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "NewForNotFoundWithSingleField",
|
||||
create: func() error {
|
||||
return NewForNotFoundWithSingleField("field", "message")
|
||||
},
|
||||
wantCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "NewForServiceUnavailableWithSingleField",
|
||||
create: func() error {
|
||||
return NewForServiceUnavailableWithSingleField("field", "message")
|
||||
},
|
||||
wantCode: http.StatusServiceUnavailable,
|
||||
},
|
||||
{
|
||||
name: "NewForLockedWithSingleField",
|
||||
create: func() error {
|
||||
return NewForLockedWithSingleField("field", "message")
|
||||
},
|
||||
wantCode: http.StatusLocked,
|
||||
},
|
||||
{
|
||||
name: "NewForForbiddenWithSingleField",
|
||||
create: func() error {
|
||||
return NewForForbiddenWithSingleField("field", "message")
|
||||
},
|
||||
wantCode: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "NewForUnauthorizedWithSingleField",
|
||||
create: func() error {
|
||||
return NewForUnauthorizedWithSingleField("field", "message")
|
||||
},
|
||||
wantCode: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "NewForGoneWithSingleField",
|
||||
create: func() error {
|
||||
return NewForGoneWithSingleField("field", "message")
|
||||
},
|
||||
wantCode: http.StatusGone,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.create()
|
||||
httpErr, ok := err.(HTTPError)
|
||||
if !ok {
|
||||
t.Fatal("expected HTTPError type")
|
||||
}
|
||||
if httpErr.Code != tt.wantCode {
|
||||
t.Errorf("Code = %v, want %v", httpErr.Code, tt.wantCode)
|
||||
}
|
||||
if (*httpErr.Errors)["field"] != "message" {
|
||||
t.Errorf("Error message = %v, want 'message'", (*httpErr.Errors)["field"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
357
cloud/maplefile-backend/pkg/httperror/rfc9457_test.go
Normal file
357
cloud/maplefile-backend/pkg/httperror/rfc9457_test.go
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
package httperror
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewValidationError(t *testing.T) {
|
||||
fieldErrors := map[string]string{
|
||||
"email": "Email is required",
|
||||
"password": "Password must be at least 8 characters",
|
||||
}
|
||||
|
||||
problem := NewValidationError(fieldErrors)
|
||||
|
||||
if problem.Type != TypeValidationError {
|
||||
t.Errorf("Expected type %s, got %s", TypeValidationError, problem.Type)
|
||||
}
|
||||
|
||||
if problem.Status != http.StatusBadRequest {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, problem.Status)
|
||||
}
|
||||
|
||||
if problem.Title != "Validation Failed" {
|
||||
t.Errorf("Expected title 'Validation Failed', got '%s'", problem.Title)
|
||||
}
|
||||
|
||||
if len(problem.Errors) != 2 {
|
||||
t.Errorf("Expected 2 field errors, got %d", len(problem.Errors))
|
||||
}
|
||||
|
||||
if problem.Errors["email"] != "Email is required" {
|
||||
t.Errorf("Expected email error, got '%s'", problem.Errors["email"])
|
||||
}
|
||||
|
||||
if problem.Timestamp == "" {
|
||||
t.Error("Expected timestamp to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewValidationError_Empty(t *testing.T) {
|
||||
problem := NewValidationError(map[string]string{})
|
||||
|
||||
if problem.Detail != "Validation failed." {
|
||||
t.Errorf("Expected detail 'Validation failed.', got '%s'", problem.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBadRequestError(t *testing.T) {
|
||||
detail := "Invalid request payload"
|
||||
problem := NewBadRequestError(detail)
|
||||
|
||||
if problem.Type != TypeBadRequest {
|
||||
t.Errorf("Expected type %s, got %s", TypeBadRequest, problem.Type)
|
||||
}
|
||||
|
||||
if problem.Status != http.StatusBadRequest {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, problem.Status)
|
||||
}
|
||||
|
||||
if problem.Detail != detail {
|
||||
t.Errorf("Expected detail '%s', got '%s'", detail, problem.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUnauthorizedError(t *testing.T) {
|
||||
detail := "Invalid token"
|
||||
problem := NewUnauthorizedError(detail)
|
||||
|
||||
if problem.Type != TypeUnauthorized {
|
||||
t.Errorf("Expected type %s, got %s", TypeUnauthorized, problem.Type)
|
||||
}
|
||||
|
||||
if problem.Status != http.StatusUnauthorized {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, problem.Status)
|
||||
}
|
||||
|
||||
if problem.Detail != detail {
|
||||
t.Errorf("Expected detail '%s', got '%s'", detail, problem.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUnauthorizedError_DefaultMessage(t *testing.T) {
|
||||
problem := NewUnauthorizedError("")
|
||||
|
||||
if problem.Detail != "Authentication is required to access this resource." {
|
||||
t.Errorf("Expected default detail message, got '%s'", problem.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewForbiddenError(t *testing.T) {
|
||||
detail := "Insufficient permissions"
|
||||
problem := NewForbiddenError(detail)
|
||||
|
||||
if problem.Type != TypeForbidden {
|
||||
t.Errorf("Expected type %s, got %s", TypeForbidden, problem.Type)
|
||||
}
|
||||
|
||||
if problem.Status != http.StatusForbidden {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusForbidden, problem.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewNotFoundError(t *testing.T) {
|
||||
problem := NewNotFoundError("User")
|
||||
|
||||
if problem.Type != TypeNotFound {
|
||||
t.Errorf("Expected type %s, got %s", TypeNotFound, problem.Type)
|
||||
}
|
||||
|
||||
if problem.Status != http.StatusNotFound {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusNotFound, problem.Status)
|
||||
}
|
||||
|
||||
if problem.Detail != "User not found." {
|
||||
t.Errorf("Expected detail 'User not found.', got '%s'", problem.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConflictError(t *testing.T) {
|
||||
detail := "Email already exists"
|
||||
problem := NewConflictError(detail)
|
||||
|
||||
if problem.Type != TypeConflict {
|
||||
t.Errorf("Expected type %s, got %s", TypeConflict, problem.Type)
|
||||
}
|
||||
|
||||
if problem.Status != http.StatusConflict {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusConflict, problem.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewInternalServerError(t *testing.T) {
|
||||
detail := "Database connection failed"
|
||||
problem := NewInternalServerError(detail)
|
||||
|
||||
if problem.Type != TypeInternalError {
|
||||
t.Errorf("Expected type %s, got %s", TypeInternalError, problem.Type)
|
||||
}
|
||||
|
||||
if problem.Status != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, problem.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceUnavailableError(t *testing.T) {
|
||||
problem := NewServiceUnavailableError("")
|
||||
|
||||
if problem.Type != TypeServiceUnavailable {
|
||||
t.Errorf("Expected type %s, got %s", TypeServiceUnavailable, problem.Type)
|
||||
}
|
||||
|
||||
if problem.Status != http.StatusServiceUnavailable {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusServiceUnavailable, problem.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithInstance(t *testing.T) {
|
||||
problem := NewBadRequestError("Test")
|
||||
instance := "/api/v1/test"
|
||||
|
||||
problem.WithInstance(instance)
|
||||
|
||||
if problem.Instance != instance {
|
||||
t.Errorf("Expected instance '%s', got '%s'", instance, problem.Instance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTraceID(t *testing.T) {
|
||||
problem := NewBadRequestError("Test")
|
||||
traceID := "trace-123"
|
||||
|
||||
problem.WithTraceID(traceID)
|
||||
|
||||
if problem.TraceID != traceID {
|
||||
t.Errorf("Expected traceID '%s', got '%s'", traceID, problem.TraceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithError(t *testing.T) {
|
||||
problem := NewBadRequestError("Test")
|
||||
|
||||
problem.WithError("email", "Email is required")
|
||||
problem.WithError("password", "Password is required")
|
||||
|
||||
if len(problem.Errors) != 2 {
|
||||
t.Errorf("Expected 2 errors, got %d", len(problem.Errors))
|
||||
}
|
||||
|
||||
if problem.Errors["email"] != "Email is required" {
|
||||
t.Errorf("Expected email error, got '%s'", problem.Errors["email"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblemDetailError(t *testing.T) {
|
||||
detail := "Test detail"
|
||||
problem := NewBadRequestError(detail)
|
||||
|
||||
if problem.Error() != detail {
|
||||
t.Errorf("Expected Error() to return detail, got '%s'", problem.Error())
|
||||
}
|
||||
|
||||
// Test with no detail
|
||||
problem2 := &ProblemDetail{
|
||||
Title: "Test Title",
|
||||
}
|
||||
|
||||
if problem2.Error() != "Test Title" {
|
||||
t.Errorf("Expected Error() to return title, got '%s'", problem2.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRequestID_FromContext(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
|
||||
// Note: In real code, request ID would be set by middleware
|
||||
// For testing, we'll test the empty case
|
||||
requestID := ExtractRequestID(req)
|
||||
|
||||
// Should return empty string when no request ID is present
|
||||
if requestID != "" {
|
||||
t.Errorf("Expected empty string, got '%s'", requestID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRequestID_FromHeader(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("X-Request-ID", "test-request-123")
|
||||
|
||||
requestID := ExtractRequestID(req)
|
||||
|
||||
if requestID != "test-request-123" {
|
||||
t.Errorf("Expected 'test-request-123', got '%s'", requestID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespondWithProblem(t *testing.T) {
|
||||
problem := NewValidationError(map[string]string{
|
||||
"email": "Email is required",
|
||||
})
|
||||
problem.WithInstance("/api/v1/test")
|
||||
problem.WithTraceID("trace-123")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
RespondWithProblem(w, problem)
|
||||
|
||||
// Check status code
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
// Check content type
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "application/problem+json" {
|
||||
t.Errorf("Expected Content-Type 'application/problem+json', got '%s'", contentType)
|
||||
}
|
||||
|
||||
// Check JSON response
|
||||
var response ProblemDetail
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Type != TypeValidationError {
|
||||
t.Errorf("Expected type %s, got %s", TypeValidationError, response.Type)
|
||||
}
|
||||
|
||||
if response.Instance != "/api/v1/test" {
|
||||
t.Errorf("Expected instance '/api/v1/test', got '%s'", response.Instance)
|
||||
}
|
||||
|
||||
if response.TraceID != "trace-123" {
|
||||
t.Errorf("Expected traceID 'trace-123', got '%s'", response.TraceID)
|
||||
}
|
||||
|
||||
if len(response.Errors) != 1 {
|
||||
t.Errorf("Expected 1 error, got %d", len(response.Errors))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespondWithError_ProblemDetail(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/v1/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
problem := NewBadRequestError("Test error")
|
||||
RespondWithError(w, req, problem)
|
||||
|
||||
// Check status code
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
// Check that instance was set
|
||||
var response ProblemDetail
|
||||
json.NewDecoder(w.Body).Decode(&response)
|
||||
|
||||
if response.Instance != "/api/v1/test" {
|
||||
t.Errorf("Expected instance to be set automatically, got '%s'", response.Instance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespondWithError_StandardError(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/v1/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
err := &customError{message: "Custom error"}
|
||||
RespondWithError(w, req, err)
|
||||
|
||||
// Check status code (should be 500 for standard errors)
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, w.Code)
|
||||
}
|
||||
|
||||
// Check that it was wrapped in a ProblemDetail
|
||||
var response ProblemDetail
|
||||
json.NewDecoder(w.Body).Decode(&response)
|
||||
|
||||
if response.Type != TypeInternalError {
|
||||
t.Errorf("Expected type %s, got %s", TypeInternalError, response.Type)
|
||||
}
|
||||
|
||||
if response.Detail != "Custom error" {
|
||||
t.Errorf("Expected detail 'Custom error', got '%s'", response.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper type for testing standard error handling
|
||||
type customError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *customError) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func TestChaining(t *testing.T) {
|
||||
// Test method chaining
|
||||
problem := NewBadRequestError("Test").
|
||||
WithInstance("/api/v1/test").
|
||||
WithTraceID("trace-123").
|
||||
WithError("field1", "error1").
|
||||
WithError("field2", "error2")
|
||||
|
||||
if problem.Instance != "/api/v1/test" {
|
||||
t.Error("Instance not set correctly through chaining")
|
||||
}
|
||||
|
||||
if problem.TraceID != "trace-123" {
|
||||
t.Error("TraceID not set correctly through chaining")
|
||||
}
|
||||
|
||||
if len(problem.Errors) != 2 {
|
||||
t.Error("Errors not set correctly through chaining")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue