Adapters transform your store's product data into MCIP's unified format. You'll create one in about 20 minutes.
Think of adapters like universal plug converters. Your laptop charger works everywhere because the converter translates between different socket types. Adapters do the same for product data.
Your Store's API MCIP Adapter Unified Schema
┌─────────────────┐ ┌─────────────┐ ┌────────────────┐
│ { │ │ │ │ { │
│ "sku": "ABC", │ ───▶ │ map() │ ───▶ │ externalId, │
│ "name": "...",│ │ │ │ title, │
│ "cost": 1299 │ │ │ │ price: {...} │
│ } │ │ │ │ } │
└─────────────────┘ └─────────────┘ └────────────────┘Every adapter implements one method: map(). It receives raw product data and returns a UnifiedProduct.
MCIP includes two adapters out of the box:
| Adapter | Best For | How It Works |
|---|---|---|
| VendureMapper | Vendure stores | Maps GraphQL responses directly |
| CustomAiMapper | Unknown formats | Uses GPT-4 to intelligently map any data |
The AI mapper is great for testing, but custom adapters are faster and more predictable.
Your adapter must produce a UnifiedProduct. Here's what that looks like:
interface UnifiedProduct {
// Required fields
externalId: string; // Your product ID (e.g., "prod_123")
url: string; // Product page URL
title: string; // Clean product name
description: string; // Plain text, no HTML
price: {
amount: number; // e.g., 99.99
currency: "UAH" | "USD" | "EUR";
};
mainImage: string; // Primary image URL
// Optional but recommended
brand?: string; // "Nike", "Apple", etc.
category?: string; // "Laptops", "Shoes"
// Product details
attributes: Array<{
name: string;
value: string | number | boolean;
}>;
variants: Array<{
sku: string;
title: string; // "Red / XL"
price: { amount: number; currency: string } | null;
available: boolean;
}>;
// For semantic search
keywords: string[]; // 5-10 searchable terms
}The schema is validated with Zod, so you'll get clear error messages if something's wrong.
Let's build an adapter for a fictional REST API. Create a new file:
// src/modules/ingestion/mapper/strategies/mystore/mystore.mapper.ts
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import {
UnifiedProduct,
UnifiedProductSchema
} from "../../../../../domain/product.schema";
import { ProductMapper } from "../../product-mapper.interface";
@Injectable()
export class MyStoreMapper implements ProductMapper {
private readonly logger = new Logger(MyStoreMapper.name);
constructor(private readonly configService: ConfigService) {}
async map(raw: any): Promise<UnifiedProduct> {
// Step 1: Validate we received something
if (!raw || typeof raw !== "object" || !raw.id) {
throw new Error("Invalid product: missing ID");
}
this.logger.debug(`Mapping product: ${raw.name}`);
// Step 2: Build the unified product
const storefrontUrl = this.configService.get<string>("STOREFRONT_URL", "");
const product: UnifiedProduct = {
externalId: String(raw.id),
url: `${storefrontUrl}/products/${raw.slug || raw.id}`,
title: raw.name,
description: this.stripHtml(raw.description || ""),
brand: raw.brand || undefined,
category: raw.category || undefined,
price: {
amount: this.normalizePrice(raw.price),
currency: this.normalizeCurrency(raw.currency),
},
mainImage: raw.image_url || raw.images?.[0] || "",
attributes: this.extractAttributes(raw),
variants: this.mapVariants(raw.variants || []),
keywords: this.generateKeywords(raw),
};
// Step 3: Validate with Zod (throws if invalid)
return UnifiedProductSchema.parse(product);
}
// Helper: Remove HTML tags from descriptions
private stripHtml(html: string): string {
if (!html) return "";
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, "")
.replace(/<[^>]*>/g, "")
.replace(/ /g, " ")
.replace(/&/g, "&")
.trim();
}
// Helper: Normalize price (handle cents, strings, etc.)
private normalizePrice(rawPrice: any): number {
if (typeof rawPrice === "number") {
return rawPrice > 1000 ? rawPrice / 100 : rawPrice;
}
return parseFloat(String(rawPrice)) || 0;
}
// Helper: Map currency codes to supported values
private normalizeCurrency(code: string): "UAH" | "USD" | "EUR" {
const mapping: Record<string, "UAH" | "USD" | "EUR"> = {
"usd": "USD",
"eur": "EUR",
"uah": "UAH",
"$": "USD",
"€": "EUR",
};
return mapping[code?.toLowerCase()] || "USD";
}
// Helper: Extract product attributes
private extractAttributes(raw: any): Array<{ name: string; value: string }> {
const attributes: Array<{ name: string; value: string }> = [];
if (raw.color) attributes.push({ name: "Color", value: raw.color });
if (raw.material) attributes.push({ name: "Material", value: raw.material });
if (raw.size) attributes.push({ name: "Size", value: raw.size });
return attributes;
}
// Helper: Map variants
private mapVariants(variants: any[]): UnifiedProduct["variants"] {
return variants.map((v, index) => ({
sku: v.sku || `VAR-${index}`,
title: v.name || v.title || `Option ${index + 1}`,
available: v.in_stock ?? v.available ?? true,
price: v.price
? { amount: this.normalizePrice(v.price), currency: "USD" }
: null,
}));
}
// Helper: Generate search keywords
private generateKeywords(raw: any): string[] {
const text = [raw.name, raw.category, raw.brand]
.filter(Boolean)
.join(" ")
.toLowerCase();
const stopWords = ["the", "and", "for", "with", "this", "that", "from"];
const words = text
.replace(/[^\w\s]/g, "")
.split(/\s+/)
.filter((w) => w.length > 3)
.filter((w) => !stopWords.includes(w));
return [...new Set(words)].slice(0, 10);
}
}Update the ingestion module to include your new mapper:
// src/modules/ingestion/ingestion.module.ts
import { Logger, Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { CustomAiMapper } from "./mapper/strategies/custom-ai.mapper";
import { VendureMapper } from "./mapper/strategies/vendure/vendure.mapper";
import { MyStoreMapper } from "./mapper/strategies/mystore/mystore.mapper"; // Add
import { PRODUCT_MAPPER } from "../../constants/tokens";
@Module({
providers: [
{
provide: PRODUCT_MAPPER,
useFactory: (
configService: ConfigService,
customAiMapper: CustomAiMapper,
vendureMapper: VendureMapper,
myStoreMapper: MyStoreMapper // Add
) => {
const provider = configService.get<string>("STORE_PROVIDER", "VENDURE");
Logger.log(`Using store provider: ${provider}`);
switch (provider.toUpperCase()) {
case "VENDURE":
return vendureMapper;
case "MYSTORE": // Add your case
return myStoreMapper;
default:
return customAiMapper; // AI fallback for unknown formats
}
},
inject: [ConfigService, CustomAiMapper, VendureMapper, MyStoreMapper],
},
CustomAiMapper,
VendureMapper,
MyStoreMapper, // Add to providers array
],
})
export class IngestionModule {}Update your .env to use the new adapter:
STORE_PROVIDER=MYSTORE
SOURCE_URL=https://api.mystore.com/products
SOURCE_API_KEY=your-store-api-key
STOREFRONT_URL=https://mystore.com
STORE_CURRENCY=USDCreate a test file to verify your mapping logic:
// src/modules/ingestion/mapper/strategies/mystore/mystore.mapper.spec.ts
import { Test, TestingModule } from "@nestjs/testing";
import { ConfigService } from "@nestjs/config";
import { MyStoreMapper } from "./mystore.mapper";
describe("MyStoreMapper", () => {
let mapper: MyStoreMapper;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MyStoreMapper,
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string) => {
const config: Record<string, string> = {
STOREFRONT_URL: "https://mystore.com",
STORE_CURRENCY: "USD",
};
return config[key];
}),
},
},
],
}).compile();
mapper = module.get<MyStoreMapper>(MyStoreMapper);
});
it("should map a valid product", async () => {
const rawProduct = {
id: "123",
name: "Cool T-Shirt",
description: "<p>A very cool shirt</p>",
price: 29.99,
currency: "USD",
brand: "Acme",
category: "Apparel",
slug: "cool-t-shirt",
image_url: "https://cdn.mystore.com/image.jpg",
variants: [
{ sku: "TSHIRT-S", name: "Small", price: 29.99, in_stock: true },
],
};
const result = await mapper.map(rawProduct);
expect(result.externalId).toBe("123");
expect(result.title).toBe("Cool T-Shirt");
expect(result.description).toBe("A very cool shirt"); // HTML stripped
expect(result.brand).toBe("Acme");
expect(result.price.amount).toBe(29.99);
});
it("should throw on invalid input", async () => {
await expect(mapper.map(null)).rejects.toThrow();
await expect(mapper.map({})).rejects.toThrow("missing ID");
});
});Run the tests:
npm test -- --testPathPattern=mystoreSync products using your new adapter:
# Restart with new config
docker compose down
docker compose up -d
# Trigger sync
curl -X POST http://localhost:8080/admin/sync \
-H "x-admin-api-key: your-secret-admin-key"
# Wait for processing, then search
curl "http://localhost:8080/search?q=shirt"Many APIs store prices in cents (12999 = $129.99):
private normalizePrice(rawPrice: number): number {
return rawPrice > 10000 ? rawPrice / 100 : rawPrice;
}private transformImageUrl(url: string): string {
const internalHost = this.configService.get("INTERNAL_HOST");
const publicHost = this.configService.get("PUBLIC_HOST");
if (internalHost && publicHost && url.includes(internalHost)) {
return url.replace(internalHost, publicHost);
}
return url;
}private extractCategory(raw: any): string | undefined {
if (typeof raw.category === "string") return raw.category;
if (raw.category?.name) return raw.category.name;
if (Array.isArray(raw.categories) && raw.categories[0]) {
return raw.categories[0].name || raw.categories[0];
}
return undefined;
}After creating your adapter:
src/modules/ingestion/mapper/
├── product-mapper.interface.ts
└── strategies/
├── custom-ai.mapper.ts
├── vendure/
│ ├── types.ts
│ └── vendure.mapper.ts
└── mystore/ ← Your new adapter
├── types.ts ← (optional) TypeScript types
└── mystore.mapper.tsProblem: Your mapped product doesn't match the schema.
Solution: Check the error message — it tells you exactly which field failed:
ZodError: [
{
"code": "too_small",
"path": ["title"],
"message": "String must contain at least 3 character(s)"
}
]Solution:
docker compose logs mcipSolution: Add defensive checks and log unexpected structures:
if (!raw.expectedField) {
this.logger.warn(`Missing expectedField in product ${raw.id}`);
}