feat: rfc7807 JSON error responses

This commit is contained in:
Daniel G. Taylor 2020-05-02 22:17:44 -07:00
parent e8c8e64c73
commit ba2a986b7d
No known key found for this signature in database
GPG key ID: 7BD6DC99C9A87E22
12 changed files with 79 additions and 65 deletions

View file

@ -21,6 +21,7 @@ Features include:
- Request body
- Responses (including errors)
- Response headers
- JSON Errors using [RFC7807](https://tools.ietf.org/html/rfc7807) and `application/problem+json`
- Default (optional) middleware
- Automatic recovery from panics with traceback & request logging
- Automatically handle CORS headers

View file

@ -22,7 +22,7 @@ func main() {
).Put("Echo back an input word",
func(word string, greet bool) (*huma.ErrorModel, *EchoResponse) {
if word == "test" {
return &huma.ErrorModel{Message: "Value not allowed: test"}, nil
return &huma.ErrorModel{Detail: "Value not allowed: test"}, nil
}
v := word

View file

@ -73,7 +73,7 @@ func main() {
}
return &huma.ErrorModel{
Message: "Note " + id + " not found",
Detail: "Note " + id + " not found",
}, nil
},
)
@ -87,7 +87,7 @@ func main() {
}
return &huma.ErrorModel{
Message: "Note " + id + " not found",
Detail: "Note " + id + " not found",
}, false
},
)

View file

@ -71,7 +71,7 @@ func Example_readme() {
}
return &huma.ErrorModel{
Message: "Note " + id + " not found",
Detail: "Note " + id + " not found",
}, nil
},
)
@ -85,7 +85,7 @@ func Example_readme() {
}
return &huma.ErrorModel{
Message: "Note " + id + " not found",
Detail: "Note " + id + " not found",
}, false
},
)

View file

@ -302,7 +302,7 @@ func (m *MemoryStore) AutoResource(r *huma.Resource, dataStructure interface{},
loaded, ok := c.Load(id)
if !ok {
error404 = &huma.ErrorModel{
Message: cfg.single + " not found with ID: " + id,
Detail: cfg.single + " not found with ID: " + id,
}
return []interface{}{lastModified, etag, error404, notModified, item}
}
@ -376,7 +376,7 @@ func (m *MemoryStore) AutoResource(r *huma.Resource, dataStructure interface{},
}
if err := checkConditionals(existing, ifMatch, ifNoneMatch, ifUnmodified, time.Time{}); err != nil {
errorPrecondition = &huma.ErrorModel{
Message: err.Error(),
Detail: err.Error(),
}
return []interface{}{lastModified, etag, errorPrecondition, success}
}
@ -428,14 +428,14 @@ func (m *MemoryStore) AutoResource(r *huma.Resource, dataStructure interface{},
if !ok {
error404 = &huma.ErrorModel{
Message: fmt.Sprintf("%s not found: %s", cfg.single, id),
Detail: fmt.Sprintf("%s not found: %s", cfg.single, id),
}
return []interface{}{error404, errorEtag, success}
}
if err := checkConditionals(item.(map[string]interface{}), ifMatch, "", ifUnmodified, time.Time{}); err != nil {
errorEtag = &huma.ErrorModel{
Message: err.Error(),
Detail: err.Error(),
}
return []interface{}{error404, errorEtag, success}
}

View file

@ -120,9 +120,7 @@ func Recovery() Middleware {
}
}
c.AbortWithStatusJSON(http.StatusInternalServerError, &ErrorModel{
Message: "Internal server error",
})
abortWithError(c, http.StatusInternalServerError, "")
}
}()
@ -230,8 +228,11 @@ func LogDependency() DependencyOption {
// Handler404 will return JSON responses for 404 errors.
func Handler404() Handler {
return func(c *gin.Context) {
c.Header("content-type", "application/problem+json")
c.JSON(http.StatusNotFound, &ErrorModel{
Message: "Not found",
Status: http.StatusNotFound,
Title: http.StatusText(http.StatusNotFound),
Detail: "Requested: " + c.Request.Method + " " + c.Request.URL.RequestURI(),
})
}
}

View file

@ -23,7 +23,7 @@ func TestRecoveryMiddleware(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, "/panic", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "application/json; charset=utf-8", w.Result().Header.Get("content-type"))
assert.Equal(t, "application/problem+json", w.Result().Header.Get("content-type"))
}
func TestRecoveryMiddlewareString(t *testing.T) {
@ -38,7 +38,7 @@ func TestRecoveryMiddlewareString(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, "/panic", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "application/json; charset=utf-8", w.Result().Header.Get("content-type"))
assert.Equal(t, "application/problem+json", w.Result().Header.Get("content-type"))
}
func TestRecoveryMiddlewareLogBody(t *testing.T) {
@ -53,7 +53,7 @@ func TestRecoveryMiddlewareLogBody(t *testing.T) {
req, _ := http.NewRequest(http.MethodPut, "/panic", strings.NewReader(`{"foo": "bar"}`))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "application/json; charset=utf-8", w.Result().Header.Get("content-type"))
assert.Equal(t, "application/problem+json", w.Result().Header.Get("content-type"))
}
func TestPreferMinimalMiddleware(t *testing.T) {
@ -100,5 +100,5 @@ func TestHandler404(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, "/notfound", nil)
g.ServeHTTP(w, req)
assert.Equal(t, w.Result().StatusCode, http.StatusNotFound)
assert.Equal(t, "application/json; charset=utf-8", w.Result().Header.Get("content-type"))
assert.Equal(t, "application/problem+json", w.Result().Header.Get("content-type"))
}

