Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
38 changes: 19 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
## Usage Example
```py
from flask import Flask
from typing import List, Optional
from typing import Optional
from flask_parameter_validation import ValidateParameters, Route, Json, Query
from datetime import datetime

Expand All @@ -22,7 +22,7 @@ def hello(
id: int = Route(),
username: str = Json(min_str_length=5, blacklist="<>"),
age: int = Json(min_int=18, max_int=99),
nicknames: List[str] = Json(),
nicknames: list[str] = Json(),
date_of_birth: datetime = Json(),
password_expiry: Optional[int] = Json(5),
is_admin: bool = Query(False),
Expand All @@ -38,7 +38,7 @@ if __name__ == "__main__":
## Usage
To validate parameters with flask-parameter-validation, two conditions must be met.
1. The `@ValidateParameters()` decorator must be applied to the function
2. Type hints ([supported types](#type-hints-and-accepted-input-types)) and a default of a subclass of `Parameter` must be supplied per parameter flask-parameter-validation parameter
2. Type hints ([supported types](#type-hints-and-accepted-input-types)) and a default of a subclass of `Parameter` must be supplied per parameter


### Enable and customize Validation for a Route with the @ValidateParameters decorator
Expand Down Expand Up @@ -108,20 +108,20 @@ 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 |
| `typing.List` (must not be `list`) | For `Query` and `Form` inputs, users can pass via either `value=1&value=2&value=3`, or `value=1,2,3`, both will be transformed to a `list`. | N | Y | Y | Y | N |
| `typing.Union` | Cannot be used inside of `typing.List` | Y | Y | Y | Y | N |
| `typing.Optional` | | Y | Y | Y | Y | Y |
| `datetime.datetime` | Received as a `str` in ISO-8601 date-time format | Y | Y | Y | Y | N |
| `datetime.date` | Received as a `str` in ISO-8601 full-date format | Y | Y | Y | Y | N |
| `datetime.time` | Received as a `str` in ISO-8601 partial-time format | Y | Y | Y | Y | N |
| `dict` | For `Query` and `Form` inputs, users should pass the stringified JSON | N | Y | Y | Y | N |
| `FileStorage` | | N | N | N | N | Y |
| Type Hint / Expected Python Type | Notes | `Route` | `Form` | `Json` | `Query` | `File` |
|------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|--------|--------|---------|--------|
| `str` | | Y | Y | Y | Y | N |
| `int` | | Y | Y | Y | Y | N |
| `bool` | | Y | Y | Y | Y | N |
| `float` | | Y | Y | Y | Y | N |
| `list`/`typing.List` (`typing.List` is [deprecated](https://docs.python.org/3/library/typing.html#typing.List)) | For `Query` and `Form` inputs, users can pass via either `value=1&value=2&value=3`, or `value=1,2,3`, both will be transformed to a `list` | N | Y | Y | Y | N |
| `typing.Union` | Cannot be used inside of `typing.List` | Y | Y | Y | Y | N |
| `typing.Optional` | | Y | Y | Y | Y | Y |
| `datetime.datetime` | Received as a `str` in ISO-8601 date-time format | Y | Y | Y | Y | N |
| `datetime.date` | Received as a `str` in ISO-8601 full-date format | Y | Y | Y | Y | N |
| `datetime.time` | Received as a `str` in ISO-8601 partial-time format | Y | Y | Y | Y | N |
| `dict` | For `Query` and `Form` inputs, users should pass the stringified JSON | N | Y | Y | Y | N |
| `FileStorage` | | N | N | N | N | Y |

These can be used in tandem to describe a parameter to validate: `parameter_name: type_hint = ParameterSubclass()`
- `parameter_name`: The field name itself, such as username
Expand All @@ -136,8 +136,8 @@ Validation beyond type-checking can be done by passing arguments into the constr
| `default` | any | All | Specifies the default value for the field, makes non-Optional fields not required |
| `min_str_length` | `int` | `str` | Specifies the minimum character length for a string input |
| `max_str_length` | `int` | `str` | Specifies the maximum character length for a string input |
| `min_list_length` | `int` | `typing.List` | Specifies the minimum number of elements in a list |
| `max_list_length` | `int` | `typing.List` | Specifies the maximum number of elements in a list |
| `min_list_length` | `int` | `list` | Specifies the minimum number of elements in a list |
| `max_list_length` | `int` | `list` | Specifies the maximum number of elements in a list |
| `min_int` | `int` | `int` | Specifies the minimum number for an integer input |
| `max_int` | `int` | `int` | Specifies the maximum number for an integer input |
| `whitelist` | `str` | `str` | A string containing allowed characters for the value |
Expand Down
14 changes: 7 additions & 7 deletions flask_parameter_validation/parameter_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

fn_list = dict()

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

class ValidateParameters:
@classmethod
Expand Down Expand Up @@ -65,8 +66,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 @@ -209,7 +209,7 @@ def validate(self, expected_input, all_request_inputs):
user_inputs = [user_input]
# If typing.List in union and user supplied valid list, convert remaining check only for list
for exp_type in expected_input_types:
if str(exp_type).startswith("typing.List"):
if 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__"):
Expand All @@ -219,7 +219,7 @@ def validate(self, expected_input, all_request_inputs):
expected_input_type_str = str(exp_type)
user_inputs = user_input
# If list, expand inner typing items. Otherwise, convert to list to match anyway.
elif expected_input_type_str.startswith("typing.List"):
elif 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
Expand All @@ -244,15 +244,15 @@ def validate(self, expected_input, all_request_inputs):
)

# Validate that if lists are required, lists are given
if expected_input_type_str.startswith("typing.List"):
if 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."):
) 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
Expand All @@ -272,6 +272,6 @@ def validate(self, expected_input, all_request_inputs):
raise ValidationError(str(e), expected_name, expected_input_type)

# Return input back to parent function
if expected_input_type_str.startswith("typing.List"):
if any(expected_input_type_str.startswith(list_hint) for list_hint in list_type_hints):
return user_inputs
return user_inputs[0]
42 changes: 42 additions & 0 deletions flask_parameter_validation/test/test_form_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -903,3 +903,45 @@ def test_max_list_length(client):
# Test that above length yields error
r = client.post(url, data={"v": ["the", "longest", "of", "lists"]})
assert "error" in r.json


def test_non_typing_list_str(client):
url = "/form/list/non_typing"
# Test that present single str input yields [input value]
r = client.post(url, data={"v": "w"})
assert "v" in r.json
assert type(r.json["v"]) is list
assert len(r.json["v"]) == 1
assert type(r.json["v"][0]) is str
assert r.json["v"][0] == "w"
# Test that present CSV str input yields [input values]
v = ["x", "y"]
r = client.post(url, data={"v": v})
assert "v" in r.json
assert type(r.json["v"]) is list
assert len(r.json["v"]) == 2
list_assertion_helper(2, str, v, r.json["v"])
# Test that missing input yields error
r = client.post(url)
assert "error" in r.json

def test_non_typing_optional_list_str(client):
url = "/form/list/optional_non_typing"
# Test that missing input yields None
r = client.post(url)
assert "v" in r.json
assert r.json["v"] is None
# Test that present str input yields [input value]
r = client.post(url, data={"v": "test"})
assert "v" in r.json
assert type(r.json["v"]) is list
assert len(r.json["v"]) == 1
assert type(r.json["v"][0]) is str
assert r.json["v"][0] == "test"
# Test that present CSV str input yields [input values]
v = ["two", "tests"]
r = client.post(url, data={"v": v})
assert "v" in r.json
assert type(r.json["v"]) is list
assert len(r.json["v"]) == 2
list_assertion_helper(2, str, v, r.json["v"])
31 changes: 30 additions & 1 deletion flask_parameter_validation/test/test_json_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -1034,4 +1034,33 @@ def test_dict_json_schema(client):
"last_name": "Doe"
}
r = client.post(url, json={"v": v})
assert "error" in r.json
assert "error" in r.json


def test_non_typing_list_str(client):
url = "/json/list/non_typing"
# Test that present list[str] input yields [input values]
v = ["x", "y"]
r = client.post(url, json={"v": v})
assert "v" in r.json
assert type(r.json["v"]) is list
assert len(r.json["v"]) == 2
list_assertion_helper(2, str, v, r.json["v"])
# Test that missing input yields error
r = client.post(url)
assert "error" in r.json


def test_non_typing_optional_list_str(client):
url = "/json/list/optional_non_typing"
# Test that missing input yields None
r = client.post(url)
assert "v" in r.json
assert r.json["v"] is None
# Test that present list[str] input yields [input values]
v = ["two", "tests"]
r = client.post(url, json={"v": v})
assert "v" in r.json
assert type(r.json["v"]) is list
assert len(r.json["v"]) == 2
list_assertion_helper(2, str, v, r.json["v"])
Loading