client.ts•3.89 kB
import axios, { AxiosInstance } from 'axios';
import { OpenFoodFactsConfig, ProductResponse, SearchResponse, ProductResponseSchema, SearchResponseSchema } from './types.js';
export class OpenFoodFactsClient {
private client: AxiosInstance;
private config: OpenFoodFactsConfig;
private requestCounts: Map<string, { count: number; resetTime: number }> = new Map();
constructor(config: OpenFoodFactsConfig) {
this.config = config;
this.client = axios.create({
baseURL: config.baseUrl,
headers: {
'User-Agent': config.userAgent,
'Accept': 'application/json',
},
timeout: 30000,
});
}
private async checkRateLimit(endpoint: 'products' | 'search' | 'facets'): Promise<void> {
const now = Date.now();
const key = endpoint;
const limit = this.config.rateLimits[endpoint];
let tracker = this.requestCounts.get(key);
if (!tracker || now > tracker.resetTime) {
tracker = { count: 0, resetTime: now + 60000 }; // Reset every minute
this.requestCounts.set(key, tracker);
}
if (tracker.count >= limit) {
const waitTime = tracker.resetTime - now;
if (waitTime > 0) {
throw new Error(`Rate limit exceeded for ${endpoint}. Wait ${Math.ceil(waitTime / 1000)} seconds.`);
}
}
tracker.count++;
}
async getProduct(barcode: string): Promise<ProductResponse> {
await this.checkRateLimit('products');
try {
const response = await this.client.get(`/api/v2/product/${barcode}`);
return ProductResponseSchema.parse(response.data);
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`Failed to fetch product ${barcode}: ${error.response?.status} ${error.message}`);
}
throw error;
}
}
async searchProducts(params: {
search?: string;
categories?: string;
brands?: string;
countries?: string;
page?: number;
page_size?: number;
sort_by?: string;
nutrition_grades?: string;
nova_groups?: string;
} = {}): Promise<SearchResponse> {
await this.checkRateLimit('search');
const searchParams = new URLSearchParams();
if (params.search) searchParams.append('search_terms', params.search);
if (params.categories) searchParams.append('categories_tags', params.categories);
if (params.brands) searchParams.append('brands_tags', params.brands);
if (params.countries) searchParams.append('countries_tags', params.countries);
if (params.nutrition_grades) searchParams.append('nutrition_grades_tags', params.nutrition_grades);
if (params.nova_groups) searchParams.append('nova_groups_tags', params.nova_groups);
if (params.sort_by) searchParams.append('sort_by', params.sort_by);
searchParams.append('page', String(params.page || 1));
searchParams.append('page_size', String(Math.min(params.page_size || 20, 100)));
searchParams.append('json', '1');
try {
const response = await this.client.get(`/cgi/search.pl?${searchParams.toString()}`);
return SearchResponseSchema.parse(response.data);
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`Search failed: ${error.response?.status} ${error.message}`);
}
throw error;
}
}
async getProductsByBarcodes(barcodes: string[]): Promise<ProductResponse[]> {
const results: ProductResponse[] = [];
for (const barcode of barcodes) {
try {
const product = await this.getProduct(barcode);
results.push(product);
// Small delay to respect rate limits
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
results.push({
status: 0,
status_verbose: `Error fetching ${barcode}: ${error instanceof Error ? error.message : 'Unknown error'}`,
});
}
}
return results;
}
}