diff --git a/openapi3/content.go b/openapi3/content.go index ab79147f8..bbe4c122c 100644 --- a/openapi3/content.go +++ b/openapi3/content.go @@ -61,6 +61,46 @@ func NewContentWithFormDataSchemaRef(schema *SchemaRef) Content { } } +// splitMediaType separates the type/subtype portion of a mime from its +// parameters, which keep the leading ';' and their original case: RFC 9110 +// section 5.6.6 leaves parameter values case-sensitive unless the parameter +// itself says otherwise. +func splitMediaType(mime string) (mediaType, parameters string) { + if i := strings.IndexByte(mime, ';'); i >= 0 { + return mime[:i], mime[i:] + } + return mime, "" +} + +func (content Content) get(mime string) *MediaType { + if v := content[mime]; v != nil { + return v + } + + mediaType, parameters := splitMediaType(mime) + if !strings.ContainsRune(mediaType, '/') { + return nil + } + + // Compare against each declaration. The lexicographically smallest match is + // taken so that a document declaring several case variants of one media + // type still resolves deterministically. + match := "" + for candidate := range content { + if match != "" && candidate >= match { + continue + } + candidateType, candidateParameters := splitMediaType(candidate) + if parameters == candidateParameters && strings.EqualFold(mediaType, candidateType) { + match = candidate + } + } + if match == "" { + return nil + } + return content[match] +} + func (content Content) Get(mime string) *MediaType { // If the mime is empty then short-circuit to the wildcard. // We do this here so that we catch only the specific case of @@ -70,33 +110,30 @@ func (content Content) Get(mime string) *MediaType { } // Start by making the most specific match possible // by using the mime type in full. - if v := content[mime]; v != nil { + if v := content.get(mime); v != nil { return v } // If an exact match is not found then we strip all // metadata from the mime type and only use the x/y - // portion. - i := strings.IndexByte(mime, ';') - if i < 0 { - // If there is no metadata then preserve the full mime type - // string for later wildcard searches. - i = len(mime) - } - mime = mime[:i] - if v := content[mime]; v != nil { - return v + // portion. Without metadata the full mime type is + // preserved for later wildcard searches, and retrying + // it here would repeat the search above. + mime, parameters := splitMediaType(mime) + if parameters != "" { + if v := content.get(mime); v != nil { + return v + } } // If the x/y pattern has no specific match then we // try the x/* pattern. - i = strings.IndexByte(mime, '/') + i := strings.IndexByte(mime, '/') if i < 0 { // In the case that the given mime type is not valid because it is // missing the subtype we return nil so that this does not accidentally // resolve with the wildcard. return nil } - mime = mime[:i] + "/*" - if v := content[mime]; v != nil { + if v := content.get(mime[:i] + "/*"); v != nil { return v } // Finally, the most generic match of */* is returned diff --git a/openapi3/content_bench_test.go b/openapi3/content_bench_test.go new file mode 100644 index 000000000..e621b70a6 --- /dev/null +++ b/openapi3/content_bench_test.go @@ -0,0 +1,48 @@ +package openapi3 + +import ( + "fmt" + "testing" +) + +var benchMediaType *MediaType + +// benchmarkContent returns a document content with n declared media types. +func benchmarkContent(n int) Content { + content := Content{ + "application/json": NewMediaType(), + "text/plain": NewMediaType(), + "multipart/form-data": NewMediaType(), + } + for i := len(content); i < n; i++ { + content[fmt.Sprintf("application/vnd.example.v%d+json", i)] = NewMediaType() + } + return content +} + +func BenchmarkContent_Get(b *testing.B) { + benchmarks := []struct { + name string + keys int + mime string + }{ + // Declared verbatim: resolves on the first map lookup. + {"exact", 3, "application/json"}, + // The most common shape on the wire: matches once parameters are stripped. + {"parameters", 3, "application/json;charset=utf-8"}, + {"case_variant", 3, "APPLICATION/JSON"}, + {"case_variant_parameters", 3, "APPLICATION/JSON;charset=utf-8"}, + // Undeclared: walks every matching stage before giving up. + {"no_match", 3, "image/png"}, + {"parameters_many_media_types", 20, "application/json;charset=utf-8"}, + } + for _, bb := range benchmarks { + content := benchmarkContent(bb.keys) + b.Run(bb.name, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + benchMediaType = content.Get(bb.mime) + } + }) + } +} diff --git a/openapi3/content_test.go b/openapi3/content_test.go index 2c25dce75..97409a2e9 100644 --- a/openapi3/content_test.go +++ b/openapi3/content_test.go @@ -11,6 +11,8 @@ func TestContent_Get(t *testing.T) { wildcard := NewMediaType() stripped := NewMediaType() fullMatch := NewMediaType() + caseVariant := NewMediaType() + malformed := NewMediaType() content := Content{ "*/*": fallback, "application/*": wildcard, @@ -21,6 +23,13 @@ func TestContent_Get(t *testing.T) { "application/json": stripped, "application/json;encoding=utf-8": fullMatch, } + contentWithCaseVariants := Content{ + "application/json": stripped, + "APPLICATION/JSON": caseVariant, + } + contentWithMalformedType := Content{ + "text": malformed, + } tests := []struct { name string content Content @@ -39,18 +48,42 @@ func TestContent_Get(t *testing.T) { mime: "application/json;encoding=utf-8", want: fullMatch, }, + { + name: "full match case insensitive", + content: content, + mime: "APPLICATION/JSON;encoding=utf-8", + want: fullMatch, + }, + { + name: "parameter value case sensitive", + content: content, + mime: "APPLICATION/JSON;encoding=UTF-8", + want: stripped, + }, { name: "stripped match", content: content, mime: "application/json;encoding=utf-16", want: stripped, }, + { + name: "stripped match case insensitive", + content: content, + mime: "APPLICATION/JSON;encoding=utf-16", + want: stripped, + }, { name: "wildcard match", content: content, mime: "application/yaml;encoding=utf-16", want: wildcard, }, + { + name: "wildcard match case insensitive", + content: content, + mime: "APPLICATION/YAML;encoding=utf-16", + want: wildcard, + }, { name: "fallback match", content: content, @@ -93,6 +126,26 @@ func TestContent_Get(t *testing.T) { mime: "text", want: nil, }, + { + name: "exact match takes precedence", + content: contentWithCaseVariants, + mime: "APPLICATION/JSON", + want: caseVariant, + }, + { + // No declaration matches verbatim, so the case-insensitive search + // decides between them: the lexicographically smallest one wins. + name: "case variants resolve deterministically", + content: contentWithCaseVariants, + mime: "Application/Json", + want: caseVariant, + }, + { + name: "invalid mime type remains case sensitive", + content: contentWithMalformedType, + mime: "TEXT", + want: nil, + }, { name: "missing mime type", content: content, diff --git a/openapi3filter/internal.go b/openapi3filter/internal.go index bb8ba6b79..c398b7133 100644 --- a/openapi3filter/internal.go +++ b/openapi3filter/internal.go @@ -13,6 +13,39 @@ func parseMediaType(contentType string) string { return before } +// lookupByContentType returns the entry registered for contentType. +// +// An exact key always wins. Otherwise the lookup falls back to a +// case-insensitive comparison, as RFC 9110 section 8.3.1 defines media type and +// subtype tokens as case-insensitive. Values missing a subtype are not valid +// media types and are matched exactly so they cannot resolve through the +// fallback. +func lookupByContentType[V any](registered map[string]V, contentType string) (V, bool) { + if v, ok := registered[contentType]; ok { + return v, true + } + var zero V + if !strings.ContainsRune(contentType, '/') { + return zero, false + } + // Compare against each entry. The lexicographically smallest match is taken + // so that several registered case variants of one content type still + // resolve deterministically. + match := "" + for candidate := range registered { + if match != "" && candidate >= match { + continue + } + if strings.EqualFold(contentType, candidate) { + match = candidate + } + } + if match == "" { + return zero, false + } + return registered[match], true +} + func isNilValue(value any) bool { if value == nil { return true diff --git a/openapi3filter/req_resp_decoder.go b/openapi3filter/req_resp_decoder.go index bff756121..528a72abf 100644 --- a/openapi3filter/req_resp_decoder.go +++ b/openapi3filter/req_resp_decoder.go @@ -1299,6 +1299,10 @@ func UnregisterBodyDecoder(contentType string) { delete(bodyDecoders, contentType) } +func getBodyDecoder(contentType string) (BodyDecoder, bool) { + return lookupByContentType(bodyDecoders, contentType) +} + var headerCT = http.CanonicalHeaderKey("Content-Type") const ( @@ -1394,7 +1398,7 @@ func decodeBody(body io.Reader, header http.Header, schema *openapi3.SchemaRef, } } - decoder, ok := bodyDecoders[mediaType] + decoder, ok := getBodyDecoder(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 diff --git a/openapi3filter/req_resp_decoder_bench_test.go b/openapi3filter/req_resp_decoder_bench_test.go new file mode 100644 index 000000000..a29094626 --- /dev/null +++ b/openapi3filter/req_resp_decoder_bench_test.go @@ -0,0 +1,63 @@ +package openapi3filter + +import ( + "bytes" + "net/http" + "testing" + + "github.com/getkin/kin-openapi/openapi3" +) + +var benchBodyDecoder BodyDecoder + +func BenchmarkGetBodyDecoder(b *testing.B) { + benchmarks := []struct { + name string + contentType string + }{ + // Registered verbatim: resolves on the first map lookup. + {"exact", "application/json"}, + {"case_variant", "APPLICATION/JSON"}, + // Unregistered: binary multipart parts take this path on every request. + {"unregistered", "image/png"}, + } + for _, bb := range benchmarks { + b.Run(bb.name, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + benchBodyDecoder, _ = getBodyDecoder(bb.contentType) + } + }) + } +} + +// BenchmarkValidateRequestBody puts the media type lookups above in proportion +// to a whole body validation. +func BenchmarkValidateRequestBody(b *testing.B) { + schema := openapi3.NewObjectSchema(). + WithProperty("name", openapi3.NewStringSchema()). + WithProperty("code", openapi3.NewIntegerSchema()) + requestBody := openapi3.NewRequestBody().WithJSONSchema(schema).WithRequired(true) + payload := []byte(`{"name":"foo","code":123}`) + + for _, contentType := range []string{ + "application/json", + "application/json;charset=utf-8", + "APPLICATION/JSON", + } { + b.Run(contentType, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + req, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(payload)) + if err != nil { + b.Fatal(err) + } + req.Header.Set(headerCT, contentType) + input := &RequestValidationInput{Request: req} + if err := ValidateRequestBody(b.Context(), input, requestBody); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/openapi3filter/req_resp_encoder.go b/openapi3filter/req_resp_encoder.go index 5c328c756..7a3390b77 100644 --- a/openapi3filter/req_resp_encoder.go +++ b/openapi3filter/req_resp_encoder.go @@ -7,7 +7,7 @@ import ( ) func encodeBody(body any, mediaType string) ([]byte, error) { - if encoder := RegisteredBodyEncoder(mediaType); encoder != nil { + if encoder, ok := getBodyEncoder(mediaType); ok { return encoder(body) } return nil, &ParseError{ @@ -16,6 +16,14 @@ func encodeBody(body any, mediaType string) ([]byte, error) { } } +// getBodyEncoder mirrors getBodyDecoder so that a body matched and decoded +// under a case variant of its declared media type can also be re-encoded. +func getBodyEncoder(contentType string) (BodyEncoder, bool) { + bodyEncodersM.RLock() + defer bodyEncodersM.RUnlock() + return lookupByContentType(bodyEncoders, contentType) +} + // BodyEncoder really is an (encoding/json).Marshaler type BodyEncoder func(body any) ([]byte, error) diff --git a/openapi3filter/req_resp_encoder_test.go b/openapi3filter/req_resp_encoder_test.go index 86ff65918..f57eda937 100644 --- a/openapi3filter/req_resp_encoder_test.go +++ b/openapi3filter/req_resp_encoder_test.go @@ -37,3 +37,29 @@ func TestRegisterAndUnregisterBodyEncoder(t *testing.T) { Reason: prefixUnsupportedCT + ` "text/csv"`, }, err) } + +func TestGetBodyEncoderCaseVariantsAreDeterministic(t *testing.T) { + var encoder BodyEncoder = func(body any) ([]byte, error) { return nil, nil } + for _, contentType := range []string{"TEXT/CSV", "text/CSV", "text/csv"} { + RegisterBodyEncoder(contentType, encoder) + defer UnregisterBodyEncoder(contentType) + } + + // None of the registrations match verbatim, so the case-insensitive search + // decides between them: the lexicographically smallest one wins. + got, ok := getBodyEncoder("Text/Csv") + require.True(t, ok) + require.Equal(t, fmt.Sprint(RegisteredBodyEncoder("TEXT/CSV")), fmt.Sprint(got)) +} + +func TestEncodeBodyCaseInsensitiveMediaType(t *testing.T) { + got, err := encodeBody(map[string]any{"name": "default"}, "APPLICATION/JSON") + require.NoError(t, err) + require.JSONEq(t, `{"name":"default"}`, string(got)) + + _, err = encodeBody(map[string]any{}, "APPLICATION") + require.Equal(t, &ParseError{ + Kind: KindUnsupportedFormat, + Reason: prefixUnsupportedCT + ` "APPLICATION"`, + }, err) +} diff --git a/openapi3filter/validate_response_test.go b/openapi3filter/validate_response_test.go index e61a58192..c3a37a065 100644 --- a/openapi3filter/validate_response_test.go +++ b/openapi3filter/validate_response_test.go @@ -9,8 +9,34 @@ import ( "github.com/stretchr/testify/require" "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/routers" ) +func TestValidateResponseContentTypeCaseInsensitive(t *testing.T) { + content := openapi3.Content{ + "text/plain": openapi3.NewMediaType().WithSchema(openapi3.NewStringSchema()), + } + operation := openapi3.NewOperation() + operation.Responses = openapi3.NewResponses(openapi3.WithStatus( + http.StatusOK, + &openapi3.ResponseRef{Value: openapi3.NewResponse().WithContent(content)}, + )) + input := &ResponseValidationInput{ + RequestValidationInput: &RequestValidationInput{ + Request: &http.Request{Method: http.MethodGet}, + Route: &routers.Route{ + Spec: &openapi3.T{OpenAPI: "3.0.3"}, + Operation: operation, + }, + }, + Status: http.StatusOK, + Header: http.Header{headerCT: []string{"TEXT/PLAIN"}}, + Body: io.NopCloser(strings.NewReader("hello")), + } + + require.NoError(t, ValidateResponse(t.Context(), input)) +} + func Test_validateResponseHeader(t *testing.T) { type args struct { headerName string diff --git a/openapi3filter/validate_set_default_test.go b/openapi3filter/validate_set_default_test.go index 03c863388..4628c2b9d 100644 --- a/openapi3filter/validate_set_default_test.go +++ b/openapi3filter/validate_set_default_test.go @@ -801,3 +801,20 @@ func TestValidateRequestBodyAndSetDefault(t *testing.T) { }) } } + +func TestValidateRequestBodyAndSetDefaultCaseInsensitiveContentType(t *testing.T) { + schema := openapi3.NewObjectSchema(). + WithProperty("name", openapi3.NewStringSchema().WithDefault("default")) + requestBody := openapi3.NewRequestBody().WithJSONSchema(schema).WithRequired(true) + + httpReq, err := http.NewRequest(http.MethodPost, "/accounts", bytes.NewReader([]byte(`{}`))) + require.NoError(t, err) + httpReq.Header.Add(headerCT, "APPLICATION/JSON") + + err = ValidateRequestBody(t.Context(), &RequestValidationInput{Request: httpReq}, requestBody) + require.NoError(t, err) + + validatedReqBody, err := io.ReadAll(httpReq.Body) + require.NoError(t, err) + require.JSONEq(t, `{"name":"default"}`, string(validatedReqBody)) +} diff --git a/openapi3filter/validation_test.go b/openapi3filter/validation_test.go index 1ac7ec4c2..f13628d67 100644 --- a/openapi3filter/validation_test.go +++ b/openapi3filter/validation_test.go @@ -413,6 +413,12 @@ func TestValidateRequestBody(t *testing.T) { mime: "text/plain", data: strings.NewReader("foo"), }, + { + name: "case-insensitive content type", + body: openapi3.NewRequestBody().WithContent(plainTextContent).WithRequired(true), + mime: "TEXT/PLAIN", + data: strings.NewReader("foo"), + }, { name: "not declared content", body: openapi3.NewRequestBody().WithRequired(true),