View file

@ -2,11 +2,21 @@ package huma
// ErrorModel defines a basic error message
type ErrorModel struct {
Message string `json:"message"`
}
// ErrorInvalidModel defines an HTTP 400 Invalid response message
type ErrorInvalidModel struct {
Message string `json:"message"`
Errors []string `json:"errors"`
// Type is a URI to get more information about the error type.
Type string `json:"type,omitempty" format:"uri" default:"about:blank" example:"https://example.com/errors/example" "doc:"A URI reference to human-readable documentation for the error."`
// Title provides a short static summary of the problem. Huma will default this
// to the HTTP response status code text if not present.
Title string `json:"title,omitempty" example:"Bad Request" doc:"A short, human-readable summary of the problem type. This value should not change between occurances of the error."`
// Status provides the HTTP status code for client convenience. Huma will
// default this to the response status code if unset. This SHOULD match the
// response status code (though proxies may modify the actual status code).
Status int `json:"status,omitempty" example:"400" doc:"HTTP status code"`
// Detail is an explanation specific to this error occurrence.
Detail string `json:"detail,omitempty" example:"Property foo is required but is missing." doc:"A human-readable explanation specific to this occurrence of the problem."`
// Instance is a URI to get more info about this error occurence.
Instance string `json:"instance,omitempty" format:"uri" example:"https://example.com/error-log/abc123" doc:"A URI reference that identifies the specific occurence of the problem."`
// Errors provides an optional mechanism of passing additional error detail
// strings as a list, which tends to display better than a large multi-line
// string with many errors.
Errors []string `json:"errors,omitempty" doc:"Optional list of individual error details"`
}

View file

