diff --git a/README.md b/README.md index fd9c371..74ef6f6 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Features include: - Automatic handling of `Prefer: return=minimal` from [RFC 7240](https://tools.ietf.org/html/rfc7240#section-4.2) - Per-operation request size limits & timeouts with sane defaults - [Content negotiation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation) between server and client - - Support for GZip ([RFC 1952](https://tools.ietf.org/html/rfc1952)) & Brotli ([RFC 7932](https://tools.ietf.org/html/rfc7932)) content encoding via the `Accept-Encoding` header. + - Support for gzip ([RFC 1952](https://tools.ietf.org/html/rfc1952)) & Brotli ([RFC 7932](https://tools.ietf.org/html/rfc7932)) content encoding via the `Accept-Encoding` header. - Support for JSON ([RFC 8259](https://tools.ietf.org/html/rfc8259)), YAML, and CBOR ([RFC 7049](https://tools.ietf.org/html/rfc7049)) content types via the `Accept` header. - Annotated Go types for input and output models - Generates JSON Schema from Go types @@ -484,7 +484,7 @@ By default, a `ReadHeaderTimeout` of _10 seconds_ and an `IdleTimeout` of _15 se Each operation's individual read timeout defaults to _15 seconds_ and can be changed as needed. This enables large request and response bodies to be sent without fear of timing out, as well as the use of WebSockets, in an opt-in fashion with sane defaults. -When using the built-in model processing and the timeout is triggered, the server sends a 408 Request Timeout as JSON with a message containing the time waited. +When using the built-in model processing and the timeout is triggered, the server sends an error as JSON with a message containing the time waited. ```go type Input struct { @@ -534,7 +534,7 @@ create.Run(func (ctx huma.Context, input struct { By default each operation has a 1 MiB reqeuest body size limit. -When using the built-in model processing and the timeout is triggered, the server sends a 413 Request Entity Too Large as JSON with a message containing the maximum body size for this operation. +When using the built-in model processing and the timeout is triggered, the server sends an error as JSON with a message containing the maximum body size for this operation. ```go app := cli.NewRouter("My API", "1.0.0") diff --git a/cli/cli_test.go b/cli/cli_test.go new file mode 100644 index 0000000..abecad4 --- /dev/null +++ b/cli/cli_test.go @@ -0,0 +1,34 @@ +package cli + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestCLI(t *testing.T) { + app := NewRouter("Test API", "1.0.0") + + started := false + app.PreStart(func() { + started = true + }) + + go func() { + // Let the OS pick a random port. + os.Setenv("SERVICE_PORT", "0") + os.Setenv("SERVICE_HOST", "127.0.0.1") + app.Root().Run(nil, []string{}) + }() + + time.Sleep(10 * time.Millisecond) + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + app.Shutdown(ctx) + + assert.Equal(t, true, started) +} diff --git a/context.go b/context.go index 14e7f17..cd73b38 100644 --- a/context.go +++ b/context.go @@ -5,17 +5,19 @@ import ( "encoding/json" "fmt" "net/http" + "reflect" "strings" "github.com/danielgtaylor/huma/negotiation" "github.com/fxamacker/cbor/v2" - "gopkg.in/yaml.v2" + "github.com/goccy/go-yaml" ) // ContextFromRequest returns a Huma context for a request, useful for // accessing high-level convenience functions from e.g. middleware. func ContextFromRequest(w http.ResponseWriter, r *http.Request) Context { return &hcontext{ + Context: r.Context(), ResponseWriter: w, r: r, } @@ -48,7 +50,7 @@ type hcontext struct { http.ResponseWriter r *http.Request errors []error - params map[string]oaParam + op *Operation } func (c *hcontext) AddError(err error) { @@ -83,7 +85,7 @@ func (c *hcontext) WriteError(status int, message string, errors ...error) { switch ct { case "application/cbor": ct = "application/problem+cbor" - case "application/json": + case "", "application/json": ct = "application/problem+json" case "application/yaml", "application/x-yaml": ct = "application/problem+yaml" @@ -93,6 +95,34 @@ func (c *hcontext) WriteError(status int, message string, errors ...error) { } func (c *hcontext) WriteModel(status int, model interface{}) { + // Is this allowed? Find the right response. + responses := []Response{} + names := []string{} + for _, r := range c.op.responses { + if r.status == status { + responses = append(responses, r) + if r.model != nil { + names = append(names, r.model.Name()) + } + } + } + + if len(responses) == 0 { + panic(fmt.Errorf("HTTP status %d not allowed for %s %s", status, c.op.method, c.op.resource.path)) + } + + found := false + for _, r := range responses { + if r.model == reflect.TypeOf(model) { + found = true + break + } + } + + if !found { + panic(fmt.Errorf("Invalid model, expecting %s for %s %s", strings.Join(names, ", "), c.op.method, c.op.resource.path)) + } + // Get the negotiated content type the client wants and we are willing to // provide. ct := selectContentType(c.r) diff --git a/context_test.go b/context_test.go new file mode 100644 index 0000000..37296c3 --- /dev/null +++ b/context_test.go @@ -0,0 +1,208 @@ +package huma + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/fxamacker/cbor" + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/assert" +) + +func TestGetContextFromRequest(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = r.WithContext(context.WithValue(r.Context(), contextKey("foo"), "bar")) + ctx := ContextFromRequest(w, r) + assert.Equal(t, "bar", ctx.Value(contextKey("foo"))) + }) + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodGet, "/", nil) + handler(w, r) +} + +func TestContentNegotiation(t *testing.T) { + type Response struct { + Value string `json:"value"` + } + + app := newTestRouter() + + app.Resource("/negotiated").Get("test", "Test", + NewResponse(200, "desc").Model(Response{}), + ).Run(func(ctx Context) { + ctx.WriteModel(http.StatusOK, Response{ + Value: "Hello, world!", + }) + }) + + var parsed Response + expected := Response{ + Value: "Hello, world!", + } + + // No preference + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/negotiated", nil) + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + err := json.Unmarshal(w.Body.Bytes(), &parsed) + assert.NoError(t, err) + assert.Equal(t, expected, parsed) + + // Prefer JSON + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/negotiated", nil) + req.Header.Set("Accept", "application/json") + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + err = json.Unmarshal(w.Body.Bytes(), &parsed) + assert.NoError(t, err) + assert.Equal(t, expected, parsed) + + // Prefer YAML + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/negotiated", nil) + req.Header.Set("Accept", "application/yaml") + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/yaml", w.Header().Get("Content-Type")) + err = yaml.Unmarshal(w.Body.Bytes(), &parsed) + assert.NoError(t, err) + assert.Equal(t, expected, parsed) + + // Prefer CBOR + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/negotiated", nil) + req.Header.Set("Accept", "application/cbor") + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/cbor", w.Header().Get("Content-Type")) + err = cbor.Unmarshal(w.Body.Bytes(), &parsed) + assert.NoError(t, err) + assert.EqualValues(t, expected, parsed) +} + +func TestErrorNegotiation(t *testing.T) { + app := newTestRouter() + + app.Resource("/error").Get("test", "Test", + NewResponse(400, "desc").Model(ErrorModel{}), + ).Run(func(ctx Context) { + ctx.AddError(fmt.Errorf("some error")) + ctx.AddError(&ErrorDetail{ + Message: "Invalid value", + Location: "body.field", + Value: "test", + }) + ctx.WriteError(http.StatusBadRequest, "test error") + }) + + var parsed ErrorModel + expected := ErrorModel{ + Status: http.StatusBadRequest, + Title: http.StatusText(http.StatusBadRequest), + Detail: "test error", + Errors: []*ErrorDetail{ + {Message: "some error"}, + { + Message: "Invalid value", + Location: "body.field", + Value: "test", + }, + }, + } + + // No preference + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/error", nil) + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, "application/problem+json", w.Header().Get("Content-Type")) + err := json.Unmarshal(w.Body.Bytes(), &parsed) + assert.NoError(t, err) + assert.Equal(t, expected, parsed) + + // Prefer JSON + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/error", nil) + req.Header.Set("Accept", "application/json") + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, "application/problem+json", w.Header().Get("Content-Type")) + err = json.Unmarshal(w.Body.Bytes(), &parsed) + assert.NoError(t, err) + assert.Equal(t, expected, parsed) + + // Prefer YAML + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/error", nil) + req.Header.Set("Accept", "application/yaml") + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, "application/problem+yaml", w.Header().Get("Content-Type")) + err = yaml.Unmarshal(w.Body.Bytes(), &parsed) + assert.NoError(t, err) + assert.EqualValues(t, expected, parsed) + + // Prefer CBOR + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/error", nil) + req.Header.Set("Accept", "application/cbor") + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, "application/problem+cbor", w.Header().Get("Content-Type")) + err = cbor.Unmarshal(w.Body.Bytes(), &parsed) + assert.NoError(t, err) + assert.Equal(t, expected, parsed) +} + +func TestInvalidModel(t *testing.T) { + type R1 struct { + Foo string `json:"foo"` + } + + type R2 struct { + Bar string `json:"bar"` + } + + app := newTestRouter() + + app.Resource("/bad-status").Get("test", "Test", + NewResponse(http.StatusOK, "desc").Model(R1{}), + ).Run(func(ctx Context) { + ctx.WriteModel(http.StatusNoContent, R2{Bar: "blah"}) + }) + + app.Resource("/bad-model").Get("test", "Test", + NewResponse(http.StatusOK, "desc").Model(R1{}), + ).Run(func(ctx Context) { + ctx.WriteModel(http.StatusOK, R2{Bar: "blah"}) + }) + + assert.Panics(t, func() { + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/bad-status", nil) + app.ServeHTTP(w, req) + }) + + assert.Panics(t, func() { + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/bad-model", nil) + app.ServeHTTP(w, req) + }) +} diff --git a/docs_test.go b/docs_test.go new file mode 100644 index 0000000..cb6ef21 --- /dev/null +++ b/docs_test.go @@ -0,0 +1,40 @@ +package huma + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +var handlers = []struct { + name string + handler http.Handler +}{ + {"RapiDoc", RapiDocHandler("Test API")}, + {"ReDoc", ReDocHandler("Test API")}, + {"SwaggerUI", SwaggerUIHandler("Test API")}, +} + +func TestDocHandlers(outer *testing.T) { + for _, tt := range handlers { + local := tt + outer.Run(local.name, func(t *testing.T) { + app := newTestRouter() + app.DocsHandler(local.handler) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/docs", nil) + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + }) + } +} + +func TestSplitDocs(t *testing.T) { + title, desc := splitDocs("One two\nthree\nfour five") + assert.Equal(t, "One two", title) + assert.Equal(t, "three\nfour five", desc) +} diff --git a/error_test.go b/error_test.go new file mode 100644 index 0000000..a774870 --- /dev/null +++ b/error_test.go @@ -0,0 +1,20 @@ +package huma + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestErrorDetailAsError(t *testing.T) { + d := ErrorDetail{ + Message: "foo", + Location: "bar", + Value: "baz", + } + + rendered := d.Error() + assert.Contains(t, rendered, "foo") + assert.Contains(t, rendered, "bar") + assert.Contains(t, rendered, "baz") +} diff --git a/go.mod b/go.mod index 0316b5d..f703cab 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,11 @@ go 1.13 require ( github.com/Jeffail/gabs/v2 v2.6.0 github.com/andybalholm/brotli v1.0.0 - github.com/davecgh/go-spew v1.1.1 github.com/fsnotify/fsnotify v1.4.9 // indirect + github.com/fxamacker/cbor v1.5.1 github.com/fxamacker/cbor/v2 v2.2.0 github.com/go-chi/chi v4.1.2+incompatible + github.com/goccy/go-yaml v1.8.1 github.com/magiconair/properties v1.8.2 // indirect github.com/mattn/go-isatty v0.0.12 github.com/mitchellh/mapstructure v1.3.3 // indirect @@ -26,5 +27,4 @@ require ( golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/ini.v1 v1.60.1 // indirect - gopkg.in/yaml.v2 v2.3.0 ) diff --git a/go.sum b/go.sum index b195d9e..960827a 100644 --- a/go.sum +++ b/go.sum @@ -44,11 +44,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fxamacker/cbor v1.5.1 h1:XjQWBgdmQyqimslUh5r4tUGmoqzHmBFQOImkWGi2awg= +github.com/fxamacker/cbor v1.5.1/go.mod h1:3aPGItF174ni7dDzd6JZ206H8cmr4GDNBGpPa971zsU= github.com/fxamacker/cbor/v2 v2.2.0 h1:6eXqdDDe588rSYAi1HfZKbx6YYQO4mxQ9eC6xYpU/JQ= github.com/fxamacker/cbor/v2 v2.2.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= @@ -59,8 +62,14 @@ github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9 github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/goccy/go-yaml v1.8.1 h1:JuZRFlqLM5cWF6A+waL8AKVuCcqvKOuhJtUQI+L3ez0= +github.com/goccy/go-yaml v1.8.1/go.mod h1:wS4gNoLalDSJxo/SpngzPQ2BN4uuZVLCmbM4S3vd4+Y= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -130,13 +139,19 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.2 h1:znVR8Q4g7/WlcvsxLBRWvo+vtFJUAbDn3w+Yak2xVMI= github.com/magiconair/properties v1.8.2/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= @@ -309,6 +324,7 @@ golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -316,6 +332,8 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 h1:DvY3Zkh7KabQE/kfzMvYvKirSiguP9Q/veMtkYyf0o8= golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -349,6 +367,8 @@ golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc h1:NCy3Ohtk6Iny5V/reW2Ktypo4zIpWBdRJ1uFMjBxdg8= golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898 h1:/atklqdjdhuosWIl6AIbOeHJjicWYPqR9bpxqxYG2pA= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= @@ -378,6 +398,10 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v9 v9.30.0 h1:Wk0Z37oBmKj9/n+tPyBHZmeL19LaCoK3Qq48VwYENss= +gopkg.in/go-playground/validator.v9 v9.30.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.60.1 h1:P5y5shSkb0CFe44qEeMBgn8JLow09MP17jlJHanke5g= diff --git a/middleware/encoding_test.go b/middleware/encoding_test.go index a792873..55bd770 100644 --- a/middleware/encoding_test.go +++ b/middleware/encoding_test.go @@ -14,7 +14,7 @@ import ( ) func TestContentEncodingTooSmall(t *testing.T) { - app, _ := NewTestRouter(t) + app, _ := newTestRouter(t) app.Resource("/").Get("root", "test", responses.OK().ContentType("text/plain"), ).Run(func(ctx huma.Context) { @@ -32,7 +32,7 @@ func TestContentEncodingTooSmall(t *testing.T) { } func TestContentEncodingIgnoredPath(t *testing.T) { - app, _ := NewTestRouter(t) + app, _ := newTestRouter(t) app.Resource("/foo.png").Get("root", "test", responses.OK().ContentType("image/png"), ).Run(func(ctx huma.Context) { @@ -51,7 +51,7 @@ func TestContentEncodingIgnoredPath(t *testing.T) { } func TestContentEncodingCompressed(t *testing.T) { - app, _ := NewTestRouter(t) + app, _ := newTestRouter(t) app.Resource("/").Get("root", "test", responses.OK(), ).Run(func(ctx huma.Context) { @@ -73,7 +73,7 @@ func TestContentEncodingCompressed(t *testing.T) { } func TestContentEncodingCompressedPick(t *testing.T) { - app, _ := NewTestRouter(t) + app, _ := newTestRouter(t) app.Resource("/").Get("root", "test", responses.OK(), ).Run(func(ctx huma.Context) { @@ -91,7 +91,7 @@ func TestContentEncodingCompressedPick(t *testing.T) { } func TestContentEncodingCompressedMultiWrite(t *testing.T) { - app, _ := NewTestRouter(t) + app, _ := newTestRouter(t) app.Resource("/").Get("root", "test", responses.OK(), ).Run(func(ctx huma.Context) { @@ -118,7 +118,7 @@ func TestContentEncodingCompressedMultiWrite(t *testing.T) { func TestContentEncodingError(t *testing.T) { var status int - app, _ := NewTestRouter(t) + app, _ := newTestRouter(t) app.Middleware(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { wrapped := &statusRecorder{ResponseWriter: w} diff --git a/middleware/minimal_test.go b/middleware/minimal_test.go index db3dbb9..fc1898c 100644 --- a/middleware/minimal_test.go +++ b/middleware/minimal_test.go @@ -11,7 +11,7 @@ import ( ) func TestPreferMinimalMiddleware(t *testing.T) { - app, _ := NewTestRouter(t) + app, _ := newTestRouter(t) app.Resource("/test").Get("id", "desc", responses.OK().ContentType("text/plain"), diff --git a/middleware/recovery_test.go b/middleware/recovery_test.go index 9978b7a..66c70ff 100644 --- a/middleware/recovery_test.go +++ b/middleware/recovery_test.go @@ -16,7 +16,7 @@ import ( "go.uber.org/zap/zaptest/observer" ) -func NewTestRouter(t testing.TB) (*huma.Router, *observer.ObservedLogs) { +func newTestRouter(t testing.TB) (*huma.Router, *observer.ObservedLogs) { core, logs := observer.New(zapcore.DebugLevel) router := huma.New("Test API", "1.0.0") @@ -31,7 +31,7 @@ func NewTestRouter(t testing.TB) (*huma.Router, *observer.ObservedLogs) { } func TestRecoveryMiddleware(t *testing.T) { - app, _ := NewTestRouter(t) + app, _ := newTestRouter(t) app.Resource("/panic").Get("panic", "Panic recovery test", responses.NoContent(), @@ -47,7 +47,7 @@ func TestRecoveryMiddleware(t *testing.T) { } func TestRecoveryMiddlewareString(t *testing.T) { - app, _ := NewTestRouter(t) + app, _ := newTestRouter(t) app.Resource("/panic").Get("panic", "Panic recovery test", responses.NoContent(), @@ -63,7 +63,7 @@ func TestRecoveryMiddlewareString(t *testing.T) { } func TestRecoveryMiddlewareLogBody(t *testing.T) { - app, log := NewTestRouter(t) + app, log := newTestRouter(t) app.Resource("/panic").Put("panic", "Panic recovery test", responses.NoContent(), diff --git a/operation.go b/operation.go index 6f3ae14..448acf7 100644 --- a/operation.go +++ b/operation.go @@ -175,7 +175,7 @@ func (o *Operation) Run(handler interface{}) { Context: r.Context(), ResponseWriter: w, r: r, - params: o.params, + op: o, } callHandler(ctx, handler) diff --git a/resolver.go b/resolver.go index 27b8a66..3f6b063 100644 --- a/resolver.go +++ b/resolver.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "io/ioutil" + "net" "net/http" "reflect" "strconv" @@ -12,7 +13,6 @@ import ( "time" "github.com/danielgtaylor/huma/schema" - "github.com/davecgh/go-spew/spew" "github.com/go-chi/chi" "github.com/xeipuuv/gojsonschema" ) @@ -246,16 +246,43 @@ func setFields(ctx *hcontext, req *http.Request, input reflect.Value, t reflect. continue } - // load body + // Check if a content-length has been sent. If it's too big then there + // is no need to waste time reading. + if length := req.Header.Get("Content-Length"); length != "" { + if l, err := strconv.ParseInt(length, 10, 64); err == nil { + if l > ctx.op.maxBodyBytes { + ctx.AddError(&ErrorDetail{ + Message: fmt.Sprintf("Request body too large, limit = %d bytes", ctx.op.maxBodyBytes), + Location: "body", + Value: length, + }) + continue + } + } + } + + // Load the body (read/unmarshal). data, err := ioutil.ReadAll(req.Body) if err != nil { - spew.Dump(err) + if strings.Contains(err.Error(), "request body too large") { + ctx.AddError(&ErrorDetail{ + Message: fmt.Sprintf("Request body too large, limit = %d bytes", ctx.op.maxBodyBytes), + Location: "body", + }) + } else if e, ok := err.(net.Error); ok && e.Timeout() { + ctx.AddError(&ErrorDetail{ + Message: fmt.Sprintf("Request body took too long to read: timed out after %v", ctx.op.bodyReadTimeout), + Location: "body", + }) + } else { + panic(err) + } + continue } err = json.Unmarshal(data, inField.Addr().Interface()) if err != nil { - spew.Dump(err) + panic(err) } - // spew.Dump("Read in body", inField.Interface()) continue } @@ -300,7 +327,7 @@ func setFields(ctx *hcontext, req *http.Request, input reflect.Value, t reflect. continue } - if oap, ok := ctx.params[pname]; ok { + if oap, ok := ctx.op.params[pname]; ok { s := oap.Schema if s.HasValidation() { data := pv diff --git a/responses/responses.go b/responses/responses.go index 5101f96..06c8607 100644 --- a/responses/responses.go +++ b/responses/responses.go @@ -6,10 +6,12 @@ import ( "github.com/danielgtaylor/huma" ) -func response(status int) huma.Response { +func newResponse(status int) huma.Response { return huma.NewResponse(status, http.StatusText(status)) } +var response func(int) huma.Response = newResponse + func errorResponse(status int) huma.Response { return response(status). ContentType(huma.NegotiatedErrorContentType). @@ -26,24 +28,84 @@ func Created() huma.Response { return response(http.StatusCreated) } +// Accepted HTTP 202 response. +func Accepted() huma.Response { + return response(http.StatusAccepted) +} + // NoContent HTTP 204 response. func NoContent() huma.Response { return response(http.StatusNoContent) } +// MovedPermanently HTTP 301 response. +func MovedPermanently() huma.Response { + return response(http.StatusMovedPermanently) +} + +// Found HTTP 302 response. +func Found() huma.Response { + return response(http.StatusFound) +} + +// NotModified HTTP 304 response. +func NotModified() huma.Response { + return response(http.StatusNotModified) +} + +// TemporaryRedirect HTTP 307 response. +func TemporaryRedirect() huma.Response { + return response(http.StatusTemporaryRedirect) +} + +// PermanentRedirect HTTP 308 response. +func PermanentRedirect() huma.Response { + return response(http.StatusPermanentRedirect) +} + // BadRequest HTTP 400 response with a structured error body (e.g. JSON). func BadRequest() huma.Response { return errorResponse(http.StatusBadRequest) } +// Unauthorized HTTP 401 response with a structured error body (e.g. JSON). +func Unauthorized() huma.Response { + return errorResponse(http.StatusUnauthorized) +} + +// Forbidden HTTP 403 response with a structured error body (e.g. JSON). +func Forbidden() huma.Response { + return errorResponse(http.StatusForbidden) +} + // NotFound HTTP 404 response with a structured error body (e.g. JSON). func NotFound() huma.Response { return errorResponse(http.StatusNotFound) } -// GatewayTimeout HTTP 504 response with a structured error body (e.g. JSON). -func GatewayTimeout() huma.Response { - return errorResponse(http.StatusGatewayTimeout) +// RequestTimeout HTTP 408 response with a structured error body (e.g. JSON). +func RequestTimeout() huma.Response { + return errorResponse(http.StatusRequestTimeout) +} + +// Conflict HTTP 409 response with a structured error body (e.g. JSON). +func Conflict() huma.Response { + return errorResponse(http.StatusConflict) +} + +// PreconditionFailed HTTP 412 response with a structured error body (e.g. JSON). +func PreconditionFailed() huma.Response { + return errorResponse(http.StatusPreconditionFailed) +} + +// RequestEntityTooLarge HTTP 413 response with a structured error body (e.g. JSON). +func RequestEntityTooLarge() huma.Response { + return errorResponse(http.StatusRequestEntityTooLarge) +} + +// PreconditionRequired HTTP 428 response with a structured error body (e.g. JSON). +func PreconditionRequired() huma.Response { + return errorResponse(http.StatusPreconditionRequired) } // InternalServerError HTTP 500 response with a structured error body (e.g. JSON). @@ -51,26 +113,23 @@ func InternalServerError() huma.Response { return errorResponse(http.StatusInternalServerError) } -// String HTTP response with the given status code. -func String(status int) huma.Response { - return huma.NewResponse(status, http.StatusText(status)).ContentType("text/plain") +// BadGateway HTTP 502 response with a structured error body (e.g. JSON). +func BadGateway() huma.Response { + return errorResponse(http.StatusBadGateway) } -// // Model response with a structured body (e.g. JSON). -// func Model(status int, model interface{}) huma.Response { -// return huma.Response{ -// Status: status, -// ContentType: huma.NegotiatedContentType, -// Model: reflect.TypeOf(model), -// } +// ServiceUnavailable HTTP 503 response with a structured error body (e.g. JSON). +func ServiceUnavailable() huma.Response { + return errorResponse(http.StatusServiceUnavailable) +} -// } +// GatewayTimeout HTTP 504 response with a structured error body (e.g. JSON). +func GatewayTimeout() huma.Response { + return errorResponse(http.StatusGatewayTimeout) +} -// // Error response with a structured error body (e.g. JSON). -// func Error(status int) huma.Response { -// return huma.Response{ -// Status: status, -// ContentType: huma.NegotiatedErrorContentType, -// Model: reflect.TypeOf(huma.ErrorModel{}), -// } -// } +// String HTTP response with the given status code and `text/plain` content +// type. +func String(status int) huma.Response { + return response(status).ContentType("text/plain") +} diff --git a/responses/responses_test.go b/responses/responses_test.go new file mode 100644 index 0000000..036dbe2 --- /dev/null +++ b/responses/responses_test.go @@ -0,0 +1,92 @@ +package responses + +import ( + "net/http" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/danielgtaylor/huma" + "github.com/stretchr/testify/assert" +) + +var funcs = struct { + Responses []func() huma.Response +}{ + Responses: []func() huma.Response{ + OK, + Created, + Accepted, + NoContent, + MovedPermanently, + Found, + NotModified, + TemporaryRedirect, + PermanentRedirect, + BadRequest, + Unauthorized, + Forbidden, + NotFound, + RequestTimeout, + Conflict, + PreconditionFailed, + RequestEntityTooLarge, + PreconditionRequired, + InternalServerError, + BadGateway, + ServiceUnavailable, + GatewayTimeout, + }, +} + +func TestResponses(t *testing.T) { + var status int + response = func(s int) huma.Response { + status = s + return newResponse(s) + } + + table := map[string]int{} + for _, s := range []int{ + http.StatusOK, + http.StatusCreated, + http.StatusAccepted, + http.StatusNoContent, + http.StatusMovedPermanently, + http.StatusFound, + http.StatusNotModified, + http.StatusTemporaryRedirect, + http.StatusPermanentRedirect, + http.StatusBadRequest, + http.StatusUnauthorized, + http.StatusForbidden, + http.StatusNotFound, + http.StatusRequestTimeout, + http.StatusConflict, + http.StatusPreconditionFailed, + http.StatusRequestEntityTooLarge, + http.StatusPreconditionRequired, + http.StatusInternalServerError, + http.StatusBadGateway, + http.StatusServiceUnavailable, + http.StatusGatewayTimeout, + } { + table[strings.Replace(http.StatusText(s), " ", "", -1)] = s + } + + for _, f := range funcs.Responses { + parts := strings.Split(runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name(), ".") + name := parts[len(parts)-1] + t.Run(name, func(t *testing.T) { + f() + + // The response we created has the right status code given the creation + // func name. + assert.Equal(t, table[name], status) + }) + } + + String(http.StatusOK) + assert.Equal(t, 200, status) +} diff --git a/router.go b/router.go index 924a331..0325958 100644 --- a/router.go +++ b/router.go @@ -2,7 +2,6 @@ package huma import ( "context" - "fmt" "net" "net/http" "sync" @@ -41,8 +40,9 @@ type Router struct { // security // Documentation handler function - docsPrefix string - docsHandler http.Handler + docsPrefix string + docsHandler http.Handler + docsAreSetup bool // Tracks the currently running server for graceful shutdown. server *http.Server @@ -161,13 +161,12 @@ func (r *Router) OpenAPIHook(hook func(*gabs.Container)) { r.openapiHook = hook } -func (r *Router) listen(addr, certFile, keyFile string) error { +// Set up the docs & OpenAPI routes. +func (r *Router) setupDocs() { // Register the docs handlers if needed. if !r.mux.Match(chi.NewRouteContext(), http.MethodGet, r.OpenAPIPath()) { r.mux.Get(r.OpenAPIPath(), func(w http.ResponseWriter, req *http.Request) { - fmt.Println("Getting spec now") spec := r.OpenAPI() - fmt.Println("Spec is done, writing response") w.Header().Set("Content-Type", "application/vnd.oai.openapi+json") w.Write(spec.Bytes()) }) @@ -177,6 +176,14 @@ func (r *Router) listen(addr, certFile, keyFile string) error { r.mux.Get(r.docsPrefix+"/docs", r.docsHandler.ServeHTTP) } + r.docsAreSetup = true +} + +func (r *Router) listen(addr, certFile, keyFile string) error { + // Setup docs on startup so we can fail fast if the handler is broken in + // some way. + r.setupDocs() + // Start the server. r.serverLock.Lock() if r.server == nil { @@ -224,6 +231,10 @@ func (r *Router) ListenTLS(addr, certFile, keyFile string) error { // ServeHTTP handles an incoming request and is compatible with the standard // library `http` package. func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { + if !r.docsAreSetup { + r.setupDocs() + } + r.mux.ServeHTTP(w, req) } @@ -268,7 +279,7 @@ func New(docs, version string) *Router { if link != "" { link += ", " } - link += `<` + r.OpenAPIPath() + `>; rel="service-desc", <` + r.OpenAPIPath() + `/docs>; rel="service-doc"` + link += `<` + r.OpenAPIPath() + `>; rel="service-desc", <` + r.docsPrefix + `/docs>; rel="service-doc"` w.Header().Set("link", link) } }) diff --git a/router_test.go b/router_test.go index 43564e3..0ae8ad2 100644 --- a/router_test.go +++ b/router_test.go @@ -6,11 +6,28 @@ import ( "io/ioutil" "net/http" "net/http/httptest" + "strings" "testing" + "time" "github.com/stretchr/testify/assert" ) +func newTestRouter() *Router { + app := New("Test API", "1.0.0") + return app +} + +func TestRouterServiceLink(t *testing.T) { + r := newTestRouter() + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/", nil) + r.ServeHTTP(w, req) + assert.Contains(t, w.Header().Get("Link"), `; rel="service-desc"`) + assert.Contains(t, w.Header().Get("Link"), `; rel="service-doc"`) +} + func TestRouterHello(t *testing.T) { r := New("Test", "1.0.0") r.Resource("/test").Get("test", "Test", @@ -61,7 +78,7 @@ func TestModelInputOutput(t *testing.T) { } r := New("Test", "1.0.0") - r.Resource("/players", "category").Post("player", "Create player", + r.Resource("/players").SubResource("category").Post("player", "Create player", NewResponse(http.StatusOK, "test").Model(Response{}), ).Run(func(ctx Context, input struct { Category string `path:"category"` @@ -95,4 +112,88 @@ func TestModelInputOutput(t *testing.T) { "id": "abc123", "age": 25 }`, w.Body.String()) + + // Should be able to get OpenAPI describing this API with its resource, + // operation, schema, etc. + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/openapi.json", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestTooBigBody(t *testing.T) { + app := newTestRouter() + + type Input struct { + Body struct { + ID string `json:"id"` + } + } + + op := app.Resource("/test").Put("put", "desc", + NewResponse(http.StatusNoContent, "desc"), + ) + op.MaxBodyBytes(5) + op.Run(func(ctx Context, input Input) { + // Do nothing... + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPut, "/test", strings.NewReader(`{"id": "foo"}`)) + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Request body too large") + + // With content length + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodPut, "/test", strings.NewReader(`{"id": "foo"}`)) + req.Header.Set("Content-Length", "13") + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Request body too large") +} + +type timeoutError struct{} + +func (e *timeoutError) Error() string { + return "timed out" +} + +func (e *timeoutError) Timeout() bool { + return true +} + +func (e *timeoutError) Temporary() bool { + return false +} + +type slowReader struct{} + +func (r *slowReader) Read(p []byte) (int, error) { + return 0, &timeoutError{} +} + +func TestBodySlow(t *testing.T) { + app := newTestRouter() + + type Input struct { + Body struct { + ID string + } + } + + op := app.Resource("/test").Put("put", "desc", + NewResponse(http.StatusNoContent, "desc"), + ) + op.BodyReadTimeout(1 * time.Millisecond) + op.Run(func(ctx Context, input Input) { + // Do nothing... + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPut, "/test", &slowReader{}) + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "timed out") } diff --git a/schema/schema_test.go b/schema/schema_test.go index 1f16f02..86c4689 100644 --- a/schema/schema_test.go +++ b/schema/schema_test.go @@ -33,7 +33,11 @@ var types = []struct { }{ {false, "boolean", ""}, {0, "integer", "int32"}, + {int64(0), "integer", "int64"}, + {uint64(0), "integer", "int64"}, + {float32(0), "number", "float"}, {0.0, "number", "double"}, + {F(0.0), "number", "double"}, {"hello", "string", ""}, {struct{}{}, "object", ""}, {[]string{"foo"}, "array", ""},