validator: Optionally record a type_structure attribute.

We plan to use these records to check and record the schema of Zulip's
events for the purposes of API documentation.

Based on an original messier commit by tabbott.

In theory, a nicer version of this would be able to work directly off
the mypy type system, but this will be good enough for our use case.
This commit is contained in:
Vishnu KS 2019-12-11 16:33:20 +05:30 committed by Tim Abbott
parent 9230213bde
commit 1c6435d4cc
3 changed files with 75 additions and 1 deletions

View File

@ -15,6 +15,9 @@ exclude_lines =
raise UnexpectedWebhookEventType raise UnexpectedWebhookEventType
# Don't require coverage for blocks only run when type-checking # Don't require coverage for blocks only run when type-checking
if TYPE_CHECKING: if TYPE_CHECKING:
# Don't require coverage for the settings.LOG_API_EVENT_TYPES code paths
# These are only run in a special testing mode, so will fail normal coverage.
if settings.LOG_API_EVENT_TYPES:
# PEP 484 overloading syntax # PEP 484 overloading syntax
^\s*\.\.\. ^\s*\.\.\.

View File

@ -28,19 +28,37 @@ for any particular type of object.
import re import re
import ujson import ujson
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import validate_email, URLValidator from django.core.validators import validate_email, URLValidator
from typing import Iterable, Optional, Tuple, cast, List from typing import Any, Dict, Iterable, Optional, Tuple, cast, List, Callable, TypeVar
from datetime import datetime from datetime import datetime
from zerver.lib.request import JsonableError from zerver.lib.request import JsonableError
from zerver.lib.types import Validator, ProfileFieldData from zerver.lib.types import Validator, ProfileFieldData
FuncT = Callable[..., Any]
TypeStructure = TypeVar("TypeStructure")
# The type_structure system is designed to support using the validators in
# test_events.py to create documentation for our event formats.
#
# Ultimately, it should be possible to do this with mypy rather than a
# parallel system.
def set_type_structure(type_structure: TypeStructure) -> Callable[[FuncT], Any]:
def _set_type_structure(func: FuncT) -> FuncT:
if settings.LOG_API_EVENT_TYPES:
func.type_structure = type_structure # type: ignore # monkey-patching
return func
return _set_type_structure
@set_type_structure("str")
def check_string(var_name: str, val: object) -> Optional[str]: def check_string(var_name: str, val: object) -> Optional[str]:
if not isinstance(val, str): if not isinstance(val, str):
return _('%s is not a string') % (var_name,) return _('%s is not a string') % (var_name,)
return None return None
@set_type_structure("str")
def check_required_string(var_name: str, val: object) -> Optional[str]: def check_required_string(var_name: str, val: object) -> Optional[str]:
error = check_string(var_name, val) error = check_string(var_name, val)
if error: if error:
@ -52,10 +70,12 @@ def check_required_string(var_name: str, val: object) -> Optional[str]:
return None return None
@set_type_structure("str")
def check_short_string(var_name: str, val: object) -> Optional[str]: def check_short_string(var_name: str, val: object) -> Optional[str]:
return check_capped_string(50)(var_name, val) return check_capped_string(50)(var_name, val)
def check_capped_string(max_length: int) -> Validator: def check_capped_string(max_length: int) -> Validator:
@set_type_structure("str")
def validator(var_name: str, val: object) -> Optional[str]: def validator(var_name: str, val: object) -> Optional[str]:
if not isinstance(val, str): if not isinstance(val, str):
return _('%s is not a string') % (var_name,) return _('%s is not a string') % (var_name,)
@ -63,9 +83,11 @@ def check_capped_string(max_length: int) -> Validator:
return _("{var_name} is too long (limit: {max_length} characters)").format( return _("{var_name} is too long (limit: {max_length} characters)").format(
var_name=var_name, max_length=max_length) var_name=var_name, max_length=max_length)
return None return None
return validator return validator
def check_string_fixed_length(length: int) -> Validator: def check_string_fixed_length(length: int) -> Validator:
@set_type_structure("str")
def validator(var_name: str, val: object) -> Optional[str]: def validator(var_name: str, val: object) -> Optional[str]:
if not isinstance(val, str): if not isinstance(val, str):
return _('%s is not a string') % (var_name,) return _('%s is not a string') % (var_name,)
@ -75,9 +97,11 @@ def check_string_fixed_length(length: int) -> Validator:
return None return None
return validator return validator
@set_type_structure("str")
def check_long_string(var_name: str, val: object) -> Optional[str]: def check_long_string(var_name: str, val: object) -> Optional[str]:
return check_capped_string(500)(var_name, val) return check_capped_string(500)(var_name, val)
@set_type_structure("date")
def check_date(var_name: str, val: object) -> Optional[str]: def check_date(var_name: str, val: object) -> Optional[str]:
if not isinstance(val, str): if not isinstance(val, str):
return _('%s is not a string') % (var_name,) return _('%s is not a string') % (var_name,)
@ -87,12 +111,14 @@ def check_date(var_name: str, val: object) -> Optional[str]:
return _('%s is not a date') % (var_name,) return _('%s is not a date') % (var_name,)
return None return None
@set_type_structure("int")
def check_int(var_name: str, val: object) -> Optional[str]: def check_int(var_name: str, val: object) -> Optional[str]:
if not isinstance(val, int): if not isinstance(val, int):
return _('%s is not an integer') % (var_name,) return _('%s is not an integer') % (var_name,)
return None return None
def check_int_in(possible_values: List[int]) -> Validator: def check_int_in(possible_values: List[int]) -> Validator:
@set_type_structure("int")
def validator(var_name: str, val: object) -> Optional[str]: def validator(var_name: str, val: object) -> Optional[str]:
not_int = check_int(var_name, val) not_int = check_int(var_name, val)
if not_int is not None: if not_int is not None:
@ -103,16 +129,19 @@ def check_int_in(possible_values: List[int]) -> Validator:
return validator return validator
@set_type_structure("float")
def check_float(var_name: str, val: object) -> Optional[str]: def check_float(var_name: str, val: object) -> Optional[str]:
if not isinstance(val, float): if not isinstance(val, float):
return _('%s is not a float') % (var_name,) return _('%s is not a float') % (var_name,)
return None return None
@set_type_structure("bool")
def check_bool(var_name: str, val: object) -> Optional[str]: def check_bool(var_name: str, val: object) -> Optional[str]:
if not isinstance(val, bool): if not isinstance(val, bool):
return _('%s is not a boolean') % (var_name,) return _('%s is not a boolean') % (var_name,)
return None return None
@set_type_structure("str")
def check_color(var_name: str, val: object) -> Optional[str]: def check_color(var_name: str, val: object) -> Optional[str]:
if not isinstance(val, str): if not isinstance(val, str):
return _('%s is not a string') % (var_name,) return _('%s is not a string') % (var_name,)
@ -123,6 +152,12 @@ def check_color(var_name: str, val: object) -> Optional[str]:
return None return None
def check_none_or(sub_validator: Validator) -> Validator: def check_none_or(sub_validator: Validator) -> Validator:
if settings.LOG_API_EVENT_TYPES:
type_structure = 'none_or_' + sub_validator.type_structure # type: ignore # monkey-patching
else:
type_structure = None
@set_type_structure(type_structure)
def f(var_name: str, val: object) -> Optional[str]: def f(var_name: str, val: object) -> Optional[str]:
if val is None: if val is None:
return None return None
@ -131,6 +166,15 @@ def check_none_or(sub_validator: Validator) -> Validator:
return f return f
def check_list(sub_validator: Optional[Validator], length: Optional[int]=None) -> Validator: def check_list(sub_validator: Optional[Validator], length: Optional[int]=None) -> Validator:
if settings.LOG_API_EVENT_TYPES:
if sub_validator:
type_structure = [sub_validator.type_structure] # type: ignore # monkey-patching
else:
type_structure = 'list' # type: ignore # monkey-patching
else:
type_structure = None # type: ignore # monkey-patching
@set_type_structure(type_structure)
def f(var_name: str, val: object) -> Optional[str]: def f(var_name: str, val: object) -> Optional[str]:
if not isinstance(val, list): if not isinstance(val, list):
return _('%s is not a list') % (var_name,) return _('%s is not a list') % (var_name,)
@ -153,6 +197,9 @@ def check_dict(required_keys: Iterable[Tuple[str, Validator]]=[],
optional_keys: Iterable[Tuple[str, Validator]]=[], optional_keys: Iterable[Tuple[str, Validator]]=[],
value_validator: Optional[Validator]=None, value_validator: Optional[Validator]=None,
_allow_only_listed_keys: bool=False) -> Validator: _allow_only_listed_keys: bool=False) -> Validator:
type_structure = {} # type: Dict[str, Any]
@set_type_structure(type_structure)
def f(var_name: str, val: object) -> Optional[str]: def f(var_name: str, val: object) -> Optional[str]:
if not isinstance(val, dict): if not isinstance(val, dict):
return _('%s is not a dict') % (var_name,) return _('%s is not a dict') % (var_name,)
@ -165,6 +212,8 @@ def check_dict(required_keys: Iterable[Tuple[str, Validator]]=[],
error = sub_validator(vname, val[k]) error = sub_validator(vname, val[k])
if error: if error:
return error return error
if settings.LOG_API_EVENT_TYPES:
type_structure[k] = sub_validator.type_structure # type: ignore # monkey-patching
for k, sub_validator in optional_keys: for k, sub_validator in optional_keys:
if k in val: if k in val:
@ -172,6 +221,8 @@ def check_dict(required_keys: Iterable[Tuple[str, Validator]]=[],
error = sub_validator(vname, val[k]) error = sub_validator(vname, val[k])
if error: if error:
return error return error
if settings.LOG_API_EVENT_TYPES:
type_structure[k] = sub_validator.type_structure # type: ignore # monkey-patching
if value_validator: if value_validator:
for key in val: for key in val:
@ -179,6 +230,8 @@ def check_dict(required_keys: Iterable[Tuple[str, Validator]]=[],
error = value_validator(vname, val[key]) error = value_validator(vname, val[key])
if error: if error:
return error return error
if settings.LOG_API_EVENT_TYPES:
type_structure['any'] = value_validator.type_structure # type: ignore # monkey-patching
if _allow_only_listed_keys: if _allow_only_listed_keys:
required_keys_set = set(x[0] for x in required_keys) required_keys_set = set(x[0] for x in required_keys)
@ -203,6 +256,13 @@ def check_variable_type(allowed_type_funcs: Iterable[Validator]) -> Validator:
`allowed_type_funcs`: the check_* validator functions for the possible data `allowed_type_funcs`: the check_* validator functions for the possible data
types for this variable. types for this variable.
""" """
if settings.LOG_API_EVENT_TYPES:
type_structure = 'any("%s")' % ([x.type_structure for x in allowed_type_funcs],) # type: ignore # monkey-patching
else:
type_structure = None # type: ignore # monkey-patching
@set_type_structure(type_structure)
def enumerated_type_check(var_name: str, val: object) -> Optional[str]: def enumerated_type_check(var_name: str, val: object) -> Optional[str]:
for func in allowed_type_funcs: for func in allowed_type_funcs:
if not func(var_name, val): if not func(var_name, val):
@ -211,6 +271,7 @@ def check_variable_type(allowed_type_funcs: Iterable[Validator]) -> Validator:
return enumerated_type_check return enumerated_type_check
def equals(expected_val: object) -> Validator: def equals(expected_val: object) -> Validator:
@set_type_structure('equals("%s")' % (str(expected_val),))
def f(var_name: str, val: object) -> Optional[str]: def f(var_name: str, val: object) -> Optional[str]:
if val != expected_val: if val != expected_val:
return (_('%(variable)s != %(expected_value)s (%(value)s is wrong)') % return (_('%(variable)s != %(expected_value)s (%(value)s is wrong)') %
@ -220,12 +281,14 @@ def equals(expected_val: object) -> Validator:
return None return None
return f return f
@set_type_structure('str')
def validate_login_email(email: str) -> None: def validate_login_email(email: str) -> None:
try: try:
validate_email(email) validate_email(email)
except ValidationError as err: except ValidationError as err:
raise JsonableError(str(err.message)) raise JsonableError(str(err.message))
@set_type_structure('str')
def check_url(var_name: str, val: object) -> Optional[str]: def check_url(var_name: str, val: object) -> Optional[str]:
# First, ensure val is a string # First, ensure val is a string
string_msg = check_string(var_name, val) string_msg = check_string(var_name, val)
@ -239,6 +302,7 @@ def check_url(var_name: str, val: object) -> Optional[str]:
except ValidationError: except ValidationError:
return _('%s is not a URL') % (var_name,) return _('%s is not a URL') % (var_name,)
@set_type_structure('str')
def check_external_account_url_pattern(var_name: str, val: object) -> Optional[str]: def check_external_account_url_pattern(var_name: str, val: object) -> Optional[str]:
error = check_string(var_name, val) error = check_string(var_name, val)
if error: if error:
@ -332,6 +396,7 @@ def check_widget_content(widget_content: object) -> Optional[str]:
# Converter functions for use with has_request_variables # Converter functions for use with has_request_variables
@set_type_structure('int')
def to_non_negative_int(s: str, max_int_size: int=2**32-1) -> int: def to_non_negative_int(s: str, max_int_size: int=2**32-1) -> int:
x = int(s) x = int(s)
if x < 0: if x < 0:
@ -340,6 +405,7 @@ def to_non_negative_int(s: str, max_int_size: int=2**32-1) -> int:
raise ValueError('%s is too large (max %s)' % (x, max_int_size)) raise ValueError('%s is too large (max %s)' % (x, max_int_size))
return x return x
@set_type_structure('any(List[int], str)]')
def check_string_or_int_list(var_name: str, val: object) -> Optional[str]: def check_string_or_int_list(var_name: str, val: object) -> Optional[str]:
if isinstance(val, str): if isinstance(val, str):
return None return None
@ -349,6 +415,7 @@ def check_string_or_int_list(var_name: str, val: object) -> Optional[str]:
return check_list(check_int)(var_name, val) return check_list(check_int)(var_name, val)
@set_type_structure('any(int, str)')
def check_string_or_int(var_name: str, val: object) -> Optional[str]: def check_string_or_int(var_name: str, val: object) -> Optional[str]:
if isinstance(val, str) or isinstance(val, int): if isinstance(val, str) or isinstance(val, int):
return None return None

View File

@ -336,6 +336,10 @@ CUSTOM_LOGO_URL = None # type: Optional[str]
# development. # development.
INITIAL_PASSWORD_SALT = None # type: Optional[str] INITIAL_PASSWORD_SALT = None # type: Optional[str]
# Settings configuring the special instrumention of the send_event
# code path used in generating API documentation for /events.
LOG_API_EVENT_TYPES = False
# Used to control whether certain management commands are run on # Used to control whether certain management commands are run on
# the server. # the server.
# TODO: Replace this with a smarter "run on only one server" system. # TODO: Replace this with a smarter "run on only one server" system.