tutorial documentation: Add documentation for typed_endpoint.

This commit is contained in:
Kenneth Rodrigues 2024-08-02 23:08:27 +05:30 committed by Tim Abbott
parent 748a56aae9
commit f27cee21e3
3 changed files with 102 additions and 41 deletions

View File

@ -254,13 +254,13 @@ above.
question works by reading the code! To understand how arguments question works by reading the code! To understand how arguments
are specified in Zulip backend endpoints, read our [REST API are specified in Zulip backend endpoints, read our [REST API
tutorial][rest-api-tutorial], paying special attention to the tutorial][rest-api-tutorial], paying special attention to the
details of `REQ` and `has_request_variables`. details of `typed_endpoint`.
Once you understand that, the best way to determine the supported Once you understand that, the best way to determine the supported
arguments for an API endpoint is to find the corresponding URL arguments for an API endpoint is to find the corresponding URL
pattern in `zprojects/urls.py`, look up the backend function for pattern in `zprojects/urls.py`, look up the backend function for
that endpoint in `zerver/views/`, and inspect its arguments that endpoint in `zerver/views/`, and inspect its keyword-only
declared using `REQ`. arguments.
You can check your formatting using these helpful tools. You can check your formatting using these helpful tools.

View File

@ -433,12 +433,14 @@ annotation).
```diff ```diff
# zerver/views/realm.py # zerver/views/realm.py
@typed_endpoint
def update_realm( def update_realm(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
name: Optional[str] = REQ(str_validator=check_string, default=None), *,
name: str | None,
# ... # ...
+ mandatory_topics: Optional[bool] = REQ(json_validator=check_bool, default=None), + mandatory_topics: Json[bool] | None = None,
# ... # ...
): ):
# ... # ...

View File

