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.
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