diff --git a/README.md b/README.md index 3b39680..ef6724e 100644 --- a/README.md +++ b/README.md @@ -87,13 +87,13 @@ 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 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 | +| 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. @@ -118,22 +118,22 @@ Note: "**POST Methods**" refers to the HTTP methods that send data in the reques #### Type Hints and Accepted Input Types Type Hints allow for inline specification of the input type of a parameter. Some types are only available to certain `Parameter` subclasses. -| Type Hint / Expected Python Type | Notes | `Route` | `Form` | `Json` | `Query` | `File` | -|-----------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|---------|--------|--------|---------|--------| -| `str` | | Y | Y | Y | Y | N | -| `int` | | Y | Y | Y | Y | N | -| `bool` | | Y | Y | Y | Y | N | -| `float` | | Y | Y | Y | Y | N | -| `list`/`typing.List` (`typing.List` is [deprecated](https://docs.python.org/3/library/typing.html#typing.List)) | For `Query` 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` | Not supported for `Route` inputs | Y | Y | Y | Y | Y | -| `datetime.datetime` | Received as a `str` in ISO-8601 date-time format | Y | Y | Y | Y | N | -| `datetime.date` | Received as a `str` in ISO-8601 full-date format | Y | Y | Y | Y | N | -| `datetime.time` | Received as a `str` in ISO-8601 partial-time format | Y | Y | Y | Y | N | -| `dict` | For `Query` and `Form` inputs, users should pass the stringified JSON | N | Y | Y | Y | N | -| `FileStorage` | | N | N | N | N | Y | -| A subclass of `StrEnum` or `IntEnum`, or a subclass of `Enum` with `str` or `int` mixins prior to Python 3.11 | | Y | Y | Y | Y | N | -| `uuid.UUID` | Received as a `str` with or without hyphens, case-insensitive | Y | Y | Y | Y | N | +| Type Hint / Expected Python Type | Notes | `Route` | `Form` | `Json` | `Query` | `File` | +|-----------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|--------|--------|---------|--------| +| `str` | | Y | Y | Y | Y | N | +| `int` | | Y | Y | Y | Y | N | +| `bool` | | Y | Y | Y | Y | N | +| `float` | | Y | Y | Y | Y | N | +| `list`/`typing.List` (`typing.List` is [deprecated](https://docs.python.org/3/library/typing.html#typing.List)) | For `Json`, received as a JSON List

For `Query`, can be received as `value=1,2,3` if `list_disable_query_csv` is `False`.

For `Form` or `Query`, received as `value=1&value=2&value=3`.

