这是indexloc提供的服务,不要输入任何密码
Skip to content
Merged
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ Features include:
- 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.
- Conditional requests support, e.g. `If-Match` or `If-Unmodified-Since` header utilities.
- Optional automatic generation of `PATCH` operations that support:
- [RFC 7386](https://www.rfc-editor.org/rfc/rfc7386) JSON Merge Patch
- [RFC 6902](https://www.rfc-editor.org/rfc/rfc6902) JSON Patch
- Annotated Go types for input and output models
- Generates JSON Schema from Go types
- Automatic input model validation & error handling
Expand Down Expand Up @@ -435,6 +438,19 @@ Try a request against the service like:
$ restish :8888/things/abc123?q=3 -H "Foo: bar" name: Kari
```

### Multiple Request Bodies

Request input structs can support multiple body types based on the content type of the request, with an unknown content type defaulting to the first-defined body. This can be used for things like versioned inputs or to support wildly different input types (e.g. JSON Merge Patch vs. JSON Patch). Example:

```go
type MyInput struct {
BodyV2 *MyInputBodyV1 `body:"application/my-type-v2+json"`
BodyV1 *MyInputBodyV1 `body:"application/my-type-v1+json"`
}
```

It's your responsibility to check which one is non-`nil` in the operation handler. If not using pointers, you'll need to check a known field to determine which was actually sent by the client.

### Parameter & Body Validation

All supported JSON Schema tags work for parameters and body fields. Validation happens before the request handler is called, and if needed an error response is returned. For example:
Expand Down Expand Up @@ -624,6 +640,14 @@ app.Resource("/resource").Put("put-resource", "Put a resource",
})
```

### Automatic PATCH Support

If a `GET` and a `PUT` exist for the same resource, but no `PATCH` exists at server start up, then by default a `PATCH` operation will be generated for you to make editing more convenient for clients. This behavior can be disabled via `app.DisableAutoPatch()`.

If the `GET` returns an `ETag` or `Last-Modified` header, then these will be used to make conditional requests on the `PUT` operation to prevent distributed write conflicts that might otherwise overwrite someone else's changes.

If the `PATCH` request has no `Content-Type` header, or uses `application/json` or a variant thereof, then JSON Merge Patch is assumed.

## Validation

Go struct tags are used to annotate inputs/output structs with information that gets turned into [JSON Schema](https://json-schema.org/) for documentation and validation.
Expand Down
7 changes: 6 additions & 1 deletion context.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ func AddAllowedHeaders(name ...string) {
}
}


// 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 {
Expand Down Expand Up @@ -310,6 +309,12 @@ func (c *hcontext) writeModel(ct string, status int, model interface{}) {
if !found {
panic(fmt.Errorf("Invalid model %s, expecting %s for %s %s", modelType, strings.Join(names, ", "), c.r.Method, c.r.URL.Path))
}
} else {
// Some automatic responses won't be registered but will have an error model
// returned. We should support these as well.
if modelType == reflect.TypeOf(&ErrorModel{}) {
modelRef = "/" + modelType.Elem().Name()
}
}

// If possible, insert a link relation header to the JSON Schema describing
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/andybalholm/brotli v1.0.4
github.com/benbjohnson/clock v1.3.0 // indirect
github.com/danielgtaylor/casing v0.0.0-20210126043903-4e55e6373ac3
github.com/evanphx/json-patch/v5 v5.6.0
github.com/fatih/structs v1.1.0
github.com/fxamacker/cbor/v2 v2.4.0
github.com/go-chi/chi v4.1.2+incompatible
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.
github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws=
github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww=
github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
Expand Down Expand Up @@ -260,6 +262,7 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
Expand Down
122 changes: 81 additions & 41 deletions operation.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,26 @@ func GetOperationInfo(ctx context.Context) *OperationInfo {
}
}

type request struct {
override bool
model reflect.Type
schema *schema.Schema
}

// Operation represents an operation (an HTTP verb, e.g. GET / PUT) against
// a resource attached to a router.
type Operation struct {
resource *Resource
method string
id string
summary string
description string
params map[string]oaParam
requestContentType string
requestSchema *schema.Schema
requestSchemaOverride bool
requestModel reflect.Type
responses []Response
maxBodyBytes int64
bodyReadTimeout time.Duration
resource *Resource
method string
id string
summary string
description string
params map[string]oaParam
defaultContentType string
requests map[string]*request
responses []Response
maxBodyBytes int64
bodyReadTimeout time.Duration
}

func newOperation(resource *Resource, method, id, docs string, responses []Response) *Operation {
Expand All @@ -62,6 +66,7 @@ func newOperation(resource *Resource, method, id, docs string, responses []Respo
id: id,
summary: summary,
description: desc,
requests: map[string]*request{},
responses: responses,
// 1 MiB body limit by default
maxBodyBytes: 1024 * 1024,
Expand Down Expand Up @@ -92,18 +97,14 @@ func (o *Operation) toOpenAPI(components *oaComponents) *gabs.Container {
}

// Request body
if o.requestSchema != nil {
ct := o.requestContentType
if ct == "" {
ct = "application/json"
}
for ct, request := range o.requests {
ref := ""
if o.requestSchemaOverride {
ref = components.AddExistingSchema(o.requestSchema, o.id+"-request", !o.resource.router.disableSchemaProperty)
if request.override {
ref = components.AddExistingSchema(request.schema, o.id+"-request", !o.resource.router.disableSchemaProperty)
} else {
// Regenerate with ModeAll so the same model can be used for both the
// input and output when possible.
ref = components.AddSchema(o.requestModel, schema.ModeAll, o.id+"-request", !o.resource.router.disableSchemaProperty)
ref = components.AddSchema(request.model, schema.ModeAll, o.id+"-request", !o.resource.router.disableSchemaProperty)
}
doc.Set(ref, "requestBody", "content", ct, "schema", "$ref")
}
Expand Down Expand Up @@ -145,6 +146,15 @@ func (o *Operation) toOpenAPI(components *oaComponents) *gabs.Container {
return doc
}

func (o *Operation) requestForContentType(ct string) (string, *request) {
req := o.requests[ct]
if req == nil {
ct = o.defaultContentType
req = o.requests[ct]
}
return ct, req
}

// MaxBodyBytes sets the max number of bytes that the request body size may be
// before the request is cancelled. The default is 1MiB.
func (o *Operation) MaxBodyBytes(size int64) {
Expand Down Expand Up @@ -175,8 +185,15 @@ func (o *Operation) NoBodyReadTimeout() {
// RequestSchema allows overriding the generated input body schema, giving you
// more control over documentation and validation.
func (o *Operation) RequestSchema(s *schema.Schema) {
o.requestSchema = s
o.requestSchemaOverride = true
o.RequestSchemaForContentType("application/json", s)
}

func (o *Operation) RequestSchemaForContentType(ct string, s *schema.Schema) {
if o.requests[ct] == nil {
o.requests[ct] = &request{}
}
o.requests[ct].override = true
o.requests[ct].schema = s
}

// Run registers the handler function for this operation. It should be of the
Expand Down Expand Up @@ -208,7 +225,6 @@ func (o *Operation) Run(handler interface{}) {

t := reflect.TypeOf(handler)
if t.Kind() == reflect.Func && t.NumIn() > 1 {
var err error
input := t.In(1)

// Get parameters
Expand All @@ -224,29 +240,51 @@ func (o *Operation) Run(handler interface{}) {
}

possible := []int{http.StatusBadRequest}
foundBody := false

for i := 0; i < input.NumField(); i++ {
f := input.Field(i)
if ct, ok := f.Tag.Lookup(locationBody); ok || f.Name == strings.Title(locationBody) {
foundBody = true

if ct == "" || ct == "true" {
// Default to JSON
ct = "application/json"
}

if o.defaultContentType == "" {
o.defaultContentType = ct
}

if _, ok := input.FieldByName("Body"); ok || len(o.params) > 0 {
if o.requests[ct] == nil {
o.requests[ct] = &request{}
}

o.requests[ct].model = f.Type

if !o.requests[ct].override {
s, err := schema.GenerateWithMode(f.Type, schema.ModeWrite, nil)
if o.resource != nil && o.resource.router != nil && !o.resource.router.disableSchemaProperty {
s.AddSchemaField()
}
if err != nil {
panic(fmt.Errorf("unable to generate JSON schema: %w", err))
}
o.requests[ct].schema = s
}
}
}

if foundBody || len(o.params) > 0 {
// Invalid parameter values or body values can cause a 422.
possible = append(possible, http.StatusUnprocessableEntity)
}

// Get body if present.
if body, ok := input.FieldByName("Body"); ok {
o.requestModel = body.Type
if foundBody {
possible = append(possible,
http.StatusRequestEntityTooLarge,
http.StatusRequestTimeout,
)

if o.requestSchema == nil {
o.requestSchema, err = schema.GenerateWithMode(body.Type, schema.ModeWrite, nil)
if o.resource != nil && o.resource.router != nil && !o.resource.router.disableSchemaProperty {
o.requestSchema.AddSchemaField()
}
if err != nil {
panic(fmt.Errorf("unable to generate JSON schema: %w", err))
}
}
}

// It's possible for the inputs to generate a few different errors, so
Expand Down Expand Up @@ -305,16 +343,18 @@ func (o *Operation) Run(handler interface{}) {
}
}

ct, reqDef := o.requestForContentType(r.Header.Get("Content-Type"))

// Set a read deadline for reading/parsing the input request body, but
// only for operations that have a request body model.
var conn net.Conn
if o.requestModel != nil && o.bodyReadTimeout > 0 {
if reqDef != nil && reqDef.model != nil && o.bodyReadTimeout > 0 {
if conn = GetConn(r.Context()); conn != nil {
conn.SetReadDeadline(time.Now().Add(o.bodyReadTimeout))
}
}

setFields(ctx, ctx.r, input, inputType)
setFields(ctx, ctx.r, input, inputType, ct, reqDef)
if !ctx.HasError() {
// No errors yet, so any errors that come after should be treated as a
// semantic rather than structural error.
Expand All @@ -329,7 +369,7 @@ func (o *Operation) Run(handler interface{}) {
// Clear any body read deadline if one was set as the body has now been
// read in. The one exception is when the body is streamed in via an
// `io.Reader` so we don't reset the deadline for that.
if conn != nil && o.requestModel != readerType {
if conn != nil && reqDef != nil && reqDef.model != readerType {
conn.SetReadDeadline(time.Time{})
}

Expand Down
Loading