Repository: getkin/kin-openapi
Pull request #662 (2022-11-14 15:13:08)

fix: optional defaults
Adding the option to skip setting default values in the request body after validation.
See https://github.com/getkin/kin-openapi/issues/639


Patch:
diff --git a/openapi2conv/openapi2_conv.go b/openapi2conv/openapi2_conv.go
index 53b4b40..8c08205 100644
--- a/openapi2conv/openapi2_conv.go
+++ b/openapi2conv/openapi2_conv.go
@@ -485,6 +485,16 @@ func ToV3SchemaRef(schema *openapi3.SchemaRef) *openapi3.SchemaRef {
 	for i, v := range schema.Value.AllOf {
 		schema.Value.AllOf[i] = ToV3SchemaRef(v)
 	}
+	if val, ok := schema.Value.Extensions["x-nullable"]; ok {
+		var nullable bool
+
+		if err := json.Unmarshal(val.(json.RawMessage), &nullable); err == nil {
+			schema.Value.Nullable = nullable
+		}
+
+		delete(schema.Value.Extensions, "x-nullable")
+	}
+
 	return schema
 }
 
@@ -824,6 +834,11 @@ func FromV3SchemaRef(schema *openapi3.SchemaRef, components *openapi3.Components
 	for i, v := range schema.Value.AllOf {
 		schema.Value.AllOf[i], _ = FromV3SchemaRef(v, components)
 	}
+	if schema.Value.Nullable {
+		schema.Value.Nullable = false
+		schema.Value.Extensions["x-nullable"] = true
+	}
+
 	return schema, nil
 }
 
diff --git a/openapi3filter/options.go b/openapi3filter/options.go
index 1622339..14843dd 100644
--- a/openapi3filter/options.go
+++ b/openapi3filter/options.go
@@ -21,4 +21,8 @@ type Options struct {
 
 	// See NoopAuthenticationFunc
 	AuthenticationFunc AuthenticationFunc
+
+	// Indicates whether default values are set in the
+	// request. If true, then they are not set
+	SkipSettingDefaults bool
 }
diff --git a/openapi3filter/validate_request.go b/openapi3filter/validate_request.go
index 4b0bd34..83bea98 100644
--- a/openapi3filter/validate_request.go
+++ b/openapi3filter/validate_request.go
@@ -258,7 +258,9 @@ func ValidateRequestBody(ctx context.Context, input *RequestValidationInput, req
 	defaultsSet := false
 	opts := make([]openapi3.SchemaValidationOption, 0, 3) // 3 potential opts here
 	opts = append(opts, openapi3.VisitAsRequest())
-	opts = append(opts, openapi3.DefaultsSet(func() { defaultsSet = true }))
+	if !options.SkipSettingDefaults {
+		opts = append(opts, openapi3.DefaultsSet(func() { defaultsSet = true }))
+	}
 	if options.MultiError {
 		opts = append(opts, openapi3.MultiErrors())
 	}
@@ -285,6 +287,7 @@ func ValidateRequestBody(ctx context.Context, input *RequestValidationInput, req
 		}
 		// Put the data back into the input
 		req.Body = ioutil.NopCloser(bytes.NewReader(data))
+		req.ContentLength = int64(len(data))
 	}
 
 	return nil


