From 43c0fc599b85602524f9ecb220127a68cae9a9ef Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Mon, 17 Jun 2024 23:47:14 -0400 Subject: [PATCH 01/18] WIP: OpenAPI 3.1.0 Documentation Generation --- flask_parameter_validation/docs_blueprint.py | 192 ++++++++++++++++++ .../exceptions/exceptions.py | 10 + .../parameter_types/parameter.py | 1 + .../parameter_types/query.py | 3 +- .../parameter_types/route.py | 3 +- .../parameter_validation.py | 6 +- 6 files changed, 212 insertions(+), 3 deletions(-) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index 8712b29..158ac9f 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -1,8 +1,13 @@ +import json +import warnings +from typing import Optional import sys from enum import Enum import flask from flask import Blueprint, current_app, jsonify from flask_parameter_validation import ValidateParameters +from flask_parameter_validation.exceptions.exceptions import ConfigurationError +import re if sys.version_info >= (3, 10): from types import UnionType @@ -42,6 +47,8 @@ def get_function_docs(func): "docstring": format_docstring(fdocs.get("docstring")), "decorators": fdocs.get("decorators"), "args": extract_argument_details(fdocs), + "deprecated": fdocs.get("deprecated"), + "responses": fdocs.get("openapi_responses"), } return None @@ -170,3 +177,188 @@ def docs_json(): "default_theme": config.get("FPV_DOCS_DEFAULT_THEME", "light"), } ) + + +def fpv_error(message): + return jsonify({"error": message}) + + +def parameter_required(param): + if param["type"].startswith("Optional["): + return False + elif "default" in param["loc_args"]: + return False + return True + +def generate_json_schema_helper(param, param_type, parent_group=None): + match = re.match(r'(\w+)\[([\w\[\] ,.]+)]', param_type) + if match: + type_group = match.group(1) + type_params = match.group(2) + return generate_json_schema_helper(param, type_params, parent_group=type_group) + elif "|" in param_type and "[" not in param_type: # Handle Union shorthand as Union + return generate_json_schema_helper(param, f"Union[{param_type.replace('|', ',')}]", parent_group=parent_group) + else: + schemas = [] + param_types = [param_type] + if parent_group in ["Union", "Optional"]: + if "," in param_type: + param_types = [p.strip() for p in param_type.split(",")] + for p in param_types: + print(f"{param['name']}: {p}") + subschema = {} + if p == "str": + subschema["type"] = "string" + if "min_str_length" in param["loc_args"]: + subschema["minLength"] = param["loc_args"]["min_str_length"] + if "max_str_length" in param["loc_args"]: + subschema["maxLength"] = param["loc_args"]["max_str_length"] + # TODO: Is it possible to make this work with whitelist, blacklist and pattern simultaneously? + elif p == "int": + subschema["type"] = "integer" + if "min_int" in param["loc_args"]: + subschema["minimum"] = param["loc_args"]["min_int"] + if "max_int" in param["loc_args"]: + subschema["maximum"] = param["loc_args"]["max_int"] + elif p == "bool": + subschema["type"] = "boolean" + elif p == "float": + subschema["type"] = "number" + elif p in ["datetime", "datetime.datetime"]: + subschema["type"] = "string" + subschema["format"] = "date-time" + if "datetime_format" in param["loc_args"]: + warnings.warn("datetime_format cannot be translated to JSON Schema, please use ISO8601 date-time", + Warning, stacklevel=2) + elif p in ["date", "datetime.date"]: + subschema["type"] = "string" + subschema["format"] = "date" + elif p in ["time", "datetime.time"]: + subschema["type"] = "string" + subschema["format"] = "time" + elif p == "dict": + subschema["type"] = "object" + elif p in ["None", "NoneType"]: + subschema["type"] = "null" + else: + print(f"Unexpected type: {p}") + schemas.append(subschema) + if len(schemas) == 1 and parent_group is None: + return schemas[0] + elif parent_group in ["Optional", "Union"]: + return {"oneOf": schemas} + elif parent_group == "List": + schema = {"type": "array", "items": schemas[0]} + if "min_list_length" in param["loc_args"]: + schema["minItems"] = param["loc_args"]["min_list_length"] + if "max_list_length" in param["loc_args"]: + schema["maxItems"] = param["loc_args"]["max_list_length"] + return schema + else: + print(f"Unexpected situation: {param_type}, {parent_group}") + + +def generate_json_schema_for_parameter(param): + return generate_json_schema_helper(param, param["type"]) + + +def generate_json_schema_for_parameters(params): + schema = { + "type": "object", + "properties": {}, + "required": [] + } + for p in params: + schema_parameter_name = p["name"] if "alias" not in p["loc_args"] else p["loc_args"]["alias"] + if "json_schema" in p["loc_args"]: + schema["properties"][schema_parameter_name] = p["loc_args"]["json_schema"] + else: + schema["properties"][schema_parameter_name] = generate_json_schema_for_parameter(p) + if parameter_required(p): + schema["required"].append(schema_parameter_name) + return schema + +def generate_openapi_paths_object(): + oapi_paths = {} + for route in get_route_docs(): + oapi_path_route = re.sub(r'<(\w+):(\w+)>', r'{\2}', route['rule']) + oapi_path_route = re.sub(r'<(\w+)>', r'{\1}', oapi_path_route) + print(f"Adding {route['rule']} to paths as {oapi_path_route}") + oapi_path_item = {} + oapi_operation = {} # tags, summary, description, externalDocs, operationId, parameters, requestBody, responses, callbacks, deprecated, security, servers + oapi_parameters = [] + oapi_request_body = {"content": {}} + for arg_loc in route["args"]: + if arg_loc == "Form": + oapi_request_body["content"]["application/x-www-form-urlencoded"] = { + "schema": generate_json_schema_for_parameters(route["args"][arg_loc])} + elif arg_loc == "Json": + oapi_request_body["content"]["application/json"] = { + "schema": generate_json_schema_for_parameters(route["args"][arg_loc])} + elif arg_loc == "File": # See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#considerations-for-file-uploads + for arg in route["args"][arg_loc]: + if "content_types" in arg["loc_args"]: + for content_type in arg["loc_args"]["content_types"]: + oapi_request_body["content"][content_type] = {} + else: + oapi_request_body["content"]["application/octet-stream"] = {} + elif arg_loc in ["Route", "Query"]: + for arg in route["args"][arg_loc]: + if "alias" in arg["loc_args"]: + oapi_path_route = oapi_path_route.replace(f'{{{arg["name"]}}}', + f'{{{arg["loc_args"]["alias"]}}}') + schema_arg_name = arg["name"] if "alias" not in arg["loc_args"] else arg["loc_args"]["alias"] + if arg_loc == "Query" or (arg_loc == "Route" and f"{{{schema_arg_name}}}" in oapi_path_route): + parameter = { + "name": schema_arg_name, + "in": "path" if arg_loc == "Route" else "query", + "required": True if arg_loc == "Route" else parameter_required(arg), + "schema": arg["loc_args"]["json_schema"] if "json_schema" in arg[ + "loc_args"] else generate_json_schema_for_parameter(arg), + } + if "deprecated" in arg["loc_args"] and arg["loc_args"]["deprecated"]: + parameter["deprecated"] = arg["loc_args"]["deprecated"] + oapi_parameters.append(parameter) + if len(oapi_parameters) > 0: + oapi_operation["parameters"] = oapi_parameters + if len(oapi_request_body["content"].keys()) > 0: + oapi_operation["requestBody"] = oapi_request_body + print(route["decorators"]) + for decorator in route["decorators"]: + for partial_decorator in ["@warnings.deprecated", "@deprecated"]: # Support for PEP 702 in Python 3.13 + if partial_decorator in decorator: + oapi_operation["deprecated"] = True + if route["deprecated"]: # Fallback on kwarg passed to @ValidateParameters() + oapi_operation["deprecated"] = route["deprecated"] + if route["responses"]: + oapi_operation["responses"] = route["responses"] + for method in route["methods"]: + if method not in ["OPTIONS", "HEAD"]: + oapi_path_item[method.lower()] = oapi_operation + if oapi_path_route in oapi_paths: + oapi_paths[oapi_path_route] = oapi_paths[oapi_path_route] | oapi_path_item + else: + oapi_paths[oapi_path_route] = oapi_path_item + return oapi_paths + + + +@docs_blueprint.route("/openapi") +def docs_openapi(): + """ + Provide the documentation in OpenAPI format + """ + config = flask.current_app.config + if not config.get("FPV_OPENAPI_ENABLE", False): + return fpv_error("FPV_OPENAPI_ENABLE is not set, and defaults to False") + + supported_versions = ["3.1.0"] + openapi_base = config.get("FPV_OPENAPI_BASE", {"openapi": None}) + if openapi_base["openapi"] not in supported_versions: + return fpv_error(f"Flask-Parameter-Validation only supports OpenAPI {', '.join(supported_versions)}, {openapi_base['openapi']} provided") + if "paths" in openapi_base: + return fpv_error(f"Flask-Parameter-Validation will overwrite the paths value of FPV_OPENAPI_BASE") + openapi_paths = generate_openapi_paths_object() + openapi_document = json.loads(json.dumps(openapi_base)) + openapi_document["paths"] = openapi_paths + return jsonify(openapi_document) diff --git a/flask_parameter_validation/exceptions/exceptions.py b/flask_parameter_validation/exceptions/exceptions.py index a20505f..66daf13 100644 --- a/flask_parameter_validation/exceptions/exceptions.py +++ b/flask_parameter_validation/exceptions/exceptions.py @@ -24,5 +24,15 @@ def __init__(self, error_string, input_name, input_type): ) super().__init__(error_string, input_name, input_type) + def __str__(self): + return self.message + +class ConfigurationError(Exception): + """Called if app configuration is invalid""" + + def __init__(self, message): + self.message = message + super().__init__(message) + def __str__(self): return self.message \ No newline at end of file diff --git a/flask_parameter_validation/parameter_types/parameter.py b/flask_parameter_validation/parameter_types/parameter.py index 187a659..26ab0d7 100644 --- a/flask_parameter_validation/parameter_types/parameter.py +++ b/flask_parameter_validation/parameter_types/parameter.py @@ -98,6 +98,7 @@ def validate(self, value): except JSONSchemaValidationError as e: raise ValueError(f"failed JSON Schema validation: {e.args[0]}") elif type(value) is dict: + # TODO: Make json_schema work for all parameters besides FileStorage and datetime.*? Or maybe even datetime.*? if self.json_schema is not None: try: jsonschema.validate(value, self.json_schema) diff --git a/flask_parameter_validation/parameter_types/query.py b/flask_parameter_validation/parameter_types/query.py index 7dde98c..c692ff0 100644 --- a/flask_parameter_validation/parameter_types/query.py +++ b/flask_parameter_validation/parameter_types/query.py @@ -11,7 +11,8 @@ class Query(Parameter): name = "query" - def __init__(self, default=None, **kwargs): + def __init__(self, default=None, deprecated=False, **kwargs): + self.deprecated = deprecated super().__init__(default, **kwargs) def convert(self, value, allowed_types, current_error=None): diff --git a/flask_parameter_validation/parameter_types/route.py b/flask_parameter_validation/parameter_types/route.py index 27a4f66..8ac1dc7 100644 --- a/flask_parameter_validation/parameter_types/route.py +++ b/flask_parameter_validation/parameter_types/route.py @@ -10,7 +10,8 @@ class Route(Parameter): name = "route" - def __init__(self, default=None, **kwargs): + def __init__(self, default=None, deprecated=False, **kwargs): + self.deprecated = deprecated super().__init__(default, **kwargs) def convert(self, value, allowed_types, current_error=None): diff --git a/flask_parameter_validation/parameter_validation.py b/flask_parameter_validation/parameter_validation.py index a0b65fb..127a1e5 100644 --- a/flask_parameter_validation/parameter_validation.py +++ b/flask_parameter_validation/parameter_validation.py @@ -35,8 +35,10 @@ class ValidateParameters: def get_fn_list(cls): return fn_list - def __init__(self, error_handler=None): + def __init__(self, error_handler=None, route_deprecated=False, openapi_responses=None): self.custom_error_handler = error_handler + self.route_deprecated = route_deprecated + self.openapi_responses = openapi_responses def __call__(self, f): """ @@ -59,6 +61,8 @@ def __call__(self, f): "argspec": argspec, "docstring": f.__doc__.strip() if f.__doc__ else None, "decorators": decorators.copy(), + "deprecated": self.route_deprecated, + "openapi_responses": self.openapi_responses, } fn_list[fsig] = fdocs From 990ad86ea94fac86ce77ee3e2354fa98748838d5 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sun, 30 Jun 2024 00:59:09 -0400 Subject: [PATCH 02/18] Update README to include OpenAPI info --- README.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1b1f74c..9709b02 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,9 @@ The `@ValidateParameters()` decorator takes parameters that alter route validati | Parameter | Type | Default | Description | |-------------------|----------------------|---------|------------------------------------------------------------------------------------------------------------------------------| | error_handler | `Optional[Response]` | `None` | Overwrite the output format of generated errors, see [Overwriting Default Errors](#overwriting-default-errors) for more | +| route_deprecated | `bool` | `False` | Marks this Route as deprecated in any generated [API Documentation](#api-documentation) | +| openapi_responses | `Optional[dict]` | `None` | The OpenAPI Responses Object for this route, as a `dict` to be used in any generated [API Documentation](#api-documentation) | +| hide_from_docs | `bool` | `False` | Hide this Route from any generated [API Documentation](#api-documentation) | #### Overwriting Default Errors By default, the error messages are returned as a JSON response, with the detailed error in the "error" field, eg: @@ -167,7 +170,7 @@ Validation beyond type-checking can be done by passing arguments into the constr | `datetime_format` | `str` | `datetime.datetime` | Python datetime format string datetime format string ([datetime format codes](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes)) | | `comment` | `str` | All | A string to display as the argument description in any generated documentation | | `alias` | `str` | All but `FileStorage` | An expected parameter name to receive instead of the function name. | -| `json_schema` | `dict` | `dict` | An expected [JSON Schema](https://json-schema.org) which the dict input must conform to | +| `json_schema` | `dict` | All but `FileStorage` | An expected [JSON Schema](https://json-schema.org) which the dict input must conform to | | `content_types` | `list[str]` | `FileStorage` | Allowed `Content-Type`s | | `min_length` | `int` | `FileStorage` | Minimum `Content-Length` for a file | | `max_length` | `int` | `FileStorage` | Maximum `Content-Length` for a file | @@ -196,7 +199,10 @@ def is_odd(val: int): ### Configuration Options -#### API Documentation Configuration +#### API Documentation (OpenAPI 3.1.0) +* `FPV_OPENAPI_BASE: dict`: The base [OpenAPI Object](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#openapi-object) that will be populated with a generated [Paths Object](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#paths-object). Must be set to enable the blueprints. Alternatively, the standalone Paths Object can be retrieved anytime through the `generate_openapi_paths_object()` method. + +#### API Documentation (Non-standard Format) * `FPV_DOCS_SITE_NAME: str`: Your site's name, to be displayed in the page title, default: `Site` * `FPV_DOCS_CUSTOM_BLOCKS: array`: An array of dicts to display as cards at the top of your documentation, with the (optional) keys: * `title: Optional[str]`: The title of the card @@ -225,6 +231,7 @@ app.register_blueprint(docs_blueprint) The default blueprint adds two `GET` routes: * `/`: HTML Page with Bootstrap CSS and toggleable light/dark mode * `/json`: Non-standard Format JSON Representation of the generated documentation +* `/openapi`: OpenAPI 3.1.0 (JSON) Representation of the generated documentation The `/json` route yields a response with the following format: ```json @@ -281,8 +288,10 @@ Documentation Generated: If you would like to use your own blueprint, you can get the raw data from the following function: ```py from flask_parameter_validation.docs_blueprint import get_route_docs +from flask_parameter_validation.docs_blueprint import generate_openapi_paths_object ... get_route_docs() +generate_openapi_paths_object() ``` ###### get_route_docs() return value format @@ -295,6 +304,10 @@ This method returns an object with the following structure: "methods": ["HTTPVerb"], "docstring": "String, unsanitized of HTML Tags", "decorators": ["@decorator1", "@decorator2(param)"], + "responses": { + "openapi": "3.1.0", + "description": "See [OpenAPI Spec 3.1.0 Responses Object](https://swagger.io/specification/#response-object)" + }, "args": { "": [ { @@ -303,7 +316,8 @@ This method returns an object with the following structure: "loc_args": { "": "Value passed to Argument", "": 0 - } + }, + "deprecated": "bool, whether this parameter is deprecated (only for Route and Query params)" } ], "": [] From 7c95095bf42988921eec0ec68524e9cd4dcaa1b4 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sat, 8 Mar 2025 18:41:56 -0500 Subject: [PATCH 03/18] Fix required not showing up for Optional Unions --- flask_parameter_validation/docs_blueprint.py | 2 +- flask_parameter_validation/test/testing_application.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index 158ac9f..8440fbb 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -184,7 +184,7 @@ def fpv_error(message): def parameter_required(param): - if param["type"].startswith("Optional["): + if param["type"].startswith("Optional[") or re.match("Union\[.*None.*", param["type"]): return False elif "default" in param["loc_args"]: return False diff --git a/flask_parameter_validation/test/testing_application.py b/flask_parameter_validation/test/testing_application.py index e25bd10..962eebf 100644 --- a/flask_parameter_validation/test/testing_application.py +++ b/flask_parameter_validation/test/testing_application.py @@ -17,7 +17,10 @@ def create_app(): app = Flask(__name__) - + app.config["FPV_OPENAPI_ENABLE"] = True + app.config["FPV_OPENAPI_BASE"] = { + "openapi": "3.1.0" + } app.register_blueprint(get_parameter_blueprint(Query, "query", "query", "get")) app.register_blueprint(get_parameter_blueprint(Json, "json", "json", "post")) app.register_blueprint(get_parameter_blueprint(Form, "form", "form", "post")) From 3cb1763d4ca7774e744ce20d3be84b4eba8e0995 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Tue, 11 Mar 2025 18:23:51 -0400 Subject: [PATCH 04/18] Docs generation now more reliable --- flask_parameter_validation/docs_blueprint.py | 206 +++++++++++-------- 1 file changed, 117 insertions(+), 89 deletions(-) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index 8440fbb..7c36200 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -1,8 +1,10 @@ +import datetime import json import warnings -from typing import Optional +from copy import deepcopy +from typing import Optional, Union import sys -from enum import Enum +from enum import Enum, EnumMeta import flask from flask import Blueprint, current_app, jsonify from flask_parameter_validation import ValidateParameters @@ -17,7 +19,7 @@ ) -def get_route_docs(): +def get_route_docs(include_raw_type=False): """ Generate documentation for all Flask routes that use the ValidateParameters decorator. Returns a list of dictionaries, each containing documentation for a particular route. @@ -27,7 +29,7 @@ def get_route_docs(): rule_func = current_app.view_functions[ rule.endpoint ] # Get the associated function - fn_docs = get_function_docs(rule_func) + fn_docs = get_function_docs(rule_func, include_raw_type=include_raw_type) if fn_docs: fn_docs["rule"] = str(rule) fn_docs["methods"] = [str(method) for method in rule.methods] @@ -35,7 +37,7 @@ def get_route_docs(): return docs -def get_function_docs(func): +def get_function_docs(func, include_raw_type=False): """ Get documentation for a specific function that uses the ValidateParameters decorator. Returns a dictionary containing documentation details, or None if the decorator is not used. @@ -46,7 +48,7 @@ def get_function_docs(func): return { "docstring": format_docstring(fdocs.get("docstring")), "decorators": fdocs.get("decorators"), - "args": extract_argument_details(fdocs), + "args": extract_argument_details(fdocs, include_raw_type=include_raw_type), "deprecated": fdocs.get("deprecated"), "responses": fdocs.get("openapi_responses"), } @@ -64,45 +66,49 @@ def format_docstring(docstring): return docstring.replace(" ", " " * 4) -def extract_argument_details(fdocs): +def extract_argument_details(fdocs, include_raw_type=False): """ Extract details about a function's arguments, including type hints and ValidateParameters details. + Optionally, raw type information can be included - the default behavior is to omit this, to allow for JSON serialization. """ args_data = {} for idx, arg_name in enumerate(fdocs["argspec"].args): + type_str, raw_type = get_arg_type_hint(fdocs, arg_name) arg_data = { "name": arg_name, - "type": get_arg_type_hint(fdocs, arg_name), + "type": type_str, "loc": get_arg_location(fdocs, idx), "loc_args": get_arg_location_details(fdocs, idx), } + if include_raw_type: + arg_data["raw_type"] = raw_type args_data.setdefault(arg_data["loc"], []).append(arg_data) return args_data +def recursively_resolve_type_hint(type_to_resolve): + if sys.version_info >= (3, 10) and isinstance(type_to_resolve, UnionType): + # support 3.10 style unions (e.g. str | int) + type_base_name = "Union" + elif 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 def get_arg_type_hint(fdocs, arg_name): """ Extract the type hint for a specific argument. """ arg_type = fdocs["argspec"].annotations[arg_name] - def recursively_resolve_type_hint(type_to_resolve): - if sys.version_info >= (3, 10) and isinstance(type_to_resolve, UnionType): - # support 3.10 style unions (e.g. str | int) - type_base_name = "Union" - elif 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) + return recursively_resolve_type_hint(arg_type), arg_type def get_arg_location(fdocs, idx): @@ -190,76 +196,88 @@ def parameter_required(param): return False return True -def generate_json_schema_helper(param, param_type, parent_group=None): - match = re.match(r'(\w+)\[([\w\[\] ,.]+)]', param_type) - if match: - type_group = match.group(1) - type_params = match.group(2) - return generate_json_schema_helper(param, type_params, parent_group=type_group) - elif "|" in param_type and "[" not in param_type: # Handle Union shorthand as Union - return generate_json_schema_helper(param, f"Union[{param_type.replace('|', ',')}]", parent_group=parent_group) +def generate_json_schema_helper(param: dict, param_type: str, raw_type): + schema = {} + if raw_type is str: + schema["type"] = "string" + if "min_str_length" in param["loc_args"]: + schema["minLength"] = param["loc_args"]["min_str_length"] + if "max_str_length" in param["loc_args"]: + schema["maxLength"] = param["loc_args"]["max_str_length"] + if "pattern" in param["loc_args"]: + schema["pattern"] = param["loc_args"]["pattern"] + elif raw_type is int: + schema["type"] = "integer" + if "min_int" in param["loc_args"]: + schema["minimum"] = param["loc_args"]["min_int"] + if "max_int" in param["loc_args"]: + schema["maximum"] = param["loc_args"]["max_int"] + elif raw_type is bool: + schema["type"] = "boolean" + elif raw_type is float: + schema["type"] = "number" + elif raw_type is datetime.datetime: + schema["type"] = "string" + schema["format"] = "date-time" + if "datetime_format" in param["loc_args"]: + warnings.warn("datetime_format cannot be translated to JSON Schema, please use ISO8601 date-time", + Warning, stacklevel=2) + elif raw_type is datetime.date: + schema["type"] = "string" + schema["format"] = "date" + elif raw_type is datetime.time: + schema["type"] = "string" + schema["format"] = "time" + elif raw_type is dict: + schema["type"] = "object" + elif raw_type is type(None): + schema["type"] = "null" + elif type(raw_type) in [type, EnumMeta] and issubclass(raw_type, Enum): + if issubclass(raw_type, str): + schema["type"] = "string" + elif issubclass(raw_type, int): + schema["type"] = "integer" + else: + warnings.warn(f"Unsupported enum type: {param_type}", Warning, stacklevel=2) + # Use oneOf:[{const}] instead of enum by recommendation https://github.com/OAI/OpenAPI-Specification/issues/348#issuecomment-336194030 + options = [{"title": opt.name, "const": opt.value} for opt in raw_type] + schema["oneOf"] = options + else: - schemas = [] - param_types = [param_type] - if parent_group in ["Union", "Optional"]: - if "," in param_type: - param_types = [p.strip() for p in param_type.split(",")] - for p in param_types: - print(f"{param['name']}: {p}") - subschema = {} - if p == "str": - subschema["type"] = "string" - if "min_str_length" in param["loc_args"]: - subschema["minLength"] = param["loc_args"]["min_str_length"] - if "max_str_length" in param["loc_args"]: - subschema["maxLength"] = param["loc_args"]["max_str_length"] - # TODO: Is it possible to make this work with whitelist, blacklist and pattern simultaneously? - elif p == "int": - subschema["type"] = "integer" - if "min_int" in param["loc_args"]: - subschema["minimum"] = param["loc_args"]["min_int"] - if "max_int" in param["loc_args"]: - subschema["maximum"] = param["loc_args"]["max_int"] - elif p == "bool": - subschema["type"] = "boolean" - elif p == "float": - subschema["type"] = "number" - elif p in ["datetime", "datetime.datetime"]: - subschema["type"] = "string" - subschema["format"] = "date-time" - if "datetime_format" in param["loc_args"]: - warnings.warn("datetime_format cannot be translated to JSON Schema, please use ISO8601 date-time", - Warning, stacklevel=2) - elif p in ["date", "datetime.date"]: - subschema["type"] = "string" - subschema["format"] = "date" - elif p in ["time", "datetime.time"]: - subschema["type"] = "string" - subschema["format"] = "time" - elif p == "dict": - subschema["type"] = "object" - elif p in ["None", "NoneType"]: - subschema["type"] = "null" + match = re.match(r'(\w+)\[([\w\[\] ,.]+)]', param_type) + if not match: + warnings.warn(f"Unsupported type {param_type}", + Warning, stacklevel=2) + return {} + type_group = match.group(1) + if type_group in ["List", "list"]: + schema["type"] = "array" + available_types = [] + for subtype in raw_type.__args__: + subtype_schema = generate_json_schema_helper(param, recursively_resolve_type_hint(subtype), subtype) + available_types.append(subtype_schema) + if len(available_types) == 1: + schema["items"] = available_types[0] else: - print(f"Unexpected type: {p}") - schemas.append(subschema) - if len(schemas) == 1 and parent_group is None: - return schemas[0] - elif parent_group in ["Optional", "Union"]: - return {"oneOf": schemas} - elif parent_group == "List": - schema = {"type": "array", "items": schemas[0]} + schema["items"] = {"oneOf": available_types} if "min_list_length" in param["loc_args"]: schema["minItems"] = param["loc_args"]["min_list_length"] if "max_list_length" in param["loc_args"]: schema["maxItems"] = param["loc_args"]["max_list_length"] - return schema + elif type_group in ["Optional", "Union"]: + available_types = [] + for subtype in raw_type.__args__: + subtype_schema = generate_json_schema_helper(param, recursively_resolve_type_hint(subtype), subtype) + available_types.append(subtype_schema) + schema["oneOf"] = available_types else: - print(f"Unexpected situation: {param_type}, {parent_group}") + warnings.warn(f"Unsupported generic type {param_type}", + Warning, stacklevel=2) + return schema def generate_json_schema_for_parameter(param): - return generate_json_schema_helper(param, param["type"]) + return generate_json_schema_helper(param, param["type"], param["raw_type"]) def generate_json_schema_for_parameters(params): @@ -280,14 +298,25 @@ def generate_json_schema_for_parameters(params): def generate_openapi_paths_object(): oapi_paths = {} - for route in get_route_docs(): + for route in get_route_docs(include_raw_type=True): oapi_path_route = re.sub(r'<(\w+):(\w+)>', r'{\2}', route['rule']) oapi_path_route = re.sub(r'<(\w+)>', r'{\1}', oapi_path_route) - print(f"Adding {route['rule']} to paths as {oapi_path_route}") oapi_path_item = {} oapi_operation = {} # tags, summary, description, externalDocs, operationId, parameters, requestBody, responses, callbacks, deprecated, security, servers oapi_parameters = [] oapi_request_body = {"content": {}} + if "MultiSource" in route["args"]: + for arg in route["args"]["MultiSource"]: + if "sources" in arg["loc_args"]: + sources = arg["loc_args"]["sources"].copy() + del arg["loc_args"]["sources"] + for source in sources: + arg["loc"] = source + arg["multisource_sources"] = sources + if source not in route["args"]: + route["args"][source] = [] + route["args"][source].append(deepcopy(arg)) + del route["args"]["MultiSource"] for arg_loc in route["args"]: if arg_loc == "Form": oapi_request_body["content"]["application/x-www-form-urlencoded"] = { @@ -323,7 +352,6 @@ def generate_openapi_paths_object(): oapi_operation["parameters"] = oapi_parameters if len(oapi_request_body["content"].keys()) > 0: oapi_operation["requestBody"] = oapi_request_body - print(route["decorators"]) for decorator in route["decorators"]: for partial_decorator in ["@warnings.deprecated", "@deprecated"]: # Support for PEP 702 in Python 3.13 if partial_decorator in decorator: From defe1c568ce69689d5af1853da6aea3446de7ca4 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sun, 13 Apr 2025 18:29:05 -0400 Subject: [PATCH 05/18] Add UUID to OpenAPI docs generation --- flask_parameter_validation/docs_blueprint.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index 7c36200..73b20f6 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -1,5 +1,6 @@ import datetime import json +import uuid import warnings from copy import deepcopy from typing import Optional, Union @@ -242,7 +243,9 @@ def generate_json_schema_helper(param: dict, param_type: str, raw_type): # Use oneOf:[{const}] instead of enum by recommendation https://github.com/OAI/OpenAPI-Specification/issues/348#issuecomment-336194030 options = [{"title": opt.name, "const": opt.value} for opt in raw_type] schema["oneOf"] = options - + elif raw_type is uuid.UUID: + schema["type"] = "string" + schema["format"] = "uuid" else: match = re.match(r'(\w+)\[([\w\[\] ,.]+)]', param_type) if not match: From 39ffc10401939e9bb08a12f0f05ed2f1cc37f2af Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sun, 13 Apr 2025 18:32:10 -0400 Subject: [PATCH 06/18] Add UUID example to README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 9709b02..f6eb2d0 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ from typing import Optional, TypedDict, NotRequired from flask_parameter_validation import ValidateParameters, Route, Json, Query from datetime import datetime from enum import Enum +from uuid import UUID class AccountStatus(int, Enum): # In Python 3.11 or later, subclass IntEnum from enum package instead of int, Enum ACTIVE = 1 @@ -42,6 +43,7 @@ def hello( is_admin: bool = Query(False), user_type: UserType = Json(alias="type"), status: AccountStatus = Json(), + unique: UUID = Json(), permissions: dict[str, str] = Query(list_disable_query_csv=True), socials: list[SocialLink] = Json() ): From f8fe52df664f73b671d2baa3c96763f8a48ca96c Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sat, 9 Aug 2025 11:21:17 -0400 Subject: [PATCH 07/18] Fix incorrect regex --- flask_parameter_validation/docs_blueprint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index 73b20f6..27dc906 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -191,7 +191,7 @@ def fpv_error(message): def parameter_required(param): - if param["type"].startswith("Optional[") or re.match("Union\[.*None.*", param["type"]): + if param["type"].startswith("Optional[") or re.match("Union\\[.*None.*", param["type"]): return False elif "default" in param["loc_args"]: return False From 74f3555c430565cefa191c3f1121db92f2378430 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Thu, 21 May 2026 11:05:15 -0400 Subject: [PATCH 08/18] Fix rebase mistake --- flask_parameter_validation/docs_blueprint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index 27dc906..d9c0ec8 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -90,7 +90,7 @@ def recursively_resolve_type_hint(type_to_resolve): if sys.version_info >= (3, 10) and isinstance(type_to_resolve, UnionType): # support 3.10 style unions (e.g. str | int) type_base_name = "Union" - elif hasattr(type_to_resolve, "__name__"): # In Python 3.9, Optional and Union do not have __name__ + elif 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 From fa6d33d7fb76ed76637a611eeef02e4f07e1c583 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Fri, 22 May 2026 19:23:39 -0400 Subject: [PATCH 09/18] Finish implementation of and add tests for OpenAPI Documentation --- README.md | 107 ++-- flask_parameter_validation/docs_blueprint.py | 146 +++-- .../parameter_types/query.py | 3 +- .../parameter_types/route.py | 3 +- .../parameter_validation.py | 4 +- flask_parameter_validation/test/enums.py | 16 +- .../test/test_api_docs.py | 518 +++++++++++++++++- .../test/testing_blueprints/dict_blueprint.py | 30 +- .../test/testing_blueprints/str_blueprint.py | 10 + .../testing_blueprints/typeddict_blueprint.py | 18 +- 10 files changed, 734 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index f6eb2d0..d737ff7 100644 --- a/README.md +++ b/README.md @@ -63,12 +63,10 @@ To validate parameters with flask-parameter-validation, two conditions must be m ### Enable and customize Validation for a Route with the @ValidateParameters decorator The `@ValidateParameters()` decorator takes parameters that alter route validation behavior or provide documentation information: -| Parameter | Type | Default | Description | -|-------------------|----------------------|---------|------------------------------------------------------------------------------------------------------------------------------| -| error_handler | `Optional[Response]` | `None` | Overwrite the output format of generated errors, see [Overwriting Default Errors](#overwriting-default-errors) for more | -| route_deprecated | `bool` | `False` | Marks this Route as deprecated in any generated [API Documentation](#api-documentation) | -| openapi_responses | `Optional[dict]` | `None` | The OpenAPI Responses Object for this route, as a `dict` to be used in any generated [API Documentation](#api-documentation) | -| hide_from_docs | `bool` | `False` | Hide this Route from any generated [API Documentation](#api-documentation) | +| Parameter | Type | Default | Description | +|-------------------|----------------------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------| +| error_handler | `Optional[Response]` | `None` | Overwrite the output format of generated errors, see [Overwriting Default Errors](#overwriting-default-errors) for more | +| openapi_responses | `Optional[dict]` | `None` | The OpenAPI Responses Object for this route, as a `dict` to be used in any generated [API Documentation](#api-documentation) | #### Overwriting Default Errors By default, the error messages are returned as a JSON response, with the detailed error in the "error" field, eg: @@ -156,28 +154,28 @@ These can be used in tandem to describe a parameter to validate: `parameter_name ### Validation with arguments to Parameter Validation beyond type-checking can be done by passing arguments into the constructor of the `Parameter` subclass. The arguments available for use on each type hint are: -| Parameter Name | Type of Argument | Effective On Types | Description | -|--------------------------|--------------------------------------------------|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `default` | any, except `NoneType` | All, except in `Route` | Specifies the default value for the field, makes non-Optional fields not required | -| `min_str_length` | `int` | `str` | Specifies the minimum character length for a string input | -| `max_str_length` | `int` | `str` | Specifies the maximum character length for a string input | -| `min_list_length` | `int` | `list` | Specifies the minimum number of elements in a list | -| `max_list_length` | `int` | `list` | Specifies the maximum number of elements in a list | -| `min_int` | `int` | `int` | Specifies the minimum number for an integer input | -| `max_int` | `int` | `int` | Specifies the maximum number for an integer input | -| `whitelist` | `str` | `str` | A string containing allowed characters for the value | -| `blacklist` | `str` | `str` | A string containing forbidden characters for the value | -| `pattern` | `str` | `str` | A regex pattern to test for string matches | -| `func` | `Callable[Any] -> Union[bool, tuple[bool, str]]` | All | A function containing a fully customized logic to validate the value. See the [custom validation function](#custom-validation-function) below for usage | -| `datetime_format` | `str` | `datetime.datetime` | Python datetime format string datetime format string ([datetime format codes](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes)) | -| `comment` | `str` | All | A string to display as the argument description in any generated documentation | -| `alias` | `str` | All but `FileStorage` | An expected parameter name to receive instead of the function name. | -| `json_schema` | `dict` | All but `FileStorage` | An expected [JSON Schema](https://json-schema.org) which the dict input must conform to | -| `content_types` | `list[str]` | `FileStorage` | Allowed `Content-Type`s | -| `min_length` | `int` | `FileStorage` | Minimum `Content-Length` for a file | -| `max_length` | `int` | `FileStorage` | Maximum `Content-Length` for a file | -| `blank_none` | `bool` | `Optional[str]` | If `True`, an empty string will be converted to `None`, defaults to configured `FPV_BLANK_NONE`, see [Validation Behavior Configuration](#validation-behavior-configuration) for more | -| `list_disable_query_csv` | `bool` | `list` in `Query` | If `False`, list-type Query parameters will be split by `,`, defaults to configured `FPV_LIST_DISABLE_QUERY_CSV`, see [Validation Behavior Configuration](#validation-behavior-configuration) for more | +| Parameter Name | Type of Argument | Effective On Types | OpenAPI Docs | Description | +|--------------------------|--------------------------------------------------|------------------------|--------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `default` | any, except `NoneType` | All, except in `Route` | ✅ | Specifies the default value for the field, makes non-Optional fields not required | +| `min_str_length` | `int` | `str` | ✅ | Specifies the minimum character length for a string input | +| `max_str_length` | `int` | `str` | ✅ | Specifies the maximum character length for a string input | +| `min_list_length` | `int` | `list` | ✅ | Specifies the minimum number of elements in a list | +| `max_list_length` | `int` | `list` | ✅ | Specifies the maximum number of elements in a list | +| `min_int` | `int` | `int` | ✅ | Specifies the minimum number for an integer input | +| `max_int` | `int` | `int` | ✅ | Specifies the maximum number for an integer input | +| `whitelist` | `str` | `str` | Use `pattern` instead | A string containing allowed characters for the value | +| `blacklist` | `str` | `str` | Use `pattern` instead | A string containing forbidden characters for the value | +| `pattern` | `str` | `str` | ✅ | A regex pattern to test for string matches | +| `func` | `Callable[Any] -> Union[bool, tuple[bool, str]]` | All | ❌ No Attribute | A function containing a fully customized logic to validate the value. See the [custom validation function](#custom-validation-function) below for usage | +| `datetime_format` | `str` | `datetime.datetime` | ❌ JSON Schema limitation | Python datetime format string datetime format string ([datetime format codes](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes)) | +| `comment` | `str` | All | ✅ | A string to display as the argument description in any generated documentation | +| `alias` | `str` | All but `FileStorage` | ✅ | An expected parameter name to receive instead of the function name. | +| `json_schema` | `dict` | All but `FileStorage` | ✅ | An expected [JSON Schema](https://json-schema.org) which the dict input must conform to | +| `content_types` | `list[str]` | `FileStorage` | ✅ | Allowed `Content-Type`s | +| `min_length` | `int` | `FileStorage` | ❌ JSON Schema limitation | Minimum `Content-Length` for a file | +| `max_length` | `int` | `FileStorage` | ❌ JSON Schema limitation | Maximum `Content-Length` for a file | +| `blank_none` | `bool` | `Optional[str]` | ❌ No Attribute | If `True`, an empty string will be converted to `None`, defaults to configured `FPV_BLANK_NONE`, see [Validation Behavior Configuration](#validation-behavior-configuration) for more | +| `list_disable_query_csv` | `bool` | `list` in `Query` | ❌ No Attribute | If `False`, list-type Query parameters will be split by `,`, defaults to configured `FPV_LIST_DISABLE_QUERY_CSV`, see [Validation Behavior Configuration](#validation-behavior-configuration) for more | These validators are passed into the `Parameter` subclass in the route function, such as: * `username: str = Json(default="defaultusername", min_length=5)` @@ -201,10 +199,10 @@ def is_odd(val: int): ### Configuration Options -#### API Documentation (OpenAPI 3.1.0) +#### API Documentation (OpenAPI 3.1.0 - 3.2.0) * `FPV_OPENAPI_BASE: dict`: The base [OpenAPI Object](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#openapi-object) that will be populated with a generated [Paths Object](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#paths-object). Must be set to enable the blueprints. Alternatively, the standalone Paths Object can be retrieved anytime through the `generate_openapi_paths_object()` method. -#### API Documentation (Non-standard Format) +#### API Documentation (Deprecated, non-standard format) * `FPV_DOCS_SITE_NAME: str`: Your site's name, to be displayed in the page title, default: `Site` * `FPV_DOCS_CUSTOM_BLOCKS: array`: An array of dicts to display as cards at the top of your documentation, with the (optional) keys: * `title: Optional[str]`: The title of the card @@ -233,7 +231,7 @@ app.register_blueprint(docs_blueprint) The default blueprint adds two `GET` routes: * `/`: HTML Page with Bootstrap CSS and toggleable light/dark mode * `/json`: Non-standard Format JSON Representation of the generated documentation -* `/openapi`: OpenAPI 3.1.0 (JSON) Representation of the generated documentation +* `/openapi`: OpenAPI 3.1.0 - 3.2.0 (JSON) Representation of the generated documentation The `/json` route yields a response with the following format: ```json @@ -287,13 +285,14 @@ Documentation Generated: ![](docs/api_documentation_example.png) ##### Custom Blueprint -If you would like to use your own blueprint, you can get the raw data from the following function: +If you would like to use your own blueprint, you can get the raw data from the following functions: ```py from flask_parameter_validation.docs_blueprint import get_route_docs -from flask_parameter_validation.docs_blueprint import generate_openapi_paths_object +from flask_parameter_validation.docs_blueprint import generate_openapi_paths_object, generate_openapi_docs ... -get_route_docs() -generate_openapi_paths_object() +get_route_docs() # The non-standard format (deprecated) +generate_openapi_paths_object() # Just the OpenAPI Paths object +generate_openapi_docs() # The entire OpenAPI Object ``` ###### get_route_docs() return value format @@ -318,8 +317,7 @@ This method returns an object with the following structure: "loc_args": { "": "Value passed to Argument", "": 0 - }, - "deprecated": "bool, whether this parameter is deprecated (only for Route and Query params)" + } } ], "": [] @@ -330,28 +328,41 @@ This method returns an object with the following structure: ] ``` +##### Marking routes as deprecated in generated documentation + +Using the `warnings.deprecated` (Python 3.13+) or `typing_extensions.deprecated` decorators, you can mark a route as deprecated. + +##### Comments in generated OpenAPI documentation + +Generated OpenAPI Documentation will pull comments from various locations in the following order (highest priority to lowest priority): +1. `comment` argument passed to a subclass of `Parameter` +2. `Annotated[T, "annotated comment"]` on a member of a TypedDict, or `# inline comment` on the same line as a member of a TypedDict or Enum +3. Docstring on a class (for Enums and TypedDicts) + ### JSON Schema Validation An example of the [JSON Schema](https://json-schema.org) validation is provided below: + ```python json_schema = { - "type": "object", - "required": ["user_id", "first_name", "last_name", "tags"], - "properties": { - "user_id": {"type": "integer"}, - "first_name": {"type": "string"}, - "last_name": {"type": "string"}, - "tags": { - "type": "array", - "items": {"type": "string"} - } + "type": "object", + "required": ["user_id", "first_name", "last_name", "tags"], + "properties": { + "user_id": {"type": "integer"}, + "first_name": {"type": "string"}, + "last_name": {"type": "string"}, + "tags": { + "type": "array", + "items": {"type": "string"} } + } } + @api.get("/json_schema_example") @ValidateParameters() def json_schema(data: dict = Json(json_schema=json_schema)): - return jsonify({"data": data}) + return jsonify({"data": data}) ``` ## Contributions diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index d9c0ec8..d78c764 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -1,11 +1,14 @@ import datetime +import inspect import json import uuid import warnings from copy import deepcopy -from typing import Optional, Union +from typing import TypedDict, is_typeddict import sys from enum import Enum, EnumMeta +from typing_extensions import deprecated + import flask from flask import Blueprint, current_app, jsonify from flask_parameter_validation import ValidateParameters @@ -50,7 +53,6 @@ def get_function_docs(func, include_raw_type=False): "docstring": format_docstring(fdocs.get("docstring")), "decorators": fdocs.get("decorators"), "args": extract_argument_details(fdocs, include_raw_type=include_raw_type), - "deprecated": fdocs.get("deprecated"), "responses": fdocs.get("openapi_responses"), } return None @@ -86,7 +88,10 @@ def extract_argument_details(fdocs, include_raw_type=False): args_data.setdefault(arg_data["loc"], []).append(arg_data) return args_data -def recursively_resolve_type_hint(type_to_resolve): +def recursively_resolve_type_hint(type_to_resolve) -> str: + """ + Generate the string representation of a type hint, including any generic arguments + """ if sys.version_info >= (3, 10) and isinstance(type_to_resolve, UnionType): # support 3.10 style unions (e.g. str | int) type_base_name = "Union" @@ -156,6 +161,7 @@ def http_badge_bg(http_method): @docs_blueprint.route("/") +@deprecated("Use /openapi instead") def docs_html(): """ Render the documentation as an HTML page. @@ -171,6 +177,7 @@ def docs_html(): @docs_blueprint.route("/json") +@deprecated("Use /openapi instead") def docs_json(): """ Provide the documentation as a JSON response. @@ -197,22 +204,24 @@ def parameter_required(param): return False return True -def generate_json_schema_helper(param: dict, param_type: str, raw_type): +def generate_json_schema_helper(param: dict | None, param_type: str, raw_type): schema = {} if raw_type is str: schema["type"] = "string" - if "min_str_length" in param["loc_args"]: - schema["minLength"] = param["loc_args"]["min_str_length"] - if "max_str_length" in param["loc_args"]: - schema["maxLength"] = param["loc_args"]["max_str_length"] - if "pattern" in param["loc_args"]: - schema["pattern"] = param["loc_args"]["pattern"] + if param is not None: + if "min_str_length" in param["loc_args"]: + schema["minLength"] = param["loc_args"]["min_str_length"] + if "max_str_length" in param["loc_args"]: + schema["maxLength"] = param["loc_args"]["max_str_length"] + if "pattern" in param["loc_args"]: + schema["pattern"] = param["loc_args"]["pattern"] elif raw_type is int: schema["type"] = "integer" - if "min_int" in param["loc_args"]: - schema["minimum"] = param["loc_args"]["min_int"] - if "max_int" in param["loc_args"]: - schema["maximum"] = param["loc_args"]["max_int"] + if param is not None: + if "min_int" in param["loc_args"]: + schema["minimum"] = param["loc_args"]["min_int"] + if "max_int" in param["loc_args"]: + schema["maximum"] = param["loc_args"]["max_int"] elif raw_type is bool: schema["type"] = "boolean" elif raw_type is float: @@ -220,19 +229,45 @@ def generate_json_schema_helper(param: dict, param_type: str, raw_type): elif raw_type is datetime.datetime: schema["type"] = "string" schema["format"] = "date-time" - if "datetime_format" in param["loc_args"]: - warnings.warn("datetime_format cannot be translated to JSON Schema, please use ISO8601 date-time", - Warning, stacklevel=2) + if param is not None: + if "datetime_format" in param["loc_args"]: + warnings.warn("datetime_format cannot be translated to JSON Schema, please use ISO8601 date-time", + Warning, stacklevel=2) elif raw_type is datetime.date: schema["type"] = "string" schema["format"] = "date" elif raw_type is datetime.time: schema["type"] = "string" schema["format"] = "time" - elif raw_type is dict: - schema["type"] = "object" elif raw_type is type(None): schema["type"] = "null" + elif is_typeddict(raw_type): + schema["type"] = "object" + schema["properties"] = {} + required_properties = [] + source = "" + try: + source = inspect.getsource(raw_type) + except OSError: + pass + for key, value in raw_type.__annotations__.items(): + value_param_type = recursively_resolve_type_hint(value) + schema["properties"][key] = generate_json_schema_helper(None, value_param_type, value) + key_match = re.findall(rf"^\s*{key}\s*:\s*{value_param_type}\s*#\s*(.*)$", source, flags=re.MULTILINE) + if key_match: + schema["properties"][key]["description"] = key_match[0] + if raw_type.__total__ and not "NotRequired[" in value_param_type: + required_properties.append(key) + elif not raw_type.__total__ and "Required[" in value_param_type and not "NotRequired[" in value_param_type: + required_properties.append(key) + if len(required_properties) > 0: + schema["required"] = required_properties + schema["title"] = param_type + type_description = inspect.getdoc(raw_type) + if type_description and type_description != inspect.getdoc(dict): + schema["description"] = type_description + elif raw_type is dict: + schema["type"] = "object" elif type(raw_type) in [type, EnumMeta] and issubclass(raw_type, Enum): if issubclass(raw_type, str): schema["type"] = "string" @@ -240,9 +275,19 @@ def generate_json_schema_helper(param: dict, param_type: str, raw_type): schema["type"] = "integer" else: warnings.warn(f"Unsupported enum type: {param_type}", Warning, stacklevel=2) - # Use oneOf:[{const}] instead of enum by recommendation https://github.com/OAI/OpenAPI-Specification/issues/348#issuecomment-336194030 - options = [{"title": opt.name, "const": opt.value} for opt in raw_type] + source = inspect.getsource(raw_type) + options = [] + for opt in raw_type: + option_schema = {"title": opt.name, "const": opt.value} + opt_match = re.findall(rf"^\s*{opt.name}\s*=\s*['\"]?{opt.value}['\"]?\s*#\s*(.*)$", source, flags=re.MULTILINE) + if opt_match: + option_schema["description"] = opt_match[0] + options.append(option_schema) schema["oneOf"] = options + type_description = inspect.getdoc(raw_type) + if type_description and type_description not in [inspect.getdoc(str), inspect.getdoc(int)]: + schema["description"] = type_description + schema["title"] = param_type elif raw_type is uuid.UUID: schema["type"] = "string" schema["format"] = "uuid" @@ -263,19 +308,37 @@ def generate_json_schema_helper(param: dict, param_type: str, raw_type): schema["items"] = available_types[0] else: schema["items"] = {"oneOf": available_types} - if "min_list_length" in param["loc_args"]: - schema["minItems"] = param["loc_args"]["min_list_length"] - if "max_list_length" in param["loc_args"]: - schema["maxItems"] = param["loc_args"]["max_list_length"] + if param is not None: + if "min_list_length" in param["loc_args"]: + schema["minItems"] = param["loc_args"]["min_list_length"] + if "max_list_length" in param["loc_args"]: + schema["maxItems"] = param["loc_args"]["max_list_length"] elif type_group in ["Optional", "Union"]: available_types = [] for subtype in raw_type.__args__: subtype_schema = generate_json_schema_helper(param, recursively_resolve_type_hint(subtype), subtype) + if "default" in subtype_schema: + del subtype_schema["default"] available_types.append(subtype_schema) schema["oneOf"] = available_types + elif type_group in ["Required", "NotRequired"]: + schema = generate_json_schema_helper(param, recursively_resolve_type_hint(raw_type.__args__[0]), raw_type.__args__[0]) + elif type_group == "dict": + schema["type"] = "object" + schema["additionalProperties"] = generate_json_schema_helper(None, recursively_resolve_type_hint(raw_type.__args__[1]), raw_type.__args__[1]) + elif type_group == "Annotated": + schema = generate_json_schema_helper(param, recursively_resolve_type_hint(raw_type.__origin__), raw_type.__origin__) + for annotation in raw_type.__metadata__: + if type(annotation) is str: + schema["description"] = annotation + break else: - warnings.warn(f"Unsupported generic type {param_type}", - Warning, stacklevel=2) + warnings.warn(f"Unsupported generic type {param_type}", Warning, stacklevel=2) + if param: + if "comment" in param["loc_args"]: + schema["description"] = param["loc_args"]["comment"] + if "default" in param["loc_args"]: + schema["default"] = param["loc_args"]["default"] return schema @@ -297,6 +360,8 @@ def generate_json_schema_for_parameters(params): schema["properties"][schema_parameter_name] = generate_json_schema_for_parameter(p) if parameter_required(p): schema["required"].append(schema_parameter_name) + if len(schema["required"]) == 0: + del schema["required"] return schema def generate_openapi_paths_object(): @@ -348,8 +413,6 @@ def generate_openapi_paths_object(): "schema": arg["loc_args"]["json_schema"] if "json_schema" in arg[ "loc_args"] else generate_json_schema_for_parameter(arg), } - if "deprecated" in arg["loc_args"] and arg["loc_args"]["deprecated"]: - parameter["deprecated"] = arg["loc_args"]["deprecated"] oapi_parameters.append(parameter) if len(oapi_parameters) > 0: oapi_operation["parameters"] = oapi_parameters @@ -359,8 +422,6 @@ def generate_openapi_paths_object(): for partial_decorator in ["@warnings.deprecated", "@deprecated"]: # Support for PEP 702 in Python 3.13 if partial_decorator in decorator: oapi_operation["deprecated"] = True - if route["deprecated"]: # Fallback on kwarg passed to @ValidateParameters() - oapi_operation["deprecated"] = route["deprecated"] if route["responses"]: oapi_operation["responses"] = route["responses"] for method in route["methods"]: @@ -373,23 +434,30 @@ def generate_openapi_paths_object(): return oapi_paths - -@docs_blueprint.route("/openapi") -def docs_openapi(): +def generate_openapi_docs(): """ - Provide the documentation in OpenAPI format + Generate the documentation in OpenAPI format """ config = flask.current_app.config if not config.get("FPV_OPENAPI_ENABLE", False): - return fpv_error("FPV_OPENAPI_ENABLE is not set, and defaults to False") + raise ConfigurationError("FPV_OPENAPI_ENABLE is not set, and defaults to False") - supported_versions = ["3.1.0"] + supported_versions = ["3.1.0", "3.1.1", "3.1.2", "3.2.0"] openapi_base = config.get("FPV_OPENAPI_BASE", {"openapi": None}) if openapi_base["openapi"] not in supported_versions: - return fpv_error(f"Flask-Parameter-Validation only supports OpenAPI {', '.join(supported_versions)}, {openapi_base['openapi']} provided") + return fpv_error( + f"Flask-Parameter-Validation only supports OpenAPI {', '.join(supported_versions)}, provided: {openapi_base['openapi']}") if "paths" in openapi_base: return fpv_error(f"Flask-Parameter-Validation will overwrite the paths value of FPV_OPENAPI_BASE") openapi_paths = generate_openapi_paths_object() openapi_document = json.loads(json.dumps(openapi_base)) openapi_document["paths"] = openapi_paths - return jsonify(openapi_document) + return openapi_document + + +@docs_blueprint.route("/openapi") +def docs_openapi(): + """ + Provide the documentation in OpenAPI format + """ + return jsonify(generate_openapi_docs()) diff --git a/flask_parameter_validation/parameter_types/query.py b/flask_parameter_validation/parameter_types/query.py index c692ff0..7dde98c 100644 --- a/flask_parameter_validation/parameter_types/query.py +++ b/flask_parameter_validation/parameter_types/query.py @@ -11,8 +11,7 @@ class Query(Parameter): name = "query" - def __init__(self, default=None, deprecated=False, **kwargs): - self.deprecated = deprecated + def __init__(self, default=None, **kwargs): super().__init__(default, **kwargs) def convert(self, value, allowed_types, current_error=None): diff --git a/flask_parameter_validation/parameter_types/route.py b/flask_parameter_validation/parameter_types/route.py index 8ac1dc7..27a4f66 100644 --- a/flask_parameter_validation/parameter_types/route.py +++ b/flask_parameter_validation/parameter_types/route.py @@ -10,8 +10,7 @@ class Route(Parameter): name = "route" - def __init__(self, default=None, deprecated=False, **kwargs): - self.deprecated = deprecated + def __init__(self, default=None, **kwargs): super().__init__(default, **kwargs) def convert(self, value, allowed_types, current_error=None): diff --git a/flask_parameter_validation/parameter_validation.py b/flask_parameter_validation/parameter_validation.py index 127a1e5..c06fa1d 100644 --- a/flask_parameter_validation/parameter_validation.py +++ b/flask_parameter_validation/parameter_validation.py @@ -35,9 +35,8 @@ class ValidateParameters: def get_fn_list(cls): return fn_list - def __init__(self, error_handler=None, route_deprecated=False, openapi_responses=None): + def __init__(self, error_handler=None, openapi_responses=None): self.custom_error_handler = error_handler - self.route_deprecated = route_deprecated self.openapi_responses = openapi_responses def __call__(self, f): @@ -61,7 +60,6 @@ def __call__(self, f): "argspec": argspec, "docstring": f.__doc__.strip() if f.__doc__ else None, "decorators": decorators.copy(), - "deprecated": self.route_deprecated, "openapi_responses": self.openapi_responses, } fn_list[fsig] = fdocs diff --git a/flask_parameter_validation/test/enums.py b/flask_parameter_validation/test/enums.py index 28e5e39..10c3d92 100644 --- a/flask_parameter_validation/test/enums.py +++ b/flask_parameter_validation/test/enums.py @@ -2,10 +2,18 @@ class Fruits(str, Enum): - APPLE = "apple" - ORANGE = "orange" + """ + Possible fruits + """ + + APPLE = "apple" # An apple a day keeps the doctor away, so they say + ORANGE = "orange" # Oranges contain vitamin C, which might also keep the doctor away class Binary(int, Enum): - ZERO = 0 - ONE = 1 + """ + Possible binary values + """ + + ZERO = 0 # Logic level low + ONE = 1 # Logic level high diff --git a/flask_parameter_validation/test/test_api_docs.py b/flask_parameter_validation/test/test_api_docs.py index 33cc773..944197a 100644 --- a/flask_parameter_validation/test/test_api_docs.py +++ b/flask_parameter_validation/test/test_api_docs.py @@ -1,5 +1,9 @@ import sys -from flask_parameter_validation.docs_blueprint import get_route_docs +from typing import Callable, Any +import pytest +from flask_parameter_validation.docs_blueprint import get_route_docs, generate_openapi_docs +from flask_parameter_validation.test.testing_blueprints.dict_blueprint import _fpv_test_dict_blueprint_json_schema + def test_http_ok(client): r = client.get("/docs/") @@ -35,7 +39,8 @@ def test_doc_types_of_default(app): "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"} + "uuid": {"opt": f"{optional_as_str}[UUID, NoneType]", "n_opt": "UUID"}, + "typeddict": {"opt": f"{optional_as_str}[Simple, NoneType]", "n_opt": "Simple"}, } route_unsupported_types = ["dict", "list"] route_docs = get_route_docs() @@ -55,3 +60,512 @@ def test_doc_types_of_default(app): n_opt = args[1] assert n_opt["type"] == types[arg_type]["n_opt"] assert opt["type"] == types[arg_type]["opt"] + +@pytest.fixture(scope="session") +def openapi_docs(app): + return generate_openapi_docs() + +def check_schema_against_parameters(schema, openapi_parameters): + all_keys = schema["properties"].keys() + for key in all_keys: + found_key = False + for parameter in openapi_parameters: + if parameter["name"] == key: + found_key = True + if "required" in schema and key in schema["required"]: + assert parameter["required"] + else: + assert "required" not in parameter or not parameter["required"] + for schema_key, schema_data in schema["properties"][key].items(): + assert schema_key in parameter["schema"] + assert parameter["schema"][schema_key] == schema["properties"][key][schema_key] + assert found_key + +def check_schema_for_all_locations(openapi_docs, schema, type_and_test, skip_route: bool = False): + check_schema_against_parameters(schema, openapi_docs["paths"][f"/query/{type_and_test}"]["get"]["parameters"]) + if not skip_route: + check_schema_against_parameters(schema, openapi_docs["paths"][f"/route/{type_and_test}/{{v}}"]["get"]["parameters"]) + assert schema == openapi_docs["paths"][f"/form/{type_and_test}"]["post"]["requestBody"]["content"]["application/x-www-form-urlencoded"]["schema"] + assert schema == openapi_docs["paths"][f"/json/{type_and_test}"]["post"]["requestBody"]["content"]["application/json"]["schema"] + + +def test_openapi_docs_min_str_length(openapi_docs): + schema = { + "properties": { + "v": { + "minLength": 2, + "type": "string" + } + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "str/min_str_length") + +def test_openapi_docs_max_str_length(openapi_docs): + schema = { + "properties": { + "v": { + "maxLength": 2, + "type": "string" + } + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "str/max_str_length") + +def test_openapi_docs_optional(openapi_docs): + schema = { + "properties": { + "v": { + "oneOf": [ + {"type": "string"}, + {"type": "null"} + ] + } + }, + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "str/optional", skip_route=True) + +def test_openapi_docs_pattern(openapi_docs): + schema = { + "properties": { + "v": { + "pattern": "\\w{3}\\d{3}", + "type": "string" + } + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "str/pattern") + +def test_openapi_docs_min_int(openapi_docs): + schema = { + "properties": { + "v": { + "minimum": 0, + "type": "integer" + } + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "int/min_int") + +def test_openapi_docs_max_int(openapi_docs): + schema = { + "properties": { + "v": { + "maximum": 0, + "type": "integer" + } + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "int/max_int") + +def test_openapi_docs_bool(openapi_docs): + schema = { + "properties": { + "v": { + "type": "boolean" + } + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "bool/required") + +def test_openapi_docs_float(openapi_docs): + schema = { + "properties": { + "v": { + "type": "number" + } + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "float/required") + +def test_openapi_docs_datetime(openapi_docs): + schema = { + "properties": { + "v": { + "format": "date-time", + "type": "string" + } + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "datetime/required") + +def test_openapi_docs_date(openapi_docs): + schema = { + "properties": { + "v": { + "format": "date", + "type": "string" + } + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "date/required") + +def test_openapi_docs_time(openapi_docs): + schema = { + "properties": { + "v": { + "format": "time", + "type": "string" + } + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "time/required") + +def test_openapi_docs_typeddict_required(openapi_docs): + schema = { + "properties": { + "v": { + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "timestamp": {"format": "date-time", "type": "string"}, + }, + "required": ["name", "timestamp"], + "title": "SimpleRequired", + "type": "object", + "description": "Comment in the decorator" + } + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "typeddict/required", skip_route=True) + +def test_openapi_docs_typeddict_notrequired(openapi_docs): + schema = { + "properties": { + "v": { + "description": "Docstring of SimpleNotRequired", + "properties": { + "id": {"type": "integer", "description": "Annotated comment on the id property"}, + "name": {"type": "string", "description": "# comment on the name property"}, + "timestamp": {"format": "date-time", "type": "string"}, + }, + "required": ["name", "timestamp"], + "title": "SimpleNotRequired", + "type": "object" + }, + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "typeddict/not_required", skip_route=True) + +def test_openapi_docs_typeddict_title(openapi_docs): + schema = { + "properties": { + "v": { + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "timestamp": {"format": "date-time", "type": "string"}, + }, + "required": ["id", "name", "timestamp"], + "title": "Simple", + "description": "Overrides the class docstring", # The class docstring is "A simple TypedDict," but is overridden by a comment in the Parameter subclass constructor + "type": "object" + } + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "typeddict/", skip_route=True) + +def test_openapi_docs_typeddict_property_comment_overrides_property_class_docstring(openapi_docs): + schema = { + "properties": { + "v": { + "properties": { + "children": { + "type": "array", + "items": { + "type": "object", + "title": "Simple", + "description": "A simple TypedDict", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "timestamp": {"format": "date-time", "type": "string"}, + }, + "required": ["id", "name", "timestamp"], + } + }, + "left": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "x": {"type": "number"}, + "y": {"type": "number"}, + "z": {"type": "number"} + }, + "required": ["x", "y", "z"], + "title": "Coord", + "description": "Docstring of Coord" + }, + "right": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "x": {"type": "number"}, + "y": {"type": "number"}, + "z": {"type": "number"} + }, + "required": ["x", "y", "z"], + "title": "Coord", + "description": "Overrides the docstring of Coord" # The class docstring is "Docstring of Coord," but is overridden by a comment on the right property of Complex + }, + "name": {"type": "string"}, + }, + "required": ["children", "left", "right", "name"], + "title": "Complex", + "type": "object" + } + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "typeddict/complex", skip_route=True) + +def test_openapi_docs_str_enum(openapi_docs): + schema = { + "properties": { + "v": { + "description": "Possible fruits", + "oneOf": [ + { + "const": "apple", + "description": "An apple a day keeps the doctor away, so they say", + "title": "APPLE" + }, + { + "const": "orange", + "description": "Oranges contain vitamin C, which might also keep the doctor away", + "title": "ORANGE" + } + ], + "title": "Fruits", + "type": "string" + } + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "str_enum/required") + +def test_openapi_docs_int_enum(openapi_docs): + schema = { + "properties": { + "v": { + "description": "Possible binary values", + "oneOf": [ + { + "const": 0, + "description": "Logic level low", + "title": "ZERO" + }, + { + "const": 1, + "description": "Logic level high", + "title": "ONE" + } + ], + "title": "Binary", + "type": "integer" + } + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "int_enum/required") + +def test_openapi_docs_uuid(openapi_docs): + schema = { + "properties": { + "v": { + "format": "uuid", + "type": "string" + } + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "uuid/required") + +def test_openapi_docs_optional_list_union(openapi_docs): + schema = { + "properties": { + "v": { + "oneOf": [ + { + "items": { + "oneOf": [ + {"type": "integer"}, + {"type": "boolean"}, + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + } + }, + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "list/opt_union", skip_route=True) + +def test_openapi_docs_min_list_length(openapi_docs): + schema = { + "properties": { + "v": { + "items": {"type": "string"}, + "minItems": 3, + "type": "array" + } + }, + "type": "object", + "required": ["v"] + } + check_schema_for_all_locations(openapi_docs, schema, "list/min_list_length", skip_route=True) + +def test_openapi_docs_max_list_length(openapi_docs): + schema = { + "properties": { + "v": { + "items": {"type": "string"}, + "maxItems": 3, + "type": "array" + } + }, + "type": "object", + "required": ["v"] + } + check_schema_for_all_locations(openapi_docs, schema, "list/max_list_length", skip_route=True) + +def test_openapi_docs_dict(openapi_docs): + schema = { + "properties": { + "v": { + "type": "object" + } + }, + "type": "object", + "required": ["v"] + } + check_schema_for_all_locations(openapi_docs, schema, "dict/required", skip_route=True) + +def test_openapi_docs_dict_json_schema(openapi_docs): + schema = { + "properties": { + "v": _fpv_test_dict_blueprint_json_schema + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "dict/json_schema", skip_route=True) + +def test_openapi_docs_dict_args(openapi_docs): + schema = { + "properties": { + "v": { + "additionalProperties": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "integer" + } + }, + { + "type": "boolean" + } + ] + }, + "type": "object" + } + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "dict/args/str/list/3_10_union", skip_route=True) + +def test_openapi_docs_default(openapi_docs): + schema = { + "properties": { + "n_opt": { + "type": "string", + "default": "not_optional" + }, + "opt": { + "oneOf": [ + {"type": "string"}, + {"type": "null"} + ], + "default": "optional" + } + }, + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "str/async_decorator/default", skip_route=True) + + +def test_openapi_docs_alias(openapi_docs): + schema = { + "properties": { + "v": { + "type": "string" + } + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "str/decorator/alias", skip_route=True) + +def test_openapi_docs_undeprecated_route(openapi_docs): + type_and_test = "str/async_decorator/func" + openapi_operations = [ + openapi_docs["paths"][f"/query/{type_and_test}"]["get"], + openapi_docs["paths"][f"/route/{type_and_test}/{{v}}"]["get"], + openapi_docs["paths"][f"/form/{type_and_test}"]["post"], + openapi_docs["paths"][f"/json/{type_and_test}"]["post"] + ] + for operation in openapi_operations: + assert "deprecated" not in operation + +def test_openapi_docs_deprecated_route(openapi_docs): + type_and_test = "str/async_decorator/deprecated" + openapi_operations = [ + openapi_docs["paths"][f"/query/{type_and_test}"]["get"], + openapi_docs["paths"][f"/route/{type_and_test}/{{v}}"]["get"], + openapi_docs["paths"][f"/form/{type_and_test}"]["post"], + openapi_docs["paths"][f"/json/{type_and_test}"]["post"] + ] + for operation in openapi_operations: + assert "deprecated" in operation and operation["deprecated"] + +def test_openapi_docs_file(openapi_docs): + assert "application/octet-stream" in openapi_docs["paths"]["/file/required"]["post"]["requestBody"]["content"] + +def test_openapi_docs_content_type(openapi_docs): + assert "application/json" in openapi_docs["paths"]["/file/content_types"]["post"]["requestBody"]["content"] \ No newline at end of file diff --git a/flask_parameter_validation/test/testing_blueprints/dict_blueprint.py b/flask_parameter_validation/test/testing_blueprints/dict_blueprint.py index 481ca81..66301dc 100644 --- a/flask_parameter_validation/test/testing_blueprints/dict_blueprint.py +++ b/flask_parameter_validation/test/testing_blueprints/dict_blueprint.py @@ -8,8 +8,22 @@ from flask_parameter_validation.parameter_types.parameter import Parameter from flask_parameter_validation.test.testing_blueprints.dummy_decorators import dummy_decorator, dummy_async_decorator +_fpv_test_dict_blueprint_json_schema = { + "type": "object", + "required": ["user_id", "first_name", "last_name", "tags"], + "properties": { + "user_id": {"type": "integer"}, + "first_name": {"type": "string"}, + "last_name": {"type": "string"}, + "tags": { + "type": "array", + "items": {"type": "string"} + } + } +} def get_dict_blueprint(ParamType: type[Parameter], bp_name: str, http_verb: str) -> Blueprint: + global _fpv_test_dict_blueprint_json_schema dict_bp = Blueprint(bp_name, __name__, url_prefix="/dict") decorator = getattr(dict_bp, http_verb) @@ -75,23 +89,9 @@ def are_keys_lowercase(v): def func(v: dict = ParamType(func=are_keys_lowercase)): return jsonify({"v": v}) - json_schema = { - "type": "object", - "required": ["user_id", "first_name", "last_name", "tags"], - "properties": { - "user_id": {"type": "integer"}, - "first_name": {"type": "string"}, - "last_name": {"type": "string"}, - "tags": { - "type": "array", - "items": {"type": "string"} - } - } - } - @decorator("/json_schema") @ValidateParameters() - def json_schema(v: dict = ParamType(json_schema=json_schema)): + def json_schema(v: dict = ParamType(json_schema=_fpv_test_dict_blueprint_json_schema)): return jsonify({"v": v}) @decorator("/args/str/str") diff --git a/flask_parameter_validation/test/testing_blueprints/str_blueprint.py b/flask_parameter_validation/test/testing_blueprints/str_blueprint.py index 53c8bdc..1387107 100644 --- a/flask_parameter_validation/test/testing_blueprints/str_blueprint.py +++ b/flask_parameter_validation/test/testing_blueprints/str_blueprint.py @@ -1,3 +1,4 @@ +from typing_extensions import deprecated from typing import Optional from flask import Blueprint, jsonify, current_app @@ -278,4 +279,13 @@ async def async_decorator_alias( ): return jsonify({"value": value}) + @decorator(path("/async_decorator/deprecated", "/")) + @dummy_async_decorator + @deprecated("Test deprecated route") + @ValidateParameters() + async def async_deprecated( + value: str = ParamType(alias="v") + ): + return jsonify({"value": value}) + return str_bp \ No newline at end of file diff --git a/flask_parameter_validation/test/testing_blueprints/typeddict_blueprint.py b/flask_parameter_validation/test/testing_blueprints/typeddict_blueprint.py index b40c7e7..626b21b 100644 --- a/flask_parameter_validation/test/testing_blueprints/typeddict_blueprint.py +++ b/flask_parameter_validation/test/testing_blueprints/typeddict_blueprint.py @@ -1,6 +1,6 @@ import datetime import sys -from typing import Optional +from typing import Optional, Annotated if sys.version_info >= (3, 11): from typing import NotRequired, Required, is_typeddict, TypedDict @@ -22,13 +22,14 @@ def get_typeddict_blueprint(ParamType: type[Parameter], bp_name: str, http_verb: # return base + (route_additions if ParamType is Route else "") class Simple(TypedDict): + """A simple TypedDict""" id: int name: str timestamp: datetime.datetime @decorator("/") @ValidateParameters() - def normal(v: Simple = ParamType(list_disable_query_csv=True)): + def normal(v: Simple = ParamType(list_disable_query_csv=True, comment="Overrides the class docstring")): assert type(v) is dict assert "id" in v and "name" in v and "timestamp" in v assert type(v["id"]) is int @@ -138,8 +139,12 @@ def json_schema(v: SimpleFunc = ParamType(json_schema=json_schema, list_disable_ return jsonify({"v": v}) class SimpleNotRequired(TypedDict): - id: NotRequired[int] - name: str + """ + Docstring of SimpleNotRequired + """ + + id: Annotated[NotRequired[int], "Annotated comment on the id property"] + name: str ## comment on the name property timestamp: datetime.datetime @decorator("/not_required") @@ -161,7 +166,7 @@ class SimpleRequired(TypedDict, total=False): @decorator("/required") @ValidateParameters() - def required(v: SimpleRequired = ParamType(list_disable_query_csv=True)): + def required(v: SimpleRequired = ParamType(list_disable_query_csv=True, comment="Comment in the decorator")): assert type(v) is dict assert "name" in v and "timestamp" in v assert type(v["name"]) is str @@ -172,6 +177,7 @@ def required(v: SimpleRequired = ParamType(list_disable_query_csv=True)): return jsonify({"v": v}) class Coord(TypedDict): + """Docstring of Coord""" x: float y: float z: float @@ -180,7 +186,7 @@ class Coord(TypedDict): class Complex(TypedDict): children: list[Simple] left: Coord - right: Coord + right: Coord # Overrides the docstring of Coord name: str @decorator("/complex") From e4d25d1bbcf5b0d3dbcd11bdaf8768ebc538c893 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Fri, 22 May 2026 19:29:27 -0400 Subject: [PATCH 10/18] Fix version-specific import --- flask_parameter_validation/docs_blueprint.py | 7 +++++-- .../test/testing_blueprints/str_blueprint.py | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index d78c764..7dd8bbc 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -7,8 +7,6 @@ from typing import TypedDict, is_typeddict import sys from enum import Enum, EnumMeta -from typing_extensions import deprecated - import flask from flask import Blueprint, current_app, jsonify from flask_parameter_validation import ValidateParameters @@ -17,6 +15,11 @@ if sys.version_info >= (3, 10): from types import UnionType +if sys.version_info >= (3, 13): + from warnings import deprecated +else: + from typing_extensions import deprecated + docs_blueprint = Blueprint( "docs", __name__, url_prefix="/docs", template_folder="./templates" diff --git a/flask_parameter_validation/test/testing_blueprints/str_blueprint.py b/flask_parameter_validation/test/testing_blueprints/str_blueprint.py index 1387107..c937fd0 100644 --- a/flask_parameter_validation/test/testing_blueprints/str_blueprint.py +++ b/flask_parameter_validation/test/testing_blueprints/str_blueprint.py @@ -1,4 +1,8 @@ -from typing_extensions import deprecated +import sys +if sys.version_info >= (3, 13): + from warnings import deprecated +else: + from typing_extensions import deprecated from typing import Optional from flask import Blueprint, jsonify, current_app From 10fca021019cd4e2eb62040b3078b7d26a667900 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Fri, 22 May 2026 19:33:20 -0400 Subject: [PATCH 11/18] Fix version-specific import of is_typeddict --- flask_parameter_validation/docs_blueprint.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index 7dd8bbc..ff8c784 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -4,7 +4,6 @@ import uuid import warnings from copy import deepcopy -from typing import TypedDict, is_typeddict import sys from enum import Enum, EnumMeta import flask @@ -12,7 +11,10 @@ from flask_parameter_validation import ValidateParameters from flask_parameter_validation.exceptions.exceptions import ConfigurationError import re - +if sys.version_info >= (3, 11): + from typing import is_typeddict +elif sys.version_info >= (3, 9): + from typing_extensions import is_typeddict if sys.version_info >= (3, 10): from types import UnionType if sys.version_info >= (3, 13): From b6ea4d9f00fb9bc265551a4dd0d8e47f754aab51 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Fri, 22 May 2026 19:34:34 -0400 Subject: [PATCH 12/18] Remove type hint to make Python 3.9 happy --- flask_parameter_validation/docs_blueprint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index ff8c784..b74cd40 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -209,7 +209,7 @@ def parameter_required(param): return False return True -def generate_json_schema_helper(param: dict | None, param_type: str, raw_type): +def generate_json_schema_helper(param, param_type, raw_type): schema = {} if raw_type is str: schema["type"] = "string" From 158bc801bc3fbf124c80d8dc50be8fac97fee973 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Fri, 22 May 2026 20:58:20 -0400 Subject: [PATCH 13/18] Fix 3.9 Annotated[] difference and use different test for dict[] in 3.9 --- flask_parameter_validation/docs_blueprint.py | 7 ++++- .../test/test_api_docs.py | 29 ++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index b74cd40..015566d 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -11,6 +11,7 @@ from flask_parameter_validation import ValidateParameters from flask_parameter_validation.exceptions.exceptions import ConfigurationError import re +from typing import Annotated if sys.version_info >= (3, 11): from typing import is_typeddict elif sys.version_info >= (3, 9): @@ -331,13 +332,17 @@ def generate_json_schema_helper(param, param_type, raw_type): elif type_group == "dict": schema["type"] = "object" schema["additionalProperties"] = generate_json_schema_helper(None, recursively_resolve_type_hint(raw_type.__args__[1]), raw_type.__args__[1]) - elif type_group == "Annotated": + elif type_group == "Annotated" or type(raw_type) is type(Annotated[int, ""]): schema = generate_json_schema_helper(param, recursively_resolve_type_hint(raw_type.__origin__), raw_type.__origin__) for annotation in raw_type.__metadata__: if type(annotation) is str: schema["description"] = annotation break else: + print(param) + print(param_type) + print(raw_type) + print(type(raw_type)) warnings.warn(f"Unsupported generic type {param_type}", Warning, stacklevel=2) if param: if "comment" in param["loc_args"]: diff --git a/flask_parameter_validation/test/test_api_docs.py b/flask_parameter_validation/test/test_api_docs.py index 944197a..dbd4e07 100644 --- a/flask_parameter_validation/test/test_api_docs.py +++ b/flask_parameter_validation/test/test_api_docs.py @@ -485,6 +485,32 @@ def test_openapi_docs_dict_json_schema(openapi_docs): } check_schema_for_all_locations(openapi_docs, schema, "dict/json_schema", skip_route=True) +if sys.version_info >= (3, 10): + def test_openapi_docs_dict_args_3_10_union(openapi_docs): + schema = { + "properties": { + "v": { + "additionalProperties": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "integer" + } + }, + { + "type": "boolean" + } + ] + }, + "type": "object" + } + }, + "required": ["v"], + "type": "object" + } + check_schema_for_all_locations(openapi_docs, schema, "dict/args/str/list/3_10_union", skip_route=True) + def test_openapi_docs_dict_args(openapi_docs): schema = { "properties": { @@ -508,7 +534,8 @@ def test_openapi_docs_dict_args(openapi_docs): "required": ["v"], "type": "object" } - check_schema_for_all_locations(openapi_docs, schema, "dict/args/str/list/3_10_union", skip_route=True) + check_schema_for_all_locations(openapi_docs, schema, "dict/args/str/list", skip_route=True) + def test_openapi_docs_default(openapi_docs): schema = { From 08f06d5f14eb8c0d06d837e343afb4bd1d93a942 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Fri, 22 May 2026 21:00:01 -0400 Subject: [PATCH 14/18] Remove debug prints --- flask_parameter_validation/docs_blueprint.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index 015566d..c5d6d6f 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -339,10 +339,6 @@ def generate_json_schema_helper(param, param_type, raw_type): schema["description"] = annotation break else: - print(param) - print(param_type) - print(raw_type) - print(type(raw_type)) warnings.warn(f"Unsupported generic type {param_type}", Warning, stacklevel=2) if param: if "comment" in param["loc_args"]: From 927786d3b6ec82c19f5bfae5d4c206365071d47d Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Fri, 22 May 2026 21:32:54 -0400 Subject: [PATCH 15/18] Add comments --- README.md | 2 +- flask_parameter_validation/docs_blueprint.py | 127 +++++++++++-------- 2 files changed, 78 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index d737ff7..8fbc0d0 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ Validation beyond type-checking can be done by passing arguments into the constr | `datetime_format` | `str` | `datetime.datetime` | ❌ JSON Schema limitation | Python datetime format string datetime format string ([datetime format codes](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes)) | | `comment` | `str` | All | ✅ | A string to display as the argument description in any generated documentation | | `alias` | `str` | All but `FileStorage` | ✅ | An expected parameter name to receive instead of the function name. | -| `json_schema` | `dict` | All but `FileStorage` | ✅ | An expected [JSON Schema](https://json-schema.org) which the dict input must conform to | +| `json_schema` | `dict` | All but `FileStorage` | ✅ | An expected [JSON Schema](https://json-schema.org) which the dict input must conform to, overrides the generated JSON Schema in API Docs when provided. | | `content_types` | `list[str]` | `FileStorage` | ✅ | Allowed `Content-Type`s | | `min_length` | `int` | `FileStorage` | ❌ JSON Schema limitation | Minimum `Content-Length` for a file | | `max_length` | `int` | `FileStorage` | ❌ JSON Schema limitation | Maximum `Content-Length` for a file | diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index c5d6d6f..0a12784 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -211,8 +211,16 @@ def parameter_required(param): return True def generate_json_schema_helper(param, param_type, raw_type): + """ + Generate a JSON Schema for the provided parameter information + :param param: Information gathered about the parameter from the Parameter constructor, or None if generating info below the root level of a FPV parameter (i.e. TypedDict members) + :param param_type: Stringified type of the parameter + :param raw_type: Raw type of the parameter + + :return: Generated JSON Schema as a Python dict + """ schema = {} - if raw_type is str: + if raw_type is str: # Non-enum str, and str-only validation criteria schema["type"] = "string" if param is not None: if "min_str_length" in param["loc_args"]: @@ -221,42 +229,50 @@ def generate_json_schema_helper(param, param_type, raw_type): schema["maxLength"] = param["loc_args"]["max_str_length"] if "pattern" in param["loc_args"]: schema["pattern"] = param["loc_args"]["pattern"] - elif raw_type is int: + + elif raw_type is int: # Non-enum int, and int-only validation criteria schema["type"] = "integer" if param is not None: if "min_int" in param["loc_args"]: schema["minimum"] = param["loc_args"]["min_int"] if "max_int" in param["loc_args"]: schema["maximum"] = param["loc_args"]["max_int"] - elif raw_type is bool: + + elif raw_type is bool: # bool schema["type"] = "boolean" - elif raw_type is float: + + elif raw_type is float: # float schema["type"] = "number" - elif raw_type is datetime.datetime: + + elif raw_type is datetime.datetime: # datetime and datetime-only validation criteria schema["type"] = "string" schema["format"] = "date-time" if param is not None: if "datetime_format" in param["loc_args"]: warnings.warn("datetime_format cannot be translated to JSON Schema, please use ISO8601 date-time", Warning, stacklevel=2) - elif raw_type is datetime.date: + + elif raw_type is datetime.date: # date schema["type"] = "string" schema["format"] = "date" - elif raw_type is datetime.time: + + elif raw_type is datetime.time: # time schema["type"] = "string" schema["format"] = "time" - elif raw_type is type(None): + + elif raw_type is type(None): # None schema["type"] = "null" - elif is_typeddict(raw_type): + + elif is_typeddict(raw_type): # TypedDict schema["type"] = "object" schema["properties"] = {} required_properties = [] source = "" - try: + try: # Get source to read comments from for description key on members source = inspect.getsource(raw_type) - except OSError: + except OSError: # Sometimes we can't get the source? pass - for key, value in raw_type.__annotations__.items(): + for key, value in raw_type.__annotations__.items(): # Iterate through the keys of the TypedDict and generate subschemas for them value_param_type = recursively_resolve_type_hint(value) schema["properties"][key] = generate_json_schema_helper(None, value_param_type, value) key_match = re.findall(rf"^\s*{key}\s*:\s*{value_param_type}\s*#\s*(.*)$", source, flags=re.MULTILINE) @@ -272,18 +288,20 @@ def generate_json_schema_helper(param, param_type, raw_type): type_description = inspect.getdoc(raw_type) if type_description and type_description != inspect.getdoc(dict): schema["description"] = type_description - elif raw_type is dict: + + elif raw_type is dict: # Plain non-generic dict schema["type"] = "object" - elif type(raw_type) in [type, EnumMeta] and issubclass(raw_type, Enum): + + elif type(raw_type) in [type, EnumMeta] and issubclass(raw_type, Enum): # Enums if issubclass(raw_type, str): schema["type"] = "string" elif issubclass(raw_type, int): schema["type"] = "integer" else: warnings.warn(f"Unsupported enum type: {param_type}", Warning, stacklevel=2) - source = inspect.getsource(raw_type) + source = inspect.getsource(raw_type) # Get source to read comments from for description key on members options = [] - for opt in raw_type: + for opt in raw_type: # Iterate over enum members and generate subschemas for them option_schema = {"title": opt.name, "const": opt.value} opt_match = re.findall(rf"^\s*{opt.name}\s*=\s*['\"]?{opt.value}['\"]?\s*#\s*(.*)$", source, flags=re.MULTILINE) if opt_match: @@ -294,20 +312,22 @@ def generate_json_schema_helper(param, param_type, raw_type): if type_description and type_description not in [inspect.getdoc(str), inspect.getdoc(int)]: schema["description"] = type_description schema["title"] = param_type - elif raw_type is uuid.UUID: + + elif raw_type is uuid.UUID: # UUID schema["type"] = "string" schema["format"] = "uuid" - else: + + else: # Generic types match = re.match(r'(\w+)\[([\w\[\] ,.]+)]', param_type) if not match: warnings.warn(f"Unsupported type {param_type}", Warning, stacklevel=2) return {} type_group = match.group(1) - if type_group in ["List", "list"]: + if type_group in ["List", "list"]: # Lists, and list-only validation criteria schema["type"] = "array" available_types = [] - for subtype in raw_type.__args__: + for subtype in raw_type.__args__: # Generate subschema for list items subtype_schema = generate_json_schema_helper(param, recursively_resolve_type_hint(subtype), subtype) available_types.append(subtype_schema) if len(available_types) == 1: @@ -319,32 +339,38 @@ def generate_json_schema_helper(param, param_type, raw_type): schema["minItems"] = param["loc_args"]["min_list_length"] if "max_list_length" in param["loc_args"]: schema["maxItems"] = param["loc_args"]["max_list_length"] - elif type_group in ["Optional", "Union"]: + + elif type_group in ["Optional", "Union"]: # Unions available_types = [] for subtype in raw_type.__args__: - subtype_schema = generate_json_schema_helper(param, recursively_resolve_type_hint(subtype), subtype) - if "default" in subtype_schema: - del subtype_schema["default"] + subtype_schema = generate_json_schema_helper(None, recursively_resolve_type_hint(subtype), subtype) available_types.append(subtype_schema) schema["oneOf"] = available_types - elif type_group in ["Required", "NotRequired"]: + + elif type_group in ["Required", "NotRequired"]: # Ignore these, as they're handled above in TypedDict logic schema = generate_json_schema_helper(param, recursively_resolve_type_hint(raw_type.__args__[0]), raw_type.__args__[0]) - elif type_group == "dict": + + elif type_group == "dict": # Generic dict schema["type"] = "object" schema["additionalProperties"] = generate_json_schema_helper(None, recursively_resolve_type_hint(raw_type.__args__[1]), raw_type.__args__[1]) - elif type_group == "Annotated" or type(raw_type) is type(Annotated[int, ""]): + + elif type_group == "Annotated" or type(raw_type) is type(Annotated[int, ""]): # Use the first str Annotated[] metadata we find as a description for the item schema = generate_json_schema_helper(param, recursively_resolve_type_hint(raw_type.__origin__), raw_type.__origin__) for annotation in raw_type.__metadata__: if type(annotation) is str: schema["description"] = annotation break + else: warnings.warn(f"Unsupported generic type {param_type}", Warning, stacklevel=2) - if param: + + if param: # Globally applicable Parameter arguments if "comment" in param["loc_args"]: schema["description"] = param["loc_args"]["comment"] + if "default" in param["loc_args"]: schema["default"] = param["loc_args"]["default"] + return schema @@ -373,13 +399,14 @@ def generate_json_schema_for_parameters(params): def generate_openapi_paths_object(): oapi_paths = {} for route in get_route_docs(include_raw_type=True): - oapi_path_route = re.sub(r'<(\w+):(\w+)>', r'{\2}', route['rule']) - oapi_path_route = re.sub(r'<(\w+)>', r'{\1}', oapi_path_route) + oapi_path_route = re.sub(r'<(\w+):(\w+)>', r'{\2}', route['rule']) # Replace path segments with {name} + oapi_path_route = re.sub(r'<(\w+)>', r'{\1}', oapi_path_route) # Replace path segments with {name} oapi_path_item = {} oapi_operation = {} # tags, summary, description, externalDocs, operationId, parameters, requestBody, responses, callbacks, deprecated, security, servers oapi_parameters = [] oapi_request_body = {"content": {}} - if "MultiSource" in route["args"]: + + if "MultiSource" in route["args"]: # Flatten MultiSource to make it easier to process in the following steps for arg in route["args"]["MultiSource"]: if "sources" in arg["loc_args"]: sources = arg["loc_args"]["sources"].copy() @@ -391,27 +418,27 @@ def generate_openapi_paths_object(): route["args"][source] = [] route["args"][source].append(deepcopy(arg)) del route["args"]["MultiSource"] - for arg_loc in route["args"]: - if arg_loc == "Form": + for arg_loc in route["args"]: # Iterate over argument locations for this route + if arg_loc == "Form": # If we have Form arguments oapi_request_body["content"]["application/x-www-form-urlencoded"] = { - "schema": generate_json_schema_for_parameters(route["args"][arg_loc])} - elif arg_loc == "Json": + "schema": generate_json_schema_for_parameters(route["args"][arg_loc])} # Generate schemas for them + elif arg_loc == "Json": # If we have Json arguments oapi_request_body["content"]["application/json"] = { - "schema": generate_json_schema_for_parameters(route["args"][arg_loc])} - elif arg_loc == "File": # See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#considerations-for-file-uploads - for arg in route["args"][arg_loc]: + "schema": generate_json_schema_for_parameters(route["args"][arg_loc])} # Generate schemas for them + elif arg_loc == "File": # See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#considerations-for-file-uploads - if we have File arguments for this route + for arg in route["args"][arg_loc]: # Generate "schemas" for them - this just amounts to Content-Type(s) if "content_types" in arg["loc_args"]: for content_type in arg["loc_args"]["content_types"]: oapi_request_body["content"][content_type] = {} else: oapi_request_body["content"]["application/octet-stream"] = {} - elif arg_loc in ["Route", "Query"]: - for arg in route["args"][arg_loc]: - if "alias" in arg["loc_args"]: + elif arg_loc in ["Route", "Query"]: # If we have Query or Route arguments + for arg in route["args"][arg_loc]: # Iterate over and generate schemas + if "alias" in arg["loc_args"]: # Handle alias in path oapi_path_route = oapi_path_route.replace(f'{{{arg["name"]}}}', f'{{{arg["loc_args"]["alias"]}}}') - schema_arg_name = arg["name"] if "alias" not in arg["loc_args"] else arg["loc_args"]["alias"] - if arg_loc == "Query" or (arg_loc == "Route" and f"{{{schema_arg_name}}}" in oapi_path_route): + schema_arg_name = arg["name"] if "alias" not in arg["loc_args"] else arg["loc_args"]["alias"] # Handle alias in argument name + if arg_loc == "Query" or (arg_loc == "Route" and f"{{{schema_arg_name}}}" in oapi_path_route): # Generate and include only if we're on the route where a route parameter is used (since multiple routes can point to one function) parameter = { "name": schema_arg_name, "in": "path" if arg_loc == "Route" else "query", @@ -420,22 +447,22 @@ def generate_openapi_paths_object(): "loc_args"] else generate_json_schema_for_parameter(arg), } oapi_parameters.append(parameter) - if len(oapi_parameters) > 0: + if len(oapi_parameters) > 0: # Only include the parameters object if we have them oapi_operation["parameters"] = oapi_parameters - if len(oapi_request_body["content"].keys()) > 0: + if len(oapi_request_body["content"].keys()) > 0: # Only include the requestBoyd object if we have one oapi_operation["requestBody"] = oapi_request_body - for decorator in route["decorators"]: + for decorator in route["decorators"]: # Handle deprecated for partial_decorator in ["@warnings.deprecated", "@deprecated"]: # Support for PEP 702 in Python 3.13 if partial_decorator in decorator: oapi_operation["deprecated"] = True - if route["responses"]: + if route["responses"]: # Only include the responses object if we've been given one oapi_operation["responses"] = route["responses"] - for method in route["methods"]: + for method in route["methods"]: # Use the generated operation object for all methods applicable to this route if method not in ["OPTIONS", "HEAD"]: oapi_path_item[method.lower()] = oapi_operation - if oapi_path_route in oapi_paths: + if oapi_path_route in oapi_paths: # Merge generated with existing oapi_paths[oapi_path_route] = oapi_paths[oapi_path_route] | oapi_path_item - else: + else: # Create new path item for this route oapi_paths[oapi_path_route] = oapi_path_item return oapi_paths From 92d8d4f2c11dd6ac4ab40db0f8ffbf550c6d5a19 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sat, 23 May 2026 13:43:06 -0400 Subject: [PATCH 16/18] Add examples of auto generated docs to readme --- README.md | 190 +++++++++++++++++- ...age_example_non_standard_documentation.png | Bin 0 -> 70853 bytes 2 files changed, 182 insertions(+), 8 deletions(-) create mode 100644 docs/usage_example_non_standard_documentation.png diff --git a/README.md b/README.md index 8fbc0d0..5bdd2f4 100644 --- a/README.md +++ b/README.md @@ -10,26 +10,36 @@ ## Usage Example ```py from flask import Flask -from typing import Optional, TypedDict, NotRequired +from typing import Optional, TypedDict, NotRequired, Annotated from flask_parameter_validation import ValidateParameters, Route, Json, Query +from flask_parameter_validation.docs_blueprint import docs_blueprint from datetime import datetime from enum import Enum from uuid import UUID class AccountStatus(int, Enum): # In Python 3.11 or later, subclass IntEnum from enum package instead of int, Enum - ACTIVE = 1 - DISABLED = 0 + """The status of a user's account""" + + ACTIVE = 1 # User can log in + DISABLED = 0 # All actions blocked class UserType(str, Enum): # In Python 3.11 or later, subclass StrEnum from enum package instead of str, Enum - USER = "user" - SERVICE = "service" + USER = "user" # Human + SERVICE = "service" # Bot user class SocialLink(TypedDict): - friendly_name: str - url: str - icon: NotRequired[str] + """A link to a user's social profiles""" + + friendly_name: str # Display name + url: Annotated[str, "Link to social profile"] + icon: NotRequired[str] # The icon for this link app = Flask(__name__) +app.config["FPV_OPENAPI_ENABLE"] = True +app.config["FPV_OPENAPI_BASE"] = { + "openapi": "3.1.0" +} +app.register_blueprint(docs_blueprint) @app.route("/update/", methods=["POST"]) @ValidateParameters() @@ -284,6 +294,170 @@ Documentation Generated: ![](docs/api_documentation_example.png) +For another example, below is the documentation generated from the [Usage Example](#usage-example). + +Documentation Generated (non-standard format): + +![](docs/usage_example_non_standard_documentation.png) + +Documentation Generated (OpenAPI): + +```json +{ + "openapi": "3.1.0", + "paths": { + "/update/{id}": { + "post": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "is_admin", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "permissions", + "required": true, + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "age": { + "maximum": 99, + "minimum": 18, + "type": "integer" + }, + "date_of_birth": { + "format": "date-time", + "type": "string" + }, + "nicknames": { + "items": { + "type": "string" + }, + "type": "array" + }, + "password_expiry": { + "default": 5, + "oneOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "socials": { + "items": { + "description": "A link to a user's social profiles", + "properties": { + "friendly_name": { + "description": "Display name", + "type": "string" + }, + "icon": { + "type": "string" + }, + "url": { + "description": "Link to social profile", + "type": "string" + } + }, + "required": [ + "friendly_name", + "url" + ], + "title": "SocialLink", + "type": "object" + }, + "type": "array" + }, + "status": { + "description": "The status of a user's account", + "oneOf": [ + { + "const": 1, + "description": "User can log in", + "title": "ACTIVE" + }, + { + "const": 0, + "description": "All actions blocked", + "title": "DISABLED" + } + ], + "title": "AccountStatus", + "type": "integer" + }, + "type": { + "description": "Whether this user is a bot or person", + "oneOf": [ + { + "const": "user", + "description": "Human", + "title": "USER" + }, + { + "const": "service", + "description": "Bot user", + "title": "SERVICE" + } + ], + "title": "UserType", + "type": "string" + }, + "unique": { + "format": "uuid", + "type": "string" + }, + "username": { + "minLength": 5, + "type": "string" + } + }, + "required": [ + "username", + "age", + "nicknames", + "date_of_birth", + "type", + "status", + "unique", + "socials" + ], + "type": "object" + } + } + } + } + } + } + } +} +``` + ##### Custom Blueprint If you would like to use your own blueprint, you can get the raw data from the following functions: ```py diff --git a/docs/usage_example_non_standard_documentation.png b/docs/usage_example_non_standard_documentation.png new file mode 100644 index 0000000000000000000000000000000000000000..b82a156668c1026dc7a4b684caeba077b41f730a GIT binary patch literal 70853 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%0D<&$fWq6mhQMVfkAAC9ZP| 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 h8i|w>vi_QB_cxt63>iNb- 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 x5}0-&VH^!jky0aY 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 literal 0 HcmV?d00001 From ddfc6cd02d885f338f884530d28fd92f23be9fd0 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Mon, 25 May 2026 16:02:30 -0400 Subject: [PATCH 17/18] Add test for responses object passed to ValidateParameters decorator --- .../test/test_api_docs.py | 14 +++++++++++++- .../test/testing_blueprints/int_blueprint.py | 18 +++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/flask_parameter_validation/test/test_api_docs.py b/flask_parameter_validation/test/test_api_docs.py index dbd4e07..b95968e 100644 --- a/flask_parameter_validation/test/test_api_docs.py +++ b/flask_parameter_validation/test/test_api_docs.py @@ -3,6 +3,7 @@ import pytest from flask_parameter_validation.docs_blueprint import get_route_docs, generate_openapi_docs from flask_parameter_validation.test.testing_blueprints.dict_blueprint import _fpv_test_dict_blueprint_json_schema +from flask_parameter_validation.test.testing_blueprints.int_blueprint import _fpv_test_openapi_responses_object def test_http_ok(client): @@ -595,4 +596,15 @@ def test_openapi_docs_file(openapi_docs): assert "application/octet-stream" in openapi_docs["paths"]["/file/required"]["post"]["requestBody"]["content"] def test_openapi_docs_content_type(openapi_docs): - assert "application/json" in openapi_docs["paths"]["/file/content_types"]["post"]["requestBody"]["content"] \ No newline at end of file + assert "application/json" in openapi_docs["paths"]["/file/content_types"]["post"]["requestBody"]["content"] + +def test_openapi_docs_responses(openapi_docs): + type_and_test = "int/required" + openapi_operations = [ + openapi_docs["paths"][f"/query/{type_and_test}"]["get"], + openapi_docs["paths"][f"/route/{type_and_test}/{{v}}"]["get"], + openapi_docs["paths"][f"/form/{type_and_test}"]["post"], + openapi_docs["paths"][f"/json/{type_and_test}"]["post"] + ] + for operation in openapi_operations: + assert "responses" in operation and operation["responses"] == _fpv_test_openapi_responses_object \ No newline at end of file diff --git a/flask_parameter_validation/test/testing_blueprints/int_blueprint.py b/flask_parameter_validation/test/testing_blueprints/int_blueprint.py index c48196b..0315d15 100644 --- a/flask_parameter_validation/test/testing_blueprints/int_blueprint.py +++ b/flask_parameter_validation/test/testing_blueprints/int_blueprint.py @@ -6,8 +6,24 @@ from flask_parameter_validation.parameter_types.parameter import Parameter from flask_parameter_validation.test.testing_blueprints.dummy_decorators import dummy_decorator, dummy_async_decorator +_fpv_test_openapi_responses_object = { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "v": {"type": "integer"}, + } + } + } + } + } + } def get_int_blueprint(ParamType: type[Parameter], bp_name: str, http_verb: str) -> Blueprint: + global _fpv_test_openapi_responses_object int_bp = Blueprint(bp_name, __name__, url_prefix="/int") decorator = getattr(int_bp, http_verb) @@ -15,7 +31,7 @@ def path(base: str, route_additions: str) -> str: return base + (route_additions if ParamType is Route else "") @decorator(path("/required", "/")) - @ValidateParameters() + @ValidateParameters(openapi_responses=_fpv_test_openapi_responses_object) def required(v: int = ParamType()): assert type(v) is int return jsonify({"v": v}) From 84fa16b99d86c60173d16fae5c7b0ba1bee494dd Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Mon, 25 May 2026 16:19:16 -0400 Subject: [PATCH 18/18] Implement docstring of route inclusion in generated OpenAPI document --- README.md | 4 +++- flask_parameter_validation/docs_blueprint.py | 2 ++ flask_parameter_validation/test/test_api_docs.py | 13 ++++++++++++- .../test/testing_blueprints/int_blueprint.py | 3 +++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5bdd2f4..b694140 100644 --- a/README.md +++ b/README.md @@ -508,7 +508,9 @@ Using the `warnings.deprecated` (Python 3.13+) or `typing_extensions.deprecated` ##### Comments in generated OpenAPI documentation -Generated OpenAPI Documentation will pull comments from various locations in the following order (highest priority to lowest priority): +Generated OpenAPI documentation uses the docstring from your routes as the `description` of the OpenAPI Operation object. + +Generated OpenAPI documentation will pull parameter comments from various locations in the following order (highest priority to lowest priority) for use in the `description` of OpenAPI Schema objects: 1. `comment` argument passed to a subclass of `Parameter` 2. `Annotated[T, "annotated comment"]` on a member of a TypedDict, or `# inline comment` on the same line as a member of a TypedDict or Enum 3. Docstring on a class (for Enums and TypedDicts) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index 0a12784..4f1d0e0 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -457,6 +457,8 @@ def generate_openapi_paths_object(): oapi_operation["deprecated"] = True if route["responses"]: # Only include the responses object if we've been given one oapi_operation["responses"] = route["responses"] + if route["docstring"]: + oapi_operation["description"] = route["docstring"] for method in route["methods"]: # Use the generated operation object for all methods applicable to this route if method not in ["OPTIONS", "HEAD"]: oapi_path_item[method.lower()] = oapi_operation diff --git a/flask_parameter_validation/test/test_api_docs.py b/flask_parameter_validation/test/test_api_docs.py index b95968e..b4fd821 100644 --- a/flask_parameter_validation/test/test_api_docs.py +++ b/flask_parameter_validation/test/test_api_docs.py @@ -607,4 +607,15 @@ def test_openapi_docs_responses(openapi_docs): openapi_docs["paths"][f"/json/{type_and_test}"]["post"] ] for operation in openapi_operations: - assert "responses" in operation and operation["responses"] == _fpv_test_openapi_responses_object \ No newline at end of file + assert "responses" in operation and operation["responses"] == _fpv_test_openapi_responses_object + +def test_openapi_docs_route_docstring(openapi_docs): + type_and_test = "int/func" + openapi_operations = [ + openapi_docs["paths"][f"/query/{type_and_test}"]["get"], + openapi_docs["paths"][f"/route/{type_and_test}/{{v}}"]["get"], + openapi_docs["paths"][f"/form/{type_and_test}"]["post"], + openapi_docs["paths"][f"/json/{type_and_test}"]["post"] + ] + for operation in openapi_operations: + assert "description" in operation and operation["description"] == "Test Docstring on Route" \ No newline at end of file diff --git a/flask_parameter_validation/test/testing_blueprints/int_blueprint.py b/flask_parameter_validation/test/testing_blueprints/int_blueprint.py index 0315d15..10a7786 100644 --- a/flask_parameter_validation/test/testing_blueprints/int_blueprint.py +++ b/flask_parameter_validation/test/testing_blueprints/int_blueprint.py @@ -83,6 +83,9 @@ def is_even(v): @decorator(path("/func", "/")) @ValidateParameters() def func(v: int = ParamType(func=is_even)): + """ + Test Docstring on Route + """ return jsonify({"v": v}) return int_bp