UNPKG

@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
'use strict'; // 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