From 1706c0b8967be85b04943f89ed3dea1434989dd5 Mon Sep 17 00:00:00 2001 From: SURESH CHOUKSEY Date: Sun, 17 May 2026 04:51:13 +0530 Subject: [PATCH] Add FastAPI real-time chat template --- fastapi-realtime-chat/.gitignore | 5 + fastapi-realtime-chat/README.md | 49 +++++ fastapi-realtime-chat/app/__init__.py | 1 + fastapi-realtime-chat/app/auth.py | 56 ++++++ fastapi-realtime-chat/app/database.py | 57 ++++++ fastapi-realtime-chat/app/main.py | 130 +++++++++++++ fastapi-realtime-chat/app/static/app.js | 155 +++++++++++++++ fastapi-realtime-chat/app/static/index.html | 47 +++++ fastapi-realtime-chat/app/static/styles.css | 205 ++++++++++++++++++++ fastapi-realtime-chat/ci.yml | 18 ++ fastapi-realtime-chat/metadata.json | 7 + fastapi-realtime-chat/requirements.txt | 6 + fastapi-realtime-chat/scripts/init_db.py | 11 ++ fastapi-realtime-chat/tests/conftest.py | 4 + fastapi-realtime-chat/tests/test_app.py | 45 +++++ 15 files changed, 796 insertions(+) create mode 100644 fastapi-realtime-chat/.gitignore create mode 100644 fastapi-realtime-chat/README.md create mode 100644 fastapi-realtime-chat/app/__init__.py create mode 100644 fastapi-realtime-chat/app/auth.py create mode 100644 fastapi-realtime-chat/app/database.py create mode 100644 fastapi-realtime-chat/app/main.py create mode 100644 fastapi-realtime-chat/app/static/app.js create mode 100644 fastapi-realtime-chat/app/static/index.html create mode 100644 fastapi-realtime-chat/app/static/styles.css create mode 100644 fastapi-realtime-chat/ci.yml create mode 100644 fastapi-realtime-chat/metadata.json create mode 100644 fastapi-realtime-chat/requirements.txt create mode 100644 fastapi-realtime-chat/scripts/init_db.py create mode 100644 fastapi-realtime-chat/tests/conftest.py create mode 100644 fastapi-realtime-chat/tests/test_app.py diff --git a/fastapi-realtime-chat/.gitignore b/fastapi-realtime-chat/.gitignore new file mode 100644 index 0000000..7b2aa31 --- /dev/null +++ b/fastapi-realtime-chat/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +chat.db +.pytest_cache/ diff --git a/fastapi-realtime-chat/README.md b/fastapi-realtime-chat/README.md new file mode 100644 index 0000000..d08b50a --- /dev/null +++ b/fastapi-realtime-chat/README.md @@ -0,0 +1,49 @@ +# FastAPI Real-Time Chat + +This template is a real-time chat application built with FastAPI, WebSockets, simple token-based authentication, and SQLite message history. It is designed to run as a single Codesphere workspace with no external services. + +## Demo Users + +The app ships with three demo users: + +| Username | Password | +| --- | --- | +| ada | `codesphere` | +| grace | `codesphere` | +| linus | `codesphere` | + +## Run Locally + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python scripts/init_db.py +uvicorn app.main:app --host 0.0.0.0 --port 3000 --reload +``` + +Open `http://localhost:3000`, sign in as a demo user, choose a room, and send messages from multiple browser tabs. + +## Codesphere + +The included `ci.yml` installs dependencies, initializes the SQLite database, runs tests, and starts the FastAPI server on port `3000`. + +## What This Template Demonstrates + +- FastAPI route handlers and static file serving. +- WebSocket connection management. +- Token-based authentication for HTTP and WebSocket requests. +- SQLite persistence without extra infrastructure. +- A browser UI that reconnects by opening a new authenticated socket per room. + +## Article Draft + +### Building a Real-Time Chat App with FastAPI WebSockets on Codesphere + +FastAPI is a strong choice for real-time applications because it supports both traditional HTTP endpoints and WebSockets in the same app. This template uses that combination to build a small authenticated chat application that can be deployed in one Codesphere workspace. + +The backend has a `/login` endpoint for demo authentication, `/rooms` and `/messages/{room}` endpoints for loading the interface, and a `/ws/{room}` WebSocket endpoint for live messages. When a user sends a message, FastAPI stores it in SQLite and broadcasts the saved payload to everyone connected to the same room. + +SQLite keeps the template simple: there is no database server to configure, but messages still survive process restarts. The `scripts/init_db.py` script creates the schema, and the CI pipeline runs it before starting the app. + +In Codesphere, the workflow is straightforward. The prepare step installs Python dependencies, initializes the database, and runs tests. The run step starts Uvicorn on port `3000`. From there, developers can replace the demo user map with a real user table, add private rooms, or connect the app to a production database. diff --git a/fastapi-realtime-chat/app/__init__.py b/fastapi-realtime-chat/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastapi-realtime-chat/app/__init__.py @@ -0,0 +1 @@ + diff --git a/fastapi-realtime-chat/app/auth.py b/fastapi-realtime-chat/app/auth.py new file mode 100644 index 0000000..1a7bae0 --- /dev/null +++ b/fastapi-realtime-chat/app/auth.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import secrets +from typing import Annotated + +from fastapi import Depends, HTTPException, Query, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +DEMO_USERS = { + "ada": "codesphere", + "grace": "codesphere", + "linus": "codesphere", +} + +_active_tokens: dict[str, str] = {} +_bearer = HTTPBearer(auto_error=False) + + +def login(username: str, password: str) -> str: + if DEMO_USERS.get(username) != password: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password", + ) + + token = secrets.token_urlsafe(24) + _active_tokens[token] = username + return token + + +def username_for_token(token: str | None) -> str | None: + if not token: + return None + return _active_tokens.get(token) + + +def require_user( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)], +) -> str: + username = username_for_token(credentials.credentials if credentials else None) + if not username: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing or invalid token", + ) + return username + + +def require_socket_user(token: Annotated[str | None, Query()] = None) -> str: + username = username_for_token(token) + if not username: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing or invalid token", + ) + return username diff --git a/fastapi-realtime-chat/app/database.py b/fastapi-realtime-chat/app/database.py new file mode 100644 index 0000000..fdd1fd7 --- /dev/null +++ b/fastapi-realtime-chat/app/database.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +DATABASE_PATH = Path(__file__).resolve().parents[1] / "chat.db" + + +def get_connection() -> sqlite3.Connection: + connection = sqlite3.connect(DATABASE_PATH) + connection.row_factory = sqlite3.Row + return connection + + +def init_db() -> None: + with get_connection() as connection: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room TEXT NOT NULL, + username TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + +def save_message(room: str, username: str, body: str) -> dict: + with get_connection() as connection: + cursor = connection.execute( + """ + INSERT INTO messages (room, username, body) + VALUES (?, ?, ?) + RETURNING id, room, username, body, created_at + """, + (room, username, body), + ) + row = cursor.fetchone() + connection.commit() + return dict(row) + + +def list_messages(room: str, limit: int = 50) -> list[dict]: + with get_connection() as connection: + rows = connection.execute( + """ + SELECT id, room, username, body, created_at + FROM messages + WHERE room = ? + ORDER BY id DESC + LIMIT ? + """, + (room, limit), + ).fetchall() + return [dict(row) for row in reversed(rows)] diff --git a/fastapi-realtime-chat/app/main.py b/fastapi-realtime-chat/app/main.py new file mode 100644 index 0000000..76bb9cd --- /dev/null +++ b/fastapi-realtime-chat/app/main.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager + +from fastapi import Depends, FastAPI, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +from app import auth, database + +ROOMS = ["general", "product", "support"] + + +@asynccontextmanager +async def lifespan(_: FastAPI): + database.init_db() + yield + + +app = FastAPI(title="FastAPI Real-Time Chat", lifespan=lifespan) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) +app.mount("/static", StaticFiles(directory="app/static"), name="static") + + +class LoginRequest(BaseModel): + username: str = Field(min_length=1, max_length=40) + password: str = Field(min_length=1, max_length=80) + + +class LoginResponse(BaseModel): + token: str + username: str + + +class ChatMessage(BaseModel): + id: int + room: str + username: str + body: str + created_at: str + + +class ConnectionManager: + def __init__(self) -> None: + self.active_connections: dict[str, set[WebSocket]] = {} + + async def connect(self, room: str, websocket: WebSocket) -> None: + await websocket.accept() + self.active_connections.setdefault(room, set()).add(websocket) + + def disconnect(self, room: str, websocket: WebSocket) -> None: + connections = self.active_connections.get(room) + if not connections: + return + connections.discard(websocket) + if not connections: + self.active_connections.pop(room, None) + + async def broadcast(self, room: str, message: dict) -> None: + for connection in list(self.active_connections.get(room, set())): + await connection.send_json(message) + + +manager = ConnectionManager() + + +@app.get("/") +async def index() -> FileResponse: + return FileResponse("app/static/index.html") + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.post("/login", response_model=LoginResponse) +async def login(payload: LoginRequest) -> LoginResponse: + token = auth.login(payload.username, payload.password) + return LoginResponse(token=token, username=payload.username) + + +@app.get("/rooms") +async def rooms(username: str = Depends(auth.require_user)) -> dict: + return {"username": username, "rooms": ROOMS} + + +@app.get("/messages/{room}", response_model=list[ChatMessage]) +async def messages(room: str, username: str = Depends(auth.require_user)) -> list[dict]: + _ = username + return database.list_messages(room) + + +@app.websocket("/ws/{room}") +async def websocket_endpoint(websocket: WebSocket, room: str) -> None: + token = websocket.query_params.get("token") + username = auth.username_for_token(token) + if not username: + await websocket.close(code=1008) + return + + await manager.connect(room, websocket) + try: + await manager.broadcast( + room, + { + "id": 0, + "room": room, + "username": "system", + "body": f"{username} joined {room}", + "created_at": "", + }, + ) + + while True: + payload = await websocket.receive_json() + body = str(payload.get("body", "")).strip() + if not body: + continue + saved = database.save_message(room, username, body[:500]) + await manager.broadcast(room, saved) + except WebSocketDisconnect: + manager.disconnect(room, websocket) diff --git a/fastapi-realtime-chat/app/static/app.js b/fastapi-realtime-chat/app/static/app.js new file mode 100644 index 0000000..cf734c1 --- /dev/null +++ b/fastapi-realtime-chat/app/static/app.js @@ -0,0 +1,155 @@ +const state = { + token: localStorage.getItem('chatToken') || '', + username: localStorage.getItem('chatUsername') || '', + room: 'general', + socket: null, +}; + +const loginForm = document.querySelector('#login-form'); +const chatApp = document.querySelector('#chat-app'); +const messages = document.querySelector('#messages'); +const rooms = document.querySelector('#rooms'); +const status = document.querySelector('#status'); +const messageForm = document.querySelector('#message-form'); +const messageInput = document.querySelector('#message'); +const userLabel = document.querySelector('#user-label'); +const logoutButton = document.querySelector('#logout'); + +function authHeaders() { + return { Authorization: `Bearer ${state.token}` }; +} + +async function request(path, options = {}) { + const response = await fetch(path, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Request failed' })); + throw new Error(error.detail || 'Request failed'); + } + return response.json(); +} + +function setStatus(text) { + status.textContent = text; +} + +function appendMessage(message) { + const item = document.createElement('li'); + item.className = message.username === state.username ? 'message mine' : 'message'; + if (message.username === 'system') item.className = 'message system'; + + const meta = document.createElement('span'); + meta.className = 'meta'; + meta.textContent = message.username === 'system' + ? 'system' + : `${message.username}${message.created_at ? ` ยท ${message.created_at}` : ''}`; + + const body = document.createElement('p'); + body.textContent = message.body; + + item.append(meta, body); + messages.appendChild(item); + messages.scrollTop = messages.scrollHeight; +} + +async function loadMessages() { + messages.replaceChildren(); + const history = await request(`/messages/${state.room}`, { + headers: authHeaders(), + }); + history.forEach(appendMessage); +} + +function connectSocket() { + if (state.socket) { + state.socket.close(); + } + + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + state.socket = new WebSocket(`${protocol}://${window.location.host}/ws/${state.room}?token=${state.token}`); + setStatus(`Connecting to #${state.room}...`); + + state.socket.addEventListener('open', () => setStatus(`Live in #${state.room}`)); + state.socket.addEventListener('message', (event) => appendMessage(JSON.parse(event.data))); + state.socket.addEventListener('close', () => setStatus('Disconnected')); +} + +async function enterChat() { + const data = await request('/rooms', { headers: authHeaders() }); + userLabel.textContent = `Signed in as ${data.username}`; + rooms.replaceChildren(); + data.rooms.forEach((room) => { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = `#${room}`; + button.className = room === state.room ? 'active' : ''; + button.addEventListener('click', async () => { + state.room = room; + [...rooms.children].forEach((child) => child.classList.remove('active')); + button.classList.add('active'); + await loadMessages(); + connectSocket(); + }); + rooms.appendChild(button); + }); + + loginForm.hidden = true; + chatApp.hidden = false; + await loadMessages(); + connectSocket(); +} + +loginForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const formData = new FormData(loginForm); + try { + const data = await request('/login', { + method: 'POST', + body: JSON.stringify({ + username: formData.get('username'), + password: formData.get('password'), + }), + }); + state.token = data.token; + state.username = data.username; + localStorage.setItem('chatToken', data.token); + localStorage.setItem('chatUsername', data.username); + await enterChat(); + } catch (error) { + setStatus(error.message); + } +}); + +messageForm.addEventListener('submit', (event) => { + event.preventDefault(); + const body = messageInput.value.trim(); + if (!body || !state.socket || state.socket.readyState !== WebSocket.OPEN) return; + state.socket.send(JSON.stringify({ body })); + messageInput.value = ''; +}); + +logoutButton.addEventListener('click', () => { + localStorage.removeItem('chatToken'); + localStorage.removeItem('chatUsername'); + state.token = ''; + state.username = ''; + state.socket?.close(); + chatApp.hidden = true; + loginForm.hidden = false; + setStatus('Signed out'); +}); + +if (state.token) { + enterChat().catch(() => { + localStorage.removeItem('chatToken'); + localStorage.removeItem('chatUsername'); + loginForm.hidden = false; + chatApp.hidden = true; + }); +} diff --git a/fastapi-realtime-chat/app/static/index.html b/fastapi-realtime-chat/app/static/index.html new file mode 100644 index 0000000..dc84d8c --- /dev/null +++ b/fastapi-realtime-chat/app/static/index.html @@ -0,0 +1,47 @@ + + + + + + FastAPI Real-Time Chat + + + +
+
+

