From 5ff1887d4aa3cb347a34dc3e482ae294e17bfec1 Mon Sep 17 00:00:00 2001 From: Kaito Tokumori Date: Wed, 3 Jun 2026 21:42:00 +0900 Subject: [PATCH] openapi3filter: honor comma-separated and wildcard encoding.contentType OAS 3.0 allows encoding.contentType to be a single media type, a wildcard, or a comma-separated list; match a multipart part against each entry instead of doing an exact string compare. Binary parts whose matched content type has no registered decoder are now read as raw bytes rather than rejected. Co-Authored-By: Claude Opus 4.8 (1M context) --- openapi3filter/req_resp_decoder.go | 42 +++++++++++++++- openapi3filter/req_resp_decoder_test.go | 64 ++++++++++++++++++++----- 2 files changed, 92 insertions(+), 14 deletions(-) diff --git a/openapi3filter/req_resp_decoder.go b/openapi3filter/req_resp_decoder.go index 2c9517ca7..539603898 100644 --- a/openapi3filter/req_resp_decoder.go +++ b/openapi3filter/req_resp_decoder.go @@ -1243,6 +1243,39 @@ func getEncodingContentType(encFn EncodingFn) string { return enc.ContentType } +// contentTypeAllowedByEncoding reports whether mediaType satisfies the +// encoding.contentType, which per OAS 3.0 may be a single media type, a wildcard +// (e.g. image/*), or a comma-separated list of those. mediaType must be a base +// type; parameters on encoding entries (e.g. "; charset=utf-8") are ignored. +func contentTypeAllowedByEncoding(mediaType, encodingContentType string) bool { + for raw := range strings.SplitSeq(encodingContentType, ",") { + want := strings.TrimSpace(raw) + if want == "" { + continue + } + if base, _, err := mime.ParseMediaType(want); err == nil { + want = base + } + if mediaTypeMatches(mediaType, want) { + return true + } + } + return false +} + +// mediaTypeMatches reports whether got matches want (exact, type/* or */* wildcard, +// case-insensitive). Both must be base media types without parameters. +func mediaTypeMatches(got, want string) bool { + if want == "*/*" || strings.EqualFold(want, got) { + return true + } + if prefix, ok := strings.CutSuffix(want, "/*"); ok { + gotType, _, found := strings.Cut(got, "/") + return found && strings.EqualFold(gotType, prefix) + } + return false +} + // decodeBody returns a decoded body. // The function returns ParseError when a body is invalid. func decodeBody(body io.Reader, header http.Header, schema *openapi3.SchemaRef, encFn EncodingFn) ( @@ -1265,7 +1298,7 @@ func decodeBody(body io.Reader, header http.Header, schema *openapi3.SchemaRef, } if encodingContentType != "" && - mediaType != encodingContentType { + !contentTypeAllowedByEncoding(mediaType, encodingContentType) { return "", nil, &ParseError{ Kind: KindOther, Reason: fmt.Sprintf( @@ -1279,6 +1312,13 @@ func decodeBody(body io.Reader, header http.Header, schema *openapi3.SchemaRef, decoder, ok := bodyDecoders[mediaType] if !ok { + // A binary part with no registered decoder (e.g. image/png) is read as + // raw bytes: encoding.contentType restricts the accepted media types but + // does not require a registered decoder. + if isBinary(schema) { + value, err := FileBodyDecoder(body, header, schema, encFn) + return mediaType, value, err + } return "", nil, &ParseError{ Kind: KindUnsupportedFormat, Reason: fmt.Sprintf("%s %q", prefixUnsupportedCT, mediaType), diff --git a/openapi3filter/req_resp_decoder_test.go b/openapi3filter/req_resp_decoder_test.go index 04c53a473..bb68b7f26 100644 --- a/openapi3filter/req_resp_decoder_test.go +++ b/openapi3filter/req_resp_decoder_test.go @@ -1676,7 +1676,7 @@ func TestDecodeBody(t *testing.T) { }) require.NoError(t, err) - multipartBinaryEncodingCTUnsupported, multipartMimeBinaryEncodingCTUnsupported, err := newTestMultipartForm([]*testFormPart{ + multipartBinaryEncodingCTFallback, multipartMimeBinaryEncodingCTFallback, err := newTestMultipartForm([]*testFormPart{ {name: "b", contentType: "application/json", data: strings.NewReader(`{"bar1": "bar1"}`), filename: "b1"}, {name: "d", contentType: "application/pdf", data: strings.NewReader("doo1"), filename: "d1"}, }) @@ -1688,6 +1688,12 @@ func TestDecodeBody(t *testing.T) { }) require.NoError(t, err) + multipartBinaryEncodingCTList, multipartMimeBinaryEncodingCTList, err := newTestMultipartForm([]*testFormPart{ + {name: "b", contentType: "text/plain", data: strings.NewReader("b1data"), filename: "b1"}, + {name: "d", contentType: "application/json", data: strings.NewReader(`{"d1": "d1"}`), filename: "d1"}, + }) + require.NoError(t, err) + testCases := []struct { name string mime string @@ -1839,9 +1845,9 @@ func TestDecodeBody(t *testing.T) { want: map[string]any{"b": `{"bar1": "bar1"}`, "d": "doo1", "f": []any{`{"foo1": "foo1"}`, "foo2"}}, }, { - name: "multipartEncodingCTUnsupported", - mime: multipartMimeBinaryEncodingCTUnsupported, - body: multipartBinaryEncodingCTUnsupported, + name: "multipartEncodingCTBinaryFallback", + mime: multipartMimeBinaryEncodingCTFallback, + body: multipartBinaryEncodingCTFallback, schema: openapi3.NewObjectSchema(). WithProperty("b", openapi3.NewStringSchema().WithFormat("binary")). WithProperty("d", openapi3.NewStringSchema().WithFormat("binary")), @@ -1849,15 +1855,7 @@ func TestDecodeBody(t *testing.T) { "b": {ContentType: "application/json"}, "d": {ContentType: "application/pdf"}, }, - want: map[string]any{"b": map[string]any{"bar1": "bar1"}}, - wantErr: &ParseError{ - Kind: KindOther, - Cause: &ParseError{ - Kind: KindUnsupportedFormat, - Reason: fmt.Sprintf("%s %q", prefixUnsupportedCT, "application/pdf"), - }, - path: []any{"d"}, - }, + want: map[string]any{"b": map[string]any{"bar1": "bar1"}, "d": "doo1"}, }, { name: "multipartEncodingCTNotMatching", @@ -1885,6 +1883,19 @@ func TestDecodeBody(t *testing.T) { path: []any{"d"}, }, }, + { + name: "multipartEncodingCTList", + mime: multipartMimeBinaryEncodingCTList, + body: multipartBinaryEncodingCTList, + schema: openapi3.NewObjectSchema(). + WithProperty("b", openapi3.NewStringSchema().WithFormat("binary")). + WithProperty("d", openapi3.NewStringSchema().WithFormat("binary")), + encoding: map[string]*openapi3.Encoding{ + "b": {ContentType: "application/json, text/plain"}, + "d": {ContentType: "application/*"}, + }, + want: map[string]any{"b": "b1data", "d": map[string]any{"d1": "d1"}}, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { @@ -1914,6 +1925,33 @@ func TestDecodeBody(t *testing.T) { } } +func TestContentTypeAllowedByEncoding(t *testing.T) { + testCases := []struct { + name string + mediaType string + encodingContentType string + want bool + }{ + {name: "exact match", mediaType: "application/json", encodingContentType: "application/json", want: true}, + {name: "exact mismatch", mediaType: "application/json", encodingContentType: "application/xml", want: false}, + {name: "comma list first", mediaType: "image/jpeg", encodingContentType: "image/jpeg, image/png", want: true}, + {name: "comma list second", mediaType: "image/png", encodingContentType: "image/jpeg, image/png", want: true}, + {name: "comma list miss", mediaType: "image/gif", encodingContentType: "image/jpeg, image/png", want: false}, + {name: "comma list no spaces", mediaType: "image/png", encodingContentType: "image/jpeg,image/png", want: true}, + {name: "subtype wildcard match", mediaType: "image/png", encodingContentType: "image/*", want: true}, + {name: "subtype wildcard miss", mediaType: "application/pdf", encodingContentType: "image/*", want: false}, + {name: "full wildcard", mediaType: "application/octet-stream", encodingContentType: "*/*", want: true}, + {name: "case insensitive", mediaType: "Image/PNG", encodingContentType: "image/png", want: true}, + {name: "entry with parameters base match", mediaType: "application/xml", encodingContentType: "application/xml; charset=utf-8", want: true}, + {name: "empty entries ignored", mediaType: "image/png", encodingContentType: " , image/png , ", want: true}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, contentTypeAllowedByEncoding(tc.mediaType, tc.encodingContentType)) + }) + } +} + type testFormPart struct { name string contentType string