Skip to content

Commit 4080bdd

Browse files
authored
Merge pull request #65 from willowrimlinger/willowrimlinger/typeddict-support
Add TypedDict Support
2 parents c50b46a + a6567f8 commit 4080bdd

File tree

10 files changed

+1080
-6
lines changed

10 files changed

+1080
-6
lines changed

README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
## Usage Example
1111
```py
1212
from flask import Flask
13-
from typing import Optional
13+
from typing import Optional, TypedDict, NotRequired
1414
from flask_parameter_validation import ValidateParameters, Route, Json, Query
1515
from datetime import datetime
1616
from enum import Enum
@@ -23,6 +23,11 @@ class UserType(str, Enum): # In Python 3.11 or later, subclass StrEnum from enu
2323
USER = "user"
2424
SERVICE = "service"
2525

26+
class SocialLink(TypedDict):
27+
friendly_name: str
28+
url: str
29+
icon: NotRequired[str]
30+
2631
app = Flask(__name__)
2732

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

@@ -131,7 +137,8 @@ Type Hints allow for inline specification of the input type of a parameter. Some
131137
| `datetime.datetime` | Received as a `str` in ISO-8601 date-time format | Y | Y | Y | Y | N |
132138
| `datetime.date` | Received as a `str` in ISO-8601 full-date format | Y | Y | Y | Y | N |
133139
| `datetime.time` | Received as a `str` in ISO-8601 partial-time format | Y | Y | Y | Y | N |
134-
| `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 |
140+
| `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 |
141+
| `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 |
135142
| `FileStorage` | | N | N | N | N | Y |
136143
| 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 |
137144
| `uuid.UUID` | Received as a `str` with or without hyphens, case-insensitive | Y | Y | Y | Y | N |
@@ -334,6 +341,7 @@ def json_schema(data: dict = Json(json_schema=json_schema)):
334341
## Contributions
335342
Many thanks to all those who have made contributions to the project:
336343
* [d3-steichman](https://github.com/d3-steichman)/[smt5541](https://github.com/smt5541): API documentation, custom error handling, datetime validation and bug fixes
344+
* [willowrimlinger](https://github.com/willowrimlinger): TypedDict support, dict subtyping, and async view handling bug fixes
337345
* [summersz](https://github.com/summersz): Parameter aliases, async support, form type conversion and list bug fixes
338346
* [Garcel](https://github.com/Garcel): Allow passing custom validator function
339347
* [iml1111](https://github.com/iml1111): Implement regex validation

flask_parameter_validation/parameter_validation.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import re
77
import uuid
88
from inspect import signature
9-
from typing import Optional, Union, get_origin, get_args, Any
9+
from typing import Optional, Union, get_origin, get_args, Any, get_type_hints
1010

1111
import flask
1212
from flask import request
@@ -25,6 +25,11 @@
2525
from types import UnionType
2626
UNION_TYPES = [Union, UnionType]
2727

28+
if sys.version_info >= (3, 11):
29+
from typing import NotRequired, Required, is_typeddict
30+
elif sys.version_info >= (3, 9):
31+
from typing_extensions import NotRequired, Required, is_typeddict
32+
2833
class ValidateParameters:
2934
@classmethod
3035
def get_fn_list(cls):
@@ -217,6 +222,40 @@ def _generic_types_validation_helper(self,
217222
converted_list.append(sub_converted_input)
218223
return converted_list, True
219224

225+
# typeddict
226+
elif is_typeddict(expected_input_type):
227+
# check for a stringified dict (like from Query)
228+
if type(user_input) is str:
229+
try:
230+
user_input = json.loads(user_input)
231+
except ValueError:
232+
return user_input, False
233+
if type(user_input) is not dict:
234+
return user_input, False
235+
# check that we have all required keys
236+
for key in expected_input_type.__required_keys__:
237+
if key not in user_input:
238+
return user_input, False
239+
240+
# process
241+
converted_dict = {}
242+
# go through each user input key and make sure the value is the correct type
243+
for key, value in user_input.items():
244+
annotations = get_type_hints(expected_input_type)
245+
if key not in annotations:
246+
# we are strict in not allowing extra keys
247+
# if you want extra keys, use NotRequired
248+
return user_input, False
249+
# get the Required and NotRequired decorators out of the way, if present
250+
annotation_type = annotations[key]
251+
if get_origin(annotation_type) is NotRequired or get_origin(annotation_type) is Required:
252+
annotation_type = get_args(annotation_type)[0]
253+
sub_converted_input, sub_success = self._generic_types_validation_helper(expected_name, annotation_type, value, source)
254+
if not sub_success:
255+
return user_input, False
256+
converted_dict[key] = sub_converted_input
257+
return converted_dict, True
258+
220259
# dict
221260
elif get_origin(expected_input_type) is dict or expected_input_type is dict:
222261
# check for a stringified dict (like from Query or Form)
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
Flask==3.0.2
22
../../
3-
requests
3+
requests
4+
pytest

flask_parameter_validation/test/test_form_params.py

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1741,4 +1741,258 @@ def test_dict_args_str_list_3_10_union(client):
17411741
r = client.post(url, data={"v": json.dumps(d)})
17421742
assert "error" in r.json
17431743

1744+
def test_typeddict_normal(client):
1745+
url = "/form/typeddict/"
1746+
# Test that correct input yields input value
1747+
d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
1748+
r = client.post(url, data={"v": json.dumps(d)})
1749+
assert "v" in r.json
1750+
assert r.json["v"] == d
1751+
# Test that missing keys yields error
1752+
d = {"id": 3}
1753+
r = client.post(url, data={"v": json.dumps(d)})
1754+
assert "error" in r.json
1755+
# Test that incorrect values yields error
1756+
d = {"id": 1.03, "name": "foo", "timestamp": datetime.datetime.now().isoformat()}
1757+
r = client.post(url, data={"v": json.dumps(d)})
1758+
assert "error" in r.json
1759+
1760+
def test_typeddict_functional(client):
1761+
url = "/form/typeddict/functional"
1762+
# Test that correct input yields input value
1763+
d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
1764+
r = client.post(url, data={"v": json.dumps(d)})
1765+
assert "v" in r.json
1766+
assert r.json["v"] == d
1767+
# Test that missing keys yields error
1768+
d = {"id": 3}
1769+
r = client.post(url, data={"v": json.dumps(d)})
1770+
assert "error" in r.json
1771+
# Test that incorrect values yields error
1772+
d = {"id": 1.03, "name": "foo", "timestamp": datetime.datetime.now().isoformat()}
1773+
r = client.post(url, data={"v": json.dumps(d)})
1774+
assert "error" in r.json
1775+
1776+
def test_typeddict_optional(client):
1777+
url = "/form/typeddict/optional"
1778+
# Test that correct input yields input value
1779+
d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
1780+
r = client.post(url, data={"v": json.dumps(d)})
1781+
assert "v" in r.json
1782+
assert r.json["v"] == d
1783+
# Test that no input yields input value
1784+
d = None
1785+
r = client.post(url, data={"v": d})
1786+
assert "v" in r.json
1787+
assert r.json["v"] == d
1788+
# Test that missing keys yields error
1789+
d = {"id": 3}
1790+
r = client.post(url, data={"v": json.dumps(d)})
1791+
assert "error" in r.json
1792+
# Test that empty dict yields error
1793+
d = {}
1794+
r = client.post(url, data={"v": json.dumps(d)})
1795+
assert "error" in r.json
1796+
1797+
if sys.version_info >= (3, 10):
1798+
def test_typeddict_union_optional(client):
1799+
url = "/form/typeddict/union_optional"
1800+
# Test that correct input yields input value
1801+
d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
1802+
r = client.post(url, data={"v": json.dumps(d)})
1803+
assert "v" in r.json
1804+
assert r.json["v"] == d
1805+
# Test that no input yields input value
1806+
d = None
1807+
r = client.post(url, data={"v": d})
1808+
assert "v" in r.json
1809+
assert r.json["v"] == d
1810+
# Test that missing keys yields error
1811+
d = {"id": 3}
1812+
r = client.post(url, data={"v": json.dumps(d)})
1813+
assert "error" in r.json
1814+
# Test that empty dict yields error
1815+
d = {}
1816+
r = client.post(url, data={"v": json.dumps(d)})
1817+
assert "error" in r.json
1818+
1819+
def test_typeddict_default(client):
1820+
url = "/form/typeddict/default"
1821+
# Test that missing input for required and optional yields default values
1822+
r = client.post(url)
1823+
assert "n_opt" in r.json
1824+
assert r.json["n_opt"] == {"id": 1, "name": "Bob", "timestamp": datetime.datetime(2025, 11, 18, 0, 0).isoformat()}
1825+
assert "opt" in r.json
1826+
assert r.json["opt"] == {"id": 2, "name": "Billy", "timestamp": datetime.datetime(2025, 11, 18, 5, 30).isoformat()}
1827+
# Test that present TypedDict input for required and optional yields input values
1828+
d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
1829+
r = client.post(url, data={
1830+
"opt": json.dumps(d),
1831+
"n_opt": json.dumps(d),
1832+
})
1833+
assert "opt" in r.json
1834+
assert r.json["opt"] == d
1835+
assert "n_opt" in r.json
1836+
assert r.json["n_opt"] == d
1837+
# Test that present non-TypedDict input for required yields error
1838+
r = client.post(url, data={"opt": {"id": 3}, "n_opt": "b"})
1839+
assert "error" in r.json
1840+
1841+
def test_typeddict_func(client):
1842+
url = "/form/typeddict/func"
1843+
# Test that correct input yields input value
1844+
d = {"id": 3, "name": "Bill", "timestamp": datetime.datetime.now().isoformat()}
1845+
r = client.post(url, data={"v": json.dumps(d)})
1846+
assert "v" in r.json
1847+
assert r.json["v"] == d
1848+
# Test that func failing input yields input value
1849+
d = {"id": 3, "name": "Billy Bob Joe", "timestamp": datetime.datetime.now().isoformat()}
1850+
r = client.post(url, data={"v": json.dumps(d)})
1851+
assert "error" in r.json
1852+
1853+
def test_typeddict_json_schema(client):
1854+
url = "/form/typeddict/json_schema"
1855+
# Test that correct input yields input value
1856+
d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
1857+
r = client.post(url, data={"v": json.dumps(d)})
1858+
assert "v" in r.json
1859+
assert r.json["v"] == d
1860+
# Test that missing keys yields error
1861+
d = {"id": 3}
1862+
r = client.post(url, data={"v": json.dumps(d)})
1863+
assert "error" in r.json
1864+
# Test that incorrect values yields error
1865+
d = {"id": 1.03, "name": "foo", "timestamp": datetime.datetime.now().isoformat()}
1866+
r = client.post(url, data={"v": json.dumps(d)})
1867+
assert "error" in r.json
1868+
1869+
def test_typeddict_not_required(client):
1870+
url = "/form/typeddict/not_required"
1871+
# Test that all keys yields input value
1872+
d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
1873+
r = client.post(url, data={"v": json.dumps(d)})
1874+
assert "v" in r.json
1875+
assert r.json["v"] == d
1876+
# Test that missing not requried key yields input value
1877+
d = {"name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
1878+
r = client.post(url, data={"v": json.dumps(d)})
1879+
assert "v" in r.json
1880+
assert r.json["v"] == d
1881+
# Test that missing required keys yields error
1882+
d = {"name": "Merriweather"}
1883+
r = client.post(url, data={"v": json.dumps(d)})
1884+
assert "error" in r.json
1885+
1886+
def test_typeddict_required(client):
1887+
url = "/form/typeddict/required"
1888+
# Test that all keys yields input value
1889+
d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
1890+
r = client.post(url, data={"v": json.dumps(d)})
1891+
assert "v" in r.json
1892+
assert r.json["v"] == d
1893+
# Test that missing not requried key yields input value
1894+
d = {"name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()}
1895+
r = client.post(url, data={"v": json.dumps(d)})
1896+
assert "v" in r.json
1897+
assert r.json["v"] == d
1898+
# Test that missing required keys yields error
1899+
d = {"name": "Merriweather"}
1900+
r = client.post(url, data={"v": json.dumps(d)})
1901+
assert "error" in r.json
1902+
1903+
def test_typeddict_complex(client):
1904+
url = "/form/typeddict/complex"
1905+
# Test that correct input yields input value
1906+
d = {
1907+
"name": "change da world",
1908+
"children": [
1909+
{
1910+
"id": 4,
1911+
"name": "my final message. Goodb ye",
1912+
"timestamp": datetime.datetime.now().isoformat(),
1913+
}
1914+
],
1915+
"left": {
1916+
"x": 3.4,
1917+
"y": 1.0,
1918+
"z": 99999.34455663
1919+
},
1920+
"right": {
1921+
"x": 3.2,
1922+
"y": 1.1,
1923+
"z": 999.3663
1924+
},
1925+
}
1926+
r = client.post(url, data={"v": json.dumps(d)})
1927+
assert "v" in r.json
1928+
assert r.json["v"] == d
1929+
# Test that empty children list yields input value
1930+
d = {
1931+
"name": "change da world",
1932+
"children": [],
1933+
"left": {
1934+
"x": 3.4,
1935+
"y": 1.0,
1936+
"z": 99999.34455663
1937+
},
1938+
"right": {
1939+
"x": 3.2,
1940+
"y": 1.1,
1941+
"z": 999.3663
1942+
},
1943+
}
1944+
r = client.post(url, data={"v": json.dumps(d)})
1945+
assert "v" in r.json
1946+
assert r.json["v"] == d
1947+
# Test that incorrect child TypedDict yields error
1948+
d = {
1949+
"name": "change da world",
1950+
"children": [
1951+
{
1952+
"id": 4,
1953+
"name": 6,
1954+
"timestamp": datetime.datetime.now().isoformat(),
1955+
}
1956+
],
1957+
"left": {
1958+
"x": 3.4,
1959+
"y": 1.0,
1960+
"z": 99999.34455663
1961+
},
1962+
"right": {
1963+
"x": 3.2,
1964+
"y": 1.1,
1965+
"z": 999.3663
1966+
},
1967+
}
1968+
r = client.post(url, data={"v": json.dumps(d)})
1969+
assert "error" in r.json
1970+
# Test that omitting NotRequired key in child yields input value
1971+
d = {
1972+
"name": "tags",
1973+
"children": [
1974+
{
1975+
"id": 4,
1976+
"name": "ice my wrist",
1977+
"timestamp": datetime.datetime.now().isoformat(),
1978+
}
1979+
],
1980+
"left": {
1981+
"x": 3.4,
1982+
"y": 1.0,
1983+
"z": 99999.34455663
1984+
},
1985+
"right": {
1986+
"x": 3.2,
1987+
"y": 1.1,
1988+
"z": 999.3663
1989+
},
1990+
}
1991+
r = client.post(url, data={"v": json.dumps(d)})
1992+
assert "v" in r.json
1993+
assert r.json["v"] == d
1994+
# Test that incorrect values yields error
1995+
d = {"id": 1.03, "name": "foo", "timestamp": datetime.datetime.now().isoformat()}
1996+
r = client.post(url, data={"v": json.dumps(d)})
1997+
assert "error" in r.json
17441998

0 commit comments

Comments
 (0)