Skip to content

Commit

Permalink
Add support for relative refs in spec (#1648)
Browse files Browse the repository at this point in the history
Fixes #254 
Fixes #967 

This PR fixes the very long-standing issue of being able to handle
relative references, which allows users to split their specification
into multiple files.
  • Loading branch information
RobbeSneyders committed Feb 22, 2023
1 parent 05c32d5 commit 2f9a461
Show file tree
Hide file tree
Showing 8 changed files with 176 additions and 16 deletions.
10 changes: 5 additions & 5 deletions connexion/json_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,23 +62,23 @@ def __call__(self, uri):
return yaml.load(fh, ExtendedSafeLoader)


default_handlers = {
handlers = {
"http": URLHandler(),
"https": URLHandler(),
"file": FileHandler(),
"": FileHandler(),
}


def resolve_refs(spec, store=None, handlers=None):
def resolve_refs(spec, store=None, base_uri=""):
"""
Resolve JSON references like {"$ref": <some URI>} in a spec.
Optionally takes a store, which is a mapping from reference URLs to a
dereferenced objects. Prepopulating the store can avoid network calls.
"""
spec = deepcopy(spec)
store = store or {}
handlers = handlers or default_handlers
resolver = RefResolver("", spec, store, handlers=handlers)
resolver = RefResolver(base_uri, spec, store, handlers=handlers)

def _do_resolve(node):
if isinstance(node, Mapping) and "$ref" in node:
Expand All @@ -94,7 +94,7 @@ def _do_resolve(node):
except KeyError:
# resolve external references
with resolver.resolving(node["$ref"]) as resolved:
return resolved
return _do_resolve(resolved)
elif isinstance(node, Mapping):
for k, v in node.items():
node[k] = _do_resolve(v)
Expand Down
22 changes: 12 additions & 10 deletions connexion/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import abc
import copy
import json
import os
import pathlib
import pkgutil
from collections.abc import Mapping
Expand Down Expand Up @@ -71,11 +72,11 @@ def canonical_base_path(base_path):


class Specification(Mapping):
def __init__(self, raw_spec):
def __init__(self, raw_spec, *, base_uri=""):
self._raw_spec = copy.deepcopy(raw_spec)
self._set_defaults(raw_spec)
self._validate_spec(raw_spec)
self._spec = resolve_refs(raw_spec)
self._spec = resolve_refs(raw_spec, base_uri=base_uri)

@classmethod
@abc.abstractmethod
Expand Down Expand Up @@ -145,13 +146,13 @@ def _load_spec_from_file(arguments, specification):
return yaml.safe_load(openapi_string)

@classmethod
def from_file(cls, spec, arguments=None):
def from_file(cls, spec, *, arguments=None, base_uri=""):
"""
Takes in a path to a YAML file, and returns a Specification
"""
specification_path = pathlib.Path(spec)
spec = cls._load_spec_from_file(arguments, specification_path)
return cls.from_dict(spec)
return cls.from_dict(spec, base_uri=base_uri)

@staticmethod
def _get_spec_version(spec):
Expand All @@ -173,7 +174,7 @@ def _get_spec_version(spec):
return version_tuple

@classmethod
def from_dict(cls, spec):
def from_dict(cls, spec, *, base_uri=""):
"""
Takes in a dictionary, and returns a Specification
"""
Expand All @@ -187,16 +188,17 @@ def enforce_string_keys(obj):
spec = enforce_string_keys(spec)
version = cls._get_spec_version(spec)
if version < (3, 0, 0):
return Swagger2Specification(spec)
return OpenAPISpecification(spec)
return Swagger2Specification(spec, base_uri=base_uri)
return OpenAPISpecification(spec, base_uri=base_uri)

def clone(self):
return type(self)(copy.deepcopy(self._raw_spec))
return type(self)(copy.deepcopy(self._spec))

@classmethod
def load(cls, spec, arguments=None):
def load(cls, spec, *, arguments=None):
if not isinstance(spec, dict):
return cls.from_file(spec, arguments=arguments)
base_uri = f"{pathlib.Path(spec).parent}{os.sep}"
return cls.from_file(spec, arguments=arguments, base_uri=base_uri)
return cls.from_dict(spec)

def with_base_path(self, base_path):
Expand Down
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ def json_datetime_dir():
return FIXTURES_FOLDER / "datetime_support"


@pytest.fixture(scope="session")
def relative_refs():
return FIXTURES_FOLDER / "relative_refs"


@pytest.fixture(scope="session", params=SPECS)
def spec(request):
return request.param
Expand Down
37 changes: 37 additions & 0 deletions tests/fixtures/relative_refs/components.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
components:
schemas:
Pet:
required:
- name
properties:
name:
type: string
example: fluffy
tag:
type: string
example: red
id:
type: integer
format: int64
readOnly: true
example: 1
last_updated:
type: string
readOnly: true
example: 2019-01-16T23:52:54.309102Z

Pets:
type: array
items:
$ref: "#/components/schemas/Pet"

Error:
properties:
code:
type: integer
format: int32
message:
type: string
required:
- code
- message
29 changes: 29 additions & 0 deletions tests/fixtures/relative_refs/definitions.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
definitions:
Pet:
type: object
properties:
id:
type: integer
format: int64
name:
type: string
registered:
type: string
format: date-time

Pets:
type: array
items:
$ref: "#/definitions/Pet"

Error:
type: object
properties:
code:
type: integer
format: int32
message:
type: string
required:
- code
- message
45 changes: 45 additions & 0 deletions tests/fixtures/relative_refs/openapi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
openapi: 3.0.0

info:
version: 1.0.0
title: Swagger Petstore
license:
name: MIT

servers:
- url: /openapi

paths:
/pets:
get:
summary: List all pets
responses:
'200':
description: A paged array of pets
content:
application/json:
schema:
$ref: "components.yaml#/components/schemas/Pets"
default:
description: Unexpected error
content:
application/json:
schema:
$ref: "components.yaml#/components/schemas/Error"

'/pets/{petId}':
get:
summary: Info for a specific pet
responses:
'200':
description: Expected response to a valid request
content:
application/json:
schema:
$ref: "components.yaml#/components/schemas/Pet"
default:
description: Unexpected error
content:
application/json:
schema:
$ref: "components.yaml#/components/schemas/Error"
36 changes: 36 additions & 0 deletions tests/fixtures/relative_refs/swagger.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
swagger: "2.0"

info:
title: "{{title}}"
version: "1.0"

basePath: /swagger

paths:
/pets:
get:
summary: List all pets
responses:
'200':
description: A paged array of pets
schema:
type: array
items:
$ref: 'definitions.yaml#/definitions/Pets'
default:
description: Unexpected Error
schema:
$ref: 'definitions.yaml#/definitions/Error'

'/pets/{id}':
get:
summary: Info for a specific pet
responses:
'200':
description: Expected response to a valid request
schema:
$ref: 'definitions.yaml#/definitions/Pet'
default:
description: Unexpected Error
schema:
$ref: 'definitions.yaml#/definitions/Error'
8 changes: 7 additions & 1 deletion tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import pytest
from connexion import FlaskApi
from connexion.exceptions import InvalidSpecification
from connexion.spec import canonical_base_path
from connexion.spec import Specification, canonical_base_path
from yaml import YAMLError

TEST_FOLDER = pathlib.Path(__file__).parent
Expand Down Expand Up @@ -138,6 +138,12 @@ def test_validation_error_on_completely_invalid_swagger_spec():
os.unlink(f.name)


def test_relative_refs(relative_refs, spec):
spec_path = relative_refs / spec
specification = Specification.load(spec_path)
assert "$ref" not in specification.raw


@pytest.fixture
def mock_api_logger(monkeypatch):
mocked_logger = MagicMock(name="mocked_logger")
Expand Down

0 comments on commit 2f9a461

Please sign in to comment.