Repository: getkin/kin-openapi
Pull request #577 (2022-08-01 15:34:41)

#482 integer support broken with yaml
This PR aims at closing issue #482. 


I also added support for int32/64 integer formats during data validation. 

cboitel: @fenollp : modified according to your requested changes except for sticking to use YAML parser used as body decoder.

Checks fails for it means you upgraded to GO v1.9 which does a bit of format differently. 

Do you want me to perform the required 1.9 changes in my PR ?
cboitel: @fenollp : fixes performed

Patch:
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index 70b6db6..d34061a 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -66,7 +66,7 @@ jobs:
       env:
         GOARCH: '386'
     - run: go test ./...
-    - run: go test -count=2 ./...
+    - run: go test -count=2 -covermode=atomic ./...
     - run: go test -v -run TestRaceyPatternSchema -race ./...
       env:
         CGO_ENABLED: '1'
@@ -151,7 +151,7 @@ jobs:
     - uses: actions/setup-go@v3
       with:
         go-version: '>=1.17.0'
-    - run: go install github.com/incu6us/goimports-reviser/v2@v2.5.1
+    - run: go install github.com/incu6us/goimports-reviser/v2@latest
     - run: which goimports-reviser
     - run: find . -type f -iname '*.go' ! -iname '*.pb.go' -exec goimports-reviser -file-path {} \;
     - run: git --no-pager diff --exit-code