@ -303,7 +303,7 @@ type openAPI struct {
// openAPIHandler returns a new handler function to generate an OpenAPI spec.
func openAPIHandler(api *openAPI) gin.HandlerFunc {
respSchema400, _ := schema.Generate(reflect.ValueOf(ErrorInvalidModel{}).Type())
respSchema400, _ := schema.Generate(reflect.ValueOf(ErrorModel{}).Type())
return func(c *gin.Context) {
openapi := gabs.New()

View file

@ -527,10 +527,11 @@ func ResponseJSON(statusCode int, description string, options ...ResponseOption)
return Response(statusCode, description, options...)
}
// ResponseError adds a new error response model. Alias for ResponseJSON
// mainly useful for documentation purposes.
// ResponseError adds a new error response model. This uses the RFC7807
// application/problem+json response content type.
func ResponseError(statusCode int, description string, options ...ResponseOption) OperationOption {
return ResponseJSON(statusCode, description, options...)
options = append(options, ContentType("application/problem+json"))
return Response(statusCode, description, options...)
}
// MaxBodyBytes sets the max number of bytes read from a request body before

View file

@ -111,15 +111,24 @@ func getConn(r *http.Request) net.Conn {
return nil
}
// abortWithError is a convenience function for setting an error on a Gin
// context with a detail string and optional error strings.
func abortWithError(c *gin.Context, status int, detail string, errors ...string) {
c.Header("content-type", "application/problem+json")
c.AbortWithStatusJSON(status, &ErrorModel{
Status: status,
Title: http.StatusText(status),
Detail: detail,
Errors: errors,
})
}
// Checks if data validates against the given schema. Returns false on failure.
func validAgainstSchema(c *gin.Context, label string, schema *schema.Schema, data []byte) bool {
defer func() {
// Catch panics from the `gojsonschema` library.
if err := recover(); err != nil {
c.AbortWithStatusJSON(400, &ErrorInvalidModel{
Message: "Invalid input: " + label,
Errors: []string{err.(error).Error() + ": " + string(data)},
})
abortWithError(c, http.StatusBadRequest, "Invalid input: "+label, err.(error).Error()+": "+string(data))
}
}()
@ -139,10 +148,7 @@ func validAgainstSchema(c *gin.Context, label string, schema *schema.Schema, dat
for _, desc := range result.Errors() {
errors = append(errors, fmt.Sprintf("%s", desc))
}
c.AbortWithStatusJSON(400, &ErrorInvalidModel{
Message: "Invalid input: " + label,
Errors: errors,
})
abortWithError(c, http.StatusBadRequest, "Invalid input: "+label, errors...)
return false
}
@ -155,9 +161,7 @@ func parseParamValue(c *gin.Context, name string, typ reflect.Type, timeFormat s
case reflect.Bool:
converted, err := strconv.ParseBool(pstr)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, &ErrorModel{
Message: fmt.Sprintf("cannot parse boolean for param %s: %s", name, pstr),
})
abortWithError(c, http.StatusBadRequest, fmt.Sprintf("cannot parse boolean for param %s: %s", name, pstr))
return nil, false
}
pv = converted
@ -165,27 +169,21 @@ func parseParamValue(c *gin.Context, name string, typ reflect.Type, timeFormat s
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
converted, err := strconv.Atoi(pstr)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, &ErrorModel{
Message: fmt.Sprintf("cannot parse integer for param %s: %s", name, pstr),
})
abortWithError(c, http.StatusBadRequest, fmt.Sprintf("cannot parse integer for param %s: %s", name, pstr))
return nil, false
}
pv = reflect.ValueOf(converted).Convert(typ).Interface()
case reflect.Float32:
converted, err := strconv.ParseFloat(pstr, 32)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, &ErrorModel{
Message: fmt.Sprintf("cannot parse float for param %s: %s", name, pstr),
})
abortWithError(c, http.StatusBadRequest, fmt.Sprintf("cannot parse float for param %s: %s", name, pstr))
return nil, false
}
pv = float32(converted)
case reflect.Float64:
converted, err := strconv.ParseFloat(pstr, 64)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, &ErrorModel{
Message: fmt.Sprintf("cannot parse float for param %s: %s", name, pstr),
})
abortWithError(c, http.StatusBadRequest, fmt.Sprintf("cannot parse float for param %s: %s", name, pstr))
return nil, false
}
pv = converted
@ -207,9 +205,7 @@ func parseParamValue(c *gin.Context, name string, typ reflect.Type, timeFormat s
if typ == timeType {
dt, err := time.Parse(timeFormat, pstr)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, &ErrorModel{
Message: fmt.Sprintf("cannot parse time for param %s: %s", name, pstr),
})
abortWithError(c, http.StatusBadRequest, fmt.Sprintf("cannot parse time for param %s: %s", name, pstr))
return nil, false
}
pv = dt
@ -291,13 +287,9 @@ func getRequestBody(c *gin.Context, t reflect.Type, op *openAPIOperation) (inter
body, err := ioutil.ReadAll(c.Request.Body)
if err != nil {
if strings.Contains(err.Error(), "request body too large") {
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, ErrorModel{
Message: fmt.Sprintf("Request body too large, limit = %d bytes", op.maxBodyBytes),
})
abortWithError(c, http.StatusRequestEntityTooLarge, fmt.Sprintf("Request body too large, limit = %d bytes", op.maxBodyBytes))
} else if e, ok := err.(net.Error); ok && e.Timeout() {
c.AbortWithStatusJSON(http.StatusRequestTimeout, ErrorModel{
Message: fmt.Sprintf("Request body took too long to read: timed out after %v", op.bodyReadTimeout),
})
abortWithError(c, http.StatusRequestTimeout, fmt.Sprintf("Request body took too long to read: timed out after %v", op.bodyReadTimeout))
} else {
panic(err)
}
@ -482,9 +474,7 @@ func (r *Router) register(method, path string, op *openAPIOperation) {
if !c.IsAborted() {
// Nothing else has handled the error, so treat it like a general
// internal server error.
c.AbortWithStatusJSON(500, &ErrorModel{
Message: "Couldn't get dependency",
})
abortWithError(c, http.StatusInternalServerError, "Couldn't get dependency")
}
}
@ -614,9 +604,20 @@ func (r *Router) register(method, path string, op *openAPIOperation) {
break
}
if strings.HasPrefix(r.ContentType, "application/json") {
if err, ok := body.(*ErrorModel); ok {
// This is an error response. Automatically set some values if missing.
if err.Status == 0 {
err.Status = r.StatusCode
}
if err.Title == "" {
err.Title = http.StatusText(r.StatusCode)
}
}
if strings.HasPrefix(r.ContentType, "application/json") || strings.HasSuffix(r.ContentType, "+json") {
c.JSON(r.StatusCode, body)
} else if strings.HasPrefix(r.ContentType, "application/yaml") {
} else if strings.HasPrefix(r.ContentType, "application/yaml") || strings.HasSuffix(r.ContentType, "+yaml") {
c.YAML(r.StatusCode, body)
} else {
if o.Kind() == reflect.Ptr {

View file

@ -108,7 +108,7 @@ func BenchmarkGinComplex(b *testing.B) {
name := c.Query("name")
if name == "test" {
c.JSON(400, &ErrorModel{
Message: "Name cannot be test",
Detail: "Name cannot be test",
})
}
if name == "" {
@ -155,7 +155,7 @@ func BenchmarkHumaComplex(b *testing.B) {
).Get("Greet the world", func(c *gin.Context, d2, d3, name string) (string, *helloResponse, *ErrorModel) {
if name == "test" {
return "", nil, &ErrorModel{
Message: "Name cannot be test",
Detail: "Name cannot be test",
}
}
@ -192,7 +192,7 @@ func TestRouter(t *testing.T) {
ResponseError(http.StatusBadRequest, "Invalid input"),
).Put("Echo back an input word.", func(word string, greet bool) (*EchoResponse, *ErrorModel) {
if word == "test" {
return nil, &ErrorModel{Message: "Value not allowed: test"}
return nil, &ErrorModel{Detail: "Value not allowed: test"}
}
v := word