Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 76 additions & 26 deletions openapi/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.0.0
info:
title: HyperFleet API
version: 1.0.2
version: 1.0.3
contact:
name: HyperFleet Team
license:
Expand Down Expand Up @@ -37,7 +37,7 @@ paths:
default:
description: An unexpected error response.
content:
application/json:
application/problem+json:
schema:
$ref: '#/components/schemas/Error'
security:
Expand All @@ -64,7 +64,7 @@ paths:
default:
description: An unexpected error response.
content:
application/json:
application/problem+json:
schema:
$ref: '#/components/schemas/Error'
requestBody:
Expand Down Expand Up @@ -98,7 +98,7 @@ paths:
default:
description: An unexpected error response.
content:
application/json:
application/problem+json:
schema:
$ref: '#/components/schemas/Error'
security:
Expand Down Expand Up @@ -132,7 +132,7 @@ paths:
default:
description: An unexpected error response.
content:
application/json:
application/problem+json:
schema:
$ref: '#/components/schemas/Error'
security:
Expand Down Expand Up @@ -160,7 +160,7 @@ paths:
default:
description: An unexpected error response.
content:
application/json:
application/problem+json:
schema:
$ref: '#/components/schemas/Error'
requestBody:
Expand Down Expand Up @@ -201,7 +201,7 @@ paths:
default:
description: An unexpected error response.
content:
application/json:
application/problem+json:
schema:
$ref: '#/components/schemas/Error'
security:
Expand Down Expand Up @@ -280,7 +280,7 @@ paths:
default:
description: An unexpected error response.
content:
application/json:
application/problem+json:
schema:
$ref: '#/components/schemas/Error'
/api/hyperfleet/v1/clusters/{cluster_id}/statuses:
Expand Down Expand Up @@ -373,7 +373,7 @@ paths:
default:
description: An unexpected error response.
content:
application/json:
application/problem+json:
schema:
$ref: '#/components/schemas/Error'
security:
Expand Down Expand Up @@ -916,33 +916,83 @@ components:
description: Status value for conditions
Error:
type: object
description: RFC 9457 Problem Details error format with HyperFleet extensions
required:
- type
- title
- status
properties:
id:
type:
type: string
kind:
format: uri
description: URI reference identifying the problem type
example: https://api.hyperfleet.io/errors/validation-error
title:
type: string
description: Resource kind
href:
description: Short human-readable summary of the problem
example: Validation Failed
status:
type: integer
description: HTTP status code
example: 400
detail:
type: string
description: Resource URI
description: Human-readable explanation specific to this occurrence
example: The cluster name field is required
instance:
type: string
format: uri
description: URI reference for this specific occurrence
example: /api/hyperfleet/v1/clusters
code:
type: string
reason:
description: Machine-readable error code in HYPERFLEET-CAT-NUM format
example: HYPERFLEET-VAL-001
timestamp:
type: string
operation_id:
format: date-time
description: RFC3339 timestamp of when the error occurred
example: '2024-01-15T10:30:00Z'
trace_id:
type: string
details:
description: Distributed trace ID for correlation
example: abc123def456
errors:
type: array
description: Field-level validation errors (for validation failures)
items:
type: object
properties:
field:
type: string
description: Field path that failed validation
error:
type: string
description: Validation error message for this field
description: Field-level validation errors (optional)
$ref: '#/components/schemas/ValidationError'
ValidationError:
type: object
description: Field-level validation error detail
required:
- field
- message
properties:
field:
type: string
description: JSON path to the field that failed validation
example: spec.name
value:
description: The invalid value that was provided (if safe to include)
constraint:
type: string
description: The validation constraint that was violated
enum:
- required
- min
- max
- min_length
- max_length
- pattern
- enum
- format
- unique
example: required
message:
type: string
description: Human-readable error message for this field
example: Cluster name is required
NodePool:
type: object
required:
Expand Down
127 changes: 84 additions & 43 deletions pkg/api/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,70 +6,111 @@ import (
"fmt"
"net/http"
"os"
"time"

"github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger"
)

