Skip to content

Commit 3f06611

Browse files
committed
feat: add soap command
1 parent 1c493d1 commit 3f06611

File tree

3 files changed

+251
-0
lines changed

3 files changed

+251
-0
lines changed

command-snapshot.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,13 @@
1414
"flagChars": ["H", "S", "X", "b", "f", "i", "o"],
1515
"flags": ["body", "file", "flags-dir", "header", "include", "method", "stream-to-file", "target-org"],
1616
"plugin": "@salesforce/plugin-api"
17+
},
18+
{
19+
"alias": [],
20+
"command": "api:request:soap",
21+
"flagAliases": [],
22+
"flagChars": ["o"],
23+
"flags": ["body", "flags-dir", "output-file", "target-org"],
24+
"plugin": "@salesforce/plugin-api"
1725
}
1826
]

messages/soap.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# summary
2+
3+
Make an authenticated SOAP API request to a Salesforce org.
4+
5+
# description
6+
7+
This command allows you to make SOAP API requests to Salesforce orgs. You provide the SOAP Body content (the method call), and the command automatically wraps it in a complete SOAP envelope with authentication headers.
8+
9+
The command constructs a full SOAP envelope with:
10+
11+
- SOAP Header containing SessionHeader with your org's access token
12+
- SOAP Body containing your provided XML content
13+
14+
For more information about the Salesforce SOAP API, see https://developer.salesforce.com/docs/atlas.en-us.api.meta/api/sforce_api_quickstart.htm.
15+
16+
# examples
17+
18+
- Make a SOAP request to get server timestamp using the Partner API:
19+
20+
<%= config.bin %> <%= command.id %> /services/Soap/u/58.0/ --body '<getServerTimestamp/>' --target-org my-org
21+
22+
- Read SOAP Body content from a file:
23+
24+
<%= config.bin %> <%= command.id %> /services/Soap/u/58.0/ --body @soap-body.xml --target-org my-org
25+
26+
- Save the SOAP response to a file:
27+
28+
<%= config.bin %> <%= command.id %> /services/Soap/u/58.0/ --body '<getServerTimestamp/>' --target-org my-org --output-file response.xml
29+
30+
- Pipe SOAP Body content from standard input:
31+
32+
$ echo '<getServerTimestamp/>' | <%= config.bin %> <%= command.id %> /services/Soap/u/58.0/ --body - --target-org my-org
33+
34+
# flags.body.summary
35+
36+
File or XML content for the SOAP Body. Specify "-" to read from standard input. If passing a file, prefix the filename with '@'. The command will extract the SOAP Body content if you provide a full SOAP envelope, or use your content as-is if it's just the method call.
37+
38+
# flags.output-file.summary
39+
40+
File path to save the SOAP response. If not specified, the response is printed to stdout.

