@malamute/ai-rules 1.0.0 → 1.2.0
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.
- package/README.md +270 -121
- package/bin/cli.js +5 -2
- package/configs/_shared/.claude/rules/conventions/documentation.md +324 -0
- package/configs/_shared/.claude/rules/conventions/git.md +265 -0
- package/configs/_shared/.claude/rules/{performance.md → conventions/performance.md} +1 -1
- package/configs/_shared/.claude/rules/conventions/principles.md +334 -0
- package/configs/_shared/.claude/rules/devops/ci-cd.md +262 -0
- package/configs/_shared/.claude/rules/devops/docker.md +275 -0
- package/configs/_shared/.claude/rules/devops/nx.md +194 -0
- package/configs/_shared/.claude/rules/domain/backend/api-design.md +203 -0
- package/configs/_shared/.claude/rules/lang/csharp/async.md +220 -0
- package/configs/_shared/.claude/rules/lang/csharp/csharp.md +314 -0
- package/configs/_shared/.claude/rules/lang/csharp/linq.md +210 -0
- package/configs/_shared/.claude/rules/lang/python/async.md +337 -0
- package/configs/_shared/.claude/rules/lang/python/celery.md +476 -0
- package/configs/_shared/.claude/rules/lang/python/config.md +339 -0
- package/configs/{python/.claude/rules → _shared/.claude/rules/lang/python}/database/sqlalchemy.md +6 -1
- package/configs/_shared/.claude/rules/lang/python/deployment.md +523 -0
- package/configs/_shared/.claude/rules/lang/python/error-handling.md +330 -0
- package/configs/_shared/.claude/rules/lang/python/migrations.md +421 -0
- package/configs/_shared/.claude/rules/lang/python/python.md +172 -0
- package/configs/_shared/.claude/rules/lang/python/repository.md +383 -0
- package/configs/{python/.claude/rules → _shared/.claude/rules/lang/python}/testing.md +2 -69
- package/configs/_shared/.claude/rules/lang/typescript/async.md +447 -0
- package/configs/_shared/.claude/rules/lang/typescript/generics.md +356 -0
- package/configs/_shared/.claude/rules/lang/typescript/typescript.md +212 -0
- package/configs/_shared/.claude/rules/quality/error-handling.md +48 -0
- package/configs/_shared/.claude/rules/quality/logging.md +45 -0
- package/configs/_shared/.claude/rules/quality/observability.md +240 -0
- package/configs/_shared/.claude/rules/quality/testing-patterns.md +65 -0
- package/configs/_shared/.claude/rules/security/secrets-management.md +222 -0
- package/configs/_shared/.claude/skills/analysis/explore/SKILL.md +257 -0
- package/configs/_shared/.claude/skills/analysis/security-audit/SKILL.md +184 -0
- package/configs/_shared/.claude/skills/dev/api-endpoint/SKILL.md +126 -0
- package/configs/_shared/.claude/{commands/generate-tests.md → skills/dev/generate-tests/SKILL.md} +6 -0
- package/configs/_shared/.claude/{commands/fix-issue.md → skills/git/fix-issue/SKILL.md} +6 -0
- package/configs/_shared/.claude/{commands/review-pr.md → skills/git/review-pr/SKILL.md} +6 -0
- package/configs/_shared/.claude/skills/infra/deploy/SKILL.md +139 -0
- package/configs/_shared/.claude/skills/infra/docker/SKILL.md +95 -0
- package/configs/_shared/.claude/skills/infra/migration/SKILL.md +158 -0
- package/configs/_shared/.claude/skills/nx/nx-affected/SKILL.md +72 -0
- package/configs/_shared/.claude/skills/nx/nx-lib/SKILL.md +375 -0
- package/configs/_shared/CLAUDE.md +52 -149
- package/configs/angular/.claude/rules/{components.md → core/components.md} +69 -15
- package/configs/angular/.claude/rules/core/resource.md +285 -0
- package/configs/angular/.claude/rules/core/signals.md +323 -0
- package/configs/angular/.claude/rules/http.md +338 -0
- package/configs/angular/.claude/rules/routing.md +291 -0
- package/configs/angular/.claude/rules/ssr.md +312 -0
- package/configs/angular/.claude/rules/state/signal-store.md +408 -0
- package/configs/angular/.claude/rules/{state.md → state/state.md} +2 -2
- package/configs/angular/.claude/rules/testing.md +7 -7
- package/configs/angular/.claude/rules/ui/aria.md +422 -0
- package/configs/angular/.claude/rules/ui/forms.md +424 -0
- package/configs/angular/.claude/rules/ui/pipes-directives.md +335 -0
- package/configs/angular/.claude/settings.json +1 -0
- package/configs/angular/.claude/skills/ngrx-slice/SKILL.md +362 -0
- package/configs/angular/.claude/skills/signal-store/SKILL.md +445 -0
- package/configs/angular/CLAUDE.md +24 -216
- package/configs/dotnet/.claude/rules/background-services.md +552 -0
- package/configs/dotnet/.claude/rules/configuration.md +426 -0
- package/configs/dotnet/.claude/rules/ddd.md +447 -0
- package/configs/dotnet/.claude/rules/dependency-injection.md +343 -0
- package/configs/dotnet/.claude/rules/mediatr.md +320 -0
- package/configs/dotnet/.claude/rules/middleware.md +489 -0
- package/configs/dotnet/.claude/rules/result-pattern.md +363 -0
- package/configs/dotnet/.claude/rules/validation.md +388 -0
- package/configs/dotnet/.claude/settings.json +21 -3
- package/configs/dotnet/CLAUDE.md +53 -286
- package/configs/fastapi/.claude/rules/background-tasks.md +254 -0
- package/configs/fastapi/.claude/rules/dependencies.md +170 -0
- package/configs/{python → fastapi}/.claude/rules/fastapi.md +61 -1
- package/configs/fastapi/.claude/rules/lifespan.md +274 -0
- package/configs/fastapi/.claude/rules/middleware.md +229 -0
- package/configs/fastapi/.claude/rules/pydantic.md +433 -0
- package/configs/fastapi/.claude/rules/responses.md +251 -0
- package/configs/fastapi/.claude/rules/routers.md +202 -0
- package/configs/fastapi/.claude/rules/security.md +222 -0
- package/configs/fastapi/.claude/rules/testing.md +251 -0
- package/configs/fastapi/.claude/rules/websockets.md +298 -0
- package/configs/fastapi/.claude/settings.json +33 -0
- package/configs/fastapi/CLAUDE.md +144 -0
- package/configs/flask/.claude/rules/blueprints.md +208 -0
- package/configs/flask/.claude/rules/cli.md +285 -0
- package/configs/flask/.claude/rules/configuration.md +281 -0
- package/configs/flask/.claude/rules/context.md +238 -0
- package/configs/flask/.claude/rules/error-handlers.md +278 -0
- package/configs/flask/.claude/rules/extensions.md +278 -0
- package/configs/flask/.claude/rules/flask.md +171 -0
- package/configs/flask/.claude/rules/marshmallow.md +206 -0
- package/configs/flask/.claude/rules/security.md +267 -0
- package/configs/flask/.claude/rules/testing.md +284 -0
- package/configs/flask/.claude/settings.json +33 -0
- package/configs/flask/CLAUDE.md +166 -0
- package/configs/nestjs/.claude/rules/common-patterns.md +300 -0
- package/configs/nestjs/.claude/rules/filters.md +376 -0
- package/configs/nestjs/.claude/rules/interceptors.md +317 -0
- package/configs/nestjs/.claude/rules/middleware.md +321 -0
- package/configs/nestjs/.claude/rules/modules.md +26 -0
- package/configs/nestjs/.claude/rules/pipes.md +351 -0
- package/configs/nestjs/.claude/rules/websockets.md +451 -0
- package/configs/nestjs/.claude/settings.json +16 -2
- package/configs/nestjs/CLAUDE.md +57 -215
- package/configs/nextjs/.claude/rules/api-routes.md +358 -0
- package/configs/nextjs/.claude/rules/authentication.md +355 -0
- package/configs/nextjs/.claude/rules/components.md +52 -0
- package/configs/nextjs/.claude/rules/data-fetching.md +249 -0
- package/configs/nextjs/.claude/rules/database.md +400 -0
- package/configs/nextjs/.claude/rules/middleware.md +303 -0
- package/configs/nextjs/.claude/rules/routing.md +324 -0
- package/configs/nextjs/.claude/rules/seo.md +350 -0
- package/configs/nextjs/.claude/rules/server-actions.md +353 -0
- package/configs/nextjs/.claude/rules/state/zustand.md +6 -6
- package/configs/nextjs/.claude/settings.json +5 -0
- package/configs/nextjs/CLAUDE.md +69 -331
- package/package.json +23 -9
- package/src/cli.js +220 -0
- package/src/config.js +29 -0
- package/src/index.js +13 -0
- package/src/installer.js +361 -0
- package/src/merge.js +116 -0
- package/src/tech-config.json +29 -0
- package/src/utils.js +96 -0
- package/configs/python/.claude/rules/flask.md +0 -332
- package/configs/python/.claude/settings.json +0 -18
- package/configs/python/CLAUDE.md +0 -273
- package/src/install.js +0 -315
- /package/configs/_shared/.claude/rules/{accessibility.md → domain/frontend/accessibility.md} +0 -0
- /package/configs/_shared/.claude/rules/{security.md → security/security.md} +0 -0
- /package/configs/_shared/.claude/skills/{debug → dev/debug}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{learning → dev/learning}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{spec → dev/spec}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{review → git/review}/SKILL.md +0 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "tests/**/*.py"
|
|
4
|
+
- "**/test_*.py"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# FastAPI Testing Patterns
|
|
8
|
+
|
|
9
|
+
## Test Client Setup
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import pytest
|
|
13
|
+
from httpx import AsyncClient, ASGITransport
|
|
14
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
|
15
|
+
from sqlalchemy.orm import sessionmaker
|
|
16
|
+
|
|
17
|
+
from app.main import app
|
|
18
|
+
from app.database import Base, get_db
|
|
19
|
+
|
|
20
|
+
TEST_DATABASE_URL = "sqlite+aiosqlite:///./test.db"
|
|
21
|
+
|
|
22
|
+
@pytest.fixture(scope="session")
|
|
23
|
+
def event_loop():
|
|
24
|
+
import asyncio
|
|
25
|
+
loop = asyncio.new_event_loop()
|
|
26
|
+
yield loop
|
|
27
|
+
loop.close()
|
|
28
|
+
|
|
29
|
+
@pytest.fixture(scope="session")
|
|
30
|
+
async def engine():
|
|
31
|
+
engine = create_async_engine(TEST_DATABASE_URL)
|
|
32
|
+
async with engine.begin() as conn:
|
|
33
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
34
|
+
yield engine
|
|
35
|
+
async with engine.begin() as conn:
|
|
36
|
+
await conn.run_sync(Base.metadata.drop_all)
|
|
37
|
+
await engine.dispose()
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
async def db_session(engine):
|
|
41
|
+
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
42
|
+
async with async_session() as session:
|
|
43
|
+
yield session
|
|
44
|
+
await session.rollback()
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
async def client(db_session):
|
|
48
|
+
async def override_get_db():
|
|
49
|
+
yield db_session
|
|
50
|
+
|
|
51
|
+
app.dependency_overrides[get_db] = override_get_db
|
|
52
|
+
|
|
53
|
+
async with AsyncClient(
|
|
54
|
+
transport=ASGITransport(app=app),
|
|
55
|
+
base_url="http://test",
|
|
56
|
+
) as client:
|
|
57
|
+
yield client
|
|
58
|
+
|
|
59
|
+
app.dependency_overrides.clear()
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## API Tests
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
class TestUsersAPI:
|
|
66
|
+
async def test_create_user(self, client: AsyncClient):
|
|
67
|
+
response = await client.post("/api/v1/users", json={
|
|
68
|
+
"email": "test@example.com",
|
|
69
|
+
"password": "password123",
|
|
70
|
+
"name": "Test User",
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
assert response.status_code == 201
|
|
74
|
+
data = response.json()
|
|
75
|
+
assert data["email"] == "test@example.com"
|
|
76
|
+
assert "id" in data
|
|
77
|
+
assert "password" not in data
|
|
78
|
+
|
|
79
|
+
async def test_create_user_duplicate_email(self, client: AsyncClient, db_session):
|
|
80
|
+
# Create existing user
|
|
81
|
+
user = User(email="existing@example.com", name="Existing")
|
|
82
|
+
db_session.add(user)
|
|
83
|
+
await db_session.commit()
|
|
84
|
+
|
|
85
|
+
response = await client.post("/api/v1/users", json={
|
|
86
|
+
"email": "existing@example.com",
|
|
87
|
+
"password": "password123",
|
|
88
|
+
"name": "New User",
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
assert response.status_code == 409
|
|
92
|
+
assert "already exists" in response.json()["detail"]
|
|
93
|
+
|
|
94
|
+
async def test_get_user_not_found(self, client: AsyncClient):
|
|
95
|
+
response = await client.get("/api/v1/users/99999")
|
|
96
|
+
|
|
97
|
+
assert response.status_code == 404
|
|
98
|
+
|
|
99
|
+
async def test_list_users_pagination(self, client: AsyncClient, db_session):
|
|
100
|
+
# Create users
|
|
101
|
+
for i in range(25):
|
|
102
|
+
db_session.add(User(email=f"user{i}@example.com", name=f"User {i}"))
|
|
103
|
+
await db_session.commit()
|
|
104
|
+
|
|
105
|
+
response = await client.get("/api/v1/users?page=2&size=10")
|
|
106
|
+
|
|
107
|
+
assert response.status_code == 200
|
|
108
|
+
data = response.json()
|
|
109
|
+
assert len(data["items"]) == 10
|
|
110
|
+
assert data["total"] == 25
|
|
111
|
+
assert data["page"] == 2
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Authentication Tests
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
@pytest.fixture
|
|
118
|
+
async def auth_headers(client: AsyncClient, db_session):
|
|
119
|
+
# Create user
|
|
120
|
+
user = User(email="auth@example.com", name="Auth User")
|
|
121
|
+
user.set_password("password123")
|
|
122
|
+
db_session.add(user)
|
|
123
|
+
await db_session.commit()
|
|
124
|
+
|
|
125
|
+
# Get token
|
|
126
|
+
response = await client.post("/api/v1/auth/login", data={
|
|
127
|
+
"username": "auth@example.com",
|
|
128
|
+
"password": "password123",
|
|
129
|
+
})
|
|
130
|
+
token = response.json()["access_token"]
|
|
131
|
+
|
|
132
|
+
return {"Authorization": f"Bearer {token}"}
|
|
133
|
+
|
|
134
|
+
class TestAuthenticatedEndpoints:
|
|
135
|
+
async def test_get_me_unauthorized(self, client: AsyncClient):
|
|
136
|
+
response = await client.get("/api/v1/users/me")
|
|
137
|
+
assert response.status_code == 401
|
|
138
|
+
|
|
139
|
+
async def test_get_me_authorized(self, client: AsyncClient, auth_headers):
|
|
140
|
+
response = await client.get("/api/v1/users/me", headers=auth_headers)
|
|
141
|
+
assert response.status_code == 200
|
|
142
|
+
assert response.json()["email"] == "auth@example.com"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Dependency Override
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from unittest.mock import AsyncMock
|
|
149
|
+
|
|
150
|
+
@pytest.fixture
|
|
151
|
+
def mock_email_service():
|
|
152
|
+
return AsyncMock()
|
|
153
|
+
|
|
154
|
+
async def test_signup_sends_email(client: AsyncClient, mock_email_service):
|
|
155
|
+
app.dependency_overrides[get_email_service] = lambda: mock_email_service
|
|
156
|
+
|
|
157
|
+
response = await client.post("/api/v1/auth/signup", json={
|
|
158
|
+
"email": "new@example.com",
|
|
159
|
+
"password": "password123",
|
|
160
|
+
"name": "New User",
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
assert response.status_code == 201
|
|
164
|
+
mock_email_service.send_welcome.assert_called_once_with("new@example.com")
|
|
165
|
+
|
|
166
|
+
app.dependency_overrides.clear()
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## WebSocket Tests
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from httpx_ws import aconnect_ws
|
|
173
|
+
|
|
174
|
+
async def test_websocket_echo(client: AsyncClient):
|
|
175
|
+
async with aconnect_ws("http://test/ws", client) as ws:
|
|
176
|
+
await ws.send_text("Hello")
|
|
177
|
+
response = await ws.receive_text()
|
|
178
|
+
assert response == "Echo: Hello"
|
|
179
|
+
|
|
180
|
+
async def test_websocket_auth_required():
|
|
181
|
+
async with AsyncClient(
|
|
182
|
+
transport=ASGITransport(app=app),
|
|
183
|
+
base_url="http://test",
|
|
184
|
+
) as client:
|
|
185
|
+
with pytest.raises(Exception):
|
|
186
|
+
async with aconnect_ws("http://test/ws", client) as ws:
|
|
187
|
+
pass # Should fail without token
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## File Upload Tests
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
async def test_file_upload(client: AsyncClient, auth_headers):
|
|
194
|
+
files = {"file": ("test.txt", b"file content", "text/plain")}
|
|
195
|
+
|
|
196
|
+
response = await client.post(
|
|
197
|
+
"/api/v1/files/upload",
|
|
198
|
+
files=files,
|
|
199
|
+
headers=auth_headers,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
assert response.status_code == 201
|
|
203
|
+
assert response.json()["filename"] == "test.txt"
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Parametrized Tests
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
@pytest.mark.parametrize("email,expected_valid", [
|
|
210
|
+
("valid@example.com", True),
|
|
211
|
+
("also.valid@example.co.uk", True),
|
|
212
|
+
("invalid", False),
|
|
213
|
+
("missing@", False),
|
|
214
|
+
("@nodomain.com", False),
|
|
215
|
+
])
|
|
216
|
+
async def test_email_validation(client: AsyncClient, email: str, expected_valid: bool):
|
|
217
|
+
response = await client.post("/api/v1/users", json={
|
|
218
|
+
"email": email,
|
|
219
|
+
"password": "password123",
|
|
220
|
+
"name": "Test",
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
if expected_valid:
|
|
224
|
+
assert response.status_code in (201, 409) # Created or duplicate
|
|
225
|
+
else:
|
|
226
|
+
assert response.status_code == 422
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Test Markers
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
# pyproject.toml
|
|
233
|
+
[tool.pytest.ini_options]
|
|
234
|
+
markers = [
|
|
235
|
+
"slow: marks tests as slow",
|
|
236
|
+
"integration: requires database",
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
# Usage
|
|
240
|
+
@pytest.mark.slow
|
|
241
|
+
async def test_heavy_computation():
|
|
242
|
+
...
|
|
243
|
+
|
|
244
|
+
@pytest.mark.integration
|
|
245
|
+
async def test_database_query():
|
|
246
|
+
...
|
|
247
|
+
|
|
248
|
+
# Run specific markers
|
|
249
|
+
# pytest -m "not slow"
|
|
250
|
+
# pytest -m integration
|
|
251
|
+
```
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*.py"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# FastAPI WebSocket Patterns
|
|
7
|
+
|
|
8
|
+
## Basic WebSocket
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from fastapi import WebSocket, WebSocketDisconnect
|
|
12
|
+
|
|
13
|
+
@app.websocket("/ws")
|
|
14
|
+
async def websocket_endpoint(websocket: WebSocket):
|
|
15
|
+
await websocket.accept()
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
while True:
|
|
19
|
+
data = await websocket.receive_text()
|
|
20
|
+
await websocket.send_text(f"Echo: {data}")
|
|
21
|
+
except WebSocketDisconnect:
|
|
22
|
+
print("Client disconnected")
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Connection Manager
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from typing import List
|
|
29
|
+
|
|
30
|
+
class ConnectionManager:
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self.active_connections: list[WebSocket] = []
|
|
33
|
+
|
|
34
|
+
async def connect(self, websocket: WebSocket):
|
|
35
|
+
await websocket.accept()
|
|
36
|
+
self.active_connections.append(websocket)
|
|
37
|
+
|
|
38
|
+
def disconnect(self, websocket: WebSocket):
|
|
39
|
+
self.active_connections.remove(websocket)
|
|
40
|
+
|
|
41
|
+
async def send_personal_message(self, message: str, websocket: WebSocket):
|
|
42
|
+
await websocket.send_text(message)
|
|
43
|
+
|
|
44
|
+
async def broadcast(self, message: str):
|
|
45
|
+
for connection in self.active_connections:
|
|
46
|
+
await connection.send_text(message)
|
|
47
|
+
|
|
48
|
+
manager = ConnectionManager()
|
|
49
|
+
|
|
50
|
+
@app.websocket("/ws/{client_id}")
|
|
51
|
+
async def websocket_endpoint(websocket: WebSocket, client_id: str):
|
|
52
|
+
await manager.connect(websocket)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
while True:
|
|
56
|
+
data = await websocket.receive_text()
|
|
57
|
+
await manager.broadcast(f"Client {client_id}: {data}")
|
|
58
|
+
except WebSocketDisconnect:
|
|
59
|
+
manager.disconnect(websocket)
|
|
60
|
+
await manager.broadcast(f"Client {client_id} left")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Room-Based Connections
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from collections import defaultdict
|
|
67
|
+
|
|
68
|
+
class RoomManager:
|
|
69
|
+
def __init__(self):
|
|
70
|
+
self.rooms: dict[str, list[WebSocket]] = defaultdict(list)
|
|
71
|
+
|
|
72
|
+
async def join_room(self, room: str, websocket: WebSocket):
|
|
73
|
+
await websocket.accept()
|
|
74
|
+
self.rooms[room].append(websocket)
|
|
75
|
+
|
|
76
|
+
def leave_room(self, room: str, websocket: WebSocket):
|
|
77
|
+
if websocket in self.rooms[room]:
|
|
78
|
+
self.rooms[room].remove(websocket)
|
|
79
|
+
if not self.rooms[room]:
|
|
80
|
+
del self.rooms[room]
|
|
81
|
+
|
|
82
|
+
async def broadcast_to_room(self, room: str, message: str, exclude: WebSocket = None):
|
|
83
|
+
for connection in self.rooms[room]:
|
|
84
|
+
if connection != exclude:
|
|
85
|
+
await connection.send_text(message)
|
|
86
|
+
|
|
87
|
+
room_manager = RoomManager()
|
|
88
|
+
|
|
89
|
+
@app.websocket("/ws/room/{room_id}")
|
|
90
|
+
async def room_websocket(websocket: WebSocket, room_id: str):
|
|
91
|
+
await room_manager.join_room(room_id, websocket)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
while True:
|
|
95
|
+
data = await websocket.receive_text()
|
|
96
|
+
await room_manager.broadcast_to_room(room_id, data, exclude=websocket)
|
|
97
|
+
except WebSocketDisconnect:
|
|
98
|
+
room_manager.leave_room(room_id, websocket)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Authentication
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from fastapi import Query, status
|
|
105
|
+
|
|
106
|
+
async def get_user_from_token(token: str) -> User | None:
|
|
107
|
+
# Verify JWT token
|
|
108
|
+
try:
|
|
109
|
+
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
|
|
110
|
+
return await get_user(payload["sub"])
|
|
111
|
+
except JWTError:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
@app.websocket("/ws")
|
|
115
|
+
async def websocket_endpoint(
|
|
116
|
+
websocket: WebSocket,
|
|
117
|
+
token: str = Query(...),
|
|
118
|
+
):
|
|
119
|
+
user = await get_user_from_token(token)
|
|
120
|
+
|
|
121
|
+
if not user:
|
|
122
|
+
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
await websocket.accept()
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
while True:
|
|
129
|
+
data = await websocket.receive_text()
|
|
130
|
+
await websocket.send_text(f"Hello {user.name}: {data}")
|
|
131
|
+
except WebSocketDisconnect:
|
|
132
|
+
pass
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## JSON Messages
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from pydantic import BaseModel
|
|
139
|
+
|
|
140
|
+
class WSMessage(BaseModel):
|
|
141
|
+
type: str
|
|
142
|
+
payload: dict
|
|
143
|
+
|
|
144
|
+
class WSResponse(BaseModel):
|
|
145
|
+
type: str
|
|
146
|
+
data: dict
|
|
147
|
+
|
|
148
|
+
@app.websocket("/ws")
|
|
149
|
+
async def websocket_endpoint(websocket: WebSocket):
|
|
150
|
+
await websocket.accept()
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
while True:
|
|
154
|
+
raw_data = await websocket.receive_json()
|
|
155
|
+
message = WSMessage(**raw_data)
|
|
156
|
+
|
|
157
|
+
if message.type == "ping":
|
|
158
|
+
response = WSResponse(type="pong", data={})
|
|
159
|
+
elif message.type == "subscribe":
|
|
160
|
+
# Handle subscription
|
|
161
|
+
response = WSResponse(type="subscribed", data=message.payload)
|
|
162
|
+
else:
|
|
163
|
+
response = WSResponse(type="error", data={"message": "Unknown type"})
|
|
164
|
+
|
|
165
|
+
await websocket.send_json(response.model_dump())
|
|
166
|
+
except WebSocketDisconnect:
|
|
167
|
+
pass
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Pub/Sub with Redis
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
import aioredis
|
|
174
|
+
import asyncio
|
|
175
|
+
|
|
176
|
+
class PubSubManager:
|
|
177
|
+
def __init__(self, redis_url: str):
|
|
178
|
+
self.redis_url = redis_url
|
|
179
|
+
self.pubsub = None
|
|
180
|
+
self.redis = None
|
|
181
|
+
|
|
182
|
+
async def connect(self):
|
|
183
|
+
self.redis = await aioredis.from_url(self.redis_url)
|
|
184
|
+
self.pubsub = self.redis.pubsub()
|
|
185
|
+
|
|
186
|
+
async def subscribe(self, channel: str):
|
|
187
|
+
await self.pubsub.subscribe(channel)
|
|
188
|
+
|
|
189
|
+
async def publish(self, channel: str, message: str):
|
|
190
|
+
await self.redis.publish(channel, message)
|
|
191
|
+
|
|
192
|
+
async def listen(self):
|
|
193
|
+
async for message in self.pubsub.listen():
|
|
194
|
+
if message["type"] == "message":
|
|
195
|
+
yield message["data"].decode()
|
|
196
|
+
|
|
197
|
+
pubsub = PubSubManager("redis://localhost")
|
|
198
|
+
|
|
199
|
+
@app.websocket("/ws/subscribe/{channel}")
|
|
200
|
+
async def subscribe_websocket(websocket: WebSocket, channel: str):
|
|
201
|
+
await websocket.accept()
|
|
202
|
+
await pubsub.connect()
|
|
203
|
+
await pubsub.subscribe(channel)
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
# Task to receive from WebSocket
|
|
207
|
+
async def receive():
|
|
208
|
+
while True:
|
|
209
|
+
data = await websocket.receive_text()
|
|
210
|
+
await pubsub.publish(channel, data)
|
|
211
|
+
|
|
212
|
+
# Task to send from Redis
|
|
213
|
+
async def send():
|
|
214
|
+
async for message in pubsub.listen():
|
|
215
|
+
await websocket.send_text(message)
|
|
216
|
+
|
|
217
|
+
await asyncio.gather(receive(), send())
|
|
218
|
+
except WebSocketDisconnect:
|
|
219
|
+
pass
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Heartbeat / Keep-Alive
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
import asyncio
|
|
226
|
+
|
|
227
|
+
@app.websocket("/ws")
|
|
228
|
+
async def websocket_endpoint(websocket: WebSocket):
|
|
229
|
+
await websocket.accept()
|
|
230
|
+
|
|
231
|
+
async def send_heartbeat():
|
|
232
|
+
while True:
|
|
233
|
+
try:
|
|
234
|
+
await asyncio.sleep(30)
|
|
235
|
+
await websocket.send_json({"type": "ping"})
|
|
236
|
+
except:
|
|
237
|
+
break
|
|
238
|
+
|
|
239
|
+
heartbeat_task = asyncio.create_task(send_heartbeat())
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
while True:
|
|
243
|
+
data = await websocket.receive_json()
|
|
244
|
+
|
|
245
|
+
if data.get("type") == "pong":
|
|
246
|
+
continue # Heartbeat response
|
|
247
|
+
|
|
248
|
+
# Handle other messages
|
|
249
|
+
await process_message(data)
|
|
250
|
+
except WebSocketDisconnect:
|
|
251
|
+
heartbeat_task.cancel()
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Rate Limiting WebSocket
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
from collections import defaultdict
|
|
258
|
+
import time
|
|
259
|
+
|
|
260
|
+
class WebSocketRateLimiter:
|
|
261
|
+
def __init__(self, max_messages: int = 10, window: int = 1):
|
|
262
|
+
self.max_messages = max_messages
|
|
263
|
+
self.window = window
|
|
264
|
+
self.messages: dict[WebSocket, list[float]] = defaultdict(list)
|
|
265
|
+
|
|
266
|
+
def is_allowed(self, websocket: WebSocket) -> bool:
|
|
267
|
+
now = time.time()
|
|
268
|
+
|
|
269
|
+
# Clean old messages
|
|
270
|
+
self.messages[websocket] = [
|
|
271
|
+
t for t in self.messages[websocket]
|
|
272
|
+
if now - t < self.window
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
if len(self.messages[websocket]) >= self.max_messages:
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
self.messages[websocket].append(now)
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
rate_limiter = WebSocketRateLimiter(max_messages=10, window=1)
|
|
282
|
+
|
|
283
|
+
@app.websocket("/ws")
|
|
284
|
+
async def websocket_endpoint(websocket: WebSocket):
|
|
285
|
+
await websocket.accept()
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
while True:
|
|
289
|
+
data = await websocket.receive_text()
|
|
290
|
+
|
|
291
|
+
if not rate_limiter.is_allowed(websocket):
|
|
292
|
+
await websocket.send_json({"error": "Rate limit exceeded"})
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
await process_and_respond(websocket, data)
|
|
296
|
+
except WebSocketDisconnect:
|
|
297
|
+
pass
|
|
298
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(python *)",
|
|
5
|
+
"Bash(python3 *)",
|
|
6
|
+
"Bash(uvicorn *)",
|
|
7
|
+
"Bash(gunicorn *)",
|
|
8
|
+
"Bash(fastapi *)",
|
|
9
|
+
"Bash(pytest *)",
|
|
10
|
+
"Bash(ruff *)",
|
|
11
|
+
"Bash(mypy *)",
|
|
12
|
+
"Bash(alembic *)",
|
|
13
|
+
"Bash(uv *)",
|
|
14
|
+
"Bash(poetry *)",
|
|
15
|
+
"Bash(pip *)",
|
|
16
|
+
"Bash(pip3 *)",
|
|
17
|
+
"Read",
|
|
18
|
+
"Edit",
|
|
19
|
+
"Write"
|
|
20
|
+
],
|
|
21
|
+
"deny": [
|
|
22
|
+
"Bash(rm -rf *)",
|
|
23
|
+
"Read(.env)",
|
|
24
|
+
"Read(.env.*)",
|
|
25
|
+
"Read(**/secrets/**)",
|
|
26
|
+
"Read(**/*.pem)"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"env": {
|
|
30
|
+
"PYTHONDONTWRITEBYTECODE": "1",
|
|
31
|
+
"PYTHONUNBUFFERED": "1"
|
|
32
|
+
}
|
|
33
|
+
}
|