这是indexloc提供的服务,不要输入任何密码
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Features include:
- Annotated Go types for input and output models
- Generates JSON Schema from Go types
- Automatic input model validation & error handling
- Customizable round-trip behavior for read-only fields
- Documentation generation using [RapiDoc](https://mrin9.github.io/RapiDoc/), [ReDoc](https://github.com/Redocly/redoc), or [SwaggerUI](https://swagger.io/tools/swagger-ui/)
- CLI built-in, configured via arguments or environment variables
- Set via e.g. `-p 8000`, `--port=8000`, or `SERVICE_PORT=8000`
Expand Down Expand Up @@ -685,6 +686,16 @@ Parameters have some additional validation tags:
| ---------- | ------------------------------ | ----------------- |
| `internal` | Internal-only (not documented) | `internal:"true"` |

### Round Trip Behavior

Since validation allows for read-only fields there is an interesting case of round trip complications when you `GET` a resource and then try to `PUT` or `POST` with those read-only fields intact. Huma's behavior is configurable:

1. `huma.RoundTripRemove` (default): read-only fields are allowed but removed from the request by being set to their zero value.
2. `huma.RoundTripReject`: read-only fields are not allowed to be set to anything but their zero value. Round trips will generally fail if read-only fields were present in the response.
3. `huma.RoundTripIgnore`: read-only fields are allowed and completely ignored by Huma, allowing the implementer to provide additional logic (e.g. allow if and only if they are zero _or_ the same as the value currently on the server).

> :whale: Using `RoundTripIgnore` is faster than removal (no additional processing of request bodies) but has implications for writing/overwriting read-only fields into your data store, so be careful with this option.

## Middleware

Standard [Go HTTP middleware](https://justinas.org/writing-http-middleware-in-go) is supported. It can be attached to the main router/app or to individual resources, but **must** be added _before_ operation handlers are added.
Expand Down
14 changes: 12 additions & 2 deletions operation.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,11 @@ func (o *Operation) Run(handler interface{}) {
o.requests[ct].model = f.Type

if !o.requests[ct].override {
s, err := schema.GenerateWithMode(f.Type, schema.ModeWrite, nil)
mode := schema.ModeWrite
if o.resource != nil && o.resource.router != nil && o.resource.router.roundTripBehavior != RoundTripReject {
mode = schema.ModeAll
}
s, err := schema.GenerateWithMode(f.Type, mode, nil)
if o.resource != nil && o.resource.router != nil && !o.resource.router.disableSchemaProperty {
s.AddSchemaField()
}
Expand Down Expand Up @@ -354,7 +358,13 @@ func (o *Operation) Run(handler interface{}) {
}
}

setFields(ctx, ctx.r, input, inputType, ct, reqDef)
removeReadOnly := false
if o.resource != nil && o.resource.router != nil && o.resource.router.roundTripBehavior == RoundTripRemove {
removeReadOnly = true
}

setFields(ctx, ctx.r, input, inputType, ct, reqDef, removeReadOnly)

if !ctx.HasError() {
// No errors yet, so any errors that come after should be treated as a
// semantic rather than structural error.
Expand Down
33 changes: 31 additions & 2 deletions resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ func parseParamValue(ctx Context, location string, name string, typ reflect.Type
return pv
}

func setFields(ctx *hcontext, req *http.Request, input reflect.Value, t reflect.Type, ct string, reqDef *request) {
func setFields(ctx *hcontext, req *http.Request, input reflect.Value, t reflect.Type, ct string, reqDef *request, removeReadOnly bool) {
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
Expand All @@ -189,7 +189,7 @@ func setFields(ctx *hcontext, req *http.Request, input reflect.Value, t reflect.

if f.Anonymous {
// Embedded struct
setFields(ctx, req, inField, f.Type, ct, reqDef)
setFields(ctx, req, inField, f.Type, ct, reqDef, removeReadOnly)
continue
}

Expand Down Expand Up @@ -252,6 +252,10 @@ func setFields(ctx *hcontext, req *http.Request, input reflect.Value, t reflect.
})
}

if removeReadOnly {
removeReadOnlyFields(inField)
}

// If requested, also provide access to the raw body bytes.
if _, ok := t.FieldByName("RawBody"); ok {
input.FieldByName("RawBody").Set(reflect.ValueOf(data))
Expand Down Expand Up @@ -350,6 +354,31 @@ func setFields(ctx *hcontext, req *http.Request, input reflect.Value, t reflect.
}
}

func removeReadOnlyFields(v reflect.Value) {
switch v.Kind() {
case reflect.Struct:
for i := 0; i < v.NumField(); i++ {
f := v.Field(i)
ro := v.Type().Field(i).Tag.Get("readOnly")
if ro == "true" {
// Set the field to its zero value!
f.Set(reflect.Zero(f.Type()))
} else {
removeReadOnlyFields(f)
}
}
case reflect.Slice, reflect.Array:
for i := 0; i < v.Len(); i++ {
removeReadOnlyFields(v.Index(i))
}
case reflect.Map:
iter := v.MapRange()
for iter.Next() {
removeReadOnlyFields(iter.Value())
}
}
}

// A smart join for JSONPath
func pathJoin(prefix string, parts ...string) string {
joined := prefix
Expand Down
29 changes: 28 additions & 1 deletion router.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,23 @@ var connContextKey contextKey = "huma-request-conn"
// has finished.
var opIDContextKey contextKey = "huma-operation-id"

// RoundTripBehavior defines how the server handles a request which has
// fields set from a response that were read-only, for example the created
// date/time for the resource. These fields are not writeable, but you can
// choose to reject, or allow them.
type RoundTripBehavior int

const (
// RoundTripRemove will cause request validation to succeed and any read-only fields will be set to their zero value before calling the operation's handler function. This is the default behavior.
RoundTripRemove RoundTripBehavior = 0

// RoundTripReject will cause request validation to fail when read-only fields are present.
RoundTripReject RoundTripBehavior = 1

// RoundTripIgnore will cause request validation to succeed and any read-only fields will be ignored. This enables service implementers to use custom logic, such as rejecting read-only fields if they do not match the value stored on the server.
RoundTripIgnore RoundTripBehavior = 2
)

// GetConn gets the underlying `net.Conn` from a context.
func GetConn(ctx context.Context) net.Conn {
conn := ctx.Value(connContextKey)
Expand Down Expand Up @@ -69,9 +86,14 @@ type Router struct {
defaultServerIdleTimeout time.Duration

// Information for creating non-relative links & schema refs.
urlPrefix string
urlPrefix string

// Disable the auto-generated `$schema` property in requests/responses.
disableSchemaProperty bool

// Specify whether resources with read-only fields can be round-tripped (i.e. GET then PUT, or POSTed with a read-only `created` field).
roundTripBehavior RoundTripBehavior

// Turn off auto-generation of HTTP PATCH operations
disableAutoPatch bool
}
Expand Down Expand Up @@ -482,6 +504,11 @@ func (r *Router) DisableSchemaProperty() {
r.disableSchemaProperty = true
}

// RoundTripBehavior controls how the server handles resources which
func (r *Router) RoundTripBehavior(behavior RoundTripBehavior) {
r.roundTripBehavior = behavior
}

// DisableAutoPatch disables the automatic generation of HTTP PATCH operations
// whenever a GET/PUT combo exists without a pre-existing PATCH.
func (r *Router) DisableAutoPatch() {
Expand Down
87 changes: 72 additions & 15 deletions router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -501,13 +501,11 @@ func TestDefaultResponse(t *testing.T) {
func TestRoundTrip(t *testing.T) {
app := newTestRouter()

// This should not crash.
resource := app.Resource("/")

type Thing struct {
Name string `json:"name"`
}

resource := app.Resource("/")
resource.Get("get-root", "docs", NewResponse(0, "").Model(Thing{})).Run(func(ctx Context) {
ctx.WriteModel(http.StatusOK, Thing{Name: "Test"})
})
Expand All @@ -519,16 +517,6 @@ func TestRoundTrip(t *testing.T) {
assert.Equal(t, "Test", input.Body.Name)
})

w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/openapi.json", nil)
app.ServeHTTP(w1, req1)
t.Log(w1.Body.String())

w1 = httptest.NewRecorder()
req1, _ = http.NewRequest(http.MethodGet, "/schemas/Thing.json", nil)
app.ServeHTTP(w1, req1)
t.Log(w1.Body.String())

w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/", nil)
app.ServeHTTP(w, req)
Expand All @@ -542,11 +530,80 @@ func TestRoundTrip(t *testing.T) {
req, _ = http.NewRequest(http.MethodPut, "/", thing)
app.ServeHTTP(w, req)

t.Log(w.Body.String())

assert.Equal(t, http.StatusOK, w.Result().StatusCode)
}

func TestRoundTripReadOnlyReject(t *testing.T) {
type Item struct {
Value string `json:"value" readOnly:"true"`
}

type Thing struct {
Name string `json:"name"`
Created time.Time `json:"created" readOnly:"true"`
Nested map[string][]Item `json:"nested"`
}

created := time.Date(2022, 01, 01, 00, 00, 00, 0, time.UTC)

tests := []struct {
Name string
Behavior RoundTripBehavior
Response int
Handler func(thing Thing)
}{
{"Reject", RoundTripReject, http.StatusUnprocessableEntity, func(thing Thing) { t.Fail() }},
{"Ignore", RoundTripIgnore, http.StatusOK, func(thing Thing) {
assert.Equal(t, created, thing.Created)
assert.Equal(t, "value", thing.Nested["test"][0].Value)
}},
{"Remove", RoundTripRemove, http.StatusOK, func(thing Thing) {
assert.True(t, thing.Created.IsZero())
assert.Equal(t, "", thing.Nested["test"][0].Value)
}},
}

for _, example := range tests {
t.Run(example.Name, func(t *testing.T) {
app := newTestRouter()
app.RoundTripBehavior(example.Behavior)

resource := app.Resource("/")
resource.Get("get-root", "docs", NewResponse(0, "").Model(Thing{})).Run(func(ctx Context) {
ctx.WriteModel(http.StatusOK, Thing{
Name: "Test",
Created: created,
Nested: map[string][]Item{
"test": {{Value: "value"}},
},
})
})

resource.Put("put-root", "", NewResponse(200, "")).Run(func(ctx Context, input struct {
Body Thing
}) {
example.Handler(input.Body)
})

w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/", nil)
app.ServeHTTP(w, req)

// This should not panic and should return the 200 OK
assert.Equal(t, http.StatusOK, w.Result().StatusCode)

thing := w.Body
t.Logf("Sending %s", w.Body.String())

w = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPut, "/", thing)
app.ServeHTTP(w, req)

assert.Equal(t, example.Response, w.Result().StatusCode, w.Body.String())
})
}
}

func TestRequestContentTypes(t *testing.T) {
app := newTestRouter()
app.DisableSchemaProperty()
Expand Down