Skip to content

Commit 18ca626

Browse files
authored
remake loading from yaml files. (#35)
* remake loading from yaml files.
1 parent 1df57b5 commit 18ca626

14 files changed

+335
-169
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.0
2+
3+
* Remake loading from yaml files.
4+
15
## Version 0.18.2
26

37
* Add setting `FIRST_DATETIME_FORMAT`.

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.18.2"
26+
version = "0.19.0"
2727

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

src/flask_first/first/exceptions.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ class FirstException(Exception):
55
"""Common exception."""
66

77

8-
class FirstOpenAPIResolverError(FirstException):
9-
"""Exception for specification resolver error."""
8+
class FirstYAMLReaderError(FirstException):
9+
"""Exception for yaml file loading error."""
10+
11+
12+
class FirstResolverError(FirstException):
13+
"""Exception for specification from yaml file resolver error."""
1014

1115

1216
class FirstOpenAPIValidation(FirstException):
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .yaml_loader import load_from_yaml
2+
3+
__all__ = ['load_from_yaml']
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from collections.abc import Hashable
2+
from functools import reduce
3+
from pathlib import Path
4+
from typing import Any
5+
6+
import yaml
7+
8+
from ..exceptions import FirstResolverError
9+
from ..exceptions import FirstYAMLReaderError
10+
11+
12+
class YAMLReader:
13+
"""
14+
Open OpenAPI specification from yaml file. The specification from multiple files is supported.
15+
"""
16+
17+
def __init__(self, path: Path):
18+
self.path = path
19+
self.root_file_name = self.path.name
20+
self.store = {}
21+
22+
@staticmethod
23+
def _yaml_to_dict(path: Path) -> dict:
24+
with open(path) as f:
25+
s = yaml.safe_load(f)
26+
return s
27+
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.')
33+
34+
if file_path and file_path not in self.store:
35+
path_to_spec_file = Path(self.path.parent, file_path)
36+
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}>')
41+
42+
def search_file(self, obj: dict or list) -> None:
43+
if isinstance(obj, dict):
44+
ref = obj.get('$ref')
45+
if ref:
46+
self.add_file_to_store(ref)
47+
else:
48+
for _, v in obj.items():
49+
self.search_file(v)
50+
51+
elif isinstance(obj, list):
52+
for item in obj:
53+
self.search_file(item)
54+
55+
else:
56+
return
57+
58+
def load(self) -> 'YAMLReader':
59+
root_file = self._yaml_to_dict(self.path)
60+
self.store[self.root_file_name] = root_file
61+
self.search_file(root_file)
62+
return self
63+
64+
65+
class Resolver:
66+
def __init__(self, yaml_reader: YAMLReader):
67+
self.yaml_reader = yaml_reader
68+
self.resolved_spec = None
69+
70+
def _get_schema_via_local_ref(self, file_path: str, node_path: str) -> dict:
71+
keys = node_path.split('/')
72+
73+
def get_value_of_key_from_dict(source_dict: dict, key: Hashable) -> Any:
74+
return source_dict[key]
75+
76+
try:
77+
return reduce(get_value_of_key_from_dict, keys, self.yaml_reader.store[file_path])
78+
except KeyError:
79+
raise FirstResolverError(f'No such path: "{node_path}"')
80+
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+
89+
if file_path and node_path:
90+
obj = self._get_schema_via_local_ref(file_path, node_path)
91+
92+
elif node_path and not file_path:
93+
obj = self._get_schema_via_local_ref(root_file_path, node_path)
94+
95+
else:
96+
raise NotImplementedError
97+
98+
return obj
99+
100+
def _resolving_all_refs(self, file_path: str, obj: Any) -> Any:
101+
if isinstance(obj, dict):
102+
ref = obj.get('$ref', ...)
103+
if ref is not ...:
104+
obj = self._resolving_all_refs(file_path, self._get_schema(file_path, ref))
105+
else:
106+
for key, value in obj.items():
107+
obj[key] = self._resolving_all_refs(file_path, value)
108+
109+
if isinstance(obj, list):
110+
objs = []
111+
for item_obj in obj:
112+
objs.append(self._resolving_all_refs(file_path, item_obj))
113+
obj = objs
114+
115+
return obj
116+
117+
def resolving(self) -> 'Resolver':
118+
root_file_path = self.yaml_reader.root_file_name
119+
root_spec = self.yaml_reader.store[root_file_path]
120+
self.resolved_spec = self._resolving_all_refs(root_file_path, root_spec)
121+
return self
122+
123+
124+
def load_from_yaml(path: Path) -> Resolver:
125+
yaml_reader = YAMLReader(path).load()
126+
resolved_obj = Resolver(yaml_reader).resolving()
127+
return resolved_obj.resolved_spec

src/flask_first/first/specification.py

Lines changed: 2 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,17 @@
1-
from collections.abc import Hashable
2-
from collections.abc import Mapping
31
from copy import deepcopy
4-
from functools import reduce
52
from pathlib import Path
6-
from typing import Any
73
from typing import Optional
84

95
from openapi_spec_validator import validate
10-
from openapi_spec_validator.readers import read_from_filename
116
from openapi_spec_validator.validation.exceptions import OpenAPIValidationError
127

138
from ..schema.schema_maker import make_marshmallow_schema
14-
from .exceptions import FirstOpenAPIResolverError
159
from .exceptions import FirstOpenAPIValidation
10+
from .loaders import load_from_yaml
1611
from .validator import OpenAPI310ValidationError
1712
from .validator import Validator
1813

1914

20-
class Resolver:
21-
"""
22-
This class creates a dictionary from the specification that contains resolved schema references.
23-
Specification from several files are supported.
24-
"""
25-
26-
def __init__(self, abs_path: Path or str):
27-
self.abs_path = Path(abs_path)
28-
self.root_dir = self.abs_path.resolve().parent
29-
30-
@staticmethod
31-
def file_to_dict(abs_path: Path) -> Mapping[Hashable, Any]:
32-
try:
33-
spec_as_dict, _ = read_from_filename(str(abs_path))
34-
except OSError as e:
35-
raise FirstOpenAPIResolverError(e)
36-
return spec_as_dict
37-
38-
@staticmethod
39-
def get_value_of_key_from_dict(source_dict: dict, key: Hashable) -> Any or KeyError:
40-
return source_dict[key]
41-
42-
def _get_schema_via_local_ref(self, root_schema: dict, keys: dict) -> dict:
43-
try:
44-
return reduce(self.get_value_of_key_from_dict, keys, root_schema)
45-
except KeyError:
46-
raise FirstOpenAPIResolverError(f'No such path: {keys}')
47-
48-
def _get_schema_from_file_ref(self, root_dir: Path, relative_path: Path, keys: dict) -> dict:
49-
abs_path_file = Path(root_dir, relative_path)
50-
root_schema = self.file_to_dict(abs_path_file)
51-
return self._get_schema_via_local_ref(root_schema, keys)
52-
53-
def _resolving(self, schema: dict, relative_path_to_file_schema: str) -> dict or list[dict]:
54-
if isinstance(schema, dict):
55-
if '$ref' in schema:
56-
try:
57-
relative_file_path_from_ref, local_path = schema['$ref'].split('#/')
58-
except AttributeError:
59-
raise FirstOpenAPIResolverError(f'"$ref" <{schema["$ref"]}> must be string.')
60-
61-
local_path_parts = local_path.split('/')
62-
63-
if relative_file_path_from_ref:
64-
schema_from_ref = self._get_schema_from_file_ref(
65-
self.root_dir, relative_file_path_from_ref, local_path_parts
66-
)
67-
schema = self._resolving(schema_from_ref, relative_file_path_from_ref)
68-
else:
69-
schema_from_ref = self._get_schema_from_file_ref(
70-
self.root_dir, relative_path_to_file_schema, local_path_parts
71-
)
72-
schema = self._resolving(schema_from_ref, relative_path_to_file_schema)
73-
74-
else:
75-
for key, value in schema.items():
76-
schema[key] = self._resolving(value, relative_path_to_file_schema)
77-
78-
return schema
79-
80-
if isinstance(schema, list):
81-
schemas = []
82-
for item in schema:
83-
schemas.append(self._resolving(item, relative_path_to_file_schema))
84-
return schemas
85-
86-
return schema
87-
88-
def resolve(self) -> Mapping[Hashable, Any]:
89-
schema = self.file_to_dict(self.abs_path)
90-
return self._resolving(schema, self.abs_path)
91-
92-
9315
class Specification:
9416
def __init__(
9517
self,
@@ -100,7 +22,7 @@ def __init__(
10022
self.path = path
10123
self.datetime_format = datetime_format
10224
self.experimental_validator = experimental_validator
103-
self.raw_spec = Resolver(self.path).resolve()
25+
self.raw_spec = load_from_yaml(self.path)
10426
self._validating_openapi_file(self.path, self.experimental_validator)
10527
self.resolved_spec = self._convert_parameters_to_schema(self.raw_spec)
10628
self.serialized_spec = self._convert_schemas(self.resolved_spec)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
import yaml
5+
from openapi_spec_validator import validate as osv_validate
6+
from openapi_spec_validator.readers import read_from_filename
7+
8+
9+
@pytest.fixture
10+
def fx_spec_minimal():
11+
payload = {
12+
'openapi': '3.1.1',
13+
'info': {'title': 'API for testing Flask-First', 'version': '1.0.0'},
14+
'paths': {
15+
'/endpoint': {
16+
'get': {
17+
'operationId': 'endpoint',
18+
'responses': {
19+
'200': {
20+
'description': 'OK',
21+
'content': {
22+
'application/json': {
23+
'schema': {
24+
'type': 'object',
25+
'properties': {'message': {'type': 'string'}},
26+
}
27+
}
28+
},
29+
}
30+
},
31+
}
32+
}
33+
},
34+
}
35+
return payload
36+
37+
38+
@pytest.fixture
39+
def fx_spec_as_file(tmp_path):
40+
def create(spec: dict, validate: bool = True, file_name: str = 'openapi.yaml') -> Path:
41+
spec_path = Path(tmp_path, file_name)
42+
with open(spec_path, 'w+') as f:
43+
yaml.dump(spec, f)
44+
45+
spec_as_dict, _ = read_from_filename(str(spec_path))
46+
if validate:
47+
osv_validate(spec_as_dict)
48+
49+
return spec_path
50+
51+
return create
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import pytest
2+
3+
from src.flask_first.first.exceptions import FirstYAMLReaderError
4+
from src.flask_first.first.loaders.yaml_loader import load_from_yaml
5+
6+
7+
def test_loaders__yaml__multiple__response_ref(fx_spec_minimal, fx_spec_as_file):
8+
endpoint_spec = {'endpoint': fx_spec_minimal['paths']['/endpoint']}
9+
endpoint_spec_file = fx_spec_as_file(endpoint_spec, validate=False, file_name='endpoint.yaml')
10+
11+
fx_spec_minimal['paths']['/endpoint'] = {'$ref': f'{endpoint_spec_file.name}#/endpoint'}
12+
spec_file = fx_spec_as_file(fx_spec_minimal, validate=False)
13+
spec_obj = load_from_yaml(spec_file)
14+
15+
assert spec_obj['paths']['/endpoint'].get('$ref') is None
16+
assert spec_obj['paths']['/endpoint'] == endpoint_spec['endpoint']
17+
18+
19+
def test_loader__internal__multiple__non_exist_file(fx_spec_minimal, fx_spec_as_file):
20+
non_exist_file_name = 'non_exist_file.yaml'
21+
fx_spec_minimal['paths']['/endpoint'] = {'$ref': f'{non_exist_file_name}#/endpoint'}
22+
spec_file = fx_spec_as_file(fx_spec_minimal, validate=False)
23+
24+
with pytest.raises(FirstYAMLReaderError) as e:
25+
load_from_yaml(spec_file)
26+
27+
assert str(e.value) == f'No such file or directory: <{non_exist_file_name}>'

0 commit comments

Comments
 (0)