Skip to content

fix(http): enforce required nil pointers in bodies, not just parameters - #1

Merged
juicycleff merged 1 commit into
mainfrom
fix/binder-required-semantics
Jul 31, 2026
Merged

fix(http): enforce required nil pointers in bodies, not just parameters#1
juicycleff merged 1 commit into
mainfrom
fix/binder-required-semantics

Conversation

@juicycleff

@juicycleff juicycleff commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

A required pointer field in a request body was silently accepted when absent:

type Req struct {
    Name *string `json:"name" required:"true"`
}
POST {}   ->  200        // no error, Name is nil

Cause

The nil check in validateCustomTags was gated on isParamField, so it only ran for query, path and header parameters. Bodies fell through to continue.

Meanwhile IsFieldRequired returns true for a required:"true" pointer regardless — the explicit tag is checked before the pointer branch — so the generated OpenAPI advertised the field as required. The published contract and the runtime disagreed, which is worse than either being wrong on its own: clients trust the spec.

A pointer is how a caller says "I need to tell absent from zero". It is not a statement about whether the field may be omitted — that is what required / optional are for.

Change

Drop the isParamField condition so a nil required pointer is reported wherever it appears.

Also collapses the two duplicate empty-string branches into one and confines that check to value types. For a non-pointer string, {"name":""} and {} are indistinguishable once decoded, so "" is the only available proxy for "not supplied" — but a non-nil pointer was demonstrably supplied, so "" behind one is a real value. Without this, the first change was self-defeating: a required *string set to "" was rejected, which is exactly the case the pointer form exists to express.

The two forms now mean distinct, useful things:

Name string  `required:"true"`   // must be supplied and non-empty
Name *string `required:"true"`   // must be supplied; "" is allowed

Tests

Both pointer cases are covered. TestBindRequest_RequiredPointerRejectsAbsent fails when the isParamField guard is restored.

Full go test ./... green. Verified downstream against forge and authsome via a local replace: authsome's suite passes across 79 packages with no changes required.

Considered and deliberately not included

Dropping omitempty as an optionality marker. It is a serialization directive in encoding/json doing double duty as a deserialization contract, and it is the reason optional:"true" goes unnoticed — the overloading caused three separate bugs in authsome.

But removing the inference flips every such field from optional to required. Measured, not estimated: 699 fields in authsome alone, breaking 77 tests across 15 packages, and it would start rejecting previously-valid client requests across every consumer of this library. That needs a major version and a codemod, not a patch.

Also considered: making required mean present rather than non-empty for value types. Not implementable at this layer — once JSON decodes into a non-pointer string, absent and empty are the same value, so removing the emptiness check deletes required-ness rather than refining it (verified: {} started returning 200). The pointer form above is the answer for that case, which is why it is worth fixing.

A required pointer field in a request body was silently accepted when
absent. The nil check was guarded by isParamField, so it only ran for
query, path and header parameters — while IsFieldRequired returns true
for a `required:"true"` pointer regardless, so the generated OpenAPI
advertised the field as required. The published contract and the runtime
disagreed, which is worse than either being wrong on its own: clients
trust the spec.

A pointer is how a caller says "I need to tell absent from zero". It is
not a statement about whether the field may be omitted — that is what
required/optional are for.

Also collapses the two empty-string branches into one and confines the
check to value types. For a non-pointer string, {"name":""} and {} are
indistinguishable once decoded, so "" is the only available proxy for
"not supplied" — but a non-nil pointer was demonstrably supplied, so ""
behind one is a real value. Applying the proxy there would have made the
pointer form pointless, and did: a required *string set to "" was
rejected.

Together these make the two forms mean distinct, useful things:

    Name string  `required:"true"`   // must be supplied and non-empty
    Name *string `required:"true"`   // must be supplied; "" is allowed

Tests cover both pointer cases. The absent case fails when the
isParamField guard is restored.

Not included: dropping omitempty as an optionality marker. It is a
serialization directive doing double duty as a deserialization contract,
and it is the reason optional:"true" goes unnoticed — but removing the
inference flips every such field from optional to required. That is 699
fields in authsome alone, breaking 77 tests across 15 packages, and it
would reject previously-valid client requests across every consumer. It
needs a major version and a codemod, not a patch.
@juicycleff
juicycleff merged commit 92a8620 into main Jul 31, 2026
6 checks passed
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.

1 participant