diff --git a/openapi3/internalize_refs.go b/openapi3/internalize_refs.go
index 3e19b79..1733a49 100644
--- a/openapi3/internalize_refs.go
+++ b/openapi3/internalize_refs.go
@@ -200,7 +200,7 @@ func (doc *T) addCallbackToSpec(c *CallbackRef, refNameResolver RefNameResolver)
 }
 
 func (doc *T) derefSchema(s *Schema, refNameResolver RefNameResolver) {
-	if s == nil {
+	if s == nil || doc.isVisitedSchema(s) {
 		return
 	}
 
@@ -229,6 +229,9 @@ func (doc *T) derefSchema(s *Schema, refNameResolver RefNameResolver) {
 func (doc *T) derefHeaders(hs Headers, refNameResolver RefNameResolver) {
 	for _, h := range hs {
 		doc.addHeaderToSpec(h, refNameResolver)
+		if doc.isVisitedHeader(h.Value) {
+			continue
+		}
 		doc.derefParameter(h.Value.Parameter, refNameResolver)
 	}
 }
@@ -324,6 +327,8 @@ func (doc *T) derefPaths(paths map[string]*PathItem, refNameResolver RefNameReso
 //
 //	doc.InternalizeRefs(context.Background(), nil)
 func (doc *T) InternalizeRefs(ctx context.Context, refNameResolver func(ref string) string) {
+	doc.resetVisited()
+
 	if refNameResolver == nil {
 		refNameResolver = DefaultRefNameResolver
 	}
diff --git a/openapi3/loader.go b/openapi3/loader.go
index e2f131e..806b819 100644
--- a/openapi3/loader.go
+++ b/openapi3/loader.go
@@ -699,7 +699,8 @@ func (loader *Loader) resolveSchemaRef(doc *T, component *SchemaRef, documentPat
 				return err
 			}
 			component.Value = resolved.Value
-			documentPath = loader.documentPathForRecursiveRef(documentPath, resolved.Ref)
+			foundPath := loader.getResolvedRefPath(ref, &resolved, documentPath, componentPath)
+			documentPath = loader.documentPathForRecursiveRef(documentPath, foundPath)
 		}
 	}
 	value := component.Value
@@ -746,6 +747,21 @@ func (loader *Loader) resolveSchemaRef(doc *T, component *SchemaRef, documentPat
 	return nil
 }
 
+func (loader *Loader) getResolvedRefPath(ref string, resolved *SchemaRef, cur, found *url.URL) string {
+	if referencedFilename := strings.Split(ref, "#")[0]; referencedFilename == "" {
+		if cur != nil {
+			return path.Base(cur.Path)
+		}
+		return ""
+	}
+	// ref. to external file
+	if resolved.Ref != "" {
+		return resolved.Ref
+	}
+	// found dest spec. file
+	return path.Dir(found.Path)[len(loader.rootDir):]
+}
+
 func (loader *Loader) resolveSecuritySchemeRef(doc *T, component *SecuritySchemeRef, documentPath *url.URL) (err error) {
 	if component != nil && component.Value != nil {
 		if loader.visitedSecurityScheme == nil {
diff --git a/openapi3/openapi3.go b/openapi3/openapi3.go
index c0188c2..cb9183d 100644
--- a/openapi3/openapi3.go
+++ b/openapi3/openapi3.go
@@ -21,6 +21,8 @@ type T struct {
 	Servers      Servers              `json:"servers,omitempty" yaml:"servers,omitempty"`
 	Tags         Tags                 `json:"tags,omitempty" yaml:"tags,omitempty"`
 	ExternalDocs *ExternalDocs        `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"`
+
+	visited visitedComponent
 }
 
 // MarshalJSON returns the JSON encoding of T.
diff --git a/openapi3/schema.go b/openapi3/schema.go
index 4c8df1c..17f547e 100644
--- a/openapi3/schema.go
+++ b/openapi3/schema.go
@@ -24,6 +24,12 @@ const (
 	TypeNumber  = "number"
 	TypeObject  = "object"
 	TypeString  = "string"
+
+	// constants for integer formats
+	formatMinInt32 = float64(math.MinInt32)
+	formatMaxInt32 = float64(math.MaxInt32)
+	formatMinInt64 = float64(math.MinInt64)
+	formatMaxInt64 = float64(math.MaxInt64)
 )
 
 var (
@@ -808,6 +814,12 @@ func (schema *Schema) visitJSON(settings *schemaValidationSettings, value interf
 	switch value := value.(type) {
 	case bool:
 		return schema.visitJSONBoolean(settings, value)
+	case int:
+		return schema.visitJSONNumber(settings, float64(value))
+	case int32:
+		return schema.visitJSONNumber(settings, float64(value))
+	case int64:
+		return schema.visitJSONNumber(settings, float64(value))
 	case float64:
 		return schema.visitJSONNumber(settings, value)
 	case string:
@@ -1019,7 +1031,7 @@ func (schema *Schema) VisitJSONNumber(value float64) error {
 func (schema *Schema) visitJSONNumber(settings *schemaValidationSettings, value float64) error {
 	var me MultiError
 	schemaType := schema.Type
-	if schemaType == "integer" {
+	if schemaType == TypeInteger {
 		if bigFloat := big.NewFloat(value); !bigFloat.IsInt() {
 			if settings.failfast {
 				return errSchema
@@ -1039,6 +1051,39 @@ func (schema *Schema) visitJSONNumber(settings *schemaValidationSettings, value
 		return schema.expectedType(settings, "number, integer")
 	}
 
+	// formats
+	if schemaType == TypeInteger && schema.Format != "" {
+		formatMin := float64(0)
+		formatMax := float64(0)
+		switch schema.Format {
+		case "int32":
+			formatMin = formatMinInt32
+			formatMax = formatMaxInt32
+		case "int64":
+			formatMin = formatMinInt64
+			formatMax = formatMaxInt64
+		default:
+			if !SchemaFormatValidationDisabled {
+				return unsupportedFormat(schema.Format)
+			}
+		}
+		if formatMin != 0 && formatMax != 0 && !(formatMin <= value && value <= formatMax) {
+			if settings.failfast {
+				return errSchema
+			}
+			err := &SchemaError{
+				Value:       value,
+				Schema:      schema,
+				SchemaField: "format",
+				Reason:      fmt.Sprintf("number must be an %s", schema.Format),
+			}
+			if !settings.multiError {
+				return err
+			}
+			me = append(me, err)
+		}
+	}
+
 	// "exclusiveMinimum"
 	if v := schema.ExclusiveMin; v && !(*schema.Min < value) {
 		if settings.failfast {
diff --git a/openapi3/visited.go b/openapi3/visited.go
new file mode 100644
index 0000000..67f970e
--- /dev/null
+++ b/openapi3/visited.go
@@ -0,0 +1,41 @@
+package openapi3
+
+func newVisited() visitedComponent {
+	return visitedComponent{
+		header: make(map[*Header]struct{}),
+		schema: make(map[*Schema]struct{}),
+	}
+}
+
+type visitedComponent struct {
+	header map[*Header]struct{}
+	schema map[*Schema]struct{}
+}
+
+// resetVisited clears visitedComponent map
+// should be called before recursion over doc *T
+func (doc *T) resetVisited() {
+	doc.visited = newVisited()
+}
+
+// isVisitedHeader returns `true` if the *Header pointer was already visited
+// otherwise it returns `false`
+func (doc *T) isVisitedHeader(h *Header) bool {
+	if _, ok := doc.visited.header[h]; ok {
+		return true
+	}
+
+	doc.visited.header[h] = struct{}{}
+	return false
+}
+
+// isVisitedHeader returns `true` if the *Schema pointer was already visited
+// otherwise it returns `false`
+func (doc *T) isVisitedSchema(s *Schema) bool {
+	if _, ok := doc.visited.schema[s]; ok {
+		return true
+	}
+
+	doc.visited.schema[s] = struct{}{}
+	return false
+}


Test patch:
diff --git a/openapi3/load_cicular_ref_with_external_file_test.go b/openapi3/load_cicular_ref_with_external_file_test.go
new file mode 100644
index 0000000..978ef8f
--- /dev/null
+++ b/openapi3/load_cicular_ref_with_external_file_test.go
@@ -0,0 +1,70 @@
+//go:build go1.16
+// +build go1.16
+
+package openapi3_test
+
+import (
+	"embed"
+	"encoding/json"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/getkin/kin-openapi/openapi3"
+)
+
+//go:embed testdata/circularRef/*
+var circularResSpecs embed.FS
+
+func TestLoadCircularRefFromFile(t *testing.T) {
+	loader := openapi3.NewLoader()
+	loader.IsExternalRefsAllowed = true
+	loader.ReadFromURIFunc = func(loader *openapi3.Loader, uri *url.URL) ([]byte, error) {
+		return circularResSpecs.ReadFile(uri.Path)
+	}
+
+	got, err := loader.LoadFromFile("testdata/circularRef/base.yml")
+	require.NoError(t, err)
+
+	foo := &openapi3.SchemaRef{
+		Value: &openapi3.Schema{
+			Properties: map[string]*openapi3.SchemaRef{
+				"foo2": {
+					Ref: "other.yml#/components/schemas/Foo2", // reference to an external file
+					Value: &openapi3.Schema{
+						Properties: map[string]*openapi3.SchemaRef{
+							"id": {
+								Value: &openapi3.Schema{Type: "string"}},
+						},
+					},
+				},
+			},
+		},
+	}
+	bar := &openapi3.SchemaRef{Value: &openapi3.Schema{Properties: make(map[string]*openapi3.SchemaRef)}}
+	// circular reference
+	bar.Value.Properties["foo"] = &openapi3.SchemaRef{Ref: "#/components/schemas/Foo", Value: foo.Value}
+	foo.Value.Properties["bar"] = &openapi3.SchemaRef{Ref: "#/components/schemas/Bar", Value: bar.Value}
+
+	want := &openapi3.T{
+		OpenAPI: "3.0.3",
+		Info: &openapi3.Info{
+			Title:   "Recursive cyclic refs example",
+			Version: "1.0",
+		},
+		Components: openapi3.Components{
+			Schemas: map[string]*openapi3.SchemaRef{
+				"Foo": foo,
+				"Bar": bar,
+			},
+		},
+	}
+
+	jsoner := func(doc *openapi3.T) string {
+		data, err := json.Marshal(doc)
+		require.NoError(t, err)
+		return string(data)
+	}
+	require.JSONEq(t, jsoner(want), jsoner(got))
+}
diff --git a/openapi3/schema_test.go b/openapi3/schema_test.go
index abdfb34..593fa17 100644
--- a/openapi3/schema_test.go
+++ b/openapi3/schema_test.go
@@ -4,13 +4,13 @@ import (
 	"context"
 	"encoding/base64"
 	"encoding/json"
-	"fmt"
 	"math"
 	"reflect"
 	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/require"
+	"gopkg.in/yaml.v3"
 )
 
 type schemaExample struct {
@@ -45,23 +45,25 @@ func testSchema(t *testing.T, example schemaExample) func(*testing.T) {
 			require.NoError(t, err)
 			require.Equal(t, dataUnserialized, dataSchema)
 		}
-		for _, value := range example.AllValid {
-			err := validateSchema(t, schema, value)
-			require.NoError(t, err)
-		}
-		for _, value := range example.AllInvalid {
-			err := validateSchema(t, schema, value)
-			require.Error(t, err)
+		for validateFuncIndex, validateFunc := range validateSchemaFuncs {
+			for index, value := range example.AllValid {
+				err := validateFunc(t, schema, value)
+				require.NoErrorf(t, err, "ValidateFunc #%d, AllValid #%d: %#v", validateFuncIndex, index, value)
+			}
+			for index, value := range example.AllInvalid {
+				err := validateFunc(t, schema, value)
+				require.Errorf(t, err, "ValidateFunc #%d, AllInvalid #%d: %#v", validateFuncIndex, index, value)
+			}
 		}
 		// NaN and Inf aren't valid JSON but are handled
-		for _, value := range []interface{}{math.NaN(), math.Inf(-1), math.Inf(+1)} {
+		for index, value := range []interface{}{math.NaN(), math.Inf(-1), math.Inf(+1)} {
 			err := schema.VisitJSON(value)
-			require.Error(t, err)
+			require.Errorf(t, err, "NaNAndInf #%d: %#v", index, value)
 		}
 	}
 }
 
-func validateSchema(t *testing.T, schema *Schema, value interface{}, opts ...SchemaValidationOption) error {
+func validateSchemaJSON(t *testing.T, schema *Schema, value interface{}, opts ...SchemaValidationOption) error {
 	data, err := json.Marshal(value)
 	require.NoError(t, err)
 	var val interface{}
@@ -70,6 +72,22 @@ func validateSchema(t *testing.T, schema *Schema, value interface{}, opts ...Sch
 	return schema.VisitJSON(val, opts...)
 }
 
+func validateSchemaYAML(t *testing.T, schema *Schema, value interface{}, opts ...SchemaValidationOption) error {
+	data, err := yaml.Marshal(value)
+	require.NoError(t, err)
+	var val interface{}
+	err = yaml.Unmarshal(data, &val)
+	require.NoError(t, err)
+	return schema.VisitJSON(val, opts...)
+}
+
+type validateSchemaFunc func(t *testing.T, schema *Schema, value interface{}, opts ...SchemaValidationOption) error
+
+var validateSchemaFuncs = []validateSchemaFunc{
+	validateSchemaJSON,
+	validateSchemaYAML,
+}
+
 var schemaExamples = []schemaExample{
 	{
 		Title:         "EMPTY SCHEMA",
@@ -234,7 +252,56 @@ var schemaExamples = []schemaExample{
 			map[string]interface{}{},
 		},
 	},
-
+	{
+		Title:  "INTEGER OPTIONAL INT64 FORMAT",
+		Schema: NewInt64Schema(),
+		Serialization: map[string]interface{}{
+			"type":   "integer",
+			"format": "int64",
+		},
+		AllValid: []interface{}{
+			1,
+			256,
+			65536,
+			int64(math.MaxInt32) + 10,
+			int64(math.MinInt32) - 10,
+		},
+		AllInvalid: []interface{}{
+			nil,
+			false,
+			3.5,
+			true,
+			"",
+			[]interface{}{},
+			map[string]interface{}{},
+		},
+	},
+	{
+		Title:  "INTEGER OPTIONAL INT32 FORMAT",
+		Schema: NewInt32Schema(),
+		Serialization: map[string]interface{}{
+			"type":   "integer",
+			"format": "int32",
+		},
+		AllValid: []interface{}{
+			1,
+			256,
+			65536,
+			int64(math.MaxInt32),
+			int64(math.MaxInt32),
+		},
+		AllInvalid: []interface{}{
+			nil,
+			false,
+			3.5,
+			int64(math.MaxInt32) + 10,
+			int64(math.MinInt32) - 10,
+			true,
+			"",
+			[]interface{}{},
+			map[string]interface{}{},
+		},
+	},
 	{
 		Title: "STRING",
 		Schema: NewStringSchema().
@@ -350,7 +417,7 @@ var schemaExamples = []schemaExample{
 		AllInvalid: []interface{}{
 			nil,
 			" ",
-			"\n",
+			"\n\n", // a \n is ok for JSON but not for YAML decoder/encoder
 			"%",
 		},
 	},
@@ -1074,28 +1141,30 @@ func TestSchemasMultiError(t *testing.T) {
 func testSchemaMultiError(t *testing.T, example schemaMultiErrorExample) func(*testing.T) {
 	return func(t *testing.T) {
 		schema := example.Schema
-		for i, value := range example.Values {
-			err := validateSchema(t, schema, value, MultiErrors())
-			require.Error(t, err)
-			require.IsType(t, MultiError{}, err)
+		for validateFuncIndex, validateFunc := range validateSchemaFuncs {
+			for i, value := range example.Values {
+				err := validateFunc(t, schema, value, MultiErrors())
+				require.Errorf(t, err, "ValidateFunc #%d, value #%d: %#", validateFuncIndex, i, value)
+				require.IsType(t, MultiError{}, err)
 
-			merr, _ := err.(MultiError)
-			expected := example.ExpectedErrors[i]
-			require.True(t, len(merr) > 0)
-			require.Len(t, merr, len(expected))
-			for _, e := range merr {
-				require.IsType(t, &SchemaError{}, e)
-				var found bool
-				scherr, _ := e.(*SchemaError)
-				for _, expectedErr := range expected {
-					expectedScherr, _ := expectedErr.(*SchemaError)
-					if reflect.DeepEqual(expectedScherr.reversePath, scherr.reversePath) &&
-						expectedScherr.SchemaField == scherr.SchemaField {
-						found = true
-						break
+				merr, _ := err.(MultiError)
+				expected := example.ExpectedErrors[i]
+				require.True(t, len(merr) > 0)
+				require.Len(t, merr, len(expected))
+				for _, e := range merr {
+					require.IsType(t, &SchemaError{}, e)
+					var found bool
+					scherr, _ := e.(*SchemaError)
+					for _, expectedErr := range expected {
+						expectedScherr, _ := expectedErr.(*SchemaError)
+						if reflect.DeepEqual(expectedScherr.reversePath, scherr.reversePath) &&
+							expectedScherr.SchemaField == scherr.SchemaField {
+							found = true
+							break
+						}
 					}
+					require.Truef(t, found, "ValidateFunc #%d, value #%d: missing %s error on %s", validateFunc, i, scherr.SchemaField, strings.Join(scherr.JSONPointer(), "."))
 				}
-				require.True(t, found, fmt.Sprintf("missing %s error on %s", scherr.SchemaField, strings.Join(scherr.JSONPointer(), ".")))
 			}
 		}
 	}
diff --git a/openapi3/testdata/circularRef/base.yml b/openapi3/testdata/circularRef/base.yml
new file mode 100644
index 0000000..ff8240e
--- /dev/null
+++ b/openapi3/testdata/circularRef/base.yml
@@ -0,0 +1,16 @@
+openapi: "3.0.3"
+info:
+  title: Recursive cyclic refs example
+  version: "1.0"
+components:
+  schemas:
+    Foo:
+      properties:
+        foo2:
+          $ref: "other.yml#/components/schemas/Foo2"
+        bar:
+          $ref: "#/components/schemas/Bar"
+    Bar:
+      properties:
+        foo:
+          $ref: "#/components/schemas/Foo"
diff --git a/openapi3/testdata/circularRef/other.yml b/openapi3/testdata/circularRef/other.yml
new file mode 100644
index 0000000..29b72d9
--- /dev/null
+++ b/openapi3/testdata/circularRef/other.yml
@@ -0,0 +1,10 @@
+openapi: "3.0.3"
+info:
+  title: Recursive cyclic refs example
+  version: "1.0"
+components:
+  schemas:
+    Foo2:
+      properties:
+        id:
+          type: string
diff --git a/openapi3/testdata/recursiveRef/components/Cat.yml b/openapi3/testdata/recursiveRef/components/Cat.yml
new file mode 100644
index 0000000..c476aa1
--- /dev/null
+++ b/openapi3/testdata/recursiveRef/components/Cat.yml
@@ -0,0 +1,4 @@
+type: object
+properties:
+  cat:
+    $ref: ../openapi.yml#/components/schemas/Cat
diff --git a/openapi3/testdata/recursiveRef/openapi.yml b/openapi3/testdata/recursiveRef/openapi.yml
index 3559c8e..675722a 100644
--- a/openapi3/testdata/recursiveRef/openapi.yml
+++ b/openapi3/testdata/recursiveRef/openapi.yml
@@ -13,3 +13,5 @@ components:
       $ref: ./components/Foo/Foo2.yml
     Bar:
       $ref: ./components/Bar.yml
+    Cat:
+      $ref: ./components/Cat.yml
diff --git a/openapi3/testdata/recursiveRef/openapi.yml.internalized.yml b/openapi3/testdata/recursiveRef/openapi.yml.internalized.yml
index d1260eb..0730590 100644
--- a/openapi3/testdata/recursiveRef/openapi.yml.internalized.yml
+++ b/openapi3/testdata/recursiveRef/openapi.yml.internalized.yml
@@ -29,6 +29,14 @@
           }
         },
         "type": "object"
+      },
+      "Cat": {
+        "properties": {
+          "cat": {
+            "$ref": "#/components/schemas/Cat"
+          }
+        },
+        "type": "object"
       }
     }
   },