Skip to content

Commit

Permalink
feat: add decomposition preset for external service registration (#1493)
Browse files Browse the repository at this point in the history
* chore: wip

* chore: license year

* chore(release): 12.11.0 [skip ci]

* chore: only check md file (#1475)

* fix: add workflow flow actions to decomposed workflow preset and allow isAddressable (#1467)

* fix: add workflow flow actions and allow isAddressable

* fix: add workflow flow actions

* chore(release): 12.11.1 [skip ci]

* W-17279149 Register MD APIs to metadata registry (#1472)

* chore: register md apis to metadata registry

* Update metadataRegistry.json

chore:empty commit

* chore(release): 12.11.2 [skip ci]

* chore: auto-update metadata coverage in METADATA_SUPPORT.md [no ci]

* fix: update snapshot (#1478)

* chore(release): 12.11.3 [skip ci]

* feat(mdTypes): register tua viz and ws metadata types (#1479)

* chore(release): 12.12.0 [skip ci]

* chore: auto-update metadata coverage in METADATA_SUPPORT.md [no ci]

* fix: resolve strict dirs before suffixes for potential metadata files (#1480)

* fix: resolve strict dirs before suffixes for potential metadata files

* fix: do not reuse suffixType if no match

* chore(release): 12.12.1 [skip ci]

* chore: wip

* chore: wip

* chore: wip

* chore: encoded in MD, chars in SD (#1485)

* chore: wip

* chore: wip

* chore: update md xml to include xml header

* chore: temp (#1490)

* chore: wip

* Wr/decompose esr (#1492)

* chore: reset snapshots to main

* fix: deploy ESR yaml and -meta

* test: fix registry test

* chore: bump core

* chore: always decompose to yaml

The schema property contents in an esr can be either yaml of json.
For simplicity, given the property alwasy represents an Open API spec, the decomposed format of schema will be yaml.
Recomposition will use an existing xml property, schemaUploadFileExtension, to determine the format when build the MD type.

* chore: add missing expected artifacts

---------

Co-authored-by: peternhale <peter.hale@salesforce.com>

* chore: fix test

* chore: address review suggestions

---------

Co-authored-by: Willhoit <iowillhoit@users.noreply.github.com>
Co-authored-by: svc-cli-bot <Svc_cli_bot@salesforce.com>
Co-authored-by: Matt Carvin <90224411+mcarvin8@users.noreply.github.com>
Co-authored-by: Idan Roas <44600753+IdanRoas@users.noreply.github.com>
Co-authored-by: Willie Ruemmele <willieruemmele@gmail.com>
Co-authored-by: PKV <43024336+PraveenViswanathan@users.noreply.github.com>
Co-authored-by: Steve Hetzel <shetzel@salesforce.com>
  • Loading branch information
8 people authored Jan 24, 2025
1 parent f2b53e1 commit dc7d20e
Show file tree
Hide file tree
Showing 50 changed files with 2,188 additions and 2,453 deletions.
2,197 changes: 458 additions & 1,739 deletions CHANGELOG.md

Large diffs are not rendered by default.

1,339 changes: 669 additions & 670 deletions METADATA_SUPPORT.md

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
"node": ">=18.0.0"
},
"dependencies": {
"@salesforce/core": "^8.8.0",
"@salesforce/kit": "^3.2.2",
"@salesforce/core": "^8.8.2",
"@salesforce/kit": "^3.2.3",
"@salesforce/ts-types": "^2.0.12",
"fast-levenshtein": "^3.0.0",
"fast-xml-parser": "^4.5.1",
Expand All @@ -37,7 +37,8 @@
"jszip": "^3.10.1",
"mime": "2.6.0",
"minimatch": "^9.0.5",
"proxy-agent": "^6.4.0"
"proxy-agent": "^6.4.0",
"yaml": "^2.6.1"
},
"devDependencies": {
"@jsforce/jsforce-node": "^3.6.3",
Expand Down
2 changes: 2 additions & 0 deletions src/convert/convertContext/convertContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { DecompositionFinalizer } from './decompositionFinalizer';
import { ConvertTransactionFinalizer } from './transactionFinalizer';
import { DecomposedLabelsFinalizer } from './decomposedLabelsFinalizer';
import { DecomposedPermissionSetFinalizer } from './decomposedPermissionSetFinalizer';
import { DecomposedExternalServiceRegistrationFinalizer } from './decomposedExternalServiceRegistrationFinalizer';
/**
* A state manager over the course of a single metadata conversion call.
*/
Expand All @@ -20,6 +21,7 @@ export class ConvertContext {
public readonly nonDecomposition = new NonDecompositionFinalizer();
public readonly decomposedLabels = new DecomposedLabelsFinalizer();
public readonly decomposedPermissionSet = new DecomposedPermissionSetFinalizer();
public readonly decomposedExternalServiceRegistration = new DecomposedExternalServiceRegistrationFinalizer();

// eslint-disable-next-line @typescript-eslint/require-await
public async *executeFinalizers(defaultDirectory?: string): AsyncIterable<WriterFormat[]> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import { join } from 'node:path';
import type { ExternalServiceRegistration } from '@jsforce/jsforce-node/lib/api/metadata/schema';
import { ensure, ensureString } from '@salesforce/ts-types';
import { WriterFormat } from '../types';
import { MetadataType } from '../../registry';
import { JsToXml } from '../streams';
import { ConvertTransactionFinalizer } from './transactionFinalizer';

type ExternalServiceRegistrationState = {
esrRecords: Map<string, ExternalServiceRegistration>;
};

export class DecomposedExternalServiceRegistrationFinalizer extends ConvertTransactionFinalizer<ExternalServiceRegistrationState> {
/** to support custom presets (the only way this code should get hit at all pass in the type from a transformer that has registry access */
public externalServiceRegistration?: MetadataType;
public transactionState: ExternalServiceRegistrationState = {
esrRecords: new Map<string, ExternalServiceRegistration>(),
};
// eslint-disable-next-line class-methods-use-this
public defaultDir: string | undefined;

public finalize(defaultDirectory: string | undefined): Promise<WriterFormat[]> {
this.defaultDir = defaultDirectory;
const writerFormats: WriterFormat[] = [];
this.transactionState.esrRecords.forEach((esrRecord, parent) =>
writerFormats.push({
component: {
type: ensure(this.externalServiceRegistration, 'DecomposedESRFinalizer should have set .ESR'),
fullName: ensureString(parent),
},
writeInfos: [
{
output: join(
ensure(this.externalServiceRegistration?.directoryName, 'directory name missing'),
`${parent}.externalServiceRegistration`
),
source: new JsToXml({ ExternalServiceRegistration: { ...esrRecord } }),
},
],
})
);
return Promise.resolve(writerFormats);
}
}
4 changes: 2 additions & 2 deletions src/convert/transformers/baseMetadataTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
*/
import { MetadataTransformer, WriteInfo } from '../types';
import { ConvertContext } from '../convertContext/convertContext';
import { SourceComponent } from '../../resolve/sourceComponent';
import { RegistryAccess } from '../../registry/registryAccess';
import { SourceComponent } from '../../resolve';
import { RegistryAccess } from '../../registry';

export abstract class BaseMetadataTransformer implements MetadataTransformer {
public readonly context: ConvertContext;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* Copyright (c) 2023, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import * as path from 'node:path';
import { Readable } from 'node:stream';
import * as yaml from 'yaml';
import { XMLBuilder } from 'fast-xml-parser';
import type { ExternalServiceRegistration } from '@jsforce/jsforce-node/lib/api/metadata/schema';
import { WriteInfo } from '../types';
import { SourceComponent } from '../../resolve';
import { DEFAULT_PACKAGE_ROOT_SFDX, META_XML_SUFFIX, XML_DECL, XML_NS_KEY } from '../../common';
import { BaseMetadataTransformer } from './baseMetadataTransformer';

type SchemaType = 'json' | 'yaml';

type ESR = {
ExternalServiceRegistration: ExternalServiceRegistration & { schemaUploadFileExtension: SchemaType };
};

const xmlDeclaration = '<?xml version="1.0" encoding="UTF-8"?>\n';

export class DecomposeExternalServiceRegistrationTransformer extends BaseMetadataTransformer {
public async toSourceFormat(input: {
component: SourceComponent;
mergeWith?: SourceComponent | undefined;
}): Promise<WriteInfo[]> {
this.context.decomposedExternalServiceRegistration.externalServiceRegistration ??=
this.registry.getTypeByName('ExternalServiceRegistration');
const writeInfos: WriteInfo[] = [];
const { component } = input;
const outputDir = path.join(
this.getOutputFolder('source', component),
this.context.decomposedExternalServiceRegistration.externalServiceRegistration.directoryName
);
const xmlContent = { ...(await component.parseXml<ESR>()).ExternalServiceRegistration };

// Extract schema content
const schemaContent: string = xmlContent.schema ?? '';
const schemaType = xmlContent.schemaUploadFileExtension ?? this.getSchemaType(schemaContent);
const asYaml = schemaType === 'yaml' ? schemaContent : yaml.stringify(JSON.parse(schemaContent));
const schemaFileName = `${component.fullName}.yaml`;
const schemaFilePath = path.join(path.dirname(outputDir), schemaFileName);

// make sure the schema type is set
xmlContent.schemaUploadFileExtension = schemaType;

// Write schema content to file
writeInfos.push({
source: Readable.from(asYaml),
output: schemaFilePath,
});

// Remove schema content from ESR content
delete xmlContent.schema;

// Write remaining ESR content to file
const esrFileName = `${component.fullName}.externalServiceRegistration`;
const esrFilePath = path.join(path.dirname(outputDir), `${esrFileName}${META_XML_SUFFIX}`);
const xmlBuilder = new XMLBuilder({
format: true,
ignoreAttributes: false,
suppressUnpairedNode: true,
processEntities: true,
indentBy: ' ',
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const source = xmlBuilder.build({ ExternalServiceRegistration: xmlContent });
writeInfos.push({
source: Readable.from(Buffer.from(xmlDeclaration + source)),
output: esrFilePath,
});

return writeInfos;
}

public async toMetadataFormat(component: SourceComponent): Promise<WriteInfo[]> {
// only need to do this once
this.context.decomposedExternalServiceRegistration.externalServiceRegistration ??=
this.registry.getTypeByName('ExternalServiceRegistration');
const esrFilePath = component.xml;
const esrContent = { ...(await component.parseXml<ESR>()).ExternalServiceRegistration };

// Read schema content from file
const schemaFileName = `${component.fullName}.yaml`; // or .json based on your logic
const schemaFilePath = path.join(path.dirname(esrFilePath ?? ''), schemaFileName);
// load the schema content from the file
const schemaContent = (await component.tree.readFile(schemaFilePath)).toString();
// Add schema content back to ESR content in its original format
// if the original format was JSON, then convert the yaml to json otherwise leave as is
esrContent.schema =
esrContent.schemaUploadFileExtension === 'json'
? JSON.stringify(yaml.parse(schemaContent), undefined, 2)
: schemaContent;

// Write combined content back to md format
this.context.decomposedExternalServiceRegistration.transactionState.esrRecords.set(component.fullName, {
// @ts-expect-error Object literal may only specify known properties
[XML_NS_KEY]: XML_DECL,
...esrContent,
});

return [];
}

// eslint-disable-next-line class-methods-use-this
private getOutputFolder(format: string, component: SourceComponent, mergeWith?: SourceComponent): string {
const base = format === 'source' ? DEFAULT_PACKAGE_ROOT_SFDX : '';
const { type } = mergeWith ?? component;
return path.join(base, type.directoryName);
}

// eslint-disable-next-line class-methods-use-this
private getSchemaType(content: string): SchemaType {
return content.trim().startsWith('{') ? 'json' : 'yaml';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export class DecomposedPermissionSetTransformer extends BaseMetadataTransformer
}

/**
* will decomopse a .permissionset into a directory containing files, and an 'objectSettings' folder for object-specific settings
* will decompose a .permissionset into a directory containing files, and an 'objectSettings' folder for object-specific settings
*
* @param {SourceComponent} component A SourceComponent representing a metadata-formatted permission set
* @param {SourceComponent | undefined} mergeWith any existing source-formatted permission sets to be merged with, think existing source merging with new information from a retrieve
Expand Down
12 changes: 6 additions & 6 deletions src/convert/transformers/metadataTransformerFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,22 @@
*/
import { Messages } from '@salesforce/core';
import { MetadataTransformer } from '../types';
import { SourceComponent } from '../../resolve/sourceComponent';
import { SourceComponent } from '../../resolve';
import { ConvertContext } from '../convertContext/convertContext';
import { RegistryAccess } from '../../registry/registryAccess';
import { RegistryAccess } from '../../registry';
import { DefaultMetadataTransformer } from './defaultMetadataTransformer';
import { DecomposedMetadataTransformer } from './decomposedMetadataTransformer';
import { StaticResourceMetadataTransformer } from './staticResourceMetadataTransformer';
import { NonDecomposedMetadataTransformer } from './nonDecomposedMetadataTransformer';
import { LabelMetadataTransformer, LabelsMetadataTransformer } from './decomposeLabelsTransformer';
import { DecomposedPermissionSetTransformer } from './decomposedPermissionSetTransformer';
import { DecomposeExternalServiceRegistrationTransformer } from './decomposeExternalServiceRegistrationTransformer';

Messages.importMessagesDirectory(__dirname);
const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr');

export class MetadataTransformerFactory {
private registry: RegistryAccess;
private context: ConvertContext;

public constructor(registry: RegistryAccess, context = new ConvertContext()) {
public constructor(private readonly registry: RegistryAccess, private readonly context = new ConvertContext()) {
this.registry = registry;
this.context = context;
}
Expand All @@ -48,6 +46,8 @@ export class MetadataTransformerFactory {
return component.type.name === 'CustomLabels'
? new LabelsMetadataTransformer(this.registry, this.context)
: new LabelMetadataTransformer(this.registry, this.context);
case 'decomposeExternalServiceRegistration':
return new DecomposeExternalServiceRegistrationTransformer(this.registry, this.context);
default:
throw messages.createError('error_missing_transformer', [type.name, transformerId]);
}
Expand Down
43 changes: 43 additions & 0 deletions src/registry/presets/decomposeExternalServiceRegistrationBeta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"types": {
"externalserviceregistration": {
"children": {
"types": {
"yaml": {
"strategies": {
"adapter": "partiallyDecomposed"
},
"directoryName": "externalServiceRegistrations",
"id": "yaml",
"isAddressable": false,
"name": "OAS Yaml Schema",
"suffix": "yaml",
"xmlElementName": "schema"
}
},
"suffixes": {
"yaml": "yaml"
}
},
"directoryName": "externalServiceRegistrations",
"id": "externalserviceregistration",
"ignoreParsedFullName": false,
"name": "ExternalServiceRegistration",
"strategies": {
"adapter": "partiallyDecomposed",
"decomposition": "topLevel",
"transformer": "decomposeExternalServiceRegistration"
},
"suffix": "externalServiceRegistration",
"supportsPartialDelete": false
}
},
"suffixes": {
"yaml": "yaml",
"externalServiceRegistration": "externalserviceregistration"
},
"strictDirectoryNames": {},
"childTypes": {
"yaml": "externalserviceregistration"
}
}
2 changes: 2 additions & 0 deletions src/registry/presets/presetMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as decomposePermissionSetBeta from './decomposePermissionSetBeta.json';
import * as decomposePermissionSetBeta2 from './decomposePermissionSetBeta2.json';
import * as decomposeSharingRulesBeta from './decomposeSharingRulesBeta.json';
import * as decomposeWorkflowBeta from './decomposeWorkflowBeta.json';
import * as decomposeExternalServiceRegistrationBeta from './decomposeExternalServiceRegistrationBeta.json';

export const presetMap = new Map<string, MetadataRegistry>([
['decomposeCustomLabelsBeta2', decomposeCustomLabelsBeta2 as MetadataRegistry],
Expand All @@ -22,4 +23,5 @@ export const presetMap = new Map<string, MetadataRegistry>([
['decomposePermissionSetBeta2', decomposePermissionSetBeta2 as MetadataRegistry],
['decomposeSharingRulesBeta', decomposeSharingRulesBeta as MetadataRegistry],
['decomposeWorkflowBeta', decomposeWorkflowBeta as MetadataRegistry],
['decomposeExternalServiceRegistrationBeta', decomposeExternalServiceRegistrationBeta as MetadataRegistry],
]);
12 changes: 10 additions & 2 deletions src/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,14 +139,22 @@ export type MetadataType = {
* Configuration for resolving and converting components of the type.
*/
strategies?: {
adapter: 'mixedContent' | 'matchingContentFile' | 'decomposed' | 'digitalExperience' | 'bundle' | 'default';
adapter:
| 'mixedContent'
| 'matchingContentFile'
| 'decomposed'
| 'digitalExperience'
| 'bundle'
| 'default'
| 'partiallyDecomposed';
transformer?:
| 'decomposed'
| 'staticResource'
| 'nonDecomposed'
| 'standard'
| 'decomposedLabels'
| 'decomposedPermissionSet';
| 'decomposedPermissionSet'
| 'decomposeExternalServiceRegistration';
decomposition?: 'topLevel' | 'folderPerType';
recomposition?: 'startEmpty';
};
Expand Down
Loading

0 comments on commit dc7d20e

Please sign in to comment.