Skip to content

Commit d916377

Browse files
authored
Merge pull request #15 from d3-steichman/d3/dev/steichman/selfdoc
API Documentation Generation
2 parents d6d9681 + 8b78d62 commit d916377

File tree

7 files changed

+370
-6
lines changed

7 files changed

+370
-6
lines changed

README.md

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ All parameters can have default values, and automatic validation.
8080
* blacklist: str, A string containing forbidden characters for the value
8181
* pattern: str, A regex pattern to test for string matches
8282
* func: Callable -> Union[bool, tuple[bool, str]], A function containing a fully customized logic to validate the value
83-
* datetime_format: str, str: datetime format string ([datetime format codes](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes))
83+
* datetime_format: str: datetime format string ([datetime format codes](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes))
84+
* comment: str: A string to display as the argument description in generated documentation (if used)
8485

8586
`File` has the following options:
8687
* content_types: array of strings, an array of allowed content types.
@@ -107,4 +108,111 @@ def error_handler(err):
107108

108109
@ValidateParameters(error_handler)
109110
def api(...)
110-
```
111+
```
112+
113+
### API Documentation
114+
Using the data provided through parameters, docstrings, and Flask route registrations, Flask Parameter Validation can generate an API Documentation page. To make this easy to use, it comes with a blueprint and the configuration options below:
115+
* `FPV_DOCS_SITE_NAME: str`: Your site's name, to be displayed in the page title, default: `Site`
116+
* `FPV_DOCS_CUSTOM_BLOCKS: array`: An array of dicts to display as cards at the top of your documentation, with the (optional) keys:
117+
* `title: Optional[str]`: The title of the card
118+
* `body: Optional[str] (HTML allowed)`: The body of the card
119+
* `order: int`: The order in which to display this card (out of the other custom cards)
120+
* `FPV_DOCS_DEFAULT_THEME: str`: The default theme to display in the generated webpage
121+
122+
#### Included Blueprint
123+
The documentation blueprint can be added using the following code:
124+
```py
125+
from flask_parameter_validation.docs_blueprint import docs_blueprint
126+
...
127+
app.register_blueprint(docs_blueprint)
128+
```
129+
130+
The default blueprint adds two `GET` routes:
131+
* `/`: HTML Page with Bootstrap CSS and toggleable light/dark mode
132+
* `/json`: JSON Representation of the generated documentation
133+
134+
The `/json` route yields a response with the following format:
135+
```json
136+
{
137+
"custom_blocks": "<array entered in the FPV_DOCS_CUSTOM_BLOCKS config option, default: []>",
138+
"default_theme": "<string entered in the FPV_DOCS_DEFAULT_THEME config option, default: 'light'>",
139+
"docs": "<see get_docs_arr() return value format below>",
140+
"site_name": "<string entered in the FPV_DOCS_SITE_NAME config option, default: 'Site'"
141+
}
142+
```
143+
144+
##### Example with included Blueprint
145+
Code:
146+
```py
147+
@config_api.get("/")
148+
@ValidateParameters()
149+
def get_all_configs():
150+
"""
151+
Get the System Configuration
152+
Returns:
153+
<code>{"configs":
154+
[{"id": int,
155+
"module": str,
156+
"name": str,
157+
"description": str,
158+
"value": str}, ...]
159+
}</code>
160+
"""
161+
system_configs = []
162+
for system_config in SystemConfig.query.all():
163+
system_configs.append(system_config.toDict())
164+
return resp_success({"configs": system_configs})
165+
166+
167+
@config_api.post("/<int:config_id>")
168+
@ValidateParameters()
169+
def edit_config(
170+
config_id: int = Route(comment="The ID of the Config Record to Edit"),
171+
value: str = Json(max_str_length=2000, comment="The value to set in the Config Record")
172+
):
173+
"""Edit a specific System Configuration value"""
174+
config = SystemConfig.get_by_id(config_id)
175+
if config is None:
176+
return resp_not_found("No link exists with ID " + str(config_id))
177+
else:
178+
config.update(value)
179+
return resp_success()
180+
```
181+
Documentation Generated:
182+
183+
![](docs/api_documentation_example.png)
184+
185+
#### Custom Blueprint
186+
If you would like to use your own blueprint, you can get the raw data from the following function:
187+
```py
188+
from flask_parameter_validation.docs_blueprint import get_docs_arr
189+
...
190+
get_docs_arr()
191+
```
192+
193+
##### get_docs_arr() return value format
194+
This method returns an object with the following structure:
195+
196+
```json
197+
[
198+
{
199+
"rule": "/path/to/route",
200+
"methods": ["HTTPVerb"],
201+
"docstring": "String, unsanitized of HTML Tags",
202+
"args": {
203+
"<Subclass of Parameter this route uses>": [
204+
{
205+
"name": "Argument Name",
206+
"type": "Argument Type",
207+
"loc_args": {
208+
"<Name of argument passed to Parameter Subclass>": "Value passed to Argument",
209+
"<Name of another argument passed to Parameter Subclass>": 0
210+
}
211+
}
212+
],
213+
"<Another Subclass of Parameter this route uses>": []
214+
}
215+
},
216+
...
217+
]
218+
```

