bottleneckcrm/docs/api/conventions.md

210 lines
11 KiB
Markdown
Raw Permalink Normal View History

# Conventions
This page describes patterns that hold across most of the API: how the base URL is put together,
how a client authenticates, how list endpoints paginate and filter, and what a response body looks
like. It is deliberately drawn from the code that is actually there (a real pagination class, a
real list view, the real `REST_FRAMEWORK` settings) rather than from what a typical DRF API "should"
look like. Several of these conventions are less uniform than that would suggest, and the
differences matter to anyone integrating against them.
## Base URL
Every endpoint in this section, and every endpoint in [Endpoint index](endpoint-index.md), is
mounted under `/api/` at the Django root (`backend/crm/urls.py`: `path("api/",
include("common.app_urls", ...))`), which in turn includes `backend/common/urls.py`, the file most
of this section's page references point at, at the same, empty prefix. Individual apps are mounted
under their own segment from there, for example `path("leads/", include("leads.urls", ...))`, so a
lead endpoint is `/api/leads/…`.
Locally this is `http://localhost:8000/api/` (the port the Docker quick start publishes. Inside
the compose network the frontend reaches the same API as `http://backend:8000`. See
[Docker quick start](../getting-started/docker-quick-start.md)). In a self-hosted deployment it is
whatever host you put `DOMAIN_NAME` / `ALLOWED_HOSTS` on, still under `/api/`.
## Authentication header
Three credential types authenticate a request, tried by `common.middleware.get_company.GetProfileAndOrg`
in this order and, independently, by the DRF authentication classes in `DEFAULT_AUTHENTICATION_CLASSES`
(`backend/crm/settings.py`, `common.pat_auth.PATAuthentication`,
`rest_framework_simplejwt.authentication.JWTAuthentication`, `common.external_auth.APIKeyAuthentication`):
```
Authorization: Bearer <jwt-access-token> # interactive sign-in (see authentication.md)
Authorization: Bearer bcrm_pat_... # personal access token
Token: bcrm_pat_... # personal access token, alternate header
Token: <org-api-key> # organization API key
```
A personal access token is recognized by its `bcrm_pat_` prefix and can be presented either as a
bearer token or in the `Token` header (`backend/common/pat_auth.py`, `_extract_raw`); an organization
API key is only ever read from `Token` (`backend/common/external_auth.py`,
`APIKeyAuthentication.authenticate`). See [Authentication](authentication.md) for how a JWT is
obtained and [Tokens and API keys](tokens-and-api-keys.md) for the other two. Which identity a
request authenticates as, and which org it is scoped to, is never something the request supplies
directly. It is derived from whichever of these three the server can validate.
The two non-interactive credentials are bounded in ways a signed-in session is not. A personal
access token is limited to its `scopes` (`<resource>:<action>`, enforced in middleware before the
view runs; an empty list means unrestricted). The organization API key is read-only. Neither may
reach `/api/profile/tokens/`, `/api/org/tokens/` or `/api/org/api-key/` at all: credential
management requires an interactive sign-in. See
[Tokens and API keys](tokens-and-api-keys.md#scopes).
One thing the generated OpenAPI schema does *not* reflect accurately: a large minority of
operations (112 of the schema's 350, about a third) declare an `org` header parameter
(`organization_params_in_header`, defined in `backend/common/swagger_params.py` and copied into the
`swagger_params.py` of six other apps: `leads`, `cases`, `opportunity`, `contacts`, `accounts` and
`tasks`). Nothing in the
request-handling code reads a header by that name. Org context comes only from the JWT's `org_id`
claim, the PAT's owning profile, or the API key's associated org, as above. Sending an `org` header
has no effect; do not rely on it.
## Pagination
List endpoints paginate with `rest_framework.pagination.LimitOffsetPagination`
(`DEFAULT_PAGINATION_CLASS` in `backend/crm/settings.py`), using the query parameters `limit` and
`offset`. The default page size is `PAGE_SIZE = 10` from the same settings block; there is no
`max_limit` set on the pagination class, so a caller can request a larger page and get it.
That default pagination class is not applied automatically, though. There is no
`generics.ListAPIView` anywhere in this codebase. Every list endpoint is a hand-written `APIView`
that mixes `LimitOffsetPagination` in directly and calls `self.paginate_queryset(...)` itself, for
example:
```python
class LeadListView(APIView, LimitOffsetPagination):
...
def get_context_data(self, **kwargs):
...
results_leads_open = self.paginate_queryset(
queryset_open.distinct(), self.request, view=self
)
```
(`backend/leads/views/lead_views.py:51`, `:167`.) The same shape repeats in `common/views/user_views.py`,
`common/views/team_views.py`, `common/views/document_views.py`, `common/views/tags_views.py`,
`cases/views.py`, `contacts/views.py`, `accounts/views.py`, `opportunity/views/opportunity_views.py`,
`tasks/views/task_views.py` and `invoices/api_views.py`, among others.
Because pagination is applied by hand, the response envelope is a per-endpoint choice, not a
guarantee. The common case in this codebase is that a view builds its own shape rather than calling
DRF's `get_paginated_response()`: `GET /api/leads/` reports pagination per section, since the list
is split into open and closed leads:
```python
context["open_leads"] = {
"leads_count": self.count, # LimitOffsetPagination's own running total
"open_leads": open_leads, # this page's serialized results
"offset": offset, # offset to request for the next page, or None on the last page
}
```
(`backend/leads/views/lead_views.py:180-184`.) But `get_paginated_response()`, and with it, DRF's
standard `count`/`next`/`previous`/`results` shape, does appear: `GET /api/cases/solutions/`
(`SolutionListView`, `backend/cases/solution_views.py:85`, routed at `backend/cases/urls.py:59`)
calls it directly and adds one extra key on top:
```python
response = self.get_paginated_response(serializer.data)
response.data["totals"] = counts
return response
```
(`backend/cases/solution_views.py:178-180`.) So both shapes are real. Treat the exact pagination
field names, and whether the standard DRF envelope or a hand-rolled one is used, as per-endpoint:
`limit`/`offset` as query parameters are consistent, but where the resulting count, next-offset and
results end up in the response body is not; check the endpoint you're calling.
## Filtering and search
There is no shared filter backend; `DEFAULT_FILTER_BACKENDS` is not set in `REST_FRAMEWORK`, and
no view uses `django_filters`. Each list view reads `request.query_params` directly inside its own
`get`/`get_context_data` and applies whatever filters it defines. `GET /api/leads/`
(`backend/leads/views/lead_views.py:105-160`) is representative of how rich these can get:
| Query parameter | Matches |
| --- | --- |
| `name` | `first_name` or `last_name`, case-insensitive contains |
| `salutation` | case-insensitive contains |
| `source` | exact |
| `assigned_to` | repeatable; `assigned_to__id__in` |
| `status` | exact |
| `tags` | repeatable; `tags__id__in` |
| `city` | case-insensitive contains |
| `email` | case-insensitive contains |
| `rating` | exact |
| `search` | `first_name`, `last_name`, `company_name` or `email`, case-insensitive contains, OR'd together |
| `created_at__gte`, `created_at__lte` | date range |
| `close_date__gte`, `close_date__lte` | date range |
| `cf_<key>` | custom field `<key>` equals the given value (`custom_fields__contains`) |
For example, `GET /api/leads/?status=assigned&source=partner&search=acme`; `status` and `source`
are exact matches against `LEAD_STATUS` and `LEAD_SOURCE` (`backend/common/utils.py:50-67`:
statuses are `assigned`, `in process`, `converted`, `recycled`, `closed`; sources are `call`,
`email`, `existing customer`, `partner`, `public relations`, `compaign`, `other`), so a filter value
outside those exact strings matches nothing rather than erroring. Other list endpoints define
their own, generally smaller, filter sets in the same style. There is no single reference for
"every filter the API supports"; read the view (or watch for a dedicated section on that resource's
page, where one exists) rather than assuming this table applies elsewhere.
## Response shape
There is no single response envelope used across the whole API. Three shapes recur, and which one
a given endpoint uses is part of that endpoint's own contract:
**Mutations wrapped in an `error` flag.** The large majority of create/update/delete endpoints
return `{"error": bool, ...}`, checked explicitly rather than relying on the HTTP status code alone,
for example a successful lead create (`backend/leads/views/lead_views.py:402-404`):
```json
{"error": false, "message": "Lead Created Successfully"}
```
and a failed one (`backend/leads/views/lead_views.py:406-408`):
```json
{"error": true, "errors": {"email": ["Another lead in this organisation already uses that email address."]}}
```
The same `{"error": false, "tokens": [...]}` shape is used by the personal-access-token list in
[Tokens and API keys](tokens-and-api-keys.md#personal-access-tokens).
**Bare serializer data.** Some newer endpoints skip the `error` wrapper entirely and return
`serializer.data` (or the created/updated object's serialization) directly, relying on
`serializer.is_valid(raise_exception=True)` and the HTTP status code to signal failure. For
example `MacroListCreateView.post` returns `Response(MacroSerializer(macro).data, status=201)`
(`backend/macros/views.py:136`).
**Single-object detail responses nest the record, not return it bare.** `GET /api/leads/{id}/`
does not return a `Lead` object at the top level; it returns the record under `lead_obj` alongside
related data fetched in the same call:
```json
{
"lead_obj": {"id": "...", "first_name": "...", "...": "..."},
"attachments": [...],
"comments": [...],
"users_mention": [...],
"assigned_data": [...]
}
```
(`backend/leads/views/lead_views.py:412-517`.) Don't assume a detail endpoint returns its resource
at the top level of the response; check the endpoint.
## Content types
`REST_FRAMEWORK` sets no `DEFAULT_PARSER_CLASSES`, so DRF's own defaults apply: a request body may
be `application/json`, `application/x-www-form-urlencoded`, or `multipart/form-data`
(`JSONParser`, `FormParser`, `MultiPartParser`, in that order). `multipart/form-data` is what every
endpoint that accepts a file upload alongside other fields needs. For example `POST /api/leads/`
reads an optional `lead_attachment` file straight off `request.FILES` in the same call that creates
the lead (`backend/leads/views/lead_views.py:330`), so a lead with an initial attachment is created
with one multipart request rather than a create-then-upload round trip.
`REST_FRAMEWORK` sets no `DEFAULT_RENDERER_CLASSES` either, so both of DRF's defaults are live:
`JSONRenderer` for `application/json` (what every example on this page assumes, and what every
client integration should send `Accept: application/json` for) and `BrowsableAPIRenderer` for
`text/html`: request the same endpoint with a browser's default `Accept` header and DRF's
interactive HTML form is what comes back instead of JSON.