Skip to content

Commit 2a14580

Browse files
authored
feat: add Schema.Const (#1004)
1 parent ffc1eb5 commit 2a14580

6 files changed

Lines changed: 92 additions & 15 deletions

File tree

docs/docs/features/request-validation.md

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,11 @@ Nullable types will generate a type array like `"type": ["string", "null"]` whic
111111
The following additional tags are supported on model fields:
112112

113113
| Tag | Description | Example |
114-
|----------------------|--------------------------------------------|---------------------------------|
114+
| -------------------- | ------------------------------------------ | ------------------------------- |
115115
| `doc` | Describe the field | `doc:"Who to greet"` |
116116
| `format` | Format hint for the field | `format:"date-time"` |
117117
| `enum` | A comma-separated list of possible values | `enum:"one,two,three"` |
118+
| `const` | A constant value that the field must equal | `const:"fixed-value"` |
118119
| `default` | Default value | `default:"123"` |
119120
| `minimum` | Minimum (inclusive) | `minimum:"1"` |
120121
| `exclusiveMinimum` | Minimum (exclusive) | `exclusiveMinimum:"0"` |
@@ -140,7 +141,7 @@ The following additional tags are supported on model fields:
140141
Built-in string formats include:
141142

142143
| Format | Description | Example |
143-
|-----------------------------------|---------------------------------|----------------------------------------|
144+
| --------------------------------- | ------------------------------- | -------------------------------------- |
144145
| `date-time` | Date and time in RFC3339 format | `2021-12-31T23:59:59Z` |
145146
| `date-time-http` | Date and time in HTTP format | `Fri, 31 Dec 2021 23:59:59 GMT` |
146147
| `date` | Date in RFC3339 format | `2021-12-31` |
@@ -239,21 +240,21 @@ When enabled, any query parameter sent by the client that is not defined in the
239240

240241
When using custom JSON Schemas, i.e. not generated from Go structs, it's possible to utilize a few more validation rules. The following schema fields are respected by the built-in validator:
241242

242-
- `not` for negation
243-
- `oneOf` for exclusive inputs
244-
- `anyOf` for matching one-or-more
245-
- `allOf` for schema unions
243+
- `not` for negation
244+
- `oneOf` for exclusive inputs
245+
- `anyOf` for matching one-or-more
246+
- `allOf` for schema unions
246247

247248
See [`huma.Schema`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Schema) for more information. Note that it may be easier to use a custom [resolver](./request-resolvers.md) to implement some of these rules.
248249

249250
## Dive Deeper
250251

251-
- Tutorial
252-
- [Your First API](../tutorial/your-first-api.md) includes string length validation
253-
- Reference
254-
- [`huma.Register`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Register) registers new operations
255-
- [`huma.Operation`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Operation) the operation
256-
- External Links
257-
- [JSON Schema Validation](https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-00)
258-
- [OpenAPI 3.1 Schema Object](https://spec.openapis.org/oas/v3.1.0#schema-object)
259-
- [OpenAPI 3.1 Operation Object](https://spec.openapis.org/oas/v3.1.0#operation-object)
252+
- Tutorial
253+
- [Your First API](../tutorial/your-first-api.md) includes string length validation
254+
- Reference
255+
- [`huma.Register`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Register) registers new operations
256+
- [`huma.Operation`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Operation) the operation
257+
- External Links
258+
- [JSON Schema Validation](https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-00)
259+
- [OpenAPI 3.1 Schema Object](https://spec.openapis.org/oas/v3.1.0#schema-object)
260+
- [OpenAPI 3.1 Operation Object](https://spec.openapis.org/oas/v3.1.0#operation-object)

schema.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ type Schema struct {
119119
AdditionalProperties any `yaml:"additionalProperties,omitempty"`
120120
Properties map[string]*Schema `yaml:"properties,omitempty"`
121121
Enum []any `yaml:"enum,omitempty"`
122+
Const any `yaml:"const,omitempty"`
122123
Minimum *float64 `yaml:"minimum,omitempty"`
123124
ExclusiveMinimum *float64 `yaml:"exclusiveMinimum,omitempty"`
124125
Maximum *float64 `yaml:"maximum,omitempty"`
@@ -156,6 +157,7 @@ type Schema struct {
156157
// Precomputed validation messages. These prevent allocations during
157158
// validation and are known at schema creation time.
158159
msgEnum string `yaml:"-"`
160+
msgConst string `yaml:"-"`
159161
msgMinimum string `yaml:"-"`
160162
msgExclusiveMinimum string `yaml:"-"`
161163
msgMaximum string `yaml:"-"`
@@ -213,6 +215,7 @@ func (s *Schema) MarshalJSON() ([]byte, error) {
213215
{"additionalProperties", s.AdditionalProperties, omitNil},
214216
{"properties", props, omitEmpty},
215217
{"enum", s.Enum, omitEmpty},
218+
{"const", s.Const, omitNil},
216219
{"minimum", s.Minimum, omitEmpty},
217220
{"exclusiveMinimum", s.ExclusiveMinimum, omitEmpty},
218221
{"maximum", s.Maximum, omitEmpty},
@@ -246,6 +249,9 @@ func (s *Schema) PrecomputeMessages() {
246249
s.msgEnum = ErrorFormatter(validation.MsgExpectedOneOf, strings.Join(mapTo(s.Enum, func(v any) string {
247250
return fmt.Sprintf("%v", v)
248251
}), ", "))
252+
if s.Const != nil {
253+
s.msgConst = ErrorFormatter(validation.MsgExpectedConst, fmt.Sprintf("%v", s.Const))
254+
}
249255
if s.Minimum != nil {
250256
s.msgMinimum = ErrorFormatter(validation.MsgExpectedMinimumNumber, *s.Minimum)
251257
}
@@ -608,6 +614,11 @@ func SchemaFromField(registry Registry, f reflect.StructField, hint string) *Sch
608614
s.Enum = enumValues
609615
}
610616

617+
if constValue := f.Tag.Get("const"); constValue != "" {
618+
s := targetSchema(fs)
619+
s.Const = jsonTagValue(registry, f.Name, s, constValue)
620+
}
621+
611622
fs.Nullable = boolTag(f, "nullable", fs.Nullable)
612623
if fs.Nullable && fs.Ref != "" && registry.SchemaFromRef(fs.Ref).Type == "object" {
613624
// Nullability is only supported for scalar types for now. Objects are

schema_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,34 @@ func TestSchema(t *testing.T) {
415415
"additionalProperties": false
416416
}`,
417417
},
418+
{
419+
name: "field-const-string",
420+
input: struct {
421+
StrValue string `json:"strValue" const:"fixed"`
422+
IntValue int `json:"intValue" const:"5"`
423+
BoolValue bool `json:"boolValue" const:"true"`
424+
}{},
425+
expected: `{
426+
"type": "object",
427+
"properties": {
428+
"strValue": {
429+
"type": "string",
430+
"const": "fixed"
431+
},
432+
"intValue": {
433+
"type": "integer",
434+
"format": "int64",
435+
"const": 5
436+
},
437+
"boolValue": {
438+
"type": "boolean",
439+
"const": true
440+
}
441+
},
442+
"required": ["strValue", "intValue", "boolValue"],
443+
"additionalProperties": false
444+
}`,
445+
},
418446
{
419447
name: "field-readonly",
420448
input: struct {

validate.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,13 @@ func Validate(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v any,
586586
res.Add(path, v, s.msgEnum)
587587
}
588588
}
589+
590+
if s.Const != nil {
591+
// Deep equality check for const validation
592+
if !reflect.DeepEqual(s.Const, v) {
593+
res.Add(path, v, s.msgConst)
594+
}
595+
}
589596
}
590597

591598
func handleArray[T any](r Registry, s *Schema, path *PathBuffer, mode ValidateMode, res *ValidateResult, arr []T) {

validate_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,6 +1099,35 @@ var validateTests = []struct {
10991099
input: map[string]any{"value": "three"},
11001100
errs: []string{"expected value to be one of \"one, two\""},
11011101
},
1102+
{
1103+
name: "const string success",
1104+
typ: reflect.TypeFor[struct {
1105+
Value string "json:\"value\" const:\"fixed\""
1106+
}](),
1107+
input: map[string]any{"value": "fixed"},
1108+
},
1109+
{
1110+
name: "const int success",
1111+
typ: reflect.TypeFor[struct {
1112+
Value int "json:\"value\" const:\"42\""
1113+
}](),
1114+
input: map[string]any{"value": 42.0},
1115+
},
1116+
{
1117+
name: "const bool success",
1118+
typ: reflect.TypeFor[struct {
1119+
Value bool "json:\"value\" const:\"true\""
1120+
}](),
1121+
input: map[string]any{"value": true},
1122+
},
1123+
{
1124+
name: "expected const",
1125+
typ: reflect.TypeFor[struct {
1126+
Value string "json:\"value\" const:\"fixed\""
1127+
}](),
1128+
input: map[string]any{"value": "not fixed"},
1129+
errs: []string{"expected value to be fixed"},
1130+
},
11021131
{
11031132
name: "optional success",
11041133
typ: reflect.TypeFor[struct {

validation/messages.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ var (
3232
MsgExpectedObject = "expected object"
3333
MsgExpectedArrayItemsUnique = "expected array items to be unique"
3434
MsgExpectedOneOf = "expected value to be one of \"%s\""
35+
MsgExpectedConst = "expected value to be %v"
3536
MsgExpectedMinimumNumber = "expected number >= %v"
3637
MsgExpectedExclusiveMinimumNumber = "expected number > %v"
3738
MsgExpectedMaximumNumber = "expected number <= %v"

0 commit comments

Comments
 (0)