src/persistence/mapiObjectStorage.ts (484 lines of code) (raw):

import * as Objects from "@paperbits/common"; import { IObjectStorage, Query, Operator, Page } from "@paperbits/common/persistence"; import { HttpHeader } from "@paperbits/common/http"; import { ArmResource } from "../contracts/armResource"; import { AppError } from "../errors"; import { defaultPageSize } from "../constants"; import { PageContract } from "../contracts/page"; import { IApiClient } from "../clients"; import * as _ from "lodash"; const supportedKeys = ["pages", "layouts", "files", "uploads", "blocks", "urls", "popups"]; const localizedContentTypes = ["page", "layout", "blogpost", "navigation", "block", "popup"]; const selectedLocaleArm = "en_us"; const selectedLocaleArmAlt = "en-us"; const selectedLocaleBits = selectedLocaleArm.replace("_", "-"); const reservedArmIds = ["containerId", "webContainerId", "appId", "accountId"]; const reservedPaperbitsIds = ["containerKey", "webContainerKey"]; export class MapiObjectStorage implements IObjectStorage { constructor(private readonly apiClient: IApiClient) { } private getContentTypeFromResource(resource: string): string { const regex = /contentTypes\/([\w]*)/gm; const match = regex.exec(resource); if (match && match.length > 0) { const contentType = match[1]; return contentType; } throw new AppError(`Could not determine content type by resource: ${resource}`); } private delocalizeBlock(contract: any): void { contract.contentKey = contract["locales"]["en-us"]["contentKey"]; contract.title = contract["locales"]["en-us"]["title"]; contract.description = contract["locales"]["en-us"]["description"]; contract.type = contract["locales"]["en-us"]["type"]; delete contract["locales"]; } private localizeBlock(contract: any): void { contract.locales = { [selectedLocaleBits]: { contentKey: contract.contentKey, title: contract.title, description: contract.description, type: contract.type } }; delete contract["contentKey"]; delete contract["title"]; delete contract["description"]; delete contract["type"]; } public armResourceToPaperbitsKey(resource: string): string { if (!resource.includes("contentTypes")) { return resource; } const regex = /contentTypes\/(.*)\/contentItems\/(.*)/gm; const match = regex.exec(resource); const mapiContentType = match[1]; const mapiContentItem = match[2]; let contentType; let contentItem; switch (mapiContentType) { case "page": contentType = "pages"; contentItem = mapiContentItem; break; case "layout": contentType = "layouts"; contentItem = mapiContentItem; break; case "blob": contentType = "uploads"; contentItem = mapiContentItem; break; case "block": contentType = "blocks"; contentItem = mapiContentItem; break; case "url": contentType = "urls"; contentItem = mapiContentItem; break; case "navigation": contentType = "navigationItems"; contentItem = null; break; case "configuration": contentType = "settings"; contentItem = null; break; case "stylesheet": contentType = "styles"; contentItem = null; break; case "document": contentType = "files"; contentItem = mapiContentItem; break; case "popup": contentType = "popups"; contentItem = mapiContentItem; break; default: throw new AppError(`Unknown content type: "${mapiContentType}"`); } let key = contentType; if (contentItem) { key += `/${contentItem}`; } return key; } public paperbitsKeyToArmResource(key: string): string { if (key.startsWith("/")) { key = key.substring(1); } if (key.startsWith("contentTypes")) { return key; } const segments = key.split("/"); const contentType = segments[0]; const contentItem = segments[1]; let mapiContentType; let mapiContentItem; switch (contentType) { case "pages": mapiContentType = "page"; mapiContentItem = contentItem; break; case "layouts": mapiContentType = "layout"; mapiContentItem = contentItem; break; case "uploads": mapiContentType = "blob"; mapiContentItem = contentItem; break; case "blocks": mapiContentType = "block"; mapiContentItem = contentItem; break; case "urls": mapiContentType = "url"; mapiContentItem = contentItem; break; case "navigationItems": mapiContentType = "document"; mapiContentItem = "navigation"; break; case "settings": mapiContentType = "document"; mapiContentItem = "configuration"; break; case "styles": mapiContentType = "document"; mapiContentItem = "stylesheet"; break; case "files": mapiContentType = "document"; mapiContentItem = contentItem; break; case "locales": mapiContentType = "locales"; mapiContentItem = "en-us"; break; case "popups": mapiContentType = "popup"; mapiContentItem = contentItem; break; default: // throw new AppError(`Unknown content type: "${contentType}"`); return key; } let resource = `contentTypes/${mapiContentType}/contentItems`; if (mapiContentItem) { resource += `/${mapiContentItem}`; } return resource; } public async addObject<T>(path: string, dataObject: T): Promise<void> { const converted = this.convertPaperbitsContractToArmContract(dataObject); const resource = this.paperbitsKeyToArmResource(path); try { const headers: HttpHeader[] = [await this.apiClient.getPortalHeader("addObject")]; await this.apiClient.put<T>(resource, headers, { properties: converted }); } catch (error) { throw new AppError(`Could not add object '${path}'.`, error); } } public async getObject<T>(key: string): Promise<T> { try { if (key === "locales") { return <any>{ key: `contentTypes/locales/contentItem/en_us`, code: "en-us", displayName: "English (US)" }; } const resourcePath = this.paperbitsKeyToArmResource(key); const contentType = this.getContentTypeFromResource(resourcePath); const isLocalized = localizedContentTypes.includes(contentType); const contentItem = await this.apiClient.get<T>(resourcePath, [await this.apiClient.getPortalHeader("getObject")]); const converted = this.convertArmContractToPaperbitsContract(contentItem, isLocalized); if (key.startsWith("blocks/")) { this.delocalizeBlock(converted); } if (key.includes("settings") || key.includes("styles")) { const result = (<any>converted).nodes[0]; const segments = key.split("/"); if (segments.length > 1) { const path = segments.slice(1).join("/"); return Objects.getObjectAt(path, result); } else { return result; } } if (key.includes("navigationItems")) { return (<any>converted).nodes; } return converted; } catch (error) { if (error?.code === "ResourceNotFound") { return null; } throw new AppError(`Could not get object '${key}'.`, error); } } public async deleteObject(path: string): Promise<void> { const resource = this.paperbitsKeyToArmResource(path); try { const headers: HttpHeader[] = []; headers.push({ name: "If-Match", value: "*" }, await this.apiClient.getPortalHeader("deleteObject")); await this.apiClient.delete(resource, headers); } catch (error) { throw new AppError(`Could not delete object '${path}'.`, error); } } public async updateObject<T>(key: string, dataObject: T): Promise<void> { const resource = this.paperbitsKeyToArmResource(key); const contentType = this.getContentTypeFromResource(resource); const isLocalized = localizedContentTypes.includes(contentType); let paperbitsContract; if (isLocalized) { if (key.startsWith("blocks/")) { this.localizeBlock(dataObject); } paperbitsContract = { ...dataObject, [selectedLocaleArm]: dataObject["locales"][selectedLocaleBits], }; delete paperbitsContract["locales"]; } else { paperbitsContract = dataObject; } let armContract = this.convertPaperbitsContractToArmContract(paperbitsContract); delete armContract["id"]; let exists: boolean; try { if (key.includes("settings") || key.includes("styles")) { armContract = { nodes: [armContract] }; } if (key.includes("navigationItems")) { armContract = { nodes: armContract }; } if (key.includes("files")) { delete armContract["type"]; } await this.apiClient.head<T>(resource); exists = true; } catch (error) { if (error?.code === "ResourceNotFound") { exists = false; } else { throw new AppError(`Could not update object '${key}'.`, error); } } try { const headers: HttpHeader[] = [await this.apiClient.getPortalHeader("updateObject")]; if (exists) { headers.push({ name: "If-Match", value: "*" }); } await this.apiClient.put<T>(resource, headers, { properties: armContract }); } catch (error) { throw new AppError(`Could not update object '${key}'.`, error); } } private async loadNextPage<T>(resource: string, localeSearchPrefix: string, filterQueryString: string, orderQueryString: string, skip: number, isLocalized: boolean): Promise<Page<T>> { const url = `${resource}?$skip=${skip}&$top=${defaultPageSize}${filterQueryString}${orderQueryString}`; const pageOfTs = await this.apiClient.get<PageContract<T>>(url, [await this.apiClient.getPortalHeader("getPageData")]); const searchResult = []; for (const item of pageOfTs.value) { const converted = this.convertArmContractToPaperbitsContract(item, isLocalized); if (resource.startsWith("contentTypes/block/contentItems")) { this.delocalizeBlock(converted); } searchResult.push(converted); } const resultPage: Page<T> = { value: searchResult, takeNext: async (): Promise<Page<T>> => { return await this.loadNextPage(resource, localeSearchPrefix, filterQueryString, orderQueryString, skip + defaultPageSize, isLocalized); } }; if (!pageOfTs.nextLink || pageOfTs.value.length === 0) { resultPage.takeNext = null; } return resultPage; } public async searchObjects<T>(key: string, query: Query<T>): Promise<Page<T>> { const resource = this.paperbitsKeyToArmResource(key); const contentType = this.getContentTypeFromResource(resource); const isLocalized = localizedContentTypes.includes(contentType); const localeSearchPrefix = isLocalized ? `${selectedLocaleArm}/` : ""; if (key === "locales") { return { value: [] }; } try { let filterQueryString = ""; let orderQueryString = ""; if (query?.filters.length > 0) { const filterExpressions = []; for (const filter of query.filters) { const operator = filter.operator; if (resource.startsWith("contentTypes/block/contentItems")) { filter.left = `en_us/${filter.left}`; } filter.left = filter.left.replace("locales/en-us/", "en_us/"); switch (operator) { case Operator.equals: filterExpressions.push(`${filter.left} eq '${filter.right}'`); break; case Operator.contains: if (filter.left !== "mimeType") { // Need to make this field indexable in content type first. filterExpressions.push(`contains(${filter.left},'${filter.right}')`); } break; default: throw new AppError(`Cannot translate operator into OData query.`); } } if (filterExpressions.length > 0) { filterQueryString = `&$filter=${filterExpressions.join(" and ")}`; } } if (query?.orderingBy) { query.orderingBy = query.orderingBy.replace("locales/en-us/", localeSearchPrefix); orderQueryString = `&$orderby=${query.orderingBy}`; } if (key.includes("navigationItems")) { const armContract = await this.apiClient.get<any>(`${resource}?$orderby=${localeSearchPrefix}title${filterQueryString}`, [await this.apiClient.getPortalHeader("searchObjects")]); const paperbitsContract = this.convertArmContractToPaperbitsContract(armContract, isLocalized); return paperbitsContract.nodes; } return await this.loadNextPage(resource, localeSearchPrefix, filterQueryString, orderQueryString, 0, isLocalized); } catch (error) { throw new AppError(`Could not search object '${key}'. Error: ${error.message}`, error); } } public convertPaperbitsContractToArmContract(contract: any): any { let converted; if (contract === null || contract === undefined) { // here we expect "false" as a value too return null; } if (Array.isArray(contract)) { converted = contract.map(x => this.convertPaperbitsContractToArmContract(x)); } else if (typeof contract === "object") { converted = {}; Object.keys(contract).forEach(propertyName => { const propertyValue = contract[propertyName]; let convertedKey = propertyName; let convertedValue = propertyValue; if (!reservedPaperbitsIds.includes(convertedKey)) { convertedKey = propertyName .replace(/contentKey/gm, "documentId") .replace(/Key\b/gm, "Id") .replace(/\bkey\b/gm, "id"); } if (typeof propertyValue === "string") { if (propertyName !== convertedKey) { convertedValue = this.paperbitsKeyToArmResource(propertyValue); } } else { convertedValue = this.convertPaperbitsContractToArmContract(propertyValue); } converted[convertedKey] = convertedValue; }); } else { converted = contract; } return converted; } public convertArmContractToPaperbitsContract(contractObject: ArmResource | any, isLocalized: boolean = false, isArm: boolean = true): any { if (contractObject === null || contractObject === undefined) { return contractObject; } let contract: any; if (isLocalized) { const localeName = contractObject.properties[selectedLocaleArm] ? selectedLocaleArm : selectedLocaleArmAlt; // fallback to alternative definition const localeObject = contractObject.properties[localeName]; const restProperties = _.omit(contractObject.properties, localeName); contract = { key: contractObject.id, locales: { [selectedLocaleBits]: localeObject, }, ...restProperties, }; } else { if (isArm) { contract = contractObject.properties; contract.key = contractObject.id; } else { contract = contractObject; } } let converted; if (Array.isArray(contract)) { converted = contract.map(x => this.convertArmContractToPaperbitsContract(x, false, false)); } else if (typeof contract === "object") { converted = {}; Object.keys(contract).forEach(propertyName => { const propertyValue = contract[propertyName]; let convertedKey = propertyName; let convertedValue = propertyValue; if (!reservedArmIds.includes(propertyName)) { convertedKey = propertyName .replace(/documentId/gm, "contentKey") .replace(/Id\b/gm, "Key") .replace(/\bid\b/gm, "key"); } if (typeof propertyValue === "string" && propertyValue.includes("contentType")) { convertedValue = this.armResourceToPaperbitsKey(propertyValue); } else { convertedValue = this.convertArmContractToPaperbitsContract(propertyValue, false, false); } converted[convertedKey] = convertedValue; }); } else { converted = contract; } return converted; } public async saveChanges(delta: Object): Promise<void> { const saveTasks = []; const keys = []; Object.keys(delta).map(key => { const firstLevelObject = delta[key]; if (supportedKeys.includes(key)) { Object.keys(firstLevelObject).forEach(subkey => { keys.push(`${key}/${subkey}`); }); } if (["navigationItems", "settings", "styles"].includes(key)) { keys.push(key); } }); keys.forEach(key => { const changeObject = Objects.getObjectAt(key, delta); if (changeObject) { saveTasks.push(this.updateObject(key, changeObject)); } else { saveTasks.push(this.deleteObject(key)); } }); await Promise.all(saveTasks); } }