diff --git a/jest.config.js b/jest.config.js index c6945a95..d2e405d3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,5 +4,8 @@ module.exports = { transform: { '^.+\\.ts?$': 'ts-jest' }, - preset: 'ts-jest' + preset: 'ts-jest', + coveragePathIgnorePatterns: [ + '^.+\\.mocks\.ts?$' + ] }; diff --git a/package-lock.json b/package-lock.json index c01e3c49..5b792482 100644 --- a/package-lock.json +++ b/package-lock.json @@ -857,6 +857,21 @@ "integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==", "dev": true }, + "@types/promise-retry": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/promise-retry/-/promise-retry-1.1.3.tgz", + "integrity": "sha512-LxIlEpEX6frE3co3vCO2EUJfHIta1IOmhDlcAsR4GMMv9hev1iTI9VwberVGkePJAuLZs5rMucrV8CziCfuJMw==", + "dev": true, + "requires": { + "@types/retry": "*" + } + }, + "@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true + }, "@types/rimraf": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/rimraf/-/rimraf-3.0.0.tgz", @@ -885,6 +900,12 @@ "integrity": "sha512-HKtXvBxU8U8evZCSlUi9HbfT/SFW7nSGCoiBEheB06jAhXeW6JbGh8biEAqIFG5rZo9f8xeJVdIn455sddmIcw==", "dev": true }, + "@types/url-template": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/@types/url-template/-/url-template-2.0.28.tgz", + "integrity": "sha512-1i/YtOhvlWDbMDTWhCfvhyUwBS9vNFs78sJOyahoruJCcDbwaSH73AlnuCp7luKPm6qqdCg4VKq/IHUncl6gZA==", + "dev": true + }, "@types/yargs": { "version": "13.0.2", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.2.tgz", @@ -1197,6 +1218,24 @@ } } }, + "axios-mock-adapter": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-1.19.0.tgz", + "integrity": "sha512-D+0U4LNPr7WroiBDvWilzTMYPYTuZlbo6BI8YHZtj7wYQS8NkARlP9KBt8IWWHTQJ0q/8oZ0ClPBtKCCkx8cQg==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3", + "is-buffer": "^2.0.3" + }, + "dependencies": { + "is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true + } + } + }, "babel-jest": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-24.9.0.tgz", @@ -2831,6 +2870,11 @@ "once": "^1.4.0" } }, + "err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==" + }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -7057,6 +7101,15 @@ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "dev": true }, + "promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "requires": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + } + }, "prompts": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.2.1.tgz", @@ -7389,6 +7442,11 @@ "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", "dev": true }, + "retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=" + }, "right-pad": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/right-pad/-/right-pad-1.0.1.tgz", diff --git a/package.json b/package.json index f4c6b306..56e45def 100644 --- a/package.json +++ b/package.json @@ -88,11 +88,14 @@ "@types/jest": "^24.0.18", "@types/lodash": "^4.14.144", "@types/node-fetch": "^2.5.7", + "@types/promise-retry": "^1.1.3", "@types/rimraf": "^3.0.0", "@types/table": "^4.0.7", + "@types/url-template": "^2.0.28", "@typescript-eslint/eslint-plugin": "^2.3.0", "@typescript-eslint/parser": "^2.3.0", "adm-zip": "^0.4.13", + "axios-mock-adapter": "^1.19.0", "commitizen": "^4.0.3", "cz-conventional-changelog": "^3.0.2", "eslint": "^6.4.0", @@ -115,6 +118,7 @@ "dc-management-sdk-js": "^1.9.0", "lodash": "^4.17.15", "node-fetch": "^2.6.0", + "promise-retry": "^2.0.1", "rimraf": "^3.0.0", "sanitize-filename": "^1.6.3", "table": "^5.4.6", diff --git a/src/commands/content-item/archive.spec.ts b/src/commands/content-item/archive.spec.ts index 378dea01..5d0ba813 100644 --- a/src/commands/content-item/archive.spec.ts +++ b/src/commands/content-item/archive.spec.ts @@ -687,7 +687,7 @@ describe('content-item archive command', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (readline as any).setResponses(['y']); - const logFileName = 'temp/content-item-unrachive.log'; + const logFileName = 'temp/content-item-unarchive.log'; const log = '// Type log test file\n' + 'UNARCHIVE 1\n' + 'UNARCHIVE 2\n' + 'UNARCHIVE idMissing'; const dir = dirname(logFileName); @@ -740,7 +740,7 @@ describe('content-item archive command', () => { await promisify(unlink)('temp/content-item-unarchive.log'); } - const logFileName = 'temp/content-item-unrachive.log'; + const logFileName = 'temp/content-item-unarchive.log'; const log = '// Type log test file\n' + 'UNARCHIVE 1\n' + 'UNARCHIVE 2\n' + 'UNARCHIVE idMissing'; const dir = dirname(logFileName); diff --git a/src/commands/content-item/copy.spec.ts b/src/commands/content-item/copy.spec.ts index b988cefd..40530386 100644 --- a/src/commands/content-item/copy.spec.ts +++ b/src/commands/content-item/copy.spec.ts @@ -147,6 +147,13 @@ describe('content-item copy command', () => { describe: 'Exclude delivery keys when importing content items.' }); + expect(spyOption).toHaveBeenCalledWith('media', { + type: 'boolean', + boolean: true, + describe: + "Detect and rewrite media links to match assets in the target account's DAM. Your client must have DAM permissions configured." + }); + expect(spyOption).toHaveBeenCalledWith('logFile', { type: 'string', default: LOG_FILENAME, @@ -227,7 +234,8 @@ describe('content-item copy command', () => { publish: true, republish: true, - excludeKeys: true + excludeKeys: true, + media: true }; await handler(argv); @@ -256,6 +264,7 @@ describe('content-item copy command', () => { expect(importCalls[0].republish).toEqual(argv.republish); expect(importCalls[0].excludeKeys).toEqual(argv.excludeKeys); + expect(importCalls[0].media).toEqual(argv.media); }); it('should forward to import-revert when revertLog is present.', async () => { diff --git a/src/commands/content-item/copy.ts b/src/commands/content-item/copy.ts index 11900e31..f798432d 100644 --- a/src/commands/content-item/copy.ts +++ b/src/commands/content-item/copy.ts @@ -127,6 +127,13 @@ export const builder = (yargs: Argv): void => { describe: 'Exclude delivery keys when importing content items.' }) + .option('media', { + type: 'boolean', + boolean: true, + describe: + "Detect and rewrite media links to match assets in the target account's DAM. Your client must have DAM permissions configured." + }) + .option('logFile', { type: 'string', default: LOG_FILENAME, @@ -213,12 +220,14 @@ export const handler = async (argv: Arguments { @@ -118,6 +120,13 @@ describe('content-item import command', () => { describe: 'Exclude delivery keys when importing content items.' }); + expect(spyOption).toHaveBeenCalledWith('media', { + type: 'boolean', + boolean: true, + describe: + "Detect and rewrite media links to match assets in the target account's DAM. Your client must have DAM permissions configured." + }); + expect(spyOption).toHaveBeenCalledWith('logFile', { type: 'string', default: LOG_FILENAME, @@ -1304,5 +1313,33 @@ describe('content-item import command', () => { await rimraf('temp/import/abort2/'); }); + + it('should call the media rewriter when --media is passed', async () => { + const templates: ItemTemplate[] = [{ label: 'item1', repoId: 'repo', typeSchemaUri: 'http://type' }]; + + await createContent('temp/import/media1/', templates, false); + + const mockContent = new MockContent(dynamicContentClientFactory as jest.Mock); + mockContent.createMockRepository('targetRepo'); + mockContent.registerContentType('http://type', 'type', 'targetRepo'); + + const argv = { + ...yargArgs, + ...config, + dir: 'temp/import/media1/', + mapFile: 'temp/import/media1.json', + baseRepo: 'targetRepo', + media: true + }; + await handler(argv); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((MediaRewriter as any).rewrites).toEqual(1); + + expect(mockContent.metrics.itemsCreated).toEqual(1); + expect(mockContent.metrics.itemsUpdated).toEqual(0); + + await rimraf('temp/import/media1/'); + }); }); }); diff --git a/src/commands/content-item/import.ts b/src/commands/content-item/import.ts index 093e6112..3a9f5799 100644 --- a/src/commands/content-item/import.ts +++ b/src/commands/content-item/import.ts @@ -31,6 +31,7 @@ import { asyncQuestion } from '../../common/archive/archive-helpers'; import { AmplienceSchemaValidator, defaultSchemaLookup } from '../../common/content-item/amplience-schema-validator'; import { getDefaultLogPath } from '../../common/log-helpers'; import { PublishQueue } from '../../common/import/publish-queue'; +import { MediaRewriter } from '../../common/media/media-rewriter'; export function getDefaultMappingPath(name: string, platform: string = process.platform): string { return join( @@ -114,6 +115,13 @@ export const builder = (yargs: Argv): void => { describe: 'Exclude delivery keys when importing content items.' }) + .option('media', { + type: 'boolean', + boolean: true, + describe: + "Detect and rewrite media links to match assets in the target account's DAM. Your client must have DAM permissions configured." + }) + .option('logFile', { type: 'string', default: LOG_FILENAME, @@ -272,7 +280,7 @@ const prepareContentForImport = async ( folder: Folder | null, mapping: ContentMapping, log: FileLog, - argv: ImportItemBuilderOptions + argv: Arguments ): Promise => { // traverse folder structure and find content items // replicate relative path string in target repo/folder (create if does not exist) @@ -509,6 +517,29 @@ const prepareContentForImport = async ( } } + // Step 2.5: Detect media links and rewrite their IDs, endpoints etc. + if (argv.media) { + log.appendLine(`Detecting and rewriting media links...`); + const rewriter = new MediaRewriter(argv, contentItems); + + let missing: Set; + try { + missing = await rewriter.rewrite(); + } catch (e) { + log.error( + `Failed to rewrite media links. Make sure your client is properly configured, or remove the --media flag.`, + e + ); + return null; + } + + log.appendLine(`Finished rewriting media links.`); + + if (missing.size > 0) { + log.warn(`${missing.size} media items could not be found in the target asset store. Ignoring.`); + } + } + // Step 3: Track dependancies between content items and update them to match the new content ids. // To do this, we must insert content that is depended on before inserting the replacement. // Circular references cannot be resolved, so they should be handled by an insert with invalid id, then subsequent update. diff --git a/src/commands/content-item/move.spec.ts b/src/commands/content-item/move.spec.ts index ac24d319..829f7942 100644 --- a/src/commands/content-item/move.spec.ts +++ b/src/commands/content-item/move.spec.ts @@ -128,6 +128,13 @@ describe('content-item move command', () => { describe: 'Exclude delivery keys when importing content items.' }); + expect(spyOption).toHaveBeenCalledWith('media', { + type: 'boolean', + boolean: true, + describe: + "Detect and rewrite media links to match assets in the target account's DAM. Your client must have DAM permissions configured." + }); + expect(spyOption).toHaveBeenCalledWith('logFile', { type: 'string', default: LOG_FILENAME, @@ -210,7 +217,8 @@ describe('content-item move command', () => { mapFile: 'map.json', force: false, validate: false, - skipIncomplete: false + skipIncomplete: false, + media: true }; await handler(argv); @@ -229,6 +237,7 @@ describe('content-item move command', () => { expect(copyCalls[0].force).toEqual(argv.force); expect(copyCalls[0].validate).toEqual(argv.validate); expect(copyCalls[0].skipIncomplete).toEqual(argv.skipIncomplete); + expect(copyCalls[0].media).toEqual(argv.media); expect(argv.exportedIds).toEqual(exportIds); diff --git a/src/commands/content-item/move.ts b/src/commands/content-item/move.ts index 438c95d4..5e73884e 100644 --- a/src/commands/content-item/move.ts +++ b/src/commands/content-item/move.ts @@ -122,6 +122,13 @@ export const builder = (yargs: Argv): void => { describe: 'Exclude delivery keys when importing content items.' }) + .option('media', { + type: 'boolean', + boolean: true, + describe: + "Detect and rewrite media links to match assets in the target account's DAM. Your client must have DAM permissions configured." + }) + .option('logFile', { type: 'string', default: LOG_FILENAME, diff --git a/src/commands/content-item/unarchive.spec.ts b/src/commands/content-item/unarchive.spec.ts index cf2f75b8..19fd1842 100644 --- a/src/commands/content-item/unarchive.spec.ts +++ b/src/commands/content-item/unarchive.spec.ts @@ -695,7 +695,7 @@ describe('content-item unarchive command', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (readline as any).setResponses(['y']); - const logFileName = 'temp/content-item-unrachive.log'; + const logFileName = 'temp/content-item-unarchive.log'; const log = '// Type log test file\n' + 'ARCHIVE 1\n' + 'ARCHIVE 2\n' + 'ARCHIVE idMissing'; const dir = dirname(logFileName); @@ -748,7 +748,7 @@ describe('content-item unarchive command', () => { await promisify(unlink)('temp/content-item-archive.log'); } - const logFileName = 'temp/content-item-unrachive.log'; + const logFileName = 'temp/content-item-unarchive.log'; const log = '// Type log test file\n' + 'ARCHIVE 1\n' + 'ARCHIVE 2\n' + 'ARCHIVE idMissing'; const dir = dirname(logFileName); diff --git a/src/common/ch-api/ContentHub.mocks.ts b/src/common/ch-api/ContentHub.mocks.ts new file mode 100644 index 00000000..cc9b5a06 --- /dev/null +++ b/src/common/ch-api/ContentHub.mocks.ts @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/camelcase */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { AxiosRequestConfig } from 'axios'; +import { ContentHub, ContentHubConfig } from './ContentHub'; +import { ApiClient } from './api/services/ApiClient'; +import { AxiosHttpClient } from './http/AxiosHttpClient'; +import { HttpClient } from './http/HttpClient'; +import { AccessTokenProvider } from './oauth2/models/AccessTokenProvider'; +import { OAuth2ClientCredentials } from './oauth2/models/OAuth2ClientCredentials'; + +/** + * @hidden + */ +import MockAdapter from 'axios-mock-adapter'; +import { DAMFixtures } from './_fixtures/ContentHubFixtures.mocks'; +import { AccessToken } from 'dc-management-sdk-js'; + +/** + * @hidden + */ +export class MockContentHub extends ContentHub { + public mock: MockAdapter; + public mockClient: ApiClient; + + constructor( + clientCredentials?: OAuth2ClientCredentials, + damConfig?: ContentHubConfig, + httpClient?: AxiosRequestConfig + ) { + super( + clientCredentials || { + client_id: 'client_id', + client_secret: 'client_secret' + }, + damConfig, + httpClient + ); + } + + protected createTokenClient( + damConfig: ContentHubConfig, + clientCredentials: OAuth2ClientCredentials, + httpClient: HttpClient + ): AccessTokenProvider { + return { + getToken: (): Promise => + Promise.resolve({ + access_token: 'token', + expires_in: 60, + refresh_token: 'refresh' + }) + }; + } + + protected createResourceClient( + damConfig: ContentHubConfig, + tokenProvider: AccessTokenProvider, + httpClient: HttpClient + ): ApiClient { + const client = super.createResourceClient(damConfig, tokenProvider, httpClient); + this.mock = new MockAdapter((httpClient as AxiosHttpClient).client); + this.mockClient = client; + DAMFixtures.install(this.mock); + return client; + } +} diff --git a/src/common/ch-api/ContentHub.spec.ts b/src/common/ch-api/ContentHub.spec.ts new file mode 100644 index 00000000..b3ff0ee1 --- /dev/null +++ b/src/common/ch-api/ContentHub.spec.ts @@ -0,0 +1,14 @@ +import { AxiosHttpClient } from 'dc-management-sdk-js'; +import { ContentHub } from './ContentHub'; + +describe('ContentHub tests', () => { + it('should use the http client given to the constructor', async () => { + const httpClient = new AxiosHttpClient({}); + + // eslint-disable-next-line @typescript-eslint/camelcase + const ch = new ContentHub({ client_id: '', client_secret: '' }, undefined, httpClient); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((ch as any)['client']['httpClient']).toEqual(httpClient); + }); +}); diff --git a/src/common/ch-api/ContentHub.ts b/src/common/ch-api/ContentHub.ts new file mode 100644 index 00000000..d56c0d3b --- /dev/null +++ b/src/common/ch-api/ContentHub.ts @@ -0,0 +1,101 @@ +import { AxiosRequestConfig } from 'axios'; +import { ApiClient, DefaultApiClient } from './api/services/ApiClient'; +import { ApiEndpoints } from './api/services/ApiEndpoints'; +import { AxiosHttpClient } from './http/AxiosHttpClient'; +import { HttpClient } from './http/HttpClient'; +import { AccessTokenProvider } from './oauth2/models/AccessTokenProvider'; +import { OAuth2ClientCredentials } from './oauth2/models/OAuth2ClientCredentials'; +import { OAuth2Client } from './oauth2/services/OAuth2Client'; + +/** + * Configuration settings for DAM API client. You can optionally + * override these values with environment specific values. + */ +export interface ContentHubConfig { + /** + * URL used to connect to the Amplience DAM API. + * This property defaults to 'https://dam-api.amplience.net/v1.5.0' if not provided + */ + apiUrl?: string; + + /** + * URL used to connect to the Amplience OAuth API. + * This property defaults to 'https://auth.amplience.net' if not provided + */ + authUrl?: string; +} + +export class ContentHub { + /** + * Asset Resources + */ + public assets: ApiEndpoints['assets']; + + /** + * DAM Settings + */ + public settings: ApiEndpoints['settings']; + + /** + * @hidden + */ + private client: ApiClient; + + /** + * Creates a Dynamic Content API client instance. You must provide credentials that will + * be used to authenticate with the API. + * + * @param clientCredentials Api credentials used to generate an authentication token + * @param damConfig Optional configuration settings for Dynamic Content + * @param clientConfig Optional request settings, can be used to provide proxy settings, add interceptors etc + */ + constructor( + clientCredentials: OAuth2ClientCredentials, + damConfig?: ContentHubConfig, + httpClient?: AxiosRequestConfig | HttpClient + ) { + damConfig = damConfig || {}; + damConfig.apiUrl = damConfig.apiUrl || 'https://dam-api.amplience.net/v1.5.0'; + damConfig.authUrl = damConfig.authUrl || 'https://auth.amplience.net'; + + let httpClientInstance: HttpClient; + if (httpClient !== undefined && 'request' in httpClient) { + httpClientInstance = httpClient as HttpClient; + } else { + httpClientInstance = new AxiosHttpClient(httpClient === undefined ? {} : (httpClient as AxiosRequestConfig)); + } + + const tokenClient = this.createTokenClient(damConfig, clientCredentials, httpClientInstance); + + this.client = this.createResourceClient(damConfig, tokenClient, httpClientInstance); + + this.initEndpoints(); + } + + protected createTokenClient( + damConfig: ContentHubConfig, + clientCredentials: OAuth2ClientCredentials, + httpClient: HttpClient + ): AccessTokenProvider { + return new OAuth2Client( + clientCredentials, + { + authUrl: damConfig.authUrl + }, + httpClient + ); + } + + protected createResourceClient( + damConfig: ContentHubConfig, + tokenProvider: AccessTokenProvider, + httpClient: HttpClient + ): ApiClient { + return new DefaultApiClient(damConfig.apiUrl as string, httpClient, tokenProvider); + } + + private initEndpoints(): void { + this.assets = this.client.endpoints.assets; + this.settings = this.client.endpoints.settings; + } +} diff --git a/src/common/ch-api/_fixtures/ContentHubFixtures.mocks.ts b/src/common/ch-api/_fixtures/ContentHubFixtures.mocks.ts new file mode 100644 index 00000000..e8fceadf --- /dev/null +++ b/src/common/ch-api/_fixtures/ContentHubFixtures.mocks.ts @@ -0,0 +1,142 @@ +import MockAdapter from 'axios-mock-adapter'; +import { readFileSync, readdirSync, statSync } from 'fs'; +import { basename, dirname, join, relative } from 'path'; +import { resolve } from 'url'; + +interface ContentHubFixtureParameters { + selectors?: string[]; + [K: string]: string | string[] | undefined; +} + +function escapeForRegex(url: string): string { + return url.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); +} + +export class DAMFixtures { + static install(adapter: MockAdapter): void { + // Load fixtures. + + const fixturesBaseDir = __dirname; + + this.installSpecific(adapter, fixturesBaseDir + '/assets', { + id: '65d78690-bf4e-415d-a16c-ca4dadbb2717', + version: '1' + }); + this.installSpecific(adapter, fixturesBaseDir + '/assets', { + id: '1f7b5ac6-bb0c-4cd1-b5a1-190ede681b8f', + selectors: ['fail'] + }); + + this.installSpecific(adapter, fixturesBaseDir + '/settings', {}); + } + + private static installSpecific(adapter: MockAdapter, baseDir: string, params: ContentHubFixtureParameters): void { + // Scan the given folder for fixtures. + + // For each file, create register a response. Filenames follow a specific format: + // uri/path/from/base/-{}-.json + // Any {paramater}s will be replaced with those provided, and be treated as the final directory name. + // Parameters can also be on folder names, too. /assets/{id}/GET.json and /assets/GET-{id}.json are both valid. + // Responses with selectors will only be registered if the specified selector has been provided. + + const contents = readdirSync(baseDir); + + contents.forEach(name => { + const fullPath = join(baseDir, name); + + if (statSync(fullPath).isDirectory()) { + // Continue traversal + this.installSpecific(adapter, fullPath, params); + } else if (fullPath.endsWith(`.json`)) { + // Add this file as a response. + this.installFile(adapter, fullPath, params); + } + }); + } + + private static installFile(adapter: MockAdapter, path: string, params: ContentHubFixtureParameters): void { + const baseUri = 'https://dam-api.amplience.net/v1.5.0/'; + + const baseName = basename(path, '.json'); + let dirName = relative(__dirname, dirname(path)); + + const nameSplit = baseName.split('-'); + + if (nameSplit.length === 0) return; + + const method = nameSplit[0]; + let pathExtension = ''; + let selectorCount = 0; + + for (let i = 1; i < nameSplit.length; i++) { + const arg = nameSplit[i]; + + if (arg.startsWith('{') && arg.endsWith('}')) { + // Insert the given ID + const paramName = arg.substr(1, arg.length - 2); + + if (params[paramName] != null) { + pathExtension = params[paramName] as string; + } else { + return; // Skip this file. + } + } else { + // Is this selector present in the params? + selectorCount++; + if (params.selectors == null || params.selectors.indexOf(arg) === -1) { + return; // Skip this file. + } + } + } + + if (selectorCount === 0 && params.selectors != null && params.selectors.length > 0) { + return; // If selectors are present, only add responses that also have selectors. + } + + for (let i = 0; i < dirName.length; i++) { + if (dirName[i] === '{') { + for (let j = i + 1; j < dirName.length; j++) { + if (dirName[j] === '}') { + const paramName = dirName.substring(i + 1, j); + + if (params[paramName] != null) { + dirName = dirName.substring(0, i) + params[paramName] + dirName.substring(j + 1); + break; + } else { + return; // Skip this file. + } + } + } + } + } + + const response = JSON.parse(readFileSync(path, { encoding: 'utf8' })); + let fullUri = resolve(baseUri, dirName); + if (pathExtension !== '') { + fullUri = resolve(fullUri + '/', pathExtension); + } + + const responseCode = method === 'POST' ? 204 : 200; + + // Allow any url with query string + const regex = new RegExp('^' + escapeForRegex(fullUri) + '(\\?.*$)?$'); + + switch (method) { + case 'GET': + adapter.onGet(regex).reply(responseCode, response); + break; + case 'POST': + adapter.onPost(regex).reply(responseCode, response); + break; + case 'PATCH': + adapter.onPatch(regex).reply(responseCode, response); + break; + case 'PUT': + adapter.onPut(regex).reply(responseCode, response); + break; + case 'DELETE': + adapter.onDelete(regex).reply(responseCode, response); + break; + } + } +} diff --git a/src/common/ch-api/_fixtures/assets/GET-{id}-select.json b/src/common/ch-api/_fixtures/assets/GET-{id}-select.json new file mode 100644 index 00000000..84b5f87b --- /dev/null +++ b/src/common/ch-api/_fixtures/assets/GET-{id}-select.json @@ -0,0 +1,130 @@ +{ + "content": { + "data": [ + { + "srcName": "AlltheLook1.jpg", + "revisionNumber": 5, + "mimeType": "image/jpeg", + "type": "image", + "locale": "en-GB", + "userID": "57b98b7f-c6bf-43d2-9f24-a408d1c6d59d", + "thumbFile": "74bef2ea-010b-4853-a5ee-f0b4daa59f0d", + "folderID": "00000000-0000-0000-0000-000000000000", + "relationships": { + "containsImage": [ + { + "schema": "image", + "schemaID": "4e357e14-5141-4153-979e-125e4bdb9c97", + "variants": [ + { + "values": { + "colorSpace": "rgb", + "resolutionY": 0, + "valid": true, + "resolutionX": 0, + "depth": 8, + "alpha": false, + "format": "JPEG", + "width": 1289, + "id": "65d78690-bf4e-415d-a16c-ca4dadbb2717", + "height": 1400, + "resolutionUnits": 0 + }, + "variantID": "3e8a4d62-8058-46d9-a75d-bb735537e85c", + "variantName": "*" + } + ], + "PK": { + "id": "65d78690-bf4e-415d-a16c-ca4dadbb2717" + } + } + ], + "containsEXIF": [ + { + "schema": "exif", + "schemaID": "cf9303dc-7b01-46e3-ba34-072390a4684d", + "variants": [ + { + "values": { + "software": "Adobe Photoshop CC 2014 (Macintosh)", + "id": "65d78690-bf4e-415d-a16c-ca4dadbb2717" + }, + "variantID": "3e8a4d62-8058-46d9-a75d-bb735537e85c", + "variantName": "*" + } + ], + "PK": { + "id": "65d78690-bf4e-415d-a16c-ca4dadbb2717" + } + } + ], + "fromFile": [ + { + "schema": "file", + "schemaID": "590ec5d8-d2f1-4df3-b52c-32df6e166d0f", + "variants": [ + { + "values": { + "size": 1109425, + "id": "65d78690-bf4e-415d-a16c-ca4dadbb2717", + "type": "JPEG" + }, + "variantID": "3e8a4d62-8058-46d9-a75d-bb735537e85c", + "variantName": "*" + } + ], + "PK": { + "id": "65d78690-bf4e-415d-a16c-ca4dadbb2717" + } + } + ] + }, + "file": "74bef2ea-010b-4853-a5ee-f0b4daa59f0d", + "unpublish": { + "last": { + "jobID": "unpublish.8f0034fd-e0e7-4a55-a81b-a87054827a8f", + "revisionNumber": 18, + "timestamp": 1610382560948, + "status": "COMPLETED" + }, + "lastSuccess": { + "jobID": "unpublish.8f0034fd-e0e7-4a55-a81b-a87054827a8f", + "revisionNumber": 18, + "timestamp": 1610382560948, + "status": "COMPLETED" + } + }, + "id": "65d78690-bf4e-415d-a16c-ca4dadbb2717", + "thumbURL": "https://thumbs.amplience.net/r/74bef2ea-010b-4853-a5ee-f0b4daa59f0d", + "publishStatus": "PUBLISHED", + "timestamp": 1610384041283, + "workflow": {}, + "bucketID": "b0d42ebb-679b-4010-ae20-9ce989837481", + "label": "AlltheLook1.jpg", + "tags": [], + "createdDate": 1606739632488, + "size": 1109425, + "publish": { + "last": { + "jobID": "p.c78c904d-72c4-497b-be11-1d0a99c52627", + "revisionNumber": 21, + "timestamp": 1610384041207, + "status": "COMPLETED" + }, + "lastSuccess": { + "jobID": "p.c78c904d-72c4-497b-be11-1d0a99c52627", + "revisionNumber": 21, + "timestamp": 1610384041207, + "status": "COMPLETED" + } + }, + "name": "AlltheLook1", + "localeGroupAssets": [], + "subType": null, + "status": "active" + } + ], + "count": 1 + }, + "status": "success" +} \ No newline at end of file diff --git a/src/common/ch-api/_fixtures/assets/GET-{id}.json b/src/common/ch-api/_fixtures/assets/GET-{id}.json new file mode 100644 index 00000000..faf9f6d2 --- /dev/null +++ b/src/common/ch-api/_fixtures/assets/GET-{id}.json @@ -0,0 +1,29 @@ +{ + "content": { + "data": [ + { + "srcName": "AlltheLook1.jpg", + "revisionNumber": 5, + "bucketID": "b0d42ebb-679b-4010-ae20-9ce989837481", + "label": "AlltheLook1.jpg", + "mimeType": "image/jpeg", + "type": "image", + "locale": "en-GB", + "userID": "57b98b7f-c6bf-43d2-9f24-a408d1c6d59d", + "thumbFile": "74bef2ea-010b-4853-a5ee-f0b4daa59f0d", + "folderID": "00000000-0000-0000-0000-000000000000", + "file": "74bef2ea-010b-4853-a5ee-f0b4daa59f0d", + "createdDate": 1606739632488, + "name": "AlltheLook1", + "subType": null, + "id": "65d78690-bf4e-415d-a16c-ca4dadbb2717", + "thumbURL": "https://thumbs.amplience.net/r/74bef2ea-010b-4853-a5ee-f0b4daa59f0d", + "publishStatus": "PUBLISHED", + "status": "active", + "timestamp": 1610384041283 + } + ], + "count": 1 + }, + "status": "success" +} \ No newline at end of file diff --git a/src/common/ch-api/_fixtures/assets/GET.json b/src/common/ch-api/_fixtures/assets/GET.json new file mode 100644 index 00000000..946a7865 --- /dev/null +++ b/src/common/ch-api/_fixtures/assets/GET.json @@ -0,0 +1,52 @@ +{ + "content": { + "data": [ + { + "srcName": "AlltheLook1.jpg", + "revisionNumber": 5, + "bucketID": "b0d42ebb-679b-4010-ae20-9ce989837481", + "label": "AlltheLook1.jpg", + "mimeType": "image/jpeg", + "type": "image", + "locale": "en-GB", + "thumbFile": "74bef2ea-010b-4853-a5ee-f0b4daa59f0d", + "folderID": "00000000-0000-0000-0000-000000000000", + "file": "74bef2ea-010b-4853-a5ee-f0b4daa59f0d", + "createdDate": 1606739632488, + "name": "AlltheLook1", + "subType": null, + "id": "65d78690-bf4e-415d-a16c-ca4dadbb2717", + "thumbURL": "https://thumbs.amplience.net/r/74bef2ea-010b-4853-a5ee-f0b4daa59f0d", + "publishStatus": "PUBLISHED", + "status": "active", + "workflow": {}, + "timestamp": 1610120563111 + }, + { + "srcName": "1.png", + "revisionNumber": 3, + "bucketID": "b0d42ebb-679b-4010-ae20-9ce989837481", + "label": "1.png", + "mimeType": "image/png", + "type": "image", + "locale": "en-GB", + "thumbFile": "2af0bde1-473c-455a-afb5-17f90da7c2f9", + "folderID": "00000000-0000-0000-0000-000000000000", + "file": "2af0bde1-473c-455a-afb5-17f90da7c2f9", + "createdDate": 1414750113914, + "name": "1", + "subType": null, + "id": "4a0bcaed-ee57-481f-98d3-4b73aad49e68", + "thumbURL": "https://thumbs.amplience.net/r/2af0bde1-473c-455a-afb5-17f90da7c2f9", + "publishStatus": "NOT_PUBLISHED", + "status": "active", + "timestamp": 1607622717672 + } + ], + "numFound": 2, + "start": 0, + "count": 2, + "pageSize": 20 + }, + "status": "success" +} \ No newline at end of file diff --git a/src/common/ch-api/_fixtures/assets/badid/GET.json b/src/common/ch-api/_fixtures/assets/badid/GET.json new file mode 100644 index 00000000..62a6f24e --- /dev/null +++ b/src/common/ch-api/_fixtures/assets/badid/GET.json @@ -0,0 +1,4 @@ +{ + "error": "Bad Request: Invalid Asset ID.", + "status": "failed" +} diff --git a/src/common/ch-api/_fixtures/settings/GET.json b/src/common/ch-api/_fixtures/settings/GET.json new file mode 100644 index 00000000..8d1b72ab --- /dev/null +++ b/src/common/ch-api/_fixtures/settings/GET.json @@ -0,0 +1,53 @@ +{ + "content": { + "companyId": 1, + "companyClassificationType": "Demo", + "system": { + "auth-api-url": "https://auth.amplience.net/", + "publishing-service": "https://publishing-service.amplience.net/", + "im-api-url": "https://im-api.adis.ws/", + "virtual-staging-service": "https://virtual-staging.adis.ws/", + "dam-mixpanel-token": "", + "analytics-beacon-url": "https://a1.adis.ws/", + "analytics-query-api-url": "https://analytics.adis.ws/", + "dam-version": "jenkins-release-1.50.0/3", + "stream-api-url": "https://download-api.amplience.net/", + "ca-mixpanel-token": "", + "access-service": "https://access.adis.ws/", + "cms-api-url": "https://content-authoring-api.amplience.net/", + "provisioning-api": "https://provisioning-api.amplience.net/", + "identity-service": "https://identity.adis.ws/", + "media-graphics-host": "x1.adis.ws/v1/media/graphics" + }, + "di": { + "endpoints": [ + { + "path": "example", + "staticHost": "s1.adis.ws", + "textProtocols": [ + "http", + "https" + ], + "schemaIds": [ + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeffffff" + ], + "dynamicHost": "i1.adis.ws", + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeffffff", + "staticProtocols": [ + "http", + "https" + ], + "serviceId": "", + "textHost": "c1.adis.ws", + "dynamicProtocols": [ + "http", + "https" + ] + } + ], + "defaultEndpoint": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeffffff", + "enabled": true + } + }, + "status": "success" +} \ No newline at end of file diff --git a/src/common/ch-api/api/model/ApiResource.ts b/src/common/ch-api/api/model/ApiResource.ts new file mode 100644 index 00000000..fed84e1e --- /dev/null +++ b/src/common/ch-api/api/model/ApiResource.ts @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ApiClient } from '../services/ApiClient'; + +/** + * @hidden + */ +export type ApiResourceConstructor = new (data?: any) => T; + +/** + * Base class for all Resources + */ +export class ApiResource { + /** + * @hidden + */ + // tslint:disable-next-line + protected _embedded: Map; + + /** + * @hidden + */ + protected client: ApiClient; + + /** + * Creates a new instance of the resource. + * If data is provided it will be parsed as if it had + * come from the remote api. + * @param data + */ + constructor(data?: any) { + if (data) { + this.parse(data); + } + } + + /** + * Parses the data returned by the API into the model class + * @hidden + */ + public parse(data: any): void { + Object.assign(this, data); + } + + /** + * Returns a copy of this resource's attributes excluding links and client references + */ + public toJSON(): any { + const result: any = Object.assign({}, this); + delete result.client; + delete result._links; + delete result.related; + return result; + } + + /** + * Set automatically by the HalClient when the resource is created. + * If this is not set the resource will be unable to resolve related + * resources and actions. + * + * @hidden + */ + public setClient(client: ApiClient): void { + this.client = client; + } +} diff --git a/src/common/ch-api/api/services/ApiClient.ts b/src/common/ch-api/api/services/ApiClient.ts new file mode 100644 index 00000000..dd07f420 --- /dev/null +++ b/src/common/ch-api/api/services/ApiClient.ts @@ -0,0 +1,112 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { HttpClient } from '../../http/HttpClient'; +import { HttpError } from '../../http/HttpError'; +import { HttpMethod, HttpRequest } from '../../http/HttpRequest'; +import { HttpResponse } from '../../http/HttpResponse'; +import { AccessTokenProvider } from '../../oauth2/models/AccessTokenProvider'; +import { combineURLs } from '../../utils/URL'; +import { ApiResource, ApiResourceConstructor } from '../model/ApiResource'; +import { ApiEndpoints } from './ApiEndpoints'; +import { CURIEs } from './CURIEs'; + +/** + * @hidden + */ +export interface ApiClient { + endpoints: ApiEndpoints; + + fetchRawResource(path: string, params: ApiParameters): Promise; + + fetchResource( + path: string, + params: ApiParameters, + resourceConstructor: ApiResourceConstructor + ): Promise; + + parse(data: any, resourceConstructor: ApiResourceConstructor): T; +} + +/** + * Query and header parameters used in a resource request. + */ +export interface ApiParameters { + header?: any; + query?: any; +} + +/** + * @hidden + */ +export class DefaultApiClient implements ApiClient { + endpoints: ApiEndpoints; + + constructor(private baseUrl: string, private httpClient: HttpClient, private tokenProvider: AccessTokenProvider) { + this.endpoints = new ApiEndpoints(this); + } + + public async fetchRawResource(path: string, params: ApiParameters): Promise { + path = CURIEs.expand(path, params.query); + path = path.replace(/%20/g, '+'); // Convert space to + + + const response = await this.invoke({ + method: HttpMethod.GET, + url: path + }); + return (response.data as any) as T; + } + + public async fetchResource( + path: string, + params: ApiParameters, + resourceConstructor: ApiResourceConstructor + ): Promise { + const data = await this.fetchRawResource(path, params); + return this.parse(data, resourceConstructor); + } + + public parse(data: any, resourceConstructor: ApiResourceConstructor): T { + const instance: T = new resourceConstructor(data); + instance.setClient(this); + return instance; + } + + protected transformDamResponse(data: any): any { + // Parse DAM response. All responses are in a common format. + + if (data.status !== 'success') { + throw new Error(`Request failed with status ${data.status}: ${JSON.stringify(data.content)}`); + } + + return data.content; + } + + public async invoke(request: HttpRequest): Promise { + const token = await this.tokenProvider.getToken(); + + const fullRequest: HttpRequest = { + data: request.data, + headers: { + 'X-Amp-Auth': token.access_token, + ...request.headers + }, + method: request.method, + url: combineURLs(this.baseUrl, request.url) + }; + + return this.httpClient.request(fullRequest).then(response => { + if (response.status >= 200 && response.status < 300) { + if (typeof response.data === 'string') { + response.data = JSON.parse(response.data); + } + response.data = this.transformDamResponse(response.data); + return response; + } else { + throw new HttpError( + `Request failed with status code ${response.status}: ${JSON.stringify(response.data)}`, + fullRequest, + response + ); + } + }); + } +} diff --git a/src/common/ch-api/api/services/ApiEndpoints.ts b/src/common/ch-api/api/services/ApiEndpoints.ts new file mode 100644 index 00000000..b398348e --- /dev/null +++ b/src/common/ch-api/api/services/ApiEndpoints.ts @@ -0,0 +1,54 @@ +import { Asset, AssetsList, AssetsPage } from '../../model/Asset'; +import { AssetListRequest } from '../../model/AssetListRequest'; +import { Settings } from '../../model/Settings'; +import { ApiClient } from './ApiClient'; + +export class ApiEndpoints { + constructor(private client: ApiClient) {} + + private assetsListToSingle(list: AssetsList, id: string): Asset { + const items = list.getItems(); + + if (items.length === 0) { + throw new Error(`Unable to find asset with id ${id}.`); + } + + return items[0]; + } + + /** + * Asset Resources + */ + public readonly assets = { + /** + * Retrieve an asset resource by id + * @param id asset id, previously generated on creation + */ + get: async (id: string): Promise => { + return this.assetsListToSingle(await this.client.fetchResource(`/assets/${id}`, {}, AssetsList), id); + }, + + /** + * Retrieve a list of asset resources shared with your client credentials. + * @param options Pagination options + */ + list: (options?: AssetListRequest): Promise => { + return this.client.fetchResource( + '/assets{?q,filter,c,n,s,f,bucket,select,variants,preferredLocales,snippetSize,hl.fl,hl.pre,hl.post,hl.max,localeGroups.collapse,localeGroups.preferredLocales,localeGroups.limit,sort}', + // + { query: options }, + AssetsPage + ); + } + }; + + /** + * DAM Settings + */ + public readonly settings = { + /** + * Retrieve settings for the DAM account. + */ + get: (): Promise => this.client.fetchResource(`/settings`, {}, Settings) + }; +} diff --git a/src/common/ch-api/api/services/CURIEs.spec.ts b/src/common/ch-api/api/services/CURIEs.spec.ts new file mode 100644 index 00000000..d0416c05 --- /dev/null +++ b/src/common/ch-api/api/services/CURIEs.spec.ts @@ -0,0 +1,44 @@ +// tslint:disable:no-expression-statement +import { CURIEs } from './CURIEs'; + +describe('AxiosHttpClient tests', () => { + test('should ignore query parameters that are not provided', () => { + const href = CURIEs.expand('/resource{?page}', {}); + expect(href).toEqual('/resource'); + }); + + test('should replace provided query parameters', () => { + const href = CURIEs.expand('/resource{?page}', { page: 1 }); + expect(href).toEqual('/resource?page=1'); + }); + + test('should replace multiple provided query parameters', () => { + const href = CURIEs.expand('/resource{?page,size}', { page: 1, size: 10 }); + expect(href).toEqual('/resource?page=1&size=10'); + }); + + test('should only include provided query parameters', () => { + const href = CURIEs.expand('/resource{?page,size}', { page: 1 }); + expect(href).toEqual('/resource?page=1'); + }); + + test('should encode query string parameters', () => { + const href = CURIEs.expand('/resource{?page,size}', { page: '=' }); + expect(href).toEqual('/resource?page=%3D'); + }); + + test('should replace path parameters', () => { + const href = CURIEs.expand('/resource/{id}', { id: 1 }); + expect(href).toEqual('/resource/1'); + }); + + test('should replace with empty value if required path parameters missing', () => { + const href = CURIEs.expand('/resource/{id}', {}); + expect(href).toEqual('/resource/'); + }); + + test('should default parameters', () => { + const href = CURIEs.expand('/resource'); + expect(href).toEqual('/resource'); + }); +}); diff --git a/src/common/ch-api/api/services/CURIEs.ts b/src/common/ch-api/api/services/CURIEs.ts new file mode 100644 index 00000000..d620e273 --- /dev/null +++ b/src/common/ch-api/api/services/CURIEs.ts @@ -0,0 +1,15 @@ +/** + * @hidden + */ +import template from 'url-template'; + +/** + * @hidden + */ +export class CURIEs { + public static expand(href: string, variables?: unknown): string { + variables = variables || {}; + const compiledTemplate = template.parse(href); + return compiledTemplate.expand(variables); + } +} diff --git a/src/common/ch-api/http/AxiosHttpClient.spec.ts b/src/common/ch-api/http/AxiosHttpClient.spec.ts new file mode 100644 index 00000000..cf862021 --- /dev/null +++ b/src/common/ch-api/http/AxiosHttpClient.spec.ts @@ -0,0 +1,107 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { AxiosHttpClient } from './AxiosHttpClient'; +import { HttpMethod } from './HttpRequest'; + +// axios-mock-adaptor's typedefs are wrong preventing calling onGet with 3 args, this is a workaround +/** + * @hidden + */ +import MockAdapter from 'axios-mock-adapter'; + +describe('AxiosHttpClient tests', () => { + test('client should use provided base url', async () => { + const client = new AxiosHttpClient({ + baseURL: 'http://mywebsite.com' + }); + + const mock = new MockAdapter(client.client); + mock.onGet('http://mywebsite.com/ping').reply(200, 'pong'); + + const response = await client.request({ + method: HttpMethod.GET, + url: 'http://mywebsite.com/ping' + }); + + expect(response.data).toEqual('pong'); + }); + + test('client should return status code', async () => { + const client = new AxiosHttpClient({}); + + const mock = new MockAdapter(client.client); + mock.onGet('/ping').reply(404); + + const response = await client.request({ + method: HttpMethod.GET, + url: '/ping' + }); + + expect(response.status).toEqual(404); + }); + + test('client should use provided method', async () => { + const client = new AxiosHttpClient({}); + + const mock = new MockAdapter(client.client); + mock.onDelete('/resource').reply(200); + + const response = await client.request({ + method: HttpMethod.DELETE, + url: '/resource' + }); + + expect(response.status).toEqual(200); + }); + + test('client should send form data', async () => { + const client = new AxiosHttpClient({}); + + const mock = new MockAdapter(client.client); + mock + .onPost('/oauth/token', 'grant_type=client_credentials&client_id=client_id&client_secret=client_secret') + .reply(200, { + access_token: 'token', + expires_in: 0, + refresh_token: 'refresh' + }); + + const response = await client.request({ + data: 'grant_type=client_credentials&client_id=client_id&client_secret=client_secret', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + method: HttpMethod.POST, + url: '/oauth/token' + }); + + expect(response.status).toEqual(200); + }); + + test('client should send JSON data', async () => { + const client = new AxiosHttpClient({}); + + const mock = new MockAdapter(client.client); + mock + .onPost('/resource/create', { + key: 'value' + }) + .reply(200, { + access_token: 'token', + expires_in: 0, + refresh_token: 'refresh' + }); + + const response = await client.request({ + data: { + key: 'value' + }, + headers: { + 'Content-Type': 'application/json' + }, + method: HttpMethod.POST, + url: '/resource/create' + }); + + expect(response.status).toEqual(200); + }); +}); diff --git a/src/common/ch-api/http/AxiosHttpClient.ts b/src/common/ch-api/http/AxiosHttpClient.ts new file mode 100644 index 00000000..303a616f --- /dev/null +++ b/src/common/ch-api/http/AxiosHttpClient.ts @@ -0,0 +1,41 @@ +import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; + +import { HttpClient } from './HttpClient'; +import { HttpRequest } from './HttpRequest'; +import { HttpResponse } from './HttpResponse'; + +/** + * @hidden + */ +export class AxiosHttpClient implements HttpClient { + public client: AxiosInstance; + + constructor(private config: AxiosRequestConfig) { + this.client = axios.create(config); + } + + public request(config: HttpRequest): Promise { + return this.client + .request({ + data: config.data, + headers: config.headers, + method: config.method, + url: config.url + }) + .then(response => { + return { + data: response.data, + status: response.status + }; + }) + .catch(error => { + if (error && error.response) { + return { + data: error.response.data, + status: error.response.status + }; + } + return error; + }); + } +} diff --git a/src/common/ch-api/http/HttpClient.ts b/src/common/ch-api/http/HttpClient.ts new file mode 100644 index 00000000..50174cf5 --- /dev/null +++ b/src/common/ch-api/http/HttpClient.ts @@ -0,0 +1,9 @@ +import { HttpRequest } from './HttpRequest'; +import { HttpResponse } from './HttpResponse'; + +/** + * @hidden + */ +export interface HttpClient { + request(config: HttpRequest): Promise; +} diff --git a/src/common/ch-api/http/HttpError.ts b/src/common/ch-api/http/HttpError.ts new file mode 100644 index 00000000..685c0565 --- /dev/null +++ b/src/common/ch-api/http/HttpError.ts @@ -0,0 +1,9 @@ +import { HttpRequest } from './HttpRequest'; +import { HttpResponse } from './HttpResponse'; + +export class HttpError extends Error { + constructor(message: string, public readonly request?: HttpRequest, public readonly response?: HttpResponse) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/src/common/ch-api/http/HttpRequest.ts b/src/common/ch-api/http/HttpRequest.ts new file mode 100644 index 00000000..02970f00 --- /dev/null +++ b/src/common/ch-api/http/HttpRequest.ts @@ -0,0 +1,20 @@ +/** + * @hidden + */ +export enum HttpMethod { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + PATCH = 'PATCH', + DELETE = 'DELETE' +} + +/** + * @hidden + */ +export interface HttpRequest { + url: string; + method: HttpMethod; + data?: string | object; + headers?: { [key: string]: string }; +} diff --git a/src/common/ch-api/http/HttpResponse.ts b/src/common/ch-api/http/HttpResponse.ts new file mode 100644 index 00000000..adc1260e --- /dev/null +++ b/src/common/ch-api/http/HttpResponse.ts @@ -0,0 +1,7 @@ +/** + * @hidden + */ +export interface HttpResponse { + status: number; + data: string | object; +} diff --git a/src/common/ch-api/model/ActionPriority.ts b/src/common/ch-api/model/ActionPriority.ts new file mode 100644 index 00000000..0ebe2607 --- /dev/null +++ b/src/common/ch-api/model/ActionPriority.ts @@ -0,0 +1,6 @@ +/** + * Parameter for actions that have a priority level. + */ +export interface ActionPriority { + 'X-Amp-Mode'?: 'UI'; +} diff --git a/src/common/ch-api/model/Asset.spec.ts b/src/common/ch-api/model/Asset.spec.ts new file mode 100644 index 00000000..c9212ec0 --- /dev/null +++ b/src/common/ch-api/model/Asset.spec.ts @@ -0,0 +1,34 @@ +import { MockContentHub } from '../ContentHub.mocks'; + +describe('AxiosHttpClient tests', () => { + test('get asset by id', async () => { + const client = new MockContentHub(); + const result = await client.assets.get('65d78690-bf4e-415d-a16c-ca4dadbb2717'); + + expect(result.label).toEqual('AlltheLook1.jpg'); + }); + + test('list assets', async () => { + const client = new MockContentHub(); + + const list = await client.assets.list({ q: 'example search query' }); + const items = list.getItems(); + + expect(items.map(item => item.label)).toEqual(['AlltheLook1.jpg', '1.png']); + + items.forEach(version => { + expect(version.related).not.toBeUndefined(); + }); + }); + + test('get asset by id (failures)', async () => { + const client = new MockContentHub(); + const fail404 = client.assets.get('fail404'); + + await expect(fail404).rejects.toThrow(); + + const badId = client.assets.get('badId'); + + await expect(badId).rejects.toThrow(); + }); +}); diff --git a/src/common/ch-api/model/Asset.ts b/src/common/ch-api/model/Asset.ts new file mode 100644 index 00000000..6cde90e0 --- /dev/null +++ b/src/common/ch-api/model/Asset.ts @@ -0,0 +1,168 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ApiResource } from '../api/model/ApiResource'; +import { PublishActivitySummary } from './PublishActivitySummary'; +import { Page } from './Page'; +import { ResourceList } from './ResourceList'; +import { AssetRelationships } from './AssetMetadata'; +import { WorkflowSummary } from './WorkflowSummary'; + +export class Asset extends ApiResource { + /** + * UUID identifier for this asset. + */ + id: string; + + /** + * The original filename of the source file for this asset. + */ + srcName?: string; + + /** + * The latest revision number for this asset. Increments with each update. + */ + revisionNumber?: number; + + /** + * UUID of the bucket this asset is contained within. + */ + bucketID: string; + + /** + * Friendly label for the asset. + */ + label?: string; + + /** + * MIME type for this asset. + */ + mimeType: string; + + /** + * High level type for this asset, e.g. 'image'. + */ + type?: string; + + /** + * Locale name for this asset. + */ + locale: string; + + /** + * User UUID of the creator of this asset. + */ + userID?: string; + + /** + * Thumbnail file for this asset, if applicable. + */ + thumbFile?: string; + + /** + * The id of the containing folder for this asset. + * Value is an empty GUID if the asset is not contained in a folder. + */ + folderID?: string; + + /** + * UUID of the file backing this asset. + */ + file?: string; + + /** + * Created timestamp for this asset. + */ + createdDate: number; + + /** + * Filesize of the asset in bytes. Only available when 'file' is selected. + */ + size: number; + + /** + * Unique name for the asset. This is unique across the account so it can be flattenned for the public URL. + */ + name?: string; + + /** + * Sub type for this asset. + */ + subType?: any; + + /** + * Thumbnail URL for this asset, if applicable. + */ + thumbUrl?: string; + + /** + * Asset publish information. Only available when 'publish' is selected. + */ + publish?: PublishActivitySummary; + + /** + * Asset unpublish information. Only available when 'publish' is selected. + */ + unpublish?: PublishActivitySummary; + + /** + * Workflow summary for this asset. Only available when 'workflow' is selected. + */ + workflow?: WorkflowSummary; + + /** + * Tags for this asset. Only available when 'tags' is selected. + */ + tags?: string[]; + + /** + * Metadata for this asset. Only available when 'meta:' is selected. + */ + relationships?: AssetRelationships; + + /** + * The current publish status of this asset. + */ + publishStatus?: 'NOT_PUBLISHED' | 'PUBLISH_IN_PROGRESS' | 'UNPUBLISH_IN_PROGRESS' | 'PUBLISHED'; + + /** + * Last modified timestamp for this asset. + */ + timestamp?: string; + + /** + * The status of this asset. + */ + status?: 'deleted' | 'active'; + + /** + * UUID of the locale of the asset + */ + localeID?: string; + + /** + * Locale group that the asset belongs to. + */ + localeGroup?: string; + + /** + * Resources and actions related to a Content Item + */ + public readonly related = {}; +} + +/** + * @hidden + */ +export class AssetsList extends ResourceList { + constructor(data?: any) { + super(Asset, data); + } +} + +/** + * @hidden + */ +export class AssetsPage extends Page { + constructor(data?: any) { + super(Asset, data); + } +} diff --git a/src/common/ch-api/model/AssetListRequest.ts b/src/common/ch-api/model/AssetListRequest.ts new file mode 100644 index 00000000..2c57d6a2 --- /dev/null +++ b/src/common/ch-api/model/AssetListRequest.ts @@ -0,0 +1,92 @@ +import { AssetRequest } from './AssetRequest'; +import { Pageable } from './Pageable'; + +/** + * Parameters for asset list requests + */ +export interface AssetListRequest extends Pageable, AssetRequest { + /** + * Search terms. The search fields are Label, Name, and srcName. + */ + q?: string; + + /** + * A query to use to restrict the set of assets that will be searched over and returned + */ + filter?: string; + + /** + * Comma-separated list of fields to return for each asset. + */ + c?: string; + + /** + * The id of a folder to search in. + */ + f?: string; + + /** + * The id of a bucket to search in. + */ + bucket?: string; + + /** + * Comma seperated list locales in order of preference from left to right. + */ + preferredLocales?: string; + + /** + * The maximum length of the text snippet. (default is 200 characters) + */ + snippetSize?: string; + + /** + * Highlight parameters. + */ + highlight?: { + /** + * Field to highlight on (All Fields available as default (*)) + */ + fl?: string; + + /** + * Pre tag to wrap highlighted text (default is ) + */ + pre?: string; + + /** + * Post tag to wrap highlighted text (default is ) + */ + post?: string; + + /** + * The maximum number of highlighted snippets to generate per field. (default is 1) + */ + max?: number; + }; + + /** + * Locale group parameters. + */ + localeGroups?: { + /** + * Whether or not to retreat assets in a locale group as a single asset or as individual assets. (default is false) + */ + collapse?: boolean; + + /** + * Order of preference for the locale of the asset which will be returned for each group of assets + */ + preferredLocales?: string; + + /** + * The maximum number of siblings to return for a single asset. (default is 20) + */ + limit?: number; + }; + + /** + * field(s) on which the sorting of the results is based off ( default is on the update field) + */ + sort?: string; +} diff --git a/src/common/ch-api/model/AssetMetadata.ts b/src/common/ch-api/model/AssetMetadata.ts new file mode 100644 index 00000000..d446b622 --- /dev/null +++ b/src/common/ch-api/model/AssetMetadata.ts @@ -0,0 +1,57 @@ +import { ApiResource } from '../api/model/ApiResource'; + +export interface AssetMetadataVariant { + /** + * The name of this metadata variant. + */ + variantName: string; + + /** + * The unique identifier of this metadata variant. + */ + variantID: string; + + /** + * Values contained in this metadata variant. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + values: { [K: string]: any }; +} + +export interface AssetMetadataItem { + /** + * Schema name for this type of metadata. + */ + schema: string; + + /** + * Schema ID for this type of metadata. + */ + schemaID: string; + + /** + * Variants of this metadata that are present on the parent asset. + */ + variants: AssetMetadataVariant[]; + + /** + * Primary key identifying the parent asset. + */ + PK: { id: string }; +} + +export class AssetMetadata extends ApiResource { + /** + * The metadata attached to the asset. + */ + metadata: AssetMetadataItem[]; + + /** + * The ID of the related asset. + */ + assetId: string; +} + +export interface AssetRelationships { + [key: string]: AssetMetadataItem; +} diff --git a/src/common/ch-api/model/AssetRequest.ts b/src/common/ch-api/model/AssetRequest.ts new file mode 100644 index 00000000..a9fd353d --- /dev/null +++ b/src/common/ch-api/model/AssetRequest.ts @@ -0,0 +1,14 @@ +/** + * Parameters for asset requests + */ +export interface AssetRequest { + /** + * Comma separated list to select subsections to include, e.g. tags,meta:{schema},meta:* + */ + select?: string; + + /** + * Comma seperated list of metadata variants to include in the result. + */ + variants?: string; +} diff --git a/src/common/ch-api/model/Page.ts b/src/common/ch-api/model/Page.ts new file mode 100644 index 00000000..6b9b66f9 --- /dev/null +++ b/src/common/ch-api/model/Page.ts @@ -0,0 +1,19 @@ +import { ApiResource } from '../api/model/ApiResource'; +import { ResourceList } from './ResourceList'; + +export class Page extends ResourceList { + /** + * The total number of resources found. + */ + numFound: number; + + /** + * The starting index of this page. + */ + start: number; + + /** + * The number of resources displayed on each page. + */ + pageSize: number; +} diff --git a/src/common/ch-api/model/Pageable.ts b/src/common/ch-api/model/Pageable.ts new file mode 100644 index 00000000..43d9281e --- /dev/null +++ b/src/common/ch-api/model/Pageable.ts @@ -0,0 +1,14 @@ +/** + * Parameters for paginated requests + */ +export interface Pageable { + /** + * Position of the first asset to return (1 based) + */ + s?: number; + + /** + * Maximum resources to return + */ + n?: number; +} diff --git a/src/common/ch-api/model/PublishActivitySummary.ts b/src/common/ch-api/model/PublishActivitySummary.ts new file mode 100644 index 00000000..b016fcc3 --- /dev/null +++ b/src/common/ch-api/model/PublishActivitySummary.ts @@ -0,0 +1,13 @@ +import { PublishInfoPut } from './PublishInfoPut'; + +export interface PublishActivitySummary { + /** + * Info on the last publish job, if present. + */ + last?: PublishInfoPut; + + /** + * Info on the last successful publish job, if present. + */ + lastsuccess?: PublishInfoPut; +} diff --git a/src/common/ch-api/model/PublishInfoPut.ts b/src/common/ch-api/model/PublishInfoPut.ts new file mode 100644 index 00000000..22cced05 --- /dev/null +++ b/src/common/ch-api/model/PublishInfoPut.ts @@ -0,0 +1,21 @@ +export interface PublishInfoPut { + /** + * The publish job ID + */ + jobID: string; + + /** + * The revision number of the published asset. + */ + revisionNumber: number; + + /** + * The timestamp of when the publish job status was last updated. + */ + timestamp: number; + + /** + * The status of this publish job. + */ + status: string; +} diff --git a/src/common/ch-api/model/ResourceList.spec.ts b/src/common/ch-api/model/ResourceList.spec.ts new file mode 100644 index 00000000..3ec5fded --- /dev/null +++ b/src/common/ch-api/model/ResourceList.spec.ts @@ -0,0 +1,22 @@ +import { ResourceList } from './ResourceList'; +import { Asset } from './Asset'; +import { MockContentHub } from '../ContentHub.mocks'; + +describe('ResourceList tests', () => { + test('creation and to JSON', async () => { + const list = { + data: [{ id: 'example1' }, { id: 'example2' }], + count: 2 + }; + + const resList = new ResourceList(Asset, list); + resList.setClient(new MockContentHub().mockClient); + + expect(resList.getItems().map(asset => asset.toJSON())).toEqual(list.data); + + // When converting to a list and back, we should get the same data. + const json = resList.toJSON(); + + expect(json).toEqual(list); + }); +}); diff --git a/src/common/ch-api/model/ResourceList.ts b/src/common/ch-api/model/ResourceList.ts new file mode 100644 index 00000000..2832f6cc --- /dev/null +++ b/src/common/ch-api/model/ResourceList.ts @@ -0,0 +1,30 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ApiResource, ApiResourceConstructor } from '../api/model/ApiResource'; + +export class ResourceList extends ApiResource { + private resourceType: ApiResourceConstructor; + private items: T[]; + + private data: any[]; + private count: number; + + constructor(resourceType: ApiResourceConstructor, data?: any) { + super(data); + this.resourceType = resourceType; + } + + public getItems(): T[] { + if (!this.items) { + this.items = this.data.map(x => this.client.parse(x, this.resourceType)); + } + return this.items; + } + + public toJSON(): any { + const result = super.toJSON(); + result.data = this.getItems().map(item => item.toJSON()); + delete result.resourceType; + delete result.items; + return result; + } +} diff --git a/src/common/ch-api/model/Settings.spec.ts b/src/common/ch-api/model/Settings.spec.ts new file mode 100644 index 00000000..fcfc5010 --- /dev/null +++ b/src/common/ch-api/model/Settings.spec.ts @@ -0,0 +1,11 @@ +import { MockContentHub } from '../ContentHub.mocks'; + +describe('Settings tests', () => { + test('get settings', async () => { + const client = new MockContentHub(); + const result = await client.settings.get(); + + expect(result.companyClassificationType).toEqual('Demo'); + expect(result.di.defaultEndpoint).toEqual('aaaaaaaa-bbbb-cccc-dddd-eeeeeeffffff'); + }); +}); diff --git a/src/common/ch-api/model/Settings.ts b/src/common/ch-api/model/Settings.ts new file mode 100644 index 00000000..f62b90ae --- /dev/null +++ b/src/common/ch-api/model/Settings.ts @@ -0,0 +1,45 @@ +import { ApiResource } from '../api/model/ApiResource'; + +export interface SystemSettings { + 'auth-api-url': string; + 'publishing-service': string; + 'im-api-url': string; + 'virtual-staging-service': string; + 'dam-mixpanel-token': string; + 'analytics-beacon-url': string; + 'analytics-query-api-url': string; + 'dam-version': string; + 'stream-api-url': string; + 'ca-mixpanel-token': string; + 'access-service': string; + 'cms-api-url': string; + 'provisioning-api': string; + 'identity-service': string; + 'media-graphics-host': string; +} + +export interface DiSettingsEndpoint { + path: string; + staticHost: string; + textProtocols: string[]; + schemaIds: string[]; + dynamicHost: string; + id: string; + staticProtocols: string[]; + serviceId: string; + textHost: string; + dynamicProtocols: string[]; +} + +export interface DiSettings { + endpoints: DiSettingsEndpoint[]; + defaultEndpoint: string; + enabled: boolean; +} + +export class Settings extends ApiResource { + companyId: string; + companyClassificationType: string; + system: SystemSettings; + di: DiSettings; +} diff --git a/src/common/ch-api/model/WorkflowSummary.ts b/src/common/ch-api/model/WorkflowSummary.ts new file mode 100644 index 00000000..e0c1e2df --- /dev/null +++ b/src/common/ch-api/model/WorkflowSummary.ts @@ -0,0 +1,4 @@ +export interface WorkflowSummary { + status?: string; + assignedTo?: string; +} diff --git a/src/common/ch-api/oauth2/models/AccessToken.ts b/src/common/ch-api/oauth2/models/AccessToken.ts new file mode 100644 index 00000000..ece94c76 --- /dev/null +++ b/src/common/ch-api/oauth2/models/AccessToken.ts @@ -0,0 +1,19 @@ +/** + * OAuth2 Access Token + */ +export interface AccessToken { + /** + * Token attached to requests to assert permissions + */ + access_token: string; + + /** + * Token used to refresh the access token after it has expired + */ + refresh_token: string; + + /** + * Period (in seconds) for how long the access token will remain valid + */ + expires_in: number; +} diff --git a/src/common/ch-api/oauth2/models/AccessTokenProvider.ts b/src/common/ch-api/oauth2/models/AccessTokenProvider.ts new file mode 100644 index 00000000..29f88b9f --- /dev/null +++ b/src/common/ch-api/oauth2/models/AccessTokenProvider.ts @@ -0,0 +1,5 @@ +import { AccessToken } from './AccessToken'; + +export interface AccessTokenProvider { + getToken(): Promise; +} diff --git a/src/common/ch-api/oauth2/models/OAuth2ClientCredentials.ts b/src/common/ch-api/oauth2/models/OAuth2ClientCredentials.ts new file mode 100644 index 00000000..23ad6652 --- /dev/null +++ b/src/common/ch-api/oauth2/models/OAuth2ClientCredentials.ts @@ -0,0 +1,14 @@ +/** + * OAuth2 Client Credentials + */ +export interface OAuth2ClientCredentials { + /** + * Client id + */ + client_id: string; + + /** + * Client secret + */ + client_secret: string; +} diff --git a/src/common/ch-api/oauth2/services/OAuth2Client.spec.ts b/src/common/ch-api/oauth2/services/OAuth2Client.spec.ts new file mode 100644 index 00000000..b92b5300 --- /dev/null +++ b/src/common/ch-api/oauth2/services/OAuth2Client.spec.ts @@ -0,0 +1,152 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { AxiosHttpClient } from '../../http/AxiosHttpClient'; +import { OAuth2Client } from './OAuth2Client'; + +// axios-mock-adaptor's typedefs are wrong preventing calling onGet with 3 args, this is a workaround +/** + * @hidden + */ +import MockAdapter from 'axios-mock-adapter'; + +describe('OAuth2Client tests', () => { + test('get token should request a token on the first invocation', async () => { + const httpClient = new AxiosHttpClient({}); + const client = new OAuth2Client( + { + client_id: 'client_id', + client_secret: 'client_secret' + }, + {}, + httpClient + ); + + const mock = new MockAdapter(httpClient.client); + mock + .onPost( + 'https://auth.amplience.net/oauth/token', + 'grant_type=client_credentials&client_id=client_id&client_secret=client_secret' + ) + .reply(200, { + access_token: 'token', + expires_in: 0, + refresh_token: 'refresh' + }); + + expect((await client.getToken()).access_token).toEqual('token'); + }); + + test('get token should cache tokens', async () => { + const httpClient = new AxiosHttpClient({}); + const client = new OAuth2Client( + { + client_id: 'client_id', + client_secret: 'client_secret' + }, + {}, + httpClient + ); + + const mock = new MockAdapter(httpClient.client); + mock + .onPost( + 'https://auth.amplience.net/oauth/token', + 'grant_type=client_credentials&client_id=client_id&client_secret=client_secret' + ) + .reply(200, { + access_token: 'token', + expires_in: 60, + refresh_token: 'refresh' + }); + + const token1 = await client.getToken(); + + mock + .onPost( + 'https://auth.amplience.net/oauth/token', + 'grant_type=client_credentials&client_id=client_id&client_secret=client_secret' + ) + .reply(200, { + access_token: 'token2', + expires_in: 60, + refresh_token: 'refresh' + }); + + const token2 = await client.getToken(); + + expect(token1.access_token).toEqual('token'); + expect(token2.access_token).toEqual('token'); + }); + + test('cached tokens should expire', async () => { + const httpClient = new AxiosHttpClient({}); + const client = new OAuth2Client( + { + client_id: 'client_id', + client_secret: 'client_secret' + }, + {}, + httpClient + ); + + const mock = new MockAdapter(httpClient.client); + mock + .onPost( + 'https://auth.amplience.net/oauth/token', + 'grant_type=client_credentials&client_id=client_id&client_secret=client_secret' + ) + .reply(200, { + access_token: 'token', + expires_in: -60, + refresh_token: 'refresh' + }); + + const token1 = await client.getToken(); + + mock + .onPost( + 'https://auth.amplience.net/oauth/token', + 'grant_type=client_credentials&client_id=client_id&client_secret=client_secret' + ) + .reply(200, { + access_token: 'token2', + expires_in: 0, + refresh_token: 'refresh' + }); + + const token2 = await client.getToken(); + + expect(token1.access_token).toEqual('token'); + expect(token2.access_token).toEqual('token2'); + }); + + test('only one token refresh should be in flight at once', async () => { + const httpClient = new AxiosHttpClient({}); + const client = new OAuth2Client( + { + client_id: 'client_id', + client_secret: 'client_secret' + }, + {}, + httpClient + ); + + const mock = new MockAdapter(httpClient.client, { delayResponse: 2000 }); + + mock + .onPost( + 'https://auth.amplience.net/oauth/token', + 'grant_type=client_credentials&client_id=client_id&client_secret=client_secret' + ) + .replyOnce(200, { + access_token: 'token', + expires_in: 0, + refresh_token: 'refresh' + }); + + const token1 = client.getToken(); + const token2 = client.getToken(); + + expect((await token1).access_token).toEqual('token'); + expect((await token2).access_token).toEqual('token'); + }); +}); diff --git a/src/common/ch-api/oauth2/services/OAuth2Client.ts b/src/common/ch-api/oauth2/services/OAuth2Client.ts new file mode 100644 index 00000000..16772d15 --- /dev/null +++ b/src/common/ch-api/oauth2/services/OAuth2Client.ts @@ -0,0 +1,84 @@ +import { HttpClient } from '../../http/HttpClient'; +import { HttpMethod } from '../../http/HttpRequest'; +import { combineURLs } from '../../utils/URL'; +import { AccessToken } from '../models/AccessToken'; +import { AccessTokenProvider } from '../models/AccessTokenProvider'; +import { OAuth2ClientCredentials } from '../models/OAuth2ClientCredentials'; + +/** + * @hidden + */ +export class OAuth2Client implements AccessTokenProvider { + public httpClient: HttpClient; + + private clientCredentials: OAuth2ClientCredentials; + private token: AccessToken; + private tokenExpires: number; + private inFlight?: Promise; + private authUrl: string; + + constructor( + clientCredentials: OAuth2ClientCredentials, + { authUrl = 'https://auth.amplience.net' }, + httpClient: HttpClient + ) { + this.authUrl = authUrl; + this.clientCredentials = clientCredentials; + this.httpClient = httpClient; + } + + /** + * Requests an authentication token that can be used + * to make requests to the Dynamic Content api. + * Tokens are reused until they expire. + * + * @returns {Promise} + */ + public async getToken(): Promise { + if (this.inFlight != null) { + return this.inFlight; + } + + if (this.token != null && this.tokenExpires > Date.now()) { + return this.token; + } + + const payload = + 'grant_type=client_credentials' + + '&client_id=' + + encodeURIComponent(this.clientCredentials.client_id) + + '&client_secret=' + + encodeURIComponent(this.clientCredentials.client_secret); + + const request = this.httpClient.request({ + data: payload, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + method: HttpMethod.POST, + url: combineURLs(this.authUrl, '/oauth/token') + }); + + this.inFlight = request.then( + (response): AccessToken => { + if (typeof response.data !== 'object') { + throw new Error('Unexpected response format from /oauth/token endpoint'); + } + + if (response.status !== 200) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const responseText = (response.data as any).error_description || JSON.stringify(response.data); + + throw new Error(`Authorization failed (${response.status}): ${responseText}`); + } + + this.token = (response.data as unknown) as AccessToken; + this.tokenExpires = Date.now() + this.token.expires_in * 1000; + this.inFlight = undefined; + return this.token; + } + ) as Promise; + + return this.inFlight; + } +} diff --git a/src/common/ch-api/utils/URL.spec.ts b/src/common/ch-api/utils/URL.spec.ts new file mode 100644 index 00000000..f68ee36b --- /dev/null +++ b/src/common/ch-api/utils/URL.spec.ts @@ -0,0 +1,39 @@ +import { combineURLs, isAbsoluteURL } from './URL'; + +describe('URL tests', () => { + test('combine url tests', () => { + const relativeX2 = combineURLs('test', 'test2'); + + expect(relativeX2).toEqual('test/test2'); + + const absoluteX2 = combineURLs('http://absoluteUrl', 'http://absoluteUrl2'); + + expect(absoluteX2).toEqual('http://absoluteUrl2'); + + const absoluteRelative = combineURLs('http://absoluteUrl', 'test2'); + + expect(absoluteRelative).toEqual('http://absoluteUrl/test2'); + + const relativeAbsolute = combineURLs('test', 'http://absoluteUrl2'); + + expect(relativeAbsolute).toEqual('http://absoluteUrl2'); + + const absoluteNone = combineURLs('http://absoluteUrl', null); + + expect(absoluteNone).toEqual('http://absoluteUrl'); + }); + + test('absolute url tests', () => { + expect(isAbsoluteURL('http://absolute/a/b')).toBeTruthy(); + expect(isAbsoluteURL('https://absolute/a/b/')).toBeTruthy(); + expect(isAbsoluteURL('//absolute/a/b/c')).toBeTruthy(); + expect(isAbsoluteURL('//absolute')).toBeTruthy(); + + expect(isAbsoluteURL('relative')).toBeFalsy(); + expect(isAbsoluteURL('relative.html')).toBeFalsy(); + expect(isAbsoluteURL('relative/a/b/c')).toBeFalsy(); + expect(isAbsoluteURL('relative/a/b/c/')).toBeFalsy(); + expect(isAbsoluteURL('/absolute/a/b/c')).toBeFalsy(); + expect(isAbsoluteURL('/absolute')).toBeFalsy(); + }); +}); diff --git a/src/common/ch-api/utils/URL.ts b/src/common/ch-api/utils/URL.ts new file mode 100644 index 00000000..b782b2a3 --- /dev/null +++ b/src/common/ch-api/utils/URL.ts @@ -0,0 +1,17 @@ +/** + * @hidden + */ +export function isAbsoluteURL(url: string): boolean { + return /^([a-z][a-z\d\+\-\.]*:)?\/\//i.test(url); +} + +/** + * @hidden + */ +export function combineURLs(baseURL: string, relativeURL: string | null): string { + if (relativeURL != null && isAbsoluteURL(relativeURL)) { + return relativeURL; + } else { + return relativeURL ? baseURL.replace(/\/+$/, '') + '/' + relativeURL.replace(/^\/+/, '') : baseURL; + } +} diff --git a/src/common/content-item/media-link-injector.spec.ts b/src/common/content-item/media-link-injector.spec.ts new file mode 100644 index 00000000..e0fa8205 --- /dev/null +++ b/src/common/content-item/media-link-injector.spec.ts @@ -0,0 +1,79 @@ +import { ContentItem, ContentRepository } from 'dc-management-sdk-js'; +import { MediaLinkInjector } from './media-link-injector'; + +describe('media-link-injector', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('Media Link Injector', () => { + it('should identify media links', async () => { + const repo = new ContentRepository(); + + const injector = new MediaLinkInjector([ + { + repo, + content: new ContentItem({ + body: { + _meta: { + schema: 'https://test-type-1.com' + }, + image: { + id: '299ba2ac-aa5b-4c18-81da-f12156ad9622', + name: 'imageProperty', + endpoint: 'old', + defaultHost: 'i1.adis.ws', + mediaType: 'image', + _meta: { + schema: 'http://bigcontent.io/cms/schema/v1/core#/definitions/image-link' + } + }, + nested: { + imageNested: { + id: '299ba2ac-aa5b-4c18-81da-f12156ad9622', + name: 'imageNested', + endpoint: 'old', + defaultHost: 'i1.adis.ws', + mediaType: 'image', + _meta: { + schema: 'http://bigcontent.io/cms/schema/v1/core#/definitions/image-link' + } + }, + imageArray: [ + { + id: '299ba2ac-aa5b-4c18-81da-f12156ad9622', + name: 'imageArray1', + endpoint: 'old', + defaultHost: 'i1.adis.ws', + mediaType: 'image', + _meta: { + schema: 'http://bigcontent.io/cms/schema/v1/core#/definitions/image-link' + } + }, + { + id: '299ba2ac-aa5b-4c18-81da-f12156ad9622', + name: 'imageArray2', + endpoint: 'old', + defaultHost: 'i1.adis.ws', + mediaType: 'image', + _meta: { + schema: 'http://bigcontent.io/cms/schema/v1/core#/definitions/image-link' + } + } + ] + } + } + }) + } + ]); + + expect(injector.all).not.toBeNull(); + expect(injector.all.length).toEqual(1); + expect(injector.all[0].links.length).toEqual(4); + + expect(injector.all.map(group => group.links.map(link => link.link.name))).toEqual([ + ['imageProperty', 'imageNested', 'imageArray1', 'imageArray2'] + ]); + }); + }); +}); diff --git a/src/common/content-item/media-link-injector.ts b/src/common/content-item/media-link-injector.ts new file mode 100644 index 00000000..23d5f301 --- /dev/null +++ b/src/common/content-item/media-link-injector.ts @@ -0,0 +1,80 @@ +import { RepositoryContentItem } from './content-dependancy-tree'; +import { Body } from './body'; + +export interface MediaLink { + id: string; + name: string; + endpoint: string; + defaultHost: string; + mediaType: string; + _meta: { + schema: + | 'http://bigcontent.io/cms/schema/v1/core#/definitions/image-link' + | 'http://bigcontent.io/cms/schema/v1/core#/definitions/video-link'; + }; +} + +export interface MediaLinkInfo { + link: MediaLink; + owner: RepositoryContentItem; +} + +export interface ItemMediaLinks { + owner: RepositoryContentItem; + links: MediaLinkInfo[]; +} + +export const linkTypes = [ + 'http://bigcontent.io/cms/schema/v1/core#/definitions/image-link', + 'http://bigcontent.io/cms/schema/v1/core#/definitions/video-link' +]; + +type RecursiveSearchStep = Body | MediaLink | Array; + +export class MediaLinkInjector { + all: ItemMediaLinks[]; + + constructor(items: RepositoryContentItem[]) { + // Identify all content dependancies. + this.all = this.identifyMediaLinks(items); + } + + private searchObjectForMediaLinks( + item: RepositoryContentItem, + body: RecursiveSearchStep, + result: MediaLinkInfo[] + ): void { + if (Array.isArray(body)) { + body.forEach(contained => { + this.searchObjectForMediaLinks(item, contained, result); + }); + } else { + const allPropertyNames = Object.getOwnPropertyNames(body); + // Does this object match the pattern expected for a content item or reference? + if ( + body._meta && + linkTypes.indexOf(body._meta.schema) !== -1 && + typeof body.name === 'string' && + typeof body.id === 'string' + ) { + result.push({ link: body as MediaLink, owner: item }); + return; + } + + allPropertyNames.forEach(propName => { + const prop = (body as Body)[propName]; + if (typeof prop === 'object') { + this.searchObjectForMediaLinks(item, prop, result); + } + }); + } + } + + private identifyMediaLinks(items: RepositoryContentItem[]): ItemMediaLinks[] { + return items.map(item => { + const result: MediaLinkInfo[] = []; + this.searchObjectForMediaLinks(item, item.content.body, result); + return { owner: item, links: result }; + }); + } +} diff --git a/src/common/media/__mocks__/ch-client-factory.ts b/src/common/media/__mocks__/ch-client-factory.ts new file mode 100644 index 00000000..86580440 --- /dev/null +++ b/src/common/media/__mocks__/ch-client-factory.ts @@ -0,0 +1,9 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ContentHub } from '../../ch-api/ContentHub'; +import { ConfigurationParameters } from '../../../commands/configure'; +import { MockContentHub } from '../mock-ch'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const chClientFactory = (_: ConfigurationParameters): ContentHub => (new MockContentHub() as any) as ContentHub; + +export default chClientFactory; diff --git a/src/common/media/__mocks__/media-rewriter.ts b/src/common/media/__mocks__/media-rewriter.ts new file mode 100644 index 00000000..da2fdfd1 --- /dev/null +++ b/src/common/media/__mocks__/media-rewriter.ts @@ -0,0 +1,14 @@ +import { ConfigurationParameters } from '../../../commands/configure'; +import { RepositoryContentItem } from '../../content-item/content-dependancy-tree'; + +export class MediaRewriter { + static rewrites = 0; + + constructor(private config: ConfigurationParameters, private items: RepositoryContentItem[]) {} + + async rewrite(): Promise> { + MediaRewriter.rewrites++; + + return new Set(); + } +} diff --git a/src/common/media/media-rewriter.spec.ts b/src/common/media/media-rewriter.spec.ts new file mode 100644 index 00000000..c23d3586 --- /dev/null +++ b/src/common/media/media-rewriter.spec.ts @@ -0,0 +1,221 @@ +import { ContentItem, ContentRepository } from 'dc-management-sdk-js'; +import chClientFactory from '../../services/ch-client-factory'; +import { RepositoryContentItem } from '../content-item/content-dependancy-tree'; +import { MediaLinkInjector } from '../content-item/media-link-injector'; +import { MediaRewriter } from './media-rewriter'; +import { MockContentHub } from './mock-ch'; + +jest.mock('../../services/ch-client-factory'); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +jest.mock('promise-retry', () => (fn: unknown, options: any): unknown => { + const retryActual = jest.requireActual('promise-retry'); + options.minTimeout = 0; + options.maxTimeout = 0; + return retryActual(fn, options); +}); + +let exampleLinks: RepositoryContentItem[] = []; + +describe('media-link-injector', () => { + beforeEach(() => { + jest.resetAllMocks(); + + MockContentHub.missingAssetList = false; + MockContentHub.throwOnGetSettings = false; + MockContentHub.returnNullEndpoint = false; + MockContentHub.throwOnAssetList = false; + MockContentHub.requests = []; + + exampleLinks = [ + { + repo: new ContentRepository(), + content: new ContentItem({ + body: { + _meta: { + schema: 'https://test-type-1.com' + }, + image: { + id: '299ba2ac-aa5b-4c18-81da-f12156ad9622', + name: 'imageProperty', + endpoint: 'old', + defaultHost: 'i1.adis.ws', + mediaType: 'image', + _meta: { + schema: 'http://bigcontent.io/cms/schema/v1/core#/definitions/image-link' + } + }, + nested: { + imageNested: { + id: '299ba2ac-aa5b-4c18-81da-f12156ad9622', + name: 'imageNested', + endpoint: 'old', + defaultHost: 'i1.adis.ws', + mediaType: 'image', + _meta: { + schema: 'http://bigcontent.io/cms/schema/v1/core#/definitions/image-link' + } + }, + imageArray: [ + { + id: '299ba2ac-aa5b-4c18-81da-f12156ad9622', + name: 'imageArray1', + endpoint: 'old', + defaultHost: 'i1.adis.ws', + mediaType: 'image', + _meta: { + schema: 'http://bigcontent.io/cms/schema/v1/core#/definitions/image-link' + } + }, + { + id: '299ba2ac-aa5b-4c18-81da-f12156ad9622', + name: 'imageArray2', + endpoint: 'old', + defaultHost: 'i1.adis.ws', + mediaType: 'image', + _meta: { + schema: 'http://bigcontent.io/cms/schema/v1/core#/definitions/image-link' + } + } + ] + } + } + }) + } + ]; + + (chClientFactory as jest.Mock).mockReturnValue(new MockContentHub()); + }); + + describe('Media Link Injector', () => { + it('should rewrite media links', async () => { + const rewriter = new MediaRewriter({ clientId: '', clientSecret: '', hubId: '' }, exampleLinks); + + const missing = await rewriter.rewrite(); + + expect(missing.size).toEqual(0); // 0 assets missing + + expect(MockContentHub.requests).toMatchInlineSnapshot(` + Array [ + Object { + "n": 4, + "q": "(name:/imageProperty|imageNested|imageArray1|imageArray2/)", + }, + ] + `); + + // The mock DAM has all IDs equal to names, which lets us distinguish them easily. + // The IDs will be rewritten to these names if all went well. + + expect(exampleLinks[0].content.body.image.id).toBe('imageProperty'); + expect(exampleLinks[0].content.body.nested.imageNested.id).toBe('imageNested'); + expect(exampleLinks[0].content.body.nested.imageArray[0].id).toBe('imageArray1'); + expect(exampleLinks[0].content.body.nested.imageArray[1].id).toBe('imageArray2'); + }); + + it('should do nothing if no assets are provided', async () => { + const rewriter = new MediaRewriter({ clientId: '', clientSecret: '', hubId: '' }, []); + + const missing = await rewriter.rewrite(); + + expect(missing.size).toEqual(0); // 0 assets missing + + expect(MockContentHub.requests.length).toEqual(0); + }); + + it('should ignore media links where content with a matching name does not exist on DAM', async () => { + MockContentHub.missingAssetList = true; + const rewriter = new MediaRewriter({ clientId: '', clientSecret: '', hubId: '' }, exampleLinks); + + const results = await rewriter.rewrite(); + + expect(results.size).toEqual(4); // All 4 assets missing + + expect(MockContentHub.requests).toMatchInlineSnapshot(` + Array [ + Object { + "n": 4, + "q": "(name:/imageProperty|imageNested|imageArray1|imageArray2/)", + }, + ] + `); + }); + + it('should fail when the settings endpoint throws', async () => { + MockContentHub.throwOnGetSettings = true; + const rewriter = new MediaRewriter({ clientId: '', clientSecret: '', hubId: '' }, []); + + await expect(rewriter.rewrite()).rejects.toThrowErrorMatchingInlineSnapshot( + `"Could not obtain settings from DAM. Make sure you have the required permissions. Error: Simulated settings error."` + ); + }); + + it('should fail when the settings do not contain a default endpoint', async () => { + MockContentHub.returnNullEndpoint = true; + const rewriter = new MediaRewriter({ clientId: '', clientSecret: '', hubId: '' }, []); + + await expect(rewriter.rewrite()).rejects.toThrowErrorMatchingInlineSnapshot( + `"Could not find the default endpoint."` + ); + }); + + it('should fail when getting assets does not work a certain number of times in a row', async () => { + MockContentHub.throwOnAssetList = true; + const rewriter = new MediaRewriter({ clientId: '', clientSecret: '', hubId: '' }, exampleLinks); + + await expect(rewriter.rewrite()).rejects.toThrowErrorMatchingInlineSnapshot( + `"Request for assets failed after 3 attempts."` + ); + + expect(MockContentHub.requests).toMatchInlineSnapshot(` + Array [ + Object { + "n": 4, + "q": "(name:/imageProperty|imageNested|imageArray1|imageArray2/)", + }, + Object { + "n": 4, + "q": "(name:/imageProperty|imageNested|imageArray1|imageArray2/)", + }, + Object { + "n": 4, + "q": "(name:/imageProperty|imageNested|imageArray1|imageArray2/)", + }, + ] + `); + }); + + it('should make multiple asset requests if the query gets too long (3000 chars)', async () => { + const expectedCharLimit = 3000; + const expectedRequests = 3; + + const injector = new MediaLinkInjector(exampleLinks); + + const names = injector.all[0].links.map(x => x.link.name + '0'); + const itemLength = names.join('|').length; + + const itemsNeeded = ((expectedRequests - 0.5) * expectedCharLimit) / itemLength; + + const newLinks: RepositoryContentItem[] = []; + const template = JSON.stringify(exampleLinks[0]); + + for (let i = 0; i < itemsNeeded; i++) { + const newLink: RepositoryContentItem = JSON.parse(template); + newLink.content.body.image.name += i; + newLink.content.body.nested.imageNested.name += i; + newLink.content.body.nested.imageArray[0].name += i; + newLink.content.body.nested.imageArray[1].name += i; + + newLinks[i] = newLink; + } + + const rewriter = new MediaRewriter({ clientId: '', clientSecret: '', hubId: '' }, newLinks); + + const missing = await rewriter.rewrite(); + + expect(missing.size).toEqual(0); // 0 assets missing + + expect(MockContentHub.requests.length).toEqual(expectedRequests); // 3 requests + }); + }); +}); diff --git a/src/common/media/media-rewriter.ts b/src/common/media/media-rewriter.ts new file mode 100644 index 00000000..9b5313cb --- /dev/null +++ b/src/common/media/media-rewriter.ts @@ -0,0 +1,168 @@ +import { ContentHub } from '../ch-api/ContentHub'; +import { Asset } from '../ch-api/model/Asset'; +import { DiSettingsEndpoint } from '../ch-api/model/Settings'; +import { ConfigurationParameters } from '../../commands/configure'; +import chClientFactory from '../../services/ch-client-factory'; +import { RepositoryContentItem } from '../content-item/content-dependancy-tree'; +import { MediaLinkInjector } from '../content-item/media-link-injector'; +import promiseRetry from 'promise-retry'; + +/** + * Exports media related to given content items from an existing repository. + * Uses the account credentials to export the media. + */ +export class MediaRewriter { + private injector: MediaLinkInjector; + private dam: ContentHub; + + private endpoint: string; + private defaultHost: string; + + constructor(private config: ConfigurationParameters, private items: RepositoryContentItem[]) { + this.injector = new MediaLinkInjector(items); + } + + private escapeForRegex(url: string): string { + return url.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + } + + private connectDam(): void { + this.dam = chClientFactory(this.config); + } + + private async getEndpoint(): Promise { + let endpoint: DiSettingsEndpoint | undefined; + try { + const settings = await this.dam.settings.get(); + + endpoint = settings.di.endpoints.find(endpoint => { + return endpoint.id === settings.di.defaultEndpoint; + }); + } catch (e) { + throw new Error(`Could not obtain settings from DAM. Make sure you have the required permissions. ${e}`); + } + + if (endpoint == null) { + throw new Error('Could not find the default endpoint.'); + } + + this.endpoint = endpoint.path; + this.defaultHost = endpoint.dynamicHost; + } + + private async queryAndAdd(query: string, count: number, assets: Map): Promise { + let attemptCount = 0; + + try { + return await promiseRetry( + async (retry, attempt) => { + try { + const result = await this.dam.assets.list({ + q: '(' + query + ')', + n: count + }); + + const items = result.getItems(); + + items.forEach(asset => { + assets.set(asset.name as string, asset); + }); + + return items.length; + } catch (e) { + attemptCount = attempt; + retry(e); + return 0; + } + }, + { retries: 2 } + ); + } catch (e) { + // Too many retries, fail the request. + throw new Error(`Request for assets failed after ${attemptCount} attempts.`); + } + } + + private getLinkNames(): Set { + return new Set(this.injector.all.flatMap(links => links.links.map(link => link.link.name))); + } + + private replaceLinks(assetsByName: Map): Set { + const missingAssets = new Set(); + + // Replace media link assets with ones that we found with matching names. + this.injector.all.forEach(links => { + links.links.forEach(link => { + const asset = assetsByName.get(link.link.name); + if (asset != null) { + link.link.id = asset.id; + + link.link.defaultHost = this.defaultHost; + link.link.endpoint = this.endpoint; + } else { + missingAssets.add(link.link.name); + } + }); + }); + + return missingAssets; + } + + async rewrite(): Promise> { + this.connectDam(); + + await this.getEndpoint(); + + // Steps: + // identify existing assets by name (unique, case sensitive) + // - content item media dependancies, flush them all into a set + // - try do a few batch requests for assets with matching name (arbitrary limit: 3000 characters) + // - replace media link assets with ones that we found with matching names + // - return non-matching assets + + const allNames = this.getLinkNames(); + + if (allNames.size == 0) { + return new Set(); + } + + const maxQueryLength = 3000; + const assetsByName = new Map(); + const names = Array.from(allNames); + + if (names.length > 0) { + let requestBuilder = 'name:/'; + let first = true; + let requestCount = 0; + + for (const name of names) { + const additionalRequest = `${this.escapeForRegex(name)}`; + + const lengthSoFar = requestBuilder.length; + if (first) { + // First entry? + requestBuilder += additionalRequest; + requestCount++; + first = false; + } else { + if (lengthSoFar + 1 + additionalRequest.length < maxQueryLength) { + // | + requestBuilder += '|' + additionalRequest; + requestCount++; + } else { + // If the query is too big, batch out what we have and start over. + + await this.queryAndAdd(requestBuilder + '/', requestCount, assetsByName); + requestBuilder = 'name:/' + additionalRequest; + } + } + } + + if (requestBuilder.length > 0) { + await this.queryAndAdd(requestBuilder + '/', requestCount, assetsByName); + } + } + + return this.replaceLinks(assetsByName); + } +} diff --git a/src/common/media/mock-ch.ts b/src/common/media/mock-ch.ts new file mode 100644 index 00000000..4992cb58 --- /dev/null +++ b/src/common/media/mock-ch.ts @@ -0,0 +1,88 @@ +import { DefaultApiClient, ApiClient } from '../ch-api/api/services/ApiClient'; +import { AssetsList } from '../ch-api/model/Asset'; +import { AssetListRequest } from '../ch-api/model/AssetListRequest'; + +export class MockContentHub { + static throwOnGetSettings = false; + static returnNullEndpoint = false; + static throwOnAssetList = false; + static missingAssetList = false; + + static requests: AssetListRequest[] = []; + + client: ApiClient; + + settings = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get: (): Promise => { + if (MockContentHub.throwOnGetSettings) { + throw new Error('Simulated settings error.'); + } + + if (MockContentHub.returnNullEndpoint) { + return Promise.resolve({ + di: { + endpoints: [], + defaultEndpoint: null + } + }); + } else { + return Promise.resolve({ + di: { + endpoints: [ + { + id: 'test-endpoint', + path: 'test-endpoint-path', + dynamicHost: 'test-endpoint-dynamicHost' + } + ], + defaultEndpoint: 'test-endpoint' + } + }); + } + } + }; + + assets = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + list: (query: AssetListRequest): Promise => { + MockContentHub.requests.push(query); + + if (MockContentHub.throwOnAssetList) { + throw new Error('Simulated asset list error.'); + } + + let list: AssetsList; + + if (MockContentHub.missingAssetList) { + list = new AssetsList({ + data: [], + count: 0 + }); + } else { + const nameStart = (query.q as string).indexOf('/'); + const nameEnd = (query.q as string).lastIndexOf('/'); + const name = (query.q as string).substring(nameStart + 1, nameEnd); + + const names = name.split('|'); + + list = new AssetsList({ + data: names.map(name => ({ + id: name, + name + })), + count: names.length + }); + } + + list.setClient(this.client); + + return Promise.resolve(list); + } + }; + + constructor() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.client = new DefaultApiClient('', null as any, null as any); + } +} diff --git a/src/interfaces/copy-item-builder-options.interface.ts b/src/interfaces/copy-item-builder-options.interface.ts index 4c38d0c2..8c2ad947 100644 --- a/src/interfaces/copy-item-builder-options.interface.ts +++ b/src/interfaces/copy-item-builder-options.interface.ts @@ -19,6 +19,7 @@ export interface CopyItemBuilderOptions { force?: boolean; validate?: boolean; skipIncomplete?: boolean; + media?: boolean; logFile?: string | FileLog; copyConfig?: string | CopyConfig; diff --git a/src/interfaces/import-item-builder-options.interface.ts b/src/interfaces/import-item-builder-options.interface.ts index 6742b45d..bbbf35e7 100644 --- a/src/interfaces/import-item-builder-options.interface.ts +++ b/src/interfaces/import-item-builder-options.interface.ts @@ -11,6 +11,7 @@ export interface ImportItemBuilderOptions { validate?: boolean; skipIncomplete?: boolean; excludeKeys?: boolean; + media?: boolean; logFile?: string | FileLog; revertLog?: string; diff --git a/src/services/ch-client-factory.ts b/src/services/ch-client-factory.ts new file mode 100644 index 00000000..422ec12a --- /dev/null +++ b/src/services/ch-client-factory.ts @@ -0,0 +1,17 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { ContentHub } from '../common/ch-api/ContentHub'; +import { ConfigurationParameters } from '../commands/configure'; + +const chClientFactory = (config: ConfigurationParameters): ContentHub => + new ContentHub( + { + client_id: config.clientId, + client_secret: config.clientSecret + }, + { + apiUrl: process.env.DAM_API_URL, + authUrl: process.env.AUTH_URL + } + ); + +export default chClientFactory; diff --git a/tsconfig.json b/tsconfig.json index f19d5faf..23b5e977 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "typeRoots" : ["./node_modules/@types", "./definitions"], "module": "commonjs", - "target": "es2018", + "target": "es2019", "outDir": "dist", "noImplicitAny": true, "removeComments": true,