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)