Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 51 additions & 14 deletions openapi3/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
48 changes: 48 additions & 0 deletions openapi3/content_bench_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
53 changes: 53 additions & 0 deletions openapi3/content_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions openapi3filter/internal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion openapi3filter/req_resp_decoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions openapi3filter/req_resp_decoder_bench_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}
10 changes: 9 additions & 1 deletion openapi3filter/req_resp_encoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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)

Expand Down
Loading
Loading