Skip to content

Commit

Permalink
Merge master into dev/smt5541/openapi
Browse files Browse the repository at this point in the history
  • Loading branch information
d3-steichman committed Aug 14, 2024
2 parents 87d9f4b + 5876281 commit 1ff9298
Show file tree
Hide file tree
Showing 19 changed files with 1,647 additions and 197 deletions.
27 changes: 27 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
### 🛠 Changes being made

#### Give examples of the changes you've made in this pull request. Include an itemized list if you can.

### 🧠 Rationale behind the change

#### Why did you choose to make these changes?

#### Does this pull request resolve any open issues?

#### Were there any trade-offs you had to consider?

### 🧪 Testing

- [ ] Have tests been added or updated for the changes introduced in this pull request?

- [ ] Are the changes backwards compatible?

#### If the changes aren't backwards compatible, what other options were explored?

### ✨ Quality check

- [ ] Are your changes free of any erroneous print statements, debuggers or other leftover code?

- [ ] Has the README been updated to reflect the changes introduced (if applicable)?

### 💬 Additional comments
12 changes: 8 additions & 4 deletions .github/workflows/python-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@ jobs:

runs-on: ubuntu-latest

strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"]

steps:
- uses: actions/checkout@v3
- name: Set up Python 3
uses: actions/setup-python@v3
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: "3.x"
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
Expand Down
138 changes: 88 additions & 50 deletions README.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion flask_parameter_validation/parameter_types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
from .json import Json
from .query import Query
from .route import Route
from .multi_source import MultiSource

__all__ = [
"File", "Form", "Json", "Query", "Route"
"File", "Form", "Json", "Query", "Route", "MultiSource"
]
11 changes: 11 additions & 0 deletions flask_parameter_validation/parameter_types/multi_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from typing import Type

from flask_parameter_validation.parameter_types.parameter import Parameter


class MultiSource(Parameter):
name = "multi_source"

def __init__(self, *sources: list[Type[Parameter]], **kwargs):
self.sources = [Source(**kwargs) for Source in sources]
super().__init__(**kwargs)
41 changes: 23 additions & 18 deletions flask_parameter_validation/parameter_types/parameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""
import re
from datetime import date, datetime, time
from enum import Enum
import dateutil.parser as parser
import jsonschema
from jsonschema.exceptions import ValidationError as JSONSchemaValidationError
Expand All @@ -13,22 +14,23 @@ class Parameter:

# Parameter initialisation
def __init__(
self,
default=None, # any: default parameter value
min_str_length=None, # int: min parameter length
max_str_length=None, # int: max parameter length
min_list_length=None, # int: min number of items in list
max_list_length=None, # int: max number of items in list
min_int=None, # int: min number (if val is int)
max_int=None, # int: max number (if val is int)
whitelist=None, # str: character whitelist
blacklist=None, # str: character blacklist
pattern=None, # str: regexp pattern
func=None, # Callable -> Union[bool, tuple[bool, str]]: function performing a fully customized validation
datetime_format=None, # str: datetime format string (https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes),
comment=None, # str: comment for autogenerated documentation
alias=None, # str: alias for parameter name
json_schema=None, # dict: JSON Schema to check received dicts or lists against
self,
default=None, # any: default parameter value
min_str_length=None, # int: min parameter length
max_str_length=None, # int: max parameter length
min_list_length=None, # int: min number of items in list
max_list_length=None, # int: max number of items in list
min_int=None, # int: min number (if val is int)
max_int=None, # int: max number (if val is int)
whitelist=None, # str: character whitelist
blacklist=None, # str: character blacklist
pattern=None, # str: regexp pattern
func=None, # Callable -> Union[bool, tuple[bool, str]]: function performing a fully customized validation
datetime_format=None,
# str: datetime format string (https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes),
comment=None, # str: comment for autogenerated documentation
alias=None, # str: alias for parameter name
json_schema=None, # dict: JSON Schema to check received dicts or lists against
):
self.default = default
self.min_list_length = min_list_length
Expand Down Expand Up @@ -151,8 +153,6 @@ def validate(self, value):
if self.func is not None and not original_value_type_list:
self.func_helper(value)



return True

