Skip to content

Commit

Permalink
Merge pull request #32 from danielgtaylor/fix-schema-prop
Browse files Browse the repository at this point in the history
fix: serialization of time.Time and struct pointers
  • Loading branch information
danielgtaylor committed Mar 22, 2022
2 parents bf3a8a3 + 1e35695 commit 78d423d
Show file tree
Hide file tree
Showing 4 changed files with 95 additions and 15 deletions.
64 changes: 51 additions & 13 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"github.com/danielgtaylor/huma/negotiation"
"github.com/fxamacker/cbor/v2"
"github.com/goccy/go-yaml"
"github.com/mitchellh/mapstructure"
)

// allowedHeaders is a list of built-in headers that are always allowed without
Expand Down Expand Up @@ -211,6 +210,52 @@ func (c *hcontext) URLPrefix() string {
return scheme + "://" + c.r.Host
}

// shallowStructToMap converts a struct to a map similar to how encoding/json
// would do it, but only one level deep so that the map may be modified before
// serialization.
func shallowStructToMap(v reflect.Value, result map[string]interface{}) {
t := v.Type()
if t.Kind() == reflect.Ptr {
shallowStructToMap(v.Elem(), result)
return
}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
name := f.Name
if len(name) > 0 && strings.ToUpper(name)[0] != name[0] {
// Private field we somehow have access to?
continue
}
if f.Anonymous {
// Anonymous embedded struct, process its fields as our own.
shallowStructToMap(v.Field(i), result)
continue
}
if json := f.Tag.Get("json"); json != "" {
parts := strings.Split(json, ",")
if parts[0] != "" {
name = parts[0]
}
if name == "-" {
continue
}
if len(parts) == 2 && parts[1] == "omitempty" && v.Field(i).IsZero() {
vf := v.Field(i)
zero := vf.IsZero()
if vf.Kind() == reflect.Slice || vf.Kind() == reflect.Map {
// Special case: omit if they have no items in them to match the
// JSON encoder.
zero = vf.Len() > 0
}
if zero {
continue
}
}
}
result[name] = v.Field(i).Interface()
}
}

func (c *hcontext) writeModel(ct string, status int, model interface{}) {
// Is this allowed? Find the right response.
modelRef := ""
Expand Down Expand Up @@ -261,19 +306,12 @@ func (c *hcontext) writeModel(ct string, status int, model interface{}) {
link += "<" + c.docsPrefix + "/schemas/" + id + ".json>; rel=\"describedby\""
c.Header().Set("Link", link)

if !c.disableSchemaProperty && modelType != nil && modelType.Kind() == reflect.Struct {
if modelType.Kind() == reflect.Ptr {
modelType = modelType.Elem()
}
if !c.disableSchemaProperty && modelType != nil && modelType.Kind() == reflect.Struct && modelType != timeType {
tmp := map[string]interface{}{}
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
TagName: "json",
Result: &tmp,
})
if err != nil {
panic(fmt.Errorf("Unable to initialize struct decoder: %w", err))
}
err = decoder.Decode(model)
if err != nil {
panic(fmt.Errorf("Unable to convert struct to map: %w", err))
}
shallowStructToMap(reflect.ValueOf(model), tmp)
if tmp["$schema"] == nil {
tmp["$schema"] = c.URLPrefix() + c.docsPrefix + "/schemas/" + id + ".json"
}
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ require (
github.com/koron-go/gqlcost v0.2.2
github.com/magiconair/properties v1.8.6 // indirect
github.com/mattn/go-isatty v0.0.14
github.com/mitchellh/mapstructure v1.4.3
github.com/opentracing/opentracing-go v1.2.0
github.com/spf13/afero v1.8.2 // indirect
github.com/spf13/cobra v1.4.0
Expand Down
5 changes: 4 additions & 1 deletion resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ func TestExhaustiveErrors(t *testing.T) {

w := httptest.NewRecorder()
r, _ := http.NewRequest(http.MethodGet, "/?bool=bad&int=bad&float32=bad&float64=bad&tags=1,2,bad&time=bad", strings.NewReader(`{"test": 1}`))
r.Host = "example.com"
app.ServeHTTP(w, r)

assert.JSONEq(t, `{"title":"Bad Request","status":400,"detail":"Error while parsing input parameters","errors":[{"message":"cannot parse boolean","location":"query.bool","value":"bad"},{"message":"cannot parse integer","location":"query.int","value":"bad"},{"message":"cannot parse float","location":"query.float32","value":"bad"},{"message":"cannot parse float","location":"query.float64","value":"bad"},{"message":"cannot parse integer","location":"query[2].tags","value":"bad"},{"message":"unable to validate against schema: invalid character 'b' looking for beginning of value","location":"query.tags","value":"[1,2,bad]"},{"message":"cannot parse time","location":"query.time","value":"bad"},{"message":"Must be greater than or equal to 5","location":"body.test","value":1}]}`, w.Body.String())
assert.JSONEq(t, `{"$schema": "https://example.com/schemas/ErrorModel.json", "title":"Bad Request","status":400,"detail":"Error while parsing input parameters","errors":[{"message":"cannot parse boolean","location":"query.bool","value":"bad"},{"message":"cannot parse integer","location":"query.int","value":"bad"},{"message":"cannot parse float","location":"query.float32","value":"bad"},{"message":"cannot parse float","location":"query.float64","value":"bad"},{"message":"cannot parse integer","location":"query[2].tags","value":"bad"},{"message":"unable to validate against schema: invalid character 'b' looking for beginning of value","location":"query.tags","value":"[1,2,bad]"},{"message":"cannot parse time","location":"query.time","value":"bad"},{"message":"Must be greater than or equal to 5","location":"body.test","value":1}]}`, w.Body.String())
}

type Dep1 struct {
Expand Down Expand Up @@ -104,9 +105,11 @@ func TestNestedResolverError(t *testing.T) {
]
}
}`))
r.Host = "example.com"
app.ServeHTTP(w, r)

assert.JSONEq(t, `{
"$schema": "https://example.com/schemas/ErrorModel.json",
"status": 400,
"title": "Bad Request",
"detail": "Error while parsing input parameters",
Expand Down
40 changes: 40 additions & 0 deletions router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
Expand Down Expand Up @@ -138,6 +139,45 @@ func TestModelInputOutput(t *testing.T) {
assert.Equal(t, http.StatusNotFound, w.Code)
}

func TestRouterEmbeddedStructOutput(t *testing.T) {
type CreatedField struct {
Created time.Time `json:"created,omitempty"`
}

type Resp struct {
CreatedField
Another string `json:"another"`
Ignored string `json:"-"`
}

now := time.Now()

r := New("Test", "1.0.0")
r.Resource("/test").Get("test", "Test",
NewResponse(http.StatusOK, "test").Model(&Resp{}),
).Run(func(ctx Context) {
ctx.WriteModel(http.StatusOK, &Resp{
CreatedField: CreatedField{
Created: now,
},
Another: "foo",
})
})

w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/test", nil)
req.Host = "example.com"
r.ServeHTTP(w, req)

// Assert the response is as expected.
assert.Equal(t, http.StatusOK, w.Code)
assert.JSONEq(t, fmt.Sprintf(`{
"$schema": "https://example.com/schemas/Resp.json",
"created": "%s",
"another": "foo"
}`, now.Format(time.RFC3339Nano)), w.Body.String())
}

func TestTooBigBody(t *testing.T) {
app := newTestRouter()

Expand Down

0 comments on commit 78d423d

Please sign in to comment.