Skip to content
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
4 changes: 2 additions & 2 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
一个现代、简单、快速且灵活的微框架,用于在 OpenAPI 3 和 JSON Schema 支持的 Go 中构建 HTTP REST/RPC API。国际音标发音:[/'hjuːmɑ/](https://en.wiktionary.org/wiki/Wiktionary:International_Phonetic_Alphabet)。该项目的目标是提供:

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

> [!TIP]
> 替换`chi.NewMux()`→`http.NewServeMux()`和`humachi.New`→`humago.New`以使用 Go 1.24+ 中的标准库路由器。只需确保其中列出了您的或更新的`go.mod`版本即可。`go 1.22`其他一切都保持不变!当你准备好时就切换。
> 替换`chi.NewMux()`→`http.NewServeMux()`和`humachi.New`→`humago.New`以使用 Go 1.22+ 中的标准库路由器。只需确保其中列出了您的或更新的`go.mod`版本即可。`go 1.22`其他一切都保持不变!当你准备好时就切换。

你可以用 `go run greet.go` 测试它(可选地传递 '--port' 来更改默认值),并使用 [Restish](https://rest.sh/)(或 `curl`) 发出示例请求:

Expand Down
2 changes: 1 addition & 1 deletion README_JA.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
本プロジェクトの主な目的は以下の通りです:

- 既存サービスを持つチーム向けの段階的な導入
- 好きなルーター(Go 1.24+対応含む)、ミドルウェア、ロギング/メトリクスを利用可能
- 好きなルーター(Go 1.22+対応含む)、ミドルウェア、ロギング/メトリクスを利用可能
- 既存ルートをドキュメント化できる拡張性の高いOpenAPI & JSON Schemaレイヤ
- Go開発者のためのモダンなREST/HTTP RPC APIバックエンドフレームワーク
- [OpenAPI 3.1](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md) & [JSON Schema](https://json-schema.org/)によるAPI記述
Expand Down
7 changes: 0 additions & 7 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,13 +284,6 @@ type Format struct {
Unmarshal func(data []byte, v any) error
}

type SchemaOptions struct {
// FieldsOptionalByDefault controls whether schema fields are treated as
// optional by default. When false, fields are marked as required unless
// they have the omitempty or omitzero tag.
FieldsOptionalByDefault bool
}

type api struct {
config Config
adapter Adapter
Expand Down
20 changes: 12 additions & 8 deletions docs/docs/features/request-inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,21 @@ description: Path, query, and header input parameters as well as input request b

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:

| Tag | Description | Example |
| ---------- | ------------------------------------- | ------------------------ |
| `path` | Name of the path parameter | `path:"thing-id"` |
| `query` | Name of the query string parameter | `query:"q"` |
| `header` | Name of the header parameter | `header:"Authorization"` |
| `cookie` | Name of the cookie parameter | `cookie:"session"` |
| `required` | Mark a query/header param as required | `required:"true"` |
| Tag | Description | Example |
| ---------- |----------------------------------------------| ------------------------ |
| `path` | Name of the path parameter | `path:"thing-id"` |
| `query` | Name of the query string parameter | `query:"q"` |
| `header` | Name of the header parameter | `header:"Authorization"` |
| `cookie` | Name of the cookie parameter | `cookie:"session"` |
| `required` | Mark a cookie/header/query param as required | `required:"true"` |

!!! info "Default Optionality"

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.

!!! info "Required"

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

### Parameter Types

Expand Down
4 changes: 3 additions & 1 deletion docs/docs/features/request-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ The standard `json` tag is supported and can be used to rename a field. Any fiel

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

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

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.

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:

```go
Expand Down
7 changes: 6 additions & 1 deletion formdata.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,12 @@ func multiPartFormFileSchema(r Registry, t reflect.Type) *Schema {
// Should we panic if [T] struct defines fields with unsupported types?
}

if _, ok := f.Tag.Lookup("required"); ok && boolTag(f, "required", false) {
fieldRequired := !r.Config().FieldsOptionalByDefault
if _, ok := f.Tag.Lookup("required"); ok {
fieldRequired = boolTag(f, "required", false)
}

if fieldRequired {
requiredFields = append(requiredFields, name)
schema.requiredMap[name] = true
}
Expand Down
18 changes: 10 additions & 8 deletions huma.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,12 @@ type paramFieldInfo struct {
Schema *Schema
}

func findParams(registry Registry, op *Operation, t reflect.Type) *findResult[*paramFieldInfo] {
func findParams(registry Registry, op *Operation, t reflect.Type, fieldsOptionalByDefault bool) *findResult[*paramFieldInfo] {
return findInType(t, nil, func(f reflect.StructField, path []int) *paramFieldInfo {
if f.Anonymous {
return nil
}

pfi := &paramFieldInfo{
Type: f.Type,
}
Expand Down Expand Up @@ -138,6 +139,7 @@ func findParams(registry Registry, op *Operation, t reflect.Type) *findResult[*p
} else if fo := f.Tag.Get("form"); fo != "" {
pfi.Loc = "form"
name = fo
pfi.Required = !fieldsOptionalByDefault
} else if c := f.Tag.Get("cookie"); c != "" {
pfi.Loc = "cookie"
name = c
Expand All @@ -163,8 +165,8 @@ func findParams(registry Registry, op *Operation, t reflect.Type) *findResult[*p
pfi.Schema = SchemaFromField(registry, f, "")

// While discouraged, make it possible to make query/header params required.
if r := f.Tag.Get("required"); r == "true" {
pfi.Required = true
if _, ok := f.Tag.Lookup("required"); ok {
pfi.Required = boolTag(f, "required", false)
}

pfi.Name = name
Expand Down Expand Up @@ -437,14 +439,14 @@ func _findInType[T comparable](t reflect.Type, path []int, result *findResult[T]
// structs. If `recurseFields` is true, then we also process named
// struct fields recursively.
visited[t] = struct{}{}
_findInType(f.Type, fi, result, onType, onField, recurseFields, visited, ignore...)
_findInType[T](f.Type, fi, result, onType, onField, recurseFields, visited, ignore...)
delete(visited, t)
}
}
case reflect.Slice:
_findInType(t.Elem(), path, result, onType, onField, recurseFields, visited, ignore...)
_findInType[T](t.Elem(), path, result, onType, onField, recurseFields, visited, ignore...)
case reflect.Map:
_findInType(t.Elem(), path, result, onType, onField, recurseFields, visited, ignore...)
_findInType[T](t.Elem(), path, result, onType, onField, recurseFields, visited, ignore...)
}
}

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

rawBodyInputParams := findParams(oapi.Components.Schemas, &op, rawBodyDataT)
rawBodyInputParams := findParams(oapi.Components.Schemas, &op, rawBodyDataT, oapi.Components.Schemas.Config().FieldsOptionalByDefault)
formValueParser = func(val reflect.Value) {
rawBodyInputParams.Every(val, func(f reflect.Value, p *paramFieldInfo) {
f = reflect.Indirect(f)
Expand Down Expand Up @@ -1146,7 +1148,7 @@ func initResponses(op *Operation) {
// processInputType validates the input type, extracts expected requests, and
// defines them on the operation op.
func processInputType(inputType reflect.Type, op *Operation, registry Registry) (*findResult[*paramFieldInfo], []int, bool, []int, rawBodyType, *Schema) {
inputParams := findParams(registry, op, inputType)
inputParams := findParams(registry, op, inputType, true)
inputBodyIndex := []int{}
hasInputBody := false
if f, ok := inputType.FieldByName("Body"); ok {
Expand Down
35 changes: 34 additions & 1 deletion huma_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1340,7 +1340,7 @@ Hello, World!
Path: "/upload",
}, func(ctx context.Context, input *struct {
RawBody huma.MultipartFormFiles[struct {
HelloWorld huma.FormFile `form:"file" contentType:"text/plain"`
HelloWorld huma.FormFile `form:"file" contentType:"text/plain" required:"false"`
}]
}) (*struct{}, error) {
assert.False(t, input.RawBody.Data().HelloWorld.IsSet)
Expand Down Expand Up @@ -3285,6 +3285,39 @@ func TestCustomValidationErrorStatus(t *testing.T) {
assert.Contains(t, resp.Body.String(), "Bad Request")
}

func TestMinItemsValidation(t *testing.T) {
_, api := humatest.New(t, huma.DefaultConfig("Test API", "1.0.0"))

huma.Get(api, "/test", func(ctx context.Context, input *struct {
Names []string `query:"names" minItems:"2" required:"true"`
}) (*struct{}, error) {
return nil, nil
})

// 1. Missing query parameter should fail because it is required
resp := api.Get("/test")
assert.Equal(t, http.StatusUnprocessableEntity, resp.Result().StatusCode)
assert.Contains(t, resp.Body.String(), "required query parameter is missing")

// 2. Query parameter with 1 item should fail minItems
resp = api.Get("/test?names=foo")
assert.Equal(t, http.StatusUnprocessableEntity, resp.Result().StatusCode)
assert.Contains(t, resp.Body.String(), "expected array length >= 2")

huma.Get(api, "/optional", func(ctx context.Context, input *struct {
Names []string `query:"names" minItems:"2"`
}) (*struct{}, error) {
return nil, nil
})

// Query Optional (should pass when missing)
resp = api.Get("/optional")
assert.Contains(t, []int{200, 204}, resp.Code)

// But still fail if provided with too few items
assert.Equal(t, 422, api.Get("/optional?names=foo").Code)
}

// func BenchmarkSecondDecode(b *testing.B) {
// //nolint: musttag
// type MediumSized struct {
Expand Down