diff --git a/README.md b/README.md index 1b1f74c..b694140 100644 --- a/README.md +++ b/README.md @@ -10,25 +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() @@ -42,6 +53,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() ): @@ -61,9 +73,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 | +| 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: @@ -151,28 +164,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` | `dict` | 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, 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 | +| `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)` @@ -196,7 +209,10 @@ def is_odd(val: int): ### Configuration Options -#### API Documentation Configuration +#### 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 (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 @@ -225,6 +241,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 - 3.2.0 (JSON) Representation of the generated documentation The `/json` route yields a response with the following format: ```json @@ -277,12 +294,179 @@ 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 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, generate_openapi_docs ... -get_route_docs() +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 @@ -295,6 +479,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": { "": [ { @@ -314,28 +502,43 @@ 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 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) + ### 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/docs/usage_example_non_standard_documentation.png b/docs/usage_example_non_standard_documentation.png new file mode 100644 index 0000000..b82a156 Binary files /dev/null and b/docs/usage_example_non_standard_documentation.png differ diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index 8712b29..4f1d0e0 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -1,18 +1,35 @@ +import datetime +import inspect +import json +import uuid +import warnings +from copy import deepcopy 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 - +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): + from typing_extensions import is_typeddict 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" ) -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. @@ -22,7 +39,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] @@ -30,7 +47,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. @@ -41,7 +58,8 @@ 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), + "responses": fdocs.get("openapi_responses"), } return None @@ -57,45 +75,52 @@ 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) -> 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" + 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): @@ -142,6 +167,7 @@ def http_badge_bg(http_method): @docs_blueprint.route("/") +@deprecated("Use /openapi instead") def docs_html(): """ Render the documentation as an HTML page. @@ -157,6 +183,7 @@ def docs_html(): @docs_blueprint.route("/json") +@deprecated("Use /openapi instead") def docs_json(): """ Provide the documentation as a JSON response. @@ -170,3 +197,302 @@ 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[") or re.match("Union\\[.*None.*", param["type"]): + return False + elif "default" in param["loc_args"]: + return False + 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: # Non-enum str, and str-only validation criteria + schema["type"] = "string" + 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: # 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: # bool + schema["type"] = "boolean" + + elif raw_type is float: # float + schema["type"] = "number" + + 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: # date + schema["type"] = "string" + schema["format"] = "date" + + elif raw_type is datetime.time: # time + schema["type"] = "string" + schema["format"] = "time" + + elif raw_type is type(None): # None + schema["type"] = "null" + + elif is_typeddict(raw_type): # TypedDict + schema["type"] = "object" + schema["properties"] = {} + required_properties = [] + source = "" + try: # Get source to read comments from for description key on members + source = inspect.getsource(raw_type) + except OSError: # Sometimes we can't get the source? + pass + 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) + 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: # Plain non-generic dict + schema["type"] = "object" + + 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) # Get source to read comments from for description key on members + options = [] + 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: + 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: # UUID + schema["type"] = "string" + schema["format"] = "uuid" + + 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"]: # Lists, and list-only validation criteria + schema["type"] = "array" + available_types = [] + 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: + schema["items"] = available_types[0] + else: + schema["items"] = {"oneOf": available_types} + 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"]: # Unions + available_types = [] + for subtype in raw_type.__args__: + 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"]: # 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": # 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, ""]): # 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: # 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 + + +def generate_json_schema_for_parameter(param): + return generate_json_schema_helper(param, param["type"], param["raw_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) + if len(schema["required"]) == 0: + del schema["required"] + return schema + +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']) # 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"]: # 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() + 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"]: # 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])} # 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])} # 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"]: # 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"] # 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", + "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), + } + oapi_parameters.append(parameter) + 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: # Only include the requestBoyd object if we have one + oapi_operation["requestBody"] = oapi_request_body + 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"]: # 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 + 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: # Create new path item for this route + oapi_paths[oapi_path_route] = oapi_path_item + return oapi_paths + + +def generate_openapi_docs(): + """ + Generate the documentation in OpenAPI format + """ + config = flask.current_app.config + if not config.get("FPV_OPENAPI_ENABLE", False): + raise ConfigurationError("FPV_OPENAPI_ENABLE is not set, and defaults to False") + + 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)}, 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 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/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_validation.py b/flask_parameter_validation/parameter_validation.py index a0b65fb..c06fa1d 100644 --- a/flask_parameter_validation/parameter_validation.py +++ b/flask_parameter_validation/parameter_validation.py @@ -35,8 +35,9 @@ class ValidateParameters: def get_fn_list(cls): return fn_list - def __init__(self, error_handler=None): + def __init__(self, error_handler=None, openapi_responses=None): self.custom_error_handler = error_handler + self.openapi_responses = openapi_responses def __call__(self, f): """ @@ -59,6 +60,7 @@ def __call__(self, f): "argspec": argspec, "docstring": f.__doc__.strip() if f.__doc__ else None, "decorators": decorators.copy(), + "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..b4fd821 100644 --- a/flask_parameter_validation/test/test_api_docs.py +++ b/flask_parameter_validation/test/test_api_docs.py @@ -1,5 +1,10 @@ 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 +from flask_parameter_validation.test.testing_blueprints.int_blueprint import _fpv_test_openapi_responses_object + def test_http_ok(client): r = client.get("/docs/") @@ -35,7 +40,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 +61,561 @@ 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) + +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": { + "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", 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"] + +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 + +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_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")) 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/int_blueprint.py b/flask_parameter_validation/test/testing_blueprints/int_blueprint.py index c48196b..10a7786 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}) @@ -67,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 diff --git a/flask_parameter_validation/test/testing_blueprints/str_blueprint.py b/flask_parameter_validation/test/testing_blueprints/str_blueprint.py index 53c8bdc..c937fd0 100644 --- a/flask_parameter_validation/test/testing_blueprints/str_blueprint.py +++ b/flask_parameter_validation/test/testing_blueprints/str_blueprint.py @@ -1,3 +1,8 @@ +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 @@ -278,4 +283,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")