Alokai
ReferenceSAP Commerce CloudNormalizers

Product normalizers

Product includes two normalizers:

  • normalizeProduct: This function is used to map SAP Product into SfProduct, which includes a full product details
  • normalizeProductCatalogItem: This function is used to map SAP Product into SfProductCatalogItem, which includes only basic product details, needed to display a product in a product catalog

Parameters

normalizeProduct

NameTypeDefault valueDescription
contextNormalizeProductContextContext needed for the normalizer. transformImageUrl is added to transform product images urls, and sku to specify a product variant
productProductSAP Product

normalizeProductCatalogItem

NameTypeDefault valueDescription
contextNormalizeProductCatalogItemContextContext needed for the normalizer. transformImageUrl is added to transform product images urls
productProductSAP Product

Extending

The SfProduct model is returned from GetProductDetails method. The SfProductCatalogItem model is returned from GetProducts method. If any of these models don't contain the information you need for your Storefront, you can extend its logic using the addCustomFields API. In the following example we extend the normalizeProduct with classifications field which is available on SAP Product and normalizeProductCatalogItem with description field.

import { normalizers } from "@vsf-enterprise/unified-api-sapcc";

export const unifiedApiExtension = createUnifiedExtension({
  normalizers: {
    addCustomFields: [
      {
        normalizeProduct: (context, product) => {
          const { quantityLimit } = normalizers.normalizeProduct(context, product);
          
          return {
            classifications: product.classifications,
            isSmallQuantityLimit: quantityLimit && quantityLimit <= 10,
          }
        },
        normalizeProductCatalogItem: (context, product) => ({
          description: product.description,
        }),
      },
    ],
  },
  config: {
    ...
  },
});

Since both normalizers accept SAP Product as a second argument, you may also use the same approach to use same representation for both SfProduct and SfProductCatalogItem models.

However, in this case you should be aware that SfProductCatalogItem will contain all the fields from SfProduct model and, it will affect the performance of the catalog page.

Source

The normalizeProduct and normalizeProductCatalogItem function consists of several smaller normalizers such as normalizeImage, normalizeDiscountablePrice, and more, which you can override as well.

import type { SfProduct, SfProductVariant } from "@alokai/connect";
import type { VariantOption, VariantOptionQualifier } from "@vsf-enterprise/sapcc-types";
import sanitizeHtml from "sanitize-html";

import { maybe, slugify } from "@vsf-enterprise/unified-api-sapcc";
import { getOptions } from "@vsf-enterprise/unified-api-sapcc";
import type { NormalizerContext } from "@vsf-enterprise/unified-api-sapcc";

import { defineNormalizer } from "../defineNormalizer";
import { createSfImages } from "./images";

export const normalizeProduct = defineNormalizer.normalizeProduct((context, product) => {
const { allOptions, currentOption } = getOptions(product, context.sku);

const attributes = getAttributes(context, currentOption?.variantOptionQualifiers ?? []);
const variants = normalizeVariants(context, allOptions);
const { id, name, price, primaryImage, quantityLimit, rating, sku, slug } =
  context.normalizers.normalizeProductCatalogItem(product);
const { gallery } = createSfImages(context, product.images);
const unsanitizedDescription = product.description ?? product.summary;
const description = unsanitizedDescription ? sanitizeHtml(unsanitizedDescription) : null;

return {
  attributes,
  description,
  gallery,
  id,
  name,
  price,
  primaryImage,
  quantityLimit,
  rating,
  sku,
  slug,
  variants,
};
});

function normalizeVariants(
context: NormalizerContext,
variants: VariantOption[],
): SfProduct["variants"] {
return variants.map((variant) => normalizeVariant(context, variant));
}

function normalizeVariant(context: NormalizerContext, variant: VariantOption): SfProductVariant {
const id = variant.code as string;

return {
  attributes: getAttributes(context, variant.variantOptionQualifiers ?? []),
  id,
  name: null,
  quantityLimit: maybe(variant.stock?.stockLevel),
  sku: id,
  slug: slugify(id),
};
}

function getAttributes(context: NormalizerContext, optionQualifiers: VariantOptionQualifier[]) {
return optionQualifiers
  .map((optionQualifier) => context.normalizers.normalizeAttribute(optionQualifier))
  .filter(Boolean);
}
import sanitizeHtml from "sanitize-html";

