Repository: getkin/kin-openapi
Pull request #347 (2021-04-23 16:14:20)

Follow callbacks references


Patch:
diff --git a/openapi3/swagger_loader.go b/openapi3/swagger_loader.go
index 65904d7..d97f803 100644
--- a/openapi3/swagger_loader.go
+++ b/openapi3/swagger_loader.go
@@ -85,7 +85,7 @@ func (swaggerLoader *SwaggerLoader) allowsExternalRefs(ref string) (err error) {
 }
 
 // loadSingleElementFromURI reads the data from ref and unmarshals to the passed element.
-func (swaggerLoader *SwaggerLoader) loadSingleElementFromURI(ref string, rootPath *url.URL, element json.Unmarshaler) (*url.URL, error) {
+func (swaggerLoader *SwaggerLoader) loadSingleElementFromURI(ref string, rootPath *url.URL, element interface{}) (*url.URL, error) {
 	if err := swaggerLoader.allowsExternalRefs(ref); err != nil {
 		return nil, err
 	}
@@ -126,7 +126,7 @@ func (swaggerLoader *SwaggerLoader) readURL(location *url.URL) ([]byte, error) {
 		}
 		defer resp.Body.Close()
 		if resp.StatusCode > 399 {
-			return nil, fmt.Errorf("request returned status code %d", resp.StatusCode)
+			return nil, fmt.Errorf("error loading %q: request returned status code %d", location.String(), resp.StatusCode)
 		}
 		return ioutil.ReadAll(resp.Body)
 	}
@@ -221,6 +221,11 @@ func (swaggerLoader *SwaggerLoader) ResolveRefsIn(swagger *Swagger, location *ur
 			return
 		}
 	}
