Skip to content

Commit 03d1f78

Browse files
davefejDPM1david.varga
authored
Support for soap attachments in response. (#1148)
* Add support of MTOM attachments in response * create test for MTOM response attachment functionality * typo fix lastReponseAttachments * fix tests without mocking httpres * lint and coverage fixes * Fix getAttachment test fails on linux (LF to CRLF) * improve code coverage and minor fixes * Rename option mtomResponse to parseReponseAttachments * feat: add mtomAttachments to result * lint fix * remove accidentally re-added request package Co-authored-by: David Polidario-Maddock <[email protected]> Co-authored-by: david.varga <[email protected]>
1 parent d74453d commit 03d1f78

File tree

22 files changed

+344
-20
lines changed

22 files changed

+344
-20
lines changed

Readme.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,9 @@ The `options` argument allows you to customize the client with the following pro
151151
- namespaceArrayElements: provides support for nonstandard array semantics. If true, JSON arrays of the form `{list: [{elem: 1}, {elem: 2}]}` are marshalled into xml as `<list><elem>1</elem></list> <list><elem>2</elem></list>`. If false, marshalls into `<list> <elem>1</elem> <elem>2</elem> </list>`. Default: `true`.
152152
- stream: allows using a stream to parse the XML SOAP response. Default: `false`
153153
- returnSaxStream: enables the library to return the sax stream, transferring to the end user the responsibility of parsing the XML. It can be used only in combination with *stream* argument set to `true`. Default: `false`
154+
- parseReponseAttachments: Treat response as multipart/related response with MTOM attachment. Reach attachments on the `lastResponseAttachments` property of SoapClient. Default: `false`
154155

155-
Note: for versions of node >0.10.X, you may need to specify `{connection: 'keep-alive'}` in SOAP headers to avoid truncation of longer chunked responses.
156+
Note: for versions of node >0.10.X, you may need to specify `{connection: 'keep-alive'}` in SOAP headers to avoid truncation of longer chunked responses.
156157

157158
### soap.listen(*server*, *path*, *services*, *wsdl*, *callback*) - create a new SOAP server that listens on *path* and provides *services*.
158159
*server* can be a [http](https://nodejs.org/api/http.html) Server or [express](http://expressjs.com/) framework based server

package-lock.json

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@
99
"dependencies": {
1010
"axios": "^0.21.1",
1111
"axios-ntlm": "^1.1.6",
12+
"content-type-parser": "^1.0.2",
1213
"debug": "^4.3.1",
14+
"formidable": "^1.2.2",
1315
"get-stream": "^6.0.1",
16+
"httpntlm": "^1.5.2",
1417
"lodash": "^4.17.21",
1518
"sax": ">=0.6",
1619
"strip-bom": "^3.0.0",

src/client.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { IncomingHttpHeaders } from 'http';
1111
import * as _ from 'lodash';
1212
import { v4 as uuidv4 } from 'uuid';
1313
import { HttpClient } from './http';
14-
import { IHeaders, IHttpClient, IOptions, ISecurity, SoapMethod, SoapMethodAsync } from './types';
14+
import { IHeaders, IHttpClient, IMTOMAttachments, IOptions, ISecurity, SoapMethod, SoapMethodAsync } from './types';
1515
import { findPrefix } from './utils';
1616
import { WSDL } from './wsdl';
1717
import { IPort, OperationElement, ServiceElement } from './wsdl/elements';
@@ -53,6 +53,7 @@ export class Client extends EventEmitter {
5353
public lastResponse?: any;
5454
public lastResponseHeaders?: IncomingHttpHeaders;
5555
public lastElapsedTime?: number;
56+
public lastResponseAttachments: IMTOMAttachments;
5657

5758
private wsdl: WSDL;
5859
private httpClient: IHttpClient;
@@ -230,11 +231,12 @@ export class Client extends EventEmitter {
230231
rawResponse: any,
231232
soapHeader: any,
232233
rawRequest: any,
234+
mtomAttachments: any,
233235
) => {
234236
if (err) {
235237
reject(err);
236238
} else {
237-
resolve([result, rawResponse, soapHeader, rawRequest]);
239+
resolve([result, rawResponse, soapHeader, rawRequest, mtomAttachments]);
238240
}
239241
};
240242
method(
@@ -263,8 +265,8 @@ export class Client extends EventEmitter {
263265
extraHeaders = options;
264266
options = temp;
265267
}
266-
this._invoke(method, args, location, (error, result, rawResponse, soapHeader, rawRequest) => {
267-
callback(error, result, rawResponse, soapHeader, rawRequest);
268+
this._invoke(method, args, location, (error, result, rawResponse, soapHeader, rawRequest, mtomAttachments) => {
269+
callback(error, result, rawResponse, soapHeader, rawRequest, mtomAttachments);
268270
}, options, extraHeaders);
269271
};
270272
}
@@ -314,7 +316,7 @@ export class Client extends EventEmitter {
314316

315317
if (!output) {
316318
// one-way, no output expected
317-
return callback(null, null, body, obj.Header, xml);
319+
return callback(null, null, body, obj.Header, xml, response.mtomResponseAttachments);
318320
}
319321

320322
// If it's not HTML and Soap Body is empty
@@ -351,7 +353,7 @@ export class Client extends EventEmitter {
351353
});
352354
}
353355

354-
callback(null, result, body, obj.Header, xml);
356+
callback(null, result, body, obj.Header, xml, response.mtomResponseAttachments);
355357
};
356358

357359
const parseSync = (body, response) => {
@@ -372,7 +374,7 @@ export class Client extends EventEmitter {
372374
error.response = response;
373375
error.body = body;
374376
this.emit('soapError', error, eid);
375-
return callback(error, response, body, undefined, xml);
377+
return callback(error, response, body, undefined, xml, response.mtomResponseAttachments);
376378
}
377379
return finish(obj, body, response);
378380
};
@@ -552,6 +554,7 @@ export class Client extends EventEmitter {
552554
if (response) {
553555
this.lastResponseHeaders = response.headers;
554556
this.lastElapsedTime = response.headers.date;
557+
this.lastResponseAttachments = response.mtomResponseAttachments;
555558
// Added mostly for testability, but possibly useful for debugging
556559
this.lastRequestHeaders = response.config && response.config.headers;
557560
}

src/http.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55

66
import * as req from 'axios';
77
import { NtlmClient } from 'axios-ntlm';
8+
import * as contentTypeParser from 'content-type-parser';
89
import * as debugBuilder from 'debug';
910
import { ReadStream } from 'fs';
1011
import * as url from 'url';
12+
1113
import { v4 as uuidv4 } from 'uuid';
12-
import { IExOptions, IHeaders, IHttpClient, IOptions } from './types';
14+
import { IExOptions, IHeaders, IHttpClient, IMTOMAttachments, IOptions } from './types';
15+
import { parseMTOMResp } from './utils';
1316

1417
const debug = debugBuilder('node-soap');
1518
const VERSION = require('../package.json').version;
@@ -29,10 +32,13 @@ export interface IAttachment {
2932
* @constructor
3033
*/
3134
export class HttpClient implements IHttpClient {
35+
3236
private _request: req.AxiosInstance;
37+
private options: IOptions;
3338

3439
constructor(options?: IOptions) {
3540
options = options || {};
41+
this.options = options;
3642
this._request = options.request || req.default.create();
3743
}
3844

@@ -166,11 +172,41 @@ export class HttpClient implements IHttpClient {
166172
});
167173
req = ntlmReq(options);
168174
} else {
175+
if (this.options.parseReponseAttachments) {
176+
options.responseType = 'arraybuffer';
177+
options.responseEncoding = 'binary';
178+
}
169179
req = this._request(options);
170180
}
171-
181+
const _this = this;
172182
req.then((res) => {
173-
res.data = this.handleResponse(req, res, res.data);
183+
let body;
184+
if (_this.options.parseReponseAttachments) {
185+
const isMultipartResp = res.headers['content-type'] && res.headers['content-type'].toLowerCase().indexOf('multipart/related') > -1;
186+
if (isMultipartResp) {
187+
let boundary;
188+
const parsedContentType = contentTypeParser(res.headers['content-type']);
189+
if (parsedContentType && parsedContentType.parameterList) {
190+
boundary = ((parsedContentType.parameterList as any[]).find((item) => item.key === 'boundary') || {}).value;
191+
}
192+
if (!boundary) {
193+
return callback(new Error('Missing boundary from content-type'));
194+
}
195+
const multipartResponse = parseMTOMResp(res.data, boundary);
196+
197+
// first part is the soap response
198+
const firstPart = multipartResponse.parts.shift();
199+
if (!firstPart || !firstPart.body) {
200+
return callback(new Error('Cannot parse multipart response'));
201+
}
202+
body = firstPart.body.toString('utf8');
203+
(res as any).mtomResponseAttachments = multipartResponse;
204+
} else {
205+
body = res.data.toString('utf8');
206+
}
207+
}
208+
209+
res.data = this.handleResponse(req, res, body || res.data);
174210
callback(null, res, res.data);
175211
return res;
176212
}, (err) => {

src/types.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,17 @@ export interface IHttpClient {
1919
export type ISoapMethod = SoapMethod;
2020
export type SoapMethod = (
2121
args: any,
22-
callback: (err: any, result: any, rawResponse: any, soapHeader: any, rawRequest: any) => void,
22+
callback: (err: any, result: any, rawResponse: any, soapHeader: any, rawRequest: any, mtomAttachments?: IMTOMAttachments) => void,
2323
options?: any,
2424
extraHeaders?: any,
25+
mtomAttachments?: IMTOMAttachments,
2526
) => void;
2627

2728
export type SoapMethodAsync = (
2829
args: any,
2930
options?: any,
3031
extraHeaders?: any,
31-
) => Promise<[any, any, any, any]>;
32+
) => Promise<[any, any, any, any, IMTOMAttachments?]>;
3233

3334
export type ISoapServiceMethod = (args: any, callback?: (data: any) => void, headers?: any, req?: any, res?: any, sender?: any) => any;
3435

@@ -133,6 +134,8 @@ export interface IOptions extends IWsdlBaseOptions {
133134
overridePromiseSuffix?: string;
134135
/** @internal */
135136
WSDL_CACHE?;
137+
/** handle MTOM soapAttachments in response */
138+
parseReponseAttachments?: boolean;
136139
}
137140

138141
export interface IOneWayOptions {
@@ -152,3 +155,10 @@ export interface IServerOptions extends IWsdlBaseOptions {
152155
/** A boolean for controlling chunked transfer encoding in response. Some client (such as Windows 10's MDM enrollment SOAP client) is sensitive to transfer-encoding mode and can't accept chunked response. This option let user disable chunked transfer encoding for such a client. Default to true for backward compatibility. */
153156
enableChunkedEncoding?: boolean;
154157
}
158+
159+
export interface IMTOMAttachments {
160+
parts: Array<{
161+
body: Buffer,
162+
headers: { [key: string]: string },
163+
}>;
164+
}

src/utils.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11

22
import * as crypto from 'crypto';
3+
import { MultipartParser } from 'formidable/lib/multipart_parser.js';
4+
import { IMTOMAttachments } from './types';
35

46
export function passwordDigest(nonce: string, created: string, password: string): string {
57
// digest = base64 ( sha1 ( nonce + created + password ) )
@@ -64,3 +66,51 @@ export function xmlEscape(obj) {
6466

6567
return obj;
6668
}
69+
70+
export function parseMTOMResp(payload: Buffer, boundary: string): IMTOMAttachments {
71+
const resp: IMTOMAttachments = {
72+
parts: [],
73+
};
74+
let headerName = '';
75+
let headerValue = '';
76+
let data: Buffer;
77+
let partIndex = 0;
78+
const parser = new MultipartParser();
79+
80+
parser.initWithBoundary(boundary);
81+
parser.onPartBegin = () => {
82+
resp.parts[partIndex] = {
83+
body: null,
84+
headers: {},
85+
};
86+
data = Buffer.from('');
87+
};
88+
89+
parser.onHeaderField = (b: Buffer, start: number, end: number) => {
90+
headerName = b.slice(start, end).toString();
91+
};
92+
93+
parser.onHeaderValue = (b: Buffer, start: number, end: number) => {
94+
headerValue = b.slice(start, end).toString();
95+
};
96+
97+
parser.onHeaderEnd = () => {
98+
resp.parts[partIndex].headers[headerName.toLowerCase()] = headerValue;
99+
};
100+
101+
parser.onHeadersEnd = () => {};
102+
103+
parser.onPartData = (b: Buffer, start: number, end: number) => {
104+
data = Buffer.concat([data, b.slice(start, end)]);
105+
};
106+
107+
parser.onPartEnd = () => {
108+
resp.parts[partIndex].body = data;
109+
partIndex++;
110+
};
111+
112+
parser.onEnd = () => {};
113+
parser.write(payload);
114+
115+
return resp;
116+
}

0 commit comments

Comments
 (0)