* See how Boundary would look with https://github.com/mvdan/gofumpt applied Results of running `gofumpt -w -s .` * Update tools file and Makefile for gofumpt Co-authored-by: Jeff Mitchell <jeffrey.mitchell@gmail.com>
208 lines
6.5 KiB
Go
208 lines
6.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
|
"github.com/hashicorp/boundary/internal/errors"
|
|
pb "github.com/hashicorp/boundary/internal/gen/controller/api"
|
|
"github.com/hashicorp/go-hclog"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
const (
|
|
genericUniquenessMsg = "Invalid request. Request attempted to make second resource with the same field value that must be unique."
|
|
genericNotFoundMsg = "Unable to find requested resource."
|
|
)
|
|
|
|
type apiError struct {
|
|
status int32
|
|
inner *pb.Error
|
|
}
|
|
|
|
func (e *apiError) Error() string {
|
|
res := fmt.Sprintf("Status: %d, Kind: %q, Error: %q", e.status, e.inner.GetKind(), e.inner.GetMessage())
|
|
var dets []string
|
|
for _, rf := range e.inner.GetDetails().GetRequestFields() {
|
|
dets = append(dets, fmt.Sprintf("{name: %q, desc: %q}", rf.GetName(), rf.GetDescription()))
|
|
}
|
|
if len(dets) > 0 {
|
|
det := strings.Join(dets, ", ")
|
|
res = fmt.Sprintf("%s, Details: {%s}", res, det)
|
|
}
|
|
return res
|
|
}
|
|
|
|
func (e *apiError) Is(target error) bool {
|
|
var tApiErr *apiError
|
|
if !errors.As(target, &tApiErr) {
|
|
return false
|
|
}
|
|
return tApiErr.inner.Kind == e.inner.Kind && tApiErr.status == e.status
|
|
}
|
|
|
|
// ApiErrorWithCode returns an api error with the provided code.
|
|
func ApiErrorWithCode(c codes.Code) error {
|
|
return &apiError{
|
|
status: int32(runtime.HTTPStatusFromCode(c)),
|
|
inner: &pb.Error{
|
|
Kind: c.String(),
|
|
},
|
|
}
|
|
}
|
|
|
|
// ApiErrorWithCodeAndMessage returns an api error with the provided code and message.
|
|
func ApiErrorWithCodeAndMessage(c codes.Code, msg string, args ...interface{}) error {
|
|
return &apiError{
|
|
status: int32(runtime.HTTPStatusFromCode(c)),
|
|
inner: &pb.Error{
|
|
Kind: c.String(),
|
|
Message: fmt.Sprintf(msg, args...),
|
|
},
|
|
}
|
|
}
|
|
|
|
// NotFoundError returns an ApiError indicating a resource couldn't be found.
|
|
func NotFoundError() error {
|
|
return &apiError{
|
|
status: http.StatusNotFound,
|
|
inner: &pb.Error{
|
|
Kind: codes.NotFound.String(),
|
|
Message: "Resource not found.",
|
|
},
|
|
}
|
|
}
|
|
|
|
// NotFoundErrorf returns an ApiError indicating a resource couldn't be found.
|
|
func NotFoundErrorf(msg string, a ...interface{}) error {
|
|
return &apiError{
|
|
status: http.StatusNotFound,
|
|
inner: &pb.Error{
|
|
Kind: codes.NotFound.String(),
|
|
Message: fmt.Sprintf(msg, a...),
|
|
},
|
|
}
|
|
}
|
|
|
|
func ForbiddenError() error {
|
|
return &apiError{
|
|
status: http.StatusForbidden,
|
|
inner: &pb.Error{
|
|
Kind: codes.PermissionDenied.String(),
|
|
Message: "Forbidden.",
|
|
},
|
|
}
|
|
}
|
|
|
|
func UnauthenticatedError() error {
|
|
return &apiError{
|
|
status: http.StatusUnauthorized,
|
|
inner: &pb.Error{
|
|
Kind: codes.Unauthenticated.String(),
|
|
Message: "Unauthenticated, or invalid token.",
|
|
},
|
|
}
|
|
}
|
|
|
|
func InvalidArgumentErrorf(msg string, fields map[string]string) error {
|
|
err := ApiErrorWithCodeAndMessage(codes.InvalidArgument, msg)
|
|
var apiErr *apiError
|
|
if !errors.As(err, &apiErr) {
|
|
hclog.L().Error("Unable to build invalid argument api error.", "original error", err)
|
|
}
|
|
|
|
if len(fields) > 0 {
|
|
apiErr.inner.Details = &pb.ErrorDetails{}
|
|
}
|
|
for k, v := range fields {
|
|
apiErr.inner.Details.RequestFields = append(apiErr.inner.Details.RequestFields, &pb.FieldError{Name: k, Description: v})
|
|
}
|
|
sort.Slice(apiErr.inner.GetDetails().GetRequestFields(), func(i, j int) bool {
|
|
return apiErr.inner.Details.RequestFields[i].GetName() < apiErr.inner.Details.RequestFields[j].GetName()
|
|
})
|
|
return apiErr
|
|
}
|
|
|
|
// Converts a known errors into an error that can presented to an end user over the API.
|
|
func backendErrorToApiError(inErr error) error {
|
|
stErr := status.Convert(inErr)
|
|
|
|
switch {
|
|
case errors.Is(inErr, runtime.ErrNotMatch):
|
|
// grpc gateway uses this error when the path was not matched, but the error uses codes.Unimplemented which doesn't match the intention.
|
|
// Overwrite the error to match our expected behavior.
|
|
return &apiError{
|
|
status: http.StatusNotFound,
|
|
inner: &pb.Error{
|
|
Kind: codes.NotFound.String(),
|
|
Message: http.StatusText(http.StatusNotFound),
|
|
},
|
|
}
|
|
case status.Code(inErr) == codes.Unimplemented:
|
|
// Instead of returning a 501 we always want to return a 405 when a method isn't implemented.
|
|
return &apiError{
|
|
status: http.StatusMethodNotAllowed,
|
|
inner: &pb.Error{
|
|
Kind: codes.Unimplemented.String(),
|
|
Message: stErr.Message(),
|
|
},
|
|
}
|
|
case errors.Is(inErr, errors.ErrRecordNotFound), errors.Match(errors.T(errors.RecordNotFound), inErr):
|
|
return NotFoundErrorf(genericNotFoundMsg)
|
|
case errors.Is(inErr, errors.ErrInvalidFieldMask), errors.Is(inErr, errors.ErrEmptyFieldMask),
|
|
errors.Match(errors.T(errors.InvalidFieldMask), inErr), errors.Match(errors.T(errors.EmptyFieldMask), inErr):
|
|
return InvalidArgumentErrorf("Error in provided request", map[string]string{"update_mask": "Invalid update mask provided."})
|
|
case errors.IsUniqueError(inErr), errors.Is(inErr, errors.ErrNotUnique):
|
|
return InvalidArgumentErrorf(genericUniquenessMsg, nil)
|
|
}
|
|
|
|
// We haven't been able to identify what this backend error is, return it as an internal error
|
|
// TODO: Don't return potentially sensitive information (like which user id an account
|
|
// is already associated with when attempting to re-associate it).
|
|
return &apiError{
|
|
status: http.StatusInternalServerError,
|
|
inner: &pb.Error{Kind: codes.Internal.String(), Message: inErr.Error()},
|
|
}
|
|
}
|
|
|
|
func ErrorHandler(logger hclog.Logger) runtime.ErrorHandlerFunc {
|
|
const errorFallback = `{"error": "failed to marshal error message"}`
|
|
return func(ctx context.Context, _ *runtime.ServeMux, mar runtime.Marshaler, w http.ResponseWriter, r *http.Request, inErr error) {
|
|
// API specified error, otherwise we need to translate repo/db errors.
|
|
var apiErr *apiError
|
|
isApiErr := errors.As(inErr, &apiErr)
|
|
if !isApiErr {
|
|
if err := backendErrorToApiError(inErr); err != nil && !errors.As(err, &apiErr) {
|
|
logger.Error("failed to cast error to api error", "error", err)
|
|
}
|
|
}
|
|
|
|
if apiErr.status == http.StatusInternalServerError {
|
|
logger.Error("internal error returned", "error", inErr)
|
|
}
|
|
|
|
buf, merr := mar.Marshal(apiErr.inner)
|
|
if merr != nil {
|
|
logger.Error("failed to marshal error response", "response", fmt.Sprintf("%#v", apiErr.inner), "error", merr)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
if _, err := io.WriteString(w, errorFallback); err != nil {
|
|
logger.Error("failed to write response", "error", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", mar.ContentType(apiErr.inner))
|
|
w.WriteHeader(int(apiErr.status))
|
|
if _, err := w.Write(buf); err != nil {
|
|
logger.Error("failed to send response chunk", "error", err)
|
|
return
|
|
}
|
|
}
|
|
}
|