base-creator.tsβ’9.71 kB
/**
* BaseCreator - Abstract base class for all resource creators
*
* Provides common functionality and utilities shared across all resource creators.
* Implements Strategy Pattern base behavior including error handling, recovery,
* and response processing.
*/
import type { AttioRecord, JsonObject } from '@shared-types/attio.js';
import type {
ResourceCreator,
ResourceCreatorContext,
ResourceCreatorError,
RecoveryOptions,
} from './types.js';
import { EnhancedApiError } from '../../../errors/enhanced-api-errors.js';
import { extractRecordId } from '../../../utils/validation/uuid-validation.js';
import {
extractAttioRecord,
assertLooksLikeCreated,
isTestRun,
debugRecordShape,
} from '../extractor.js';
import { validateRequiredArrayField } from '../validators.js';
/**
* Abstract base class for resource creators
* Provides shared functionality and enforces creator interface
*/
export abstract class BaseCreator implements ResourceCreator {
abstract readonly resourceType: string;
abstract readonly endpoint: string;
/**
* Creates a resource record (implemented by subclasses)
*/
abstract create(
input: JsonObject,
context: ResourceCreatorContext
): Promise<AttioRecord>;
/**
* Normalizes input data for the specific resource type
* Override in subclasses for resource-specific normalization
*/
protected normalizeInput(input: JsonObject): JsonObject {
return input;
}
/**
* Validates required array field presence
*/
protected assertRequiredArray(
payload: JsonObject,
field: string,
errorMessage: string
): void {
validateRequiredArrayField(payload, field, errorMessage);
}
/**
* Creates the API payload for resource creation
*/
protected createPayload(normalizedInput: JsonObject): JsonObject {
return {
data: {
values: normalizedInput,
},
};
}
/**
* Processes API response and extracts record
*/
protected async processResponse(
response: JsonObject,
context: ResourceCreatorContext,
normalizedInput?: JsonObject
): Promise<AttioRecord> {
this.logResponseMetadata(response, context);
const extractedRecord = this.extractAndEnrichRecord(response);
const finalRecord = await this.validateAndRecoverRecord(
extractedRecord,
context,
normalizedInput
);
return this.finalizeAndDebugRecord(finalRecord, context);
}
/**
* Logs API response metadata for debugging
*/
private logResponseMetadata(
response: JsonObject,
context: ResourceCreatorContext
): void {
context.debug(this.constructor.name, `${this.resourceType} API response`, {
status: response?.status,
statusText: response?.statusText,
hasData: !!response?.data,
hasNestedData: !!(response?.data as Record<string, unknown>)?.data,
dataKeys: response?.data
? Object.keys(response.data as Record<string, unknown>)
: [],
});
}
/**
* Extracts record from response and enriches with ID if needed
*/
private extractAndEnrichRecord(response: JsonObject): JsonObject {
const record = extractAttioRecord(response);
return this.enrichRecordId(record || ({} as JsonObject), response);
}
/**
* Validates record and attempts recovery if needed
*/
private async validateAndRecoverRecord(
record: JsonObject,
context: ResourceCreatorContext,
normalizedInput?: JsonObject
): Promise<AttioRecord> {
const mustRecover = this.shouldAttemptRecovery(record);
if (mustRecover) {
return await this.attemptRecovery(context, normalizedInput);
}
return record as AttioRecord;
}
/**
* Determines if recovery should be attempted based on record state
*/
private shouldAttemptRecovery(record: JsonObject): boolean {
return (
!record ||
!(record as JsonObject).id ||
!((record as JsonObject).id as JsonObject)?.record_id
);
}
/**
* Finalizes record validation and adds debug logging
*/
private finalizeAndDebugRecord(
record: AttioRecord,
context: ResourceCreatorContext
): AttioRecord {
assertLooksLikeCreated(record, `${this.constructor.name}.create`);
if (isTestRun()) {
context.debug(
this.constructor.name,
`Normalized ${this.resourceType} record`,
debugRecordShape(record)
);
}
return record;
}
/**
* Enriches record with ID extracted from web_url if missing
*/
protected enrichRecordId(
record: JsonObject,
response: JsonObject
): JsonObject {
if (record && (!record.id || !(record.id as JsonObject)?.record_id)) {
const webUrl = record?.web_url || (response?.data as JsonObject)?.web_url;
const rid = webUrl ? extractRecordId(String(webUrl)) : undefined;
if (rid) {
const existingId = (record.id as JsonObject) || ({} as JsonObject);
record.id = { ...existingId, record_id: rid };
}
}
return record;
}
/**
* Attempts to recover record by searching for it
* Override in subclasses to implement resource-specific recovery
*/
protected async attemptRecovery(
context: ResourceCreatorContext,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_normalizedInput?: JsonObject
): Promise<AttioRecord> {
const recoveryOptions = this.getRecoveryOptions();
if (!recoveryOptions) {
throw this.createEnhancedError(
new Error(
`${this.resourceType} creation returned empty/invalid record`
),
context,
500
);
}
for (const filter of recoveryOptions.searchFilters) {
try {
const searchEndpoint = `${this.endpoint}/search`;
const searchFilter: JsonObject = {
[filter.field]:
filter.operator === 'contains'
? { contains: filter.value }
: { eq: filter.value },
};
const { data: searchResult } = await context.client.post(
searchEndpoint,
{
filter: searchFilter,
limit: 1,
order: { created_at: 'desc' },
}
);
const record = extractAttioRecord(searchResult);
const typedRecord = record as JsonObject | undefined;
const recordId = typedRecord?.id
? (typedRecord.id as JsonObject)?.record_id
: undefined;
if (recordId) {
context.debug(
this.constructor.name,
`${this.resourceType} recovery succeeded`,
{
recoveredBy: filter.field,
recordId,
}
);
return typedRecord as unknown as AttioRecord;
}
} catch (e) {
context.debug(
this.constructor.name,
`${this.resourceType} recovery attempt failed`,
{
field: filter.field,
message: (e as Error)?.message,
}
);
}
}
throw this.createEnhancedError(
new Error(`${this.resourceType} creation and recovery both failed`),
context,
500
);
}
/**
* Gets recovery options for this resource type
* Override in subclasses to provide resource-specific recovery
*/
protected getRecoveryOptions(): RecoveryOptions | null {
return null;
}
/**
* Creates enhanced API error with context
*/
/**
* Fails fast if auth is missing to avoid confusing "200 {}" responses
*/
protected assertClientHasAuth(context: ResourceCreatorContext) {
const common = context.client?.defaults?.headers?.common ?? {};
const direct = context.client?.defaults?.headers ?? {};
const auth = (common['Authorization'] ??
common['authorization'] ??
direct['Authorization'] ??
direct['authorization']) as string | undefined;
if (!auth) {
throw new Error('Attio client has no Authorization header.');
}
}
protected createEnhancedError(
error: Error,
context: ResourceCreatorContext,
status: number = 500
): EnhancedApiError {
const errorInfo: ResourceCreatorError = {
operation: 'create',
endpoint: this.endpoint,
resourceType: this.resourceType,
originalError: error,
httpStatus: status,
};
context.logError(
this.constructor.name,
`${this.resourceType} creation error`,
errorInfo as unknown as JsonObject
);
let message: string;
if (status === 500) {
message = `invalid request: Attio ${this.resourceType} creation failed with a server error.`;
} else {
message = `Attio ${this.resourceType} creation failed (${status}): ${error.message}`;
}
return new EnhancedApiError(message, status, this.endpoint, 'POST', {
httpStatus: status,
resourceType: this.resourceType,
operation: 'create',
originalError: error,
});
}
/**
* Handles API errors during creation
*/
protected handleApiError(
err: unknown,
context: ResourceCreatorContext,
payload?: JsonObject
): never {
const error = err as {
response?: { status?: number; data?: { message?: string } };
message?: string;
name?: string;
};
const status = error?.response?.status ?? 500;
const data = error?.response?.data;
const detailMessage = data?.message;
context.logError(
this.constructor.name,
`${this.resourceType} API error details`,
{
status,
errorBody: data,
requestPayload: payload,
}
);
throw this.createEnhancedError(
new Error(
detailMessage || error?.message || `${this.resourceType} creation error`
),
context,
status
);
}
}