Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
13 changes: 10 additions & 3 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 Optional
from typing import Optional, TypedDict, NotRequired
from flask_parameter_validation import ValidateParameters, Route, Json, Query
from datetime import datetime
from enum import Enum
Expand All @@ -23,6 +23,11 @@ class UserType(str, Enum): # In Python 3.11 or later, subclass StrEnum from enu
USER = "user"
SERVICE = "service"

class SocialLink(TypedDict):
friendly_name: str
url: str
icon: NotRequired[str]

app = Flask(__name__)

@app.route("/update/<int:id>", methods=["POST"])
Expand All @@ -37,7 +42,8 @@ def hello(
is_admin: bool = Query(False),
user_type: UserType = Json(alias="type"),
status: AccountStatus = Json(),
permissions: dict[str, str] = Query(list_disable_query_csv=True)
permissions: dict[str, str] = Query(list_disable_query_csv=True),
socials: list[SocialLink] = Json()
):
return "Hello World!"

Expand Down Expand Up @@ -131,7 +137,8 @@ Type Hints allow for inline specification of the input type of a parameter. Some
| `datetime.datetime` | Received as a `str` in ISO-8601 date-time format | Y | Y | Y | Y | N |
| `datetime.date` | Received as a `str` in ISO-8601 full-date format | Y | Y | Y | Y | N |
| `datetime.time` | Received as a `str` in ISO-8601 partial-time format | Y | Y | Y | Y | N |
| `dict` | For `Query` and `Form` inputs, users should pass the stringified JSON. For `Query`, you likely will need to use `list_disable_query_csv=True`. | N | Y | Y | Y | N |
| `dict` | For `Query` and `Form` inputs, users should pass the stringified JSON. For `Query`, you likely will need to use `list_disable_query_csv=True`. | N | Y | Y | Y | N |
| `TypedDict` | For `Query` and `Form` inputs, users should pass the stringified JSON. For `Query`, you likely will need to use `list_disable_query_csv=True`. | 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 |
Expand Down
41 changes: 40 additions & 1 deletion flask_parameter_validation/parameter_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import re
import uuid
from inspect import signature
from typing import Optional, Union, get_origin, get_args, Any
from typing import Optional, Union, get_origin, get_args, Any, get_type_hints

import flask
from flask import request
Expand All @@ -25,6 +25,11 @@
from types import UnionType
UNION_TYPES = [Union, UnionType]

if sys.version_info >= (3, 11):
from typing import NotRequired, Required, is_typeddict
elif sys.version_info >= (3, 9):
from typing_extensions import NotRequired, Required, is_typeddict

