Refactor code structure for improved readability and maintainability

This commit is contained in:
Firman Ramdhani
2026-05-22 17:53:35 +07:00
parent af0fe70ac6
commit 4260e240a0
37 changed files with 2915 additions and 74 deletions
@@ -0,0 +1,314 @@
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import type {
BaseEntity,
ApiURLMap,
RequestMethodMap,
RequestDescriptor,
ExecuteOptions,
DataServicesConfig,
} from './types';
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<ApiResponse<T>>` (no callback-based onSuccess/onFailed)
* - All operations are fully typed end-to-end
* - Includes `customRequest<T>()` as an escape hatch for non-standard endpoints
*
* @typeParam E - The domain entity type (must extend BaseEntity)
*
* @example
* ```ts
* class BookingDataServices extends BaseRemoteDataServices<BookingEntity> {}
*
* const services = new BookingDataServices(apiClient, {
* apiUrl: '/bookings',
* moduleKey: 'BOOKING',
* });
*
* const { data, status } = await services.getOne<BookingEntity>('42');
* ```
*/
export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity> {
/** 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;
constructor(httpClient: AxiosInstance, config: DataServicesConfig) {
this.httpClient = httpClient;
this.moduleKey = config.moduleKey;
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<T = unknown>(
descriptor: RequestDescriptor,
options?: ExecuteOptions,
): Promise<ApiResponse<T>> {
const { urlKey, methodKey, action } = descriptor;
const response = await this.httpClient.request<T>({
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<TaxResult>({
* url: '/bookings/42/calculate-tax',
* method: 'POST',
* data: { items: [...] },
* });
* ```
*/
async customRequest<T = unknown>(
config: AxiosRequestConfig,
): Promise<ApiResponse<T>> {
const response = await this.httpClient.request<T>({
...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. */
getMany<T = E[]>(config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return this.execute<T>(DESCRIPTORS.getMany, { config });
}
/** Fetch a single entity by ID. */
getOne<T = E>(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return this.execute<T>(DESCRIPTORS.getOne, {
variableURL: { id },
config,
});
}
/** Create a new entity. */
create<T = E>(data: Partial<E>, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return this.execute<T>(DESCRIPTORS.create, {
config: { ...config, data },
});
}
/** Update an existing entity by ID. */
edit<T = E>(id: string, data: Partial<E>, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return this.execute<T>(DESCRIPTORS.edit, {
variableURL: { id },
config: { ...config, data },
});
}
/** Delete a single entity by ID. */
delete(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.delete, {
variableURL: { id },
config,
});
}
/** Delete multiple entities by IDs. */
batchDelete(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchDelete, {
config: { ...config, data: { ids } },
});
}
// ─── Activation Lifecycle ─────────────────────────────────────
/** Activate a single entity. */
activate(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.activate, {
variableURL: { id },
config,
});
}
/** Activate multiple entities. */
batchActivate(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchActivate, {
config: { ...config, data: { ids } },
});
}
/** Deactivate a single entity. */
deactivate(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.deactivate, {
variableURL: { id },
config,
});
}
/** Deactivate multiple entities. */
batchDeactivate(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchDeactivate, {
config: { ...config, data: { ids } },
});
}
// ─── Data Processing Lifecycle ────────────────────────────────
/** Confirm processing of a single data record. */
confirmProcessData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.confirmProcessData, {
variableURL: { id },
config,
});
}
/** Confirm processing of multiple data records. */
batchConfirmProcessData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchConfirmProcessData, {
config: { ...config, data: { ids } },
});
}
/** Cancel processing of a single data record. */
cancelProcessData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.cancelProcessData, {
variableURL: { id },
config,
});
}
/** Cancel processing of multiple data records. */
batchCancelProcessData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchCancelProcessData, {
config: { ...config, data: { ids } },
});
}
// ─── Transaction Lifecycle ────────────────────────────────────
/** Confirm a transaction. */
confirmProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.confirmProcessTransaction, {
variableURL: { id },
config,
});
}
/** Confirm multiple transactions. */
batchConfirmProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchConfirmProcessTransaction, {
config: { ...config, data: { ids } },
});
}
/** Cancel a transaction. */
cancelProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.cancelProcessTransaction, {
variableURL: { id },
config,
});
}
/** Cancel multiple transactions. */
batchCancelProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchCancelProcessTransaction, {
config: { ...config, data: { ids } },
});
}
/** Rollback a transaction. */
rollbackProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.rollbackProcessTransaction, {
variableURL: { id },
config,
});
}
/** Rollback multiple transactions. */
batchRollbackProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchRollbackProcessTransaction, {
config: { ...config, data: { ids } },
});
}
/** Hold a transaction. */
holdProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.holdProcessTransaction, {
variableURL: { id },
config,
});
}
/** Hold multiple transactions. */
batchHoldProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchHoldProcessTransaction, {
config: { ...config, data: { ids } },
});
}
}
@@ -0,0 +1,43 @@
import type { BaseEntity } from './types';
import { BaseRemoteDataServices } from './base-remote.data-services';
/**
* General-purpose remote data services.
*
* A concrete, non-abstract version of BaseRemoteDataServices that
* can be instantiated directly for standard CRUD modules that don't
* need additional custom methods.
*
* For modules requiring domain-specific operations beyond standard
* CRUD + lifecycle, extend BaseRemoteDataServices instead and add
* custom methods using `this.execute()` or `this.customRequest()`.
*
* @typeParam E - The domain entity type
*
* @example
* ```ts
* // Direct instantiation for standard modules
* const bookingServices = new CommonRemoteDataServices<BookingEntity>(
* apiClient,
* { apiUrl: '/bookings', moduleKey: 'BOOKING' },
* );
*
* const { data } = await bookingServices.getMany();
* ```
*
* @example
* ```ts
* // For modules needing custom operations, extend the base:
* class InvoiceDataServices extends BaseRemoteDataServices<InvoiceEntity> {
* async calculateTax(invoiceId: string) {
* return this.customRequest<TaxResult>({
* url: `/invoices/${invoiceId}/calculate-tax`,
* method: 'POST',
* });
* }
* }
* ```
*/
export class CommonRemoteDataServices<
E extends BaseEntity = BaseEntity,
> extends BaseRemoteDataServices<E> {}
@@ -0,0 +1,126 @@
import type { RequestMethodMap, RequestDescriptor, ApiURLMap } from './types';
// ─── Request Actions ────────────────────────────────────────────
/**
* Constants for the 'ex-module-action' header.
* Maps to backend permission/audit checks.
*/
export const REQUEST_ACTION = {
VIEW: 'VIEW',
CREATE: 'CREATE',
EDIT: 'EDIT',
DELETE: 'DELETE',
CONFIRM_DATA: 'CONFIRM_DATA',
CANCEL_DATA: 'CANCEL_DATA',
CONFIRM_PROCESS_TRANSACTION: 'CONFIRM_PROCESS_TRANSACTION',
CANCEL_PROCESS_TRANSACTION: 'CANCEL_PROCESS_TRANSACTION',
} as const;
// ─── Default HTTP Methods ───────────────────────────────────────
/**
* Sensible REST defaults for all operations.
* Can be overridden per data-services instance.
*/
export const DEFAULT_METHODS: RequestMethodMap = {
getManyMethod: 'GET',
getOneMethod: 'GET',
createMethod: 'POST',
editMethod: 'PUT',
deleteMethod: 'DELETE',
batchDeleteMethod: 'POST',
activateMethod: 'PATCH',
batchActivateMethod: 'POST',
deactivateMethod: 'PATCH',
batchDeactivateMethod: 'POST',
confirmProcessDataMethod: 'PATCH',
batchConfirmProcessDataMethod: 'POST',
cancelProcessDataMethod: 'PATCH',
batchCancelProcessDataMethod: 'POST',
confirmProcessTransactionMethod: 'PATCH',
batchConfirmProcessTransactionMethod: 'POST',
cancelProcessTransactionMethod: 'PATCH',
batchCancelProcessTransactionMethod: 'POST',
rollbackProcessTransactionMethod: 'PATCH',
batchRollbackProcessTransactionMethod: 'POST',
holdProcessTransactionMethod: 'PATCH',
batchHoldProcessTransactionMethod: 'POST',
};
// ─── Operation Descriptors ──────────────────────────────────────
/**
* Pre-defined descriptors for all standard operations.
* Each descriptor maps an operation to its URL key, method key,
* and action header — eliminating 22 boilerplate methods.
*/
export const DESCRIPTORS = {
getMany: { urlKey: 'getManyUrl', methodKey: 'getManyMethod', action: REQUEST_ACTION.VIEW },
getOne: { urlKey: 'getOneUrl', methodKey: 'getOneMethod', action: REQUEST_ACTION.VIEW },
create: { urlKey: 'createUrl', methodKey: 'createMethod', action: REQUEST_ACTION.CREATE },
edit: { urlKey: 'editUrl', methodKey: 'editMethod', action: REQUEST_ACTION.EDIT },
delete: { urlKey: 'deleteUrl', methodKey: 'deleteMethod', action: REQUEST_ACTION.DELETE },
batchDelete: { urlKey: 'batchDeleteUrl', methodKey: 'batchDeleteMethod', action: REQUEST_ACTION.DELETE },
activate: { urlKey: 'activateUrl', methodKey: 'activateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
batchActivate: { urlKey: 'batchActivateUrl', methodKey: 'batchActivateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
deactivate: { urlKey: 'deactivateUrl', methodKey: 'deactivateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
batchDeactivate: { urlKey: 'batchDeactivateUrl', methodKey: 'batchDeactivateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
confirmProcessData: { urlKey: 'confirmProcessDataUrl', methodKey: 'confirmProcessDataMethod', action: REQUEST_ACTION.CONFIRM_DATA },
batchConfirmProcessData: { urlKey: 'batchConfirmProcessDataUrl', methodKey: 'batchConfirmProcessDataMethod', action: REQUEST_ACTION.CONFIRM_DATA },
cancelProcessData: { urlKey: 'cancelProcessDataUrl', methodKey: 'cancelProcessDataMethod', action: REQUEST_ACTION.CANCEL_DATA },
batchCancelProcessData: { urlKey: 'batchCancelProcessDataUrl', methodKey: 'batchCancelProcessDataMethod', action: REQUEST_ACTION.CANCEL_DATA },
confirmProcessTransaction: { urlKey: 'confirmProcessTransactionUrl', methodKey: 'confirmProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
batchConfirmProcessTransaction: { urlKey: 'batchConfirmProcessTransactionUrl', methodKey: 'batchConfirmProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
cancelProcessTransaction: { urlKey: 'cancelProcessTransactionUrl', methodKey: 'cancelProcessTransactionMethod', action: REQUEST_ACTION.CANCEL_PROCESS_TRANSACTION },
batchCancelProcessTransaction: { urlKey: 'batchCancelProcessTransactionUrl', methodKey: 'batchCancelProcessTransactionMethod', action: REQUEST_ACTION.CANCEL_PROCESS_TRANSACTION },
rollbackProcessTransaction: { urlKey: 'rollbackProcessTransactionUrl', methodKey: 'rollbackProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
batchRollbackProcessTransaction: { urlKey: 'batchRollbackProcessTransactionUrl', methodKey: 'batchRollbackProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
holdProcessTransaction: { urlKey: 'holdProcessTransactionUrl', methodKey: 'holdProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
batchHoldProcessTransaction: { urlKey: 'batchHoldProcessTransactionUrl', methodKey: 'batchHoldProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
} as const satisfies Record<string, RequestDescriptor>;
// ─── Default URL Factory ────────────────────────────────────────
/**
* Generates the complete API URL map from a base path.
*
* @param apiUrl - Base API path (e.g., '/bookings')
* @returns Full ApiURLMap with all CRUD + lifecycle URLs
*/
export function makeDefaultURLs(apiUrl: string): ApiURLMap {
return {
getManyUrl: `${apiUrl}`,
getOneUrl: `${apiUrl}/:id`,
createUrl: `${apiUrl}`,
editUrl: `${apiUrl}/:id`,
deleteUrl: `${apiUrl}/:id`,
batchDeleteUrl: `${apiUrl}/batch-delete`,
activateUrl: `${apiUrl}/:id/active`,
batchActivateUrl: `${apiUrl}/batch-active`,
deactivateUrl: `${apiUrl}/:id/inactive`,
batchDeactivateUrl: `${apiUrl}/batch-inactive`,
confirmProcessDataUrl: `${apiUrl}/:id/confirm`,
batchConfirmProcessDataUrl: `${apiUrl}/batch-confirm`,
cancelProcessDataUrl: `${apiUrl}/:id/cancel`,
batchCancelProcessDataUrl: `${apiUrl}/batch-cancel`,
confirmProcessTransactionUrl: `${apiUrl}/:id/confirm-data`,
batchConfirmProcessTransactionUrl: `${apiUrl}/batch-confirm-data`,
cancelProcessTransactionUrl: `${apiUrl}/:id/cancel`,
batchCancelProcessTransactionUrl: `${apiUrl}/batch-cancel`,
rollbackProcessTransactionUrl: `${apiUrl}/:id/confirm-rollback`,
batchRollbackProcessTransactionUrl: `${apiUrl}/batch-confirm-rollback`,
holdProcessTransactionUrl: `${apiUrl}/:id/confirm-hold`,
batchHoldProcessTransactionUrl: `${apiUrl}/batch-confirm-hold`,
};
}
@@ -0,0 +1,19 @@
// ─── Classes ────────────────────────────────────────────────────
export { BaseRemoteDataServices } from './base-remote.data-services';
export { CommonRemoteDataServices } from './common-remote.data-services';
// ─── Utilities ──────────────────────────────────────────────────
export { interpolateUrl } from './url-builder';
// ─── Constants ──────────────────────────────────────────────────
export { REQUEST_ACTION, DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants';
// ─── Types ──────────────────────────────────────────────────────
export type {
BaseEntity,
ApiURLMap,
RequestMethodMap,
RequestDescriptor,
ExecuteOptions,
DataServicesConfig,
} from './types';
@@ -0,0 +1,125 @@
import type { AxiosRequestConfig } from 'axios';
import type { TelemetryContext } from '../http-client/types';
// ─── Base Entity ────────────────────────────────────────────────
/**
* Minimal entity contract. All domain entities must have
* an optional `id` field for CRUD operations.
*/
export interface BaseEntity {
id?: string;
}
// ─── API URL Map ────────────────────────────────────────────────
/**
* Complete URL map for all standard CRUD and lifecycle operations.
* Each key maps to a URL template string (e.g., '/bookings/:id').
*/
export interface ApiURLMap {
getManyUrl: string;
getOneUrl: string;
createUrl: string;
editUrl: string;
deleteUrl: string;
batchDeleteUrl: string;
activateUrl: string;
batchActivateUrl: string;
deactivateUrl: string;
batchDeactivateUrl: string;
confirmProcessDataUrl: string;
batchConfirmProcessDataUrl: string;
cancelProcessDataUrl: string;
batchCancelProcessDataUrl: string;
confirmProcessTransactionUrl: string;
batchConfirmProcessTransactionUrl: string;
cancelProcessTransactionUrl: string;
batchCancelProcessTransactionUrl: string;
rollbackProcessTransactionUrl: string;
batchRollbackProcessTransactionUrl: string;
holdProcessTransactionUrl: string;
batchHoldProcessTransactionUrl: string;
}
// ─── HTTP Method Map ────────────────────────────────────────────
/**
* HTTP method overrides for each operation.
* Defaults to sensible REST conventions (GET, POST, PUT, DELETE, PATCH).
*/
export interface RequestMethodMap {
getManyMethod: string;
getOneMethod: string;
createMethod: string;
editMethod: string;
deleteMethod: string;
batchDeleteMethod: string;
activateMethod: string;
batchActivateMethod: string;
deactivateMethod: string;
batchDeactivateMethod: string;
confirmProcessDataMethod: string;
batchConfirmProcessDataMethod: string;
cancelProcessDataMethod: string;
batchCancelProcessDataMethod: string;
confirmProcessTransactionMethod: string;
batchConfirmProcessTransactionMethod: string;
cancelProcessTransactionMethod: string;
batchCancelProcessTransactionMethod: string;
rollbackProcessTransactionMethod: string;
batchRollbackProcessTransactionMethod: string;
holdProcessTransactionMethod: string;
batchHoldProcessTransactionMethod: string;
}
// ─── Request Descriptor ─────────────────────────────────────────
/**
* Describes a single operation in terms of its URL, method, and
* action header. Used by the generic `execute()` method.
*/
export interface RequestDescriptor {
/** Key into the ApiURLMap. */
urlKey: keyof ApiURLMap;
/** Key into the RequestMethodMap. */
methodKey: keyof RequestMethodMap;
/** Value for the 'ex-module-action' header. */
action: string;
}
// ─── Execute Options ────────────────────────────────────────────
/**
* Options passed to the generic `execute()` method.
*/
export interface ExecuteOptions {
/** Dynamic URL parameters (e.g., `{ id: '42' }`). */
variableURL?: Record<string, string>;
/** Additional Axios request config (params, data, headers, etc). */
config?: AxiosRequestConfig;
/** Per-request telemetry context for custom spans, tags, events. */
telemetryContext?: TelemetryContext;
}
// ─── Data Services Constructor ──────────────────────────────────
/**
* Configuration for constructing a BaseRemoteDataServices instance.
*/
export interface DataServicesConfig {
/** Base API path (e.g., '/bookings'). Used to generate all URL templates. */
apiUrl?: string;
/** Module key for the 'ex-module-key' header (e.g., 'BOOKING'). */
moduleKey?: string;
/** Override specific URL templates. */
urls?: Partial<ApiURLMap>;
/** Override specific HTTP methods. */
methods?: Partial<RequestMethodMap>;
}
@@ -0,0 +1,44 @@
/**
* Interpolates dynamic URL parameters.
*
* Replaces `:paramName` segments in a URL template with values
* from the provided variables object.
*
* @param template - URL template (e.g., '/bookings/:id/confirm')
* @param variables - Key-value map of parameter names to values
* @returns The interpolated URL string
*
* @example
* ```ts
* interpolateUrl('/bookings/:id/confirm', { id: '42' });
* // => '/bookings/42/confirm'
*
* interpolateUrl('/orgs/:orgId/users/:userId', { orgId: 'a', userId: 'b' });
* // => '/orgs/a/users/b'
* ```
*/
export function interpolateUrl(
template: string,
variables?: Record<string, string>,
): string {
if (!variables || Object.keys(variables).length === 0) {
return template;
}
return template
.split('/')
.map((segment) => {
if (segment.startsWith(':')) {
const key = segment.slice(1);
const value = variables[key];
if (value === undefined) {
throw new Error(
`[interpolateUrl] Missing value for URL parameter ":${key}" in template "${template}"`,
);
}
return encodeURIComponent(value);
}
return segment;
})
.join('/');
}