Kentico/gatsby-source-kontent

View on GitHub
packages/gatsby-source/src/webhookProcessor.ts

Summary

Maintainability
D
2 days
Test Coverage
import { SourceNodesArgs, Node } from "gatsby"
import { CustomPluginOptions, KontentItem, KontentItemInput } from "./types"

import * as client from "./client";
import { addPreferredLanguageProperty, alterRichTextElements, getKontentItemLanguageVariantArtifact } from "./sourceNodes.items";
import { getKontentItemNodeStringForId, getKontentTaxonomyTypeName, getKontentTypeTypeName, RICH_TEXT_ELEMENT_TYPE_NAME, PREFERRED_LANGUAGE_IDENTIFIER, getKontentItemInterfaceName } from "./naming";
import { IWebhookDeliveryResponse, IWebhookMessage, IWebhookWorkflowResponse } from 
'@kontent-ai/webhook-helper';
import _ from 'lodash';

const parseKontentWebhookBody = (api: SourceNodesArgs): IWebhookDeliveryResponse | IWebhookWorkflowResponse | null => {

  if ((api.webhookBody as IWebhookDeliveryResponse)?.message?.api_name === "delivery_preview" || (api.webhookBody as IWebhookDeliveryResponse)?.message?.api_name === "delivery_production") {
    const parsedBody = api.webhookBody as IWebhookDeliveryResponse;

    const isCorrectStructure = parsedBody?.data?.items?.every(item => item.language && item.id)
      && parsedBody?.message?.api_name
      && parsedBody?.message?.project_id
      && parsedBody?.message?.operation !== null;

    if (isCorrectStructure) {
      return parsedBody;
    }
  }
  else if ((api.webhookBody as IWebhookWorkflowResponse)?.message?.api_name === "content_management") {
    const parsedBody = api.webhookBody as IWebhookWorkflowResponse;

    const isCorrectStructure = parsedBody?.data?.items?.every(item => item.language && item.transition_from && item.transition_to && item.item.id)
      && parsedBody?.message?.api_name
      && parsedBody?.message?.project_id
      && parsedBody?.message?.operation !== null;

    if (isCorrectStructure) {
      return parsedBody;
    }
  }

  return null;
}

const isKontentSupportedWebhook = (message: IWebhookMessage, pluginConfig: CustomPluginOptions): boolean => {
  const isCorrectProject = message.project_id === pluginConfig.projectId;
  const isPreviewWebhook = 'delivery_preview' === message.api_name
    && ['upsert', 'archive', 'restore'].includes(message.operation);
  const isBuildWebhook = 'delivery_production' === message.api_name
    && ['publish', 'unpublish'].includes(message.operation);
  const isWorkflowWebhook = 'content_management' === message.api_name
    && message.operation === 'change_workflow_step';
  const isCorrectMessageType = message.type == 'content_item_variant';

  return isCorrectProject
    && (isPreviewWebhook || isBuildWebhook || (isWorkflowWebhook && pluginConfig?.experimental?.managementApiTriggersUpdate))
    && isCorrectMessageType
};

const createNodeFromRawKontentItem = (api: SourceNodesArgs, rawKontentItem: KontentItemInput, includeRawContent: boolean, preferredLanguage: string): string => {
  addPreferredLanguageProperty([rawKontentItem], preferredLanguage);
  alterRichTextElements([rawKontentItem]);
  const nodeData = getKontentItemLanguageVariantArtifact(
    api,
    rawKontentItem,
    includeRawContent,
  );
  api.actions.createNode(nodeData);
  return nodeData.id;
}

const isContentComponent = (data: KontentItem): boolean => {
  // Components have substring 01 in its id starting at position 14.
  // xxxxxxxx-xxxx-01xx-xxxx-xxxxxxxxxxxx
  const id = data?.system?.id;
  return id !== null && id.substring(14, 16) === "01";
}