def convert(self, value, allowed_types):
Expand Down Expand Up @@ -184,4 +184,9 @@ def convert(self, value, allowed_types):
return date.fromisoformat(str(value))
except ValueError:
raise ValueError("date format does not match ISO 8601")
elif len(allowed_types) == 1 and (issubclass(allowed_types[0], str) or issubclass(allowed_types[0], int) and issubclass(allowed_types[0], Enum)):
if issubclass(allowed_types[0], int):
value = int(value)
returning = allowed_types[0](value)
return returning
return value
207 changes: 111 additions & 96 deletions flask_parameter_validation/parameter_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
from .exceptions import (InvalidParameterTypeError, MissingInputError,
ValidationError)
from .parameter_types import File, Form, Json, Query, Route
from .parameter_types.multi_source import MultiSource

fn_list = dict()

list_type_hints = ["typing.List", "typing.Optional[typing.List", "list", "typing.Optional[list"]

class ValidateParameters:
@classmethod
Expand Down Expand Up @@ -58,7 +60,7 @@ def nested_func_helper(**kwargs):
json_input = None
if request.headers.get("Content-Type") is not None:
if re.search(
"application/[^+]*[+]?(json);?", request.headers.get("Content-Type")
"application/[^+]*[+]?(json);?", request.headers.get("Content-Type")
):
try:
json_input = request.json
Expand All @@ -68,8 +70,7 @@ def nested_func_helper(**kwargs):
# Step 3 - Extract list of parameters expected to be lists (otherwise all values are converted to lists)
expected_list_params = []
for name, param in expected_inputs.items():
if str(param.annotation).startswith("typing.List") or str(param.annotation).startswith(
"typing.Optional[typing.List"):
if True in [str(param.annotation).startswith(list_hint) for list_hint in list_type_hints]:
expected_list_params.append(param.default.alias or name)

# Step 4 - Convert request inputs to dicts
Expand Down Expand Up @@ -119,7 +120,7 @@ def nested_func(**kwargs):
return nested_func

def _to_dict_with_lists(
self, multi_dict: ImmutableMultiDict, expected_lists: list, split_strings: bool = False
self, multi_dict: ImmutableMultiDict, expected_lists: list, split_strings: bool = False
) -> dict:
dict_with_lists = {}
for key, values in multi_dict.lists():
Expand Down Expand Up @@ -159,108 +160,122 @@ def validate(self, expected_input, all_request_inputs):
original_expected_input_type = expected_input.annotation
original_expected_input_type_str = expected_input_type_str

# Validate that the expected delivery type is valid
if expected_delivery_type.__class__ not in all_request_inputs.keys():
raise InvalidParameterTypeError(expected_delivery_type)
# Expected delivery types can be a list if using MultiSource
expected_delivery_types = [expected_delivery_type]
if type(expected_delivery_type) is MultiSource:
expected_delivery_types = expected_delivery_type.sources

# Validate that user supplied input in expected delivery type (unless specified as Optional)
user_input = all_request_inputs[expected_delivery_type.__class__].get(
expected_name
)
if user_input is None:
# If default is given, set and continue
if expected_delivery_type.default is not None:
user_input = expected_delivery_type.default
else:
# Optionals are Unions with a NoneType, so we should check if None is part of Union __args__ (if exist)
if (
hasattr(expected_input_type, "__args__") and type(None) in expected_input_type.__args__
):
return user_input
for source_index, source in enumerate(expected_delivery_types):
# Validate that the expected delivery type is valid
if source.__class__ not in all_request_inputs.keys():
raise InvalidParameterTypeError(source)

# Validate that user supplied input in expected delivery type (unless specified as Optional)
user_input = all_request_inputs[source.__class__].get(
expected_name
)
if user_input is None:
# If default is given, set and continue
if source.default is not None:
user_input = source.default
else:
raise MissingInputError(
expected_name, expected_delivery_type.__class__
)
# Optionals are Unions with a NoneType, so we should check if None is part of Union __args__ (if exist)
if (
hasattr(expected_input_type, "__args__") and type(None) in expected_input_type.__args__
and source_index == len(expected_delivery_types) - 1 # If MultiSource, only return None for last source
):
return user_input
else:
if len(expected_delivery_types) == 1:
raise MissingInputError(
expected_name, source.__class__
)
elif source_index != len(expected_delivery_types) - 1:
continue
else:
raise MissingInputError(
expected_name, source.__class__
)

# Skip validation if typing.Any is given
if expected_input_type_str.startswith("typing.Any"):
return user_input
# Skip validation if typing.Any is given
if expected_input_type_str.startswith("typing.Any"):
return user_input

