Skip to content

Commit

Permalink
Support for soap attachments in response. (#1148)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
3 people authored Jul 13, 2021
1 parent d74453d commit 03d1f78
Show file tree
Hide file tree
Showing 22 changed files with 344 additions and 20 deletions.
3 changes: 2 additions & 1 deletion Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,9 @@ The `options` argument allows you to customize the client with the following pro
- 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`.
- stream: allows using a stream to parse the XML SOAP response. Default: `false`
- 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`
- parseReponseAttachments: Treat response as multipart/related response with MTOM attachment. Reach attachments on the `lastResponseAttachments` property of SoapClient. Default: `false`

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.
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.

### soap.listen(*server*, *path*, *services*, *wsdl*, *callback*) - create a new SOAP server that listens on *path* and provides *services*.
*server* can be a [http](https://nodejs.org/api/http.html) Server or [express](http://expressjs.com/) framework based server
Expand Down
29 changes: 29 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@
"dependencies": {
"axios": "^0.21.1",
"axios-ntlm": "^1.1.6",
"content-type-parser": "^1.0.2",
"debug": "^4.3.1",
"formidable": "^1.2.2",
"get-stream": "^6.0.1",
"httpntlm": "^1.5.2",
"lodash": "^4.17.21",
"sax": ">=0.6",
"strip-bom": "^3.0.0",
Expand Down
17 changes: 10 additions & 7 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { IncomingHttpHeaders } from 'http';
import * as _ from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { HttpClient } from './http';
import { IHeaders, IHttpClient, IOptions, ISecurity, SoapMethod, SoapMethodAsync } from './types';
import { IHeaders, IHttpClient, IMTOMAttachments, IOptions, ISecurity, SoapMethod, SoapMethodAsync } from './types';
import { findPrefix } from './utils';
import { WSDL } from './wsdl';
import { IPort, OperationElement, ServiceElement } from './wsdl/elements';
Expand Down Expand Up @@ -53,6 +53,7 @@ export class Client extends EventEmitter {
public lastResponse?: any;
public lastResponseHeaders?: IncomingHttpHeaders;
public lastElapsedTime?: number;
public lastResponseAttachments: IMTOMAttachments;

private wsdl: WSDL;
private httpClient: IHttpClient;
Expand Down Expand Up @@ -230,11 +231,12 @@ export class Client extends EventEmitter {
rawResponse: any,
soapHeader: any,
rawRequest: any,
mtomAttachments: any,
) => {
if (err) {
reject(err);
} else {
resolve([result, rawResponse, soapHeader, rawRequest]);
resolve([result, rawResponse, soapHeader, rawRequest, mtomAttachments]);
}
};
method(
Expand Down Expand Up @@ -263,8 +265,8 @@ export class Client extends EventEmitter {
extraHeaders = options;
options = temp;
}
this._invoke(method, args, location, (error, result, rawResponse, soapHeader, rawRequest) => {
callback(error, result, rawResponse, soapHeader, rawRequest);
this._invoke(method, args, location, (error, result, rawResponse, soapHeader, rawRequest, mtomAttachments) => {
callback(error, result, rawResponse, soapHeader, rawRequest, mtomAttachments);
}, options, extraHeaders);
};
}
Expand Down Expand Up @@ -314,7 +316,7 @@ export class Client extends EventEmitter {

if (!output) {
// one-way, no output expected
return callback(null, null, body, obj.Header, xml);
return callback(null, null, body, obj.Header, xml, response.mtomResponseAttachments);
}

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

callback(null, result, body, obj.Header, xml);
callback(null, result, body, obj.Header, xml, response.mtomResponseAttachments);
};

const parseSync = (body, response) => {
Expand All @@ -372,7 +374,7 @@ export class Client extends EventEmitter {
error.response = response;
error.body = body;
this.emit('soapError', error, eid);
return callback(error, response, body, undefined, xml);
return callback(error, response, body, undefined, xml, response.mtomResponseAttachments);
}
return finish(obj, body, response);
};
Expand Down Expand Up @@ -552,6 +554,7 @@ export class Client extends EventEmitter {
if (response) {
this.lastResponseHeaders = response.headers;
this.lastElapsedTime = response.headers.date;
this.lastResponseAttachments = response.mtomResponseAttachments;
// Added mostly for testability, but possibly useful for debugging
this.lastRequestHeaders = response.config && response.config.headers;
}
Expand Down
42 changes: 39 additions & 3 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@

import * as req from 'axios';
import { NtlmClient } from 'axios-ntlm';
import * as contentTypeParser from 'content-type-parser';
import * as debugBuilder from 'debug';
import { ReadStream } from 'fs';
import * as url from 'url';

import { v4 as uuidv4 } from 'uuid';
import { IExOptions, IHeaders, IHttpClient, IOptions } from './types';
import { IExOptions, IHeaders, IHttpClient, IMTOMAttachments, IOptions } from './types';
import { parseMTOMResp } from './utils';

const debug = debugBuilder('node-soap');
const VERSION = require('../package.json').version;
Expand All @@ -29,10 +32,13 @@ export interface IAttachment {
* @constructor
*/
export class HttpClient implements IHttpClient {

private _request: req.AxiosInstance;
private options: IOptions;

constructor(options?: IOptions) {
options = options || {};
this.options = options;
this._request = options.request || req.default.create();
}

Expand Down Expand Up @@ -166,11 +172,41 @@ export class HttpClient implements IHttpClient {
});
req = ntlmReq(options);
} else {
if (this.options.parseReponseAttachments) {
options.responseType = 'arraybuffer';
options.responseEncoding = 'binary';
}
req = this._request(options);
}

const _this = this;
req.then((res) => {
res.data = this.handleResponse(req, res, res.data);
let body;
if (_this.options.parseReponseAttachments) {
const isMultipartResp = res.headers['content-type'] && res.headers['content-type'].toLowerCase().indexOf('multipart/related') > -1;
if (isMultipartResp) {
let boundary;
const parsedContentType = contentTypeParser(res.headers['content-type']);
if (parsedContentType && parsedContentType.parameterList) {
boundary = ((parsedContentType.parameterList as any[]).find((item) => item.key === 'boundary') || {}).value;
}
if (!boundary) {
return callback(new Error('Missing boundary from content-type'));
}
const multipartResponse = parseMTOMResp(res.data, boundary);

// first part is the soap response
const firstPart = multipartResponse.parts.shift();
if (!firstPart || !firstPart.body) {
return callback(new Error('Cannot parse multipart response'));
}
body = firstPart.body.toString('utf8');
(res as any).mtomResponseAttachments = multipartResponse;
} else {
body = res.data.toString('utf8');
}
}

res.data = this.handleResponse(req, res, body || res.data);
callback(null, res, res.data);
return res;
}, (err) => {
Expand Down
14 changes: 12 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,17 @@ export interface IHttpClient {
export type ISoapMethod = SoapMethod;
export type SoapMethod = (
args: any,
callback: (err: any, result: any, rawResponse: any, soapHeader: any, rawRequest: any) => void,
callback: (err: any, result: any, rawResponse: any, soapHeader: any, rawRequest: any, mtomAttachments?: IMTOMAttachments) => void,
options?: any,
extraHeaders?: any,
mtomAttachments?: IMTOMAttachments,
) => void;

export type SoapMethodAsync = (
args: any,
options?: any,
extraHeaders?: any,
) => Promise<[any, any, any, any]>;
) => Promise<[any, any, any, any, IMTOMAttachments?]>;

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

Expand Down Expand Up @@ -133,6 +134,8 @@ export interface IOptions extends IWsdlBaseOptions {
overridePromiseSuffix?: string;
/** @internal */
WSDL_CACHE?;
/** handle MTOM soapAttachments in response */
parseReponseAttachments?: boolean;
}

export interface IOneWayOptions {
Expand All @@ -152,3 +155,10 @@ export interface IServerOptions extends IWsdlBaseOptions {
/** 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. */
enableChunkedEncoding?: boolean;
}

export interface IMTOMAttachments {
parts: Array<{
body: Buffer,
headers: { [key: string]: string },
}>;
}
50 changes: 50 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@

import * as crypto from 'crypto';
import { MultipartParser } from 'formidable/lib/multipart_parser.js';
import { IMTOMAttachments } from './types';

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

return obj;
}

export function parseMTOMResp(payload: Buffer, boundary: string): IMTOMAttachments {
const resp: IMTOMAttachments = {
parts: [],
};
let headerName = '';
let headerValue = '';
let data: Buffer;
let partIndex = 0;
const parser = new MultipartParser();

parser.initWithBoundary(boundary);
parser.onPartBegin = () => {
resp.parts[partIndex] = {
body: null,
headers: {},
};
data = Buffer.from('');
};

parser.onHeaderField = (b: Buffer, start: number, end: number) => {
headerName = b.slice(start, end).toString();
};

parser.onHeaderValue = (b: Buffer, start: number, end: number) => {
headerValue = b.slice(start, end).toString();
};

parser.onHeaderEnd = () => {
resp.parts[partIndex].headers[headerName.toLowerCase()] = headerValue;
};

parser.onHeadersEnd = () => {};

parser.onPartData = (b: Buffer, start: number, end: number) => {
data = Buffer.concat([data, b.slice(start, end)]);
};

parser.onPartEnd = () => {
resp.parts[partIndex].body = data;
partIndex++;
};

parser.onEnd = () => {};
parser.write(payload);

return resp;
}
Loading

0 comments on commit 03d1f78

Please sign in to comment.