const handleUpsertItem = async (
  api: SourceNodesArgs,
  pluginConfig: CustomPluginOptions,
  itemId: string,
  itemLanguage?: string,
): Promise<string[]> => {

  // TODO - language codename is not provided for management call
  if (itemLanguage && !pluginConfig.languageCodenames.includes(itemLanguage)) {
    api.reporter.verbose(`Cant find specified language ${itemLanguage} in plugin configuration`);
    return [];
  }

  // TODO could be optimized to by checking the fallback structure and save some requests
  // not recreate the ones that has different system.language
  // be careful on fallback language - verify cz->de->en fallbacks

  const createdItemsIds = [];
  for (const lang of pluginConfig.languageCodenames) {
    const { item: kontentItem, modularKontent } = await client.loadKontentItem(itemId, lang, pluginConfig, true);
    if (kontentItem === undefined) {
      api.reporter.verbose(`Item (${itemId}) language variant (${lang}) not found on the kontent.ai delivery API for update`);
      continue;
    }

    const nodeId = createNodeFromRawKontentItem(api, kontentItem, pluginConfig.includeRawContent, lang);
    createdItemsIds.push(nodeId);

    for (const key in modularKontent) {
      if (Object.prototype.hasOwnProperty.call(modularKontent, key)) {
        const modularKontentItem = modularKontent[key];
        const nodeId = createNodeFromRawKontentItem(api, modularKontentItem, pluginConfig.includeRawContent, lang);
        createdItemsIds.push(nodeId);
      }
    }
  }

  return createdItemsIds;
}

const handleDeleteItem = async (
  api: SourceNodesArgs,
  pluginConfig: CustomPluginOptions
): Promise<string[]> => {

  const itemInfo = (api.webhookBody as IWebhookDeliveryResponse)?.data.items[0];

  if (!pluginConfig.languageCodenames.includes(itemInfo.language)) {
    api.reporter.verbose(`Cant find specified language ${itemInfo.language} in plugin configuration`);
    return [];
  }

  // TODO could be optimized to by checking the fallback structure and save some requests
  // not recreate the ones that has different system.language
  // be careful on fallback language - verify cz->de->en fallbacks

  const touchedItemsIds = [];
  for (const lang of pluginConfig.languageCodenames) {
    const { item: kontentItem, modularKontent } = await client.loadKontentItem(itemInfo.id, lang, pluginConfig, true);
    if (kontentItem === undefined) { //item  was deleted (with content components)
      const idString = getKontentItemNodeStringForId(itemInfo.id, lang);
      const node = api.getNode(api.createNodeId(idString));

      if (!node) {  
        api.reporter.warn(`Node with ${idString} not found - skipping`);
        continue;
      }

      // Remove content components
      const kontentItemNodes: KontentItem[] = api.getNodes()
        .filter((node: Node) => node.internal.type.startsWith(getKontentItemInterfaceName()))
        .map(node => node as KontentItem);

      const modularItemCodenames = _.flatMap(
        Object.values((node as KontentItem).elements)
          .filter(element => element.type === RICH_TEXT_ELEMENT_TYPE_NAME)
          .map(richTextElement => richTextElement.modular_content)
      );

      modularItemCodenames.forEach(modularItemCodename => {
        const candidate = kontentItemNodes.find((candidateNode) =>
          candidateNode?.system?.codename === modularItemCodename
          && candidateNode[PREFERRED_LANGUAGE_IDENTIFIER] === node[PREFERRED_LANGUAGE_IDENTIFIER])

        if (candidate && isContentComponent(candidate)) {
          touchedItemsIds.push(candidate.id);
          api.actions.deleteNode(candidate);
        }
      })


      if (node) {
        touchedItemsIds.push(node.id);
        api.actions.deleteNode(node);
      }
      continue;
    } else { // fallback version still available
      const nodeId = createNodeFromRawKontentItem(api, kontentItem, pluginConfig.includeRawContent, lang);
      touchedItemsIds.push(nodeId);

      for (const key in modularKontent) {
        if (Object.prototype.hasOwnProperty.call(modularKontent, key)) {
          const modularKontentItem = modularKontent[key];
          const nodeId = createNodeFromRawKontentItem(api, modularKontentItem, pluginConfig.includeRawContent, lang);
          touchedItemsIds.push(nodeId);
        }
      }
    }
  }

  if ((api.webhookBody as IWebhookDeliveryResponse)?.data.items.length > 1) {
    api.reporter.verbose(`Webhook contains more than one item for un-publish operation - upserting all (all but first in webhook) related items.`)
    for (const relatedItem of (api.webhookBody as IWebhookDeliveryResponse)?.data.items.slice(1)) {
      api.reporter.verbose(`Upserting item ${relatedItem.codename} (${relatedItem.codename}) - ${relatedItem.language}`)
      const processedIds = await handleUpsertItem(api, pluginConfig, relatedItem.id, relatedItem.language);
      touchedItemsIds.push(...processedIds);
    }
  }

  return touchedItemsIds;
}

