import type { AxiosInstance, AxiosRequestConfig } from 'axios'; import type { BaseEntity, ApiURLMap, RequestMethodMap, RequestDescriptor, ExecuteOptions, DataServicesConfig, EntityId, } from './types'; import type { IDataTransformer } from './base-data.transformer'; import type { ApiResponse } from '../http-client/types'; import { interpolateUrl } from './url-builder'; import { DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants'; /** * Abstract base class for remote data services. * * Provides a generic `execute()` method that eliminates the 22 * near-identical methods from the legacy `BaseRemoteDataServices`. * Each operation is reduced to a one-liner calling `execute()` * with the appropriate descriptor. * * **Key architectural differences from legacy:** * - Receives an **injected** AxiosInstance (no global axios import) * - Returns `Promise>` (no callback-based onSuccess/onFailed) * - All operations are fully typed end-to-end * - Includes `customRequest()` as an escape hatch for non-standard endpoints * * @typeParam E - The domain entity type (must extend BaseEntity) * @typeParam TDTO - The API DTO shape (defaults to E for backward compatibility) * * @example * ```ts * // Without transformer (backward compatible) * class BookingDataServices extends BaseRemoteDataServices {} * * const services = new BookingDataServices(apiClient, { * apiUrl: '/bookings', * moduleKey: 'BOOKING', * }); * * // With transformer (DTO ↔ Entity mapping) * const services = new BookingDataServices(apiClient, { * apiUrl: '/bookings', * moduleKey: 'BOOKING', * transformer: new BookingTransformer(), * }); * * const { data, status } = await services.getOne('42'); * ``` */ export abstract class BaseRemoteDataServices { /** The injected, isolated HTTP client instance. */ protected readonly httpClient: AxiosInstance; /** Resolved URL map for all operations. */ protected readonly urls: ApiURLMap; /** Resolved HTTP method map for all operations. */ protected readonly methods: RequestMethodMap; /** Module key for the 'ex-module-key' audit header. */ protected readonly moduleKey: string | undefined; /** * Optional data transformer for DTO ↔ Entity mapping. * * When present, CRUD methods automatically transform responses * and payloads. When absent, data passes through unchanged. */ protected readonly transformer: IDataTransformer | undefined; constructor(httpClient: AxiosInstance, config: DataServicesConfig) { this.httpClient = httpClient; this.moduleKey = config.moduleKey; this.transformer = config.transformer; this.urls = { ...makeDefaultURLs(config.apiUrl ?? ''), ...(config.urls ?? {}), }; this.methods = { ...DEFAULT_METHODS, ...(config.methods ?? {}), }; } // ─── Generic Executor ─────────────────────────────────────────── /** * The single generic request executor. * * All standard operations delegate to this method with a * pre-defined descriptor. This is the engine that replaces * 22 near-identical legacy methods. * * @typeParam T - Expected response data type * @param descriptor - Defines which URL, method, and action to use * @param options - Dynamic URL params and additional Axios config * @returns Typed API response with data and status */ protected async execute( descriptor: RequestDescriptor, options?: ExecuteOptions, ): Promise> { const { urlKey, methodKey, action } = descriptor; const response = await this.httpClient.request({ url: interpolateUrl(this.urls[urlKey], options?.variableURL), method: this.methods[methodKey], ...(options?.config ?? {}), headers: { ...(this.moduleKey ? { 'ex-module-key': this.moduleKey } : {}), 'ex-module-action': action, ...(options?.config?.headers ?? {}), }, telemetryContext: options?.telemetryContext ?? options?.config?.telemetryContext, // FIXME, }); return { data: response.data, status: response.status }; } // ─── Escape Hatch ─────────────────────────────────────────────── /** * Execute a fully custom request that doesn't fit standard CRUD. * * Use this for non-standard endpoints like `/calculate-tax`, * custom aggregations, or third-party integrations. * * The request still flows through the injected httpClient, so * all interceptors (auth, observability, error handling) are * preserved automatically. * * @typeParam T - Expected response data type * @param config - Complete Axios request configuration * @returns Typed API response with data and status * * @example * ```ts * const tax = await services.customRequest({ * url: '/bookings/42/calculate-tax', * method: 'POST', * data: { items: [...] }, * }); * ``` */ async customRequest(config: AxiosRequestConfig): Promise> { const response = await this.httpClient.request({ ...config, headers: { ...(this.moduleKey ? { 'ex-module-key': this.moduleKey } : {}), ...(config.headers ?? {}), }, }); return { data: response.data, status: response.status }; } // ─── CRUD Operations ─────────────────────────────────────────── /** * Fetch a paginated list of entities. * * When a transformer is injected, the raw API response is passed * through `transformGetManyResponse()` before being returned. */ async getMany(config?: AxiosRequestConfig): Promise> { const finalConfig = config?.params && this.transformer?.transformPayloadFilter ? { ...config, params: this.transformer.transformPayloadFilter(config.params) } : config; const result = await this.execute(DESCRIPTORS.getMany, { config: finalConfig }); if (this.transformer && Array.isArray(result.data)) { return { ...result, data: this.transformer.transformGetManyResponse ? this.transformer.transformGetManyResponse(result.data as unknown as TDTO[]) : result.data.map((item: unknown) => this.transformer!.transformToEntity(item as TDTO)), } as unknown as ApiResponse; } return result; } /** * Fetch a single entity by ID. * * When a transformer is injected, the raw API response is passed * through `transformGetOneResponse()` before being returned. */ async getOne(id: string, config?: AxiosRequestConfig): Promise> { const result = await this.execute(DESCRIPTORS.getOne, { variableURL: { id }, config, }); if (this.transformer && result.data != null) { return { ...result, data: this.transformer.transformGetOneResponse ? this.transformer.transformGetOneResponse(result.data as unknown as TDTO) : this.transformer.transformToEntity(result.data as unknown as TDTO), } as unknown as ApiResponse; } return result; } /** * Create a new entity. * * When a transformer is injected, the entity payload is passed * through `transformCreatePayload()` before being sent to the API. */ create(data: Partial, config?: AxiosRequestConfig): Promise> { const transformedData = this.transformer?.transformCreatePayload ? this.transformer.transformCreatePayload(data) : this.transformer ? this.transformer.transformToDTO(data as E) : data; return this.execute(DESCRIPTORS.create, { config: { ...config, data: transformedData }, }); } /** * Update an existing entity by ID. * * When a transformer is injected, the entity payload is passed * through `transformEditPayload()` before being sent to the API. */ edit(id: string, data: Partial, config?: AxiosRequestConfig): Promise> { const transformedData = this.transformer?.transformEditPayload ? this.transformer.transformEditPayload(data) : this.transformer ? this.transformer.transformToDTO(data as E) : data; return this.execute(DESCRIPTORS.edit, { variableURL: { id }, config: { ...config, data: transformedData }, }); } /** Delete a single entity by ID. Optionally sends form data as `meta` in the request body. */ delete(id: EntityId, meta?: Record, config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.delete, { variableURL: { id }, config: { ...config, ...(meta ? { data: { meta } } : {}) }, }); } /** Delete multiple entities by IDs. */ batchDelete(ids: EntityId[], config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.batchDelete, { config: { ...config, data: { ids } }, }); } // ─── Activation Lifecycle ───────────────────────────────────── /** Activate a single entity. Optionally sends form data as `meta` in the request body. */ activate(id: EntityId, meta?: Record, config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.activate, { variableURL: { id }, config: { ...config, ...(meta ? { data: { meta } } : {}) }, }); } /** Activate multiple entities. */ batchActivate(ids: EntityId[], config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.batchActivate, { config: { ...config, data: { ids } }, }); } /** Deactivate a single entity. Optionally sends form data as `meta` in the request body. */ deactivate(id: EntityId, meta?: Record, config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.deactivate, { variableURL: { id }, config: { ...config, ...(meta ? { data: { meta } } : {}) }, }); } /** Deactivate multiple entities. */ batchDeactivate(ids: EntityId[], config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.batchDeactivate, { config: { ...config, data: { ids } }, }); } // ─── Data Processing Lifecycle ──────────────────────────────── /** Confirm processing of a single data record. Optionally sends form data as `meta` in the request body. */ confirmData(id: EntityId, meta?: Record, config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.confirmData, { variableURL: { id }, config: { ...config, ...(meta ? { data: { meta } } : {}) }, }); } /** Confirm processing of multiple data records. */ batchConfirmData(ids: EntityId[], config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.batchConfirmData, { config: { ...config, data: { ids } }, }); } /** Cancel processing of a single data record. Optionally sends form data as `meta` in the request body. */ cancelData(id: EntityId, meta?: Record, config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.cancelData, { variableURL: { id }, config: { ...config, ...(meta ? { data: { meta } } : {}) }, }); } /** Cancel processing of multiple data records. */ batchCancelData(ids: EntityId[], config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.batchCancelData, { config: { ...config, data: { ids } }, }); } // ─── Transaction Lifecycle ──────────────────────────────────── /** Rollback a transaction. Optionally sends form data as `meta` in the request body. */ rollbackData(id: EntityId, meta?: Record, config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.rollbackData, { variableURL: { id }, config: { ...config, ...(meta ? { data: { meta } } : {}) }, }); } /** Rollback multiple transactions. */ batchRollbackData(ids: EntityId[], config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.batchRollbackData, { config: { ...config, data: { ids } }, }); } /** Hold a transaction. Optionally sends form data as `meta` in the request body. */ holdData(id: EntityId, meta?: Record, config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.holdData, { variableURL: { id }, config: { ...config, ...(meta ? { data: { meta } } : {}) }, }); } /** Hold multiple transactions. */ batchHoldData(ids: EntityId[], config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.batchHoldData, { config: { ...config, data: { ids } }, }); } }