Company Logo
MCIP

First Adapter

Adapters transform your store's product data into MCIP's unified format. You'll create one in about 20 minutes.

Understanding Adapters

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.


Built-in Adapters

MCIP includes two adapters out of the box:

AdapterBest ForHow It Works
VendureMapperVendure storesMaps GraphQL responses directly
CustomAiMapperUnknown formatsUses GPT-4 to intelligently map any data

The AI mapper is great for testing, but custom adapters are faster and more predictable.


Step 1: Understand the Target Schema

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.


Step 2: Create Your Adapter

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(/&nbsp;/g, " ")
      .replace(/&amp;/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);
  }
}

Step 3: Register Your Adapter

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 {}

Step 4: Configure Environment

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=USD

Step 5: Test Your Adapter

Unit Test

Create 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=mystore

Integration Test

Sync 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"

Common Patterns

Handling Price in Cents

Many APIs store prices in cents (12999 = $129.99):

private normalizePrice(rawPrice: number): number {
  return rawPrice > 10000 ? rawPrice / 100 : rawPrice;
}

Transforming Image URLs

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;
}

Handling Nested Categories

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;
}

Directory Structure

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.ts

Troubleshooting

"Zod validation failed"

Problem: 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:

  1. Check logs: docker compose logs mcip
  2. Verify products were indexed in Qdrant
  3. Wait for async processing to complete (30-60 seconds)

API returns different structure than expected

Solution: Add defensive checks and log unexpected structures:

if (!raw.expectedField) {
  this.logger.warn(`Missing expectedField in product ${raw.id}`);
}