A single `value=` with no value will always be transformed to an empty list, but `value=,` (`Query` only) and `value=&value=` will be transformed to a list of empty `str` | N | Y | Y | Y | N | +| `typing.Union` | | Y | Y | Y | Y | N | +| `typing.Optional` | Not supported for `Route` inputs | Y | Y | Y | Y | Y | +| `datetime.datetime` | Received as a `str` in ISO-8601 date-time format | Y | Y | Y | Y | N | +| `datetime.date` | Received as a `str` in ISO-8601 full-date format | Y | Y | Y | Y | N | +| `datetime.time` | Received as a `str` in ISO-8601 partial-time format | Y | Y | Y | Y | N | +| `dict` | For `Query` and `Form` inputs, users should pass the stringified JSON | N | Y | Y | Y | N | +| `FileStorage` | | N | N | N | N | Y | +| A subclass of `StrEnum` or `IntEnum`, or a subclass of `Enum` with `str` or `int` mixins prior to Python 3.11 | | Y | Y | Y | Y | N | +| `uuid.UUID` | Received as a `str` with or without hyphens, case-insensitive | Y | Y | Y | Y | N | These can be used in tandem to describe a parameter to validate: `parameter_name: type_hint = ParameterSubclass()` - `parameter_name`: The field name itself, such as username @@ -143,27 +143,28 @@ These can be used in tandem to describe a parameter to validate: `parameter_name ### Validation with arguments to Parameter Validation beyond type-checking can be done by passing arguments into the constructor of the `Parameter` subclass. The arguments available for use on each type hint are: -| Parameter Name | Type of Argument | Effective On Types | Description | -|-------------------|--------------------------------------------------|------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `default` | any | All, except in `Route` | Specifies the default value for the field, makes non-Optional fields not required | -| `min_str_length` | `int` | `str` | Specifies the minimum character length for a string input | -| `max_str_length` | `int` | `str` | Specifies the maximum character length for a string input | -| `min_list_length` | `int` | `list` | Specifies the minimum number of elements in a list | -| `max_list_length` | `int` | `list` | Specifies the maximum number of elements in a list | -| `min_int` | `int` | `int` | Specifies the minimum number for an integer input | -| `max_int` | `int` | `int` | Specifies the maximum number for an integer input | -| `whitelist` | `str` | `str` | A string containing allowed characters for the value | -| `blacklist` | `str` | `str` | A string containing forbidden characters for the value | -| `pattern` | `str` | `str` | A regex pattern to test for string matches | -| `func` | `Callable[Any] -> Union[bool, tuple[bool, str]]` | All | A function containing a fully customized logic to validate the value. See the [custom validation function](#custom-validation-function) below for usage | -| `datetime_format` | `str` | `datetime.datetime` | Python datetime format string datetime format string ([datetime format codes](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes)) | -| `comment` | `str` | All | A string to display as the argument description in any generated documentation | -| `alias` | `str` | All but `FileStorage` | An expected parameter name to receive instead of the function name. | -| `json_schema` | `dict` | `dict` | An expected [JSON Schema](https://json-schema.org) which the dict input must conform to | -| `content_types` | `list[str]` | `FileStorage` | Allowed `Content-Type`s | -| `min_length` | `int` | `FileStorage` | Minimum `Content-Length` for a file | -| `max_length` | `int` | `FileStorage` | Maximum `Content-Length` for a file | -| `blank_none` | `bool` | `Optional[str]` | If `True`, an empty string will be converted to `None`, defaults to configured `FPV_BLANK_NONE`, see [Validation Behavior Configuration](#validation-behavior-configuration) for more | +| Parameter Name | Type of Argument | Effective On Types | Description | +|--------------------------|--------------------------------------------------|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `default` | any, except `NoneType` | All, except in `Route` | Specifies the default value for the field, makes non-Optional fields not required | +| `min_str_length` | `int` | `str` | Specifies the minimum character length for a string input | +| `max_str_length` | `int` | `str` | Specifies the maximum character length for a string input | +| `min_list_length` | `int` | `list` | Specifies the minimum number of elements in a list | +| `max_list_length` | `int` | `list` | Specifies the maximum number of elements in a list | +| `min_int` | `int` | `int` | Specifies the minimum number for an integer input | +| `max_int` | `int` | `int` | Specifies the maximum number for an integer input | +| `whitelist` | `str` | `str` | A string containing allowed characters for the value | +| `blacklist` | `str` | `str` | A string containing forbidden characters for the value | +| `pattern` | `str` | `str` | A regex pattern to test for string matches | +| `func` | `Callable[Any] -> Union[bool, tuple[bool, str]]` | All | A function containing a fully customized logic to validate the value. See the [custom validation function](#custom-validation-function) below for usage | +| `datetime_format` | `str` | `datetime.datetime` | Python datetime format string datetime format string ([datetime format codes](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes)) | +| `comment` | `str` | All | A string to display as the argument description in any generated documentation | +| `alias` | `str` | All but `FileStorage` | An expected parameter name to receive instead of the function name. | +| `json_schema` | `dict` | `dict` | An expected [JSON Schema](https://json-schema.org) which the dict input must conform to | +| `content_types` | `list[str]` | `FileStorage` | Allowed `Content-Type`s | +| `min_length` | `int` | `FileStorage` | Minimum `Content-Length` for a file | +| `max_length` | `int` | `FileStorage` | Maximum `Content-Length` for a file | +| `blank_none` | `bool` | `Optional[str]` | If `True`, an empty string will be converted to `None`, defaults to configured `FPV_BLANK_NONE`, see [Validation Behavior Configuration](#validation-behavior-configuration) for more | +| `list_disable_query_csv` | `bool` | `list` in `Query` | If `False`, list-type Query parameters will be split by `,`, defaults to configured `FPV_LIST_DISABLE_QUERY_CSV`, see [Validation Behavior Configuration](#validation-behavior-configuration) for more | These validators are passed into the `Parameter` subclass in the route function, such as: * `username: str = Json(default="defaultusername", min_length=5)` @@ -198,7 +199,8 @@ def is_odd(val: int): See the [API Documentation](#api-documentation) below for other information on API Documentation generation #### Validation Behavior Configuration -* `FPV_BLANK_NONE: bool`: Globally override the default `blank_none` behavior for routes in your application, defaults to `False` if unset +* `FPV_BLANK_NONE: bool`: Set the default `blank_none` behavior for routes in your application, defaults to `False` if unset +* `FPV_LIST_DISABLE_QUERY_CSV: bool`: Set the default `list_disable_query_csv` behavior for routes in your application, defaults to `False` if unset ### API Documentation Using the data provided through parameters, docstrings, and Flask route registrations, Flask Parameter Validation can generate API Documentation in various formats. diff --git a/flask_parameter_validation/parameter_types/parameter.py b/flask_parameter_validation/parameter_types/parameter.py index 72b5395..4fc5f81 100644 --- a/flask_parameter_validation/parameter_types/parameter.py +++ b/flask_parameter_validation/parameter_types/parameter.py @@ -34,6 +34,7 @@ def __init__( alias=None, # str: alias for parameter name json_schema=None, # dict: JSON Schema to check received dicts or lists against blank_none=None, # bool: Whether blank strings should be converted to None when validating a type of Optional[str] + list_disable_query_csv=None, # bool: Whether query strings should be split by `,` when validating a type of list ): self.default = default self.min_list_length = min_list_length @@ -51,6 +52,8 @@ def __init__( self.alias = alias self.json_schema = json_schema self.blank_none = blank_none + self.list_disable_query_csv = list_disable_query_csv + def func_helper(self, v): func_result = self.func(v) @@ -158,53 +161,66 @@ def validate(self, value): return True - def convert(self, value, allowed_types): + def convert(self, value, allowed_types, current_error=None): """Some parameter types require manual type conversion (see Query)""" + print(f"Converting '{value}' ({type(value)}) to '{allowed_types}'") blank_none = self.blank_none if blank_none is None: # Default blank_none to False if not provided or set in app config blank_none = False if "FPV_BLANK_NONE" not in flask.current_app.config else flask.current_app.config["FPV_BLANK_NONE"] + error = None # Datetime conversion if None in allowed_types and value is None: return value + if date in allowed_types: + try: + return date.fromisoformat(str(value)) + except ValueError: + error = ValueError("date format does not match ISO 8601") + if time in allowed_types: + try: + return time.fromisoformat(str(value)) + except ValueError: + error = ValueError("time format does not match ISO 8601") if datetime in allowed_types: if self.datetime_format is None: try: - return parser.parse(str(value)) - except parser._parser.ParserError: - pass + return datetime.fromisoformat(str(value)) + except ValueError: + error = ValueError("datetime format does not match ISO 8601") else: try: return datetime.strptime(str(value), self.datetime_format) except ValueError: - raise ValueError( - f"datetime format does not match: {self.datetime_format}" - ) - pass - elif time in allowed_types: - try: - return time.fromisoformat(str(value)) - except ValueError: - raise ValueError("time format does not match ISO 8601") - elif date in allowed_types: - try: - return date.fromisoformat(str(value)) - except ValueError: - raise ValueError("date format does not match ISO 8601") - elif blank_none and type(None) in allowed_types and str in allowed_types and type(value) is str and len(value) == 0: + error = ValueError(f"datetime format does not match: {self.datetime_format}") + if blank_none and type(None) in allowed_types and str in allowed_types and type(value) is str and len(value) == 0: return None - elif any(isclass(allowed_type) and (issubclass(allowed_type, str) or issubclass(allowed_type, int) and issubclass(allowed_type, Enum)) for allowed_type in allowed_types): + if any(isclass(allowed_type) and (issubclass(allowed_type, str) or issubclass(allowed_type, int) and issubclass(allowed_type, Enum)) for allowed_type in allowed_types): + print("Enums allowed") for allowed_type in allowed_types: if issubclass(allowed_type, Enum): - if issubclass(allowed_types[0], int): - value = int(value) - returning = allowed_types[0](value) - return returning - elif uuid.UUID in allowed_types: + try: + if issubclass(allowed_type, int): + value = int(value) + returning = allowed_type(value) + print(f"type(returning): {type(returning)}") + return returning + except ValueError as e: + error = e + if uuid.UUID in allowed_types: try: if type(value) == uuid.UUID: # Handle default of type UUID return value - return uuid.UUID(value) + try: + return uuid.UUID(value) + except ValueError as e: + error = e except AttributeError: - raise ValueError("UUID format is incorrect") - return value + error = ValueError("UUID format is incorrect") + if str in allowed_types: + return value + print(value) + print(type(value)) + if error and type(value) is str: + raise error + return value \ No newline at end of file diff --git a/flask_parameter_validation/parameter_types/query.py b/flask_parameter_validation/parameter_types/query.py index 3941b4b..96353cd 100644 --- a/flask_parameter_validation/parameter_types/query.py +++ b/flask_parameter_validation/parameter_types/query.py @@ -3,6 +3,7 @@ - i.e. sent in GET requests, /?username=myname """ import json +from enum import Enum from .parameter import Parameter @@ -13,33 +14,42 @@ class Query(Parameter): def __init__(self, default=None, **kwargs): super().__init__(default, **kwargs) - def convert(self, value, allowed_types): + def convert(self, value, allowed_types, current_error=None): """Convert query parameters to corresponding types.""" + print(f"value: {value}, type: {type(value)}") + original_value = value + error = None if type(value) is str: - # int conversion + # int conversion done before dict to handle potential IntEnum if int in allowed_types: try: - value = int(value) - except ValueError: + enum_test = super().convert(value, allowed_types, current_error) + if issubclass(type(enum_test), Enum) and issubclass(type(enum_test), int): + return enum_test + return int(value) + except ValueError or TypeError: pass - # float conversion - if float in allowed_types: + if dict in allowed_types: try: - value = float(value) + return json.loads(value) except ValueError: - pass + error = ValueError(f"invalid JSON") + # float conversion + if float in allowed_types: + if not (type(value) is int and str(value) == original_value): # If we've already converted an int and the conversion is exact, skip float conversion + try: + return float(value) + except ValueError: + pass # bool conversion if bool in allowed_types: try: if value.lower() == "true": - value = True + return True elif value.lower() == "false": - value = False + return False except AttributeError: pass - if dict in allowed_types: - try: - value = json.loads(value) - except ValueError: - raise ValueError(f"invalid JSON") - return super().convert(value, allowed_types) + if type(value) is not str: + error = None + return super().convert(value, allowed_types, current_error=error) diff --git a/flask_parameter_validation/parameter_types/route.py b/flask_parameter_validation/parameter_types/route.py index 1cccb80..27a4f66 100644 --- a/flask_parameter_validation/parameter_types/route.py +++ b/flask_parameter_validation/parameter_types/route.py @@ -2,6 +2,8 @@ Route Parameters - Sent as part of a path, i.e. /user/ """ +from enum import Enum + from .parameter import Parameter @@ -11,13 +13,17 @@ class Route(Parameter): def __init__(self, default=None, **kwargs): super().__init__(default, **kwargs) - def convert(self, value, allowed_types): + def convert(self, value, allowed_types, current_error=None): """Convert query parameters to corresponding types.""" if type(value) is str: # int conversion if int in allowed_types: try: - value = int(value) + enum_test = super().convert(value, allowed_types, current_error) + if issubclass(type(enum_test), Enum) and issubclass(type(enum_test), int): + value = enum_test + else: + value = int(value) except ValueError: pass # float conversion diff --git a/flask_parameter_validation/parameter_validation.py b/flask_parameter_validation/parameter_validation.py index 2eaef20..41f2671 100644 --- a/flask_parameter_validation/parameter_validation.py +++ b/flask_parameter_validation/parameter_validation.py @@ -3,6 +3,9 @@ import inspect import re from inspect import signature +from typing import Optional + +import flask from flask import request, Response from werkzeug.datastructures import ImmutableMultiDict from werkzeug.exceptions import BadRequest @@ -27,6 +30,7 @@ def __call__(self, f): """ Parent flow for validating each required parameter """ + print("__call__") fsig = f.__module__ + "." + f.__name__ argspec = inspect.getfullargspec(f) source = inspect.getsource(f) @@ -49,6 +53,7 @@ def nested_func_helper(**kwargs): if it should unpack the resulting dictionary of inputs as kwargs, or just return the error message. """ + print("nested_func_helper") # Step 1 - Get expected input details as dict expected_inputs = signature(f).parameters @@ -63,34 +68,43 @@ def nested_func_helper(**kwargs): except BadRequest: return {"error": ({"error": "Could not parse JSON."}, 400), "validated": False} - # Step 3 - Extract list of parameters expected to be lists (otherwise all values are converted to lists) - expected_list_params = [] + # Step 3 - Extract list of parameters expected to be lists (otherwise all values are converted to lists), and for Query params, whether they should split strings by `,` + expected_list_params = {} + default_list_disable_query_csv = flask.current_app.config.get("FPV_LIST_DISABLE_QUERY_CSV", False) for name, param in expected_inputs.items(): - if True in [str(param.annotation).startswith(list_hint) for list_hint in list_type_hints]: - expected_list_params.append(param.default.alias or name) + if any([str(param.annotation).startswith(list_hint) for list_hint in list_type_hints]): + list_disable_query_csv = default_list_disable_query_csv + if param.default.list_disable_query_csv is not None: + list_disable_query_csv = param.default.list_disable_query_csv + expected_list_params[param.default.alias or name] = not list_disable_query_csv # Step 4 - Convert request inputs to dicts request_inputs = { Route: kwargs.copy(), Json: json_input or {}, - Query: self._to_dict_with_lists(request.args, expected_list_params, True), - Form: self._to_dict_with_lists(request.form, expected_list_params), - File: self._to_dict_with_lists(request.files, expected_list_params), + Query: self._to_dict_with_lists(request.args, list(expected_list_params.keys()), list(expected_list_params.values())), + Form: self._to_dict_with_lists(request.form, list(expected_list_params.keys())), + File: self._to_dict_with_lists(request.files, list(expected_list_params.keys())), } # Step 5 - Validate each expected input validated_inputs = {} for expected in expected_inputs.values(): + if Query in request_inputs: + print(f"Checking {expected} and {request_inputs[Query]}") if self.custom_error_handler is None: try: new_input = self.validate(expected, request_inputs) + print(f"new_input 1: {new_input}") except (MissingInputError, ValidationError) as e: + print(f"caught error: {e}") return {"error": ({"error": str(e)}, 400), "validated": False} else: try: new_input = self.validate(expected, request_inputs) except Exception as e: return {"error": self.custom_error_handler(e), "validated": False} + print(f"new_input 2: {new_input}") validated_inputs[expected.name] = new_input return {"inputs": validated_inputs, "validated": True} @@ -107,6 +121,7 @@ async def nested_func(**kwargs): # If the view function is not async, return a function @functools.wraps(f) def nested_func(**kwargs): + print(f"nested_func called with {kwargs}") validated_inputs = nested_func_helper(**kwargs) if validated_inputs["validated"]: return f(**validated_inputs["inputs"]) @@ -116,24 +131,106 @@ 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[str], split_strings: Optional[list[bool]] = None ) -> dict: dict_with_lists = {} for key, values in multi_dict.lists(): # Only create lists for keys that are expected to be lists if key in expected_lists: + key_index = expected_lists.index(key) list_values = [] for value in values: - if split_strings: - list_values.extend(value.split(",")) - else: - list_values.append(value) + if value != "" or len(values) > 1: + if split_strings and split_strings[key_index]: + list_values.extend(value.split(",")) + else: + list_values.append(value) dict_with_lists[key] = list_values else: # If only one value and not expected to be a list, don't use a list dict_with_lists[key] = values[0] if len(values) == 1 else values return dict_with_lists + def _generic_types_validation_helper(self, expected_name, expected_input_type, expected_input_type_str, user_input, source): + """ + Perform recursive validation of generic types (Optional, Union, and List/list) + """ + print(f"_gtvh({expected_name}, {expected_input_type}, '{expected_input_type_str}', {user_input}, {source})") + # In python3.7+, typing.Optional is used instead of typing.Union[..., None] + if expected_input_type_str.startswith("typing.Optional"): + sub_expected_input_types = expected_input_type + sub_expected_input_type_str = expected_input_type_str.replace("typing.Optional[", "typing.Union[None, ") + user_inputs, sub_expected_input_types = self._generic_types_validation_helper(expected_name, sub_expected_input_types, sub_expected_input_type_str, user_input, source) + elif expected_input_type_str.startswith("typing.Union"): + if type(expected_input_type) is tuple or type(expected_input_type) is list: + sub_expected_input_types = expected_input_type + else: + sub_expected_input_types = expected_input_type.__args__ + sub_expected_input_type_str = expected_input_type_str[expected_input_type_str.index("[") + 1:-1] + print(sub_expected_input_types) + if type(user_input) is list: + user_inputs = user_input + else: + user_inputs = [user_input] + user_inputs, sub_expected_input_types = self._generic_types_validation_helper(expected_name, sub_expected_input_types, sub_expected_input_type_str, user_inputs, source) + # If typing.List in optional and user supplied valid list, convert remaining check only for list + for exp_type in sub_expected_input_types: + print(f"str(exp_type): {str(exp_type)}") + if any(str(exp_type).startswith(list_hint) for list_hint in list_type_hints): + print(f"type(user_input): {type(user_input)}") + if type(user_input) is list: + print(f"hasattr(exp_type.__args__): {hasattr(exp_type, '__args__')}") + if hasattr(exp_type, "__args__"): + print(f"exp_type.__args__: {exp_type.__args__}") + sub_expected_input_types = exp_type.__args__ + if len(sub_expected_input_types) == 1: + sub_expected_input_types = sub_expected_input_types[0] + print(f"sub_expected_input_types: {sub_expected_input_types}") + sub_expected_input_type_str = str(sub_expected_input_types) + user_inputs = user_input + user_inputs, sub_expected_input_types = self._generic_types_validation_helper(expected_name, sub_expected_input_types, sub_expected_input_type_str, user_inputs, source) + print(f"user_inputs after union branch: {user_inputs}") + # If list, expand inner typing items. Otherwise, convert to list to match anyway. + elif any(expected_input_type_str.startswith(list_hint) for list_hint in list_type_hints): + if hasattr(expected_input_type, "__args__"): + sub_expected_input_types = expected_input_type.__args__[0] + else: + sub_expected_input_types = expected_input_type + sub_expected_input_type_str = expected_input_type_str[expected_input_type_str.index("[")+1:-1] + if type(user_input) is list: + user_inputs = user_input + else: + user_inputs = [user_input] + print(f"user_inputs mid list branch: {user_inputs}") + user_inputs, sub_expected_input_types = self._generic_types_validation_helper(expected_name, sub_expected_input_types, sub_expected_input_type_str, user_inputs, source) + print(f"user_inputs after list branch: {user_inputs}") + else: + if type(user_input) is list: + user_inputs = user_input + else: + user_inputs = [user_input] + print(f"user_inputs mid else: {user_inputs}") + print(f"expected_input_type: {expected_input_type} ({type(expected_input_type)})") + if type(expected_input_type) is list or type(expected_input_type) is tuple: + sub_expected_input_types = expected_input_type + print(f"expected_input_type: {expected_input_type}; first member: {f'{user_inputs[0]} ({type(user_inputs[0])})' if len(user_inputs) > 0 else 'empty list'}") + elif type(expected_input_type) is list and len(expected_input_type) > 0 and hasattr(expected_input_type[0], "__len__"): + sub_expected_input_types = expected_input_type[0] + elif expected_input_type is list and not hasattr(expected_input_type, "__args__"): + return [user_inputs], [expected_input_type] + else: + sub_expected_input_types = [expected_input_type] + for count, value in enumerate(user_inputs): + try: + print(f"Trying convert {value} ({type(value)})") + user_inputs[count] = source.convert( + value, sub_expected_input_types + ) + except ValueError as e: + print("ValueError") + raise ValidationError(str(e), expected_name, expected_input_type) + return user_inputs, sub_expected_input_types + def validate(self, expected_input, all_request_inputs): """ Validate that a given expected input exists in the requested input collection @@ -197,79 +294,33 @@ def validate(self, expected_input, all_request_inputs): 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"): - expected_input_types = expected_input_type.__args__ - user_inputs = [user_input] - # If typing.List in optional and user supplied valid list, convert remaining check only for list - for exp_type in expected_input_types: - if any(str(exp_type).startswith(list_hint) for list_hint in list_type_hints): - if type(user_input) is list: - 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 - elif int in exp_type.__args__: # Ints from list[str] sources haven't been converted yet, so give it a typecast for good measure - if all(type(int(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 - # Prepare expected type checks for unions, lists and plain types - elif 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 any(str(exp_type).startswith(list_hint) for list_hint in list_type_hints): - if type(user_input) is list: - # Only convert if validation passes - if hasattr(exp_type, "__args__"): - 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 any(expected_input_type_str.startswith(list_hint) for list_hint in list_type_hints): - expected_input_types = expected_input_type.__args__ - if type(user_input) is list: - user_inputs = user_input - else: - user_inputs = [user_input] - else: - user_inputs = [user_input] - 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] = source.convert( - value, expected_input_types - ) - except ValueError as e: - raise ValidationError(str(e), expected_name, expected_input_type) + user_inputs, expected_input_types = self._generic_types_validation_helper(expected_name, expected_input_type, expected_input_type_str, user_input, source) + print(f"_gtvh final return: {user_inputs}, {expected_input_types}") # Validate that user type(s) match expected type(s) validation_success = all( type(inp) in expected_input_types for inp in user_inputs ) + for inp in user_inputs: + print(f"type(inp): {type(inp)}, expected_input_types: {expected_input_types}") + print(f"validation_success 1: {validation_success}") # Validate that if lists are required, lists are given if any(expected_input_type_str.startswith(list_hint) for list_hint in list_type_hints): if type(user_input) is not list: validation_success = False + print(f"validation_success 2: {validation_success}") # Error if types don't match if not validation_success: + print(f"Validation Failed: {user_input}") if hasattr( original_expected_input_type, "__name__" ) and not (original_expected_input_type_str.startswith("typing.") or original_expected_input_type_str.startswith("list")): type_name = original_expected_input_type.__name__ else: type_name = original_expected_input_type_str + print("Raising ValidationError") raise ValidationError( f"must be type '{type_name}'", expected_name, diff --git a/flask_parameter_validation/test/test_form_params.py b/flask_parameter_validation/test/test_form_params.py index 33e14d9..bb6b5ea 100644 --- a/flask_parameter_validation/test/test_form_params.py +++ b/flask_parameter_validation/test/test_form_params.py @@ -1,5 +1,6 @@ # String Validation import datetime +import json import uuid from typing import Type, List, Optional @@ -64,11 +65,6 @@ def test_optional_str(client): def test_optional_str_blank_none_unset(client, app): url = "/form/str/blank_none/unset" - # Test that FPV_BLANK_NONE runs as False by default - app.config.update({"FPV_BLANK_NONE": None}) - r = client.post(f"{url}", data={"v": ""}) - assert "v" in r.json - assert r.json["v"] == "" # Test that FPV_BLANK_NONE returns empty string when False app.config.update({"FPV_BLANK_NONE": False}) r = client.post(f"{url}", data={"v": ""}) @@ -79,15 +75,15 @@ def test_optional_str_blank_none_unset(client, app): r = client.post(f"{url}", data={"v": ""}) assert "v" in r.json assert r.json["v"] is None + # Test that FPV_BLANK_NONE runs as False by default + app.config.pop("FPV_BLANK_NONE", None) + r = client.post(f"{url}", data={"v": ""}) + assert "v" in r.json + assert r.json["v"] == "" def test_optional_str_blank_none_true(client, app): url = "/form/str/blank_none/true" - # Test that unset FPV_BLANK_NONE can be overridden to True per-route - app.config.update({"FPV_BLANK_NONE": None}) - r = client.post(f"{url}", data={"v": ""}) - assert "v" in r.json - assert r.json["v"] is None # Test that FPV_BLANK_NONE of False can be overridden to True per-route app.config.update({"FPV_BLANK_NONE": False}) r = client.post(f"{url}", data={"v": ""}) @@ -98,15 +94,15 @@ def test_optional_str_blank_none_true(client, app): r = client.post(f"{url}", data={"v": ""}) assert "v" in r.json assert r.json["v"] is None + # Test that unset FPV_BLANK_NONE can be overridden to True per-route + app.config.pop("FPV_BLANK_NONE", None) + r = client.post(f"{url}", data={"v": ""}) + assert "v" in r.json + assert r.json["v"] is None def test_optional_str_blank_none_false(client, app): url = "/form/str/blank_none/false" - # Test that unset FPV_BLANK_NONE can be 'overridden' to False per-route - app.config.update({"FPV_BLANK_NONE": None}) - r = client.post(f"{url}", data={"v": ""}) - assert "v" in r.json - assert r.json["v"] == "" # Test that FPV_BLANK_NONE of False can be 'overridden' to False per-route app.config.update({"FPV_BLANK_NONE": False}) r = client.post(f"{url}", data={"v": ""}) @@ -117,6 +113,11 @@ def test_optional_str_blank_none_false(client, app): r = client.post(f"{url}", data={"v": ""}) assert "v" in r.json assert r.json["v"] == "" + # Test that unset FPV_BLANK_NONE can be 'overridden' to False per-route + app.config.pop("FPV_BLANK_NONE", None) + r = client.post(f"{url}", data={"v": ""}) + assert "v" in r.json + assert r.json["v"] == "" def test_str_default(client): @@ -735,6 +736,11 @@ def test_union_func(client): # List Validation def test_required_list_str(client): url = "/form/list/req_str" + # Test that preset empty list input yields input value + r = client.post(url, data={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 # Test that present List[str] input yields input value v = ["x", "y"] r = client.post(url, data={"v": v}) @@ -749,6 +755,11 @@ def test_required_list_str(client): def test_required_list_int(client): url = "/form/list/req_int" + # Test that present single empty string input yields empty list + r = client.post(url, data={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 # Test that present List[int] input yields input value v = [0, 1] r = client.post(url, data={"v": v}) @@ -766,6 +777,11 @@ def test_required_list_int(client): def test_required_list_bool(client): url = "/form/list/req_bool" + # Test that present single empty string input yields empty list + r = client.post(url, data={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 # Test that present List[bool] input yields input value v = [False, True] r = client.post(url, data={"v": v}) @@ -781,42 +797,77 @@ def test_required_list_bool(client): assert "error" in r.json -# List[Union[]] not currently supported -# def test_required_list_union(client): -# url = "/form/list/req_union" -# # Test that present single int input yields [input value] -# r = client.post(f"{url}?v=2") -# assert "v" in r.json -# assert type(r.json["v"]) is list -# assert len(r.json["v"]) == 1 -# assert type(r.json["v"][0]) is int -# assert r.json["v"][0] == 2 -# # Test that present single float input yields [input value] -# r = client.post(f"{url}?v=3.14") -# assert "v" in r.json -# assert type(r.json["v"]) is list -# assert len(r.json["v"]) == 1 -# assert type(r.json["v"][0]) is float -# assert r.json["v"][0] == 3.14 -# # Test that present CSV int/float input yields [input values] -# r = client.post(f"{url}?v=4,5.62") -# assert "v" in r.json -# assert type(r.json["v"]) is list -# assert len(r.json["v"]) == 2 -# assert type(r.json["v"][0]) is int -# assert type(r.json["v"][1]) is float -# assert r.json["v"][0] == 4 -# assert r.json["v"][1] == 5.62 -# # Test that present non-int/float list items yields error -# r = client.post(f"{url}?v=a") -# assert "error" in r.json -# # Test that missing input yields error -# r = client.post(url) -# assert "error" in r.json +def test_required_list_union(client): + url = "/form/list/req_union" + # Test that present single empty string input yields empty list + r = client.post(url, data={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single int input yields [input value] + r = client.post(f"{url}", data={"v": 2}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is int + assert r.json["v"][0] == 2 + # Test that present single float input yields [input value] + r = client.post(f"{url}", data={"v": 3.14}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is float + assert r.json["v"][0] == 3.14 + # Test that present CSV int/float input yields [input values] + r = client.post(f"{url}", data={"v": [4, 5.62]}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + assert type(r.json["v"][0]) is int + assert type(r.json["v"][1]) is float + assert r.json["v"][0] == 4 + assert r.json["v"][1] == 5.62 + # Test that present non-int/float list items yields error + r = client.post(f"{url}", data={"v": "a"}) + assert "error" in r.json + # Test that missing input yields error + r = client.post(url) + assert "error" in r.json + +def test_required_list_union_everything(client): + url = "/form/list/req_union_everything" + v = [ + "testing", + 5, + True, + 3.14, + datetime.datetime(2025, 4, 20, 15, 13, 32).isoformat(), + datetime.date(2025, 4, 20).isoformat(), + datetime.time(15, 14, 22).isoformat(), + json.dumps({"i": "am", "a": "dictionary"}), + Fruits.APPLE.value, + Binary.ONE.value, + str(uuid.uuid4()) + ] + r = client.post(f"{url}", data={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(v) == len(r.json["v"]) + for i in range(len(v)): + if i == 7: + assert r.json["v"][i] == json.loads(v[i]) + else: + assert r.json["v"][i] == v[i] + def test_required_list_datetime(client): url = "/form/list/req_datetime" + # Test that present single empty string input yields empty list + r = client.post(url, data={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 # Test that present List[datetime] input yields input value v = [datetime.datetime(2024, 2, 10, 14, 32, 38), datetime.datetime(2024, 2, 10, 14, 32, 53)] @@ -835,6 +886,11 @@ def test_required_list_datetime(client): def test_required_list_date(client): url = "/form/list/req_date" + # Test that present single empty string input yields empty list + r = client.post(url, data={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 # Test that present List[date] input yields input value v = [datetime.date(2024, 2, 10), datetime.date(2024, 2, 11)] r = client.post(url, data={"v": [d.isoformat() for d in v]}) @@ -852,6 +908,11 @@ def test_required_list_date(client): def test_required_list_time(client): url = "/form/list/req_time" + # Test that present single empty string input yields empty list + r = client.post(url, data={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 # Test that present List[time] input yields input value v = [datetime.time(14, 37, 34), datetime.time(14, 37, 45)] r = client.post(url, data={"v": [d.isoformat() for d in v]}) @@ -867,19 +928,97 @@ def test_required_list_time(client): assert "error" in r.json -def test_optional_list(client): - url = "/form/list/optional" - # Test that missing input yields None +def test_required_list_dict(client): + url = "/form/list/req_dict" + # Test that present single empty string input yields empty list + r = client.post(url, data={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single dict input yields [input value] + v = {"hello": "world"} + r = client.post(url, data={"v": json.dumps(v)}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is dict + assert r.json["v"][0] == v + # Test that present dict input in multiple of the same form param yields [input values] + v = [{"one": "dict"}, {"two": "dict", "red": "dict"}, {"blue": "dict"}] + r = client.post(url, data={"v": [json.dumps(d) for d in v]}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 3 + list_assertion_helper(2, dict, v, r.json["v"]) + # Test that present non-dict list items yields error + r = client.post(url, data={"v": "a"}) + assert "error" in r.json + # Test that missing input yields error r = client.post(url) + assert "error" in r.json + + +def test_required_list_str_enum(client): + url = "/form/list/req_str_enum" + # Test that present single empty string input yields empty list + r = client.post(url, data={"v": ""}) assert "v" in r.json - assert r.json["v"] is None - # Test that present List[str] input yields input value - v = ["two", "tests"] + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single Fruits input yields [input value] + v = Fruits.APPLE + r = client.post(url, data={"v": v.value}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is str + assert r.json["v"][0] == v.value + # Test that present Fruits input in multiple of the same query param yields [input values] + v = [Fruits.APPLE.value, Fruits.ORANGE.value] r = client.post(url, data={"v": v}) assert "v" in r.json assert type(r.json["v"]) is list assert len(r.json["v"]) == 2 list_assertion_helper(2, str, v, r.json["v"]) + # Test that present non-Fruits list items yields error + r = client.post(url, data={"v": "a"}) + assert "error" in r.json + # Test that missing input yields error + r = client.post(url) + assert "error" in r.json + + +def test_required_list_int_enum(client): + url = "/form/list/req_int_enum" + # Test that present single empty string input yields empty list + r = client.post(url, data={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single Binary input yields [input value] + v = Binary.ZERO + r = client.post(url, data={"v": v.value}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is int + assert r.json["v"][0] == v.value + # Test that present Binary input in multiple of the same query param yields [input values] + v = [ + Binary.ONE.value, Binary.ZERO.value, Binary.ZERO.value, + Binary.ONE.value, Binary.ONE.value + ] + r = client.post(url, data={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 5 + list_assertion_helper(5, int, v, r.json["v"]) + # Test that present non-Binary list items yields error + r = client.post(url, data={"v": "crying zeros and I'm hearing"}) + assert "error" in r.json + # Test that missing input yields error + r = client.post(url) + assert "error" in r.json def test_list_default(client): diff --git a/flask_parameter_validation/test/test_json_params.py b/flask_parameter_validation/test/test_json_params.py index cf8a53f..79d944e 100644 --- a/flask_parameter_validation/test/test_json_params.py +++ b/flask_parameter_validation/test/test_json_params.py @@ -42,11 +42,6 @@ def test_optional_str(client): def test_optional_str_blank_none_unset(client, app): url = "/json/str/blank_none/unset" - # Test that FPV_BLANK_NONE runs as False by default - app.config.update({"FPV_BLANK_NONE": None}) - r = client.post(f"{url}", json={"v": ""}) - assert "v" in r.json - assert r.json["v"] == "" # Test that FPV_BLANK_NONE returns empty string when False app.config.update({"FPV_BLANK_NONE": False}) r = client.post(f"{url}", json={"v": ""}) @@ -57,15 +52,15 @@ def test_optional_str_blank_none_unset(client, app): r = client.post(f"{url}", json={"v": ""}) assert "v" in r.json assert r.json["v"] is None + # Test that FPV_BLANK_NONE runs as False by default + app.config.pop("FPV_BLANK_NONE", None) + r = client.post(f"{url}", json={"v": ""}) + assert "v" in r.json + assert r.json["v"] == "" def test_optional_str_blank_none_true(client, app): url = "/json/str/blank_none/true" - # Test that unset FPV_BLANK_NONE can be overridden to True per-route - app.config.update({"FPV_BLANK_NONE": None}) - r = client.post(f"{url}", json={"v": ""}) - assert "v" in r.json - assert r.json["v"] is None # Test that FPV_BLANK_NONE of False can be overridden to True per-route app.config.update({"FPV_BLANK_NONE": False}) r = client.post(f"{url}", json={"v": ""}) @@ -76,15 +71,15 @@ def test_optional_str_blank_none_true(client, app): r = client.post(f"{url}", json={"v": ""}) assert "v" in r.json assert r.json["v"] is None + # Test that unset FPV_BLANK_NONE can be overridden to True per-route + app.config.pop("FPV_BLANK_NONE", None) + r = client.post(f"{url}", json={"v": ""}) + assert "v" in r.json + assert r.json["v"] is None def test_optional_str_blank_none_false(client, app): url = "/json/str/blank_none/false" - # Test that unset FPV_BLANK_NONE can be 'overridden' to False per-route - app.config.update({"FPV_BLANK_NONE": None}) - r = client.post(f"{url}", json={"v": ""}) - assert "v" in r.json - assert r.json["v"] == "" # Test that FPV_BLANK_NONE of False can be 'overridden' to False per-route app.config.update({"FPV_BLANK_NONE": False}) r = client.post(f"{url}", json={"v": ""}) @@ -95,6 +90,11 @@ def test_optional_str_blank_none_false(client, app): r = client.post(f"{url}", json={"v": ""}) assert "v" in r.json assert r.json["v"] == "" + # Test that unset FPV_BLANK_NONE can be 'overridden' to False per-route + app.config.pop("FPV_BLANK_NONE", None) + r = client.post(f"{url}", json={"v": ""}) + assert "v" in r.json + assert r.json["v"] == "" def test_str_default(client): @@ -713,6 +713,11 @@ def test_union_func(client): # List Validation def test_required_list_str(client): url = "/json/list/req_str" + # Test that preset empty list input yields input value + r = client.post(url, json={"v": []}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 # Test that present List[str] input yields input value v = ["x", "y"] r = client.post(url, json={"v": v}) @@ -845,21 +850,6 @@ def test_required_list_time(client): assert "error" in r.json -def test_optional_list(client): - url = "/json/list/optional" - # Test that missing input yields None - r = client.post(url) - assert "v" in r.json - assert r.json["v"] is None - # Test that present List[str] input yields input value - v = ["two", "tests"] - r = client.post(url, json={"v": v}) - assert "v" in r.json - assert type(r.json["v"]) is list - assert len(r.json["v"]) == 2 - list_assertion_helper(2, str, v, r.json["v"]) - - def test_list_default(client): url = "/json/list/default" # Test that missing input for required and optional yields default values @@ -948,6 +938,7 @@ def test_list_json_schema(client): # Test that passing schema validation yields input v = [{"user_id": 1, "first_name": "John", "last_name": "Doe", "tags": ["test_account"]}] r = client.post(url, json={"v": v}) + print(r.json) assert type(r.json["v"]) is list assert len(r.json["v"]) == 1 assert type(r.json["v"][0]) is dict diff --git a/flask_parameter_validation/test/test_query_params.py b/flask_parameter_validation/test/test_query_params.py index f6a11a0..07d2d65 100644 --- a/flask_parameter_validation/test/test_query_params.py +++ b/flask_parameter_validation/test/test_query_params.py @@ -1,5 +1,6 @@ # String Validation import datetime +import json import uuid from typing import Type, List, Optional @@ -88,11 +89,6 @@ def test_optional_str_async_decorator(client): def test_optional_str_blank_none_unset(client, app): url = "/query/str/blank_none/unset" - # Test that FPV_BLANK_NONE runs as False by default - app.config.update({"FPV_BLANK_NONE": None}) - r = client.get(f"{url}", query_string={"v": ""}) - assert "v" in r.json - assert r.json["v"] == "" # Test that FPV_BLANK_NONE returns empty string when False app.config.update({"FPV_BLANK_NONE": False}) r = client.get(f"{url}", query_string={"v": ""}) @@ -103,15 +99,15 @@ def test_optional_str_blank_none_unset(client, app): r = client.get(f"{url}", query_string={"v": ""}) assert "v" in r.json assert r.json["v"] is None + # Test that FPV_BLANK_NONE runs as False by default + app.config.pop("FPV_BLANK_NONE", None) + r = client.get(f"{url}", query_string={"v": ""}) + assert "v" in r.json + assert r.json["v"] == "" def test_optional_str_blank_none_true(client, app): url = "/query/str/blank_none/true" - # Test that unset FPV_BLANK_NONE can be overridden to True per-route - app.config.update({"FPV_BLANK_NONE": None}) - r = client.get(f"{url}", query_string={"v": ""}) - assert "v" in r.json - assert r.json["v"] is None # Test that FPV_BLANK_NONE of False can be overridden to True per-route app.config.update({"FPV_BLANK_NONE": False}) r = client.get(f"{url}", query_string={"v": ""}) @@ -122,15 +118,15 @@ def test_optional_str_blank_none_true(client, app): r = client.get(f"{url}", query_string={"v": ""}) assert "v" in r.json assert r.json["v"] is None + # Test that unset FPV_BLANK_NONE can be overridden to True per-route + app.config.pop("FPV_BLANK_NONE", None) + r = client.get(f"{url}", query_string={"v": ""}) + assert "v" in r.json + assert r.json["v"] is None def test_optional_str_blank_none_false(client, app): url = "/query/str/blank_none/false" - # Test that unset FPV_BLANK_NONE can be 'overridden' to False per-route - app.config.update({"FPV_BLANK_NONE": None}) - r = client.get(f"{url}", query_string={"v": ""}) - assert "v" in r.json - assert r.json["v"] == "" # Test that FPV_BLANK_NONE of False can be 'overridden' to False per-route app.config.update({"FPV_BLANK_NONE": False}) r = client.get(f"{url}", query_string={"v": ""}) @@ -141,6 +137,11 @@ def test_optional_str_blank_none_false(client, app): r = client.get(f"{url}", query_string={"v": ""}) assert "v" in r.json assert r.json["v"] == "" + # Test that unset FPV_BLANK_NONE can be 'overridden' to False per-route + app.config.pop("FPV_BLANK_NONE", None) + r = client.get(f"{url}", query_string={"v": ""}) + assert "v" in r.json + assert r.json["v"] == "" def test_str_default(client): @@ -1213,8 +1214,13 @@ def test_union_func(client): # List Validation -def test_required_list_str(client): +def test_required_list_str(client, app): url = "/query/list/req_str" + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 # Test that present single str input yields [input value] r = client.get(url, query_string={"v": "w"}) assert "v" in r.json @@ -1224,7 +1230,26 @@ def test_required_list_str(client): assert r.json["v"][0] == "w" # Test that present CSV str input yields [input values] v = ["x", "y"] - r = client.get(url, query_string={"v": v}) + r = client.get(url, query_string={"v": ','.join(v)}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that present list with 2 CSV empty strings yields two empty strings + r = client.get(url, query_string={"v": ","}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + # Test that present str input in multiple of the same query param yields [input values] + v = ["z", "a"] + r = client.get(f"{url}", query_string={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that present empty str input in multiple of the same query param yields [input values] + v = ["", ""] + r = client.get(f"{url}", query_string={"v": v}) assert "v" in r.json assert type(r.json["v"]) is list assert len(r.json["v"]) == 2 @@ -1236,6 +1261,11 @@ def test_required_list_str(client): def test_required_list_str_decorator(client): url = "/query/list/decorator/req_str" + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 # Test that present single str input yields [input value] r = client.get(url, query_string={"v": "w"}) assert "v" in r.json @@ -1245,7 +1275,26 @@ def test_required_list_str_decorator(client): assert r.json["v"][0] == "w" # Test that present CSV str input yields [input values] v = ["x", "y"] - r = client.get(url, query_string={"v": v}) + r = client.get(url, query_string={"v": ','.join(v)}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that present list with 2 CSV empty strings yields two empty strings + r = client.get(url, query_string={"v": ","}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + # Test that present str input in multiple of the same query param yields [input values] + v = ["z", "a"] + r = client.get(f"{url}", query_string={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that present empty str input in multiple of the same query param yields [input values] + v = ["", ""] + r = client.get(f"{url}", query_string={"v": v}) assert "v" in r.json assert type(r.json["v"]) is list assert len(r.json["v"]) == 2 @@ -1257,6 +1306,11 @@ def test_required_list_str_decorator(client): def test_required_list_str_async_decorator(client): url = "/query/list/async_decorator/req_str" + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 # Test that present single str input yields [input value] r = client.get(url, query_string={"v": "w"}) assert "v" in r.json @@ -1266,7 +1320,26 @@ def test_required_list_str_async_decorator(client): assert r.json["v"][0] == "w" # Test that present CSV str input yields [input values] v = ["x", "y"] - r = client.get(url, query_string={"v": v}) + r = client.get(url, query_string={"v": ','.join(v)}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that present list with 2 CSV empty strings yields two empty strings + r = client.get(url, query_string={"v": ","}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + # Test that present str input in multiple of the same query param yields [input values] + v = ["z", "a"] + r = client.get(f"{url}", query_string={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that present empty str input in multiple of the same query param yields [input values] + v = ["", ""] + r = client.get(f"{url}", query_string={"v": v}) assert "v" in r.json assert type(r.json["v"]) is list assert len(r.json["v"]) == 2 @@ -1276,71 +1349,147 @@ def test_required_list_str_async_decorator(client): assert "error" in r.json -def test_required_list_str_multiple_params(client): - url = "/query/list/req_str" - # Test that present single str input yields [input value] - r = client.get(url, query_string={"v": "w"}) +def test_required_list_str_disable_query_csv_unset(client, app): + url = "/query/list/disable_query_csv/unset" + # Test that FPV_LIST_DISABLE_QUERY_CSV returns array of two strings when False + app.config.update({"FPV_LIST_DISABLE_QUERY_CSV": False}) + v = ["b", "c"] + r = client.get(f"{url}", query_string={"v": ",".join(v)}) assert "v" in r.json assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that FPV_LIST_DISABLE_QUERY_CSV returns array of single string when True + app.config.update({"FPV_LIST_DISABLE_QUERY_CSV": True}) + v = "d,e" + r = client.get(f"{url}", query_string={"v": v}) + assert "v" in r.json + print(r.json["v"]) + assert type(r.json["v"]) is list assert len(r.json["v"]) == 1 - assert type(r.json["v"][0]) is str - assert r.json["v"][0] == "w" - # Test that present multiple separate str inputs yields [input values] - v = ["x", "y"] - r = client.get(f"{url}?v=x&v=y") + list_assertion_helper(1, str, [v], r.json["v"]) + # Test that FPV_LIST_DISABLE_QUERY_CSV runs as False by default + app.config.pop("FPV_LIST_DISABLE_QUERY_CSV", None) + v = ["f", "g"] + r = client.get(f"{url}", query_string={"v": ",".join(v)}) assert "v" in r.json assert type(r.json["v"]) is list assert len(r.json["v"]) == 2 list_assertion_helper(2, str, v, r.json["v"]) - # Test that missing input yields error - r = client.get(url) - assert "error" in r.json -def test_required_list_str_multiple_params_decorator(client): - url = "/query/list/decorator/req_str" - # Test that present single str input yields [input value] - r = client.get(url, query_string={"v": "w"}) +def test_required_list_str_disable_query_csv_true(client, app): + url = "/query/list/disable_query_csv/true" + # Test that FPV_LIST_DISABLE_QUERY_CSV of False can be overridden to True per-route + app.config.update({"FPV_LIST_DISABLE_QUERY_CSV": False}) + v = "h,i" + r = client.get(f"{url}", query_string={"v": v}) assert "v" in r.json + print(r.json["v"]) assert type(r.json["v"]) is list assert len(r.json["v"]) == 1 - assert type(r.json["v"][0]) is str - assert r.json["v"][0] == "w" - # Test that present multiple separate str inputs yields [input values] - v = ["x", "y"] - r = client.get(f"{url}?v=x&v=y") + list_assertion_helper(1, str, [v], r.json["v"]) + # Test that FPV_LIST_DISABLE_QUERY_CSV of True can be 'overridden' to True per-route + app.config.update({"FPV_LIST_DISABLE_QUERY_CSV": True}) + v = "j,k" + r = client.get(f"{url}", query_string={"v": v}) + assert "v" in r.json + print(r.json["v"]) + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + list_assertion_helper(1, str, [v], r.json["v"]) + # Test that unset FPV_LIST_DISABLE_QUERY_CSV can be overridden to True per-route + app.config.pop("FPV_LIST_DISABLE_QUERY_CSV", None) + v = "l,m" + r = client.get(f"{url}", query_string={"v": v}) + assert "v" in r.json + print(r.json["v"]) + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + list_assertion_helper(1, str, [v], r.json["v"]) + + +def test_required_list_str_disable_query_csv_false(client, app): + url = "/query/list/disable_query_csv/false" + # Test that FPV_LIST_DISABLE_QUERY_CSV of False can be 'overridden' to False per-route + app.config.update({"FPV_LIST_DISABLE_QUERY_CSV": False}) + v = ["n", "o"] + r = client.get(f"{url}", query_string={"v": ",".join(v)}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that FPV_LIST_DISABLE_QUERY_CSV of True can be overridden to False per-route + app.config.update({"FPV_LIST_DISABLE_QUERY_CSV": True}) + v = ["p", "q"] + r = client.get(f"{url}", query_string={"v": ",".join(v)}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that unset FPV_LIST_DISABLE_QUERY_CSV can be 'overridden' to False per-route + app.config.pop("FPV_LIST_DISABLE_QUERY_CSV", None) + v = ["r", "s"] + r = client.get(f"{url}", query_string={"v": ",".join(v)}) assert "v" in r.json assert type(r.json["v"]) is list assert len(r.json["v"]) == 2 list_assertion_helper(2, str, v, r.json["v"]) - # Test that missing input yields error - r = client.get(url) - assert "error" in r.json -def test_required_list_str_multiple_params_async_decorator(client): - url = "/query/list/async_decorator/req_str" +def test_optional_list_str(client): + url = "/query/list/opt_str" + # Test that missing input yields None + r = client.get(url) + assert "v" in r.json + assert r.json["v"] is None + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 # Test that present single str input yields [input value] - r = client.get(url, query_string={"v": "w"}) + r = client.get(url, query_string={"v": "t"}) assert "v" in r.json assert type(r.json["v"]) is list assert len(r.json["v"]) == 1 assert type(r.json["v"][0]) is str - assert r.json["v"][0] == "w" - # Test that present multiple separate str inputs yields [input values] - v = ["x", "y"] - r = client.get(f"{url}?v=x&v=y") + assert r.json["v"][0] == "t" + # Test that present CSV str input yields [input values] + v = ["u", "v"] + r = client.get(url, query_string={"v": ','.join(v)}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that present list with 2 CSV empty strings yields two empty strings + r = client.get(url, query_string={"v": ","}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + # Test that present str input in multiple of the same query param yields [input values] + v = ["w", "x"] + r = client.get(f"{url}", query_string={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that present empty str input in multiple of the same query param yields [input values] + v = ["", ""] + r = client.get(f"{url}", query_string={"v": v}) assert "v" in r.json assert type(r.json["v"]) is list assert len(r.json["v"]) == 2 list_assertion_helper(2, str, v, r.json["v"]) - # Test that missing input yields error - r = client.get(url) - assert "error" in r.json def test_required_list_int(client): url = "/query/list/req_int" + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 # Test that present single int input yields [input value] r = client.get(url, query_string={"v": -1}) assert "v" in r.json @@ -1350,6 +1499,13 @@ def test_required_list_int(client): assert r.json["v"][0] == -1 # Test that present CSV int input yields [input values] v = [0, 1] + r = client.get(url, query_string={"v": ','.join([str(i) for i in v])}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, int, v, r.json["v"]) + # Test that present int input in multiple of the same param yields [input values] + v = [2, 3] r = client.get(url, query_string={"v": v}) assert "v" in r.json assert type(r.json["v"]) is list @@ -1363,8 +1519,49 @@ def test_required_list_int(client): assert "error" in r.json +def test_optional_list_int(client): + url = "/query/list/opt_int" + # Test the missing input yields None + r = client.get(url) + assert "v" in r.json + assert r.json["v"] is None + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single int input yields [input value] + r = client.get(url, query_string={"v": -1}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is int + assert r.json["v"][0] == -1 + # Test that present CSV int input yields [input values] + v = [0, 1] + r = client.get(url, query_string={"v": ','.join([str(i) for i in v])}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, int, v, r.json["v"]) + # Test that present int input in multiple of the same param yields [input values] + v = [2, 3] + r = client.get(url, query_string={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, int, v, r.json["v"]) + # Test that present non-int list items yields error + r = client.get(url, query_string={"v": "a"}) + assert "error" in r.json + def test_required_list_bool(client): url = "/query/list/req_bool" + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 # Test that present single bool input yields [input value] r = client.get(url, query_string={"v": True}) assert "v" in r.json @@ -1379,6 +1576,13 @@ def test_required_list_bool(client): assert type(r.json["v"]) is list assert len(r.json["v"]) == 2 list_assertion_helper(2, bool, v, r.json["v"]) + # Test that present bool input in multiple of the same param yields [input values] + v = [True, False] + r = client.get(url, query_string={"v": ','.join([str(b) for b in v])}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, bool, v, r.json["v"]) # Test that present non-bool list items yields error r = client.get(url, query_string={"v": "a"}) assert "error" in r.json @@ -1387,109 +1591,72 @@ def test_required_list_bool(client): assert "error" in r.json -# List[Union[]] not currently supported -# def test_required_list_union(client): -# url = "/query/list/req_union" -# # Test that present single int input yields [input value] -# r = client.get(f"{url}?v=2") -# assert "v" in r.json -# assert type(r.json["v"]) is list -# assert len(r.json["v"]) == 1 -# assert type(r.json["v"][0]) is int -# assert r.json["v"][0] == 2 -# # Test that present single float input yields [input value] -# r = client.get(f"{url}?v=3.14") -# assert "v" in r.json -# assert type(r.json["v"]) is list -# assert len(r.json["v"]) == 1 -# assert type(r.json["v"][0]) is float -# assert r.json["v"][0] == 3.14 -# # Test that present CSV int/float input yields [input values] -# r = client.get(f"{url}?v=4,5.62") -# assert "v" in r.json -# assert type(r.json["v"]) is list -# assert len(r.json["v"]) == 2 -# assert type(r.json["v"][0]) is int -# assert type(r.json["v"][1]) is float -# assert r.json["v"][0] == 4 -# assert r.json["v"][1] == 5.62 -# # Test that present non-int/float list items yields error -# r = client.get(f"{url}?v=a") -# assert "error" in r.json -# # Test that missing input yields error -# r = client.get(url) -# assert "error" in r.json - - -def test_required_list_datetime(client): - url = "/query/list/req_datetime" - # Test that present single datetime input yields [input value] - v0 = datetime.datetime(2024, 2, 10, 14, 31, 47) - r = client.get(url, query_string={"v": v0.isoformat()}) +def test_optional_list_bool(client): + url = "/query/list/opt_bool" + # Test the missing input yields None + r = client.get(url) assert "v" in r.json - assert type(r.json["v"]) is list - assert len(r.json["v"]) == 1 - assert type(r.json["v"][0]) is str - assert r.json["v"][0] == v0.isoformat() - # Test that present CSV datetime input yields [input values] - v = [datetime.datetime(2024, 2, 10, 14, 32, 38), - datetime.datetime(2024, 2, 10, 14, 32, 53)] - r = client.get(url, query_string={"v": [d.isoformat() for d in v]}) + assert r.json["v"] is None + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) assert "v" in r.json assert type(r.json["v"]) is list - assert len(r.json["v"]) == 2 - list_assertion_helper(2, str, v, r.json["v"], expected_call="isoformat") - # Test that present non-datetime list items yields error - r = client.get(url, query_string={"v": "a"}) - assert "error" in r.json - # Test that missing input yields error - r = client.get(url) - assert "error" in r.json - - -def test_required_list_date(client): - url = "/query/list/req_date" - # Test that present single date input yields [input value] - v0 = datetime.date(2024, 2, 9) - r = client.get(url, query_string={"v": v0.isoformat()}) + assert len(r.json["v"]) == 0 + # Test that present single bool input yields [input value] + r = client.get(url, query_string={"v": True}) assert "v" in r.json assert type(r.json["v"]) is list assert len(r.json["v"]) == 1 - assert type(r.json["v"][0]) is str - assert r.json["v"][0] == v0.isoformat() - # Test that present CSV date input yields [input values] - v = [datetime.date(2024, 2, 10), datetime.date(2024, 2, 11)] - r = client.get(url, query_string={"v": [d.isoformat() for d in v]}) + assert type(r.json["v"][0]) is bool + assert r.json["v"][0] is True + # Test that present CSV bool input yields [input values] + v = [False, True] + r = client.get(url, query_string={"v": v}) assert "v" in r.json assert type(r.json["v"]) is list assert len(r.json["v"]) == 2 - list_assertion_helper(2, str, v, r.json["v"], expected_call="isoformat") - # Test that present non-date list items yields error + list_assertion_helper(2, bool, v, r.json["v"]) + # Test that present bool input in multiple of the same param yields [input values] + v = [True, False] + r = client.get(url, query_string={"v": ','.join([str(b) for b in v])}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, bool, v, r.json["v"]) + # Test that present non-bool list items yields error r = client.get(url, query_string={"v": "a"}) assert "error" in r.json - # Test that missing input yields error - r = client.get(url) - assert "error" in r.json -def test_required_list_time(client): - url = "/query/list/req_time" - # Test that present single time input yields [input value] - v0 = datetime.time(14, 37, 2) - r = client.get(url, query_string={"v": v0.isoformat()}) +def test_required_list_float(client): + url = "/query/list/req_float" + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single float input yields [input value] + r = client.get(url, query_string={"v": 7.39}) assert "v" in r.json assert type(r.json["v"]) is list assert len(r.json["v"]) == 1 - assert type(r.json["v"][0]) is str - assert r.json["v"][0] == v0.isoformat() - # Test that present CSV time input yields [input values] - v = [datetime.time(14, 37, 34), datetime.time(14, 37, 45)] - r = client.get(url, query_string={"v": [d.isoformat() for d in v]}) + assert type(r.json["v"][0]) is float + assert r.json["v"][0] == 7.39 + # Test that present CSV int input yields [input values] + v = [1.2, 3.4] + r = client.get(url, query_string={"v": ','.join([str(i) for i in v])}) assert "v" in r.json assert type(r.json["v"]) is list assert len(r.json["v"]) == 2 - list_assertion_helper(2, str, v, r.json["v"], expected_call="isoformat") - # Test that present non-time list items yields error + list_assertion_helper(2, float, v, r.json["v"]) + # Test that present int input in multiple of the same param yields [input values] + v = [5.6, 7.8] + r = client.get(url, query_string={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, float, v, r.json["v"]) + # Test that present non-int list items yields error r = client.get(url, query_string={"v": "a"}) assert "error" in r.json # Test that missing input yields error @@ -1497,26 +1664,650 @@ def test_required_list_time(client): assert "error" in r.json -def test_optional_list(client): - url = "/query/list/optional" - # Test that missing input yields None +def test_optional_list_float(client): + url = "/query/list/opt_float" + # Test the missing input yields None r = client.get(url) assert "v" in r.json assert r.json["v"] is None - # Test that present str input yields [input value] - r = client.get(url, query_string={"v": "test"}) + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single float input yields [input value] + r = client.get(url, query_string={"v": 7.39}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is float + assert r.json["v"][0] == 7.39 + # Test that present CSV int input yields [input values] + v = [1.2, 3.4] + r = client.get(url, query_string={"v": ','.join([str(i) for i in v])}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, float, v, r.json["v"]) + # Test that present int input in multiple of the same param yields [input values] + v = [5.6, 7.8] + r = client.get(url, query_string={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, float, v, r.json["v"]) + # Test that present non-int list items yields error + r = client.get(url, query_string={"v": "a"}) + assert "error" in r.json + + +def test_required_list_union(client): + url = "/query/list/req_union" + # Test that present single int input yields [input value] + r = client.get(f"{url}?v=2") + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is int + assert r.json["v"][0] == 2 + # Test that present single float input yields [input value] + r = client.get(f"{url}?v=3.14") + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is float + assert r.json["v"][0] == 3.14 + # Test that present CSV int/float input yields [input values] + r = client.get(f"{url}?v=4,5.62") + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + assert type(r.json["v"][0]) is int + assert type(r.json["v"][1]) is float + assert r.json["v"][0] == 4 + assert r.json["v"][1] == 5.62 + # Test that present non-int/float list items yields error + r = client.get(f"{url}?v=a") + assert "error" in r.json + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + +def test_required_list_union_everything(client): + url = "/query/list/req_union_everything" + v = [ + "testing", + 5, + True, + 3.14, + datetime.datetime(2025, 4, 20, 15, 13, 32).isoformat(), + datetime.date(2025, 4, 20).isoformat(), + datetime.time(15, 14, 22).isoformat(), + json.dumps({"i": "am", "a": "dictionary"}), + Fruits.APPLE.value, + Binary.ONE.value, + str(uuid.uuid4()) + ] + r = client.get(f"{url}", query_string={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(v) == len(r.json["v"]) + for i in range(len(v)): + if i == 7: + assert r.json["v"][i] == json.loads(v[i]) + else: + assert r.json["v"][i] == v[i] + +def test_optional_list_union(client): + url = "/query/list/opt_union" + # Test the missing input yields None + r = client.get(url) + assert "v" in r.json + assert r.json["v"] is None + # Test that present single int input yields [input value] + r = client.get(f"{url}?v=2") + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is int + assert r.json["v"][0] == 2 + # Test that present single bool input yields [input value] + r = client.get(f"{url}?v=true") + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is bool + assert r.json["v"][0] == True + # Test that present CSV int/bool input yields [input values] + r = client.get(f"{url}?v=4,false") + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + assert type(r.json["v"][0]) is int + assert type(r.json["v"][1]) is bool + assert r.json["v"][0] == 4 + assert r.json["v"][1] == False + # Test that present non-int/bool list items yields error + r = client.get(f"{url}?v=a") + assert "error" in r.json + +def test_required_list_datetime(client): + url = "/query/list/req_datetime" + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single datetime input yields [input value] + v0 = datetime.datetime(2024, 2, 10, 14, 31, 47) + r = client.get(url, query_string={"v": v0.isoformat()}) assert "v" in r.json assert type(r.json["v"]) is list assert len(r.json["v"]) == 1 assert type(r.json["v"][0]) is str - assert r.json["v"][0] == "test" - # Test that present CSV str input yields [input values] - v = ["two", "tests"] + assert r.json["v"][0] == v0.isoformat() + # Test that present CSV datetime input yields [input values] + v = [datetime.datetime(2024, 2, 10, 14, 32, 38), + datetime.datetime(2024, 2, 10, 14, 32, 53)] + r = client.get(url, query_string={"v": ','.join([d.isoformat() for d in v])}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"], expected_call="isoformat") + # Test that present datetime input in multiple of the same query param yields [input values] + v = [datetime.datetime(2025, 4, 18, 16, 58, 19), + datetime.datetime(2025, 4, 18, 16, 58, 39)] + r = client.get(url, query_string={"v": [d.isoformat() for d in v]}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"], expected_call="isoformat") + # Test that present non-datetime list items yields error + r = client.get(url, query_string={"v": "a"}) + assert "error" in r.json + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + + +def test_optional_list_datetime(client): + url = "/query/list/opt_datetime" + # Test the missing input yields None + r = client.get(url) + assert "v" in r.json + assert r.json["v"] is None + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single datetime input yields [input value] + v0 = datetime.datetime(2024, 2, 10, 14, 31, 47) + r = client.get(url, query_string={"v": v0.isoformat()}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is str + assert r.json["v"][0] == v0.isoformat() + # Test that present CSV datetime input yields [input values] + v = [datetime.datetime(2024, 2, 10, 14, 32, 38), + datetime.datetime(2024, 2, 10, 14, 32, 53)] + r = client.get(url, query_string={"v": ','.join([d.isoformat() for d in v])}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"], expected_call="isoformat") + # Test that present datetime input in multiple of the same query param yields [input values] + v = [datetime.datetime(2025, 4, 18, 16, 58, 19), + datetime.datetime(2025, 4, 18, 16, 58, 39)] + r = client.get(url, query_string={"v": [d.isoformat() for d in v]}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"], expected_call="isoformat") + # Test that present non-datetime list items yields error + r = client.get(url, query_string={"v": "a"}) + assert "error" in r.json + + +def test_required_list_date(client): + url = "/query/list/req_date" + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single date input yields [input value] + v0 = datetime.date(2024, 2, 9) + r = client.get(url, query_string={"v": v0.isoformat()}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is str + assert r.json["v"][0] == v0.isoformat() + # Test that present CSV date input yields [input values] + v = [datetime.date(2024, 2, 10), datetime.date(2024, 2, 11)] + r = client.get(url, query_string={"v": ','.join([d.isoformat() for d in v])}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"], expected_call="isoformat") + # Test that present date input in multiple of the same query param yields [input values] + v = [datetime.date(2025, 2, 18), datetime.date(2025, 2, 19)] + r = client.get(url, query_string={"v": [d.isoformat() for d in v]}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"], expected_call="isoformat") + # Test that present non-date list items yields error + r = client.get(url, query_string={"v": "a"}) + assert "error" in r.json + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + + +def test_optional_list_date(client): + url = "/query/list/opt_date" + # Test the missing input yields None + r = client.get(url) + assert "v" in r.json + assert r.json["v"] is None + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single date input yields [input value] + v0 = datetime.date(2024, 2, 9) + r = client.get(url, query_string={"v": v0.isoformat()}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is str + assert r.json["v"][0] == v0.isoformat() + # Test that present CSV date input yields [input values] + v = [datetime.date(2024, 2, 10), datetime.date(2024, 2, 11)] + r = client.get(url, query_string={"v": ','.join([d.isoformat() for d in v])}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"], expected_call="isoformat") + # Test that present date input in multiple of the same query param yields [input values] + v = [datetime.date(2025, 2, 18), datetime.date(2025, 2, 19)] + r = client.get(url, query_string={"v": [d.isoformat() for d in v]}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"], expected_call="isoformat") + # Test that present non-date list items yields error + r = client.get(url, query_string={"v": "a"}) + assert "error" in r.json + + +def test_required_list_time(client): + url = "/query/list/req_time" + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single time input yields [input value] + v0 = datetime.time(14, 37, 2) + r = client.get(url, query_string={"v": v0.isoformat()}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is str + assert r.json["v"][0] == v0.isoformat() + # Test that present CSV time input yields [input values] + v = [datetime.time(14, 37, 34), datetime.time(14, 37, 45)] + r = client.get(url, query_string={"v": ','.join([d.isoformat() for d in v])}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"], expected_call="isoformat") + # Test that present time input in multiple of the same query param yields [input values] + v = [datetime.time(17, 2, 45), datetime.time(17, 2, 55)] + r = client.get(url, query_string={"v": [d.isoformat() for d in v]}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"], expected_call="isoformat") + # Test that present non-time list items yields error + r = client.get(url, query_string={"v": "a"}) + assert "error" in r.json + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + + +def test_optional_list_time(client): + url = "/query/list/opt_time" + # Test the missing input yields None + r = client.get(url) + assert "v" in r.json + assert r.json["v"] is None + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single time input yields [input value] + v0 = datetime.time(14, 37, 2) + r = client.get(url, query_string={"v": v0.isoformat()}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is str + assert r.json["v"][0] == v0.isoformat() + # Test that present CSV time input yields [input values] + v = [datetime.time(14, 37, 34), datetime.time(14, 37, 45)] + r = client.get(url, query_string={"v": ','.join([d.isoformat() for d in v])}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"], expected_call="isoformat") + # Test that present time input in multiple of the same query param yields [input values] + v = [datetime.time(17, 2, 45), datetime.time(17, 2, 55)] + r = client.get(url, query_string={"v": [d.isoformat() for d in v]}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"], expected_call="isoformat") + # Test that present non-time list items yields error + r = client.get(url, query_string={"v": "a"}) + assert "error" in r.json + + +# list[dict] is not supported for Query unless query_list_disable_csv is set +def test_required_list_dict(client): + url = "/query/list/req_dict" + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single dict input yields [input value] + v = {"hello": "world"} + r = client.get(url, query_string={"v": json.dumps(v)}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is dict + assert r.json["v"][0] == v + # Test that present dict input in multiple of the same query param yields [input values] + v = [{"one": "dict"}, {"two": "dict", "red": "dict"}, {"blue": "dict"}] + r = client.get(url, query_string={"v": [json.dumps(d) for d in v]}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 3 + list_assertion_helper(2, dict, v, r.json["v"]) + # Test that present non-dict list items yields error + r = client.get(url, query_string={"v": "a"}) + assert "error" in r.json + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + + +def test_optional_list_dict(client): + url = "/query/list/opt_dict" + # Test the missing input yields None + r = client.get(url) + assert "v" in r.json + assert r.json["v"] is None + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single dict input yields [input value] + v = {"hello": "world"} + r = client.get(url, query_string={"v": json.dumps(v)}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is dict + assert r.json["v"][0] == v + # Test that present dict input in multiple of the same query param yields [input values] + v = [{"one": "dict"}, {"two": "dict", "red": "dict"}, {"blue": "dict"}] + r = client.get(url, query_string={"v": [json.dumps(d) for d in v]}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 3 + list_assertion_helper(3, dict, v, r.json["v"]) + # Test that present non-dict list items yields error + r = client.get(url, query_string={"v": "a"}) + assert "error" in r.json + + +def test_required_list_str_enum(client): + url = "/query/list/req_str_enum" + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single Fruits input yields [input value] + v = Fruits.APPLE + r = client.get(url, query_string={"v": v.value}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is str + assert r.json["v"][0] == v.value + # Test that present CSV Fruits input yields [input values] + v = [Fruits.APPLE.value, Fruits.ORANGE.value] + r = client.get(url, query_string={"v": ','.join([e for e in v])}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that present Fruits input in multiple of the same query param yields [input values] + v = [Fruits.APPLE.value, Fruits.ORANGE.value] + r = client.get(url, query_string={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that present non-Fruits list items yields error + r = client.get(url, query_string={"v": "a"}) + assert "error" in r.json + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + + +def test_optional_list_str_enum(client): + url = "/query/list/opt_str_enum" + # Test the missing input yields None + r = client.get(url) + assert "v" in r.json + assert r.json["v"] is None + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single Fruits input yields [input value] + v = Fruits.APPLE + r = client.get(url, query_string={"v": v.value}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is str + assert r.json["v"][0] == v.value + # Test that present CSV Fruits input yields [input values] + v = [Fruits.ORANGE.value, Fruits.APPLE.value] + r = client.get(url, query_string={"v": ','.join([e for e in v])}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that present Fruits input in multiple of the same query param yields [input values] + v = [Fruits.APPLE.value, Fruits.ORANGE.value] r = client.get(url, query_string={"v": v}) assert "v" in r.json assert type(r.json["v"]) is list assert len(r.json["v"]) == 2 list_assertion_helper(2, str, v, r.json["v"]) + # Test that present non-Fruits list items yields error + r = client.get(url, query_string={"v": "a"}) + assert "error" in r.json + + +def test_required_list_int_enum(client): + url = "/query/list/req_int_enum" + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single Binary input yields [input value] + v = Binary.ZERO + r = client.get(url, query_string={"v": v.value}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is int + assert r.json["v"][0] == v.value + # Test that present CSV Binary input yields [input values] + v = [Binary.ONE.value, Binary.ONE.value] + r = client.get(url, query_string={"v": ','.join([str(e) for e in v])}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, int, v, r.json["v"]) + # Test that present Binary input in multiple of the same query param yields [input values] + v = [ + Binary.ONE.value, Binary.ZERO.value, Binary.ZERO.value, + Binary.ONE.value, Binary.ONE.value + ] + r = client.get(url, query_string={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 5 + list_assertion_helper(5, int, v, r.json["v"]) + # Test that present non-Binary list items yields error + r = client.get(url, query_string={"v": "crying zeros and I'm hearing"}) + assert "error" in r.json + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + + +def test_optional_list_int_enum(client): + url = "/query/list/opt_int_enum" + # Test the missing input yields None + r = client.get(url) + assert "v" in r.json + assert r.json["v"] is None + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single Binary input yields [input value] + v = Binary.ONE + r = client.get(url, query_string={"v": v.value}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is int + assert r.json["v"][0] == v.value + # Test that present CSV Binary input yields [input values] + v = [Binary.ONE.value]*2 + r = client.get(url, query_string={"v": ','.join([str(e) for e in v])}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, int, v, r.json["v"]) + # Test that present Binary input in multiple of the same query param yields [input values] + v = [Binary.ONE.value]*2 + r = client.get(url, query_string={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, int, v, r.json["v"]) + # Test that present non-Binary list items yields error + r = client.get(url, query_string={"v": "Cut my somersaults, sign my backflip"}) + assert "error" in r.json + + +def test_required_list_uuid(client): + url = "/query/list/req_uuid" + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single UUID input yields [input value] + v = str(uuid.uuid4()) + r = client.get(url, query_string={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is str + assert r.json["v"][0] == v + # Test that present CSV UUID input yields [input values] + v = [str(uuid.uuid4()), str(uuid.uuid4())] + r = client.get(url, query_string={"v": ','.join([u for u in v])}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that present UUID input in multiple of the same query param yields [input values] + v = [str(uuid.uuid4()), str(uuid.uuid4())] + r = client.get(url, query_string={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that present non-UUID list items yields error + r = client.get(url, query_string={"v": "a"}) + assert "error" in r.json + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + + +def test_optional_list_uuid(client): + url = "/query/list/opt_uuid" + # Test the missing input yields None + r = client.get(url) + assert "v" in r.json + assert r.json["v"] is None + # Test that present single empty string input yields empty list + r = client.get(url, query_string={"v": ""}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 0 + # Test that present single UUID input yields [input value] + v = str(uuid.uuid4()) + r = client.get(url, query_string={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 1 + assert type(r.json["v"][0]) is str + assert r.json["v"][0] == v + # Test that present CSV UUID input yields [input values] + v = [str(uuid.uuid4()), str(uuid.uuid4())] + r = client.get(url, query_string={"v": ','.join([u for u in v])}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that present UUID input in multiple of the same query param yields [input values] + v = [str(uuid.uuid4()), str(uuid.uuid4())] + r = client.get(url, query_string={"v": v}) + assert "v" in r.json + assert type(r.json["v"]) is list + assert len(r.json["v"]) == 2 + list_assertion_helper(2, str, v, r.json["v"]) + # Test that present non-UUID list items yields error + r = client.get(url, query_string={"v": "a"}) + assert "error" in r.json def test_list_default(client): @@ -1718,6 +2509,10 @@ def test_required_str_enum(client): def test_optional_str_enum(client): url = "/query/str_enum/optional" + # Test that missing input yields None + r = client.get(url) + assert "v" in r.json + assert r.json["v"] is None # Test that present str_enum input yields input value r = client.get(url, query_string={"v": Fruits.ORANGE.value}) assert "v" in r.json diff --git a/flask_parameter_validation/test/testing_blueprints/list_blueprint.py b/flask_parameter_validation/test/testing_blueprints/list_blueprint.py index 7dff64c..30a7e29 100644 --- a/flask_parameter_validation/test/testing_blueprints/list_blueprint.py +++ b/flask_parameter_validation/test/testing_blueprints/list_blueprint.py @@ -1,10 +1,12 @@ import datetime +import uuid from typing import Optional, List, Union from flask import Blueprint, jsonify from flask_parameter_validation import ValidateParameters, Route from flask_parameter_validation.parameter_types.parameter import Parameter +from flask_parameter_validation.test.enums import Binary, Fruits from flask_parameter_validation.test.testing_blueprints.dummy_decorators import dummy_decorator, dummy_async_decorator @@ -20,7 +22,8 @@ def get_list_blueprint(ParamType: type[Parameter], bp_name: str, http_verb: str) @ValidateParameters() def req_str(v: List[str] = ParamType()): assert type(v) is list - assert type(v[0]) is str + if len(v) > 0: + assert type(v[0]) is str return jsonify({"v": v}) @decorator("/decorator/req_str") @@ -28,7 +31,8 @@ def req_str(v: List[str] = ParamType()): @ValidateParameters() def decorator_req_str(v: List[str] = ParamType()): assert type(v) is list - assert type(v[0]) is str + if len(v) > 0: + assert type(v[0]) is str return jsonify({"v": v}) @decorator("/async_decorator/req_str") @@ -36,59 +40,240 @@ def decorator_req_str(v: List[str] = ParamType()): @ValidateParameters() async def async_decorator_req_str(v: List[str] = ParamType()): assert type(v) is list - assert type(v[0]) is str + if len(v) > 0: + assert type(v[0]) is str + return jsonify({"v": v}) + + @decorator("/opt_str") + @ValidateParameters() + def opt_str(v: Optional[List[str]] = ParamType()): + assert type(v) is list or v is None + if v and len(v) > 0: + assert type(v[0]) is str + return jsonify({"v": v}) + + @decorator("/disable_query_csv/unset") + @ValidateParameters() + def disable_query_csv_unset(v: List[str] = ParamType()): + return jsonify({"v": v}) + + @decorator("/disable_query_csv/true") + @ValidateParameters() + def disable_query_csv_true(v: List[str] = ParamType(list_disable_query_csv=True)): + return jsonify({"v": v}) + + @decorator("/disable_query_csv/false") + @ValidateParameters() + def disable_query_csv_false(v: List[str] = ParamType(list_disable_query_csv=False)): return jsonify({"v": v}) @decorator("/req_int") @ValidateParameters() def req_int(v: List[int] = ParamType()): assert type(v) is list - assert type(v[0]) is int + if len(v) > 0: + assert type(v[0]) is int + return jsonify({"v": v}) + + @decorator("/opt_int") + @ValidateParameters() + def opt_int(v: Optional[List[int]] = ParamType()): + assert type(v) is list or v is None + if v and len(v) > 0: + assert type(v[0]) is int return jsonify({"v": v}) @decorator("/req_bool") @ValidateParameters() def req_bool(v: List[bool] = ParamType()): assert type(v) is list - assert type(v[0]) is bool + if len(v) > 0: + assert type(v[0]) is bool return jsonify({"v": v}) - # List[Union[]] not currently supported - # @decorator("/req_union") - # @ValidateParameters() - # def req_union(v: List[Union[int, float]] = ParamType()): - # assert type(v) is list - # assert type(v[0]) is int - # assert type(v[1]) is float - # return jsonify({"v": v}) + @decorator("/opt_bool") + @ValidateParameters() + def opt_bool(v: Optional[List[bool]] = ParamType()): + assert type(v) is list or v is None + if v and len(v) > 0: + assert type(v[0]) is bool + return jsonify({"v": v}) + + @decorator("/req_float") + @ValidateParameters() + def req_float(v: List[float] = ParamType()): + assert type(v) is list + if len(v) > 0: + assert type(v[0]) is float + return jsonify({"v": v}) + + @decorator("/opt_float") + @ValidateParameters() + def opt_float(v: Optional[List[float]] = ParamType()): + assert type(v) is list or v is None + if v and len(v) > 0: + assert type(v[0]) is float + return jsonify({"v": v}) + + @decorator("/req_union") + @ValidateParameters() + def req_union(v: List[Union[int, float]] = ParamType()): + assert type(v) is list + for i in v: + assert type(i) is int or type(i) is float + return jsonify({"v": v}) + + @decorator("/req_union_everything") + @ValidateParameters() + def req_union_everything(v: List[Union[str, int, bool, float, datetime.datetime, datetime.date, datetime.time, dict, Fruits, Binary, uuid.UUID]] = ParamType(list_disable_query_csv=True)): + assert type(v) is list + assert len(v) > 0 + assert type(v[0]) is str + assert type(v[1]) is int + assert type(v[2]) is bool + assert type(v[3]) is float + assert type(v[4]) is datetime.datetime + assert type(v[5]) is datetime.date + assert type(v[6]) is datetime.time + assert type(v[7]) is dict + assert type(v[8]) is Fruits + assert type(v[9]) is Binary + assert type(v[10]) is uuid.UUID + return jsonify({"v": [ + v[0], v[1], v[2], v[3], + v[4].isoformat(), + v[5].isoformat(), + v[6].isoformat(), + v[7], v[8], v[9], v[10] + ]}) + + @decorator("/opt_union") + @ValidateParameters() + def opt_union(v: Optional[List[Union[int, bool]]] = ParamType()): + assert type(v) is list or v is None + if v: + for i in v: + assert type(i) is int or type(i) is bool + return jsonify({"v": v}) @decorator("/req_datetime") @ValidateParameters() def req_datetime(v: List[datetime.datetime] = ParamType()): assert type(v) is list - assert type(v[0]) is datetime.datetime - v = [t.isoformat() for t in v] + if len(v) > 0: + assert type(v[0]) is datetime.datetime + v = [t.isoformat() for t in v] + return jsonify({"v": v}) + + @decorator("/opt_datetime") + @ValidateParameters() + def opt_datetime(v: Optional[List[datetime.datetime]] = ParamType()): + assert type(v) is list or v is None + if v and len(v) > 0: + assert type(v[0]) is datetime.datetime + v = [t.isoformat() for t in v] return jsonify({"v": v}) @decorator("/req_date") @ValidateParameters() def req_date(v: List[datetime.date] = ParamType()): assert type(v) is list - assert type(v[0]) is datetime.date - v = [t.isoformat() for t in v] + if len(v) > 0: + assert type(v[0]) is datetime.date + v = [t.isoformat() for t in v] + return jsonify({"v": v}) + + @decorator("/opt_date") + @ValidateParameters() + def opt_date(v: Optional[List[datetime.date]] = ParamType()): + assert type(v) is list or v is None + if v and len(v) > 0: + assert type(v[0]) is datetime.date + v = [t.isoformat() for t in v] return jsonify({"v": v}) @decorator("/req_time") @ValidateParameters() def req_time(v: List[datetime.time] = ParamType()): assert type(v) is list - assert type(v[0]) is datetime.time - v = [t.isoformat() for t in v] + if len(v) > 0: + assert type(v[0]) is datetime.time + v = [t.isoformat() for t in v] + return jsonify({"v": v}) + + @decorator("/opt_time") + @ValidateParameters() + def opt_time(v: Optional[List[datetime.time]] = ParamType()): + assert type(v) is list or v is None + if v and len(v) > 0: + assert type(v[0]) is datetime.time + v = [t.isoformat() for t in v] + return jsonify({"v": v}) + + @decorator("/req_dict") + @ValidateParameters() + def req_dict(v: List[dict] = ParamType(list_disable_query_csv=True)): + assert type(v) is list + if len(v) > 0: + assert type(v[0]) is dict + return jsonify({"v": v}) + + @decorator("/opt_dict") + @ValidateParameters() + def opt_dict(v: Optional[List[dict]] = ParamType(list_disable_query_csv=True)): + assert type(v) is list or v is None + if v and len(v) > 0: + assert type(v[0]) is dict + return jsonify({"v": v}) + + @decorator("/req_str_enum") + @ValidateParameters() + def req_str_enum(v: List[Fruits] = ParamType()): + assert type(v) is list + if len(v) > 0: + assert type(v[0]) is Fruits + return jsonify({"v": v}) + + @decorator("/opt_str_enum") + @ValidateParameters() + def opt_str_enum(v: Optional[List[Fruits]] = ParamType()): + assert type(v) is list or v is None + if v and len(v) > 0: + assert type(v[0]) is Fruits + return jsonify({"v": v}) + + @decorator("/req_int_enum") + @ValidateParameters() + def req_int_enum(v: List[Binary] = ParamType()): + assert type(v) is list + if len(v) > 0: + assert type(v[0]) is Binary + return jsonify({"v": v}) + + @decorator("/opt_int_enum") + @ValidateParameters() + def opt_int_enum(v: Optional[List[Binary]] = ParamType()): + assert type(v) is list or v is None + if v and len(v) > 0: + assert type(v[0]) is Binary + return jsonify({"v": v}) + + @decorator("/req_uuid") + @ValidateParameters() + def req_uuid(v: List[uuid.UUID] = ParamType()): + assert type(v) is list + if len(v) > 0: + assert type(v[0]) is uuid.UUID + v = [str(u) for u in v] return jsonify({"v": v}) - @decorator("/optional") + @decorator("/opt_uuid") @ValidateParameters() - def optional(v: Optional[List[str]] = ParamType()): + def opt_uuid(v: Optional[List[uuid.UUID]] = ParamType()): + assert type(v) is list or v is None + if v and len(v) > 0: + assert type(v[0]) is uuid.UUID + v = [str(u) for u in v] return jsonify({"v": v}) @decorator("/default")