From 0280a9857f4d59c121cd3b99682cda33a569e05f Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sun, 30 Jun 2024 00:33:01 -0400 Subject: [PATCH 01/22] Implement #44: Validate subclasses of StrEnum and IntEnum --- README.md | 69 +++++----- .../parameter_types/parameter.py | 42 +++--- flask_parameter_validation/test/conftest.py | 1 + flask_parameter_validation/test/enums.py | 11 ++ .../test/test_form_params.py | 122 +++++++++++++++++ .../test/test_json_params.py | 123 +++++++++++++++++- .../test/test_query_params.py | 121 +++++++++++++++++ .../test/test_route_params.py | 54 ++++++++ .../test/testing_blueprints/enum_blueprint.py | 68 ++++++++++ .../testing_blueprints/parameter_blueprint.py | 4 + 10 files changed, 562 insertions(+), 53 deletions(-) create mode 100644 flask_parameter_validation/test/enums.py create mode 100644 flask_parameter_validation/test/testing_blueprints/enum_blueprint.py diff --git a/README.md b/README.md index 142d4fe..49487b1 100644 --- a/README.md +++ b/README.md @@ -81,20 +81,21 @@ The `Parameter` class provides a base for validation common among all input type #### Type Hints and Accepted Input Types Type Hints allow for inline specification of the input type of a parameter. Some types are only available to certain `Parameter` subclasses. -| Type Hint / Expected Python Type | Notes | `Route` | `Form` | `Json` | `Query` | `File` | -|------------------------------------|--------------------------------------------------------------------------------------------------------------------------------|---------|--------|--------|---------|--------| -| `str` | | Y | Y | Y | Y | N | -| `int` | | Y | Y | Y | Y | N | -| `bool` | | Y | Y | Y | Y | N | -| `float` | | Y | Y | Y | Y | N | -| `typing.List` (must not be `list`) | For `Query` inputs, users can pass via either `value=1&value=2&value=3`, or `value=1,2,3`, both will be transformed to a `list`. | N | Y | Y | Y | N | -| `typing.Union` | | Y | Y | Y | Y | N | -| `typing.Optional` | | Y | Y | Y | Y | Y | -| `datetime.datetime` | received as a `str` in ISO-8601 date-time format | Y | Y | Y | Y | N | -| `datetime.date` | received as a `str` in ISO-8601 full-date format | Y | Y | Y | Y | N | -| `datetime.time` | received as a `str` in ISO-8601 partial-time format | Y | Y | Y | Y | N | -| `dict` | | N | N | Y | N | N | -| `FileStorage` | | N | N | N | N | Y | +| Type Hint / Expected Python Type | Notes | `Route` | `Form` | `Json` | `Query` | `File` | +|--------------------------------------|----------------------------------------------------------------------------------------------------------------------------------|---------|--------|--------|---------|--------| +| `str` | | Y | Y | Y | Y | N | +| `int` | | Y | Y | Y | Y | N | +| `bool` | | Y | Y | Y | Y | N | +| `float` | | Y | Y | Y | Y | N | +| `typing.List` (must not be `list`) | For `Query` inputs, users can pass via either `value=1&value=2&value=3`, or `value=1,2,3`, both will be transformed to a `list`. | N | Y | Y | Y | N | +| `typing.Union` | | Y | Y | Y | Y | N | +| `typing.Optional` | Not supported for `Route` inputs | Y | Y | Y | Y | Y | +| `datetime.datetime` | received as a `str` in ISO-8601 date-time format | Y | Y | Y | Y | N | +| `datetime.date` | received as a `str` in ISO-8601 full-date format | Y | Y | Y | Y | N | +| `datetime.time` | received as a `str` in ISO-8601 partial-time format | Y | Y | Y | Y | N | +| `dict` | | N | N | Y | N | N | +| `FileStorage` | | N | N | N | N | Y | +| A subclass of `StrEnum` or `IntEnum` | | Y | Y | Y | Y | N | These can be used in tandem to describe a parameter to validate: `parameter_name: type_hint = ParameterSubclass()` - `parameter_name`: The field name itself, such as username @@ -104,26 +105,26 @@ These can be used in tandem to describe a parameter to validate: `parameter_name ### Validation with arguments to Parameter Validation beyond type-checking can be done by passing arguments into the constructor of the `Parameter` subclass. The arguments available for use on each type hint are: -| Parameter Name | Type of Parameter | Effective On Types | Description | -|-------------------|---------------------------------------------|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `default` | any | All | Specifies the default value for the field, makes non-Optional fields not required | -| `min_str_length` | `int` | `str` | Specifies the minimum character length for a string input | -| `max_str_length` | `int` | `str` | Specifies the maximum character length for a string input | -| `min_list_length` | `int` | `typing.List` | Specifies the minimum number of elements in a list | -| `max_list_length` | `int` | `typing.List` | Specifies the maximum number of elements in a list | -| `min_int` | `int` | `int` | Specifies the minimum number for an integer input | -| `max_int` | `int` | `int` | Specifies the maximum number for an integer input | -| `whitelist` | `str` | `str` | A string containing allowed characters for the value | -| `blacklist` | `str` | `str` | A string containing forbidden characters for the value | -| `pattern` | `str` | `str` | A regex pattern to test for string matches | -| `func` | `Callable -> Union[bool, tuple[bool, str]]` | All | A function containing a fully customized logic to validate the value. See the [custom validation function](#custom-validation-function) below for usage | -| `datetime_format` | `str` | `datetime.datetime` | Python datetime format string datetime format string ([datetime format codes](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes)) | -| `comment` | `str` | All | A string to display as the argument description in any generated documentation | -| `alias` | `str` | All but `FileStorage` | An expected parameter name to receive instead of the function name. | -| `json_schema` | `dict` | `dict` | An expected [JSON Schema](https://json-schema.org) which the dict input must conform to | -| `content_types` | `list[str]` | `FileStorage` | Allowed `Content-Type`s | -| `min_length` | `int` | `FileStorage` | Minimum `Content-Length` for a file | -| `max_length` | `int` | `FileStorage` | Maximum `Content-Length` for a file | +| Parameter Name | Type of Argument | Effective On Types | Description | +|-------------------|--------------------------------------------------|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `default` | any | All, except in `Route` | Specifies the default value for the field, makes non-Optional fields not required | +| `min_str_length` | `int` | `str` | Specifies the minimum character length for a string input | +| `max_str_length` | `int` | `str` | Specifies the maximum character length for a string input | +| `min_list_length` | `int` | `typing.List` | Specifies the minimum number of elements in a list | +| `max_list_length` | `int` | `typing.List` | Specifies the maximum number of elements in a list | +| `min_int` | `int` | `int` | Specifies the minimum number for an integer input | +| `max_int` | `int` | `int` | Specifies the maximum number for an integer input | +| `whitelist` | `str` | `str` | A string containing allowed characters for the value | +| `blacklist` | `str` | `str` | A string containing forbidden characters for the value | +| `pattern` | `str` | `str` | A regex pattern to test for string matches | +| `func` | `Callable[Any] -> Union[bool, tuple[bool, str]]` | All | A function containing a fully customized logic to validate the value. See the [custom validation function](#custom-validation-function) below for usage | +| `datetime_format` | `str` | `datetime.datetime` | Python datetime format string datetime format string ([datetime format codes](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes)) | +| `comment` | `str` | All | A string to display as the argument description in any generated documentation | +| `alias` | `str` | All but `FileStorage` | An expected parameter name to receive instead of the function name. | +| `json_schema` | `dict` | `dict` | An expected [JSON Schema](https://json-schema.org) which the dict input must conform to | +| `content_types` | `list[str]` | `FileStorage` | Allowed `Content-Type`s | +| `min_length` | `int` | `FileStorage` | Minimum `Content-Length` for a file | +| `max_length` | `int` | `FileStorage` | Maximum `Content-Length` for a file | These validators are passed into the `Parameter` subclass in the route function, such as: * `username: str = Json(default="defaultusername", min_length=5)` diff --git a/flask_parameter_validation/parameter_types/parameter.py b/flask_parameter_validation/parameter_types/parameter.py index 063dac9..e486aac 100644 --- a/flask_parameter_validation/parameter_types/parameter.py +++ b/flask_parameter_validation/parameter_types/parameter.py @@ -4,6 +4,8 @@ """ import re from datetime import date, datetime, time +from enum import Enum, StrEnum, IntEnum + import dateutil.parser as parser import jsonschema from jsonschema.exceptions import ValidationError as JSONSchemaValidationError @@ -13,22 +15,23 @@ class Parameter: # Parameter initialisation def __init__( - self, - default=None, # any: default parameter value - min_str_length=None, # int: min parameter length - max_str_length=None, # int: max parameter length - min_list_length=None, # int: min number of items in list - max_list_length=None, # int: max number of items in list - min_int=None, # int: min number (if val is int) - max_int=None, # int: max number (if val is int) - whitelist=None, # str: character whitelist - blacklist=None, # str: character blacklist - pattern=None, # str: regexp pattern - func=None, # Callable -> Union[bool, tuple[bool, str]]: function performing a fully customized validation - datetime_format=None, # str: datetime format string (https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes), - comment=None, # str: comment for autogenerated documentation - alias=None, # str: alias for parameter name - json_schema=None, # dict: JSON Schema to check received dicts or lists against + self, + default=None, # any: default parameter value + min_str_length=None, # int: min parameter length + max_str_length=None, # int: max parameter length + min_list_length=None, # int: min number of items in list + max_list_length=None, # int: max number of items in list + min_int=None, # int: min number (if val is int) + max_int=None, # int: max number (if val is int) + whitelist=None, # str: character whitelist + blacklist=None, # str: character blacklist + pattern=None, # str: regexp pattern + func=None, # Callable -> Union[bool, tuple[bool, str]]: function performing a fully customized validation + datetime_format=None, + # str: datetime format string (https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes), + comment=None, # str: comment for autogenerated documentation + alias=None, # str: alias for parameter name + json_schema=None, # dict: JSON Schema to check received dicts or lists against ): self.default = default self.min_list_length = min_list_length @@ -150,8 +153,6 @@ def validate(self, value): if self.func is not None and not original_value_type_list: self.func_helper(value) - - return True def convert(self, value, allowed_types): @@ -183,4 +184,9 @@ def convert(self, value, allowed_types): return date.fromisoformat(str(value)) except ValueError: raise ValueError("date format does not match ISO 8601") + elif len(allowed_types) == 1 and (issubclass(allowed_types[0], StrEnum) or issubclass(allowed_types[0], IntEnum)): + if issubclass(allowed_types[0], IntEnum): + value = int(value) + returning = allowed_types[0](value) + return returning return value diff --git a/flask_parameter_validation/test/conftest.py b/flask_parameter_validation/test/conftest.py index 9fbaf6e..42b3fd0 100644 --- a/flask_parameter_validation/test/conftest.py +++ b/flask_parameter_validation/test/conftest.py @@ -11,6 +11,7 @@ def app(): @pytest.fixture() def client(app): + print(app.url_map) return app.test_client() diff --git a/flask_parameter_validation/test/enums.py b/flask_parameter_validation/test/enums.py new file mode 100644 index 0000000..c7143d4 --- /dev/null +++ b/flask_parameter_validation/test/enums.py @@ -0,0 +1,11 @@ +from enum import StrEnum, IntEnum + + +class Fruits(StrEnum): + APPLE = "apple" + ORANGE = "orange" + + +class Binary(IntEnum): + ZERO = 0 + ONE = 1 diff --git a/flask_parameter_validation/test/test_form_params.py b/flask_parameter_validation/test/test_form_params.py index 3683bf9..eda4449 100644 --- a/flask_parameter_validation/test/test_form_params.py +++ b/flask_parameter_validation/test/test_form_params.py @@ -2,6 +2,8 @@ import datetime from typing import Type, List, Optional +from flask_parameter_validation.test.enums import Fruits, Binary + def list_assertion_helper(length: int, list_children_type: Type, expected_list: List, tested_list, expected_call: Optional[str] = None): @@ -903,3 +905,123 @@ def test_max_list_length(client): # Test that above length yields error r = client.post(url, data={"v": ["the", "longest", "of", "lists"]}) assert "error" in r.json + + +# Enum validation +def test_required_str_enum(client): + url = "/form/str_enum/required" + # Test that present str_enum input yields input value + r = client.post(url, data={"v": Fruits.APPLE.value}) + assert "v" in r.json + print(r.json["v"]) + assert r.json["v"] == Fruits.APPLE.value + # Test that missing input yields error + r = client.post(url) + assert "error" in r.json + # Test that present non-str_enum input yields error + r = client.post(url, data={"v": "a"}) + assert "error" in r.json + + +def test_optional_str_enum(client): + url = "/form/str_enum/optional" + # Test that missing input yields None + r = client.post(url) + assert "v" in r.json + assert r.json["v"] is None + # Test that present str_enum input yields input value + r = client.post(url, data={"v": Fruits.ORANGE.value}) + assert "v" in r.json + assert r.json["v"] == Fruits.ORANGE.value + # Test that present non-str_enum input yields error + r = client.post(url, data={"v": "v"}) + assert "error" in r.json + + +def test_str_enum_default(client): + url = "/form/str_enum/default" + # Test that missing input for required and optional yields default values + r = client.post(url) + assert "n_opt" in r.json + assert r.json["n_opt"] == Fruits.APPLE.value + assert "opt" in r.json + assert r.json["opt"] == Fruits.ORANGE.value + # Test that present str_enum input for required and optional yields input values + r = client.post(url, data={"opt": Fruits.ORANGE.value, "n_opt": Fruits.APPLE.value}) + assert "opt" in r.json + assert r.json["opt"] == Fruits.ORANGE.value + assert "n_opt" in r.json + assert r.json["n_opt"] == Fruits.APPLE.value + # Test that present non-str_enum input for required yields error + r = client.post(url, data={"opt": "a", "n_opt": "b"}) + assert "error" in r.json + + +def test_str_enum_func(client): + url = "/form/str_enum/func" + # Test that input passing func yields input + r = client.post(url, data={"v": Fruits.ORANGE.value}) + assert "v" in r.json + assert r.json["v"] == Fruits.ORANGE.value + # Test that input failing func yields error + r = client.post(url, data={"v": Fruits.APPLE.value}) + assert "error" in r.json + + +def test_required_int_enum(client): + url = "/form/int_enum/required" + # Test that present int_enum input yields input value + r = client.post(url, data={"v": Binary.ONE.value}) + assert "v" in r.json + assert r.json["v"] == Binary.ONE.value + # Test that missing input yields error + r = client.post(url) + assert "error" in r.json + # Test that present non-int_enum input yields error + r = client.post(url, data={"v": 8}) + assert "error" in r.json + + +def test_optional_int_enum(client): + url = "/form/int_enum/optional" + # Test that missing input yields None + r = client.post(url) + assert "v" in r.json + assert r.json["v"] is None + # Test that present int_enum input yields input value + r = client.post(url, data={"v": Binary.ZERO.value}) + assert "v" in r.json + assert r.json["v"] == Binary.ZERO.value + # Test that present non-int_enum input yields error + r = client.post(url, data={"v": 8}) + assert "error" in r.json + + +def test_int_enum_default(client): + url = "/form/int_enum/default" + # Test that missing input for required and optional yields default values + r = client.post(url) + assert "n_opt" in r.json + assert r.json["n_opt"] == Binary.ZERO.value + assert "opt" in r.json + assert r.json["opt"] == Binary.ONE.value + # Test that present int_enum input for required and optional yields input values + r = client.post(url, data={"opt": Binary.ONE.value, "n_opt": Binary.ZERO.value}) + assert "opt" in r.json + assert r.json["opt"] == Binary.ONE.value + assert "n_opt" in r.json + assert r.json["n_opt"] == Binary.ZERO.value + # Test that present non-int_enum input for required yields error + r = client.post(url, data={"opt": "a", "n_opt": 9}) + assert "error" in r.json + + +def test_int_enum_func(client): + url = "/form/int_enum/func" + # Test that input passing func yields input + r = client.post(url, data={"v": Binary.ZERO.value}) + assert "v" in r.json + assert r.json["v"] == Binary.ZERO.value + # Test that input failing func yields error + r = client.post(url, data={"v": Binary.ONE.value}) + assert "error" in r.json diff --git a/flask_parameter_validation/test/test_json_params.py b/flask_parameter_validation/test/test_json_params.py index 14231b5..bfd7966 100644 --- a/flask_parameter_validation/test/test_json_params.py +++ b/flask_parameter_validation/test/test_json_params.py @@ -2,6 +2,8 @@ import datetime from typing import Type, List, Optional +from flask_parameter_validation.test.enums import Binary, Fruits + def list_assertion_helper(length: int, list_children_type: Type, expected_list: List, tested_list, expected_call: Optional[str] = None): @@ -1034,4 +1036,123 @@ def test_dict_json_schema(client): "last_name": "Doe" } r = client.post(url, json={"v": v}) - assert "error" in r.json \ No newline at end of file + assert "error" in r.json + + +# Enum validation +def test_required_str_enum(client): + url = "/json/str_enum/required" + # Test that present str_enum input yields input value + r = client.post(url, json={"v": Fruits.APPLE.value}) + assert "v" in r.json + assert r.json["v"] == Fruits.APPLE.value + # Test that missing input yields error + r = client.post(url) + assert "error" in r.json + # Test that present non-str_enum input yields error + r = client.post(url, json={"v": "a"}) + assert "error" in r.json + + +def test_optional_str_enum(client): + url = "/json/str_enum/optional" + # Test that missing input yields None + r = client.post(url) + assert "v" in r.json + assert r.json["v"] is None + # Test that present str_enum input yields input value + r = client.post(url, json={"v": Fruits.ORANGE.value}) + assert "v" in r.json + assert r.json["v"] == Fruits.ORANGE.value + # Test that present non-str_enum input yields error + r = client.post(url, json={"v": "v"}) + assert "error" in r.json + + +def test_str_enum_default(client): + url = "/json/str_enum/default" + # Test that missing input for required and optional yields default values + r = client.post(url) + assert "n_opt" in r.json + assert r.json["n_opt"] == Fruits.APPLE.value + assert "opt" in r.json + assert r.json["opt"] == Fruits.ORANGE.value + # Test that present str_enum input for required and optional yields input values + r = client.post(url, json={"opt": Fruits.ORANGE.value, "n_opt": Fruits.APPLE.value}) + assert "opt" in r.json + assert r.json["opt"] == Fruits.ORANGE.value + assert "n_opt" in r.json + assert r.json["n_opt"] == Fruits.APPLE.value + # Test that present non-str_enum input for required yields error + r = client.post(url, json={"opt": "a", "n_opt": "b"}) + assert "error" in r.json + + +def test_str_enum_func(client): + url = "/json/str_enum/func" + # Test that input passing func yields input + r = client.post(url, json={"v": Fruits.ORANGE.value}) + assert "v" in r.json + assert r.json["v"] == Fruits.ORANGE.value + # Test that input failing func yields error + r = client.post(url, json={"v": Fruits.APPLE.value}) + assert "error" in r.json + + +def test_required_int_enum(client): + url = "/json/int_enum/required" + # Test that present int_enum input yields input value + r = client.post(url, json={"v": Binary.ONE.value}) + assert "v" in r.json + assert r.json["v"] == Binary.ONE.value + # Test that missing input yields error + r = client.post(url) + assert "error" in r.json + # Test that present non-int_enum input yields error + r = client.post(url, json={"v": 8}) + assert "error" in r.json + + +def test_optional_int_enum(client): + url = "/json/int_enum/optional" + # Test that missing input yields None + r = client.post(url) + assert "v" in r.json + assert r.json["v"] is None + # Test that present int_enum input yields input value + r = client.post(url, json={"v": Binary.ZERO.value}) + assert "v" in r.json + assert r.json["v"] == Binary.ZERO.value + # Test that present non-int_enum input yields error + r = client.post(url, json={"v": 8}) + assert "error" in r.json + + +def test_int_enum_default(client): + url = "/json/int_enum/default" + # Test that missing input for required and optional yields default values + r = client.post(url) + assert "n_opt" in r.json + assert r.json["n_opt"] == Binary.ZERO.value + assert "opt" in r.json + assert r.json["opt"] == Binary.ONE.value + # Test that present int_enum input for required and optional yields input values + r = client.post(url, json={"opt": Binary.ONE.value, "n_opt": Binary.ZERO.value}) + assert "opt" in r.json + assert r.json["opt"] == Binary.ONE.value + assert "n_opt" in r.json + assert r.json["n_opt"] == Binary.ZERO.value + # Test that present non-int_enum input for required yields error + r = client.post(url, json={"opt": "a", "n_opt": 9}) + assert "error" in r.json + + +def test_int_enum_func(client): + url = "/json/int_enum/func" + # Test that input passing func yields input + r = client.post(url, json={"v": Binary.ZERO}) + assert "v" in r.json + assert r.json["v"] == Binary.ZERO.value + # Test that input failing func yields error + r = client.post(url, json={"v": Binary.ONE.value}) + assert "error" in r.json diff --git a/flask_parameter_validation/test/test_query_params.py b/flask_parameter_validation/test/test_query_params.py index e7ea85c..1e79060 100644 --- a/flask_parameter_validation/test/test_query_params.py +++ b/flask_parameter_validation/test/test_query_params.py @@ -2,6 +2,8 @@ import datetime from typing import Type, List, Optional +from flask_parameter_validation.test.enums import Binary, Fruits + def list_assertion_helper(length: int, list_children_type: Type, expected_list: List, tested_list, expected_call: Optional[str] = None): @@ -1596,3 +1598,122 @@ def test_max_list_length(client): # Test that above length yields error r = client.get(url, query_string={"v": ["the", "longest", "of", "lists"]}) assert "error" in r.json + + +# Enum validation +def test_required_str_enum(client): + url = "/query/str_enum/required" + # Test that present str_enum input yields input value + r = client.get(url, query_string={"v": Fruits.APPLE.value}) + assert "v" in r.json + assert r.json["v"] == Fruits.APPLE.value + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + # Test that present non-str_enum input yields error + r = client.get(url, query_string={"v": "a"}) + assert "error" in r.json + + +def test_optional_str_enum(client): + url = "/query/str_enum/optional" + # Test that missing input yields None + r = client.get(url) + assert "v" in r.json + assert r.json["v"] is None + # Test that present str_enum input yields input value + r = client.get(url, query_string={"v": Fruits.ORANGE.value}) + assert "v" in r.json + assert r.json["v"] == Fruits.ORANGE.value + # Test that present non-str_enum input yields error + r = client.get(url, query_string={"v": "v"}) + assert "error" in r.json + + +def test_str_enum_default(client): + url = "/query/str_enum/default" + # Test that missing input for required and optional yields default values + r = client.get(url) + assert "n_opt" in r.json + assert r.json["n_opt"] == Fruits.APPLE.value + assert "opt" in r.json + assert r.json["opt"] == Fruits.ORANGE.value + # Test that present str_enum input for required and optional yields input values + r = client.get(url, query_string={"opt": Fruits.ORANGE.value, "n_opt": Fruits.APPLE.value}) + assert "opt" in r.json + assert r.json["opt"] == Fruits.ORANGE.value + assert "n_opt" in r.json + assert r.json["n_opt"] == Fruits.APPLE.value + # Test that present non-str_enum input for required yields error + r = client.get(url, query_string={"opt": "a", "n_opt": "b"}) + assert "error" in r.json + + +def test_str_enum_func(client): + url = "/query/str_enum/func" + # Test that input passing func yields input + r = client.get(url, query_string={"v": Fruits.ORANGE.value}) + assert "v" in r.json + assert r.json["v"] == Fruits.ORANGE.value + # Test that input failing func yields error + r = client.get(url, query_string={"v": Fruits.APPLE.value}) + assert "error" in r.json + + +def test_required_int_enum(client): + url = "/query/int_enum/required" + # Test that present int_enum input yields input value + r = client.get(url, query_string={"v": Binary.ONE.value}) + assert "v" in r.json + assert r.json["v"] == Binary.ONE.value + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + # Test that present non-int_enum input yields error + r = client.get(url, query_string={"v": 8}) + assert "error" in r.json + + +def test_optional_int_enum(client): + url = "/query/int_enum/optional" + # Test that missing input yields None + r = client.get(url) + assert "v" in r.json + assert r.json["v"] is None + # Test that present int_enum input yields input value + r = client.get(url, query_string={"v": Binary.ZERO.value}) + assert "v" in r.json + assert r.json["v"] == Binary.ZERO.value + # Test that present non-int_enum input yields error + r = client.get(url, query_string={"v": 8}) + assert "error" in r.json + + +def test_int_enum_default(client): + url = "/query/int_enum/default" + # Test that missing input for required and optional yields default values + r = client.get(url) + assert "n_opt" in r.json + assert r.json["n_opt"] == Binary.ZERO.value + assert "opt" in r.json + assert r.json["opt"] == Binary.ONE.value + # Test that present int_enum input for required and optional yields input values + r = client.get(url, query_string={"opt": Binary.ONE.value, "n_opt": Binary.ZERO.value}) + assert "opt" in r.json + assert r.json["opt"] == Binary.ONE.value + assert "n_opt" in r.json + assert r.json["n_opt"] == Binary.ZERO.value + # Test that present non-int_enum input for required yields error + r = client.get(url, query_string={"opt": "a", "n_opt": 9}) + assert "error" in r.json + + +def test_int_enum_func(client): + url = "/query/int_enum/func" + # Test that input passing func yields input + r = client.get(url, query_string={"v": Binary.ZERO.value}) + assert "v" in r.json + assert r.json["v"] == Binary.ZERO.value + # Test that input failing func yields error + r = client.get(url, query_string={"v": Binary.ONE.value}) + assert "error" in r.json diff --git a/flask_parameter_validation/test/test_route_params.py b/flask_parameter_validation/test/test_route_params.py index 05784b7..434534b 100644 --- a/flask_parameter_validation/test/test_route_params.py +++ b/flask_parameter_validation/test/test_route_params.py @@ -2,6 +2,8 @@ import datetime from typing import Type, List, Optional +from flask_parameter_validation.test.enums import Fruits, Binary + def list_assertion_helper(length: int, list_children_type: Type, expected_list: List, tested_list, expected_call: Optional[str] = None): @@ -126,6 +128,7 @@ def test_str_func(client): r = client.get(f"{url}/abc") assert "error" in r.json + # Int Validation def test_required_int(client): url = "/route/int/required" @@ -373,3 +376,54 @@ def test_union_func(client): # Test that int input failing func yields error r = client.get(f"{url}/0") assert "error" in r.json + + +# Enum validation +def test_required_str_enum(client): + url = "/route/str_enum/required" + # Test that present str_enum input yields input value + r = client.get(f"{url}/{Fruits.APPLE.value}") + assert "v" in r.json + assert r.json["v"] == Fruits.APPLE.value + # Test that missing input is 404 + r = client.get(url) + assert r.status_code == 404 + # Test that present non-str_enum input yields error + r = client.get(f"{url}/a") + assert "error" in r.json + + +def test_str_enum_func(client): + url = "/route/str_enum/func" + # Test that input passing func yields input + r = client.get(f"{url}/{Fruits.ORANGE.value}") + assert "v" in r.json + assert r.json["v"] == Fruits.ORANGE.value + # Test that input failing func yields error + r = client.get(f"{url}/{Fruits.APPLE.value}") + assert "error" in r.json + + +def test_required_int_enum(client): + url = "/route/int_enum/required" + # Test that present int_enum input yields input value + r = client.get(f"{url}/{Binary.ONE.value}") + assert "v" in r.json + assert r.json["v"] == Binary.ONE.value + # Test that missing input is 404 + r = client.get(url) + assert r.status_code == 404 + # Test that present non-int_enum input yields error + r = client.get(f"{url}/8") + assert "error" in r.json + + +def test_int_enum_func(client): + url = "/route/int_enum/func" + # Test that input passing func yields input + r = client.get(f"{url}/{Binary.ZERO.value}") + assert "v" in r.json + assert r.json["v"] == Binary.ZERO.value + # Test that input failing func yields error + r = client.get(f"{url}/{Binary.ONE.value}") + assert "error" in r.json diff --git a/flask_parameter_validation/test/testing_blueprints/enum_blueprint.py b/flask_parameter_validation/test/testing_blueprints/enum_blueprint.py new file mode 100644 index 0000000..63e3d7b --- /dev/null +++ b/flask_parameter_validation/test/testing_blueprints/enum_blueprint.py @@ -0,0 +1,68 @@ +from enum import Enum +from typing import Optional, Type + +from flask import Blueprint, jsonify + +from flask_parameter_validation import ValidateParameters, Route +from flask_parameter_validation.parameter_types.parameter import Parameter +from flask_parameter_validation.test.enums import Fruits, Binary +from flask_parameter_validation.test.testing_blueprints.dummy_decorators import dummy_decorator, dummy_async_decorator + + +def get_enum_blueprint(ParamType: type[Parameter], bp_name: str, http_verb: str, enum: Type[Enum], + bp_path: str) -> Blueprint: + enum_bp = Blueprint(bp_name, __name__, url_prefix=f"/{bp_path}") + decorator = getattr(enum_bp, http_verb) + + def path(base: str, route_additions: str) -> str: + return base + (route_additions if ParamType is Route else "") + + @decorator(path("/required", "/")) + @ValidateParameters() + def required(v: enum = ParamType()): + assert type(v) is enum + return jsonify({"v": v.value}) + + @decorator(path("/decorator/required", "/")) + @dummy_decorator + @ValidateParameters() + def decorator_required(v: enum = ParamType()): + assert type(v) is enum + return jsonify({"v": v.value}) + + @decorator(path("/async_decorator/required", "/")) + @dummy_async_decorator + @ValidateParameters() + async def async_decorator_required(v: enum = ParamType()): + assert type(v) is enum + return jsonify({"v": v.value}) + + @decorator("/optional") # Route not supported by Optional + @ValidateParameters() + def optional(v: Optional[enum] = ParamType()): + return jsonify({"v": v.value if v is not None else v}) + + @decorator("/default") # Route not supported by default + @ValidateParameters() + def default( + n_opt: enum = ParamType(default=Fruits.APPLE if enum == Fruits else Binary.ZERO), + opt: Optional[enum] = ParamType(default=Fruits.ORANGE if enum == Fruits else Binary.ONE) + ): + return jsonify({ + "n_opt": n_opt.value, + "opt": opt.value + }) + + def magic_func(v): + if type(v) is Fruits: + return v == Fruits.ORANGE + elif type(v) is Binary: + return v == Binary.ZERO + return True # Wouldn't usually do this, but it'll be called on + + @decorator(path("/func", "/")) + @ValidateParameters() + def func(v: enum = ParamType(func=magic_func)): + return jsonify({"v": v.value}) + + return enum_bp diff --git a/flask_parameter_validation/test/testing_blueprints/parameter_blueprint.py b/flask_parameter_validation/test/testing_blueprints/parameter_blueprint.py index 72b75b4..9eb7a85 100644 --- a/flask_parameter_validation/test/testing_blueprints/parameter_blueprint.py +++ b/flask_parameter_validation/test/testing_blueprints/parameter_blueprint.py @@ -1,10 +1,12 @@ from flask import Blueprint from flask_parameter_validation.parameter_types.parameter import Parameter +from flask_parameter_validation.test.enums import Fruits, Binary from flask_parameter_validation.test.testing_blueprints.bool_blueprint import get_bool_blueprint from flask_parameter_validation.test.testing_blueprints.date_blueprint import get_date_blueprint from flask_parameter_validation.test.testing_blueprints.datetime_blueprint import get_datetime_blueprint from flask_parameter_validation.test.testing_blueprints.dict_blueprint import get_dict_blueprint +from flask_parameter_validation.test.testing_blueprints.enum_blueprint import get_enum_blueprint from flask_parameter_validation.test.testing_blueprints.float_blueprint import get_float_blueprint from flask_parameter_validation.test.testing_blueprints.int_blueprint import get_int_blueprint from flask_parameter_validation.test.testing_blueprints.list_blueprint import get_list_blueprint @@ -27,4 +29,6 @@ def get_parameter_blueprint(ParamType: type[Parameter], bp_name: str, param_name param_bp.register_blueprint(get_date_blueprint(ParamType, f"{bp_name}_date", http_verb)) param_bp.register_blueprint(get_time_blueprint(ParamType, f"{bp_name}_time", http_verb)) param_bp.register_blueprint(get_dict_blueprint(ParamType, f"{bp_name}_dict", http_verb)) + param_bp.register_blueprint(get_enum_blueprint(ParamType, f"{bp_name}_str_enum", http_verb, Fruits, "str_enum")) + param_bp.register_blueprint(get_enum_blueprint(ParamType, f"{bp_name}_int_enum", http_verb, Binary, "int_enum")) return param_bp From 9d87f639099df0d4b9f53840cb7062b3cde19a9a Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sun, 30 Jun 2024 18:35:57 -0400 Subject: [PATCH 02/22] Add pull request template --- .github/pull_request_template.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..6d073a1 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,29 @@ +### ๐Ÿ›  Changes being made + +#### Give examples of the changes you've made in this pull request. Include an itemized list if you can. + +### ๐Ÿง  Rationale behind the change + +#### Why did you choose to make these changes? + +#### Does this pull request resolve any open issues? + +#### Were there any trade-offs you had to consider? + +### ๐Ÿงช Testing + +- [ ] Have tests been added or updated for the changes introduced in this pull request? + +- [ ] Are the changes backwards compatible? + +#### If the changes aren't backwards compatible, what other options were explored? + +### โœจ Quality check + +- [ ] Are your changes free of any erroneous print statements, debuggers or other leftover code? + +- [ ] Has the README been updated to reflect the changes introduced (if applicable)? + +### ๐Ÿ’ฌ Additional comments + +#### Feel free to add any further information below \ No newline at end of file From 9e098474dce4954186614c35ef1f5441422615e1 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Mon, 1 Jul 2024 17:10:40 -0400 Subject: [PATCH 03/22] Implement #43: Allow parameters to accept ingress from multiple possible sources --- README.md | 61 ++- .../parameter_types/__init__.py | 3 +- .../parameter_types/multi_source.py | 9 + .../parameter_validation.py | 203 +++---- .../test/test_multi_source_params.py | 516 ++++++++++++++++++ .../test/testing_application.py | 15 +- .../multi_source_blueprint.py | 148 +++++ 7 files changed, 835 insertions(+), 120 deletions(-) create mode 100644 flask_parameter_validation/parameter_types/multi_source.py create mode 100644 flask_parameter_validation/test/test_multi_source_params.py create mode 100644 flask_parameter_validation/test/testing_blueprints/multi_source_blueprint.py diff --git a/README.md b/README.md index 142d4fe..0254355 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ def error_handler(err): "error_message": str(err) }, 400 -@ValidateParameters(error_handler) @app.route(...) +@ValidateParameters(error_handler) def api(...) ``` @@ -70,31 +70,46 @@ def api(...) #### Parameter Class The `Parameter` class provides a base for validation common among all input types, all location-specific classes extend `Parameter`. These subclasses are: -| Subclass Name | Input Source | Available For | -|---------------|------------------------------------------------------------------------------------------------------------------------|------------------| -| Route | Parameter passed in the pathname of the URL, such as `/users/` | All HTTP Methods | -| Form | Parameter in an HTML form or a `FormData` object in the request body, often with `Content-Type: x-www-form-urlencoded` | POST Methods | -| Json | Parameter in the JSON object in the request body, must have header `Content-Type: application/json` | POST Method | -| Query | Parameter in the query of the URL, such as /news_article?id=55 | All HTTP Methods | -| File | Parameter is a file uploaded in the request body | POST Method | +| Subclass Name | Input Source | Available For | +|---------------|------------------------------------------------------------------------------------------------------------------------|---------------------------------| +| Route | Parameter passed in the pathname of the URL, such as `/users/` | All HTTP Methods | +| Form | Parameter in an HTML form or a `FormData` object in the request body, often with `Content-Type: x-www-form-urlencoded` | POST Methods | +| Json | Parameter in the JSON object in the request body, must have header `Content-Type: application/json` | POST Method | +| Query | Parameter in the query of the URL, such as /news_article?id=55 | All HTTP Methods | +| File | Parameter is a file uploaded in the request body | POST Method | +| MultiSource | Parameter is in one of the locations provided to the constructor | Dependent on selected locations | + +##### MultiSource Parameters +Using the `MultiSource` parameter type, parameters can be accepted from any combination of `Parameter` subclasses. Example usage is as follows: + +```py +@app.route("/") +@app.route("/") # If accepting parameters by Route and another type, a path with and without that Route parameter must be specified +@ValidateParameters() +def multi_source_example( + value: int = MultiSource([Route(), Query(), Json()]) +) +``` + +The above example will accept parameters passed to the route through Route, Query, and JSON Body. Validation options must be specified on each constructor in order to be processed. #### Type Hints and Accepted Input Types Type Hints allow for inline specification of the input type of a parameter. Some types are only available to certain `Parameter` subclasses. -| Type Hint / Expected Python Type | Notes | `Route` | `Form` | `Json` | `Query` | `File` | -|------------------------------------|--------------------------------------------------------------------------------------------------------------------------------|---------|--------|--------|---------|--------| -| `str` | | Y | Y | Y | Y | N | -| `int` | | Y | Y | Y | Y | N | -| `bool` | | Y | Y | Y | Y | N | -| `float` | | Y | Y | Y | Y | N | -| `typing.List` (must not be `list`) | For `Query` inputs, users can pass via either `value=1&value=2&value=3`, or `value=1,2,3`, both will be transformed to a `list`. | N | Y | Y | Y | N | -| `typing.Union` | | Y | Y | Y | Y | N | -| `typing.Optional` | | Y | Y | Y | Y | Y | -| `datetime.datetime` | received as a `str` in ISO-8601 date-time format | Y | Y | Y | Y | N | -| `datetime.date` | received as a `str` in ISO-8601 full-date format | Y | Y | Y | Y | N | -| `datetime.time` | received as a `str` in ISO-8601 partial-time format | Y | Y | Y | Y | N | -| `dict` | | N | N | Y | N | N | -| `FileStorage` | | N | N | N | N | Y | +| Type Hint / Expected Python Type | Notes | `Route` | `Form` | `Json` | `Query` | `File` | +|------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------|---------|--------|--------|---------|--------| +| `str` | | Y | Y | Y | Y | N | +| `int` | | Y | Y | Y | Y | N | +| `bool` | | Y | Y | Y | Y | N | +| `float` | | Y | Y | Y | Y | N | +| `typing.List` (must not be `list`) | For `Query` and `Form` inputs, users can pass via either `value=1&value=2&value=3`, or `value=1,2,3`, both will be transformed to a `list`. | N | Y | Y | Y | N | +| `typing.Union` | Cannot be used inside of `typing.List` | Y | Y | Y | Y | N | +| `typing.Optional` | | Y | Y | Y | Y | Y | +| `datetime.datetime` | Received as a `str` in ISO-8601 date-time format | Y | Y | Y | Y | N | +| `datetime.date` | Received as a `str` in ISO-8601 full-date format | Y | Y | Y | Y | N | +| `datetime.time` | Received as a `str` in ISO-8601 partial-time format | Y | Y | Y | Y | N | +| `dict` | For `Query` and `Form` inputs, users should pass the stringified JSON | N | N | Y | N | N | +| `FileStorage` | | N | N | N | N | Y | These can be used in tandem to describe a parameter to validate: `parameter_name: type_hint = ParameterSubclass()` - `parameter_name`: The field name itself, such as username @@ -102,7 +117,7 @@ These can be used in tandem to describe a parameter to validate: `parameter_name - `ParameterSubclass`: An instance of a subclass of `Parameter` ### Validation with arguments to Parameter -Validation beyond type-checking can be done by passing arguments into the constructor of the `Parameter` subclass. The arguments available for use on each type hint are: +Validation beyond type-checking can be done by passing arguments into the constructor of the `Parameter` subclass (with the exception of `MultiSource`). The arguments available for use on each type hint are: | Parameter Name | Type of Parameter | Effective On Types | Description | |-------------------|---------------------------------------------|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------| diff --git a/flask_parameter_validation/parameter_types/__init__.py b/flask_parameter_validation/parameter_types/__init__.py index f9b9ae4..935aad7 100644 --- a/flask_parameter_validation/parameter_types/__init__.py +++ b/flask_parameter_validation/parameter_types/__init__.py @@ -3,7 +3,8 @@ from .json import Json from .query import Query from .route import Route +from .multi_source import MultiSource __all__ = [ - "File", "Form", "Json", "Query", "Route" + "File", "Form", "Json", "Query", "Route", "MultiSource" ] diff --git a/flask_parameter_validation/parameter_types/multi_source.py b/flask_parameter_validation/parameter_types/multi_source.py new file mode 100644 index 0000000..6be4380 --- /dev/null +++ b/flask_parameter_validation/parameter_types/multi_source.py @@ -0,0 +1,9 @@ +from flask_parameter_validation.parameter_types.parameter import Parameter + + +class MultiSource(Parameter): + name = "multi_source" + + def __init__(self, sources: list[Parameter], default=None, **kwargs): + self.sources = sources + super().__init__(default, **kwargs) diff --git a/flask_parameter_validation/parameter_validation.py b/flask_parameter_validation/parameter_validation.py index 20f72c7..666d884 100644 --- a/flask_parameter_validation/parameter_validation.py +++ b/flask_parameter_validation/parameter_validation.py @@ -9,6 +9,7 @@ from .exceptions import (InvalidParameterTypeError, MissingInputError, ValidationError) from .parameter_types import File, Form, Json, Query, Route +from .parameter_types.multi_source import MultiSource fn_list = dict() @@ -54,7 +55,7 @@ def nested_func_helper(**kwargs): json_input = None if request.headers.get("Content-Type") is not None: if re.search( - "application/[^+]*[+]?(json);?", request.headers.get("Content-Type") + "application/[^+]*[+]?(json);?", request.headers.get("Content-Type") ): try: json_input = request.json @@ -115,7 +116,7 @@ def nested_func(**kwargs): return nested_func def _to_dict_with_lists( - self, multi_dict: ImmutableMultiDict, expected_lists: list, split_strings: bool = False + self, multi_dict: ImmutableMultiDict, expected_lists: list, split_strings: bool = False ) -> dict: dict_with_lists = {} for key, values in multi_dict.lists(): @@ -155,108 +156,122 @@ def validate(self, expected_input, all_request_inputs): original_expected_input_type = expected_input.annotation original_expected_input_type_str = expected_input_type_str - # Validate that the expected delivery type is valid - if expected_delivery_type.__class__ not in all_request_inputs.keys(): - raise InvalidParameterTypeError(expected_delivery_type) + # Expected delivery types can be a list if using MultiSource + expected_delivery_types = [expected_delivery_type] + if type(expected_delivery_type) is MultiSource: + expected_delivery_types = expected_delivery_type.sources - # Validate that user supplied input in expected delivery type (unless specified as Optional) - user_input = all_request_inputs[expected_delivery_type.__class__].get( - expected_name - ) - if user_input is None: - # If default is given, set and continue - if expected_delivery_type.default is not None: - user_input = expected_delivery_type.default - else: - # Optionals are Unions with a NoneType, so we should check if None is part of Union __args__ (if exist) - if ( - hasattr(expected_input_type, "__args__") and type(None) in expected_input_type.__args__ - ): - return user_input + for source_index, source in enumerate(expected_delivery_types): + # Validate that the expected delivery type is valid + if source.__class__ not in all_request_inputs.keys(): + raise InvalidParameterTypeError(source) + + # Validate that user supplied input in expected delivery type (unless specified as Optional) + user_input = all_request_inputs[source.__class__].get( + expected_name + ) + if user_input is None: + # If default is given, set and continue + if source.default is not None: + user_input = source.default else: - raise MissingInputError( - expected_name, expected_delivery_type.__class__ - ) + # Optionals are Unions with a NoneType, so we should check if None is part of Union __args__ (if exist) + if ( + hasattr(expected_input_type, "__args__") and type(None) in expected_input_type.__args__ + and source_index == len(expected_delivery_types) - 1 # If MultiSource, only return None for last source + ): + return user_input + else: + if len(expected_delivery_types) == 1: + raise MissingInputError( + expected_name, source.__class__ + ) + elif source_index != len(expected_delivery_types) - 1: + continue + else: + raise MissingInputError( + expected_name, source.__class__ + ) - # Skip validation if typing.Any is given - if expected_input_type_str.startswith("typing.Any"): - return user_input + # Skip validation if typing.Any is given + if expected_input_type_str.startswith("typing.Any"): + return user_input - # In python3.7+, typing.Optional is used instead of typing.Union[..., None] - if expected_input_type_str.startswith("typing.Optional"): - new_type = expected_input_type.__args__[0] - expected_input_type = new_type - expected_input_type_str = str(new_type) + # In python3.7+, typing.Optional is used instead of typing.Union[..., None] + if expected_input_type_str.startswith("typing.Optional"): + new_type = expected_input_type.__args__[0] + expected_input_type = new_type + expected_input_type_str = str(new_type) - # Prepare expected type checks for unions, lists and plain types - if expected_input_type_str.startswith("typing.Union"): - expected_input_types = expected_input_type.__args__ - user_inputs = [user_input] - # If typing.List in union and user supplied valid list, convert remaining check only for list - for exp_type in expected_input_types: - if str(exp_type).startswith("typing.List"): - if type(user_input) is list: - # Only convert if validation passes - if hasattr(exp_type, "__args__"): - if all(type(inp) in exp_type.__args__ for inp in user_input): - expected_input_type = exp_type - expected_input_types = expected_input_type.__args__ - expected_input_type_str = str(exp_type) - user_inputs = user_input - # If list, expand inner typing items. Otherwise, convert to list to match anyway. - elif expected_input_type_str.startswith("typing.List"): - expected_input_types = expected_input_type.__args__ - if type(user_input) is list: - user_inputs = user_input + # Prepare expected type checks for unions, lists and plain types + if expected_input_type_str.startswith("typing.Union"): + expected_input_types = expected_input_type.__args__ + user_inputs = [user_input] + # If typing.List in union and user supplied valid list, convert remaining check only for list + for exp_type in expected_input_types: + if str(exp_type).startswith("typing.List"): + if type(user_input) is list: + # Only convert if validation passes + if hasattr(exp_type, "__args__"): + if all(type(inp) in exp_type.__args__ for inp in user_input): + expected_input_type = exp_type + expected_input_types = expected_input_type.__args__ + expected_input_type_str = str(exp_type) + user_inputs = user_input + # If list, expand inner typing items. Otherwise, convert to list to match anyway. + elif expected_input_type_str.startswith("typing.List"): + expected_input_types = expected_input_type.__args__ + if type(user_input) is list: + user_inputs = user_input + else: + user_inputs = [user_input] else: user_inputs = [user_input] - else: - user_inputs = [user_input] - expected_input_types = [expected_input_type] + expected_input_types = [expected_input_type] - # Perform automatic type conversion for parameter types (i.e. "true" -> True) - for count, value in enumerate(user_inputs): - try: - user_inputs[count] = expected_delivery_type.convert( - value, expected_input_types - ) - except ValueError as e: - raise ValidationError(str(e), expected_name, expected_input_type) + # Perform automatic type conversion for parameter types (i.e. "true" -> True) + for count, value in enumerate(user_inputs): + try: + user_inputs[count] = source.convert( + value, expected_input_types + ) + except ValueError as e: + raise ValidationError(str(e), expected_name, expected_input_type) - # Validate that user type(s) match expected type(s) - validation_success = all( - type(inp) in expected_input_types for inp in user_inputs - ) + # Validate that user type(s) match expected type(s) + validation_success = all( + type(inp) in expected_input_types for inp in user_inputs + ) - # Validate that if lists are required, lists are given - if expected_input_type_str.startswith("typing.List"): - if type(user_input) is not list: - validation_success = False + # Validate that if lists are required, lists are given + if expected_input_type_str.startswith("typing.List"): + if type(user_input) is not list: + validation_success = False - # Error if types don't match - if not validation_success: - if hasattr( - original_expected_input_type, "__name__" - ) and not original_expected_input_type_str.startswith("typing."): - type_name = original_expected_input_type.__name__ - else: - type_name = original_expected_input_type_str - raise ValidationError( - f"must be type '{type_name}'", - expected_name, - original_expected_input_type, - ) + # Error if types don't match + if not validation_success: + if hasattr( + original_expected_input_type, "__name__" + ) and not original_expected_input_type_str.startswith("typing."): + type_name = original_expected_input_type.__name__ + else: + type_name = original_expected_input_type_str + raise ValidationError( + f"must be type '{type_name}'", + expected_name, + original_expected_input_type, + ) - # Validate parameter-specific requirements are met - try: - if type(user_input) is list: - expected_delivery_type.validate(user_input) - else: - expected_delivery_type.validate(user_inputs[0]) - except ValueError as e: - raise ValidationError(str(e), expected_name, expected_input_type) + # Validate parameter-specific requirements are met + try: + if type(user_input) is list: + source.validate(user_input) + else: + source.validate(user_inputs[0]) + except ValueError as e: + raise ValidationError(str(e), expected_name, expected_input_type) - # Return input back to parent function - if expected_input_type_str.startswith("typing.List"): - return user_inputs - return user_inputs[0] + # Return input back to parent function + if expected_input_type_str.startswith("typing.List"): + return user_inputs + return user_inputs[0] diff --git a/flask_parameter_validation/test/test_multi_source_params.py b/flask_parameter_validation/test/test_multi_source_params.py new file mode 100644 index 0000000..9d0b3ac --- /dev/null +++ b/flask_parameter_validation/test/test_multi_source_params.py @@ -0,0 +1,516 @@ +import datetime +import json + +import pytest + +from flask_parameter_validation.test.testing_application import multi_source_sources + +common_parameters = "source_a, source_b", [(source_a['name'], source_b['name']) for source_a in multi_source_sources for source_b in multi_source_sources] + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_bool(client, source_a, source_b): + if source_a == source_b: # This shouldn't be something someone does, so we won't test for it + return + url = f"/ms_{source_a}_{source_b}/required_bool" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + b = True + if source == "query": + r = client.get(url, query_string={"v": b}) + elif source == "form": + r = client.get(url, data={"v": b}) + elif source == "json": + r = client.get(url, json={"v": b}) + elif source == "route": + r = client.get(f"{url}/{b}") + assert r is not None + assert "v" in r.json + assert r.json["v"] is True + + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_optional_bool(client, source_a, source_b): + if source_a == source_b: # This shouldn't be something someone does, so we won't test for it + return + url = f"/ms_{source_a}_{source_b}/optional_bool" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + b = True + if source == "query": + r = client.get(url, query_string={"v": b}) + elif source == "form": + r = client.get(url, data={"v": b}) + elif source == "json": + r = client.get(url, json={"v": b}) + elif source == "route": + r = client.get(f"{url}/{b}") + assert r is not None + assert "v" in r.json + assert r.json["v"] is True + # Test that missing input yields error + r = client.get(url) + assert r.json["v"] is None + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_date(client, source_a, source_b): + if source_a == source_b: # This shouldn't be something someone does, so we won't test for it + return + d = datetime.date(2024, 6, 1) + url = f"/ms_{source_a}_{source_b}/required_date" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + if source == "query": + r = client.get(url, query_string={"v": d.isoformat()}) + elif source == "form": + r = client.get(url, data={"v": d.isoformat()}) + elif source == "json": + r = client.get(url, json={"v": d.isoformat()}) + elif source == "route": + r = client.get(f"{url}/{d.isoformat()}") + assert r is not None + assert "v" in r.json + assert r.json["v"] == d.isoformat() + + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_optional_date(client, source_a, source_b): + if source_a == source_b: # This shouldn't be something someone does, so we won't test for it + return + d = datetime.date(2024, 6, 1) + url = f"/ms_{source_a}_{source_b}/optional_date" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + if source == "query": + r = client.get(url, query_string={"v": d.isoformat()}) + elif source == "form": + r = client.get(url, data={"v": d.isoformat()}) + elif source == "json": + r = client.get(url, json={"v": d.isoformat()}) + elif source == "route": + r = client.get(f"{url}/{d.isoformat()}") + assert r is not None + assert "v" in r.json + assert r.json["v"] == d.isoformat() + # Test that missing input yields error + r = client.get(url) + assert r.json["v"] is None + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_datetime(client, source_a, source_b): + if source_a == source_b: # This shouldn't be something someone does, so we won't test for it + return + d = datetime.datetime(2024, 6, 1, 15, 44) + url = f"/ms_{source_a}_{source_b}/required_datetime" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + if source == "query": + r = client.get(url, query_string={"v": d.isoformat()}) + elif source == "form": + r = client.get(url, data={"v": d.isoformat()}) + elif source == "json": + r = client.get(url, json={"v": d.isoformat()}) + elif source == "route": + r = client.get(f"{url}/{d.isoformat()}") + assert r is not None + assert "v" in r.json + assert r.json["v"] == d.isoformat() + + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_optional_datetime(client, source_a, source_b): + if source_a == source_b: # This shouldn't be something someone does, so we won't test for it + return + d = datetime.datetime(2024, 6, 1, 15, 45) + url = f"/ms_{source_a}_{source_b}/optional_datetime" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + if source == "query": + r = client.get(url, query_string={"v": d.isoformat()}) + elif source == "form": + r = client.get(url, data={"v": d.isoformat()}) + elif source == "json": + r = client.get(url, json={"v": d.isoformat()}) + elif source == "route": + r = client.get(f"{url}/{d.isoformat()}") + assert r is not None + assert "v" in r.json + assert r.json["v"] == d.isoformat() + # Test that missing input yields error + r = client.get(url) + assert r.json["v"] is None + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_dict(client, source_a, source_b): + if source_a == source_b or "route" in [source_a, source_b]: # Duplicate sources shouldn't be something someone does, so we won't test for it, Route does not support parameters of type 'dict' + return + d = {"a": "b"} + url = f"/ms_{source_a}_{source_b}/required_dict" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + if source == "query": + r = client.get(url, query_string={"v": json.dumps(d)}) + elif source == "form": + r = client.get(url, data={"v": json.dumps(d)}) + elif source == "json": + r = client.get(url, json={"v": d}) + assert r is not None + assert "v" in r.json + assert json.dumps(r.json["v"]) == json.dumps(d) + + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_optional_dict(client, source_a, source_b): + if source_a == source_b or "route" in [source_a, source_b]: # Duplicate sources shouldn't be something someone does, so we won't test for it, Route does not support parameters of type 'dict' + return + d = {"c": "d"} + url = f"/ms_{source_a}_{source_b}/optional_dict" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + if source == "query": + r = client.get(url, query_string={"v": json.dumps(d)}) + elif source == "form": + r = client.get(url, data={"v": json.dumps(d)}) + elif source == "json": + r = client.get(url, json={"v": d}) + assert r is not None + assert "v" in r.json + assert json.dumps(r.json["v"]) == json.dumps(d) + # Test that missing input yields error + r = client.get(url) + assert r.json["v"] is None + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_float(client, source_a, source_b): + if source_a == source_b: # This shouldn't be something someone does, so we won't test for it + return + url = f"/ms_{source_a}_{source_b}/required_float" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + f = 3.14 + if source == "query": + r = client.get(url, query_string={"v": f}) + elif source == "form": + r = client.get(url, data={"v": f}) + elif source == "json": + r = client.get(url, json={"v": f}) + elif source == "route": + r = client.get(f"{url}/{f}") + assert r is not None + assert "v" in r.json + assert r.json["v"] == f + + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_optional_float(client, source_a, source_b): + if source_a == source_b: # This shouldn't be something someone does, so we won't test for it + return + url = f"/ms_{source_a}_{source_b}/optional_float" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + f = 3.14 + if source == "query": + r = client.get(url, query_string={"v": f}) + elif source == "form": + r = client.get(url, data={"v": f}) + elif source == "json": + r = client.get(url, json={"v": f}) + elif source == "route": + r = client.get(f"{url}/{f}") + assert r is not None + assert "v" in r.json + assert r.json["v"] == f + # Test that missing input yields error + r = client.get(url) + assert r.json["v"] is None + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_int(client, source_a, source_b): + if source_a == source_b: # This shouldn't be something someone does, so we won't test for it + return + url = f"/ms_{source_a}_{source_b}/required_int" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + i = 3 + if source == "query": + r = client.get(url, query_string={"v": i}) + elif source == "form": + r = client.get(url, data={"v": i}) + elif source == "json": + r = client.get(url, json={"v": i}) + elif source == "route": + r = client.get(f"{url}/{i}") + assert r is not None + assert "v" in r.json + assert r.json["v"] == i + + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_optional_int(client, source_a, source_b): + if source_a == source_b: # This shouldn't be something someone does, so we won't test for it + return + url = f"/ms_{source_a}_{source_b}/optional_int" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + i = 3 + if source == "query": + r = client.get(url, query_string={"v": i}) + elif source == "form": + r = client.get(url, data={"v": i}) + elif source == "json": + r = client.get(url, json={"v": i}) + elif source == "route": + r = client.get(f"{url}/{i}") + assert r is not None + assert "v" in r.json + assert r.json["v"] == i + # Test that missing input yields error + r = client.get(url) + assert r.json["v"] is None + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_list(client, source_a, source_b): + if source_a == source_b or "route" in [source_a, source_b]: # Duplicate sources shouldn't be something someone does, so we won't test for it, Route does not support parameters of type 'List' + return + l = [1, 2] + url = f"/ms_{source_a}_{source_b}/required_list" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + if source == "query": + r = client.get(url, query_string={"v": l}) + elif source == "form": + r = client.get(url, data={"v": l}) + elif source == "json": + r = client.get(url, json={"v": l}) + assert r is not None + assert "v" in r.json + assert json.dumps(r.json["v"]) == json.dumps(l) + + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_optional_list(client, source_a, source_b): + if source_a == source_b or "route" in [source_a, source_b]: # Duplicate sources shouldn't be something someone does, so we won't test for it, Route does not support parameters of type 'List' + return + l = [1, 2] + url = f"/ms_{source_a}_{source_b}/optional_list" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + if source == "query": + r = client.get(url, query_string={"v": l}) + elif source == "form": + r = client.get(url, data={"v": l}) + elif source == "json": + r = client.get(url, json={"v": l}) + assert r is not None + assert "v" in r.json + assert json.dumps(r.json["v"]) == json.dumps(l) + # Test that missing input yields error + r = client.get(url) + assert r.json["v"] is None + + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_str(client, source_a, source_b): + if source_a == source_b: # This shouldn't be something someone does, so we won't test for it + return + url = f"/ms_{source_a}_{source_b}/required_str" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + s = "Testing MultiSource" + if source == "query": + r = client.get(url, query_string={"v": s}) + elif source == "form": + r = client.get(url, data={"v": s}) + elif source == "json": + r = client.get(url, json={"v": s}) + elif source == "route": + r = client.get(f"{url}/{s}") + assert r is not None + assert "v" in r.json + assert r.json["v"] == s + + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_optional_str(client, source_a, source_b): + if source_a == source_b: # This shouldn't be something someone does, so we won't test for it + return + url = f"/ms_{source_a}_{source_b}/optional_str" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + s = "Testing MultiSource" + if source == "query": + r = client.get(url, query_string={"v": s}) + elif source == "form": + r = client.get(url, data={"v": s}) + elif source == "json": + r = client.get(url, json={"v": s}) + elif source == "route": + r = client.get(f"{url}/{s}") + assert r is not None + assert "v" in r.json + assert r.json["v"] == s + # Test that missing input yields error + r = client.get(url) + assert r.json["v"] is None + + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_time(client, source_a, source_b): + if source_a == source_b: # This shouldn't be something someone does, so we won't test for it + return + t = datetime.time(16, 43) + url = f"/ms_{source_a}_{source_b}/required_time" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + if source == "query": + r = client.get(url, query_string={"v": t.isoformat()}) + elif source == "form": + r = client.get(url, data={"v": t.isoformat()}) + elif source == "json": + r = client.get(url, json={"v": t.isoformat()}) + elif source == "route": + r = client.get(f"{url}/{t.isoformat()}") + assert r is not None + assert "v" in r.json + assert r.json["v"] == t.isoformat() + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_optional_datetime(client, source_a, source_b): + if source_a == source_b: # This shouldn't be something someone does, so we won't test for it + return + t = datetime.time(16, 44) + url = f"/ms_{source_a}_{source_b}/optional_time" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + if source == "query": + r = client.get(url, query_string={"v": t.isoformat()}) + elif source == "form": + r = client.get(url, data={"v": t.isoformat()}) + elif source == "json": + r = client.get(url, json={"v": t.isoformat()}) + elif source == "route": + r = client.get(f"{url}/{t.isoformat()}") + assert r is not None + assert "v" in r.json + assert r.json["v"] == t.isoformat() + # Test that missing input yields error + r = client.get(url) + assert r.json["v"] is None + + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_union(client, source_a, source_b): + if source_a == source_b: # This shouldn't be something someone does, so we won't test for it + return + url = f"/ms_{source_a}_{source_b}/required_union" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + i = 1 + if source == "query": + r = client.get(url, query_string={"v": i}) + elif source == "form": + r = client.get(url, data={"v": i}) + elif source == "json": + r = client.get(url, json={"v": i}) + elif source == "route": + r = client.get(f"{url}/{i}") + assert r is not None + assert "v" in r.json + assert r.json["v"] == i + s = "Testing MultiSource Union" + if source == "query": + r = client.get(url, query_string={"v": s}) + elif source == "form": + r = client.get(url, data={"v": s}) + elif source == "json": + r = client.get(url, json={"v": s}) + elif source == "route": + r = client.get(f"{url}/{s}") + assert r is not None + assert "v" in r.json + assert r.json["v"] == s + + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_optional_union(client, source_a, source_b): + if source_a == source_b: # This shouldn't be something someone does, so we won't test for it + return + url = f"/ms_{source_a}_{source_b}/optional_union" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + i = 1 + if source == "query": + r = client.get(url, query_string={"v": i}) + elif source == "form": + r = client.get(url, data={"v": i}) + elif source == "json": + r = client.get(url, json={"v": i}) + elif source == "route": + r = client.get(f"{url}/{i}") + assert r is not None + assert "v" in r.json + assert r.json["v"] == i + s = "Testing MultiSource Union" + if source == "query": + r = client.get(url, query_string={"v": s}) + elif source == "form": + r = client.get(url, data={"v": s}) + elif source == "json": + r = client.get(url, json={"v": s}) + elif source == "route": + r = client.get(f"{url}/{s}") + assert r is not None + assert "v" in r.json + assert r.json["v"] == s + # Test that missing input yields error + r = client.get(url) + assert r.json["v"] is None \ No newline at end of file diff --git a/flask_parameter_validation/test/testing_application.py b/flask_parameter_validation/test/testing_application.py index 29945c0..85fd02e 100644 --- a/flask_parameter_validation/test/testing_application.py +++ b/flask_parameter_validation/test/testing_application.py @@ -2,10 +2,17 @@ from flask import Flask, jsonify -from flask_parameter_validation import ValidateParameters, Query, Json, Form, Route +from flask_parameter_validation import Query, Json, Form, Route from flask_parameter_validation.test.testing_blueprints.file_blueprint import get_file_blueprint +from flask_parameter_validation.test.testing_blueprints.multi_source_blueprint import get_multi_source_blueprint from flask_parameter_validation.test.testing_blueprints.parameter_blueprint import get_parameter_blueprint +multi_source_sources = [ + {"class": Query, "name": "query"}, + {"class": Json, "name": "json"}, + {"class": Form, "name": "form"}, + {"class": Route, "name": "route"} +] def create_app(): app = Flask(__name__) @@ -15,4 +22,8 @@ def create_app(): app.register_blueprint(get_parameter_blueprint(Form, "form", "form", "post")) app.register_blueprint(get_parameter_blueprint(Route, "route", "route", "get")) app.register_blueprint(get_file_blueprint("file")) - return app \ No newline at end of file + for source_a in multi_source_sources: + for source_b in multi_source_sources: + combined_name = f"ms_{source_a['name']}_{source_b['name']}" + app.register_blueprint(get_multi_source_blueprint([source_a['class'], source_b['class']], combined_name)) + return app diff --git a/flask_parameter_validation/test/testing_blueprints/multi_source_blueprint.py b/flask_parameter_validation/test/testing_blueprints/multi_source_blueprint.py new file mode 100644 index 0000000..bd3148c --- /dev/null +++ b/flask_parameter_validation/test/testing_blueprints/multi_source_blueprint.py @@ -0,0 +1,148 @@ +import datetime +from typing import Optional, List, Union + +from flask import Blueprint, jsonify + +from flask_parameter_validation import ValidateParameters +from flask_parameter_validation.parameter_types.multi_source import MultiSource + + +def get_multi_source_blueprint(sources, name): + param_bp = Blueprint(name, __name__, url_prefix=f"/{name}") + + @param_bp.route("/required_bool", methods=["GET", "POST"]) + @param_bp.route("/required_bool/", methods=["GET", "POST"]) + @ValidateParameters() + def multi_source_bool(v: bool = MultiSource([sources[0](), sources[1]()])): + assert type(v) is bool + return jsonify({"v": v}) + + @param_bp.route("/optional_bool", methods=["GET", "POST"]) + @param_bp.route("/optional_bool/", methods=["GET", "POST"]) + @ValidateParameters() + def multi_source_optional_bool(v: Optional[bool] = MultiSource([sources[0](), sources[1]()])): + return jsonify({"v": v}) + + @param_bp.route("/required_date", methods=["GET", "POST"]) + @param_bp.route("/required_date/", methods=["GET", "POST"]) + @ValidateParameters() + def multi_source_date(v: datetime.date = MultiSource([sources[0](), sources[1]()])): + assert type(v) is datetime.date + return jsonify({"v": v.isoformat()}) + + @param_bp.route("/optional_date", methods=["GET", "POST"]) + @param_bp.route("/optional_date/", methods=["GET", "POST"]) + @ValidateParameters() + def multi_source_optional_date(v: Optional[datetime.date] = MultiSource([sources[0](), sources[1]()])): + return jsonify({"v": v.isoformat() if v else v}) + + @param_bp.route("/required_datetime", methods=["GET", "POST"]) + @param_bp.route("/required_datetime/", methods=["GET", "POST"]) + @ValidateParameters() + def multi_source_datetime(v: datetime.datetime = MultiSource([sources[0](), sources[1]()])): + assert type(v) is datetime.datetime + return jsonify({"v": v.isoformat()}) + + @param_bp.route("/optional_datetime", methods=["GET", "POST"]) + @param_bp.route("/optional_datetime/", methods=["GET", "POST"]) + @ValidateParameters() + def multi_source_optional_datetime(v: Optional[datetime.datetime] = MultiSource([sources[0](), sources[1]()])): + return jsonify({"v": v.isoformat() if v else v}) + + @param_bp.route("/required_dict", methods=["GET", "POST"]) + # Route doesn't support dict parameters + @ValidateParameters() + def multi_source_dict(v: dict = MultiSource([sources[0](), sources[1]()])): + assert type(v) is dict + return jsonify({"v": v}) + + @param_bp.route("/optional_dict", methods=["GET", "POST"]) + # Route doesn't support dict parameters + @ValidateParameters() + def multi_source_optional_dict(v: Optional[dict] = MultiSource([sources[0](), sources[1]()])): + return jsonify({"v": v}) + + @param_bp.route("/required_float", methods=["GET", "POST"]) + @param_bp.route("/required_float/", methods=["GET", "POST"]) + @ValidateParameters() + def multi_source_float(v: float = MultiSource([sources[0](), sources[1]()])): + assert type(v) is float + return jsonify({"v": v}) + + @param_bp.route("/optional_float", methods=["GET", "POST"]) + @param_bp.route("/optional_float/", methods=["GET", "POST"]) + @ValidateParameters() + def multi_source_optional_float(v: Optional[float] = MultiSource([sources[0](), sources[1]()])): + return jsonify({"v": v}) + + + @param_bp.route("/required_int", methods=["GET", "POST"]) + @param_bp.route("/required_int/", methods=["GET", "POST"]) + @ValidateParameters() + def multi_source_int(v: int = MultiSource([sources[0](), sources[1]()])): + assert type(v) is int + return jsonify({"v": v}) + + @param_bp.route("/optional_int", methods=["GET", "POST"]) + @param_bp.route("/optional_int/", methods=["GET", "POST"]) + @ValidateParameters() + def multi_source_optional_int(v: Optional[int] = MultiSource([sources[0](), sources[1]()])): + return jsonify({"v": v}) + + + # Only List[int] is tested here - the other existing tests for lists should be exhaustive enough to catch issues + @param_bp.route("/required_list", methods=["GET", "POST"]) + # Route doesn't support List parameters + @ValidateParameters() + def multi_source_list(v: List[int] = MultiSource([sources[0](), sources[1]()])): + assert type(v) is list + assert len(v) > 0 + assert type(v[0]) is int + return jsonify({"v": v}) + + @param_bp.route("/optional_list", methods=["GET", "POST"]) + # Route doesn't support List parameters + @ValidateParameters() + def multi_source_optional_list(v: Optional[List[int]] = MultiSource([sources[0](), sources[1]()])): + return jsonify({"v": v}) + + @param_bp.route("/required_str", methods=["GET", "POST"]) + @param_bp.route("/required_str/", methods=["GET", "POST"]) + @ValidateParameters() + def multi_source_str(v: str = MultiSource([sources[0](), sources[1]()])): + assert type(v) is str + return jsonify({"v": v}) + + @param_bp.route("/optional_str", methods=["GET", "POST"]) + @param_bp.route("/optional_str/", methods=["GET", "POST"]) + @ValidateParameters() + def multi_source_optional_str(v: Optional[str] = MultiSource([sources[0](), sources[1]()])): + return jsonify({"v": v}) + + @param_bp.route("/required_time", methods=["GET", "POST"]) + @param_bp.route("/required_time/", methods=["GET", "POST"]) + @ValidateParameters() + def multi_source_time(v: datetime.time = MultiSource([sources[0](), sources[1]()])): + assert type(v) is datetime.time + return jsonify({"v": v.isoformat()}) + + @param_bp.route("/optional_time", methods=["GET", "POST"]) + @param_bp.route("/optional_time/", methods=["GET", "POST"]) + @ValidateParameters() + def multi_source_optional_time(v: Optional[datetime.time] = MultiSource([sources[0](), sources[1]()])): + return jsonify({"v": v.isoformat() if v else v}) + + @param_bp.route("/required_union", methods=["GET", "POST"]) + @param_bp.route("/required_union/", methods=["GET", "POST"]) + @ValidateParameters() + def multi_source_union(v: Union[int, str] = MultiSource([sources[0](), sources[1]()])): + assert type(v) is int or type(v) is str + return jsonify({"v": v}) + + @param_bp.route("/optional_union", methods=["GET", "POST"]) + @param_bp.route("/optional_union/", methods=["GET", "POST"]) + @ValidateParameters() + def multi_source_optional_union(v: Optional[Union[int, str]] = MultiSource([sources[0](), sources[1]()])): + return jsonify({"v": v}) + + return param_bp \ No newline at end of file From 6577b2b532743cea42f5b12eb19f6562a41b32d1 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Mon, 1 Jul 2024 17:35:50 -0400 Subject: [PATCH 04/22] Update MultiSource to use *args for the expected delivery types, pass **kwargs to those delivery types --- README.md | 2 +- .../parameter_types/multi_source.py | 8 +-- .../test/test_multi_source_params.py | 40 ++++++++++++++- .../multi_source_blueprint.py | 50 ++++++++++--------- 4 files changed, 72 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 0254355..15f27d1 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Using the `MultiSource` parameter type, parameters can be accepted from any comb @app.route("/") # If accepting parameters by Route and another type, a path with and without that Route parameter must be specified @ValidateParameters() def multi_source_example( - value: int = MultiSource([Route(), Query(), Json()]) + value: int = MultiSource(Route, Query, Json, min_int=0) ) ``` diff --git a/flask_parameter_validation/parameter_types/multi_source.py b/flask_parameter_validation/parameter_types/multi_source.py index 6be4380..290ec48 100644 --- a/flask_parameter_validation/parameter_types/multi_source.py +++ b/flask_parameter_validation/parameter_types/multi_source.py @@ -1,9 +1,11 @@ +from typing import Type + from flask_parameter_validation.parameter_types.parameter import Parameter class MultiSource(Parameter): name = "multi_source" - def __init__(self, sources: list[Parameter], default=None, **kwargs): - self.sources = sources - super().__init__(default, **kwargs) + def __init__(self, *sources: list[Type[Parameter]], **kwargs): + self.sources = [Source(**kwargs) for Source in sources] + super().__init__(**kwargs) diff --git a/flask_parameter_validation/test/test_multi_source_params.py b/flask_parameter_validation/test/test_multi_source_params.py index 9d0b3ac..fddc12c 100644 --- a/flask_parameter_validation/test/test_multi_source_params.py +++ b/flask_parameter_validation/test/test_multi_source_params.py @@ -513,4 +513,42 @@ def test_multi_source_optional_union(client, source_a, source_b): assert r.json["v"] == s # Test that missing input yields error r = client.get(url) - assert r.json["v"] is None \ No newline at end of file + assert r.json["v"] is None + + +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_int(client, source_a, source_b): + if source_a == source_b: # This shouldn't be something someone does, so we won't test for it + return + url = f"/ms_{source_a}_{source_b}/kwargs" + for source in [source_a, source_b]: + # Test that present input matching validation yields input value + r = None + i = 3 + if source == "query": + r = client.get(url, query_string={"v": i}) + elif source == "form": + r = client.get(url, data={"v": i}) + elif source == "json": + r = client.get(url, json={"v": i}) + elif source == "route": + r = client.get(f"{url}/{i}") + assert r is not None + assert "v" in r.json + assert r.json["v"] == i + # Test that present input failing validation yields error + r = None + i = -1 + if source == "query": + r = client.get(url, query_string={"v": i}) + elif source == "form": + r = client.get(url, data={"v": i}) + elif source == "json": + r = client.get(url, json={"v": i}) + elif source == "route": + r = client.get(f"{url}/{i}") + assert r is not None + assert "error" in r.json + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json \ No newline at end of file diff --git a/flask_parameter_validation/test/testing_blueprints/multi_source_blueprint.py b/flask_parameter_validation/test/testing_blueprints/multi_source_blueprint.py index bd3148c..e9724e1 100644 --- a/flask_parameter_validation/test/testing_blueprints/multi_source_blueprint.py +++ b/flask_parameter_validation/test/testing_blueprints/multi_source_blueprint.py @@ -13,88 +13,86 @@ def get_multi_source_blueprint(sources, name): @param_bp.route("/required_bool", methods=["GET", "POST"]) @param_bp.route("/required_bool/", methods=["GET", "POST"]) @ValidateParameters() - def multi_source_bool(v: bool = MultiSource([sources[0](), sources[1]()])): + def multi_source_bool(v: bool = MultiSource(sources[0], sources[1])): assert type(v) is bool return jsonify({"v": v}) @param_bp.route("/optional_bool", methods=["GET", "POST"]) @param_bp.route("/optional_bool/", methods=["GET", "POST"]) @ValidateParameters() - def multi_source_optional_bool(v: Optional[bool] = MultiSource([sources[0](), sources[1]()])): + def multi_source_optional_bool(v: Optional[bool] = MultiSource(sources[0], sources[1])): return jsonify({"v": v}) @param_bp.route("/required_date", methods=["GET", "POST"]) @param_bp.route("/required_date/", methods=["GET", "POST"]) @ValidateParameters() - def multi_source_date(v: datetime.date = MultiSource([sources[0](), sources[1]()])): + def multi_source_date(v: datetime.date = MultiSource(sources[0], sources[1])): assert type(v) is datetime.date return jsonify({"v": v.isoformat()}) @param_bp.route("/optional_date", methods=["GET", "POST"]) @param_bp.route("/optional_date/", methods=["GET", "POST"]) @ValidateParameters() - def multi_source_optional_date(v: Optional[datetime.date] = MultiSource([sources[0](), sources[1]()])): + def multi_source_optional_date(v: Optional[datetime.date] = MultiSource(sources[0], sources[1])): return jsonify({"v": v.isoformat() if v else v}) @param_bp.route("/required_datetime", methods=["GET", "POST"]) @param_bp.route("/required_datetime/", methods=["GET", "POST"]) @ValidateParameters() - def multi_source_datetime(v: datetime.datetime = MultiSource([sources[0](), sources[1]()])): + def multi_source_datetime(v: datetime.datetime = MultiSource(sources[0], sources[1])): assert type(v) is datetime.datetime return jsonify({"v": v.isoformat()}) @param_bp.route("/optional_datetime", methods=["GET", "POST"]) @param_bp.route("/optional_datetime/", methods=["GET", "POST"]) @ValidateParameters() - def multi_source_optional_datetime(v: Optional[datetime.datetime] = MultiSource([sources[0](), sources[1]()])): + def multi_source_optional_datetime(v: Optional[datetime.datetime] = MultiSource(sources[0], sources[1])): return jsonify({"v": v.isoformat() if v else v}) @param_bp.route("/required_dict", methods=["GET", "POST"]) # Route doesn't support dict parameters @ValidateParameters() - def multi_source_dict(v: dict = MultiSource([sources[0](), sources[1]()])): + def multi_source_dict(v: dict = MultiSource(sources[0], sources[1])): assert type(v) is dict return jsonify({"v": v}) @param_bp.route("/optional_dict", methods=["GET", "POST"]) # Route doesn't support dict parameters @ValidateParameters() - def multi_source_optional_dict(v: Optional[dict] = MultiSource([sources[0](), sources[1]()])): + def multi_source_optional_dict(v: Optional[dict] = MultiSource(sources[0], sources[1])): return jsonify({"v": v}) @param_bp.route("/required_float", methods=["GET", "POST"]) @param_bp.route("/required_float/", methods=["GET", "POST"]) @ValidateParameters() - def multi_source_float(v: float = MultiSource([sources[0](), sources[1]()])): + def multi_source_float(v: float = MultiSource(sources[0], sources[1])): assert type(v) is float return jsonify({"v": v}) @param_bp.route("/optional_float", methods=["GET", "POST"]) @param_bp.route("/optional_float/", methods=["GET", "POST"]) @ValidateParameters() - def multi_source_optional_float(v: Optional[float] = MultiSource([sources[0](), sources[1]()])): + def multi_source_optional_float(v: Optional[float] = MultiSource(sources[0], sources[1])): return jsonify({"v": v}) - @param_bp.route("/required_int", methods=["GET", "POST"]) @param_bp.route("/required_int/", methods=["GET", "POST"]) @ValidateParameters() - def multi_source_int(v: int = MultiSource([sources[0](), sources[1]()])): + def multi_source_int(v: int = MultiSource(sources[0], sources[1])): assert type(v) is int return jsonify({"v": v}) @param_bp.route("/optional_int", methods=["GET", "POST"]) @param_bp.route("/optional_int/", methods=["GET", "POST"]) @ValidateParameters() - def multi_source_optional_int(v: Optional[int] = MultiSource([sources[0](), sources[1]()])): + def multi_source_optional_int(v: Optional[int] = MultiSource(sources[0], sources[1])): return jsonify({"v": v}) - # Only List[int] is tested here - the other existing tests for lists should be exhaustive enough to catch issues @param_bp.route("/required_list", methods=["GET", "POST"]) # Route doesn't support List parameters @ValidateParameters() - def multi_source_list(v: List[int] = MultiSource([sources[0](), sources[1]()])): + def multi_source_list(v: List[int] = MultiSource(sources[0], sources[1])): assert type(v) is list assert len(v) > 0 assert type(v[0]) is int @@ -103,46 +101,52 @@ def multi_source_list(v: List[int] = MultiSource([sources[0](), sources[1]()])): @param_bp.route("/optional_list", methods=["GET", "POST"]) # Route doesn't support List parameters @ValidateParameters() - def multi_source_optional_list(v: Optional[List[int]] = MultiSource([sources[0](), sources[1]()])): + def multi_source_optional_list(v: Optional[List[int]] = MultiSource(sources[0], sources[1])): return jsonify({"v": v}) @param_bp.route("/required_str", methods=["GET", "POST"]) @param_bp.route("/required_str/", methods=["GET", "POST"]) @ValidateParameters() - def multi_source_str(v: str = MultiSource([sources[0](), sources[1]()])): + def multi_source_str(v: str = MultiSource(sources[0], sources[1])): assert type(v) is str return jsonify({"v": v}) @param_bp.route("/optional_str", methods=["GET", "POST"]) @param_bp.route("/optional_str/", methods=["GET", "POST"]) @ValidateParameters() - def multi_source_optional_str(v: Optional[str] = MultiSource([sources[0](), sources[1]()])): + def multi_source_optional_str(v: Optional[str] = MultiSource(sources[0], sources[1])): return jsonify({"v": v}) @param_bp.route("/required_time", methods=["GET", "POST"]) @param_bp.route("/required_time/", methods=["GET", "POST"]) @ValidateParameters() - def multi_source_time(v: datetime.time = MultiSource([sources[0](), sources[1]()])): + def multi_source_time(v: datetime.time = MultiSource(sources[0], sources[1])): assert type(v) is datetime.time return jsonify({"v": v.isoformat()}) @param_bp.route("/optional_time", methods=["GET", "POST"]) @param_bp.route("/optional_time/", methods=["GET", "POST"]) @ValidateParameters() - def multi_source_optional_time(v: Optional[datetime.time] = MultiSource([sources[0](), sources[1]()])): + def multi_source_optional_time(v: Optional[datetime.time] = MultiSource(sources[0], sources[1])): return jsonify({"v": v.isoformat() if v else v}) @param_bp.route("/required_union", methods=["GET", "POST"]) @param_bp.route("/required_union/", methods=["GET", "POST"]) @ValidateParameters() - def multi_source_union(v: Union[int, str] = MultiSource([sources[0](), sources[1]()])): + def multi_source_union(v: Union[int, str] = MultiSource(sources[0], sources[1])): assert type(v) is int or type(v) is str return jsonify({"v": v}) @param_bp.route("/optional_union", methods=["GET", "POST"]) @param_bp.route("/optional_union/", methods=["GET", "POST"]) @ValidateParameters() - def multi_source_optional_union(v: Optional[Union[int, str]] = MultiSource([sources[0](), sources[1]()])): + def multi_source_optional_union(v: Optional[Union[int, str]] = MultiSource(sources[0], sources[1])): + return jsonify({"v": v}) + + @param_bp.route("/kwargs", methods=["GET", "POST"]) + @param_bp.route("/kwargs/", methods=["GET", "POST"]) + @ValidateParameters() + def multi_source_kwargs(v: int = MultiSource(sources[0], sources[1], min_int=0)): return jsonify({"v": v}) - return param_bp \ No newline at end of file + return param_bp From c664ae489f20d9f07e98e742cfafdd0155dfdd1a Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Mon, 1 Jul 2024 17:36:27 -0400 Subject: [PATCH 05/22] Update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 15f27d1..a782b74 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ def multi_source_example( ) ``` -The above example will accept parameters passed to the route through Route, Query, and JSON Body. Validation options must be specified on each constructor in order to be processed. +The above example will accept parameters passed to the route through Route, Query, and JSON Body. #### Type Hints and Accepted Input Types Type Hints allow for inline specification of the input type of a parameter. Some types are only available to certain `Parameter` subclasses. From cae2a9a093c8070e9f2bd1dcb4b160c7f55e7ae0 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Mon, 1 Jul 2024 17:37:37 -0400 Subject: [PATCH 06/22] Update README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a782b74..f43da7a 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Type Hints allow for inline specification of the input type of a parameter. Some | `datetime.datetime` | Received as a `str` in ISO-8601 date-time format | Y | Y | Y | Y | N | | `datetime.date` | Received as a `str` in ISO-8601 full-date format | Y | Y | Y | Y | N | | `datetime.time` | Received as a `str` in ISO-8601 partial-time format | Y | Y | Y | Y | N | -| `dict` | For `Query` and `Form` inputs, users should pass the stringified JSON | N | N | Y | N | N | +| `dict` | For `Query` and `Form` inputs, users should pass the stringified JSON | N | Y | Y | Y | N | | `FileStorage` | | N | N | N | N | Y | These can be used in tandem to describe a parameter to validate: `parameter_name: type_hint = ParameterSubclass()` @@ -117,7 +117,7 @@ These can be used in tandem to describe a parameter to validate: `parameter_name - `ParameterSubclass`: An instance of a subclass of `Parameter` ### Validation with arguments to Parameter -Validation beyond type-checking can be done by passing arguments into the constructor of the `Parameter` subclass (with the exception of `MultiSource`). The arguments available for use on each type hint are: +Validation beyond type-checking can be done by passing arguments into the constructor of the `Parameter` subclass. The arguments available for use on each type hint are: | Parameter Name | Type of Parameter | Effective On Types | Description | |-------------------|---------------------------------------------|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------| From f4b3321b845bd7648b059301dd9c5cb18bfdf9c0 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Mon, 1 Jul 2024 18:03:52 -0400 Subject: [PATCH 07/22] Remove print statements --- flask_parameter_validation/test/conftest.py | 1 - flask_parameter_validation/test/test_form_params.py | 1 - 2 files changed, 2 deletions(-) diff --git a/flask_parameter_validation/test/conftest.py b/flask_parameter_validation/test/conftest.py index 42b3fd0..9fbaf6e 100644 --- a/flask_parameter_validation/test/conftest.py +++ b/flask_parameter_validation/test/conftest.py @@ -11,7 +11,6 @@ def app(): @pytest.fixture() def client(app): - print(app.url_map) return app.test_client() diff --git a/flask_parameter_validation/test/test_form_params.py b/flask_parameter_validation/test/test_form_params.py index eda4449..cf3919b 100644 --- a/flask_parameter_validation/test/test_form_params.py +++ b/flask_parameter_validation/test/test_form_params.py @@ -913,7 +913,6 @@ def test_required_str_enum(client): # Test that present str_enum input yields input value r = client.post(url, data={"v": Fruits.APPLE.value}) assert "v" in r.json - print(r.json["v"]) assert r.json["v"] == Fruits.APPLE.value # Test that missing input yields error r = client.post(url) From 81e2858df4e65e070ae7cea054681f2e014383eb Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Mon, 1 Jul 2024 19:22:00 -0400 Subject: [PATCH 08/22] Implement support for non-typing-package list type hints --- README.md | 36 ++++++++-------- .../parameter_validation.py | 14 +++--- .../test/test_form_params.py | 42 ++++++++++++++++++ .../test/test_json_params.py | 31 ++++++++++++- .../test/test_query_params.py | 43 +++++++++++++++++++ .../test/testing_blueprints/list_blueprint.py | 12 ++++++ 6 files changed, 152 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 142d4fe..c90f37e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ ## Usage Example ```py from flask import Flask -from typing import List, Optional +from typing import Optional from flask_parameter_validation import ValidateParameters, Route, Json, Query from datetime import datetime @@ -22,7 +22,7 @@ def hello( id: int = Route(), username: str = Json(min_str_length=5, blacklist="<>"), age: int = Json(min_int=18, max_int=99), - nicknames: List[str] = Json(), + nicknames: list[str] = Json(), date_of_birth: datetime = Json(), password_expiry: Optional[int] = Json(5), is_admin: bool = Query(False), @@ -81,20 +81,20 @@ The `Parameter` class provides a base for validation common among all input type #### Type Hints and Accepted Input Types Type Hints allow for inline specification of the input type of a parameter. Some types are only available to certain `Parameter` subclasses. -| Type Hint / Expected Python Type | Notes | `Route` | `Form` | `Json` | `Query` | `File` | -|------------------------------------|--------------------------------------------------------------------------------------------------------------------------------|---------|--------|--------|---------|--------| -| `str` | | Y | Y | Y | Y | N | -| `int` | | Y | Y | Y | Y | N | -| `bool` | | Y | Y | Y | Y | N | -| `float` | | Y | Y | Y | Y | N | -| `typing.List` (must not be `list`) | For `Query` inputs, users can pass via either `value=1&value=2&value=3`, or `value=1,2,3`, both will be transformed to a `list`. | N | Y | Y | Y | N | -| `typing.Union` | | Y | Y | Y | Y | N | -| `typing.Optional` | | Y | Y | Y | Y | Y | -| `datetime.datetime` | received as a `str` in ISO-8601 date-time format | Y | Y | Y | Y | N | -| `datetime.date` | received as a `str` in ISO-8601 full-date format | Y | Y | Y | Y | N | -| `datetime.time` | received as a `str` in ISO-8601 partial-time format | Y | Y | Y | Y | N | -| `dict` | | N | N | Y | N | N | -| `FileStorage` | | N | N | N | N | Y | +| Type Hint / Expected Python Type | Notes | `Route` | `Form` | `Json` | `Query` | `File` | +|-----------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------|---------|--------|--------|---------|--------| +| `str` | | Y | Y | Y | Y | N | +| `int` | | Y | Y | Y | Y | N | +| `bool` | | Y | Y | Y | Y | N | +| `float` | | Y | Y | Y | Y | N | +| `list`/`typing.List` (`typing.List` is [deprecated](https://docs.python.org/3/library/typing.html#typing.List)) | For `Query` inputs, users can pass via either `value=1&value=2&value=3`, or `value=1,2,3`, both will be transformed to a `list`. | N | Y | Y | Y | N | +| `typing.Union` | | Y | Y | Y | Y | N | +| `typing.Optional` | | Y | Y | Y | Y | Y | +| `datetime.datetime` | received as a `str` in ISO-8601 date-time format | Y | Y | Y | Y | N | +| `datetime.date` | received as a `str` in ISO-8601 full-date format | Y | Y | Y | Y | N | +| `datetime.time` | received as a `str` in ISO-8601 partial-time format | Y | Y | Y | Y | N | +| `dict` | | N | N | Y | N | N | +| `FileStorage` | | N | N | N | N | Y | These can be used in tandem to describe a parameter to validate: `parameter_name: type_hint = ParameterSubclass()` - `parameter_name`: The field name itself, such as username @@ -109,8 +109,8 @@ Validation beyond type-checking can be done by passing arguments into the constr | `default` | any | All | Specifies the default value for the field, makes non-Optional fields not required | | `min_str_length` | `int` | `str` | Specifies the minimum character length for a string input | | `max_str_length` | `int` | `str` | Specifies the maximum character length for a string input | -| `min_list_length` | `int` | `typing.List` | Specifies the minimum number of elements in a list | -| `max_list_length` | `int` | `typing.List` | Specifies the maximum number of elements in a list | +| `min_list_length` | `int` | `list` | Specifies the minimum number of elements in a list | +| `max_list_length` | `int` | `list` | Specifies the maximum number of elements in a list | | `min_int` | `int` | `int` | Specifies the minimum number for an integer input | | `max_int` | `int` | `int` | Specifies the maximum number for an integer input | | `whitelist` | `str` | `str` | A string containing allowed characters for the value | diff --git a/flask_parameter_validation/parameter_validation.py b/flask_parameter_validation/parameter_validation.py index 20f72c7..a40c109 100644 --- a/flask_parameter_validation/parameter_validation.py +++ b/flask_parameter_validation/parameter_validation.py @@ -12,6 +12,7 @@ fn_list = dict() +list_type_hints = ["typing.List", "typing.Optional[typing.List", "list", "typing.Optional[list"] class ValidateParameters: @classmethod @@ -64,8 +65,7 @@ def nested_func_helper(**kwargs): # Step 3 - Extract list of parameters expected to be lists (otherwise all values are converted to lists) expected_list_params = [] for name, param in expected_inputs.items(): - if str(param.annotation).startswith("typing.List") or str(param.annotation).startswith( - "typing.Optional[typing.List"): + if True in [str(param.annotation).startswith(list_hint) for list_hint in list_type_hints]: expected_list_params.append(param.default.alias or name) # Step 4 - Convert request inputs to dicts @@ -194,7 +194,7 @@ def validate(self, expected_input, all_request_inputs): user_inputs = [user_input] # If typing.List in union and user supplied valid list, convert remaining check only for list for exp_type in expected_input_types: - if str(exp_type).startswith("typing.List"): + if True in [str(exp_type).startswith(list_hint) for list_hint in list_type_hints]: if type(user_input) is list: # Only convert if validation passes if hasattr(exp_type, "__args__"): @@ -204,7 +204,7 @@ def validate(self, expected_input, all_request_inputs): expected_input_type_str = str(exp_type) user_inputs = user_input # If list, expand inner typing items. Otherwise, convert to list to match anyway. - elif expected_input_type_str.startswith("typing.List"): + elif True in [expected_input_type_str.startswith(list_hint) for list_hint in list_type_hints]: expected_input_types = expected_input_type.__args__ if type(user_input) is list: user_inputs = user_input @@ -229,7 +229,7 @@ def validate(self, expected_input, all_request_inputs): ) # Validate that if lists are required, lists are given - if expected_input_type_str.startswith("typing.List"): + if True in [expected_input_type_str.startswith(list_hint) for list_hint in list_type_hints]: if type(user_input) is not list: validation_success = False @@ -237,7 +237,7 @@ def validate(self, expected_input, all_request_inputs): if not validation_success: if hasattr( original_expected_input_type, "__name__" - ) and not original_expected_input_type_str.startswith("typing."): + ) and not (original_expected_input_type_str.startswith("typing.") or original_expected_input_type_str.startswith("list")): type_name = original_expected_input_type.__name__ else: type_name = original_expected_input_type_str @@ -257,6 +257,6 @@ def validate(self, expected_input, all_request_inputs): raise ValidationError(str(e), expected_name, expected_input_type) # Return input back to parent function - if expected_input_type_str.startswith("typing.List"): + if True in [expected_input_type_str.startswith(list_hint) for list_hint in list_type_hints]: return user_inputs return user_inputs[0] diff --git a/flask_parameter_validation/test/test_form_params.py b/flask_parameter_validation/test/test_form_params.py index 3683bf9..467bcdf 100644 --- a/flask_parameter_validation/test/test_form_params.py +++ b/flask_parameter_validation/test/test_form_params.py @@ -903,3 +903,45 @@ def test_max_list_length(client): # Test that above length yields error r = client.post(url, data={"v": ["the", "longest", "of", "lists"]}) assert "error" in r.json + + +def test_non_typing_list_str(client): + url = "/form/list/non_typing" + # Test that present single str input yields [input value] + r = client.post(url, data={"v": "w"}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is str + assert r.json["v"][0] == "w" + # Test that present CSV str input yields [input values] + v = ["x", "y"] + r = client.post(url, data={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that missing input yields error + r = client.post(url) + assert "error" in r.json + +def test_non_typing_optional_list_str(client): + url = "/form/list/optional_non_typing" + # Test that missing input yields None + r = client.post(url) + assert "v" in r.json + assert r.json["v"] is None + # Test that present str input yields [input value] + r = client.post(url, data={"v": "test"}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is str + assert r.json["v"][0] == "test" + # Test that present CSV str input yields [input values] + v = ["two", "tests"] + r = client.post(url, data={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) diff --git a/flask_parameter_validation/test/test_json_params.py b/flask_parameter_validation/test/test_json_params.py index 14231b5..1c8ca16 100644 --- a/flask_parameter_validation/test/test_json_params.py +++ b/flask_parameter_validation/test/test_json_params.py @@ -1034,4 +1034,33 @@ def test_dict_json_schema(client): "last_name": "Doe" } r = client.post(url, json={"v": v}) - assert "error" in r.json \ No newline at end of file + assert "error" in r.json + + +def test_non_typing_list_str(client): + url = "/json/list/non_typing" + # Test that present list[str] input yields [input values] + v = ["x", "y"] + r = client.post(url, json={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that missing input yields error + r = client.post(url) + assert "error" in r.json + + +def test_non_typing_optional_list_str(client): + url = "/json/list/optional_non_typing" + # Test that missing input yields None + r = client.post(url) + assert "v" in r.json + assert r.json["v"] is None + # Test that present list[str] input yields [input values] + v = ["two", "tests"] + r = client.post(url, json={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) diff --git a/flask_parameter_validation/test/test_query_params.py b/flask_parameter_validation/test/test_query_params.py index e7ea85c..b03e8dc 100644 --- a/flask_parameter_validation/test/test_query_params.py +++ b/flask_parameter_validation/test/test_query_params.py @@ -1596,3 +1596,46 @@ def test_max_list_length(client): # Test that above length yields error r = client.get(url, query_string={"v": ["the", "longest", "of", "lists"]}) assert "error" in r.json + + +def test_non_typing_list_str(client): + url = "/query/list/non_typing" + # Test that present single str input yields [input value] + r = client.get(url, query_string={"v": "w"}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is str + assert r.json["v"][0] == "w" + # Test that present CSV str input yields [input values] + v = ["x", "y"] + r = client.get(url, query_string={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + + +def test_non_typing_optional_list_str(client): + url = "/query/list/optional_non_typing" + # Test that missing input yields None + r = client.get(url) + assert "v" in r.json + assert r.json["v"] is None + # Test that present str input yields [input value] + r = client.get(url, query_string={"v": "test"}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is str + assert r.json["v"][0] == "test" + # Test that present CSV str input yields [input values] + v = ["two", "tests"] + r = client.get(url, query_string={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) \ No newline at end of file diff --git a/flask_parameter_validation/test/testing_blueprints/list_blueprint.py b/flask_parameter_validation/test/testing_blueprints/list_blueprint.py index d72a7c7..7dff64c 100644 --- a/flask_parameter_validation/test/testing_blueprints/list_blueprint.py +++ b/flask_parameter_validation/test/testing_blueprints/list_blueprint.py @@ -167,4 +167,16 @@ def max_list_length(v: List[str] = ParamType(max_list_length=3)): def json_schema(v: list = ParamType(json_schema=json_schema)): return jsonify({"v": v}) + @decorator("/non_typing") + @ValidateParameters() + def non_typing(v: list[str] = ParamType()): + assert type(v) is list + assert type(v[0]) is str + return jsonify({"v": v}) + + @decorator("/optional_non_typing") + @ValidateParameters() + def optional_non_typing(v: Optional[list[str]] = ParamType()): + return jsonify({"v": v}) + return list_bp From e6b05aa86a523d670c0699625970b516cab984e0 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Mon, 1 Jul 2024 19:38:48 -0400 Subject: [PATCH 09/22] Add testing for Python 3.9-3.12 --- .github/workflows/python-test.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 150282d..f895c3d 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -17,12 +17,16 @@ jobs: runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + steps: - - uses: actions/checkout@v3 - - name: Set up Python 3 - uses: actions/setup-python@v3 + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 with: - python-version: "3.x" + python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip From 72170700a34340a43ba095421e5591e9e64b5296 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Mon, 1 Jul 2024 20:17:06 -0400 Subject: [PATCH 10/22] Add python_requires and more classifiers to setup.py --- setup.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/setup.py b/setup.py index 6d77136..ed7ad35 100644 --- a/setup.py +++ b/setup.py @@ -32,12 +32,22 @@ 'python-dateutil', 'jsonschema', ], + python_requires=">=3.9,<=3.12", classifiers=[ 'Environment :: Web Environment', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Development Status :: 5 - Production/Stable', + 'Framework :: Flask', + 'Topic :: Software Development :: Documentation', + 'Topic :: File Formats :: JSON :: JSON Schema', 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 'Topic :: Software Development :: Libraries :: Python Modules' ] From 2f3bc8bb9598c12ccefe4b41d9389f5fc730c670 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Mon, 1 Jul 2024 20:20:57 -0400 Subject: [PATCH 11/22] Correct python_requires in setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ed7ad35..a47e4ab 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ 'python-dateutil', 'jsonschema', ], - python_requires=">=3.9,<=3.12", + python_requires=">=3.9,<=3.12.*", classifiers=[ 'Environment :: Web Environment', 'Intended Audience :: Developers', From 4e2dfbb059898403546d9485a121c15f4a8d94fe Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Mon, 1 Jul 2024 21:00:03 -0400 Subject: [PATCH 12/22] Actually correct python_requires in setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a47e4ab..6d2041f 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ 'python-dateutil', 'jsonschema', ], - python_requires=">=3.9,<=3.12.*", + python_requires=">=3.9,<3.13", classifiers=[ 'Environment :: Web Environment', 'Intended Audience :: Developers', From 1ca4f2af197e5fd2a5eda148d499a767943c3577 Mon Sep 17 00:00:00 2001 From: George O <16269580+Ge0rg3@users.noreply.github.com> Date: Thu, 11 Jul 2024 17:04:19 +0100 Subject: [PATCH 13/22] Minor README updates --- README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 142d4fe..8e346f6 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ if __name__ == "__main__": ## Usage To validate parameters with flask-parameter-validation, two conditions must be met. 1. The `@ValidateParameters()` decorator must be applied to the function -2. Type hints ([supported types](#type-hints-and-accepted-input-types)) and a default of a subclass of `Parameter` for the parameters you want to use flask-parameter-validation on +2. Type hints ([supported types](#type-hints-and-accepted-input-types)) and a default of a subclass of `Parameter` must be supplied per parameter flask-parameter-validation parameter ### Enable and customize Validation for a Route with the @ValidateParameters decorator @@ -49,7 +49,14 @@ The `@ValidateParameters()` decorator takes parameters that alter route validati | error_handler | `Optional[Response]` | `None` | Overwrite the output format of generated errors, see [Overwriting Default Errors](#overwriting-default-errors) for more | #### Overwriting Default Errors -By default, the error messages are returned as a JSON response, with the detailed error in the "error" field. However, this can be edited by passing a custom error function into the `ValidateParameters()` decorator. For example: +By default, the error messages are returned as a JSON response, with the detailed error in the "error" field, eg: +```json +{ + "error": "Parameter 'age' must be type 'int'" +} +``` + +However, this can be edited by passing a custom error function into the `ValidateParameters()` decorator. For example: ```py def error_handler(err): error_name = type(err) @@ -74,10 +81,12 @@ The `Parameter` class provides a base for validation common among all input type |---------------|------------------------------------------------------------------------------------------------------------------------|------------------| | Route | Parameter passed in the pathname of the URL, such as `/users/` | All HTTP Methods | | Form | Parameter in an HTML form or a `FormData` object in the request body, often with `Content-Type: x-www-form-urlencoded` | POST Methods | -| Json | Parameter in the JSON object in the request body, must have header `Content-Type: application/json` | POST Method | +| Json | Parameter in the JSON object in the request body, must have header `Content-Type: application/json` | POST Methods | | Query | Parameter in the query of the URL, such as /news_article?id=55 | All HTTP Methods | | File | Parameter is a file uploaded in the request body | POST Method | +Note: "**POST Methods**" refers to the HTTP methods that send data in the request body, such as POST, PUT, PATCH and DELETE. Although sending data via some methods such as DELETE is not standard, it is supported by Flask and this library. + #### Type Hints and Accepted Input Types Type Hints allow for inline specification of the input type of a parameter. Some types are only available to certain `Parameter` subclasses. From c155593bfe52d90f346f1543a50068d7b498cde2 Mon Sep 17 00:00:00 2001 From: George O <16269580+Ge0rg3@users.noreply.github.com> Date: Thu, 11 Jul 2024 17:08:51 +0100 Subject: [PATCH 14/22] Remove final PR template line --- .github/pull_request_template.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6d073a1..be7e859 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -25,5 +25,3 @@ - [ ] Has the README been updated to reflect the changes introduced (if applicable)? ### ๐Ÿ’ฌ Additional comments - -#### Feel free to add any further information below \ No newline at end of file From 2fc55240b3d31c534d7b8f3a1295cbd767e23b03 Mon Sep 17 00:00:00 2001 From: George O <16269580+Ge0rg3@users.noreply.github.com> Date: Thu, 11 Jul 2024 17:42:48 +0100 Subject: [PATCH 15/22] Fix merge conflicts in readme --- README.md | 28 +++++++++++++------ .../parameter_types/parameter.py | 3 +- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f43da7a..8963494 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ if __name__ == "__main__": ## Usage To validate parameters with flask-parameter-validation, two conditions must be met. 1. The `@ValidateParameters()` decorator must be applied to the function -2. Type hints ([supported types](#type-hints-and-accepted-input-types)) and a default of a subclass of `Parameter` for the parameters you want to use flask-parameter-validation on +2. Type hints ([supported types](#type-hints-and-accepted-input-types)) and a default of a subclass of `Parameter` must be supplied per parameter flask-parameter-validation parameter ### Enable and customize Validation for a Route with the @ValidateParameters decorator @@ -49,7 +49,14 @@ The `@ValidateParameters()` decorator takes parameters that alter route validati | error_handler | `Optional[Response]` | `None` | Overwrite the output format of generated errors, see [Overwriting Default Errors](#overwriting-default-errors) for more | #### Overwriting Default Errors -By default, the error messages are returned as a JSON response, with the detailed error in the "error" field. However, this can be edited by passing a custom error function into the `ValidateParameters()` decorator. For example: +By default, the error messages are returned as a JSON response, with the detailed error in the "error" field, eg: +```json +{ + "error": "Parameter 'age' must be type 'int'" +} +``` + +However, this can be edited by passing a custom error function into the `ValidateParameters()` decorator. For example: ```py def error_handler(err): error_name = type(err) @@ -70,15 +77,17 @@ def api(...) #### Parameter Class The `Parameter` class provides a base for validation common among all input types, all location-specific classes extend `Parameter`. These subclasses are: -| Subclass Name | Input Source | Available For | -|---------------|------------------------------------------------------------------------------------------------------------------------|---------------------------------| -| Route | Parameter passed in the pathname of the URL, such as `/users/` | All HTTP Methods | -| Form | Parameter in an HTML form or a `FormData` object in the request body, often with `Content-Type: x-www-form-urlencoded` | POST Methods | -| Json | Parameter in the JSON object in the request body, must have header `Content-Type: application/json` | POST Method | -| Query | Parameter in the query of the URL, such as /news_article?id=55 | All HTTP Methods | -| File | Parameter is a file uploaded in the request body | POST Method | +| Subclass Name | Input Source | Available For | +|---------------|------------------------------------------------------------------------------------------------------------------------|------------------| +| Route | Parameter passed in the pathname of the URL, such as `/users/` | All HTTP Methods | +| Form | Parameter in an HTML form or a `FormData` object in the request body, often with `Content-Type: x-www-form-urlencoded` | POST Methods | +| Json | Parameter in the JSON object in the request body, must have header `Content-Type: application/json` | POST Methods | +| Query | Parameter in the query of the URL, such as /news_article?id=55 | All HTTP Methods | +| File | Parameter is a file uploaded in the request body | POST Method | | MultiSource | Parameter is in one of the locations provided to the constructor | Dependent on selected locations | +Note: "**POST Methods**" refers to the HTTP methods that send data in the request body, such as POST, PUT, PATCH and DELETE. Although sending data via some methods such as DELETE is not standard, it is supported by Flask and this library. + ##### MultiSource Parameters Using the `MultiSource` parameter type, parameters can be accepted from any combination of `Parameter` subclasses. Example usage is as follows: @@ -93,6 +102,7 @@ def multi_source_example( The above example will accept parameters passed to the route through Route, Query, and JSON Body. + #### Type Hints and Accepted Input Types Type Hints allow for inline specification of the input type of a parameter. Some types are only available to certain `Parameter` subclasses. diff --git a/flask_parameter_validation/parameter_types/parameter.py b/flask_parameter_validation/parameter_types/parameter.py index 063dac9..cc43d75 100644 --- a/flask_parameter_validation/parameter_types/parameter.py +++ b/flask_parameter_validation/parameter_types/parameter.py @@ -4,6 +4,7 @@ """ import re from datetime import date, datetime, time + import dateutil.parser as parser import jsonschema from jsonschema.exceptions import ValidationError as JSONSchemaValidationError @@ -150,8 +151,6 @@ def validate(self, value): if self.func is not None and not original_value_type_list: self.func_helper(value) - - return True def convert(self, value, allowed_types): From 32395f0b1d8f5424ef9ee24a5554b73a6ad5efec Mon Sep 17 00:00:00 2001 From: George O <16269580+Ge0rg3@users.noreply.github.com> Date: Thu, 11 Jul 2024 18:00:02 +0100 Subject: [PATCH 16/22] Remove README merge conflicts --- README.md | 63 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index c90f37e..deaa236 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ if __name__ == "__main__": ## Usage To validate parameters with flask-parameter-validation, two conditions must be met. 1. The `@ValidateParameters()` decorator must be applied to the function -2. Type hints ([supported types](#type-hints-and-accepted-input-types)) and a default of a subclass of `Parameter` for the parameters you want to use flask-parameter-validation on +2. Type hints ([supported types](#type-hints-and-accepted-input-types)) and a default of a subclass of `Parameter` must be supplied per parameter flask-parameter-validation parameter ### Enable and customize Validation for a Route with the @ValidateParameters decorator @@ -49,7 +49,14 @@ The `@ValidateParameters()` decorator takes parameters that alter route validati | error_handler | `Optional[Response]` | `None` | Overwrite the output format of generated errors, see [Overwriting Default Errors](#overwriting-default-errors) for more | #### Overwriting Default Errors -By default, the error messages are returned as a JSON response, with the detailed error in the "error" field. However, this can be edited by passing a custom error function into the `ValidateParameters()` decorator. For example: +By default, the error messages are returned as a JSON response, with the detailed error in the "error" field, eg: +```json +{ + "error": "Parameter 'age' must be type 'int'" +} +``` + +However, this can be edited by passing a custom error function into the `ValidateParameters()` decorator. For example: ```py def error_handler(err): error_name = type(err) @@ -61,8 +68,8 @@ def error_handler(err): "error_message": str(err) }, 400 -@ValidateParameters(error_handler) @app.route(...) +@ValidateParameters(error_handler) def api(...) ``` @@ -74,27 +81,47 @@ The `Parameter` class provides a base for validation common among all input type |---------------|------------------------------------------------------------------------------------------------------------------------|------------------| | Route | Parameter passed in the pathname of the URL, such as `/users/` | All HTTP Methods | | Form | Parameter in an HTML form or a `FormData` object in the request body, often with `Content-Type: x-www-form-urlencoded` | POST Methods | -| Json | Parameter in the JSON object in the request body, must have header `Content-Type: application/json` | POST Method | +| Json | Parameter in the JSON object in the request body, must have header `Content-Type: application/json` | POST Methods | | Query | Parameter in the query of the URL, such as /news_article?id=55 | All HTTP Methods | | File | Parameter is a file uploaded in the request body | POST Method | +| MultiSource | Parameter is in one of the locations provided to the constructor | Dependent on selected locations | + +Note: "**POST Methods**" refers to the HTTP methods that send data in the request body, such as POST, PUT, PATCH and DELETE. Although sending data via some methods such as DELETE is not standard, it is supported by Flask and this library. + +##### MultiSource Parameters +Using the `MultiSource` parameter type, parameters can be accepted from any combination of `Parameter` subclasses. Example usage is as follows: + +```py +@app.route("/") +@app.route("/") # If accepting parameters by Route and another type, a path with and without that Route parameter must be specified +@ValidateParameters() +def multi_source_example( + value: int = MultiSource(Route, Query, Json, min_int=0) +) +``` + +The above example will accept parameters passed to the route through Route, Query, and JSON Body. + + +Note: "**POST Methods**" refers to the HTTP methods that send data in the request body, such as POST, PUT, PATCH and DELETE. Although sending data via some methods such as DELETE is not standard, it is supported by Flask and this library. #### Type Hints and Accepted Input Types Type Hints allow for inline specification of the input type of a parameter. Some types are only available to certain `Parameter` subclasses. -| Type Hint / Expected Python Type | Notes | `Route` | `Form` | `Json` | `Query` | `File` | -|-----------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------|---------|--------|--------|---------|--------| -| `str` | | Y | Y | Y | Y | N | -| `int` | | Y | Y | Y | Y | N | -| `bool` | | Y | Y | Y | Y | N | -| `float` | | Y | Y | Y | Y | N | -| `list`/`typing.List` (`typing.List` is [deprecated](https://docs.python.org/3/library/typing.html#typing.List)) | For `Query` inputs, users can pass via either `value=1&value=2&value=3`, or `value=1,2,3`, both will be transformed to a `list`. | N | Y | Y | Y | N | -| `typing.Union` | | Y | Y | Y | Y | N | -| `typing.Optional` | | Y | Y | Y | Y | Y | -| `datetime.datetime` | received as a `str` in ISO-8601 date-time format | Y | Y | Y | Y | N | -| `datetime.date` | received as a `str` in ISO-8601 full-date format | Y | Y | Y | Y | N | -| `datetime.time` | received as a `str` in ISO-8601 partial-time format | Y | Y | Y | Y | N | -| `dict` | | N | N | Y | N | N | -| `FileStorage` | | N | N | N | N | Y | +| Type Hint / Expected Python Type | Notes | `Route` | `Form` | `Json` | `Query` | `File` | +|------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------|---------|--------|--------|---------|--------| +| `str` | | Y | Y | Y | Y | N | +| `int` | | Y | Y | Y | Y | N | +| `bool` | | Y | Y | Y | Y | N | +| `float` | | Y | Y | Y | Y | N | +|`list`/`typing.List` (`typing.List` is [deprecated](https://docs.python.org/3/library/typing.html#typing.List)) | For `Query` and `Form` inputs, users can pass via either `value=1&value=2&value=3`, or `value=1,2,3`, both will be transformed to a `list`. | N | Y | Y | Y | N | +| `typing.Union` | Cannot be used inside of `typing.List` | Y | Y | Y | Y | N | +| `typing.Optional` | | Y | Y | Y | Y | Y | +| `datetime.datetime` | Received as a `str` in ISO-8601 date-time format | Y | Y | Y | Y | N | +| `datetime.date` | Received as a `str` in ISO-8601 full-date format | Y | Y | Y | Y | N | +| `datetime.time` | Received as a `str` in ISO-8601 partial-time format | Y | Y | Y | Y | N | +| `dict` | For `Query` and `Form` inputs, users should pass the stringified JSON | N | Y | Y | Y | N | +| `FileStorage` | | N | N | N | N | Y | These can be used in tandem to describe a parameter to validate: `parameter_name: type_hint = ParameterSubclass()` - `parameter_name`: The field name itself, such as username From ca8ab1bd67fae84203146d7e0302cee8d06798fc Mon Sep 17 00:00:00 2001 From: George O <16269580+Ge0rg3@users.noreply.github.com> Date: Thu, 11 Jul 2024 18:02:55 +0100 Subject: [PATCH 17/22] Minor styling refactoring --- flask_parameter_validation/parameter_validation.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flask_parameter_validation/parameter_validation.py b/flask_parameter_validation/parameter_validation.py index a40c109..e455324 100644 --- a/flask_parameter_validation/parameter_validation.py +++ b/flask_parameter_validation/parameter_validation.py @@ -194,7 +194,7 @@ def validate(self, expected_input, all_request_inputs): user_inputs = [user_input] # If typing.List in union and user supplied valid list, convert remaining check only for list for exp_type in expected_input_types: - if True in [str(exp_type).startswith(list_hint) for list_hint in list_type_hints]: + if any(str(exp_type).startswith(list_hint) for list_hint in list_type_hints): if type(user_input) is list: # Only convert if validation passes if hasattr(exp_type, "__args__"): @@ -204,7 +204,7 @@ def validate(self, expected_input, all_request_inputs): expected_input_type_str = str(exp_type) user_inputs = user_input # If list, expand inner typing items. Otherwise, convert to list to match anyway. - elif True in [expected_input_type_str.startswith(list_hint) for list_hint in list_type_hints]: + elif any(expected_input_type_str.startswith(list_hint) for list_hint in list_type_hints): expected_input_types = expected_input_type.__args__ if type(user_input) is list: user_inputs = user_input @@ -229,7 +229,7 @@ def validate(self, expected_input, all_request_inputs): ) # Validate that if lists are required, lists are given - if True in [expected_input_type_str.startswith(list_hint) for list_hint in list_type_hints]: + if any(expected_input_type_str.startswith(list_hint) for list_hint in list_type_hints): if type(user_input) is not list: validation_success = False @@ -257,6 +257,6 @@ def validate(self, expected_input, all_request_inputs): raise ValidationError(str(e), expected_name, expected_input_type) # Return input back to parent function - if True in [expected_input_type_str.startswith(list_hint) for list_hint in list_type_hints]: + if any(expected_input_type_str.startswith(list_hint) for list_hint in list_type_hints): return user_inputs return user_inputs[0] From be72df89abc4fe4e19ed996f6cbd8af9adf34fd1 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sat, 13 Jul 2024 20:28:18 -0400 Subject: [PATCH 18/22] Switch Enum validation to check for subclasses of Enum and either str or int rather than StrEnum or IntEnum to preserve backwards compatibility --- flask_parameter_validation/parameter_types/parameter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flask_parameter_validation/parameter_types/parameter.py b/flask_parameter_validation/parameter_types/parameter.py index e486aac..41cfd70 100644 --- a/flask_parameter_validation/parameter_types/parameter.py +++ b/flask_parameter_validation/parameter_types/parameter.py @@ -4,7 +4,7 @@ """ import re from datetime import date, datetime, time -from enum import Enum, StrEnum, IntEnum +from enum import Enum import dateutil.parser as parser import jsonschema @@ -184,8 +184,8 @@ def convert(self, value, allowed_types): return date.fromisoformat(str(value)) except ValueError: raise ValueError("date format does not match ISO 8601") - elif len(allowed_types) == 1 and (issubclass(allowed_types[0], StrEnum) or issubclass(allowed_types[0], IntEnum)): - if issubclass(allowed_types[0], IntEnum): + elif len(allowed_types) == 1 and (issubclass(allowed_types[0], str) or issubclass(allowed_types[0], int) and issubclass(allowed_types[0], Enum)): + if issubclass(allowed_types[0], int): value = int(value) returning = allowed_types[0](value) return returning From 9216a0b8696fb911467e874ddb447d7bd30a34e4 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sat, 13 Jul 2024 20:54:52 -0400 Subject: [PATCH 19/22] Switch test Enums to use Enum with mixin --- flask_parameter_validation/test/enums.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flask_parameter_validation/test/enums.py b/flask_parameter_validation/test/enums.py index c7143d4..28e5e39 100644 --- a/flask_parameter_validation/test/enums.py +++ b/flask_parameter_validation/test/enums.py @@ -1,11 +1,11 @@ -from enum import StrEnum, IntEnum +from enum import Enum -class Fruits(StrEnum): +class Fruits(str, Enum): APPLE = "apple" ORANGE = "orange" -class Binary(IntEnum): +class Binary(int, Enum): ZERO = 0 ONE = 1 From 8a5be2b5c11a184cc1e652bfa83d4a49eaf85005 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Tue, 13 Aug 2024 10:31:45 -0400 Subject: [PATCH 20/22] Add Enum usage example to README --- README.md | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 90c91ff..e6a5d86 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,15 @@ from flask import Flask from typing import Optional from flask_parameter_validation import ValidateParameters, Route, Json, Query from datetime import datetime +from enum import Enum + +class AccountStatus(int, Enum): + ACTIVE = 1 + DISABLED = 0 + +class UserType(str, Enum): + USER = "user" + SERVICE = "service" app = Flask(__name__) @@ -26,7 +35,8 @@ def hello( date_of_birth: datetime = Json(), password_expiry: Optional[int] = Json(5), is_admin: bool = Query(False), - user_type: str = Json(alias="type") + user_type: UserType = Json(alias="type"), + status: AccountStatus = Json() ): return "Hello World!" @@ -36,7 +46,7 @@ if __name__ == "__main__": ``` ## Usage -To validate parameters with flask-parameter-validation, two conditions must be met. +To validate parameters with flask-parameter-validation, two conditions must be met. 1. The `@ValidateParameters()` decorator must be applied to the function 2. Type hints ([supported types](#type-hints-and-accepted-input-types)) and a default of a subclass of `Parameter` must be supplied per parameter @@ -137,8 +147,8 @@ Validation beyond type-checking can be done by passing arguments into the constr | `default` | any | All, except in `Route` | Specifies the default value for the field, makes non-Optional fields not required | | `min_str_length` | `int` | `str` | Specifies the minimum character length for a string input | | `max_str_length` | `int` | `str` | Specifies the maximum character length for a string input | -| `min_list_length` | `int` | `list` | Specifies the minimum number of elements in a list | -| `max_list_length` | `int` | `list` | Specifies the maximum number of elements in a list | +| `min_list_length` | `int` | `list` | Specifies the minimum number of elements in a list | +| `max_list_length` | `int` | `list` | Specifies the maximum number of elements in a list | | `min_int` | `int` | `int` | Specifies the minimum number for an integer input | | `max_int` | `int` | `int` | Specifies the maximum number for an integer input | | `whitelist` | `str` | `str` | A string containing allowed characters for the value | @@ -174,7 +184,7 @@ def is_odd(val: int): ``` ### API Documentation -Using the data provided through parameters, docstrings, and Flask route registrations, Flask Parameter Validation can generate API Documentation in various formats. +Using the data provided through parameters, docstrings, and Flask route registrations, Flask Parameter Validation can generate API Documentation in various formats. To make this easy to use, it comes with a `Blueprint` and the output and configuration options below: #### Format @@ -280,7 +290,7 @@ This method returns an object with the following structure: "": [] } }, - + ... ] ``` From 099b3999f42463fb1533b9c91fb569a5be123442 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Tue, 13 Aug 2024 10:34:21 -0400 Subject: [PATCH 21/22] Add clarifications on Enum usage in 3.11+ --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e6a5d86..9ea804a 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,11 @@ from flask_parameter_validation import ValidateParameters, Route, Json, Query from datetime import datetime from enum import Enum -class AccountStatus(int, Enum): +class AccountStatus(int, Enum): # In Python 3.11 or later, subclass IntEnum from enum package instead of int, Enum ACTIVE = 1 DISABLED = 0 -class UserType(str, Enum): +class UserType(str, Enum): # In Python 3.11 or later, subclass StrEnum from enum package instead of str, Enum USER = "user" SERVICE = "service" From 58762817dd91b794de33a956b7ad3602e7e0ad62 Mon Sep 17 00:00:00 2001 From: George O <16269580+Ge0rg3@users.noreply.github.com> Date: Tue, 13 Aug 2024 15:41:00 +0100 Subject: [PATCH 22/22] Update version to 2.4.0 --- setup.py | 67 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 33 insertions(+), 34 deletions(-) diff --git a/setup.py b/setup.py index 6d2041f..5ecf388 100644 --- a/setup.py +++ b/setup.py @@ -4,51 +4,50 @@ Get and validate all Flask input parameters with ease. """ + from setuptools import setup with open("README.md", "r") as f: long_description = f.read() setup( - name='Flask-Parameter-Validation', - version='2.3.1', - url='https://github.com/Ge0rg3/flask-parameter-validation', - license='MIT', - author='George Omnet', - author_email='flaskparametervalidation@georgeom.net', - description='Get and validate all Flask input parameters with ease.', + name="Flask-Parameter-Validation", + version="2.4.0", + url="https://github.com/Ge0rg3/flask-parameter-validation", + license="MIT", + author="George Omnet", + author_email="flaskparametervalidation@georgeom.net", + description="Get and validate all Flask input parameters with ease.", long_description=long_description, - long_description_content_type='text/markdown', - packages=['flask_parameter_validation', - 'flask_parameter_validation.exceptions', - 'flask_parameter_validation.parameter_types'], + long_description_content_type="text/markdown", + packages=["flask_parameter_validation", "flask_parameter_validation.exceptions", "flask_parameter_validation.parameter_types"], zip_safe=False, - package_data={'': ['templates/fpv_default_docs.html']}, + package_data={"": ["templates/fpv_default_docs.html"]}, include_package_data=True, - platforms='any', + platforms="any", install_requires=[ - 'Flask', - 'flask[async]', - 'python-dateutil', - 'jsonschema', + "Flask", + "flask[async]", + "python-dateutil", + "jsonschema", ], python_requires=">=3.9,<3.13", classifiers=[ - 'Environment :: Web Environment', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Development Status :: 5 - Production/Stable', - 'Framework :: Flask', - 'Topic :: Software Development :: Documentation', - 'Topic :: File Formats :: JSON :: JSON Schema', - 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', - 'Topic :: Software Development :: Libraries :: Python Modules' - ] + "Environment :: Web Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Development Status :: 5 - Production/Stable", + "Framework :: Flask", + "Topic :: Software Development :: Documentation", + "Topic :: File Formats :: JSON :: JSON Schema", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", + "Topic :: Software Development :: Libraries :: Python Modules", + ], )