Skip to content

Commit

Permalink
Merge pull request #150 from danielgtaylor/file-uploads
Browse files Browse the repository at this point in the history
feat: better support for file uploads
  • Loading branch information
danielgtaylor authored Oct 23, 2023
2 parents cc92206 + a6532af commit ebefca4
Show file tree
Hide file tree
Showing 4 changed files with 92 additions and 15 deletions.
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

0 comments on commit ebefca4

Please sign in to comment.