// SendNotFound sends a 404 response with some details about the non existing resource.
// SendNotFound sends a 404 response in RFC 9457 Problem Details format.
func SendNotFound(w http.ResponseWriter, r *http.Request) {
// Set the content type:
w.Header().Set("Content-Type", "application/json")

// Prepare the body:
id := "404"
reason := fmt.Sprintf(
"The requested resource '%s' doesn't exist",
r.URL.Path,
)
body := Error{
Type: ErrorType,
ID: id,
HREF: "/api/hyperfleet/v1/errors/" + id,
Code: "hyperfleet-" + id,
Reason: reason,
w.Header().Set("Content-Type", "application/problem+json")

traceID, _ := logger.GetRequestID(r.Context())
now := time.Now().UTC()
detail := fmt.Sprintf("The requested resource '%s' doesn't exist", r.URL.Path)

body := openapi.Error{
Type: errors.ErrorTypeNotFound,
Title: "Resource Not Found",
Status: http.StatusNotFound,
Detail: &detail,
Instance: &r.URL.Path,
Code: ptrString(errors.CodeNotFoundGeneric),
Timestamp: &now,
TraceId: &traceID,
}

data, err := json.Marshal(body)
if err != nil {
logger.WithError(r.Context(), err).Error("Failed to marshal not found response")
SendPanic(w, r)
return
}

// Send the response:
w.WriteHeader(http.StatusNotFound)
_, err = w.Write(data)
if err != nil {
err = fmt.Errorf("can't send response body for request '%s'", r.URL.Path)
logger.WithError(r.Context(), err).Error("Failed to send response")
return
}
}

// SendUnauthorized sends a 401 response in RFC 9457 Problem Details format.
func SendUnauthorized(w http.ResponseWriter, r *http.Request, message string) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Type", "application/problem+json")

traceID, _ := logger.GetRequestID(r.Context())
now := time.Now().UTC()

body := openapi.Error{
Type: errors.ErrorTypeAuth,
Title: "Authentication Required",
Status: http.StatusUnauthorized,
Detail: &message,
Instance: &r.URL.Path,
Code: ptrString(errors.CodeAuthNoCredentials),
Timestamp: &now,
TraceId: &traceID,
}

// Prepare the body:
apiError := errors.Unauthorized("%s", message)
data, err := json.Marshal(apiError)
data, err := json.Marshal(body)
if err != nil {
logger.WithError(r.Context(), err).Error("Failed to marshal unauthorized response")
SendPanic(w, r)
return
}

// Send the response:
w.WriteHeader(http.StatusUnauthorized)
_, err = w.Write(data)
if err != nil {
err = fmt.Errorf("can't send response body for request '%s'", r.URL.Path)
logger.WithError(r.Context(), err).Error("Failed to send response")
return
}
}

// SendPanic sends a panic error response to the client, but it doesn't end the process.
// SendPanic sends a panic error response in RFC 9457 Problem Details format.
// It attempts to include trace_id and timestamp dynamically, falling back to
// a pre-computed body if marshaling fails.
func SendPanic(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, err := w.Write(panicBody)
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(http.StatusInternalServerError)

// Try to generate a complete response with trace_id and timestamp
traceID, _ := logger.GetRequestID(r.Context())
now := time.Now().UTC()
detail := "An unexpected error happened, please check the log of the service for details"
instance := r.URL.Path

panicError := openapi.Error{
Type: errors.ErrorTypeInternal,
Title: "Internal Server Error",
Status: http.StatusInternalServerError,
Detail: &detail,
Instance: &instance,
Code: ptrString(errors.CodeInternalGeneral),
Timestamp: &now,
TraceId: &traceID,
}

data, err := json.Marshal(panicError)
if err != nil {
// Fallback to pre-computed body without trace_id/timestamp
data = panicBody
}

_, err = w.Write(data)
if err != nil {
err = fmt.Errorf(
"can't send panic response for request '%s': %s",
Expand All @@ -81,33 +122,33 @@ func SendPanic(w http.ResponseWriter, r *http.Request) {
}

// panicBody is the error body that will be sent when something unexpected happens while trying to
// send another error response. For example, if sending an error response fails because the error
// description can't be converted to JSON.
// send another error response.
var panicBody []byte

func init() {
ctx := context.Background()
var err error

// Create the panic error body:
panicID := "1000"
panicError := Error{
Type: ErrorType,
ID: panicID,
HREF: "/api/hyperfleet/v1/" + panicID,
Code: "hyperfleet-" + panicID,
Reason: "An unexpected error happened, please check the log of the service " +
"for details",
detail := "An unexpected error happened, please check the log of the service for details"
instance := "/api/hyperfleet/v1"

panicError := openapi.Error{
Type: errors.ErrorTypeInternal,
Title: "Internal Server Error",
Status: http.StatusInternalServerError,
Detail: &detail,
Instance: &instance,
Code: ptrString(errors.CodeInternalGeneral),
}

// Convert it to JSON:
panicBody, err = json.Marshal(panicError)
if err != nil {
err = fmt.Errorf(
"can't create the panic error body: %s",
err.Error(),
)
err = fmt.Errorf("can't create the panic error body: %s", err.Error())
logger.WithError(ctx, err).Error("Failed to create panic error body")
os.Exit(1)
}
}

func ptrString(s string) *string {
return &s
}
13 changes: 0 additions & 13 deletions pkg/api/error_types.go

This file was deleted.

5 changes: 3 additions & 2 deletions pkg/api/presenters/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors"
)

func PresentError(err *errors.ServiceError) openapi.Error {
return err.AsOpenapiError("")
// PresentError converts a ServiceError to RFC 9457 Problem Details format
func PresentError(err *errors.ServiceError, instance string, traceID string) openapi.Error {
return err.AsProblemDetails(instance, traceID)
}
Loading