snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
This commit is contained in:
415
docs/tutorials/writing-views.md
Normal file
415
docs/tutorials/writing-views.md
Normal file
@@ -0,0 +1,415 @@
|
||||
# Writing views in Zulip
|
||||
|
||||
## What this covers
|
||||
|
||||
This page documents how views work in Zulip. You may want to read the
|
||||
[new feature tutorial](new-feature-tutorial.md)
|
||||
and treat this as a reference.
|
||||
|
||||
If you have experience with Django, much of this will be familiar, but
|
||||
you may want to read about how REST requests are dispatched, and how
|
||||
request authentication works.
|
||||
|
||||
This document supplements the [new feature tutorial](new-feature-tutorial.md)
|
||||
and the [testing](../testing/testing.md)
|
||||
documentation.
|
||||
|
||||
## What is a view?
|
||||
|
||||
A view in Zulip is everything that helps implement a server endpoint.
|
||||
Every path that the Zulip server supports (doesn't show a 404 page
|
||||
for) is a view. The obvious ones are those you can visit in your
|
||||
browser, for example
|
||||
[/integrations](https://zulip.com/integrations/), which shows the
|
||||
integration documentation. These paths show up in the address bar of
|
||||
the browser. There are other views that are only seen by software,
|
||||
namely the API views. They are used to build the various clients that
|
||||
Zulip has, namely the web client (which is also used by the desktop
|
||||
client) and the mobile clients.
|
||||
|
||||
## Modifying urls.py
|
||||
|
||||
A view is anything with an entry in the appropriate urls.py, usually
|
||||
`zproject/urls.py`. Zulip views either serve HTML (pages for browsers)
|
||||
or JSON (data for Zulip clients on all platforms, custom bots, and
|
||||
integrations).
|
||||
|
||||
The format of the URL patterns in Django is [documented
|
||||
here](https://docs.djangoproject.com/en/5.0/topics/http/urls/), and
|
||||
the Zulip specific details for these are discussed in detail in the
|
||||
[life of a request doc](life-of-a-request.md#options).
|
||||
|
||||
We have two Zulip-specific conventions we use for internationalization and for
|
||||
our REST API, respectively.
|
||||
|
||||
## Writing human-readable views
|
||||
|
||||
If you're writing a new page for the website, make sure to add it
|
||||
to `i18n_urls` in `zproject/urls.py`
|
||||
|
||||
```diff
|
||||
i18n_urls = [
|
||||
...
|
||||
+ path('quote-of-the-day', TemplateView.as_view(template_name='zerver/qotd.html')),
|
||||
+ path('postcards', 'zerver.views.postcards'),
|
||||
]
|
||||
```
|
||||
|
||||
As an example, if a request comes in for Spanish, language code `es`,
|
||||
the server path will be something like: `es/features/`.
|
||||
|
||||
### Decorators used for webpage views
|
||||
|
||||
This section documents a few simple decorators that we use for webpage
|
||||
views, as an introduction to view decorators.
|
||||
|
||||
`require_post`:
|
||||
|
||||
```py
|
||||
|
||||
@require_post
|
||||
def accounts_register(request: HttpRequest) -> HttpResponse:
|
||||
```
|
||||
|
||||
This decorator ensures that the request was a POST--here, we're
|
||||
checking that the registration submission page is requested with a
|
||||
post, and inside the function, we'll check the form data. If you
|
||||
request this page with GET, you'll get an HTTP 405 METHOD NOT ALLOWED
|
||||
error.
|
||||
|
||||
`zulip_login_required`:
|
||||
|
||||
This decorator verifies that the browser is logged in (i.e. has a
|
||||
valid session cookie) before providing the view for this route, or
|
||||
redirects the browser to a login page. This is used in the root path
|
||||
(`/`) of the website for the web client. If a request comes from a
|
||||
browser without a valid session cookie, they are redirected to a login
|
||||
page. It is a small fork of Django's
|
||||
[login_required][login-required-link], adding a few extra checks
|
||||
specific to Zulip.
|
||||
|
||||
```py
|
||||
@zulip_login_required
|
||||
def home(request: HttpRequest) -> HttpResponse:
|
||||
```
|
||||
|
||||
[login-required-link]: https://docs.djangoproject.com/en/5.0/topics/auth/default/#django.contrib.auth.decorators.login_required
|
||||
|
||||
### Writing a template
|
||||
|
||||
Templates for the main website are found in
|
||||
[templates/zerver/app](https://github.com/zulip/zulip/tree/main/templates/zerver/app).
|
||||
|
||||
## Writing API REST endpoints
|
||||
|
||||
These are code-parsable views that take x-www-form-urlencoded or JSON
|
||||
request bodies, and return JSON-string responses. Almost all Zulip
|
||||
view code is in the implementations of API REST endpoints.
|
||||
|
||||
The REST API does authentication of the user through `rest_dispatch`,
|
||||
which is documented in detail at
|
||||
[zerver/lib/rest.py](https://github.com/zulip/zulip/blob/main/zerver/lib/rest.py).
|
||||
This method will authenticate the user either through a session token
|
||||
from a cookie on the browser, or from a base64 encoded `email:api-key`
|
||||
string given via HTTP basic auth for API clients.
|
||||
|
||||
```py
|
||||
>>> import requests
|
||||
>>> r = requests.get('https://api.github.com/user', auth=('hello@example.com', '0123456789abcdeFGHIJKLmnopQRSTUV'))
|
||||
>>> r.status_code
|
||||
-> 200
|
||||
```
|
||||
|
||||
### Request variables
|
||||
|
||||
Most API views will have some arguments that are passed as part of the
|
||||
request to control the behavior of the view. In any well-engineered
|
||||
view, you need to write code to parse and validate that the arguments
|
||||
exist and have the correct form. For many applications, this leads to
|
||||
one of several bad outcomes:
|
||||
|
||||
- The code isn't written, so arguments aren't validated, leading to
|
||||
bugs and confusing error messages for users of the API.
|
||||
- Every function starts with a long list of semi-redundant validation
|
||||
code, usually with highly inconsistent error messages.
|
||||
- Every view function comes with another function that does the
|
||||
validation that has the problems from the last bullet point.
|
||||
|
||||
In Zulip, we solve this problem with a special decorator called
|
||||
`typed_endpoint` which allows a developer to declare the
|
||||
arguments a view function takes and validate their types all within
|
||||
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
|
||||
conveniently located in the same place as the method it is validating
|
||||
arguments for.
|
||||
|
||||
Here's an example:
|
||||
|
||||
```py
|
||||
from zerver.decorator import require_realm_admin
|
||||
from zerver.lib.typed_endpoint import typed_endpoint
|
||||
|
||||
@require_realm_admin
|
||||
@typed_endpoint
|
||||
def create_user_backend(
|
||||
request: HttpRequest,
|
||||
user_profile: UserProfile,
|
||||
*,
|
||||
email: str,
|
||||
password: str,
|
||||
full_name: str,
|
||||
):
|
||||
# ... code here
|
||||
```
|
||||
|
||||
The `typed_endpoint` decorator parses the declared
|
||||
[keyword-only arguments](https://docs.python.org/3/glossary.html#term-parameter)
|
||||
of the decorated function, and for each argument that has been declared,
|
||||
it extracts the HTTP parameter with that name from the request,
|
||||
parses it according to the type annotation, and then passes it to
|
||||
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
|
||||
otherwise is invalid.
|
||||
|
||||
`require_realm_admin` is another decorator which checks the
|
||||
authorization of the given `user_profile` to make sure it belongs to a
|
||||
realm administrator (and thus has permission to create a user); we
|
||||
show it here primarily to show how `typed_endpoint` should be
|
||||
the inner decorator.
|
||||
|
||||
The implementation of `typed_endpoint` is documented in detail
|
||||
in
|
||||
[zerver/lib/typed_endpoint.py](https://github.com/zulip/zulip/blob/main/zerver/lib/typed_endpoint.py)
|
||||
|
||||
Pydantic also helps us with request variable validation. For example:
|
||||
|
||||
- `msg_ids: Json[list[int]]` will check that the `msg_ids`
|
||||
HTTP parameter is a list of integers, marshalled as JSON,
|
||||
and pass it into the function as the `msg_ids` Python
|
||||
keyword argument.
|
||||
|
||||
- `streams_raw: Annotated[Json[list[str]], ApiParamConfig("subscriptions")]`
|
||||
will check that the "subscriptions" HTTP parameter is a list of
|
||||
strings, marshalled as JSON, and pass it into the function with the
|
||||
Python keyword argument `streams_raw`.
|
||||
|
||||
- `message_id: Json[NonNegativeInt]` will check that the `message_id`
|
||||
HTTP parameter is a string containing a JSON encoded non-negative
|
||||
integer.
|
||||
|
||||
[Annotated](https://docs.python.org/3/library/typing.html#typing.Annotated)
|
||||
can be used in combination with
|
||||
[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.
|
||||
|
||||
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
|
||||
[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.
|
||||
|
||||
### Deciding which HTTP verb to use
|
||||
|
||||
When writing a new API view, you should writing a view to do just one
|
||||
type of thing. Usually that's either a read or write operation.
|
||||
|
||||
If you're reading data, GET is the best option. Other read-only verbs
|
||||
are HEAD, which should be used for testing if a resource is available to
|
||||
be read with GET, without the expense of the full GET. OPTIONS is also
|
||||
read-only, and used by clients to determine which HTTP verbs are
|
||||
available for a given path. This isn't something you need to write, as
|
||||
it happens automatically in the implementation of `rest_dispatch`--see
|
||||
[zerver/lib/rest.py](https://github.com/zulip/zulip/blob/main/zerver/lib/rest.py)
|
||||
for more.
|
||||
|
||||
If you're creating new data, try to figure out if the thing you are
|
||||
creating is uniquely identifiable. For example, if you're creating a
|
||||
user, there's only one user per email. If you can find a unique ID,
|
||||
you should use PUT for the view. If you want to create the data multiple
|
||||
times for multiple requests (for example, requesting the send_message
|
||||
view multiple times with the same content should send multiple
|
||||
messages), you should use POST.
|
||||
|
||||
If you're updating existing data, use PATCH.
|
||||
|
||||
If you're removing data, use DELETE.
|
||||
|
||||
### Idempotency
|
||||
|
||||
When writing a new API endpoint, with the exception of things like
|
||||
sending messages, requests should be safe to repeat, without impacting
|
||||
the state of the server. This is _idempotency_.
|
||||
|
||||
You will often want to return an error if a request to change
|
||||
something would do nothing because the state is already as desired, to
|
||||
make debugging Zulip clients easier. This means that the response for
|
||||
repeated requests may not be the same, but the repeated requests won't
|
||||
change the server more than once or cause unwanted side effects.
|
||||
|
||||
### Making changes to the database
|
||||
|
||||
If the view does any modification to the database, that change is done
|
||||
in a helper function in `zerver/actions/*.py`. Those functions are
|
||||
responsible for doing a complete update to the state of the server,
|
||||
which often entails both updating the database and sending any events
|
||||
to notify clients about the state change. When possible, we prefer to
|
||||
design a clean boundary between the view function and the actions
|
||||
function is such that all user input validation happens in the view
|
||||
code (i.e. all 400 type errors are thrown there), and the actions code
|
||||
is responsible for atomically executing the change (this is usually
|
||||
signalled by having the actions function have a name starting with
|
||||
`do_`). So in most cases, errors in an actions function will be the
|
||||
result of an operational problem (e.g., lost connection to the
|
||||
database) and lead to a 500 error. If an actions function is
|
||||
responsible for validation as well, it should have a name starting
|
||||
with `check_`.
|
||||
|
||||
For example, in [zerver/views/realm.py](https://github.com/zulip/zulip/blob/main/zerver/views/realm.py):
|
||||
|
||||
```py
|
||||
@require_realm_admin
|
||||
@typed_endpoint
|
||||
def update_realm(
|
||||
request: HttpRequest,
|
||||
user_profile: UserProfile,
|
||||
*,
|
||||
name: Annotated[str | None, StringConstraints(max_length=Realm.MAX_REALM_NAME_LENGTH)] = None,
|
||||
# ...
|
||||
):
|
||||
realm = user_profile.realm
|
||||
# ...
|
||||
do_set_realm_property(realm, k, v, acting_user=user_profile)
|
||||
# ...
|
||||
```
|
||||
|
||||
`realm.save()` actually saves the changes to the realm to the
|
||||
database, and `send_event_on_commit` sends the event to active clients
|
||||
belonging to the provided list of users (in this case, all active
|
||||
users in the Zulip realm), once the current transaction completes.
|
||||
|
||||
### Calling from the web application
|
||||
|
||||
You should always use `channel.<method>` to make an `HTTP <method>` call
|
||||
to the Zulip JSON API. As an example, in
|
||||
[web/src/admin.ts](https://github.com/zulip/zulip/blob/main/web/src/admin.ts)
|
||||
|
||||
```js
|
||||
var url = "/json/realm";
|
||||
var data = {
|
||||
name: JSON.stringify(new_name),
|
||||
}
|
||||
channel.patch({
|
||||
url: url,
|
||||
data: data,
|
||||
success: function (response_data) {
|
||||
if (response_data.name !== undefined) {
|
||||
ui_report.success($t({defaultMessage: "Name changed!"}), name_status);
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
### Calling from an API client
|
||||
|
||||
Here's how you might manually make a call from python:
|
||||
|
||||
```py
|
||||
payload = {'name': new_name}
|
||||
|
||||
# email and API key
|
||||
api_auth = ('hello@example.com', '0123456789abcdeFGHIJKLmnopQRSTUV')
|
||||
|
||||
r = requests.patch(SERVER_URL + 'api/v1/realm',
|
||||
data=json.dumps(payload),
|
||||
auth=api_auth,
|
||||
)
|
||||
```
|
||||
|
||||
This is simply an illustration; we recommend making use of the [Zulip
|
||||
Python API bindings](https://zulip.com/api/) since they provide
|
||||
a nice interface for accessing the API.
|
||||
|
||||
## Legacy endpoints used by the web client
|
||||
|
||||
New features should conform the REST API style. The legacy, web-only
|
||||
endpoints can't effectively enforce usage of a browser, so they aren't
|
||||
preferable from a security perspective, and it is generally a good idea
|
||||
to make your feature available to other clients, especially the mobile
|
||||
clients.
|
||||
|
||||
These endpoints make use the older authentication decorator
|
||||
`authenticated_json_view`, so you may see it in the code.
|
||||
|
||||
## Incoming webhook integrations
|
||||
|
||||
Incoming webhooks are called by other services, often to send a message as part
|
||||
of those services' integrations. They are most often POST requests, and
|
||||
often there is very little you can customize about them. Usually you can
|
||||
expect that the webhook for a service will allow specification for the
|
||||
target server for the webhook, and an API key.
|
||||
|
||||
If the webhook does not have an option to provide a bot email, use the
|
||||
`webhook_view` decorator, to fill in the `user_profile` and
|
||||
`request.client` fields of a request:
|
||||
|
||||
```py
|
||||
@webhook_view("PagerDuty", all_event_types=ALL_EVENT_TYPES)
|
||||
@typed_endpoint
|
||||
def api_pagerduty_webhook(
|
||||
request: HttpRequest,
|
||||
user_profile: UserProfile,
|
||||
*,
|
||||
payload: JsonBodyPayload[WildValue],
|
||||
```
|
||||
|
||||
`request.client` will be the result of `get_client("ZulipPagerDutyWebhook")`
|
||||
in this example and it will be passed to `check_send_stream_message`. For
|
||||
more information, see [Clients in Zulip](../subsystems/client.md).
|
||||
Reference in New Issue
Block a user