From 9e098474dce4954186614c35ef1f5441422615e1 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Mon, 1 Jul 2024 17:10:40 -0400 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 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 5/5] 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):