Skip to content
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
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## Version 0.18.2

* Add setting `FIRST_DATETIME_FORMAT`.

## Version 0.18.1

* Improve exception message for response validation error.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
2 changes: 2 additions & 0 deletions src/flask_first/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 18 additions & 6 deletions src/flask_first/first/specification.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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]

Expand All @@ -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
Expand Down
32 changes: 22 additions & 10 deletions src/flask_first/schema/schema_maker.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Any
from typing import Optional

from marshmallow import fields
from marshmallow import INCLUDE
Expand Down Expand Up @@ -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 (
Expand All @@ -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
Expand All @@ -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]()
Expand Down Expand Up @@ -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']()
Expand All @@ -135,17 +143,21 @@ 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'
and schema['additionalProperties'].get('oneOf')
):
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']]()

Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
28 changes: 28 additions & 0 deletions tests/specs/v3.1.0/datetime.openapi.yaml
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions tests/test_responses.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)