When Options.Prefix is set and the spec carries a security scheme, every POST/PUT/PATCH through the prefixed mount reaches its handler with a zero-byte body.
Mechanism. ValidateRequestFromContext handles Prefix by cloning the request and trimming the clone's path, so downstream code keeps the original URL. But http.Request.Clone copies the Body reference: when a security requirement is present, openapi3filter.validateSecurityRequirement does io.ReadAll(input.Request.Body) before invoking the AuthenticationFunc, then restores a fresh reader — onto the clone. The original request — the one the echo context still holds and the one the handler binds from — is left holding a drained reader with its ContentLength intact. This happens even with ExcludeRequestBody: true, because the read belongs to the security path, not body validation.
Why the suite misses it. TestOapiRequestValidatorWithPrefix only sends GETs; no test sends a body through a prefixed mount.
Reproduction (fails on v0.1.1 with Should NOT be empty, but was []):
func TestPrefixKeepsTheRequestBody(t *testing.T) {
const specYAML = `
openapi: "3.0.0"
info:
title: t
version: 1.0.0
paths:
/thing:
post:
operationId: createThing
security:
- BearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'204':
description: ok
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
`
spec, err := openapi3.NewLoader().LoadFromData([]byte(specYAML))
require.NoError(t, err)
e := echo.New()
e.Use(OapiRequestValidatorWithOptions(spec, &Options{
Prefix: "/api",
SilenceServersWarning: true,
Options: openapi3filter.Options{
ExcludeRequestBody: true,
AuthenticationFunc: func(context.Context, *openapi3filter.AuthenticationInput) error {
return nil
},
},
}))
var got []byte
e.POST("/api/thing", func(c *echo.Context) error {
got, _ = io.ReadAll(c.Request().Body)
return c.NoContent(http.StatusNoContent)
})
r := httptest.NewRequest(http.MethodPost, "/api/thing", strings.NewReader(`{"name":"x"}`))
r.Header.Set("content-type", "application/json")
r.Header.Set("authorization", "Bearer token")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, r)
require.Equal(t, http.StatusNoContent, rec.Code)
require.NotEmpty(t, got, "the handler received a drained body")
}
The fix we run in production (a vendored copy of this file): trim req.URL.Path — and RawPath, which the clone approach leaves inconsistent with the trimmed Path — in place, restoring both in a defer. RequestValidationInput.Request is then the live request, so openapi3filter's own body restore lands where it is needed, and handlers still observe the original path because the restore runs before next (your existing prefix-path assertion keeps passing). Eleven lines, one behaviour. Happy to send the PR with the fix and the test above if wanted.
When
Options.Prefixis set and the spec carries a security scheme, every POST/PUT/PATCH through the prefixed mount reaches its handler with a zero-byte body.Mechanism.
ValidateRequestFromContexthandlesPrefixby cloning the request and trimming the clone's path, so downstream code keeps the original URL. Buthttp.Request.Clonecopies theBodyreference: when a security requirement is present,openapi3filter.validateSecurityRequirementdoesio.ReadAll(input.Request.Body)before invoking theAuthenticationFunc, then restores a fresh reader — onto the clone. The original request — the one the echo context still holds and the one the handler binds from — is left holding a drained reader with itsContentLengthintact. This happens even withExcludeRequestBody: true, because the read belongs to the security path, not body validation.Why the suite misses it.
TestOapiRequestValidatorWithPrefixonly sends GETs; no test sends a body through a prefixed mount.Reproduction (fails on v0.1.1 with
Should NOT be empty, but was []):The fix we run in production (a vendored copy of this file): trim
req.URL.Path— andRawPath, which the clone approach leaves inconsistent with the trimmedPath— in place, restoring both in adefer.RequestValidationInput.Requestis then the live request, so openapi3filter's own body restore lands where it is needed, and handlers still observe the original path because the restore runs beforenext(your existing prefix-path assertion keeps passing). Eleven lines, one behaviour. Happy to send the PR with the fix and the test above if wanted.