tests: add additional tests

This commit is contained in:
Daniel G. Taylor 2020-08-30 23:58:12 -07:00
parent 6797f3a848
commit 3d0b40a742
No known key found for this signature in database
GPG key ID: 7BD6DC99C9A87E22
18 changed files with 707 additions and 57 deletions

View file

@ -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")

34
cli/cli_test.go Normal file
View file

@ -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)
}

View file

@ -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)

208
context_test.go Normal file
View file

@ -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)
})
}

40
docs_test.go Normal file
View file

@ -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)
}

20
error_test.go Normal file
View file

@ -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")
}

4
go.mod
View file

@ -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
)

24
go.sum
View file

@ -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=

View file

@ -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}

View file

@ -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"),

View file

@ -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(),

View file

@ -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)

View file

@ -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

View file

@ -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")
}

View file

@ -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)
}

View file

@ -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)
}
})

View file

@ -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"), `</openapi.json>; rel="service-desc"`)
assert.Contains(t, w.Header().Get("Link"), `</docs>; 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")
}

View file

@ -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", ""},