class ValidateParameters:
@classmethod
def get_fn_list(cls):
Expand Down Expand Up @@ -217,6 +222,40 @@ def _generic_types_validation_helper(self,
converted_list.append(sub_converted_input)
return converted_list, True

# typeddict
elif is_typeddict(expected_input_type):
# check for a stringified dict (like from Query)
if type(user_input) is str:
try:
user_input = json.loads(user_input)
except ValueError:
return user_input, False
if type(user_input) is not dict:
return user_input, False
# check that we have all required keys
for key in expected_input_type.__required_keys__:
if key not in user_input:
return user_input, False

# process
converted_dict = {}
# go through each user input key and make sure the value is the correct type
for key, value in user_input.items():
annotations = get_type_hints(expected_input_type)
if key not in annotations:
# we are strict in not allowing extra keys
# if you want extra keys, use NotRequired
return user_input, False
# get the Required and NotRequired decorators out of the way, if present
annotation_type = annotations[key]
if get_origin(annotation_type) is NotRequired or get_origin(annotation_type) is Required:
annotation_type = get_args(annotation_type)[0]
sub_converted_input, sub_success = self._generic_types_validation_helper(expected_name, annotation_type, value, source)
if not sub_success:
return user_input, False
converted_dict[key] = sub_converted_input
return converted_dict, True

# dict
elif get_origin(expected_input_type) is dict or expected_input_type is dict:
# check for a stringified dict (like from Query or Form)
Expand Down
3 changes: 2 additions & 1 deletion flask_parameter_validation/test/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
Flask==3.0.2
../../
requests
requests
pytest
254 changes: 254 additions & 0 deletions flask_parameter_validation/test/test_form_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -1741,4 +1741,258 @@ def test_dict_args_str_list_3_10_union(client):
r = client.post(url, data={"v": json.dumps(d)})
assert "error" in r.json

def test_typeddict_normal(client):
url = "/form/typeddict/"
# Test that correct input yields input value
d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
r = client.post(url, data={"v": json.dumps(d)})
assert "v" in r.json
assert r.json["v"] == d
# Test that missing keys yields error
d = {"id": 3}
r = client.post(url, data={"v": json.dumps(d)})
assert "error" in r.json
# Test that incorrect values yields error
d = {"id": 1.03, "name": "foo", "timestamp": datetime.datetime.now().isoformat()}
r = client.post(url, data={"v": json.dumps(d)})
assert "error" in r.json

def test_typeddict_functional(client):
url = "/form/typeddict/functional"
# Test that correct input yields input value
d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
r = client.post(url, data={"v": json.dumps(d)})
assert "v" in r.json
assert r.json["v"] == d
# Test that missing keys yields error
d = {"id": 3}
r = client.post(url, data={"v": json.dumps(d)})
assert "error" in r.json
# Test that incorrect values yields error
d = {"id": 1.03, "name": "foo", "timestamp": datetime.datetime.now().isoformat()}
r = client.post(url, data={"v": json.dumps(d)})
assert "error" in r.json

def test_typeddict_optional(client):
url = "/form/typeddict/optional"
# Test that correct input yields input value
d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
r = client.post(url, data={"v": json.dumps(d)})
assert "v" in r.json
assert r.json["v"] == d
# Test that no input yields input value
d = None
r = client.post(url, data={"v": d})
assert "v" in r.json
assert r.json["v"] == d
# Test that missing keys yields error
d = {"id": 3}
r = client.post(url, data={"v": json.dumps(d)})
assert "error" in r.json
# Test that empty dict yields error
d = {}
r = client.post(url, data={"v": json.dumps(d)})
assert "error" in r.json

if sys.version_info >= (3, 10):
def test_typeddict_union_optional(client):
url = "/form/typeddict/union_optional"
# Test that correct input yields input value
d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
r = client.post(url, data={"v": json.dumps(d)})
assert "v" in r.json
assert r.json["v"] == d
# Test that no input yields input value
d = None
r = client.post(url, data={"v": d})
assert "v" in r.json
assert r.json["v"] == d
# Test that missing keys yields error
d = {"id": 3}
r = client.post(url, data={"v": json.dumps(d)})
assert "error" in r.json
# Test that empty dict yields error
d = {}
r = client.post(url, data={"v": json.dumps(d)})
assert "error" in r.json

def test_typeddict_default(client):
url = "/form/typeddict/default"
# Test that missing input for required and optional yields default values
r = client.post(url)
assert "n_opt" in r.json
assert r.json["n_opt"] == {"id": 1, "name": "Bob", "timestamp": datetime.datetime(2025, 11, 18, 0, 0).isoformat()}
assert "opt" in r.json
assert r.json["opt"] == {"id": 2, "name": "Billy", "timestamp": datetime.datetime(2025, 11, 18, 5, 30).isoformat()}
# Test that present TypedDict input for required and optional yields input values
d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
r = client.post(url, data={
"opt": json.dumps(d),
"n_opt": json.dumps(d),
})
assert "opt" in r.json
assert r.json["opt"] == d
assert "n_opt" in r.json
assert r.json["n_opt"] == d
# Test that present non-TypedDict input for required yields error
r = client.post(url, data={"opt": {"id": 3}, "n_opt": "b"})
assert "error" in r.json

def test_typeddict_func(client):
url = "/form/typeddict/func"
# Test that correct input yields input value
d = {"id": 3, "name": "Bill", "timestamp": datetime.datetime.now().isoformat()}
r = client.post(url, data={"v": json.dumps(d)})
assert "v" in r.json
assert r.json["v"] == d
# Test that func failing input yields input value
d = {"id": 3, "name": "Billy Bob Joe", "timestamp": datetime.datetime.now().isoformat()}
r = client.post(url, data={"v": json.dumps(d)})
assert "error" in r.json

def test_typeddict_json_schema(client):
url = "/form/typeddict/json_schema"
# Test that correct input yields input value
d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
r = client.post(url, data={"v": json.dumps(d)})
assert "v" in r.json
assert r.json["v"] == d
# Test that missing keys yields error
d = {"id": 3}
r = client.post(url, data={"v": json.dumps(d)})
assert "error" in r.json
# Test that incorrect values yields error
d = {"id": 1.03, "name": "foo", "timestamp": datetime.datetime.now().isoformat()}
r = client.post(url, data={"v": json.dumps(d)})
assert "error" in r.json

def test_typeddict_not_required(client):
url = "/form/typeddict/not_required"
# Test that all keys yields input value
d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
r = client.post(url, data={"v": json.dumps(d)})
assert "v" in r.json
assert r.json["v"] == d
# Test that missing not requried key yields input value
d = {"name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
r = client.post(url, data={"v": json.dumps(d)})
assert "v" in r.json
assert r.json["v"] == d
# Test that missing required keys yields error
d = {"name": "Merriweather"}
r = client.post(url, data={"v": json.dumps(d)})
assert "error" in r.json

def test_typeddict_required(client):
url = "/form/typeddict/required"
# Test that all keys yields input value
d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
r = client.post(url, data={"v": json.dumps(d)})
assert "v" in r.json
assert r.json["v"] == d
# Test that missing not requried key yields input value
d = {"name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
r = client.post(url, data={"v": json.dumps(d)})
assert "v" in r.json
assert r.json["v"] == d
# Test that missing required keys yields error
d = {"name": "Merriweather"}
r = client.post(url, data={"v": json.dumps(d)})
assert "error" in r.json

def test_typeddict_complex(client):
url = "/form/typeddict/complex"
# Test that correct input yields input value
d = {
"name": "change da world",
"children": [
{
"id": 4,
"name": "my final message. Goodb ye",
"timestamp": datetime.datetime.now().isoformat(),
}
],
"left": {
"x": 3.4,
"y": 1.0,
"z": 99999.34455663
},
"right": {
"x": 3.2,
"y": 1.1,
"z": 999.3663
},
}
r = client.post(url, data={"v": json.dumps(d)})
assert "v" in r.json
assert r.json["v"] == d
# Test that empty children list yields input value
d = {
"name": "change da world",
"children": [],
"left": {
"x": 3.4,
"y": 1.0,
"z": 99999.34455663
},
"right": {
"x": 3.2,
"y": 1.1,
"z": 999.3663
},
}
r = client.post(url, data={"v": json.dumps(d)})
assert "v" in r.json
assert r.json["v"] == d
# Test that incorrect child TypedDict yields error
d = {
"name": "change da world",
"children": [
{
"id": 4,
"name": 6,
"timestamp": datetime.datetime.now().isoformat(),
}
],
"left": {
"x": 3.4,
"y": 1.0,
"z": 99999.34455663
},
"right": {
"x": 3.2,
"y": 1.1,
"z": 999.3663
},
}
r = client.post(url, data={"v": json.dumps(d)})
assert "error" in r.json
# Test that omitting NotRequired key in child yields input value
d = {
"name": "tags",
"children": [
{
"id": 4,
"name": "ice my wrist",
"timestamp": datetime.datetime.now().isoformat(),
}
],
"left": {
"x": 3.4,
"y": 1.0,
"z": 99999.34455663
},
"right": {
"x": 3.2,
"y": 1.1,
"z": 999.3663
},
}
r = client.post(url, data={"v": json.dumps(d)})
assert "v" in r.json
assert r.json["v"] == d
# Test that incorrect values yields error
d = {"id": 1.03, "name": "foo", "timestamp": datetime.datetime.now().isoformat()}
r = client.post(url, data={"v": json.dumps(d)})
assert "error" in r.json

Loading