Repository: getkin/kin-openapi
Pull request #1047 (2024-12-24 00:58:26)

openapi2conv: convert references in nested additionalProperties schemas
When the schema specified in `additionalProperties` contains another nested schema with `additionalProperties`, the schema references must be converted at all of levels of the nested schemas.

The following examples illustrate the problem.  This pull request fixes the issue by traversing all of the nested schemas in `additionalProperties`, making copies of the objects that are modified by the conversion.

Prior to this change, a schema reference is specified in a top-level `additionalProperties` was being converted correctly.  In the following example OpenAPI 2.0 definition, the reference to "#/definitions/Foo" is correctly converted to "#/components/schemas/Foo" in the OpenAPI 3.0 definition that is produced.

```
{
    "basePath": "/v2",
    "host": "test.example.com",
    "info": {
        "title": "MyAPI",
        "version": "0.1"
    },
    "paths": {
        "/foo": {
            "get": {
                "operationId": "getFoo",
                "produces": [
                    "application/json"
                ],
                "responses": {
                    "200": {
                        "description": "returns all information",
                        "schema":{
                            "type":"object",
                            "additionalProperties":{
                               "$ref":"#/definitions/Foo"
                            }
                        }
                    }
                },
                "summary": "get foo"
            }
        }
    },
        "definitions": {
            "Foo": {
                    "type": "object",
                        "properties": {
                            "a": {
                                    "type": "string"
                                }
                        }
                }
        },
    "schemes": [
        "http"
    ],
    "swagger": "2.0"
}
```

When there is a schema reference in an `additionalProperties` that is nested in the schema of another `additionalProperties`, then the conversion from OpenAPI 2.0 to OpenAPI 3.0 did not convert the schema reference to "#/components/schema/Foo".  This case is fixed by this pull request.

```
{
    "basePath": "/v2",
    "host": "test.example.com",
    "info": {
        "title": "MyAPI",
        "version": "0.1"
    },
    "paths": {
        "/foo": {
            "get": {
                "operationId": "getFoo",
                "produces": [
                    "application/json"
                ],
                "responses": {
                    "200": {
                        "description": "returns all information",
                        "schema":{
                            "type":"object",
                            "additionalProperties":{
                               "type":"object",
                               "additionalProperties":{
                                   "$ref":"#/definitions/Foo"
                               }
                            }
                        }
                    }
                },
                "summary": "get foo"
            }
        }
    },
        "definitions": {
            "Foo": {
                    "type": "object",
                        "properties": {
                            "a": {
                                    "type": "string"
                                }
                        }
                }
        },
    "schemes": [
        "http"
    ],
    "swagger": "2.0"
}
```


Patch:
diff --git a/openapi2conv/openapi2_conv.go b/openapi2conv/openapi2_conv.go
index ec7646d..8aa4525 100644
--- a/openapi2conv/openapi2_conv.go
+++ b/openapi2conv/openapi2_conv.go
@@ -513,11 +513,7 @@ func ToV3SchemaRef(schema *openapi2.SchemaRef) *openapi3.SchemaRef {
 		MaxProps:             schema.Value.MaxProps,
 		AllOf:                make(openapi3.SchemaRefs, len(schema.Value.AllOf)),
 		Properties:           make(openapi3.Schemas),
-		AdditionalProperties: schema.Value.AdditionalProperties,
-	}
-
-	if schema.Value.AdditionalProperties.Schema != nil {
-		v3Schema.AdditionalProperties.Schema.Ref = ToV3Ref(schema.Value.AdditionalProperties.Schema.Ref)
+		AdditionalProperties: toV3AdditionalProperties(schema.Value.AdditionalProperties),
 	}
 
 	if schema.Value.Discriminator != "" {
@@ -551,6 +547,27 @@ func ToV3SchemaRef(schema *openapi2.SchemaRef) *openapi3.SchemaRef {
 	}
 }
 
