Pydantic -> Typescript conversion work
This commit is contained in:
parent
2908fe1ee7
commit
3cb49c70ef
@ -2,6 +2,7 @@
|
|||||||
!voicebot
|
!voicebot
|
||||||
!server
|
!server
|
||||||
!client
|
!client
|
||||||
|
!shared
|
||||||
node_modules
|
node_modules
|
||||||
build
|
build
|
||||||
dist
|
dist
|
||||||
|
157
TYPESCRIPT_GENERATION.md
Normal file
157
TYPESCRIPT_GENERATION.md
Normal 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.
|
@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
# Launch server in production or development mode
|
# Launch server in production or development mode
|
||||||
if [ "$PRODUCTION" = "true" ]; then
|
if [ "$PRODUCTION" = "true" ]; then
|
||||||
export REACT_APP_AI_VOICECHAT_BUILD="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
export REACT_APP_AI_VOICECHAT_BUILD="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
781
client/openapi-schema.json
Normal file
781
client/openapi-schema.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -25,14 +25,23 @@
|
|||||||
"@types/node": "^20.11.30",
|
"@types/node": "^20.11.30",
|
||||||
"@types/react": "^18.2.70",
|
"@types/react": "^18.2.70",
|
||||||
"@types/react-dom": "^18.2.19",
|
"@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": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
"test": "react-scripts test",
|
"test": "react-scripts test",
|
||||||
"eject": "react-scripts eject",
|
"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": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
|
134
client/src/api-client.ts
Normal file
134
client/src/api-client.ts
Normal 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
375
client/src/api-types.ts
Normal 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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
91
client/src/api-usage-examples.ts
Normal file
91
client/src/api-usage-examples.ts
Normal 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 };
|
@ -32,6 +32,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8001:8000"
|
- "8001:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
- ./shared:/shared:ro
|
||||||
- ./server:/server:rw
|
- ./server:/server:rw
|
||||||
- ./server/.venv:/server/.venv:rw
|
- ./server/.venv:/server/.venv:rw
|
||||||
- ./client/build:/client/build:ro
|
- ./client/build:/client/build:ro
|
||||||
@ -52,6 +53,7 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
network_mode: host
|
network_mode: host
|
||||||
volumes:
|
volumes:
|
||||||
|
- ./shared:/shared:ro
|
||||||
- ./voicebot:/voicebot:rw
|
- ./voicebot:/voicebot:rw
|
||||||
- ./voicebot/.venv:/voicebot/.venv:rw
|
- ./voicebot/.venv:/voicebot/.venv:rw
|
||||||
# network_mode: host
|
# network_mode: host
|
||||||
|
38
generate-ts-types.sh
Executable file
38
generate-ts-types.sh
Executable 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';"
|
43
server/generate_schema_simple.py
Normal file
43
server/generate_schema_simple.py
Normal 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)
|
213
server/main.py
213
server/main.py
@ -6,46 +6,52 @@ from fastapi import (
|
|||||||
FastAPI,
|
FastAPI,
|
||||||
Path,
|
Path,
|
||||||
WebSocket,
|
WebSocket,
|
||||||
WebSocketDisconnect,
|
|
||||||
Request,
|
Request,
|
||||||
Response,
|
Response,
|
||||||
|
WebSocketDisconnect,
|
||||||
)
|
)
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
import secrets
|
import secrets
|
||||||
import os
|
import os
|
||||||
import httpx
|
|
||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
import binascii
|
import binascii
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
import httpx
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from logger import logger
|
from logger import logger
|
||||||
|
|
||||||
# Import shared models
|
# Import shared models
|
||||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
from shared.models import (
|
from shared.models import (
|
||||||
|
HealthResponse,
|
||||||
|
LobbiesResponse,
|
||||||
|
LobbyCreateRequest,
|
||||||
|
LobbyCreateResponse,
|
||||||
|
LobbyListItem,
|
||||||
|
LobbyModel,
|
||||||
NamePasswordRecord,
|
NamePasswordRecord,
|
||||||
LobbySaved,
|
LobbySaved,
|
||||||
|
SessionResponse,
|
||||||
SessionSaved,
|
SessionSaved,
|
||||||
SessionsPayload,
|
SessionsPayload,
|
||||||
AdminNamesResponse,
|
AdminNamesResponse,
|
||||||
AdminActionResponse,
|
AdminActionResponse,
|
||||||
AdminSetPassword,
|
AdminSetPassword,
|
||||||
AdminClearPassword,
|
AdminClearPassword,
|
||||||
HealthResponse,
|
JoinStatusModel,
|
||||||
LobbyListItem,
|
|
||||||
LobbiesResponse,
|
|
||||||
LobbyModel,
|
|
||||||
SessionResponse,
|
|
||||||
LobbyCreateRequest,
|
|
||||||
LobbyCreateResponse,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Mapping of reserved names to password records (lowercased name -> {salt:..., hash:...})
|
# Mapping of reserved names to password records (lowercased name -> {salt:..., hash:...})
|
||||||
name_passwords: dict[str, dict[str, str]] = {}
|
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]:
|
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
|
"""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}
|
return {"status": "not_found", "name": payload.name}
|
||||||
|
|
||||||
|
|
||||||
|
lobbies: dict[str, Lobby] = {}
|
||||||
|
|
||||||
|
|
||||||
class LobbyResponse(TypedDict):
|
class LobbyResponse(TypedDict):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
private: bool
|
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:
|
class Session:
|
||||||
_instances: list[Session] = []
|
_instances: list[Session] = []
|
||||||
_save_file = "sessions.json"
|
_save_file = "sessions.json"
|
||||||
@ -249,13 +320,10 @@ class Session:
|
|||||||
|
|
||||||
if lobby.id in self.lobby_peers or self.id in lobby.sessions:
|
if lobby.id in self.lobby_peers or self.id in lobby.sessions:
|
||||||
logger.info(f"{self.getName()} - Already joined to {lobby.getName()}.")
|
logger.info(f"{self.getName()} - Already joined to {lobby.getName()}.")
|
||||||
await self.ws.send_json(
|
data = JoinStatusModel(
|
||||||
{
|
status="Joined", message=f"Already joined to lobby {lobby.getName()}"
|
||||||
"type": "join_status",
|
|
||||||
"status": "Joined",
|
|
||||||
"message": f"Already joined to lobby {lobby.getName()}",
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
await self.ws.send_json({"type": "join_status", "data": data.model_dump()})
|
||||||
return
|
return
|
||||||
|
|
||||||
# Initialize the peer list for this lobby
|
# Initialize the peer list for this lobby
|
||||||
@ -315,7 +383,7 @@ class Session:
|
|||||||
await lobby.addSession(self)
|
await lobby.addSession(self)
|
||||||
Session.save()
|
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):
|
async def part(self, lobby: Lobby):
|
||||||
if lobby.id not in self.lobby_peers or self.id not in lobby.sessions:
|
if lobby.id not in self.lobby_peers or self.id not in lobby.sessions:
|
||||||
@ -377,77 +445,12 @@ class Session:
|
|||||||
Session.save()
|
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:
|
def getName(session: Session | None) -> str | None:
|
||||||
if session and session.name:
|
if session and session.name:
|
||||||
return session.name
|
return session.name
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
lobbies: dict[str, Lobby] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def getSession(session_id: str) -> Session | None:
|
def getSession(session_id: str) -> Session | None:
|
||||||
return Session.getSession(session_id)
|
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
|
# Register websocket endpoint directly on app with full public_url path
|
||||||
@app.websocket(f"{public_url}" + "ws/lobby/{lobby_id}/{session_id}")
|
@app.websocket(f"{public_url}" + "ws/lobby/{lobby_id}/{session_id}")
|
||||||
async def lobby_join(
|
async def lobby_join(
|
||||||
@ -647,11 +644,11 @@ async def lobby_join(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
message = await websocket.receive_json()
|
packet = await websocket.receive_json()
|
||||||
type = message.get("type", None)
|
type = packet.get("type", None)
|
||||||
data: dict[str, Any] | None = message.get("data", None)
|
data: dict[str, Any] | None = packet.get("data", None)
|
||||||
if not type:
|
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"})
|
await websocket.send_json({"type": "error", "error": "Invalid request"})
|
||||||
continue
|
continue
|
||||||
# logger.info(f"{session.getName()} <- RAW Rx: {data}")
|
# logger.info(f"{session.getName()} <- RAW Rx: {data}")
|
||||||
@ -685,11 +682,13 @@ async def lobby_join(
|
|||||||
logger.info(f"{session.getName()}: -> update('name', {name})")
|
logger.info(f"{session.getName()}: -> update('name', {name})")
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
"type": "update",
|
"type": "update_name",
|
||||||
"name": name,
|
"data": {
|
||||||
"protected": True
|
"name": name,
|
||||||
if name.lower() in name_passwords
|
"protected": True
|
||||||
else False,
|
if name.lower() in name_passwords
|
||||||
|
else False,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
# For any clients in any lobby with this session, update their user lists
|
# For any clients in any lobby with this session, update their user lists
|
||||||
@ -757,9 +756,11 @@ async def lobby_join(
|
|||||||
try:
|
try:
|
||||||
await displaced.ws.send_json(
|
await displaced.ws.send_json(
|
||||||
{
|
{
|
||||||
"type": "update",
|
"type": "update_name",
|
||||||
"name": fallback,
|
"data": {
|
||||||
"protected": False,
|
"name": fallback,
|
||||||
|
"protected": False,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -782,11 +783,13 @@ async def lobby_join(
|
|||||||
)
|
)
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
"type": "update",
|
"type": "update_name",
|
||||||
"name": name,
|
"data": {
|
||||||
"protected": True
|
"name": name,
|
||||||
if name.lower() in name_passwords
|
"protected": True
|
||||||
else False,
|
if name.lower() in name_passwords
|
||||||
|
else False,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
# Notify lobbies for this session
|
# Notify lobbies for this session
|
||||||
|
@ -114,12 +114,6 @@ class LobbyCreateResponse(BaseModel):
|
|||||||
# WebSocket Message Models
|
# WebSocket Message Models
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
class WebSocketMessageModel(BaseModel):
|
|
||||||
"""Base model for all WebSocket messages"""
|
|
||||||
type: str
|
|
||||||
data: Dict[str, object] = {}
|
|
||||||
|
|
||||||
|
|
||||||
class JoinStatusModel(BaseModel):
|
class JoinStatusModel(BaseModel):
|
||||||
"""WebSocket message for join status updates"""
|
"""WebSocket message for join status updates"""
|
||||||
status: str
|
status: str
|
||||||
@ -137,10 +131,16 @@ class LobbyStateModel(BaseModel):
|
|||||||
participants: List[ParticipantModel] = []
|
participants: List[ParticipantModel] = []
|
||||||
|
|
||||||
|
|
||||||
class UpdateModel(BaseModel):
|
class UpdateNameModel(BaseModel):
|
||||||
"""Generic update message model"""
|
name: str
|
||||||
class Config:
|
protected: Optional[bool] = False
|
||||||
extra = "allow"
|
|
||||||
|
|
||||||
|
class WebSocketMessageModel(BaseModel):
|
||||||
|
"""Base model for all WebSocket messages"""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
data: JoinStatusModel | UserJoinedModel | LobbyStateModel | UpdateNameModel
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
@ -17,26 +17,6 @@ def test_models():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Test that models can be imported
|
# 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")
|
print("✓ All shared models imported successfully")
|
||||||
|
|
||||||
# Test model definitions (without actually creating instances since we don't have pydantic)
|
# Test model definitions (without actually creating instances since we don't have pydantic)
|
||||||
|
@ -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="n_jobs value 1 overridden")
|
||||||
warnings.filterwarnings("ignore", message=".*websocket.*is deprecated")
|
warnings.filterwarnings("ignore", message=".*websocket.*is deprecated")
|
||||||
|
|
||||||
logging.getLogger("aiortc").setLevel(logging.DEBUG)
|
logging.getLogger("aiortc").setLevel(logging.INFO)
|
||||||
logging.getLogger("aioice").setLevel(logging.DEBUG)
|
logging.getLogger("aioice").setLevel(logging.INFO)
|
||||||
logging.getLogger("asyncio").setLevel(logging.DEBUG)
|
logging.getLogger("asyncio").setLevel(logging.INFO)
|
||||||
|
|
||||||
numeric_level = getattr(logging, level.upper(), None)
|
numeric_level = getattr(logging, level.upper(), None)
|
||||||
if not isinstance(numeric_level, int):
|
if not isinstance(numeric_level, int):
|
||||||
|
@ -44,13 +44,10 @@ from shared.models import (
|
|||||||
WebSocketMessageModel,
|
WebSocketMessageModel,
|
||||||
JoinStatusModel,
|
JoinStatusModel,
|
||||||
UserJoinedModel,
|
UserJoinedModel,
|
||||||
ParticipantModel,
|
|
||||||
LobbyStateModel,
|
LobbyStateModel,
|
||||||
UpdateModel,
|
UpdateNameModel,
|
||||||
ICECandidateDictModel,
|
|
||||||
AddPeerModel,
|
AddPeerModel,
|
||||||
RemovePeerModel,
|
RemovePeerModel,
|
||||||
SessionDescriptionTypedModel,
|
|
||||||
SessionDescriptionModel,
|
SessionDescriptionModel,
|
||||||
IceCandidateModel,
|
IceCandidateModel,
|
||||||
)
|
)
|
||||||
@ -352,7 +349,7 @@ class AnimatedVideoTrack(MediaStreamTrack):
|
|||||||
|
|
||||||
# Add frame counter text
|
# Add frame counter text
|
||||||
frame_text = f"Frame: {int(time.time() * 1000) % 10000}"
|
frame_text = f"Frame: {int(time.time() * 1000) % 10000}"
|
||||||
logger.info(frame_text)
|
# logger.info(frame_text)
|
||||||
cv2.putText(
|
cv2.putText(
|
||||||
frame_array,
|
frame_array,
|
||||||
frame_text,
|
frame_text,
|
||||||
@ -653,9 +650,9 @@ class WebRTCSignalingClient:
|
|||||||
return
|
return
|
||||||
participants = validated.participants
|
participants = validated.participants
|
||||||
logger.info(f"Lobby state updated: {len(participants)} participants")
|
logger.info(f"Lobby state updated: {len(participants)} participants")
|
||||||
elif msg_type == "update":
|
elif msg_type == "update_name":
|
||||||
try:
|
try:
|
||||||
validated = UpdateModel.model_validate(data)
|
validated = UpdateNameModel.model_validate(data)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
logger.error(f"Invalid update payload: {e}", exc_info=True)
|
logger.error(f"Invalid update payload: {e}", exc_info=True)
|
||||||
return
|
return
|
||||||
|
Loading…
x
Reference in New Issue
Block a user