Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

cloudapi: more details for non-service errors #4227

Merged
merged 4 commits into from
Jun 25, 2024
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
59 changes: 28 additions & 31 deletions internal/cloudapi/v2/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,12 @@ func HTTPError(code ServiceErrorCode) error {

// echo.HTTPError has a message interface{} field, which can be used to include the ServiceErrorCode
func HTTPErrorWithInternal(code ServiceErrorCode, internalErr error) error {
se := find(code)
he := echo.NewHTTPError(se.httpStatus, detailsError{code, ""})
de := detailsError{code, ""}
if internalErr != nil {
de.details = internalErr.Error()
}

he := echo.NewHTTPError(find(code).httpStatus, de)
if internalErr != nil {
he.Internal = internalErr
}
Expand All @@ -202,28 +206,26 @@ func HTTPErrorWithDetails(code ServiceErrorCode, internalErr error, details stri

// Convert a ServiceErrorCode into an Error as defined in openapi.v2.yml
// serviceError is optional, prevents multiple find() calls
func APIError(code ServiceErrorCode, serviceError *serviceError, c echo.Context, details *interface{}) *Error {
se := serviceError
if se == nil {
se = find(code)
}

func APIError(serviceError *serviceError, c echo.Context, details interface{}) *Error {
operationID, ok := c.Get("operationID").(string)
if !ok || operationID == "" {
se = find(ErrorMalformedOperationID)
serviceError = find(ErrorMalformedOperationID)
}

return &Error{
apiErr := &Error{
ObjectReference: ObjectReference{
Href: fmt.Sprintf("%s/%d", ErrorHREF, se.code),
Id: fmt.Sprintf("%d", se.code),
Href: fmt.Sprintf("%s/%d", ErrorHREF, serviceError.code),
Id: fmt.Sprintf("%d", serviceError.code),
Kind: "Error",
},
Code: fmt.Sprintf("%s%d", ErrorCodePrefix, se.code),
Code: fmt.Sprintf("%s%d", ErrorCodePrefix, serviceError.code),
OperationId: operationID, // set operation id from context
Reason: se.reason,
Details: details,
Reason: serviceError.reason,
}
if details != nil {
apiErr.Details = &details
}
return apiErr
}

// Helper to make the ErrorList as defined in openapi.v2.yml
Expand Down Expand Up @@ -253,7 +255,7 @@ func APIErrorList(page int, pageSize int, c echo.Context) *ErrorList {
for _, e := range errs {
// Implicit memory alasing doesn't couse any bug in this case
/* #nosec G601 */
list.Items = append(list.Items, *APIError(e.code, &e, c, nil))
list.Items = append(list.Items, *APIError(&e, c, nil))
}
list.Size = len(list.Items)
return list
Expand All @@ -273,15 +275,14 @@ func apiErrorFromEchoError(echoError *echo.HTTPError) ServiceErrorCode {
}

// Convert an echo error into an AOC compliant one so we send a correct json error response
func (s *Server) HTTPErrorHandler(echoError error, c echo.Context) {
doResponse := func(details *interface{}, code ServiceErrorCode, c echo.Context, internal error) {
// don't anticipate serviceerrorcode, instead check what type it is
func HTTPErrorHandler(echoError error, c echo.Context) {
doResponse := func(details interface{}, code ServiceErrorCode, c echo.Context, internal error) {
if !c.Response().Committed {
var err error
sec := find(code)
apiErr := APIError(code, sec, c, details)
se := find(code)
apiErr := APIError(se, c, details)

if sec.httpStatus == http.StatusInternalServerError {
if se.httpStatus == http.StatusInternalServerError {
errMsg := fmt.Sprintf("Internal server error. Code: %s, OperationId: %s", apiErr.Code, apiErr.OperationId)

if internal != nil {
Expand All @@ -292,9 +293,9 @@ func (s *Server) HTTPErrorHandler(echoError error, c echo.Context) {
}

if c.Request().Method == http.MethodHead {
err = c.NoContent(sec.httpStatus)
err = c.NoContent(se.httpStatus)
} else {
err = c.JSON(sec.httpStatus, apiErr)
err = c.JSON(se.httpStatus, apiErr)
}
if err != nil {
c.Logger().Errorf("Failed to return error response: %v", err)
Expand All @@ -307,19 +308,15 @@ func (s *Server) HTTPErrorHandler(echoError error, c echo.Context) {
he, ok := echoError.(*echo.HTTPError)
if !ok {
c.Logger().Errorf("ErrorNotHTTPError %v", echoError)
doResponse(nil, ErrorNotHTTPError, c, echoError)
doResponse(echoError.Error(), ErrorNotHTTPError, c, echoError)
return
}

err, ok := he.Message.(detailsError)
if !ok {
// No service code was set, so Echo threw this error
doResponse(nil, apiErrorFromEchoError(he), c, he.Internal)
doResponse(he.Error(), apiErrorFromEchoError(he), c, he.Internal)
return
}
var det *interface{}
if err.details != nil {
det = &err.details
}
doResponse(det, err.errorCode, c, he.Internal)
doResponse(err.details, err.errorCode, c, he.Internal)
}
119 changes: 114 additions & 5 deletions internal/cloudapi/v2/errors_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package v2

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"

"github.com/labstack/echo/v4"
Expand All @@ -22,10 +25,11 @@ func TestHTTPErrorReturnsEchoHTTPError(t *testing.T) {

func TestAPIError(t *testing.T) {
e := echo.New()
for _, se := range getServiceErrors() {
for _, svcErr := range getServiceErrors() {
ctx := e.NewContext(nil, nil)
ctx.Set("operationID", "test-operation-id")
apiError := APIError(se.code, nil, ctx, nil)
se := svcErr // avoid G601
apiError := APIError(&se, ctx, nil)
require.Equal(t, fmt.Sprintf("/api/image-builder-composer/v2/errors/%d", se.code), apiError.Href)
require.Equal(t, fmt.Sprintf("%d", se.code), apiError.Id)
require.Equal(t, "Error", apiError.Kind)
Expand All @@ -38,15 +42,15 @@ func TestAPIError(t *testing.T) {
func TestAPIErrorOperationID(t *testing.T) {
ctx := echo.New().NewContext(nil, nil)

apiError := APIError(ErrorUnauthenticated, nil, ctx, nil)
apiError := APIError(find(ErrorUnauthenticated), ctx, nil)
require.Equal(t, "IMAGE-BUILDER-COMPOSER-10003", apiError.Code)

ctx.Set("operationID", 5)
apiError = APIError(ErrorUnauthenticated, nil, ctx, nil)
apiError = APIError(find(ErrorUnauthenticated), ctx, nil)
require.Equal(t, "IMAGE-BUILDER-COMPOSER-10003", apiError.Code)

ctx.Set("operationID", "test-operation-id")
apiError = APIError(ErrorUnauthenticated, nil, ctx, nil)
apiError = APIError(find(ErrorUnauthenticated), ctx, nil)
require.Equal(t, "IMAGE-BUILDER-COMPOSER-401", apiError.Code)
}

Expand Down Expand Up @@ -93,3 +97,108 @@ func TestAPIErrorList(t *testing.T) {
require.Equal(t, len(getServiceErrors()), errs.Total)
require.Equal(t, 1000, errs.Page)
}

func TestHTTPErrorHandler(t *testing.T) {
e := echo.New()

// HTTPError
{
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.Set("operationID", "opid")
HTTPErrorHandler(HTTPError(ErrorEnqueueingJob), c)
require.Equal(t, find(ErrorEnqueueingJob).httpStatus, rec.Code)
var apiErr Error
require.NoError(t, json.NewDecoder(rec.Body).Decode(&apiErr))
require.NotNil(t, apiErr)
require.Equal(t, "opid", apiErr.OperationId)
require.Equal(t, find(ErrorEnqueueingJob).reason, apiErr.Reason)
require.Empty(t, *apiErr.Details)
}

// HTTPErrorWithInternal
{
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.Set("operationID", "opid")
err := fmt.Errorf("some more details")
HTTPErrorHandler(HTTPErrorWithInternal(ErrorEnqueueingJob, err), c)
require.Equal(t, find(ErrorEnqueueingJob).httpStatus, rec.Code)
var apiErr Error
require.NoError(t, json.NewDecoder(rec.Body).Decode(&apiErr))
require.NotNil(t, apiErr)
require.Equal(t, "opid", apiErr.OperationId)
require.Equal(t, find(ErrorEnqueueingJob).reason, apiErr.Reason)
require.Equal(t, err.Error(), *apiErr.Details)
}

// HTTPErrorWithDetails
// internalErr gets ignored for explicit details
{
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.Set("operationID", "opid")
err := fmt.Errorf("some more details")
HTTPErrorHandler(HTTPErrorWithDetails(ErrorEnqueueingJob, err, "even more extra details"), c)
require.Equal(t, find(ErrorEnqueueingJob).httpStatus, rec.Code)
var apiErr Error
require.NoError(t, json.NewDecoder(rec.Body).Decode(&apiErr))
require.NotNil(t, apiErr)
require.Equal(t, "opid", apiErr.OperationId)
require.Equal(t, find(ErrorEnqueueingJob).reason, apiErr.Reason)
require.Equal(t, "even more extra details", *apiErr.Details)
}

// echo.HTTPError
{
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.Set("operationID", "opid")
err := fmt.Errorf("some unexpected internal http error")
HTTPErrorHandler(echo.NewHTTPError(http.StatusInternalServerError, err), c)
require.Equal(t, find(ErrorUnspecified).httpStatus, rec.Code)
var apiErr Error
require.NoError(t, json.NewDecoder(rec.Body).Decode(&apiErr))
require.NotNil(t, apiErr)
require.Equal(t, "opid", apiErr.OperationId)
require.Equal(t, find(ErrorUnspecified).reason, apiErr.Reason)
require.Equal(t, "code=500, message=some unexpected internal http error", *apiErr.Details)
}

// echo.HTTPError and internalErr is nil
{
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.Set("operationID", "opid")
HTTPErrorHandler(echo.NewHTTPError(http.StatusInternalServerError, nil), c)
require.Equal(t, find(ErrorUnspecified).httpStatus, rec.Code)
var apiErr Error
require.NoError(t, json.NewDecoder(rec.Body).Decode(&apiErr))
require.NotNil(t, apiErr)
require.Equal(t, "opid", apiErr.OperationId)
require.Equal(t, find(ErrorUnspecified).reason, apiErr.Reason)
require.Equal(t, "code=500, message=<nil>", *apiErr.Details)
}

// plain error
{
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.Set("operationID", "opid")
err := fmt.Errorf("some unexpected internal error")
HTTPErrorHandler(err, c)
require.Equal(t, find(ErrorNotHTTPError).httpStatus, rec.Code)
var apiErr Error
require.NoError(t, json.NewDecoder(rec.Body).Decode(&apiErr))
require.NotNil(t, apiErr)
require.Equal(t, "opid", apiErr.OperationId)
require.Equal(t, find(ErrorNotHTTPError).reason, apiErr.Reason)
require.Equal(t, "some unexpected internal error", *apiErr.Details)
}
}
2 changes: 1 addition & 1 deletion internal/cloudapi/v2/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func (h *apiHandlers) GetError(ctx echo.Context, id string) error {
return HTTPError(ErrorInvalidErrorId)
}

apiError := APIError(ServiceErrorCode(errorId), nil, ctx, nil)
apiError := APIError(find(ServiceErrorCode(errorId)), ctx, nil)
// If the service error wasn't found, it's a 404 in this instance
if apiError.Id == fmt.Sprintf("%d", ErrorServiceErrorNotFound) {
return HTTPError(ErrorErrorNotFound)
Expand Down
2 changes: 1 addition & 1 deletion internal/cloudapi/v2/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func NewServer(workers *worker.Server, distros *distrofactory.Factory, repos *re
func (s *Server) Handler(path string) http.Handler {
e := echo.New()
e.Binder = binder{}
e.HTTPErrorHandler = s.HTTPErrorHandler
e.HTTPErrorHandler = HTTPErrorHandler
e.Logger = common.Logger()

// OperationIDMiddleware - generates OperationID random string and puts it into the contexts
Expand Down
2 changes: 1 addition & 1 deletion internal/cloudapi/v2/v2_koji_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,6 @@ func TestKojiJobTypeValidation(t *testing.T) {
}

badID := uuid.New()
test.TestRoute(t, handler, false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%s%s", badID, path), ``, http.StatusNotFound, `{"code":"IMAGE-BUILDER-COMPOSER-15", "details": "", "href":"/api/image-builder-composer/v2/errors/15","id":"15","kind":"Error","reason":"Compose with given id not found"}`, `operation_id`)
test.TestRoute(t, handler, false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%s%s", badID, path), ``, http.StatusNotFound, `{"code":"IMAGE-BUILDER-COMPOSER-15", "details": "job does not exist", "href":"/api/image-builder-composer/v2/errors/15","id":"15","kind":"Error","reason":"Compose with given id not found"}`, `operation_id`)
}
}
Loading