diff --git a/CHANGES.md b/CHANGES.md index 0236f9e..508befd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,7 @@ +## Version 0.18.2 + +* Add setting `FIRST_DATETIME_FORMAT`. + ## Version 0.18.1 * Improve exception message for response validation error. diff --git a/README.md b/README.md index b3469e0..0b227e2 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,10 @@ $ pip install -U flask_first ## Settings -`FIRST_RESPONSE_VALIDATION` - Default: `False`. Enabling response body validation. Useful when +* `FIRST_RESPONSE_VALIDATION` - Default: `False`. Enabling response body validation. Useful when developing. Must be disabled in a production environment. +* `FIRST_DATETIME_FORMAT` - Default: `None`. Set format for `format: date-time`. +Example: `%Y-%m-%dT%H:%M:%S.%fZ`. ## Tools diff --git a/pyproject.toml b/pyproject.toml index a9d4765..337d97a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ license = {file = "LICENSE"} name = "Flask-First" readme = "README.md" requires-python = ">=3.9" -version = "0.18.1" +version = "0.18.2" [project.optional-dependencies] dev = [ diff --git a/src/flask_first/__init__.py b/src/flask_first/__init__.py index 649c2c8..6679ea6 100755 --- a/src/flask_first/__init__.py +++ b/src/flask_first/__init__.py @@ -247,11 +247,13 @@ def init_app(self, app: Flask) -> None: self.app = app self.app.config.setdefault('FIRST_RESPONSE_VALIDATION', False) self.app.config.setdefault('FIRST_EXPERIMENTAL_VALIDATOR', False) + self.app.config.setdefault('FIRST_DATETIME_FORMAT', None) self.app.extensions['first'] = self self.spec = Specification( self.path_to_spec, experimental_validator=self.app.config['FIRST_EXPERIMENTAL_VALIDATOR'], + datetime_format=self.app.config['FIRST_DATETIME_FORMAT'], ) if self.swagger_ui_path: diff --git a/src/flask_first/first/specification.py b/src/flask_first/first/specification.py index c5b8d6a..af18dbb 100644 --- a/src/flask_first/first/specification.py +++ b/src/flask_first/first/specification.py @@ -91,8 +91,14 @@ def resolve(self) -> Mapping[Hashable, Any]: class Specification: - def __init__(self, path: Path or str, experimental_validator: bool = False): + def __init__( + self, + path: Path or str, + experimental_validator: bool = False, + datetime_format: Optional[str] = None, + ): self.path = path + self.datetime_format = datetime_format self.experimental_validator = experimental_validator self.raw_spec = Resolver(self.path).resolve() self._validating_openapi_file(self.path, self.experimental_validator) @@ -153,9 +159,9 @@ def _from_list_to_schemas(self, parameters: list) -> dict: def _convert_parameters_to_schema(self, spec_without_refs) -> dict: schema = deepcopy(spec_without_refs) for _, path_item in schema['paths'].items(): - common_parameters: list | None = path_item.pop('parameters', []) + common_parameters: Optional[list] = path_item.pop('parameters', []) for method, operation in path_item.items(): - parameters_from_method: list | None = operation.get('parameters', []) + parameters_from_method: Optional[list] = operation.get('parameters', []) combined_params = [*common_parameters, *parameters_from_method] @@ -173,12 +179,18 @@ def _convert_schemas(self, resolved_schema: dict) -> dict or list: if isinstance(converted_schema, dict): for key, value in converted_schema.items(): if key in {'header_args', 'view_args', 'args', 'cookie'}: - converted_schema[key] = make_marshmallow_schema(value) + converted_schema[key] = make_marshmallow_schema( + value, datetime_format=self.datetime_format + ) elif key == 'schema': - converted_schema['schema'] = make_marshmallow_schema(value) + converted_schema['schema'] = make_marshmallow_schema( + value, datetime_format=self.datetime_format + ) elif key == 'schemas': for schema_name, schema_value in value.items(): - value[schema_name] = make_marshmallow_schema(schema_value) + value[schema_name] = make_marshmallow_schema( + schema_value, datetime_format=self.datetime_format + ) else: converted_schema[key] = self._convert_schemas(value) return converted_schema diff --git a/src/flask_first/schema/schema_maker.py b/src/flask_first/schema/schema_maker.py index 08d3247..64aa452 100644 --- a/src/flask_first/schema/schema_maker.py +++ b/src/flask_first/schema/schema_maker.py @@ -1,4 +1,5 @@ from typing import Any +from typing import Optional from marshmallow import fields from marshmallow import INCLUDE @@ -55,7 +56,9 @@ class Meta: unknown = INCLUDE -def _make_object_field(schema: dict, as_nested: bool = True) -> fields.Nested or type: +def _make_object_field( + schema: dict, as_nested: bool = True, datetime_format: Optional[str] = None +) -> fields.Nested or type: fields_obj = {} for field_name, field_schema in schema['properties'].items(): if ( @@ -65,9 +68,11 @@ def _make_object_field(schema: dict, as_nested: bool = True) -> fields.Nested or ): field = HashmapField() elif field_schema['type'] == 'object': - field = make_marshmallow_schema(field_schema, as_nested=True) + field = make_marshmallow_schema( + field_schema, as_nested=True, datetime_format=datetime_format + ) else: - field = make_marshmallow_schema(field_schema) + field = make_marshmallow_schema(field_schema, datetime_format=datetime_format) if field_name in schema.get('required', ()): field.required = True @@ -82,15 +87,18 @@ def _make_object_field(schema: dict, as_nested: bool = True) -> fields.Nested or return schema_object -def _make_array_field(schema: dict) -> fields.Field: +def _make_array_field(schema: dict, datetime_format: Optional[str] = None) -> fields.Field: data_type = schema['items']['type'] data_format = schema['items'].get('format') if data_type == 'object': - nested_field = _make_object_field(schema['items']) + nested_field = _make_object_field(schema['items'], datetime_format=datetime_format) nested_field.many = True field = nested_field elif data_format in FIELDS_VIA_FORMATS: - nested_field = FIELDS_VIA_FORMATS[data_format]() + if data_format == 'date-time': + nested_field = FIELDS_VIA_FORMATS['date-time'](format=datetime_format) + else: + nested_field = FIELDS_VIA_FORMATS[data_format]() field = fields.List(nested_field) else: nested_field = FIELDS_VIA_TYPES[data_type]() @@ -124,7 +132,7 @@ def _make_field_validators(schema: dict) -> list[validate.Validator]: def make_marshmallow_schema( - schema: dict, as_nested: bool = False + schema: dict, as_nested: bool = False, datetime_format: Optional[str] = None ) -> type[HashmapSchema] or Field or Nested or type or Boolean or Any: if 'nullable' in schema and schema.get('type', ...) is ...: field = FIELDS_VIA_TYPES['boolean']() @@ -135,7 +143,11 @@ def make_marshmallow_schema( elif 'oneOf' in schema: field = _make_multiple_field(schema['oneOf'], 'oneOf') elif schema.get('format'): - field = FIELDS_VIA_FORMATS[schema['format']]() + if schema['format'] == 'date-time': + field = FIELDS_VIA_FORMATS['date-time'](format=datetime_format) + else: + field = FIELDS_VIA_FORMATS[schema['format']]() + elif ( schema.get('properties') is None and schema['type'] == 'object' @@ -143,9 +155,9 @@ def make_marshmallow_schema( ): field = HashmapSchema elif schema['type'] == 'object': - field = _make_object_field(schema, as_nested=as_nested) + field = _make_object_field(schema, as_nested=as_nested, datetime_format=datetime_format) elif schema['type'] == 'array': - field = _make_array_field(schema) + field = _make_array_field(schema, datetime_format=datetime_format) else: field = FIELDS_VIA_TYPES[schema['type']]() diff --git a/tests/conftest.py b/tests/conftest.py index be9edbd..2c447b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -49,6 +49,7 @@ def _create_app(path_to_spec: str, routes_functions: Iterable): app = Flask('testing_app') app.debug = True app.config['FIRST_RESPONSE_VALIDATION'] = True + app.config['FIRST_DATETIME_FORMAT'] = "%Y-%m-%dT%H:%M:%S.%fZ" first = First(path_to_spec, app, swagger_ui_path='/docs') for func in routes_functions: diff --git a/tests/specs/v3.1.0/datetime.openapi.yaml b/tests/specs/v3.1.0/datetime.openapi.yaml new file mode 100644 index 0000000..f6d65f8 --- /dev/null +++ b/tests/specs/v3.1.0/datetime.openapi.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Mini API for testing Flask-First + version: 1.0.0 +paths: + /datetime: + post: + operationId: create_datetime + requestBody: + content: + application/json: + schema: + type: object + properties: + datetime: + type: string + format: date-time + responses: + default: + description: OK + content: + application/json: + schema: + type: object + properties: + datetime: + type: string + format: date-time diff --git a/tests/test_responses.py b/tests/test_responses.py index b57820c..dc4ea14 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -1,6 +1,10 @@ +from datetime import datetime from pathlib import Path +import pytest from flask import request +from flask_first.first.exceptions import FirstRequestJSONValidation +from flask_first.first.exceptions import FirstResponseJSONValidation from .conftest import BASEDIR @@ -16,3 +20,48 @@ def default_responses() -> dict: r = test_client.post('/message', json={'message': 'OK'}) assert r.status_code == 200 assert r.json['message'] == 'OK' + + +def test_responses__datetime(fx_create_app): + def create_datetime() -> dict: + datetime = request.extensions['first']['json']['datetime'].strftime("%Y-%m-%dT%H:%M:%S.%fZ") + return {'datetime': datetime} + + test_client = fx_create_app( + Path(BASEDIR, 'specs/v3.1.0/datetime.openapi.yaml'), [create_datetime] + ) + + json = {'datetime': datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%fZ")} + r = test_client.post('/datetime', json=json) + assert r.status_code == 200 + assert r.json['datetime'] == json['datetime'] + + +def test_responses__request_datetime_error(fx_create_app): + def create_datetime() -> dict: + datetime = request.extensions['first']['json']['datetime'] + return {'datetime': datetime} + + test_client = fx_create_app( + Path(BASEDIR, 'specs/v3.1.0/datetime.openapi.yaml'), [create_datetime] + ) + + json = {'datetime': datetime.utcnow().isoformat()} + + with pytest.raises(FirstRequestJSONValidation): + test_client.post('/datetime', json=json) + + +def test_responses__response_datetime_error(fx_create_app): + def create_datetime() -> dict: + datetime = request.extensions['first']['json']['datetime'].isoformat() + return {'datetime': datetime} + + test_client = fx_create_app( + Path(BASEDIR, 'specs/v3.1.0/datetime.openapi.yaml'), [create_datetime] + ) + + json = {'datetime': datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%fZ")} + + with pytest.raises(FirstResponseJSONValidation): + test_client.post('/datetime', json=json)