diff --git a/connexion/json_schema.py b/connexion/json_schema.py index 9154994c6..1cf26aa96 100644 --- a/connexion/json_schema.py +++ b/connexion/json_schema.py @@ -62,14 +62,15 @@ 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": } in a spec. Optionally takes a store, which is a mapping from reference URLs to a @@ -77,8 +78,7 @@ def resolve_refs(spec, store=None, handlers=None): """ 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: @@ -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) diff --git a/connexion/spec.py b/connexion/spec.py index 1115cd63f..a142ed306 100644 --- a/connexion/spec.py +++ b/connexion/spec.py @@ -5,6 +5,7 @@ import abc import copy import json +import os import pathlib import pkgutil from collections.abc import Mapping @@ -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 @@ -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): @@ -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 """ @@ -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): diff --git a/tests/conftest.py b/tests/conftest.py index fb8942658..ec182fef8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/fixtures/relative_refs/components.yaml b/tests/fixtures/relative_refs/components.yaml new file mode 100644 index 000000000..fea892398 --- /dev/null +++ b/tests/fixtures/relative_refs/components.yaml @@ -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 diff --git a/tests/fixtures/relative_refs/definitions.yaml b/tests/fixtures/relative_refs/definitions.yaml new file mode 100644 index 000000000..6266fbed5 --- /dev/null +++ b/tests/fixtures/relative_refs/definitions.yaml @@ -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 \ No newline at end of file diff --git a/tests/fixtures/relative_refs/openapi.yaml b/tests/fixtures/relative_refs/openapi.yaml new file mode 100644 index 000000000..eb5a09799 --- /dev/null +++ b/tests/fixtures/relative_refs/openapi.yaml @@ -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" diff --git a/tests/fixtures/relative_refs/swagger.yaml b/tests/fixtures/relative_refs/swagger.yaml new file mode 100644 index 000000000..0a88619fd --- /dev/null +++ b/tests/fixtures/relative_refs/swagger.yaml @@ -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' diff --git a/tests/test_api.py b/tests/test_api.py index acace4253..26bc9c9c1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 @@ -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")