Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix issue where complex setups like the testing application cause API docs to break down #59

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
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
35 changes: 29 additions & 6 deletions flask_parameter_validation/docs_blueprint.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions flask_parameter_validation/parameter_validation.py
Original file line number Diff line number Diff line change
@@ -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 ")
3 changes: 2 additions & 1 deletion flask_parameter_validation/test/conftest.py
Original file line number Diff line number Diff line change
@@ -6,7 +6,8 @@
def app():
app = create_app()
app.config.update({"TESTING": True})
yield app
with app.app_context():
yield app


@pytest.fixture()
56 changes: 56 additions & 0 deletions flask_parameter_validation/test/test_api_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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]"}
}
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"]
8 changes: 6 additions & 2 deletions flask_parameter_validation/test/testing_application.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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,