src/commands/api/request/soap.ts

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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 { readFileSync, writeFileSync } from 'node:fs';
8+
import * as fs from 'node:fs';
9+
import { ProxyAgent } from 'proxy-agent';
10+
import { Flags, SfCommand } from '@salesforce/sf-plugins-core';
11+
import { Messages, Org, SfError } from '@salesforce/core';
12+
import { Args } from '@oclif/core';
13+
import got from 'got';
14+
15+
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
16+
const messages = Messages.loadMessages('@salesforce/plugin-api', 'soap');
17+
18+
/**
19+
* Extract namespace from XML content if present
20+
*/
21+
function extractNamespace(xmlContent: string): string | undefined {
22+
// Try to find xmlns attribute in the root element
23+
const xmlnsMatch = xmlContent.match(/<[^>]+xmlns\s*=\s*["']([^"']+)["']/);
24+
if (xmlnsMatch) {
25+
return xmlnsMatch[1];
26+
}
27+
return undefined;
28+
}
29+
30+
/**
31+
* Remove xmlns attribute from root element if present
32+
*/
33+
function removeXmlnsFromBody(bodyContent: string): string {
34+
// Remove xmlns attribute from the root element
35+
return bodyContent.replace(/<([^>\s]+)([^>]*)\s+xmlns\s*=\s*["'][^"']+["']([^>]*)>/i, '<$1$2$3>');
36+
}
37+
38+
/**
39+
* Extract SOAP Body content from user-provided XML.
40+
* If user provides a full SOAP envelope, extract just the Body content.
41+
* If user provides just Body content, return it as-is.
42+
*/
43+
function extractSoapBody(xmlContent: string): string {
44+
// Try to find SOAP Body content
45+
// Match <soapenv:Body>...</soapenv:Body> or <Body>...</Body> or <soap:Body>...</soap:Body>
46+
const bodyMatch = xmlContent.match(
47+
/<(?:soapenv|soap|SOAP-ENV):Body[^>]*>([\s\S]*?)<\/(?:soapenv|soap|SOAP-ENV):Body>/i
48+
);
49+
if (bodyMatch) {
50+
return bodyMatch[1].trim();
51+
}
52+
53+
// Try without namespace prefix
54+
const bodyMatchNoNs = xmlContent.match(/<Body[^>]*>([\s\S]*?)<\/Body>/i);
55+
if (bodyMatchNoNs) {
56+
return bodyMatchNoNs[1].trim();
57+
}
58+
59+
// If no SOAP envelope structure found, assume user provided just the Body content
60+
return xmlContent.trim();
61+
}
62+
63+
/**
64+
* Escape XML special characters
65+
*/
66+
function escapeXml(text: string): string {
67+
return text
68+
.replace(/&/g, '&amp;')
69+
.replace(/</g, '&lt;')
70+
.replace(/>/g, '&gt;')
71+
.replace(/"/g, '&quot;')
72+
.replace(/'/g, '&apos;');
73+
}
74+
75+
/**
76+
* Create SOAP envelope with SessionHeader and Body content
77+
*/
78+
function createSoapEnvelope(
79+
bodyContent: string,
80+
accessToken: string,
81+
xmlns: string = 'urn:partner.soap.sforce.com'
82+
): string {
83+
const escapedToken = escapeXml(accessToken);
84+
85+
return `<?xml version="1.0" encoding="UTF-8"?>
86+
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
87+
<soapenv:Header xmlns="${xmlns}">
88+
<SessionHeader>
89+
<sessionId>${escapedToken}</sessionId>
90+
</SessionHeader>
91+
</soapenv:Header>
92+
<soapenv:Body xmlns="${xmlns}">
93+
${bodyContent}
94+
</soapenv:Body>
95+
</soapenv:Envelope>`;
96+
}
97+
98+
export class Soap extends SfCommand<void> {
99+
public static readonly summary = messages.getMessage('summary');
100+
public static readonly description = messages.getMessage('description');
101+
public static readonly examples = messages.getMessages('examples');
102+
public static state = 'beta';
103+
public static enableJsonFlag = false;
104+
public static readonly flags = {
105+
'target-org': Flags.requiredOrg(),
106+
body: Flags.string({
107+
summary: messages.getMessage('flags.body.summary'),
108+
allowStdin: true,
109+
helpValue: 'file',
110+
required: true,
111+
}),
112+
'output-file': Flags.string({
113+
summary: messages.getMessage('flags.output-file.summary'),
114+
helpValue: 'file',
115+
}),
116+
};
117+
118+
public static args = {
119+
url: Args.string({
120+
description: 'SOAP API endpoint',
121+
required: true,
122+
}),
123+
};
124+
125+
public async run(): Promise<void> {
126+
const { flags, args } = await this.parse(Soap);
127+
128+
const org = flags['target-org'];
129+
const outputFile = flags['output-file'];
130+
131+
// Read body content
132+
let bodyContent: string;
133+
if (flags.body.startsWith('@')) {
134+
// Remove '@' prefix and read file
135+
bodyContent = readFileSync(flags.body.substring(1), 'utf8');
136+
} else if (fs.existsSync(flags.body)) {
137+
// Check if it's a file path
138+
bodyContent = readFileSync(flags.body, 'utf8');
139+
} else {
140+
// Use body content directly (or stdin content if allowStdin handled it)
141+
bodyContent = flags.body;
142+
}
143+
144+
// Extract SOAP Body content from user input
145+
let soapBodyContent = extractSoapBody(bodyContent);
146+
147+
// Detect namespace from body content
148+
let namespace = extractNamespace(bodyContent);
149+
if (!namespace) {
150+
// Default to partner namespace if not detected
151+
namespace = 'urn:partner.soap.sforce.com';
152+
}
153+
154+
// Remove xmlns attribute from body content since it will be set on the Body element
155+
soapBodyContent = removeXmlnsFromBody(soapBodyContent);
156+
157+
// Build URL
158+
const specifiedUrl = args.url.replace(/^\//, '');
159+
const url = new URL(`${org.getField<string>(Org.Fields.INSTANCE_URL)}/${specifiedUrl}`);
160+
161+
// Refresh access token to ensure we have a valid access token
162+
await org.refreshAuth();
163+
164+
// Get access token
165+
// eslint-disable-next-line sf-plugin/get-connection-with-version
166+
const accessToken = org.getConnection().getConnectionOptions().accessToken!;
167+
if (!accessToken) {
168+
throw new SfError('No access token available for the org');
169+
}
170+
171+
// Create SOAP envelope with detected namespace
172+
const soapEnvelope = createSoapEnvelope(soapBodyContent, accessToken, namespace);
173+
174+
// Make SOAP request
175+
const options = {
176+
agent: { https: new ProxyAgent() },
177+
method: 'POST' as const,
178+
headers: {
179+
'Content-Type': 'text/xml',
180+
SOAPAction: '""',
181+
},
182+
body: soapEnvelope,
183+
throwHttpErrors: false,
184+
followRedirect: false,
185+
};
186+
187+
const response = await got(url, options);
188+
189+
// Handle response
190+
if (outputFile) {
191+
writeFileSync(outputFile, response.body, 'utf8');
192+
this.log(`Response saved to ${outputFile}`);
193+
} else {
194+
// Print response body to stdout
195+
this.log(response.body);
196+
}
197+
198+
// Set exit code for errors
199+
if (response.statusCode >= 400) {
200+
process.exitCode = 1;
201+
}
202+
}
203+
}

0 commit comments

Comments
 (0)