From a7d12abd1f24b771e134e8e92ef3266c400da7ff Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sat, 13 Sep 2025 00:55:15 +0800 Subject: [PATCH] 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. --- package.json | 1 + packages/shared/IpcChannel.ts | 9 + packages/shared/data/README.md | 198 +++++ packages/shared/data/api/apiModels.ts | 107 +++ packages/shared/data/api/apiPaths.ts | 63 ++ packages/shared/data/api/apiSchemas.ts | 492 +++++++++++ packages/shared/data/api/apiTypes.ts | 289 +++++++ packages/shared/data/api/errorCodes.ts | 193 +++++ packages/shared/data/api/index.ts | 121 +++ .../shared/data/pending_default_values.ts | 22 - src/main/data/DataApiService.ts | 128 +++ src/main/data/api/core/ApiServer.ts | 264 ++++++ src/main/data/api/core/MiddlewareEngine.ts | 177 ++++ src/main/data/api/core/adapters/IpcAdapter.ts | 158 ++++ src/main/data/api/handlers/index.ts | 211 +++++ src/main/data/api/index.ts | 32 + src/main/data/api/services/IBaseService.ts | 108 +++ src/main/data/api/services/TestService.ts | 452 ++++++++++ src/main/index.ts | 22 +- src/main/services/LoggerService.ts | 2 +- src/preload/index.ts | 19 + src/renderer/src/data/DataApiService.ts | 517 ++++++++++++ src/renderer/src/data/hooks/useDataApi.ts | 609 ++++++++++++++ src/renderer/src/hooks/useRuntime.ts | 4 + src/renderer/src/hooks/useSettings.ts | 6 +- src/renderer/src/store/index.ts | 4 + .../src/windows/dataRefactorTest/TestApp.tsx | 86 +- .../components/DataApiAdvancedTests.tsx | 713 ++++++++++++++++ .../components/DataApiBasicTests.tsx | 604 ++++++++++++++ .../components/DataApiHookTests.tsx | 658 +++++++++++++++ .../components/DataApiStressTests.tsx | 770 ++++++++++++++++++ yarn.lock | 13 + 32 files changed, 7013 insertions(+), 39 deletions(-) create mode 100644 packages/shared/data/README.md create mode 100644 packages/shared/data/api/apiModels.ts create mode 100644 packages/shared/data/api/apiPaths.ts create mode 100644 packages/shared/data/api/apiSchemas.ts create mode 100644 packages/shared/data/api/apiTypes.ts create mode 100644 packages/shared/data/api/errorCodes.ts create mode 100644 packages/shared/data/api/index.ts delete mode 100644 packages/shared/data/pending_default_values.ts create mode 100644 src/main/data/DataApiService.ts create mode 100644 src/main/data/api/core/ApiServer.ts create mode 100644 src/main/data/api/core/MiddlewareEngine.ts create mode 100644 src/main/data/api/core/adapters/IpcAdapter.ts create mode 100644 src/main/data/api/handlers/index.ts create mode 100644 src/main/data/api/index.ts create mode 100644 src/main/data/api/services/IBaseService.ts create mode 100644 src/main/data/api/services/TestService.ts create mode 100644 src/renderer/src/data/DataApiService.ts create mode 100644 src/renderer/src/data/hooks/useDataApi.ts create mode 100644 src/renderer/src/windows/dataRefactorTest/components/DataApiAdvancedTests.tsx create mode 100644 src/renderer/src/windows/dataRefactorTest/components/DataApiBasicTests.tsx create mode 100644 src/renderer/src/windows/dataRefactorTest/components/DataApiHookTests.tsx create mode 100644 src/renderer/src/windows/dataRefactorTest/components/DataApiStressTests.tsx diff --git a/package.json b/package.json index 7edb7f0543..be23f06594 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 9d464a5feb..5bad530f95 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -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', diff --git a/packages/shared/data/README.md b/packages/shared/data/README.md new file mode 100644 index 0000000000..ae05e5d4ce --- /dev/null +++ b/packages/shared/data/README.md @@ -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 + +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> = { + 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 + } + } +} +``` + +### 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.* \ No newline at end of file diff --git a/packages/shared/data/api/apiModels.ts b/packages/shared/data/api/apiModels.ts new file mode 100644 index 0000000000..08107a9729 --- /dev/null +++ b/packages/shared/data/api/apiModels.ts @@ -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 +} + +/** + * 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 +} + +/** + * 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 +} + +/** + * Bulk operation types for batch processing + */ + +/** + * Request for bulk operations on multiple items + */ +export interface BulkOperationRequest { + /** 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 + }> +} diff --git a/packages/shared/data/api/apiPaths.ts b/packages/shared/data/api/apiPaths.ts new file mode 100644 index 0000000000..4dc52d53e6 --- /dev/null +++ b/packages/shared/data/api/apiPaths.ts @@ -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 `${infer Prefix}/:${string}/${infer Suffix}` + ? `${Prefix}/${string}/${ResolvedPath}` + : 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 +}[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 = { + [K in keyof ApiSchemas]: Path extends ResolvedPath ? K : never +}[keyof ApiSchemas] + +/** + * Extract query parameters type for a given concrete path + */ +export type QueryParamsForPath = + MatchApiPath extends keyof ApiSchemas + ? ApiSchemas[MatchApiPath] extends { GET: { query?: infer Q } } + ? Q + : Record + : Record + +/** + * Extract request body type for a given concrete path and HTTP method + */ +export type BodyForPath = + MatchApiPath extends keyof ApiSchemas + ? ApiSchemas[MatchApiPath] extends { [M in Method]: { body: infer B } } + ? B + : any + : any + +/** + * Extract response type for a given concrete path and HTTP method + */ +export type ResponseForPath = + MatchApiPath extends keyof ApiSchemas + ? ApiSchemas[MatchApiPath] extends { [M in Method]: { response: infer R } } + ? R + : any + : any diff --git a/packages/shared/data/api/apiSchemas.ts b/packages/shared/data/api/apiSchemas.ts new file mode 100644 index 0000000000..d333b1f4c7 --- /dev/null +++ b/packages/shared/data/api/apiSchemas.ts @@ -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 + } + /** Create a new test item */ + POST: { + body: { + title: string + description?: string + type?: string + status?: string + priority?: string + tags?: string[] + metadata?: Record + } + 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 + } + 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 + } + } + + /** + * 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 + /** Item count grouped by status */ + byStatus: Record + /** Item count grouped by priority */ + byPriority: Record + /** 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 + } + /** Update test configuration */ + PUT: { + body: Record + response: Record + } + } + + /** + * 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 = keyof ApiSchemas[TPath] & HttpMethod +export type ApiResponse = 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 keyof ApiSchemas + ? TMethod extends keyof ApiSchemas[TPath] + ? ApiSchemas[TPath][TMethod] extends { params: infer P } + ? P + : never + : never + : never + +export type ApiQuery = 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 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( + path: TPath, + options?: { + query?: QueryParamsForPath + headers?: Record + signal?: AbortSignal + } + ): Promise> + + post( + path: TPath, + options: { + body?: BodyForPath + query?: Record + headers?: Record + signal?: AbortSignal + } + ): Promise> + + put( + path: TPath, + options: { + body: BodyForPath + query?: Record + headers?: Record + signal?: AbortSignal + } + ): Promise> + + delete( + path: TPath, + options?: { + query?: Record + headers?: Record + signal?: AbortSignal + } + ): Promise> + + patch( + path: TPath, + options: { + body?: BodyForPath + query?: Record + headers?: Record + signal?: AbortSignal + } + ): Promise> +} + +/** + * Helper types to determine if parameters are required based on schema + */ +type HasRequiredQuery> = Path extends keyof ApiSchemas + ? Method extends keyof ApiSchemas[Path] + ? ApiSchemas[Path][Method] extends { query: any } + ? true + : false + : false + : false + +type HasRequiredBody> = Path extends keyof ApiSchemas + ? Method extends keyof ApiSchemas[Path] + ? ApiSchemas[Path][Method] extends { body: any } + ? true + : false + : false + : false + +type HasRequiredParams> = 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> = ( + params: (HasRequiredParams extends true + ? { params: ApiParams } + : { params?: ApiParams }) & + (HasRequiredQuery extends true + ? { query: ApiQuery } + : { query?: ApiQuery }) & + (HasRequiredBody extends true ? { body: ApiBody } : { body?: ApiBody }) +) => Promise> + +/** + * 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]: ApiHandler + } +} diff --git a/packages/shared/data/api/apiTypes.ts b/packages/shared/data/api/apiTypes.ts new file mode 100644 index 0000000000..e45c45603c --- /dev/null +++ b/packages/shared/data/api/apiTypes.ts @@ -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 { + /** 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 + /** Request body data */ + body?: T + /** Request headers */ + headers?: Record + /** 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 { + /** 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 { + /** 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 + /** Whether to receive initial data */ + includeInitial?: boolean + /** Custom subscription metadata */ + metadata?: Record +} + +/** + * Subscription callback function + */ +export type SubscriptionCallback = (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): Promise +} + +/** + * 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 +} + +/** + * 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 +} + +/** + * Standard service response wrapper + */ +export interface ServiceResult { + /** Whether operation was successful */ + success: boolean + /** Result data if successful */ + data?: T + /** Error information if failed */ + error?: DataApiError + /** Additional metadata */ + metadata?: Record +} diff --git a/packages/shared/data/api/errorCodes.ts b/packages/shared/data/api/errorCodes.ts new file mode 100644 index 0000000000..6c6a9dbd82 --- /dev/null +++ b/packages/shared/data/api/errorCodes.ts @@ -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 = { + // 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.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, 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 } + ) +} diff --git a/packages/shared/data/api/index.ts b/packages/shared/data/api/index.ts new file mode 100644 index 0000000000..3b00e37473 --- /dev/null +++ b/packages/shared/data/api/index.ts @@ -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 +} diff --git a/packages/shared/data/pending_default_values.ts b/packages/shared/data/pending_default_values.ts deleted file mode 100644 index 9a1db3ccb6..0000000000 --- a/packages/shared/data/pending_default_values.ts +++ /dev/null @@ -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' } -] diff --git a/src/main/data/DataApiService.ts b/src/main/data/DataApiService.ts new file mode 100644 index 0000000000..aa913e279e --- /dev/null +++ b/src/main/data/DataApiService.ts @@ -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 { + 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 { + 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() diff --git a/src/main/data/api/core/ApiServer.ts b/src/main/data/api/core/ApiServer.ts new file mode 100644 index 0000000000..038f7cc1b8 --- /dev/null +++ b/src/main/data/api/core/ApiServer.ts @@ -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; query?: any; body?: any }) => Promise + +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 { + 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 { + 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 } | 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 | null { + const patternParts = pattern.split('/') + const pathParts = path.split('/') + + if (patternParts.length !== pathParts.length) { + return null + } + + const params: Record = {} + + 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 } + ): Promise { + 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 + } + } +} diff --git a/src/main/data/api/core/MiddlewareEngine.ts b/src/main/data/api/core/MiddlewareEngine.ts new file mode 100644 index 0000000000..1f6bf1915d --- /dev/null +++ b/src/main/data/api/core/MiddlewareEngine.ts @@ -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() + 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 { + let index = 0 + + const next = async (): Promise => { + 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) => { + 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) => { + 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) => { + 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') + } +} diff --git a/src/main/data/api/core/adapters/IpcAdapter.ts b/src/main/data/api/core/adapters/IpcAdapter.ts new file mode 100644 index 0000000000..4285d16d24 --- /dev/null +++ b/src/main/data/api/core/adapters/IpcAdapter.ts @@ -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 => { + 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 => { + 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 => { + 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 + } +} diff --git a/src/main/data/api/handlers/index.ts b/src/main/data/api/handlers/index.ts new file mode 100644 index 0000000000..39122449ed --- /dev/null +++ b/src/main/data/api/handlers/index.ts @@ -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() } + })) + } + } +} diff --git a/src/main/data/api/index.ts b/src/main/data/api/index.ts new file mode 100644 index 0000000000..56add846f4 --- /dev/null +++ b/src/main/data/api/index.ts @@ -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' diff --git a/src/main/data/api/services/IBaseService.ts b/src/main/data/api/services/IBaseService.ts new file mode 100644 index 0000000000..446de55716 --- /dev/null +++ b/src/main/data/api/services/IBaseService.ts @@ -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 { + /** + * Find entity by ID + */ + findById(id: string, options?: ServiceOptions): Promise + + /** + * Find multiple entities with pagination + */ + findMany( + params: PaginationParams & Record, + options?: ServiceOptions + ): Promise<{ + items: T[] + total: number + hasNext?: boolean + nextCursor?: string + }> + + /** + * Create new entity + */ + create(data: TCreate, options?: ServiceOptions): Promise + + /** + * Update existing entity + */ + update(id: string, data: TUpdate, options?: ServiceOptions): Promise + + /** + * Delete entity (hard or soft delete depending on implementation) + */ + delete(id: string, options?: ServiceOptions): Promise + + /** + * Check if entity exists + */ + exists(id: string, options?: ServiceOptions): Promise +} + +/** + * Extended service interface for soft delete operations + */ +export interface ISoftDeleteService extends IBaseService { + /** + * Archive entity (soft delete) + */ + archive(id: string, options?: ServiceOptions): Promise + + /** + * Restore archived entity + */ + restore(id: string, options?: ServiceOptions): Promise +} + +/** + * Extended service interface for search operations + */ +export interface ISearchableService extends IBaseService { + /** + * 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 extends IBaseService { + /** + * 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 + + /** + * Remove child entity from parent + */ + removeChild(parentId: string, childId: string, options?: ServiceOptions): Promise +} diff --git a/src/main/data/api/services/TestService.ts b/src/main/data/api/services/TestService.ts new file mode 100644 index 0000000000..d678ba252d --- /dev/null +++ b/src/main/data/api/services/TestService.ts @@ -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 { + 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 { + 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 + }): Promise { + 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 + }> + ): Promise { + 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 { + 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 + byStatus: Record + byPriority: Record + recentActivity: Array<{ + date: string + count: number + }> + }> { + await this.simulateDelay() + + const byType: Record = {} + const byStatus: Record = {} + const byPriority: Record = {} + + 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 + } = {} + ): 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 { + 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 { + await this.simulateDelay() + + this.testItems = [] + this.nextId = 1 + this.initializeMockData() + + logger.info('Test data reset to initial state') + } +} diff --git a/src/main/index.ts b/src/main/index.ts index e45ab55f25..9b0b5726a8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 diff --git a/src/main/services/LoggerService.ts b/src/main/services/LoggerService.ts index b48c601cd5..aa78405705 100644 --- a/src/main/services/LoggerService.ts +++ b/src/main/services/LoggerService.ts @@ -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 diff --git a/src/preload/index.ts b/src/preload/index.ts index 5ef38e049a..f2a40e9a2f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -451,6 +451,8 @@ const api = { } } }, + // PreferenceService related APIs + // DO NOT MODIFY THIS SECTION preference: { get: (key: K): Promise => 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) + } } } diff --git a/src/renderer/src/data/DataApiService.ts b/src/renderer/src/data/DataApiService.ts new file mode 100644 index 0000000000..f044bf0fa9 --- /dev/null +++ b/src/renderer/src/data/DataApiService.ts @@ -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): 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(request: DataRequest, retryCount = 0): Promise { + 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(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(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(retryRequest, retryCount + 1) + .then(resolve) + .catch(reject) + }, delay) + } else { + reject(error) + } + }) + }) + } + + /** + * Make HTTP request with enhanced features + */ + private async makeRequest( + method: HttpMethod, + path: string, + options: { + params?: any + body?: any + headers?: Record + metadata?: Record + signal?: AbortSignal + } = {} + ): Promise { + 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(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( + path: TPath, + options?: { + query?: any + headers?: Record + signal?: AbortSignal + } + ): Promise { + return this.makeRequest('GET', path as string, { + params: options?.query, + headers: options?.headers, + signal: options?.signal + }) + } + + /** + * Type-safe POST request + */ + async post( + path: TPath, + options: { + body?: any + query?: Record + headers?: Record + signal?: AbortSignal + } + ): Promise { + return this.makeRequest('POST', path as string, { + params: options.query, + body: options.body, + headers: options.headers, + signal: options.signal + }) + } + + /** + * Type-safe PUT request + */ + async put( + path: TPath, + options: { + body: any + query?: Record + headers?: Record + signal?: AbortSignal + } + ): Promise { + return this.makeRequest('PUT', path as string, { + params: options.query, + body: options.body, + headers: options.headers, + signal: options.signal + }) + } + + /** + * Type-safe DELETE request + */ + async delete( + path: TPath, + options?: { + query?: Record + headers?: Record + signal?: AbortSignal + } + ): Promise { + return this.makeRequest('DELETE', path as string, { + params: options?.query, + headers: options?.headers, + signal: options?.signal + }) + } + + /** + * Type-safe PATCH request + */ + async patch( + path: TPath, + options: { + body?: any + query?: Record + headers?: Record + signal?: AbortSignal + } + ): Promise { + return this.makeRequest('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 { + const batchRequest: BatchRequest = { + requests, + parallel: options.parallel ?? true + } + + return this.makeRequest('POST', '/batch', { body: batchRequest }) + } + + /** + * Execute requests in a transaction + */ + async transaction(operations: DataRequest[], options?: TransactionRequest['options']): Promise { + const transactionRequest: TransactionRequest = { + operations, + options + } + + return this.makeRequest('POST', '/transaction', { body: transactionRequest }) + } + + /** + * Subscribe to real-time updates + */ + subscribe(options: SubscriptionOptions, callback: SubscriptionCallback): () => 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() + }) +} diff --git a/src/renderer/src/data/hooks/useDataApi.ts b/src/renderer/src/data/hooks/useDataApi.ts new file mode 100644 index 0000000000..e13b0c37c5 --- /dev/null +++ b/src/renderer/src/data/hooks/useDataApi.ts @@ -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( + method: TMethod +) { + return async ( + path: TPath, + options?: { + body?: BodyForPath + query?: Record + } + ): Promise> => { + 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( + path: TPath, + query?: Record +): [TPath, Record?] | 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([path, query]: [TPath, Record?]): Promise< + ResponseForPath +> { + 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 + * + * // 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( + path: TPath, + options?: { + /** Query parameters for filtering, pagination, etc. */ + query?: QueryParamsForPath + /** Disable the request */ + enabled?: boolean + /** Custom SWR options */ + swrOptions?: Parameters[2] + } +): { + /** The fetched data */ + data?: ResponseForPath + /** 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) : 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( + method: TMethod, + path: TPath, + options?: { + /** Called when mutation succeeds */ + onSuccess?: (data: ResponseForPath) => 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 + } +): { + /** Function to execute the mutation */ + mutate: (data?: { + body?: BodyForPath + query?: QueryParamsForPath + }) => Promise> + /** 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 + query?: Record + } + } + ): Promise> => { + 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 + query?: QueryParamsForPath + }): Promise> => { + if (options?.optimistic && options?.optimisticData) { + // Apply optimistic update + await globalMutate(path, options.optimisticData, false) + } + + try { + // Convert user's strongly-typed query to Record for internal use + const convertedData = data + ? { + body: data.body, + query: data.query as Record + } + : 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 + query?: QueryParamsForPath + }): Promise> => { + // Convert user's strongly-typed query to Record for internal use + const convertedData = data + ? { + body: data.body, + query: data.query as Record + } + : 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 + * } + * ``` + */ +export function useInvalidateCache() { + const { mutate } = useSWRConfig() + + const invalidate = (keys?: string | string[] | boolean): Promise => { + 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( + path: TPath, + options?: { + query?: QueryParamsForPath + } +): Promise> { + const apiFetcher = createApiFetcher('GET') + return apiFetcher(path, { query: options?.query as Record }) +} + +/** + * React hook for paginated data fetching with type safety + * Automatically manages pagination state and provides navigation controls + * Works with API endpoints that return PaginatedResponse + * + * @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 + *
+ * + * Page {page} of {Math.ceil(total / 20)} + * + *
+ * + * // Reset pagination when search changes + * useEffect(() => { + * reset() // Go back to first page + * }, [searchTerm]) + * ``` + */ +export function usePaginatedQuery( + path: TPath, + options?: { + /** Additional query parameters (excluding pagination) */ + query?: Omit, 'page' | 'limit'> + /** Items per page (default: 10) */ + limit?: number + /** Custom SWR options */ + swrOptions?: Parameters[2] + } +): ResponseForPath extends PaginatedResponse + ? { + /** 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 + + const { data, loading, error, refetch } = useQuery(path, { + query: queryWithPagination as QueryParamsForPath, + swrOptions: options?.swrOptions + }) + + // Extract paginated response data with type safety + const paginatedData = data as PaginatedResponse + 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 extends PaginatedResponse + ? { + items: T[] + total: number + page: number + loading: boolean + error?: Error + hasMore: boolean + hasPrev: boolean + prevPage: () => void + nextPage: () => void + refresh: () => void + reset: () => void + } + : never +} diff --git a/src/renderer/src/hooks/useRuntime.ts b/src/renderer/src/hooks/useRuntime.ts index c82d815214..7ff5713c92 100644 --- a/src/renderer/src/hooks/useRuntime.ts +++ b/src/renderer/src/hooks/useRuntime.ts @@ -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' diff --git a/src/renderer/src/hooks/useSettings.ts b/src/renderer/src/hooks/useSettings.ts index ee632c332c..bf866398e9 100644 --- a/src/renderer/src/hooks/useSettings.ts +++ b/src/renderer/src/hooks/useSettings.ts @@ -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' diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 64e8ce1a4a..dc4f510d8c 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -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' diff --git a/src/renderer/src/windows/dataRefactorTest/TestApp.tsx b/src/renderer/src/windows/dataRefactorTest/TestApp.tsx index ee6db367a9..c213744516 100644 --- a/src/renderer/src/windows/dataRefactorTest/TestApp.tsx +++ b/src/renderer/src/windows/dataRefactorTest/TestApp.tsx @@ -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 = () => { - PreferenceService 功能测试 (窗口 #{windowNumber}) + 数据重构项目测试套件 (窗口 #{windowNumber}) - 此测试窗口用于验证 PreferenceService 和 usePreference hooks - 的各项功能,包括单个偏好设置的读写、多个偏好设置的批量操作、跨窗口数据同步等。 + 此测试窗口用于验证数据重构项目的各项功能,包括 PreferenceService、DataApiService 和相关 React hooks + 的完整测试套件。 - 测试使用的都是真实的偏好设置系统,所有操作都会影响实际的数据库存储。 + PreferenceService 测试使用真实的偏好设置系统,DataApiService 测试使用专用的测试路由和假数据。 📋 跨窗口测试指南:在一个窗口中修改偏好设置,观察其他窗口是否实时同步更新。 + + 🚀 数据API测试:包含基础CRUD、高级功能、React hooks和压力测试,全面验证数据请求架构。 + @@ -170,6 +177,75 @@ const TestApp: React.FC = () => { + + + + DataApiService 功能测试 + + + + + {/* DataApi Basic Tests */} + + + + DataApi 基础功能测试 (CRUD操作) + + } + size="small" + style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}> + + + + + {/* DataApi Advanced Tests */} + + + + DataApi 高级功能测试 (取消、重试、批量) + + } + size="small" + style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}> + + + + + {/* DataApi Hook Tests */} + + + + DataApi React Hooks 测试 + + } + size="small" + style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}> + + + + + {/* DataApi Stress Tests */} + + + + DataApi 压力测试 (性能与错误处理) + + } + size="small" + style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}> + + + + + diff --git a/src/renderer/src/windows/dataRefactorTest/components/DataApiAdvancedTests.tsx b/src/renderer/src/windows/dataRefactorTest/components/DataApiAdvancedTests.tsx new file mode 100644 index 0000000000..4805e32c05 --- /dev/null +++ b/src/renderer/src/windows/dataRefactorTest/components/DataApiAdvancedTests.tsx @@ -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 +} + +interface RetryTestConfig { + maxRetries: number + retryDelay: number + backoffMultiplier: number +} + +const DataApiAdvancedTests: React.FC = () => { + const [testResults, setTestResults] = useState([]) + const [isRunning, setIsRunning] = useState(false) + const [, setCancelledRequests] = useState([]) + const [, setRetryStats] = useState(null) + const [performanceStats, setPerformanceStats] = useState(null) + + // Keep track of active abort controllers + const abortControllersRef = useRef>(new Map()) + + const updateTestResult = (id: string, updates: Partial) => { + 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 + ) => { + 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 + case 'error': + return + case 'cancelled': + return + case 'running': + return + default: + return + } + } + + const getCategoryIcon = (category: AdvancedTestResult['category']) => { + switch (category) { + case 'cancellation': + return + case 'retry': + return + case 'batch': + return + case 'error': + return + case 'performance': + return + default: + return + } + } + + const tableColumns = [ + { + title: 'Category', + dataIndex: 'category', + key: 'category', + render: (category: string) => ( + + {getCategoryIcon(category as any)} + {category} + + ) + }, + { + title: 'Test', + dataIndex: 'name', + key: 'name', + render: (name: string) => ( + + {name} + + ) + }, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + render: (status: AdvancedTestResult['status']) => ( + + {status.toUpperCase()} + + ) + }, + { + title: 'Duration', + dataIndex: 'duration', + key: 'duration', + render: (duration?: number) => (duration ? `${duration}ms` : '-') + }, + { + title: 'Actions', + key: 'actions', + render: (_: any, record: AdvancedTestResult) => ( + + {record.status === 'running' && ( + + )} + + ) + } + ] + + return ( + + + {/* Control Panel */} + + + + + + + + + + + + + + + + + + + + + + + + + + {abortControllersRef.current.size > 0 && ( + + )} + Running: {testResults.filter((t) => t.status === 'running').length} + + + + + + {/* Statistics */} + + + + } /> + + + + + t.status === 'success').length} + prefix={} + valueStyle={{ color: '#3f8600' }} + /> + + + + + t.status === 'error').length} + prefix={} + valueStyle={{ color: '#cf1322' }} + /> + + + + + t.status === 'cancelled').length} + prefix={} + valueStyle={{ color: '#d48806' }} + /> + + + + + {/* Performance Stats */} + {performanceStats && ( + + + + + + + + + + + + + + + + + )} + + {/* Test Results Table */} + + {testResults.length === 0 ? ( + + ) : ( + + )} + + + + ) +} + +const TestContainer = styled.div` + .animate-spin { + animation: spin 1s linear infinite; + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } +` + +export default DataApiAdvancedTests diff --git a/src/renderer/src/windows/dataRefactorTest/components/DataApiBasicTests.tsx b/src/renderer/src/windows/dataRefactorTest/components/DataApiBasicTests.tsx new file mode 100644 index 0000000000..c3341bf688 --- /dev/null +++ b/src/renderer/src/windows/dataRefactorTest/components/DataApiBasicTests.tsx @@ -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 +} + +const DataApiBasicTests: React.FC = () => { + const [testResults, setTestResults] = useState([]) + const [isRunning, setIsRunning] = useState(false) + const [testItems, setTestItems] = useState([]) + const [selectedItem, setSelectedItem] = useState(null) + const [newItemTitle, setNewItemTitle] = useState('') + const [newItemDescription, setNewItemDescription] = useState('') + const [newItemType, setNewItemType] = useState('data') + + const updateTestResult = (id: string, updates: Partial) => { + setTestResults((prev) => prev.map((result) => (result.id === id ? { ...result, ...updates } : result))) + } + + const runTest = async (testId: string, testName: string, testFn: () => Promise) => { + 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 + case 'error': + return + case 'running': + return + default: + return null + } + } + + const tableColumns = [ + { + title: 'Test', + dataIndex: 'name', + key: 'name', + render: (name: string) => {name} + }, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + render: (status: TestResult['status']) => ( + + {status.toUpperCase()} + + ) + }, + { + title: 'Duration', + dataIndex: 'duration', + key: 'duration', + render: (duration?: number) => (duration ? `${duration}ms` : '-') + }, + { + title: 'Actions', + key: 'actions', + render: (_: any, record: TestResult) => ( + + ) + } + ] + + const itemsColumns = [ + { + title: 'ID', + dataIndex: 'id', + key: 'id', + render: (id: string) => {id.substring(0, 20)}... + }, + { + title: 'Title', + dataIndex: 'title', + key: 'title' + }, + { + title: 'Type', + dataIndex: 'type', + key: 'type', + render: (type: string) => {type} + }, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + render: (status: string) => {status} + }, + { + title: 'Priority', + dataIndex: 'priority', + key: 'priority', + render: (priority: string) => ( + {priority} + ) + }, + { + title: 'Actions', + key: 'actions', + render: (_: any, record: TestItem) => ( + + ) + } + ] + + return ( + + + {/* Control Panel */} + + + + + + + + + + + + + + + + + {/* Test Results */} + + {testResults.length === 0 ? ( + + ) : ( +
+ )} + + + {/* Manual Testing */} + + + + + setNewItemTitle(e.target.value)} + /> +