Skip to content

Commit dc7d20e

Browse files
peternhaleiowillhoitsvc-cli-botmcarvin8IdanRoas
authored
feat: add decomposition preset for external service registration (#1493)
* 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 <[email protected]> * chore: fix test * chore: address review suggestions --------- Co-authored-by: Willhoit <[email protected]> Co-authored-by: svc-cli-bot <[email protected]> Co-authored-by: Matt Carvin <[email protected]> Co-authored-by: Idan Roas <[email protected]> Co-authored-by: Willie Ruemmele <[email protected]> Co-authored-by: PKV <[email protected]> Co-authored-by: Steve Hetzel <[email protected]>
1 parent f2b53e1 commit dc7d20e

File tree

50 files changed

+2188
-2453
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+2188
-2453
lines changed

CHANGELOG.md

Lines changed: 458 additions & 1739 deletions
Large diffs are not rendered by default.

METADATA_SUPPORT.md

Lines changed: 669 additions & 670 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525
"node": ">=18.0.0"
2626
},
2727
"dependencies": {
28-
"@salesforce/core": "^8.8.0",
29-
"@salesforce/kit": "^3.2.2",
28+
"@salesforce/core": "^8.8.2",
29+
"@salesforce/kit": "^3.2.3",
3030
"@salesforce/ts-types": "^2.0.12",
3131
"fast-levenshtein": "^3.0.0",
3232
"fast-xml-parser": "^4.5.1",
@@ -37,7 +37,8 @@
3737
"jszip": "^3.10.1",
3838
"mime": "2.6.0",
3939
"minimatch": "^9.0.5",
40-
"proxy-agent": "^6.4.0"
40+
"proxy-agent": "^6.4.0",
41+
"yaml": "^2.6.1"
4142
},
4243
"devDependencies": {
4344
"@jsforce/jsforce-node": "^3.6.3",

src/convert/convertContext/convertContext.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { DecompositionFinalizer } from './decompositionFinalizer';
1111
import { ConvertTransactionFinalizer } from './transactionFinalizer';
1212
import { DecomposedLabelsFinalizer } from './decomposedLabelsFinalizer';
1313
import { DecomposedPermissionSetFinalizer } from './decomposedPermissionSetFinalizer';
14+
import { DecomposedExternalServiceRegistrationFinalizer } from './decomposedExternalServiceRegistrationFinalizer';
1415
/**
1516
* A state manager over the course of a single metadata conversion call.
1617
*/
@@ -20,6 +21,7 @@ export class ConvertContext {
2021
public readonly nonDecomposition = new NonDecompositionFinalizer();
2122
public readonly decomposedLabels = new DecomposedLabelsFinalizer();
2223
public readonly decomposedPermissionSet = new DecomposedPermissionSetFinalizer();
24+
public readonly decomposedExternalServiceRegistration = new DecomposedExternalServiceRegistrationFinalizer();
2325

2426
// eslint-disable-next-line @typescript-eslint/require-await
2527
public async *executeFinalizers(defaultDirectory?: string): AsyncIterable<WriterFormat[]> {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright (c) 2025, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import { join } from 'node:path';
8+
import type { ExternalServiceRegistration } from '@jsforce/jsforce-node/lib/api/metadata/schema';
9+
import { ensure, ensureString } from '@salesforce/ts-types';
10+
import { WriterFormat } from '../types';
11+
import { MetadataType } from '../../registry';
12+
import { JsToXml } from '../streams';
13+
import { ConvertTransactionFinalizer } from './transactionFinalizer';
14+
15+
type ExternalServiceRegistrationState = {
16+
esrRecords: Map<string, ExternalServiceRegistration>;
17+
};
18+
19+
export class DecomposedExternalServiceRegistrationFinalizer extends ConvertTransactionFinalizer<ExternalServiceRegistrationState> {
20+
/** 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 */
21+
public externalServiceRegistration?: MetadataType;
22+
public transactionState: ExternalServiceRegistrationState = {
23+
esrRecords: new Map<string, ExternalServiceRegistration>(),
24+
};
25+
// eslint-disable-next-line class-methods-use-this
26+
public defaultDir: string | undefined;
27+
28+
public finalize(defaultDirectory: string | undefined): Promise<WriterFormat[]> {
29+
this.defaultDir = defaultDirectory;
30+
const writerFormats: WriterFormat[] = [];
31+
this.transactionState.esrRecords.forEach((esrRecord, parent) =>
32+
writerFormats.push({
33+
component: {
34+
type: ensure(this.externalServiceRegistration, 'DecomposedESRFinalizer should have set .ESR'),
35+
fullName: ensureString(parent),
36+
},
37+
writeInfos: [
38+
{
39+
output: join(
40+
ensure(this.externalServiceRegistration?.directoryName, 'directory name missing'),
41+
`${parent}.externalServiceRegistration`
42+
),
43+
source: new JsToXml({ ExternalServiceRegistration: { ...esrRecord } }),
44+
},
45+
],
46+
})
47+
);
48+
return Promise.resolve(writerFormats);
49+
}
50+
}

src/convert/transformers/baseMetadataTransformer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
*/
77
import { MetadataTransformer, WriteInfo } from '../types';
88
import { ConvertContext } from '../convertContext/convertContext';
9-
import { SourceComponent } from '../../resolve/sourceComponent';
10-
import { RegistryAccess } from '../../registry/registryAccess';
9+
import { SourceComponent } from '../../resolve';
10+
import { RegistryAccess } from '../../registry';
1111

1212
export abstract class BaseMetadataTransformer implements MetadataTransformer {
1313
public readonly context: ConvertContext;
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
* Copyright (c) 2023, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import * as path from 'node:path';
8+
import { Readable } from 'node:stream';
9+
import * as yaml from 'yaml';
10+
import { XMLBuilder } from 'fast-xml-parser';
11+
import type { ExternalServiceRegistration } from '@jsforce/jsforce-node/lib/api/metadata/schema';
12+
import { WriteInfo } from '../types';
13+
import { SourceComponent } from '../../resolve';
14+
import { DEFAULT_PACKAGE_ROOT_SFDX, META_XML_SUFFIX, XML_DECL, XML_NS_KEY } from '../../common';
15+
import { BaseMetadataTransformer } from './baseMetadataTransformer';
16+
17+
type SchemaType = 'json' | 'yaml';
18+
19+
type ESR = {
20+
ExternalServiceRegistration: ExternalServiceRegistration & { schemaUploadFileExtension: SchemaType };
21+
};
22+
23+
const xmlDeclaration = '<?xml version="1.0" encoding="UTF-8"?>\n';
24+
25+
export class DecomposeExternalServiceRegistrationTransformer extends BaseMetadataTransformer {
26+
public async toSourceFormat(input: {
27+
component: SourceComponent;
28+
mergeWith?: SourceComponent | undefined;
29+
}): Promise<WriteInfo[]> {
30+
this.context.decomposedExternalServiceRegistration.externalServiceRegistration ??=
31+
this.registry.getTypeByName('ExternalServiceRegistration');
32+
const writeInfos: WriteInfo[] = [];
33+
const { component } = input;
34+
const outputDir = path.join(
35+
this.getOutputFolder('source', component),
36+
this.context.decomposedExternalServiceRegistration.externalServiceRegistration.directoryName
37+
);
38+
const xmlContent = { ...(await component.parseXml<ESR>()).ExternalServiceRegistration };
39+
40+
// Extract schema content
41+
const schemaContent: string = xmlContent.schema ?? '';
42+
const schemaType = xmlContent.schemaUploadFileExtension ?? this.getSchemaType(schemaContent);
43+
const asYaml = schemaType === 'yaml' ? schemaContent : yaml.stringify(JSON.parse(schemaContent));
44+
const schemaFileName = `${component.fullName}.yaml`;
45+
const schemaFilePath = path.join(path.dirname(outputDir), schemaFileName);
46+
47+
// make sure the schema type is set
48+
xmlContent.schemaUploadFileExtension = schemaType;
49+
50+
// Write schema content to file
51+
writeInfos.push({
52+
source: Readable.from(asYaml),
53+
output: schemaFilePath,
54+
});
55+
56+
// Remove schema content from ESR content
57+
delete xmlContent.schema;
58+
59+
// Write remaining ESR content to file
60+
const esrFileName = `${component.fullName}.externalServiceRegistration`;
61+
const esrFilePath = path.join(path.dirname(outputDir), `${esrFileName}${META_XML_SUFFIX}`);
62+
const xmlBuilder = new XMLBuilder({
63+
format: true,
64+
ignoreAttributes: false,
65+
suppressUnpairedNode: true,
66+
processEntities: true,
67+
indentBy: ' ',
68+
});
69+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
70+
const source = xmlBuilder.build({ ExternalServiceRegistration: xmlContent });
71+
writeInfos.push({
72+
source: Readable.from(Buffer.from(xmlDeclaration + source)),
73+
output: esrFilePath,
74+
});
75+
76+
return writeInfos;
77+
}
78+
79+
public async toMetadataFormat(component: SourceComponent): Promise<WriteInfo[]> {
80+
// only need to do this once
81+
this.context.decomposedExternalServiceRegistration.externalServiceRegistration ??=
82+
this.registry.getTypeByName('ExternalServiceRegistration');
83+
const esrFilePath = component.xml;
84+
const esrContent = { ...(await component.parseXml<ESR>()).ExternalServiceRegistration };
85+
86+
// Read schema content from file
87+
const schemaFileName = `${component.fullName}.yaml`; // or .json based on your logic
88+
const schemaFilePath = path.join(path.dirname(esrFilePath ?? ''), schemaFileName);
89+
// load the schema content from the file
90+
const schemaContent = (await component.tree.readFile(schemaFilePath)).toString();
91+
// Add schema content back to ESR content in its original format
92+
// if the original format was JSON, then convert the yaml to json otherwise leave as is
93+
esrContent.schema =
94+
esrContent.schemaUploadFileExtension === 'json'
95+
? JSON.stringify(yaml.parse(schemaContent), undefined, 2)
96+
: schemaContent;
97+
98+
// Write combined content back to md format
99+
this.context.decomposedExternalServiceRegistration.transactionState.esrRecords.set(component.fullName, {
100+
// @ts-expect-error Object literal may only specify known properties
101+
[XML_NS_KEY]: XML_DECL,
102+
...esrContent,
103+
});
104+
105+
return [];
106+
}
107+
108+
// eslint-disable-next-line class-methods-use-this
109+
private getOutputFolder(format: string, component: SourceComponent, mergeWith?: SourceComponent): string {
110+
const base = format === 'source' ? DEFAULT_PACKAGE_ROOT_SFDX : '';
111+
const { type } = mergeWith ?? component;
112+
return path.join(base, type.directoryName);
113+
}
114+
115+
// eslint-disable-next-line class-methods-use-this
116+
private getSchemaType(content: string): SchemaType {
117+
return content.trim().startsWith('{') ? 'json' : 'yaml';
118+
}
119+
}

src/convert/transformers/decomposedPermissionSetTransformer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export class DecomposedPermissionSetTransformer extends BaseMetadataTransformer
6868
}
6969

7070
/**
71-
* will decomopse a .permissionset into a directory containing files, and an 'objectSettings' folder for object-specific settings
71+
* will decompose a .permissionset into a directory containing files, and an 'objectSettings' folder for object-specific settings
7272
*
7373
* @param {SourceComponent} component A SourceComponent representing a metadata-formatted permission set
7474
* @param {SourceComponent | undefined} mergeWith any existing source-formatted permission sets to be merged with, think existing source merging with new information from a retrieve

src/convert/transformers/metadataTransformerFactory.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,22 @@
66
*/
77
import { Messages } from '@salesforce/core';
88
import { MetadataTransformer } from '../types';
9-
import { SourceComponent } from '../../resolve/sourceComponent';
9+
import { SourceComponent } from '../../resolve';
1010
import { ConvertContext } from '../convertContext/convertContext';
11-
import { RegistryAccess } from '../../registry/registryAccess';
11+
import { RegistryAccess } from '../../registry';
1212
import { DefaultMetadataTransformer } from './defaultMetadataTransformer';
1313
import { DecomposedMetadataTransformer } from './decomposedMetadataTransformer';
1414
import { StaticResourceMetadataTransformer } from './staticResourceMetadataTransformer';
1515
import { NonDecomposedMetadataTransformer } from './nonDecomposedMetadataTransformer';
1616
import { LabelMetadataTransformer, LabelsMetadataTransformer } from './decomposeLabelsTransformer';
1717
import { DecomposedPermissionSetTransformer } from './decomposedPermissionSetTransformer';
18+
import { DecomposeExternalServiceRegistrationTransformer } from './decomposeExternalServiceRegistrationTransformer';
1819

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

2223
export class MetadataTransformerFactory {
23-
private registry: RegistryAccess;
24-
private context: ConvertContext;
25-
26-
public constructor(registry: RegistryAccess, context = new ConvertContext()) {
24+
public constructor(private readonly registry: RegistryAccess, private readonly context = new ConvertContext()) {
2725
this.registry = registry;
2826
this.context = context;
2927
}
@@ -48,6 +46,8 @@ export class MetadataTransformerFactory {
4846
return component.type.name === 'CustomLabels'
4947
? new LabelsMetadataTransformer(this.registry, this.context)
5048
: new LabelMetadataTransformer(this.registry, this.context);
49+
case 'decomposeExternalServiceRegistration':
50+
return new DecomposeExternalServiceRegistrationTransformer(this.registry, this.context);
5151
default:
5252
throw messages.createError('error_missing_transformer', [type.name, transformerId]);
5353
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"types": {
3+
"externalserviceregistration": {
4+
"children": {
5+
"types": {
6+
"yaml": {
7+
"strategies": {
8+
"adapter": "partiallyDecomposed"
9+
},
10+
"directoryName": "externalServiceRegistrations",
11+
"id": "yaml",
12+
"isAddressable": false,
13+
"name": "OAS Yaml Schema",
14+
"suffix": "yaml",
15+
"xmlElementName": "schema"
16+
}
17+
},
18+
"suffixes": {
19+
"yaml": "yaml"
20+
}
21+
},
22+
"directoryName": "externalServiceRegistrations",
23+
"id": "externalserviceregistration",
24+
"ignoreParsedFullName": false,
25+
"name": "ExternalServiceRegistration",
26+
"strategies": {
27+
"adapter": "partiallyDecomposed",
28+
"decomposition": "topLevel",
29+
"transformer": "decomposeExternalServiceRegistration"
30+
},
31+
"suffix": "externalServiceRegistration",
32+
"supportsPartialDelete": false
33+
}
34+
},
35+
"suffixes": {
36+
"yaml": "yaml",
37+
"externalServiceRegistration": "externalserviceregistration"
38+
},
39+
"strictDirectoryNames": {},
40+
"childTypes": {
41+
"yaml": "externalserviceregistration"
42+
}
43+
}

src/registry/presets/presetMap.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import * as decomposePermissionSetBeta from './decomposePermissionSetBeta.json';
1414
import * as decomposePermissionSetBeta2 from './decomposePermissionSetBeta2.json';
1515
import * as decomposeSharingRulesBeta from './decomposeSharingRulesBeta.json';
1616
import * as decomposeWorkflowBeta from './decomposeWorkflowBeta.json';
17+
import * as decomposeExternalServiceRegistrationBeta from './decomposeExternalServiceRegistrationBeta.json';
1718

1819
export const presetMap = new Map<string, MetadataRegistry>([
1920
['decomposeCustomLabelsBeta2', decomposeCustomLabelsBeta2 as MetadataRegistry],
@@ -22,4 +23,5 @@ export const presetMap = new Map<string, MetadataRegistry>([
2223
['decomposePermissionSetBeta2', decomposePermissionSetBeta2 as MetadataRegistry],
2324
['decomposeSharingRulesBeta', decomposeSharingRulesBeta as MetadataRegistry],
2425
['decomposeWorkflowBeta', decomposeWorkflowBeta as MetadataRegistry],
26+
['decomposeExternalServiceRegistrationBeta', decomposeExternalServiceRegistrationBeta as MetadataRegistry],
2527
]);

src/registry/types.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,14 +139,22 @@ export type MetadataType = {
139139
* Configuration for resolving and converting components of the type.
140140
*/
141141
strategies?: {
142-
adapter: 'mixedContent' | 'matchingContentFile' | 'decomposed' | 'digitalExperience' | 'bundle' | 'default';
142+
adapter:
143+
| 'mixedContent'
144+
| 'matchingContentFile'
145+
| 'decomposed'
146+
| 'digitalExperience'
147+
| 'bundle'
148+
| 'default'
149+
| 'partiallyDecomposed';
143150
transformer?:
144151
| 'decomposed'
145152
| 'staticResource'
146153
| 'nonDecomposed'
147154
| 'standard'
148155
| 'decomposedLabels'
149-
| 'decomposedPermissionSet';
156+
| 'decomposedPermissionSet'
157+
| 'decomposeExternalServiceRegistration';
150158
decomposition?: 'topLevel' | 'folderPerType';
151159
recomposition?: 'startEmpty';
152160
};

0 commit comments

Comments
 (0)