Skip to content

Commit bf7b494

Browse files
authored
fix ref resolver. (#36)
* fix ref resolver.
1 parent 18ca626 commit bf7b494

File tree

6 files changed

+132
-29
lines changed

6 files changed

+132
-29
lines changed

CHANGES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## Version 0.19.1
2+
3+
* Fix ref resolver.
4+
15
## Version 0.19.0
26

37
* Remake loading from yaml files.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ license = {file = "LICENSE"}
2323
name = "Flask-First"
2424
readme = "README.md"
2525
requires-python = ">=3.9"
26-
version = "0.19.0"
26+
version = "0.19.1"
2727

2828
[project.optional-dependencies]
2929
dev = [

src/flask_first/first/loaders/yaml_loader.py

Lines changed: 43 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from collections.abc import Hashable
2+
from copy import deepcopy
23
from functools import reduce
34
from pathlib import Path
45
from typing import Any
@@ -25,25 +26,27 @@ def _yaml_to_dict(path: Path) -> dict:
2526
s = yaml.safe_load(f)
2627
return s
2728

28-
def add_file_to_store(self, ref: str) -> None:
29-
try:
30-
file_path, node_path = ref.split('#/')
31-
except (AttributeError, ValueError):
32-
raise FirstYAMLReaderError(f'"$ref" with value <{ref}> is not valid.')
29+
def add_file_to_store(self, file_path: str) -> None:
30+
path_to_spec_file = Path(self.path.parent, file_path)
3331

34-
if file_path and file_path not in self.store:
35-
path_to_spec_file = Path(self.path.parent, file_path)
32+
try:
33+
self.store[file_path] = self._yaml_to_dict(path_to_spec_file)
34+
except FileNotFoundError:
35+
raise FirstYAMLReaderError(f'No such file or directory: <{file_path}>')
3636

37-
try:
38-
self.store[file_path] = self._yaml_to_dict(path_to_spec_file)
39-
except FileNotFoundError:
40-
raise FirstYAMLReaderError(f'No such file or directory: <{file_path}>')
37+
return self.store[file_path]
4138

4239
def search_file(self, obj: dict or list) -> None:
4340
if isinstance(obj, dict):
4441
ref = obj.get('$ref')
4542
if ref:
46-
self.add_file_to_store(ref)
43+
try:
44+
file_path, _ = ref.split('#/')
45+
except (AttributeError, ValueError):
46+
raise FirstYAMLReaderError(f'"$ref" with value <{ref}> is not valid.')
47+
48+
if file_path and file_path not in self.store:
49+
self.search_file(self.add_file_to_store(file_path))
4750
else:
4851
for _, v in obj.items():
4952
self.search_file(v)
@@ -62,7 +65,9 @@ def load(self) -> 'YAMLReader':
6265
return self
6366

6467

65-
class Resolver:
68+
class RefResolver:
69+
"""Resolve links to various parts of the specification."""
70+
6671
def __init__(self, yaml_reader: YAMLReader):
6772
self.yaml_reader = yaml_reader
6873
self.resolved_spec = None
@@ -74,23 +79,18 @@ def get_value_of_key_from_dict(source_dict: dict, key: Hashable) -> Any:
7479
return source_dict[key]
7580

7681
try:
77-
return reduce(get_value_of_key_from_dict, keys, self.yaml_reader.store[file_path])
82+
return deepcopy(
83+
reduce(get_value_of_key_from_dict, keys, self.yaml_reader.store[file_path])
84+
)
7885
except KeyError:
7986
raise FirstResolverError(f'No such path: "{node_path}"')
8087

81-
def _get_schema(self, root_file_path: str, ref: str) -> Any:
82-
try:
83-
file_path, node_path = ref.split('#/')
84-
except (AttributeError, ValueError):
85-
raise FirstResolverError(
86-
f'"$ref" with value <{ref}> is not valid in file <{root_file_path}>'
87-
)
88-
88+
def _get_schema(self, root_file_name: str, file_path: str or None, node_path: str) -> Any:
8989
if file_path and node_path:
9090
obj = self._get_schema_via_local_ref(file_path, node_path)
9191

9292
elif node_path and not file_path:
93-
obj = self._get_schema_via_local_ref(root_file_path, node_path)
93+
obj = self._get_schema_via_local_ref(root_file_name, node_path)
9494

9595
else:
9696
raise NotImplementedError
@@ -101,7 +101,23 @@ def _resolving_all_refs(self, file_path: str, obj: Any) -> Any:
101101
if isinstance(obj, dict):
102102
ref = obj.get('$ref', ...)
103103
if ref is not ...:
104-
obj = self._resolving_all_refs(file_path, self._get_schema(file_path, ref))
104+
try:
105+
file_path_from_ref, node_path = ref.split('#/')
106+
except (AttributeError, ValueError):
107+
raise FirstResolverError(
108+
f'"$ref" with value <{ref}> is not valid in file <{file_path}>'
109+
)
110+
111+
if file_path_from_ref:
112+
obj = self._resolving_all_refs(
113+
file_path_from_ref,
114+
self._get_schema(file_path, file_path_from_ref, node_path),
115+
)
116+
else:
117+
obj = self._resolving_all_refs(
118+
file_path, self._get_schema(file_path, file_path_from_ref, node_path)
119+
)
120+
105121
else:
106122
for key, value in obj.items():
107123
obj[key] = self._resolving_all_refs(file_path, value)
@@ -114,14 +130,14 @@ def _resolving_all_refs(self, file_path: str, obj: Any) -> Any:
114130

115131
return obj
116132

117-
def resolving(self) -> 'Resolver':
133+
def resolving(self) -> 'RefResolver':
118134
root_file_path = self.yaml_reader.root_file_name
119135
root_spec = self.yaml_reader.store[root_file_path]
120136
self.resolved_spec = self._resolving_all_refs(root_file_path, root_spec)
121137
return self
122138

123139

124-
def load_from_yaml(path: Path) -> Resolver:
140+
def load_from_yaml(path: Path) -> RefResolver:
125141
yaml_reader = YAMLReader(path).load()
126-
resolved_obj = Resolver(yaml_reader).resolving()
142+
resolved_obj = RefResolver(yaml_reader).resolving()
127143
return resolved_obj.resolved_spec

tests/first/loaders/yaml_loader/conftest.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
from collections.abc import Iterable
12
from pathlib import Path
23

34
import pytest
45
import yaml
6+
from flask import Flask
7+
from flask_first import First
58
from openapi_spec_validator import validate as osv_validate
69
from openapi_spec_validator.readers import read_from_filename
710

@@ -49,3 +52,24 @@ def create(spec: dict, validate: bool = True, file_name: str = 'openapi.yaml') -
4952
return spec_path
5053

5154
return create
55+
56+
57+
@pytest.fixture()
58+
def fx_create_app():
59+
def _create_app(path_to_spec: str, routes_functions: Iterable):
60+
app = Flask('testing_app')
61+
app.debug = True
62+
app.config['FIRST_RESPONSE_VALIDATION'] = True
63+
app.config['FIRST_DATETIME_FORMAT'] = "%Y-%m-%dT%H:%M:%S.%fZ"
64+
65+
first = First(path_to_spec, app, swagger_ui_path='/docs')
66+
for func in routes_functions:
67+
first.add_view_func(func)
68+
69+
app_context = app.app_context()
70+
app_context.push()
71+
72+
with app.test_client() as test_client:
73+
return test_client
74+
75+
return _create_app

tests/first/loaders/yaml_loader/test_loaders__yaml_loader__multiple_files.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,20 @@
66

77
def test_loaders__yaml__multiple__response_ref(fx_spec_minimal, fx_spec_as_file):
88
endpoint_spec = {'endpoint': fx_spec_minimal['paths']['/endpoint']}
9+
endpoint_spec['endpoint']['get']['responses']['200']['content']['application/json']['schema'][
10+
'properties'
11+
]['message'] = {'$ref': '#/components/schemas/Message'}
12+
endpoint_spec['components'] = {'schemas': {'Message': {'type': 'string'}}}
913
endpoint_spec_file = fx_spec_as_file(endpoint_spec, validate=False, file_name='endpoint.yaml')
1014

1115
fx_spec_minimal['paths']['/endpoint'] = {'$ref': f'{endpoint_spec_file.name}#/endpoint'}
1216
spec_file = fx_spec_as_file(fx_spec_minimal, validate=False)
1317
spec_obj = load_from_yaml(spec_file)
1418

1519
assert spec_obj['paths']['/endpoint'].get('$ref') is None
16-
assert spec_obj['paths']['/endpoint'] == endpoint_spec['endpoint']
20+
assert spec_obj['paths']['/endpoint']['get']['responses']['200']['content']['application/json'][
21+
'schema'
22+
]['properties']['message'] == {'type': 'string'}
1723

1824

1925
def test_loader__internal__multiple__non_exist_file(fx_spec_minimal, fx_spec_as_file):
@@ -25,3 +31,28 @@ def test_loader__internal__multiple__non_exist_file(fx_spec_minimal, fx_spec_as_
2531
load_from_yaml(spec_file)
2632

2733
assert str(e.value) == f'No such file or directory: <{non_exist_file_name}>'
34+
35+
36+
def test_loaders__yaml__multiple__method_as_ref(fx_spec_minimal, fx_spec_as_file, fx_create_app):
37+
method_spec = {'CORS': {'summary': 'CORS support'}}
38+
method_spec_file = fx_spec_as_file(method_spec, validate=False, file_name='CORS.openapi.yaml')
39+
40+
endpoint_spec = {'endpoint': fx_spec_minimal['paths']['/endpoint']}
41+
endpoint_spec['endpoint']['options'] = {'$ref': f'{method_spec_file.name}#/CORS'}
42+
endpoint_spec_file = fx_spec_as_file(endpoint_spec, validate=False, file_name='endpoint.yaml')
43+
44+
fx_spec_minimal['paths']['/endpoint'] = {'$ref': f'{endpoint_spec_file.name}#/endpoint'}
45+
spec_file = fx_spec_as_file(fx_spec_minimal, validate=False)
46+
spec_obj = load_from_yaml(spec_file)
47+
48+
assert spec_obj['paths']['/endpoint'].get('$ref') is None
49+
assert spec_obj['paths']['/endpoint']['get'] == endpoint_spec['endpoint']['get']
50+
assert spec_obj['paths']['/endpoint']['options'] == method_spec['CORS']
51+
52+
def endpoint() -> dict:
53+
return {'message': 'OK'}
54+
55+
test_client = fx_create_app(spec_file, [endpoint])
56+
message = test_client.get('/endpoint')
57+
58+
assert message.json == {'message': 'OK'}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
def test_loaders__yaml__multiple__parameters(fx_spec_minimal, fx_spec_as_file, fx_create_app):
2+
endpoint_spec = {'endpoint': fx_spec_minimal['paths']['/endpoint']}
3+
endpoint_spec['endpoint']['parameters'] = [{'$ref': '#/components/parameters/path_param'}]
4+
endpoint_spec['components'] = {
5+
'parameters': {
6+
'path_param': {
7+
'name': 'path_param',
8+
'in': 'path',
9+
'required': True,
10+
'schema': {'type': 'string', 'enum': ['path_param']},
11+
}
12+
}
13+
}
14+
endpoint_spec_file = fx_spec_as_file(endpoint_spec, validate=False, file_name='endpoint.yaml')
15+
16+
fx_spec_minimal['paths']['/endpoint/{path_param}'] = {
17+
'$ref': f'{endpoint_spec_file.name}#/endpoint'
18+
}
19+
fx_spec_minimal['paths'].pop('/endpoint')
20+
spec_file = fx_spec_as_file(fx_spec_minimal, validate=False)
21+
22+
def endpoint(path_param) -> dict:
23+
return {'message': path_param}
24+
25+
test_client = fx_create_app(spec_file, [endpoint])
26+
message = test_client.get('/endpoint/path_param')
27+
28+
assert message.json == {'message': 'path_param'}

0 commit comments

Comments
 (0)