From 02287a3a4382b663eccc95c328b9964eac3dcacd Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Mon, 17 Jun 2024 23:47:14 -0400 Subject: [PATCH 1/9] WIP: OpenAPI 3.1.0 Documentation Generation --- flask_parameter_validation/docs_blueprint.py | 194 +++++++++++++++++- .../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, 213 insertions(+), 4 deletions(-) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index 47a8c8f..0ff6756 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -1,7 +1,12 @@ +import json +import warnings +from typing import Optional + 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 docs_blueprint = Blueprint( "docs", __name__, url_prefix="/docs", template_folder="./templates" @@ -38,6 +43,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 @@ -141,3 +148,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 063dac9..000c6ac 100644 --- a/flask_parameter_validation/parameter_types/parameter.py +++ b/flask_parameter_validation/parameter_types/parameter.py @@ -89,6 +89,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 3941b4b..6727ec7 100644 --- a/flask_parameter_validation/parameter_types/query.py +++ b/flask_parameter_validation/parameter_types/query.py @@ -10,7 +10,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): diff --git a/flask_parameter_validation/parameter_types/route.py b/flask_parameter_validation/parameter_types/route.py index 1cccb80..3c03288 100644 --- a/flask_parameter_validation/parameter_types/route.py +++ b/flask_parameter_validation/parameter_types/route.py @@ -8,7 +8,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): diff --git a/flask_parameter_validation/parameter_validation.py b/flask_parameter_validation/parameter_validation.py index 20f72c7..ff4fa30 100644 --- a/flask_parameter_validation/parameter_validation.py +++ b/flask_parameter_validation/parameter_validation.py @@ -18,8 +18,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): """ @@ -37,6 +39,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 87d9f4ba306a8c1c61d4a0be691140b36acfb99e Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sun, 30 Jun 2024 00:59:09 -0400 Subject: [PATCH 2/9] Update README to include OpenAPI info --- README.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 142d4fe..1bc4d27 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,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. However, this can be edited by passing a custom error function into the `ValidateParameters()` decorator. For example: @@ -120,7 +123,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 | @@ -148,8 +151,10 @@ def is_odd(val: int): ### API Documentation Using the data provided through parameters, docstrings, and Flask route registrations, Flask Parameter Validation can generate API Documentation in various formats. To make this easy to use, it comes with a `Blueprint` and the output and configuration options below: +#### 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. -#### Format +#### 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 @@ -168,6 +173,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 @@ -224,8 +230,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 @@ -238,6 +246,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": { "": [ { @@ -246,7 +258,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 4c6aa5e00e85d30798eba30a193c5902463d3fea Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Wed, 14 Aug 2024 17:45:16 -0400 Subject: [PATCH 3/9] Add Enum and MultiSource handling to API documentation generation --- flask_parameter_validation/docs_blueprint.py | 63 ++++++++++++++++--- .../templates/fpv_default_docs.html | 24 ++++++- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index 0ff6756..1e0aae4 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -1,17 +1,16 @@ import json import warnings -from typing import Optional - +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 +import copy docs_blueprint = Blueprint( "docs", __name__, url_prefix="/docs", template_folder="./templates" ) - +# TODO: Replace prints with warnings where useful, else remove def get_route_docs(): """ @@ -72,16 +71,29 @@ def extract_argument_details(fdocs): "loc": get_arg_location(fdocs, idx), "loc_args": get_arg_location_details(fdocs, idx), } + if arg_data["type"] in ["StrEnum", "IntEnum"]: + arg_data["enum_values"] = get_arg_enum_values(fdocs, arg_name) args_data.setdefault(arg_data["loc"], []).append(arg_data) return args_data +def get_arg_enum_values(fdocs, arg_name): + """ + Extract the Enum values for a specific argument. + """ + arg_type = fdocs["argspec"].annotations[arg_name] + return list(map(lambda e: e.value, arg_type)) def get_arg_type_hint(fdocs, arg_name): """ Extract the type hint for a specific argument. """ arg_type = fdocs["argspec"].annotations[arg_name] - if hasattr(arg_type, "__args__"): + if issubclass(arg_type, Enum) and (issubclass(arg_type, str) or issubclass(arg_type, int)): + if issubclass(arg_type, str): + return "StrEnum" + elif issubclass(arg_type, int): + return "IntEnum" + elif hasattr(arg_type, "__args__"): return ( f"{arg_type.__name__}[{', '.join([a.__name__ for a in arg_type.__args__])}]" ) @@ -140,10 +152,18 @@ def docs_json(): Provide the documentation as a JSON response. """ config = flask.current_app.config + route_docs = get_route_docs() + for route in route_docs: + if "MultiSource" in route["args"]: + for arg in route["args"]["MultiSource"]: + sources = [] + for source in arg["loc_args"]["sources"]: + sources.append(source.__class__.__name__) + arg["loc_args"]["sources"] = sources return jsonify( { "site_name": config.get("FPV_DOCS_SITE_NAME", "Site"), - "docs": get_route_docs(), + "docs": route_docs, "custom_blocks": config.get("FPV_DOCS_CUSTOM_BLOCKS", []), "default_theme": config.get("FPV_DOCS_DEFAULT_THEME", "light"), } @@ -184,7 +204,13 @@ def generate_json_schema_helper(param, param_type, parent_group=None): 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? + if "json_schema" in param["loc_args"]: + # Without significant complexity, it is impossible to write a single regex to encompass + # the FPV blacklist, whitelist and pattern arguments, so only pattern is considered. + subschema["pattern"] = param["loc_args"]["json_schema"] + if "whitelist" in param["loc_args"] or "blacklist" in param["loc_args"]: + warnings.warn("whitelist and blacklist cannot be translated to JSON Schema, please use pattern", + Warning, stacklevel=2) elif p == "int": subschema["type"] = "integer" if "min_int" in param["loc_args"]: @@ -211,6 +237,12 @@ def generate_json_schema_helper(param, param_type, parent_group=None): subschema["type"] = "object" elif p in ["None", "NoneType"]: subschema["type"] = "null" + elif p in ["StrEnum", "IntEnum"]: + if p == "StrEnum": + subschema["type"] = "string" + elif p == "IntEnum": + subschema["type"] = "integer" + subschema["enum"] = param["enum_values"] else: print(f"Unexpected type: {p}") schemas.append(subschema) @@ -218,7 +250,7 @@ def generate_json_schema_helper(param, param_type, parent_group=None): return schemas[0] elif parent_group in ["Optional", "Union"]: return {"oneOf": schemas} - elif parent_group == "List": + elif parent_group in ["List", "list"]: schema = {"type": "array", "items": schemas[0]} if "min_list_length" in param["loc_args"]: schema["minItems"] = param["loc_args"]["min_list_length"] @@ -259,6 +291,18 @@ def generate_openapi_paths_object(): 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"]: + mod_arg = copy.deepcopy(arg) + mod_arg["loc_args"].pop("sources") + for source in arg["loc_args"]["sources"]: + print(source) + source_name = source.__class__.__name__ + if source_name in route["args"]: + route["args"][source_name].append(mod_arg) + else: + route["args"][source_name] = [mod_arg] + route["args"].pop("MultiSource") for arg_loc in route["args"]: if arg_loc == "Form": oapi_request_body["content"]["application/x-www-form-urlencoded"] = { @@ -290,6 +334,9 @@ def generate_openapi_paths_object(): if "deprecated" in arg["loc_args"] and arg["loc_args"]["deprecated"]: parameter["deprecated"] = arg["loc_args"]["deprecated"] oapi_parameters.append(parameter) + else: + warnings.warn(f"generate_openapi_paths_object encountered unexpected location: {arg_loc}", + Warning, stacklevel=2) if len(oapi_parameters) > 0: oapi_operation["parameters"] = oapi_parameters if len(oapi_request_body["content"].keys()) > 0: diff --git a/flask_parameter_validation/templates/fpv_default_docs.html b/flask_parameter_validation/templates/fpv_default_docs.html index 153d95d..e06e7e0 100644 --- a/flask_parameter_validation/templates/fpv_default_docs.html +++ b/flask_parameter_validation/templates/fpv_default_docs.html @@ -69,10 +69,30 @@

{{ arg_loc }}

{% for arg in route.args[arg_loc] %} {#
  • {{ arg }}
  • #}
  • {{ arg.name }}: {{ arg.type }}{% if "comment" in arg.loc_args %} {{ arg.loc_args.comment }}{% endif %}
  • - {% if ("comment" in arg.loc_args and arg.loc_args | length > 1) or ("comment" not in arg.loc_args and arg.loc_args | length > 0) %} + {% if "sources" in arg.loc_args %} +
      +
    • Sources:
    • +
        + {% for source in arg.loc_args.sources %} +
      • {{ source.__class__.__name__ }}
      • + {% endfor %} +
      +
    + {% endif %} + {% if "enum_values" in arg %} +
      +
    • Enum Values:
    • +
        + {% for val in arg.enum_values %} +
      • {{ val }}
      • + {% endfor %} +
      +
    + {% endif %} + {% if ("comment" in arg.loc_args and "sources" not in arg.loc_args and arg.loc_args | length > 1) or ("comment" not in arg.loc_args and "sources" in arg.loc_args and arg.loc_args | length > 1) or ("comment" not in arg.loc_args and "sources" not in arg.loc_args and arg.loc_args | length > 0) or ("comment" in arg.loc_args and "sources" in arg.loc_args and arg.loc_args | length > 2) %}
      {% for loc_arg in arg.loc_args %} - {% if loc_arg != "comment" %} + {% if loc_arg not in ["comment", "sources"] %}
    • {{ loc_arg }} = {{ arg.loc_args[loc_arg] }}
    • {% endif %} {% endfor %} From 80ef751fef9943506a13884491f0e12d40c036eb Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Wed, 14 Aug 2024 18:40:22 -0400 Subject: [PATCH 4/9] Enable json_schema for other types, add tests for this where not redundant, update README --- README.md | 41 ++++++++++--------- .../parameter_types/parameter.py | 20 ++++----- .../test/test_form_params.py | 35 ++++++++++++++++ .../test/test_json_params.py | 33 +++++++++++++++ .../test/test_query_params.py | 32 +++++++++++++++ .../test/test_route_params.py | 33 +++++++++++++++ .../testing_blueprints/float_blueprint.py | 5 +++ .../test/testing_blueprints/int_blueprint.py | 5 +++ .../test/testing_blueprints/str_blueprint.py | 5 +++ setup.py | 1 + 10 files changed, 177 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index a045076..1bb7ef0 100644 --- a/README.md +++ b/README.md @@ -145,26 +145,27 @@ 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 | 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 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 | +| Parameter Name | Type of Argument | Effective On Types | Description | +|-------------------|--------------------------------------------------|---------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `default` | any | 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` | `str`, `int`, `float`, `dict`, `list`1 | An expected [JSON Schema](https://json-schema.org) which the input must conform to. See [python-jsonschema docs](https://python-jsonschema.readthedocs.io/en/latest/validate/#validating-formats) for information about string format validation | +| `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 | +1 `json_schema` is tested to work with `str`, `int`, `float`, `dict` and `list` - other types may work, but are redundant in use and testing (i.e. JSON Schema provides no further validation on booleans beyond checking that it is a boolean) These validators are passed into the `Parameter` subclass in the route function, such as: * `username: str = Json(default="defaultusername", min_length=5)` diff --git a/flask_parameter_validation/parameter_types/parameter.py b/flask_parameter_validation/parameter_types/parameter.py index 8f3b41b..25e4cd3 100644 --- a/flask_parameter_validation/parameter_types/parameter.py +++ b/flask_parameter_validation/parameter_types/parameter.py @@ -8,6 +8,7 @@ import dateutil.parser as parser import jsonschema from jsonschema.exceptions import ValidationError as JSONSchemaValidationError +from jsonschema.validators import Draft202012Validator class Parameter: @@ -69,6 +70,12 @@ def func_helper(self, v): # Validator def validate(self, value): original_value_type_list = type(value) is list + if self.json_schema is not None: + try: + # Uses JSON Schema 2020-12 as OpenAPI 3.1.0 is fully compatible with this draft + jsonschema.validate(value, self.json_schema, format_checker=Draft202012Validator.FORMAT_CHECKER) + except JSONSchemaValidationError as e: + raise ValueError(f"failed JSON Schema validation: {e.args[0]}") if type(value) is list: values = value # Min list len @@ -85,19 +92,6 @@ def validate(self, value): ) if self.func is not None: self.func_helper(value) - if self.json_schema is not None: - try: - jsonschema.validate(value, self.json_schema) - 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) - except JSONSchemaValidationError as e: - raise ValueError(f"failed JSON Schema validation: {e.args[0]}") - values = [value] else: values = [value] diff --git a/flask_parameter_validation/test/test_form_params.py b/flask_parameter_validation/test/test_form_params.py index a7a91b4..64b4ac9 100644 --- a/flask_parameter_validation/test/test_form_params.py +++ b/flask_parameter_validation/test/test_form_params.py @@ -168,6 +168,19 @@ def test_str_alias(client): assert r.json["value"] == "abc" +def test_str_json_schema(client): + url = "/form/str/json_schema" + # Test that input matching schema yields input + r = client.post(url, data={"v": "test@example.com"}) + assert "v" in r.json + assert r.json["v"] == "test@example.com" + # Test that input failing schema yields error + r = client.post(url, data={"v": "not an email"}) + assert "error" in r.json + + + + # Int Validation def test_required_int(client): url = "/form/int/required" @@ -258,6 +271,17 @@ def test_int_func(client): assert "error" in r.json +def test_int_json_schema(client): + url = "/form/int/json_schema" + # Test that input matching schema yields input + r = client.post(url, data={"v": 10}) + assert "v" in r.json + assert r.json["v"] == 10 + # Test that input failing schema yields error + r = client.post(url, data={"v": 100}) + assert "error" in r.json + + # Bool Validation def test_required_bool(client): url = "/form/bool/required" @@ -382,6 +406,17 @@ def test_float_func(client): assert "error" in r.json +def test_float_json_schema(client): + url = "/form/float/json_schema" + # Test that input matching schema yields input + r = client.post(url, data={"v": 3.14}) + assert "v" in r.json + assert r.json["v"] == 3.14 + # Test that input failing schema yields error + r = client.post(url, data={"v": 3.141592}) + assert "error" in r.json + + # datetime Validation def test_required_datetime(client): url = "/form/datetime/required" diff --git a/flask_parameter_validation/test/test_json_params.py b/flask_parameter_validation/test/test_json_params.py index 2edf5d0..bfe21e4 100644 --- a/flask_parameter_validation/test/test_json_params.py +++ b/flask_parameter_validation/test/test_json_params.py @@ -146,6 +146,17 @@ def test_str_alias(client): assert r.json["value"] == "abc" +def test_str_json_schema(client): + url = "/json/str/json_schema" + # Test that input matching schema yields input + r = client.post(url, json={"v": "test@example.com"}) + assert "v" in r.json + assert r.json["v"] == "test@example.com" + # Test that input failing schema yields error + r = client.post(url, json={"v": "not an email"}) + assert "error" in r.json + + # Int Validation def test_required_int(client): url = "/json/int/required" @@ -236,6 +247,17 @@ def test_int_func(client): assert "error" in r.json +def test_int_json_schema(client): + url = "/json/int/json_schema" + # Test that input matching schema yields input + r = client.post(url, json={"v": 10}) + assert "v" in r.json + assert r.json["v"] == 10 + # Test that input failing schema yields error + r = client.post(url, json={"v": 100}) + assert "error" in r.json + + # Bool Validation def test_required_bool(client): url = "/json/bool/required" @@ -360,6 +382,17 @@ def test_float_func(client): assert "error" in r.json +def test_float_json_schema(client): + url = "/json/float/json_schema" + # Test that input matching schema yields input + r = client.post(url, json={"v": 3.14}) + assert "v" in r.json + assert r.json["v"] == 3.14 + # Test that input failing schema yields error + r = client.post(url, json={"v": 3.141592}) + assert "error" in r.json + + # datetime Validation def test_required_datetime(client): url = "/json/datetime/required" diff --git a/flask_parameter_validation/test/test_query_params.py b/flask_parameter_validation/test/test_query_params.py index 2e09685..877d043 100644 --- a/flask_parameter_validation/test/test_query_params.py +++ b/flask_parameter_validation/test/test_query_params.py @@ -406,6 +406,17 @@ def test_str_alias_async_decorator(client): assert r.json["value"] == "abc" +def test_str_json_schema(client): + url = "/query/str/json_schema" + # Test that input matching schema yields input + r = client.get(url, query_string={"v": "test@example.com"}) + assert "v" in r.json + assert r.json["v"] == "test@example.com" + # Test that input failing schema yields error + r = client.get(url, query_string={"v": "not an email"}) + assert "error" in r.json + + # Int Validation def test_required_int(client): url = "/query/int/required" @@ -524,6 +535,17 @@ def test_int_func(client): assert "error" in r.json +def test_int_json_schema(client): + url = "/query/int/json_schema" + # Test that input matching schema yields input + r = client.get(url, query_string={"v": 10}) + assert "v" in r.json + assert r.json["v"] == 10 + # Test that input failing schema yields error + r = client.get(url, query_string={"v": 100}) + assert "error" in r.json + + # Bool Validation def test_required_bool(client): url = "/query/bool/required" @@ -736,6 +758,16 @@ def test_float_func(client): assert "error" in r.json +def test_float_json_schema(client): + url = "/query/float/json_schema" + # Test that input matching schema yields input + r = client.get(url, query_string={"v": 3.14}) + assert "v" in r.json + assert r.json["v"] == 3.14 + # Test that input failing schema yields error + r = client.get(url, query_string={"v": 3.141592}) + assert "error" in r.json + # datetime Validation def test_required_datetime(client): url = "/query/datetime/required" diff --git a/flask_parameter_validation/test/test_route_params.py b/flask_parameter_validation/test/test_route_params.py index 434534b..09bb051 100644 --- a/flask_parameter_validation/test/test_route_params.py +++ b/flask_parameter_validation/test/test_route_params.py @@ -129,6 +129,17 @@ def test_str_func(client): assert "error" in r.json +def test_str_json_schema(client): + url = "/route/str/json_schema" + # Test that input matching schema yields input + r = client.get(f"{url}/test@example.com") + assert "v" in r.json + assert r.json["v"] == "test@example.com" + # Test that input failing schema yields error + r = client.get(f"{url}/notanemail") + assert "error" in r.json + + # Int Validation def test_required_int(client): url = "/route/int/required" @@ -185,6 +196,17 @@ def test_int_func(client): assert "error" in r.json +def test_int_json_schema(client): + url = "/route/int/json_schema" + # Test that input matching schema yields input + r = client.get(f"{url}/10") + assert "v" in r.json + assert r.json["v"] == 10 + # Test that input failing schema yields error + r = client.get(f"{url}/100") + assert "error" in r.json + + # Bool Validation def test_required_bool(client): url = "/route/bool/required" @@ -241,6 +263,17 @@ def test_float_func(client): assert "error" in r.json +def test_float_json_schema(client): + url = "/route/float/json_schema" + # Test that input matching schema yields input + r = client.get(f"{url}/3.14") + assert "v" in r.json + assert r.json["v"] == 3.14 + # Test that input failing schema yields error + r = client.get(f"{url}/3.141592") + assert "error" in r.json + + # datetime Validation def test_required_datetime(client): url = "/route/datetime/required" diff --git a/flask_parameter_validation/test/testing_blueprints/float_blueprint.py b/flask_parameter_validation/test/testing_blueprints/float_blueprint.py index d823cd8..6601dfd 100644 --- a/flask_parameter_validation/test/testing_blueprints/float_blueprint.py +++ b/flask_parameter_validation/test/testing_blueprints/float_blueprint.py @@ -60,4 +60,9 @@ def is_approx_pi(v): def func(v: float = ParamType(func=is_approx_pi)): return jsonify({"v": v}) + @decorator(path("/json_schema", "/")) + @ValidateParameters() + def json_schema(v: float = ParamType(json_schema={"type": "number", "multipleOf": 0.01})): + return jsonify({"v": v}) + return float_bp diff --git a/flask_parameter_validation/test/testing_blueprints/int_blueprint.py b/flask_parameter_validation/test/testing_blueprints/int_blueprint.py index c48196b..2102666 100644 --- a/flask_parameter_validation/test/testing_blueprints/int_blueprint.py +++ b/flask_parameter_validation/test/testing_blueprints/int_blueprint.py @@ -69,4 +69,9 @@ def is_even(v): def func(v: int = ParamType(func=is_even)): return jsonify({"v": v}) + @decorator(path("/json_schema", "/")) + @ValidateParameters() + def json_schema(v: int = ParamType(json_schema={"type": "number", "exclusiveMaximum": 100})): + return jsonify({"v": v}) + return int_bp diff --git a/flask_parameter_validation/test/testing_blueprints/str_blueprint.py b/flask_parameter_validation/test/testing_blueprints/str_blueprint.py index 1638172..fe39349 100644 --- a/flask_parameter_validation/test/testing_blueprints/str_blueprint.py +++ b/flask_parameter_validation/test/testing_blueprints/str_blueprint.py @@ -257,4 +257,9 @@ async def async_decorator_alias( ): return jsonify({"value": value}) + @decorator(path("/json_schema", "/")) + @ValidateParameters() + def json_schema(v: str = ParamType(json_schema={"type": "string", "format": "email"})): + return jsonify({"v": v}) + return str_bp \ No newline at end of file diff --git a/setup.py b/setup.py index 5ecf388..4ba5e0c 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,7 @@ "flask[async]", "python-dateutil", "jsonschema", + "jsonschema[format]" ], python_requires=">=3.9,<3.13", classifiers=[ From 045f88ab4e6a8ccb1bd5d9d576ff9233a12e4f5d Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Wed, 14 Aug 2024 18:48:11 -0400 Subject: [PATCH 5/9] Remove print statements or replace with warnings --- flask_parameter_validation/docs_blueprint.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index 1e0aae4..7537460 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -10,7 +10,6 @@ docs_blueprint = Blueprint( "docs", __name__, url_prefix="/docs", template_folder="./templates" ) -# TODO: Replace prints with warnings where useful, else remove def get_route_docs(): """ @@ -196,7 +195,6 @@ def generate_json_schema_helper(param, param_type, parent_group=None): 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" @@ -244,7 +242,8 @@ def generate_json_schema_helper(param, param_type, parent_group=None): subschema["type"] = "integer" subschema["enum"] = param["enum_values"] else: - print(f"Unexpected type: {p}") + warnings.warn(f"generate_json_schema_helper received an unexpected parameter type: {p}", + Warning, stacklevel=2) schemas.append(subschema) if len(schemas) == 1 and parent_group is None: return schemas[0] @@ -258,7 +257,8 @@ def generate_json_schema_helper(param, param_type, parent_group=None): schema["maxItems"] = param["loc_args"]["max_list_length"] return schema else: - print(f"Unexpected situation: {param_type}, {parent_group}") + warnings.warn(f"generate_json_schema_helper encountered an unexpected type: {param_type} with parent: " + f"{parent_group}", Warning, stacklevel=2) def generate_json_schema_for_parameter(param): @@ -286,7 +286,6 @@ def generate_openapi_paths_object(): 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 = [] @@ -296,7 +295,6 @@ def generate_openapi_paths_object(): mod_arg = copy.deepcopy(arg) mod_arg["loc_args"].pop("sources") for source in arg["loc_args"]["sources"]: - print(source) source_name = source.__class__.__name__ if source_name in route["args"]: route["args"][source_name].append(mod_arg) @@ -341,7 +339,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 7e92bb2a3ad0f583cfe0358d0fa6d655ba2dd2e9 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Wed, 14 Aug 2024 18:48:41 -0400 Subject: [PATCH 6/9] Update README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1bb7ef0..88cb1ac 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,8 @@ def is_odd(val: int): Using the data provided through parameters, docstrings, and Flask route registrations, Flask Parameter Validation can generate API Documentation in various formats. To make this easy to use, it comes with a `Blueprint` and the output and configuration options below: #### 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. +* `FPV_OPENAPI_ENABLE: bool = False`: Whether to enable OpenAPI Generation for this app, may generate warnings, as certain `Parameter` arguments are not able to be converted to OpenAPI/JSON Schema. +* `FPV_OPENAPI_BASE: dict = {"openapi": None}`: 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. #### Non-standard Format * `FPV_DOCS_SITE_NAME: str`: Your site's name, to be displayed in the page title, default: `Site` From 0f84b96700300a83c00034fc63024d7b4cd26024 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Thu, 15 Aug 2024 10:29:00 -0400 Subject: [PATCH 7/9] Fix bug where documentation was generated based on a function signature ending with a function name, which could be satisfied incorrectly if one function's name was the end of another function name --- 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 7537460..da3ae4b 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -36,7 +36,7 @@ def get_function_docs(func): """ fn_list = ValidateParameters().get_fn_list() for fsig, fdocs in fn_list.items(): - if fsig.endswith(func.__name__): + if fsig.split(".")[-1] == func.__name__: return { "docstring": format_docstring(fdocs.get("docstring")), "decorators": fdocs.get("decorators"), From 43630eae0488b565e17afae63c8f8196c53b7b99 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Thu, 15 Aug 2024 10:29:25 -0400 Subject: [PATCH 8/9] Fix bug where issubclass was used without first checking if the input was a class --- flask_parameter_validation/docs_blueprint.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index da3ae4b..141ccd0 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -1,4 +1,4 @@ -import json +import inspect import warnings from enum import Enum import flask @@ -87,7 +87,8 @@ def get_arg_type_hint(fdocs, arg_name): Extract the type hint for a specific argument. """ arg_type = fdocs["argspec"].annotations[arg_name] - if issubclass(arg_type, Enum) and (issubclass(arg_type, str) or issubclass(arg_type, int)): + if (inspect.isclass(arg_type) and issubclass(arg_type, Enum) and + (issubclass(arg_type, str) or issubclass(arg_type, int))): if issubclass(arg_type, str): return "StrEnum" elif issubclass(arg_type, int): @@ -374,6 +375,6 @@ def docs_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 = copy.deepcopy(openapi_base) openapi_document["paths"] = openapi_paths return jsonify(openapi_document) From 9364edd9db8de08101926f8b2f1fd57ba8ca5806 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Thu, 15 Aug 2024 10:29:45 -0400 Subject: [PATCH 9/9] Add comments, fix formatting --- flask_parameter_validation/docs_blueprint.py | 30 ++++++++++++++------ 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index 141ccd0..eded207 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -11,6 +11,7 @@ "docs", __name__, url_prefix="/docs", template_folder="./templates" ) + def get_route_docs(): """ Generate documentation for all Flask routes that use the ValidateParameters decorator. @@ -75,6 +76,7 @@ def extract_argument_details(fdocs): args_data.setdefault(arg_data["loc"], []).append(arg_data) return args_data + def get_arg_enum_values(fdocs, arg_name): """ Extract the Enum values for a specific argument. @@ -82,6 +84,7 @@ def get_arg_enum_values(fdocs, arg_name): arg_type = fdocs["argspec"].annotations[arg_name] return list(map(lambda e: e.value, arg_type)) + def get_arg_type_hint(fdocs, arg_name): """ Extract the type hint for a specific argument. @@ -171,25 +174,29 @@ def docs_json(): def fpv_error(message): + """ Error response helper for view functions """ return jsonify({"error": message}) def parameter_required(param): + """ Determine if a parameter is required, for OpenAPI Generation """ 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: + """ Helper function for generating JSON Schema for a parameter """ + match = re.match(r'(\w+)\[([\w\[\] ,.]+)]', param_type) # Check for type hints that take arguments (Union[]) + if match: # Break down the type into its parent (Union) and the arguments (int, float) and recurse with those args 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 + elif "|" in param_type and "[" not in param_type: # Convert Union shorthand to Union, recurse with that as input return generate_json_schema_helper(param, f"Union[{param_type.replace('|', ',')}]", parent_group=parent_group) - else: + else: # Input is basic types, generate JSON Schema schemas = [] param_types = [param_type] if parent_group in ["Union", "Optional"]: @@ -263,10 +270,12 @@ def generate_json_schema_helper(param, param_type, parent_group=None): def generate_json_schema_for_parameter(param): + """ Generate JSON Schema for a single parameter """ return generate_json_schema_helper(param, param["type"]) def generate_json_schema_for_parameters(params): + """ Generate JSON Schema for all parameters of a route""" schema = { "type": "object", "properties": {}, @@ -282,13 +291,16 @@ def generate_json_schema_for_parameters(params): schema["required"].append(schema_parameter_name) return schema + def generate_openapi_paths_object(): + """ 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) oapi_path_item = {} - oapi_operation = {} # tags, summary, description, externalDocs, operationId, parameters, requestBody, responses, callbacks, deprecated, security, servers + 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"]: @@ -309,7 +321,8 @@ def generate_openapi_paths_object(): 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 + elif arg_loc == "File": + # 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"]: @@ -358,7 +371,6 @@ def generate_openapi_paths_object(): return oapi_paths - @docs_blueprint.route("/openapi") def docs_openapi(): """ @@ -371,7 +383,9 @@ def docs_openapi(): 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") + return fpv_error( + f"Flask-Parameter-Validation only supports OpenAPI {', '.join(supported_versions)}, " + f"{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()