# In python3.7+, typing.Optional is used instead of typing.Union[..., None]
if expected_input_type_str.startswith("typing.Optional"):
new_type = expected_input_type.__args__[0]
expected_input_type = new_type
expected_input_type_str = str(new_type)
# In python3.7+, typing.Optional is used instead of typing.Union[..., None]
if expected_input_type_str.startswith("typing.Optional"):
new_type = expected_input_type.__args__[0]
expected_input_type = new_type
expected_input_type_str = str(new_type)

# Prepare expected type checks for unions, lists and plain types
if expected_input_type_str.startswith("typing.Union"):
expected_input_types = expected_input_type.__args__
user_inputs = [user_input]
# If typing.List in union and user supplied valid list, convert remaining check only for list
for exp_type in expected_input_types:
if str(exp_type).startswith("typing.List"):
if type(user_input) is list:
# Only convert if validation passes
if hasattr(exp_type, "__args__"):
if all(type(inp) in exp_type.__args__ for inp in user_input):
expected_input_type = exp_type
expected_input_types = expected_input_type.__args__
expected_input_type_str = str(exp_type)
user_inputs = user_input
# If list, expand inner typing items. Otherwise, convert to list to match anyway.
elif expected_input_type_str.startswith("typing.List"):
expected_input_types = expected_input_type.__args__
if type(user_input) is list:
user_inputs = user_input
# Prepare expected type checks for unions, lists and plain types
if expected_input_type_str.startswith("typing.Union"):
expected_input_types = expected_input_type.__args__
user_inputs = [user_input]
# If typing.List in union and user supplied valid list, convert remaining check only for list
for exp_type in expected_input_types:
if 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]
else:
user_inputs = [user_input]
expected_input_types = [expected_input_type]
expected_input_types = [expected_input_type]

# Perform automatic type conversion for parameter types (i.e. "true" -> True)
for count, value in enumerate(user_inputs):
try:
user_inputs[count] = expected_delivery_type.convert(
value, expected_input_types
)
except ValueError as e:
raise ValidationError(str(e), expected_name, expected_input_type)
# Perform automatic type conversion for parameter types (i.e. "true" -> True)
for count, value in enumerate(user_inputs):
try:
user_inputs[count] = source.convert(
value, expected_input_types
)
except ValueError as e:
raise ValidationError(str(e), expected_name, expected_input_type)

# Validate that user type(s) match expected type(s)
validation_success = all(
type(inp) in expected_input_types for inp in user_inputs
)
# Validate that user type(s) match expected type(s)
validation_success = all(
type(inp) in expected_input_types for inp in user_inputs
)

# Validate that if lists are required, lists are given
if expected_input_type_str.startswith("typing.List"):
if type(user_input) is not list:
validation_success = False
# Validate that if lists are required, lists are given
if 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

# Error if types don't match
if not validation_success:
if hasattr(
original_expected_input_type, "__name__"
) and not original_expected_input_type_str.startswith("typing."):
type_name = original_expected_input_type.__name__
else:
type_name = original_expected_input_type_str
raise ValidationError(
f"must be type '{type_name}'",
expected_name,
original_expected_input_type,
)
# Error if types don't match
if not validation_success:
if hasattr(
original_expected_input_type, "__name__"
) and not (original_expected_input_type_str.startswith("typing.") or original_expected_input_type_str.startswith("list")):
type_name = original_expected_input_type.__name__
else:
type_name = original_expected_input_type_str
raise ValidationError(
f"must be type '{type_name}'",
expected_name,
original_expected_input_type,
)

# Validate parameter-specific requirements are met
try:
if type(user_input) is list:
expected_delivery_type.validate(user_input)
else:
expected_delivery_type.validate(user_inputs[0])
except ValueError as e:
raise ValidationError(str(e), expected_name, expected_input_type)
# Validate parameter-specific requirements are met
try:
if type(user_input) is list:
source.validate(user_input)
else:
source.validate(user_inputs[0])
except ValueError as e:
raise ValidationError(str(e), expected_name, expected_input_type)

# Return input back to parent function
if expected_input_type_str.startswith("typing.List"):
return user_inputs
return user_inputs[0]
# Return input back to parent function
if any(expected_input_type_str.startswith(list_hint) for list_hint in list_type_hints):
return user_inputs
return user_inputs[0]
Loading

0 comments on commit 1ff9298

Please sign in to comment.