diff --git a/README.md b/README.md index b12f6ff..1b1f74c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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/", methods=["POST"]) @@ -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!" @@ -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 | @@ -334,6 +341,7 @@ def json_schema(data: dict = Json(json_schema=json_schema)): ## Contributions Many thanks to all those who have made contributions to the project: * [d3-steichman](https://github.com/d3-steichman)/[smt5541](https://github.com/smt5541): API documentation, custom error handling, datetime validation and bug fixes +* [willowrimlinger](https://github.com/willowrimlinger): TypedDict support, dict subtyping, and async view handling bug fixes * [summersz](https://github.com/summersz): Parameter aliases, async support, form type conversion and list bug fixes * [Garcel](https://github.com/Garcel): Allow passing custom validator function * [iml1111](https://github.com/iml1111): Implement regex validation diff --git a/flask_parameter_validation/parameter_validation.py b/flask_parameter_validation/parameter_validation.py index 83ac50d..69b82b8 100644 --- a/flask_parameter_validation/parameter_validation.py +++ b/flask_parameter_validation/parameter_validation.py @@ -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 @@ -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): @@ -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) diff --git a/flask_parameter_validation/test/requirements.txt b/flask_parameter_validation/test/requirements.txt index b2bbede..3e574ab 100644 --- a/flask_parameter_validation/test/requirements.txt +++ b/flask_parameter_validation/test/requirements.txt @@ -1,3 +1,4 @@ Flask==3.0.2 ../../ -requests \ No newline at end of file +requests +pytest \ No newline at end of file diff --git a/flask_parameter_validation/test/test_form_params.py b/flask_parameter_validation/test/test_form_params.py index 4cf6a66..28aa698 100644 --- a/flask_parameter_validation/test/test_form_params.py +++ b/flask_parameter_validation/test/test_form_params.py @@ -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 diff --git a/flask_parameter_validation/test/test_json_params.py b/flask_parameter_validation/test/test_json_params.py index 0131035..8b56576 100644 --- a/flask_parameter_validation/test/test_json_params.py +++ b/flask_parameter_validation/test/test_json_params.py @@ -1985,4 +1985,258 @@ def test_dict_args_str_list_3_10_union(client): r = client.post(url, json={"v": d}) assert "error" in r.json +def test_typeddict_normal(client): + url = "/json/typeddict/" + # Test that correct input yields input value + d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()} + r = client.post(url, json={"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, json={"v": 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, json={"v": d}) + assert "error" in r.json + +def test_typeddict_functional(client): + url = "/json/typeddict/functional" + # Test that correct input yields input value + d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()} + r = client.post(url, json={"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, json={"v": 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, json={"v": d}) + assert "error" in r.json + +def test_typeddict_optional(client): + url = "/json/typeddict/optional" + # Test that correct input yields input value + d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()} + r = client.post(url, json={"v": d}) + assert "v" in r.json + assert r.json["v"] == d + # Test that no input yields input value + d = None + r = client.post(url, json={"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, json={"v": d}) + assert "error" in r.json + # Test that empty dict yields error + d = {} + r = client.post(url, json={"v": d}) + assert "error" in r.json + +if sys.version_info >= (3, 10): + def test_typeddict_union_optional(client): + url = "/json/typeddict/union_optional" + # Test that correct input yields input value + d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()} + r = client.post(url, json={"v": d}) + assert "v" in r.json + assert r.json["v"] == d + # Test that no input yields input value + d = None + r = client.post(url, json={"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, json={"v": d}) + assert "error" in r.json + # Test that empty dict yields error + d = {} + r = client.post(url, json={"v": d}) + assert "error" in r.json + +def test_typeddict_default(client): + url = "/json/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, json={ + "opt": d, + "n_opt": 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, json={"opt": {"id": 3}, "n_opt": "b"}) + assert "error" in r.json + +def test_typeddict_func(client): + url = "/json/typeddict/func" + # Test that correct input yields input value + d = {"id": 3, "name": "Bill", "timestamp": datetime.datetime.now().isoformat()} + r = client.post(url, json={"v": 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, json={"v": d}) + assert "error" in r.json + +def test_typeddict_json_schema(client): + url = "/json/typeddict/json_schema" + # Test that correct input yields input value + d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()} + r = client.post(url, json={"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, json={"v": 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, json={"v": d}) + assert "error" in r.json + +def test_typeddict_not_required(client): + url = "/json/typeddict/not_required" + # Test that all keys yields input value + d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()} + r = client.post(url, json={"v": 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, json={"v": 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, json={"v": d}) + assert "error" in r.json + +def test_typeddict_required(client): + url = "/json/typeddict/required" + # Test that all keys yields input value + d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()} + r = client.post(url, json={"v": 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, json={"v": 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, json={"v": d}) + assert "error" in r.json + +def test_typeddict_complex(client): + url = "/json/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, json={"v": 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, json={"v": 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, json={"v": 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, json={"v": 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, json={"v": d}) + assert "error" in r.json diff --git a/flask_parameter_validation/test/test_multi_source_params.py b/flask_parameter_validation/test/test_multi_source_params.py index bc4ff41..99e516c 100644 --- a/flask_parameter_validation/test/test_multi_source_params.py +++ b/flask_parameter_validation/test/test_multi_source_params.py @@ -769,3 +769,25 @@ def test_multi_source_dict_args_str_list_3_10_union(client, source_a, source_b): r = client.get(url) assert "error" in r.json +@pytest.mark.parametrize(*common_parameters) +def test_multi_source_typeddict(client, source_a, source_b): + if source_a == source_b or "route" in [source_a, source_b]: # Duplicate sources shouldn't be something someone does, so we won't test for it, Route does not support parameters of type 'dict' + return + d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()} + url = f"/ms_{source_a}_{source_b}/typeddict/" + for source in [source_a, source_b]: + # Test that present input yields input value + r = None + if source == "query": + r = client.get(url, query_string={"v": json.dumps(d)}) + elif source == "form": + r = client.get(url, data={"v": json.dumps(d)}) + elif source == "json": + r = client.get(url, json={"v": d}) + assert r is not None + assert "v" in r.json + assert r.json["v"] == d + # Test that missing input yields error + r = client.get(url) + assert "error" in r.json + diff --git a/flask_parameter_validation/test/test_query_params.py b/flask_parameter_validation/test/test_query_params.py index 7c0b3eb..2feb83c 100644 --- a/flask_parameter_validation/test/test_query_params.py +++ b/flask_parameter_validation/test/test_query_params.py @@ -2850,4 +2850,258 @@ def test_dict_args_str_list_3_10_union(client): r = client.get(url, query_string={"v": json.dumps(d)}) assert "error" in r.json +def test_typeddict_normal(client): + url = "/query/typeddict/" + # Test that correct input yields input value + d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()} + r = client.get(url, query_string={"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.get(url, query_string={"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.get(url, query_string={"v": json.dumps(d)}) + assert "error" in r.json + +def test_typeddict_functional(client): + url = "/query/typeddict/functional" + # Test that correct input yields input value + d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()} + r = client.get(url, query_string={"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.get(url, query_string={"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.get(url, query_string={"v": json.dumps(d)}) + assert "error" in r.json + +def test_typeddict_optional(client): + url = "/query/typeddict/optional" + # Test that correct input yields input value + d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()} + r = client.get(url, query_string={"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.get(url, query_string={"v": d}) + assert "v" in r.json + assert r.json["v"] == d + # Test that missing keys yields error + d = {"id": 3} + r = client.get(url, query_string={"v": json.dumps(d)}) + assert "error" in r.json + # Test that empty dict yields error + d = {} + r = client.get(url, query_string={"v": json.dumps(d)}) + assert "error" in r.json + +if sys.version_info >= (3, 10): + def test_typeddict_union_optional(client): + url = "/query/typeddict/union_optional" + # Test that correct input yields input value + d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()} + r = client.get(url, query_string={"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.get(url, query_string={"v": d}) + assert "v" in r.json + assert r.json["v"] == d + # Test that missing keys yields error + d = {"id": 3} + r = client.get(url, query_string={"v": json.dumps(d)}) + assert "error" in r.json + # Test that empty dict yields error + d = {} + r = client.get(url, query_string={"v": json.dumps(d)}) + assert "error" in r.json + +def test_typeddict_default(client): + url = "/query/typeddict/default" + # Test that missing input for required and optional yields default values + r = client.get(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.get(url, query_string={ + "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.get(url, query_string={"opt": {"id": 3}, "n_opt": "b"}) + assert "error" in r.json + +def test_typeddict_func(client): + url = "/query/typeddict/func" + # Test that correct input yields input value + d = {"id": 3, "name": "Bill", "timestamp": datetime.datetime.now().isoformat()} + r = client.get(url, query_string={"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.get(url, query_string={"v": json.dumps(d)}) + assert "error" in r.json + +def test_typeddict_json_schema(client): + url = "/query/typeddict/json_schema" + # Test that correct input yields input value + d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()} + r = client.get(url, query_string={"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.get(url, query_string={"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.get(url, query_string={"v": json.dumps(d)}) + assert "error" in r.json + +def test_typeddict_not_required(client): + url = "/query/typeddict/not_required" + # Test that all keys yields input value + d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()} + r = client.get(url, query_string={"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.get(url, query_string={"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.get(url, query_string={"v": json.dumps(d)}) + assert "error" in r.json + +def test_typeddict_required(client): + url = "/query/typeddict/required" + # Test that all keys yields input value + d = {"id": 3, "name": "Merriweather", "timestamp": datetime.datetime.now().isoformat()} + r = client.get(url, query_string={"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.get(url, query_string={"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.get(url, query_string={"v": json.dumps(d)}) + assert "error" in r.json + +def test_typeddict_complex(client): + url = "/query/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.get(url, query_string={"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.get(url, query_string={"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.get(url, query_string={"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.get(url, query_string={"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.get(url, query_string={"v": json.dumps(d)}) + assert "error" in r.json diff --git a/flask_parameter_validation/test/testing_blueprints/multi_source_blueprint.py b/flask_parameter_validation/test/testing_blueprints/multi_source_blueprint.py index d57dfc4..aa62ec2 100644 --- a/flask_parameter_validation/test/testing_blueprints/multi_source_blueprint.py +++ b/flask_parameter_validation/test/testing_blueprints/multi_source_blueprint.py @@ -1,7 +1,12 @@ import sys import datetime import uuid -from typing import Optional, List, Union +from typing import Optional, List, Union, TypedDict + +if sys.version_info >= (3, 11): + from typing import NotRequired, Required, is_typeddict, TypedDict +elif sys.version_info >= (3, 9): + from typing_extensions import NotRequired, Required, is_typeddict, TypedDict from flask import Blueprint, jsonify @@ -238,5 +243,20 @@ def multi_source_dict_str_list_3_10_union(v: dict[str, Union[list[int], bool]] = assert type(ele) is int return jsonify({"v": v}) + class Simple(TypedDict): + id: int + name: str + timestamp: datetime.datetime + + @param_bp.route("/typeddict/", methods=["GET", "POST"]) + @ValidateParameters() + def multi_source_typeddict_normal(v: Simple = MultiSource(sources[0], sources[1], list_disable_query_csv=True)): + assert type(v) is dict + assert "id" in v and "name" in v and "timestamp" in v + assert type(v["id"]) is int + assert type(v["name"]) is str + assert type(v["timestamp"]) is datetime.datetime + v["timestamp"] = v["timestamp"].isoformat() + return jsonify({"v": v}) return param_bp diff --git a/flask_parameter_validation/test/testing_blueprints/parameter_blueprint.py b/flask_parameter_validation/test/testing_blueprints/parameter_blueprint.py index 9a55876..d9fc013 100644 --- a/flask_parameter_validation/test/testing_blueprints/parameter_blueprint.py +++ b/flask_parameter_validation/test/testing_blueprints/parameter_blueprint.py @@ -14,6 +14,7 @@ from flask_parameter_validation.test.testing_blueprints.time_blueprint import get_time_blueprint from flask_parameter_validation.test.testing_blueprints.union_blueprint import get_union_blueprint from flask_parameter_validation.test.testing_blueprints.uuid_blueprint import get_uuid_blueprint +from flask_parameter_validation.test.testing_blueprints.typeddict_blueprint import get_typeddict_blueprint def get_parameter_blueprint(ParamType: type[Parameter], bp_name: str, param_name: str, http_verb: str) -> Blueprint: @@ -33,4 +34,5 @@ def get_parameter_blueprint(ParamType: type[Parameter], bp_name: str, param_name param_bp.register_blueprint(get_enum_blueprint(ParamType, f"{bp_name}_str_enum", http_verb, Fruits, "str_enum")) param_bp.register_blueprint(get_enum_blueprint(ParamType, f"{bp_name}_int_enum", http_verb, Binary, "int_enum")) param_bp.register_blueprint(get_uuid_blueprint(ParamType, f"{bp_name}_uuid", http_verb)) + param_bp.register_blueprint(get_typeddict_blueprint(ParamType, f"{bp_name}_typeddict", http_verb)) return param_bp diff --git a/flask_parameter_validation/test/testing_blueprints/typeddict_blueprint.py b/flask_parameter_validation/test/testing_blueprints/typeddict_blueprint.py new file mode 100644 index 0000000..b40c7e7 --- /dev/null +++ b/flask_parameter_validation/test/testing_blueprints/typeddict_blueprint.py @@ -0,0 +1,220 @@ +import datetime +import sys +from typing import Optional + +if sys.version_info >= (3, 11): + from typing import NotRequired, Required, is_typeddict, TypedDict +elif sys.version_info >= (3, 9): + from typing_extensions import NotRequired, Required, is_typeddict, TypedDict + +from flask import Blueprint, jsonify + +from flask_parameter_validation import ValidateParameters +from flask_parameter_validation.parameter_types.parameter import Parameter + + +def get_typeddict_blueprint(ParamType: type[Parameter], bp_name: str, http_verb: str) -> Blueprint: + typeddict_bp = Blueprint(bp_name, __name__, url_prefix="/typeddict") + decorator = getattr(typeddict_bp, http_verb) + + # TypedDict not currently supported by Route + # def path(base: str, route_additions: str) -> str: + # return base + (route_additions if ParamType is Route else "") + + class Simple(TypedDict): + id: int + name: str + timestamp: datetime.datetime + + @decorator("/") + @ValidateParameters() + def normal(v: Simple = ParamType(list_disable_query_csv=True)): + assert type(v) is dict + assert "id" in v and "name" in v and "timestamp" in v + assert type(v["id"]) is int + assert type(v["name"]) is str + assert type(v["timestamp"]) is datetime.datetime + v["timestamp"] = v["timestamp"].isoformat() + return jsonify({"v": v}) + + SimpleFunc = TypedDict("SimpleFunc", {"id": int, "name": str, "timestamp": datetime.datetime}) + + @decorator("/functional") + @ValidateParameters() + def functional(v: SimpleFunc = ParamType(list_disable_query_csv=True)): + assert type(v) is dict + assert "id" in v and "name" in v and "timestamp" in v + assert type(v["id"]) is int + assert type(v["name"]) is str + assert type(v["timestamp"]) is datetime.datetime + v["timestamp"] = v["timestamp"].isoformat() + return jsonify({"v": v}) + + @decorator("/optional") + @ValidateParameters() + def optional(v: Optional[Simple] = ParamType(list_disable_query_csv=True)): + if v is not None: + assert type(v) is dict + assert "id" in v and "name" in v and "timestamp" in v + assert type(v["id"]) is int + assert type(v["name"]) is str + assert type(v["timestamp"]) is datetime.datetime + v["timestamp"] = v["timestamp"].isoformat() + return jsonify({"v": v}) + + if sys.version_info >= (3, 10): + @decorator("/union_optional") + @ValidateParameters() + def union_optional(v: Simple | None = ParamType(list_disable_query_csv=True)): + if v is not None: + assert type(v) is dict + assert "id" in v and "name" in v and "timestamp" in v + assert type(v["id"]) is int + assert type(v["name"]) is str + assert type(v["timestamp"]) is datetime.datetime + v["timestamp"] = v["timestamp"].isoformat() + return jsonify({"v": v}) + + @decorator("/default") + @ValidateParameters() + def decorator_default( + n_opt: Simple = ParamType(default={"id": 1, "name": "Bob", "timestamp": datetime.datetime(2025, 11, 18, 0, 0)}, list_disable_query_csv=True), + opt: Optional[Simple] = ParamType(default={"id": 2, "name": "Billy", "timestamp": datetime.datetime(2025, 11, 18, 5, 30)}, list_disable_query_csv=True) + ): + assert type(n_opt) is dict + assert "id" in n_opt and "name" in n_opt and "timestamp" in n_opt + assert type(n_opt["id"]) is int + assert type(n_opt["name"]) is str + assert type(n_opt["timestamp"]) is datetime.datetime + if opt is not None: + assert type(opt) is dict + assert "id" in opt and "name" in opt and "timestamp" in opt + assert type(opt["id"]) is int + assert type(opt["name"]) is str + assert type(opt["timestamp"]) is datetime.datetime + opt["timestamp"] = opt["timestamp"].isoformat() + n_opt["timestamp"] = n_opt["timestamp"].isoformat() + return jsonify({ + "n_opt": n_opt, + "opt": opt + }) + + def is_name_short(v): + assert type(v) is dict + assert "name" in v + return len(v["name"]) <= 4 + + @decorator("/func") + @ValidateParameters() + def func(v: Simple = ParamType(func=is_name_short, list_disable_query_csv=True)): + assert type(v) is dict + assert "id" in v and "name" in v and "timestamp" in v + assert type(v["id"]) is int + assert type(v["name"]) is str + assert type(v["timestamp"]) is datetime.datetime + assert len(v["name"]) <= 4 + v["timestamp"] = v["timestamp"].isoformat() + return jsonify({"v": v}) + + json_schema = { + "type": "object", + "required": ["id", "name", "timestamp"], + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "last_name": {"type": "string"}, + } + } + + @decorator("/json_schema") + @ValidateParameters() + def json_schema(v: SimpleFunc = ParamType(json_schema=json_schema, list_disable_query_csv=True)): + assert type(v) is dict + assert "id" in v and "name" in v and "timestamp" in v + assert type(v["id"]) is int + assert type(v["name"]) is str + assert type(v["timestamp"]) is datetime.datetime + v["timestamp"] = v["timestamp"].isoformat() + return jsonify({"v": v}) + + class SimpleNotRequired(TypedDict): + id: NotRequired[int] + name: str + timestamp: datetime.datetime + + @decorator("/not_required") + @ValidateParameters() + def not_required(v: SimpleNotRequired = ParamType(list_disable_query_csv=True)): + assert type(v) is dict + assert "name" in v and "timestamp" in v + assert type(v["name"]) is str + assert type(v["timestamp"]) is datetime.datetime + if "id" in v: + assert type(v["id"]) is int + v["timestamp"] = v["timestamp"].isoformat() + return jsonify({"v": v}) + + class SimpleRequired(TypedDict, total=False): + id: int + name: Required[str] + timestamp: Required[datetime.datetime] + + @decorator("/required") + @ValidateParameters() + def required(v: SimpleRequired = ParamType(list_disable_query_csv=True)): + assert type(v) is dict + assert "name" in v and "timestamp" in v + assert type(v["name"]) is str + assert type(v["timestamp"]) is datetime.datetime + if "id" in v: + assert type(v["id"]) is int + v["timestamp"] = v["timestamp"].isoformat() + return jsonify({"v": v}) + + class Coord(TypedDict): + x: float + y: float + z: float + id: NotRequired[int] + + class Complex(TypedDict): + children: list[Simple] + left: Coord + right: Coord + name: str + + @decorator("/complex") + @ValidateParameters() + def complex(v: Complex = ParamType(list_disable_query_csv=True)): + assert type(v) is dict + assert "children" in v and "left" in v and "right" in v and "name" in v + assert type(v["name"]) is str + assert type(v["left"]) is dict + assert type(v["right"]) is dict + assert type(v["children"]) is list + new_children = [] + for ele in v["children"]: + assert type(v) is dict + assert "id" in ele and "name" in ele and "timestamp" in ele + assert type(ele["id"]) is int + assert type(ele["name"]) is str + assert type(ele["timestamp"]) is datetime.datetime + ele["timestamp"] = ele["timestamp"].isoformat() + new_children.append(ele) + v["children"] = new_children + assert "x" in v["left"] and "y" in v["left"] and "z" in v["left"] + assert type(v["left"]["x"]) is float + assert type(v["left"]["y"]) is float + assert type(v["left"]["z"]) is float + if "id" in v["left"]: + assert type(id) is int + assert "x" in v["right"] and "y" in v["right"] and "z" in v["right"] + assert type(v["right"]["x"]) is float + assert type(v["right"]["y"]) is float + assert type(v["right"]["z"]) is float + if "id" in v["right"]: + assert type(id) is int + return jsonify({"v": v}) + + return typeddict_bp +