Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: better support for file uploads #150

Merged
merged 1 commit into from
Oct 23, 2023
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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -407,6 +414,8 @@ $ restish api my-op 123 --detail=true --authorization=foo <body.json
$ restish api/my-op/123?detail=true -H "Authorization: foo" <body.json
```

> :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.
Expand Down
44 changes: 37 additions & 7 deletions huma.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ func findParams(registry Registry, op *Operation, t reflect.Type) *findResult[*p

pfi := &paramFieldInfo{
Type: f.Type,
Schema: SchemaFromField(registry, nil, f),
Schema: SchemaFromField(registry, f, ""),
}

var example any
Expand Down Expand Up @@ -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,
},
},
}
Expand All @@ -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
Expand Down Expand Up @@ -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{}
}
Expand Down Expand Up @@ -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)),
}
}

Expand Down Expand Up @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions huma_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 3 additions & 7 deletions schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down