UNPKG

@ritas-inc/hanaqueryapi-client

Version:

TypeScript client for HANA Query API with full type safety and error handling

901 lines (819 loc) 30.5 kB
/** * HANA Query API Client - Main Client Class * * Complete TypeScript client for the HANA Query API with full type safety, * error handling, retry logic, and logging. */ import type { ClientConfig, RequestOptions, RequestContext, SalesParams, HealthData, HealthMetadata, DocsData, DocsMetadata, ItemsStatusData, ItemsStatusMetadata, ItemGroupsData, ItemGroupsMetadata, SalesData, SalesMetadata, HierarchiesData, HierarchiesMetadata, TreesData, TreesMetadata, ProductionSectorsData, ProductionSectorsMetadata, PlansData, PlansMetadata, SinglePlanData, SinglePlanMetadata, PlanProductsData, PlanProductsMetadata, PlanWorkOrdersData, PlanWorkOrdersMetadata, PlanSectorsSummaryData, PlanSectorsSummaryMetadata, AllPlansSectorsSummaryData, AllPlansSectorsSummaryMetadata, PlanSectorSummary, UserData, UserMetadata, APIResponse, APIResponseHeaders, SuccessResponse } from './types.ts'; import { createConfig, createConfigFromEnvironment, getApiUrl, replacePathParams, buildQueryString, getEndpointTimeout, ENDPOINTS, HEADERS, AUTH_STATUS, RETRY_CONFIG } from './config.ts'; import { HanaQueryClientError, AuthorizationError, NotFoundError, createErrorFromResponse, createNetworkError, isRetryableError, getRetryDelay } from './errors.ts'; /** * Main HANA Query API Client */ export class HanaQueryClient { private readonly config: Required<ClientConfig>; constructor(config: ClientConfig, customConfig?: Partial<Omit<ClientConfig, 'baseUrl'>>) { this.config = createConfig(config, customConfig); this.log('debug', 'Client initialized', { config: this.config }); } // ========================================================================= // Public API Methods // ========================================================================= /** * Get API health status with system information * * @param options - Optional request configuration * @returns Promise resolving to health data and system metadata * * @example * ```typescript * const { data, metadata } = await client.getHealth(); * console.log(`API Status: ${data.status}`); * console.log(`Uptime: ${Math.round(data.uptime / 60)} minutes`); * console.log(`Node Version: ${metadata.nodeVersion}`); * console.log(`Platform: ${metadata.platform}`); * ``` * * @returns {Object} result - Health status result * @returns {Object} result.data - Health status data * @returns {string} result.data.status - Always 'ok' when API is running * @returns {string} result.data.timestamp - Current timestamp in ISO 8601 format * @returns {number} result.data.uptime - Server uptime in seconds * @returns {Object} result.metadata - System information metadata * @returns {boolean} result.metadata.systemInfo - Indicates system info is included * @returns {string} result.metadata.serverTime - Server current time * @returns {string} result.metadata.nodeVersion - Node.js version * @returns {string} result.metadata.platform - Operating system platform * @returns {string} result.metadata.arch - System architecture */ async getHealth(options?: RequestOptions): Promise<{ data: HealthData; metadata: HealthMetadata }> { const response = await this.executeAPIRequest<SuccessResponse<HealthData, HealthMetadata>>(ENDPOINTS.HEALTH, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get API documentation with endpoint information * * @param options - Optional request configuration * @returns Promise resolving to API documentation and metadata * * @example * ```typescript * const { data, metadata } = await client.getDocs(); * console.log(`API: ${data.name} v${data.version}`); * console.log(`Available endpoints: ${metadata.endpointCount}`); * console.log('Endpoints:', Object.keys(data.endpoints)); * ``` * * @returns {Object} result - API documentation result * @returns {Object} result.data - API documentation data * @returns {string} result.data.name - API name * @returns {string} result.data.version - API version * @returns {Object} result.data.endpoints - Map of endpoint paths to descriptions * @returns {Object} result.metadata - Documentation metadata * @returns {number} result.metadata.endpointCount - Total number of available endpoints * @returns {string} result.metadata.apiVersion - API version identifier * @returns {string} result.metadata.generatedAt - Documentation generation timestamp * @returns {boolean} result.metadata.documentation - Indicates this is documentation endpoint */ async getDocs(options?: RequestOptions): Promise<{ data: DocsData; metadata: DocsMetadata }> { const response = await this.executeAPIRequest<SuccessResponse<DocsData, DocsMetadata>>(ENDPOINTS.DOCS, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get all items without date filtering */ async getItems(options?: RequestOptions): Promise<{ data: ItemsStatusData; metadata: ItemsStatusMetadata }> { const response = await this.executeAPIRequest<SuccessResponse<ItemsStatusData, ItemsStatusMetadata>>(ENDPOINTS.ITEMS, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get all item groups * * Retrieves all item groups from the SAP HANA database with their codes and names. * Item groups provide categorization information for inventory items. * * @param {RequestOptions} [options] - Optional request configuration * @returns {Promise<{data: ItemGroupsData, metadata: ItemGroupsMetadata}>} Item groups with metadata * * @example * ```typescript * const response = await client.getItemGroups(); * console.log(`Found ${response.metadata.count} item groups`); * response.data.groups.forEach(group => { * console.log(`${group.code}: ${group.name}`); * }); * ``` */ async getItemGroups(options?: RequestOptions): Promise<{ data: ItemGroupsData; metadata: ItemGroupsMetadata }> { const response = await this.executeAPIRequest<SuccessResponse<ItemGroupsData, ItemGroupsMetadata>>(ENDPOINTS.ITEMS_GROUPS, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get sales data within a date range */ async getSales( params: SalesParams, options?: RequestOptions ): Promise<{ data: SalesData; metadata: SalesMetadata }> { const endpoint = ENDPOINTS.SALES + buildQueryString(params as unknown as Record<string, string | number | boolean | undefined>); const response = await this.executeAPIRequest<SuccessResponse<SalesData, SalesMetadata>>(endpoint, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get all item hierarchies */ async getItemHierarchies(options?: RequestOptions): Promise<{ data: HierarchiesData; metadata: HierarchiesMetadata }> { const response = await this.executeAPIRequest<SuccessResponse<HierarchiesData, HierarchiesMetadata>>(ENDPOINTS.ITEMS_HIERARCHIES, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get raw tree relationships (parent-child-quantity) */ async getItemTrees(options?: RequestOptions): Promise<{ data: TreesData; metadata: TreesMetadata }> { const response = await this.executeAPIRequest<SuccessResponse<TreesData, TreesMetadata>>(ENDPOINTS.ITEMS_TREES, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get all production sectors */ async getProductionSectors(options?: RequestOptions): Promise<{ data: ProductionSectorsData; metadata: ProductionSectorsMetadata }> { const response = await this.executeAPIRequest<SuccessResponse<ProductionSectorsData, ProductionSectorsMetadata>>(ENDPOINTS.PRODUCTION_SECTORS, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get all production plans */ async getPlans(options?: RequestOptions): Promise<{ data: PlansData; metadata: PlansMetadata }> { const response = await this.executeAPIRequest<SuccessResponse<PlansData, PlansMetadata>>(ENDPOINTS.PLANS, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get a specific production plan by ID */ async getPlan(planId: number | string, options?: RequestOptions): Promise<{ data: SinglePlanData; metadata: SinglePlanMetadata }> { const endpoint = replacePathParams(ENDPOINTS.PLAN_DETAIL, { planId }); const response = await this.executeAPIRequest<SuccessResponse<SinglePlanData, SinglePlanMetadata>>(endpoint, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get products for a specific production plan * * @param planId - The plan ID to get products for * @param options - Optional request configuration * @returns Promise resolving to plan products data and metadata * * @throws {NotFoundError} When the plan with the specified ID doesn't exist (HTTP 404) * @throws {ValidationError} When the planId parameter is invalid (HTTP 400) * @throws {AuthorizationError} When the request is not authorized (HTTP 401/403) * @throws {HanaQueryClientError} For other client or server errors * * @example * ```typescript * try { * const result = await client.getPlanProducts(123); * if (result.data.products.length === 0) { * console.log('Plan exists but has no products'); * } else { * console.log(`Found ${result.data.products.length} products`); * } * } catch (error) { * if (error instanceof NotFoundError) { * console.log('Plan does not exist'); * } else { * console.log('Other error:', error.message); * } * } * ``` * * @remarks * - Returns HTTP 200 with empty array when plan exists but has no products * - Returns HTTP 404 only when the plan itself doesn't exist * - Empty products array is a normal, successful response */ async getPlanProducts( planId: number | string, options?: RequestOptions ): Promise<{ data: PlanProductsData; metadata: PlanProductsMetadata }> { const endpoint = replacePathParams(ENDPOINTS.PLAN_PRODUCTS, { planId }); const response = await this.executeAPIRequest<SuccessResponse<PlanProductsData, PlanProductsMetadata>>(endpoint, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get work orders for a specific production plan * * @param planId - The plan ID to get work orders for * @param options - Optional request configuration * @returns Promise resolving to plan work orders data and metadata * * @throws {NotFoundError} When the plan with the specified ID doesn't exist (HTTP 404) * @throws {ValidationError} When the planId parameter is invalid (HTTP 400) * @throws {AuthorizationError} When the request is not authorized (HTTP 401/403) * @throws {HanaQueryClientError} For other client or server errors * * @example * ```typescript * try { * const result = await client.getPlanWorkOrders(123); * if (result.data.workOrders.length === 0) { * console.log('Plan exists but has no work orders'); * } else { * console.log(`Found ${result.data.workOrders.length} work orders`); * } * } catch (error) { * if (error instanceof NotFoundError) { * console.log('Plan does not exist'); * } else { * console.log('Other error:', error.message); * } * } * ``` * * @remarks * - Returns HTTP 200 with empty array when plan exists but has no work orders * - Returns HTTP 404 only when the plan itself doesn't exist * - Empty work orders array is a normal, successful response */ async getPlanWorkOrders( planId: number | string, options?: RequestOptions ): Promise<{ data: PlanWorkOrdersData; metadata: PlanWorkOrdersMetadata }> { const endpoint = replacePathParams(ENDPOINTS.PLAN_WORK_ORDERS, { planId }); const response = await this.executeAPIRequest<SuccessResponse<PlanWorkOrdersData, PlanWorkOrdersMetadata>>(endpoint, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get sector summaries for a specific production plan * * @param planId - The plan ID to get sector summaries for * @param options - Optional request configuration * @returns Promise resolving to plan sectors summary data and metadata * * @throws {NotFoundError} When the plan with the specified ID doesn't exist (HTTP 404) * @throws {ValidationError} When the planId parameter is invalid (HTTP 400) * @throws {AuthorizationError} When the request is not authorized (HTTP 401/403) * @throws {HanaQueryClientError} For other client or server errors * * @example * ```typescript * try { * const result = await client.getPlanSectorsSummary(123); * if (result.data.sectors.length === 0) { * console.log('Plan exists but has no sectors'); * } else { * console.log(`Found ${result.data.sectors.length} sectors`); * console.log(`Completion rate: ${result.metadata.completion_rate}%`); * } * } catch (error) { * if (error instanceof NotFoundError) { * console.log('Plan does not exist'); * } else { * console.log('Other error:', error.message); * } * } * ``` * * @remarks * - Returns HTTP 200 with empty array when plan exists but has no sectors * - Returns HTTP 404 only when the plan itself doesn't exist * - Empty sectors array is a normal, successful response * - Metadata includes aggregated statistics (total planned, completed, completion rate) */ async getPlanSectorsSummary( planId: number | string, options?: RequestOptions ): Promise<{ data: PlanSectorsSummaryData; metadata: PlanSectorsSummaryMetadata }> { const endpoint = replacePathParams(ENDPOINTS.PLAN_SECTORS, { planId }); const response = await this.executeAPIRequest<SuccessResponse<PlanSectorsSummaryData, PlanSectorsSummaryMetadata>>(endpoint, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get sector summaries for all production plans * * @param options - Optional request configuration * @returns Promise resolving to all plans sectors summary data and metadata * * @throws {ValidationError} When the request parameters are invalid (HTTP 400) * @throws {AuthorizationError} When the request is not authorized (HTTP 401/403) * @throws {HanaQueryClientError} For other client or server errors * * @example * ```typescript * const result = await client.getAllPlansSectorsSummary(); * console.log(`Found ${result.data.sectors.length} total sectors`); * console.log(`Across ${result.metadata.unique_plans} unique plans`); * console.log(`Overall completion rate: ${result.metadata.overall_completion_rate}%`); * * // Group sectors by plan * const sectorsByPlan = result.data.sectors.reduce((acc, sector) => { * if (!acc[sector.plan_entry]) acc[sector.plan_entry] = []; * acc[sector.plan_entry].push(sector); * return acc; * }, {} as Record<number, PlanSectorSummary[]>); * ``` * * @remarks * - Returns all sector summaries across all plans in the system * - Empty array indicates no sectors exist in any plan * - Metadata includes overall statistics and unique counts */ async getAllPlansSectorsSummary( options?: RequestOptions ): Promise<{ data: AllPlansSectorsSummaryData; metadata: AllPlansSectorsSummaryMetadata }> { const response = await this.executeAPIRequest<SuccessResponse<AllPlansSectorsSummaryData, AllPlansSectorsSummaryMetadata>>(ENDPOINTS.ALL_PLANS_SECTORS, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get user by username */ async getUser(username: string, options?: RequestOptions): Promise<{ data: UserData; metadata: UserMetadata }> { const endpoint = replacePathParams(ENDPOINTS.USER, { username: encodeURIComponent(username) }); const response = await this.executeAPIRequest<SuccessResponse<UserData, UserMetadata>>(endpoint, undefined, options); return { data: response.data, metadata: response.metadata! }; } // ========================================================================= // Convenience Methods // ========================================================================= /** * Check if a plan exists without fetching its full details * * @param planId - The plan ID to check * @param options - Optional request configuration * @returns Promise resolving to true if plan exists, false otherwise * * @example * ```typescript * const exists = await client.planExists(123); * if (exists) { * console.log('Plan exists'); * } else { * console.log('Plan not found'); * } * ``` */ async planExists(planId: number | string, options?: RequestOptions): Promise<boolean> { try { await this.getPlan(planId, options); return true; } catch (error) { if (error instanceof NotFoundError) { return false; } throw error; } } /** * Get plan products with explicit plan existence information * * @param planId - The plan ID to get products for * @param options - Optional request configuration * @returns Promise resolving to plan existence status, products, and metadata * * @example * ```typescript * const result = await client.getPlanProductsSafe(123); * if (!result.planExists) { * console.log('Plan does not exist'); * } else if (result.products.length === 0) { * console.log('Plan exists but has no products'); * } else { * console.log(`Plan has ${result.products.length} products`); * } * ``` */ async getPlanProductsSafe( planId: number | string, options?: RequestOptions ): Promise<{ planExists: boolean; products: PlanProductsData['products']; metadata: PlanProductsMetadata; }> { try { const result = await this.getPlanProducts(planId, options); return { planExists: true, products: result.data.products, metadata: result.metadata }; } catch (error) { if (error instanceof NotFoundError) { return { planExists: false, products: [], metadata: { count: 0, planId: Number(planId) } }; } throw error; } } /** * Get plan work orders with explicit plan existence information * * @param planId - The plan ID to get work orders for * @param options - Optional request configuration * @returns Promise resolving to plan existence status, work orders, and metadata * * @example * ```typescript * const result = await client.getPlanWorkOrdersSafe(123); * if (!result.planExists) { * console.log('Plan does not exist'); * } else if (result.workOrders.length === 0) { * console.log('Plan exists but has no work orders'); * } else { * console.log(`Plan has ${result.workOrders.length} work orders`); * } * ``` */ async getPlanWorkOrdersSafe( planId: number | string, options?: RequestOptions ): Promise<{ planExists: boolean; workOrders: PlanWorkOrdersData['workOrders']; metadata: PlanWorkOrdersMetadata; }> { try { const result = await this.getPlanWorkOrders(planId, options); return { planExists: true, workOrders: result.data.workOrders, metadata: result.metadata }; } catch (error) { if (error instanceof NotFoundError) { return { planExists: false, workOrders: [], metadata: { count: 0, planId: Number(planId) } }; } throw error; } } // ========================================================================= // Request Builder API (Fluent Interface) // ========================================================================= /** * Create a request builder for fluent API usage */ request(endpoint: string): RequestBuilder { return new RequestBuilder(this, endpoint); } // ========================================================================= // Core Request Method // ========================================================================= /** * Core request method with retry logic and error handling */ private async executeAPIRequest<T extends APIResponse>( endpoint: string, body?: any, options?: RequestOptions ): Promise<T> { const mergedOptions = { timeout: getEndpointTimeout(endpoint, this.config.timeout), retries: this.config.retries, ...options }; let lastError: HanaQueryClientError | undefined; for (let attempt = 1; attempt <= mergedOptions.retries + 1; attempt++) { const context: RequestContext = { method: 'GET', url: endpoint, startTime: Date.now(), attempt, maxAttempts: mergedOptions.retries + 1 }; try { this.log('debug', `Request attempt ${attempt}`, { endpoint, attempt }); const result = await this.executeRequest<T>(endpoint, body, mergedOptions, context); this.log('info', 'Request successful', { endpoint, attempt, duration: context.duration }); return result; } catch (error) { context.endTime = Date.now(); context.duration = context.endTime - context.startTime; if (error instanceof HanaQueryClientError) { lastError = error; } else { lastError = createNetworkError(error as Error, context); } this.log('warn', `Request attempt ${attempt} failed`, { endpoint, attempt, error: lastError.message, duration: context.duration }); // Don't retry if this is the last attempt or error is not retryable if (attempt >= mergedOptions.retries + 1 || !isRetryableError(lastError)) { break; } // Calculate delay and wait before retrying const delay = getRetryDelay(attempt, this.config.retryDelay, 30000); this.log('debug', `Retrying in ${delay}ms`, { endpoint, attempt, delay }); await this.sleep(delay); } } this.log('error', 'Request failed after all retries', { endpoint, attempts: mergedOptions.retries + 1, error: lastError?.message }); throw lastError!; } /** * Execute a single HTTP request */ private async executeRequest<T extends APIResponse>( endpoint: string, body: any, options: RequestOptions & { timeout: number }, context: RequestContext ): Promise<T> { const url = getApiUrl(this.config.baseUrl, endpoint); context.url = url; // Setup request options const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), options.timeout); // Merge signal if provided if (options.signal) { options.signal.addEventListener('abort', () => controller.abort()); } const requestInit: RequestInit = { method: 'GET', headers: { ...this.config.headers }, signal: controller.signal }; // Add body for POST/PUT requests (currently API only uses GET) if (body && requestInit.method !== 'GET') { requestInit.body = JSON.stringify(body); } let response: Response; try { this.log('debug', 'Sending HTTP request', { url, method: requestInit.method }); response = await fetch(url, requestInit); } catch (error) { throw createNetworkError(error as Error, context); } finally { clearTimeout(timeoutId); context.endTime = Date.now(); context.duration = context.endTime - context.startTime; } // Parse response headers const headers = this.parseResponseHeaders(response); // Log response info this.log('debug', 'Received HTTP response', { url, status: response.status, headers, duration: context.duration }); // Check authorization header this.checkAuthorization(headers, context); // Parse response body let responseData: any; try { const responseText = await response.text(); responseData = responseText ? JSON.parse(responseText) : null; } catch (error) { throw createNetworkError( new Error(`Failed to parse response JSON: ${(error as Error).message}`), context ); } // Handle HTTP errors if (!response.ok) { throw createErrorFromResponse(response, responseData, context); } // Handle API errors (success: false) if (responseData && responseData.success === false) { throw createErrorFromResponse(response, responseData, context); } // Validate response structure if (!responseData || typeof responseData.success !== 'boolean') { throw createNetworkError( new Error('Invalid API response format'), context ); } return responseData as T; } // ========================================================================= // Helper Methods // ========================================================================= /** * Parse response headers into typed object */ private parseResponseHeaders(response: Response): APIResponseHeaders { const responseTime = response.headers.get(HEADERS.RESPONSE_TIME); const authResponse = response.headers.get(HEADERS.AUTHORIZATION_RESPONSE); const contentType = response.headers.get(HEADERS.CONTENT_TYPE); return { 'x-response-time': responseTime || undefined, 'x-authorization-response': (authResponse as 'ok' | 'unauthorized') || undefined, 'content-type': contentType || undefined }; } /** * Check authorization status from headers */ private checkAuthorization(headers: APIResponseHeaders, context: RequestContext): void { const authStatus = headers['x-authorization-response']; if (authStatus === AUTH_STATUS.UNAUTHORIZED) { throw new AuthorizationError( 'Request unauthorized', 401, context ); } if (authStatus && authStatus !== AUTH_STATUS.OK) { this.log('warn', `Unknown authorization status: ${authStatus}`, { context }); } } /** * Sleep for specified milliseconds */ private sleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Logging method */ private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, data?: any): void { if (!this.config.enableLogging) { return; } const logLevels = ['debug', 'info', 'warn', 'error']; const configLevel = logLevels.indexOf(this.config.logLevel); const messageLevel = logLevels.indexOf(level); if (messageLevel < configLevel) { return; } const timestamp = new Date().toISOString(); const prefix = `[${timestamp}] [HanaQueryClient] [${level.toUpperCase()}]`; if (data) { console[level === 'debug' ? 'log' : level](`${prefix} ${message}`, data); } else { console[level === 'debug' ? 'log' : level](`${prefix} ${message}`); } } // ========================================================================= // Configuration and Utilities // ========================================================================= /** * Get current client configuration */ getConfig(): Readonly<Required<ClientConfig>> { return { ...this.config }; } /** * Update client configuration */ updateConfig(updates: Partial<ClientConfig>): void { Object.assign(this.config, updates); this.log('debug', 'Configuration updated', { updates }); } /** * Test connection to the API */ async testConnection(): Promise<boolean> { try { await this.getHealth({ timeout: 5000, retries: 1 }); return true; } catch { return false; } } } /** * Request Builder for fluent API usage */ export class RequestBuilder { private client: HanaQueryClient; private endpoint: string; private params: Record<string, any> = {}; private requestOptions: RequestOptions = {}; constructor(client: HanaQueryClient, endpoint: string) { this.client = client; this.endpoint = endpoint; } /** * Add query parameters */ query(params: Record<string, any>): this { this.params = { ...this.params, ...params }; return this; } /** * Set request timeout */ timeout(ms: number): this { this.requestOptions.timeout = ms; return this; } /** * Set retry count */ retries(count: number): this { this.requestOptions.retries = count; return this; } /** * Set abort signal */ signal(signal: AbortSignal): this { this.requestOptions.signal = signal; return this; } /** * Execute the request */ async execute<T extends APIResponse>(): Promise<T> { const finalEndpoint = this.endpoint + buildQueryString(this.params); // Access the private method through the prototype const clientMethod = (this.client as any).executeAPIRequest; return clientMethod.call(this.client, finalEndpoint, undefined, this.requestOptions); } } /** * Factory function to create a client instance */ export function createClient(config: ClientConfig, customConfig?: Partial<Omit<ClientConfig, 'baseUrl'>>): HanaQueryClient { return new HanaQueryClient(config, customConfig); } /** * Factory function to create a client from environment configuration */ export function createClientFromEnvironment(environment: string, customConfig?: Partial<ClientConfig>): HanaQueryClient { const envConfig = createConfigFromEnvironment(environment, customConfig); return new HanaQueryClient(envConfig); } /** * Default client instance (requires baseUrl to be set via environment or manual configuration) * Example: createClient({ baseUrl: 'http://localhost:3001' }) */ // export const defaultClient = createClient({ baseUrl: 'http://localhost:3001' });