Skip to content

Document the elements of array parameters and slice fields - #117

Merged
FumingPower3925 merged 1 commit into
mainfrom
item-constraints
Jul 16, 2026
Merged

Document the elements of array parameters and slice fields#117
FumingPower3925 merged 1 commit into
mainfrom
item-constraints

Conversation

@FumingPower3925

Copy link
Copy Markdown
Owner

Closes #116.

An array parameter could document its container but not its contents: ?severity=high&severity=low emitted an opaque items: {type: string}, and both ways of asking for more panicked. The array story was half-built — and it was the one place where "Go types become JSON Schemas with validation rules from struct tags" wasn't true.

What it does

Tags describe the elements. On a slice or array field the scalar constraints now retarget to items, because an array has no enum/format/pattern/length/bound of its own and the flat tag vocabulary can't tell the levels apart:

Severity []string `query:"severity" enum:"info,low,high"`   // -> items.enum

This runs through the shared reflection, so bodies, responses and webhook payloads get it too — not just query params. Only minItems/maxItems/uniqueItems still describe the array.

Options say the level out loud — a new ItemOpt family (ItemEnum, ItemFormat, ItemPattern, ItemMinLength, ItemMaxLength, ItemMinimum, ItemMaximum, ItemExclusiveMinimum, ItemExclusiveMaximum) nested inside ParamItems, which owns the element schema:

stdocs.QueryParam("severity", "array", "Repeated severity filter",
    stdocs.ParamItems("string", stdocs.ItemEnum("info", "low", "high")))

Nesting is load-bearing: ParamItems replaces Items wholesale, so a sibling item option would be silently discarded by a later ParamItems. A new test pins that both paths emit the same document.

default:/example: stay rejected on a slice, and there is no ItemDefault/ItemExample — a lone value can't say whether it's the whole array or one element. Both panics now explain themselves, and ParamEnum on an array points at ItemEnum.

It came out cheaper than it looks

The emitters and tsgen needed no changes — they already recurse into items. So this falls out for free:

  • tsgen: severity?: ("info" | "low" | "high")[] (a real union array — verified a bogus member fails tsc --strict with TS2322)
  • ogen: synthesizes a typed GetTasksSeverityItem enum
  • UIs: Scalar renders severity — array string[] · unique! · enum with the values listed

Two adjacent fixes this made necessary

  • Lint was blind to element bounds. lintComponents walked Properties and never Items, so []float64 + exclusiveMinimum:"0" would emit the exact generator-hostile numeric 3.1/3.2 form lint.go itself warns about — unreported. It now sees them.
  • ParamFormat had no guard, writing format onto the array. After the tag change that would have made the two param paths document the same intent differently, breaking the promise at params.go:22 that the modifiers mirror the tags. It now rejects arrays and points at ItemFormat.
  • Also: a WithParams field whose elements have no JSON form ([]func()) was accepted with an empty element schema; and those same elements would have nil-dereferenced under the retarget. Both guarded, and []func()/[][]string are now in the fuzz corpus.

Verification

  • gofmt/vet/build/go test -race ./.../golangci-lint clean; nested YAML module passes; fuzzer green over 3M+ execs with the new field kinds.
  • External validation of all three versions (openapi-spec-validator 3.0/3.1 + official OAS 3.2 schema), including the nullable []*string item enum — the anyOf+hoisted-null-enum shape neither corpus had produced.
  • ogen (the CI-pinned v1.20.3) verified locally on both the 3.0.4 doc and the 3.1 anyOf nullable item enum before the fixture landed — exit 0 on both.
  • tsc harness unchanged and still green (the new corpus field is optional, so tags: ["one"] still type-checks), plus a negative control proving the union is enforced.
  • uismoke renders all nine UIs under the enforced CSP; Scalar spot-checked on a repeated filter.
  • Every new guard is negative-controlled — a green suite that never fires a guard proves nothing.

Note for review

[N]byte reflects to {array, items:{integer}}, so format:"uuid" on a [16]byte now applies to the integer elements instead of panicking. Accepted deliberately (the schema model can't distinguish it from []uint16, and format/type coherence is validated nowhere); documented in doc.go + CHANGELOG, pointing at openapi:"type=string,format=uuid".

An array parameter and a slice field could describe the container but
not what it holds, so a repeated query filter — ?severity=high&
severity=low — documented as an opaque items: {type: string}. Both ways
of asking for more panicked.

On a slice or array field the scalar constraint tags now describe the
ELEMENTS, because an array has no enum, format, pattern, length, or
bound of its own and the flat tag vocabulary cannot tell the two levels
apart:

    Severity []string `query:"severity" enum:"info,low,high"`

emits items.enum. That runs through the shared reflection, so bodies,
responses, and webhook payloads get it too. Only minItems, maxItems, and
uniqueItems still describe the array.

The option path says the level out loud instead, with a new ItemOpt
family nested inside ParamItems, which owns the element schema:

    stdocs.QueryParam("severity", "array", "Repeated severity filter",
        stdocs.ParamItems("string", stdocs.ItemEnum("info", "low", "high")))

Both paths produce the same document. The emitters and tsgen needed no
changes — they already recurse into items — so an element enum renders
as ("info" | "low" | "high")[] in the generated TypeScript, ogen
synthesizes a typed enum for it, and the docs UIs list its values.

default: and example: stay rejected on a slice, and there is no
ItemDefault or ItemExample: a lone value cannot say whether it is the
whole array or one element. Elements that cannot carry a constraint are
named in the panic rather than described as though they were the field.

Two adjacent fixes the change made necessary: Lint could not see an
exclusive bound on a slice's elements, which would have let the
generator-hostile numeric form ship unreported; and a params field whose
elements have no JSON form was accepted with an empty element schema.

Closes #116
@FumingPower3925 FumingPower3925 added this to the v0.9.0 milestone Jul 16, 2026
@FumingPower3925
FumingPower3925 merged commit 7090ed6 into main Jul 16, 2026
40 checks passed
@FumingPower3925
FumingPower3925 deleted the item-constraints branch July 16, 2026 23:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support item-level enums (and scalar constraints) on array parameters and slice fields

1 participant