Skip to content

Commit 20f5e04

Browse files
authored
Require form data by default (#963)
1 parent 897fab0 commit 20f5e04

8 files changed

Lines changed: 68 additions & 29 deletions

File tree

README_CN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
一个现代、简单、快速且灵活的微框架,用于在 OpenAPI 3 和 JSON Schema 支持的 Go 中构建 HTTP REST/RPC API。国际音标发音:[/'hjuːmɑ/](https://en.wiktionary.org/wiki/Wiktionary:International_Phonetic_Alphabet)。该项目的目标是提供:
2020

2121
- 拥有现有服务的团队逐步采用
22-
- 带上您自己的路由器(包括 Go 1.24+)、中间件和日志记录/指标
22+
- 带上您自己的路由器(包括 Go 1.22+)、中间件和日志记录/指标
2323
- 可扩展的 OpenAPI 和 JSON Schema 层来记录现有路由
2424
- 适合 Go 开发人员的现代 REST 或 HTTP RPC API 后端框架
2525
- [由OpenAPI 3.1](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md)[JSON Schema](https://json-schema.org/)描述
@@ -150,7 +150,7 @@ func main() {
150150
```
151151

152152
> [!TIP]
153-
> 替换`chi.NewMux()``http.NewServeMux()``humachi.New``humago.New`以使用 Go 1.24+ 中的标准库路由器。只需确保其中列出了您的或更新的`go.mod`版本即可。`go 1.22`其他一切都保持不变!当你准备好时就切换。
153+
> 替换`chi.NewMux()``http.NewServeMux()``humachi.New``humago.New`以使用 Go 1.22+ 中的标准库路由器。只需确保其中列出了您的或更新的`go.mod`版本即可。`go 1.22`其他一切都保持不变!当你准备好时就切换。
154154
155155
你可以用 `go run greet.go` 测试它(可选地传递 '--port' 来更改默认值),并使用 [Restish](https://rest.sh/)(或 `curl`) 发出示例请求:
156156

README_JA.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
本プロジェクトの主な目的は以下の通りです:
3030

3131
- 既存サービスを持つチーム向けの段階的な導入
32-
- 好きなルーター(Go 1.24+対応含む)、ミドルウェア、ロギング/メトリクスを利用可能
32+
- 好きなルーター(Go 1.22+対応含む)、ミドルウェア、ロギング/メトリクスを利用可能
3333
- 既存ルートをドキュメント化できる拡張性の高いOpenAPI & JSON Schemaレイヤ
3434
- Go開発者のためのモダンなREST/HTTP RPC APIバックエンドフレームワーク
3535
- [OpenAPI 3.1](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md) & [JSON Schema](https://json-schema.org/)によるAPI記述

api.go

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -284,13 +284,6 @@ type Format struct {
284284
Unmarshal func(data []byte, v any) error
285285
}
286286

287-
type SchemaOptions struct {
288-
// FieldsOptionalByDefault controls whether schema fields are treated as
289-
// optional by default. When false, fields are marked as required unless
290-
// they have the omitempty or omitzero tag.
291-
FieldsOptionalByDefault bool
292-
}
293-
294287
type api struct {
295288
config Config
296289
adapter Adapter

docs/docs/features/request-inputs.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,21 @@ description: Path, query, and header input parameters as well as input request b
88

99
Requests can have parameters and/or a body as input to the handler function. Inputs use standard Go structs with special fields and/or tags. Here are the available tags:
1010

11-
| Tag | Description | Example |
12-
| ---------- | ------------------------------------- | ------------------------ |
13-
| `path` | Name of the path parameter | `path:"thing-id"` |
14-
| `query` | Name of the query string parameter | `query:"q"` |
15-
| `header` | Name of the header parameter | `header:"Authorization"` |
16-
| `cookie` | Name of the cookie parameter | `cookie:"session"` |
17-
| `required` | Mark a query/header param as required | `required:"true"` |
11+
| Tag | Description | Example |
12+
| ---------- |----------------------------------------------| ------------------------ |
13+
| `path` | Name of the path parameter | `path:"thing-id"` |
14+
| `query` | Name of the query string parameter | `query:"q"` |
15+
| `header` | Name of the header parameter | `header:"Authorization"` |
16+
| `cookie` | Name of the cookie parameter | `cookie:"session"` |
17+
| `required` | Mark a cookie/header/query param as required | `required:"true"` |
18+
19+
!!! info "Default Optionality"
20+
21+
Cookier, header, and query parameters are **optional by default**. Path parameters are always required. This differs from object fields (e.g. in a request body), which are required by default unless `omitempty` or `omitzero` is used.
1822

1923
!!! info "Required"
2024

21-
The `required` tag is discouraged and is only used for query/header params, which should generally be optional for clients to send.
25+
The `required` tag is discouraged and is only used for header/query params, which should generally be optional for clients to send.
2226

2327
### Parameter Types
2428

docs/docs/features/request-validation.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@ The standard `json` tag is supported and can be used to rename a field. Any fiel
2323

2424
Fields being optional/required is determined automatically but can be overridden as needed using the logic below:
2525

26-
1. Start with all fields required.
26+
1. Start with all fields required, **except for cookie, header, and query parameters which are optional by default**.
2727
2. If a field has `omitempty`, it is optional.
2828
3. If a field has `omitzero`, it is optional.
2929
4. If a field has `required:"false"`, it is optional.
3030
5. If a field has `required:"true"`, it is required.
3131

32+
Path parameters are always required. Cookie, header, and query parameters are optional unless explicitly marked with `required:"true"`. All other fields (like those in a request body or multipart form) follow the default required status.
33+
3234
Pointers have no effect on optional/required. The same rules apply regardless of whether the struct is being used for request input or response output. Some examples:
3335

3436
```go

formdata.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,12 @@ func multiPartFormFileSchema(r Registry, t reflect.Type) *Schema {
223223
// Should we panic if [T] struct defines fields with unsupported types?
224224
}
225225

226-
if _, ok := f.Tag.Lookup("required"); ok && boolTag(f, "required", false) {
226+
fieldRequired := !r.Config().FieldsOptionalByDefault
227+
if _, ok := f.Tag.Lookup("required"); ok {
228+
fieldRequired = boolTag(f, "required", false)
229+
}
230+
231+
if fieldRequired {
227232
requiredFields = append(requiredFields, name)
228233
schema.requiredMap[name] = true
229234
}

huma.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,12 @@ type paramFieldInfo struct {
9696
Schema *Schema
9797
}
9898

99-
func findParams(registry Registry, op *Operation, t reflect.Type) *findResult[*paramFieldInfo] {
99+
func findParams(registry Registry, op *Operation, t reflect.Type, fieldsOptionalByDefault bool) *findResult[*paramFieldInfo] {
100100
return findInType(t, nil, func(f reflect.StructField, path []int) *paramFieldInfo {
101101
if f.Anonymous {
102102
return nil
103103
}
104+
104105
pfi := &paramFieldInfo{
105106
Type: f.Type,
106107
}
@@ -138,6 +139,7 @@ func findParams(registry Registry, op *Operation, t reflect.Type) *findResult[*p
138139
} else if fo := f.Tag.Get("form"); fo != "" {
139140
pfi.Loc = "form"
140141
name = fo
142+
pfi.Required = !fieldsOptionalByDefault
141143
} else if c := f.Tag.Get("cookie"); c != "" {
142144
pfi.Loc = "cookie"
143145
name = c
@@ -163,8 +165,8 @@ func findParams(registry Registry, op *Operation, t reflect.Type) *findResult[*p
163165
pfi.Schema = SchemaFromField(registry, f, "")
164166

165167
// While discouraged, make it possible to make query/header params required.
166-
if r := f.Tag.Get("required"); r == "true" {
167-
pfi.Required = true
168+
if _, ok := f.Tag.Lookup("required"); ok {
169+
pfi.Required = boolTag(f, "required", false)
168170
}
169171

170172
pfi.Name = name
@@ -437,14 +439,14 @@ func _findInType[T comparable](t reflect.Type, path []int, result *findResult[T]
437439
// structs. If `recurseFields` is true, then we also process named
438440
// struct fields recursively.
439441
visited[t] = struct{}{}
440-
_findInType(f.Type, fi, result, onType, onField, recurseFields, visited, ignore...)
442+
_findInType[T](f.Type, fi, result, onType, onField, recurseFields, visited, ignore...)
441443
delete(visited, t)
442444
}
443445
}
444446
case reflect.Slice:
445-
_findInType(t.Elem(), path, result, onType, onField, recurseFields, visited, ignore...)
447+
_findInType[T](t.Elem(), path, result, onType, onField, recurseFields, visited, ignore...)
446448
case reflect.Map:
447-
_findInType(t.Elem(), path, result, onType, onField, recurseFields, visited, ignore...)
449+
_findInType[T](t.Elem(), path, result, onType, onField, recurseFields, visited, ignore...)
448450
}
449451
}
450452

@@ -778,7 +780,7 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
778780
rawBodyDataF := rawBodyF.FieldByName("data")
779781
rawBodyDataT := rawBodyDataF.Type()
780782

781-
rawBodyInputParams := findParams(oapi.Components.Schemas, &op, rawBodyDataT)
783+
rawBodyInputParams := findParams(oapi.Components.Schemas, &op, rawBodyDataT, oapi.Components.Schemas.Config().FieldsOptionalByDefault)
782784
formValueParser = func(val reflect.Value) {
783785
rawBodyInputParams.Every(val, func(f reflect.Value, p *paramFieldInfo) {
784786
f = reflect.Indirect(f)
@@ -1146,7 +1148,7 @@ func initResponses(op *Operation) {
11461148
// processInputType validates the input type, extracts expected requests, and
11471149
// defines them on the operation op.
11481150
func processInputType(inputType reflect.Type, op *Operation, registry Registry) (*findResult[*paramFieldInfo], []int, bool, []int, rawBodyType, *Schema) {
1149-
inputParams := findParams(registry, op, inputType)
1151+
inputParams := findParams(registry, op, inputType, true)
11501152
inputBodyIndex := []int{}
11511153
hasInputBody := false
11521154
if f, ok := inputType.FieldByName("Body"); ok {

huma_test.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1340,7 +1340,7 @@ Hello, World!
13401340
Path: "/upload",
13411341
}, func(ctx context.Context, input *struct {
13421342
RawBody huma.MultipartFormFiles[struct {
1343-
HelloWorld huma.FormFile `form:"file" contentType:"text/plain"`
1343+
HelloWorld huma.FormFile `form:"file" contentType:"text/plain" required:"false"`
13441344
}]
13451345
}) (*struct{}, error) {
13461346
assert.False(t, input.RawBody.Data().HelloWorld.IsSet)
@@ -3285,6 +3285,39 @@ func TestCustomValidationErrorStatus(t *testing.T) {
32853285
assert.Contains(t, resp.Body.String(), "Bad Request")
32863286
}
32873287

3288+
func TestMinItemsValidation(t *testing.T) {
3289+
_, api := humatest.New(t, huma.DefaultConfig("Test API", "1.0.0"))
3290+
3291+
huma.Get(api, "/test", func(ctx context.Context, input *struct {
3292+
Names []string `query:"names" minItems:"2" required:"true"`
3293+
}) (*struct{}, error) {
3294+
return nil, nil
3295+
})
3296+
3297+
// 1. Missing query parameter should fail because it is required
3298+
resp := api.Get("/test")
3299+
assert.Equal(t, http.StatusUnprocessableEntity, resp.Result().StatusCode)
3300+
assert.Contains(t, resp.Body.String(), "required query parameter is missing")
3301+
3302+
// 2. Query parameter with 1 item should fail minItems
3303+
resp = api.Get("/test?names=foo")
3304+
assert.Equal(t, http.StatusUnprocessableEntity, resp.Result().StatusCode)
3305+
assert.Contains(t, resp.Body.String(), "expected array length >= 2")
3306+
3307+
huma.Get(api, "/optional", func(ctx context.Context, input *struct {
3308+
Names []string `query:"names" minItems:"2"`
3309+
}) (*struct{}, error) {
3310+
return nil, nil
3311+
})
3312+
3313+
// Query Optional (should pass when missing)
3314+
resp = api.Get("/optional")
3315+
assert.Contains(t, []int{200, 204}, resp.Code)
3316+
3317+
// But still fail if provided with too few items
3318+
assert.Equal(t, 422, api.Get("/optional?names=foo").Code)
3319+
}
3320+
32883321
// func BenchmarkSecondDecode(b *testing.B) {
32893322
// //nolint: musttag
32903323
// type MediumSized struct {

0 commit comments

Comments
 (0)