mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-28 13:31:32 +08:00
feat: add Data API channels and integrate swr for data fetching
- Introduced new IPC channels for Data API requests and responses in IpcChannel. - Added swr library to package.json for improved data fetching capabilities. - Updated preload API to include Data API related methods for handling requests and subscriptions. - Removed deprecated pending_default_values.ts file as part of data refactor.
This commit is contained in:
parent
9e3618bc17
commit
a7d12abd1f
@ -310,6 +310,7 @@
|
||||
"string-width": "^7.2.0",
|
||||
"striptags": "^3.2.0",
|
||||
"styled-components": "^6.1.11",
|
||||
"swr": "^2.3.6",
|
||||
"tar": "^7.4.3",
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
"tokenx": "^1.1.0",
|
||||
|
||||
@ -307,6 +307,15 @@ export enum IpcChannel {
|
||||
Preference_Subscribe = 'preference:subscribe',
|
||||
Preference_Changed = 'preference:changed',
|
||||
|
||||
// Data API channels
|
||||
DataApi_Request = 'data-api:request',
|
||||
DataApi_Response = 'data-api:response',
|
||||
DataApi_Batch = 'data-api:batch',
|
||||
DataApi_Transaction = 'data-api:transaction',
|
||||
DataApi_Subscribe = 'data-api:subscribe',
|
||||
DataApi_Unsubscribe = 'data-api:unsubscribe',
|
||||
DataApi_Stream = 'data-api:stream',
|
||||
|
||||
// TRACE
|
||||
TRACE_SAVE_DATA = 'trace:saveData',
|
||||
TRACE_GET_DATA = 'trace:getData',
|
||||
|
||||
198
packages/shared/data/README.md
Normal file
198
packages/shared/data/README.md
Normal file
@ -0,0 +1,198 @@
|
||||
# Cherry Studio Shared Data
|
||||
|
||||
This directory contains shared data structures and API type definitions for the Cherry Studio application. It includes both persistent data schemas and API contract definitions.
|
||||
|
||||
## 📁 File Organization
|
||||
|
||||
### Persistent Data (Root Level)
|
||||
|
||||
- **`preferences.ts`** - User preference data schema and default values
|
||||
- **`preferenceTypes.ts`** - TypeScript types for preference system
|
||||
|
||||
### API Types (`api/` subdirectory)
|
||||
|
||||
- **`api/index.ts`** - Barrel export file providing clean imports for all API types
|
||||
- **`api/apiTypes.ts`** - Core request/response types and API infrastructure
|
||||
- **`api/apiModels.ts`** - Business entity types and Data Transfer Objects (DTOs)
|
||||
- **`api/apiSchemas.ts`** - Complete API endpoint definitions with type mappings
|
||||
- **`api/errorCodes.ts`** - Error handling utilities and standardized error codes
|
||||
|
||||
## 🏗️ Architecture Overview
|
||||
|
||||
These files are part of the **Renderer-Main Virtual Data Acquisition Architecture** that enables:
|
||||
|
||||
- **Type-safe IPC communication** between Electron processes
|
||||
- **RESTful API patterns** within the Electron application
|
||||
- **Standardized error handling** across the application
|
||||
- **Future extensibility** to standalone API servers
|
||||
|
||||
## 🔄 Classification Status
|
||||
|
||||
**Important**: These files are **NOT classified** in the data refactor system because they are:
|
||||
- ✅ **Type definitions** - Not actual data storage
|
||||
- ✅ **Compile-time artifacts** - Exist only during TypeScript compilation
|
||||
- ✅ **Framework infrastructure** - Enable the data API architecture
|
||||
- ✅ **Development tools** - Similar to interfaces in other languages
|
||||
|
||||
## 📖 Usage Examples
|
||||
|
||||
### Basic Imports
|
||||
|
||||
```typescript
|
||||
// Import API types from the api subdirectory
|
||||
import {
|
||||
Topic,
|
||||
CreateTopicDto,
|
||||
DataRequest,
|
||||
ApiSchemas,
|
||||
ErrorCode
|
||||
} from '@shared/data/api'
|
||||
|
||||
// Import specific groups
|
||||
import type { TopicTypes, MessageTypes } from '@shared/data/api'
|
||||
|
||||
// Import preferences
|
||||
import type { UserPreferences } from '@shared/data/preferences'
|
||||
```
|
||||
|
||||
### API Schema Usage
|
||||
|
||||
```typescript
|
||||
import type { ApiSchemas, ApiResponse } from '@shared/data/api'
|
||||
|
||||
// Get the response type for a specific endpoint
|
||||
type TopicsListResponse = ApiResponse<'/topics', 'GET'>
|
||||
// Result: PaginatedResponse<Topic>
|
||||
|
||||
type CreateTopicResponse = ApiResponse<'/topics', 'POST'>
|
||||
// Result: Topic
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
import { DataApiErrorFactory, ErrorCode, isDataApiError } from '@shared/data/api'
|
||||
|
||||
// Create standardized errors
|
||||
const notFoundError = DataApiErrorFactory.notFound('Topic', '123')
|
||||
const validationError = DataApiErrorFactory.validation({
|
||||
title: ['Title is required']
|
||||
})
|
||||
|
||||
// Check if error is a Data API error
|
||||
if (isDataApiError(error)) {
|
||||
console.log(`Error ${error.code}: ${error.message}`)
|
||||
}
|
||||
```
|
||||
|
||||
### Request/Response Types
|
||||
|
||||
```typescript
|
||||
import type { DataRequest, DataResponse, PaginatedResponse, Topic } from '@shared/data/api'
|
||||
|
||||
// Type-safe request construction
|
||||
const request: DataRequest = {
|
||||
id: 'req_123',
|
||||
method: 'GET',
|
||||
path: '/topics',
|
||||
params: { page: 1, limit: 10 }
|
||||
}
|
||||
|
||||
// Type-safe response handling
|
||||
const response: DataResponse<PaginatedResponse<Topic>> = {
|
||||
id: 'req_123',
|
||||
status: 200,
|
||||
data: {
|
||||
items: [...],
|
||||
total: 100,
|
||||
page: 1,
|
||||
pageCount: 10,
|
||||
hasNext: true,
|
||||
hasPrev: false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Development Guidelines
|
||||
|
||||
### Adding New Domain Models
|
||||
|
||||
1. Add the interface to `api/apiModels.ts`
|
||||
2. Create corresponding DTOs (Create/Update)
|
||||
3. Export from `api/index.ts`
|
||||
4. Update API schemas if needed
|
||||
|
||||
```typescript
|
||||
// In api/apiModels.ts
|
||||
export interface NewEntity {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateNewEntityDto {
|
||||
name: string
|
||||
}
|
||||
```
|
||||
|
||||
### Adding New API Endpoints
|
||||
|
||||
1. Add endpoint definition to `api/apiSchemas.ts`
|
||||
2. Include JSDoc comments with examples
|
||||
3. Ensure all types are properly referenced
|
||||
|
||||
```typescript
|
||||
// In api/apiSchemas.ts
|
||||
export interface ApiSchemas {
|
||||
/**
|
||||
* New entity endpoint
|
||||
* @example GET /entities?page=1&limit=10
|
||||
*/
|
||||
'/entities': {
|
||||
/** List all entities */
|
||||
GET: {
|
||||
query?: PaginationParams
|
||||
response: PaginatedResponse<NewEntity>
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Type Safety Best Practices
|
||||
|
||||
- Always use `import type` for type-only imports
|
||||
- Leverage the type helpers (`ApiResponse`, `ApiParams`, etc.)
|
||||
- Use the barrel export for clean imports
|
||||
- Document complex types with JSDoc comments
|
||||
|
||||
## 🔗 Related Files
|
||||
|
||||
### Main Process Implementation
|
||||
- `src/main/data/DataApiService.ts` - Main process data service
|
||||
- `src/main/data/api/` - Controllers, services, and routing
|
||||
|
||||
### Renderer Process Implementation
|
||||
- `src/renderer/src/data/DataApiService.ts` - Renderer API client
|
||||
- `src/renderer/src/data/hooks/` - React hooks for data fetching
|
||||
|
||||
### Shared Data Types
|
||||
- `packages/shared/data/api/` - API contract definitions
|
||||
- `packages/shared/data/preferences.ts` - User preference schemas
|
||||
|
||||
### Architecture Documentation
|
||||
- `.claude/data-request-arch.md` - Complete architecture documentation
|
||||
- `CLAUDE.md` - Project development guidelines
|
||||
|
||||
## 🚀 Future Enhancements
|
||||
|
||||
The type system is designed to support:
|
||||
|
||||
- **HTTP API Server** - Types can be reused for standalone HTTP APIs
|
||||
- **GraphQL Integration** - Schema can be mapped to GraphQL resolvers
|
||||
- **Real-time Subscriptions** - WebSocket/SSE event types are defined
|
||||
- **Advanced Caching** - Cache-related types are ready for implementation
|
||||
|
||||
---
|
||||
|
||||
*This README is part of the Cherry Studio data refactor project. For more information, see the project documentation in `.claude/` directory.*
|
||||
107
packages/shared/data/api/apiModels.ts
Normal file
107
packages/shared/data/api/apiModels.ts
Normal file
@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Generic test model definitions
|
||||
* Contains flexible types for comprehensive API testing
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generic test item entity - flexible structure for testing various scenarios
|
||||
*/
|
||||
export interface TestItem {
|
||||
/** Unique identifier */
|
||||
id: string
|
||||
/** Item title */
|
||||
title: string
|
||||
/** Optional description */
|
||||
description?: string
|
||||
/** Type category */
|
||||
type: string
|
||||
/** Current status */
|
||||
status: string
|
||||
/** Priority level */
|
||||
priority: string
|
||||
/** Associated tags */
|
||||
tags: string[]
|
||||
/** Creation timestamp */
|
||||
createdAt: string
|
||||
/** Last update timestamp */
|
||||
updatedAt: string
|
||||
/** Additional metadata */
|
||||
metadata: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Data Transfer Objects (DTOs) for test operations
|
||||
*/
|
||||
|
||||
/**
|
||||
* DTO for creating a new test item
|
||||
*/
|
||||
export interface CreateTestItemDto {
|
||||
/** Item title */
|
||||
title: string
|
||||
/** Optional description */
|
||||
description?: string
|
||||
/** Type category */
|
||||
type?: string
|
||||
/** Current status */
|
||||
status?: string
|
||||
/** Priority level */
|
||||
priority?: string
|
||||
/** Associated tags */
|
||||
tags?: string[]
|
||||
/** Additional metadata */
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for updating an existing test item
|
||||
*/
|
||||
export interface UpdateTestItemDto {
|
||||
/** Updated title */
|
||||
title?: string
|
||||
/** Updated description */
|
||||
description?: string
|
||||
/** Updated type */
|
||||
type?: string
|
||||
/** Updated status */
|
||||
status?: string
|
||||
/** Updated priority */
|
||||
priority?: string
|
||||
/** Updated tags */
|
||||
tags?: string[]
|
||||
/** Updated metadata */
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk operation types for batch processing
|
||||
*/
|
||||
|
||||
/**
|
||||
* Request for bulk operations on multiple items
|
||||
*/
|
||||
export interface BulkOperationRequest<TData = any> {
|
||||
/** Type of bulk operation to perform */
|
||||
operation: 'create' | 'update' | 'delete' | 'archive' | 'restore'
|
||||
/** Array of data items to process */
|
||||
data: TData[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from a bulk operation
|
||||
*/
|
||||
export interface BulkOperationResponse {
|
||||
/** Number of successfully processed items */
|
||||
successful: number
|
||||
/** Number of items that failed processing */
|
||||
failed: number
|
||||
/** Array of errors that occurred during processing */
|
||||
errors: Array<{
|
||||
/** Index of the item that failed */
|
||||
index: number
|
||||
/** Error message */
|
||||
error: string
|
||||
/** Optional additional error data */
|
||||
data?: any
|
||||
}>
|
||||
}
|
||||
63
packages/shared/data/api/apiPaths.ts
Normal file
63
packages/shared/data/api/apiPaths.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import type { ApiSchemas } from './apiSchemas'
|
||||
|
||||
/**
|
||||
* Template literal type utilities for converting parameterized paths to concrete paths
|
||||
* This enables type-safe API calls with actual paths like '/test/items/123' instead of '/test/items/:id'
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert parameterized path templates to concrete path types
|
||||
* @example '/test/items/:id' -> '/test/items/${string}'
|
||||
* @example '/topics/:id/messages' -> '/topics/${string}/messages'
|
||||
*/
|
||||
export type ResolvedPath<T extends string> = T extends `${infer Prefix}/:${string}/${infer Suffix}`
|
||||
? `${Prefix}/${string}/${ResolvedPath<Suffix>}`
|
||||
: T extends `${infer Prefix}/:${string}`
|
||||
? `${Prefix}/${string}`
|
||||
: T
|
||||
|
||||
/**
|
||||
* Generate all possible concrete paths from ApiSchemas
|
||||
* This creates a union type of all valid API paths
|
||||
*/
|
||||
export type ConcreteApiPaths = {
|
||||
[K in keyof ApiSchemas]: ResolvedPath<K & string>
|
||||
}[keyof ApiSchemas]
|
||||
|
||||
/**
|
||||
* Reverse lookup: from concrete path back to original template path
|
||||
* Used to determine which ApiSchema entry matches a concrete path
|
||||
*/
|
||||
export type MatchApiPath<Path extends string> = {
|
||||
[K in keyof ApiSchemas]: Path extends ResolvedPath<K & string> ? K : never
|
||||
}[keyof ApiSchemas]
|
||||
|
||||
/**
|
||||
* Extract query parameters type for a given concrete path
|
||||
*/
|
||||
export type QueryParamsForPath<Path extends string> =
|
||||
MatchApiPath<Path> extends keyof ApiSchemas
|
||||
? ApiSchemas[MatchApiPath<Path>] extends { GET: { query?: infer Q } }
|
||||
? Q
|
||||
: Record<string, any>
|
||||
: Record<string, any>
|
||||
|
||||
/**
|
||||
* Extract request body type for a given concrete path and HTTP method
|
||||
*/
|
||||
export type BodyForPath<Path extends string, Method extends string> =
|
||||
MatchApiPath<Path> extends keyof ApiSchemas
|
||||
? ApiSchemas[MatchApiPath<Path>] extends { [M in Method]: { body: infer B } }
|
||||
? B
|
||||
: any
|
||||
: any
|
||||
|
||||
/**
|
||||
* Extract response type for a given concrete path and HTTP method
|
||||
*/
|
||||
export type ResponseForPath<Path extends string, Method extends string> =
|
||||
MatchApiPath<Path> extends keyof ApiSchemas
|
||||
? ApiSchemas[MatchApiPath<Path>] extends { [M in Method]: { response: infer R } }
|
||||
? R
|
||||
: any
|
||||
: any
|
||||
492
packages/shared/data/api/apiSchemas.ts
Normal file
492
packages/shared/data/api/apiSchemas.ts
Normal file
@ -0,0 +1,492 @@
|
||||
// NOTE: Types are defined inline in the schema for simplicity
|
||||
// If needed, specific types can be imported from './apiModels'
|
||||
import type { BodyForPath, ConcreteApiPaths, QueryParamsForPath, ResponseForPath } from './apiPaths'
|
||||
import type { HttpMethod, PaginatedResponse, PaginationParams } from './apiTypes'
|
||||
|
||||
// Re-export for external use
|
||||
export type { ConcreteApiPaths } from './apiPaths'
|
||||
|
||||
/**
|
||||
* Complete API Schema definitions for Test API
|
||||
*
|
||||
* Each path defines the supported HTTP methods with their:
|
||||
* - Request parameters (params, query, body)
|
||||
* - Response types
|
||||
* - Type safety guarantees
|
||||
*
|
||||
* This schema serves as the contract between renderer and main processes,
|
||||
* enabling full TypeScript type checking across IPC boundaries.
|
||||
*/
|
||||
export interface ApiSchemas {
|
||||
/**
|
||||
* Test items collection endpoint
|
||||
* @example GET /test/items?page=1&limit=10&search=hello
|
||||
* @example POST /test/items { "title": "New Test Item" }
|
||||
*/
|
||||
'/test/items': {
|
||||
/** List all test items with optional filtering and pagination */
|
||||
GET: {
|
||||
query?: PaginationParams & {
|
||||
/** Search items by title or description */
|
||||
search?: string
|
||||
/** Filter by item type */
|
||||
type?: string
|
||||
/** Filter by status */
|
||||
status?: string
|
||||
}
|
||||
response: PaginatedResponse<any>
|
||||
}
|
||||
/** Create a new test item */
|
||||
POST: {
|
||||
body: {
|
||||
title: string
|
||||
description?: string
|
||||
type?: string
|
||||
status?: string
|
||||
priority?: string
|
||||
tags?: string[]
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
response: any
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual test item endpoint
|
||||
* @example GET /test/items/123
|
||||
* @example PUT /test/items/123 { "title": "Updated Title" }
|
||||
* @example DELETE /test/items/123
|
||||
*/
|
||||
'/test/items/:id': {
|
||||
/** Get a specific test item by ID */
|
||||
GET: {
|
||||
params: { id: string }
|
||||
response: any
|
||||
}
|
||||
/** Update a specific test item */
|
||||
PUT: {
|
||||
params: { id: string }
|
||||
body: {
|
||||
title?: string
|
||||
description?: string
|
||||
type?: string
|
||||
status?: string
|
||||
priority?: string
|
||||
tags?: string[]
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
response: any
|
||||
}
|
||||
/** Delete a specific test item */
|
||||
DELETE: {
|
||||
params: { id: string }
|
||||
response: void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test search endpoint
|
||||
* @example GET /test/search?query=hello&page=1&limit=20
|
||||
*/
|
||||
'/test/search': {
|
||||
/** Search test items */
|
||||
GET: {
|
||||
query: {
|
||||
/** Search query string */
|
||||
query: string
|
||||
/** Page number for pagination */
|
||||
page?: number
|
||||
/** Number of results per page */
|
||||
limit?: number
|
||||
/** Additional filters */
|
||||
type?: string
|
||||
status?: string
|
||||
}
|
||||
response: PaginatedResponse<any>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test statistics endpoint
|
||||
* @example GET /test/stats
|
||||
*/
|
||||
'/test/stats': {
|
||||
/** Get comprehensive test statistics */
|
||||
GET: {
|
||||
response: {
|
||||
/** Total number of items */
|
||||
total: number
|
||||
/** Item count grouped by type */
|
||||
byType: Record<string, number>
|
||||
/** Item count grouped by status */
|
||||
byStatus: Record<string, number>
|
||||
/** Item count grouped by priority */
|
||||
byPriority: Record<string, number>
|
||||
/** Recent activity timeline */
|
||||
recentActivity: Array<{
|
||||
/** Date of activity */
|
||||
date: string
|
||||
/** Number of items on that date */
|
||||
count: number
|
||||
}>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bulk operations endpoint
|
||||
* @example POST /test/bulk { "operation": "create", "data": [...] }
|
||||
*/
|
||||
'/test/bulk': {
|
||||
/** Perform bulk operations on test items */
|
||||
POST: {
|
||||
body: {
|
||||
/** Operation type */
|
||||
operation: 'create' | 'update' | 'delete'
|
||||
/** Array of data items to process */
|
||||
data: any[]
|
||||
}
|
||||
response: {
|
||||
successful: number
|
||||
failed: number
|
||||
errors: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test error simulation endpoint
|
||||
* @example POST /test/error { "errorType": "timeout" }
|
||||
*/
|
||||
'/test/error': {
|
||||
/** Simulate various error scenarios for testing */
|
||||
POST: {
|
||||
body: {
|
||||
/** Type of error to simulate */
|
||||
errorType:
|
||||
| 'timeout'
|
||||
| 'network'
|
||||
| 'server'
|
||||
| 'notfound'
|
||||
| 'validation'
|
||||
| 'unauthorized'
|
||||
| 'ratelimit'
|
||||
| 'generic'
|
||||
}
|
||||
response: never
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test slow response endpoint
|
||||
* @example POST /test/slow { "delay": 2000 }
|
||||
*/
|
||||
'/test/slow': {
|
||||
/** Test slow response for performance testing */
|
||||
POST: {
|
||||
body: {
|
||||
/** Delay in milliseconds */
|
||||
delay: number
|
||||
}
|
||||
response: {
|
||||
message: string
|
||||
delay: number
|
||||
timestamp: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test data reset endpoint
|
||||
* @example POST /test/reset
|
||||
*/
|
||||
'/test/reset': {
|
||||
/** Reset all test data to initial state */
|
||||
POST: {
|
||||
response: {
|
||||
message: string
|
||||
timestamp: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test config endpoint
|
||||
* @example GET /test/config
|
||||
* @example PUT /test/config { "setting": "value" }
|
||||
*/
|
||||
'/test/config': {
|
||||
/** Get test configuration */
|
||||
GET: {
|
||||
response: Record<string, any>
|
||||
}
|
||||
/** Update test configuration */
|
||||
PUT: {
|
||||
body: Record<string, any>
|
||||
response: Record<string, any>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test status endpoint
|
||||
* @example GET /test/status
|
||||
*/
|
||||
'/test/status': {
|
||||
/** Get system test status */
|
||||
GET: {
|
||||
response: {
|
||||
status: string
|
||||
timestamp: string
|
||||
version: string
|
||||
uptime: number
|
||||
environment: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test performance endpoint
|
||||
* @example GET /test/performance
|
||||
*/
|
||||
'/test/performance': {
|
||||
/** Get performance metrics */
|
||||
GET: {
|
||||
response: {
|
||||
requestsPerSecond: number
|
||||
averageLatency: number
|
||||
memoryUsage: number
|
||||
cpuUsage: number
|
||||
uptime: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch execution of multiple requests
|
||||
* @example POST /batch { "requests": [...], "parallel": true }
|
||||
*/
|
||||
'/batch': {
|
||||
/** Execute multiple API requests in a single call */
|
||||
POST: {
|
||||
body: {
|
||||
/** Array of requests to execute */
|
||||
requests: Array<{
|
||||
/** HTTP method for the request */
|
||||
method: HttpMethod
|
||||
/** API path for the request */
|
||||
path: string
|
||||
/** URL parameters */
|
||||
params?: any
|
||||
/** Request body */
|
||||
body?: any
|
||||
}>
|
||||
/** Execute requests in parallel vs sequential */
|
||||
parallel?: boolean
|
||||
}
|
||||
response: {
|
||||
/** Results array matching input order */
|
||||
results: Array<{
|
||||
/** HTTP status code */
|
||||
status: number
|
||||
/** Response data if successful */
|
||||
data?: any
|
||||
/** Error information if failed */
|
||||
error?: any
|
||||
}>
|
||||
/** Batch execution metadata */
|
||||
metadata: {
|
||||
/** Total execution duration in ms */
|
||||
duration: number
|
||||
/** Number of successful requests */
|
||||
successCount: number
|
||||
/** Number of failed requests */
|
||||
errorCount: number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic transaction of multiple operations
|
||||
* @example POST /transaction { "operations": [...], "options": { "rollbackOnError": true } }
|
||||
*/
|
||||
'/transaction': {
|
||||
/** Execute multiple operations in a database transaction */
|
||||
POST: {
|
||||
body: {
|
||||
/** Array of operations to execute atomically */
|
||||
operations: Array<{
|
||||
/** HTTP method for the operation */
|
||||
method: HttpMethod
|
||||
/** API path for the operation */
|
||||
path: string
|
||||
/** URL parameters */
|
||||
params?: any
|
||||
/** Request body */
|
||||
body?: any
|
||||
}>
|
||||
/** Transaction configuration options */
|
||||
options?: {
|
||||
/** Database isolation level */
|
||||
isolation?: 'read-uncommitted' | 'read-committed' | 'repeatable-read' | 'serializable'
|
||||
/** Rollback all operations on any error */
|
||||
rollbackOnError?: boolean
|
||||
/** Transaction timeout in milliseconds */
|
||||
timeout?: number
|
||||
}
|
||||
}
|
||||
response: Array<{
|
||||
/** HTTP status code */
|
||||
status: number
|
||||
/** Response data if successful */
|
||||
data?: any
|
||||
/** Error information if failed */
|
||||
error?: any
|
||||
}>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified type extraction helpers
|
||||
*/
|
||||
export type ApiPaths = keyof ApiSchemas
|
||||
export type ApiMethods<TPath extends ApiPaths> = keyof ApiSchemas[TPath] & HttpMethod
|
||||
export type ApiResponse<TPath extends ApiPaths, TMethod extends string> = TPath extends keyof ApiSchemas
|
||||
? TMethod extends keyof ApiSchemas[TPath]
|
||||
? ApiSchemas[TPath][TMethod] extends { response: infer R }
|
||||
? R
|
||||
: never
|
||||
: never
|
||||
: never
|
||||
|
||||
export type ApiParams<TPath extends ApiPaths, TMethod extends string> = TPath extends keyof ApiSchemas
|
||||
? TMethod extends keyof ApiSchemas[TPath]
|
||||
? ApiSchemas[TPath][TMethod] extends { params: infer P }
|
||||
? P
|
||||
: never
|
||||
: never
|
||||
: never
|
||||
|
||||
export type ApiQuery<TPath extends ApiPaths, TMethod extends string> = TPath extends keyof ApiSchemas
|
||||
? TMethod extends keyof ApiSchemas[TPath]
|
||||
? ApiSchemas[TPath][TMethod] extends { query: infer Q }
|
||||
? Q
|
||||
: never
|
||||
: never
|
||||
: never
|
||||
|
||||
export type ApiBody<TPath extends ApiPaths, TMethod extends string> = TPath extends keyof ApiSchemas
|
||||
? TMethod extends keyof ApiSchemas[TPath]
|
||||
? ApiSchemas[TPath][TMethod] extends { body: infer B }
|
||||
? B
|
||||
: never
|
||||
: never
|
||||
: never
|
||||
|
||||
/**
|
||||
* Type-safe API client interface using concrete paths
|
||||
* Accepts actual paths like '/test/items/123' instead of '/test/items/:id'
|
||||
* Automatically infers query, body, and response types from ApiSchemas
|
||||
*/
|
||||
export interface ApiClient {
|
||||
get<TPath extends ConcreteApiPaths>(
|
||||
path: TPath,
|
||||
options?: {
|
||||
query?: QueryParamsForPath<TPath>
|
||||
headers?: Record<string, string>
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<ResponseForPath<TPath, 'GET'>>
|
||||
|
||||
post<TPath extends ConcreteApiPaths>(
|
||||
path: TPath,
|
||||
options: {
|
||||
body?: BodyForPath<TPath, 'POST'>
|
||||
query?: Record<string, any>
|
||||
headers?: Record<string, string>
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<ResponseForPath<TPath, 'POST'>>
|
||||
|
||||
put<TPath extends ConcreteApiPaths>(
|
||||
path: TPath,
|
||||
options: {
|
||||
body: BodyForPath<TPath, 'PUT'>
|
||||
query?: Record<string, any>
|
||||
headers?: Record<string, string>
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<ResponseForPath<TPath, 'PUT'>>
|
||||
|
||||
delete<TPath extends ConcreteApiPaths>(
|
||||
path: TPath,
|
||||
options?: {
|
||||
query?: Record<string, any>
|
||||
headers?: Record<string, string>
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<ResponseForPath<TPath, 'DELETE'>>
|
||||
|
||||
patch<TPath extends ConcreteApiPaths>(
|
||||
path: TPath,
|
||||
options: {
|
||||
body?: BodyForPath<TPath, 'PATCH'>
|
||||
query?: Record<string, any>
|
||||
headers?: Record<string, string>
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<ResponseForPath<TPath, 'PATCH'>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper types to determine if parameters are required based on schema
|
||||
*/
|
||||
type HasRequiredQuery<Path extends ApiPaths, Method extends ApiMethods<Path>> = Path extends keyof ApiSchemas
|
||||
? Method extends keyof ApiSchemas[Path]
|
||||
? ApiSchemas[Path][Method] extends { query: any }
|
||||
? true
|
||||
: false
|
||||
: false
|
||||
: false
|
||||
|
||||
type HasRequiredBody<Path extends ApiPaths, Method extends ApiMethods<Path>> = Path extends keyof ApiSchemas
|
||||
? Method extends keyof ApiSchemas[Path]
|
||||
? ApiSchemas[Path][Method] extends { body: any }
|
||||
? true
|
||||
: false
|
||||
: false
|
||||
: false
|
||||
|
||||
type HasRequiredParams<Path extends ApiPaths, Method extends ApiMethods<Path>> = Path extends keyof ApiSchemas
|
||||
? Method extends keyof ApiSchemas[Path]
|
||||
? ApiSchemas[Path][Method] extends { params: any }
|
||||
? true
|
||||
: false
|
||||
: false
|
||||
: false
|
||||
|
||||
/**
|
||||
* Handler function for a specific API endpoint
|
||||
* Provides type-safe parameter extraction based on ApiSchemas
|
||||
* Parameters are required or optional based on the schema definition
|
||||
*/
|
||||
export type ApiHandler<Path extends ApiPaths, Method extends ApiMethods<Path>> = (
|
||||
params: (HasRequiredParams<Path, Method> extends true
|
||||
? { params: ApiParams<Path, Method> }
|
||||
: { params?: ApiParams<Path, Method> }) &
|
||||
(HasRequiredQuery<Path, Method> extends true
|
||||
? { query: ApiQuery<Path, Method> }
|
||||
: { query?: ApiQuery<Path, Method> }) &
|
||||
(HasRequiredBody<Path, Method> extends true ? { body: ApiBody<Path, Method> } : { body?: ApiBody<Path, Method> })
|
||||
) => Promise<ApiResponse<Path, Method>>
|
||||
|
||||
/**
|
||||
* Complete API implementation that must match ApiSchemas structure
|
||||
* TypeScript will error if any endpoint is missing - this ensures exhaustive coverage
|
||||
*/
|
||||
export type ApiImplementation = {
|
||||
[Path in ApiPaths]: {
|
||||
[Method in ApiMethods<Path>]: ApiHandler<Path, Method>
|
||||
}
|
||||
}
|
||||
289
packages/shared/data/api/apiTypes.ts
Normal file
289
packages/shared/data/api/apiTypes.ts
Normal file
@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Core types for the Data API system
|
||||
* Provides type definitions for request/response handling across renderer-main IPC communication
|
||||
*/
|
||||
|
||||
/**
|
||||
* Standard HTTP methods supported by the Data API
|
||||
*/
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
||||
|
||||
/**
|
||||
* Request object structure for Data API calls
|
||||
*/
|
||||
export interface DataRequest<T = any> {
|
||||
/** Unique request identifier for tracking and correlation */
|
||||
id: string
|
||||
/** HTTP method for the request */
|
||||
method: HttpMethod
|
||||
/** API path (e.g., '/topics', '/topics/123') */
|
||||
path: string
|
||||
/** URL parameters for the request */
|
||||
params?: Record<string, any>
|
||||
/** Request body data */
|
||||
body?: T
|
||||
/** Request headers */
|
||||
headers?: Record<string, string>
|
||||
/** Additional metadata for request processing */
|
||||
metadata?: {
|
||||
/** Request timestamp */
|
||||
timestamp: number
|
||||
/** OpenTelemetry span context for tracing */
|
||||
spanContext?: any
|
||||
/** Cache options for this specific request */
|
||||
cache?: CacheOptions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Response object structure for Data API calls
|
||||
*/
|
||||
export interface DataResponse<T = any> {
|
||||
/** Request ID that this response corresponds to */
|
||||
id: string
|
||||
/** HTTP status code */
|
||||
status: number
|
||||
/** Response data if successful */
|
||||
data?: T
|
||||
/** Error information if request failed */
|
||||
error?: DataApiError
|
||||
/** Response metadata */
|
||||
metadata?: {
|
||||
/** Request processing duration in milliseconds */
|
||||
duration: number
|
||||
/** Whether response was served from cache */
|
||||
cached?: boolean
|
||||
/** Cache TTL if applicable */
|
||||
cacheTtl?: number
|
||||
/** Response timestamp */
|
||||
timestamp: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized error structure for Data API
|
||||
*/
|
||||
export interface DataApiError {
|
||||
/** Error code for programmatic handling */
|
||||
code: string
|
||||
/** Human-readable error message */
|
||||
message: string
|
||||
/** HTTP status code */
|
||||
status: number
|
||||
/** Additional error details */
|
||||
details?: any
|
||||
/** Error stack trace (development mode only) */
|
||||
stack?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard error codes for Data API
|
||||
*/
|
||||
export enum ErrorCode {
|
||||
// Client errors (4xx)
|
||||
BAD_REQUEST = 'BAD_REQUEST',
|
||||
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||
FORBIDDEN = 'FORBIDDEN',
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED',
|
||||
VALIDATION_ERROR = 'VALIDATION_ERROR',
|
||||
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
|
||||
|
||||
// Server errors (5xx)
|
||||
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
|
||||
DATABASE_ERROR = 'DATABASE_ERROR',
|
||||
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
|
||||
|
||||
// Custom application errors
|
||||
MIGRATION_ERROR = 'MIGRATION_ERROR',
|
||||
PERMISSION_DENIED = 'PERMISSION_DENIED',
|
||||
RESOURCE_LOCKED = 'RESOURCE_LOCKED',
|
||||
CONCURRENT_MODIFICATION = 'CONCURRENT_MODIFICATION'
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache configuration options
|
||||
*/
|
||||
export interface CacheOptions {
|
||||
/** Cache TTL in seconds */
|
||||
ttl?: number
|
||||
/** Return stale data while revalidating in background */
|
||||
staleWhileRevalidate?: boolean
|
||||
/** Custom cache key override */
|
||||
cacheKey?: string
|
||||
/** Operations that should invalidate this cache entry */
|
||||
invalidateOn?: string[]
|
||||
/** Whether to bypass cache entirely */
|
||||
noCache?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Transaction request wrapper for atomic operations
|
||||
*/
|
||||
export interface TransactionRequest {
|
||||
/** List of operations to execute in transaction */
|
||||
operations: DataRequest[]
|
||||
/** Transaction options */
|
||||
options?: {
|
||||
/** Database isolation level */
|
||||
isolation?: 'read-uncommitted' | 'read-committed' | 'repeatable-read' | 'serializable'
|
||||
/** Whether to rollback entire transaction on any error */
|
||||
rollbackOnError?: boolean
|
||||
/** Transaction timeout in milliseconds */
|
||||
timeout?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch request for multiple operations
|
||||
*/
|
||||
export interface BatchRequest {
|
||||
/** List of requests to execute */
|
||||
requests: DataRequest[]
|
||||
/** Whether to execute requests in parallel */
|
||||
parallel?: boolean
|
||||
/** Stop on first error */
|
||||
stopOnError?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch response containing results for all requests
|
||||
*/
|
||||
export interface BatchResponse {
|
||||
/** Individual response for each request */
|
||||
results: DataResponse[]
|
||||
/** Overall batch execution metadata */
|
||||
metadata: {
|
||||
/** Total execution time */
|
||||
duration: number
|
||||
/** Number of successful operations */
|
||||
successCount: number
|
||||
/** Number of failed operations */
|
||||
errorCount: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination parameters for list operations
|
||||
*/
|
||||
export interface PaginationParams {
|
||||
/** Page number (1-based) */
|
||||
page?: number
|
||||
/** Items per page */
|
||||
limit?: number
|
||||
/** Cursor for cursor-based pagination */
|
||||
cursor?: string
|
||||
/** Sort field and direction */
|
||||
sort?: {
|
||||
field: string
|
||||
order: 'asc' | 'desc'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated response wrapper
|
||||
*/
|
||||
export interface PaginatedResponse<T> {
|
||||
/** Items for current page */
|
||||
items: T[]
|
||||
/** Total number of items */
|
||||
total: number
|
||||
/** Current page number */
|
||||
page: number
|
||||
/** Total number of pages */
|
||||
pageCount: number
|
||||
/** Whether there are more pages */
|
||||
hasNext: boolean
|
||||
/** Whether there are previous pages */
|
||||
hasPrev: boolean
|
||||
/** Next cursor for cursor-based pagination */
|
||||
nextCursor?: string
|
||||
/** Previous cursor for cursor-based pagination */
|
||||
prevCursor?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription options for real-time data updates
|
||||
*/
|
||||
export interface SubscriptionOptions {
|
||||
/** Path pattern to subscribe to */
|
||||
path: string
|
||||
/** Filters to apply to subscription */
|
||||
filters?: Record<string, any>
|
||||
/** Whether to receive initial data */
|
||||
includeInitial?: boolean
|
||||
/** Custom subscription metadata */
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription callback function
|
||||
*/
|
||||
export type SubscriptionCallback<T = any> = (data: T, event: SubscriptionEvent) => void
|
||||
|
||||
/**
|
||||
* Subscription event types
|
||||
*/
|
||||
export enum SubscriptionEvent {
|
||||
CREATED = 'created',
|
||||
UPDATED = 'updated',
|
||||
DELETED = 'deleted',
|
||||
INITIAL = 'initial',
|
||||
ERROR = 'error'
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware interface
|
||||
*/
|
||||
export interface Middleware {
|
||||
/** Middleware name */
|
||||
name: string
|
||||
/** Execution priority (lower = earlier) */
|
||||
priority?: number
|
||||
/** Middleware execution function */
|
||||
execute(req: DataRequest, res: DataResponse, next: () => Promise<void>): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Request context passed through middleware chain
|
||||
*/
|
||||
export interface RequestContext {
|
||||
/** Original request */
|
||||
request: DataRequest
|
||||
/** Response being built */
|
||||
response: DataResponse
|
||||
/** Path that matched this request */
|
||||
path?: string
|
||||
/** HTTP method */
|
||||
method?: HttpMethod
|
||||
/** Authenticated user (if any) */
|
||||
user?: any
|
||||
/** Additional context data */
|
||||
data: Map<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Base options for service operations
|
||||
*/
|
||||
export interface ServiceOptions {
|
||||
/** Database transaction to use */
|
||||
transaction?: any
|
||||
/** User context for authorization */
|
||||
user?: any
|
||||
/** Additional service-specific options */
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard service response wrapper
|
||||
*/
|
||||
export interface ServiceResult<T = any> {
|
||||
/** Whether operation was successful */
|
||||
success: boolean
|
||||
/** Result data if successful */
|
||||
data?: T
|
||||
/** Error information if failed */
|
||||
error?: DataApiError
|
||||
/** Additional metadata */
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
193
packages/shared/data/api/errorCodes.ts
Normal file
193
packages/shared/data/api/errorCodes.ts
Normal file
@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Centralized error code definitions for the Data API system
|
||||
* Provides consistent error handling across renderer and main processes
|
||||
*/
|
||||
|
||||
import { DataApiError, ErrorCode } from './apiTypes'
|
||||
|
||||
// Re-export ErrorCode for convenience
|
||||
export { ErrorCode } from './apiTypes'
|
||||
|
||||
/**
|
||||
* Error code to HTTP status mapping
|
||||
*/
|
||||
export const ERROR_STATUS_MAP: Record<ErrorCode, number> = {
|
||||
// Client errors (4xx)
|
||||
[ErrorCode.BAD_REQUEST]: 400,
|
||||
[ErrorCode.UNAUTHORIZED]: 401,
|
||||
[ErrorCode.FORBIDDEN]: 403,
|
||||
[ErrorCode.NOT_FOUND]: 404,
|
||||
[ErrorCode.METHOD_NOT_ALLOWED]: 405,
|
||||
[ErrorCode.VALIDATION_ERROR]: 422,
|
||||
[ErrorCode.RATE_LIMIT_EXCEEDED]: 429,
|
||||
|
||||
// Server errors (5xx)
|
||||
[ErrorCode.INTERNAL_SERVER_ERROR]: 500,
|
||||
[ErrorCode.DATABASE_ERROR]: 500,
|
||||
[ErrorCode.SERVICE_UNAVAILABLE]: 503,
|
||||
|
||||
// Custom application errors (5xx)
|
||||
[ErrorCode.MIGRATION_ERROR]: 500,
|
||||
[ErrorCode.PERMISSION_DENIED]: 403,
|
||||
[ErrorCode.RESOURCE_LOCKED]: 423,
|
||||
[ErrorCode.CONCURRENT_MODIFICATION]: 409
|
||||
}
|
||||
|
||||
/**
|
||||
* Default error messages for each error code
|
||||
*/
|
||||
export const ERROR_MESSAGES: Record<ErrorCode, string> = {
|
||||
[ErrorCode.BAD_REQUEST]: 'Bad request: Invalid request format or parameters',
|
||||
[ErrorCode.UNAUTHORIZED]: 'Unauthorized: Authentication required',
|
||||
[ErrorCode.FORBIDDEN]: 'Forbidden: Insufficient permissions',
|
||||
[ErrorCode.NOT_FOUND]: 'Not found: Requested resource does not exist',
|
||||
[ErrorCode.METHOD_NOT_ALLOWED]: 'Method not allowed: HTTP method not supported for this endpoint',
|
||||
[ErrorCode.VALIDATION_ERROR]: 'Validation error: Request data does not meet requirements',
|
||||
[ErrorCode.RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded: Too many requests',
|
||||
|
||||
[ErrorCode.INTERNAL_SERVER_ERROR]: 'Internal server error: An unexpected error occurred',
|
||||
[ErrorCode.DATABASE_ERROR]: 'Database error: Failed to access or modify data',
|
||||
[ErrorCode.SERVICE_UNAVAILABLE]: 'Service unavailable: The service is temporarily unavailable',
|
||||
|
||||
[ErrorCode.MIGRATION_ERROR]: 'Migration error: Failed to migrate data',
|
||||
[ErrorCode.PERMISSION_DENIED]: 'Permission denied: Operation not allowed for current user',
|
||||
[ErrorCode.RESOURCE_LOCKED]: 'Resource locked: Resource is currently locked by another operation',
|
||||
[ErrorCode.CONCURRENT_MODIFICATION]: 'Concurrent modification: Resource was modified by another user'
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility class for creating standardized Data API errors
|
||||
*/
|
||||
export class DataApiErrorFactory {
|
||||
/**
|
||||
* Create a DataApiError with standard properties
|
||||
*/
|
||||
static create(code: ErrorCode, customMessage?: string, details?: any, stack?: string): DataApiError {
|
||||
return {
|
||||
code,
|
||||
message: customMessage || ERROR_MESSAGES[code],
|
||||
status: ERROR_STATUS_MAP[code],
|
||||
details,
|
||||
stack: process.env.NODE_ENV === 'development' ? stack : undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a validation error with field-specific details
|
||||
*/
|
||||
static validation(fieldErrors: Record<string, string[]>, message?: string): DataApiError {
|
||||
return this.create(ErrorCode.VALIDATION_ERROR, message || 'Request validation failed', { fieldErrors })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a not found error for specific resource
|
||||
*/
|
||||
static notFound(resource: string, id?: string): DataApiError {
|
||||
const message = id ? `${resource} with id '${id}' not found` : `${resource} not found`
|
||||
|
||||
return this.create(ErrorCode.NOT_FOUND, message, { resource, id })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a database error with query details
|
||||
*/
|
||||
static database(originalError: Error, operation?: string): DataApiError {
|
||||
return this.create(
|
||||
ErrorCode.DATABASE_ERROR,
|
||||
`Database operation failed${operation ? `: ${operation}` : ''}`,
|
||||
{
|
||||
originalError: originalError.message,
|
||||
operation
|
||||
},
|
||||
originalError.stack
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a permission denied error
|
||||
*/
|
||||
static permissionDenied(action: string, resource?: string): DataApiError {
|
||||
const message = resource ? `Permission denied: Cannot ${action} ${resource}` : `Permission denied: Cannot ${action}`
|
||||
|
||||
return this.create(ErrorCode.PERMISSION_DENIED, message, { action, resource })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an internal server error from an unexpected error
|
||||
*/
|
||||
static internal(originalError: Error, context?: string): DataApiError {
|
||||
const message = context
|
||||
? `Internal error in ${context}: ${originalError.message}`
|
||||
: `Internal error: ${originalError.message}`
|
||||
|
||||
return this.create(
|
||||
ErrorCode.INTERNAL_SERVER_ERROR,
|
||||
message,
|
||||
{ originalError: originalError.message, context },
|
||||
originalError.stack
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a rate limit exceeded error
|
||||
*/
|
||||
static rateLimit(limit: number, windowMs: number): DataApiError {
|
||||
return this.create(ErrorCode.RATE_LIMIT_EXCEEDED, `Rate limit exceeded: ${limit} requests per ${windowMs}ms`, {
|
||||
limit,
|
||||
windowMs
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a resource locked error
|
||||
*/
|
||||
static resourceLocked(resource: string, id: string, lockedBy?: string): DataApiError {
|
||||
const message = lockedBy
|
||||
? `${resource} '${id}' is locked by ${lockedBy}`
|
||||
: `${resource} '${id}' is currently locked`
|
||||
|
||||
return this.create(ErrorCode.RESOURCE_LOCKED, message, { resource, id, lockedBy })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a concurrent modification error
|
||||
*/
|
||||
static concurrentModification(resource: string, id: string): DataApiError {
|
||||
return this.create(ErrorCode.CONCURRENT_MODIFICATION, `${resource} '${id}' was modified by another user`, {
|
||||
resource,
|
||||
id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a Data API error
|
||||
*/
|
||||
export function isDataApiError(error: any): error is DataApiError {
|
||||
return (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
typeof error.code === 'string' &&
|
||||
typeof error.message === 'string' &&
|
||||
typeof error.status === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a generic error to a DataApiError
|
||||
*/
|
||||
export function toDataApiError(error: unknown, context?: string): DataApiError {
|
||||
if (isDataApiError(error)) {
|
||||
return error
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return DataApiErrorFactory.internal(error, context)
|
||||
}
|
||||
|
||||
return DataApiErrorFactory.create(
|
||||
ErrorCode.INTERNAL_SERVER_ERROR,
|
||||
`Unknown error${context ? ` in ${context}` : ''}: ${String(error)}`,
|
||||
{ originalError: error, context }
|
||||
)
|
||||
}
|
||||
121
packages/shared/data/api/index.ts
Normal file
121
packages/shared/data/api/index.ts
Normal file
@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Cherry Studio Data API - Barrel Exports
|
||||
*
|
||||
* This file provides a centralized entry point for all data API types,
|
||||
* schemas, and utilities. Import everything you need from this single location.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Topic, CreateTopicDto, ApiSchemas, DataRequest, ErrorCode } from '@/shared/data'
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Core data API types and infrastructure
|
||||
export type {
|
||||
BatchRequest,
|
||||
BatchResponse,
|
||||
CacheOptions,
|
||||
DataApiError,
|
||||
DataRequest,
|
||||
DataResponse,
|
||||
HttpMethod,
|
||||
Middleware,
|
||||
PaginatedResponse,
|
||||
PaginationParams,
|
||||
RequestContext,
|
||||
ServiceOptions,
|
||||
ServiceResult,
|
||||
SubscriptionCallback,
|
||||
SubscriptionOptions,
|
||||
TransactionRequest
|
||||
} from './apiTypes'
|
||||
export { ErrorCode, SubscriptionEvent } from './apiTypes'
|
||||
|
||||
// Domain models and DTOs
|
||||
export type {
|
||||
BulkOperationRequest,
|
||||
BulkOperationResponse,
|
||||
CreateTestItemDto,
|
||||
TestItem,
|
||||
UpdateTestItemDto
|
||||
} from './apiModels'
|
||||
|
||||
// API schema definitions and type helpers
|
||||
export type {
|
||||
ApiBody,
|
||||
ApiClient,
|
||||
ApiMethods,
|
||||
ApiParams,
|
||||
ApiPaths,
|
||||
ApiQuery,
|
||||
ApiResponse,
|
||||
ApiSchemas
|
||||
} from './apiSchemas'
|
||||
|
||||
// Path type utilities for template literal types
|
||||
export type {
|
||||
BodyForPath,
|
||||
ConcreteApiPaths,
|
||||
MatchApiPath,
|
||||
QueryParamsForPath,
|
||||
ResolvedPath,
|
||||
ResponseForPath
|
||||
} from './apiPaths'
|
||||
|
||||
// Error handling utilities
|
||||
export {
|
||||
ErrorCode as DataApiErrorCode,
|
||||
DataApiErrorFactory,
|
||||
ERROR_MESSAGES,
|
||||
ERROR_STATUS_MAP,
|
||||
isDataApiError,
|
||||
toDataApiError
|
||||
} from './errorCodes'
|
||||
|
||||
/**
|
||||
* Re-export commonly used type combinations for convenience
|
||||
*/
|
||||
|
||||
// Import types for re-export convenience types
|
||||
import type { CreateTestItemDto, TestItem, UpdateTestItemDto } from './apiModels'
|
||||
import type {
|
||||
BatchRequest,
|
||||
BatchResponse,
|
||||
DataApiError,
|
||||
DataRequest,
|
||||
DataResponse,
|
||||
ErrorCode,
|
||||
PaginatedResponse,
|
||||
PaginationParams,
|
||||
TransactionRequest
|
||||
} from './apiTypes'
|
||||
import type { DataApiErrorFactory } from './errorCodes'
|
||||
|
||||
/** All test item-related types */
|
||||
export type TestItemTypes = {
|
||||
TestItem: TestItem
|
||||
CreateTestItemDto: CreateTestItemDto
|
||||
UpdateTestItemDto: UpdateTestItemDto
|
||||
}
|
||||
|
||||
/** All error-related types and utilities */
|
||||
export type ErrorTypes = {
|
||||
DataApiError: DataApiError
|
||||
ErrorCode: ErrorCode
|
||||
ErrorFactory: typeof DataApiErrorFactory
|
||||
}
|
||||
|
||||
/** All request/response types */
|
||||
export type RequestTypes = {
|
||||
DataRequest: DataRequest
|
||||
DataResponse: DataResponse
|
||||
BatchRequest: BatchRequest
|
||||
BatchResponse: BatchResponse
|
||||
TransactionRequest: TransactionRequest
|
||||
}
|
||||
|
||||
/** All pagination-related types */
|
||||
export type PaginationTypes = {
|
||||
PaginationParams: PaginationParams
|
||||
PaginatedResponse: PaginatedResponse<any>
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
/**
|
||||
* 数据重构,临时存放的默认值,需在全部完成后重新整理,并整理到preferences.ts中
|
||||
*/
|
||||
|
||||
import type { SelectionActionItem } from './preferenceTypes'
|
||||
|
||||
export const defaultActionItems: SelectionActionItem[] = [
|
||||
{ id: 'translate', name: 'selection.action.builtin.translate', enabled: true, isBuiltIn: true, icon: 'languages' },
|
||||
{ id: 'explain', name: 'selection.action.builtin.explain', enabled: true, isBuiltIn: true, icon: 'file-question' },
|
||||
{ id: 'summary', name: 'selection.action.builtin.summary', enabled: true, isBuiltIn: true, icon: 'scan-text' },
|
||||
{
|
||||
id: 'search',
|
||||
name: 'selection.action.builtin.search',
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
icon: 'search',
|
||||
searchEngine: 'Google|https://www.google.com/search?q={{queryString}}'
|
||||
},
|
||||
{ id: 'copy', name: 'selection.action.builtin.copy', enabled: true, isBuiltIn: true, icon: 'clipboard-copy' },
|
||||
{ id: 'refine', name: 'selection.action.builtin.refine', enabled: false, isBuiltIn: true, icon: 'wand-sparkles' },
|
||||
{ id: 'quote', name: 'selection.action.builtin.quote', enabled: false, isBuiltIn: true, icon: 'quote' }
|
||||
]
|
||||
128
src/main/data/DataApiService.ts
Normal file
128
src/main/data/DataApiService.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { loggerService } from '@logger'
|
||||
|
||||
import { ApiServer, IpcAdapter } from './api'
|
||||
import { apiHandlers } from './api/handlers'
|
||||
|
||||
const logger = loggerService.withContext('DataApiService')
|
||||
|
||||
/**
|
||||
* Data API service for Electron environment
|
||||
* Coordinates the API server and IPC adapter
|
||||
*/
|
||||
class DataApiService {
|
||||
private static instance: DataApiService
|
||||
private initialized = false
|
||||
private apiServer: ApiServer
|
||||
private ipcAdapter: IpcAdapter
|
||||
|
||||
private constructor() {
|
||||
// Initialize ApiServer with handlers
|
||||
this.apiServer = ApiServer.initialize(apiHandlers)
|
||||
this.ipcAdapter = new IpcAdapter(this.apiServer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
public static getInstance(): DataApiService {
|
||||
if (!DataApiService.instance) {
|
||||
DataApiService.instance = new DataApiService()
|
||||
}
|
||||
return DataApiService.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Data API system
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
logger.warn('DataApiService already initialized')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('Initializing Data API system...')
|
||||
|
||||
// API handlers are already registered during ApiServer initialization
|
||||
logger.debug('API handlers initialized with type-safe routing')
|
||||
|
||||
// Setup IPC handlers
|
||||
this.ipcAdapter.setupHandlers()
|
||||
|
||||
this.initialized = true
|
||||
logger.info('Data API system initialized successfully')
|
||||
|
||||
// Log system info
|
||||
this.logSystemInfo()
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize Data API system', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log system information for debugging
|
||||
*/
|
||||
private logSystemInfo(): void {
|
||||
const systemInfo = this.apiServer.getSystemInfo()
|
||||
|
||||
logger.info('Data API system ready', {
|
||||
server: systemInfo.server,
|
||||
version: systemInfo.version,
|
||||
handlers: systemInfo.handlers,
|
||||
middlewares: systemInfo.middlewares
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system status and statistics
|
||||
*/
|
||||
public getSystemStatus() {
|
||||
if (!this.initialized) {
|
||||
return {
|
||||
initialized: false,
|
||||
error: 'DataApiService not initialized'
|
||||
}
|
||||
}
|
||||
|
||||
const systemInfo = this.apiServer.getSystemInfo()
|
||||
|
||||
return {
|
||||
initialized: true,
|
||||
ipcInitialized: this.ipcAdapter.isInitialized(),
|
||||
...systemInfo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API server instance (for advanced usage)
|
||||
*/
|
||||
public getApiServer(): ApiServer {
|
||||
return this.apiServer
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the Data API system
|
||||
*/
|
||||
public async shutdown(): Promise<void> {
|
||||
if (!this.initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('Shutting down Data API system...')
|
||||
|
||||
// Remove IPC handlers
|
||||
this.ipcAdapter.removeHandlers()
|
||||
|
||||
this.initialized = false
|
||||
logger.info('Data API system shutdown complete')
|
||||
} catch (error) {
|
||||
logger.error('Error during Data API shutdown', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const dataApiService = DataApiService.getInstance()
|
||||
264
src/main/data/api/core/ApiServer.ts
Normal file
264
src/main/data/api/core/ApiServer.ts
Normal file
@ -0,0 +1,264 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { ApiImplementation } from '@shared/data/api/apiSchemas'
|
||||
import type { DataRequest, DataResponse, HttpMethod, RequestContext } from '@shared/data/api/apiTypes'
|
||||
import { DataApiErrorFactory, ErrorCode } from '@shared/data/api/errorCodes'
|
||||
|
||||
import { MiddlewareEngine } from './MiddlewareEngine'
|
||||
|
||||
// Handler function type
|
||||
type HandlerFunction = (params: { params?: Record<string, string>; query?: any; body?: any }) => Promise<any>
|
||||
|
||||
const logger = loggerService.withContext('DataApiServer')
|
||||
|
||||
/**
|
||||
* Core API Server - Transport agnostic request processor
|
||||
* Now uses direct handler mapping for type-safe routing
|
||||
*/
|
||||
export class ApiServer {
|
||||
private static instance: ApiServer
|
||||
private middlewareEngine: MiddlewareEngine
|
||||
private handlers: ApiImplementation
|
||||
|
||||
private constructor(handlers: ApiImplementation) {
|
||||
this.middlewareEngine = new MiddlewareEngine()
|
||||
this.handlers = handlers
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize singleton instance with handlers
|
||||
*/
|
||||
public static initialize(handlers: ApiImplementation): ApiServer {
|
||||
if (ApiServer.instance) {
|
||||
throw new Error('ApiServer already initialized')
|
||||
}
|
||||
ApiServer.instance = new ApiServer(handlers)
|
||||
return ApiServer.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
public static getInstance(): ApiServer {
|
||||
if (!ApiServer.instance) {
|
||||
throw new Error('ApiServer not initialized. Call initialize() first.')
|
||||
}
|
||||
return ApiServer.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Register middleware
|
||||
*/
|
||||
use(middleware: any): void {
|
||||
this.middlewareEngine.use(middleware)
|
||||
}
|
||||
|
||||
/**
|
||||
* Main request handler - direct handler lookup
|
||||
*/
|
||||
async handleRequest(request: DataRequest): Promise<DataResponse> {
|
||||
const { method, path } = request
|
||||
const startTime = Date.now()
|
||||
|
||||
logger.debug(`Processing request: ${method} ${path}`)
|
||||
|
||||
try {
|
||||
// Find handler
|
||||
const handlerMatch = this.findHandler(path, method as HttpMethod)
|
||||
|
||||
if (!handlerMatch) {
|
||||
throw DataApiErrorFactory.create(ErrorCode.NOT_FOUND, `Handler not found: ${method} ${path}`)
|
||||
}
|
||||
|
||||
// Create request context
|
||||
const requestContext = this.createRequestContext(request, path, method as HttpMethod)
|
||||
|
||||
// Execute middleware chain
|
||||
await this.middlewareEngine.executeMiddlewares(requestContext)
|
||||
|
||||
// Execute handler if middleware didn't set error
|
||||
if (!requestContext.response.error) {
|
||||
await this.executeHandler(requestContext, handlerMatch)
|
||||
}
|
||||
|
||||
// Set timing metadata
|
||||
requestContext.response.metadata = {
|
||||
...requestContext.response.metadata,
|
||||
duration: Date.now() - startTime,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
return requestContext.response
|
||||
} catch (error) {
|
||||
logger.error(`Request handling failed: ${method} ${path}`, error as Error)
|
||||
|
||||
const apiError = DataApiErrorFactory.create(ErrorCode.INTERNAL_SERVER_ERROR, (error as Error).message)
|
||||
|
||||
return {
|
||||
id: request.id,
|
||||
status: apiError.status,
|
||||
error: apiError,
|
||||
metadata: {
|
||||
duration: Date.now() - startTime,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle batch requests
|
||||
*/
|
||||
async handleBatchRequest(batchRequest: DataRequest): Promise<DataResponse> {
|
||||
const requests = batchRequest.body?.requests || []
|
||||
|
||||
if (!Array.isArray(requests)) {
|
||||
throw DataApiErrorFactory.create(ErrorCode.VALIDATION_ERROR, 'Batch request body must contain requests array')
|
||||
}
|
||||
|
||||
logger.debug(`Processing batch request with ${requests.length} requests`)
|
||||
|
||||
// Use the batch handler from our handlers
|
||||
const batchHandler = this.handlers['/batch']?.POST
|
||||
if (!batchHandler) {
|
||||
throw DataApiErrorFactory.create(ErrorCode.NOT_FOUND, 'Batch handler not found')
|
||||
}
|
||||
|
||||
const result = await batchHandler({ body: batchRequest.body })
|
||||
|
||||
return {
|
||||
id: batchRequest.id,
|
||||
status: 200,
|
||||
data: result,
|
||||
metadata: {
|
||||
duration: 0,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find handler for given path and method
|
||||
*/
|
||||
private findHandler(
|
||||
path: string,
|
||||
method: HttpMethod
|
||||
): { handler: HandlerFunction; params: Record<string, string> } | null {
|
||||
// Direct lookup first
|
||||
const directHandler = (this.handlers as any)[path]?.[method]
|
||||
if (directHandler) {
|
||||
return { handler: directHandler, params: {} }
|
||||
}
|
||||
|
||||
// Pattern matching for parameterized paths
|
||||
for (const [pattern, methods] of Object.entries(this.handlers)) {
|
||||
if (pattern.includes(':') && (methods as any)[method]) {
|
||||
const params = this.extractPathParams(pattern, path)
|
||||
if (params !== null) {
|
||||
return { handler: (methods as any)[method], params }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path parameters from URL
|
||||
*/
|
||||
private extractPathParams(pattern: string, path: string): Record<string, string> | null {
|
||||
const patternParts = pattern.split('/')
|
||||
const pathParts = path.split('/')
|
||||
|
||||
if (patternParts.length !== pathParts.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {}
|
||||
|
||||
for (let i = 0; i < patternParts.length; i++) {
|
||||
if (patternParts[i].startsWith(':')) {
|
||||
const paramName = patternParts[i].slice(1)
|
||||
params[paramName] = pathParts[i]
|
||||
} else if (patternParts[i] !== pathParts[i]) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* Create request context
|
||||
*/
|
||||
private createRequestContext(request: DataRequest, path: string, method: HttpMethod): RequestContext {
|
||||
const response: DataResponse = {
|
||||
id: request.id,
|
||||
status: 200
|
||||
}
|
||||
|
||||
return {
|
||||
request,
|
||||
response,
|
||||
path,
|
||||
method,
|
||||
data: new Map()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute handler function
|
||||
*/
|
||||
private async executeHandler(
|
||||
context: RequestContext,
|
||||
handlerMatch: { handler: HandlerFunction; params: Record<string, string> }
|
||||
): Promise<void> {
|
||||
const { request, response } = context
|
||||
const { handler, params } = handlerMatch
|
||||
|
||||
try {
|
||||
// Prepare handler parameters
|
||||
const handlerParams = {
|
||||
params,
|
||||
query: request.params, // URL query parameters
|
||||
body: request.body
|
||||
}
|
||||
|
||||
// Execute handler
|
||||
const result = await handler(handlerParams)
|
||||
|
||||
// Set response data
|
||||
if (result !== undefined) {
|
||||
response.data = result
|
||||
}
|
||||
|
||||
if (!response.status) {
|
||||
response.status = 200
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Handler execution failed', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system information
|
||||
*/
|
||||
getSystemInfo() {
|
||||
const handlerPaths = Object.keys(this.handlers)
|
||||
const handlerCount = handlerPaths.reduce((count, path) => {
|
||||
return count + Object.keys((this.handlers as any)[path]).length
|
||||
}, 0)
|
||||
|
||||
const middlewares = this.middlewareEngine.getMiddlewares()
|
||||
|
||||
return {
|
||||
server: 'DataApiServer',
|
||||
version: '2.0',
|
||||
handlers: {
|
||||
paths: handlerPaths,
|
||||
total: handlerCount
|
||||
},
|
||||
middlewares: middlewares
|
||||
}
|
||||
}
|
||||
}
|
||||
177
src/main/data/api/core/MiddlewareEngine.ts
Normal file
177
src/main/data/api/core/MiddlewareEngine.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { DataRequest, DataResponse, Middleware, RequestContext } from '@shared/data/api/apiTypes'
|
||||
import { toDataApiError } from '@shared/data/api/errorCodes'
|
||||
|
||||
const logger = loggerService.withContext('MiddlewareEngine')
|
||||
|
||||
/**
|
||||
* Middleware engine for executing middleware chains
|
||||
* Extracted from ResponseService to support reusability
|
||||
*/
|
||||
export class MiddlewareEngine {
|
||||
private middlewares = new Map<string, Middleware>()
|
||||
private middlewareOrder: string[] = []
|
||||
|
||||
constructor() {
|
||||
this.setupDefaultMiddlewares()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register middleware
|
||||
*/
|
||||
use(middleware: Middleware): void {
|
||||
this.middlewares.set(middleware.name, middleware)
|
||||
|
||||
// Insert based on priority
|
||||
const priority = middleware.priority || 50
|
||||
let insertIndex = 0
|
||||
|
||||
for (let i = 0; i < this.middlewareOrder.length; i++) {
|
||||
const existingMiddleware = this.middlewares.get(this.middlewareOrder[i])
|
||||
const existingPriority = existingMiddleware?.priority || 50
|
||||
|
||||
if (priority < existingPriority) {
|
||||
insertIndex = i
|
||||
break
|
||||
}
|
||||
insertIndex = i + 1
|
||||
}
|
||||
|
||||
this.middlewareOrder.splice(insertIndex, 0, middleware.name)
|
||||
|
||||
logger.debug(`Registered middleware: ${middleware.name} (priority: ${priority})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute middleware chain
|
||||
*/
|
||||
async executeMiddlewares(context: RequestContext, middlewareNames: string[] = this.middlewareOrder): Promise<void> {
|
||||
let index = 0
|
||||
|
||||
const next = async (): Promise<void> => {
|
||||
if (index >= middlewareNames.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const middlewareName = middlewareNames[index++]
|
||||
const middleware = this.middlewares.get(middlewareName)
|
||||
|
||||
if (!middleware) {
|
||||
logger.warn(`Middleware not found: ${middlewareName}`)
|
||||
return next()
|
||||
}
|
||||
|
||||
await middleware.execute(context.request, context.response, next)
|
||||
}
|
||||
|
||||
await next()
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup default middlewares
|
||||
*/
|
||||
private setupDefaultMiddlewares(): void {
|
||||
// Error handling middleware (should be first)
|
||||
this.use({
|
||||
name: 'error-handler',
|
||||
priority: 0,
|
||||
execute: async (req: DataRequest, res: DataResponse, next: () => Promise<void>) => {
|
||||
try {
|
||||
await next()
|
||||
} catch (error) {
|
||||
logger.error(`Request error: ${req.method} ${req.path}`, error as Error)
|
||||
|
||||
const apiError = toDataApiError(error, `${req.method} ${req.path}`)
|
||||
res.error = apiError
|
||||
res.status = apiError.status
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Request logging middleware
|
||||
this.use({
|
||||
name: 'request-logger',
|
||||
priority: 10,
|
||||
execute: async (req: DataRequest, res: DataResponse, next: () => Promise<void>) => {
|
||||
const startTime = Date.now()
|
||||
|
||||
logger.debug(`Incoming request: ${req.method} ${req.path}`, {
|
||||
id: req.id,
|
||||
params: req.params,
|
||||
body: req.body
|
||||
})
|
||||
|
||||
await next()
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
res.metadata = {
|
||||
...res.metadata,
|
||||
duration,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
logger.debug(`Request completed: ${req.method} ${req.path}`, {
|
||||
id: req.id,
|
||||
status: res.status,
|
||||
duration
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Response formatting middleware (should be last)
|
||||
this.use({
|
||||
name: 'response-formatter',
|
||||
priority: 100,
|
||||
execute: async (_req: DataRequest, res: DataResponse, next: () => Promise<void>) => {
|
||||
await next()
|
||||
|
||||
// Ensure response always has basic structure
|
||||
if (!res.status) {
|
||||
res.status = 200
|
||||
}
|
||||
|
||||
if (!res.metadata) {
|
||||
res.metadata = {
|
||||
duration: 0,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all middleware names in execution order
|
||||
*/
|
||||
getMiddlewares(): string[] {
|
||||
return [...this.middlewareOrder]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get middleware by name
|
||||
*/
|
||||
getMiddleware(name: string): Middleware | undefined {
|
||||
return this.middlewares.get(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove middleware
|
||||
*/
|
||||
removeMiddleware(name: string): void {
|
||||
this.middlewares.delete(name)
|
||||
const index = this.middlewareOrder.indexOf(name)
|
||||
if (index > -1) {
|
||||
this.middlewareOrder.splice(index, 1)
|
||||
}
|
||||
logger.debug(`Removed middleware: ${name}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all middlewares
|
||||
*/
|
||||
clear(): void {
|
||||
this.middlewares.clear()
|
||||
this.middlewareOrder = []
|
||||
logger.debug('Cleared all middlewares')
|
||||
}
|
||||
}
|
||||
158
src/main/data/api/core/adapters/IpcAdapter.ts
Normal file
158
src/main/data/api/core/adapters/IpcAdapter.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { DataRequest, DataResponse } from '@shared/data/api/apiTypes'
|
||||
import { toDataApiError } from '@shared/data/api/errorCodes'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
import { ApiServer } from '../ApiServer'
|
||||
|
||||
const logger = loggerService.withContext('DataApiIpcAdapter')
|
||||
|
||||
/**
|
||||
* IPC Adapter for Electron environment
|
||||
* Handles IPC communication and forwards requests to ApiServer
|
||||
*/
|
||||
export class IpcAdapter {
|
||||
private initialized = false
|
||||
|
||||
constructor(private apiServer: ApiServer) {}
|
||||
|
||||
/**
|
||||
* Setup IPC handlers
|
||||
*/
|
||||
setupHandlers(): void {
|
||||
if (this.initialized) {
|
||||
logger.warn('IPC handlers already initialized')
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug('Setting up IPC handlers...')
|
||||
|
||||
// Main data request handler
|
||||
ipcMain.handle(IpcChannel.DataApi_Request, async (event, request: DataRequest): Promise<DataResponse> => {
|
||||
try {
|
||||
logger.debug(`Handling data request: ${request.method} ${request.path}`, {
|
||||
id: request.id,
|
||||
params: request.params
|
||||
})
|
||||
|
||||
const response = await this.apiServer.handleRequest(request)
|
||||
|
||||
// Send response back via Data_Response channel for async handling
|
||||
event.sender.send(IpcChannel.DataApi_Response, response)
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error(`Data request failed: ${request.method} ${request.path}`, error as Error)
|
||||
|
||||
const apiError = toDataApiError(error, `${request.method} ${request.path}`)
|
||||
const errorResponse: DataResponse = {
|
||||
id: request.id,
|
||||
status: apiError.status,
|
||||
error: apiError,
|
||||
metadata: {
|
||||
duration: 0,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
// Send error response
|
||||
event.sender.send(IpcChannel.DataApi_Response, errorResponse)
|
||||
|
||||
return errorResponse
|
||||
}
|
||||
})
|
||||
|
||||
// Batch request handler
|
||||
ipcMain.handle(IpcChannel.DataApi_Batch, async (_event, batchRequest: DataRequest): Promise<DataResponse> => {
|
||||
try {
|
||||
logger.debug('Handling batch request', { requestCount: batchRequest.body?.requests?.length })
|
||||
|
||||
const response = await this.apiServer.handleBatchRequest(batchRequest)
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error('Batch request failed', error as Error)
|
||||
|
||||
const apiError = toDataApiError(error, 'batch request')
|
||||
return {
|
||||
id: batchRequest.id,
|
||||
status: apiError.status,
|
||||
error: apiError,
|
||||
metadata: {
|
||||
duration: 0,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Transaction handler (placeholder)
|
||||
ipcMain.handle(
|
||||
IpcChannel.DataApi_Transaction,
|
||||
async (_event, transactionRequest: DataRequest): Promise<DataResponse> => {
|
||||
try {
|
||||
logger.debug('Handling transaction request')
|
||||
|
||||
// TODO: Implement transaction support
|
||||
throw new Error('Transaction support not yet implemented')
|
||||
} catch (error) {
|
||||
logger.error('Transaction request failed', error as Error)
|
||||
|
||||
const apiError = toDataApiError(error, 'transaction request')
|
||||
return {
|
||||
id: transactionRequest.id,
|
||||
status: apiError.status,
|
||||
error: apiError,
|
||||
metadata: {
|
||||
duration: 0,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Subscription handlers (placeholder for future real-time features)
|
||||
ipcMain.handle(IpcChannel.DataApi_Subscribe, async (_event, path: string) => {
|
||||
logger.debug(`Data subscription request: ${path}`)
|
||||
// TODO: Implement real-time subscriptions
|
||||
return { success: true, subscriptionId: `sub_${Date.now()}` }
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.DataApi_Unsubscribe, async (_event, subscriptionId: string) => {
|
||||
logger.debug(`Data unsubscription request: ${subscriptionId}`)
|
||||
// TODO: Implement real-time subscriptions
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
this.initialized = true
|
||||
logger.debug('IPC handlers setup complete')
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove IPC handlers
|
||||
*/
|
||||
removeHandlers(): void {
|
||||
if (!this.initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug('Removing IPC handlers...')
|
||||
|
||||
ipcMain.removeHandler(IpcChannel.DataApi_Request)
|
||||
ipcMain.removeHandler(IpcChannel.DataApi_Batch)
|
||||
ipcMain.removeHandler(IpcChannel.DataApi_Transaction)
|
||||
ipcMain.removeHandler(IpcChannel.DataApi_Subscribe)
|
||||
ipcMain.removeHandler(IpcChannel.DataApi_Unsubscribe)
|
||||
|
||||
this.initialized = false
|
||||
logger.debug('IPC handlers removed')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if handlers are initialized
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return this.initialized
|
||||
}
|
||||
}
|
||||
211
src/main/data/api/handlers/index.ts
Normal file
211
src/main/data/api/handlers/index.ts
Normal file
@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Complete API handler implementation
|
||||
*
|
||||
* This file implements ALL endpoints defined in ApiSchemas.
|
||||
* TypeScript will error if any endpoint is missing.
|
||||
*/
|
||||
|
||||
import type { ApiImplementation } from '@shared/data/api/apiSchemas'
|
||||
|
||||
import { TestService } from '../services/TestService'
|
||||
|
||||
// Service instances
|
||||
const testService = TestService.getInstance()
|
||||
|
||||
/**
|
||||
* Complete API handlers implementation
|
||||
* Must implement every path+method combination from ApiSchemas
|
||||
*/
|
||||
export const apiHandlers: ApiImplementation = {
|
||||
'/test/items': {
|
||||
GET: async ({ query }) => {
|
||||
return await testService.getItems({
|
||||
page: (query as any)?.page,
|
||||
limit: (query as any)?.limit,
|
||||
search: (query as any)?.search,
|
||||
type: (query as any)?.type,
|
||||
status: (query as any)?.status
|
||||
})
|
||||
},
|
||||
|
||||
POST: async ({ body }) => {
|
||||
return await testService.createItem({
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
type: body.type,
|
||||
status: body.status,
|
||||
priority: body.priority,
|
||||
tags: body.tags,
|
||||
metadata: body.metadata
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
'/test/items/:id': {
|
||||
GET: async ({ params }) => {
|
||||
const item = await testService.getItemById(params.id)
|
||||
if (!item) {
|
||||
throw new Error(`Test item not found: ${params.id}`)
|
||||
}
|
||||
return item
|
||||
},
|
||||
|
||||
PUT: async ({ params, body }) => {
|
||||
const item = await testService.updateItem(params.id, {
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
type: body.type,
|
||||
status: body.status,
|
||||
priority: body.priority,
|
||||
tags: body.tags,
|
||||
metadata: body.metadata
|
||||
})
|
||||
if (!item) {
|
||||
throw new Error(`Test item not found: ${params.id}`)
|
||||
}
|
||||
return item
|
||||
},
|
||||
|
||||
DELETE: async ({ params }) => {
|
||||
const deleted = await testService.deleteItem(params.id)
|
||||
if (!deleted) {
|
||||
throw new Error(`Test item not found: ${params.id}`)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
|
||||
'/test/search': {
|
||||
GET: async ({ query }) => {
|
||||
return await testService.searchItems(query.query, {
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
filters: {
|
||||
type: query.type,
|
||||
status: query.status
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
'/test/stats': {
|
||||
GET: async () => {
|
||||
return await testService.getStats()
|
||||
}
|
||||
},
|
||||
|
||||
'/test/bulk': {
|
||||
POST: async ({ body }) => {
|
||||
return await testService.bulkOperation(body.operation, body.data)
|
||||
}
|
||||
},
|
||||
|
||||
'/test/error': {
|
||||
POST: async ({ body }) => {
|
||||
return await testService.simulateError(body.errorType)
|
||||
}
|
||||
},
|
||||
|
||||
'/test/slow': {
|
||||
POST: async ({ body }) => {
|
||||
const delay = body.delay
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
return {
|
||||
message: `Slow response completed after ${delay}ms`,
|
||||
delay,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'/test/reset': {
|
||||
POST: async () => {
|
||||
await testService.resetData()
|
||||
return {
|
||||
message: 'Test data reset successfully',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'/test/config': {
|
||||
GET: async () => {
|
||||
return {
|
||||
environment: 'test',
|
||||
version: '1.0.0',
|
||||
debug: true,
|
||||
features: {
|
||||
bulkOperations: true,
|
||||
search: true,
|
||||
statistics: true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
PUT: async ({ body }) => {
|
||||
return {
|
||||
...body,
|
||||
updated: true,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'/test/status': {
|
||||
GET: async () => {
|
||||
return {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0.0',
|
||||
uptime: Math.floor(process.uptime()),
|
||||
environment: 'test'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'/test/performance': {
|
||||
GET: async () => {
|
||||
const memUsage = process.memoryUsage()
|
||||
return {
|
||||
requestsPerSecond: Math.floor(Math.random() * 100) + 50,
|
||||
averageLatency: Math.floor(Math.random() * 200) + 50,
|
||||
memoryUsage: memUsage.heapUsed / 1024 / 1024, // MB
|
||||
cpuUsage: Math.random() * 100,
|
||||
uptime: Math.floor(process.uptime())
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'/batch': {
|
||||
POST: async ({ body }) => {
|
||||
// Mock batch implementation - can be enhanced with actual batch processing
|
||||
const { requests } = body
|
||||
|
||||
const results = requests.map(() => ({
|
||||
status: 200,
|
||||
data: { processed: true, timestamp: new Date().toISOString() }
|
||||
}))
|
||||
|
||||
return {
|
||||
results,
|
||||
metadata: {
|
||||
duration: Math.floor(Math.random() * 500) + 100,
|
||||
successCount: requests.length,
|
||||
errorCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'/transaction': {
|
||||
POST: async ({ body }) => {
|
||||
// Mock transaction implementation - can be enhanced with actual transaction support
|
||||
const { operations } = body
|
||||
|
||||
return operations.map(() => ({
|
||||
status: 200,
|
||||
data: { executed: true, timestamp: new Date().toISOString() }
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/main/data/api/index.ts
Normal file
32
src/main/data/api/index.ts
Normal file
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* API Module - Unified entry point
|
||||
*
|
||||
* This module exports all necessary components for the Data API system
|
||||
* Designed to be portable and reusable in different environments
|
||||
*/
|
||||
|
||||
// Core components
|
||||
export { ApiServer } from './core/ApiServer'
|
||||
export { MiddlewareEngine } from './core/MiddlewareEngine'
|
||||
|
||||
// Adapters
|
||||
export { IpcAdapter } from './core/adapters/IpcAdapter'
|
||||
// export { HttpAdapter } from './core/adapters/HttpAdapter' // Future implementation
|
||||
|
||||
// Handlers (new type-safe system)
|
||||
export { apiHandlers } from './handlers'
|
||||
|
||||
// Services (still used by handlers)
|
||||
export { TestService } from './services/TestService'
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { CreateTestItemDto, TestItem, UpdateTestItemDto } from '@shared/data/api'
|
||||
export type {
|
||||
DataRequest,
|
||||
DataResponse,
|
||||
Middleware,
|
||||
PaginatedResponse,
|
||||
PaginationParams,
|
||||
RequestContext,
|
||||
ServiceOptions
|
||||
} from '@shared/data/api/apiTypes'
|
||||
108
src/main/data/api/services/IBaseService.ts
Normal file
108
src/main/data/api/services/IBaseService.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import type { PaginationParams, ServiceOptions } from '@shared/data/api/apiTypes'
|
||||
|
||||
/**
|
||||
* Standard service interface for data operations
|
||||
* Defines the contract that all services should implement
|
||||
*/
|
||||
export interface IBaseService<T = any, TCreate = any, TUpdate = any> {
|
||||
/**
|
||||
* Find entity by ID
|
||||
*/
|
||||
findById(id: string, options?: ServiceOptions): Promise<T | null>
|
||||
|
||||
/**
|
||||
* Find multiple entities with pagination
|
||||
*/
|
||||
findMany(
|
||||
params: PaginationParams & Record<string, any>,
|
||||
options?: ServiceOptions
|
||||
): Promise<{
|
||||
items: T[]
|
||||
total: number
|
||||
hasNext?: boolean
|
||||
nextCursor?: string
|
||||
}>
|
||||
|
||||
/**
|
||||
* Create new entity
|
||||
*/
|
||||
create(data: TCreate, options?: ServiceOptions): Promise<T>
|
||||
|
||||
/**
|
||||
* Update existing entity
|
||||
*/
|
||||
update(id: string, data: TUpdate, options?: ServiceOptions): Promise<T>
|
||||
|
||||
/**
|
||||
* Delete entity (hard or soft delete depending on implementation)
|
||||
*/
|
||||
delete(id: string, options?: ServiceOptions): Promise<void>
|
||||
|
||||
/**
|
||||
* Check if entity exists
|
||||
*/
|
||||
exists(id: string, options?: ServiceOptions): Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended service interface for soft delete operations
|
||||
*/
|
||||
export interface ISoftDeleteService<T = any, TCreate = any, TUpdate = any> extends IBaseService<T, TCreate, TUpdate> {
|
||||
/**
|
||||
* Archive entity (soft delete)
|
||||
*/
|
||||
archive(id: string, options?: ServiceOptions): Promise<T | void>
|
||||
|
||||
/**
|
||||
* Restore archived entity
|
||||
*/
|
||||
restore(id: string, options?: ServiceOptions): Promise<T | void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended service interface for search operations
|
||||
*/
|
||||
export interface ISearchableService<T = any, TCreate = any, TUpdate = any> extends IBaseService<T, TCreate, TUpdate> {
|
||||
/**
|
||||
* Search entities by query
|
||||
*/
|
||||
search(
|
||||
query: string,
|
||||
params?: PaginationParams,
|
||||
options?: ServiceOptions
|
||||
): Promise<{
|
||||
items: T[]
|
||||
total: number
|
||||
hasNext?: boolean
|
||||
nextCursor?: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Service interface for hierarchical data (parent-child relationships)
|
||||
*/
|
||||
export interface IHierarchicalService<TParent = any, TChild = any, TChildCreate = any> extends IBaseService<TParent> {
|
||||
/**
|
||||
* Get child entities for a parent
|
||||
*/
|
||||
getChildren(
|
||||
parentId: string,
|
||||
params?: PaginationParams,
|
||||
options?: ServiceOptions
|
||||
): Promise<{
|
||||
items: TChild[]
|
||||
total: number
|
||||
hasNext?: boolean
|
||||
nextCursor?: string
|
||||
}>
|
||||
|
||||
/**
|
||||
* Add child entity to parent
|
||||
*/
|
||||
addChild(parentId: string, childData: TChildCreate, options?: ServiceOptions): Promise<TChild>
|
||||
|
||||
/**
|
||||
* Remove child entity from parent
|
||||
*/
|
||||
removeChild(parentId: string, childId: string, options?: ServiceOptions): Promise<void>
|
||||
}
|
||||
452
src/main/data/api/services/TestService.ts
Normal file
452
src/main/data/api/services/TestService.ts
Normal file
@ -0,0 +1,452 @@
|
||||
import { loggerService } from '@logger'
|
||||
|
||||
const logger = loggerService.withContext('TestService')
|
||||
|
||||
/**
|
||||
* Test service for API testing scenarios
|
||||
* Provides mock data and various test cases for comprehensive API testing
|
||||
*/
|
||||
export class TestService {
|
||||
private static instance: TestService
|
||||
private testItems: any[] = []
|
||||
private nextId = 1
|
||||
|
||||
private constructor() {
|
||||
this.initializeMockData()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
public static getInstance(): TestService {
|
||||
if (!TestService.instance) {
|
||||
TestService.instance = new TestService()
|
||||
}
|
||||
return TestService.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize mock test data
|
||||
*/
|
||||
private initializeMockData() {
|
||||
// Initialize test items with various types
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
this.testItems.push({
|
||||
id: `test-item-${i}`,
|
||||
title: `Test Item ${i}`,
|
||||
description: `This is test item ${i} for comprehensive API testing`,
|
||||
type: ['data', 'config', 'user', 'system'][i % 4],
|
||||
status: ['active', 'inactive', 'pending', 'archived'][i % 4],
|
||||
priority: ['low', 'medium', 'high'][i % 3],
|
||||
tags: [`tag${(i % 3) + 1}`, `category${(i % 2) + 1}`],
|
||||
createdAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - i * 12 * 60 * 60 * 1000).toISOString(),
|
||||
metadata: {
|
||||
version: `1.${i % 10}.0`,
|
||||
size: Math.floor(Math.random() * 1000) + 100,
|
||||
author: `TestUser${(i % 5) + 1}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.nextId = 100
|
||||
logger.info('Mock test data initialized', {
|
||||
itemCount: this.testItems.length,
|
||||
types: ['data', 'config', 'user', 'system'],
|
||||
statuses: ['active', 'inactive', 'pending', 'archived']
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new test ID
|
||||
*/
|
||||
private generateId(prefix: string = 'test-item'): string {
|
||||
return `${prefix}-${this.nextId++}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate network delay for realistic testing
|
||||
*/
|
||||
private async simulateDelay(min = 100, max = 500): Promise<void> {
|
||||
const delay = Math.floor(Math.random() * (max - min + 1)) + min
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated list of test items
|
||||
*/
|
||||
async getItems(
|
||||
params: {
|
||||
page?: number
|
||||
limit?: number
|
||||
type?: string
|
||||
status?: string
|
||||
search?: string
|
||||
} = {}
|
||||
): Promise<{
|
||||
items: any[]
|
||||
total: number
|
||||
page: number
|
||||
pageCount: number
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
}> {
|
||||
await this.simulateDelay()
|
||||
|
||||
const { page = 1, limit = 20, type, status, search } = params
|
||||
let filteredItems = [...this.testItems]
|
||||
|
||||
// Apply filters
|
||||
if (type) {
|
||||
filteredItems = filteredItems.filter((item) => item.type === type)
|
||||
}
|
||||
if (status) {
|
||||
filteredItems = filteredItems.filter((item) => item.status === status)
|
||||
}
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase()
|
||||
filteredItems = filteredItems.filter(
|
||||
(item) => item.title.toLowerCase().includes(searchLower) || item.description.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
const startIndex = (page - 1) * limit
|
||||
const items = filteredItems.slice(startIndex, startIndex + limit)
|
||||
const total = filteredItems.length
|
||||
const pageCount = Math.ceil(total / limit)
|
||||
|
||||
logger.debug('Retrieved test items', {
|
||||
page,
|
||||
limit,
|
||||
filters: { type, status, search },
|
||||
total,
|
||||
itemCount: items.length
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageCount,
|
||||
hasNext: startIndex + limit < total,
|
||||
hasPrev: page > 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single test item by ID
|
||||
*/
|
||||
async getItemById(id: string): Promise<any | null> {
|
||||
await this.simulateDelay()
|
||||
|
||||
const item = this.testItems.find((item) => item.id === id)
|
||||
|
||||
if (!item) {
|
||||
logger.warn('Test item not found', { id })
|
||||
return null
|
||||
}
|
||||
|
||||
logger.debug('Retrieved test item by ID', { id, title: item.title })
|
||||
return item
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new test item
|
||||
*/
|
||||
async createItem(data: {
|
||||
title: string
|
||||
description?: string
|
||||
type?: string
|
||||
status?: string
|
||||
priority?: string
|
||||
tags?: string[]
|
||||
metadata?: Record<string, any>
|
||||
}): Promise<any> {
|
||||
await this.simulateDelay()
|
||||
|
||||
const newItem = {
|
||||
id: this.generateId(),
|
||||
title: data.title,
|
||||
description: data.description || '',
|
||||
type: data.type || 'data',
|
||||
status: data.status || 'active',
|
||||
priority: data.priority || 'medium',
|
||||
tags: data.tags || [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
size: Math.floor(Math.random() * 1000) + 100,
|
||||
author: 'TestUser',
|
||||
...data.metadata
|
||||
}
|
||||
}
|
||||
|
||||
this.testItems.unshift(newItem)
|
||||
logger.info('Created new test item', { id: newItem.id, title: newItem.title })
|
||||
|
||||
return newItem
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing test item
|
||||
*/
|
||||
async updateItem(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
title: string
|
||||
description: string
|
||||
type: string
|
||||
status: string
|
||||
priority: string
|
||||
tags: string[]
|
||||
metadata: Record<string, any>
|
||||
}>
|
||||
): Promise<any | null> {
|
||||
await this.simulateDelay()
|
||||
|
||||
const itemIndex = this.testItems.findIndex((item) => item.id === id)
|
||||
|
||||
if (itemIndex === -1) {
|
||||
logger.warn('Test item not found for update', { id })
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedItem = {
|
||||
...this.testItems[itemIndex],
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {
|
||||
...this.testItems[itemIndex].metadata,
|
||||
...data.metadata
|
||||
}
|
||||
}
|
||||
|
||||
this.testItems[itemIndex] = updatedItem
|
||||
logger.info('Updated test item', { id, changes: Object.keys(data) })
|
||||
|
||||
return updatedItem
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete test item
|
||||
*/
|
||||
async deleteItem(id: string): Promise<boolean> {
|
||||
await this.simulateDelay()
|
||||
|
||||
const itemIndex = this.testItems.findIndex((item) => item.id === id)
|
||||
|
||||
if (itemIndex === -1) {
|
||||
logger.warn('Test item not found for deletion', { id })
|
||||
return false
|
||||
}
|
||||
|
||||
this.testItems.splice(itemIndex, 1)
|
||||
logger.info('Deleted test item', { id })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get test statistics
|
||||
*/
|
||||
async getStats(): Promise<{
|
||||
total: number
|
||||
byType: Record<string, number>
|
||||
byStatus: Record<string, number>
|
||||
byPriority: Record<string, number>
|
||||
recentActivity: Array<{
|
||||
date: string
|
||||
count: number
|
||||
}>
|
||||
}> {
|
||||
await this.simulateDelay()
|
||||
|
||||
const byType: Record<string, number> = {}
|
||||
const byStatus: Record<string, number> = {}
|
||||
const byPriority: Record<string, number> = {}
|
||||
|
||||
this.testItems.forEach((item) => {
|
||||
byType[item.type] = (byType[item.type] || 0) + 1
|
||||
byStatus[item.status] = (byStatus[item.status] || 0) + 1
|
||||
byPriority[item.priority] = (byPriority[item.priority] || 0) + 1
|
||||
})
|
||||
|
||||
// Generate recent activity (mock data)
|
||||
const recentActivity: Array<{ date: string; count: number }> = []
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
recentActivity.push({
|
||||
date,
|
||||
count: Math.floor(Math.random() * 10) + 1
|
||||
})
|
||||
}
|
||||
|
||||
const stats = {
|
||||
total: this.testItems.length,
|
||||
byType,
|
||||
byStatus,
|
||||
byPriority,
|
||||
recentActivity
|
||||
}
|
||||
|
||||
logger.debug('Retrieved test statistics', stats)
|
||||
return stats
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk operations on test items
|
||||
*/
|
||||
async bulkOperation(
|
||||
operation: 'create' | 'update' | 'delete',
|
||||
data: any[]
|
||||
): Promise<{
|
||||
successful: number
|
||||
failed: number
|
||||
errors: string[]
|
||||
}> {
|
||||
await this.simulateDelay(200, 800)
|
||||
|
||||
let successful = 0
|
||||
let failed = 0
|
||||
const errors: string[] = []
|
||||
|
||||
for (const item of data) {
|
||||
try {
|
||||
switch (operation) {
|
||||
case 'create':
|
||||
await this.createItem(item)
|
||||
successful++
|
||||
break
|
||||
case 'update': {
|
||||
const updated = await this.updateItem(item.id, item)
|
||||
if (updated) successful++
|
||||
else {
|
||||
failed++
|
||||
errors.push(`Item not found: ${item.id}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'delete': {
|
||||
const deleted = await this.deleteItem(item.id)
|
||||
if (deleted) successful++
|
||||
else {
|
||||
failed++
|
||||
errors.push(`Item not found: ${item.id}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
failed++
|
||||
errors.push(`Error processing item: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Completed bulk operation', { operation, successful, failed, errorCount: errors.length })
|
||||
|
||||
return { successful, failed, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Search test items
|
||||
*/
|
||||
async searchItems(
|
||||
query: string,
|
||||
options: {
|
||||
page?: number
|
||||
limit?: number
|
||||
filters?: Record<string, any>
|
||||
} = {}
|
||||
): Promise<{
|
||||
items: any[]
|
||||
total: number
|
||||
page: number
|
||||
pageCount: number
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
}> {
|
||||
await this.simulateDelay()
|
||||
|
||||
const { page = 1, limit = 20, filters = {} } = options
|
||||
const queryLower = query.toLowerCase()
|
||||
|
||||
const results = this.testItems.filter((item) => {
|
||||
// Text search
|
||||
const matchesQuery =
|
||||
item.title.toLowerCase().includes(queryLower) ||
|
||||
item.description.toLowerCase().includes(queryLower) ||
|
||||
item.tags.some((tag: string) => tag.toLowerCase().includes(queryLower))
|
||||
|
||||
// Apply additional filters
|
||||
let matchesFilters = true
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value && item[key] !== value) {
|
||||
matchesFilters = false
|
||||
}
|
||||
})
|
||||
|
||||
return matchesQuery && matchesFilters
|
||||
})
|
||||
|
||||
// Apply pagination
|
||||
const startIndex = (page - 1) * limit
|
||||
const items = results.slice(startIndex, startIndex + limit)
|
||||
const total = results.length
|
||||
const pageCount = Math.ceil(total / limit)
|
||||
|
||||
logger.debug('Search completed', { query, total, itemCount: items.length })
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageCount,
|
||||
hasNext: startIndex + limit < total,
|
||||
hasPrev: page > 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate error scenarios for testing
|
||||
*/
|
||||
async simulateError(errorType: string): Promise<never> {
|
||||
await this.simulateDelay()
|
||||
|
||||
logger.warn('Simulating error scenario', { errorType })
|
||||
|
||||
switch (errorType) {
|
||||
case 'timeout':
|
||||
await new Promise((resolve) => setTimeout(resolve, 35000))
|
||||
throw new Error('Request timeout')
|
||||
case 'network':
|
||||
throw new Error('Network connection failed')
|
||||
case 'server':
|
||||
throw new Error('Internal server error (500)')
|
||||
case 'notfound':
|
||||
throw new Error('Resource not found (404)')
|
||||
case 'validation':
|
||||
throw new Error('Validation failed: Invalid input data')
|
||||
case 'unauthorized':
|
||||
throw new Error('Unauthorized access (401)')
|
||||
case 'ratelimit':
|
||||
throw new Error('Rate limit exceeded (429)')
|
||||
default:
|
||||
throw new Error('Generic test error occurred')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all test data to initial state
|
||||
*/
|
||||
async resetData(): Promise<void> {
|
||||
await this.simulateDelay()
|
||||
|
||||
this.testItems = []
|
||||
this.nextId = 1
|
||||
this.initializeMockData()
|
||||
|
||||
logger.info('Test data reset to initial state')
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@ import { loggerService } from '@logger'
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import { dbService } from '@data/db/DbService'
|
||||
import { preferenceService } from '@data/PreferenceService'
|
||||
import { dataApiService } from '@data/DataApiService'
|
||||
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
|
||||
import { app, dialog } from 'electron'
|
||||
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
@ -163,16 +164,19 @@ if (!app.requestSingleInstanceLock()) {
|
||||
|
||||
await preferenceService.initialize()
|
||||
|
||||
// // Create two test windows for cross-window preference sync testing
|
||||
// logger.info('Creating test windows for PreferenceService cross-window sync testing')
|
||||
// const testWindow1 = dataRefactorMigrateService.createTestWindow()
|
||||
// const testWindow2 = dataRefactorMigrateService.createTestWindow()
|
||||
// Initialize DataApiService
|
||||
await dataApiService.initialize()
|
||||
|
||||
// // Position windows to avoid overlap
|
||||
// testWindow1.once('ready-to-show', () => {
|
||||
// const [x, y] = testWindow1.getPosition()
|
||||
// testWindow2.setPosition(x + 50, y + 50)
|
||||
// })
|
||||
// Create two test windows for cross-window preference sync testing
|
||||
logger.info('Creating test windows for PreferenceService cross-window sync testing')
|
||||
const testWindow1 = dataRefactorMigrateService.createTestWindow()
|
||||
const testWindow2 = dataRefactorMigrateService.createTestWindow()
|
||||
|
||||
// Position windows to avoid overlap
|
||||
testWindow1.once('ready-to-show', () => {
|
||||
const [x, y] = testWindow1.getPosition()
|
||||
testWindow2.setPosition(x + 50, y + 50)
|
||||
})
|
||||
/************FOR TESTING ONLY END****************/
|
||||
|
||||
// Set app user model id for windows
|
||||
|
||||
@ -48,7 +48,7 @@ const DEFAULT_LEVEL = isDev ? LEVEL.SILLY : LEVEL.INFO
|
||||
* English: `docs/technical/how-to-use-logger-en.md`
|
||||
* Chinese: `docs/technical/how-to-use-logger-zh.md`
|
||||
*/
|
||||
class LoggerService {
|
||||
export class LoggerService {
|
||||
private static instance: LoggerService
|
||||
private logger: winston.Logger
|
||||
|
||||
|
||||
@ -451,6 +451,8 @@ const api = {
|
||||
}
|
||||
}
|
||||
},
|
||||
// PreferenceService related APIs
|
||||
// DO NOT MODIFY THIS SECTION
|
||||
preference: {
|
||||
get: <K extends PreferenceKeyType>(key: K): Promise<PreferenceDefaultScopeType[K]> =>
|
||||
ipcRenderer.invoke(IpcChannel.Preference_Get, key),
|
||||
@ -467,6 +469,23 @@ const api = {
|
||||
ipcRenderer.on(IpcChannel.Preference_Changed, listener)
|
||||
return () => ipcRenderer.off(IpcChannel.Preference_Changed, listener)
|
||||
}
|
||||
},
|
||||
// Data API related APIs
|
||||
dataApi: {
|
||||
request: (req: any) => ipcRenderer.invoke(IpcChannel.DataApi_Request, req),
|
||||
batch: (req: any) => ipcRenderer.invoke(IpcChannel.DataApi_Batch, req),
|
||||
transaction: (req: any) => ipcRenderer.invoke(IpcChannel.DataApi_Transaction, req),
|
||||
subscribe: (path: string, callback: (data: any, event: string) => void) => {
|
||||
const channel = `${IpcChannel.DataApi_Stream}:${path}`
|
||||
const listener = (_: any, data: any, event: string) => callback(data, event)
|
||||
ipcRenderer.on(channel, listener)
|
||||
return () => ipcRenderer.off(channel, listener)
|
||||
},
|
||||
onResponse: (callback: (response: any) => void) => {
|
||||
const listener = (_: any, response: any) => callback(response)
|
||||
ipcRenderer.on(IpcChannel.DataApi_Response, listener)
|
||||
return () => ipcRenderer.off(IpcChannel.DataApi_Response, listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
517
src/renderer/src/data/DataApiService.ts
Normal file
517
src/renderer/src/data/DataApiService.ts
Normal file
@ -0,0 +1,517 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { ApiClient, ConcreteApiPaths } from '@shared/data/api/apiSchemas'
|
||||
import type {
|
||||
BatchRequest,
|
||||
BatchResponse,
|
||||
DataRequest,
|
||||
DataResponse,
|
||||
HttpMethod,
|
||||
SubscriptionCallback,
|
||||
SubscriptionEvent,
|
||||
SubscriptionOptions,
|
||||
TransactionRequest
|
||||
} from '@shared/data/api/apiTypes'
|
||||
import { toDataApiError } from '@shared/data/api/errorCodes'
|
||||
|
||||
const logger = loggerService.withContext('DataApiService')
|
||||
|
||||
/**
|
||||
* Retry options interface
|
||||
*/
|
||||
interface RetryOptions {
|
||||
maxRetries: number
|
||||
retryDelay: number
|
||||
backoffMultiplier: number
|
||||
retryCondition: (error: Error) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Strongly-typed HTTP client for Data API
|
||||
* Simplified version using SWR for caching and request management
|
||||
* Focuses on IPC communication between renderer and main process
|
||||
*/
|
||||
export class DataApiService implements ApiClient {
|
||||
private static instance: DataApiService
|
||||
private requestId = 0
|
||||
private pendingRequests = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: DataResponse) => void
|
||||
reject: (error: Error) => void
|
||||
timeout?: NodeJS.Timeout
|
||||
abortController?: AbortController
|
||||
}
|
||||
>()
|
||||
|
||||
// Subscriptions
|
||||
private subscriptions = new Map<
|
||||
string,
|
||||
{
|
||||
callback: SubscriptionCallback
|
||||
options: SubscriptionOptions
|
||||
}
|
||||
>()
|
||||
|
||||
// Default retry options
|
||||
private defaultRetryOptions: RetryOptions = {
|
||||
maxRetries: 2,
|
||||
retryDelay: 1000,
|
||||
backoffMultiplier: 2,
|
||||
retryCondition: (error: Error) => {
|
||||
// Retry on network errors or temporary failures
|
||||
const message = error.message.toLowerCase()
|
||||
return (
|
||||
message.includes('timeout') ||
|
||||
message.includes('network') ||
|
||||
message.includes('connection') ||
|
||||
message.includes('unavailable') ||
|
||||
message.includes('500') ||
|
||||
message.includes('503')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
this.setupResponseHandler()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
public static getInstance(): DataApiService {
|
||||
if (!DataApiService.instance) {
|
||||
DataApiService.instance = new DataApiService()
|
||||
}
|
||||
return DataApiService.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup IPC response handler
|
||||
*/
|
||||
private setupResponseHandler() {
|
||||
if (!window.api.dataApi?.onResponse) {
|
||||
logger.error('Data API not available in preload context')
|
||||
return
|
||||
}
|
||||
|
||||
window.api.dataApi.onResponse((response: DataResponse) => {
|
||||
const pending = this.pendingRequests.get(response.id)
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout)
|
||||
this.pendingRequests.delete(response.id)
|
||||
|
||||
if (response.error) {
|
||||
pending.reject(new Error(response.error.message))
|
||||
} else {
|
||||
pending.resolve(response)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique request ID
|
||||
*/
|
||||
private generateRequestId(): string {
|
||||
return `req_${Date.now()}_${++this.requestId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel request by ID
|
||||
*/
|
||||
cancelRequest(requestId: string): void {
|
||||
const pending = this.pendingRequests.get(requestId)
|
||||
if (pending) {
|
||||
pending.abortController?.abort()
|
||||
clearTimeout(pending.timeout)
|
||||
this.pendingRequests.delete(requestId)
|
||||
pending.reject(new Error('Request cancelled'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all pending requests
|
||||
*/
|
||||
cancelAllRequests(): void {
|
||||
const requestIds = Array.from(this.pendingRequests.keys())
|
||||
requestIds.forEach((id) => this.cancelRequest(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure retry options
|
||||
* @param options Partial retry options to override defaults
|
||||
*/
|
||||
configureRetry(options: Partial<RetryOptions>): void {
|
||||
this.defaultRetryOptions = {
|
||||
...this.defaultRetryOptions,
|
||||
...options
|
||||
}
|
||||
|
||||
logger.debug('Retry options updated', this.defaultRetryOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current retry configuration
|
||||
*/
|
||||
getRetryConfig(): RetryOptions {
|
||||
return { ...this.defaultRetryOptions }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send request via IPC with AbortController support and retry logic
|
||||
*/
|
||||
private async sendRequest<T>(request: DataRequest, retryCount = 0): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.api.dataApi.request) {
|
||||
reject(new Error('Data API not available'))
|
||||
return
|
||||
}
|
||||
|
||||
// Create abort controller for cancellation
|
||||
const abortController = new AbortController()
|
||||
|
||||
// Set up timeout
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(request.id)
|
||||
const timeoutError = new Error(`Request timeout: ${request.path}`)
|
||||
|
||||
// Check if should retry
|
||||
if (retryCount < this.defaultRetryOptions.maxRetries && this.defaultRetryOptions.retryCondition(timeoutError)) {
|
||||
logger.debug(
|
||||
`Request timeout, retrying attempt ${retryCount + 1}/${this.defaultRetryOptions.maxRetries}: ${request.path}`
|
||||
)
|
||||
|
||||
// Calculate delay with exponential backoff
|
||||
const delay =
|
||||
this.defaultRetryOptions.retryDelay * Math.pow(this.defaultRetryOptions.backoffMultiplier, retryCount)
|
||||
|
||||
setTimeout(() => {
|
||||
// Create new request with new ID for retry
|
||||
const retryRequest = { ...request, id: this.generateRequestId() }
|
||||
this.sendRequest<T>(retryRequest, retryCount + 1)
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
}, delay)
|
||||
} else {
|
||||
reject(timeoutError)
|
||||
}
|
||||
}, 30000) // 30 second timeout
|
||||
|
||||
// Store pending request with abort controller
|
||||
this.pendingRequests.set(request.id, {
|
||||
resolve: (response: DataResponse) => resolve(response.data),
|
||||
reject: (error: Error) => {
|
||||
// Check if should retry on error
|
||||
if (retryCount < this.defaultRetryOptions.maxRetries && this.defaultRetryOptions.retryCondition(error)) {
|
||||
logger.debug(
|
||||
`Request failed, retrying attempt ${retryCount + 1}/${this.defaultRetryOptions.maxRetries}: ${request.path}`,
|
||||
error
|
||||
)
|
||||
|
||||
const delay =
|
||||
this.defaultRetryOptions.retryDelay * Math.pow(this.defaultRetryOptions.backoffMultiplier, retryCount)
|
||||
|
||||
setTimeout(() => {
|
||||
const retryRequest = { ...request, id: this.generateRequestId() }
|
||||
this.sendRequest<T>(retryRequest, retryCount + 1)
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
}, delay)
|
||||
} else {
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
timeout,
|
||||
abortController
|
||||
})
|
||||
|
||||
// Handle abort signal
|
||||
abortController.signal.addEventListener('abort', () => {
|
||||
clearTimeout(timeout)
|
||||
this.pendingRequests.delete(request.id)
|
||||
reject(new Error('Request cancelled'))
|
||||
})
|
||||
|
||||
// Send request
|
||||
window.api.dataApi.request(request).catch((error) => {
|
||||
clearTimeout(timeout)
|
||||
this.pendingRequests.delete(request.id)
|
||||
|
||||
// Check if should retry
|
||||
if (retryCount < this.defaultRetryOptions.maxRetries && this.defaultRetryOptions.retryCondition(error)) {
|
||||
logger.debug(
|
||||
`Request failed, retrying attempt ${retryCount + 1}/${this.defaultRetryOptions.maxRetries}: ${request.path}`,
|
||||
error
|
||||
)
|
||||
|
||||
const delay =
|
||||
this.defaultRetryOptions.retryDelay * Math.pow(this.defaultRetryOptions.backoffMultiplier, retryCount)
|
||||
|
||||
setTimeout(() => {
|
||||
const retryRequest = { ...request, id: this.generateRequestId() }
|
||||
this.sendRequest<T>(retryRequest, retryCount + 1)
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
}, delay)
|
||||
} else {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Make HTTP request with enhanced features
|
||||
*/
|
||||
private async makeRequest<T>(
|
||||
method: HttpMethod,
|
||||
path: string,
|
||||
options: {
|
||||
params?: any
|
||||
body?: any
|
||||
headers?: Record<string, string>
|
||||
metadata?: Record<string, any>
|
||||
signal?: AbortSignal
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
const { params, body, headers, metadata, signal } = options
|
||||
|
||||
// Check if already aborted
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Request cancelled')
|
||||
}
|
||||
|
||||
// Create request
|
||||
const request: DataRequest = {
|
||||
id: this.generateRequestId(),
|
||||
method,
|
||||
path,
|
||||
params,
|
||||
body,
|
||||
headers,
|
||||
metadata: {
|
||||
timestamp: Date.now(),
|
||||
...metadata
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`Making ${method} request to ${path}`, { request })
|
||||
|
||||
// Set up external abort signal handling
|
||||
const requestPromise = this.sendRequest<T>(request)
|
||||
|
||||
if (signal) {
|
||||
// If external signal is aborted during request, cancel our internal request
|
||||
const abortListener = () => {
|
||||
this.cancelRequest(request.id)
|
||||
}
|
||||
signal.addEventListener('abort', abortListener)
|
||||
|
||||
// Clean up listener when request completes
|
||||
requestPromise.finally(() => {
|
||||
signal.removeEventListener('abort', abortListener)
|
||||
})
|
||||
}
|
||||
|
||||
return requestPromise.catch((error) => {
|
||||
logger.error(`Request failed: ${method} ${path}`, error)
|
||||
throw toDataApiError(error, `${method} ${path}`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe GET request
|
||||
*/
|
||||
async get<TPath extends ConcreteApiPaths>(
|
||||
path: TPath,
|
||||
options?: {
|
||||
query?: any
|
||||
headers?: Record<string, string>
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<any> {
|
||||
return this.makeRequest<any>('GET', path as string, {
|
||||
params: options?.query,
|
||||
headers: options?.headers,
|
||||
signal: options?.signal
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe POST request
|
||||
*/
|
||||
async post<TPath extends ConcreteApiPaths>(
|
||||
path: TPath,
|
||||
options: {
|
||||
body?: any
|
||||
query?: Record<string, any>
|
||||
headers?: Record<string, string>
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<any> {
|
||||
return this.makeRequest<any>('POST', path as string, {
|
||||
params: options.query,
|
||||
body: options.body,
|
||||
headers: options.headers,
|
||||
signal: options.signal
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe PUT request
|
||||
*/
|
||||
async put<TPath extends ConcreteApiPaths>(
|
||||
path: TPath,
|
||||
options: {
|
||||
body: any
|
||||
query?: Record<string, any>
|
||||
headers?: Record<string, string>
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<any> {
|
||||
return this.makeRequest<any>('PUT', path as string, {
|
||||
params: options.query,
|
||||
body: options.body,
|
||||
headers: options.headers,
|
||||
signal: options.signal
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe DELETE request
|
||||
*/
|
||||
async delete<TPath extends ConcreteApiPaths>(
|
||||
path: TPath,
|
||||
options?: {
|
||||
query?: Record<string, any>
|
||||
headers?: Record<string, string>
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<any> {
|
||||
return this.makeRequest<any>('DELETE', path as string, {
|
||||
params: options?.query,
|
||||
headers: options?.headers,
|
||||
signal: options?.signal
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe PATCH request
|
||||
*/
|
||||
async patch<TPath extends ConcreteApiPaths>(
|
||||
path: TPath,
|
||||
options: {
|
||||
body?: any
|
||||
query?: Record<string, any>
|
||||
headers?: Record<string, string>
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<any> {
|
||||
return this.makeRequest<any>('PATCH', path as string, {
|
||||
params: options.query,
|
||||
body: options.body,
|
||||
headers: options.headers,
|
||||
signal: options.signal
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute multiple requests in batch
|
||||
*/
|
||||
async batch(requests: DataRequest[], options: { parallel?: boolean } = {}): Promise<BatchResponse> {
|
||||
const batchRequest: BatchRequest = {
|
||||
requests,
|
||||
parallel: options.parallel ?? true
|
||||
}
|
||||
|
||||
return this.makeRequest<BatchResponse>('POST', '/batch', { body: batchRequest })
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute requests in a transaction
|
||||
*/
|
||||
async transaction(operations: DataRequest[], options?: TransactionRequest['options']): Promise<DataResponse[]> {
|
||||
const transactionRequest: TransactionRequest = {
|
||||
operations,
|
||||
options
|
||||
}
|
||||
|
||||
return this.makeRequest<DataResponse[]>('POST', '/transaction', { body: transactionRequest })
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to real-time updates
|
||||
*/
|
||||
subscribe<T>(options: SubscriptionOptions, callback: SubscriptionCallback<T>): () => void {
|
||||
if (!window.api.dataApi?.subscribe) {
|
||||
throw new Error('Real-time subscriptions not supported')
|
||||
}
|
||||
|
||||
const subscriptionId = `sub_${Date.now()}_${Math.random()}`
|
||||
|
||||
this.subscriptions.set(subscriptionId, {
|
||||
callback: callback as SubscriptionCallback,
|
||||
options
|
||||
})
|
||||
|
||||
const unsubscribe = window.api.dataApi.subscribe(options.path, (data, event) => {
|
||||
// Convert string event to SubscriptionEvent enum
|
||||
const subscriptionEvent = event as SubscriptionEvent
|
||||
callback(data, subscriptionEvent)
|
||||
})
|
||||
|
||||
logger.debug(`Subscribed to ${options.path}`, { subscriptionId })
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.subscriptions.delete(subscriptionId)
|
||||
unsubscribe()
|
||||
logger.debug(`Unsubscribed from ${options.path}`, { subscriptionId })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an AbortController for request cancellation
|
||||
* @returns Object with AbortController and convenience methods
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { signal, cancel } = requestService.createAbortController()
|
||||
*
|
||||
* // Use signal in requests
|
||||
* const dataPromise = requestService.get('/topics', { signal })
|
||||
*
|
||||
* // Cancel if needed
|
||||
* setTimeout(() => cancel(), 5000) // Cancel after 5 seconds
|
||||
* ```
|
||||
*/
|
||||
createAbortController() {
|
||||
const controller = new AbortController()
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
cancel: () => controller.abort(),
|
||||
aborted: () => controller.signal.aborted
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive request statistics
|
||||
*/
|
||||
getRequestStats() {
|
||||
return {
|
||||
pendingRequests: this.pendingRequests.size,
|
||||
activeSubscriptions: this.subscriptions.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const dataApiService = DataApiService.getInstance()
|
||||
|
||||
// Clean up on window unload
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
dataApiService.cancelAllRequests()
|
||||
})
|
||||
}
|
||||
609
src/renderer/src/data/hooks/useDataApi.ts
Normal file
609
src/renderer/src/data/hooks/useDataApi.ts
Normal file
@ -0,0 +1,609 @@
|
||||
import type { BodyForPath, QueryParamsForPath, ResponseForPath } from '@shared/data/api/apiPaths'
|
||||
import type { ConcreteApiPaths } from '@shared/data/api/apiSchemas'
|
||||
import type { PaginatedResponse } from '@shared/data/api/apiTypes'
|
||||
import { useState } from 'react'
|
||||
import useSWR, { useSWRConfig } from 'swr'
|
||||
import useSWRMutation from 'swr/mutation'
|
||||
|
||||
import { dataApiService } from '../DataApiService'
|
||||
|
||||
// buildPath function removed - users now pass concrete paths directly
|
||||
|
||||
/**
|
||||
* Unified fetcher utility for API requests
|
||||
* Provides type-safe method dispatching to reduce code duplication
|
||||
*/
|
||||
function createApiFetcher<TPath extends ConcreteApiPaths, TMethod extends 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
|
||||
method: TMethod
|
||||
) {
|
||||
return async (
|
||||
path: TPath,
|
||||
options?: {
|
||||
body?: BodyForPath<TPath, TMethod>
|
||||
query?: Record<string, any>
|
||||
}
|
||||
): Promise<ResponseForPath<TPath, TMethod>> => {
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return dataApiService.get(path, { query: options?.query })
|
||||
case 'POST':
|
||||
return dataApiService.post(path, { body: options?.body, query: options?.query })
|
||||
case 'PUT':
|
||||
return dataApiService.put(path, { body: options?.body || {}, query: options?.query })
|
||||
case 'DELETE':
|
||||
return dataApiService.delete(path, { query: options?.query })
|
||||
case 'PATCH':
|
||||
return dataApiService.patch(path, { body: options?.body, query: options?.query })
|
||||
default:
|
||||
throw new Error(`Unsupported method: ${method}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SWR cache key for request identification and caching
|
||||
* Creates a unique key based on path and query parameters
|
||||
*
|
||||
* @param path - The concrete API path
|
||||
* @param query - Query parameters
|
||||
* @returns SWR key tuple or null if disabled
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* buildSWRKey('/topics', { page: 1 }) // Returns ['/topics', { page: 1 }]
|
||||
* buildSWRKey('/topics/123') // Returns ['/topics/123']
|
||||
* ```
|
||||
*/
|
||||
function buildSWRKey<TPath extends ConcreteApiPaths>(
|
||||
path: TPath,
|
||||
query?: Record<string, any>
|
||||
): [TPath, Record<string, any>?] | null {
|
||||
if (query && Object.keys(query).length > 0) {
|
||||
return [path, query]
|
||||
}
|
||||
|
||||
return [path]
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request fetcher for SWR
|
||||
* @param args - Tuple containing [path, query] parameters
|
||||
* @returns Promise resolving to the fetched data
|
||||
*/
|
||||
function getFetcher<TPath extends ConcreteApiPaths>([path, query]: [TPath, Record<string, any>?]): Promise<
|
||||
ResponseForPath<TPath, 'GET'>
|
||||
> {
|
||||
const apiFetcher = createApiFetcher('GET')
|
||||
return apiFetcher(path, { query })
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for data fetching with SWR
|
||||
* Provides type-safe API calls with caching, revalidation, and error handling
|
||||
*
|
||||
* @template TPath - The concrete API path type
|
||||
* @param path - The concrete API endpoint path (e.g., '/test/items/123')
|
||||
* @param options - Configuration options
|
||||
* @param options.query - Query parameters for filtering, pagination, etc.
|
||||
* @param options.enabled - Whether the request should be executed (default: true)
|
||||
* @param options.swrOptions - Additional SWR configuration options
|
||||
* @returns Object containing data, loading state, error state, and control functions
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Basic usage with type-safe concrete path
|
||||
* const { data, loading, error } = useQuery('/test/items')
|
||||
* // data is automatically typed as PaginatedResponse<any>
|
||||
*
|
||||
* // With dynamic ID - full type safety
|
||||
* const { data, loading, error } = useQuery(`/test/items/${itemId}`, {
|
||||
* enabled: !!itemId
|
||||
* })
|
||||
* // data is automatically typed as the specific item type
|
||||
*
|
||||
* // With type-safe query parameters
|
||||
* const { data, loading, error } = useQuery('/test/items', {
|
||||
* query: {
|
||||
* page: 1,
|
||||
* limit: 20,
|
||||
* search: 'hello', // TypeScript validates these fields
|
||||
* status: 'active'
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* // With custom SWR options
|
||||
* const { data, loading, error, refetch } = useQuery('/test/items', {
|
||||
* swrOptions: {
|
||||
* refreshInterval: 5000, // Auto-refresh every 5 seconds
|
||||
* revalidateOnFocus: true
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function useQuery<TPath extends ConcreteApiPaths>(
|
||||
path: TPath,
|
||||
options?: {
|
||||
/** Query parameters for filtering, pagination, etc. */
|
||||
query?: QueryParamsForPath<TPath>
|
||||
/** Disable the request */
|
||||
enabled?: boolean
|
||||
/** Custom SWR options */
|
||||
swrOptions?: Parameters<typeof useSWR>[2]
|
||||
}
|
||||
): {
|
||||
/** The fetched data */
|
||||
data?: ResponseForPath<TPath, 'GET'>
|
||||
/** Loading state */
|
||||
loading: boolean
|
||||
/** Error if request failed */
|
||||
error?: Error
|
||||
/** Function to manually refetch data */
|
||||
refetch: () => void
|
||||
} {
|
||||
// Internal type conversion for SWR compatibility
|
||||
const key = options?.enabled !== false ? buildSWRKey(path, options?.query as Record<string, any>) : null
|
||||
|
||||
const { data, error, isLoading, isValidating, mutate } = useSWR(key, getFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: true,
|
||||
dedupingInterval: 5000,
|
||||
errorRetryCount: 3,
|
||||
errorRetryInterval: 1000,
|
||||
...options?.swrOptions
|
||||
})
|
||||
|
||||
const refetch = () => {
|
||||
mutate()
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
loading: isLoading || isValidating,
|
||||
error: error as Error | undefined,
|
||||
refetch
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for mutation operations (POST, PUT, DELETE, PATCH)
|
||||
* Provides optimized handling of side-effect operations with automatic cache invalidation
|
||||
*
|
||||
* @template TPath - The concrete API path type
|
||||
* @param method - HTTP method for the mutation
|
||||
* @param path - The concrete API endpoint path (e.g., '/test/items/123')
|
||||
* @param options - Configuration options
|
||||
* @param options.onSuccess - Callback executed on successful mutation
|
||||
* @param options.onError - Callback executed on mutation error
|
||||
* @param options.revalidate - Cache invalidation strategy (true = invalidate all, string[] = specific paths)
|
||||
* @returns Object containing mutate function, loading state, and error state
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Create a new item with full type safety
|
||||
* const createItem = useMutation('POST', '/test/items', {
|
||||
* onSuccess: (data) => {
|
||||
* console.log('Item created:', data) // data is properly typed
|
||||
* },
|
||||
* onError: (error) => {
|
||||
* console.error('Failed to create item:', error)
|
||||
* },
|
||||
* revalidate: ['/test/items'] // Refresh items list after creation
|
||||
* })
|
||||
*
|
||||
* // Update existing item with optimistic updates
|
||||
* const updateItem = useMutation('PUT', `/test/items/${itemId}`, {
|
||||
* optimistic: true,
|
||||
* optimisticData: { id: itemId, title: 'Updated Item' }, // Type-safe
|
||||
* revalidate: true // Refresh all cached data
|
||||
* })
|
||||
*
|
||||
* // Delete item
|
||||
* const deleteItem = useMutation('DELETE', `/test/items/${itemId}`)
|
||||
*
|
||||
* // Usage in component with type-safe parameters
|
||||
* const handleCreate = async () => {
|
||||
* try {
|
||||
* const result = await createItem.mutate({
|
||||
* body: {
|
||||
* title: 'New Item',
|
||||
* description: 'Item description',
|
||||
* // TypeScript validates all fields against ApiSchemas
|
||||
* tags: ['tag1', 'tag2']
|
||||
* }
|
||||
* })
|
||||
* console.log('Created:', result)
|
||||
* } catch (error) {
|
||||
* console.error('Creation failed:', error)
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* const handleUpdate = async () => {
|
||||
* try {
|
||||
* const result = await updateItem.mutate({
|
||||
* body: { title: 'Updated Item' } // Type-safe, only valid fields allowed
|
||||
* })
|
||||
* } catch (error) {
|
||||
* console.error('Update failed:', error)
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* const handleDelete = async () => {
|
||||
* try {
|
||||
* await deleteItem.mutate()
|
||||
* } catch (error) {
|
||||
* console.error('Delete failed:', error)
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
export function useMutation<TPath extends ConcreteApiPaths, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
|
||||
method: TMethod,
|
||||
path: TPath,
|
||||
options?: {
|
||||
/** Called when mutation succeeds */
|
||||
onSuccess?: (data: ResponseForPath<TPath, TMethod>) => void
|
||||
/** Called when mutation fails */
|
||||
onError?: (error: Error) => void
|
||||
/** Automatically revalidate these SWR keys on success */
|
||||
revalidate?: boolean | string[]
|
||||
/** Enable optimistic updates */
|
||||
optimistic?: boolean
|
||||
/** Optimistic data to use for updates */
|
||||
optimisticData?: ResponseForPath<TPath, TMethod>
|
||||
}
|
||||
): {
|
||||
/** Function to execute the mutation */
|
||||
mutate: (data?: {
|
||||
body?: BodyForPath<TPath, TMethod>
|
||||
query?: QueryParamsForPath<TPath>
|
||||
}) => Promise<ResponseForPath<TPath, TMethod>>
|
||||
/** True while the mutation is in progress */
|
||||
loading: boolean
|
||||
/** Error object if the mutation failed */
|
||||
error: Error | undefined
|
||||
} {
|
||||
const { mutate: globalMutate } = useSWRConfig()
|
||||
|
||||
const apiFetcher = createApiFetcher(method)
|
||||
|
||||
const fetcher = async (
|
||||
_key: string,
|
||||
{
|
||||
arg
|
||||
}: {
|
||||
arg?: {
|
||||
body?: BodyForPath<TPath, TMethod>
|
||||
query?: Record<string, any>
|
||||
}
|
||||
}
|
||||
): Promise<ResponseForPath<TPath, TMethod>> => {
|
||||
return apiFetcher(path, { body: arg?.body, query: arg?.query })
|
||||
}
|
||||
|
||||
const { trigger, isMutating, error } = useSWRMutation(path as string, fetcher, {
|
||||
populateCache: false,
|
||||
revalidate: false,
|
||||
onSuccess: async (data) => {
|
||||
options?.onSuccess?.(data)
|
||||
|
||||
if (options?.revalidate === true) {
|
||||
await globalMutate(() => true)
|
||||
} else if (Array.isArray(options?.revalidate)) {
|
||||
for (const path of options.revalidate) {
|
||||
await globalMutate(path)
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: options?.onError
|
||||
})
|
||||
|
||||
const optimisticMutate = async (data?: {
|
||||
body?: BodyForPath<TPath, TMethod>
|
||||
query?: QueryParamsForPath<TPath>
|
||||
}): Promise<ResponseForPath<TPath, TMethod>> => {
|
||||
if (options?.optimistic && options?.optimisticData) {
|
||||
// Apply optimistic update
|
||||
await globalMutate(path, options.optimisticData, false)
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert user's strongly-typed query to Record<string, any> for internal use
|
||||
const convertedData = data
|
||||
? {
|
||||
body: data.body,
|
||||
query: data.query as Record<string, any>
|
||||
}
|
||||
: undefined
|
||||
|
||||
const result = await trigger(convertedData)
|
||||
|
||||
// Revalidate with real data after successful mutation
|
||||
if (options?.optimistic) {
|
||||
await globalMutate(path)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (err) {
|
||||
// Revert optimistic update on error
|
||||
if (options?.optimistic && options?.optimisticData) {
|
||||
await globalMutate(path)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper for non-optimistic mutations to handle type conversion
|
||||
const normalMutate = async (data?: {
|
||||
body?: BodyForPath<TPath, TMethod>
|
||||
query?: QueryParamsForPath<TPath>
|
||||
}): Promise<ResponseForPath<TPath, TMethod>> => {
|
||||
// Convert user's strongly-typed query to Record<string, any> for internal use
|
||||
const convertedData = data
|
||||
? {
|
||||
body: data.body,
|
||||
query: data.query as Record<string, any>
|
||||
}
|
||||
: undefined
|
||||
|
||||
return trigger(convertedData)
|
||||
}
|
||||
|
||||
return {
|
||||
mutate: options?.optimistic ? optimisticMutate : normalMutate,
|
||||
loading: isMutating,
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for invalidating SWR cache entries
|
||||
* Must be used inside a React component or hook
|
||||
*
|
||||
* @returns Function to invalidate cache entries
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* function MyComponent() {
|
||||
* const invalidate = useInvalidateCache()
|
||||
*
|
||||
* const handleInvalidate = async () => {
|
||||
* // Invalidate specific cache key
|
||||
* await invalidate('/test/items')
|
||||
*
|
||||
* // Invalidate multiple keys
|
||||
* await invalidate(['/test/items', '/test/stats'])
|
||||
*
|
||||
* // Invalidate all cache entries
|
||||
* await invalidate(true)
|
||||
* }
|
||||
*
|
||||
* return <button onClick={handleInvalidate}>Refresh Data</button>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useInvalidateCache() {
|
||||
const { mutate } = useSWRConfig()
|
||||
|
||||
const invalidate = (keys?: string | string[] | boolean): Promise<any> => {
|
||||
if (keys === true || keys === undefined) {
|
||||
return mutate(() => true)
|
||||
} else if (typeof keys === 'string') {
|
||||
return mutate(keys)
|
||||
} else if (Array.isArray(keys)) {
|
||||
return Promise.all(keys.map((key) => mutate(key)))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return invalidate
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch data for a given path without caching
|
||||
* Useful for warming up data before user interactions or pre-loading critical resources
|
||||
*
|
||||
* @template TPath - The concrete API path type
|
||||
* @param path - The concrete API endpoint path (e.g., '/test/items/123')
|
||||
* @param options - Configuration options for the prefetch request
|
||||
* @param options.query - Query parameters for filtering, pagination, etc.
|
||||
* @returns Promise resolving to the prefetched data
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Prefetch items list on component mount
|
||||
* useEffect(() => {
|
||||
* prefetch('/test/items', {
|
||||
* query: { page: 1, limit: 20 }
|
||||
* })
|
||||
* }, [])
|
||||
*
|
||||
* // Prefetch specific item on hover
|
||||
* const handleItemHover = (itemId: string) => {
|
||||
* prefetch(`/test/items/${itemId}`)
|
||||
* }
|
||||
*
|
||||
* // Prefetch with search parameters
|
||||
* const preloadSearchResults = async (searchTerm: string) => {
|
||||
* const results = await prefetch('/test/search', {
|
||||
* query: {
|
||||
* query: searchTerm,
|
||||
* limit: 10
|
||||
* }
|
||||
* })
|
||||
* console.log('Preloaded:', results)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function prefetch<TPath extends ConcreteApiPaths>(
|
||||
path: TPath,
|
||||
options?: {
|
||||
query?: QueryParamsForPath<TPath>
|
||||
}
|
||||
): Promise<ResponseForPath<TPath, 'GET'>> {
|
||||
const apiFetcher = createApiFetcher('GET')
|
||||
return apiFetcher(path, { query: options?.query as Record<string, any> })
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for paginated data fetching with type safety
|
||||
* Automatically manages pagination state and provides navigation controls
|
||||
* Works with API endpoints that return PaginatedResponse<T>
|
||||
*
|
||||
* @template TPath - The concrete API path type
|
||||
* @param path - API endpoint path that returns paginated data (e.g., '/test/items')
|
||||
* @param options - Configuration options for pagination and filtering
|
||||
* @param options.query - Additional query parameters (excluding page/limit)
|
||||
* @param options.limit - Items per page (default: 10)
|
||||
* @param options.swrOptions - Additional SWR configuration options
|
||||
* @returns Object containing paginated data, navigation controls, and state
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Basic paginated list
|
||||
* const {
|
||||
* items,
|
||||
* loading,
|
||||
* total,
|
||||
* page,
|
||||
* hasMore,
|
||||
* nextPage,
|
||||
* prevPage
|
||||
* } = usePaginatedQuery('/test/items', {
|
||||
* limit: 20
|
||||
* })
|
||||
*
|
||||
* // With search and filtering
|
||||
* const paginatedItems = usePaginatedQuery('/test/items', {
|
||||
* query: {
|
||||
* search: searchTerm,
|
||||
* status: 'active',
|
||||
* type: 'premium'
|
||||
* },
|
||||
* limit: 25,
|
||||
* swrOptions: {
|
||||
* refreshInterval: 30000 // Refresh every 30 seconds
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* // Navigation controls usage
|
||||
* <div>
|
||||
* <button onClick={prevPage} disabled={!hasPrev}>
|
||||
* Previous
|
||||
* </button>
|
||||
* <span>Page {page} of {Math.ceil(total / 20)}</span>
|
||||
* <button onClick={nextPage} disabled={!hasMore}>
|
||||
* Next
|
||||
* </button>
|
||||
* </div>
|
||||
*
|
||||
* // Reset pagination when search changes
|
||||
* useEffect(() => {
|
||||
* reset() // Go back to first page
|
||||
* }, [searchTerm])
|
||||
* ```
|
||||
*/
|
||||
export function usePaginatedQuery<TPath extends ConcreteApiPaths>(
|
||||
path: TPath,
|
||||
options?: {
|
||||
/** Additional query parameters (excluding pagination) */
|
||||
query?: Omit<QueryParamsForPath<TPath>, 'page' | 'limit'>
|
||||
/** Items per page (default: 10) */
|
||||
limit?: number
|
||||
/** Custom SWR options */
|
||||
swrOptions?: Parameters<typeof useSWR>[2]
|
||||
}
|
||||
): ResponseForPath<TPath, 'GET'> extends PaginatedResponse<infer T>
|
||||
? {
|
||||
/** Array of items for current page */
|
||||
items: T[]
|
||||
/** Total number of items across all pages */
|
||||
total: number
|
||||
/** Current page number (1-based) */
|
||||
page: number
|
||||
/** Loading state */
|
||||
loading: boolean
|
||||
/** Error if request failed */
|
||||
error?: Error
|
||||
/** Whether there are more pages available */
|
||||
hasMore: boolean
|
||||
/** Whether there are previous pages available */
|
||||
hasPrev: boolean
|
||||
/** Navigate to previous page */
|
||||
prevPage: () => void
|
||||
/** Navigate to next page */
|
||||
nextPage: () => void
|
||||
/** Refresh current page data */
|
||||
refresh: () => void
|
||||
/** Reset to first page */
|
||||
reset: () => void
|
||||
}
|
||||
: never {
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const limit = options?.limit || 10
|
||||
|
||||
// Convert user's strongly-typed query with pagination for internal use
|
||||
const queryWithPagination = {
|
||||
...options?.query,
|
||||
page: currentPage,
|
||||
limit
|
||||
} as Record<string, any>
|
||||
|
||||
const { data, loading, error, refetch } = useQuery(path, {
|
||||
query: queryWithPagination as QueryParamsForPath<TPath>,
|
||||
swrOptions: options?.swrOptions
|
||||
})
|
||||
|
||||
// Extract paginated response data with type safety
|
||||
const paginatedData = data as PaginatedResponse<any>
|
||||
const items = paginatedData?.items || []
|
||||
const total = paginatedData?.total || 0
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
|
||||
const hasMore = currentPage < totalPages
|
||||
const hasPrev = currentPage > 1
|
||||
|
||||
const nextPage = () => {
|
||||
if (hasMore) {
|
||||
setCurrentPage((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const prevPage = () => {
|
||||
if (hasPrev) {
|
||||
setCurrentPage((prev) => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page: currentPage,
|
||||
loading,
|
||||
error,
|
||||
hasMore,
|
||||
hasPrev,
|
||||
prevPage,
|
||||
nextPage,
|
||||
refresh: refetch,
|
||||
reset
|
||||
} as ResponseForPath<TPath, 'GET'> extends PaginatedResponse<infer T>
|
||||
? {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
loading: boolean
|
||||
error?: Error
|
||||
hasMore: boolean
|
||||
hasPrev: boolean
|
||||
prevPage: () => void
|
||||
nextPage: () => void
|
||||
refresh: () => void
|
||||
reset: () => void
|
||||
}
|
||||
: never
|
||||
}
|
||||
@ -1,3 +1,7 @@
|
||||
/**
|
||||
* Data Refactor, notes by fullex
|
||||
* //TODO @deprecated this file will be removed
|
||||
*/
|
||||
import i18n from '@renderer/i18n'
|
||||
import store, { useAppSelector } from '@renderer/store'
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
//TODO data refactor
|
||||
// this file will be removed
|
||||
/**
|
||||
* Data Refactor, notes by fullex
|
||||
* //TODO @deprecated this file will be removed
|
||||
*/
|
||||
|
||||
import { usePreference } from '@data/hooks/usePreference'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
/**
|
||||
* Data Refactor, notes by fullex
|
||||
* //TODO @deprecated this file will be removed
|
||||
*/
|
||||
import { loggerService } from '@logger'
|
||||
import { combineReducers, configureStore } from '@reduxjs/toolkit'
|
||||
import { useDispatch, useSelector, useStore } from 'react-redux'
|
||||
|
||||
@ -3,10 +3,14 @@ import { usePreference } from '@renderer/data/hooks/usePreference'
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { ThemeMode } from '@shared/data/preferenceTypes'
|
||||
import { Button, Card, Col, Divider, Layout, Row, Space, Typography } from 'antd'
|
||||
import { Database, FlaskConical, Settings, TestTube } from 'lucide-react'
|
||||
import { Activity, AlertTriangle, Database, FlaskConical, Settings, TestTube, TrendingUp, Zap } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import DataApiAdvancedTests from './components/DataApiAdvancedTests'
|
||||
import DataApiBasicTests from './components/DataApiBasicTests'
|
||||
import DataApiHookTests from './components/DataApiHookTests'
|
||||
import DataApiStressTests from './components/DataApiStressTests'
|
||||
import PreferenceBasicTests from './components/PreferenceBasicTests'
|
||||
import PreferenceHookTests from './components/PreferenceHookTests'
|
||||
import PreferenceMultipleTests from './components/PreferenceMultipleTests'
|
||||
@ -92,19 +96,22 @@ const TestApp: React.FC = () => {
|
||||
<Space align="center">
|
||||
<TestTube size={24} color="var(--color-primary)" />
|
||||
<Title level={3} style={{ margin: 0, color: textColor }}>
|
||||
PreferenceService 功能测试 (窗口 #{windowNumber})
|
||||
数据重构项目测试套件 (窗口 #{windowNumber})
|
||||
</Title>
|
||||
</Space>
|
||||
<Text style={{ color: isDarkTheme ? '#d9d9d9' : 'rgba(0, 0, 0, 0.45)' }}>
|
||||
此测试窗口用于验证 PreferenceService 和 usePreference hooks
|
||||
的各项功能,包括单个偏好设置的读写、多个偏好设置的批量操作、跨窗口数据同步等。
|
||||
此测试窗口用于验证数据重构项目的各项功能,包括 PreferenceService、DataApiService 和相关 React hooks
|
||||
的完整测试套件。
|
||||
</Text>
|
||||
<Text style={{ color: isDarkTheme ? '#d9d9d9' : 'rgba(0, 0, 0, 0.45)' }}>
|
||||
测试使用的都是真实的偏好设置系统,所有操作都会影响实际的数据库存储。
|
||||
PreferenceService 测试使用真实的偏好设置系统,DataApiService 测试使用专用的测试路由和假数据。
|
||||
</Text>
|
||||
<Text style={{ color: 'var(--color-primary)', fontWeight: 'bold' }}>
|
||||
📋 跨窗口测试指南:在一个窗口中修改偏好设置,观察其他窗口是否实时同步更新。
|
||||
</Text>
|
||||
<Text style={{ color: 'var(--color-secondary)', fontWeight: 'bold' }}>
|
||||
🚀 数据API测试:包含基础CRUD、高级功能、React hooks和压力测试,全面验证数据请求架构。
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
@ -170,6 +177,75 @@ const TestApp: React.FC = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider orientation="left" style={{ color: textColor }}>
|
||||
<Space>
|
||||
<Zap size={20} color="var(--color-primary)" />
|
||||
<Text style={{ color: textColor, fontSize: 16, fontWeight: 600 }}>DataApiService 功能测试</Text>
|
||||
</Space>
|
||||
</Divider>
|
||||
|
||||
<Row gutter={[24, 24]}>
|
||||
{/* DataApi Basic Tests */}
|
||||
<Col span={24}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Database size={18} color={isDarkTheme ? '#fff' : '#000'} />
|
||||
<span style={{ color: textColor }}>DataApi 基础功能测试 (CRUD操作)</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
|
||||
<DataApiBasicTests />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* DataApi Advanced Tests */}
|
||||
<Col span={24}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Activity size={18} color={isDarkTheme ? '#fff' : '#000'} />
|
||||
<span style={{ color: textColor }}>DataApi 高级功能测试 (取消、重试、批量)</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
|
||||
<DataApiAdvancedTests />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* DataApi Hook Tests */}
|
||||
<Col span={24}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<TrendingUp size={18} color={isDarkTheme ? '#fff' : '#000'} />
|
||||
<span style={{ color: textColor }}>DataApi React Hooks 测试</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
|
||||
<DataApiHookTests />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* DataApi Stress Tests */}
|
||||
<Col span={24}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<AlertTriangle size={18} color={isDarkTheme ? '#fff' : '#000'} />
|
||||
<span style={{ color: textColor }}>DataApi 压力测试 (性能与错误处理)</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
|
||||
<DataApiStressTests />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Row justify="center">
|
||||
|
||||
@ -0,0 +1,713 @@
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { Alert, Button, Card, Col, message, Row, Space, Statistic, Table, Tag, Typography } from 'antd'
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
RotateCcw,
|
||||
Shield,
|
||||
StopCircle,
|
||||
Timer,
|
||||
XCircle,
|
||||
Zap
|
||||
} from 'lucide-react'
|
||||
import React, { useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { dataApiService } from '../../../data/DataApiService'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const logger = loggerService.withContext('DataApiAdvancedTests')
|
||||
|
||||
interface AdvancedTestResult {
|
||||
id: string
|
||||
name: string
|
||||
category: 'cancellation' | 'retry' | 'batch' | 'error' | 'performance'
|
||||
status: 'pending' | 'running' | 'success' | 'error' | 'cancelled'
|
||||
startTime?: number
|
||||
duration?: number
|
||||
response?: any
|
||||
error?: string
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
interface RetryTestConfig {
|
||||
maxRetries: number
|
||||
retryDelay: number
|
||||
backoffMultiplier: number
|
||||
}
|
||||
|
||||
const DataApiAdvancedTests: React.FC = () => {
|
||||
const [testResults, setTestResults] = useState<AdvancedTestResult[]>([])
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [, setCancelledRequests] = useState<string[]>([])
|
||||
const [, setRetryStats] = useState<any>(null)
|
||||
const [performanceStats, setPerformanceStats] = useState<any>(null)
|
||||
|
||||
// Keep track of active abort controllers
|
||||
const abortControllersRef = useRef<Map<string, AbortController>>(new Map())
|
||||
|
||||
const updateTestResult = (id: string, updates: Partial<AdvancedTestResult>) => {
|
||||
setTestResults((prev) => prev.map((result) => (result.id === id ? { ...result, ...updates } : result)))
|
||||
}
|
||||
|
||||
const addTestResult = (result: AdvancedTestResult) => {
|
||||
setTestResults((prev) => [...prev, result])
|
||||
}
|
||||
|
||||
const runAdvancedTest = async (
|
||||
testId: string,
|
||||
testName: string,
|
||||
category: AdvancedTestResult['category'],
|
||||
testFn: (signal?: AbortSignal) => Promise<any>
|
||||
) => {
|
||||
const startTime = Date.now()
|
||||
|
||||
const testResult: AdvancedTestResult = {
|
||||
id: testId,
|
||||
name: testName,
|
||||
category,
|
||||
status: 'running',
|
||||
startTime
|
||||
}
|
||||
|
||||
addTestResult(testResult)
|
||||
|
||||
// Create abort controller for cancellation testing
|
||||
const abortController = new AbortController()
|
||||
abortControllersRef.current.set(testId, abortController)
|
||||
|
||||
try {
|
||||
logger.info(`Starting advanced test: ${testName}`, { category })
|
||||
|
||||
const response = await testFn(abortController.signal)
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
logger.info(`Advanced test completed: ${testName}`, { duration, response })
|
||||
|
||||
updateTestResult(testId, {
|
||||
status: 'success',
|
||||
duration,
|
||||
response,
|
||||
error: undefined
|
||||
})
|
||||
|
||||
message.success(`${testName} completed successfully`)
|
||||
return response
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
const isAborted = error instanceof Error && error.message.includes('cancelled')
|
||||
const status = isAborted ? 'cancelled' : 'error'
|
||||
|
||||
logger.error(`Advanced test ${status}: ${testName}`, error as Error)
|
||||
|
||||
updateTestResult(testId, {
|
||||
status,
|
||||
duration,
|
||||
error: errorMessage,
|
||||
response: undefined
|
||||
})
|
||||
|
||||
if (isAborted) {
|
||||
message.warning(`${testName} was cancelled`)
|
||||
setCancelledRequests((prev) => [...prev, testId])
|
||||
} else {
|
||||
message.error(`${testName} failed: ${errorMessage}`)
|
||||
}
|
||||
|
||||
throw error
|
||||
} finally {
|
||||
abortControllersRef.current.delete(testId)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelTest = (testId: string) => {
|
||||
const controller = abortControllersRef.current.get(testId)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
message.info(`Test ${testId} cancellation requested`)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelAllTests = () => {
|
||||
const controllers = Array.from(abortControllersRef.current.entries())
|
||||
controllers.forEach(([, controller]) => {
|
||||
controller.abort()
|
||||
})
|
||||
message.info(`${controllers.length} tests cancelled`)
|
||||
}
|
||||
|
||||
// Request Cancellation Tests
|
||||
const testRequestCancellation = async () => {
|
||||
if (isRunning) return
|
||||
setIsRunning(true)
|
||||
|
||||
try {
|
||||
// Test 1: Cancel a slow request
|
||||
const slowTestPromise = runAdvancedTest('cancel-slow', 'Cancel Slow Request (5s)', 'cancellation', (signal) =>
|
||||
dataApiService.post('/test/slow', { body: { delay: 5000 }, signal })
|
||||
)
|
||||
|
||||
// Wait a bit then cancel
|
||||
setTimeout(() => cancelTest('cancel-slow'), 2000)
|
||||
|
||||
try {
|
||||
await slowTestPromise
|
||||
} catch {
|
||||
// Expected to be cancelled
|
||||
}
|
||||
|
||||
// Test 2: Cancel a request that should succeed
|
||||
const quickTestPromise = runAdvancedTest('cancel-quick', 'Cancel Quick Request', 'cancellation', (signal) =>
|
||||
dataApiService.get('/test/items', { signal })
|
||||
)
|
||||
|
||||
// Cancel immediately
|
||||
setTimeout(() => cancelTest('cancel-quick'), 100)
|
||||
|
||||
try {
|
||||
await quickTestPromise
|
||||
} catch {
|
||||
// Expected to be cancelled
|
||||
}
|
||||
|
||||
// Test 3: Test using DataApiService's built-in cancellation
|
||||
await runAdvancedTest('service-cancel', 'DataApiService Built-in Cancellation', 'cancellation', async () => {
|
||||
const { signal, cancel } = dataApiService.createAbortController()
|
||||
|
||||
// Start a slow request
|
||||
const requestPromise = dataApiService.post('/test/slow', { body: { delay: 3000 }, signal })
|
||||
|
||||
// Cancel after 1 second
|
||||
setTimeout(() => cancel(), 1000)
|
||||
|
||||
try {
|
||||
return await requestPromise
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('cancelled')) {
|
||||
return { cancelled: true, message: 'Successfully cancelled using DataApiService' }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Retry Mechanism Tests
|
||||
const testRetryMechanism = async () => {
|
||||
if (isRunning) return
|
||||
setIsRunning(true)
|
||||
|
||||
try {
|
||||
// Configure retry settings for testing
|
||||
const originalConfig = dataApiService.getRetryConfig()
|
||||
const testConfig: RetryTestConfig = {
|
||||
maxRetries: 3,
|
||||
retryDelay: 500,
|
||||
backoffMultiplier: 2
|
||||
}
|
||||
|
||||
dataApiService.configureRetry(testConfig)
|
||||
setRetryStats({ config: testConfig, attempts: [] })
|
||||
|
||||
// Test 1: Network error retry
|
||||
await runAdvancedTest('retry-network', 'Network Error Retry Test', 'retry', async () => {
|
||||
try {
|
||||
return await dataApiService.post('/test/error', { body: { errorType: 'network' } })
|
||||
} catch (error) {
|
||||
return {
|
||||
retriedAndFailed: true,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: 'Retry mechanism tested - expected to fail after retries'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test 2: Timeout retry
|
||||
await runAdvancedTest('retry-timeout', 'Timeout Error Retry Test', 'retry', async () => {
|
||||
try {
|
||||
return await dataApiService.post('/test/error', { body: { errorType: 'timeout' } })
|
||||
} catch (error) {
|
||||
return {
|
||||
retriedAndFailed: true,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: 'Timeout retry tested - expected to fail after retries'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test 3: Server error retry
|
||||
await runAdvancedTest('retry-server', 'Server Error Retry Test', 'retry', async () => {
|
||||
try {
|
||||
return await dataApiService.post('/test/error', { body: { errorType: 'server' } })
|
||||
} catch (error) {
|
||||
return {
|
||||
retriedAndFailed: true,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: 'Server error retry tested - expected to fail after retries'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Restore original retry configuration
|
||||
dataApiService.configureRetry(originalConfig)
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Batch Operations Test
|
||||
const testBatchOperations = async () => {
|
||||
if (isRunning) return
|
||||
setIsRunning(true)
|
||||
|
||||
try {
|
||||
// Test 1: Batch GET requests
|
||||
await runAdvancedTest('batch-get', 'Batch GET Requests', 'batch', async () => {
|
||||
const requests = [
|
||||
{ method: 'GET', path: '/test/items', params: { page: 1, limit: 3 } },
|
||||
{ method: 'GET', path: '/test/stats' },
|
||||
{ method: 'GET', path: '/test/items', params: { page: 2, limit: 3 } }
|
||||
]
|
||||
|
||||
return await dataApiService.batch(
|
||||
requests.map((req, index) => ({
|
||||
id: `batch-get-${index}`,
|
||||
method: req.method as any,
|
||||
path: req.path,
|
||||
params: req.params
|
||||
})),
|
||||
{ parallel: true }
|
||||
)
|
||||
})
|
||||
|
||||
// Test 2: Mixed batch operations
|
||||
await runAdvancedTest('batch-mixed', 'Mixed Batch Operations', 'batch', async () => {
|
||||
const requests = [
|
||||
{
|
||||
id: 'batch-create-item',
|
||||
method: 'POST',
|
||||
path: '/test/items',
|
||||
body: {
|
||||
title: `Batch Created Item ${Date.now()}`,
|
||||
description: 'Created via batch operation',
|
||||
type: 'batch-test'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'batch-get-items',
|
||||
method: 'GET',
|
||||
path: '/test/items',
|
||||
params: { page: 1, limit: 5 }
|
||||
}
|
||||
]
|
||||
|
||||
return await dataApiService.batch(requests as any, { parallel: false })
|
||||
})
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Error Handling Tests
|
||||
const testErrorHandling = async () => {
|
||||
if (isRunning) return
|
||||
setIsRunning(true)
|
||||
|
||||
const errorTypes = ['notfound', 'validation', 'unauthorized', 'server', 'ratelimit']
|
||||
|
||||
try {
|
||||
for (const errorType of errorTypes) {
|
||||
await runAdvancedTest(`error-${errorType}`, `Error Type: ${errorType.toUpperCase()}`, 'error', async () => {
|
||||
try {
|
||||
return await dataApiService.post('/test/error', { body: { errorType: errorType as any } })
|
||||
} catch (error) {
|
||||
return {
|
||||
errorTested: true,
|
||||
errorType,
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Successfully caught and handled ${errorType} error`
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Performance Tests
|
||||
const testPerformance = async () => {
|
||||
if (isRunning) return
|
||||
setIsRunning(true)
|
||||
|
||||
try {
|
||||
const concurrentRequests = 10
|
||||
const stats = {
|
||||
concurrentRequests,
|
||||
totalTime: 0,
|
||||
averageTime: 0,
|
||||
successCount: 0,
|
||||
errorCount: 0,
|
||||
requests: [] as any[]
|
||||
}
|
||||
|
||||
// Test concurrent requests
|
||||
await runAdvancedTest('perf-concurrent', `${concurrentRequests} Concurrent Requests`, 'performance', async () => {
|
||||
const startTime = Date.now()
|
||||
|
||||
const promises = Array.from({ length: concurrentRequests }, (_, i) =>
|
||||
dataApiService.get('/test/items').then(
|
||||
(result) => ({ success: true, result, index: i }),
|
||||
(error) => ({ success: false, error: error instanceof Error ? error.message : 'Unknown error', index: i })
|
||||
)
|
||||
)
|
||||
|
||||
const results = await Promise.all(promises)
|
||||
const totalTime = Date.now() - startTime
|
||||
|
||||
stats.totalTime = totalTime
|
||||
stats.averageTime = totalTime / concurrentRequests
|
||||
stats.successCount = results.filter((r) => r.success).length
|
||||
stats.errorCount = results.filter((r) => !r.success).length
|
||||
stats.requests = results
|
||||
|
||||
return stats
|
||||
})
|
||||
|
||||
setPerformanceStats(stats)
|
||||
|
||||
// Test memory usage
|
||||
await runAdvancedTest('perf-memory', 'Memory Usage Test', 'performance', async () => {
|
||||
const initialMemory = (performance as any).memory?.usedJSHeapSize || 0
|
||||
|
||||
// Create many requests to test memory handling
|
||||
const largeRequests = Array.from({ length: 50 }, () => dataApiService.get('/test/items'))
|
||||
|
||||
await Promise.allSettled(largeRequests)
|
||||
|
||||
const finalMemory = (performance as any).memory?.usedJSHeapSize || 0
|
||||
const memoryIncrease = finalMemory - initialMemory
|
||||
|
||||
return {
|
||||
initialMemory,
|
||||
finalMemory,
|
||||
memoryIncrease,
|
||||
memoryIncreaseKB: Math.round(memoryIncrease / 1024),
|
||||
message: `Memory increase: ${Math.round(memoryIncrease / 1024)}KB after 50 requests`
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetTests = () => {
|
||||
// Cancel any running tests
|
||||
cancelAllTests()
|
||||
|
||||
setTestResults([])
|
||||
setCancelledRequests([])
|
||||
setRetryStats(null)
|
||||
setPerformanceStats(null)
|
||||
setIsRunning(false)
|
||||
message.info('Advanced tests reset')
|
||||
}
|
||||
|
||||
const getStatusColor = (status: AdvancedTestResult['status']) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'success'
|
||||
case 'error':
|
||||
return 'error'
|
||||
case 'cancelled':
|
||||
return 'warning'
|
||||
case 'running':
|
||||
return 'processing'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: AdvancedTestResult['status']) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <CheckCircle size={16} />
|
||||
case 'error':
|
||||
return <XCircle size={16} />
|
||||
case 'cancelled':
|
||||
return <StopCircle size={16} />
|
||||
case 'running':
|
||||
return <Activity size={16} className="animate-spin" />
|
||||
default:
|
||||
return <Clock size={16} />
|
||||
}
|
||||
}
|
||||
|
||||
const getCategoryIcon = (category: AdvancedTestResult['category']) => {
|
||||
switch (category) {
|
||||
case 'cancellation':
|
||||
return <StopCircle size={16} />
|
||||
case 'retry':
|
||||
return <RotateCcw size={16} />
|
||||
case 'batch':
|
||||
return <Zap size={16} />
|
||||
case 'error':
|
||||
return <AlertTriangle size={16} />
|
||||
case 'performance':
|
||||
return <Timer size={16} />
|
||||
default:
|
||||
return <Activity size={16} />
|
||||
}
|
||||
}
|
||||
|
||||
const tableColumns = [
|
||||
{
|
||||
title: 'Category',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
render: (category: string) => (
|
||||
<Space>
|
||||
{getCategoryIcon(category as any)}
|
||||
<Text>{category}</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Test',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (name: string) => (
|
||||
<Text code style={{ fontSize: 12 }}>
|
||||
{name}
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: AdvancedTestResult['status']) => (
|
||||
<Tag color={getStatusColor(status)} icon={getStatusIcon(status)}>
|
||||
{status.toUpperCase()}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Duration',
|
||||
dataIndex: 'duration',
|
||||
key: 'duration',
|
||||
render: (duration?: number) => (duration ? `${duration}ms` : '-')
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
render: (_: any, record: AdvancedTestResult) => (
|
||||
<Space>
|
||||
{record.status === 'running' && (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
icon={<StopCircle size={12} />}
|
||||
onClick={() => cancelTest(record.id)}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<TestContainer>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{/* Control Panel */}
|
||||
<Card size="small">
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<StopCircle size={16} />}
|
||||
onClick={testRequestCancellation}
|
||||
loading={isRunning}
|
||||
disabled={isRunning}
|
||||
block>
|
||||
Test Cancellation
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<RotateCcw size={16} />}
|
||||
onClick={testRetryMechanism}
|
||||
loading={isRunning}
|
||||
disabled={isRunning}
|
||||
block>
|
||||
Test Retry
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Zap size={16} />}
|
||||
onClick={testBatchOperations}
|
||||
loading={isRunning}
|
||||
disabled={isRunning}
|
||||
block>
|
||||
Test Batch
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
onClick={testErrorHandling}
|
||||
loading={isRunning}
|
||||
disabled={isRunning}
|
||||
block>
|
||||
Test Errors
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Timer size={16} />}
|
||||
onClick={testPerformance}
|
||||
loading={isRunning}
|
||||
disabled={isRunning}
|
||||
block>
|
||||
Test Performance
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Button icon={<Shield size={16} />} onClick={resetTests} disabled={isRunning} block>
|
||||
Reset Tests
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Space style={{ float: 'right' }}>
|
||||
{abortControllersRef.current.size > 0 && (
|
||||
<Button danger icon={<StopCircle size={16} />} onClick={cancelAllTests}>
|
||||
Cancel All ({abortControllersRef.current.size})
|
||||
</Button>
|
||||
)}
|
||||
<Text type="secondary">Running: {testResults.filter((t) => t.status === 'running').length}</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Statistics */}
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="Total Tests" value={testResults.length} prefix={<Activity size={16} />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Successful"
|
||||
value={testResults.filter((t) => t.status === 'success').length}
|
||||
prefix={<CheckCircle size={16} />}
|
||||
valueStyle={{ color: '#3f8600' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Failed"
|
||||
value={testResults.filter((t) => t.status === 'error').length}
|
||||
prefix={<XCircle size={16} />}
|
||||
valueStyle={{ color: '#cf1322' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Cancelled"
|
||||
value={testResults.filter((t) => t.status === 'cancelled').length}
|
||||
prefix={<StopCircle size={16} />}
|
||||
valueStyle={{ color: '#d48806' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Performance Stats */}
|
||||
{performanceStats && (
|
||||
<Card title="Performance Statistics" size="small">
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Statistic title="Concurrent Requests" value={performanceStats.concurrentRequests} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="Total Time" value={performanceStats.totalTime} suffix="ms" />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="Average Time" value={Math.round(performanceStats.averageTime)} suffix="ms" />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="Success Rate"
|
||||
value={Math.round((performanceStats.successCount / performanceStats.concurrentRequests) * 100)}
|
||||
suffix="%"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Test Results Table */}
|
||||
<Card title="Advanced Test Results" size="small">
|
||||
{testResults.length === 0 ? (
|
||||
<Alert
|
||||
message="No advanced tests executed yet"
|
||||
description="Click any of the test buttons above to start testing advanced features"
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
dataSource={testResults}
|
||||
columns={tableColumns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{ pageSize: 15 }}
|
||||
scroll={{ x: true }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Space>
|
||||
</TestContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const TestContainer = styled.div`
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default DataApiAdvancedTests
|
||||
@ -0,0 +1,604 @@
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { Alert, Button, Card, Col, Divider, Input, message, Row, Space, Table, Tag, Typography } from 'antd'
|
||||
import { Check, Database, Play, RotateCcw, X } from 'lucide-react'
|
||||
import React, { useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { dataApiService } from '../../../data/DataApiService'
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
const { TextArea } = Input
|
||||
|
||||
const logger = loggerService.withContext('DataApiBasicTests')
|
||||
|
||||
interface TestResult {
|
||||
id: string
|
||||
name: string
|
||||
status: 'pending' | 'running' | 'success' | 'error'
|
||||
duration?: number
|
||||
response?: any
|
||||
error?: string
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
interface TestItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
type: string
|
||||
status: string
|
||||
priority: string
|
||||
tags: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
metadata: Record<string, any>
|
||||
}
|
||||
|
||||
const DataApiBasicTests: React.FC = () => {
|
||||
const [testResults, setTestResults] = useState<TestResult[]>([])
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [testItems, setTestItems] = useState<TestItem[]>([])
|
||||
const [selectedItem, setSelectedItem] = useState<TestItem | null>(null)
|
||||
const [newItemTitle, setNewItemTitle] = useState('')
|
||||
const [newItemDescription, setNewItemDescription] = useState('')
|
||||
const [newItemType, setNewItemType] = useState('data')
|
||||
|
||||
const updateTestResult = (id: string, updates: Partial<TestResult>) => {
|
||||
setTestResults((prev) => prev.map((result) => (result.id === id ? { ...result, ...updates } : result)))
|
||||
}
|
||||
|
||||
const runTest = async (testId: string, testName: string, testFn: () => Promise<any>) => {
|
||||
const startTime = Date.now()
|
||||
|
||||
updateTestResult(testId, {
|
||||
status: 'running',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
try {
|
||||
logger.info(`Starting test: ${testName}`)
|
||||
const response = await testFn()
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
logger.info(`Test completed: ${testName}`, { duration, response })
|
||||
|
||||
updateTestResult(testId, {
|
||||
status: 'success',
|
||||
duration,
|
||||
response,
|
||||
error: undefined
|
||||
})
|
||||
|
||||
message.success(`${testName} completed successfully`)
|
||||
return response
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
logger.error(`Test failed: ${testName}`, error as Error)
|
||||
|
||||
updateTestResult(testId, {
|
||||
status: 'error',
|
||||
duration,
|
||||
error: errorMessage,
|
||||
response: undefined
|
||||
})
|
||||
|
||||
message.error(`${testName} failed: ${errorMessage}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const initializeTests = () => {
|
||||
const tests: TestResult[] = [
|
||||
{ id: 'get-items', name: 'GET /test/items', status: 'pending' },
|
||||
{ id: 'create-item', name: 'POST /test/items', status: 'pending' },
|
||||
{ id: 'get-item-by-id', name: 'GET /test/items/:id', status: 'pending' },
|
||||
{ id: 'update-item', name: 'PUT /test/items/:id', status: 'pending' },
|
||||
{ id: 'delete-item', name: 'DELETE /test/items/:id', status: 'pending' },
|
||||
{ id: 'search-items', name: 'GET /test/search', status: 'pending' },
|
||||
{ id: 'get-stats', name: 'GET /test/stats', status: 'pending' }
|
||||
]
|
||||
|
||||
setTestResults(tests)
|
||||
setTestItems([])
|
||||
setSelectedItem(null)
|
||||
}
|
||||
|
||||
const runAllTests = async () => {
|
||||
if (isRunning) return
|
||||
|
||||
setIsRunning(true)
|
||||
initializeTests()
|
||||
|
||||
try {
|
||||
// Test 1: Get test items list
|
||||
const itemsResponse = await runTest('get-items', 'Get Test Items List', () => dataApiService.get('/test/items'))
|
||||
setTestItems(itemsResponse.items || [])
|
||||
|
||||
// Test 2: Create a new test item
|
||||
const newItem = {
|
||||
title: `Test Item ${Date.now()}`,
|
||||
description: 'This is a test item created by the DataApi test suite',
|
||||
type: 'data',
|
||||
status: 'active',
|
||||
priority: 'medium',
|
||||
tags: ['test', 'api'],
|
||||
metadata: { source: 'test-suite', version: '1.0.0' }
|
||||
}
|
||||
|
||||
const createResponse = await runTest('create-item', 'Create New Test Item', () =>
|
||||
dataApiService.post('/test/items', { body: newItem })
|
||||
)
|
||||
|
||||
// Debug: Log the create response
|
||||
logger.info('Create response received', { createResponse, id: createResponse?.id })
|
||||
|
||||
// Update items list with new item
|
||||
if (createResponse) {
|
||||
setTestItems((prev) => [createResponse, ...prev])
|
||||
setSelectedItem(createResponse)
|
||||
}
|
||||
|
||||
// Test 3: Get item by ID
|
||||
if (createResponse?.id) {
|
||||
logger.info('About to test get item by ID', { id: createResponse.id })
|
||||
await runTest('get-item-by-id', 'Get Test Item By ID', () =>
|
||||
dataApiService.get(`/test/items/${createResponse.id}`)
|
||||
)
|
||||
} else {
|
||||
logger.warn('Skipping get item by ID test - no valid item ID from create response', { createResponse })
|
||||
updateTestResult('get-item-by-id', {
|
||||
status: 'error',
|
||||
error: 'No valid item ID from create response'
|
||||
})
|
||||
}
|
||||
|
||||
// Only proceed with update/delete if we have a valid ID
|
||||
if (createResponse?.id) {
|
||||
// Test 4: Update item
|
||||
const updatedData = {
|
||||
title: `${createResponse.title} (Updated)`,
|
||||
description: `${createResponse.description}\n\nUpdated by test at ${new Date().toISOString()}`,
|
||||
priority: 'high'
|
||||
}
|
||||
|
||||
const updateResponse = await runTest('update-item', 'Update Test Item', () =>
|
||||
dataApiService.put(`/test/items/${createResponse.id}`, {
|
||||
body: updatedData
|
||||
})
|
||||
)
|
||||
|
||||
if (updateResponse) {
|
||||
setSelectedItem(updateResponse)
|
||||
setTestItems((prev) => prev.map((item) => (item.id === createResponse.id ? updateResponse : item)))
|
||||
}
|
||||
}
|
||||
|
||||
// Test 5: Search items
|
||||
await runTest('search-items', 'Search Test Items', () =>
|
||||
dataApiService.get('/test/search', {
|
||||
query: {
|
||||
query: 'test',
|
||||
page: 1,
|
||||
limit: 10
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Test 6: Get statistics
|
||||
await runTest('get-stats', 'Get Test Statistics', () => dataApiService.get('/test/stats'))
|
||||
|
||||
// Test 7: Delete item (optional, comment out to keep test data)
|
||||
// if (createResponse?.id) {
|
||||
// await runTest(
|
||||
// 'delete-item',
|
||||
// 'Delete Test Item',
|
||||
// () => dataApiService.delete(`/test/items/${createResponse.id}`)
|
||||
// )
|
||||
// }
|
||||
|
||||
message.success('All basic tests completed!')
|
||||
} catch (error) {
|
||||
logger.error('Test suite failed', error as Error)
|
||||
message.error('Test suite execution failed')
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runSingleTest = async (testId: string) => {
|
||||
if (isRunning) return
|
||||
|
||||
switch (testId) {
|
||||
case 'create-item': {
|
||||
if (!newItemTitle.trim()) {
|
||||
message.warning('Please enter an item title')
|
||||
return
|
||||
}
|
||||
|
||||
const createResponse = await runTest(testId, 'Create New Item', () =>
|
||||
dataApiService.post('/test/items', {
|
||||
body: {
|
||||
title: newItemTitle,
|
||||
description: newItemDescription,
|
||||
type: newItemType
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if (createResponse) {
|
||||
setTestItems((prev) => [createResponse, ...prev])
|
||||
setNewItemTitle('')
|
||||
setNewItemDescription('')
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
message.warning(`Single test execution not implemented for ${testId}`)
|
||||
}
|
||||
}
|
||||
|
||||
const resetTests = () => {
|
||||
setTestResults([])
|
||||
setTestItems([])
|
||||
setSelectedItem(null)
|
||||
setNewItemTitle('')
|
||||
setNewItemDescription('')
|
||||
message.info('Tests reset')
|
||||
}
|
||||
|
||||
const resetTestData = async () => {
|
||||
try {
|
||||
await dataApiService.post('/test/reset', {})
|
||||
message.success('Test data reset successfully')
|
||||
setTestItems([])
|
||||
setSelectedItem(null)
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset test data', error as Error)
|
||||
message.error('Failed to reset test data')
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: TestResult['status']) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'success'
|
||||
case 'error':
|
||||
return 'error'
|
||||
case 'running':
|
||||
return 'processing'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: TestResult['status']) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <Check size={16} />
|
||||
case 'error':
|
||||
return <X size={16} />
|
||||
case 'running':
|
||||
return <RotateCcw size={16} className="animate-spin" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const tableColumns = [
|
||||
{
|
||||
title: 'Test',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (name: string) => <Text code>{name}</Text>
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: TestResult['status']) => (
|
||||
<Tag color={getStatusColor(status)} icon={getStatusIcon(status)}>
|
||||
{status.toUpperCase()}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Duration',
|
||||
dataIndex: 'duration',
|
||||
key: 'duration',
|
||||
render: (duration?: number) => (duration ? `${duration}ms` : '-')
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
render: (_: any, record: TestResult) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<Play size={14} />}
|
||||
onClick={() => runSingleTest(record.id)}
|
||||
disabled={isRunning}>
|
||||
Run
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
const itemsColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
render: (id: string) => <Text code>{id.substring(0, 20)}...</Text>
|
||||
},
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
key: 'title'
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
render: (type: string) => <Tag>{type}</Tag>
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => <Tag color={status === 'active' ? 'green' : 'orange'}>{status}</Tag>
|
||||
},
|
||||
{
|
||||
title: 'Priority',
|
||||
dataIndex: 'priority',
|
||||
key: 'priority',
|
||||
render: (priority: string) => (
|
||||
<Tag color={priority === 'high' ? 'red' : priority === 'medium' ? 'orange' : 'blue'}>{priority}</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
render: (_: any, record: TestItem) => (
|
||||
<Button size="small" type="link" onClick={() => setSelectedItem(record)}>
|
||||
View
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<TestContainer>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{/* Control Panel */}
|
||||
<Card size="small">
|
||||
<Row gutter={16} align="middle">
|
||||
<Col span={12}>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Play size={16} />}
|
||||
onClick={runAllTests}
|
||||
loading={isRunning}
|
||||
disabled={isRunning}>
|
||||
Run All Basic Tests
|
||||
</Button>
|
||||
<Button icon={<RotateCcw size={16} />} onClick={resetTests} disabled={isRunning}>
|
||||
Reset Tests
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Space style={{ float: 'right' }}>
|
||||
<Button icon={<Database size={16} />} onClick={resetTestData} disabled={isRunning}>
|
||||
Reset Test Data
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Test Results */}
|
||||
<Card title="Test Results" size="small">
|
||||
{testResults.length === 0 ? (
|
||||
<Alert
|
||||
message="No tests executed yet"
|
||||
description="Click 'Run All Basic Tests' to start testing basic CRUD operations"
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
dataSource={testResults}
|
||||
columns={tableColumns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ x: true }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Manual Testing */}
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card title="Create Item Manually" size="small">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="Item Title"
|
||||
value={newItemTitle}
|
||||
onChange={(e) => setNewItemTitle(e.target.value)}
|
||||
/>
|
||||
<TextArea
|
||||
placeholder="Item Description (optional)"
|
||||
value={newItemDescription}
|
||||
onChange={(e) => setNewItemDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<Input placeholder="Item Type" value={newItemType} onChange={(e) => setNewItemType(e.target.value)} />
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => runSingleTest('create-item')}
|
||||
disabled={isRunning || !newItemTitle.trim()}
|
||||
block>
|
||||
Create Item
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Card title="Selected Item Details" size="small">
|
||||
{selectedItem ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<strong>ID:</strong> <Text code>{selectedItem.id}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Title:</strong> {selectedItem.title}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Type:</strong> <Tag>{selectedItem.type}</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Status:</strong>{' '}
|
||||
<Tag color={selectedItem.status === 'active' ? 'green' : 'orange'}>{selectedItem.status}</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Priority:</strong>{' '}
|
||||
<Tag
|
||||
color={
|
||||
selectedItem.priority === 'high'
|
||||
? 'red'
|
||||
: selectedItem.priority === 'medium'
|
||||
? 'orange'
|
||||
: 'blue'
|
||||
}>
|
||||
{selectedItem.priority}
|
||||
</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Created:</strong> {new Date(selectedItem.createdAt).toLocaleString()}
|
||||
</div>
|
||||
{selectedItem.description && (
|
||||
<div>
|
||||
<strong>Description:</strong>
|
||||
<Paragraph ellipsis={{ rows: 3, expandable: true }}>{selectedItem.description}</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
{selectedItem.tags && selectedItem.tags.length > 0 && (
|
||||
<div>
|
||||
<strong>Tags:</strong>{' '}
|
||||
{selectedItem.tags.map((tag: string) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
) : (
|
||||
<Text type="secondary">No item selected</Text>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Items Table */}
|
||||
<Card title={`Test Items (${testItems.length})`} size="small">
|
||||
{testItems.length === 0 ? (
|
||||
<Alert
|
||||
message="No items loaded"
|
||||
description="Run the tests or create a new item to see data here"
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
dataSource={testItems}
|
||||
columns={itemsColumns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{ pageSize: 10 }}
|
||||
scroll={{ x: true }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Test Details */}
|
||||
{testResults.some((t) => t.response || t.error) && (
|
||||
<Card title="Test Details" size="small">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{testResults.map(
|
||||
(result) =>
|
||||
(result.response || result.error) && (
|
||||
<div key={result.id}>
|
||||
<Divider orientation="left" plain>
|
||||
<Text code>{result.name}</Text> -
|
||||
<Tag color={getStatusColor(result.status)}>{result.status}</Tag>
|
||||
</Divider>
|
||||
{result.response && (
|
||||
<div>
|
||||
<Text strong>Response:</Text>
|
||||
<pre
|
||||
style={{
|
||||
background: '#f5f5f5',
|
||||
padding: 8,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
{JSON.stringify(result.response, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{result.error && (
|
||||
<div>
|
||||
<Text strong type="danger">
|
||||
Error:
|
||||
</Text>
|
||||
<pre
|
||||
style={{
|
||||
background: '#fff2f0',
|
||||
padding: 8,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
overflow: 'auto',
|
||||
border: '1px solid #ffccc7'
|
||||
}}>
|
||||
{result.error}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
</Space>
|
||||
</TestContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const TestContainer = styled.div`
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 8px 0;
|
||||
max-height: 200px;
|
||||
}
|
||||
`
|
||||
|
||||
export default DataApiBasicTests
|
||||
@ -0,0 +1,658 @@
|
||||
import { prefetch, useInvalidateCache, useMutation, usePaginatedQuery, useQuery } from '@renderer/data/hooks/useDataApi'
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { Alert, Button, Card, Col, Input, message, Row, Space, Spin, Table, Tag, Typography } from 'antd'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Database,
|
||||
Edit,
|
||||
Eye,
|
||||
GitBranch,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
Zap
|
||||
} from 'lucide-react'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Text } = Typography
|
||||
const { TextArea } = Input
|
||||
|
||||
const logger = loggerService.withContext('DataApiHookTests')
|
||||
|
||||
interface TestItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
status: string
|
||||
type: string
|
||||
priority: string
|
||||
tags: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
metadata: Record<string, any>
|
||||
}
|
||||
|
||||
const DataApiHookTests: React.FC = () => {
|
||||
// Hook for cache invalidation
|
||||
const invalidateCache = useInvalidateCache()
|
||||
|
||||
// State for manual testing
|
||||
const [selectedItemId, setSelectedItemId] = useState<string>('')
|
||||
const [newItemTitle, setNewItemTitle] = useState('')
|
||||
const [newItemDescription, setNewItemDescription] = useState('')
|
||||
const [updateTitle, setUpdateTitle] = useState('')
|
||||
const [updateDescription, setUpdateDescription] = useState('')
|
||||
const [queryParams, setQueryParams] = useState({ page: 1, limit: 5 })
|
||||
const [cacheTestCount, setCacheTestCount] = useState(0)
|
||||
|
||||
// useQuery hook test - Basic data fetching
|
||||
const {
|
||||
data: itemsData,
|
||||
loading: itemsLoading,
|
||||
error: itemsError,
|
||||
refetch: refreshItems
|
||||
} = useQuery('/test/items' as any, {
|
||||
query: queryParams,
|
||||
swrOptions: {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 2000
|
||||
}
|
||||
})
|
||||
|
||||
// useQuery hook test - Single item by ID
|
||||
const {
|
||||
data: singleItem,
|
||||
loading: singleItemLoading,
|
||||
error: singleItemError,
|
||||
refetch: refreshSingleItem
|
||||
} = useQuery(selectedItemId ? `/test/items/${selectedItemId}` : (null as any), {
|
||||
enabled: !!selectedItemId,
|
||||
swrOptions: {
|
||||
revalidateOnFocus: false
|
||||
}
|
||||
})
|
||||
|
||||
// usePaginatedQuery hook test
|
||||
const {
|
||||
items: paginatedItems,
|
||||
total: totalItems,
|
||||
page: currentPage,
|
||||
loading: paginatedLoading,
|
||||
error: paginatedError,
|
||||
hasMore,
|
||||
hasPrev,
|
||||
prevPage,
|
||||
nextPage,
|
||||
refresh: refreshPaginated,
|
||||
reset: resetPagination
|
||||
} = usePaginatedQuery('/test/items' as any, {
|
||||
query: { type: 'test' },
|
||||
limit: 3,
|
||||
swrOptions: {
|
||||
revalidateOnFocus: false
|
||||
}
|
||||
})
|
||||
|
||||
// useMutation hook tests
|
||||
const createItemMutation = useMutation('POST', '/test/items', {
|
||||
onSuccess: (data) => {
|
||||
logger.info('Item created successfully', { data })
|
||||
message.success('Item created!')
|
||||
setNewItemTitle('')
|
||||
setNewItemDescription('')
|
||||
// Invalidate cache to refresh the list
|
||||
invalidateCache(['/test/items'])
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to create item', error as Error)
|
||||
message.error(`Failed to create item: ${error.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
const updateItemMutation = useMutation(
|
||||
'PUT',
|
||||
selectedItemId ? `/test/items/${selectedItemId}` : '/test/items/placeholder',
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
logger.info('Item updated successfully', { data })
|
||||
message.success('Item updated!')
|
||||
setUpdateTitle('')
|
||||
setUpdateDescription('')
|
||||
// Invalidate specific item and items list
|
||||
invalidateCache(['/test/items', ...(selectedItemId ? [`/test/items/${selectedItemId}`] : [])])
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to update item', error as Error)
|
||||
message.error(`Failed to update item: ${error.message}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const deleteItemMutation = useMutation(
|
||||
'DELETE',
|
||||
selectedItemId ? `/test/items/${selectedItemId}` : '/test/items/placeholder',
|
||||
{
|
||||
onSuccess: () => {
|
||||
logger.info('Item deleted successfully')
|
||||
message.success('Item deleted!')
|
||||
setSelectedItemId('')
|
||||
// Invalidate cache
|
||||
invalidateCache(['/test/items'])
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to delete item', error as Error)
|
||||
message.error(`Failed to delete item: ${error.message}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// useMutation with optimistic updates
|
||||
const optimisticUpdateMutation = useMutation(
|
||||
'PUT',
|
||||
selectedItemId ? `/test/items/${selectedItemId}` : '/test/items/placeholder',
|
||||
{
|
||||
optimistic: true,
|
||||
optimisticData: { title: 'Optimistically Updated Item' },
|
||||
revalidate: ['/test/items']
|
||||
}
|
||||
)
|
||||
|
||||
// Handle functions
|
||||
const handleCreateItem = useCallback(async () => {
|
||||
if (!newItemTitle.trim()) {
|
||||
message.warning('Please enter an item title')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createItemMutation.mutate({
|
||||
body: {
|
||||
title: newItemTitle,
|
||||
description: newItemDescription,
|
||||
type: 'hook-test'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
// Error already handled by mutation
|
||||
}
|
||||
}, [newItemTitle, newItemDescription, createItemMutation])
|
||||
|
||||
const handleUpdateTopic = useCallback(async () => {
|
||||
if (!selectedItemId || !updateTitle.trim()) {
|
||||
message.warning('Please select an item and enter a title')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateItemMutation.mutate({
|
||||
body: {
|
||||
title: updateTitle,
|
||||
description: updateDescription
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
// Error already handled by mutation
|
||||
}
|
||||
}, [selectedItemId, updateTitle, updateDescription, updateItemMutation])
|
||||
|
||||
const handleDeleteTopic = useCallback(async () => {
|
||||
if (!selectedItemId) {
|
||||
message.warning('Please select an item to delete')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteItemMutation.mutate()
|
||||
} catch (error) {
|
||||
// Error already handled by mutation
|
||||
}
|
||||
}, [selectedItemId, deleteItemMutation])
|
||||
|
||||
const handleOptimisticUpdate = useCallback(async () => {
|
||||
if (!selectedItemId || !singleItem) {
|
||||
message.warning('Please select an item for optimistic update')
|
||||
return
|
||||
}
|
||||
|
||||
const optimisticData = {
|
||||
...(singleItem || {}),
|
||||
title: `${(singleItem as any)?.title || 'Unknown'} (Optimistic Update)`,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
try {
|
||||
await optimisticUpdateMutation.mutate({
|
||||
body: {
|
||||
title: optimisticData.title,
|
||||
description: (singleItem as any)?.description
|
||||
}
|
||||
})
|
||||
message.success('Optimistic update completed!')
|
||||
} catch (error) {
|
||||
message.error(`Optimistic update failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}, [selectedItemId, singleItem, optimisticUpdateMutation])
|
||||
|
||||
const handlePrefetch = useCallback(async () => {
|
||||
try {
|
||||
// Prefetch the next page of items
|
||||
await prefetch('/test/items', {
|
||||
query: { page: queryParams.page + 1, limit: queryParams.limit }
|
||||
})
|
||||
message.success('Next page prefetched!')
|
||||
} catch (error) {
|
||||
message.error('Prefetch failed')
|
||||
}
|
||||
}, [queryParams])
|
||||
|
||||
const handleCacheTest = useCallback(async () => {
|
||||
// Test cache invalidation and refresh
|
||||
setCacheTestCount((prev) => prev + 1)
|
||||
|
||||
if (cacheTestCount % 3 === 0) {
|
||||
await invalidateCache()
|
||||
message.info('All cache invalidated')
|
||||
} else if (cacheTestCount % 3 === 1) {
|
||||
await invalidateCache('/test/items')
|
||||
message.info('Topics cache invalidated')
|
||||
} else {
|
||||
refreshItems()
|
||||
message.info('Topics refreshed')
|
||||
}
|
||||
}, [cacheTestCount, refreshItems, invalidateCache])
|
||||
|
||||
const itemsColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
render: (id: string) => (
|
||||
<Text code style={{ fontSize: 11 }}>
|
||||
{id.substring(0, 15)}...
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
render: (type: string) => <Tag>{type}</Tag>
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => <Tag color={status === 'active' ? 'green' : 'orange'}>{status}</Tag>
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
render: (_: any, record: TestItem) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<Eye size={12} />}
|
||||
onClick={() => {
|
||||
setSelectedItemId(record.id)
|
||||
setUpdateTitle(record.title)
|
||||
setUpdateDescription((record as any).description || '')
|
||||
}}>
|
||||
Select
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<TestContainer>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{/* Hook Status Overview */}
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Space direction="vertical" align="center" style={{ width: '100%' }}>
|
||||
<Database size={20} />
|
||||
<Text strong>useQuery</Text>
|
||||
{itemsLoading ? (
|
||||
<Spin size="small" />
|
||||
) : itemsError ? (
|
||||
<Tag color="red">Error</Tag>
|
||||
) : (
|
||||
<Tag color="green">Active</Tag>
|
||||
)}
|
||||
<Text type="secondary">{(itemsData as any)?.items?.length || 0} items</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Space direction="vertical" align="center" style={{ width: '100%' }}>
|
||||
<TrendingUp size={20} />
|
||||
<Text strong>usePaginated</Text>
|
||||
{paginatedLoading ? (
|
||||
<Spin size="small" />
|
||||
) : paginatedError ? (
|
||||
<Tag color="red">Error</Tag>
|
||||
) : (
|
||||
<Tag color="green">Active</Tag>
|
||||
)}
|
||||
<Text type="secondary">
|
||||
{paginatedItems.length} / {totalItems}
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Space direction="vertical" align="center" style={{ width: '100%' }}>
|
||||
<GitBranch size={20} />
|
||||
<Text strong>useMutation</Text>
|
||||
{createItemMutation.loading || updateItemMutation.loading || deleteItemMutation.loading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<Tag color="blue">Ready</Tag>
|
||||
)}
|
||||
<Text type="secondary">CRUD Operations</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Space direction="vertical" align="center" style={{ width: '100%' }}>
|
||||
<Zap size={20} />
|
||||
<Text strong>Cache</Text>
|
||||
<Tag color="purple">Active</Tag>
|
||||
<Text type="secondary">Test #{cacheTestCount}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* useQuery Test - Basic Topics List */}
|
||||
<Card title="useQuery Hook Test - Topics List" size="small">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Space>
|
||||
<Text>Page: {queryParams.page}</Text>
|
||||
<Text>Limit: {queryParams.limit}</Text>
|
||||
{itemsLoading && <Spin size="small" />}
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button size="small" icon={<RefreshCw size={14} />} onClick={refreshItems} loading={itemsLoading}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="small" onClick={handlePrefetch}>
|
||||
Prefetch Next
|
||||
</Button>
|
||||
<Button size="small" onClick={handleCacheTest}>
|
||||
Cache Test
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{itemsError ? (
|
||||
<Alert message="useQuery Error" description={itemsError.message} type="error" showIcon />
|
||||
) : (
|
||||
<Table
|
||||
dataSource={(itemsData as any)?.items || []}
|
||||
columns={itemsColumns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
loading={itemsLoading}
|
||||
scroll={{ x: true }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Row justify="space-between">
|
||||
<Col>
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={queryParams.page <= 1}
|
||||
onClick={() => setQueryParams((prev) => ({ ...prev, page: prev.page - 1 }))}>
|
||||
Previous
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setQueryParams((prev) => ({ ...prev, page: prev.page + 1 }))}>
|
||||
Next
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Text type="secondary">Total: {(itemsData as any)?.total || 0} items</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Single Topic Query */}
|
||||
<Card title="useQuery Hook Test - Single Topic" size="small">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="Enter item ID"
|
||||
value={selectedItemId}
|
||||
onChange={(e) => setSelectedItemId(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={refreshSingleItem}
|
||||
loading={singleItemLoading}
|
||||
disabled={!selectedItemId}
|
||||
block>
|
||||
Fetch Topic
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
{singleItemLoading ? (
|
||||
<Spin />
|
||||
) : singleItemError ? (
|
||||
<Alert message="Error" description={singleItemError.message} type="error" showIcon />
|
||||
) : singleItem ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<strong>Title:</strong> {(singleItem as any)?.title || 'N/A'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Type:</strong> <Tag>{(singleItem as any)?.type || 'N/A'}</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Status:</strong>{' '}
|
||||
<Tag color={(singleItem as any)?.status === 'active' ? 'green' : 'orange'}>
|
||||
{(singleItem as any)?.status || 'N/A'}
|
||||
</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Updated:</strong> {new Date((singleItem as any)?.updatedAt || Date.now()).toLocaleString()}
|
||||
</div>
|
||||
</Space>
|
||||
) : (
|
||||
<Text type="secondary">No item selected</Text>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* usePaginatedQuery Test */}
|
||||
<Card title="usePaginatedQuery Hook Test" size="small">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Space>
|
||||
<Text>Page: {currentPage}</Text>
|
||||
<Text>Items: {paginatedItems.length}</Text>
|
||||
<Text>Total: {totalItems}</Text>
|
||||
{paginatedLoading && <Spin size="small" />}
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button size="small" icon={<ArrowLeft size={14} />} onClick={prevPage} disabled={!hasPrev}>
|
||||
Previous
|
||||
</Button>
|
||||
<Button size="small" icon={<ArrowRight size={14} />} onClick={nextPage} disabled={!hasMore}>
|
||||
{hasMore ? 'Load More' : 'No More'}
|
||||
</Button>
|
||||
<Button size="small" icon={<RefreshCw size={14} />} onClick={refreshPaginated}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="small" onClick={resetPagination}>
|
||||
Reset
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{paginatedError ? (
|
||||
<Alert message="usePaginatedQuery Error" description={paginatedError.message} type="error" showIcon />
|
||||
) : (
|
||||
<div style={{ maxHeight: 300, overflow: 'auto' }}>
|
||||
{paginatedItems.map((item, index) => {
|
||||
const typedItem = item as any
|
||||
return (
|
||||
<Card key={typedItem.id} size="small" style={{ marginBottom: 8 }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col span={18}>
|
||||
<Space direction="vertical" size="small">
|
||||
<Text strong>{typedItem.title}</Text>
|
||||
<Space>
|
||||
<Tag>{typedItem.type}</Tag>
|
||||
<Tag color={typedItem.status === 'active' ? 'green' : 'orange'}>{typedItem.status}</Tag>
|
||||
</Space>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Text type="secondary">#{index + 1}</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* useMutation Tests */}
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card title="useMutation - Create Topic" size="small">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="Topic Title"
|
||||
value={newItemTitle}
|
||||
onChange={(e) => setNewItemTitle(e.target.value)}
|
||||
/>
|
||||
<TextArea
|
||||
placeholder="Topic Content (optional)"
|
||||
value={newItemDescription}
|
||||
onChange={(e) => setNewItemDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={handleCreateItem}
|
||||
loading={createItemMutation.loading}
|
||||
disabled={!newItemTitle.trim()}
|
||||
block>
|
||||
Create Topic
|
||||
</Button>
|
||||
{createItemMutation.error && (
|
||||
<Alert
|
||||
message="Creation Error"
|
||||
description={createItemMutation.error.message}
|
||||
type="error"
|
||||
showIcon
|
||||
closable
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Card title="useMutation - Update Topic" size="small">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Input placeholder="New Title" value={updateTitle} onChange={(e) => setUpdateTitle(e.target.value)} />
|
||||
<TextArea
|
||||
placeholder="New Content"
|
||||
value={updateDescription}
|
||||
onChange={(e) => setUpdateDescription(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
<Space style={{ width: '100%' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Edit size={14} />}
|
||||
onClick={handleUpdateTopic}
|
||||
loading={updateItemMutation.loading}
|
||||
disabled={!selectedItemId || !updateTitle.trim()}>
|
||||
Update
|
||||
</Button>
|
||||
<Button icon={<Zap size={14} />} onClick={handleOptimisticUpdate} disabled={!selectedItemId}>
|
||||
Optimistic
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<Trash2 size={14} />}
|
||||
onClick={handleDeleteTopic}
|
||||
loading={deleteItemMutation.loading}
|
||||
disabled={!selectedItemId}>
|
||||
Delete
|
||||
</Button>
|
||||
</Space>
|
||||
{(updateItemMutation.error || deleteItemMutation.error) && (
|
||||
<Alert
|
||||
message="Operation Error"
|
||||
description={(updateItemMutation.error || deleteItemMutation.error)?.message}
|
||||
type="error"
|
||||
showIcon
|
||||
closable
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
</TestContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const TestContainer = styled.div`
|
||||
.ant-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ant-table-small .ant-table-tbody > tr > td {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.ant-tag {
|
||||
border-radius: 4px;
|
||||
}
|
||||
`
|
||||
|
||||
export default DataApiHookTests
|
||||
@ -0,0 +1,770 @@
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
InputNumber,
|
||||
message,
|
||||
Progress,
|
||||
Row,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Typography
|
||||
} from 'antd'
|
||||
import { Activity, AlertTriangle, Cpu, Gauge, RefreshCw, StopCircle, Timer, TrendingUp, Zap } from 'lucide-react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { dataApiService } from '../../../data/DataApiService'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const logger = loggerService.withContext('DataApiStressTests')
|
||||
|
||||
interface StressTestConfig {
|
||||
concurrentRequests: number
|
||||
totalRequests: number
|
||||
requestDelay: number
|
||||
testDuration: number // in seconds
|
||||
errorRate: number // percentage of requests that should fail
|
||||
}
|
||||
|
||||
interface StressTestResult {
|
||||
id: string
|
||||
requestId: number
|
||||
startTime: number
|
||||
endTime?: number
|
||||
duration?: number
|
||||
status: 'pending' | 'success' | 'error' | 'timeout'
|
||||
response?: any
|
||||
error?: string
|
||||
memoryBefore?: number
|
||||
memoryAfter?: number
|
||||
}
|
||||
|
||||
interface StressTestSummary {
|
||||
totalRequests: number
|
||||
completedRequests: number
|
||||
successfulRequests: number
|
||||
failedRequests: number
|
||||
timeoutRequests: number
|
||||
averageResponseTime: number
|
||||
minResponseTime: number
|
||||
maxResponseTime: number
|
||||
requestsPerSecond: number
|
||||
errorRate: number
|
||||
memoryUsage: {
|
||||
initial: number
|
||||
peak: number
|
||||
final: number
|
||||
leaked: number
|
||||
}
|
||||
testDuration: number
|
||||
}
|
||||
|
||||
const DataApiStressTests: React.FC = () => {
|
||||
const [config, setConfig] = useState<StressTestConfig>({
|
||||
concurrentRequests: 10,
|
||||
totalRequests: 100,
|
||||
requestDelay: 100,
|
||||
testDuration: 60,
|
||||
errorRate: 10
|
||||
})
|
||||
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [currentTest, setCurrentTest] = useState<string>('')
|
||||
const [results, setResults] = useState<StressTestResult[]>([])
|
||||
const [summary, setSummary] = useState<StressTestSummary | null>(null)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [realtimeStats, setRealtimeStats] = useState({
|
||||
rps: 0,
|
||||
avgResponseTime: 0,
|
||||
errorRate: 0,
|
||||
memoryUsage: 0
|
||||
})
|
||||
|
||||
// Test control
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const testStartTimeRef = useRef<number>(0)
|
||||
const completedCountRef = useRef<number>(0)
|
||||
const statsIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Memory monitoring
|
||||
const getMemoryUsage = () => {
|
||||
if ((performance as any).memory) {
|
||||
return (performance as any).memory.usedJSHeapSize
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Update realtime statistics
|
||||
const updateRealtimeStats = useCallback(() => {
|
||||
const completedResults = results.filter((r) => r.status !== 'pending')
|
||||
const errorResults = completedResults.filter((r) => r.status === 'error')
|
||||
|
||||
if (completedResults.length === 0) return
|
||||
|
||||
const durations = completedResults.map((r) => r.duration || 0).filter((d) => d > 0)
|
||||
const avgResponseTime = durations.length > 0 ? durations.reduce((sum, d) => sum + d, 0) / durations.length : 0
|
||||
|
||||
const elapsedTime = (Date.now() - testStartTimeRef.current) / 1000
|
||||
const rps = elapsedTime > 0 ? completedResults.length / elapsedTime : 0
|
||||
const errorRate = completedResults.length > 0 ? (errorResults.length / completedResults.length) * 100 : 0
|
||||
|
||||
setRealtimeStats({
|
||||
rps: Math.round(rps * 100) / 100,
|
||||
avgResponseTime: Math.round(avgResponseTime),
|
||||
errorRate: Math.round(errorRate * 100) / 100,
|
||||
memoryUsage: Math.round((getMemoryUsage() / 1024 / 1024) * 100) / 100 // MB
|
||||
})
|
||||
}, [results])
|
||||
|
||||
// Update statistics every second during testing
|
||||
useEffect(() => {
|
||||
if (isRunning) {
|
||||
statsIntervalRef.current = setInterval(updateRealtimeStats, 1000)
|
||||
} else if (statsIntervalRef.current) {
|
||||
clearInterval(statsIntervalRef.current)
|
||||
statsIntervalRef.current = null
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (statsIntervalRef.current) {
|
||||
clearInterval(statsIntervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [isRunning, updateRealtimeStats])
|
||||
|
||||
const executeStressTest = async (
|
||||
testName: string,
|
||||
testFn: (requestId: number, signal: AbortSignal) => Promise<any>
|
||||
) => {
|
||||
setIsRunning(true)
|
||||
setCurrentTest(testName)
|
||||
setResults([])
|
||||
setSummary(null)
|
||||
setProgress(0)
|
||||
completedCountRef.current = 0
|
||||
testStartTimeRef.current = Date.now()
|
||||
|
||||
// Create abort controller
|
||||
abortControllerRef.current = new AbortController()
|
||||
const { signal } = abortControllerRef.current
|
||||
|
||||
const initialMemory = getMemoryUsage()
|
||||
let peakMemory = initialMemory
|
||||
|
||||
logger.info(`Starting stress test: ${testName}`, config)
|
||||
|
||||
try {
|
||||
const testResults: StressTestResult[] = []
|
||||
|
||||
// Initialize pending results
|
||||
for (let i = 0; i < config.totalRequests; i++) {
|
||||
testResults.push({
|
||||
id: `request-${i}`,
|
||||
requestId: i,
|
||||
startTime: 0,
|
||||
status: 'pending'
|
||||
})
|
||||
}
|
||||
setResults([...testResults])
|
||||
|
||||
// Execute requests with controlled concurrency
|
||||
const executeRequest = async (requestId: number): Promise<void> => {
|
||||
if (signal.aborted) return
|
||||
|
||||
const startTime = Date.now()
|
||||
const memoryBefore = getMemoryUsage()
|
||||
|
||||
// Update memory peak
|
||||
peakMemory = Math.max(peakMemory, memoryBefore)
|
||||
|
||||
testResults[requestId].startTime = startTime
|
||||
testResults[requestId].memoryBefore = memoryBefore
|
||||
setResults([...testResults])
|
||||
|
||||
try {
|
||||
const response = await testFn(requestId, signal)
|
||||
const endTime = Date.now()
|
||||
const duration = endTime - startTime
|
||||
const memoryAfter = getMemoryUsage()
|
||||
|
||||
testResults[requestId] = {
|
||||
...testResults[requestId],
|
||||
endTime,
|
||||
duration,
|
||||
status: 'success',
|
||||
response,
|
||||
memoryAfter
|
||||
}
|
||||
|
||||
completedCountRef.current++
|
||||
setProgress((completedCountRef.current / config.totalRequests) * 100)
|
||||
} catch (error) {
|
||||
const endTime = Date.now()
|
||||
const duration = endTime - startTime
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
testResults[requestId] = {
|
||||
...testResults[requestId],
|
||||
endTime,
|
||||
duration,
|
||||
status: errorMessage.includes('timeout') ? 'timeout' : 'error',
|
||||
error: errorMessage,
|
||||
memoryAfter: getMemoryUsage()
|
||||
}
|
||||
|
||||
completedCountRef.current++
|
||||
setProgress((completedCountRef.current / config.totalRequests) * 100)
|
||||
}
|
||||
|
||||
setResults([...testResults])
|
||||
}
|
||||
|
||||
// Control concurrency
|
||||
let activeRequests = 0
|
||||
let nextRequestId = 0
|
||||
|
||||
const processNextRequest = async () => {
|
||||
if (nextRequestId >= config.totalRequests || signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = nextRequestId++
|
||||
activeRequests++
|
||||
|
||||
await executeRequest(requestId)
|
||||
|
||||
activeRequests--
|
||||
|
||||
// Add delay between requests if configured
|
||||
if (config.requestDelay > 0 && !signal.aborted) {
|
||||
await new Promise((resolve) => setTimeout(resolve, config.requestDelay))
|
||||
}
|
||||
|
||||
// Continue processing if we haven't reached limits
|
||||
if (activeRequests < config.concurrentRequests && nextRequestId < config.totalRequests) {
|
||||
processNextRequest()
|
||||
}
|
||||
}
|
||||
|
||||
// Start initial concurrent requests
|
||||
const initialRequests = Math.min(config.concurrentRequests, config.totalRequests)
|
||||
for (let i = 0; i < initialRequests; i++) {
|
||||
processNextRequest()
|
||||
}
|
||||
|
||||
// Wait for all requests to complete or timeout
|
||||
const maxWaitTime = config.testDuration * 1000
|
||||
const waitStartTime = Date.now()
|
||||
|
||||
while (completedCountRef.current < config.totalRequests && !signal.aborted) {
|
||||
if (Date.now() - waitStartTime > maxWaitTime) {
|
||||
logger.warn('Stress test timeout reached')
|
||||
break
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
// Calculate final summary
|
||||
const finalMemory = getMemoryUsage()
|
||||
const completedResults = testResults.filter((r) => r.status !== 'pending')
|
||||
const failedResults = completedResults.filter((r) => r.status === 'error')
|
||||
const timeoutResults = completedResults.filter((r) => r.status === 'timeout')
|
||||
|
||||
const durations = completedResults.map((r) => r.duration || 0).filter((d) => d > 0)
|
||||
const avgResponseTime = durations.length > 0 ? durations.reduce((sum, d) => sum + d, 0) / durations.length : 0
|
||||
const minResponseTime = durations.length > 0 ? Math.min(...durations) : 0
|
||||
const maxResponseTime = durations.length > 0 ? Math.max(...durations) : 0
|
||||
|
||||
const testDuration = (Date.now() - testStartTimeRef.current) / 1000
|
||||
const requestsPerSecond = testDuration > 0 ? completedResults.length / testDuration : 0
|
||||
const errorRate = completedResults.length > 0 ? (failedResults.length / completedResults.length) * 100 : 0
|
||||
|
||||
const testSummary: StressTestSummary = {
|
||||
totalRequests: config.totalRequests,
|
||||
completedRequests: completedResults.length,
|
||||
successfulRequests: completedResults.filter((r) => r.status === 'success').length,
|
||||
failedRequests: failedResults.length,
|
||||
timeoutRequests: timeoutResults.length,
|
||||
averageResponseTime: Math.round(avgResponseTime),
|
||||
minResponseTime,
|
||||
maxResponseTime,
|
||||
requestsPerSecond: Math.round(requestsPerSecond * 100) / 100,
|
||||
errorRate: Math.round(errorRate * 100) / 100,
|
||||
memoryUsage: {
|
||||
initial: Math.round((initialMemory / 1024 / 1024) * 100) / 100,
|
||||
peak: Math.round((peakMemory / 1024 / 1024) * 100) / 100,
|
||||
final: Math.round((finalMemory / 1024 / 1024) * 100) / 100,
|
||||
leaked: Math.round(((finalMemory - initialMemory) / 1024 / 1024) * 100) / 100
|
||||
},
|
||||
testDuration: Math.round(testDuration * 100) / 100
|
||||
}
|
||||
|
||||
setSummary(testSummary)
|
||||
|
||||
logger.info(`Stress test completed: ${testName}`, testSummary)
|
||||
const successfulCount = completedResults.filter((r) => r.status === 'success').length
|
||||
message.success(`Stress test completed! ${successfulCount}/${config.totalRequests} requests succeeded`)
|
||||
} catch (error) {
|
||||
logger.error('Stress test failed', error as Error)
|
||||
message.error('Stress test failed')
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
setCurrentTest('')
|
||||
abortControllerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const runConcurrentRequestsTest = async () => {
|
||||
await executeStressTest('Concurrent Requests Test', async (requestId, signal) => {
|
||||
// Alternate between different endpoints to simulate real usage
|
||||
const endpoints = ['/test/items', '/test/stats'] as const
|
||||
const endpoint = endpoints[requestId % endpoints.length]
|
||||
|
||||
return await dataApiService.get(endpoint, { signal })
|
||||
})
|
||||
}
|
||||
|
||||
const runMemoryLeakTest = async () => {
|
||||
await executeStressTest('Memory Leak Test', async (requestId, signal) => {
|
||||
// Create and immediately cancel some requests to test cleanup
|
||||
if (requestId % 5 === 0) {
|
||||
const { signal: cancelSignal, cancel } = dataApiService.createAbortController()
|
||||
const requestPromise = dataApiService.post('/test/slow', { body: { delay: 2000 }, signal: cancelSignal })
|
||||
setTimeout(() => cancel(), 100)
|
||||
|
||||
try {
|
||||
return await requestPromise
|
||||
} catch (error) {
|
||||
return { cancelled: true, error: error instanceof Error ? error.message : 'Unknown error' }
|
||||
}
|
||||
} else {
|
||||
// Normal request
|
||||
return await dataApiService.get('/test/items', { signal })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const runErrorHandlingTest = async () => {
|
||||
await executeStressTest('Error Handling Stress Test', async (requestId, signal) => {
|
||||
// Mix of successful and error requests
|
||||
const errorTypes = ['network', 'server', 'timeout', 'notfound', 'validation']
|
||||
const shouldError = Math.random() * 100 < config.errorRate
|
||||
|
||||
if (shouldError) {
|
||||
const errorType = errorTypes[requestId % errorTypes.length]
|
||||
try {
|
||||
return await dataApiService.post('/test/error', { body: { errorType: errorType as any }, signal })
|
||||
} catch (error) {
|
||||
return { expectedError: true, error: error instanceof Error ? error.message : 'Unknown error' }
|
||||
}
|
||||
} else {
|
||||
return await dataApiService.get('/test/items', { signal })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const runMixedOperationsTest = async () => {
|
||||
await executeStressTest('Mixed Operations Stress Test', async (requestId, signal) => {
|
||||
const operations = ['GET', 'POST', 'PUT', 'DELETE']
|
||||
const operation = operations[requestId % operations.length]
|
||||
|
||||
switch (operation) {
|
||||
case 'GET':
|
||||
return await dataApiService.get('/test/items', { signal })
|
||||
|
||||
case 'POST':
|
||||
return await dataApiService.post('/test/items', {
|
||||
body: {
|
||||
title: `Stress Test Topic ${requestId}`,
|
||||
description: `Created during stress test #${requestId}`,
|
||||
type: 'stress-test'
|
||||
},
|
||||
signal
|
||||
})
|
||||
|
||||
case 'PUT':
|
||||
// First get an item, then update it
|
||||
try {
|
||||
const items = await dataApiService.get('/test/items', { signal })
|
||||
if ((items as any).items && (items as any).items.length > 0) {
|
||||
const itemId = (items as any).items[0].id
|
||||
return await dataApiService.put(`/test/items/${itemId}`, {
|
||||
body: {
|
||||
title: `Updated by stress test ${requestId}`
|
||||
},
|
||||
signal
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
return { updateFailed: true, error: error instanceof Error ? error.message : 'Unknown error' }
|
||||
}
|
||||
return { updateFailed: true, message: 'No items found to update' }
|
||||
|
||||
default:
|
||||
return await dataApiService.get('/test/items', { signal })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const stopTest = () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
message.info('Stress test stopped by user')
|
||||
}
|
||||
}
|
||||
|
||||
const resetTests = () => {
|
||||
stopTest()
|
||||
setResults([])
|
||||
setSummary(null)
|
||||
setProgress(0)
|
||||
setRealtimeStats({ rps: 0, avgResponseTime: 0, errorRate: 0, memoryUsage: 0 })
|
||||
message.info('Stress tests reset')
|
||||
}
|
||||
|
||||
const resultColumns = [
|
||||
{
|
||||
title: 'Request ID',
|
||||
dataIndex: 'requestId',
|
||||
key: 'requestId',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => {
|
||||
const colorMap = {
|
||||
success: 'green',
|
||||
error: 'red',
|
||||
timeout: 'orange',
|
||||
pending: 'blue'
|
||||
}
|
||||
return <Tag color={colorMap[status] || 'default'}>{status.toUpperCase()}</Tag>
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Duration (ms)',
|
||||
dataIndex: 'duration',
|
||||
key: 'duration',
|
||||
render: (duration?: number) => (duration ? `${duration}ms` : '-')
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage (KB)',
|
||||
key: 'memory',
|
||||
render: (_: any, record: StressTestResult) => {
|
||||
if (record.memoryBefore && record.memoryAfter) {
|
||||
const diff = Math.round((record.memoryAfter - record.memoryBefore) / 1024)
|
||||
return diff > 0 ? `+${diff}` : `${diff}`
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<TestContainer>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{/* Configuration Panel */}
|
||||
<Card title="Stress Test Configuration" size="small">
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Text>Concurrent Requests:</Text>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={50}
|
||||
value={config.concurrentRequests}
|
||||
onChange={(value) => setConfig((prev) => ({ ...prev, concurrentRequests: value || 10 }))}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Text>Total Requests:</Text>
|
||||
<InputNumber
|
||||
min={10}
|
||||
max={1000}
|
||||
value={config.totalRequests}
|
||||
onChange={(value) => setConfig((prev) => ({ ...prev, totalRequests: value || 100 }))}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Text>Request Delay (ms):</Text>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={5000}
|
||||
value={config.requestDelay}
|
||||
onChange={(value) => setConfig((prev) => ({ ...prev, requestDelay: value || 0 }))}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Text>Error Rate (%):</Text>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.errorRate}
|
||||
onChange={(value) => setConfig((prev) => ({ ...prev, errorRate: value || 0 }))}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Control Panel */}
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Cpu size={16} />}
|
||||
onClick={runConcurrentRequestsTest}
|
||||
loading={isRunning && currentTest === 'Concurrent Requests Test'}
|
||||
disabled={isRunning}
|
||||
block>
|
||||
Concurrent Test
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Activity size={16} />}
|
||||
onClick={runMemoryLeakTest}
|
||||
loading={isRunning && currentTest === 'Memory Leak Test'}
|
||||
disabled={isRunning}
|
||||
block>
|
||||
Memory Test
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
onClick={runErrorHandlingTest}
|
||||
loading={isRunning && currentTest === 'Error Handling Stress Test'}
|
||||
disabled={isRunning}
|
||||
block>
|
||||
Error Test
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Zap size={16} />}
|
||||
onClick={runMixedOperationsTest}
|
||||
loading={isRunning && currentTest === 'Mixed Operations Stress Test'}
|
||||
disabled={isRunning}
|
||||
block>
|
||||
Mixed Test
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Space>
|
||||
<Button danger icon={<StopCircle size={16} />} onClick={stopTest} disabled={!isRunning}>
|
||||
Stop Test
|
||||
</Button>
|
||||
<Button icon={<RefreshCw size={16} />} onClick={resetTests} disabled={isRunning}>
|
||||
Reset
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text type="secondary" style={{ float: 'right' }}>
|
||||
{isRunning && currentTest && `Running: ${currentTest}`}
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Progress and Real-time Stats */}
|
||||
{isRunning && (
|
||||
<Card title="Test Progress" size="small">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Progress percent={Math.round(progress)} status={isRunning ? 'active' : 'success'} showInfo />
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="Requests/Second"
|
||||
value={realtimeStats.rps}
|
||||
precision={2}
|
||||
prefix={<TrendingUp size={16} />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="Avg Response Time"
|
||||
value={realtimeStats.avgResponseTime}
|
||||
suffix="ms"
|
||||
prefix={<Timer size={16} />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="Error Rate"
|
||||
value={realtimeStats.errorRate}
|
||||
suffix="%"
|
||||
prefix={<AlertTriangle size={16} />}
|
||||
valueStyle={{ color: realtimeStats.errorRate > 10 ? '#cf1322' : '#3f8600' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="Memory Usage"
|
||||
value={realtimeStats.memoryUsage}
|
||||
suffix="MB"
|
||||
prefix={<Gauge size={16} />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Test Summary */}
|
||||
{summary && (
|
||||
<Card title="Test Summary" size="small">
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Card size="small" title="Request Statistics">
|
||||
<Space direction="vertical">
|
||||
<Text>
|
||||
Total Requests: <strong>{summary.totalRequests}</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Completed: <strong>{summary.completedRequests}</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Successful: <strong style={{ color: '#3f8600' }}>{summary.successfulRequests}</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Failed: <strong style={{ color: '#cf1322' }}>{summary.failedRequests}</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Timeouts: <strong style={{ color: '#d48806' }}>{summary.timeoutRequests}</strong>
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={8}>
|
||||
<Card size="small" title="Performance Metrics">
|
||||
<Space direction="vertical">
|
||||
<Text>
|
||||
Test Duration: <strong>{summary.testDuration}s</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Requests/Second: <strong>{summary.requestsPerSecond}</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Avg Response Time: <strong>{summary.averageResponseTime}ms</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Min Response Time: <strong>{summary.minResponseTime}ms</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Max Response Time: <strong>{summary.maxResponseTime}ms</strong>
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={8}>
|
||||
<Card size="small" title="Memory Usage">
|
||||
<Space direction="vertical">
|
||||
<Text>
|
||||
Initial: <strong>{summary.memoryUsage.initial}MB</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Peak: <strong>{summary.memoryUsage.peak}MB</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Final: <strong>{summary.memoryUsage.final}MB</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Memory Change:
|
||||
<strong
|
||||
style={{
|
||||
color: summary.memoryUsage.leaked > 5 ? '#cf1322' : '#3f8600'
|
||||
}}>
|
||||
{summary.memoryUsage.leaked > 0 ? '+' : ''}
|
||||
{summary.memoryUsage.leaked}MB
|
||||
</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Error Rate: <strong>{summary.errorRate}%</strong>
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Results Table */}
|
||||
{results.length > 0 && (
|
||||
<Card
|
||||
title={`Test Results (${results.filter((r) => r.status !== 'pending').length}/${results.length} completed)`}
|
||||
size="small">
|
||||
<Table
|
||||
dataSource={results.slice(-50)} // Show last 50 results to avoid performance issues
|
||||
columns={resultColumns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{ pageSize: 20 }}
|
||||
scroll={{ y: 400 }}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Memory Usage Alert */}
|
||||
{summary && summary.memoryUsage.leaked > 10 && (
|
||||
<Alert
|
||||
message="Potential Memory Leak Detected"
|
||||
description={`Memory usage increased by ${summary.memoryUsage.leaked}MB during the test. This might indicate a memory leak.`}
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</TestContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const TestContainer = styled.div`
|
||||
.ant-statistic-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-statistic-content {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ant-progress-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-table-small .ant-table-thead > tr > th {
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-table-small .ant-table-tbody > tr > td {
|
||||
padding: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
`
|
||||
|
||||
export default DataApiStressTests
|
||||
13
yarn.lock
13
yarn.lock
@ -10292,6 +10292,7 @@ __metadata:
|
||||
string-width: "npm:^7.2.0"
|
||||
striptags: "npm:^3.2.0"
|
||||
styled-components: "npm:^6.1.11"
|
||||
swr: "npm:^2.3.6"
|
||||
tar: "npm:^7.4.3"
|
||||
tesseract.js: "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch"
|
||||
tiny-pinyin: "npm:^1.3.2"
|
||||
@ -23910,6 +23911,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"swr@npm:^2.3.6":
|
||||
version: 2.3.6
|
||||
resolution: "swr@npm:2.3.6"
|
||||
dependencies:
|
||||
dequal: "npm:^2.0.3"
|
||||
use-sync-external-store: "npm:^1.4.0"
|
||||
peerDependencies:
|
||||
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
checksum: 10c0/9534f350982e36a3ae0a13da8c0f7da7011fc979e77f306e60c4e5db0f9b84f17172c44f973441ba56bb684b69b0d9838ab40011a6b6b3e32d0cd7f3d5405f99
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"symbol-tree@npm:^3.2.4":
|
||||
version: 3.2.4
|
||||
resolution: "symbol-tree@npm:3.2.4"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user