Pydantic -> Typescript conversion work

This commit is contained in:
James Ketr 2025-09-01 14:29:49 -07:00
parent 2908fe1ee7
commit 3cb49c70ef
16 changed files with 1759 additions and 148 deletions

View File

@ -2,6 +2,7 @@
!voicebot
!server
!client
!shared
node_modules
build
dist

157
TYPESCRIPT_GENERATION.md Normal file
View File

@ -0,0 +1,157 @@
# OpenAPI TypeScript Generation
This project now supports automatic TypeScript type generation from the FastAPI server's Pydantic models using OpenAPI schema generation.
## Overview
The implementation follows the "OpenAPI Schema Generation (Recommended for FastAPI)" approach:
1. **Server-side**: FastAPI automatically generates OpenAPI schema from Pydantic models
2. **Generation**: Python script extracts the schema and saves it as JSON
3. **TypeScript**: `openapi-typescript` converts the schema to TypeScript types
4. **Client**: Typed API client provides type-safe server communication
## Generated Files
- `client/openapi-schema.json` - OpenAPI schema extracted from FastAPI
- `client/src/api-types.ts` - TypeScript interfaces generated from OpenAPI schema
- `client/src/api-client.ts` - Typed API client with convenience methods
## How It Works
### 1. Schema Generation
The `server/generate_schema_simple.py` script:
- Imports the FastAPI app from `main.py`
- Extracts the OpenAPI schema using `app.openapi()`
- Saves the schema as JSON in `client/openapi-schema.json`
### 2. TypeScript Generation
The `openapi-typescript` package:
- Reads the OpenAPI schema JSON
- Generates TypeScript interfaces in `client/src/api-types.ts`
- Creates type-safe definitions for all Pydantic models
### 3. API Client
The `client/src/api-client.ts` file provides:
- Type-safe API client class
- Convenience functions for each endpoint
- Proper error handling with custom `ApiError` class
- Re-exported types for easy importing
## Usage in React Components
```typescript
import { apiClient, adminApi, healthApi, lobbiesApi, sessionsApi } from './api-client';
import type { LobbyModel, SessionModel, AdminSetPassword } from './api-client';
// Using the convenience APIs
const healthStatus = await healthApi.check();
const lobbies = await lobbiesApi.getAll();
const session = await sessionsApi.getCurrent();
// Using the main client
const adminNames = await apiClient.adminListNames();
// With type safety for request data
const passwordData: AdminSetPassword = {
name: "admin",
password: "newpassword"
};
const result = await adminApi.setPassword(passwordData);
// Type-safe lobby creation
const lobbyRequest: LobbyCreateRequest = {
type: "lobby_create",
data: {
name: "My Lobby",
private: false
}
};
const newLobby = await sessionsApi.createLobby("session-id", lobbyRequest);
```
## Regenerating Types
### Manual Generation
```bash
# Generate schema from server
docker compose exec server uv run python3 generate_schema_simple.py
# Generate TypeScript types
docker compose exec static-frontend npx openapi-typescript openapi-schema.json -o src/api-types.ts
# Type check
docker compose exec static-frontend npm run type-check
```
### Automated Generation
```bash
# Run the comprehensive generation script
./generate-ts-types.sh
```
### NPM Scripts (in frontend container)
```bash
# Generate just the schema
npm run generate-schema
# Generate just the TypeScript types (requires schema to exist)
npm run generate-types
# Generate both schema and types
npm run generate-api-types
```
## Development Workflow
1. **Modify Pydantic models** in `shared/models.py`
2. **Regenerate types** using one of the methods above
3. **Update React components** to use the new types
4. **Type check** to ensure everything compiles
## Benefits
- ✅ **Type Safety**: Full TypeScript type checking for API requests/responses
- ✅ **Auto-completion**: IDE support with auto-complete for API methods and data structures
- ✅ **Error Prevention**: Catch type mismatches at compile time
- ✅ **Documentation**: Self-documenting API with TypeScript interfaces
- ✅ **Sync Guarantee**: Types are always in sync with server models
- ✅ **Refactoring Safety**: IDE can safely refactor across frontend/backend
## File Structure
```
server/
├── main.py # FastAPI app with Pydantic models
├── generate_schema_simple.py # Schema extraction script
└── generate_api_client.py # Enhanced generator (backup)
shared/
└── models.py # Pydantic models (source of truth)
client/
├── openapi-schema.json # Generated OpenAPI schema
├── package.json # Updated with openapi-typescript dependency
└── src/
├── api-types.ts # Generated TypeScript interfaces
└── api-client.ts # Typed API client
```
## Troubleshooting
### Container Issues
If the frontend container has dependency conflicts:
```bash
# Rebuild the frontend container
docker compose build static-frontend
docker compose up -d static-frontend
```
### TypeScript Errors
Ensure the generated types are up to date:
```bash
./generate-ts-types.sh
```
### Module Not Found Errors
Check that the volume mounts are working correctly and files are synced between host and container.

View File

@ -1,5 +1,5 @@
#!/bin/bash
set -e
# Launch server in production or development mode
if [ "$PRODUCTION" = "true" ]; then
export REACT_APP_AI_VOICECHAT_BUILD="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

