Repository: getkin/kin-openapi
Pull request #78 (2019-03-24 12:14:59)

Add "required" validation of a request's body
Proposed fix for issue #77.


Patch:
diff --git a/README.md b/README.md
index 50ec9bd..6d6dbc5 100644
--- a/README.md
+++ b/README.md
@@ -112,9 +112,45 @@ func main() {
 		responseValidationInput.SetBodyBytes(data)
 	}
 
-	// Get response
+	// Validate response.
 	if err := openapi3filter.ValidateResponse(ctx, responseValidationInput); err != nil {
 		panic(err)
 	}
 }
 ```
+
+## Custom content type for body of HTTP request/response
+
+By default, the library parses a body of HTTP request and response
+if it has one of the next content types: `"plain/text"` or `"application/json"`.
+To support other content types you must register decoders for them:
+
+```go
+func main() {
+	// ...
+
+	// Register a body's decoder for content type "application/xml".
+	openapi3filter.RegisterBodyDecoder("application/xml", xmlBodyDecoder)
+
+	// Now you can validate HTTP request that contains a body with content type "application/xml".
+	requestValidationInput := &openapi3filter.RequestValidationInput{
+		Request:    httpReq,
+		PathParams: pathParams,
+		Route:      route,
+	}
+	if err := openapi3filter.ValidateRequest(ctx, requestValidationInput); err != nil {
+		panic(err)
+	}
+
+	// ...
+
+	// And you can validate HTTP response that contains a body with content type "application/xml".
+	if err := openapi3filter.ValidateResponse(ctx, responseValidationInput); err != nil {
+		panic(err)
+	}
+}
+
+func xmlBodyDecoder(body []byte) (interface{}, error) {
+	// Decode body to a primitive, []inteface{}, or map[string]interface{}.
+}
+```
diff --git a/openapi3filter/media_type.go b/openapi3filter/media_type.go
deleted file mode 100644
index d564c3e..0000000
--- a/openapi3filter/media_type.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package openapi3filter
-
-// Definition of MediaType to be interpreted as JSON
-var DefaultJSONMediaTypes = []string{
-	"application/json",
-}
-
-func isMediaTypeJSON(mediaType string) bool {
-	for _, mt := range DefaultJSONMediaTypes {
-		if mediaType == mt {
-			return true
-		}
-	}
-	return false
-}
diff --git a/openapi3filter/param_decoder.go b/openapi3filter/req_resp_decoder.go
similarity index 89%
rename from openapi3filter/param_decoder.go
rename to openapi3filter/req_resp_decoder.go
index 7e9aa02..f62b6eb 100644
--- a/openapi3filter/param_decoder.go
+++ b/openapi3filter/req_resp_decoder.go
@@ -1,6 +1,7 @@
 package openapi3filter
 
 import (
+	"encoding/json"
 	"fmt"
 	"net/http"
 	"regexp"
@@ -19,6 +20,8 @@ type ParseErrorKind int
 const (
 	// KindOther describes an untyped parsing error.
 	KindOther ParseErrorKind = iota
+	// KindUnsupportedFormat describes an error that happens when a value has an unsupported format.
+	KindUnsupportedFormat
 	// KindInvalidFormat describes an error that happens when a value does not conform a format
 	// that is required by a serialization method.
 	KindInvalidFormat
@@ -30,7 +33,7 @@ const (
 	KindInvalidBool
 )
 
-// ParseError describes errors which happens while parse operation's parameters.
+// ParseError describes errors which happens while parse operation's parameters, requestBody, or response.
 type ParseError struct {
 	Kind   ParseErrorKind
 	Path   []interface{}
@@ -595,3 +598,63 @@ func parsePrimitive(raw string, schema *openapi3.SchemaRef) (interface{}, error)
 		panic(fmt.Sprintf("schema has non primitive type %q", schema.Value.Type))
 	}
 }
+
+// BodyDecoder is an interface to decode a body of a request or response.
+// An implementation must return a value that is a primitive, []interface{}, or map[string]interface{}.
+type BodyDecoder func(data []byte) (interface{}, error)
+
+// bodyDecoders contains decoders for supported content types of a body.
+// By default, there is content type "application/json" is supported only.
+var bodyDecoders = map[string]BodyDecoder{
+	"plain/text": func(body []byte) (interface{}, error) {
+		return string(body), nil
+	},
+	"application/json": func(body []byte) (interface{}, error) {
+		var value interface{}
+		if err := json.Unmarshal(body, &value); err != nil {
+			return nil, err
+		}
+		return value, nil
+	},
+}
+
+// RegisterBodyDecoder registers a request body's decoder for a content type.
+//
+// If a decoder for the specified content type already exists, the function replaces
+// it with the specified decoder.
+func RegisterBodyDecoder(contentType string, decoder BodyDecoder) {
+	if contentType == "" {
+		panic("contentType is empty")
+	}
+	if decoder == nil {
+		panic("decoder is not defined")
+	}
+	bodyDecoders[contentType] = decoder
+}
+
+// UnregisterBodyDecoder dissociates a body decoder from a content type.
+//
+// Decoding this content type will result in an error.
+func UnregisterBodyDecoder(contentType string) {
+	if contentType == "" {
+		panic("contentType is empty")
+	}
+	delete(bodyDecoders, contentType)
+}
+
+// decodeBody returns a decoded body.
+// The function returns ParseError when a body is invalid.
+func decodeBody(body []byte, contentType string) (interface{}, error) {
+	decoder, ok := bodyDecoders[contentType]
+	if !ok {
+		return nil, &ParseError{
+			Kind:   KindUnsupportedFormat,
+			Reason: fmt.Sprintf("an unsupported content type %q", contentType),
+		}
+	}
+	value, err := decoder(body)
+	if err != nil {
+		return nil, &ParseError{Kind: KindInvalidFormat, Cause: err}
+	}
+	return value, nil
+}
diff --git a/openapi3filter/validate_request.go b/openapi3filter/validate_request.go
index 70d932d..23c7273 100644
--- a/openapi3filter/validate_request.go
+++ b/openapi3filter/validate_request.go
@@ -3,16 +3,16 @@ package openapi3filter
 import (
 	"bytes"
 	"context"
-	"encoding/json"
 	"errors"
 	"fmt"
 	"io/ioutil"
+	"net/http"
 	"sort"
 
 	"github.com/getkin/kin-openapi/openapi3"
 )
 
-// ErrInvalidRequired is an error that happens when a required value of a parameter is not defined.
+// ErrInvalidRequired is an error that happens when a required value of a parameter or request's body is not defined.
 var ErrInvalidRequired = fmt.Errorf("must have a value")
 
 func ValidateRequest(c context.Context, input *RequestValidationInput) error {
@@ -97,58 +97,86 @@ func ValidateParameter(c context.Context, input *RequestValidationInput, paramet
 	return nil
 }
 
+// ValidateRequestBody validates data of a request's body.
+//
+// The function returns RequestError with ErrInvalidRequired cause when a value is required but not defined.
+// The function returns RequestError with a openapi3.SchemaError cause when a value is invalid by JSON schema.
 func ValidateRequestBody(c context.Context, input *RequestValidationInput, requestBody *openapi3.RequestBody) error {
-	req := input.Request
-	content := requestBody.Content
-	if content != nil && len(content) > 0 {
-		inputMIME := req.Header.Get("Content-Type")
-		mediaType := parseMediaType(inputMIME)
-		contentType := requestBody.Content[mediaType]
-		if contentType == nil {
+	var (
+		req  = input.Request
+		data []byte
+	)
+
+	if req.Body != http.NoBody {
+		defer req.Body.Close()
+		var err error
+		if data, err = ioutil.ReadAll(req.Body); err != nil {
 			return &RequestError{
 				Input:       input,
 				RequestBody: requestBody,
-				Reason:      fmt.Sprintf("header 'Content-Type' has unexpected value: %q", inputMIME),
+				Reason:      "reading failed",
+				Err:         err,
 			}
 		}
-		schemaRef := contentType.Schema
-		if schemaRef != nil && isMediaTypeJSON(mediaType) {
-			schema := schemaRef.Value
-			body := req.Body
-			defer body.Close()
-			data, err := ioutil.ReadAll(body)
-			if err != nil {
-				return &RequestError{
-					Input:       input,
-					RequestBody: requestBody,
-					Reason:      "reading failed",
-					Err:         err,
-				}
-			}
+		// Put the data back into the input
+		req.Body = ioutil.NopCloser(bytes.NewReader(data))
+	}
 
-			// Put the data back into the input
-			req.Body = ioutil.NopCloser(bytes.NewReader(data))
+	if len(data) == 0 {
+		if requestBody.Required {
+			return &RequestError{Input: input, RequestBody: requestBody, Err: ErrInvalidRequired}
+		}
+		return nil
+	}
 
-			// Decode JSON
-			var value interface{}
-			if err := json.Unmarshal(data, &value); err != nil {
-				return &RequestError{
-					Input:       input,
-					RequestBody: requestBody,
-					Reason:      "decoding JSON failed",
-					Err:         err,
-				}
-			}
+	content := requestBody.Content
+	if len(content) == 0 {
+		// A request's body does not have declared content, so skip validation.
+		return nil
+	}
 
-			// Validate JSON with the schema
-			if err := schema.VisitJSON(value); err != nil {
-				return &RequestError{
-					Input:       input,
-					RequestBody: requestBody,
-					Reason:      "doesn't input the schema",
-					Err:         err,
-				}
-			}
+	inputMIME := req.Header.Get("Content-Type")
+	mediaType := parseMediaType(inputMIME)
+	if mediaType == "" {
+		return &RequestError{
+			Input:       input,
+			RequestBody: requestBody,
+			Reason:      "content type of request body is missed",
+		}
+	}
+
+	contentType := requestBody.Content[mediaType]
+	if contentType == nil {
+		return &RequestError{
+			Input:       input,
+			RequestBody: requestBody,
+			Reason:      fmt.Sprintf("header 'Content-Type' has unexpected value: %q", inputMIME),
+		}
+	}
+
+	schemaRef := contentType.Schema
+	if schemaRef == nil {
+		// A JSON schema that describes the received data is not declared, so skip validation.
+		return nil
+	}
+
+	value, err := decodeBody(data, mediaType)
+	if err != nil {
+		return &RequestError{
+			Input:       input,
+			RequestBody: requestBody,
+			Reason:      "failed to decode request body",
+			Err:         err,
+		}
+	}
+
+	// Validate JSON with the schema
+	if err := schemaRef.Value.VisitJSON(value); err != nil {
+		return &RequestError{
+			Input:       input,
+			RequestBody: requestBody,
+			Reason:      "doesn't match the schema",
+			Err:         err,
 		}
 	}
 	return nil
diff --git a/openapi3filter/validate_response.go b/openapi3filter/validate_response.go
index 05cf4cf..8dd078b 100644
--- a/openapi3filter/validate_response.go
+++ b/openapi3filter/validate_response.go
@@ -3,7 +3,6 @@ package openapi3filter
 
 import (
 	"context"
-	"encoding/json"
 	"fmt"
 	"io/ioutil"
 	"net/http"
@@ -41,87 +40,99 @@ func ValidateResponse(c context.Context, input *ResponseValidationInput) error {
 
 	// Find input for the current status
 	responses := route.Operation.Responses
-	if responses != nil && len(responses) > 0 {
-		responseRef := responses.Get(status) // Response
-		if responseRef == nil {
-			responseRef = responses.Default() // Default input
+	if len(responses) == 0 {
+		return nil
+	}
+	responseRef := responses.Get(status) // Response
+	if responseRef == nil {
+		responseRef = responses.Default() // Default input
+	}
+	if responseRef == nil {
+		// By default, status that is not documented is allowed.
+		if !options.IncludeResponseStatus {
+			return nil
+		}
+
+		return &ResponseError{Input: input, Reason: "status is not supported"}
+	}
+	response := responseRef.Value
+	if response == nil {
+		return &ResponseError{Input: input, Reason: "response has not been resolved"}
+	}
+
+	if options.ExcludeResponseBody {
+		// A user turned off validation of a response's body.
+		return nil
+	}
+
+	content := response.Content
+	if len(content) == 0 || options.ExcludeResponseBody {
+		// An operation does not contains a validation schema for responses with this status code.
+		return nil
+	}
+
+	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]
+	if contentType == nil {
+		return &ResponseError{
+			Input:  input,
+			Reason: fmt.Sprintf("input header 'Content-Type' has unexpected value: %q", inputMIME),
 		}
-		if responseRef == nil {
-			// By default, status that is not documented is allowed
-			if !options.IncludeResponseStatus {
-				return nil
-			}
-
-			// Other.
-			return &ResponseError{
-				Input:  input,
-				Reason: "status is not supported",
-			}
+	}
+
+	schema := contentType.Schema
+	if schema == nil {
+		// An operation does not contains a validation schema for responses with this status code.
+		return nil
+	}
+
+	// Read response's body.
+	body := input.Body
+
+	// Response would contain partial or empty input body
+	// after we begin reading.
+	// Ensure that this doesn't happen.
+	input.Body = nil
+
+	// Ensure we close the reader
+	defer body.Close()
+
+	// Read all
+	data, err := ioutil.ReadAll(body)
+	if err != nil {
+		return &ResponseError{
+			Input:  input,
+			Reason: "failed to read response body",
+			Err:    err,
 		}
-		response := responseRef.Value
-		if response == nil {
-			return &ResponseError{
-				Input:  input,
-				Reason: "response has not been resolved",
-			}
+	}
+
+	// Put the data back into the response.
+	input.SetBodyBytes(data)
+
+	value, err := decodeBody(data, mediaType)
+	if err != nil {
+		return &ResponseError{
+			Input:  input,
+			Reason: "failed to decode response body",
+			Err:    err,
 		}
-		content := response.Content
-		if len(content) > 0 && !options.ExcludeResponseBody {
-			inputMIME := input.Header.Get("Content-Type")
-			mediaType := parseMediaType(inputMIME)
-			contentType := content[mediaType]
-			if contentType == nil {
-				return &ResponseError{
-					Input:  input,
-					Reason: fmt.Sprintf("input header 'Content-Type' has unexpected value: %q", inputMIME),
-				}
-			}
-			schemaRef := contentType.Schema
-			if schemaRef != nil && isMediaTypeJSON(mediaType) {
-				schema := schemaRef.Value
-
-				// Read request body
-				body := input.Body
-
-				// Response would contain partial or empty input body
-				// after we begin reading.
-				// Ensure that this doesn't happen.
-				input.Body = nil
-
-				// Ensure we close the reader
-				defer body.Close()
-
-				// Read all
-				data, err := ioutil.ReadAll(body)
-				if err != nil {
-					return &ResponseError{
-						Input:  input,
-						Reason: "reading the input body failed",
-						Err:    err,
-					}
-				}
-
-				// Put the data back into the request
-				input.SetBodyBytes(data)
-
-				// Decode JSON
-				var value interface{}
-				if err := json.Unmarshal(data, &value); err != nil {
-					return &ResponseError{
-						Input:  input,
-						Reason: "decoding JSON in the input body failed",
-						Err:    err,
-					}
-				}
-
-				// Validate JSON with the schema
-				if err := schema.VisitJSON(value); err != nil {
-					return &ResponseError{
-						Input: input,
-						Err:   err,
-					}
-				}
-			}
+	}
+
+	// Validate data with the schema.
+	if err := schema.Value.VisitJSON(value); err != nil {
+		return &ResponseError{
+			Input:  input,
+			Reason: "response body doesn't match the schema",
+			Err:    err,
 		}
 	}
 	return nil


Test patch:
diff --git a/openapi3filter/param_decoder_test.go b/openapi3filter/req_resp_decoder_test.go
similarity index 94%
rename from openapi3filter/param_decoder_test.go
rename to openapi3filter/req_resp_decoder_test.go
index be985bc..1ac280f 100644
--- a/openapi3filter/param_decoder_test.go
+++ b/openapi3filter/req_resp_decoder_test.go
@@ -862,6 +862,76 @@ func TestDecodeParameter(t *testing.T) {
 	}
 }
 
+func TestDecodeBody(t *testing.T) {
+	testCases := []struct {
+		name    string
+		mime    string
+		body    []byte
+		want    interface{}
+		wantErr error
+	}{
+		{
+			name:    "unsupported content type",
+			mime:    "application/xml",
+			wantErr: &ParseError{Kind: KindUnsupportedFormat},
+		},
+		{
+			name:    "invalid body data",
+			mime:    "application/json",
+			body:    []byte("invalid"),
+			wantErr: &ParseError{Kind: KindInvalidFormat},
+		},
+		{
+			name: "valid body data",
+			mime: "application/json",
+			body: []byte("\"foo\""),
+			want: "foo",
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			got, err := decodeBody(tc.body, tc.mime)
+
+			if tc.wantErr != nil {
+				require.Error(t, err)
+				require.Truef(t, matchParseError(err, tc.wantErr), "got error:\n%v\nwant error:\n%v", err, tc.wantErr)
+				return
+			}
+
+			require.NoError(t, err)
+			require.Truef(t, reflect.DeepEqual(got, tc.want), "got %v, want %v", got, tc.want)
+		})
+	}
+}
+
+func TestRegisterAndUnregisterBodyDecoder(t *testing.T) {
+	var (
+		contentType = "text/csv"
+		decoder     = func(body []byte) (interface{}, error) {
+			var vv []interface{}
+			for _, v := range strings.Split(string(body), ",") {
+				vv = append(vv, v)
+			}
+			return vv, nil
+		}
+		body    = []byte("foo,bar")
+		want    = []interface{}{"foo", "bar"}
+		wantErr = &ParseError{Kind: KindUnsupportedFormat}
+	)
+
+	RegisterBodyDecoder(contentType, decoder)
+	got, err := decodeBody(body, contentType)
+
+	require.NoError(t, err)
+	require.Truef(t, reflect.DeepEqual(got, want), "got %v, want %v", got, want)
+
+	UnregisterBodyDecoder(contentType)
+	_, err = decodeBody(body, contentType)
+
+	require.Error(t, err)
+	require.Truef(t, matchParseError(err, wantErr), "got error:\n%v\nwant error:\n%v", err, wantErr)
+}
+
 func matchParseError(got, want error) bool {
 	wErr, ok := want.(*ParseError)
 	if !ok {
diff --git a/openapi3filter/validation_test.go b/openapi3filter/validation_test.go
index 355ca28..88bf415 100644
--- a/openapi3filter/validation_test.go
+++ b/openapi3filter/validation_test.go
@@ -7,6 +7,9 @@ import (
 	"io"
 	"io/ioutil"
 	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"strings"
 	"testing"
 
 	"github.com/getkin/kin-openapi/openapi3"
@@ -172,3 +175,108 @@ func marshalReader(value interface{}) io.ReadCloser {
 	}
 	return ioutil.NopCloser(bytes.NewReader(data))
 }
+
+func TestValidateRequestBody(t *testing.T) {
+	requiredReqBody := openapi3.NewRequestBody().
+		WithContent(openapi3.NewContentWithJSONSchema(openapi3.NewStringSchema())).
+		WithRequired(true)
+
+	plainTextContent := openapi3.NewContent()
+	plainTextContent["plain/text"] = openapi3.NewMediaType().WithSchema(openapi3.NewStringSchema())
+
+	testCases := []struct {
+		name    string
+		body    *openapi3.RequestBody
+		mime    string
+		data    io.Reader
+		wantErr error
+	}{
+		{
+			name: "non required empty",
+			body: openapi3.NewRequestBody().
+				WithContent(openapi3.NewContentWithJSONSchema(openapi3.NewStringSchema())),
+		},
+		{
+			name: "non required not empty",
+			body: openapi3.NewRequestBody().
+				WithContent(openapi3.NewContentWithJSONSchema(openapi3.NewStringSchema())),
+			mime: "application/json",
+			data: toJSON("foo"),
+		},
+		{
+			name:    "required empty",
+			body:    requiredReqBody,
+			wantErr: &openapi3filter.RequestError{RequestBody: requiredReqBody, Err: openapi3filter.ErrInvalidRequired},
+		},
+		{
+			name: "required not empty",
+			body: requiredReqBody,
+			mime: "application/json",
+			data: toJSON("foo"),
+		},
+		{
+			name: "not JSON data",
+			body: openapi3.NewRequestBody().WithContent(plainTextContent).WithRequired(true),
+			mime: "plain/text",
+			data: strings.NewReader("foo"),
+		},
+		{
+			name: "not declared content",
+			body: openapi3.NewRequestBody().WithRequired(true),
+			mime: "application/json",
+			data: toJSON("foo"),
+		},
+		{
+			name: "not declared schema",
+			body: openapi3.NewRequestBody().WithJSONSchemaRef(nil),
+			mime: "application/json",
+			data: toJSON("foo"),
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodPost, "/test", tc.data)
+			if tc.mime != "" {
+				req.Header.Set(http.CanonicalHeaderKey("Content-Type"), tc.mime)
+			}
+			inp := &openapi3filter.RequestValidationInput{Request: req}
+			err := openapi3filter.ValidateRequestBody(context.Background(), inp, tc.body)
+
+			if tc.wantErr == nil {
+				require.NoError(t, err)
+				return
+			}
+
+			require.True(t, matchReqBodyError(tc.wantErr, err), "got error:\n%s\nwant error\n%s", err, tc.wantErr)
+		})
+	}
+}
+
+func matchReqBodyError(want, got error) bool {
+	if want == got {
+		return true
+	}
+	wErr, ok := want.(*openapi3filter.RequestError)
+	if !ok {
+		return false
+	}
+	gErr, ok := got.(*openapi3filter.RequestError)
+	if !ok {
+		return false
+	}
+	if !reflect.DeepEqual(wErr.RequestBody, gErr.RequestBody) {
+		return false
+	}
+	if wErr.Err != nil {
+		return matchReqBodyError(wErr.Err, gErr.Err)
+	}
+	return false
+}
+
+func toJSON(v interface{}) io.Reader {
+	data, err := json.Marshal(v)
+	if err != nil {
+		panic(err)
+	}
+	return bytes.NewReader(data)
+}