vintasend-api 3.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vintasend_api-3.5.0/PKG-INFO +528 -0
- vintasend_api-3.5.0/README.md +489 -0
- vintasend_api-3.5.0/openapi.yaml +666 -0
- vintasend_api-3.5.0/pyproject.toml +146 -0
- vintasend_api-3.5.0/vintasend_api/__init__.py +0 -0
- vintasend_api-3.5.0/vintasend_api/asgi.py +15 -0
- vintasend_api-3.5.0/vintasend_api/dashboard/__init__.py +0 -0
- vintasend_api-3.5.0/vintasend_api/dashboard/api.py +390 -0
- vintasend_api-3.5.0/vintasend_api/dashboard/apps.py +88 -0
- vintasend_api-3.5.0/vintasend_api/dashboard/auth.py +152 -0
- vintasend_api-3.5.0/vintasend_api/dashboard/bodies.py +73 -0
- vintasend_api-3.5.0/vintasend_api/dashboard/capabilities.py +78 -0
- vintasend_api-3.5.0/vintasend_api/dashboard/conf.py +113 -0
- vintasend_api-3.5.0/vintasend_api/dashboard/contract.py +204 -0
- vintasend_api-3.5.0/vintasend_api/dashboard/cors.py +81 -0
- vintasend_api-3.5.0/vintasend_api/dashboard/errors.py +84 -0
- vintasend_api-3.5.0/vintasend_api/dashboard/filters.py +155 -0
- vintasend_api-3.5.0/vintasend_api/dashboard/hooks.py +129 -0
- vintasend_api-3.5.0/vintasend_api/dashboard/preview.py +91 -0
- vintasend_api-3.5.0/vintasend_api/dashboard/query.py +89 -0
- vintasend_api-3.5.0/vintasend_api/dashboard/serialize.py +173 -0
- vintasend_api-3.5.0/vintasend_api/dashboard/service.py +324 -0
- vintasend_api-3.5.0/vintasend_api/dashboard/template_source.py +290 -0
- vintasend_api-3.5.0/vintasend_api/dashboard/urls.py +25 -0
- vintasend_api-3.5.0/vintasend_api/dashboard/views.py +23 -0
- vintasend_api-3.5.0/vintasend_api/py.typed +0 -0
- vintasend_api-3.5.0/vintasend_api/settings.py +147 -0
- vintasend_api-3.5.0/vintasend_api/urls.py +21 -0
- vintasend_api-3.5.0/vintasend_api/vintasend_config.example.py +69 -0
- vintasend_api-3.5.0/vintasend_api/wsgi.py +10 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vintasend-api
|
|
3
|
+
Version: 3.5.0
|
|
4
|
+
Summary: REST API that exposes a VintaSend notification service over HTTP for the VintaSend dashboard UI
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: vintasend,notifications,django,django-ninja,api
|
|
7
|
+
Author: Vinta Software
|
|
8
|
+
Author-email: contact@vinta.com.br
|
|
9
|
+
Requires-Python: >=3.10,<3.15
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Environment :: Web Environment
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Framework :: Django :: 4.2
|
|
14
|
+
Classifier: Framework :: Django :: 5.0
|
|
15
|
+
Classifier: Framework :: Django :: 5.1
|
|
16
|
+
Classifier: Framework :: Django :: 5.2
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Communications :: Email
|
|
26
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Dist: django (>=4.2,<6.0)
|
|
29
|
+
Requires-Dist: django-ninja (>=1.4.3,<2.0.0)
|
|
30
|
+
Requires-Dist: python-dotenv (>=1.1.1,<2.0.0)
|
|
31
|
+
Requires-Dist: requests (>=2.34.2,<3.0.0)
|
|
32
|
+
Requires-Dist: vintasend (==3.5.0)
|
|
33
|
+
Project-URL: Changelog, https://github.com/vintasoftware/vintasend/blob/main/RELEASE_NOTES.md
|
|
34
|
+
Project-URL: Homepage, https://github.com/vintasoftware/vintasend-api
|
|
35
|
+
Project-URL: Issues, https://github.com/vintasoftware/vintasend-api/issues
|
|
36
|
+
Project-URL: Repository, https://github.com/vintasoftware/vintasend-api
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# VintaSend API (Python)
|
|
40
|
+
|
|
41
|
+
REST API that exposes a [VintaSend](https://github.com/vintasoftware/vintasend)
|
|
42
|
+
notification service over HTTP, built with Django and
|
|
43
|
+
[django-ninja](https://django-ninja.dev/).
|
|
44
|
+
|
|
45
|
+
It exists so the [VintaSend dashboard](https://github.com/vintasoftware/vintasend-dashboard)
|
|
46
|
+
no longer has to embed a notification service: the dashboard is a pure API client, and
|
|
47
|
+
any implementation of this contract can serve it.
|
|
48
|
+
|
|
49
|
+
**[`openapi.yaml`](https://github.com/vintasoftware/vintasend-api/blob/main/openapi.yaml) is the contract.** It is shipped here byte-identical
|
|
50
|
+
to the copy in [`vintasend-ts-api`](https://github.com/vintasoftware/vintasend-ts-api),
|
|
51
|
+
the TypeScript reference implementation. This project is the Python implementation of the
|
|
52
|
+
same document, so one dashboard consumes either without knowing which is behind it.
|
|
53
|
+
|
|
54
|
+
This repository is developed and released on its own, and the
|
|
55
|
+
[`vintasend`](https://github.com/vintasoftware/vintasend) library repository tracks it as a
|
|
56
|
+
git submodule under `tools/vintasend-api` — the same arrangement its `implementations/`
|
|
57
|
+
packages use. Contribute here; the parent repo only records which commit it points at.
|
|
58
|
+
|
|
59
|
+
## Why Django, for a library with no web framework
|
|
60
|
+
|
|
61
|
+
The notification store is the deciding factor. `vintasend-django` persists notifications
|
|
62
|
+
through the Django ORM, and reading them needs a Django app registry and connection
|
|
63
|
+
handling — a FastAPI process would have to boot a half-configured Django anyway to use
|
|
64
|
+
it. Serving from Django removes that.
|
|
65
|
+
|
|
66
|
+
Nothing is lost for non-Django deployments. The backend is a pluggable seam, so a
|
|
67
|
+
FastAPI application storing notifications through `vintasend-sqlalchemy` is served by
|
|
68
|
+
this same API: point `NOTIFICATION_SERVICE_FACTORY` at a factory that builds a
|
|
69
|
+
SQLAlchemy-backed service and the HTTP layer neither knows nor cares.
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
┌─────────────────────┐ HTTPS + API key ┌──────────────────┐
|
|
73
|
+
│ Dashboard (Next) │ ───────────────────▶ │ vintasend-api │
|
|
74
|
+
│ server-side only │ ◀─────────────────── │ (this project) │
|
|
75
|
+
└─────────────────────┘ JSON contract └────────┬─────────┘
|
|
76
|
+
│
|
|
77
|
+
┌──────────────┴──────────────┐
|
|
78
|
+
│ Your VintaSend service │
|
|
79
|
+
│ backend + adapters + │
|
|
80
|
+
│ template renderer │
|
|
81
|
+
└─────────────────────────────┘
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The API owns everything that needs backend credentials — database access, template
|
|
85
|
+
rendering, GitHub template lookups. The UI owns presentation and user authentication.
|
|
86
|
+
|
|
87
|
+
## Endpoints
|
|
88
|
+
|
|
89
|
+
| Method | Path | Purpose |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| GET | `/health` | Liveness probe (unauthenticated) |
|
|
92
|
+
| GET | `/api/v1/capabilities` | Filter/order capabilities of the configured backend |
|
|
93
|
+
| GET | `/api/v1/notifications` | List notifications with filters, ordering and pagination |
|
|
94
|
+
| GET | `/api/v1/notifications/pending` | Notifications awaiting send |
|
|
95
|
+
| GET | `/api/v1/notifications/future` | Notifications scheduled for the future |
|
|
96
|
+
| GET | `/api/v1/notifications/one-off` | One-off notifications |
|
|
97
|
+
| GET | `/api/v1/notifications/{id}` | One notification, including context payloads |
|
|
98
|
+
| GET | `/api/v1/notifications/{id}/preview` | Templates rendered at the notification's commit |
|
|
99
|
+
| POST | `/api/v1/notifications/{id}/resend` | Resend a notification |
|
|
100
|
+
| POST | `/api/v1/notifications/{id}/cancel` | Cancel a pending notification |
|
|
101
|
+
|
|
102
|
+
A browsable version of the generated schema is served at `/api/v1/docs`.
|
|
103
|
+
|
|
104
|
+
Conventions the dashboard depends on:
|
|
105
|
+
|
|
106
|
+
- `page` is **1-indexed** on the wire.
|
|
107
|
+
- `hasMore` is `true` when the next page has at least one row, so a list that exactly
|
|
108
|
+
fills its last page never offers an empty one. Backends are not required to produce a
|
|
109
|
+
total count: after a full page, the API reads the one row that would follow it.
|
|
110
|
+
- List rows carry a `kind` field (`user` or `one-off`) so clients can discriminate
|
|
111
|
+
without sniffing for the presence of fields.
|
|
112
|
+
- Timestamps are ISO-8601 UTC strings, `null` when unset — never absent.
|
|
113
|
+
- Every notification payload carries `requestedTemplateVersion` and `usedTemplateVersion`,
|
|
114
|
+
which are `null` for a service whose template renderer has no versions. See
|
|
115
|
+
[Template versions](#template-versions).
|
|
116
|
+
- Errors always use the envelope `{ "error": { "code", "message", "details"? } }`, and
|
|
117
|
+
every 400 carries `details.issues: [{ path, message }]` — `path` empty for the body as a
|
|
118
|
+
whole.
|
|
119
|
+
- Request bodies are JSON. A request declaring `application/json` (or any
|
|
120
|
+
`application/*+json`) must carry a valid JSON object. One declaring no media type, or
|
|
121
|
+
another one, counts as an omitted body when it is empty and is a 400 otherwise:
|
|
122
|
+
`curl -d` sends form encoding unless told otherwise, and reading that body as `{}` would
|
|
123
|
+
resend with a regenerated context instead of the stored one.
|
|
124
|
+
- `FORBIDDEN` (403) is declared on every route, for a host that authenticates callers
|
|
125
|
+
itself and refuses one it knows. The API key alone never produces it.
|
|
126
|
+
|
|
127
|
+
## Template versions
|
|
128
|
+
|
|
129
|
+
A notification records which version of its template it renders, for services whose
|
|
130
|
+
template renderer versions templates at all — a store-backed one such as
|
|
131
|
+
[`vintasend-managed-templates`](https://github.com/vintasoftware/vintasend-managed-templates).
|
|
132
|
+
Two fields on every notification payload say it:
|
|
133
|
+
|
|
134
|
+
| Field | Written | Means |
|
|
135
|
+
| --- | --- | --- |
|
|
136
|
+
| `requestedTemplateVersion` | at create/update | The version this notification is pinned to. `null` is not "unknown": it means the notification was deliberately left unpinned and renders whatever version is current when it sends. |
|
|
137
|
+
| `usedTemplateVersion` | after the send | What the renderer reported it actually used. `null` until the notification has been sent. |
|
|
138
|
+
|
|
139
|
+
On a pinned notification the two read the same, and an edit to the template cannot change
|
|
140
|
+
what it renders — that is what pinning is for. On an unpinned one they differ, and
|
|
141
|
+
`usedTemplateVersion` is the only record of which version went out: by the time anyone
|
|
142
|
+
asks, the template has moved on.
|
|
143
|
+
|
|
144
|
+
Both are `null` for a file-based renderer, which has no versions to report, and for any
|
|
145
|
+
notification created before pinning existed. A dashboard should read `null` as *not
|
|
146
|
+
applicable* rather than as a missing value, and hide the field rather than showing a zero.
|
|
147
|
+
|
|
148
|
+
This is independent of `gitCommitSha`, which pins the same idea for the other kind of
|
|
149
|
+
renderer: templates as files in a repository. A notification uses one mechanism or the
|
|
150
|
+
other, never both, so a payload with a `gitCommitSha` normally has null versions and one
|
|
151
|
+
with versions normally has a null SHA.
|
|
152
|
+
|
|
153
|
+
Both are filterable on the listing:
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
GET /api/v1/notifications?requestedTemplateVersion=3 # pinned to v3
|
|
157
|
+
GET /api/v1/notifications?usedTemplateVersion=3 # actually rendered v3 — the audit query
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
They match exactly, and they never match a notification whose version is `null` — the
|
|
161
|
+
library's NULL semantics, the same as every other filter field. So there is no way to ask
|
|
162
|
+
for "the unpinned ones" positively; `not` is what includes them, and this API exposes no
|
|
163
|
+
`not`. A negative version is a 400, but the floor is 0 rather than 1: version numbering
|
|
164
|
+
belongs to the template renderer, and this API has no basis for assuming it is 1-based.
|
|
165
|
+
|
|
166
|
+
A backend that cannot evaluate either filter reports `fields.requestedTemplateVersion` /
|
|
167
|
+
`fields.usedTemplateVersion` as `false` in `/capabilities`, and the dashboard greys the
|
|
168
|
+
control out. Unlike ordering, an unsupported *field* filter is not dropped server-side —
|
|
169
|
+
that has always been this API's split, and these two follow it.
|
|
170
|
+
|
|
171
|
+
## Authentication
|
|
172
|
+
|
|
173
|
+
By default, every `/api/v1` request must carry the shared secret:
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
Authorization: Bearer $VINTASEND_API_KEY
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The dashboard calls this API only from its own server side, so the key never reaches a
|
|
180
|
+
browser. If you do need to call the API from a browser, set `VINTASEND_API_CORS_ORIGINS`
|
|
181
|
+
to the allowed origins — and put a per-user auth layer in front of it first.
|
|
182
|
+
|
|
183
|
+
A host that authenticates callers itself sets `VINTASEND_API_AUTHENTICATOR` instead. See
|
|
184
|
+
[Authenticating callers yourself](#authenticating-callers-yourself).
|
|
185
|
+
|
|
186
|
+
## Installing and embedding
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
pip install vintasend-api
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The package is a Django app, `vintasend_api.dashboard`, plus a project that runs it on its
|
|
193
|
+
own (see [Running it on its own](#running-it-on-its-own)). A Django project of yours can
|
|
194
|
+
serve the API itself: install the app and include its URLs under a prefix.
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
# settings.py
|
|
198
|
+
INSTALLED_APPS = [
|
|
199
|
+
# ...
|
|
200
|
+
"vintasend_api.dashboard",
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
NOTIFICATION_SERVICE_FACTORY = "myproject.notifications.create_notification_service"
|
|
204
|
+
VINTASEND_API_AUTHENTICATOR = "myproject.notifications_auth.authenticate"
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
# urls.py
|
|
209
|
+
from django.urls import include, path
|
|
210
|
+
|
|
211
|
+
urlpatterns = [
|
|
212
|
+
# ...
|
|
213
|
+
path("notifications-api/", include("vintasend_api.dashboard.urls")),
|
|
214
|
+
]
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
That serves `/notifications-api/health` and everything under `/notifications-api/api/v1/`.
|
|
218
|
+
The app's own namespace is `vintasend_api`, so `reverse("vintasend_api:api-root")` finds the
|
|
219
|
+
API wherever it is mounted, and a project can mount
|
|
220
|
+
[`vintasend-templates-management-api`](https://github.com/vintasoftware/vintasend-templates-management-api)
|
|
221
|
+
beside it without the two colliding. Include the URLconf once per project: a second
|
|
222
|
+
include registers the same namespaces again, and `reverse` only ever finds one of them. The
|
|
223
|
+
app has no models and no migrations. It needs nothing from the bundled project: not its
|
|
224
|
+
settings module, its `handler404` or its middleware.
|
|
225
|
+
|
|
226
|
+
The app reads these settings, each at the moment it is used, so `override_settings` works
|
|
227
|
+
on them. Every one the host leaves out falls back to the default shown.
|
|
228
|
+
|
|
229
|
+
| Setting | Required | Default | Description |
|
|
230
|
+
| --- | --- | --- | --- |
|
|
231
|
+
| `NOTIFICATION_SERVICE_FACTORY` | yes | — | Dotted path to the callable building your VintaSend service. See [Configuring your VintaSend service](#configuring-your-vintasend-service). |
|
|
232
|
+
| `VINTASEND_API_AUTHENTICATOR` | this or the key | unset | Callable `(request) -> None`, or its dotted path, that authenticates callers. When set, the key is not checked. |
|
|
233
|
+
| `VINTASEND_API_KEY` | this or the authenticator | `""` | Shared secret callers send as a bearer token. Only read when no authenticator is set. |
|
|
234
|
+
| `VINTASEND_BACKEND_IDENTIFIER` | no | `None` | Read from a non-primary backend registered in your service. |
|
|
235
|
+
| `VINTASEND_UNHANDLED_ERROR_HANDLER` | no | unset | Callable `(exc, request, request_id)`, or its dotted path, receiving every unexpected error. See [Unexpected errors](#unexpected-errors). |
|
|
236
|
+
| `VINTASEND_API_CORS_ORIGINS` | no | `()` | Browser origins allowed to call the API. Only read by the optional CORS middleware. |
|
|
237
|
+
| `GITHUB_REPO` / `GITHUB_API_KEY` | preview only | `""` | Repository holding the templates, and a token with read access to it. |
|
|
238
|
+
| `GITHUB_API_BASE_URL` | no | `https://api.github.com` | GitHub API root. |
|
|
239
|
+
| `GITHUB_TEMPLATES_BASE_PATH` | no | `""` | Prefix added to template paths before the GitHub lookup. |
|
|
240
|
+
| `GITHUB_TEMPLATE_CACHE_MAX_ENTRIES` | no | `100` | Template files kept in memory per process. |
|
|
241
|
+
| `GITHUB_TEMPLATE_TIMEOUT_SECONDS` | no | `10` | Timeout of each GitHub request. |
|
|
242
|
+
|
|
243
|
+
`manage.py check` reports a missing service factory, a missing key when no authenticator is
|
|
244
|
+
set, and an authenticator or error handler that cannot be imported. See
|
|
245
|
+
[Environment variables](#environment-variables) for the check IDs.
|
|
246
|
+
|
|
247
|
+
### Authenticating callers yourself
|
|
248
|
+
|
|
249
|
+
`VINTASEND_API_AUTHENTICATOR` runs before every `/api/v1` route, in place of the shared key.
|
|
250
|
+
It refuses a caller by raising `ApiError.unauthorized(...)` when no valid credential was
|
|
251
|
+
presented, and `ApiError.forbidden(...)` when it knows who is calling and refuses them: a
|
|
252
|
+
401 would tell a signed-in user to sign in again. Both answer in the contract's error
|
|
253
|
+
envelope. Returning lets the request through. It may be `async`. It mirrors the TypeScript
|
|
254
|
+
reference's `authenticate` option.
|
|
255
|
+
|
|
256
|
+
`bearer_token(request)` reads the token of an `Authorization: Bearer` header, matching the
|
|
257
|
+
scheme in any case, and is `None` when the request carries none:
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
# myproject/notifications_auth.py
|
|
261
|
+
from django.http import HttpRequest
|
|
262
|
+
|
|
263
|
+
from vintasend_api.dashboard.auth import ApiError, bearer_token
|
|
264
|
+
|
|
265
|
+
from myproject.identity import verify_access_token # your own
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def authenticate(request: HttpRequest) -> None:
|
|
269
|
+
token = bearer_token(request)
|
|
270
|
+
claims = verify_access_token(token) if token else None
|
|
271
|
+
if claims is None:
|
|
272
|
+
raise ApiError.unauthorized("Sign in first.")
|
|
273
|
+
if "notifications:manage" not in claims.scopes:
|
|
274
|
+
raise ApiError.forbidden("Not allowed.")
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Give the setting as a dotted path. Assigning the function itself also works, but importing
|
|
278
|
+
it into `settings.py` imports django-ninja while the settings are still being defined, and
|
|
279
|
+
django-ninja reads its own `NINJA_*` settings at import: any defined further down are missed.
|
|
280
|
+
|
|
281
|
+
Raise `ApiError`, which `vintasend_api.dashboard.auth` re-exports. The setting has the same
|
|
282
|
+
name and shape in `vintasend-templates-management-api`, so a project mounting both can point
|
|
283
|
+
them at one function, and that function may raise either package's `ApiError`: a refusal is
|
|
284
|
+
recognised by its class name and its code, as the TypeScript packages do, not by its class.
|
|
285
|
+
Only `UNAUTHORIZED` and `FORBIDDEN` count as a refusal; an `ApiError` with any other code, like
|
|
286
|
+
any other exception, is an unexpected error and answers 500.
|
|
287
|
+
|
|
288
|
+
`check_api_key(request)` in the same module is the shared-key check the app runs when no
|
|
289
|
+
authenticator is set, for an authenticator that still accepts the key, such as from a
|
|
290
|
+
server-side caller.
|
|
291
|
+
|
|
292
|
+
An authenticator that cannot be imported does not fall back to the key: every request is
|
|
293
|
+
answered with a 500 until it is fixed, and `manage.py check` reports it as
|
|
294
|
+
`vintasend_api.E004`.
|
|
295
|
+
|
|
296
|
+
Prefer a credential the caller sends explicitly, like the bearer token above. The API's views
|
|
297
|
+
are CSRF-exempt, as a bearer-token API's are, so an authenticator that trusts the session
|
|
298
|
+
cookie leaves the resend and cancel routes open to cross-site request forgery.
|
|
299
|
+
|
|
300
|
+
### Optional extras
|
|
301
|
+
|
|
302
|
+
Two things the bundled project sets up that a host may want too:
|
|
303
|
+
|
|
304
|
+
- **CORS.** Add `"vintasend_api.dashboard.cors.CorsMiddleware"` to `MIDDLEWARE` and list the
|
|
305
|
+
origins in `VINTASEND_API_CORS_ORIGINS` to let browsers call the API. It applies to the
|
|
306
|
+
API's routes only, under whatever prefix they are mounted. Without it, the host's own CORS
|
|
307
|
+
handling applies, or none.
|
|
308
|
+
- **The 404 envelope.** Within the API's routes, errors use the contract's envelope. A path
|
|
309
|
+
that matches no route at all is answered by the project's `handler404`, which in the
|
|
310
|
+
bundled project is `vintasend_api.dashboard.views.envelope_404`. A host can set it too,
|
|
311
|
+
but it then answers every unmatched path in the host's project in that shape.
|
|
312
|
+
|
|
313
|
+
## Running it on its own
|
|
314
|
+
|
|
315
|
+
The package also carries a complete Django project for a deployment with no Django project of
|
|
316
|
+
its own. It is configured from environment variables, read from a `.env` file in the working
|
|
317
|
+
directory when there is one.
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
pip install vintasend-api gunicorn
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Write the factory building your service in a module of your own, say `vintasend_config.py`
|
|
324
|
+
in the working directory, and point `NOTIFICATION_SERVICE_FACTORY` at it (see
|
|
325
|
+
[Configuring your VintaSend service](#configuring-your-vintasend-service)). Then check the
|
|
326
|
+
configuration and serve:
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
export DJANGO_SETTINGS_MODULE=vintasend_api.settings
|
|
330
|
+
export NOTIFICATION_SERVICE_FACTORY=vintasend_config.create_notification_service
|
|
331
|
+
export VINTASEND_API_KEY=... # or VINTASEND_API_AUTHENTICATOR
|
|
332
|
+
|
|
333
|
+
python -m django check
|
|
334
|
+
gunicorn vintasend_api.wsgi:application --bind 0.0.0.0:3333
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
gunicorn is not a dependency of the package, so install it, or another WSGI server, yourself.
|
|
338
|
+
An ASGI server can serve `vintasend_api.asgi:application` instead. The routes are at the root:
|
|
339
|
+
`/health` and `/api/v1/`. See [Environment variables](#environment-variables) for the rest of
|
|
340
|
+
the configuration.
|
|
341
|
+
|
|
342
|
+
## Getting started
|
|
343
|
+
|
|
344
|
+
From a checkout, for development:
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
poetry install
|
|
348
|
+
cp .env.example .env
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Then configure the service the API should read from (below), and run:
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
poetry run python manage.py runserver 0.0.0.0:3333
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
## Configuring your VintaSend service
|
|
358
|
+
|
|
359
|
+
The API ships no backend of its own: which database, adapters and template renderer to
|
|
360
|
+
use is a deployment decision. Point `NOTIFICATION_SERVICE_FACTORY` at a callable that
|
|
361
|
+
returns a configured service:
|
|
362
|
+
|
|
363
|
+
```python
|
|
364
|
+
# vintasend_api/vintasend_config.py
|
|
365
|
+
from vintasend.services.notification_service import NotificationService
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def create_notification_service():
|
|
369
|
+
backend = ... # your backend
|
|
370
|
+
renderer = ... # your template renderer
|
|
371
|
+
adapter = ... # your notification adapter
|
|
372
|
+
|
|
373
|
+
return NotificationService(
|
|
374
|
+
notification_adapters=[adapter],
|
|
375
|
+
notification_backend=backend,
|
|
376
|
+
)
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
In a checkout, start from [`vintasend_api/vintasend_config.example.py`](https://github.com/vintasoftware/vintasend-api/blob/main/vintasend_api/vintasend_config.example.py),
|
|
380
|
+
copying it to `vintasend_api/vintasend_config.py` (gitignored). Installed, put it in a module
|
|
381
|
+
of your own anywhere on the Python path. The factory is called
|
|
382
|
+
once per process and its result reused, so it must be safe to call once and the service
|
|
383
|
+
it returns must be safe to share across requests.
|
|
384
|
+
|
|
385
|
+
Either service class works. `NotificationService` and `AsyncIONotificationService` have
|
|
386
|
+
matching method names, and every call the API makes is awaited when it comes back as a
|
|
387
|
+
coroutine. The view layer stays synchronous either way, which is what Django ORM
|
|
388
|
+
backends need.
|
|
389
|
+
|
|
390
|
+
This is the same setting VintaSend's background-send worker reads, so one factory can
|
|
391
|
+
serve the worker and this API — and pointing both at it guarantees they agree about
|
|
392
|
+
which backend holds the notifications.
|
|
393
|
+
|
|
394
|
+
## Environment variables
|
|
395
|
+
|
|
396
|
+
| Variable | Required | Description |
|
|
397
|
+
| --- | --- | --- |
|
|
398
|
+
| `VINTASEND_API_KEY` | unless an authenticator is set | Shared secret clients must send as a bearer token. |
|
|
399
|
+
| `VINTASEND_API_AUTHENTICATOR` | no | Dotted path to a callable `(request)` authenticating callers in place of the key. See [Authenticating callers yourself](#authenticating-callers-yourself). |
|
|
400
|
+
| `NOTIFICATION_SERVICE_FACTORY` | yes | Dotted path to the callable building your VintaSend service. |
|
|
401
|
+
| `VINTASEND_BACKEND_IDENTIFIER` | no | Read from a non-primary backend registered in your service. |
|
|
402
|
+
| `VINTASEND_UNHANDLED_ERROR_HANDLER` | no | Dotted path to a callable `(exc, request, request_id)` receiving every unexpected error. See [Unexpected errors](#unexpected-errors). |
|
|
403
|
+
| `VINTASEND_API_CORS_ORIGINS` | no | Comma-separated browser origins allowed to call the API. |
|
|
404
|
+
| `DJANGO_SECRET_KEY` | no | Django requires one; this API signs nothing. |
|
|
405
|
+
| `DJANGO_DEBUG` / `DJANGO_ALLOWED_HOSTS` / `DJANGO_LOG_LEVEL` | no | Standard Django knobs. |
|
|
406
|
+
| `DJANGO_DB_*` | no | Only needed by backends that resolve their model through Django. |
|
|
407
|
+
| `GITHUB_REPO` | preview only | Repository holding the templates, as `owner/repo` or a full URL. |
|
|
408
|
+
| `GITHUB_API_KEY` | preview only | Token with read access to that repository. |
|
|
409
|
+
| `GITHUB_API_BASE_URL` | no | Defaults to `https://api.github.com`. |
|
|
410
|
+
| `GITHUB_TEMPLATES_BASE_PATH` | no | Prefix added to template paths before the GitHub lookup. |
|
|
411
|
+
|
|
412
|
+
The `GITHUB_*` variables are only read when `/preview` is called, so the API runs fine
|
|
413
|
+
without them if you do not use template previews.
|
|
414
|
+
|
|
415
|
+
The key and the service factory are enforced by a Django system check, so a deployment
|
|
416
|
+
missing either fails on `manage.py check` and on `runserver` rather than on the first
|
|
417
|
+
request: `vintasend_api.E001` for the key, which is only required when no authenticator is
|
|
418
|
+
set, and `vintasend_api.E002` for the factory. So is a setting naming something that cannot
|
|
419
|
+
be imported or called: `vintasend_api.E003` for `VINTASEND_UNHANDLED_ERROR_HANDLER` and
|
|
420
|
+
`vintasend_api.E004` for `VINTASEND_API_AUTHENTICATOR`. Run `manage.py check` in your release
|
|
421
|
+
step if you serve with gunicorn.
|
|
422
|
+
|
|
423
|
+
## Unexpected errors
|
|
424
|
+
|
|
425
|
+
An error the API does not map to a contract error answers a generic 500
|
|
426
|
+
`INTERNAL_ERROR` with an `X-Request-Id` header, and is logged as one line: the error's
|
|
427
|
+
class, the request id, the method and the route pattern (`api/v1/notifications/<id>`, not
|
|
428
|
+
the path). Never its message, its traceback, the request body or the notification id: an
|
|
429
|
+
error from the notification store, a provider or a context generator can quote
|
|
430
|
+
notification content, recipients and context values, which in the applications this API
|
|
431
|
+
serves can be health data. Django's own `django.request` record for the 500 is suppressed
|
|
432
|
+
too, since it would repeat the concrete path and attach the request.
|
|
433
|
+
|
|
434
|
+
The request id is the client's `X-Request-Id` when it matches `[A-Za-z0-9._-]{1,128}`, and
|
|
435
|
+
a fresh UUID otherwise, so a client cannot forge a log line through it.
|
|
436
|
+
|
|
437
|
+
Set `VINTASEND_UNHANDLED_ERROR_HANDLER` to send errors to a tracker with its own scrubbing
|
|
438
|
+
instead; keeping health data out of it is then your call. It may be `async`. If it raises,
|
|
439
|
+
the redacted line is logged in its place, and what it raised is not.
|
|
440
|
+
|
|
441
|
+
## Development
|
|
442
|
+
|
|
443
|
+
```bash
|
|
444
|
+
poetry run python manage.py runserver # dev server
|
|
445
|
+
poetry run pytest # tests
|
|
446
|
+
poetry run ruff check . # lint
|
|
447
|
+
poetry run ruff format . # format
|
|
448
|
+
poetry run mypy # type-check
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Tests drive the real Django application through the test client with an injected fake
|
|
452
|
+
service, so they cover routing, auth, validation, filter negotiation and serialization
|
|
453
|
+
without needing a database, a mail provider or GitHub. They mirror the TypeScript
|
|
454
|
+
reference's suite case for case, which is what keeps the two implementations honest.
|
|
455
|
+
|
|
456
|
+
## Notes for anyone comparing the two implementations
|
|
457
|
+
|
|
458
|
+
The wire contract is identical. These are the places where getting there took different
|
|
459
|
+
code, and each is worth knowing if you are implementing the contract a third time.
|
|
460
|
+
|
|
461
|
+
**Pagination.** Both APIs are 1-indexed on the wire. Underneath, the two ecosystems
|
|
462
|
+
genuinely differ: `vintasend-ts` backends page from 0, VintaSend's Python backends page
|
|
463
|
+
from 1. So a literal port of either implementation's page arithmetic is wrong in the other.
|
|
464
|
+
|
|
465
|
+
Neither API hardcodes it any more. Backends report `pagination.oneIndexed` in their
|
|
466
|
+
capability map — defaulting to `True` in `vintasend` and `false` in `vintasend-ts`, matching
|
|
467
|
+
what their backends actually do — and each API converts from that. Here it lives in
|
|
468
|
+
`ServiceCaller`, so the routes only ever deal in contract pages and none of them can forget.
|
|
469
|
+
That also covers a custom backend that disagrees with its ecosystem's default, which no
|
|
470
|
+
hardcoded rule would.
|
|
471
|
+
|
|
472
|
+
The whole `pagination.*` namespace is withheld from `/api/v1/capabilities`, in both
|
|
473
|
+
implementations: the wire is unconditionally 1-indexed and the conversion happens
|
|
474
|
+
server-side, so telling the dashboard about the backend's convention could only lead it to
|
|
475
|
+
convert a second time.
|
|
476
|
+
|
|
477
|
+
Worth knowing because this failure mode is silent — an off-by-one in page numbering raises
|
|
478
|
+
nothing, it just serves the wrong page or an empty first one. `vintasend-ts` documented its
|
|
479
|
+
backends as 1-indexed when they page from 0; the docs were wrong for long enough that anyone
|
|
480
|
+
writing a backend from them would have shipped the bug without a failing test.
|
|
481
|
+
|
|
482
|
+
**Capability keys.** Every key both libraries define is spelled identically, on purpose,
|
|
483
|
+
so the filter negotiation here reads `stringLookups.caseInsensitive` with no translation.
|
|
484
|
+
|
|
485
|
+
Watch out for one trap if you implement this yourself: both libraries also define
|
|
486
|
+
`stringLookups.caseSensitive`, and the pair are **independent capabilities, not a flag and
|
|
487
|
+
its negation**. A backend on a case-insensitive collation reports `caseSensitive: False`
|
|
488
|
+
and can match only case-insensitively; a backend with no case folding reports
|
|
489
|
+
`caseInsensitive: False` and can match only case-sensitively. Deriving either from the
|
|
490
|
+
other inverts the answer for exactly the backends that had something to report, and you'd
|
|
491
|
+
end up declining the one lookup they support. Read the key you actually mean.
|
|
492
|
+
|
|
493
|
+
**Filter vocabulary.** The wire is camelCase throughout. The Python filter vocabulary is
|
|
494
|
+
snake_case (`notification_type`, `sent_at_range`, `case_sensitive`), so
|
|
495
|
+
`dashboard/filters.py` translates. The TypeScript implementation needs no such layer.
|
|
496
|
+
|
|
497
|
+
**One-off listing.** `vintasend-ts` backends expose `getOneOffNotifications`. The Python
|
|
498
|
+
package has no equivalent, and the filter vocabulary has no field discriminating the two
|
|
499
|
+
variants, so `GET /notifications/one-off` scans the notification stream and keeps the
|
|
500
|
+
one-offs. A service that does expose `get_one_off_notifications` is used directly
|
|
501
|
+
instead. The scan is bounded and logs when it truncates.
|
|
502
|
+
|
|
503
|
+
**Notification lookup.** The contract describes looking an id up as a user notification
|
|
504
|
+
first, then as a one-off. Python's `get_notification` returns whichever variant matches,
|
|
505
|
+
so that is one call here rather than two. Same outcome.
|
|
506
|
+
|
|
507
|
+
**Preview context.** `render_email_template_from_content` takes a materialised context in
|
|
508
|
+
Python, so this API resolves which context to render with — the stored one, or a
|
|
509
|
+
regenerated one — before calling it. The TypeScript version passes either shape down into
|
|
510
|
+
its service. Same outcome.
|
|
511
|
+
|
|
512
|
+
**Template versions.** `requestedTemplateVersion` and `usedTemplateVersion` — both the
|
|
513
|
+
payload fields and the two query parameters that filter on them — come from `vintasend`
|
|
514
|
+
additions that `vintasend-ts` has not made, so the TypeScript reference serves the fields
|
|
515
|
+
as `null` at best and omits them at worst, and ignores the filters, until it catches up.
|
|
516
|
+
They are additive and nullable, so a dashboard reading them tolerantly works against
|
|
517
|
+
either — but this is the one place where `openapi.yaml` is currently ahead of the
|
|
518
|
+
TypeScript implementation rather than describing both.
|
|
519
|
+
|
|
520
|
+
**`UPSTREAM_ERROR`.** `openapi.yaml` documents a 502 when the template source cannot be
|
|
521
|
+
reached. This implementation emits it. The TypeScript reference declares the code but
|
|
522
|
+
lets template-source failures fall through to a 500, so that one status differs today —
|
|
523
|
+
the normative document is what this follows.
|
|
524
|
+
|
|
525
|
+
## License
|
|
526
|
+
|
|
527
|
+
MIT
|
|
528
|
+
|