@bradsearch/search-sdk
Version:
TypeScript SDK for BradSearch API with JWT authentication, field mapping, and faceted search capabilities
401 lines (395 loc) • 13.2 kB
JavaScript
;
// API Error types
class ApiError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.name = "ApiError";
}
}
class AuthenticationError extends ApiError {
constructor(message = "Authentication failed") {
super(message, 401);
this.name = "AuthenticationError";
}
}
class ValidationError extends ApiError {
constructor(message = "Validation failed") {
super(message, 400);
this.name = "ValidationError";
}
}
class NetworkError extends ApiError {
constructor(message = "Network error") {
super(message);
this.name = "NetworkError";
}
}
// Validation decorators
function filterable(target, propertyKey) {
// Store metadata about filterable fields
if (!target.constructor._filterableFields) {
target.constructor._filterableFields = [];
}
target.constructor._filterableFields.push(propertyKey);
}
/**
* Validate field configuration
* @param fields Field configuration object
* @throws ValidationError if configuration is invalid
*/
function validateFieldConfig(fields) {
if (!fields || typeof fields !== "object") {
throw new ValidationError("Fields configuration must be an object");
}
Object.entries(fields).forEach(([fieldName, config]) => {
if (!config || typeof config !== "object") {
throw new ValidationError(`Invalid configuration for field '${fieldName}'`);
}
if (!config.type) {
throw new ValidationError(`Field '${fieldName}' must have a type`);
}
const validTypes = [
"text",
"keyword",
"text_keyword",
"hierarchy",
"variants",
"object",
"url",
"image_url",
"integer",
];
if (!validTypes.includes(config.type)) {
throw new ValidationError(`Invalid type '${config.type}' for field '${fieldName}'`);
}
});
}
/**
* Get filterable fields from configuration
* @param fields Field configuration
* @returns Array of filterable field names (including variant subfields)
*/
function getFilterableFields(fields) {
const filterableFields = [];
Object.entries(fields).forEach(([fieldName, config]) => {
if (config.filterable) {
filterableFields.push(fieldName);
}
// Add variant subfields if they are filterable
if (config.type === "variants" && config.fields) {
Object.entries(config.fields).forEach(([subFieldName, subFieldConfig]) => {
if (subFieldConfig.filterable) {
filterableFields.push(subFieldName);
}
});
}
});
return filterableFields;
}
/**
* Normalize filters to consistent format
* @param filters Raw filters
* @returns Normalized filters with arrays
*/
function normalizeFilters(filters) {
const normalized = {};
Object.entries(filters).forEach(([fieldName, values]) => {
if (Array.isArray(values)) {
normalized[fieldName] = values.filter((v) => v && v.trim());
}
else if (typeof values === "string" && values.trim()) {
normalized[fieldName] = [values.trim()];
}
});
return normalized;
}
/**
* Get nested value from object using dot notation
* @param obj Source object
* @param path Dot notation path (e.g., 'categoryDefault.localizedName')
* @returns Value at the path or undefined
*/
function getNestedValue(obj, path) {
if (!obj || !path)
return undefined;
return path.split(".").reduce((current, key) => {
return current && current[key] !== undefined ? current[key] : undefined;
}, obj);
}
/**
* Transform API response item to mapped item
* @param apiItem Raw API response item
* @param mapping Field mapping configuration
* @returns Mapped item
*/
function transformItem(apiItem, mapping = {}) {
const result = {};
// Always include ID if present
result.id = apiItem.id || apiItem._id || "";
// Copy _highlights if present
if (apiItem._highlights) {
result._highlights = apiItem._highlights;
}
// Apply field mappings
Object.entries(mapping).forEach(([targetField, sourceField]) => {
if (sourceField) {
const value = getNestedValue(apiItem, sourceField);
if (value !== undefined) {
result[targetField] = value;
}
}
});
// Set default values for required fields if not mapped
if (!result.title && !mapping.title) {
result.title = apiItem.name || apiItem.title || "";
}
if (!result.link && !mapping.link) {
result.link = apiItem.productUrl || apiItem.url || apiItem.link || "";
}
if (!result.reference && !mapping.reference) {
result.reference = apiItem.sku || apiItem.reference || "";
}
if (!result.imageUrl && !mapping.imageUrl) {
result.imageUrl = apiItem.imageUrl ||
apiItem.imageUrls || { small: "", medium: "" };
}
// Ensure imageUrl has correct structure
if (result.imageUrl && typeof result.imageUrl === "object") {
result.imageUrl = {
small: result.imageUrl.small || "",
medium: result.imageUrl.medium || result.imageUrl.small || "",
};
}
else if (typeof result.imageUrl === "string") {
// If imageUrl is a string, use it for both sizes
result.imageUrl = {
small: result.imageUrl,
medium: result.imageUrl,
};
}
else {
result.imageUrl = { small: "", medium: "" };
}
return result;
}
/**
* Transform array of API items to mapped items
* @param apiItems Raw API response items
* @param mapping Field mapping configuration
* @returns Array of mapped items
*/
function transformItems(apiItems, mapping = {}) {
if (!Array.isArray(apiItems))
return [];
return apiItems.map((item) => transformItem(item, mapping));
}
class ApiClient {
constructor(config) {
this.validateConfig(config);
this.config = config;
this.baseUrl = config.baseUrl;
}
/**
* Get current configuration
*/
getConfig() {
return { ...this.config };
}
/**
* Validate SDK configuration
*/
validateConfig(config) {
if (!config) {
throw new ValidationError("Configuration is required");
}
if (!config.token || typeof config.token !== "string") {
throw new ValidationError("JWT token is required");
}
if (!config.fields) {
throw new ValidationError("Fields configuration is required");
}
validateFieldConfig(config.fields);
}
/**
* Perform search query
* @param query Search query string
* @param filters Optional filters
* @param options Optional query options
* @returns Promise with search response
*/
async query(query = "", filters, options) {
// Build query parameters
const params = this.buildQueryParams(query, filters, options);
try {
const response = await this.makeRequest(params, options?.signal, options?.locale);
return this.processResponse(response);
}
catch (error) {
throw this.handleError(error);
}
}
/**
* Perform search query
* @param query Search query string
* @param filters Optional filters
* @param options Optional query options
* @returns Promise with search response
*/
async autocomplete(query = "", filters, options) {
return this.query(query, filters, { ...options, autocomplete: true });
}
/**
* Build query parameters for API request
*/
buildQueryParams(query, filters, options) {
const params = new URLSearchParams();
// Add JWT token
params.append("token", this.config.token);
// Add query string
if (query && query.trim()) {
params.append("q", encodeURIComponent(query.trim()));
}
// Add correlation ID if provided
if (options?.correlationId) {
params.append("X-Request-Correlation-ID", options.correlationId);
}
// Add search all flag
if (options?.searchAll) {
params.append("searchAll", "true");
}
// Add autocomplete flag
if (options?.autocomplete) {
params.append("autocomplete", "true");
}
// Add pagination
if (options?.limit !== undefined) {
params.append("limit", options.limit.toString());
}
if (options?.offset !== undefined) {
params.append("offset", options.offset.toString());
}
// Add sorting
if (options?.sortBy) {
params.append("sortby", options.sortBy);
}
if (options?.order) {
params.append("order", options.order);
}
// Add filters
if (filters) {
const normalizedFilters = normalizeFilters(filters);
Object.entries(normalizedFilters).forEach(([fieldName, values]) => {
values.forEach((value) => {
params.append(fieldName, value);
});
});
}
// Add context parameters
if (options?.context?.price?.withTaxes !== undefined) {
params.append("context.price.withTaxes", String(options.context.price.withTaxes));
}
return params;
}
/**
* Make HTTP request to API
*/
async makeRequest(params, signal, locale) {
// Use URL API to properly handle existing query parameters
const urlObject = new URL(this.baseUrl);
params.forEach((value, key) => urlObject.searchParams.append(key, value));
const url = urlObject.toString();
const headers = {
Accept: "application/json",
"Accept-Language": locale ||
(() => {
if (typeof navigator !== "undefined") {
return navigator.language || "en-US";
}
return "en-US";
})(),
};
const response = await fetch(url, {
method: "GET",
headers,
signal,
});
if (!response.ok) {
throw new ApiError(`API request failed: ${response.statusText}`, response.status);
}
return response.json();
}
/**
* Process API response and transform items
*/
processResponse(apiResponse) {
if (!apiResponse || typeof apiResponse !== "object") {
throw new ValidationError("Invalid API response format");
}
// Transform documents using mapping configuration
const documents = transformItems(apiResponse.documents || apiResponse.foundProducts || [], this.config.mapping);
return {
limit: apiResponse.limit || 0,
total: apiResponse.total || 0,
offset: apiResponse.offset || 0,
facets: apiResponse.facets || {},
documents,
...(apiResponse['did-you-mean'] && { 'did-you-mean': apiResponse['did-you-mean'] }),
};
}
/**
* Handle and convert errors to appropriate types
*/
handleError(error) {
if (error instanceof ApiError) {
// Handle specific HTTP status codes
switch (error.statusCode) {
case 401:
return new AuthenticationError("Invalid or expired JWT token");
case 400:
return new ValidationError(error.message);
default:
return error;
}
}
if (error instanceof TypeError && error.message.includes("fetch")) {
return new NetworkError("Network request failed");
}
if (error.name === "AbortError") {
return new NetworkError("Request was cancelled");
}
return error instanceof Error
? error
: new ApiError("Unknown error occurred");
}
/**
* Get filterable fields from configuration
*/
getFilterableFields() {
return getFilterableFields(this.config.fields);
}
/**
* Update configuration (useful for token refresh)
*/
updateConfig(updates) {
if (updates.fields) {
validateFieldConfig(updates.fields);
}
this.config = { ...this.config, ...updates };
if (updates.baseUrl) {
this.baseUrl = updates.baseUrl;
}
}
}
exports.ApiClient = ApiClient;
exports.ApiError = ApiError;
exports.ApiSdk = ApiClient;
exports.AuthenticationError = AuthenticationError;
exports.NetworkError = NetworkError;
exports.ValidationError = ValidationError;
exports.filterable = filterable;
exports.getNestedValue = getNestedValue;
exports.normalizeFilters = normalizeFilters;
exports.transformItem = transformItem;
exports.transformItems = transformItems;
//# sourceMappingURL=index.js.map