UNPKG

@ritas-inc/hanaqueryapi-client

Version:

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

1,270 lines (1,173 loc) 46.1 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, ItemsParams, ItemsStatusData, ItemsStatusMetadata, ItemGroupsData, ItemGroupsMetadata, SalesData, SalesMetadata, HierarchiesData, HierarchiesMetadata, TreesData, TreesMetadata, QtyPerTagData, QtyPerTagMetadata, ProductionSectorsData, ProductionSectorsMetadata, DatabaseCompaniesData, DatabaseCompaniesMetadata, TagData, TagMetadata, WorkOrderTagsData, WorkOrderTagsMetadata, InjectionMachinesData, InjectionMachinesMetadata, SingleInjectionMachineData, SingleInjectionMachineMetadata, MoldsData, MoldsMetadata, SingleMoldData, SingleMoldMetadata, BusinessPartnersData, BusinessPartnersMetadata, BusinessPartnersSearchMetadata, ContactsData, ContactsMetadata, ContactsSearchMetadata, BusinessPartnerContactsMetadata, SearchCriteria, PlansData, PlansMetadata, SinglePlanData, SinglePlanMetadata, PlanProductsData, PlanProductsMetadata, PlanWorkOrdersData, PlanWorkOrdersMetadata, PlanTagsData, PlanTagsMetadata, 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 items with optional group filtering * * @param params - Optional filtering parameters with numeric group codes * @param options - Optional request configuration * @returns Promise resolving to items data and metadata * * @example * // Get all items * const allItems = await client.getItems(); * * // Get items for specific group codes (numeric) * const filteredItems = await client.getItems({ * groups: [101, 102, 103] * }); */ async getItems( params?: ItemsParams, options?: RequestOptions ): Promise<{ data: ItemsStatusData; metadata: ItemsStatusMetadata }> { const endpoint = params?.groups?.length ? ENDPOINTS.ITEMS + buildQueryString({ groups: params.groups.join(',') }) : ENDPOINTS.ITEMS; const response = await this.executeAPIRequest<SuccessResponse<ItemsStatusData, ItemsStatusMetadata>>( endpoint, 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 children of a specific parent item (filtered by groups 131, 144) * * @param itemCode - Parent item code to get children for * @param options - Optional request configuration * @returns Promise resolving to tree data and metadata * * @example * ```typescript * const { data, metadata } = await client.getItemTree('PARENT-001'); * console.log(`Found ${metadata.count} children for parent ${metadata.parent}`); * data.trees.forEach(tree => { * console.log(`Child: ${tree.itemcode}, Quantity: ${tree.quantity}`); * }); * ``` * * @returns {Object} result - Tree result * @returns {Object} result.data - Tree data * @returns {Array} result.data.trees - Array of tree relationships (parent-child-quantity) * @returns {Object} result.metadata - Metadata about the response * @returns {number} result.metadata.count - Total number of children returned * @returns {string} result.metadata.parent - Parent item code queried */ async getItemTree(itemCode: string, options?: RequestOptions): Promise<{ data: TreesData; metadata: TreesMetadata }> { const endpoint = `/items/${itemCode}/tree`; const response = await this.executeAPIRequest<SuccessResponse<TreesData, TreesMetadata>>(endpoint, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get quantity per tag data for items with mold resources * * @param options - Optional request configuration * @returns Promise resolving to quantity per tag data and metadata * * @example * ```typescript * const { data, metadata } = await client.getQtyPerTag(); * console.log(`Found ${metadata.count} items with quantity per tag data`); * data.qtyPerTag.forEach(item => { * console.log(`${item.itemcode}: ${item.qtyPerTag} units per tag`); * }); * ``` * * @returns {Object} result - Quantity per tag result * @returns {Object} result.data - Quantity per tag data * @returns {Array} result.data.qtyPerTag - Array of items with their quantity per tag values * @returns {Object} result.metadata - Metadata about the response * @returns {number} result.metadata.count - Total number of items returned */ async getQtyPerTag(options?: RequestOptions): Promise<{ data: QtyPerTagData; metadata: QtyPerTagMetadata }> { const response = await this.executeAPIRequest<SuccessResponse<QtyPerTagData, QtyPerTagMetadata>>(ENDPOINTS.ITEMS_QTY_PER_TAG, 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 database companies */ async getDatabaseCompanies(options?: RequestOptions): Promise<{ data: DatabaseCompaniesData; metadata: DatabaseCompaniesMetadata }> { const response = await this.executeAPIRequest<SuccessResponse<DatabaseCompaniesData, DatabaseCompaniesMetadata>>(ENDPOINTS.DATABASE_COMPANIES, 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 tags for a specific production plan * * @param planId - The plan ID to get tags for * @param options - Optional request configuration * @returns Promise resolving to plan tags 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.getPlanTags(123); * if (result.data.tags.length === 0) { * console.log('Plan exists but has no tags'); * } else { * console.log(`Found ${result.data.tags.length} tags`); * result.data.tags.forEach(tag => { * console.log(`Tag ${tag.tag_entry}: ${tag.tag_item_name} (WO ${tag.tag_workorder_num})`); * }); * } * } 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 tags * - Returns HTTP 404 only when the plan itself doesn't exist * - Empty tags array is a normal, successful response */ async getPlanTags( planId: number | string, options?: RequestOptions ): Promise<{ data: PlanTagsData; metadata: PlanTagsMetadata }> { const endpoint = replacePathParams(ENDPOINTS.PLAN_TAGS, { planId }); const response = await this.executeAPIRequest<SuccessResponse<PlanTagsData, PlanTagsMetadata>>(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! }; } /** * Get a specific production tag by entry number */ async getTag(tagEntry: number | string, options?: RequestOptions): Promise<{ data: TagData; metadata: TagMetadata }> { const endpoint = replacePathParams(ENDPOINTS.TAG, { tagEntry }); const response = await this.executeAPIRequest<SuccessResponse<TagData, TagMetadata>>(endpoint, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get all tags for a specific work order * * @param workOrderEntry - The work order entry (DocEntry) to get tags for * @param options - Optional request configuration * @returns Promise resolving to work order tags data and metadata * * @throws {ValidationError} When the workOrderEntry 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 * const result = await client.getWorkOrderTags(12345); * console.log(`Found ${result.data.tags.length} tags for work order ${result.metadata.workOrderEntry}`); * * // Iterate through all tags * result.data.tags.forEach(tag => { * console.log(`Tag ${tag.tagEntry}: Status=${tag.status}, Quantity=${tag.quantity}${tag.quantityUoM}`); * if (tag.activationDateTime) { * console.log(` Activated: ${tag.activationDateTime} by ${tag.activationEmployee}`); * } * if (tag.quantificationDateTime) { * console.log(` Quantified: ${tag.quantificationDateTime} by ${tag.quantificationEmployee}`); * } * }); * ``` * * @remarks * - Returns empty array if work order has no tags * - No 404 if work order doesn't exist - just returns empty array * - Each tag includes full details (activation, quantification, use, discard info) * - Useful for tracking tag lifecycle for a specific production order */ async getWorkOrderTags( workOrderEntry: number | string, options?: RequestOptions ): Promise<{ data: WorkOrderTagsData; metadata: WorkOrderTagsMetadata }> { const endpoint = replacePathParams(ENDPOINTS.WORK_ORDER_TAGS, { workOrderEntry }); const response = await this.executeAPIRequest<SuccessResponse<WorkOrderTagsData, WorkOrderTagsMetadata>>(endpoint, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get all injection machines */ async getInjectionMachines(options?: RequestOptions): Promise<{ data: InjectionMachinesData; metadata: InjectionMachinesMetadata }> { const response = await this.executeAPIRequest<SuccessResponse<InjectionMachinesData, InjectionMachinesMetadata>>(ENDPOINTS.INJECTION_MACHINES, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get a specific injection machine by code */ async getInjectionMachine(machineCode: string, options?: RequestOptions): Promise<{ data: SingleInjectionMachineData; metadata: SingleInjectionMachineMetadata }> { const endpoint = replacePathParams(ENDPOINTS.INJECTION_MACHINE, { machineCode: encodeURIComponent(machineCode) }); const response = await this.executeAPIRequest<SuccessResponse<SingleInjectionMachineData, SingleInjectionMachineMetadata>>(endpoint, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get all molds with specifications */ async getMolds(options?: RequestOptions): Promise<{ data: MoldsData; metadata: MoldsMetadata }> { const response = await this.executeAPIRequest<SuccessResponse<MoldsData, MoldsMetadata>>(ENDPOINTS.MOLDS, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get a specific mold by code */ async getMold(moldCode: string, options?: RequestOptions): Promise<{ data: SingleMoldData; metadata: SingleMoldMetadata }> { const endpoint = replacePathParams(ENDPOINTS.MOLD, { moldCode: encodeURIComponent(moldCode) }); const response = await this.executeAPIRequest<SuccessResponse<SingleMoldData, SingleMoldMetadata>>(endpoint, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * List all business partners. * * @example * ```typescript * const { data, metadata } = await client.getBusinessPartners(); * console.log(`${metadata.count} partners`); * data.partners.forEach(p => console.log(p.cardcode, p.cardname)); * ``` * * @remarks Returns 404 (mapped to `NotFoundError`) if the table is empty. */ async getBusinessPartners(options?: RequestOptions): Promise<{ data: BusinessPartnersData; metadata: BusinessPartnersMetadata }> { const response = await this.executeAPIRequest<SuccessResponse<BusinessPartnersData, BusinessPartnersMetadata>>(ENDPOINTS.BUSINESS_PARTNERS, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Search business partners by phone, email, name (fuzzy), and/or CardCode. * * At least one of `phone`, `email`, `q`, or `cardCode` must be supplied — * otherwise the server returns 400 (surfaced as `ValidationError`). * * @param criteria - Search criteria. `fuzziness` (0.1–1.0, default 0.7) tunes * the FUZZY threshold for `q`. * @example * ```typescript * const { data, metadata } = await client.searchBusinessPartners({ * q: 'jose silva', * fuzziness: 0.8 * }); * console.log(`${metadata.count} matches`); * ``` * * @remarks * - `phone` is normalized to digits and matched as a substring. * - `email` is exact after canonical email extraction (case-insensitive). * - `q` is HANA `CONTAINS FUZZY` across CardName, City, State, Country, * Block, Address, ZipCode, IndName. Words are OR-ed. * - `cardCode` is an exact match. * - Empty result returns 200 with `partners: []`; no NotFoundError. */ async searchBusinessPartners( criteria: SearchCriteria, options?: RequestOptions ): Promise<{ data: BusinessPartnersData; metadata: BusinessPartnersSearchMetadata }> { const endpoint = ENDPOINTS.BUSINESS_PARTNERS_SEARCH + buildQueryString(criteria as unknown as Record<string, string | number | boolean | undefined>); const response = await this.executeAPIRequest<SuccessResponse<BusinessPartnersData, BusinessPartnersSearchMetadata>>(endpoint, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Get the contacts for a specific business partner. * * @param cardCode - The business partner's CardCode. * @example * ```typescript * const { data, metadata } = await client.getBusinessPartnerContacts('C12345'); * console.log(`${metadata.count} contacts for ${metadata.cardCode}`); * ``` * * @remarks * Returns 200 with an empty array even if the cardCode does not exist — * this endpoint does **not** 404 on unknown partners. Use * `searchBusinessPartners({ cardCode })` first if you need an existence check. */ async getBusinessPartnerContacts( cardCode: string, options?: RequestOptions ): Promise<{ data: ContactsData; metadata: BusinessPartnerContactsMetadata }> { const endpoint = replacePathParams(ENDPOINTS.BUSINESS_PARTNER_CONTACTS, { cardCode: encodeURIComponent(cardCode) }); const response = await this.executeAPIRequest<SuccessResponse<ContactsData, BusinessPartnerContactsMetadata>>(endpoint, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * List all contacts. * * @example * ```typescript * const { data, metadata } = await client.getContacts(); * console.log(`${metadata.count} contacts`); * ``` * * @remarks * - Returns 404 (NotFoundError) if the table is empty. * - `cardname` on the returned contacts is `null`/absent here — only the * search endpoint joins to ocrd and populates it. */ async getContacts(options?: RequestOptions): Promise<{ data: ContactsData; metadata: ContactsMetadata }> { const response = await this.executeAPIRequest<SuccessResponse<ContactsData, ContactsMetadata>>(ENDPOINTS.CONTACTS, undefined, options); return { data: response.data, metadata: response.metadata! }; } /** * Search contacts by phone, email, name (fuzzy), and/or CardCode. * * Same criteria shape as {@link searchBusinessPartners}; at least one of * `phone` / `email` / `q` / `cardCode` is required (400 otherwise). * * @param criteria - Search criteria. `fuzziness` (0.1–1.0, default 0.7) tunes * the FUZZY threshold for `q`. * * @example * ```typescript * // Find contacts named "jose" within partner C12345 * const { data } = await client.searchContacts({ q: 'jose', cardCode: 'C12345' }); * data.contacts.forEach(c => console.log(c.contactname, '@', c.cardname)); * ``` * * @remarks * Cross-table — `phone` / `email` / `q` also match the parent BP's fields. * Returned contacts include `cardname` populated from the joined BP row. */ async searchContacts( criteria: SearchCriteria, options?: RequestOptions ): Promise<{ data: ContactsData; metadata: ContactsSearchMetadata }> { const endpoint = ENDPOINTS.CONTACTS_SEARCH + buildQueryString(criteria as unknown as Record<string, string | number | boolean | undefined>); const response = await this.executeAPIRequest<SuccessResponse<ContactsData, ContactsSearchMetadata>>(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' });