FastAPI Template

+

Real-time chat with WebSockets

+ Sign in to join a room +
+ +
+ + + +
+ + +
+ + + diff --git a/fastapi-realtime-chat/app/static/styles.css b/fastapi-realtime-chat/app/static/styles.css new file mode 100644 index 0000000..926b8f1 --- /dev/null +++ b/fastapi-realtime-chat/app/static/styles.css @@ -0,0 +1,205 @@ +:root { + color: #172033; + background: #eef4f8; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; +} + +button, +input { + font: inherit; +} + +.shell { + width: min(1120px, calc(100% - 32px)); + margin: 0 auto; + padding: 32px 0; +} + +.hero { + display: grid; + gap: 10px; + margin-bottom: 24px; +} + +.hero p { + margin: 0; + color: #0f766e; + font-size: 0.8rem; + font-weight: 800; + text-transform: uppercase; +} + +.hero h1 { + max-width: 760px; + margin: 0; + color: #102a43; + font-size: clamp(2rem, 5vw, 4.5rem); + line-height: 0.98; +} + +#status { + color: #526173; + font-weight: 700; +} + +.panel, +.chat { + border: 1px solid #d7e2ea; + border-radius: 10px; + background: #ffffff; + box-shadow: 0 18px 60px rgba(23, 32, 51, 0.08); +} + +.panel { + display: grid; + gap: 14px; + max-width: 420px; + padding: 22px; +} + +label { + display: grid; + gap: 7px; + color: #526173; + font-size: 0.85rem; + font-weight: 800; +} + +input { + width: 100%; + border: 1px solid #c6d3df; + border-radius: 8px; + padding: 12px 14px; + color: #172033; + background: #f8fbfd; +} + +button { + border: 0; + border-radius: 8px; + padding: 12px 16px; + color: #ffffff; + background: #0f766e; + cursor: pointer; + font-weight: 800; +} + +.chat { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + min-height: 600px; + overflow: hidden; +} + +.sidebar { + display: grid; + align-content: start; + gap: 18px; + border-right: 1px solid #d7e2ea; + padding: 20px; + background: #f8fbfd; +} + +.sidebar strong { + color: #102a43; +} + +nav { + display: grid; + gap: 8px; +} + +nav button, +#logout { + width: 100%; + color: #203047; + background: #e7eef5; + text-align: left; +} + +nav button.active { + color: #ffffff; + background: #0f766e; +} + +.conversation { + display: grid; + grid-template-rows: 1fr auto; + min-width: 0; +} + +#messages { + display: flex; + flex-direction: column; + gap: 10px; + margin: 0; + padding: 20px; + overflow-y: auto; + list-style: none; +} + +.message { + max-width: min(620px, 90%); + border: 1px solid #d7e2ea; + border-radius: 10px; + padding: 12px 14px; + background: #ffffff; +} + +.message.mine { + align-self: flex-end; + border-color: #93c5bd; + background: #ecfdf5; +} + +.message.system { + align-self: center; + color: #526173; + background: #eef4f8; +} + +.meta { + display: block; + margin-bottom: 4px; + color: #526173; + font-size: 0.75rem; + font-weight: 800; +} + +.message p { + margin: 0; + line-height: 1.45; +} + +#message-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + border-top: 1px solid #d7e2ea; + padding: 16px; +} + +@media (max-width: 760px) { + .chat { + grid-template-columns: 1fr; + } + + .sidebar { + border-right: 0; + border-bottom: 1px solid #d7e2ea; + } + + #message-form { + grid-template-columns: 1fr; + } +} diff --git a/fastapi-realtime-chat/ci.yml b/fastapi-realtime-chat/ci.yml new file mode 100644 index 0000000..e10b19f --- /dev/null +++ b/fastapi-realtime-chat/ci.yml @@ -0,0 +1,18 @@ +prepare: + steps: + - name: "Install dependencies" + command: "python3 -m pip install -r requirements.txt" + - name: "Initialize database" + command: "python3 scripts/init_db.py" + - name: "Run tests" + command: "python3 -m pytest" + +test: + steps: + - name: "Run tests" + command: "python3 -m pytest" + +run: + steps: + - name: "Run" + command: "python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000" diff --git a/fastapi-realtime-chat/metadata.json b/fastapi-realtime-chat/metadata.json new file mode 100644 index 0000000..c57a225 --- /dev/null +++ b/fastapi-realtime-chat/metadata.json @@ -0,0 +1,7 @@ +{ + "Workspace": "free", + "Links": {}, + "Categories": ["Framework"], + "Contributors": ["sureshchouksey8"], + "Title": "FastAPI Real-Time Chat" +} diff --git a/fastapi-realtime-chat/requirements.txt b/fastapi-realtime-chat/requirements.txt new file mode 100644 index 0000000..60d6389 --- /dev/null +++ b/fastapi-realtime-chat/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.111.1 +httpx==0.27.2 +pydantic==2.8.2 +pytest==8.3.2 +uvicorn[standard]==0.30.3 +websockets==12.0 diff --git a/fastapi-realtime-chat/scripts/init_db.py b/fastapi-realtime-chat/scripts/init_db.py new file mode 100644 index 0000000..3f6dc2e --- /dev/null +++ b/fastapi-realtime-chat/scripts/init_db.py @@ -0,0 +1,11 @@ +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.database import init_db + + +if __name__ == "__main__": + init_db() + print("Database initialized") diff --git a/fastapi-realtime-chat/tests/conftest.py b/fastapi-realtime-chat/tests/conftest.py new file mode 100644 index 0000000..c893e38 --- /dev/null +++ b/fastapi-realtime-chat/tests/conftest.py @@ -0,0 +1,4 @@ +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) diff --git a/fastapi-realtime-chat/tests/test_app.py b/fastapi-realtime-chat/tests/test_app.py new file mode 100644 index 0000000..c2acab4 --- /dev/null +++ b/fastapi-realtime-chat/tests/test_app.py @@ -0,0 +1,45 @@ +from fastapi.testclient import TestClient + +from app.main import app + + +client = TestClient(app) + + +def login_token() -> str: + response = client.post("/login", json={"username": "ada", "password": "codesphere"}) + assert response.status_code == 200 + return response.json()["token"] + + +def test_health_check() -> None: + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_login_and_rooms() -> None: + token = login_token() + response = client.get("/rooms", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 200 + assert "general" in response.json()["rooms"] + + +def test_rejects_bad_login() -> None: + response = client.post("/login", json={"username": "ada", "password": "wrong"}) + assert response.status_code == 401 + + +def test_websocket_saves_and_broadcasts_message() -> None: + token = login_token() + with client.websocket_connect(f"/ws/general?token={token}") as websocket: + websocket.receive_json() + websocket.send_json({"body": "Hello from tests"}) + message = websocket.receive_json() + + assert message["username"] == "ada" + assert message["body"] == "Hello from tests" + + response = client.get("/messages/general", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 200 + assert any(item["body"] == "Hello from tests" for item in response.json())