Pydantic -> Typescript conversion work
This commit is contained in:
parent
2908fe1ee7
commit
3cb49c70ef
@ -2,6 +2,7 @@
|
||||
!voicebot
|
||||
!server
|
||||
!client
|
||||
!shared
|
||||
node_modules
|
||||
build
|
||||
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
|
||||
|
||||
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
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/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
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:
|
||||
- "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
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)
|
193
server/main.py
193
server/main.py
@ -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",
|
||||
"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",
|
||||
"type": "update_name",
|
||||
"data": {
|
||||
"name": fallback,
|
||||
"protected": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
@ -782,11 +783,13 @@ async def lobby_join(
|
||||
)
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "update",
|
||||
"type": "update_name",
|
||||
"data": {
|
||||
"name": name,
|
||||
"protected": True
|
||||
if name.lower() in name_passwords
|
||||
else False,
|
||||
},
|
||||
}
|
||||
)
|
||||
# Notify lobbies for this session
|
||||
|
@ -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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user