const handleIncomingWebhook = async (
  api: SourceNodesArgs,
  pluginConfig: CustomPluginOptions,
  itemTypes: string[],
): Promise<void> => {

  const webhook = parseKontentWebhookBody(api);

  if (webhook === null) {
    api.reporter.verbose('Webhook ignored - webhook does not come from Kontent.ai');
    return;
  }

  if (!isKontentSupportedWebhook(webhook.message, pluginConfig)) {
    api.reporter.verbose('This Kontent.ai webhook is not handled by the Gatsby source kontent source plugin');
    return;
  }

  api.reporter.verbose(`Handling ${webhook.message.operation} from ${webhook.message.api_name} API`);
  if (webhook.data.items.length > 1) {
    if (webhook.message.operation != 'unpublish') {
      api.reporter.warn(`Webhook contains more than one item! - contains (${webhook.data.items.length})`)
    } else {
      api.reporter.verbose(`Unpublish webhook contains more than one item (${webhook.data.items.length})`)
    }
  }

  const processedItemIds: string[] = [];
  if (webhook.message.api_name === 'delivery_preview') {

    const item = (webhook as IWebhookDeliveryResponse).data.items[0];

    // TODO: Webhook header signature (once headers are available)
    // use signatureHelper '@kontent-ai/kontent-webhook-helper'
    // https://github.com/gatsbyjs/gatsby/issues/23593

    if (webhook.message.operation === "upsert" || webhook.message.operation === "restore") {
      const processedIds = await handleUpsertItem(api, pluginConfig, item.id, item.language);
      processedItemIds.push(...processedIds);
    }

    if (webhook.message.operation === "archive") {
      const processedIds = await handleDeleteItem(api, pluginConfig);
      processedItemIds.push(...processedIds);
    }
  } else if (webhook.message.api_name === 'delivery_production') {

    const item = (webhook as IWebhookDeliveryResponse).data.items[0];

    // TODO: Webhook header signature (once headers are available)
    // use signatureHelper '@kontent-ai/kontent-webhook-helper'
    // https://github.com/gatsbyjs/gatsby/issues/23593

    if (webhook.message.operation === "publish") {
      const processedIds = await handleUpsertItem(api, pluginConfig, item.id, item.language);
      processedItemIds.push(...processedIds);
    }

    if (webhook.message.operation === "unpublish") {
      const processedIds = await handleDeleteItem(api, pluginConfig);
      processedItemIds.push(...processedIds);
    }
  } else if (pluginConfig?.experimental?.managementApiTriggersUpdate && webhook.message.api_name === 'content_management') {

    const item = (webhook as IWebhookWorkflowResponse).data.items[0];

    // TODO: Webhook header signature (once headers are available)
    // use signatureHelper '@kontent-ai/kontent-webhook-helper'
    // https://github.com/gatsbyjs/gatsby/issues/23593

    if (webhook.message.operation === "change_workflow_step") {
      const processedIds = await handleUpsertItem(api, pluginConfig, item.item.id);
      processedItemIds.push(...processedIds);
    } else {
      api.reporter.verbose(`Operation '${webhook.message.operation}' is not supported yet!`);
    }
  }
  else {
    api.reporter.verbose(`Webhook is not supported yet!`);
    api.reporter.verbose(JSON.stringify(webhook, null, 2));
    return;
  }

  for (const itemType of itemTypes) {
    const itemsToTouch = api.getNodesByType(itemType);
    itemsToTouch
      .filter(item => !processedItemIds.includes(item.id))
      .forEach(itemToTouch => api.actions.touchNode(itemToTouch))
  }

  if (pluginConfig.includeTaxonomies) {
    const taxonomies = api.getNodesByType(getKontentTaxonomyTypeName());
    for (const taxonomy of taxonomies) {
      api.actions.touchNode(taxonomy);
    }
  }

  if (pluginConfig.includeTypes) {
    const types = api.getNodesByType(getKontentTypeTypeName());
    for (const type of types) {
      api.actions.touchNode(type);
    }
  }
}

export {
  handleIncomingWebhook
}