Test patch:
diff --git a/openapi2conv/openapi2_conv_test.go b/openapi2conv/openapi2_conv_test.go
index 9edce54..3177721 100644
--- a/openapi2conv/openapi2_conv_test.go
+++ b/openapi2conv/openapi2_conv_test.go
@@ -89,7 +89,8 @@ const exampleV2 = `
 			"additionalProperties": true,
 			"properties": {
 				"foo": {
-					"type": "string"
+					"type": "string",
+					"x-nullable": true
 				},
 				"quux": {
 					"$ref": "#/definitions/ItemExtension"
@@ -463,7 +464,8 @@ const exampleV3 = `
 				"additionalProperties": true,
 				"properties": {
 					"foo": {
-						"type": "string"
+						"type": "string",
+						"nullable": true
 					},
 					"quux": {
 						"$ref": "#/components/schemas/ItemExtension"
diff --git a/openapi3filter/issue639_test.go b/openapi3filter/issue639_test.go
new file mode 100644
index 0000000..2caf1bd
--- /dev/null
+++ b/openapi3filter/issue639_test.go
@@ -0,0 +1,100 @@
+package openapi3filter
+
+import (
+	"encoding/json"
+	"io/ioutil"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/getkin/kin-openapi/openapi3"
+	"github.com/getkin/kin-openapi/routers/gorillamux"
+)
+
+func TestIssue639(t *testing.T) {
+	loader := openapi3.NewLoader()
+	ctx := loader.Context
+	spec := `
+    openapi: 3.0.0
+    info:
+     version: 1.0.0
+     title: Sample API
+    paths:
+     /items:
+      put:
+        requestBody:
+         content:
+          application/json:
+            schema:
+              properties:
+                testWithdefault:
+                  default: false
+                  type: boolean
+                testNoDefault:
+                  type: boolean
+              type: object
+        responses:
+          '200':
+           description: Successful respons
+`[1:]
+
+	doc, err := loader.LoadFromData([]byte(spec))
+	require.NoError(t, err)
+
+	err = doc.Validate(ctx)
+	require.NoError(t, err)
+
+	router, err := gorillamux.NewRouter(doc)
+	require.NoError(t, err)
+
+	tests := []struct {
+		name               string
+		options            *Options
+		expectedDefaultVal interface{}
+	}{
+		{
+			name: "no defaults are added to requests",
+			options: &Options{
+				SkipSettingDefaults: true,
+			},
+			expectedDefaultVal: nil,
+		},
+
+		{
+			name:               "defaults are added to requests",
+			expectedDefaultVal: false,
+		},
+	}
+
+	for _, testcase := range tests {
+		t.Run(testcase.name, func(t *testing.T) {
+			body := "{\"testNoDefault\": true}"
+			httpReq, err := http.NewRequest(http.MethodPut, "/items", strings.NewReader(body))
+			require.NoError(t, err)
+			httpReq.Header.Set("Content-Type", "application/json")
+			require.NoError(t, err)
+
+			route, pathParams, err := router.FindRoute(httpReq)
+			require.NoError(t, err)
+
+			requestValidationInput := &RequestValidationInput{
+				Request:    httpReq,
+				PathParams: pathParams,
+				Route:      route,
+				Options:    testcase.options,
+			}
+			err = ValidateRequest(ctx, requestValidationInput)
+			require.NoError(t, err)
+			bodyAfterValidation, err := ioutil.ReadAll(httpReq.Body)
+			require.NoError(t, err)
+
+			raw := map[string]interface{}{}
+			err = json.Unmarshal(bodyAfterValidation, &raw)
+			require.NoError(t, err)
+			require.Equal(t, testcase.expectedDefaultVal,
+				raw["testWithdefault"], "default value must not be included")
+		})
+	}
+}
diff --git a/openapi3filter/validate_request_test.go b/openapi3filter/validate_request_test.go
index 957b692..450ee59 100644
--- a/openapi3filter/validate_request_test.go
+++ b/openapi3filter/validate_request_test.go
@@ -59,6 +59,9 @@ paths:
               properties:
                 subCategory:
                   type: string
+                category:
+                  type: string
+                  default: Sweets
       responses:
         '201':
           description: Created
@@ -95,6 +98,7 @@ components:
 
 	type testRequestBody struct {
 		SubCategory string `json:"subCategory"`
+		Category    string `json:"category,omitempty"`
 	}
 	type args struct {
 		requestBody *testRequestBody
@@ -102,18 +106,30 @@ components:
 		apiKey      string
 	}
 	tests := []struct {
-		name        string
-		args        args
-		expectedErr error
+		name                 string
+		args                 args
+		expectedModification bool
+		expectedErr          error
 	}{
 		{
-			name: "Valid request",
+			name: "Valid request with all fields set",
+			args: args{
+				requestBody: &testRequestBody{SubCategory: "Chocolate", Category: "Food"},
+				url:         "/category?category=cookies",
+				apiKey:      "SomeKey",
+			},
+			expectedModification: false,
+			expectedErr:          nil,
+		},
+		{
+			name: "Valid request without certain fields",
 			args: args{
 				requestBody: &testRequestBody{SubCategory: "Chocolate"},
 				url:         "/category?category=cookies",
 				apiKey:      "SomeKey",
 			},
-			expectedErr: nil,
+			expectedModification: true,
+			expectedErr:          nil,
 		},
 		{
 			name: "Invalid operation params",
@@ -122,7 +138,8 @@ components:
 				url:         "/category?invalidCategory=badCookie",
 				apiKey:      "SomeKey",
 			},
-			expectedErr: &RequestError{},
+			expectedModification: false,
+			expectedErr:          &RequestError{},
 		},
 		{
 			name: "Invalid request body",
@@ -131,7 +148,8 @@ components:
 				url:         "/category?category=cookies",
 				apiKey:      "SomeKey",
 			},
-			expectedErr: &RequestError{},
+			expectedModification: false,
+			expectedErr:          &RequestError{},
 		},
 		{
 			name: "Invalid security",
@@ -140,7 +158,8 @@ components:
 				url:         "/category?category=cookies",
 				apiKey:      "",
 			},
-			expectedErr: &SecurityRequirementsError{},
+			expectedModification: false,
+			expectedErr:          &SecurityRequirementsError{},
 		},
 		{
 			name: "Invalid request body and security",
@@ -149,16 +168,19 @@ components:
 				url:         "/category?category=cookies",
 				apiKey:      "",
 			},
-			expectedErr: &SecurityRequirementsError{},
+			expectedModification: false,
+			expectedErr:          &SecurityRequirementsError{},
 		},
 	}
 	for _, tc := range tests {
 		t.Run(tc.name, func(t *testing.T) {
 			var requestBody io.Reader
+			var originalBodySize int
 			if tc.args.requestBody != nil {
 				testingBody, err := json.Marshal(tc.args.requestBody)
 				require.NoError(t, err)
 				requestBody = bytes.NewReader(testingBody)
+				originalBodySize = len(testingBody)
 			}
 			req, err := http.NewRequest(http.MethodPost, tc.args.url, requestBody)
 			require.NoError(t, err)
@@ -180,6 +202,16 @@ components:
 			}
 			err = ValidateRequest(context.Background(), validationInput)
 			assert.IsType(t, tc.expectedErr, err, "ValidateRequest(): error = %v, expectedError %v", err, tc.expectedErr)
+			if tc.expectedErr != nil {
+				return
+			}
+			body, err := io.ReadAll(validationInput.Request.Body)
+			contentLen := int(validationInput.Request.ContentLength)
+			bodySize := len(body)
+			assert.NoError(t, err, "unable to read request body: %v", err)
+			assert.Equal(t, contentLen, bodySize, "expect ContentLength %d to equal body size %d", contentLen, bodySize)
+			bodyModified := originalBodySize != bodySize
+			assert.Equal(t, bodyModified, tc.expectedModification, "expect request body modification happened: %t, expected %t", bodyModified, tc.expectedModification)
 		})
 	}
 }