import { formatApiParams } from "./utils.js";
import type {
Recipe,
RecipeListResponse,
MealPlanListResponse,
MealPlanResponse,
IngredientFood,
FoodListResponse,
CreateIngredientFood,
IngredientUnitFull,
UnitListResponse,
CreateIngredientUnit,
} from "./types/index.js";
export class MealieApiError extends Error {
constructor(
public statusCode: number,
message: string,
public responseText?: string
) {
super(`${message} (Status Code: ${statusCode})`);
this.name = "MealieApiError";
}
}
export interface GetRecipesParams {
search?: string;
page?: number;
perPage?: number;
categories?: string[];
tags?: string[];
}
export interface GetMealplansParams {
startDate?: string;
endDate?: string;
page?: number;
perPage?: number;
}
export interface CreateMealplanParams {
date: string;
recipeId?: string;
title?: string;
entryType?: string;
}
export interface GetFoodsParams {
search?: string;
page?: number;
perPage?: number;
}
export interface GetUnitsParams {
search?: string;
page?: number;
perPage?: number;
}
export class MealieClient {
private baseUrl: string;
private headers: Record<string, string>;
private timeout: number = 30000;
constructor(baseUrl: string, apiKey: string) {
if (!baseUrl) {
throw new Error("Base URL cannot be empty");
}
if (!apiKey) {
throw new Error("API key cannot be empty");
}
this.baseUrl = baseUrl.replace(/\/$/, "");
this.headers = {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
};
}
async testConnection(): Promise<void> {
try {
await this._handleRequest("GET", "/api/app/about");
console.error("Successfully connected to Mealie API");
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error";
throw new Error(`Failed to connect to Mealie API: ${message}`);
}
}
private async _handleRequest<T>(
method: string,
path: string,
options?: {
params?: Record<string, string>;
body?: unknown;
}
): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
let url = `${this.baseUrl}${path}`;
if (options?.params && Object.keys(options.params).length > 0) {
const searchParams = new URLSearchParams(options.params);
url += `?${searchParams.toString()}`;
}
const fetchOptions: RequestInit = {
method,
headers: this.headers,
signal: controller.signal,
};
if (options?.body) {
fetchOptions.body = JSON.stringify(options.body);
}
const response = await fetch(url, fetchOptions);
if (!response.ok) {
let errorDetail: string;
try {
const errorJson = await response.json();
errorDetail = JSON.stringify(errorJson);
} catch {
errorDetail = await response.text();
}
throw new MealieApiError(
response.status,
`API error for ${method} ${path}: ${errorDetail}`,
errorDetail
);
}
const text = await response.text();
if (!text) {
return {} as T;
}
try {
return JSON.parse(text) as T;
} catch {
return text as unknown as T;
}
} catch (error) {
if (error instanceof MealieApiError) {
throw error;
}
if (error instanceof Error) {
if (error.name === "AbortError") {
throw new Error(`Request timeout for ${method} ${path}`);
}
throw new Error(`Request failed for ${method} ${path}: ${error.message}`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
// Recipe API methods
async getRecipes(params: GetRecipesParams = {}): Promise<RecipeListResponse> {
const apiParams = formatApiParams({
search: params.search,
page: params.page,
perPage: params.perPage,
categories: params.categories,
tags: params.tags,
});
return this._handleRequest<RecipeListResponse>("GET", "/api/recipes", {
params: apiParams,
});
}
async getRecipe(slug: string): Promise<Recipe> {
if (!slug) {
throw new Error("Recipe slug cannot be empty");
}
return this._handleRequest<Recipe>("GET", `/api/recipes/${slug}`);
}
async createRecipe(name: string): Promise<string> {
const response = await this._handleRequest<string>("POST", "/api/recipes", {
body: { name },
});
return response;
}
async updateRecipe(
slug: string,
recipeData: Partial<Recipe>
): Promise<Recipe> {
if (!slug) {
throw new Error("Recipe slug cannot be empty");
}
return this._handleRequest<Recipe>("PUT", `/api/recipes/${slug}`, {
body: recipeData,
});
}
async deleteRecipe(slug: string): Promise<void> {
if (!slug) {
throw new Error("Recipe slug cannot be empty");
}
await this._handleRequest<void>("DELETE", `/api/recipes/${slug}`);
}
// Mealplan API methods
async getMealplans(
params: GetMealplansParams = {}
): Promise<MealPlanListResponse> {
const apiParams = formatApiParams({
startDate: params.startDate,
endDate: params.endDate,
page: params.page,
perPage: params.perPage,
});
return this._handleRequest<MealPlanListResponse>(
"GET",
"/api/households/mealplans",
{ params: apiParams }
);
}
async createMealplan(params: CreateMealplanParams): Promise<MealPlanResponse> {
if (!params.recipeId && !params.title) {
throw new Error("Either recipeId or title must be provided");
}
if (!params.date) {
throw new Error("Date cannot be empty");
}
const payload: Record<string, unknown> = {
date: params.date,
entryType: params.entryType || "breakfast",
};
if (params.recipeId) {
payload.recipeId = params.recipeId;
}
if (params.title) {
payload.title = params.title;
}
return this._handleRequest<MealPlanResponse>(
"POST",
"/api/households/mealplans",
{ body: payload }
);
}
async getTodaysMealplan(): Promise<MealPlanResponse[]> {
return this._handleRequest<MealPlanResponse[]>(
"GET",
"/api/households/mealplans/today"
);
}
// Foods API methods
async getFoods(params: GetFoodsParams = {}): Promise<FoodListResponse> {
const apiParams = formatApiParams({
search: params.search,
page: params.page,
perPage: params.perPage,
});
return this._handleRequest<FoodListResponse>("GET", "/api/foods", {
params: apiParams,
});
}
async getFood(id: string): Promise<IngredientFood> {
if (!id) {
throw new Error("Food ID cannot be empty");
}
return this._handleRequest<IngredientFood>("GET", `/api/foods/${id}`);
}
async createFood(data: CreateIngredientFood): Promise<IngredientFood> {
if (!data.name) {
throw new Error("Food name cannot be empty");
}
return this._handleRequest<IngredientFood>("POST", "/api/foods", {
body: data,
});
}
async deleteFood(id: string): Promise<IngredientFood> {
if (!id) {
throw new Error("Food ID cannot be empty");
}
return this._handleRequest<IngredientFood>("DELETE", `/api/foods/${id}`);
}
async findOrCreateFood(name: string): Promise<IngredientFood> {
// First, search for existing food
const searchResult = await this.getFoods({ search: name, perPage: 50 });
// Look for exact match (case-insensitive)
const exactMatch = searchResult.items.find(
(food) => food.name.toLowerCase() === name.toLowerCase()
);
if (exactMatch) {
return exactMatch;
}
// Try to create the food
try {
return await this.createFood({ name });
} catch (error) {
// If creation fails due to duplicate (race condition or search miss),
// try searching again with exact name
if (error instanceof MealieApiError && error.statusCode === 400) {
const retryResult = await this.getFoods({ search: name, perPage: 100 });
const retryMatch = retryResult.items.find(
(food) => food.name.toLowerCase() === name.toLowerCase()
);
if (retryMatch) {
return retryMatch;
}
}
throw error;
}
}
// Units API methods
async getUnits(params: GetUnitsParams = {}): Promise<UnitListResponse> {
const apiParams = formatApiParams({
search: params.search,
page: params.page,
perPage: params.perPage,
});
return this._handleRequest<UnitListResponse>("GET", "/api/units", {
params: apiParams,
});
}
async getUnit(id: string): Promise<IngredientUnitFull> {
if (!id) {
throw new Error("Unit ID cannot be empty");
}
return this._handleRequest<IngredientUnitFull>("GET", `/api/units/${id}`);
}
async createUnit(data: CreateIngredientUnit): Promise<IngredientUnitFull> {
if (!data.name) {
throw new Error("Unit name cannot be empty");
}
return this._handleRequest<IngredientUnitFull>("POST", "/api/units", {
body: data,
});
}
async findOrCreateUnit(name: string): Promise<IngredientUnitFull> {
// First, search for existing unit
const searchResult = await this.getUnits({ search: name, perPage: 50 });
// Look for exact match (case-insensitive) on name or abbreviation
const exactMatch = searchResult.items.find(
(unit) =>
unit.name.toLowerCase() === name.toLowerCase() ||
unit.abbreviation?.toLowerCase() === name.toLowerCase()
);
if (exactMatch) {
return exactMatch;
}
// Try to create the unit
try {
return await this.createUnit({ name });
} catch (error) {
// If creation fails due to duplicate, try searching again
if (error instanceof MealieApiError && error.statusCode === 400) {
const retryResult = await this.getUnits({ search: name, perPage: 100 });
const retryMatch = retryResult.items.find(
(unit) =>
unit.name.toLowerCase() === name.toLowerCase() ||
unit.abbreviation?.toLowerCase() === name.toLowerCase()
);
if (retryMatch) {
return retryMatch;
}
}
throw error;
}
}
// Image API methods
async uploadRecipeImage(
slug: string,
imageData: Buffer,
filename: string
): Promise<void> {
if (!slug) {
throw new Error("Recipe slug cannot be empty");
}
if (!imageData || imageData.length === 0) {
throw new Error("Image data cannot be empty");
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
// Create form data with the image
const formData = new FormData();
// Convert Buffer to ArrayBuffer for Blob compatibility
const arrayBuffer = imageData.buffer.slice(
imageData.byteOffset,
imageData.byteOffset + imageData.byteLength
) as ArrayBuffer;
const blob = new Blob([arrayBuffer]);
formData.append("image", blob, filename);
formData.append("extension", filename.split(".").pop() || "jpg");
const url = `${this.baseUrl}/api/recipes/${slug}/image`;
// Use headers without Content-Type (browser/node will set it with boundary)
const headers: Record<string, string> = {
Authorization: this.headers.Authorization,
};
const response = await fetch(url, {
method: "PUT",
headers,
body: formData,
signal: controller.signal,
});
if (!response.ok) {
let errorDetail: string;
try {
const errorJson = await response.json();
errorDetail = JSON.stringify(errorJson);
} catch {
errorDetail = await response.text();
}
throw new MealieApiError(
response.status,
`Failed to upload image to recipe '${slug}': ${errorDetail}`,
errorDetail
);
}
} catch (error) {
if (error instanceof MealieApiError) {
throw error;
}
if (error instanceof Error) {
if (error.name === "AbortError") {
throw new Error(`Image upload timeout for recipe '${slug}'`);
}
throw new Error(`Image upload failed for recipe '${slug}': ${error.message}`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
async fetchImageFromUrl(url: string): Promise<{ data: Buffer; contentType: string }> {
if (!url) {
throw new Error("Image URL cannot be empty");
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(url, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Failed to fetch image from URL: HTTP ${response.status}`);
}
const contentType = response.headers.get("content-type") || "image/jpeg";
const arrayBuffer = await response.arrayBuffer();
const data = Buffer.from(arrayBuffer);
if (data.length === 0) {
throw new Error("Fetched image is empty");
}
return { data, contentType };
} catch (error) {
if (error instanceof Error) {
if (error.name === "AbortError") {
throw new Error(`Timeout fetching image from URL: ${url}`);
}
throw error;
}
throw new Error(`Unknown error fetching image from URL: ${url}`);
} finally {
clearTimeout(timeoutId);
}
}
}