+	for _, component := range components.Callbacks {
+		if err = swaggerLoader.resolveCallbackRef(swagger, component, location); err != nil {
+			return
+		}
+	}
 
 	// Visit all operations
 	for entrypoint, pathItem := range swagger.Paths {
@@ -799,6 +804,94 @@ func (swaggerLoader *SwaggerLoader) resolveExampleRef(swagger *Swagger, componen
 	return nil
 }
 
+func (swaggerLoader *SwaggerLoader) resolveCallbackRef(swagger *Swagger, component *CallbackRef, documentPath *url.URL) (err error) {
+
+	if component == nil {
+		return errors.New("invalid callback: value MUST be an object")
+	}
+	if ref := component.Ref; ref != "" {
+		if isSingleRefElement(ref) {
+			var resolved Callback
+			if documentPath, err = swaggerLoader.loadSingleElementFromURI(ref, documentPath, &resolved); err != nil {
+				return err
+			}
+			component.Value = &resolved
+		} else {
+			var resolved CallbackRef
+			componentPath, err := swaggerLoader.resolveComponent(swagger, ref, documentPath, &resolved)
+			if err != nil {
+				return err
+			}
+			if err := swaggerLoader.resolveCallbackRef(swagger, &resolved, componentPath); err != nil {
+				return err
+			}
+			component.Value = resolved.Value
+		}
+	}
+	value := component.Value
+	if value == nil {
+		return nil
+	}
+
+	for entrypoint, pathItem := range *value {
+		entrypoint, pathItem := entrypoint, pathItem
+		err = func() (err error) {
+			key := "-"
+			if documentPath != nil {
+				key = documentPath.EscapedPath()
+			}
+			key += entrypoint
+			if _, ok := swaggerLoader.visitedPathItemRefs[key]; ok {
+				return nil
+			}
+			swaggerLoader.visitedPathItemRefs[key] = struct{}{}
+
+			if pathItem == nil {
+				return errors.New("invalid path item: value MUST be an object")
+			}
+			ref := pathItem.Ref
+			if ref != "" {
+				if isSingleRefElement(ref) {
+					var p PathItem
+					if documentPath, err = swaggerLoader.loadSingleElementFromURI(ref, documentPath, &p); err != nil {
+						return err
+					}
+					*pathItem = p
+				} else {
+					if swagger, ref, documentPath, err = swaggerLoader.resolveRefSwagger(swagger, ref, documentPath); err != nil {
+						return
+					}
+
+					rest := strings.TrimPrefix(ref, "#/components/callbacks/")
+					if rest == ref {
+						return fmt.Errorf(`expected prefix "#/components/callbacks/" in URI %q`, ref)
+					}
+					id := unescapeRefString(rest)
+
+					definitions := swagger.Components.Callbacks
+					if definitions == nil {
+						return failedToResolveRefFragmentPart(ref, "callbacks")
+					}
+					resolved := definitions[id]
+					if resolved == nil {
+						return failedToResolveRefFragmentPart(ref, id)
+					}
+
+					for _, p := range *resolved.Value {
+						*pathItem = *p
+						break
+					}
+				}
+			}
+			return swaggerLoader.resolvePathItemRefContinued(swagger, pathItem, documentPath)
+		}()
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 func (swaggerLoader *SwaggerLoader) resolveLinkRef(swagger *Swagger, component *LinkRef, documentPath *url.URL) (err error) {
 	if component != nil && component.Value != nil {
 		if swaggerLoader.visitedLink == nil {
@@ -880,7 +973,10 @@ func (swaggerLoader *SwaggerLoader) resolvePathItemRef(swagger *Swagger, entrypo
 			*pathItem = *resolved
 		}
 	}
+	return swaggerLoader.resolvePathItemRefContinued(swagger, pathItem, documentPath)
+}
 
+func (swaggerLoader *SwaggerLoader) resolvePathItemRefContinued(swagger *Swagger, pathItem *PathItem, documentPath *url.URL) (err error) {
 	for _, parameter := range pathItem.Parameters {
 		if err = swaggerLoader.resolveParameterRef(swagger, parameter, documentPath); err != nil {
 			return
@@ -902,6 +998,11 @@ func (swaggerLoader *SwaggerLoader) resolvePathItemRef(swagger *Swagger, entrypo
 				return
 			}
 		}
+		for _, callback := range operation.Callbacks {
+			if err = swaggerLoader.resolveCallbackRef(swagger, callback, documentPath); err != nil {
+				return
+			}
+		}
 	}
 	return
 }


Test patch:
diff --git a/openapi3/issue301_test.go b/openapi3/issue301_test.go
new file mode 100644
index 0000000..15298a5
--- /dev/null
+++ b/openapi3/issue301_test.go
@@ -0,0 +1,28 @@
+package openapi3
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestIssue301(t *testing.T) {
+	sl := NewSwaggerLoader()
+	sl.IsExternalRefsAllowed = true
+
+	doc, err := sl.LoadSwaggerFromFile("testdata/callbacks.yml")
+	require.NoError(t, err)
+
+	err = doc.Validate(sl.Context)
+	require.NoError(t, err)
+
+	transCallbacks := doc.Paths["/trans"].Post.Callbacks["transactionCallback"].Value
+	require.Equal(t, "object", (*transCallbacks)["http://notificationServer.com?transactionId={$request.body#/id}&email={$request.body#/email}"].Post.RequestBody.
+		Value.Content["application/json"].Schema.
+		Value.Type)
+
+	otherCallbacks := doc.Paths["/other"].Post.Callbacks["myEvent"].Value
+	require.Equal(t, "boolean", (*otherCallbacks)["{$request.query.queryUrl}"].Post.RequestBody.
+		Value.Content["application/json"].Schema.
+		Value.Type)
+}
diff --git a/openapi3/swagger_loader_http_error_test.go b/openapi3/swagger_loader_http_error_test.go
index 3b3d06c..ba361f4 100644
--- a/openapi3/swagger_loader_http_error_test.go
+++ b/openapi3/swagger_loader_http_error_test.go
@@ -10,7 +10,7 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestLoadFromRemoteURLFailsWithHttpError(t *testing.T) {
+func TestLoadReferenceFromRemoteURLFailsWithHttpError(t *testing.T) {
 	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		w.WriteHeader(http.StatusBadRequest)
 		fmt.Fprint(w, "")
@@ -47,9 +47,53 @@ func TestLoadFromRemoteURLFailsWithHttpError(t *testing.T) {
 	swagger, err := loader.LoadSwaggerFromDataWithPath(spec, &url.URL{Path: "testdata/testfilename.openapi.json"})
 
 	require.Nil(t, swagger)
-	require.EqualError(t, err, fmt.Sprintf("error resolving reference \"%s/components.openapi.json#/components/headers/CustomTestHeader\": request returned status code 400", ts.URL))
+	require.EqualError(t, err, fmt.Sprintf("error resolving reference \"%s/components.openapi.json#/components/headers/CustomTestHeader\": error loading \"%s/components.openapi.json\": request returned status code 400", ts.URL, ts.URL))
+
+	swagger, err = loader.LoadSwaggerFromData(spec)
+	require.Nil(t, swagger)
+	require.EqualError(t, err, fmt.Sprintf("error resolving reference \"%s/components.openapi.json#/components/headers/CustomTestHeader\": error loading \"%s/components.openapi.json\": request returned status code 400", ts.URL, ts.URL))
+}
+
+func TestLoadFromRemoteURLFailsWithHttpError(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusBadRequest)
+		fmt.Fprint(w, "")
+	}))
+	defer ts.Close()
+
+	spec := []byte(`
+{
+    "openapi": "3.0.0",
+    "info": {
+        "title": "",
+        "version": "1"
+    },
+    "paths": {
+        "/test": {
+            "post": {
+                "responses": {
+                    "default": {
+                        "description": "test",
+                        "headers": {
+                            "X-TEST-HEADER": {
+                                "$ref": "` + ts.URL + `/components.openapi.json"
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}`)
+
+	loader := NewSwaggerLoader()
+	loader.IsExternalRefsAllowed = true
+	swagger, err := loader.LoadSwaggerFromDataWithPath(spec, &url.URL{Path: "testdata/testfilename.openapi.json"})
+
+	require.Nil(t, swagger)
+	require.EqualError(t, err, fmt.Sprintf("error loading \"%s/components.openapi.json\": request returned status code 400", ts.URL))
 
 	swagger, err = loader.LoadSwaggerFromData(spec)
 	require.Nil(t, swagger)
-	require.EqualError(t, err, fmt.Sprintf("error resolving reference \"%s/components.openapi.json#/components/headers/CustomTestHeader\": request returned status code 400", ts.URL))
+	require.EqualError(t, err, fmt.Sprintf("error loading \"%s/components.openapi.json\": request returned status code 400", ts.URL))
 }
diff --git a/openapi3/testdata/callback-transactioned.yml b/openapi3/testdata/callback-transactioned.yml
new file mode 100644
index 0000000..2d58b39
--- /dev/null
+++ b/openapi3/testdata/callback-transactioned.yml
@@ -0,0 +1,10 @@
+post:
+  requestBody:
+    description: Callback payload
+    content:
+      'application/json':
+        schema:
+          $ref: 'callbacks.yml#/components/schemas/SomePayload'
+  responses:
+    '200':
+      description: callback successfully processed
diff --git a/openapi3/testdata/callbacks.yml b/openapi3/testdata/callbacks.yml
new file mode 100644
index 0000000..4ad3f7d
--- /dev/null
+++ b/openapi3/testdata/callbacks.yml
@@ -0,0 +1,71 @@
+openapi: 3.1.0
+info:
+  title: Callback refd
+  version: 1.2.3
+paths:
+  /trans:
+    post:
+      description: ''
+      requestBody:
+        description: ''
+        content:
+          'application/json':
+            schema:
+              properties:
+                id: {type: string}
+                email: {format: email}
+      responses:
+        '201':
+          description: subscription successfully created
+          content:
+            application/json:
+              schema:
+                type: object
+      callbacks:
+        transactionCallback:
+          'http://notificationServer.com?transactionId={$request.body#/id}&email={$request.body#/email}':
+            $ref: callback-transactioned.yml
+
+  /other:
+    post:
+      description: ''
+      parameters:
+      - name: queryUrl
+        in: query
+        required: true
+        description: |
+          bla
+          bla
+          bla
+        schema:
+          type: string
+          format: uri
+          example: https://example.com
+      responses:
+        '201':
+          description: ''
+          content:
+            application/json:
+              schema:
+                type: object
+      callbacks:
+        myEvent:
+          $ref: '#/components/callbacks/MyCallbackEvent'
+
+components:
+  schemas:
+    SomePayload: {type: object}
+    SomeOtherPayload: {type: boolean}
+  callbacks:
+    MyCallbackEvent:
+      '{$request.query.queryUrl}':
+        post:
+          requestBody:
+            description: Callback payload
+            content:
+              'application/json':
+                schema:
+                  $ref: '#/components/schemas/SomeOtherPayload'
+          responses:
+            '200':
+              description: callback successfully processed