From 1a2d32fec8e167d497afdefe7367eca50e309175 Mon Sep 17 00:00:00 2001 From: Hittrich Date: Sun, 9 Aug 2026 12:20:11 +0200 Subject: [PATCH] Fix optional request bodies --- pkg/codegen/codegen_test.go | 31 ++++++++++ pkg/codegen/templates/handler/adapter.tmpl | 35 +++++++++--- .../testdata/optional-request-body.yml | 57 +++++++++++++++++++ 3 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 pkg/codegen/testdata/optional-request-body.yml diff --git a/pkg/codegen/codegen_test.go b/pkg/codegen/codegen_test.go index 287ec57e..00e1406c 100644 --- a/pkg/codegen/codegen_test.go +++ b/pkg/codegen/codegen_test.go @@ -15,6 +15,7 @@ import ( "go/format" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -743,3 +744,33 @@ func TestCollectWebhookDefinitions(t *testing.T) { } }) } + +// An operation whose requestBody is optional must accept a request that carries +// none: the decoder reports an empty body, and the handler leaves opts.Body nil +// so the request reaches the service. A required body keeps failing to decode. +func TestOptionalRequestBodyToleratesAnEmptyBody(t *testing.T) { + cfg := Configuration{ + PackageName: "api", + Output: &Output{UseSingleFile: true}, + Generate: &GenerateOptions{ + Handler: &HandlerOptions{Kind: "chi"}, + }, + } + + codes, err := Generate([]byte(readTestdata(t, "optional-request-body.yml")), cfg) + require.NoError(t, err) + + code := codes.GetCombined() + + assert.Contains(t, code, "case errors.Is(err, runtime.ErrRequestBodyEmpty):", + "the optional body admits a request that carries none") + assert.Equal(t, 1, strings.Count(code, "runtime.ErrRequestBodyEmpty"), + "only the optional operation admits it") + + // The required body keeps the shape it had before, so no consumer's + // generated output shifts for an operation this does not change. + assert.Contains(t, code, "if err := a.jsonBodyDecoder(r.Body, &body); err != nil {") + + _, err = format.Source([]byte(code)) + require.NoError(t, err, "Generated code should compile without syntax errors") +} diff --git a/pkg/codegen/templates/handler/adapter.tmpl b/pkg/codegen/templates/handler/adapter.tmpl index eaada269..62ab4234 100644 --- a/pkg/codegen/templates/handler/adapter.tmpl +++ b/pkg/codegen/templates/handler/adapter.tmpl @@ -13,6 +13,17 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */}} +{{- define "decode-error" -}} +{{- if .HasTypedError }} + a.errHandler.HandleError(w, r, {{ .Op.Response.Error.StatusCode }}, New{{ .ErrorTypeName }}(err.Error())) +{{- else }} + a.errHandler.HandleError(w, r, http.StatusBadRequest, OapiHandlerError{ + Kind: OapiErrorKindDecode, + OperationID: "{{ .Op.ID }}", + Message: err.Error(), + }) +{{- end }} +{{- end -}} {{- $config := .Config -}} {{- $operations := .Operations -}} {{- $serviceName := $config.Generate.Handler.Name -}} @@ -332,19 +343,25 @@ func (a *HTTPAdapter) {{ $op.ID | ucFirst }}(w http.ResponseWriter, r *http.Requ defer r.Body.Close() {{- if or (eq $op.Body.ContentType "application/json") (hasSuffix $op.Body.ContentType "+json") }} var body {{ $op.Body.Name }} + {{- if $op.Body.Required }} if err := a.jsonBodyDecoder(r.Body, &body); err != nil { - {{- if $hasTypedError }} - a.errHandler.HandleError(w, r, {{ $op.Response.Error.StatusCode }}, New{{ $errorTypeName }}(err.Error())) - {{- else }} - a.errHandler.HandleError(w, r, http.StatusBadRequest, OapiHandlerError{ - Kind: OapiErrorKindDecode, - OperationID: "{{ $op.ID }}", - Message: err.Error(), - }) - {{- end }} + {{- template "decode-error" dict "Op" $op "HasTypedError" $hasTypedError "ErrorTypeName" $errorTypeName }} return } opts.Body = &body + {{- else }} + switch err := a.jsonBodyDecoder(r.Body, &body); { + case errors.Is(err, runtime.ErrRequestBodyEmpty): + // requestBody is optional, so a request carrying none leaves opts.Body + // nil rather than failing. Decoding into the zero value instead would + // hand the validator a body nobody sent, and fail on its required fields. + case err != nil: + {{- template "decode-error" dict "Op" $op "HasTypedError" $hasTypedError "ErrorTypeName" $errorTypeName }} + return + default: + opts.Body = &body + } + {{- end }} {{- else if eq $op.Body.ContentType "application/x-www-form-urlencoded" }} var body {{ $op.Body.Name }} formBytes, err := io.ReadAll(r.Body) diff --git a/pkg/codegen/testdata/optional-request-body.yml b/pkg/codegen/testdata/optional-request-body.yml new file mode 100644 index 00000000..1d461e28 --- /dev/null +++ b/pkg/codegen/testdata/optional-request-body.yml @@ -0,0 +1,57 @@ +openapi: 3.0.3 +info: + title: Optional request body + version: 1.0.0 +paths: + /things/{id}/settle: + post: + operationId: settleThing + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/SettleRequest' + responses: + '200': + description: Settled. + content: + application/json: + schema: + $ref: '#/components/schemas/Thing' + /things: + post: + operationId: createThing + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Thing' + responses: + '201': + description: Created. + content: + application/json: + schema: + $ref: '#/components/schemas/Thing' +components: + schemas: + Thing: + type: object + required: [name] + properties: + name: + type: string + SettleRequest: + type: object + required: [amount] + properties: + amount: + type: integer