chanx 0.1.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.
Files changed (38) hide show
  1. chanx/__init__.py +0 -0
  2. chanx/generic/__init__.py +0 -0
  3. chanx/generic/authenticator.py +360 -0
  4. chanx/generic/websocket.py +487 -0
  5. chanx/messages/__init__.py +0 -0
  6. chanx/messages/base.py +222 -0
  7. chanx/messages/incoming.py +35 -0
  8. chanx/messages/outgoing.py +99 -0
  9. chanx/playground/__init__.py +0 -0
  10. chanx/playground/static/playground/css/websocket.css +520 -0
  11. chanx/playground/static/playground/js/websocket/connection.js +119 -0
  12. chanx/playground/static/playground/js/websocket/endpoints.js +86 -0
  13. chanx/playground/static/playground/js/websocket/main.js +83 -0
  14. chanx/playground/static/playground/js/websocket/messages.js +295 -0
  15. chanx/playground/static/playground/js/websocket/parameters.js +700 -0
  16. chanx/playground/static/playground/js/websocket/ui.js +42 -0
  17. chanx/playground/static/playground/js/websocket/utils.js +32 -0
  18. chanx/playground/static/playground/js/websocket.js +11 -0
  19. chanx/playground/templates/playground/websocket.html +166 -0
  20. chanx/playground/urls.py +20 -0
  21. chanx/playground/utils.py +243 -0
  22. chanx/playground/views.py +124 -0
  23. chanx/routing.py +48 -0
  24. chanx/settings.py +126 -0
  25. chanx/testing.py +270 -0
  26. chanx/types.py +32 -0
  27. chanx/utils/__init__.py +0 -0
  28. chanx/utils/asgi.py +32 -0
  29. chanx/utils/asyncio.py +90 -0
  30. chanx/utils/logging.py +10 -0
  31. chanx/utils/request.py +49 -0
  32. chanx/utils/settings.py +123 -0
  33. chanx/utils/websocket.py +268 -0
  34. chanx-0.1.0.dist-info/METADATA +166 -0
  35. chanx-0.1.0.dist-info/RECORD +38 -0
  36. chanx-0.1.0.dist-info/WHEEL +4 -0
  37. chanx-0.1.0.dist-info/licenses/AUTHORS.rst +13 -0
  38. chanx-0.1.0.dist-info/licenses/LICENSE +31 -0
