vintasend-api 3.5.0__py3-none-any.whl
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/__init__.py +0 -0
- vintasend_api/asgi.py +15 -0
- vintasend_api/dashboard/__init__.py +0 -0
- vintasend_api/dashboard/api.py +390 -0
- vintasend_api/dashboard/apps.py +88 -0
- vintasend_api/dashboard/auth.py +152 -0
- vintasend_api/dashboard/bodies.py +73 -0
- vintasend_api/dashboard/capabilities.py +78 -0
- vintasend_api/dashboard/conf.py +113 -0
- vintasend_api/dashboard/contract.py +204 -0
- vintasend_api/dashboard/cors.py +81 -0
- vintasend_api/dashboard/errors.py +84 -0
- vintasend_api/dashboard/filters.py +155 -0
- vintasend_api/dashboard/hooks.py +129 -0
- vintasend_api/dashboard/preview.py +91 -0
- vintasend_api/dashboard/query.py +89 -0
- vintasend_api/dashboard/serialize.py +173 -0
- vintasend_api/dashboard/service.py +324 -0
- vintasend_api/dashboard/template_source.py +290 -0
- vintasend_api/dashboard/urls.py +25 -0
- vintasend_api/dashboard/views.py +23 -0
- vintasend_api/py.typed +0 -0
- vintasend_api/settings.py +147 -0
- vintasend_api/urls.py +21 -0
- vintasend_api/vintasend_config.example.py +69 -0
- vintasend_api/wsgi.py +10 -0
- vintasend_api-3.5.0.dist-info/METADATA +528 -0
- vintasend_api-3.5.0.dist-info/RECORD +29 -0
- vintasend_api-3.5.0.dist-info/WHEEL +4 -0
|
File without changes
|
vintasend_api/asgi.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""ASGI entrypoint. Point uvicorn/daphne at ``vintasend_api.asgi:application``.
|
|
2
|
+
|
|
3
|
+
The API's own view layer is synchronous (see ``dashboard/service.py`` for why), so
|
|
4
|
+
running under ASGI buys nothing on its own. It is here for deployments that already
|
|
5
|
+
standardise on an ASGI server.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from django.core.asgi import get_asgi_application
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "vintasend_api.settings")
|
|
14
|
+
|
|
15
|
+
application = get_asgi_application()
|
|
File without changes
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Assembles the HTTP application: error envelope, auth, and the notification routes.
|
|
2
|
+
|
|
3
|
+
Each handler maps HTTP input to a VintaSend service call and the result back to the wire
|
|
4
|
+
contract -- no business logic beyond the translation itself.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from django.http import Http404, HttpRequest, HttpResponse, JsonResponse
|
|
11
|
+
from ninja import NinjaAPI, Query, Status
|
|
12
|
+
from ninja.errors import AuthenticationError, HttpError, ValidationError
|
|
13
|
+
|
|
14
|
+
from .auth import ApiKeyAuth
|
|
15
|
+
from .bodies import JsonBodyParser, refuse_an_empty_json_body
|
|
16
|
+
from .contract import (
|
|
17
|
+
API_VERSION,
|
|
18
|
+
URLS_NAMESPACE,
|
|
19
|
+
ApiErrorResponse,
|
|
20
|
+
CancelledNotificationOut,
|
|
21
|
+
DataResponse,
|
|
22
|
+
HealthOut,
|
|
23
|
+
NotificationDetailOut,
|
|
24
|
+
NotificationOut,
|
|
25
|
+
NotificationPreviewOut,
|
|
26
|
+
PaginatedResponse,
|
|
27
|
+
UserNotificationOut,
|
|
28
|
+
)
|
|
29
|
+
from .errors import STATUS_BY_CODE, ApiError, invalid_request, issue
|
|
30
|
+
from .filters import build_backend_filter, build_order_by
|
|
31
|
+
from .hooks import REQUEST_ID_HEADER, report_unhandled_error, request_id_for
|
|
32
|
+
from .preview import build_notification_preview
|
|
33
|
+
from .query import NotificationListQuery, PaginationQuery, ResendBody
|
|
34
|
+
from .serialize import (
|
|
35
|
+
AnyNotification,
|
|
36
|
+
ListNotificationOut,
|
|
37
|
+
serialize_notification,
|
|
38
|
+
serialize_notification_detail,
|
|
39
|
+
serialize_user_notification,
|
|
40
|
+
)
|
|
41
|
+
from .service import ServiceCaller, get_service_caller
|
|
42
|
+
from .template_source import get_template_client
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
NotificationPage = PaginatedResponse[NotificationOut]
|
|
46
|
+
|
|
47
|
+
# Error responses are produced by the exception handlers below rather than returned from
|
|
48
|
+
# a view, so they are declared purely so the generated schema documents them the way
|
|
49
|
+
# `openapi.yaml` does. Each route declares the subset the contract lists for it, and a test
|
|
50
|
+
# pins the two together.
|
|
51
|
+
#
|
|
52
|
+
# Every authenticated route can refuse the caller twice over: 401 when no valid credential
|
|
53
|
+
# was presented, 403 (FORBIDDEN) when a host authenticated the caller and then refused it.
|
|
54
|
+
AUTH_ERRORS: dict[int, Any] = {401: ApiErrorResponse, 403: ApiErrorResponse}
|
|
55
|
+
LIST_ERRORS: dict[int, Any] = {400: ApiErrorResponse, **AUTH_ERRORS}
|
|
56
|
+
LOOKUP_ERRORS: dict[int, Any] = {**AUTH_ERRORS, 404: ApiErrorResponse}
|
|
57
|
+
PREVIEW_ERRORS: dict[int, Any] = {
|
|
58
|
+
**LOOKUP_ERRORS,
|
|
59
|
+
409: ApiErrorResponse,
|
|
60
|
+
502: ApiErrorResponse,
|
|
61
|
+
}
|
|
62
|
+
RESEND_ERRORS: dict[int, Any] = {
|
|
63
|
+
400: ApiErrorResponse,
|
|
64
|
+
**AUTH_ERRORS,
|
|
65
|
+
409: ApiErrorResponse,
|
|
66
|
+
}
|
|
67
|
+
# The request body is optional, so an omitted one falls back to this. Shared rather
|
|
68
|
+
# than constructed per call because it is only ever read.
|
|
69
|
+
DEFAULT_RESEND_BODY = ResendBody(useStoredContext=False)
|
|
70
|
+
|
|
71
|
+
CANCEL_ERRORS: dict[int, Any] = {
|
|
72
|
+
**LOOKUP_ERRORS,
|
|
73
|
+
409: ApiErrorResponse,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
api = NinjaAPI(
|
|
77
|
+
title="VintaSend Dashboard API",
|
|
78
|
+
version="1.0.0",
|
|
79
|
+
description=(
|
|
80
|
+
"HTTP contract between a VintaSend notification service and the VintaSend "
|
|
81
|
+
"dashboard UI. openapi.yaml in the repository root is the source of truth."
|
|
82
|
+
),
|
|
83
|
+
# Unique, so the templates management API can be mounted beside it. See contract.py.
|
|
84
|
+
urls_namespace=URLS_NAMESPACE,
|
|
85
|
+
auth=ApiKeyAuth(),
|
|
86
|
+
# Reads a body under the contract's media-type rule; see `bodies.py`.
|
|
87
|
+
parser=JsonBodyParser(),
|
|
88
|
+
# The contract's own envelope is emitted by the handlers below, so Ninja's default
|
|
89
|
+
# 404/validation bodies are never used.
|
|
90
|
+
docs_url="/docs",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --- error envelope ------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _envelope(
|
|
98
|
+
request: HttpRequest, code: str, message: str, details: Any | None = None
|
|
99
|
+
) -> HttpResponse:
|
|
100
|
+
error: dict[str, Any] = {"code": code, "message": message}
|
|
101
|
+
if details is not None:
|
|
102
|
+
error["details"] = details
|
|
103
|
+
return JsonResponse({"error": error}, status=STATUS_BY_CODE[code])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@api.exception_handler(ApiError)
|
|
107
|
+
def handle_api_error(request: HttpRequest, exc: ApiError) -> HttpResponse:
|
|
108
|
+
return _envelope(request, exc.code, exc.message, exc.details)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@api.exception_handler(AuthenticationError)
|
|
112
|
+
def handle_authentication_error(request: HttpRequest, exc: AuthenticationError) -> HttpResponse:
|
|
113
|
+
"""A safety net for Ninja's own authentication failure.
|
|
114
|
+
|
|
115
|
+
``ApiKeyAuth`` refuses every caller by raising ``ApiError`` itself -- a missing or
|
|
116
|
+
non-bearer header as much as a wrong key -- and a host's authenticator does the same, so
|
|
117
|
+
Ninja only raises this if an auth callable returns nothing. It still gets the envelope.
|
|
118
|
+
"""
|
|
119
|
+
return _envelope(request, "UNAUTHORIZED", "A valid API key is required.")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@api.exception_handler(ValidationError)
|
|
123
|
+
def handle_validation_error(request: HttpRequest, exc: ValidationError) -> HttpResponse:
|
|
124
|
+
"""Report invalid input as a 400 listing the offending fields.
|
|
125
|
+
|
|
126
|
+
``loc`` arrives as ``("query", "status")`` / ``("body", "payload", "useStoredContext")``.
|
|
127
|
+
The leading source segment and Ninja's synthetic body-argument name are dropped so
|
|
128
|
+
the reported path is the field name the client actually sent.
|
|
129
|
+
"""
|
|
130
|
+
issues = [
|
|
131
|
+
issue(
|
|
132
|
+
".".join(str(part) for part in _issue_path(failure.get("loc", ()))),
|
|
133
|
+
str(failure.get("msg", "")),
|
|
134
|
+
)
|
|
135
|
+
for failure in exc.errors
|
|
136
|
+
]
|
|
137
|
+
return handle_api_error(request, invalid_request(issues))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _issue_path(loc: Any) -> list[Any]:
|
|
141
|
+
parts = list(loc)
|
|
142
|
+
if parts and parts[0] in {"query", "body", "path", "form", "header", "cookie"}:
|
|
143
|
+
parts = parts[1:]
|
|
144
|
+
# Ninja names the request-body argument after the view parameter ("payload"), which
|
|
145
|
+
# is an implementation detail the client never sent and should not be told about.
|
|
146
|
+
if parts and parts[0] == "payload":
|
|
147
|
+
parts = parts[1:]
|
|
148
|
+
return parts
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@api.exception_handler(HttpError)
|
|
152
|
+
def handle_http_error(request: HttpRequest, exc: HttpError) -> HttpResponse:
|
|
153
|
+
"""Put Ninja's own refusals in the error envelope.
|
|
154
|
+
|
|
155
|
+
Ninja raises ``HttpError`` when it cannot read a request body, wrapping whatever the
|
|
156
|
+
parser raised. ``JsonBodyParser`` raises the contract's 400, so that is unwrapped and
|
|
157
|
+
answered as it stands. Any other 400 gets the same shape. Anything else is unexpected.
|
|
158
|
+
"""
|
|
159
|
+
if isinstance(exc.__cause__, ApiError):
|
|
160
|
+
return handle_api_error(request, exc.__cause__)
|
|
161
|
+
if exc.status_code == 400:
|
|
162
|
+
return handle_api_error(request, invalid_request([issue("", str(exc))]))
|
|
163
|
+
return handle_unexpected_error(request, exc)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@api.exception_handler(Http404)
|
|
167
|
+
def handle_not_found(request: HttpRequest, exc: Http404) -> HttpResponse:
|
|
168
|
+
return _envelope(
|
|
169
|
+
request,
|
|
170
|
+
"NOT_FOUND",
|
|
171
|
+
f"No route matches {request.method} {request.path}.",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@api.exception_handler(Exception)
|
|
176
|
+
def handle_unexpected_error(request: HttpRequest, exc: Exception) -> HttpResponse:
|
|
177
|
+
"""Report unexpected errors generically, and never log them whole.
|
|
178
|
+
|
|
179
|
+
The client gets a fixed message, so backend internals -- connection strings, credentials
|
|
180
|
+
in driver messages -- never leak to it. The log gets one line by default: the error's
|
|
181
|
+
class, a request id, the method and the route pattern. An error from the notification
|
|
182
|
+
store, a provider or a context generator can carry notification content, recipients or
|
|
183
|
+
context values, which can be health data, so its message, its traceback and the concrete
|
|
184
|
+
path are not logged. A host that wants more sets ``VINTASEND_UNHANDLED_ERROR_HANDLER`` --
|
|
185
|
+
see ``hooks``. The response carries the request id in ``X-Request-Id``, to match a
|
|
186
|
+
client's report to the log line.
|
|
187
|
+
"""
|
|
188
|
+
request_id = request_id_for(request)
|
|
189
|
+
report_unhandled_error(exc, request, request_id)
|
|
190
|
+
response = _envelope(
|
|
191
|
+
request,
|
|
192
|
+
"INTERNAL_ERROR",
|
|
193
|
+
"An unexpected error occurred while handling the request.",
|
|
194
|
+
)
|
|
195
|
+
response[REQUEST_ID_HEADER] = request_id
|
|
196
|
+
# Django logs every 5xx response again on ``django.request``, with the concrete path and
|
|
197
|
+
# the request object attached -- which mail_admins or an error tracker on the root logger
|
|
198
|
+
# turns into a report with request data. This error has been reported above, so that
|
|
199
|
+
# second record is suppressed.
|
|
200
|
+
response._has_been_logged = True # type: ignore[attr-defined]
|
|
201
|
+
return response
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# --- helpers -------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _paginate(
|
|
208
|
+
read: Callable[[int, int], Sequence[AnyNotification]], page: int, page_size: int
|
|
209
|
+
) -> dict[str, Any]:
|
|
210
|
+
"""One page of a listing, and whether the next page has a row.
|
|
211
|
+
|
|
212
|
+
Backends are not required to count, so that is asked directly: a full page is followed by
|
|
213
|
+
a one-row read of the first row after it, which is page ``page * page_size + 1`` of one-row
|
|
214
|
+
pages. A short page is the last one without asking. ``read`` takes the contract's
|
|
215
|
+
1-indexed pages; ``ServiceCaller`` converts them for the backend.
|
|
216
|
+
"""
|
|
217
|
+
notifications = read(page, page_size)
|
|
218
|
+
has_more = len(notifications) == page_size and bool(read(page * page_size + 1, 1))
|
|
219
|
+
data: list[ListNotificationOut] = [
|
|
220
|
+
serialize_notification(notification) for notification in notifications
|
|
221
|
+
]
|
|
222
|
+
return {"data": data, "page": page, "pageSize": page_size, "hasMore": has_more}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _find_notification(service: ServiceCaller, notification_id: str) -> AnyNotification:
|
|
226
|
+
notification = service.get_notification(notification_id)
|
|
227
|
+
if notification is None:
|
|
228
|
+
raise ApiError.not_found(f"Notification with ID {notification_id} was not found.")
|
|
229
|
+
return notification
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# --- system --------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@api.get(
|
|
236
|
+
"/capabilities",
|
|
237
|
+
response={200: DataResponse[dict[str, bool]], **AUTH_ERRORS},
|
|
238
|
+
tags=["system"],
|
|
239
|
+
)
|
|
240
|
+
def get_capabilities(request: HttpRequest) -> dict[str, Any]:
|
|
241
|
+
return {"data": get_service_caller().get_capabilities()}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# --- notifications -------------------------------------------------------------------
|
|
245
|
+
#
|
|
246
|
+
# The literal collection paths are registered before `/notifications/{id}` on purpose:
|
|
247
|
+
# routes match in registration order, so declaring the detail route first would make it
|
|
248
|
+
# swallow `/notifications/pending`.
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@api.get(
|
|
252
|
+
"/notifications",
|
|
253
|
+
response={200: NotificationPage, **LIST_ERRORS},
|
|
254
|
+
tags=["notifications"],
|
|
255
|
+
)
|
|
256
|
+
def list_notifications(request: HttpRequest, query: Query[NotificationListQuery]) -> dict[str, Any]:
|
|
257
|
+
service = get_service_caller()
|
|
258
|
+
capabilities = service.get_capabilities()
|
|
259
|
+
|
|
260
|
+
# Page numbers stay in the contract's 1-indexed terms here. `ServiceCaller` converts
|
|
261
|
+
# to whatever the configured backend uses, which it learns from the backend's own
|
|
262
|
+
# `pagination.oneIndexed` capability rather than assuming.
|
|
263
|
+
backend_filter = build_backend_filter(query, capabilities)
|
|
264
|
+
order_by = build_order_by(query, capabilities)
|
|
265
|
+
|
|
266
|
+
return _paginate(
|
|
267
|
+
lambda page, page_size: service.filter_notifications(
|
|
268
|
+
backend_filter, page, page_size, order_by
|
|
269
|
+
),
|
|
270
|
+
query.page,
|
|
271
|
+
query.pageSize,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@api.get(
|
|
276
|
+
"/notifications/pending",
|
|
277
|
+
response={200: NotificationPage, **LIST_ERRORS},
|
|
278
|
+
tags=["notifications"],
|
|
279
|
+
)
|
|
280
|
+
def list_pending_notifications(
|
|
281
|
+
request: HttpRequest, query: Query[PaginationQuery]
|
|
282
|
+
) -> dict[str, Any]:
|
|
283
|
+
service = get_service_caller()
|
|
284
|
+
return _paginate(service.get_pending_notifications, query.page, query.pageSize)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@api.get(
|
|
288
|
+
"/notifications/future",
|
|
289
|
+
response={200: NotificationPage, **LIST_ERRORS},
|
|
290
|
+
tags=["notifications"],
|
|
291
|
+
)
|
|
292
|
+
def list_future_notifications(
|
|
293
|
+
request: HttpRequest, query: Query[PaginationQuery]
|
|
294
|
+
) -> dict[str, Any]:
|
|
295
|
+
service = get_service_caller()
|
|
296
|
+
return _paginate(service.get_future_notifications, query.page, query.pageSize)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@api.get(
|
|
300
|
+
"/notifications/one-off",
|
|
301
|
+
response={200: NotificationPage, **LIST_ERRORS},
|
|
302
|
+
tags=["notifications"],
|
|
303
|
+
)
|
|
304
|
+
def list_one_off_notifications(
|
|
305
|
+
request: HttpRequest, query: Query[PaginationQuery]
|
|
306
|
+
) -> dict[str, Any]:
|
|
307
|
+
service = get_service_caller()
|
|
308
|
+
return _paginate(service.get_one_off_notifications, query.page, query.pageSize)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@api.get(
|
|
312
|
+
"/notifications/{id}",
|
|
313
|
+
response={200: DataResponse[NotificationDetailOut], **LOOKUP_ERRORS},
|
|
314
|
+
tags=["notifications"],
|
|
315
|
+
)
|
|
316
|
+
def get_notification(request: HttpRequest, id: str) -> dict[str, Any]: # noqa: A002
|
|
317
|
+
service = get_service_caller()
|
|
318
|
+
notification = _find_notification(service, id)
|
|
319
|
+
return {"data": serialize_notification_detail(notification)}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@api.get(
|
|
323
|
+
"/notifications/{id}/preview",
|
|
324
|
+
response={200: DataResponse[NotificationPreviewOut], **PREVIEW_ERRORS},
|
|
325
|
+
tags=["notifications"],
|
|
326
|
+
)
|
|
327
|
+
def preview_notification(request: HttpRequest, id: str) -> dict[str, Any]: # noqa: A002
|
|
328
|
+
service = get_service_caller()
|
|
329
|
+
notification = _find_notification(service, id)
|
|
330
|
+
|
|
331
|
+
preview = build_notification_preview(service, get_template_client(), notification)
|
|
332
|
+
return {"data": preview}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@api.post(
|
|
336
|
+
"/notifications/{id}/resend",
|
|
337
|
+
response={201: DataResponse[UserNotificationOut], **RESEND_ERRORS},
|
|
338
|
+
tags=["notifications"],
|
|
339
|
+
)
|
|
340
|
+
def resend_notification(
|
|
341
|
+
request: HttpRequest,
|
|
342
|
+
id: str, # noqa: A002
|
|
343
|
+
payload: ResendBody = DEFAULT_RESEND_BODY,
|
|
344
|
+
) -> Status:
|
|
345
|
+
refuse_an_empty_json_body(request)
|
|
346
|
+
service = get_service_caller()
|
|
347
|
+
resent = service.resend_notification(id, payload.useStoredContext)
|
|
348
|
+
|
|
349
|
+
if resent is None:
|
|
350
|
+
raise ApiError.conflict(
|
|
351
|
+
"The notification could not be resent. It may not exist, may be a one-off "
|
|
352
|
+
"notification, or may be scheduled for the future."
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
return Status(201, {"data": serialize_user_notification(resent)})
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@api.post(
|
|
359
|
+
"/notifications/{id}/cancel",
|
|
360
|
+
response={200: DataResponse[CancelledNotificationOut], **CANCEL_ERRORS},
|
|
361
|
+
tags=["notifications"],
|
|
362
|
+
)
|
|
363
|
+
def cancel_notification(request: HttpRequest, id: str) -> dict[str, Any]: # noqa: A002
|
|
364
|
+
service = get_service_caller()
|
|
365
|
+
notification = _find_notification(service, id)
|
|
366
|
+
|
|
367
|
+
if notification.status != "PENDING_SEND":
|
|
368
|
+
raise ApiError.conflict("Only notifications in PENDING_SEND status can be cancelled.")
|
|
369
|
+
|
|
370
|
+
service.cancel_notification(id)
|
|
371
|
+
|
|
372
|
+
return {"data": CancelledNotificationOut(id=id, status="CANCELLED")}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# --- health --------------------------------------------------------------------------
|
|
376
|
+
#
|
|
377
|
+
# Unauthenticated and outside the versioned prefix: load balancers and container health
|
|
378
|
+
# checks have no API key.
|
|
379
|
+
|
|
380
|
+
health_api = NinjaAPI(
|
|
381
|
+
version="health",
|
|
382
|
+
urls_namespace="vintasend_api_health",
|
|
383
|
+
auth=None,
|
|
384
|
+
docs_url=None,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@health_api.get("/health", response=HealthOut, tags=["system"])
|
|
389
|
+
def health(request: HttpRequest) -> dict[str, str]:
|
|
390
|
+
return {"status": "ok", "apiVersion": API_VERSION}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""App configuration, including the startup checks that make a bad deployment fail fast."""
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
from django.core.checks import Error, register
|
|
5
|
+
|
|
6
|
+
from . import conf
|
|
7
|
+
from .auth import configured_authenticator
|
|
8
|
+
from .hooks import HANDLER_SETTING, configured_handler
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DashboardConfig(AppConfig):
|
|
12
|
+
name = "vintasend_api.dashboard"
|
|
13
|
+
label = "vintasend_dashboard"
|
|
14
|
+
verbose_name = "VintaSend Dashboard API"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@register()
|
|
18
|
+
def check_api_configuration(app_configs: object, **kwargs: object) -> list[Error]:
|
|
19
|
+
"""Refuse to start without the settings every request depends on.
|
|
20
|
+
|
|
21
|
+
Registered as a Django system check rather than raised at import time so
|
|
22
|
+
``manage.py`` stays usable and the message arrives as a readable checklist. Both
|
|
23
|
+
``runserver`` and ``manage.py check`` run these; ``gunicorn`` deployments should run
|
|
24
|
+
``manage.py check --deploy`` in their release step to get the same guarantee.
|
|
25
|
+
|
|
26
|
+
``VINTASEND_API_KEY`` is only required when no ``VINTASEND_API_AUTHENTICATOR`` is
|
|
27
|
+
configured: an authenticator replaces the key check.
|
|
28
|
+
|
|
29
|
+
The ``GITHUB_*`` settings are deliberately absent: they are only read when
|
|
30
|
+
``/preview`` is called, so an API that does not use template previews is correctly
|
|
31
|
+
configured without them.
|
|
32
|
+
"""
|
|
33
|
+
errors: list[Error] = []
|
|
34
|
+
|
|
35
|
+
if not conf.hook_setting(conf.AUTHENTICATOR):
|
|
36
|
+
if not conf.api_key():
|
|
37
|
+
errors.append(
|
|
38
|
+
Error(
|
|
39
|
+
"VINTASEND_API_KEY is not set.",
|
|
40
|
+
hint=(
|
|
41
|
+
"Every /api/v1 request must present this as a bearer token. Set it "
|
|
42
|
+
"to a long random string shared with the dashboard, or set "
|
|
43
|
+
f"{conf.AUTHENTICATOR} to authenticate callers yourself."
|
|
44
|
+
),
|
|
45
|
+
id="vintasend_api.E001",
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
try:
|
|
50
|
+
configured_authenticator()
|
|
51
|
+
except (ImportError, TypeError) as error:
|
|
52
|
+
errors.append(
|
|
53
|
+
Error(
|
|
54
|
+
f"{conf.AUTHENTICATOR} cannot be used: {error}",
|
|
55
|
+
hint=(
|
|
56
|
+
"Point it at an importable callable taking the request, or leave it "
|
|
57
|
+
"empty to require VINTASEND_API_KEY instead."
|
|
58
|
+
),
|
|
59
|
+
id="vintasend_api.E004",
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if not conf.service_factory():
|
|
64
|
+
errors.append(
|
|
65
|
+
Error(
|
|
66
|
+
"NOTIFICATION_SERVICE_FACTORY is not set.",
|
|
67
|
+
hint=(
|
|
68
|
+
"Point it at a callable returning a configured NotificationService "
|
|
69
|
+
"or AsyncIONotificationService, for example "
|
|
70
|
+
"'vintasend_api.vintasend_config.create_notification_service'. "
|
|
71
|
+
"Start from vintasend_config.example.py."
|
|
72
|
+
),
|
|
73
|
+
id="vintasend_api.E002",
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
configured_handler()
|
|
79
|
+
except (ImportError, TypeError) as error:
|
|
80
|
+
errors.append(
|
|
81
|
+
Error(
|
|
82
|
+
f"{HANDLER_SETTING} cannot be used: {error}",
|
|
83
|
+
hint="Point it at an importable callable, or leave it empty.",
|
|
84
|
+
id="vintasend_api.E003",
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return errors
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Who may call ``/api/v1``.
|
|
2
|
+
|
|
3
|
+
Two ways, chosen by settings, both read per request so ``override_settings`` works:
|
|
4
|
+
|
|
5
|
+
``VINTASEND_API_AUTHENTICATOR`` -- ``(request) -> None``
|
|
6
|
+
A host's own check, as a dotted path or as the callable itself. It refuses a caller by
|
|
7
|
+
raising ``ApiError("UNAUTHORIZED", ...)`` (401, no valid credential) or
|
|
8
|
+
``ApiError("FORBIDDEN", ...)`` (403, a caller it knows and refuses); returning lets the
|
|
9
|
+
request through. It may be ``async``. When it is set, the shared key is not checked and
|
|
10
|
+
need not be configured. ``bearer_token`` reads the header for one built around a token
|
|
11
|
+
the host verifies itself, such as its identity provider's. Mirrors the TypeScript
|
|
12
|
+
reference's ``authenticate`` option.
|
|
13
|
+
|
|
14
|
+
``VINTASEND_API_KEY``
|
|
15
|
+
The shared secret, when no authenticator is set: every request must carry
|
|
16
|
+
``Authorization: Bearer <VINTASEND_API_KEY>``. The dashboard calls the API from its
|
|
17
|
+
server side only, so the key never reaches a browser.
|
|
18
|
+
|
|
19
|
+
An authenticator that cannot be resolved answers every request with a 500 rather than falling
|
|
20
|
+
back to the key: a typo in a setting must not quietly change who is let in. The system check
|
|
21
|
+
``vintasend_api.E004`` reports it before that.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import hmac
|
|
25
|
+
import inspect
|
|
26
|
+
import re
|
|
27
|
+
from collections.abc import Awaitable, Callable
|
|
28
|
+
from typing import cast
|
|
29
|
+
|
|
30
|
+
from django.http import HttpRequest
|
|
31
|
+
from ninja.security.http import HttpAuthBase
|
|
32
|
+
|
|
33
|
+
from asgiref.sync import async_to_sync
|
|
34
|
+
|
|
35
|
+
from . import conf
|
|
36
|
+
from .errors import ApiError
|
|
37
|
+
from .hooks import _awaited, configured_hook
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"ApiError",
|
|
42
|
+
"ApiKeyAuth",
|
|
43
|
+
"Authenticator",
|
|
44
|
+
"as_refusal",
|
|
45
|
+
"bearer_token",
|
|
46
|
+
"check_api_key",
|
|
47
|
+
"configured_authenticator",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
Authenticator = Callable[[HttpRequest], "None | Awaitable[None]"]
|
|
51
|
+
|
|
52
|
+
# The scheme is matched in any case, as RFC 9110 has it. `\s+` rather than one space, and the
|
|
53
|
+
# token stripped, so the answer is the same as the TypeScript twin's `bearerToken`.
|
|
54
|
+
_BEARER = re.compile(r"Bearer\s+(.+)", re.IGNORECASE)
|
|
55
|
+
|
|
56
|
+
_KEY_REQUIRED = "A valid API key is required."
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# What an authenticator answers when it refuses a caller.
|
|
60
|
+
_REFUSALS = ("UNAUTHORIZED", "FORBIDDEN")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def as_refusal(error: BaseException) -> ApiError | None:
|
|
64
|
+
"""The refusal an authenticator raised, as this package's ``ApiError``, or None.
|
|
65
|
+
|
|
66
|
+
One authenticator can serve both VintaSend APIs mounted in one project, so it may raise
|
|
67
|
+
the other package's ``ApiError``. That one is recognised by its class name and code, as
|
|
68
|
+
the TypeScript packages do, rather than by its class, which this package cannot import.
|
|
69
|
+
Only a 401 or a 403 is an answer an authenticator gives; any other code is a failure.
|
|
70
|
+
"""
|
|
71
|
+
if isinstance(error, ApiError):
|
|
72
|
+
return error
|
|
73
|
+
code = getattr(error, "code", None)
|
|
74
|
+
if type(error).__name__ != "ApiError" or code not in _REFUSALS:
|
|
75
|
+
return None
|
|
76
|
+
message = getattr(error, "message", None)
|
|
77
|
+
return ApiError(code, message if isinstance(message, str) else str(error))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def bearer_token(request_or_header: HttpRequest | str | None) -> str | None:
|
|
81
|
+
"""The token in an ``Authorization: Bearer <token>`` header, or None when there is none.
|
|
82
|
+
|
|
83
|
+
Takes the request or the header's value. A header with another scheme, or with an empty
|
|
84
|
+
token, is None too, so a caller only has to check for one thing.
|
|
85
|
+
"""
|
|
86
|
+
header = (
|
|
87
|
+
request_or_header.headers.get("Authorization")
|
|
88
|
+
if isinstance(request_or_header, HttpRequest)
|
|
89
|
+
else request_or_header
|
|
90
|
+
)
|
|
91
|
+
if not header:
|
|
92
|
+
return None
|
|
93
|
+
match = _BEARER.fullmatch(header)
|
|
94
|
+
token = match.group(1).strip() if match else ""
|
|
95
|
+
return token or None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def configured_authenticator() -> Authenticator | None:
|
|
99
|
+
"""The callable ``VINTASEND_API_AUTHENTICATOR`` names, or None when it is unset.
|
|
100
|
+
|
|
101
|
+
raises ImportError: if a dotted path cannot be imported.
|
|
102
|
+
raises TypeError: if the setting names something that is not callable.
|
|
103
|
+
"""
|
|
104
|
+
return cast("Authenticator | None", configured_hook(conf.AUTHENTICATOR))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def check_api_key(request: HttpRequest) -> None:
|
|
108
|
+
"""The shared-secret check, used when no authenticator is configured.
|
|
109
|
+
|
|
110
|
+
``hmac.compare_digest`` keeps the comparison time-independent of how many leading
|
|
111
|
+
characters match, so the key cannot be recovered a byte at a time. An unset key matches
|
|
112
|
+
nothing, so a deployment that never set one refuses every request.
|
|
113
|
+
"""
|
|
114
|
+
expected = conf.api_key()
|
|
115
|
+
token = bearer_token(request)
|
|
116
|
+
|
|
117
|
+
if not expected or token is None:
|
|
118
|
+
raise ApiError.unauthorized(_KEY_REQUIRED)
|
|
119
|
+
|
|
120
|
+
if not hmac.compare_digest(token.encode("utf-8"), expected.encode("utf-8")):
|
|
121
|
+
raise ApiError.unauthorized(_KEY_REQUIRED)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ApiKeyAuth(HttpAuthBase):
|
|
125
|
+
"""Ninja's auth hook for every ``/api/v1`` route.
|
|
126
|
+
|
|
127
|
+
Declared as an HTTP bearer scheme, and still named ``ApiKeyAuth``, because Ninja
|
|
128
|
+
documents the security scheme from both: the generated schema describes the same scheme
|
|
129
|
+
whichever check a host chooses. Not an ``HttpBearer``: that refuses a request without a
|
|
130
|
+
bearer header before a host authenticator could look at it, and under ``DEBUG`` it logs a
|
|
131
|
+
header it cannot parse, credentials included.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
openapi_scheme = "bearer"
|
|
135
|
+
|
|
136
|
+
def __call__(self, request: HttpRequest) -> bool:
|
|
137
|
+
authenticator = configured_authenticator()
|
|
138
|
+
if authenticator is None:
|
|
139
|
+
check_api_key(request)
|
|
140
|
+
else:
|
|
141
|
+
try:
|
|
142
|
+
outcome = authenticator(request)
|
|
143
|
+
# An unawaited coroutine would let every caller through.
|
|
144
|
+
if inspect.isawaitable(outcome):
|
|
145
|
+
async_to_sync(_awaited)(outcome)
|
|
146
|
+
except Exception as error:
|
|
147
|
+
refusal = as_refusal(error)
|
|
148
|
+
if refusal is None or refusal is error:
|
|
149
|
+
raise
|
|
150
|
+
raise refusal from error
|
|
151
|
+
# Ninja treats a falsy answer as "not authenticated", so success is spelled out.
|
|
152
|
+
return True
|