From abcb48058aff0950fafdb33ed0fb5bc5b2e7ffc4 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sat, 29 Jun 2024 19:50:14 -0400 Subject: [PATCH 1/6] Update and reorganize README --- README.md | 181 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 108 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index acf571d..142d4fe 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,7 @@ - `git clone https://github.com/Ge0rg3/flask-parameter-validation.git` - `python setup.py install` - -## Simple Usage +## Usage Example ```py from flask import Flask from typing import List, Optional @@ -17,7 +16,6 @@ from datetime import datetime app = Flask(__name__) - @app.route("/update/", methods=["POST"]) @ValidateParameters() def hello( @@ -37,70 +35,21 @@ if __name__ == "__main__": app.run() ``` -## Detailed Usage -1. We use the ValidateParameters decorator on all functions that this modules should be used in. -2. The format for arguments is as follows: -`parameter_name: parameter_type = Class()` -In this example, `parameter_name` would be the field name itself, such as "username". `parameter_type` would be the expected python data type, such as str, int, List, Union etc. Finally, `Class()` is one of the class inputs, as detailed below: - -### Classes -1. Route() -This is the data passed through the URL, such as `/users/` -2. Form() -This is the data passed by a normal HTML form, often with x-www-form-urlencoded content-type. -3. Json() -This is any JSON body sent -- request must have application/json content type for flask to read this. -4. Query() -This covers query parameters (aka GET parameters), such as `/news/article?id=55` -5. File() -The validation on files are different to the others, but file input can still be obtained here as their Flask FileStorage objects. - -### Input types -* str -* int -* bool -* float -* typing.List (must use this, not just `list`) -* typing.Union -* typing.Optional -* datetime.datetime -* datetime.date -* datetime.time -* dict - -### Validation -All parameters can have default values, and automatic validation. -`Route`, `Form`, `Json` and `Query` have the following options: -* default: any, Specifies the default value for the field. -* min_str_length: int, Specifies the minimum character length for a string input -* max_str_length: int, Specifies the maximum character length for a string input -* min_list_length: int, Specifies the minimum number of elements in a list -* max_list_length: int, Specifies the maximum number of elements in a list -* min_int: int, Specifies the minimum number for an int input -* max_int: int, Specifies the maximum number for an int input -* whitelist: str, A string containing allowed characters for the value -* blacklist: str, A string containing forbidden characters for the value -* pattern: str, A regex pattern to test for string matches -* func: Callable -> Union[bool, tuple[bool, str]], A function containing a fully customized logic to validate the value -* datetime_format: str, datetime format string ([datetime format codes](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes)) -* comment: str, A string to display as the argument description in generated documentation (if used) -* alias: str, An expected parameter name instead of the function name. See `access_type` example for clarification. -* json_schema: dict, An expected [JSON Schema](https://json-schema.org) which the dict input must conform to - -`File` has the following options: -* content_types: array of strings, an array of allowed content types. -* min_length: Minimum content-length for a file -* max_length: Maximum content-length for a file - -These validators are passed into the classes in the route function, such as: -* `username: str = Json("defaultusername", min_length=5)` -* `profile_picture: werkzeug.datastructures.FileStorage = File(content_types=["image/png", "image/jpeg"])` -* `filter: str = Query()` +## 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 + -Note: For `typing.List` 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. +### Enable and customize Validation for a Route with the @ValidateParameters decorator +The `@ValidateParameters()` decorator takes parameters that alter route validation behavior or provide documentation information: -### 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: +| Parameter | Type | Default | Description | +|-------------------|----------------------|---------|------------------------------------------------------------------------------------------------------------------------------| +| 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: ```py def error_handler(err): error_name = type(err) @@ -113,11 +62,94 @@ def error_handler(err): }, 400 @ValidateParameters(error_handler) +@app.route(...) def api(...) ``` +### Specify Parameter types and constraints with type hints and subclasses of Parameter +#### 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 | + +#### 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 | + +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 +- `type_hint`: The expected Python data type +- `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: + +| 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 | + +These validators are passed into the `Parameter` subclass in the route function, such as: +* `username: str = Json(default="defaultusername", min_length=5)` +* `profile_picture: werkzeug.datastructures.FileStorage = File(content_types=["image/png", "image/jpeg"])` +* `filter: str = Query()` + +#### Custom Validation Function + +Custom validation functions passed into the `func` property can be used to validate an input against custom logic and return customized error responses for that validation + +Example custom validation functions are below: +```py +def is_even(val: int): + """Return a single bool, True if valid, False if invalid""" + return val % 2 == 0 + +def is_odd(val: int): + """Return a tuple with a bool, as above, and the error message if the bool is False""" + return val % 2 != 0, "val must be odd" +``` + ### API Documentation -Using the data provided through parameters, docstrings, and Flask route registrations, Flask Parameter Validation can generate an API Documentation page. To make this easy to use, it comes with a blueprint and the configuration options below: +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 * `FPV_DOCS_SITE_NAME: str`: Your site's name, to be displayed in the page title, default: `Site` * `FPV_DOCS_CUSTOM_BLOCKS: array`: An array of dicts to display as cards at the top of your documentation, with the (optional) keys: * `title: Optional[str]`: The title of the card @@ -135,14 +167,14 @@ app.register_blueprint(docs_blueprint) The default blueprint adds two `GET` routes: * `/`: HTML Page with Bootstrap CSS and toggleable light/dark mode -* `/json`: JSON Representation of the generated documentation +* `/json`: Non-standard Format JSON Representation of the generated documentation The `/json` route yields a response with the following format: ```json { "custom_blocks": "", "default_theme": "", - "docs": "", + "docs": "", "site_name": "": [ { @@ -219,10 +252,12 @@ This method returns an object with the following structure: "": [] } }, + ... ] ``` + ### JSON Schema Validation An example of the [JSON Schema](https://json-schema.org) validation is provided below: ```python @@ -248,7 +283,7 @@ def json_schema(data: dict = Json(json_schema=json_schema)): ## Contributions Many thanks to all those who have made contributions to the project: -* [d3-steichman](https://github.com/d3-steichman): API documentation, custom error handling, datetime validation and bug fixes +* [d3-steichman](https://github.com/d3-steichman)/[smt5541](https://github.com/smt5541): API documentation, custom error handling, datetime validation and bug fixes * [summersz](https://github.com/summersz): Parameter aliases, async support, form type conversion and list bug fixes * [Garcel](https://github.com/Garcel): Allow passing custom validator function * [iml1111](https://github.com/iml1111): Implement regex validation From 9e098474dce4954186614c35ef1f5441422615e1 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Mon, 1 Jul 2024 17:10:40 -0400 Subject: [PATCH 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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):