@ -136,9 +136,11 @@ one of several bad outcomes:
validation that has the problems from the last bullet point. validation that has the problems from the last bullet point.
In Zulip, we solve this problem with a special decorator called In Zulip, we solve this problem with a special decorator called
`has_request_variables` which allows a developer to declare the `typed_endpoint` which allows a developer to declare the
arguments a view function takes and validate their types all within arguments a view function takes and validate their types all within
the `def` line of the function. We like this framework because we the `def` line of the function. This framework uses
[Pydantic V2](https://docs.pydantic.dev/dev/) to perform data validation
and parsing for the view arguments. We like this framework because we
have found it makes the validation code compact, readable, and have found it makes the validation code compact, readable, and
conveniently located in the same place as the method it is validating conveniently located in the same place as the method it is validating
arguments for. arguments for.
@ -147,20 +149,26 @@ Here's an example:
```py ```py
from zerver.decorator import require_realm_admin from zerver.decorator import require_realm_admin
from zerver.lib.request import has_request_variables, REQ from zerver.lib.typed_endpoint import typed_endpoint
@require_realm_admin @require_realm_admin
@has_request_variables @typed_endpoint
def create_user_backend(request, user_profile, email=REQ(), password=REQ(), def create_user_backend(
full_name=REQ()): request: HttpRequest,
user_profile: UserProfile,
*,
email: str,
password: str,
full_name: str,
):
# ... code here # ... code here
``` ```
You will notice the special `REQ()` in the keyword arguments to The `typed_endpoint` decorator parses the declared
`create_user_backend`. `has_request_variables` parses the declared [keyword-only arguments](https://docs.python.org/3/glossary.html#term-parameter)
keyword arguments of the decorated function, and for each that has an of the decorated function, and for each argument that has been declared,
instance of `REQ` as the default value, it extracts the HTTP parameter it extracts the HTTP parameter with that name from the request,
with that name from the request, parses it as JSON, and passes it to parses it according to the type annotation, and then passes it to
the function. It will return an nicely JSON formatted HTTP 400 error the function. It will return an nicely JSON formatted HTTP 400 error
in the event that an argument is missing, doesn't parse as JSON, or in the event that an argument is missing, doesn't parse as JSON, or
otherwise is invalid. otherwise is invalid.
@ -168,37 +176,85 @@ otherwise is invalid.
`require_realm_admin` is another decorator which checks the `require_realm_admin` is another decorator which checks the
authorization of the given `user_profile` to make sure it belongs to a authorization of the given `user_profile` to make sure it belongs to a
realm administrator (and thus has permission to create a user); we realm administrator (and thus has permission to create a user); we
show it here primarily to show how `has_request_variables` should be show it here primarily to show how `typed_endpoint` should be
the inner decorator. the inner decorator.
The implementation of `has_request_variables` is documented in detail The implementation of `typed_endpoint` is documented in detail
in in
[zerver/lib/request.py](https://github.com/zulip/zulip/blob/main/zerver/lib/request.py)) [zerver/lib/typed_endpoint.py](https://github.com/zulip/zulip/blob/main/zerver/lib/typed_endpoint.py)
REQ also helps us with request variable validation. For example: Pydantic also helps us with request variable validation. For example:
- `msg_ids = REQ(json_validator=check_list(check_int))` will check - `msg_ids: Json[list[int]]` will check that the `msg_ids`
that the `msg_ids` HTTP parameter is a list of integers, marshalled HTTP parameter is a list of integers, marshalled as JSON,
as JSON, and pass it into the function as the `msg_ids` Python and pass it into the function as the `msg_ids` Python
keyword argument. keyword argument.
- `streams_raw = REQ("subscriptions", json_validator=check_list(check_string))` - `streams_raw: Annotated[Json[list[str]], ApiParamConfig("subscriptions")]`
will check that the "subscriptions" HTTP parameter is a list of will check that the "subscriptions" HTTP parameter is a list of
strings, marshalled as JSON, and pass it into the function with the strings, marshalled as JSON, and pass it into the function with the
Python keyword argument `streams_raw`. Python keyword argument `streams_raw`.
- `message_id=REQ(converter=to_non_negative_int)` will check that the - `message_id: Json[NonNegativeInt]` will check that the `message_id`
`message_id` HTTP parameter is a string containing a non-negative HTTP parameter is a string containing a JSON encoded non-negative
integer (`converter` differs from `json_validator` in that it does integer.
not automatically marshall the input from JSON).
- Since there is no need to JSON-encode strings, usually simply [Annotated](https://docs.python.org/3/library/typing.html#typing.Annotated)
`my_string=REQ()` is correct. One can pass, for example, can be used in combination with
`str_validator=check_string_in(...)` where one wants to run a [Pydantic's validators](https://docs.pydantic.dev/latest/api/functional_validators/)
to provide additional validation for the arguments.
- `name: Annotated[str, StringConstraints(max_length=60)]` will check that the
`name` HTTP parameter is a string containing up to 60 characters.
- Since there is no need to JSON-encode strings
(lists, integers, bools and complex objects require JSON encoding), usually simply
`my_string: str` is correct. One can pass, for example,
`Annotated[str, check_string_in_validator(...)]` where one wants to run a
validator on the value of a string. validator on the value of a string.
Default values can be specified for optional arguments similar to how we would specify
default values in regular python function.
- `is_default_stream: Json[bool] = False` will assign False to the `is_default_stream` argument
if no value is specified when making a request to the endpoint.
- We can use `None` as the default value for optional arguments when we don't
want to specify any specific default value, for example,
`narrow: Json[list[NarrowParameter]] | None = None`. This does not allow the
caller to pass `None` as the value, the only way `narrow` can be set to `None` is
by using the default value.
[Pydantic models](https://docs.pydantic.dev/latest/concepts/models/) can be used to
define the schema of complex objects that can be passed to the endpoint.
Here's an example:
```py
from typing import Annotated
from pydantic import BaseModel, StringConstraints, model_validator
class AddSubscriptionData(BaseModel):
name: str
color: str | None = None
description: (
Annotated[str, StringConstraints(max_length=Stream.MAX_DESCRIPTION_LENGTH)] | None
) = None
@model_validator(mode="after")
def validate_terms(self) -> "AddSubscriptionData":
# ... Validation logic here
return self
```
- `add: Json[list[AddSubscriptionData]]` will require the `add` argument to be a list of objects
having the keys that are specified in the `AddSubscriptionData` model.
- `@model_validator` can be used to specify additional validation logic for the model.
See See
[zerver/lib/validator.py](https://github.com/zulip/zulip/blob/main/zerver/lib/validator.py) [zerver/lib/typed_endpoint_validators.py](https://github.com/zulip/zulip/blob/main/zerver/lib/typed_endpoint_validators.py)
for more validators and their documentation. for more validators and their documentation.
### Deciding which HTTP verb to use ### Deciding which HTTP verb to use
@ -261,10 +317,12 @@ For example, in [zerver/views/realm.py](https://github.com/zulip/zulip/blob/main
```py ```py
@require_realm_admin @require_realm_admin
@has_request_variables @typed_endpoint
def update_realm( def update_realm(
request: HttpRequest, user_profile: UserProfile, request: HttpRequest,
name: Optional[str]=REQ(str_validator=check_string, default=None), user_profile: UserProfile,
*,
name: Annotated[str | None, StringConstraints(max_length=Realm.MAX_REALM_NAME_LENGTH)] = None,
# ... # ...
): ):
realm = user_profile.realm realm = user_profile.realm
@ -343,12 +401,13 @@ If the webhook does not have an option to provide a bot email, use the
`request.client` fields of a request: `request.client` fields of a request:
```py ```py
@webhook_view('PagerDuty') @webhook_view("PagerDuty", all_event_types=ALL_EVENT_TYPES)
@has_request_variables @typed_endpoint
def api_pagerduty_webhook(request, user_profile, def api_pagerduty_webhook(
payload=REQ(argument_type='body'), request: HttpRequest,
stream=REQ(default='pagerduty'), user_profile: UserProfile,
topic=REQ(default=None)): *,
payload: JsonBodyPayload[WildValue],
``` ```
`request.client` will be the result of `get_client("ZulipPagerDutyWebhook")` `request.client` will be the result of `get_client("ZulipPagerDutyWebhook")`