diff --git a/README.md b/README.md index 9ea804a..88cb1ac 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,9 @@ The `@ValidateParameters()` decorator takes parameters that alter route validati | Parameter | Type | Default | Description | |-------------------|----------------------|---------|------------------------------------------------------------------------------------------------------------------------------| | error_handler | `Optional[Response]` | `None` | Overwrite the output format of generated errors, see [Overwriting Default Errors](#overwriting-default-errors) for more | +| route_deprecated | `bool` | `False` | Marks this Route as deprecated in any generated [API Documentation](#api-documentation) | +| openapi_responses | `Optional[dict]` | `None` | The OpenAPI Responses Object for this route, as a `dict` to be used in any generated [API Documentation](#api-documentation) | +| hide_from_docs | `bool` | `False` | Hide this Route from any generated [API Documentation](#api-documentation) | #### Overwriting Default Errors By default, the error messages are returned as a JSON response, with the detailed error in the "error" field, eg: @@ -87,13 +90,13 @@ def api(...) #### Parameter Class The `Parameter` class provides a base for validation common among all input types, all location-specific classes extend `Parameter`. These subclasses are: -| Subclass Name | Input Source | Available For | -|---------------|------------------------------------------------------------------------------------------------------------------------|------------------| -| Route | Parameter passed in the pathname of the URL, such as `/users/` | All HTTP Methods | -| Form | Parameter in an HTML form or a `FormData` object in the request body, often with `Content-Type: x-www-form-urlencoded` | POST Methods | -| Json | Parameter in the JSON object in the request body, must have header `Content-Type: application/json` | POST Methods | -| Query | Parameter in the query of the URL, such as /news_article?id=55 | All HTTP Methods | -| File | Parameter is a file uploaded in the request body | POST Method | +| Subclass Name | Input Source | Available For | +|---------------|------------------------------------------------------------------------------------------------------------------------|---------------------------------| +| Route | Parameter passed in the pathname of the URL, such as `/users/` | All HTTP Methods | +| Form | Parameter in an HTML form or a `FormData` object in the request body, often with `Content-Type: x-www-form-urlencoded` | POST Methods | +| Json | Parameter in the JSON object in the request body, must have header `Content-Type: application/json` | POST Methods | +| Query | Parameter in the query of the URL, such as /news_article?id=55 | All HTTP Methods | +| File | Parameter is a file uploaded in the request body | POST Method | | MultiSource | Parameter is in one of the locations provided to the constructor | Dependent on selected locations | Note: "**POST Methods**" refers to the HTTP methods that send data in the request body, such as POST, PUT, PATCH and DELETE. Although sending data via some methods such as DELETE is not standard, it is supported by Flask and this library. @@ -142,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` | `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 | +| 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)` @@ -186,8 +190,11 @@ 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_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. -#### 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 @@ -206,6 +213,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 @@ -262,8 +270,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 @@ -276,6 +286,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": { "": [ { @@ -284,7 +298,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)" } ], "": [] diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index 47a8c8f..eded207 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -1,7 +1,11 @@ +import inspect +import warnings +from enum import Enum import flask from flask import Blueprint, current_app, jsonify - from flask_parameter_validation import ValidateParameters +import re +import copy docs_blueprint = Blueprint( "docs", __name__, url_prefix="/docs", template_folder="./templates" @@ -33,11 +37,13 @@ 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"), "args": extract_argument_details(fdocs), + "deprecated": fdocs.get("deprecated"), + "responses": fdocs.get("openapi_responses"), } return None @@ -65,16 +71,32 @@ 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 (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): + return "IntEnum" + elif hasattr(arg_type, "__args__"): return ( f"{arg_type.__name__}[{', '.join([a.__name__ for a in arg_type.__args__])}]" ) @@ -133,11 +155,240 @@ 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"), } ) + + +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): + """ 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: # 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: # Input is basic types, generate JSON Schema + 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: + 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"] + 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"]: + 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" + elif p in ["StrEnum", "IntEnum"]: + if p == "StrEnum": + subschema["type"] = "string" + elif p == "IntEnum": + subschema["type"] = "integer" + subschema["enum"] = param["enum_values"] + else: + 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] + elif parent_group in ["Optional", "Union"]: + return {"oneOf": schemas} + 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"] + if "max_list_length" in param["loc_args"]: + schema["maxItems"] = param["loc_args"]["max_list_length"] + return schema + else: + 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): + """ 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": {}, + "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(): + """ 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_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"]: + 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"] = { + "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": + # 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) + 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: + oapi_operation["requestBody"] = oapi_request_body + 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)}, " + 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() + openapi_document = copy.deepcopy(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 5595a7d..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,18 +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: - 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/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 9f53b8c..08ecd01 100644 --- a/flask_parameter_validation/parameter_validation.py +++ b/flask_parameter_validation/parameter_validation.py @@ -20,8 +20,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): """ @@ -39,6 +41,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 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 %} 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=[