@stackbit/cms-contentful
Version:
Stackbit Contentful CMS Interface
1,219 lines (1,116 loc) • 63.7 kB
text/typescript
import * as path from 'path';
import _ from 'lodash';
import {
PlainClientAPI,
EntryProps,
CreateEntryProps,
AssetProps,
Link,
VersionedLink,
LocaleProps,
UserProps,
AppInstallationProps,
ContentTypeProps,
EditorInterfaceProps,
BulkActionProps,
BulkActionPayload,
WebhookProps,
SnapshotProps
} from 'contentful-management';
import type { DocumentVersion, DocumentVersionWithDocument, FieldList, FieldSpecificProps, Model, Schema } from '@stackbit/types';
import type * as ContentSourceTypes from '@stackbit/types';
import * as stackbitUtils from '@stackbit/types';
import { TaskQueue } from '@stackbit/utils';
import {
createPlainApiClient,
fetchAllContentTypes,
fetchAllEditorInterfaces,
fetchAllEntries,
fetchEntriesUpdatedAfter,
fetchAllAssets,
fetchAssetsUpdatedAfter,
fetchAllLocales,
fetchAllUsers,
fetchAllAppInstallations,
createEntry,
fetchEntryById,
fetchAssetById,
updateEntry,
unpublishEntry,
deleteEntry,
createAsset,
createAssetFromFile,
processAssetForAllLocales,
publishAsset,
createValidateBulkAction,
createPublishBulkAction,
getBulkAction,
fetchEntriesByIds,
fetchAssetsByIds,
fetchAllWebhooks,
createWebhook,
fetchAllReleases,
fetchAllSchedules,
createScheduledAction,
updateScheduledAction,
cancelScheduledAction,
getScheduledAction,
fetchScheduleById,
fetchEntityVersions,
fetchEntityVersion,
createUnpublishBulkAction,
archiveEntry,
unarchiveEntry,
updateAsset
} from './contentful-api-client';
import { convertSchema } from './contentful-schema-converter';
import {
DocumentContext,
AssetContext,
ContextualDocument,
ContextualAsset,
convertAssets,
convertEntities,
EntityProps,
convertDocumentVersions
} from './contentful-entries-converter';
import { convertAndFilterScheduledActions } from './contentful-scheduled-actions-converter';
import { ContentPoller, ContentPollerSyncResult, ContentPollerSyncContext } from './content-poller';
import { Readable } from 'stream';
import { CONTENTFUL_BUILT_IN_IMAGE_SOURCES, CONTENTFUL_BYNDER_APP, CONTENTFUL_CLOUDINARY_APP } from './contentful-consts';
import { getLastUpdatedEntityDate } from './utils';
export interface ContentSourceOptions {
/** Contentful Space ID */
spaceId: string;
/** Contentful Space Environment. Default: 'master' */
environment?: string;
/** Contentful Preview Token */
previewToken: string;
/** Contentful Personal Access Token */
accessToken: string;
/**
* Use webhook for content updates.
*/
useWebhookForContentUpdates?: boolean;
/**
* Use accessToken instead of userContext.accessToken for write operations.
*/
useAccessTokenForUpdates?: boolean;
/**
* Switches the ContentfulContentSource to work with EU data regions.
* When set to true, the following updated accordingly:
* previewHost: 'preview.eu.contentful.com'
* manageHost: 'api.eu.contentful.com'
* uploadHost: 'upload.eu.contentful.com'
*/
useEURegion?: boolean;
/**
* The host of the Contentful's preview API.
* By default, this value is set to 'preview.contentful.com'.
* If `useEURegion` is set, uses 'preview.eu.contentful.com'.
*/
previewHost?: string;
/**
* The host of the Contentful's management API.
* By default, this value is `undefined` which makes the contentful-management
* use the 'api.contentful.com'.
* If `useEURegion` is set, this value is set to 'api.eu.contentful.com'.
*/
managementHost?: string;
/**
* The host used to upload assets to Contentful.
* By default, this value is `undefined` which makes the contentful-management
* use the 'upload.contentful.com'.
* If `useEURegion` is set, this value is set to 'upload.eu.contentful.com'.
*/
uploadHost?: string;
}
type UserContext = {
// The user's accessToken is provided by the "Netlify Create" Contentful
// OAuth Application when users connect their Netlify Create and Contentful
// accounts via OAuth flow.
accessToken: string;
};
export type SchemaContext = {
lastUpdatedContentTypeDate?: string;
};
type EntityError = {
name: string;
path: Array<string | number>;
details: string;
customMessage?: string;
};
export class ContentfulContentSource implements ContentSourceTypes.ContentSourceInterface<UserContext, SchemaContext, DocumentContext, AssetContext> {
private readonly spaceId: string;
private readonly environment: string;
private readonly accessToken: string;
private readonly previewToken: string;
private logger!: ContentSourceTypes.Logger;
private userLogger!: ContentSourceTypes.Logger;
private localDev!: boolean;
private contentPoller: ContentPoller | null = null;
private useWebhookForContentUpdates: boolean;
private useAccessTokenForUpdates: boolean;
private useEURegion: boolean;
private previewHost?: string;
private managementHost?: string;
private uploadHost?: string;
private locales: LocaleProps[] = [];
private defaultLocale?: LocaleProps;
private userMap: Record<string, UserProps> = {};
private cloudinaryImagesAsList!: boolean;
private bynderImagesAsList!: boolean;
private plainClient!: PlainClientAPI;
private webhookUrl?: string;
private devAppRestartNeeded?: () => void;
private cache!: ContentSourceTypes.Cache<SchemaContext, DocumentContext, AssetContext>;
private taskQueue: TaskQueue;
constructor(options: ContentSourceOptions) {
this.spaceId = options.spaceId ?? process.env.CONTENTFUL_SPACE_ID;
this.environment = options.environment ?? process.env.CONTENTFUL_ENVIRONMENT ?? 'master';
this.accessToken = options.accessToken ?? process.env.CONTENTFUL_ACCESS_TOKEN;
this.previewToken = options.previewToken ?? process.env.CONTENTFUL_PREVIEW_TOKEN;
this.useWebhookForContentUpdates = options.useWebhookForContentUpdates ?? false;
this.useAccessTokenForUpdates = options.useAccessTokenForUpdates ?? false;
this.useEURegion = options.useEURegion ?? false;
this.previewHost = options.previewHost ?? (this.useEURegion ? 'preview.eu.contentful.com' : undefined);
this.managementHost = options.managementHost ?? (this.useEURegion ? 'api.eu.contentful.com' : undefined);
this.uploadHost = options.uploadHost ?? (this.useEURegion ? 'upload.eu.contentful.com' : undefined);
// Max active bulk actions per space is limited to 5.
// Max of 200 items per bulk action.
this.taskQueue = new TaskQueue({ limit: 5 });
}
async getVersion(): Promise<ContentSourceTypes.Version> {
return stackbitUtils.getVersion({ packageJsonPath: path.join(__dirname, '../package.json') });
}
getContentSourceType(): string {
return this.useEURegion ? 'contentfuleu' : 'contentful';
}
getProjectId(): string {
return this.spaceId;
}
getProjectEnvironment(): string {
return this.environment;
}
getProjectManageUrl(): string {
return `${this.getProjectUrl()}/home`;
}
private getProjectUrl(): string {
return `https://app.${this.useEURegion ? 'eu.' : ''}contentful.com/spaces/${this.spaceId}`;
}
async init({
logger,
userLogger,
localDev,
webhookUrl,
devAppRestartNeeded,
cache
}: ContentSourceTypes.InitOptions<SchemaContext, DocumentContext, AssetContext>): Promise<void> {
this.logger = logger.createLogger({ label: 'cms-contentful' });
this.userLogger = userLogger.createLogger({ label: 'cms-contentful' });
this.localDev = localDev;
this.webhookUrl = webhookUrl;
this.devAppRestartNeeded = devAppRestartNeeded;
this.cache = cache;
// If running locally, use ContentPoller instead of webhook unless explicitly debugging webhooks
if (localDev && !webhookUrl) {
this.useWebhookForContentUpdates = false;
}
if (this.useWebhookForContentUpdates) {
this.userLogger.info('Using webhook for content updates');
}
if (this.useEURegion) {
this.userLogger.info('Using EU data region');
}
this.plainClient = createPlainApiClient({
spaceId: this.spaceId,
accessToken: this.accessToken,
environment: this.environment,
managementHost: this.managementHost,
uploadHost: this.uploadHost
});
await this.validateConfig();
await this.createWebhookIfNeeded();
await this.reset();
}
async reset() {
const locales: LocaleProps[] = await fetchAllLocales(this.plainClient);
const appInstallations: AppInstallationProps[] = await fetchAllAppInstallations(this.plainClient);
await this.fetchUsers();
// replace all data at once in atomic action
this.locales = locales.filter((locale) => {
// filter out disabled locales
return locale.contentManagementApi;
});
this.defaultLocale = this.locales.find((locale) => locale.default)!;
// Fetch up installations, and find the cloudinary app installation.
// If cloudinary app is installed, use its 'maxFiles' parameter to decide
// if cloudinary images should be presented as a single 'image' field or
// as an array of 'images'.
const cloudinaryAppInstallation = appInstallations.find((appInstallation) => appInstallation.sys.appDefinition.sys.id === CONTENTFUL_CLOUDINARY_APP);
const maxFiles = _.get(cloudinaryAppInstallation, 'parameters.maxFiles', 1);
this.cloudinaryImagesAsList = maxFiles > 1;
if (cloudinaryAppInstallation) {
this.logger.debug(
`found cloudinary app installation, maxFiles=${maxFiles}, treating cloudinary images as ${
this.cloudinaryImagesAsList ? 'list of images' : 'a single image field'
}`
);
}
// same story with bynder as with cloudinary images - either array or one
const bynderAppInstallation = appInstallations.find((appInstallation) => appInstallation.sys.appDefinition.sys.id === CONTENTFUL_BYNDER_APP);
const viewMode = _.get(bynderAppInstallation, 'parameters.compactViewMode', 'MultiSelect');
this.bynderImagesAsList = viewMode === 'MultiSelect';
if (bynderAppInstallation) {
this.logger.debug(
`found bynder app installation, viewMode=${viewMode}, treating bynder images as ${
this.bynderImagesAsList ? 'list of images' : 'a single image field'
}`
);
}
}
async destroy(): Promise<void> {}
async validateConfig() {
this.logger.debug('Validating config...');
let previewTokens;
let environments;
try {
previewTokens = await this.plainClient.previewApiKey.getMany({});
environments = await this.plainClient.environment.getMany({});
} catch (err: any) {
if (err?.message?.includes('The resource could not be found')) {
throw new Error(`Can't find Contentful space '${this.spaceId}'. Verify that the space exists and accessible with the provided access token.`);
} else {
throw new Error(`Can't access Contentful. Verify that the access token you provided is valid.`);
}
}
const foundPreviewToken = previewTokens?.items.find((item) => item.accessToken === this.previewToken);
if (!foundPreviewToken) {
throw new Error(
`Can't find Contentful preview token. Verify that the preview token is valid and associated with the provided Contentful space-id.`
);
}
const foundEnvironment = environments?.items.find(
(item) => item.name === this.environment || item.sys?.aliases?.map((alias) => alias?.sys?.id).includes(this.environment)
);
if (!foundEnvironment) {
throw new Error(`Can't find Contentful environment '${this.environment}'. Verify that the environment exists in the provided Contentful space-id.`);
}
}
async createWebhookIfNeeded() {
if (!this.webhookUrl) {
return;
}
try {
this.logger.debug('Creating webhook...');
const webhooks: WebhookProps[] = await fetchAllWebhooks(this.plainClient);
if (!webhooks.find((webhook) => webhook.url === this.webhookUrl)) {
await this.createWebhook(this.webhookUrl);
}
} catch (err) {
this.userLogger.error('Error fetching or creating a contentful webhook', {
srcProjectId: this.getProjectId(),
error: err
});
if (this.useWebhookForContentUpdates) {
this.userLogger.error('Falling back to using content poller', { srcProjectId: this.getProjectId() });
this.useWebhookForContentUpdates = false;
}
}
}
async createWebhook(webhookURL: string) {
return createWebhook(this.plainClient, {
url: webhookURL,
name: 'stackbit-content-source-webhook',
topics: ['*.*'],
transformation: {
contentType: 'application/json'
}
});
}
startWatchingContentUpdates() {
this.logger.debug('startWatchingContentUpdates');
if (this.useWebhookForContentUpdates) {
return;
}
if (this.contentPoller) {
this.stopWatchingContentUpdates();
}
this.contentPoller = new ContentPoller({
spaceId: this.spaceId,
environment: this.environment,
previewToken: this.previewToken,
managementToken: this.accessToken,
previewHost: this.previewHost,
managementHost: this.managementHost,
uploadHost: this.uploadHost,
pollType: 'date',
syncContext: this.getSyncContextForContentPollerFromCache(),
notificationCallback: async (syncResult) => {
if (syncResult.contentTypes.length) {
this.logger.debug('content type was changed, invalidate schema');
await this.cache.invalidateSchema();
} else {
const result = await this.convertSyncResult(syncResult);
await this.cache.updateContent(result);
}
},
logger: this.logger
});
this.contentPoller.start();
}
stopWatchingContentUpdates() {
this.logger.debug('stopWatchingContentUpdates');
if (this.contentPoller) {
this.contentPoller.stop();
this.contentPoller = null;
}
}
private async fetchUsers() {
const users: UserProps[] = await fetchAllUsers(this.plainClient);
this.userMap = _.keyBy(users, 'sys.id');
}
private async fetchUsersIfNeeded(entities: (EntryProps | AssetProps)[]) {
const entityHasNoCachedAuthor = entities.some((entity) => !this.userMap[entity.sys.updatedBy?.sys.id ?? '']);
if (entityHasNoCachedAuthor) {
await this.fetchUsers();
}
}
private updateContentPollerSyncContext(syncContext: ContentPollerSyncContext) {
if (this.contentPoller && this.contentPoller.pollType === 'date') {
this.contentPoller.setSyncContext({
...this.getSyncContextForContentPollerFromCache(),
...syncContext
});
}
}
private getSyncContextForContentPollerFromCache() {
const cacheSyncContext = this.cache.getSyncContext();
return {
lastUpdatedEntryDate: cacheSyncContext.documentsSyncContext as string | undefined,
lastUpdatedAssetDate: cacheSyncContext.assetsSyncContext as string | undefined,
lastUpdatedContentTypeDate: this.cache.getSchema().context?.lastUpdatedContentTypeDate
};
}
private async convertSyncResult(syncResult: ContentPollerSyncResult) {
// remove deleted entries and assets from fieldData
// generally, the "sync" method of the preview API never notifies of deleted objects, therefore we rely on
// the deleteObject method to notify the user that restart is needed. Then, once user restarts the SSG, it
// will re-fetch the data effectively removing deleted objects
// https://www.notion.so/stackbit/Contentful-Sync-API-preview-issue-6b4816ebceef4ab181cdf1603058d324
this.logger.debug('received sync data', {
entries: syncResult.entries.length,
assets: syncResult.assets.length,
deletedEntries: syncResult.deletedEntries.length,
deletedAssets: syncResult.deletedAssets.length
});
await this.fetchUsersIfNeeded([...syncResult.entries, ...syncResult.assets]);
// convert updated/created entries and assets
const documents = this.convertEntries(syncResult.entries, this.cache.getModelByName);
const assets = this.convertAssets(syncResult.assets);
return {
documents,
assets,
deletedDocumentIds: syncResult.deletedEntries.map((entry) => entry.sys.id),
deletedAssetIds: syncResult.deletedAssets.map((asset) => asset.sys.id)
};
}
async getSchema(): Promise<Schema<SchemaContext>> {
this.logger.debug('getSchema');
const contentTypes: ContentTypeProps[] = await fetchAllContentTypes(this.plainClient);
const editorInterfaces: EditorInterfaceProps[] = await fetchAllEditorInterfaces(this.plainClient);
const defaultLocaleCode = this.localeOrDefaultOrThrow();
const { models } = convertSchema({
contentTypes: contentTypes,
editorInterfaces: editorInterfaces,
defaultLocaleCode: defaultLocaleCode,
cloudinaryImagesAsList: this.cloudinaryImagesAsList,
bynderImagesAsList: this.bynderImagesAsList
});
const prevLastUpdatedContentTypeDate = this.cache.getSchema().context?.lastUpdatedContentTypeDate;
// Check if one of the content types was changed from the last time the
// content types were fetched, in which case remove all cached content.
const lastUpdatedContentTypeDate = getLastUpdatedEntityDate(contentTypes);
if (prevLastUpdatedContentTypeDate !== lastUpdatedContentTypeDate) {
this.logger.debug(
`last updated content type date '${lastUpdatedContentTypeDate}' is different ` +
`from the cached date '${prevLastUpdatedContentTypeDate}', clearing cache`
);
await this.cache.clearSyncContext({
clearDocumentsSyncContext: true,
clearAssetsSyncContext: false
});
}
this.updateContentPollerSyncContext({ lastUpdatedContentTypeDate });
return {
models,
locales: this.locales.map((locale) => ({
code: locale.code,
default: locale.default
})),
context: {
lastUpdatedContentTypeDate: lastUpdatedContentTypeDate
}
};
}
async getDocuments(options?: { syncContext?: string }): Promise<ContextualDocument[] | { documents: ContextualDocument[]; syncContext?: string }> {
this.logger.debug('getDocuments');
let lastUpdatedEntryDate = options?.syncContext;
let entries: EntryProps[];
try {
if (lastUpdatedEntryDate) {
this.logger.debug(`fetching entries updated after ${lastUpdatedEntryDate}`);
entries = await fetchEntriesUpdatedAfter(this.plainClient, lastUpdatedEntryDate, this.userLogger);
this.logger.debug(`got ${entries.length} updated/created entries after ${lastUpdatedEntryDate}`);
if (entries.length) {
lastUpdatedEntryDate = getLastUpdatedEntityDate(entries);
}
} else {
entries = await fetchAllEntries(this.plainClient, this.userLogger);
lastUpdatedEntryDate = getLastUpdatedEntityDate(entries);
}
} catch (error: any) {
// Stackbit won't be able to work properly even if one of the entries was not fetched.
// All fetch methods use Contentful's API client that handles errors and retries automatically.
this.logger.error(`Failed fetching documents from Contentful, error: ${error.message}`);
// By returning the original syncContext we are ensuring that next
// time the getDocuments is called, it will try to get the documents
// using the same syncContext
return { documents: [], syncContext: lastUpdatedEntryDate };
}
this.updateContentPollerSyncContext({ lastUpdatedEntryDate });
this.logger.debug(
`got ${entries.length} entries from space ${this.spaceId}, environment ${this.environment}, lastUpdatedEntryDate: ${lastUpdatedEntryDate}`
);
await this.fetchUsersIfNeeded(entries);
const documents = this.convertEntries(entries, this.cache.getModelByName);
return { documents, syncContext: lastUpdatedEntryDate };
}
async getAssets(options?: { syncContext?: string }): Promise<ContextualAsset[] | { assets: ContextualAsset[]; syncContext?: string }> {
this.logger.debug('getAssets');
let lastUpdatedAssetDate = options?.syncContext;
let ctflAssets: AssetProps[];
try {
if (lastUpdatedAssetDate) {
this.logger.debug(`fetching assets updated after ${lastUpdatedAssetDate}`);
ctflAssets = await fetchAssetsUpdatedAfter(this.plainClient, lastUpdatedAssetDate, this.userLogger);
this.logger.debug(`got ${ctflAssets.length} updated/created assets after ${lastUpdatedAssetDate}`);
if (ctflAssets.length) {
lastUpdatedAssetDate = getLastUpdatedEntityDate(ctflAssets);
}
} else {
ctflAssets = await fetchAllAssets(this.plainClient, this.userLogger);
lastUpdatedAssetDate = getLastUpdatedEntityDate(ctflAssets);
}
} catch (error: any) {
// Stackbit won't be able to work properly even if one of the entries or the assets was not fetched.
// All fetch methods use Contentful's API client that handles errors and retries automatically.
this.logger.error(`Failed fetching assets from Contentful, error: ${error.message}`);
return { assets: [], syncContext: lastUpdatedAssetDate };
}
this.updateContentPollerSyncContext({ lastUpdatedAssetDate });
this.logger.debug(
`got ${ctflAssets.length} assets from space ${this.spaceId}, environment ${this.environment}, lastUpdatedAssetDate: ${lastUpdatedAssetDate}`
);
await this.fetchUsersIfNeeded(ctflAssets);
const assets = this.convertAssets(ctflAssets);
return { assets, syncContext: lastUpdatedAssetDate };
}
async hasAccess({ userContext }: { userContext?: UserContext }): Promise<{ hasConnection: boolean; hasPermissions: boolean }> {
if (!this.localDev && !this.useAccessTokenForUpdates && !userContext?.accessToken) {
return {
hasConnection: false,
hasPermissions: false
};
}
try {
const apiClient = this.getPlainApiClientForUser({ userContext });
await apiClient.entry.getMany({ query: { limit: 1 } });
return {
hasConnection: true,
hasPermissions: true
};
} catch (error) {
this.logger.debug('Contentful: failed to access space', { error });
return {
hasConnection: true,
hasPermissions: false
};
}
}
async createDocument({
updateOperationFields,
model,
locale,
userContext
}: {
updateOperationFields: Record<string, ContentSourceTypes.UpdateOperationField>;
model: Model;
locale?: string;
userContext?: UserContext;
}): Promise<{ documentId: string }> {
this.logger.debug('createDocument');
const entry: CreateEntryProps = { fields: {} };
_.forEach(updateOperationFields, (operationField, fieldName) => {
const modelField = model.fields?.find((field) => field.name === fieldName);
if (!modelField) {
return;
}
const value = mapOperationFieldToContentfulValue(operationField, modelField, false);
const localeOrDefault = this.localeOrDefaultOrThrow(locale);
setEntryField(entry, value, [fieldName], modelField.localized ? localeOrDefault : this.defaultLocale!.code);
});
const apiClient = this.getPlainApiClientForUser({ userContext });
const entryResult = await createEntry(apiClient, model.name, entry);
return { documentId: entryResult.sys.id };
}
async updateDocument({
document,
operations,
userContext
}: {
document: ContextualDocument;
operations: ContentSourceTypes.UpdateOperation[];
userContext?: UserContext;
}): Promise<void> {
this.logger.debug('updateDocument');
const documentId = document.id;
const entry = await fetchEntryById(this.plainClient, documentId);
const modelName = entry.sys.contentType.sys.id;
const model = this.cache.getModelByName(modelName);
if (!model) {
throw new Error(`Error updating document: could not find document model '${modelName}'.`);
}
const defaultLocale = this.defaultLocale?.code ?? 'en-US';
for (const operation of operations) {
const locale = 'localized' in operation.modelField && operation.modelField.localized ? operation.locale ?? defaultLocale : defaultLocale;
const operationWithLocale = {
...operation,
locale
};
const opFunc = Operations[operationWithLocale.opType] as OperationFunction<typeof operationWithLocale.opType>;
await opFunc({ entry, operation: operationWithLocale });
}
const apiClient = this.getPlainApiClientForUser({ userContext });
await updateEntry(apiClient, documentId, entry);
}
async deleteDocument({ document, userContext }: { document: ContextualDocument; userContext?: UserContext }): Promise<void> {
this.logger.debug('deleteDocument');
const documentId = document.id;
try {
const entry = await fetchEntryById(this.plainClient, documentId);
const apiClient = this.getPlainApiClientForUser({ userContext });
// Contentful won't let us delete a published entry, so if the entry is
// published we must unpublish it first.
if (_.get(entry, 'sys.publishedVersion')) {
await unpublishEntry(apiClient, documentId);
}
await deleteEntry(apiClient, documentId);
} catch (err) {
this.logger.error('Contentful: Failed to delete object', {
documentId: documentId,
error: err
});
throw err;
}
}
async uploadAsset({
url,
base64,
fileName,
mimeType,
locale,
userContext
}: {
url?: string;
base64?: string;
fileName: string;
mimeType: string;
locale?: string;
userContext?: UserContext;
}): Promise<ContextualAsset> {
this.logger.debug('uploadAsset');
locale = this.localeOrDefaultOrThrow(locale);
const apiClient = this.getPlainApiClientForUser({ userContext });
let asset: AssetProps;
if (url) {
asset = await createAsset(apiClient, {
fields: {
title: {
[locale]: fileName
},
file: {
[locale]: {
fileName: fileName,
contentType: mimeType,
upload: url
}
}
}
});
} else {
const imgBuffer = Buffer.from(base64!, 'base64');
const readable = new Readable();
readable.push(imgBuffer);
readable.push(null);
asset = await createAssetFromFile(apiClient, {
fields: {
title: {
[locale]: fileName
},
// the description is marked as required in Contentful's Typescript
description: {
[locale]: ''
},
file: {
[locale]: {
fileName: fileName,
contentType: mimeType,
file: readable
}
}
}
});
}
const processedAsset = await processAssetForAllLocales(apiClient, asset);
const publishedAsset = await publishAsset(apiClient, processedAsset);
const assetDocument = this.convertAssets([publishedAsset]);
return assetDocument[0]!;
}
async updateAsset({
asset,
operations,
userContext
}: {
asset: ContextualAsset;
operations: ContentSourceTypes.UpdateOperation[];
userContext?: UserContext;
}): Promise<void> {
this.logger.debug('updateAsset');
const assetId = asset.id;
const assetEntry = await fetchAssetById(this.plainClient, assetId);
const defaultLocale = this.defaultLocale?.code ?? 'en-US';
for (const operation of operations) {
const locale = 'localized' in operation.modelField && operation.modelField.localized ? operation.locale ?? defaultLocale : defaultLocale;
const operationWithLocale = {
...operation,
locale
};
const opFunc = Operations[operationWithLocale.opType] as OperationFunction<typeof operationWithLocale.opType>;
await opFunc({ entry: assetEntry, operation: operationWithLocale });
}
const apiClient = this.getPlainApiClientForUser({ userContext });
await updateAsset(apiClient, assetId, assetEntry);
}
async validateDocuments({
documents,
assets,
locale,
userContext
}: {
documents: ContextualDocument[];
assets: ContextualAsset[];
locale?: string;
userContext?: UserContext;
}): Promise<{ errors: ContentSourceTypes.ValidationError[] }> {
const linkChunks: Link<'Entry' | 'Asset'>[][] = _.chunk(
[
...documents.map((document) => ({ sys: { type: 'Link' as const, id: document.id, linkType: 'Entry' as const } })),
...assets.map((asset) => ({ sys: { type: 'Link' as const, id: asset.id, linkType: 'Asset' as const } }))
],
200
);
const apiClient = this.getPlainApiClientForUser({ userContext });
locale = this.localeOrDefaultOrThrow(locale);
const result = await Promise.all(
linkChunks.map((link) => {
return this.taskQueue.addTask(async (): Promise<ContentSourceTypes.ValidationError[]> => {
const bulkActionOptions = {
spaceId: this.spaceId,
environmentId: this.environment
};
const bulkActionCreateResult = await createValidateBulkAction(apiClient, bulkActionOptions, {
entities: {
sys: { type: 'Array' },
items: link
}
});
const bulkAction = await waitBulkActionToComplete(bulkActionCreateResult, apiClient, bulkActionOptions);
if (bulkAction.sys.status === 'failed') {
const errors = bulkAction.error?.details?.errors ?? [];
return errors.reduce((result: ContentSourceTypes.ValidationError[], { error, entity }) => {
const entityErrors: EntityError[] = error?.details?.errors ?? [];
return entityErrors.reduce((result: ContentSourceTypes.ValidationError[], entityError) => {
// error.path is:
// ['fields', 'title'] => ['title']
// ['fields', 'title', 'en-US'] => ['title']
// ['fields', 'action', 'en-US', 1] => ['title']
const fieldPath = entityError.path.slice(1);
const fieldLocale = fieldPath[1];
if (typeof fieldLocale === 'string' && ['en-US', locale].includes(fieldLocale)) {
// remove locale from fieldPath
fieldPath.splice(1, 1);
}
result.push({
message: entityError?.customMessage ?? entityError.details,
objectType: entity.sys.linkType === 'Entry' ? 'document' : 'asset',
objectId: entity.sys.id,
fieldPath: fieldPath,
isUniqueValidation: entityError.name === 'unique'
});
return result;
}, result);
}, []);
}
return [];
});
})
);
return { errors: _.flatten(result) };
}
async publishDocuments({
documents,
assets,
userContext
}: {
documents: ContextualDocument[];
assets: ContextualAsset[];
userContext?: UserContext;
}): Promise<void> {
const apiClient = this.getPlainApiClientForUser({ userContext });
// to publish, we need the most recent entity version, fetch the entities
// by id to get their latest version and also ensure they were not publish
// by someone else.
const entries = await fetchEntriesByIds(
apiClient,
documents.map((document) => document.id),
{
'sys.archivedVersion[exists]': false,
select: 'sys.id,sys.type,sys.version,sys.publishedVersion,sys.archivedVersion'
}
);
const contentfulAssets = await fetchAssetsByIds(
apiClient,
assets.map((asset) => asset.id),
{
'sys.archivedVersion[exists]': false,
select: 'sys.id,sys.type,sys.version,sys.publishedVersion,sys.archivedVersion'
}
);
const entities = [...entries, ...contentfulAssets];
const entitiesToPublish = entities.filter((entity) => {
const isDraft = !entity.sys.publishedVersion && !entity.sys.archivedVersion;
const isChanged = entity.sys.publishedVersion && entity.sys.version >= entity.sys.publishedVersion + 2;
return isDraft || isChanged;
});
const versionedLinkChunks: VersionedLink<'Entry' | 'Asset'>[][] = _.chunk(
entitiesToPublish.map((entity) => ({
sys: {
type: 'Link',
id: entity.sys.id,
linkType: entity.sys.type as 'Entry' | 'Asset',
version: entity.sys.version
}
})),
200
);
await Promise.all(
versionedLinkChunks.map((versionedLinks) => {
return this.taskQueue.addTask(async (): Promise<void> => {
const bulkActionOptions = {
spaceId: this.spaceId,
environmentId: this.environment
};
const bulkActionCreateResult = await createPublishBulkAction(apiClient, bulkActionOptions, {
entities: {
sys: { type: 'Array' },
items: versionedLinks
}
});
await waitBulkActionToComplete(bulkActionCreateResult, apiClient, bulkActionOptions);
});
})
);
}
async unpublishDocuments({
documents,
assets,
userContext
}: {
documents: ContextualDocument[];
assets: ContextualAsset[];
userContext?: UserContext;
}): Promise<void> {
const apiClient = this.getPlainApiClientForUser({ userContext });
const entities = [...documents, ...assets];
const linkChunks: Link<'Entry' | 'Asset'>[][] = _.chunk(
entities.map((entity) => ({
sys: {
type: 'Link',
id: entity.id,
linkType: entity.type === 'document' ? 'Entry' : 'Asset'
}
})),
200
);
await Promise.all(
linkChunks.map((links) => {
return this.taskQueue.addTask(async (): Promise<void> => {
const bulkActionOptions = {
spaceId: this.spaceId,
environmentId: this.environment
};
const bulkActionCreateResult = await createUnpublishBulkAction(apiClient, bulkActionOptions, {
entities: {
sys: { type: 'Array' },
items: links
}
});
await waitBulkActionToComplete(bulkActionCreateResult, apiClient, bulkActionOptions);
});
})
);
}
async archiveDocument({ document, userContext }: { document: ContextualDocument; userContext?: UserContext }): Promise<void> {
this.logger.debug('archiveDocument');
const documentId = document.id;
try {
const entry = await fetchEntryById(this.plainClient, documentId);
const apiClient = this.getPlainApiClientForUser({ userContext });
// Contentful won't let us archive a published entry, so if the entry is
// published we must unpublish it first.
if (_.get(entry, 'sys.publishedVersion')) {
await unpublishEntry(apiClient, documentId);
}
await archiveEntry(apiClient, documentId);
} catch (err) {
this.logger.error('Contentful: Failed to archive object', {
documentId: documentId,
error: err
});
throw err;
}
}
async unarchiveDocument({ document, userContext }: { document: ContextualDocument; userContext?: UserContext }): Promise<void> {
this.logger.debug('unarchiveDocument');
const documentId = document.id;
try {
const apiClient = this.getPlainApiClientForUser({ userContext });
await unarchiveEntry(apiClient, documentId);
} catch (err) {
this.logger.error('Contentful: Failed to archive object', {
documentId: documentId,
error: err
});
throw err;
}
}
async onWebhook({ data, headers }: { data: unknown; headers: Record<string, string> }) {
const topic = headers['x-contentful-topic'] || '';
const didContentTypeChange = topic.startsWith('ContentManagement.ContentType.');
const didDocumentDelete = topic === 'ContentManagement.Entry.delete';
const didAssetDelete = topic === 'ContentManagement.Asset.delete';
const didScheduledActionDelete = topic === 'ContentManagement.ScheduledAction.delete' || topic === 'ContentManagement.Release.delete';
const environment = _.get(data, 'sys.environment.sys.id');
// skip updates on other environments
if (environment !== this.environment) {
return;
}
// skip updates on BulkAction event type
if (topic.includes('BulkAction')) {
return;
}
// if contentPoller.pollType === 'date', then the invalidateSchema will
// be called by contentPoller when it identifies change in content types
if (didContentTypeChange && this.contentPoller?.pollType !== 'date') {
this.cache.invalidateSchema();
}
if (didDocumentDelete) {
const documentId = _.get(data, 'sys.id');
if (documentId) {
this.cache.updateContent({
documents: [],
assets: [],
deletedDocumentIds: [documentId],
deletedAssetIds: []
});
}
}
if (didAssetDelete) {
const assetId = _.get(data, 'sys.id');
if (assetId) {
this.cache.updateContent({
documents: [],
assets: [],
deletedDocumentIds: [],
deletedAssetIds: [assetId]
});
}
}
if (didScheduledActionDelete) {
let scheduledActionId;
if (_.get(data, 'sys.type') === 'ScheduledAction') {
// refetch schedule because we're not getting the entity.sys.id in the webhook data
const schedule = await fetchScheduleById(this.plainClient, { environment: this.environment, scheduleId: _.get(data, 'sys.id') });
scheduledActionId = schedule.entity.sys.id;
} else {
scheduledActionId = _.get(data, 'sys.id');
}
if (scheduledActionId) {
this.cache.updateContent({
deletedScheduledActionIds: [scheduledActionId]
});
}
}
// ScheduledActions are handled outside of this.useWebhookForContentUpdates block below because Releases and Schedules are not handled by sync.
if ((topic.startsWith('ContentManagement.ScheduledAction') || topic.startsWith('ContentManagement.Release')) && !didScheduledActionDelete) {
const scheduledActionId =
_.get(data, 'sys.type') === 'ScheduledAction' && _.get(data, 'entity.sys.linkType') === 'Release'
? _.get(data, 'entity.sys.id')
: _.get(data, 'sys.type') === 'Release'
? _.get(data, 'sys.id')
: null;
this.logger.debug(`onWebhook ScheduledAction/Release - ${scheduledActionId}`);
if (scheduledActionId) {
const scheduledAction = await getScheduledAction(this.plainClient, scheduledActionId, { environment: this.environment });
if (scheduledAction) {
// If a schedule is updated from the studio, both the ScheduledAction and the Release will post a webhook update,
// So this diff change makes sure we send only one update.
const cachedScheduledAction = this.cache.getScheduledActions().find((schedule) => schedule.id === scheduledActionId);
const updatedActions = _.differenceWith([scheduledAction], [cachedScheduledAction], _.isEqual);
if (updatedActions.length) {
this.cache.updateContent({ scheduledActions: updatedActions });
}
}
}
}
// if sync poller is used, content updates are handled in the poller and we don't need to do anything here
if (this.useWebhookForContentUpdates) {
const userId = _.get(data, 'sys.updatedBy.sys.id');
if (userId && !this.userMap[userId]) {
await this.fetchUsers();
}
if (topic.startsWith('ContentManagement.Entry') && !didDocumentDelete) {
const documentId = _.get(data, 'sys.id');
this.logger.debug(`onWebhook ${topic} ${documentId}`);
if (documentId) {
const entry = await fetchEntryById(this.plainClient, documentId);
this.cache.updateContent({
documents: this.convertEntries([entry], this.cache.getModelByName),
assets: [],
deletedDocumentIds: [],
deletedAssetIds: []
});
}
} else if (topic.startsWith('ContentManagement.Asset') && !didAssetDelete) {
const assetId = _.get(data, 'sys.id');
this.logger.debug(`onWebhook ${topic} ${assetId}`);
if (assetId) {
const asset = await fetchAssetById(this.plainClient, assetId);
this.cache.updateContent({
documents: [],
assets: this.convertAssets([asset]),
deletedDocumentIds: [],
deletedAssetIds: []
});
}
}
}
}
private getPlainApiClientForUser({ userContext }: { userContext?: UserContext }): PlainClientAPI {
if (this.localDev || this.useAccessTokenForUpdates) {
return this.plainClient;
}
const userAccessToken = userContext?.accessToken;
if (!userAccessToken) {
throw new Error(`User does not have an access token for space '${this.spaceId}'.`);
}
return createPlainApiClient({
spaceId: this.spaceId,
accessToken: userAccessToken,
environment: this.environment,
managementHost: this.managementHost,
uploadHost: this.uploadHost
});
}
private convertEntries(entries: EntityProps[], getModelByName: ContentSourceTypes.Cache['getModelByName']) {
return convertEntities({
entries: entries,
getModelByName: getModelByName,
userMap: this.userMap,
defaultLocale: this.localeOrDefaultOrThrow(),
projectUrl: this.getProjectUrl()
});
}
private convertAssets(assets: AssetProps[]) {
return convertAssets({
assets: assets,
userMap: this.userMap,
defaultLocale: this.localeOrDefaultOrThrow(),
projectUrl: this.getProjectUrl()
});
}
private convertVersions(entries: SnapshotProps<EntryProps>[]) {
return convertDocumentVersions({
entries,
getModelByName: this.cache.getModelByName,
userMap: this.userMap,
defaultLocale: this.localeOrDefaultOrThrow(),
projectUrl: this.getProjectUrl()
});
}
private localeOrDefaultOrThrow(locale?: string): string {
const result = locale ?? this.defaultLocale?.code;
if (!result) {
throw new Error('Localization error: default locale is not set.');
}
return result;
}
async cancelScheduledAction({
scheduledActionId,
userContext
}: {
scheduledActionId: string;
userContext?: UserContext;
}): Promise<{ cancelledScheduledActionId: string }> {
try {
this.logger.debug('cancelScheduledAction', { scheduledActionId });
const apiClient = this.getPlainApiClientForUser({ userContext });
const schedule = await cancelScheduledAction(apiClient, scheduledActionId, {
environment: this.environment
});
this.logger.debug('cancelScheduledAction - success', { schedule });
return { cancelledScheduledActionId: schedule.id };
} catch (err) {
this.logger.debug('Failed to update scheduled actions');
throw err;
}
}
async createScheduledAction({
documentIds,
name,
action,
executeAt,
userContext
}: {
documentIds: string[];
name: string;
action: ContentSourceTypes.ScheduledActionActionType;
executeAt: string;
userContext?: UserContext;
}): Promise<{ newScheduledActionId: string }> {
this.logger.debug('createScheduledAction', { action, executeAt });
const apiClient = this.getPlainApiClientForUser({ userContext });
const { schedule, contentfulSchedule, contentfulRelease } = await createScheduledAction(apiClient, {
name,
documentIds,
executeAt,
action,
environment: this.environment
});
this.logger.debug('createScheduledAction - Create success', { schedule, contentfulSchedule, contentfulRelease });
return { newScheduledActionId: schedule.id };
}
async getScheduledActions(): Promise<ContentSourceTypes.ScheduledAction[]> {
this.logger.debug('getScheduledActions');
c