import { maybe, slugify } from "@vsf-enterprise/unified-api-sapcc";

import { defineNormalizer } from "../defineNormalizer";
import { createSfImages } from "./images";

export const normalizeProductCatalogItem = defineNormalizer.normalizeProductCatalogItem(
(context, product) => {
  const id = product.code as string;
  const { primaryImage } = createSfImages(context, product.images);
  const price = product.price
    ? context.normalizers.normalizeDiscountablePrice(product.price)
    : null;
  const rating = context.normalizers.normalizeRating(product);

  return {
    id,
    name: product.name ? sanitizeHtml(product.name) : null,
    price,
    primaryImage,
    quantityLimit: maybe(product.stock?.stockLevel),
    rating,
    sku: maybe(product?.code),
    slug: slugify(id, product?.name ?? ""),
  };
},
);
import type { SfProduct } from "@alokai/connect";
import { type Image, ImageImageTypeEnum } from "@vsf-enterprise/sapcc-types";

import type { NormalizerContext } from "@vsf-enterprise/unified-api-sapcc";

export type SfImages = Pick<SfProduct, "gallery" | "primaryImage">;

export function createSfImages(context: NormalizerContext, images: Image[] = []): SfImages {
const primaryImage = getPrimaryImage(images);
return {
  gallery: getGalleryImages(images).map((image) => context.normalizers.normalizeImage(image)),
  primaryImage: primaryImage ? context.normalizers.normalizeImage(primaryImage) : null,
};
}

function getGalleryImages(images: Image[]): Image[] {
// Prefer superZoom for gallery — highest quality source, Cloudinary handles downscaling
const superZoomGallery = images.filter(
  (image) => image.imageType === ImageImageTypeEnum.Gallery && isFormat(image, "superZoom"),
);
if (superZoomGallery.length > 0) return superZoomGallery;

// Fallback: zoom-format gallery images
const zoomGallery = images.filter(
  (image) => image.imageType === ImageImageTypeEnum.Gallery && isFormat(image, "zoom"),
);
if (zoomGallery.length > 0) return zoomGallery;

// Fallback: any superZoom-format image (regardless of imageType)
const anySuperZoom = images.filter((image) => isFormat(image, "superZoom"));
if (anySuperZoom.length > 0) return anySuperZoom;

// Fallback: any zoom-format image (regardless of imageType)
const anyZoom = images.filter((image) => isFormat(image, "zoom"));
if (anyZoom.length > 0) return anyZoom;

// Fallback: product-format gallery images
const productGallery = images.filter(
  (image) => image.imageType === ImageImageTypeEnum.Gallery && isFormat(image, "product"),
);
if (productGallery.length > 0) return productGallery;

// Last resort: any product-format image
return images.filter((image) => isFormat(image, "product"));
}

function getPrimaryImage(images: Image[]): Image | null {
if (images.length === 0) return null;

// Prefer superZoom for primary (highest quality for hero/main display)
const primary =
  images.find(
    (image) => image.imageType === ImageImageTypeEnum.Primary && isFormat(image, "superZoom"),
  ) ??
  images.find(
    (image) => image.imageType === ImageImageTypeEnum.Primary && isFormat(image, "zoom"),
  ) ??
  images.find(
    (image) => image.imageType === ImageImageTypeEnum.Primary && isFormat(image, "product"),
  );

return primary || images[0] || null;
}

function isFormat(image: Image, format = "product"): image is Image {
return image.format === format;
}
import type { Product } from "@vsf-enterprise/sapcc-types";

import { defineNormalizer } from "../defineNormalizer";

export const normalizeRating = defineNormalizer.normalizeRating((_context, product) => {
if (!isValidProductRating(product)) {
  return null;
}
return {
  average: product.averageRating,
  count: product.numberOfReviews,
};
});

type ValidProductRating = Product & Required<Pick<Product, "averageRating" | "numberOfReviews">>;

function notNullable<TData>(value: TData): value is NonNullable<TData> {
return value !== null && value !== undefined;
}

function isValidProductRating(product: Product): product is ValidProductRating {
return notNullable(product.averageRating) && notNullable(product.numberOfReviews);
}

On this page