diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index 47a8c8f..671e251 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -1,3 +1,4 @@ +from enum import Enum import flask from flask import Blueprint, current_app, jsonify @@ -33,7 +34,7 @@ def get_function_docs(func): """ fn_list = ValidateParameters().get_fn_list() for fsig, fdocs in fn_list.items(): - if fsig.endswith(func.__name__): + if hasattr(func, "__fpv_discriminated_sig__") and func.__fpv_discriminated_sig__ == fsig: return { "docstring": format_docstring(fdocs.get("docstring")), "decorators": fdocs.get("decorators"), @@ -74,11 +75,21 @@ def get_arg_type_hint(fdocs, arg_name): Extract the type hint for a specific argument. """ arg_type = fdocs["argspec"].annotations[arg_name] - if hasattr(arg_type, "__args__"): - return ( - f"{arg_type.__name__}[{', '.join([a.__name__ for a in arg_type.__args__])}]" - ) - return arg_type.__name__ + def recursively_resolve_type_hint(type_to_resolve): + if hasattr(type_to_resolve, "__name__"): # In Python 3.9, Optional and Union do not have __name__ + type_base_name = type_to_resolve.__name__ + elif hasattr(type_to_resolve, "_name") and type_to_resolve._name is not None: + # In Python 3.9, _name exists on list[whatever] and has a non-None value + type_base_name = type_to_resolve._name + else: + # But, in Python 3.9, Optional[whatever] has _name of None - but its __origin__ is Union + type_base_name = type_to_resolve.__origin__._name + if hasattr(type_to_resolve, "__args__"): + return ( + f"{type_base_name}[{', '.join([recursively_resolve_type_hint(a) for a in type_to_resolve.__args__])}]" + ) + return type_base_name + return recursively_resolve_type_hint(arg_type) def get_arg_location(fdocs, idx): @@ -98,6 +109,18 @@ def get_arg_location_details(fdocs, idx): if value is not None: if callable(value): loc_details[param] = f"{value.__module__}.{value.__name__}" + elif issubclass(type(value), Enum): + loc_details[param] = f"{type(value).__name__}.{value.name}: " + if issubclass(type(value), int): + loc_details[param] += f"{value.value}" + elif issubclass(type(value), str): + loc_details[param] += f"'{value.value}'" + else: + loc_details[param] = f"FPV: Unsupported Enum type" + elif type(value).__name__ == 'time': + loc_details[param] = value.isoformat() + elif param == 'sources': + loc_details[param] = [type(source).__name__ for source in value] else: loc_details[param] = value return loc_details diff --git a/flask_parameter_validation/parameter_validation.py b/flask_parameter_validation/parameter_validation.py index 2eaef20..e3e24f3 100644 --- a/flask_parameter_validation/parameter_validation.py +++ b/flask_parameter_validation/parameter_validation.py @@ -2,6 +2,7 @@ import functools import inspect import re +import uuid from inspect import signature from flask import request, Response from werkzeug.datastructures import ImmutableMultiDict @@ -28,6 +29,11 @@ def __call__(self, f): Parent flow for validating each required parameter """ fsig = f.__module__ + "." + f.__name__ + # Add a discriminator to the function signature, store it in the properties of the function + # This is used in documentation generation to associate the info gathered from inspecting the + # function with the properties passed to the ValidateParameters decorator + f.__fpv_discriminated_sig__ = f"{uuid.uuid4()}_{fsig}" + fsig = f.__fpv_discriminated_sig__ argspec = inspect.getfullargspec(f) source = inspect.getsource(f) index = source.find("def ") diff --git a/flask_parameter_validation/test/conftest.py b/flask_parameter_validation/test/conftest.py index 9fbaf6e..500622a 100644 --- a/flask_parameter_validation/test/conftest.py +++ b/flask_parameter_validation/test/conftest.py @@ -6,7 +6,8 @@ def app(): app = create_app() app.config.update({"TESTING": True}) - yield app + with app.app_context(): + yield app @pytest.fixture() diff --git a/flask_parameter_validation/test/test_api_docs.py b/flask_parameter_validation/test/test_api_docs.py new file mode 100644 index 0000000..33cc773 --- /dev/null +++ b/flask_parameter_validation/test/test_api_docs.py @@ -0,0 +1,57 @@ +import sys +from flask_parameter_validation.docs_blueprint import get_route_docs + +def test_http_ok(client): + r = client.get("/docs/") + assert r.status_code == 200 + r = client.get("/docs/json") + assert r.status_code == 200 +import sys +def test_routes_added(app): + routes = [] + for rule in app.url_map.iter_rules(): + routes.append(str(rule)) + for doc in get_route_docs(): + assert doc["rule"] in routes + +def test_doc_types_of_default(app): + locs = { + "form": "Form", + "json": "Json", + "query": "Query", + "route": "Route" + } + optional_as_str = "Optional" if sys.version_info >= (3,10) else "Union" + types = { + "bool": {"opt": f"{optional_as_str}[bool, NoneType]", "n_opt": "bool"}, + "date": {"opt": f"{optional_as_str}[date, NoneType]", "n_opt": "date"}, + "datetime": {"opt": f"{optional_as_str}[datetime, NoneType]", "n_opt": "datetime"}, + "dict": {"opt": f"{optional_as_str}[dict, NoneType]", "n_opt": "dict"}, + "float": {"opt": f"{optional_as_str}[float, NoneType]", "n_opt": "float"}, + "int": {"opt": f"{optional_as_str}[int, NoneType]", "n_opt": "int"}, + "int_enum": {"opt": f"{optional_as_str}[Binary, NoneType]", "n_opt": "Binary"}, + "list": {"opt": f"{optional_as_str}[List[int], NoneType]", "n_opt": "List[str]"}, + "str": {"opt": f"{optional_as_str}[str, NoneType]", "n_opt": "str"}, + "str_enum": {"opt": f"{optional_as_str}[Fruits, NoneType]", "n_opt": "Fruits"}, + "time": {"opt": f"{optional_as_str}[time, NoneType]", "n_opt": "time"}, + "union": {"opt": "Union[bool, int, NoneType]", "n_opt": "Union[bool, int]"}, + "uuid": {"opt": f"{optional_as_str}[UUID, NoneType]", "n_opt": "UUID"} + } + route_unsupported_types = ["dict", "list"] + route_docs = get_route_docs() + for loc in locs.keys(): + for arg_type in types.keys(): + if loc == "route" and arg_type in route_unsupported_types: + continue + route_to_check = f"/{loc}/{arg_type}/default" + for doc in route_docs: + if doc["rule"] == route_to_check: + args = doc["args"][locs[loc]] + if args[0]["name"] == "n_opt": + n_opt = args[0] + opt = args[1] + else: + opt = args[0] + n_opt = args[1] + assert n_opt["type"] == types[arg_type]["n_opt"] + assert opt["type"] == types[arg_type]["opt"] diff --git a/flask_parameter_validation/test/testing_application.py b/flask_parameter_validation/test/testing_application.py index 85fd02e..e25bd10 100644 --- a/flask_parameter_validation/test/testing_application.py +++ b/flask_parameter_validation/test/testing_application.py @@ -6,6 +6,7 @@ from flask_parameter_validation.test.testing_blueprints.file_blueprint import get_file_blueprint from flask_parameter_validation.test.testing_blueprints.multi_source_blueprint import get_multi_source_blueprint from flask_parameter_validation.test.testing_blueprints.parameter_blueprint import get_parameter_blueprint +from flask_parameter_validation.docs_blueprint import docs_blueprint multi_source_sources = [ {"class": Query, "name": "query"}, @@ -22,8 +23,11 @@ def create_app(): app.register_blueprint(get_parameter_blueprint(Form, "form", "form", "post")) app.register_blueprint(get_parameter_blueprint(Route, "route", "route", "get")) app.register_blueprint(get_file_blueprint("file")) + app.register_blueprint(docs_blueprint) for source_a in multi_source_sources: for source_b in multi_source_sources: - combined_name = f"ms_{source_a['name']}_{source_b['name']}" - app.register_blueprint(get_multi_source_blueprint([source_a['class'], source_b['class']], combined_name)) + if source_a["name"] != source_b["name"]: + # There's no reason to test multi-source with two of the same source + combined_name = f"ms_{source_a['name']}_{source_b['name']}" + app.register_blueprint(get_multi_source_blueprint([source_a['class'], source_b['class']], combined_name)) return app diff --git a/flask_parameter_validation/test/testing_blueprints/dict_blueprint.py b/flask_parameter_validation/test/testing_blueprints/dict_blueprint.py index 16d71b8..f914a91 100644 --- a/flask_parameter_validation/test/testing_blueprints/dict_blueprint.py +++ b/flask_parameter_validation/test/testing_blueprints/dict_blueprint.py @@ -31,7 +31,7 @@ def optional(v: Optional[dict] = ParamType()): @ValidateParameters() def default( n_opt: dict = ParamType(default={"a": "b"}), - opt: dict = ParamType(default={"c": "d"}) + opt: Optional[dict] = ParamType(default={"c": "d"}) ): return jsonify({ "n_opt": n_opt, @@ -43,7 +43,7 @@ def default( @ValidateParameters() def decorator_default( n_opt: dict = ParamType(default={"a": "b"}), - opt: dict = ParamType(default={"c": "d"}) + opt: Optional[dict] = ParamType(default={"c": "d"}) ): return jsonify({ "n_opt": n_opt, @@ -55,7 +55,7 @@ def decorator_default( @ValidateParameters() async def async_decorator_default( n_opt: dict = ParamType(default={"a": "b"}), - opt: dict = ParamType(default={"c": "d"}) + opt: Optional[dict] = ParamType(default={"c": "d"}) ): return jsonify({ "n_opt": n_opt,