docs/api_documentation_example.png

71.3 KB
Loading
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import flask
2+
from flask import Blueprint, current_app, jsonify
3+
4+
from flask_parameter_validation import ValidateParameters
5+
6+
docs_blueprint = Blueprint(
7+
"docs", __name__, url_prefix="/docs", template_folder="./templates"
8+
)
9+
10+
11+
def get_route_docs():
12+
"""
13+
Generate documentation for all Flask routes that use the ValidateParameters decorator.
14+
Returns a list of dictionaries, each containing documentation for a particular route.
15+
"""
16+
docs = []
17+
for rule in current_app.url_map.iter_rules(): # Iterate through all Flask Routes
18+
rule_func = current_app.view_functions[
19+
rule.endpoint
20+
] # Get the associated function
21+
fn_docs = get_function_docs(rule_func)
22+
if fn_docs:
23+
fn_docs["rule"] = str(rule)
24+
fn_docs["methods"] = [str(method) for method in rule.methods]
25+
docs.append(fn_docs)
26+
return docs
27+
28+
29+
def get_function_docs(func):
30+
"""
31+
Get documentation for a specific function that uses the ValidateParameters decorator.
32+
Returns a dictionary containing documentation details, or None if the decorator is not used.
33+
"""
34+
fn_list = ValidateParameters().get_fn_list()
35+
for fsig, fdocs in fn_list.items():
36+
if fsig.endswith(func.__name__):
37+
return {
38+
"docstring": format_docstring(fdocs.get("docstring")),
39+
"decorators": fdocs.get("decorators"),
40+
"args": extract_argument_details(fdocs),
41+
}
42+
return None
43+
44+
45+
def format_docstring(docstring):
46+
"""
47+
Format a function's docstring for HTML display.
48+
"""
49+
if not docstring:
50+
return None
51+
52+
docstring = docstring.strip().replace("\n", "<br/>")
53+
return docstring.replace(" ", "&nbsp;" * 4)
54+
55+
56+
def extract_argument_details(fdocs):
57+
"""
58+
Extract details about a function's arguments, including type hints and ValidateParameters details.
59+
"""
60+
args_data = {}
61+
for idx, arg_name in enumerate(fdocs["argspec"].args):
62+
arg_data = {
63+
"name": arg_name,
64+
"type": get_arg_type_hint(fdocs, arg_name),
65+
"loc": get_arg_location(fdocs, idx),
66+
"loc_args": get_arg_location_details(fdocs, idx),
67+
}
68+
args_data.setdefault(arg_data["loc"], []).append(arg_data)
69+
return args_data
70+
71+
72+
def get_arg_type_hint(fdocs, arg_name):
73+
"""
74+
Extract the type hint for a specific argument.
75+
"""
76+
arg_type = fdocs["argspec"].annotations[arg_name]
77+
if hasattr(arg_type, "__args__"):
78+
return (
79+
f"{arg_type.__name__}[{', '.join([a.__name__ for a in arg_type.__args__])}]"
80+
)
81+
return arg_type.__name__
82+
83+
84+
def get_arg_location(fdocs, idx):
85+
"""
86+
Determine where in the request the argument comes from (e.g., Route, Json, Query).
87+
"""
88+
return type(fdocs["argspec"].defaults[idx]).__name__
89+
90+
91+
def get_arg_location_details(fdocs, idx):
92+
"""
93+
Extract additional details about the location of an argument in the request.
94+
"""
95+
loc_details = {}
96+
location = fdocs["argspec"].defaults[idx]
97+
for param, value in location.__dict__.items():
98+
if value is not None:
99+
if callable(value):
100+
loc_details[param] = f"{value.__module__}.{value.__name__}"
101+
else:
102+
loc_details[param] = value
103+
return loc_details
104+
105+
106+
@docs_blueprint.app_template_filter()
107+
def http_badge_bg(http_method):
108+
"""
109+
Provide a color badge for various HTTP methods.
110+
"""
111+
color_map = {"GET": "bg-primary", "POST": "bg-success", "DELETE": "bg-danger"}
112+
return color_map.get(http_method, "bg-warning")
113+
114+
115+
@docs_blueprint.route("/")
116+
def docs_html():
117+
"""
118+
Render the documentation as an HTML page.
119+
"""
120+
config = flask.current_app.config
121+
return flask.render_template(
122+
"fpv_default_docs.html",
123+
site_name=config.get("FPV_DOCS_SITE_NAME", "Site"),
124+
docs=get_route_docs(),
125+
custom_blocks=config.get("FPV_DOCS_CUSTOM_BLOCKS", []),
126+
default_theme=config.get("FPV_DOCS_DEFAULT_THEME", "light"),
127+
)
128+
129+
130+
@docs_blueprint.route("/json")
131+
def docs_json():
132+
"""
133+
Provide the documentation as a JSON response.
134+
"""
135+
config = flask.current_app.config
136+
return jsonify(
137+
{
138+
"site_name": config.get("FPV_DOCS_SITE_NAME", "Site"),
139+
"docs": get_route_docs(),
140+
"custom_blocks": config.get("FPV_DOCS_CUSTOM_BLOCKS", []),
141+
"default_theme": config.get("FPV_DOCS_DEFAULT_THEME", "light"),
142+
}
143+
)