chanx/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,360 @@
1
+ """
2
+ WebSocket authentication system for Chanx using Django REST Framework.
3
+
4
+ This module provides a bridge between WebSocket connections and Django REST
5
+ Framework's authentication and permission systems. It enables WebSocket
6
+ connections to be authenticated and authorized using the same mechanisms
7
+ as RESTful APIs, ensuring consistency across your application.
8
+
9
+ The authenticator translates ASGI WebSocket connection scopes into Django HTTP
10
+ requests that can be processed by DRF authentication classes and permission
11
+ checks. It supports object-level permissions and handles validation of
12
+ configuration to prevent common errors.
13
+
14
+ Key components:
15
+ - AuthenticationResult: Dataclass for authentication outcomes
16
+ - ChanxAuthView: DRF-compatible view for processing authentication
17
+ - ChanxWebsocketAuthenticator: Main authenticator that processes WebSocket connections
18
+ """
19
+
20
+ import re
21
+ import uuid
22
+ import warnings
23
+ from collections.abc import Sequence
24
+ from dataclasses import dataclass, field
25
+ from typing import Any, Generic, Literal, TypeVar, cast
26
+
27
+ from django.contrib.auth.base_user import AbstractBaseUser
28
+ from django.contrib.auth.models import AnonymousUser
29
+ from django.db.models import Manager, Model, QuerySet
30
+ from django.http import HttpRequest
31
+ from rest_framework import serializers, status
32
+ from rest_framework.authentication import BaseAuthentication
33
+ from rest_framework.generics import GenericAPIView
34
+ from rest_framework.permissions import (
35
+ BasePermission,
36
+ OperandHolder,
37
+ SingleOperandHolder,
38
+ )
39
+ from rest_framework.request import Request
40
+ from rest_framework.response import Response
41
+
42
+ import structlog
43
+ from asgiref.sync import sync_to_async
44
+
45
+ from chanx.utils.logging import logger
46
+ from chanx.utils.request import request_from_scope
47
+
48
+ _MT_co = TypeVar("_MT_co", bound=Model, covariant=True)
49
+
50
+
51
+ @dataclass
52
+ class AuthenticationResult:
53
+ """
54
+ Result of websocket authentication.
55
+
56
+ Attributes:
57
+ is_authenticated: Whether authentication was successful
58
+ status_code: HTTP status code representing auth result
59
+ status_text: Text description of status
60
+ data: Additional data related to authentication result
61
+ user: Authenticated user object if successful
62
+ obj: Related model object if requested
63
+ """
64
+
65
+ is_authenticated: bool
66
+ status_code: int
67
+ status_text: str
68
+ data: dict[str, Any] = field(default_factory=dict)
69
+ user: AbstractBaseUser | AnonymousUser | None = None
70
+ obj: Model | None = None
71
+
72
+
73
+ class ChanxSerializer(serializers.Serializer[Any]):
74
+ """Base serializer for Chanx authentication."""
75
+
76
+ detail = serializers.CharField(write_only=True, required=False)
77
+
78
+
79
+ # Type annotation to extend the DRF Request class
80
+ class ExtendedRequest(Request):
81
+ """Extended Request class that includes an obj attribute."""
82
+
83
+ obj: Model | None
84
+
85
+
86
+ class ChanxAuthView(GenericAPIView[Model]):
87
+ """
88
+ Base authentication view for Chanx websockets.
89
+
90
+ Provides a REST-like interface for WebSocket authentication
91
+ with Django REST Framework authentication and permissions.
92
+ """
93
+
94
+ serializer_class = ChanxSerializer
95
+ detail: bool | None = None
96
+
97
+ def get_response(self, request: ExtendedRequest) -> Response:
98
+ """
99
+ Get standard response with object if required.
100
+
101
+ Args:
102
+ request: The HTTP request object
103
+
104
+ Returns:
105
+ Response with OK status and object if needed
106
+ """
107
+ request.obj = None
108
+ if self.detail or (self.detail is None and self.kwargs.get(self.lookup_field)):
109
+ request.obj = self.get_object()
110
+ return Response({"detail": "ok"})
111
+
112
+ def get(self, request: ExtendedRequest, *args: Any, **kwargs: Any) -> Response:
113
+ """Stub get method"""
114
+ return self.get_response(request)
115
+
116
+ def post(self, request: ExtendedRequest, *args: Any, **kwargs: Any) -> Response:
117
+ """Stub post method"""
118
+
119
+ return self.get_response(request)
120
+
121
+ def put(self, request: ExtendedRequest, *args: Any, **kwargs: Any) -> Response:
122
+ """Stub put method"""
123
+
124
+ return self.get_response(request)
125
+
126
+ def patch(self, request: ExtendedRequest, *args: Any, **kwargs: Any) -> Response:
127
+ """Stub patch method"""
128
+
129
+ return self.get_response(request)
130
+
131
+ def delete(self, request: ExtendedRequest, *args: Any, **kwargs: Any) -> Response:
132
+ """Stub delete method"""
133
+
134
+ return self.get_response(request)
135
+
136
+
137
+ # Define a type for QuerysetLike that can be True, QuerySet, or Manager
138
+ QuerysetLike = Literal[True] | QuerySet[Any] | Manager[Any]
139
+
140
+
141
+ class ChanxWebsocketAuthenticator(Generic[_MT_co]):
142
+ """
143
+ Authenticator for Chanx WebSocket connections.
144
+
145
+ Uses Django REST Framework authentication classes and permissions to authenticate
146
+ WebSocket connections with consistent behavior to RESTful APIs.
147
+
148
+ Attributes:
149
+ authentication_classes: DRF authentication classes for connection verification
150
+ permission_classes: DRF permission classes for connection authorization
151
+ queryset: QuerySet or Manager used for retrieving objects, or True if no objects needed
152
+ auth_method: HTTP verb to emulate for authentication
153
+ """
154
+
155
+ # Authentication configuration (set from consumer)
156
+ authentication_classes: Sequence[type[BaseAuthentication]] | None = None
157
+ permission_classes: (
158
+ Sequence[type[BasePermission] | OperandHolder | SingleOperandHolder] | None
159
+ ) = None
160
+ queryset: QuerysetLike = True
161
+ auth_method: Literal["get", "post", "put", "patch", "delete", "options"] = "get"
162
+
163
+ def __init__(self) -> None:
164
+ """Initialize the authenticator."""
165
+ self._view: ChanxAuthView
166
+ self.request: HttpRequest | None = None
167
+
168
+ # Main public methods
169
+
170
+ async def authenticate(self, scope: dict[str, Any]) -> AuthenticationResult:
171
+ """
172
+ Authenticate the WebSocket connection using DRF authentication.
173
+
174
+ Creates an HTTP request from the WebSocket scope, applies DRF authentication,
175
+ and returns the authentication result.
176
+
177
+ Args:
178
+ scope: The ASGI connection scope
179
+
180
+ Returns:
181
+ Authentication result with authentication status, data, user, and object
182
+ """
183
+ try:
184
+ # Create a request from the WebSocket scope
185
+ self.request = request_from_scope(scope, self.auth_method.upper())
186
+
187
+ # Bind context for structured logging
188
+ self._bind_structlog_request_context(self.request, scope)
189
+
190
+ # Perform authentication
191
+ response, request = await self._perform_dispatch(self.request, scope)
192
+
193
+ # Store the updated request
194
+ self.request = request
195
+
196
+ # Extract authentication results
197
+ status_code = response.status_code
198
+ status_text = response.status_text
199
+ is_authenticated = status_code == status.HTTP_200_OK
200
+
201
+ # Parse response data
202
+ response_data = response.data
203
+
204
+ # Success message
205
+ if is_authenticated:
206
+ response_data = {"detail": "OK"}
207
+
208
+ user = request.user
209
+ obj = getattr(request, "obj", None)
210
+
211
+ return AuthenticationResult(
212
+ is_authenticated=is_authenticated,
213
+ status_code=status_code,
214
+ status_text=status_text,
215
+ data=response_data,
216
+ user=user,
217
+ obj=obj,
218
+ )
219
+ except Exception as e:
220
+ # Log the exception but don't expose details to the client
221
+ await logger.aexception(
222
+ f"Authentication failed with unexpected error: {str(e)}"
223
+ )
224
+
225
+ return AuthenticationResult(
226
+ is_authenticated=False,
227
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
228
+ status_text="Internal Server Error",
229
+ data={"detail": "Internal server error"},
230
+ user=None,
231
+ obj=None,
232
+ )
233
+
234
+ # Configuration validation methods
235
+
236
+ def validate_configuration(self) -> None:
237
+ """
238
+ Validate authenticator configuration to catch common issues early.
239
+
240
+ Warns if permissions that might need object access are used without a queryset.
241
+ """
242
+ # Check if we might need object retrieval
243
+ needs_object = False
244
+ regex = r"(^|\.)BasePermission."
245
+ if self.permission_classes:
246
+ # Check if any permission class might need an object
247
+ for perm_class in self.permission_classes:
248
+ if hasattr(perm_class, "has_object_permission"):
249
+ meth = perm_class.has_object_permission
250
+ qname = meth.__qualname__
251
+
252
+ if not re.match(regex, qname):
253
+ needs_object = True
254
+ break
255
+
256
+ # Warn if we likely need an object but have no queryset
257
+ if needs_object and self.queryset is True:
258
+ warnings.warn(
259
+ "The authenticator has permissions that may require object "
260
+ "access, but no queryset is defined. This might cause errors during "
261
+ "authentication.",
262
+ RuntimeWarning,
263
+ stacklevel=2,
264
+ )
265
+
266
+ def _validate_scope_configuration(self, scope: dict[str, Any]) -> None:
267
+ """
268
+ Validate that the authenticator is properly configured for the given scope.
269
+
270
+ Args:
271
+ scope: The ASGI connection scope
272
+
273
+ Raises:
274
+ ValueError: If configuration is invalid for the given scope
275
+ """
276
+ # Check if we have URL parameters that would trigger get_object()
277
+ url_kwargs = scope.get("url_route", {}).get("kwargs", {})
278
+ has_lookup_param = bool(url_kwargs)
279
+
280
+ # If we have lookup parameters but no queryset, this will fail later
281
+ if has_lookup_param and self.queryset is True:
282
+ raise ValueError(
283
+ "Object retrieval requires a queryset. Please set the 'queryset' "
284
+ "attribute on your consumer or use an auth_class with a defined queryset."
285
+ )
286
+
287
+ # Helper methods
288
+
289
+ def _setup_auth_view(self) -> None:
290
+ """
291
+ Get or create the ChanxAuthView instance.
292
+
293
+ Returns:
294
+ Configured ChanxAuthView instance
295
+ """
296
+ self._view = ChanxAuthView()
297
+
298
+ # Apply configuration from consumer
299
+ if self.authentication_classes is not None:
300
+ self._view.authentication_classes = self.authentication_classes
301
+ if self.permission_classes is not None:
302
+ self._view.permission_classes = self.permission_classes
303
+ if not isinstance(self.queryset, bool): # Only set if it's not a boolean value
304
+ self._view.queryset = self.queryset
305
+
306
+ @sync_to_async
307
+ def _perform_dispatch(
308
+ self, req: HttpRequest, scope: dict[str, Any]
309
+ ) -> tuple[Response, HttpRequest]:
310
+ """
311
+ Perform authentication dispatch synchronously.
312
+
313
+ Args:
314
+ req: The HTTP request created from the WebSocket scope
315
+ scope: The ASGI connection scope
316
+
317
+ Returns:
318
+ Tuple of (response, updated request)
319
+ """
320
+ # Validate configuration before attempting dispatch
321
+ self._validate_scope_configuration(scope)
322
+
323
+ # Get the authentication view
324
+ self._setup_auth_view()
325
+
326
+ # Extract URL route arguments
327
+ url_route: dict[str, Any] = scope.get("url_route", {})
328
+ args = url_route.get("args", [])
329
+ kwargs = url_route.get("kwargs", {})
330
+
331
+ # Dispatch to the view
332
+ res = cast(Response, self._view.dispatch(req, *args, **kwargs))
333
+
334
+ # Ensure response is rendered
335
+ res.render()
336
+
337
+ # Get updated request from renderer context
338
+ req = (
339
+ res.renderer_context.get("request", req)
340
+ if hasattr(res, "renderer_context")
341
+ else req
342
+ )
343
+
344
+ return res, req
345
+
346
+ def _bind_structlog_request_context(
347
+ self, req: HttpRequest, scope: dict[str, Any]
348
+ ) -> None:
349
+ """
350
+ Bind structured logging context variables from request.
351
+
352
+ Args:
353
+ req: The HTTP request
354
+ scope: The ASGI connection scope
355
+ """
356
+ request_id = req.headers.get("x-request-id") or str(uuid.uuid4())
357
+
358
+ structlog.contextvars.bind_contextvars(
359
+ request_id=request_id, path=req.path, ip=scope.get("client", [None])[0]
360
+ )