mirror of https://github.com/zulip/zulip.git
api: Add new typed_endpoint decorators.
The goal of typed_endpoint is to replicate most features supported by has_request_variables, and to improve on top of it. There are some unresolved issues that we don't plan to work on currently. For example, typed_endpoint does not support ignored_parameters_supported for 400 responses, and it does not run validators on path-only arguments. Unlike has_request_variables, typed_endpoint supports error handling by processing validation errors from Pydantic. Most features supported by has_request_variables are supported by typed_endpoint in various ways. To define a function, use a syntax like this with Annotated if there is any metadata you want to associate with a parameter, do note that parameters that are not keyword-only are ignored from the request: ``` @typed_endpoint def view( request: HttpRequest, user_profile: UserProfile, *, foo: Annotated[int, ApiParamConfig(path_only=True)], bar: Json[int], other: Annotated[ Json[int], ApiParamConfig( whence="lorem", documentation_status=NTENTIONALLY_UNDOCUMENTED ) ] = 10, ) -> HttpResponse: .... ``` There are also some shorthands for the commonly used annotated types, which are encouraged when applicable for better readability and less typing: ``` WebhookPayload = Annotated[Json[T], ApiParamConfig(argument_type_is_body=True)] PathOnly = Annotated[T, ApiParamConfig(path_only=True)] ``` Then the view function above can be rewritten as: ``` @typed_endpoint def view( request: HttpRequest, user_profile: UserProfile, *, foo: PathOnly[int], bar: Json[int], other: Annotated[ Json[int], ApiParamConfig( whence="lorem", documentation_status=INTENTIONALLY_UNDOCUMENTED ) ] = 10, ) -> HttpResponse: .... ``` There are some intentional restrictions: - A single parameter cannot have more than one ApiParamConfig - Path-only parameters cannot have default values - argument_type_is_body is incompatible with whence - Arguments of name "request", "user_profile", "args", and "kwargs" and etc. are ignored by typed_endpoint. - positional-only arguments are not supported by typed_endpoint. Only keyword-only parameters are expected to be parsed from the request. - Pydantic's strict mode is always enabled, because we don't want to coerce input parsed from JSON into other types unnecessarily. - Using strict mode all the time also means that we should always use Json[int] instead of int, because it is only possible for the request to have data of type str, and a type annotation of int will always reject such data. typed_endpoint's handling of ignored_parameters_unsupported is mostly identical to that of has_request_variables.
This commit is contained in:
parent
7da4e73d57
commit
f4caf9dd79
|
@ -23,6 +23,7 @@ FILES_WITH_LEGACY_SUBJECT = {
|
|||
"zerver/lib/email_mirror.py",
|
||||
"zerver/lib/email_notifications.py",
|
||||
"zerver/lib/send_email.py",
|
||||
"zerver/lib/typed_endpoint.py",
|
||||
"zerver/tests/test_new_users.py",
|
||||
"zerver/tests/test_email_mirror.py",
|
||||
"zerver/tests/test_message_notification_emails.py",
|
||||
|
|
|
@ -169,3 +169,20 @@ rules:
|
|||
message: 'A batched migration should not be atomic. Add "atomic = False" to the Migration class'
|
||||
languages: [python]
|
||||
severity: ERROR
|
||||
|
||||
- id: typed_endpoint_without_keyword_only_param
|
||||
patterns:
|
||||
- pattern: |
|
||||
@typed_endpoint
|
||||
def $F(...)-> ...:
|
||||
...
|
||||
- pattern-not-inside: |
|
||||
@typed_endpoint
|
||||
def $F(..., *, ...)-> ...:
|
||||
...
|
||||
message: |
|
||||
@typed_endpoint should not be used without keyword-only parameters.
|
||||
Make parameters to be parsed from the request as keyword-only,
|
||||
or use @typed_endpoint_without_parameters instead.
|
||||
languages: [python]
|
||||
severity: ERROR
|
||||
|
|
|
@ -527,3 +527,9 @@ class ReactionDoesNotExistError(JsonableError):
|
|||
@staticmethod
|
||||
def msg_format() -> str:
|
||||
return _("Reaction doesn't exist.")
|
||||
|
||||
|
||||
class ApiParamValidationError(JsonableError):
|
||||
def __init__(self, msg: str, error_type: str) -> None:
|
||||
super().__init__(msg)
|
||||
self.error_type = error_type
|
||||
|
|
|
@ -0,0 +1,511 @@
|
|||
import inspect
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
from functools import wraps
|
||||
from typing import Callable, Generic, List, Optional, Sequence, Tuple, Type, TypeVar, Union
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext as _
|
||||
from pydantic import Json, StringConstraints, TypeAdapter, ValidationError
|
||||
from typing_extensions import (
|
||||
Annotated,
|
||||
Concatenate,
|
||||
ParamSpec,
|
||||
TypeAlias,
|
||||
get_args,
|
||||
get_origin,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
from zerver.lib.exceptions import ApiParamValidationError, JsonableError
|
||||
from zerver.lib.request import (
|
||||
_REQ,
|
||||
RequestConfusingParamsError,
|
||||
RequestNotes,
|
||||
RequestVariableMissingError,
|
||||
arguments_map,
|
||||
)
|
||||
from zerver.lib.response import MutableJsonResponse
|
||||
|
||||
T = TypeVar("T")
|
||||
ParamT = ParamSpec("ParamT")
|
||||
ReturnT = TypeVar("ReturnT")
|
||||
|
||||
|
||||
class DocumentationStatus(Enum):
|
||||
DOCUMENTED = auto()
|
||||
INTENTIONALLY_UNDOCUMENTED = auto()
|
||||
DOCUMENTATION_PENDING = auto()
|
||||
|
||||
|
||||
DOCUMENTED = DocumentationStatus.DOCUMENTED
|
||||
INTENTIONALLY_UNDOCUMENTED = DocumentationStatus.INTENTIONALLY_UNDOCUMENTED
|
||||
DOCUMENTATION_PENDING = DocumentationStatus.DOCUMENTATION_PENDING
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiParamConfig:
|
||||
"""The metadata associated with a view function parameter as an annotation
|
||||
to configure how the typed_endpoint decorator should process it.
|
||||
|
||||
It should be used with Annotated as the type annotation of a parameter
|
||||
in a @typed_endpoint-decorated function:
|
||||
```
|
||||
@typed_endpoint
|
||||
def view(
|
||||
request: HttpRequest,
|
||||
*,
|
||||
flag_value: Annotated[Json[bool], ApiParamConfig(
|
||||
whence="flag",
|
||||
documentation_status=INTENTIONALLY_UNDOCUMENTED,
|
||||
)]
|
||||
) -> HttpResponse:
|
||||
...
|
||||
```
|
||||
|
||||
For a parameter that is not annotated with ApiParamConfig, typed_endpoint
|
||||
will construct a configuration using the defaults.
|
||||
|
||||
whence:
|
||||
The name of the request variable that should be used for this parameter.
|
||||
If None, it is set to the name of the function parameter.
|
||||
|
||||
path_only:
|
||||
Used for parameters included in the URL.
|
||||
|
||||
argument_type_is_body:
|
||||
When set to true, the value of the parameter will be extracted from the
|
||||
request body instead of a single query parameter.
|
||||
|
||||
documentation_status:
|
||||
The OpenAPI documentation status of this parameter. Unless it is set to
|
||||
INTENTIONALLY_UNDOCUMENTED or DOCUMENTATION_PENDING, the test suite is
|
||||
configured to raise an error when its documentation cannot be found.
|
||||
|
||||
aliases:
|
||||
The names allowed for the request variable other than that specified with
|
||||
"whence".
|
||||
"""
|
||||
|
||||
whence: Optional[str] = None
|
||||
path_only: bool = False
|
||||
argument_type_is_body: bool = False
|
||||
documentation_status: DocumentationStatus = DOCUMENTED
|
||||
aliases: Tuple[str, ...] = ()
|
||||
|
||||
|
||||
# TypeAliases for common Annotated types
|
||||
|
||||
# Commonly used for webhook views, where the payload has a content type of
|
||||
# application/json. It reads the data from request body and parse it from JSON.
|
||||
WebhookPayload: TypeAlias = Annotated[Json[T], ApiParamConfig(argument_type_is_body=True)]
|
||||
# A shorthand to declare path only variables that should not be parsed from the
|
||||
# request by the @typed_endpoint decorator.
|
||||
PathOnly: TypeAlias = Annotated[T, ApiParamConfig(path_only=True)]
|
||||
|
||||
# Reusable annotation metadata for Annotated types
|
||||
|
||||
# This disallows strings of length 0 after stripping.
|
||||
# Example usage: Annotated[T, StringRequiredConstraint()]
|
||||
RequiredStringConstraint = lambda: StringConstraints(strip_whitespace=True, min_length=1)
|
||||
|
||||
# Implementation
|
||||
|
||||
|
||||
class _NotSpecified:
|
||||
pass
|
||||
|
||||
|
||||
NotSpecified = _NotSpecified()
|
||||
|
||||
|
||||
# For performance reasons, attributes needed from ApiParamConfig are copied to
|
||||
# FuncParam. We should use slotted dataclass once the entire codebase is
|
||||
# switched to Python 3.10+
|
||||
@dataclass(frozen=True)
|
||||
class FuncParam(Generic[T]):
|
||||
# Default value of the parameter.
|
||||
default: Union[T, _NotSpecified]
|
||||
# Name of the function parameter as defined in the original function.
|
||||
param_name: str
|
||||
# Inspected the underlying type of the parameter by unwrapping the Annotated
|
||||
# type if there is one.
|
||||
param_type: Type[T]
|
||||
# The Pydantic TypeAdapter used to parse arbitrary input to the desired type.
|
||||
# We store it on the FuncParam object as soon as the view function is
|
||||
# decorated because it is expensive to construct.
|
||||
# See also: https://docs.pydantic.dev/latest/usage/type_adapter/
|
||||
type_adapter: TypeAdapter[T]
|
||||
|
||||
# The following group of attributes are computed from the ApiParamConfig
|
||||
# annotation associated with this param:
|
||||
# Name of the corresponding variable in the request data to look
|
||||
# for. When argument_type_is_body is True, this is set to "request".
|
||||
aliases: Tuple[str, ...]
|
||||
argument_type_is_body: bool
|
||||
documentation_status: DocumentationStatus
|
||||
path_only: bool
|
||||
request_var_name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ViewFuncInfo:
|
||||
view_func_full_name: str
|
||||
parameters: Sequence[FuncParam[object]]
|
||||
|
||||
|
||||
def is_annotated(type_annotation: Type[object]) -> bool:
|
||||
origin = get_origin(type_annotation)
|
||||
return origin is Annotated
|
||||
|
||||
|
||||
def parse_single_parameter(
|
||||
param_name: str, param_type: Type[T], parameter: inspect.Parameter
|
||||
) -> FuncParam[T]:
|
||||
param_default = parameter.default
|
||||
# inspect._empty is the internal type used by inspect to indicate not
|
||||
# specified defaults.
|
||||
if param_default is inspect._empty:
|
||||
param_default = NotSpecified
|
||||
|
||||
# Defaulting a value to None automatically wraps the type annotation with
|
||||
# Optional. We explicitly unwrap it for the case of Annotated, which
|
||||
# otherwise causes undesired behaviors that the annotated metadata gets
|
||||
# lost. This is fixed in Python 3.11:
|
||||
# https://github.com/python/cpython/issues/90353
|
||||
if param_default is None:
|
||||
origin = get_origin(param_type)
|
||||
type_args = get_args(param_type)
|
||||
if origin is Union and type(None) in type_args and len(type_args) == 2:
|
||||
inner_type = type_args[0] if type_args[1] is type(None) else type_args[1]
|
||||
if is_annotated(inner_type):
|
||||
param_type = inner_type
|
||||
|
||||
param_config: Optional[ApiParamConfig] = None
|
||||
if is_annotated(param_type):
|
||||
# The first type is the underlying type of the parameter, the rest are
|
||||
# metadata attached to Annotated. Note that we do not transform
|
||||
# param_type to its underlying type because the Annotated metadata might
|
||||
# still be needed by other parties like Pydantic.
|
||||
_, *annotations = get_args(param_type)
|
||||
for annotation in annotations:
|
||||
if not isinstance(annotation, ApiParamConfig):
|
||||
continue
|
||||
assert param_config is None, "ApiParamConfig can only be defined once per parameter"
|
||||
param_config = annotation
|
||||
# If param_config is still None at this point, we could not find an instance
|
||||
# of it in the type annotation of the function parameter. In this case, we
|
||||
# fallback to the defaults by constructing ApiParamConfig here.
|
||||
# This is common for simple parameters of type str, Json[int] and etc.
|
||||
if param_config is None:
|
||||
param_config = ApiParamConfig()
|
||||
|
||||
# Metadata defines a validator making sure that argument_type_is_body is
|
||||
# incompatible with whence.
|
||||
if param_config.argument_type_is_body:
|
||||
request_var_name = "request"
|
||||
else:
|
||||
request_var_name = param_config.whence if param_config.whence is not None else param_name
|
||||
|
||||
return FuncParam(
|
||||
default=param_default,
|
||||
param_name=param_name,
|
||||
param_type=param_type,
|
||||
type_adapter=TypeAdapter(param_type),
|
||||
aliases=param_config.aliases,
|
||||
argument_type_is_body=param_config.argument_type_is_body,
|
||||
documentation_status=param_config.documentation_status,
|
||||
path_only=param_config.path_only,
|
||||
request_var_name=request_var_name,
|
||||
)
|
||||
|
||||
|
||||
def parse_view_func_signature(
|
||||
view_func: Callable[Concatenate[HttpRequest, ParamT], object]
|
||||
) -> ViewFuncInfo:
|
||||
"""This is responsible for inspecting the function signature and getting the
|
||||
metadata from the parameters. We want to keep this function as pure as
|
||||
possible not leaking side effects to the global state. Side effects should
|
||||
be executed separately after the ViewFuncInfo is returned.
|
||||
"""
|
||||
type_hints = get_type_hints(view_func, include_extras=True)
|
||||
parameters = inspect.signature(view_func).parameters
|
||||
view_func_full_name = f"{view_func.__module__}.{view_func.__name__}"
|
||||
|
||||
process_parameters: List[FuncParam[object]] = []
|
||||
|
||||
for param_name, parameter in parameters.items():
|
||||
assert param_name in type_hints
|
||||
if parameter.kind != inspect.Parameter.KEYWORD_ONLY:
|
||||
continue
|
||||
param_info = parse_single_parameter(
|
||||
param_name=param_name, param_type=type_hints[param_name], parameter=parameter
|
||||
)
|
||||
process_parameters.append(param_info)
|
||||
|
||||
return ViewFuncInfo(
|
||||
view_func_full_name=view_func_full_name,
|
||||
parameters=process_parameters,
|
||||
)
|
||||
|
||||
|
||||
# TODO: To get coverage data, we should switch to match-case syntax when we
|
||||
# upgrade to Python 3.10.
|
||||
# This should be sorted alphabetically.
|
||||
ERROR_TEMPLATES = {
|
||||
"bool_parsing": _("{var_name} is not a boolean"),
|
||||
"bool_type": _("{var_name} is not a boolean"),
|
||||
"datetime_parsing": _("{var_name} is not a date"),
|
||||
"datetime_type": _("{var_name} is not a date"),
|
||||
"dict_type": _("{var_name} is not a dict"),
|
||||
"extra_forbidden": _('Argument "{argument}" at {var_name} is unexpected'),
|
||||
"float_parsing": _("{var_name} is not a float"),
|
||||
"float_type": _("{var_name} is not a float"),
|
||||
"greater_than": _("{var_name} is too small"),
|
||||
"int_parsing": _("{var_name} is not an integer"),
|
||||
"int_type": _("{var_name} is not an integer"),
|
||||
"json_invalid": _("{var_name} is not valid JSON"),
|
||||
"json_type": _("{var_name} is not valid JSON"),
|
||||
"less_than": _("{var_name} is too large"),
|
||||
"list_type": _("{var_name} is not a list"),
|
||||
"literal_error": _("Invalid {var_name}"),
|
||||
"string_too_long": _("{var_name} is too long (limit: {max_length} characters)"),
|
||||
"string_too_short": _("{var_name} is too short."),
|
||||
"string_type": _("{var_name} is not a string"),
|
||||
"unexpected_keyword_argument": _('Argument "{argument}" at {var_name} is unexpected'),
|
||||
}
|
||||
|
||||
|
||||
def parse_value_for_parameter(parameter: FuncParam[T], value: object) -> T:
|
||||
try:
|
||||
return parameter.type_adapter.validate_python(value, strict=True)
|
||||
except ValidationError as exc:
|
||||
# If the validation fails, it is possible to get multiple errors from
|
||||
# Pydantic. We only send the first error back to the client.
|
||||
# See also on ValidationError:
|
||||
# https://docs.pydantic.dev/latest/errors/validation_errors/
|
||||
error = exc.errors()[0]
|
||||
# We require all Pydantic raised error types that we expect to be
|
||||
# explicitly handled here. The end result should either be a 400
|
||||
# error with an translated message or an internal server error.
|
||||
error_template = ERROR_TEMPLATES.get(error["type"])
|
||||
var_name = parameter.request_var_name + "".join(
|
||||
f"[{json.dumps(loc)}]" for loc in error["loc"]
|
||||
)
|
||||
context = {
|
||||
"var_name": var_name,
|
||||
**error.get("ctx", {}),
|
||||
}
|
||||
|
||||
if error["type"] == "json_invalid" and parameter.argument_type_is_body:
|
||||
# argument_type_is_body is usually used by webhooks that do not
|
||||
# require a specific var_name for payload JSON decoding error.
|
||||
# We override it here.
|
||||
error_template = _("Malformed JSON")
|
||||
elif error["type"] in ("unexpected_keyword_argument", "extra_forbidden"):
|
||||
context["argument"] = error["loc"][-1]
|
||||
# This condition matches our StringRequiredConstraint
|
||||
elif error["type"] == "string_too_short" and error["ctx"].get("min_length") == 1:
|
||||
error_template = _("{var_name} cannot be blank")
|
||||
|
||||
assert error_template is not None, MISSING_ERROR_TEMPLATE.format(
|
||||
error_type=error["type"],
|
||||
url=error.get("url", "(documentation unavailable)"),
|
||||
error=json.dumps(error, indent=4),
|
||||
)
|
||||
raise ApiParamValidationError(error_template.format(**context), error["type"])
|
||||
|
||||
|
||||
MISSING_ERROR_TEMPLATE = f"""
|
||||
Pydantic validation error of type "{{error_type}}" does not have the
|
||||
corresponding error message template or is not handled explicitly. We expect
|
||||
that every validation error is formatted into a client-facing error message.
|
||||
Consider adding this type to {__package__}.ERROR_TEMPLATES with the appropriate
|
||||
internationalized error message or handle it in {__package__}.{parse_value_for_parameter.__name__}.
|
||||
|
||||
Documentation for "{{error_type}}" can be found at {{url}}.
|
||||
|
||||
Error information:
|
||||
{{error}}
|
||||
"""
|
||||
|
||||
|
||||
UNEXPECTEDLY_MISSING_KEYWORD_ONLY_PARAMETERS = """
|
||||
Parameters expected to be parsed from the request should be defined as
|
||||
keyword-only parameters, but there is no keyword-only parameter found in
|
||||
{view_func_name}.
|
||||
|
||||
Example usage:
|
||||
|
||||
```
|
||||
@typed_endpoint
|
||||
def view(
|
||||
request: HttpRequest,
|
||||
*,
|
||||
flag_value: Annotated[Json[bool], ApiParamConfig(
|
||||
whence="flag", documentation_status=INTENTIONALLY_UNDOCUMENTED,
|
||||
)]
|
||||
) -> HttpResponse:
|
||||
...
|
||||
```
|
||||
|
||||
This is likely a programming error. See https://peps.python.org/pep-3102/ for details on how
|
||||
to correctly declare your parameters as keyword-only parameters.
|
||||
Endpoints that do not accept parameters should use @typed_endpoint_without_parameters.
|
||||
"""
|
||||
|
||||
UNEXPECTED_KEYWORD_ONLY_PARAMETERS = """
|
||||
Unexpected keyword-only parameters found in {view_func_name}.
|
||||
keyword-only parameters are treated as parameters to be parsed from the request,
|
||||
but @typed_endpoint_without_parameters does not expect any.
|
||||
|
||||
Use @typed_endpoint instead.
|
||||
"""
|
||||
|
||||
|
||||
def typed_endpoint_without_parameters(
|
||||
view_func: Callable[Concatenate[HttpRequest, ParamT], ReturnT],
|
||||
) -> Callable[Concatenate[HttpRequest, ParamT], ReturnT]:
|
||||
return typed_endpoint(view_func, expect_no_parameters=True)
|
||||
|
||||
|
||||
def typed_endpoint(
|
||||
view_func: Callable[Concatenate[HttpRequest, ParamT], ReturnT],
|
||||
*,
|
||||
expect_no_parameters: bool = False,
|
||||
) -> Callable[Concatenate[HttpRequest, ParamT], ReturnT]:
|
||||
# Extract all the type information from the view function.
|
||||
endpoint_info = parse_view_func_signature(view_func)
|
||||
if expect_no_parameters:
|
||||
assert len(endpoint_info.parameters) == 0, UNEXPECTED_KEYWORD_ONLY_PARAMETERS.format(
|
||||
view_func_name=endpoint_info.view_func_full_name
|
||||
)
|
||||
else:
|
||||
assert (
|
||||
len(endpoint_info.parameters) != 0
|
||||
), UNEXPECTEDLY_MISSING_KEYWORD_ONLY_PARAMETERS.format(
|
||||
view_func_name=endpoint_info.view_func_full_name
|
||||
)
|
||||
for func_param in endpoint_info.parameters:
|
||||
assert not isinstance(
|
||||
func_param.default, _REQ
|
||||
), f"Unexpected REQ for parameter {func_param.param_name}; REQ is incompatible with typed_endpoint"
|
||||
if func_param.path_only:
|
||||
assert (
|
||||
func_param.default is NotSpecified
|
||||
), f"Path-only parameter {func_param.param_name} should not have a default value"
|
||||
# Record arguments that should be documented so that our
|
||||
# automated OpenAPI docs tests can compare these against the code.
|
||||
if (
|
||||
func_param.documentation_status is DocumentationStatus.DOCUMENTED
|
||||
and not func_param.path_only
|
||||
):
|
||||
# TODO: Move arguments_map to here once zerver.lib.request does not
|
||||
# need it anymore.
|
||||
arguments_map[endpoint_info.view_func_full_name].append(func_param.request_var_name)
|
||||
|
||||
@wraps(view_func)
|
||||
def _wrapped_view_func(
|
||||
request: HttpRequest, /, *args: ParamT.args, **kwargs: ParamT.kwargs
|
||||
) -> ReturnT:
|
||||
request_notes = RequestNotes.get_notes(request)
|
||||
for parameter in endpoint_info.parameters:
|
||||
if parameter.path_only:
|
||||
# For path_only parameters, they should already have been passed via
|
||||
# the URL, so there's no need for us to do anything.
|
||||
#
|
||||
# TODO: Run validators for path_only parameters for NewType.
|
||||
assert (
|
||||
parameter.param_name in kwargs
|
||||
), f"Path-only variable {parameter.param_name} should be passed already"
|
||||
if parameter.param_name in kwargs:
|
||||
# Skip parameters that are already supplied by the caller.
|
||||
continue
|
||||
|
||||
# Extract the value to parse from the request body if specified.
|
||||
if parameter.argument_type_is_body:
|
||||
try:
|
||||
request_notes.processed_parameters.add(parameter.request_var_name)
|
||||
kwargs[parameter.param_name] = parse_value_for_parameter(
|
||||
parameter, request.body.decode(request.encoding or "utf-8")
|
||||
)
|
||||
except UnicodeDecodeError:
|
||||
raise JsonableError(_("Malformed payload"))
|
||||
# test_typed_endpoint.TestEndpoint.test_argument_type has
|
||||
# coverage of this, but coverage.py fails to recognize it for
|
||||
# some reason.
|
||||
continue # nocoverage
|
||||
|
||||
# Otherwise, try to find the matching request variable in one of the QueryDicts
|
||||
# This is a view bug, not a user error, and thus should throw a 500.
|
||||
possible_aliases = [parameter.request_var_name, *parameter.aliases]
|
||||
alias_used = None
|
||||
value_to_parse = None
|
||||
|
||||
for current_alias in possible_aliases:
|
||||
if current_alias in request.POST:
|
||||
value_to_parse = request.POST[current_alias]
|
||||
elif current_alias in request.GET:
|
||||
value_to_parse = request.GET[current_alias]
|
||||
else:
|
||||
# This is covered by
|
||||
# test_typed_endpoint.TestEndpoint.test_aliases, but
|
||||
# coverage.py fails to recognize this for some reason.
|
||||
continue # nocoverage
|
||||
if alias_used is not None:
|
||||
raise RequestConfusingParamsError(alias_used, current_alias)
|
||||
alias_used = current_alias
|
||||
|
||||
if alias_used is None:
|
||||
alias_used = parameter.request_var_name
|
||||
if parameter.default is NotSpecified:
|
||||
raise RequestVariableMissingError(alias_used)
|
||||
# By skipping here, we leave it to Python to use the default value
|
||||
# of this parameter, because we cannot find the request variable in
|
||||
# the request.
|
||||
# This is tested test_typed_endpoint.TestEndpoint.test_json, but
|
||||
# coverage.py fails to recognize this for some reason.
|
||||
continue # nocoverage
|
||||
|
||||
# Note that value_to_parse comes from a QueryDict, so it has no chance
|
||||
# of having a user-provided None value.
|
||||
assert value_to_parse is not None
|
||||
request_notes.processed_parameters.add(alias_used)
|
||||
kwargs[parameter.param_name] = parse_value_for_parameter(parameter, value_to_parse)
|
||||
return_value = view_func(request, *args, **kwargs)
|
||||
|
||||
if (
|
||||
isinstance(return_value, MutableJsonResponse)
|
||||
# TODO: Move is_webhook_view to the decorator
|
||||
and not request_notes.is_webhook_view
|
||||
# Implemented only for 200 responses.
|
||||
# TODO: Implement returning unsupported ignored parameters for 400
|
||||
# JSON error responses. This is complex because typed_endpoint can be
|
||||
# called multiple times, so when an error response is raised, there
|
||||
# may be supported parameters that have not yet been processed,
|
||||
# which could lead to inaccurate output.
|
||||
and 200 <= return_value.status_code < 300
|
||||
):
|
||||
ignored_parameters = set(
|
||||
list(request.POST.keys()) + list(request.GET.keys())
|
||||
).difference(request_notes.processed_parameters)
|
||||
|
||||
# This will be called each time a function decorated with @typed_endpoint
|
||||
# returns a MutableJsonResponse with a success status_code. Because
|
||||
# a shared processed_parameters value is checked each time, the
|
||||
# value for the ignored_parameters_unsupported key is either
|
||||
# added/updated to the response data or it is removed in the case
|
||||
# that all of the request parameters have been processed.
|
||||
if ignored_parameters:
|
||||
return_value.get_data()["ignored_parameters_unsupported"] = sorted(
|
||||
ignored_parameters
|
||||
)
|
||||
else:
|
||||
return_value.get_data().pop("ignored_parameters_unsupported", None)
|
||||
|
||||
return return_value
|
||||
|
||||
# TODO: Remove this once we replace has_request_variables with typed_endpoint.
|
||||
_wrapped_view_func.use_endpoint = True # type: ignore[attr-defined] # Distinguish functions decorated with @typed_endpoint from those decorated with has_request_variables
|
||||
return _wrapped_view_func
|
|
@ -54,6 +54,8 @@ import orjson
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import URLValidator, validate_email
|
||||
from django.utils.translation import gettext as _
|
||||
from pydantic import ValidationInfo, model_validator
|
||||
from pydantic.functional_validators import ModelWrapValidatorHandler
|
||||
|
||||
from zerver.lib.exceptions import InvalidJSONError, JsonableError
|
||||
from zerver.lib.timezone import canonicalize_timezone
|
||||
|
@ -632,6 +634,17 @@ class WildValue:
|
|||
var_name: str
|
||||
value: object
|
||||
|
||||
@model_validator(mode="wrap") # type: ignore[arg-type] # The upstream's type annotation uses a TypeVar that is incorrectly unbounded.
|
||||
@classmethod
|
||||
def to_wild_value(
|
||||
cls,
|
||||
value: object,
|
||||
# We bypass the original WildValue handler to customize it
|
||||
handler: ModelWrapValidatorHandler["WildValue"],
|
||||
info: ValidationInfo,
|
||||
) -> "WildValue":
|
||||
return wrap_wild_value("request", value)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.value)
|
||||
|
||||
|
|
|
@ -0,0 +1,582 @@
|
|||
from typing import Any, Callable, Dict, List, Literal, Optional, TypeVar, Union
|
||||
|
||||
import orjson
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from pydantic import BaseModel, ConfigDict, Json, ValidationInfo, WrapValidator
|
||||
from pydantic.dataclasses import dataclass
|
||||
from pydantic.functional_validators import ModelWrapValidatorHandler
|
||||
from typing_extensions import Annotated
|
||||
|
||||
from zerver.lib.exceptions import ApiParamValidationError, JsonableError
|
||||
from zerver.lib.request import RequestConfusingParamsError, RequestVariableMissingError
|
||||
from zerver.lib.response import MutableJsonResponse, json_success
|
||||
from zerver.lib.test_classes import ZulipTestCase
|
||||
from zerver.lib.test_helpers import HostRequestMock
|
||||
from zerver.lib.typed_endpoint import (
|
||||
ApiParamConfig,
|
||||
DocumentationStatus,
|
||||
PathOnly,
|
||||
RequiredStringConstraint,
|
||||
WebhookPayload,
|
||||
typed_endpoint,
|
||||
typed_endpoint_without_parameters,
|
||||
)
|
||||
from zerver.lib.validator import WildValue, check_bool
|
||||
from zerver.models import UserProfile
|
||||
|
||||
ParamTypes = Literal["none", "json_only", "both"]
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def call_endpoint(
|
||||
view: Callable[..., T], request: HttpRequest, *args: object, **kwargs: object
|
||||
) -> T:
|
||||
"""A helper to let us ignore the view function's signature"""
|
||||
return view(request, *args, **kwargs)
|
||||
|
||||
|
||||
class TestEndpoint(ZulipTestCase):
|
||||
def test_coerce(self) -> None:
|
||||
@typed_endpoint
|
||||
def view(request: HttpRequest, *, strict_int: int) -> None:
|
||||
...
|
||||
|
||||
with self.assertRaisesMessage(JsonableError, "strict_int is not an integer"):
|
||||
call_endpoint(view, HostRequestMock({"strict_int": orjson.dumps("10").decode()}))
|
||||
with self.assertRaisesMessage(JsonableError, "strict_int is not an integer"):
|
||||
self.assertEqual(call_endpoint(view, HostRequestMock({"strict_int": 10})), 20)
|
||||
|
||||
@typed_endpoint
|
||||
def view2(request: HttpRequest, *, strict_int: Json[int]) -> int:
|
||||
return strict_int * 2
|
||||
|
||||
with self.assertRaisesMessage(JsonableError, "strict_int is not an integer"):
|
||||
call_endpoint(view2, HostRequestMock({"strict_int": orjson.dumps("10").decode()}))
|
||||
# This is the same as orjson.dumps(10).decode()
|
||||
self.assertEqual(call_endpoint(view2, HostRequestMock({"strict_int": "10"})), 20)
|
||||
self.assertEqual(call_endpoint(view2, HostRequestMock({"strict_int": 10})), 20)
|
||||
|
||||
def test_json(self) -> None:
|
||||
@dataclass(frozen=True)
|
||||
class Foo:
|
||||
num1: int
|
||||
num2: int
|
||||
|
||||
__pydantic_config__ = ConfigDict(extra="forbid")
|
||||
|
||||
@typed_endpoint
|
||||
def view(
|
||||
request: HttpRequest,
|
||||
*,
|
||||
json_int: Json[int],
|
||||
json_str: Json[str],
|
||||
json_data: Json[Foo],
|
||||
json_optional: Optional[Json[Union[int, None]]] = None,
|
||||
json_default: Json[Foo] = Foo(10, 10),
|
||||
non_json: str = "ok",
|
||||
non_json_optional: Optional[str] = None,
|
||||
) -> HttpResponse:
|
||||
return MutableJsonResponse(
|
||||
data={
|
||||
"result1": json_int * json_data.num1 * json_data.num2,
|
||||
"result2": json_default.num1 * json_default.num2,
|
||||
"optional": json_optional,
|
||||
"str": json_str + non_json,
|
||||
},
|
||||
content_type="application/json",
|
||||
status=200,
|
||||
)
|
||||
|
||||
response = call_endpoint(
|
||||
view,
|
||||
HostRequestMock(
|
||||
post_data={
|
||||
"json_int": "2",
|
||||
"json_str": orjson.dumps("asd").decode(),
|
||||
"json_data": orjson.dumps({"num1": 5, "num2": 7}).decode(),
|
||||
}
|
||||
),
|
||||
)
|
||||
self.assertDictEqual(
|
||||
orjson.loads(response.content),
|
||||
{"result1": 70, "result2": 100, "str": "asdok", "optional": None},
|
||||
)
|
||||
|
||||
data = {
|
||||
"json_int": "2",
|
||||
"json_str": orjson.dumps("asd").decode(),
|
||||
"json_data": orjson.dumps({"num1": 5, "num2": 7}).decode(),
|
||||
"json_default": orjson.dumps({"num1": 3, "num2": 11}).decode(),
|
||||
"json_optional": "5",
|
||||
"non_json": "asd",
|
||||
}
|
||||
response = call_endpoint(
|
||||
view,
|
||||
HostRequestMock(post_data=data),
|
||||
)
|
||||
self.assertDictEqual(
|
||||
orjson.loads(response.content),
|
||||
{"result1": 70, "result2": 33, "str": "asdasd", "optional": 5},
|
||||
)
|
||||
|
||||
request = HostRequestMock()
|
||||
request.GET.update(data)
|
||||
response = call_endpoint(
|
||||
view,
|
||||
request,
|
||||
)
|
||||
self.assertDictEqual(
|
||||
orjson.loads(response.content),
|
||||
{"result1": 70, "result2": 33, "str": "asdasd", "optional": 5},
|
||||
)
|
||||
|
||||
with self.assertRaisesMessage(JsonableError, "json_int is not valid JSON"):
|
||||
call_endpoint(
|
||||
view,
|
||||
HostRequestMock(
|
||||
post_data={
|
||||
"json_int": "foo",
|
||||
"json_str": "asd",
|
||||
"json_data": orjson.dumps({"num1": 5, "num2": 7}).decode(),
|
||||
}
|
||||
),
|
||||
)
|
||||
with self.assertRaisesMessage(JsonableError, "json_str is not valid JSON"):
|
||||
call_endpoint(
|
||||
view,
|
||||
HostRequestMock(
|
||||
post_data={
|
||||
"json_int": 5,
|
||||
"json_str": "asd",
|
||||
"json_data": orjson.dumps({"num1": 5, "num2": 7}).decode(),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
with self.assertRaisesMessage(RequestVariableMissingError, "Missing 'json_int' argument"):
|
||||
call_endpoint(view, HostRequestMock())
|
||||
|
||||
with self.assertRaisesMessage(JsonableError, "json_int is not an integer"):
|
||||
call_endpoint(
|
||||
view,
|
||||
HostRequestMock(
|
||||
{
|
||||
"json_int": orjson.dumps(False).decode(),
|
||||
"json_str": orjson.dumps("10").decode(),
|
||||
"json_data": orjson.dumps({"num1": "a", "num2": "b"}).decode(),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
with self.assertRaisesMessage(JsonableError, 'json_data["num1"] is not an integer'):
|
||||
call_endpoint(
|
||||
view,
|
||||
HostRequestMock(
|
||||
{
|
||||
"json_int": orjson.dumps(0).decode(),
|
||||
"json_str": orjson.dumps("test").decode(),
|
||||
"json_data": orjson.dumps({"num1": "10", "num2": 20}).decode(),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
response = call_endpoint(
|
||||
view,
|
||||
HostRequestMock(
|
||||
post_data={
|
||||
"json_int": 5,
|
||||
"json_str": orjson.dumps("asd").decode(),
|
||||
"json_data": orjson.dumps({"num1": 5, "num2": 7}).decode(),
|
||||
"json_optional": orjson.dumps(None).decode(),
|
||||
"non_json_optional": None,
|
||||
}
|
||||
),
|
||||
json_optional="asd",
|
||||
)
|
||||
# Note that json_optional is ignored because we have passed it as a kwarg already.
|
||||
self.assertDictEqual(
|
||||
orjson.loads(response.content),
|
||||
{
|
||||
"result1": 175,
|
||||
"result2": 100,
|
||||
"str": "asdok",
|
||||
"optional": "asd",
|
||||
"ignored_parameters_unsupported": ["json_optional"],
|
||||
},
|
||||
)
|
||||
|
||||
with self.assertRaisesMessage(
|
||||
JsonableError, 'Argument "unknown" at json_data["unknown"] is unexpected'
|
||||
):
|
||||
call_endpoint(
|
||||
view,
|
||||
HostRequestMock(
|
||||
{
|
||||
"json_int": orjson.dumps(19).decode(),
|
||||
"json_str": orjson.dumps("10").decode(),
|
||||
"json_data": orjson.dumps({"num1": 1, "num2": 4, "unknown": "c"}).decode(),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def test_whence(self) -> None:
|
||||
@typed_endpoint
|
||||
def whence_view(
|
||||
request: HttpRequest, *, param: Annotated[str, ApiParamConfig(whence="foo")]
|
||||
) -> str:
|
||||
return param
|
||||
|
||||
with self.assertRaisesMessage(RequestVariableMissingError, "Missing 'foo' argument"):
|
||||
call_endpoint(whence_view, HostRequestMock({"param": "hi"}))
|
||||
|
||||
result = call_endpoint(whence_view, HostRequestMock({"foo": "hi"}))
|
||||
self.assertEqual(result, "hi")
|
||||
|
||||
def test_argument_type(self) -> None:
|
||||
@typed_endpoint
|
||||
def webhook(
|
||||
request: HttpRequest,
|
||||
*,
|
||||
body: WebhookPayload[WildValue],
|
||||
foo: Json[int],
|
||||
bar: Json[int] = 0,
|
||||
) -> Dict[str, object]:
|
||||
status = body["totame"]["status"].tame(check_bool)
|
||||
return {"status": status, "foo": foo, "bar": bar}
|
||||
|
||||
# Simulate a paylaod that uses JSON encoding. We use the body setter to
|
||||
# overwrite the request body. The HostRequestMock initializer sets the
|
||||
# POST QueryDict, which is normally done by Django by parsing the body.
|
||||
data = {"foo": 15, "totame": {"status": True}}
|
||||
request = HostRequestMock(data)
|
||||
request.body = orjson.dumps(data)
|
||||
result = call_endpoint(webhook, request)
|
||||
self.assertDictEqual(result, {"status": True, "foo": 15, "bar": 0})
|
||||
|
||||
request.body = orjson.dumps([])
|
||||
with self.assertRaisesRegex(DjangoValidationError, "request is not a dict"):
|
||||
result = call_endpoint(webhook, request)
|
||||
|
||||
request.body = orjson.dumps(10)
|
||||
with self.assertRaisesRegex(DjangoValidationError, "request is not a dict"):
|
||||
result = call_endpoint(webhook, request)
|
||||
|
||||
request = HostRequestMock()
|
||||
request.GET.update({"foo": "15", "bar": "10"})
|
||||
request.body = orjson.dumps(data)
|
||||
result = call_endpoint(webhook, request)
|
||||
self.assertDictEqual(result, {"status": True, "foo": 15, "bar": 10})
|
||||
|
||||
with self.assertRaisesMessage(JsonableError, "Malformed JSON"):
|
||||
call_endpoint(webhook, HostRequestMock())
|
||||
|
||||
with self.assertRaisesMessage(JsonableError, "Malformed payload"):
|
||||
request = HostRequestMock()
|
||||
# This body triggers UnicodeDecodeError
|
||||
request.body = b"\x81"
|
||||
call_endpoint(webhook, request)
|
||||
|
||||
def test_path_only(self) -> None:
|
||||
@typed_endpoint
|
||||
def path_only(
|
||||
request: HttpRequest,
|
||||
*,
|
||||
path_var: PathOnly[int],
|
||||
other: Json[int],
|
||||
) -> MutableJsonResponse:
|
||||
# Return a MutableJsonResponse to see parameters ignored
|
||||
return json_success(request, data={"val": path_var + other})
|
||||
|
||||
response = call_endpoint(path_only, HostRequestMock(post_data={"other": 1}), path_var=20)
|
||||
self.assert_json_success(response)
|
||||
self.assertEqual(orjson.loads(response.content)["val"], 21)
|
||||
|
||||
with self.assertRaisesMessage(
|
||||
AssertionError, "Path-only variable path_var should be passed already"
|
||||
):
|
||||
call_endpoint(path_only, HostRequestMock(post_data={"other": 1}))
|
||||
|
||||
# Even if the path-only variable is present in the request data, it
|
||||
# shouldn't be parsed either.
|
||||
with self.assertRaisesMessage(
|
||||
AssertionError, "Path-only variable path_var should be passed already"
|
||||
):
|
||||
call_endpoint(path_only, HostRequestMock(post_data={"path_var": 15, "other": 1}))
|
||||
|
||||
# path_var in the request body is ignored
|
||||
response = call_endpoint(
|
||||
path_only, HostRequestMock(post_data={"path_var": 15, "other": 1}), path_var=10
|
||||
)
|
||||
self.assert_json_success(response, ignored_parameters=["path_var"])
|
||||
self.assertEqual(orjson.loads(response.content)["val"], 11)
|
||||
|
||||
def path_only_default(
|
||||
request: HttpRequest,
|
||||
*,
|
||||
path_var_default: PathOnly[str] = "test",
|
||||
) -> None:
|
||||
...
|
||||
|
||||
with self.assertRaisesMessage(
|
||||
AssertionError, "Path-only parameter path_var_default should not have a default value"
|
||||
):
|
||||
typed_endpoint(path_only_default)
|
||||
|
||||
def test_documentation_status(self) -> None:
|
||||
def documentation(
|
||||
request: HttpRequest,
|
||||
*,
|
||||
foo: Annotated[
|
||||
str,
|
||||
ApiParamConfig(documentation_status=DocumentationStatus.INTENTIONALLY_UNDOCUMENTED),
|
||||
],
|
||||
bar: Annotated[
|
||||
str, ApiParamConfig(documentation_status=DocumentationStatus.DOCUMENTATION_PENDING)
|
||||
],
|
||||
baz: Annotated[
|
||||
str, ApiParamConfig(documentation_status=DocumentationStatus.DOCUMENTED)
|
||||
],
|
||||
paz: PathOnly[int],
|
||||
other: str,
|
||||
) -> None:
|
||||
...
|
||||
|
||||
from zerver.lib.request import arguments_map
|
||||
|
||||
view_func_full_name = f"{documentation.__module__}.{documentation.__name__}"
|
||||
typed_endpoint(documentation)
|
||||
# Path-only and non DOCUMENTED parameters should not be added
|
||||
self.assertEqual(arguments_map[view_func_full_name], ["baz", "other"])
|
||||
|
||||
def test_annotated(self) -> None:
|
||||
@typed_endpoint
|
||||
def valid_usage_of_api_param_config(
|
||||
request: HttpRequest,
|
||||
*,
|
||||
foo: Annotated[
|
||||
Json[int],
|
||||
ApiParamConfig(path_only=True),
|
||||
],
|
||||
) -> None:
|
||||
...
|
||||
|
||||
def annotated_with_repeated_api_param_config(
|
||||
request: HttpRequest,
|
||||
user_profile: UserProfile,
|
||||
*,
|
||||
foo: Annotated[Json[int], ApiParamConfig(), ApiParamConfig()],
|
||||
) -> None:
|
||||
...
|
||||
|
||||
with self.assertRaisesMessage(
|
||||
AssertionError, "ApiParamConfig can only be defined once per parameter"
|
||||
):
|
||||
typed_endpoint(annotated_with_repeated_api_param_config)
|
||||
|
||||
@typed_endpoint
|
||||
def annotated_with_extra_unrelated_metadata(
|
||||
request: HttpRequest,
|
||||
user_profile: UserProfile,
|
||||
*,
|
||||
foo: Annotated[Json[bool], str, "unrelated"],
|
||||
) -> bool:
|
||||
return foo
|
||||
|
||||
hamlet = self.example_user("hamlet")
|
||||
result = call_endpoint(
|
||||
annotated_with_extra_unrelated_metadata,
|
||||
HostRequestMock({"foo": orjson.dumps(False).decode()}),
|
||||
hamlet,
|
||||
)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_aliases(self) -> None:
|
||||
@typed_endpoint
|
||||
def view_with_aliased_parameter(
|
||||
request: HttpRequest, *, topic: Annotated[str, ApiParamConfig(aliases=["legacy_topic"])]
|
||||
) -> HttpResponse:
|
||||
return json_success(request, {"value": topic})
|
||||
|
||||
result = call_endpoint(
|
||||
view_with_aliased_parameter, HostRequestMock({"topic": "topic is topic"})
|
||||
)
|
||||
value = self.assert_json_success(result)["value"]
|
||||
self.assertEqual(value, "topic is topic")
|
||||
|
||||
req = HostRequestMock({"topic": "topic is topic"})
|
||||
req.GET["legacy_topic"] = "topic is"
|
||||
with self.assertRaisesMessage(
|
||||
RequestConfusingParamsError, "Can't decide between 'topic' and 'legacy_topic' arguments"
|
||||
):
|
||||
call_endpoint(view_with_aliased_parameter, req)
|
||||
|
||||
with self.assertRaisesMessage(
|
||||
RequestConfusingParamsError, "Can't decide between 'topic' and 'legacy_topic' arguments"
|
||||
):
|
||||
call_endpoint(
|
||||
view_with_aliased_parameter,
|
||||
HostRequestMock({"topic": "test", "legacy_topic": "test2"}),
|
||||
)
|
||||
|
||||
result = call_endpoint(
|
||||
view_with_aliased_parameter, HostRequestMock({"legacy_topic": "legacy_topic is topic"})
|
||||
)
|
||||
value = self.assert_json_success(result)["value"]
|
||||
self.assertEqual(value, "legacy_topic is topic")
|
||||
|
||||
result = call_endpoint(
|
||||
view_with_aliased_parameter,
|
||||
HostRequestMock(
|
||||
{"legacy_topic": "legacy_topic is topic", "ignored": "extra parameter"}
|
||||
),
|
||||
)
|
||||
value = self.assert_json_success(result, ignored_parameters=["ignored"])["value"]
|
||||
self.assertEqual(value, "legacy_topic is topic")
|
||||
|
||||
# aliases should work in combination with whence
|
||||
@typed_endpoint
|
||||
def view_with_aliased_and_whenced_parameter(
|
||||
request: HttpRequest,
|
||||
*,
|
||||
topic: Annotated[str, ApiParamConfig(whence="topic_name", aliases=["legacy_topic"])],
|
||||
) -> HttpResponse:
|
||||
return json_success(request, {"value": topic})
|
||||
|
||||
result = call_endpoint(
|
||||
view_with_aliased_and_whenced_parameter,
|
||||
HostRequestMock({"legacy_topic": "legacy_topic is topic", "topic": "extra parameter"}),
|
||||
)
|
||||
value = self.assert_json_success(result, ignored_parameters=["topic"])["value"]
|
||||
self.assertEqual(value, "legacy_topic is topic")
|
||||
|
||||
with self.assertRaisesMessage(
|
||||
RequestConfusingParamsError,
|
||||
"Can't decide between 'topic_name' and 'legacy_topic' arguments",
|
||||
):
|
||||
call_endpoint(
|
||||
view_with_aliased_and_whenced_parameter,
|
||||
HostRequestMock({"topic_name": "test", "legacy_topic": "test2"}),
|
||||
)
|
||||
|
||||
def test_expect_no_parameters(self) -> None:
|
||||
def no_parameter(request: HttpRequest) -> None:
|
||||
...
|
||||
|
||||
def has_parameters(request: HttpRequest, *, foo: int, bar: str) -> None:
|
||||
...
|
||||
|
||||
with self.assertRaisesRegex(AssertionError, "there is no keyword-only parameter found"):
|
||||
typed_endpoint(no_parameter)
|
||||
# No assertion errors expected
|
||||
typed_endpoint(has_parameters)
|
||||
|
||||
with self.assertRaisesMessage(AssertionError, "Unexpected keyword-only parameters found"):
|
||||
typed_endpoint_without_parameters(has_parameters)
|
||||
# No assertion errors expected
|
||||
typed_endpoint_without_parameters(no_parameter)
|
||||
|
||||
def test_custom_validator(self) -> None:
|
||||
@dataclass
|
||||
class CustomType:
|
||||
val: int
|
||||
|
||||
def validate_custom_type(
|
||||
value: object,
|
||||
handler: ModelWrapValidatorHandler[CustomType],
|
||||
info: ValidationInfo,
|
||||
) -> CustomType:
|
||||
return CustomType(42)
|
||||
|
||||
@typed_endpoint
|
||||
def test_view(
|
||||
request: HttpRequest, *, foo: Annotated[CustomType, WrapValidator(validate_custom_type)]
|
||||
) -> None:
|
||||
self.assertEqual(foo.val, 42)
|
||||
|
||||
call_endpoint(test_view, HostRequestMock({"foo": ""}))
|
||||
|
||||
|
||||
class ValidationErrorHandlingTest(ZulipTestCase):
|
||||
def test_special_handling_errors(self) -> None:
|
||||
"""Test for errors that require special handling beyond an ERROR_TEMPLATES lookup.
|
||||
Not all error types need to be tested here."""
|
||||
|
||||
@dataclass
|
||||
class DataFoo:
|
||||
__pydantic_config__ = ConfigDict(extra="forbid")
|
||||
message: str
|
||||
|
||||
class DataModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
message: str
|
||||
|
||||
@dataclass
|
||||
class SubTest:
|
||||
"""This describes a parameterized test case
|
||||
for our handling of Pydantic validation errors"""
|
||||
|
||||
# The type of the error, can be found at
|
||||
# https://docs.pydantic.dev/latest/errors/validation_errors/
|
||||
error_type: str
|
||||
# The type of the parameter. We set on a view function decorated
|
||||
# with @typed_endpoint for a parameter named "input".
|
||||
param_type: object
|
||||
# Because QueryDict always converts the data into a str, this
|
||||
# conversion can be unexpected so we ask the caller to convert
|
||||
# input_data to str explicitly beforehand. The input data is
|
||||
# automatically set to POST["input"] in the mock request.
|
||||
input_data: str
|
||||
# The exact error message we expect from the ApiValidationError
|
||||
# raised when the view function is called with the provided input
|
||||
# data.
|
||||
error_message: str
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Pydantic error type: {self.error_type}; Parameter type: {self.param_type}; Expected error message: {self.error_message}"
|
||||
|
||||
parameterized_tests: List[SubTest] = [
|
||||
SubTest(
|
||||
error_type="string_too_short",
|
||||
param_type=Json[List[Annotated[str, RequiredStringConstraint()]]],
|
||||
input_data=orjson.dumps([""]).decode(),
|
||||
error_message="input[0] cannot be blank",
|
||||
),
|
||||
SubTest(
|
||||
error_type="string_too_short",
|
||||
param_type=Json[List[Annotated[str, RequiredStringConstraint()]]],
|
||||
input_data=orjson.dumps(["g", " "]).decode(),
|
||||
error_message="input[1] cannot be blank",
|
||||
),
|
||||
SubTest(
|
||||
error_type="unexpected_keyword_argument",
|
||||
param_type=Json[DataFoo],
|
||||
input_data=orjson.dumps({"message": "asd", "test": ""}).decode(),
|
||||
error_message='Argument "test" at input["test"] is unexpected',
|
||||
),
|
||||
SubTest(
|
||||
error_type="extra_forbidden",
|
||||
param_type=Json[DataModel],
|
||||
input_data=orjson.dumps({"message": "asd", "test": ""}).decode(),
|
||||
error_message='Argument "test" at input["test"] is unexpected',
|
||||
),
|
||||
]
|
||||
|
||||
for index, subtest in enumerate(parameterized_tests):
|
||||
subtest_title = f"Subtest #{index + 1}: {subtest!r}"
|
||||
with self.subTest(subtest_title):
|
||||
# We use Any here so that we don't perform unnecessary type
|
||||
# checking.
|
||||
# Without this, mypy crashes with an internal error:
|
||||
# INTERNAL ERROR: maximum semantic analysis iteration count reached
|
||||
input_type: Any = subtest.param_type
|
||||
|
||||
@typed_endpoint
|
||||
def func(request: HttpRequest, *, input: input_type) -> None:
|
||||
...
|
||||
|
||||
with self.assertRaises(ApiParamValidationError) as m:
|
||||
call_endpoint(func, HostRequestMock({"input": subtest.input_data}))
|
||||
|
||||
self.assertEqual(m.exception.msg, subtest.error_message)
|
||||
self.assertEqual(m.exception.error_type, subtest.error_type)
|
Loading…
Reference in New Issue