+func toV3AdditionalProperties(from openapi3.AdditionalProperties) openapi3.AdditionalProperties {
+	return openapi3.AdditionalProperties{
+		Has:    from.Has,
+		Schema: convertRefsInV3SchemaRef(from.Schema),
+	}
+}
+
+func convertRefsInV3SchemaRef(from *openapi3.SchemaRef) *openapi3.SchemaRef {
+	if from == nil {
+		return nil
+	}
+	to := *from
+	to.Ref = ToV3Ref(to.Ref)
+	if to.Value != nil {
+		v := *from.Value
+		to.Value = &v
+		to.Value.AdditionalProperties = toV3AdditionalProperties(to.Value.AdditionalProperties)
+	}
+	return &to
+}
+
 var ref2To3 = map[string]string{
 	"#/definitions/": "#/components/schemas/",
 	"#/responses/":   "#/components/responses/",
diff --git a/openapi3filter/req_resp_decoder.go b/openapi3filter/req_resp_decoder.go
index c77289f..998a91f 100644
--- a/openapi3filter/req_resp_decoder.go
+++ b/openapi3filter/req_resp_decoder.go
@@ -1340,18 +1340,6 @@ func urlencodedBodyDecoder(body io.Reader, header http.Header, schema *openapi3.
 	obj := make(map[string]any)
 	dec := &urlValuesDecoder{values: values}
 
-	// Decode schema constructs (allOf, anyOf, oneOf)
-	if err := decodeSchemaConstructs(dec, schema.Value.AllOf, obj, encFn); err != nil {
-		return nil, err
-	}
-	if err := decodeSchemaConstructs(dec, schema.Value.AnyOf, obj, encFn); err != nil {
-		return nil, err
-	}
-	if err := decodeSchemaConstructs(dec, schema.Value.OneOf, obj, encFn); err != nil {
-		return nil, err
-	}
-
-	// Decode properties from the main schema
 	if err := decodeSchemaConstructs(dec, []*openapi3.SchemaRef{schema}, obj, encFn); err != nil {
 		return nil, err
 	}
@@ -1363,6 +1351,18 @@ func urlencodedBodyDecoder(body io.Reader, header http.Header, schema *openapi3.
 // This function is for decoding purposes only and not for validation.
 func decodeSchemaConstructs(dec *urlValuesDecoder, schemas []*openapi3.SchemaRef, obj map[string]any, encFn EncodingFn) error {
 	for _, schemaRef := range schemas {
+
+		// Decode schema constructs (allOf, anyOf, oneOf)
+		if err := decodeSchemaConstructs(dec, schemaRef.Value.AllOf, obj, encFn); err != nil {
+			return err
+		}
+		if err := decodeSchemaConstructs(dec, schemaRef.Value.AnyOf, obj, encFn); err != nil {
+			return err
+		}
+		if err := decodeSchemaConstructs(dec, schemaRef.Value.OneOf, obj, encFn); err != nil {
+			return err
+		}
+
 		for name, prop := range schemaRef.Value.Properties {
 			value, _, err := decodeProperty(dec, name, prop, encFn)
 			if err != nil {


Test patch:
diff --git a/openapi2conv/openapi2_conv_test.go b/openapi2conv/openapi2_conv_test.go
index 3177721..659ccb6 100644
--- a/openapi2conv/openapi2_conv_test.go
+++ b/openapi2conv/openapi2_conv_test.go
@@ -65,6 +65,134 @@ func TestConvOpenAPIV2ToV3(t *testing.T) {
 	require.JSONEq(t, exampleV3, string(data))
 }
 
+func TestConvOpenAPIV2ToV3WithAdditionalPropertiesSchemaRef(t *testing.T) {
+	v2 := []byte(`
+{
+    "basePath": "/v2",
+    "host": "test.example.com",
+    "info": {
+        "title": "MyAPI",
+        "version": "0.1"
+    },
+    "paths": {
+        "/foo": {
+            "get": {
+                "operationId": "getFoo",
+                "produces": [
+                    "application/json"
+                ],
+                "responses": {
+                    "200": {
+                        "description": "returns all information",
+                        "schema":{
+                            "type":"object",
+                            "additionalProperties":{
+                               "$ref":"#/definitions/Foo"
+                            }
+                        }
+                    }
+                },
+                "summary": "get foo"
+            }
+        }
+    },
+	"definitions": {
+	    "Foo": {
+		    "type": "object",
+			"properties": {
+			    "a": {
+				    "type": "string"
+				}
+			}
+		}
+	},
+    "schemes": [
+        "http"
+    ],
+    "swagger": "2.0"
+}
+`)
+
+	var doc2 openapi2.T
+	err := json.Unmarshal(v2, &doc2)
+	require.NoError(t, err)
+
+	doc3, err := ToV3(&doc2)
+	require.NoError(t, err)
+	err = doc3.Validate(context.Background())
+	require.NoError(t, err)
+
+	responseSchema := doc3.Paths.Value("/foo").Get.Responses.Value("200").Value.Content.Get("application/json").Schema.Value
+	require.Equal(t, &openapi3.Types{"object"}, responseSchema.Type)
+	require.Equal(t, "#/components/schemas/Foo", responseSchema.AdditionalProperties.Schema.Ref)
+}
+
+func TestConvOpenAPIV2ToV3WithNestedAdditionalPropertiesSchemaRef(t *testing.T) {
+	v2 := []byte(`
+{
+    "basePath": "/v2",
+    "host": "test.example.com",
+    "info": {
+        "title": "MyAPI",
+        "version": "0.1"
+    },
+    "paths": {
+        "/foo": {
+            "get": {
+                "operationId": "getFoo",
+                "produces": [
+                    "application/json"
+                ],
+                "responses": {
+                    "200": {
+                        "description": "returns all information",
+                        "schema":{
+                            "type":"object",
+                            "additionalProperties":{
+                               "type":"object",
+                               "additionalProperties":{
+                                   "$ref":"#/definitions/Foo"
+                               }
+                            }
+                        }
+                    }
+                },
+                "summary": "get foo"
+            }
+        }
+    },
+	"definitions": {
+	    "Foo": {
+		    "type": "object",
+			"properties": {
+			    "a": {
+				    "type": "string"
+				}
+			}
+		}
+	},
+    "schemes": [
+        "http"
+    ],
+    "swagger": "2.0"
+}
+`)
+
+	var doc2 openapi2.T
+	err := json.Unmarshal(v2, &doc2)
+	require.NoError(t, err)
+
+	doc3, err := ToV3(&doc2)
+	require.NoError(t, err)
+	err = doc3.Validate(context.Background())
+	require.NoError(t, err)
+
+	responseSchema := doc3.Paths.Value("/foo").Get.Responses.Value("200").Value.Content.Get("application/json").Schema.Value
+	require.Equal(t, &openapi3.Types{"object"}, responseSchema.Type)
+	require.Equal(t, &openapi3.Types{"object"}, responseSchema.AdditionalProperties.Schema.Value.Type)
+	require.Equal(t, "#/components/schemas/Foo", responseSchema.AdditionalProperties.Schema.Value.AdditionalProperties.Schema.Ref)
+}
+
 const exampleV2 = `
 {
 	"basePath": "/v2",
diff --git a/openapi3filter/issue1045_test.go b/openapi3filter/issue1045_test.go
new file mode 100644
index 0000000..048dbd3
--- /dev/null
+++ b/openapi3filter/issue1045_test.go
@@ -0,0 +1,142 @@
+package openapi3filter
+
+import (
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/getkin/kin-openapi/openapi3"
+	"github.com/getkin/kin-openapi/routers/gorillamux"
+)
+
+func TestIssue1045(t *testing.T) {
+	spec := `
+openapi: 3.0.3
+info:
+  version: 1.0.0
+  title: sample api
+  description: api service paths to test the issue
+paths:
+  /api/path:
+    post:
+      summary: path
+      tags:
+        - api
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema: { $ref: '#/components/schemas/PathRequest' }
+          application/x-www-form-urlencoded:
+            schema: { $ref: '#/components/schemas/PathRequest' }
+      responses:
+        '200':
+          description: Ok
+          content:
+            application/json:
+              schema: { $ref: '#/components/schemas/PathResponse' }
+components:
+  schemas:
+    Msg_Opt:
+      properties:
+        msg: { type: string }
+    Msg:
+      allOf:
+        - $ref: '#/components/schemas/Msg_Opt'
+        - required: [ msg ]
+    Name:
+      properties:
+        name: { type: string }
+      required: [ name ]
+    Id:
+      properties:
+        id:
+          type: string
+          format: uint64
+      required: [ id ]
+    PathRequest:
+      type: object
+      allOf:
+        - $ref: '#/components/schemas/Msg'
+        - $ref: '#/components/schemas/Name'
+    PathResponse:
+      type: object
+      allOf:
+        - $ref: '#/components/schemas/PathRequest'
+        - $ref: '#/components/schemas/Id'
+    `[1:]
+
+	loader := openapi3.NewLoader()
+
+	doc, err := loader.LoadFromData([]byte(spec))
+	require.NoError(t, err)
+
+	err = doc.Validate(loader.Context)
+	require.NoError(t, err)
+
+	router, err := gorillamux.NewRouter(doc)
+	require.NoError(t, err)
+
+	for _, testcase := range []struct {
+		name       string
+		endpoint   string
+		ct         string
+		data       string
+		shouldFail bool
+	}{
+		{
+			name:       "json success",
+			endpoint:   "/api/path",
+			ct:         "application/json",
+			data:       `{"msg":"message", "name":"some+name"}`,
+			shouldFail: false,
+		},
+		{
+			name:       "json failure",
+			endpoint:   "/api/path",
+			ct:         "application/json",
+			data:       `{"name":"some+name"}`,
+			shouldFail: true,
+		},
+
+		// application/x-www-form-urlencoded
+		{
+			name:       "form success",
+			endpoint:   "/api/path",
+			ct:         "application/x-www-form-urlencoded",
+			data:       "msg=message&name=some+name",
+			shouldFail: false,
+		},
+		{
+			name:       "form failure",
+			endpoint:   "/api/path",
+			ct:         "application/x-www-form-urlencoded",
+			data:       "name=some+name",
+			shouldFail: true,
+		},
+	} {
+		t.Run(testcase.name, func(t *testing.T) {
+			data := strings.NewReader(testcase.data)
+			req, err := http.NewRequest("POST", testcase.endpoint, data)
+			require.NoError(t, err)
+			req.Header.Add("Content-Type", testcase.ct)
+
+			route, pathParams, err := router.FindRoute(req)
+			require.NoError(t, err)
+
+			validationInput := &RequestValidationInput{
+				Request:    req,
+				PathParams: pathParams,
+				Route:      route,
+			}
+			err = ValidateRequest(loader.Context, validationInput)
+			if testcase.shouldFail {
+				require.Error(t, err, "This test case should fail "+testcase.data)
+			} else {
+				require.NoError(t, err, "This test case should pass "+testcase.data)
+			}
+		})
+	}
+}