Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for relative refs in spec #1648

Merged
merged 1 commit into from
Feb 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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