mirror of
https://github.com/Fishwaldo/huma.git
synced 2025-03-15 19:31:27 +00:00
feat: rfc7807 JSON error responses
This commit is contained in:
parent
e8c8e64c73
commit
ba2a986b7d
12 changed files with 79 additions and 65 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
},
|
||||
)
|
||||
|
|
|
@ -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
|
||||
},
|
||||
)
|
||||
|
|
|
@ -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}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"))
|
||||
}
|
||||
|
|
24
models.go
24
models.go
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
69
router.go
69
router.go
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue