Skip to content

Commit a6f20f6

Browse files
authored
add specification experimental validator. (#29)
* add specification experimental validator.
1 parent 6a6ea0f commit a6f20f6

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+202
-203
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.15.0
2+
3+
* Add specification experimental validator for OpenAPI 3.1.
4+
15
## Version 0.14.6
26

37
* The `default` response is now processed correctly.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Simple example
4141
OpenAPI 3 specification file `openapi.yaml`:
4242

4343
```yaml
44-
openapi: 3.0.3
44+
openapi: 3.1.0
4545
info:
4646
title: Simple API for Flask-First
4747
version: 1.0.0

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ classifiers = [
1414
]
1515
dependencies = [
1616
'Flask>=2.0.2',
17+
'PyYAML>=6.0.1',
1718
'openapi-spec-validator>=0.5.0',
1819
'marshmallow>=3.14.1'
1920
]
@@ -22,7 +23,7 @@ license = {file = "LICENSE"}
2223
name = "Flask-First"
2324
readme = "README.md"
2425
requires-python = ">=3.9"
25-
version = "0.14.6"
26+
version = "0.15.0"
2627

2728
[project.optional-dependencies]
2829
dev = [

src/flask_first/__init__.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,11 @@ def __init__(
3434
self.app = app
3535
self.path_to_spec = path_to_spec
3636
self.swagger_ui_path = swagger_ui_path
37+
self.spec = None
3738

3839
if self.app is not None:
3940
self.init_app(app)
4041

41-
self.spec = Specification(path_to_spec)
42-
4342
self._mapped_routes_from_spec = []
4443

4544
@staticmethod
@@ -51,12 +50,15 @@ def _route_registration_in_flask(self, func: callable) -> None:
5150

5251
for path, path_item in self.spec.resolved_spec['paths'].items():
5352
for method_name, operation in path_item.items():
54-
if operation.get('operationId') == func.__name__:
53+
route_from_spec = operation.get('operationId')
54+
if route_from_spec == func.__name__:
5555
route: str = path
5656
method: str = method_name
5757

5858
if not route:
59-
raise FirstException(f'Route function <{route}> not found in OpenAPI specification!')
59+
raise FirstException(
60+
f'Route function <{func.__name__}> not found in OpenAPI specification!'
61+
)
6062

6163
params_schema = self.spec.resolved_spec['paths'][route][method].get('parameters')
6264

@@ -243,8 +245,14 @@ def add_response_validating(response: Response) -> Response:
243245
def init_app(self, app: Flask) -> None:
244246
self.app = app
245247
self.app.config.setdefault('FIRST_RESPONSE_VALIDATION', False)
248+
self.app.config.setdefault('FIRST_EXPERIMENTAL_VALIDATOR', False)
246249
self.app.extensions['first'] = self
247250

251+
self.spec = Specification(
252+
self.path_to_spec,
253+
experimental_validator=self.app.config['FIRST_EXPERIMENTAL_VALIDATOR'],
254+
)
255+
248256
if self.swagger_ui_path:
249257
add_swagger_ui_blueprint(self.app, self.path_to_spec, self.swagger_ui_path)
250258

src/flask_first/first/specification.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,29 @@
88

99
from ..schema.schema_maker import make_marshmallow_schema
1010
from .exceptions import FirstOpenAPIValidation
11+
from .validator import OpenAPI310ValidationError
12+
from .validator import Validator
1113

1214

1315
class Specification:
14-
def __init__(self, path: Path):
15-
self.raw_spec, _ = read_from_filename(path)
16-
self._validating_openapi_file()
16+
def __init__(self, path: [Path | str], experimental_validator: bool = False):
17+
self.path = path
18+
self.experimental_validator = experimental_validator
19+
self.raw_spec, _ = read_from_filename(self.path)
20+
self._validating_openapi_file(self.path, self.experimental_validator)
1721
self.resolved_spec = self._resolving_refs()
1822
self.serialized_spec = self._convert_schemas(self.resolved_spec)
1923

20-
def _validating_openapi_file(self):
24+
def _validating_openapi_file(self, path: Path, experimental_validator: bool):
25+
if experimental_validator:
26+
try:
27+
Validator(path).validate()
28+
except OpenAPI310ValidationError as e:
29+
raise FirstOpenAPIValidation(repr(e))
30+
2131
try:
2232
validate(self.raw_spec)
23-
except OpenAPIValidationError as e:
33+
except (OpenAPIValidationError, TypeError) as e:
2434
raise FirstOpenAPIValidation(repr(e))
2535

2636
def _resolving_all_refs(self, schema: dict) -> dict or list:

src/flask_first/first/validator.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from pathlib import Path
2+
3+
import yaml
4+
from marshmallow import fields
5+
from marshmallow import pre_load
6+
from marshmallow import Schema
7+
from marshmallow import validate
8+
from marshmallow import ValidationError
9+
10+
OPENAPI_VERSION = '3.1.0'
11+
TYPES = ['array', 'boolean', 'integer', 'number', 'object', 'string']
12+
RE_VERSION = r'^[0-9]+.[0-9]+.[0-9]+$'
13+
14+
ENDPOINT_FIELD = fields.String(required=True, validate=validate.Regexp(r'^[/][0-9a-z-{}/]*[^/]$'))
15+
HTTP_CODE_FIELD = fields.String(required=True, validate=validate.Regexp(r'^[1-5]{1}\d{2}|default$'))
16+
DESCRIPTION_FIELD = fields.String(required=True)
17+
MEDIA_TYPE_FIELD = fields.String(required=True)
18+
19+
20+
class OpenAPI310ValidationError(Exception):
21+
"""OpenAPI specification validation error."""
22+
23+
24+
class InfoSchema(Schema):
25+
title = fields.String(required=True)
26+
version = fields.String(required=True, validate=validate.Regexp(RE_VERSION))
27+
28+
29+
class SchemaObjectSchema(Schema):
30+
type = fields.String(required=True, validate=validate.OneOf(TYPES))
31+
properties = fields.Dict(
32+
keys=fields.String(required=True), values=fields.Nested('SchemaObjectSchema')
33+
)
34+
35+
36+
class MediaTypeObjectSchema(Schema):
37+
schema = fields.Nested(SchemaObjectSchema)
38+
39+
40+
class ResponsesObjectSchema(Schema):
41+
description = DESCRIPTION_FIELD
42+
content = fields.Dict(keys=MEDIA_TYPE_FIELD, values=fields.Nested(MediaTypeObjectSchema))
43+
44+
45+
class OperationObjectSchema(Schema):
46+
operation_id = fields.String(data_key='operationId')
47+
responses = fields.Dict(keys=HTTP_CODE_FIELD, values=fields.Nested(ResponsesObjectSchema))
48+
49+
@pre_load
50+
def normalize_nested_keys(self, data, **kwargs):
51+
responses_with_key_as_str = {str(k): v for k, v in data['responses'].items()}
52+
data['responses'] = responses_with_key_as_str
53+
return data
54+
55+
56+
class PathItemObjectSchema(Schema):
57+
get = fields.Nested(OperationObjectSchema)
58+
59+
60+
class RootSchema(Schema):
61+
openapi = fields.String(
62+
required=True,
63+
validate=validate.And(
64+
validate.Equal(OPENAPI_VERSION), validate.Regexp(r'^[/][0-9a-z-{}/]*[^/]$')
65+
),
66+
)
67+
info = fields.Nested(InfoSchema, required=True)
68+
paths = fields.Dict(
69+
required=True,
70+
keys=ENDPOINT_FIELD,
71+
values=fields.Nested(PathItemObjectSchema, required=True),
72+
)
73+
74+
75+
class Validator:
76+
def __init__(self, path: Path | str):
77+
self.path = path
78+
self.raw_spec = self._yaml_to_dict(self.path)
79+
80+
@staticmethod
81+
def _yaml_to_dict(path: Path) -> dict:
82+
with open(path) as f:
83+
s = yaml.safe_load(f)
84+
return s
85+
86+
def validate(self) -> None or OpenAPI310ValidationError:
87+
try:
88+
RootSchema().validate(self.raw_spec)
89+
except ValidationError as e:
90+
raise OpenAPI310ValidationError(e)

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
@pytest.fixture()
1313
def fx_config():
1414
class Config:
15-
PATH_TO_SPEC = Path(BASEDIR, 'specs/v3.0/openapi.yaml')
15+
PATH_TO_SPEC = Path(BASEDIR, 'specs/v3.1.0/openapi.yaml')
1616

1717
return Config
1818

tests/specs/bad.openapi.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
openapi: 3.0.3
1+
openapi: 3.1.0
22
info:
33
title: Bad API for testing Flask-First

tests/specs/not_valid/empty.openapi.yaml

Whitespace-only changes.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
openapi: 3.1.0
2+
info:
3+
title: API for testing Flask-First
4+
version: 1.0.0

0 commit comments

Comments
 (0)