diff --git a/README.md b/README.md index 2230bd1d..06809eca 100644 --- a/README.md +++ b/README.md @@ -383,7 +383,14 @@ The following parameter types are supported out of the box: For example, if the parameter is a query param and the type is `[]string` it might look like `?tags=tag1,tag2` in the URI. -The special struct field `Body` will be treated as the input request body and can refer to any other type or you can embed a struct or slice inline. Using `[]byte` as the `Body` type will bypass parsing and validation completely. `RawBody []byte` can also be used alongside `Body` to provide access to the `[]byte` used to validate & parse `Body`. +The special struct field `Body` will be treated as the input request body and can refer to any other type or you can embed a struct or slice inline. If the body is a pointer, then it is optional. All doc & validation tags are allowed on the body in addition to these tags: + +| Tag | Description | Example | +| ------------- | ------------------------- | ---------------------------------------- | +| `contenttype` | Override the content type | `contenttype:"application/octet-stream"` | +| `required` | Mark the body as required | `required:"true"` | + +`RawBody []byte` can also be used alongside `Body` or standalone to provide access to the `[]byte` used to validate & parse `Body`, or to the raw input without any validation/parsing. Example: @@ -407,6 +414,8 @@ $ restish api my-op 123 --detail=true --authorization=foo :whale: You can use `RawBody []byte` without a corresponding `Body` field in order to support file uploads. + #### 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. diff --git a/huma.go b/huma.go index 967509be..38b6c81b 100644 --- a/huma.go +++ b/huma.go @@ -84,7 +84,7 @@ func findParams(registry Registry, op *Operation, t reflect.Type) *findResult[*p pfi := ¶mFieldInfo{ Type: f.Type, - Schema: SchemaFromField(registry, nil, f), + Schema: SchemaFromField(registry, f, ""), } var example any @@ -379,11 +379,23 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) if f, ok := inputType.FieldByName("Body"); ok { inputBodyIndex = f.Index[0] if op.RequestBody == nil { + required := f.Type.Kind() != reflect.Ptr && f.Type.Kind() != reflect.Interface + if f.Tag.Get("required") == "true" { + required = true + } + + contentType := "application/json" + if c := f.Tag.Get("contentType"); c != "" { + contentType = c + } + + s := SchemaFromField(registry, f, getHint(inputType, f.Name, op.OperationID+"Request")) + op.RequestBody = &RequestBody{ - Required: f.Type.Kind() != reflect.Ptr && f.Type.Kind() != reflect.Interface, + Required: required, Content: map[string]*MediaType{ - "application/json": { - Schema: registry.Schema(f.Type, true, getHint(inputType, f.Name, op.OperationID+"Request")), + contentType: { + Schema: s, }, }, } @@ -402,6 +414,24 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) rawBodyIndex := -1 if f, ok := inputType.FieldByName("RawBody"); ok { rawBodyIndex = f.Index[0] + if op.RequestBody == nil { + contentType := "application/octet-stream" + if c := f.Tag.Get("contentType"); c != "" { + contentType = c + } + + op.RequestBody = &RequestBody{ + Required: true, + Content: map[string]*MediaType{ + contentType: { + Schema: &Schema{ + Type: "string", + Format: "binary", + }, + }, + }, + } + } } var inSchema *Schema @@ -457,7 +487,7 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) op.Responses[statusStr].Headers = map[string]*Param{} } if !outBodyFunc { - outSchema := registry.Schema(f.Type, true, getHint(outputType, f.Name, op.OperationID+"Response")) + outSchema := SchemaFromField(registry, f, getHint(outputType, f.Name, op.OperationID+"Response")) if op.Responses[statusStr].Content == nil { op.Responses[statusStr].Content = map[string]*MediaType{} } @@ -491,7 +521,7 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) op.Responses[defaultStatusStr].Headers[v.Name] = &Header{ // We need to generate the schema from the field to get validation info // like min/max and enums. Useful to let the client know possible values. - Schema: SchemaFromField(registry, outputType, v.Field), + Schema: SchemaFromField(registry, v.Field, getHint(outputType, v.Field.Name, op.OperationID+defaultStatusStr+v.Name)), } } @@ -704,7 +734,7 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) } } else { parseErrCount := 0 - if !op.SkipValidateBody { + if inputBodyIndex != -1 && !op.SkipValidateBody { // Validate the input. First, parse the body into []any or map[string]any // or equivalent, which can be easily validated. Then, convert to the // expected struct type to call the handler. diff --git a/huma_test.go b/huma_test.go index 2339bae3..6bb6f3d5 100644 --- a/huma_test.go +++ b/huma_test.go @@ -250,6 +250,26 @@ func TestFeatures(t *testing.T) { assert.Equal(t, http.StatusBadRequest, resp.Code) }, }, + { + Name: "request-ptr-body-required", + Register: func(t *testing.T, api API) { + Register(api, Operation{ + Method: http.MethodPut, + Path: "/body", + }, func(ctx context.Context, input *struct { + Body *struct { + Name string `json:"name"` + } `required:"true"` + }) (*struct{}, error) { + return nil, nil + }) + }, + Method: http.MethodPut, + URL: "/body", + Assert: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusBadRequest, resp.Code) + }, + }, { Name: "request-body-too-large", Register: func(t *testing.T, api API) { @@ -293,6 +313,28 @@ func TestFeatures(t *testing.T) { assert.Equal(t, http.StatusBadRequest, resp.Code) }, }, + { + Name: "request-body-file-upload", + Register: func(t *testing.T, api API) { + Register(api, Operation{ + Method: http.MethodPut, + Path: "/file", + }, func(ctx context.Context, input *struct { + RawBody []byte `contentType:"application/foo"` + }) (*struct{}, error) { + assert.Equal(t, `some-data`, string(input.RawBody)) + return nil, nil + }) + + // Ensure OpenAPI spec is listed as a binary upload. This enables + // generated documentation to show a file upload button. + assert.Equal(t, api.OpenAPI().Paths["/file"].Put.RequestBody.Content["application/foo"].Schema.Format, "binary") + }, + Method: http.MethodPut, + URL: "/file", + Headers: map[string]string{"Content-Type": "application/foo"}, + Body: `some-data`, + }, { Name: "handler-error", Register: func(t *testing.T, api API) { diff --git a/schema.go b/schema.go index 04512cea..8cdb9628 100644 --- a/schema.go +++ b/schema.go @@ -296,12 +296,8 @@ func jsonTag(f reflect.StructField, name string, multi bool) any { // // This is used by `huma.SchemaFromType` when it encounters a struct, and // is used to generate schemas for path/query/header parameters. -func SchemaFromField(registry Registry, parent reflect.Type, f reflect.StructField) *Schema { - parentName := "" - if parent != nil { - parentName = parent.Name() - } - fs := registry.Schema(f.Type, true, parentName+f.Name+"Struct") +func SchemaFromField(registry Registry, f reflect.StructField, hint string) *Schema { + fs := registry.Schema(f.Type, true, hint) if fs == nil { return fs } @@ -536,7 +532,7 @@ func SchemaFromType(r Registry, t reflect.Type) *Schema { continue } - fs := SchemaFromField(r, info.Parent, f) + fs := SchemaFromField(r, f, t.Name()+f.Name+"Struct") if fs != nil { props[name] = fs propNames = append(propNames, name)