common/transport/http/src/http.ts (144 lines of code) (raw):
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
'use strict';
import { request as http_request, ClientRequest, IncomingMessage } from 'http';
import { request as https_request, RequestOptions } from 'https';
import { Message, X509 } from 'azure-iot-common';
import dbg = require('debug');
const debug = dbg('azure-iot-http-base.Http');
/**
* @private
*/
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
/**
* @private
*/
export type HttpCallback = (err: Error, body?: string, response?: IncomingMessage) => void;
/**
* @private
*
* This interface defines optional HTTP request options that one can set on a per request basis.
*/
export interface HttpRequestOptions {
/**
* The TCP port to use when connecting to the HTTP server. Defaults to 80 for HTTP
* traffic and 443 for HTTPS traffic.
*/
port?: number;
/**
* The request function to use when connecting to the HTTP server. Must be the 'request'
* function from either the 'http' Node.js package or the 'https' Node.js package.
*/
request?: (options: RequestOptions | string, callback?: (res: IncomingMessage) => void) => ClientRequest;
}
/**
* @private
* @class module:azure-iot-http-base.Http
* @classdesc Basic HTTP request/response functionality used by higher-level IoT Hub libraries.
* You generally want to use these higher-level objects (such as [azure-iot-device-http.Http]{@link module:azure-iot-device-http.Http}) rather than this one.
*/
export class Http {
private _options: any;
/**
* @method module:azure-iot-http-base.Http.buildRequest
* @description Builds an HTTP request object using the parameters supplied by the caller.
*
* @param {String} method The HTTP verb to use (GET, POST, PUT, DELETE...).
* @param {String} path The section of the URI that should be appended after the hostname.
* @param {Object} httpHeaders An object containing the headers that should be used for the request.
* @param {String} host Fully-Qualified Domain Name of the server to which the request should be sent to.
* @param {Object} options X509 options or HTTP request options
* @param {Function} done The callback to call when a response or an error is received.
*
* @returns An HTTP request object.
*/
/*Codes_SRS_NODE_HTTP_05_001: [buildRequest shall accept the following arguments:
method - an HTTP verb, e.g., 'GET', 'POST', 'DELETE'
path - the path to the resource, not including the hostname
httpHeaders - an object whose properties represent the names and values of HTTP headers to include in the request
host - the fully-qualified DNS hostname of the IoT hub
options - [optional] the x509 certificate options or HTTP request options
done - a callback that will be invoked when a completed response is returned from the server]*/
buildRequest(method: HttpMethod,
path: string,
httpHeaders: { [key: string]: string | string[] | number },
host: string | { socketPath: string },
options: X509 | HttpRequestOptions | HttpCallback,
done?: HttpCallback): ClientRequest {
// NOTE: The `options` parameter above, the way its structured prevents
// this function from being called with *both* X509 options AND
// HttpRequestOptions simultaneously. This is not strictly required at
// this time. But when it is required, we may need to split the request
// options out as its own parameter and then appropriately modify the
// overload detection logic below.
if (!done && (typeof options === 'function')) {
done = options;
options = undefined;
}
let requestOptions: HttpRequestOptions = null;
if (options && this.isHttpRequestOptions(options)) {
requestOptions = options as HttpRequestOptions;
options = undefined;
}
let x509Options: X509 = null;
if (options && this.isX509Options(options)) {
x509Options = options as X509;
options = undefined;
}
const httpOptions: RequestOptions = {
path: path,
method: method,
headers: httpHeaders
};
// Codes_SRS_NODE_HTTP_13_004: [ Use the request object from the `options` object if one has been provided or default to HTTPS request. ]
let request = https_request;
if (requestOptions && requestOptions.request) {
request = requestOptions.request as any;
}
if (typeof(host) === 'string') {
// Codes_SRS_NODE_HTTP_13_002: [ If the host argument is a string then assign its value to the host property of httpOptions. ]
httpOptions.host = host;
// Codes_SRS_NODE_HTTP_13_006: [ If the options object has a port property set then assign that to the port property on httpOptions. ]
if (requestOptions && requestOptions.port) {
httpOptions.port = requestOptions.port;
}
} else {
// Codes_SRS_NODE_HTTP_13_003: [ If the host argument is an object then assign its socketPath property to the socketPath property of httpOptions. ]
// this is a unix domain socket so use `socketPath` property in options
// instead of `host`
httpOptions.socketPath = host.socketPath;
// Codes_SRS_NODE_HTTP_13_005: [ Use the request object from the http module when dealing with unix domain socket based HTTP requests. ]
// unix domain sockets only work with the HTTP request function; https's
// request function cannot handle UDS
request = http_request;
}
/*Codes_SRS_NODE_HTTP_18_001: [ If the `options` object passed into `setOptions` has a value in `http.agent`, that value shall be passed into the `request` function as `httpOptions.agent` ]*/
if (this._options && this._options.http && this._options.http.agent) {
httpOptions.agent = this._options.http.agent;
}
/*Codes_SRS_NODE_HTTP_16_001: [If `options` has x509 properties, the certificate, key and passphrase in the structure shall be used to authenticate the connection.]*/
if (x509Options) {
httpOptions.cert = (x509Options as X509).cert;
httpOptions.key = (x509Options as X509).key;
httpOptions.passphrase = (x509Options as X509).passphrase;
httpOptions.clientCertEngine = (x509Options as X509).clientCertEngine;
}
if (this._options && this._options.ca) {
httpOptions.ca = this._options.ca;
}
const httpReq = request(httpOptions, (response: IncomingMessage): void => {
let responseBody = '';
response.on('error', (err: Error): void => {
done(err);
});
response.on('data', (chunk: string | Buffer): void => {
responseBody += chunk;
});
response.on('end', (): void => {
/*Codes_SRS_NODE_HTTP_05_005: [When an HTTP response is received, the callback function indicated by the done argument shall be invoked with the following arguments:
err - the standard JavaScript Error object if status code >= 300, otherwise null
body - the body of the HTTP response as a string
response - the Node.js http.IncomingMessage object returned by the transport]*/
const err = (response.statusCode >= 300) ?
new Error(response.statusMessage) :
null;
done(err, responseBody, response);
});
});
/*Codes_SRS_NODE_HTTP_05_003: [If buildRequest encounters an error before it can send the request, it shall invoke the done callback function and pass the standard JavaScript Error object with a text description of the error (err.message).]*/
httpReq.on('error', done);
/*Codes_SRS_NODE_HTTP_05_002: [buildRequest shall return a Node.js https.ClientRequest/http.ClientRequest object, upon which the caller must invoke the end method in order to actually send the request.]*/
return httpReq;
}
/**
* @method module:azure-iot-http-base.Http.toMessage
* @description Transforms the body of an HTTP response into a {@link module:azure-iot-common.Message} that can be treated by the client.
*
* @param {module:http.IncomingMessage} response A response as returned from the node.js http module
* @param {Object} body The section of the URI that should be appended after the hostname.
*
* @returns {module:azure-iot-common.Message} A Message object.
*/
toMessage(response: IncomingMessage, body: Message.BufferConvertible): Message {
let msg: Message;
/*Codes_SRS_NODE_HTTP_05_006: [If the status code of the HTTP response < 300, toMessage shall create a new azure-iot-common.Message object with data equal to the body of the HTTP response.]*/
if (response.statusCode < 300) {
msg = new Message(body);
for (const item in response.headers) {
if (item.search('iothub-') !== -1) {
if (item.toLowerCase() === 'iothub-messageid') {
/*Codes_SRS_NODE_HTTP_05_007: [If the HTTP response has an 'iothub-messageid' header, it shall be saved as the messageId property on the created Message.]*/
msg.messageId = response.headers[item] as any;
} else if (item.toLowerCase() === 'iothub-to') {
/*Codes_SRS_NODE_HTTP_05_008: [If the HTTP response has an 'iothub-to' header, it shall be saved as the to property on the created Message.]*/
msg.to = response.headers[item] as any;
} else if (item.toLowerCase() === 'iothub-expiry') {
/*Codes_SRS_NODE_HTTP_05_009: [If the HTTP response has an 'iothub-expiry' header, it shall be saved as the expiryTimeUtc property on the created Message.]*/
msg.expiryTimeUtc = response.headers[item] as any;
} else if (item.toLowerCase() === 'iothub-correlationid') {
/*Codes_SRS_NODE_HTTP_05_010: [If the HTTP response has an 'iothub-correlationid' header, it shall be saved as the correlationId property on the created Message.]*/
msg.correlationId = response.headers[item] as any;
} else if (item.search('iothub-app-') !== -1) {
/*Codes_SRS_NODE_HTTP_13_001: [ If the HTTP response has a header with the prefix iothub-app- then a new property with the header name and value as the key and value shall be added to the message. ]*/
msg.properties.add(item, response.headers[item] as string);
}
} else if (item.toLowerCase() === 'etag') {
/*Codes_SRS_NODE_HTTP_05_011: [If the HTTP response has an 'etag' header, it shall be saved as the lockToken property on the created Message, minus any surrounding quotes.]*/
// Need to strip the quotes from the string
const len = response.headers[item].length;
msg.lockToken = (response.headers[item] as string).substring(1, len - 1);
}
}
}
return msg;
}
/**
* @private
*/
setOptions(options: any): void {
this._options = options;
}
/**
* @private
*/
isX509Options(options: any): boolean {
return !!options && typeof(options) === 'object' &&
(options.cert || options.key || options.passphrase ||
options.certFile || options.keyFile || options.clientCertEngine);
}
/**
* @private
*/
isHttpRequestOptions(options: any): boolean {
return !!options && typeof(options) === 'object' &&
(typeof(options.port) === 'number' || typeof(options.request) === 'function');
}
/**
* @method module:azure-iot-http-base.Http#parseErrorBody
* @description Parses the body of an error response and returns it as an object.
*
* @params {String} body The body of the HTTP error response
* @returns {Object} An object with 2 properties: code and message.
*/
static parseErrorBody(body: string): { code: string; message: string } {
let result = null;
try {
const jsonErr = JSON.parse(body);
const errParts = jsonErr.Message.split(';');
const errMessage = errParts[1];
const errCode = errParts[0].split(':')[1];
if (!!errCode && !!errMessage) {
result = {
message: errMessage,
code: errCode
};
}
} catch (err) {
if (err instanceof SyntaxError) {
debug('Could not parse error body: Invalid JSON');
} else if (err instanceof TypeError) {
debug('Could not parse error body: Unknown body format');
} else {
throw err;
}
}
return result;
}
}