Repository: getkin/kin-openapi (go)

Issue:
Request validation doesn't support content-type wildcards
The OpenAPI documentation (https://swagger.io/docs/specification/describing-request-body/) describes the content-type keys under `requestBody.content` as supporting wildcards:

> content allows wildcard media types. For example, image/* represents all image types; \*/\* represents all types and is functionally equivalent to application/octet-stream. Specific media types have preference over wildcard media types when interpreting the spec, for example, image/png \> image/* \> \*/\* 

However, the current request validation only supports a direct match of content types via a map lookup. Additionally, the request validation rejects any request without a content-type header which should otherwise be handled by a `*/*` if defined.

My team has a specialized case where this causes validation errors because we need to support a requestor that doesn't add the content-type header. I'm happy to put up a PR that helps with this but thought I might get some feedback on the approach first:

The current lookup is handled [here](https://github.com/getkin/kin-openapi/blob/master/openapi3filter/validate_request.go#L148) with a map[string]*MediaType lookup by key. However, the `requestBody.Content` type has a `Get` method for resolving the type that also incorporates the behavior of `parseMediaType` which is used [here](https://github.com/getkin/kin-openapi/blob/master/openapi3filter/validate_request.go#L139) before the map lookup. I'm guessing the `Get` method was added after the initial implementation of the request validation and that the parsing and map lookup in the validation is a hold-over.

I believe we can hide the complexity of the lookup behavior in the [`Content.Get`](https://github.com/getkin/kin-openapi/blob/master/openapi3/content.go#L26) method. If that approach sounds acceptable then I'll get a PR up that implements the hierarchical wild-card logic.

Pull request:
Add content-type wildcard support to validation
This implements the cascading wildcard behavior described in the OpenAPI
specification for request body and response body validation.

https://swagger.io/docs/specification/describing-request-body/

Closes #91 

Patch:
diff --git a/openapi3/content.go b/openapi3/content.go
index 57eb89a..941c032 100644
--- a/openapi3/content.go
+++ b/openapi3/content.go
@@ -24,14 +24,40 @@ func NewContentWithJSONSchemaRef(schema *SchemaRef) Content {
 }
 
 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 {
 		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
+	}
+	// If the x/y pattern has no specific match then we
+	// try the x/* pattern.
+	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
 	}
-	return content[mime[:i]]
+	mime = mime[:i] + "/*"
+	if v := content[mime]; v != nil {
+		return v
+	}
+	// Finally, the most generic match of */* is returned
+	// as a catch-all.
+	return content["*/*"]
 }
 
 func (content Content) Validate(c context.Context) error {
diff --git a/openapi3filter/req_resp_decoder.go b/openapi3filter/req_resp_decoder.go
index 8bf826d..2a5f489 100644
--- a/openapi3filter/req_resp_decoder.go
+++ b/openapi3filter/req_resp_decoder.go
@@ -148,7 +148,7 @@ func decodeContentParameter(param *openapi3.Parameter, input *RequestValidationI
 func defaultContentParameterDecoder(param *openapi3.Parameter, values []string) (
 	outValue interface{}, outSchema *openapi3.Schema, err error) {
 	// Only query parameters can have multiple values.
-	if len(values) > 1 && param.In != "query" {
+	if len(values) > 1 && param.In != openapi3.ParameterInQuery {
 		err = fmt.Errorf("%s parameter '%s' can't have multiple values", param.In, param.Name)
 		return
 	}
diff --git a/openapi3filter/validate_request.go b/openapi3filter/validate_request.go
index 0231cf4..1a3a9ba 100644
--- a/openapi3filter/validate_request.go
+++ b/openapi3filter/validate_request.go
@@ -154,16 +154,7 @@ func ValidateRequestBody(c context.Context, input *RequestValidationInput, reque
 	}
 
 	inputMIME := req.Header.Get("Content-Type")
-	mediaType := parseMediaType(inputMIME)
-	if mediaType == "" {
-		return &RequestError{
-			Input:       input,
-			RequestBody: requestBody,
-			Reason:      "content type is missed",
-		}
-	}
-
-	contentType := requestBody.Content[mediaType]
+	contentType := requestBody.Content.Get(inputMIME)
 	if contentType == nil {
 		return &RequestError{
 			Input:       input,
diff --git a/openapi3filter/validate_response.go b/openapi3filter/validate_response.go
index 33bef79..ce376ce 100644
--- a/openapi3filter/validate_response.go
+++ b/openapi3filter/validate_response.go
@@ -75,15 +75,7 @@ func ValidateResponse(c context.Context, input *ResponseValidationInput) error {
 	}
 
 	inputMIME := input.Header.Get("Content-Type")
-	mediaType := parseMediaType(inputMIME)
-	if mediaType == "" {
-		return &ResponseError{
-			Input:  input,
-			Reason: "content type of response body is missed",
-		}
-	}
-
-	contentType := content[mediaType]
+	contentType := content.Get(inputMIME)
 	if contentType == nil {
 		return &ResponseError{
 			Input:  input,


Test patch:
diff --git a/openapi3/content_test.go b/openapi3/content_test.go
new file mode 100644
index 0000000..4dff3a7
--- /dev/null
+++ b/openapi3/content_test.go
@@ -0,0 +1,107 @@
+package openapi3
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestContent_Get(t *testing.T) {
+	fallback := NewMediaType()
+	wildcard := NewMediaType()
+	stripped := NewMediaType()
+	fullMatch := NewMediaType()
+	content := Content{
+		"*/*":                             fallback,
+		"application/*":                   wildcard,
+		"application/json":                stripped,
+		"application/json;encoding=utf-8": fullMatch,
+	}
+	contentWithoutWildcards := Content{
+		"application/json":                stripped,
+		"application/json;encoding=utf-8": fullMatch,
+	}
+	tests := []struct {
+		name    string
+		content Content
+		mime    string
+		want    *MediaType
+	}{
+		{
+			name:    "missing",
+			content: contentWithoutWildcards,
+			mime:    "text/plain;encoding=utf-8",
+			want:    nil,
+		},
+		{
+			name:    "full match",
+			content: content,
+			mime:    "application/json;encoding=utf-8",
+			want:    fullMatch,
+		},
+		{
+			name:    "stripped match",
+			content: content,
+			mime:    "application/json;encoding=utf-16",
+			want:    stripped,
+		},
+		{
+			name:    "wildcard match",
+			content: content,
+			mime:    "application/yaml;encoding=utf-16",
+			want:    wildcard,
+		},
+		{
+			name:    "fallback match",
+			content: content,
+			mime:    "text/plain;encoding=utf-16",
+			want:    fallback,
+		},
+		{
+			name:    "invalid mime type",
+			content: content,
+			mime:    "text;encoding=utf16",
+			want:    nil,
+		},
+		{
+			name:    "missing no encoding",
+			content: contentWithoutWildcards,
+			mime:    "text/plain",
+			want:    nil,
+		},
+		{
+			name:    "stripped match no encoding",
+			content: content,
+			mime:    "application/json",
+			want:    stripped,
+		},
+		{
+			name:    "wildcard match no encoding",
+			content: content,
+			mime:    "application/yaml",
+			want:    wildcard,
+		},
+		{
+			name:    "fallback match no encoding",
+			content: content,
+			mime:    "text/plain",
+			want:    fallback,
+		},
+		{
+			name:    "invalid mime type no encoding",
+			content: content,
+			mime:    "text",
+			want:    nil,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Using require.True here because require.Same is not yet released.
+			// We're comparing pointer values and the require.Equal will
+			// dereference and compare the pointed to values rather than check
+			// if the memory addresses are the same. Once require.Same is released
+			// this test should convert to using that.
+			require.True(t, tt.want == tt.content.Get(tt.mime))
+		})
+	}
+}