flask_parameter_validation/parameter_types/parameter.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ def __init__(
2525
blacklist=None, # str: character blacklist
2626
pattern=None, # str: regexp pattern
2727
func=None, # Callable -> Union[bool, tuple[bool, str]]: function performing a fully customized validation
28-
datetime_format=None, # str: datetime format string (https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes)
28+
datetime_format=None, # str: datetime format string (https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes),
29+
comment=None
2930
):
3031
self.default = default
3132
self.min_list_length = min_list_length
@@ -39,6 +40,7 @@ def __init__(
3940
self.pattern = pattern
4041
self.func = func
4142
self.datetime_format = datetime_format
43+
self.comment = comment
4244

4345
# Validator
4446
def validate(self, value):

flask_parameter_validation/parameter_validation.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
1+
import typing
2+
13
import re
24

35
from .parameter_types import Route, Json, Query, Form, File
46
from .exceptions import MissingInputError, InvalidParameterTypeError, ValidationError
5-
from flask import request
7+
from flask import request, current_app
68
from inspect import signature
79
from werkzeug.exceptions import BadRequest
10+
import inspect
811

9-
12+
fn_list = dict()
1013
class ValidateParameters:
14+
@classmethod
15+
def get_fn_list(cls):
16+
return fn_list
1117

1218
def __init__(self, error_handler=None):
1319
self.custom_error_handler = error_handler
@@ -16,6 +22,17 @@ def __call__(self, f):
1622
"""
1723
Parent flow for validating each required parameter
1824
"""
25+
fsig = f.__module__+"."+f.__name__
26+
argspec = inspect.getfullargspec(f)
27+
source = inspect.getsource(f)
28+
index = source.find("def ")
29+
decorators = []
30+
for line in source[:index].strip().splitlines():
31+
if line.strip()[0] == "@":
32+
decorators.append(line)
33+
fdocs = {"argspec": argspec, "docstring": f.__doc__.strip() if f.__doc__ else None, "decorators": decorators.copy()}
34+
fn_list[fsig] = fdocs
35+
1936
def nested_func(**kwargs):
2037
# Step 1 - Combine all flask input types to one dict
2138
json_input = None
@@ -165,3 +182,4 @@ def validate(self, expected_input, all_request_inputs):
165182
if len(user_inputs) == 1:
166183
return user_inputs[0]
167184
return user_inputs
185+

0 commit comments

Comments
 (0)