781
client/openapi-schema.json Normal file
View File

@ -0,0 +1,781 @@
{
"openapi": "3.1.0",
"info": {
"title": "FastAPI",
"version": "0.1.0"
},
"paths": {
"/ai-voicebot/api/admin/names": {
"get": {
"summary": "Admin List Names",
"operationId": "admin_list_names_ai_voicebot_api_admin_names_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminNamesResponse"
}
}
}
}
}
}
},
"/ai-voicebot/api/admin/set_password": {
"post": {
"summary": "Admin Set Password",
"operationId": "admin_set_password_ai_voicebot_api_admin_set_password_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminSetPassword"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminActionResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/ai-voicebot/api/admin/clear_password": {
"post": {
"summary": "Admin Clear Password",
"operationId": "admin_clear_password_ai_voicebot_api_admin_clear_password_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminClearPassword"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminActionResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/ai-voicebot/api/health": {
"get": {
"summary": "Health",
"operationId": "health_ai_voicebot_api_health_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HealthResponse"
}
}
}
}
}
}
},
"/ai-voicebot/api/session": {
"get": {
"summary": "Session",
"operationId": "session_ai_voicebot_api_session_get",
"parameters": [
{
"name": "session_id",
"in": "cookie",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Session Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/ai-voicebot/api/lobby": {
"get": {
"summary": "Get Lobbies",
"operationId": "get_lobbies_ai_voicebot_api_lobby_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LobbiesResponse"
}
}
}
}
}
}
},
"/ai-voicebot/api/lobby/{session_id}": {
"post": {
"summary": "Lobby Create",
"operationId": "lobby_create_ai_voicebot_api_lobby__session_id__post",
"parameters": [
{
"name": "session_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Session Id"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LobbyCreateRequest"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LobbyCreateResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/ai-voicebot/{path}": {
"put": {
"summary": "Proxy Static",
"operationId": "proxy_static_ai_voicebot__path__put",
"parameters": [
{
"name": "path",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Path"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"head": {
"summary": "Proxy Static",
"operationId": "proxy_static_ai_voicebot__path__put",
"parameters": [
{
"name": "path",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Path"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"options": {
"summary": "Proxy Static",
"operationId": "proxy_static_ai_voicebot__path__put",
"parameters": [
{
"name": "path",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Path"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"post": {
"summary": "Proxy Static",
"operationId": "proxy_static_ai_voicebot__path__put",
"parameters": [
{
"name": "path",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Path"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"delete": {
"summary": "Proxy Static",
"operationId": "proxy_static_ai_voicebot__path__put",
"parameters": [
{
"name": "path",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Path"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"get": {
"summary": "Proxy Static",
"operationId": "proxy_static_ai_voicebot__path__put",
"parameters": [
{
"name": "path",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Path"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"patch": {
"summary": "Proxy Static",
"operationId": "proxy_static_ai_voicebot__path__put",
"parameters": [
{
"name": "path",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Path"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"AdminActionResponse": {
"properties": {
"status": {
"type": "string",
"enum": [
"ok",
"not_found"
],
"title": "Status"
},
"name": {
"type": "string",
"title": "Name"
}
},
"type": "object",
"required": [
"status",
"name"
],
"title": "AdminActionResponse",
"description": "Response for admin actions"
},
"AdminClearPassword": {
"properties": {
"name": {
"type": "string",
"title": "Name"
}
},
"type": "object",
"required": [
"name"
],
"title": "AdminClearPassword",
"description": "Request model for clearing admin password"
},
"AdminNamesResponse": {
"properties": {
"name_passwords": {
"additionalProperties": {
"$ref": "#/components/schemas/NamePasswordRecord"
},
"type": "object",
"title": "Name Passwords"
}
},
"type": "object",
"required": [
"name_passwords"
],
"title": "AdminNamesResponse",
"description": "Response for admin names endpoint"
},
"AdminSetPassword": {
"properties": {
"name": {
"type": "string",
"title": "Name"
},
"password": {
"type": "string",
"title": "Password"
}
},
"type": "object",
"required": [
"name",
"password"
],
"title": "AdminSetPassword",
"description": "Request model for setting admin password"
},
"HTTPValidationError": {
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
},
"HealthResponse": {
"properties": {
"status": {
"type": "string",
"title": "Status"
}
},
"type": "object",
"required": [
"status"
],
"title": "HealthResponse",
"description": "Health check response"
},
"LobbiesResponse": {
"properties": {
"lobbies": {
"items": {
"$ref": "#/components/schemas/LobbyListItem"
},
"type": "array",
"title": "Lobbies"
}
},
"type": "object",
"required": [
"lobbies"
],
"title": "LobbiesResponse",
"description": "Response containing list of lobbies"
},
"LobbyCreateData": {
"properties": {
"name": {
"type": "string",
"title": "Name"
},
"private": {
"type": "boolean",
"title": "Private",
"default": false
}
},
"type": "object",
"required": [
"name"
],
"title": "LobbyCreateData",
"description": "Data for lobby creation"
},
"LobbyCreateRequest": {
"properties": {
"type": {
"type": "string",
"const": "lobby_create",
"title": "Type"
},
"data": {
"$ref": "#/components/schemas/LobbyCreateData"
}
},
"type": "object",
"required": [
"type",
"data"
],
"title": "LobbyCreateRequest",
"description": "Request for creating a lobby"
},
"LobbyCreateResponse": {
"properties": {
"type": {
"type": "string",
"const": "lobby_created",
"title": "Type"
},
"data": {
"$ref": "#/components/schemas/LobbyModel"
}
},
"type": "object",
"required": [
"type",
"data"
],
"title": "LobbyCreateResponse",
"description": "Response for lobby creation"
},
"LobbyListItem": {
"properties": {
"id": {
"type": "string",
"title": "Id"
},
"name": {
"type": "string",
"title": "Name"
}
},
"type": "object",
"required": [
"id",
"name"
],
"title": "LobbyListItem",
"description": "Lobby item for list responses"
},
"LobbyModel": {
"properties": {
"id": {
"type": "string",
"title": "Id"
},
"name": {
"type": "string",
"title": "Name"
},
"private": {
"type": "boolean",
"title": "Private",
"default": false
}
},
"type": "object",
"required": [
"id",
"name"
],
"title": "LobbyModel",
"description": "Core lobby model used across components"
},
"NamePasswordRecord": {
"properties": {
"salt": {
"type": "string",
"title": "Salt"
},
"hash": {
"type": "string",
"title": "Hash"
}
},
"type": "object",
"required": [
"salt",
"hash"
],
"title": "NamePasswordRecord",
"description": "Password hash record for reserved names"
},
"SessionResponse": {
"properties": {
"id": {
"type": "string",
"title": "Id"
},
"name": {
"type": "string",
"title": "Name"
},
"lobbies": {
"items": {
"$ref": "#/components/schemas/LobbyModel"
},
"type": "array",
"title": "Lobbies"
}
},
"type": "object",
"required": [
"id",
"name",
"lobbies"
],
"title": "SessionResponse",
"description": "Session response model"
},
"ValidationError": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
}
}
}
}

View File

@ -25,14 +25,23 @@
"@types/node": "^20.11.30",
"@types/react": "^18.2.70",
"@types/react-dom": "^18.2.19",
"@types/react-router-dom": "^5.3.3"
"@types/react-router-dom": "^5.3.3",
"typescript": "^4.9.5",
"openapi-typescript": "^6.7.6"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"generate-schema": "cd ../server && uv run python3 generate_schema_simple.py",
"generate-types": "npx openapi-typescript openapi-schema.json -o src/api-types.ts",
"generate-api-types": "npm run generate-schema && npm run generate-types",
"prebuild": "npm run generate-api-types"
},
"npmConfig": {
"legacy-peer-deps": true
},
"eslintConfig": {
"extends": [

134
client/src/api-client.ts Normal file
View File

@ -0,0 +1,134 @@
// TypeScript API client for AI Voicebot server
import { components } from './api-types';
// Re-export commonly used types from the generated schema
export type LobbyModel = components['schemas']['LobbyModel'];
export type LobbyListItem = components['schemas']['LobbyListItem'];
export type LobbyCreateData = components['schemas']['LobbyCreateData'];
export type NamePasswordRecord = components['schemas']['NamePasswordRecord'];
// Type aliases for API methods
export type AdminNamesResponse = components['schemas']['AdminNamesResponse'];
export type AdminActionResponse = components['schemas']['AdminActionResponse'];
export type AdminSetPassword = components['schemas']['AdminSetPassword'];
export type AdminClearPassword = components['schemas']['AdminClearPassword'];
export type HealthResponse = components['schemas']['HealthResponse'];
export type LobbiesResponse = components['schemas']['LobbiesResponse'];
export type SessionResponse = components['schemas']['SessionResponse'];
export type LobbyCreateRequest = components['schemas']['LobbyCreateRequest'];
export type LobbyCreateResponse = components['schemas']['LobbyCreateResponse'];
export class ApiError extends Error {
constructor(
public status: number,
public statusText: string,
public data?: any
) {
super(`HTTP ${status}: ${statusText}`);
this.name = 'ApiError';
}
}
export class ApiClient {
private baseURL: string;
private defaultHeaders: Record<string, string>;
constructor(baseURL?: string) {
this.baseURL = baseURL || process.env.REACT_APP_API_URL || 'http://localhost:8001';
this.defaultHeaders = {};
}
private async request<T>(path: string, options: {
method: string;
body?: any;
params?: Record<string, string>;
}): Promise<T> {
const url = new URL(path, this.baseURL);
if (options.params) {
Object.entries(options.params).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
}
const requestInit: RequestInit = {
method: options.method,
headers: {
'Content-Type': 'application/json',
...this.defaultHeaders,
},
};
if (options.body && options.method !== 'GET') {
requestInit.body = JSON.stringify(options.body);
}
const response = await fetch(url.toString(), requestInit);
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
errorData = await response.text();
}
throw new ApiError(response.status, response.statusText, errorData);
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return response.json();
}
return response.text() as unknown as T;
}
// Admin API methods
async adminListNames(): Promise<AdminNamesResponse> {
return this.request<AdminNamesResponse>('/ai-voicebot/api/admin/names', { method: 'GET' });
}
async adminSetPassword(data: AdminSetPassword): Promise<AdminActionResponse> {
return this.request<AdminActionResponse>('/ai-voicebot/api/admin/set_password', { method: 'POST', body: data });
}
async adminClearPassword(data: AdminClearPassword): Promise<AdminActionResponse> {
return this.request<AdminActionResponse>('/ai-voicebot/api/admin/clear_password', { method: 'POST', body: data });
}
// Health check
async healthCheck(): Promise<HealthResponse> {
return this.request<HealthResponse>('/ai-voicebot/api/health', { method: 'GET' });
}
// Session methods
async getSession(): Promise<SessionResponse> {
return this.request<SessionResponse>('/ai-voicebot/api/session', { method: 'GET' });
}
// Lobby methods
async getLobbies(): Promise<LobbiesResponse> {
return this.request<LobbiesResponse>('/ai-voicebot/api/lobby', { method: 'GET' });
}
async createLobby(sessionId: string, data: LobbyCreateRequest): Promise<LobbyCreateResponse> {
return this.request<LobbyCreateResponse>(`/ai-voicebot/api/lobby/${sessionId}`, { method: 'POST', body: data });
}
}
// Default client instance
export const apiClient = new ApiClient();
// Convenience API namespaces
export const adminApi = {
listNames: () => apiClient.adminListNames(),
setPassword: (data: AdminSetPassword) => apiClient.adminSetPassword(data),
clearPassword: (data: AdminClearPassword) => apiClient.adminClearPassword(data),
};
export const healthApi = { check: () => apiClient.healthCheck() };
export const lobbiesApi = { getAll: () => apiClient.getLobbies() };
export const sessionsApi = {
getCurrent: () => apiClient.getSession(),
createLobby: (sessionId: string, data: LobbyCreateRequest) => apiClient.createLobby(sessionId, data),
};

375
client/src/api-types.ts Normal file
View File

@ -0,0 +1,375 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/ai-voicebot/api/admin/names": {
/** Admin List Names */
get: operations["admin_list_names_ai_voicebot_api_admin_names_get"];
};
"/ai-voicebot/api/admin/set_password": {
/** Admin Set Password */
post: operations["admin_set_password_ai_voicebot_api_admin_set_password_post"];
};
"/ai-voicebot/api/admin/clear_password": {
/** Admin Clear Password */
post: operations["admin_clear_password_ai_voicebot_api_admin_clear_password_post"];
};
"/ai-voicebot/api/health": {
/** Health */
get: operations["health_ai_voicebot_api_health_get"];
};
"/ai-voicebot/api/session": {
/** Session */
get: operations["session_ai_voicebot_api_session_get"];
};
"/ai-voicebot/api/lobby": {
/** Get Lobbies */
get: operations["get_lobbies_ai_voicebot_api_lobby_get"];
};
"/ai-voicebot/api/lobby/{session_id}": {
/** Lobby Create */
post: operations["lobby_create_ai_voicebot_api_lobby__session_id__post"];
};
"/ai-voicebot/{path}": {
/** Proxy Static */
get: operations["proxy_static_ai_voicebot__path__put"];
/** Proxy Static */
put: operations["proxy_static_ai_voicebot__path__put"];
/** Proxy Static */
post: operations["proxy_static_ai_voicebot__path__put"];
/** Proxy Static */
delete: operations["proxy_static_ai_voicebot__path__put"];
/** Proxy Static */
options: operations["proxy_static_ai_voicebot__path__put"];
/** Proxy Static */
head: operations["proxy_static_ai_voicebot__path__put"];
/** Proxy Static */
patch: operations["proxy_static_ai_voicebot__path__put"];
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/**
* AdminActionResponse
* @description Response for admin actions
*/
AdminActionResponse: {
/**
* Status
* @enum {string}
*/
status: "ok" | "not_found";
/** Name */
name: string;
};
/**
* AdminClearPassword
* @description Request model for clearing admin password
*/
AdminClearPassword: {
/** Name */
name: string;
};
/**
* AdminNamesResponse
* @description Response for admin names endpoint
*/
AdminNamesResponse: {
/** Name Passwords */
name_passwords: {
[key: string]: components["schemas"]["NamePasswordRecord"];
};
};
/**
* AdminSetPassword
* @description Request model for setting admin password
*/
AdminSetPassword: {
/** Name */
name: string;
/** Password */
password: string;
};
/** HTTPValidationError */
HTTPValidationError: {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/**
* HealthResponse
* @description Health check response
*/
HealthResponse: {
/** Status */
status: string;
};
/**
* LobbiesResponse
* @description Response containing list of lobbies
*/
LobbiesResponse: {
/** Lobbies */
lobbies: components["schemas"]["LobbyListItem"][];
};
/**
* LobbyCreateData
* @description Data for lobby creation
*/
LobbyCreateData: {
/** Name */
name: string;
/**
* Private
* @default false
*/
private?: boolean;
};
/**
* LobbyCreateRequest
* @description Request for creating a lobby
*/
LobbyCreateRequest: {
/**
* Type
* @constant
*/
type: "lobby_create";
data: components["schemas"]["LobbyCreateData"];
};
/**
* LobbyCreateResponse
* @description Response for lobby creation
*/
LobbyCreateResponse: {
/**
* Type
* @constant
*/
type: "lobby_created";
data: components["schemas"]["LobbyModel"];
};
/**
* LobbyListItem
* @description Lobby item for list responses
*/
LobbyListItem: {
/** Id */
id: string;
/** Name */
name: string;
};
/**
* LobbyModel
* @description Core lobby model used across components
*/
LobbyModel: {
/** Id */
id: string;
/** Name */
name: string;
/**
* Private
* @default false
*/
private?: boolean;
};
/**
* NamePasswordRecord
* @description Password hash record for reserved names
*/
NamePasswordRecord: {
/** Salt */
salt: string;
/** Hash */
hash: string;
};
/**
* SessionResponse
* @description Session response model
*/
SessionResponse: {
/** Id */
id: string;
/** Name */
name: string;
/** Lobbies */
lobbies: components["schemas"]["LobbyModel"][];
};
/** ValidationError */
ValidationError: {
/** Location */
loc: (string | number)[];
/** Message */
msg: string;
/** Error Type */
type: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export type external = Record<string, never>;
export interface operations {
/** Admin List Names */
admin_list_names_ai_voicebot_api_admin_names_get: {
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": components["schemas"]["AdminNamesResponse"];
};
};
};
};
/** Admin Set Password */
admin_set_password_ai_voicebot_api_admin_set_password_post: {
requestBody: {
content: {
"application/json": components["schemas"]["AdminSetPassword"];
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": components["schemas"]["AdminActionResponse"];
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
/** Admin Clear Password */
admin_clear_password_ai_voicebot_api_admin_clear_password_post: {
requestBody: {
content: {
"application/json": components["schemas"]["AdminClearPassword"];
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": components["schemas"]["AdminActionResponse"];
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
/** Health */
health_ai_voicebot_api_health_get: {
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": components["schemas"]["HealthResponse"];
};
};
};
};
/** Session */
session_ai_voicebot_api_session_get: {
parameters: {
cookie?: {
session_id?: string | null;
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": components["schemas"]["SessionResponse"];
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
/** Get Lobbies */
get_lobbies_ai_voicebot_api_lobby_get: {
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": components["schemas"]["LobbiesResponse"];
};
};
};
};
/** Lobby Create */
lobby_create_ai_voicebot_api_lobby__session_id__post: {
parameters: {
path: {
session_id: string;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["LobbyCreateRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": components["schemas"]["LobbyCreateResponse"];
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
/** Proxy Static */
proxy_static_ai_voicebot__path__put: {
parameters: {
path: {
path: string;
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
}

View File

@ -0,0 +1,91 @@
/**
* Example: How to update App.tsx to use the generated API types
*
* This demonstrates how to replace local type definitions with the
* generated TypeScript types from your Pydantic models.
*/
// BEFORE (local type definitions):
// type Lobby = {
// id: string;
// name: string;
// private: boolean;
// };
// AFTER (using generated types):
import {
apiClient,
lobbiesApi,
sessionsApi,
healthApi,
adminApi,
ApiError,
type LobbyModel, // Use this instead of local Lobby type
type LobbyCreateRequest,
type AdminSetPassword
} from './api-client';
// Example usage in a React component:
// 1. Fetching data with type safety
const fetchLobbies = async (setLobbies: (lobbies: any[]) => void, setError: (error: string) => void) => {
try {
const response = await lobbiesApi.getAll();
// response.lobbies is properly typed as LobbyListItem[]
setLobbies(response.lobbies);
} catch (error) {
if (error instanceof ApiError) {
console.error(`API Error ${error.status}: ${error.statusText}`, error.data);
}
setError('Failed to fetch lobbies');
}
};
// 2. Creating a lobby with typed request
const createNewLobby = async (sessionId: string, lobbyName: string, isPrivate: boolean, setError: (error: string) => void) => {
const lobbyRequest: LobbyCreateRequest = {
type: "lobby_create",
data: {
name: lobbyName,
private: isPrivate
}
};
try {
const newLobby = await sessionsApi.createLobby(sessionId, lobbyRequest);
// newLobby.data is properly typed as LobbyModel
console.log('Created lobby:', newLobby.data);
} catch (error) {
if (error instanceof ApiError) {
setError(`Failed to create lobby: ${error.message}`);
}
}
};
// 3. Health check with typing
const checkServerHealth = async () => {
try {
const health = await healthApi.check();
// health.status is properly typed as string
console.log('Server status:', health.status);
} catch (error) {
console.error('Server health check failed:', error);
}
};
// 4. Admin operations with type safety
const setAdminPassword = async (name: string, password: string) => {
const passwordData: AdminSetPassword = { name, password };
try {
const result = await adminApi.setPassword(passwordData);
// result.status is typed as "ok" | "not_found"
if (result.status === "ok") {
console.log('Password set successfully');
}
} catch (error) {
console.error('Failed to set password:', error);
}
};
export { fetchLobbies, createNewLobby, checkServerHealth, setAdminPassword };

View File

@ -32,6 +32,7 @@ services:
ports:
- "8001:8000"
volumes:
- ./shared:/shared:ro
- ./server:/server:rw
- ./server/.venv:/server/.venv:rw
- ./client/build:/client/build:ro
@ -52,6 +53,7 @@ services:
restart: always
network_mode: host
volumes:
- ./shared:/shared:ro
- ./voicebot:/voicebot:rw
- ./voicebot/.venv:/voicebot/.venv:rw
# network_mode: host

38
generate-ts-types.sh Executable file
View File

@ -0,0 +1,38 @@
#!/bin/bash
# Comprehensive script to generate TypeScript types from FastAPI OpenAPI schema.
# This script coordinates between the server and frontend containers to:
# 1. Generate OpenAPI schema from FastAPI server
# 2. Generate TypeScript types from the schema
# 3. Ensure frontend container dependencies are installed
set -e
echo "🚀 Starting OpenAPI TypeScript generation process..."
# Change to the project directory
cd "$(dirname "$0")"
echo "📋 Step 1: Generating OpenAPI schema from FastAPI server..."
docker compose exec server uv run python3 generate_schema_simple.py
echo "📋 Step 2: Ensuring frontend container is running..."
docker compose up -d static-frontend
echo "📋 Step 3: Installing/updating frontend dependencies..."
docker compose exec static-frontend npm install --legacy-peer-deps
echo "📋 Step 4: Generating TypeScript types from OpenAPI schema..."
docker compose exec static-frontend npx openapi-typescript openapi-schema.json -o src/api-types.ts
echo "📋 Step 5: Running TypeScript type checking..."
docker compose exec static-frontend npm run type-check
echo "✅ TypeScript generation complete!"
echo "📄 Generated files:"
echo " - client/openapi-schema.json (OpenAPI schema)"
echo " - client/src/api-types.ts (TypeScript types)"
echo " - client/src/api-client.ts (API client utilities)"
echo ""
echo "💡 Usage in your React components:"
echo " import { apiClient, adminApi, healthApi } from './api-client';"
echo " import type { LobbyModel, SessionModel } from './api-client';"

View File

@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""
Simple OpenAPI schema generator for FastAPI.
This generates only the JSON schema file that can be used with openapi-typescript.
"""
import json
import sys
from pathlib import Path
def generate_schema():
"""Generate OpenAPI schema from the FastAPI app"""
try:
# Add shared module to path for Docker environment
shared_path = "/shared"
if shared_path not in sys.path:
sys.path.insert(0, shared_path)
# Import the FastAPI app
from main import app
# Get the OpenAPI schema
schema = app.openapi()
# Write the schema to a JSON file that can be accessed from outside container
schema_file = Path("/client/openapi-schema.json")
with open(schema_file, 'w', encoding='utf-8') as f:
json.dump(schema, f, indent=2, ensure_ascii=False)
print(f"✅ OpenAPI schema generated successfully at: {schema_file}")
print(f"Schema contains {len(schema.get('components', {}).get('schemas', {}))} component schemas")
return True
except Exception as e:
print(f"❌ Error generating OpenAPI schema: {e}")
import traceback
traceback.print_exc()
return False
if __name__ == "__main__":
success = generate_schema()
sys.exit(0 if success else 1)

View File

@ -6,46 +6,52 @@ from fastapi import (
FastAPI,
Path,
WebSocket,
WebSocketDisconnect,
Request,
Response,
WebSocketDisconnect,
)
from fastapi.staticfiles import StaticFiles
import secrets
import os
import httpx
import json
import hashlib
import binascii
import sys
from fastapi.staticfiles import StaticFiles
import httpx
from pydantic import ValidationError
from logger import logger
# Import shared models
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from shared.models import (
HealthResponse,
LobbiesResponse,
LobbyCreateRequest,
LobbyCreateResponse,
LobbyListItem,
LobbyModel,
NamePasswordRecord,
LobbySaved,
SessionResponse,
SessionSaved,
SessionsPayload,
AdminNamesResponse,
AdminActionResponse,
AdminSetPassword,
AdminClearPassword,
HealthResponse,
LobbyListItem,
LobbiesResponse,
LobbyModel,
SessionResponse,
LobbyCreateRequest,
LobbyCreateResponse,
JoinStatusModel,
)
# Mapping of reserved names to password records (lowercased name -> {salt:..., hash:...})
name_passwords: dict[str, dict[str, str]] = {}
all_label = "[ all ]"
info_label = "[ info ]"
todo_label = "[ todo ]"
unset_label = "[ ---- ]"
def _hash_password(password: str, salt_hex: str | None = None) -> tuple[str, str]:
"""Return (salt_hex, hash_hex) for the given password. If salt_hex is provided
@ -109,12 +115,77 @@ def admin_clear_password(request: Request, payload: AdminClearPassword = Body(..
return {"status": "not_found", "name": payload.name}
lobbies: dict[str, Lobby] = {}
class LobbyResponse(TypedDict):
id: str
name: str
private: bool
class Lobby:
def __init__(self, name: str, id: str | None = None, private: bool = False):
self.id = secrets.token_hex(16) if id is None else id
self.short = self.id[:8]
self.name = name
self.sessions: dict[str, Session] = {} # All lobby members
self.private = private
def getName(self) -> str:
return f"{self.short}:{self.name}"
async def update_state(self, requesting_session: Session | None = None):
users: list[dict[str, str | bool]] = [
{
"name": s.name,
"live": True if s.ws else False,
"session_id": s.id,
"protected": True
if s.name and s.name.lower() in name_passwords
else False,
}
for s in self.sessions.values()
if s.name
]
if requesting_session:
logger.info(
f"{requesting_session.getName()} -> lobby_state({self.getName()})"
)
if requesting_session.ws:
await requesting_session.ws.send_json(
{"type": "lobby_state", "data": {"participants": users}}
)
else:
logger.warning(
f"{requesting_session.getName()} - No WebSocket connection."
)
else:
for s in self.sessions.values():
logger.info(f"{s.getName()} -> lobby_state({self.getName()})")
if s.ws:
await s.ws.send_json(
{"type": "lobby_state", "data": {"participants": users}}
)
def getSession(self, id: str) -> Session | None:
return self.sessions.get(id, None)
async def addSession(self, session: Session) -> None:
if session.id in self.sessions:
logger.warning(f"{session.getName()} - Already in lobby {self.getName()}.")
return None
self.sessions[session.id] = session
await self.update_state()
async def removeSession(self, session: Session) -> None:
if session.id not in self.sessions:
logger.warning(f"{session.getName()} - Not in lobby {self.getName()}.")
return None
del self.sessions[session.id]
await self.update_state()
class Session:
_instances: list[Session] = []
_save_file = "sessions.json"
@ -249,13 +320,10 @@ class Session:
if lobby.id in self.lobby_peers or self.id in lobby.sessions:
logger.info(f"{self.getName()} - Already joined to {lobby.getName()}.")
await self.ws.send_json(
{
"type": "join_status",
"status": "Joined",
"message": f"Already joined to lobby {lobby.getName()}",
}
data = JoinStatusModel(
status="Joined", message=f"Already joined to lobby {lobby.getName()}"
)
await self.ws.send_json({"type": "join_status", "data": data.model_dump()})
return
# Initialize the peer list for this lobby
@ -315,7 +383,7 @@ class Session:
await lobby.addSession(self)
Session.save()
await self.ws.send_json({"type": "join_status", "status": "Joined"})
await self.ws.send_json({"type": "join_status", "data": {"status": "Joined"}})
async def part(self, lobby: Lobby):
if lobby.id not in self.lobby_peers or self.id not in lobby.sessions:
@ -377,77 +445,12 @@ class Session:
Session.save()
class Lobby:
def __init__(self, name: str, id: str | None = None, private: bool = False):
self.id = secrets.token_hex(16) if id is None else id
self.short = self.id[:8]
self.name = name
self.sessions: dict[str, Session] = {} # All lobby members
self.private = private
def getName(self) -> str:
return f"{self.short}:{self.name}"
async def update_state(self, requesting_session: Session | None = None):
users: list[dict[str, str | bool]] = [
{
"name": s.name,
"live": True if s.ws else False,
"session_id": s.id,
"protected": True
if s.name and s.name.lower() in name_passwords
else False,
}
for s in self.sessions.values()
if s.name
]
if requesting_session:
logger.info(
f"{requesting_session.getName()} -> lobby_state({self.getName()})"
)
if requesting_session.ws:
await requesting_session.ws.send_json(
{"type": "lobby_state", "data": {"participants": users}}
)
else:
logger.warning(
f"{requesting_session.getName()} - No WebSocket connection."
)
else:
for s in self.sessions.values():
logger.info(f"{s.getName()} -> lobby_state({self.getName()})")
if s.ws:
await s.ws.send_json(
{"type": "lobby_state", "data": {"participants": users}}
)
def getSession(self, id: str) -> Session | None:
return self.sessions.get(id, None)
async def addSession(self, session: Session) -> None:
if session.id in self.sessions:
logger.warning(f"{session.getName()} - Already in lobby {self.getName()}.")
return None
self.sessions[session.id] = session
await self.update_state()
async def removeSession(self, session: Session) -> None:
if session.id not in self.sessions:
logger.warning(f"{session.getName()} - Not in lobby {self.getName()}.")
return None
del self.sessions[session.id]
await self.update_state()
def getName(session: Session | None) -> str | None:
if session and session.name:
return session.name
return None
lobbies: dict[str, Lobby] = {}
def getSession(session_id: str) -> Session | None:
return Session.getSession(session_id)
@ -573,12 +576,6 @@ async def lobby_create(
)
all_label = "[ all ]"
info_label = "[ info ]"
todo_label = "[ todo ]"
unset_label = "[ ---- ]"
# Register websocket endpoint directly on app with full public_url path
@app.websocket(f"{public_url}" + "ws/lobby/{lobby_id}/{session_id}")
async def lobby_join(
@ -647,11 +644,11 @@ async def lobby_join(
try:
while True:
message = await websocket.receive_json()
type = message.get("type", None)
data: dict[str, Any] | None = message.get("data", None)
packet = await websocket.receive_json()
type = packet.get("type", None)
data: dict[str, Any] | None = packet.get("data", None)
if not type:
logger.error(f"{session.getName()} - Invalid request: {message}")
logger.error(f"{session.getName()} - Invalid request: {packet}")
await websocket.send_json({"type": "error", "error": "Invalid request"})
continue
# logger.info(f"{session.getName()} <- RAW Rx: {data}")
@ -685,11 +682,13 @@ async def lobby_join(
logger.info(f"{session.getName()}: -> update('name', {name})")
await websocket.send_json(
{
"type": "update",
"name": name,
"protected": True
if name.lower() in name_passwords
else False,
"type": "update_name",
"data": {
"name": name,
"protected": True
if name.lower() in name_passwords
else False,
},
}
)
# For any clients in any lobby with this session, update their user lists
@ -757,9 +756,11 @@ async def lobby_join(
try:
await displaced.ws.send_json(
{
"type": "update",
"name": fallback,
"protected": False,
"type": "update_name",
"data": {
"name": fallback,
"protected": False,
},
}
)
except Exception:
@ -782,11 +783,13 @@ async def lobby_join(
)
await websocket.send_json(
{
"type": "update",
"name": name,
"protected": True
if name.lower() in name_passwords
else False,
"type": "update_name",
"data": {
"name": name,
"protected": True
if name.lower() in name_passwords
else False,
},
}
)
# Notify lobbies for this session

View File

@ -114,12 +114,6 @@ class LobbyCreateResponse(BaseModel):
# WebSocket Message Models
# =============================================================================
class WebSocketMessageModel(BaseModel):
"""Base model for all WebSocket messages"""
type: str
data: Dict[str, object] = {}
class JoinStatusModel(BaseModel):
"""WebSocket message for join status updates"""
status: str
@ -137,10 +131,16 @@ class LobbyStateModel(BaseModel):
participants: List[ParticipantModel] = []
class UpdateModel(BaseModel):
"""Generic update message model"""
class Config:
extra = "allow"
class UpdateNameModel(BaseModel):
name: str
protected: Optional[bool] = False
class WebSocketMessageModel(BaseModel):
"""Base model for all WebSocket messages"""
type: str
data: JoinStatusModel | UserJoinedModel | LobbyStateModel | UpdateNameModel
# =============================================================================

View File

@ -17,26 +17,6 @@ def test_models():
try:
# Test that models can be imported
from models import (
LobbyModel,
SessionModel,
ParticipantModel,
WebSocketMessageModel,
JoinStatusModel,
UserJoinedModel,
LobbyStateModel,
UpdateModel,
AddPeerModel,
RemovePeerModel,
SessionDescriptionModel,
IceCandidateModel,
ICECandidateDictModel,
SessionDescriptionTypedModel,
LobbyCreateRequest,
LobbyCreateResponse,
AdminActionResponse,
HealthResponse,
)
print("✓ All shared models imported successfully")
# Test model definitions (without actually creating instances since we don't have pydantic)

View File

@ -48,9 +48,9 @@ def _setup_logging(level: str=logging_level) -> logging.Logger:
warnings.filterwarnings("ignore", message="n_jobs value 1 overridden")
warnings.filterwarnings("ignore", message=".*websocket.*is deprecated")
logging.getLogger("aiortc").setLevel(logging.DEBUG)
logging.getLogger("aioice").setLevel(logging.DEBUG)
logging.getLogger("asyncio").setLevel(logging.DEBUG)
logging.getLogger("aiortc").setLevel(logging.INFO)
logging.getLogger("aioice").setLevel(logging.INFO)
logging.getLogger("asyncio").setLevel(logging.INFO)
numeric_level = getattr(logging, level.upper(), None)
if not isinstance(numeric_level, int):

View File

@ -44,13 +44,10 @@ from shared.models import (
WebSocketMessageModel,
JoinStatusModel,
UserJoinedModel,
ParticipantModel,
LobbyStateModel,
UpdateModel,
ICECandidateDictModel,
UpdateNameModel,
AddPeerModel,
RemovePeerModel,
SessionDescriptionTypedModel,
SessionDescriptionModel,
IceCandidateModel,
)
@ -352,7 +349,7 @@ class AnimatedVideoTrack(MediaStreamTrack):
# Add frame counter text
frame_text = f"Frame: {int(time.time() * 1000) % 10000}"
logger.info(frame_text)
# logger.info(frame_text)
cv2.putText(
frame_array,
frame_text,
@ -653,9 +650,9 @@ class WebRTCSignalingClient:
return
participants = validated.participants
logger.info(f"Lobby state updated: {len(participants)} participants")
elif msg_type == "update":
elif msg_type == "update_name":
try:
validated = UpdateModel.model_validate(data)
validated = UpdateNameModel.model_validate(data)
except ValidationError as e:
logger.error(f"Invalid update payload: {e}", exc_info=True)
return