src/common-http/rest_api_client.ts (233 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 { SharedAccessSignature } from '../common-core/shared_access_signature';
import * as errors from '../common-core/errors';
import { X509, anHourFromNow } from '../common-core/authorization';
import { Http as HttpBase, HttpRequestOptions } from './http';
import { AccessToken, TokenCredential } from '@azure/core-auth';
import * as uuid from 'uuid';
import { ClientRequest } from 'http';
import dbg = require('debug');
const debug = dbg('azure-iot-http-base.RestApiClient');
/**
* @private
*/
export type HttpMethodVerb = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
/**
* @private
*/
export interface HttpTransportError extends Error {
response?: any;
responseBody?: any;
}
/**
* @private
* @class module:azure-iothub.RestApiClient
* @classdesc Constructs an {@linkcode RestApiClient} object that can be used to make REST calls to the IoT Hub service.
*
* @instance {Object} config The configuration object that should be used to connect to the IoT Hub service.
* @instance {Object} httpRequestBuilder OPTIONAL: The base http transport object. `azure-iot-common.Http` will be used if no argument is provided.
*
* @throws {ReferenceError} If the config argument is falsy
* @throws {ArgumentError} If the config argument is missing a host or sharedAccessSignature error
*/
export class RestApiClient {
private _BearerTokenPrefix: string = 'Bearer ';
private _MinutesBeforeProactiveRenewal: number = 9;
private _MillisecsBeforeProactiveRenewal: number = this._MinutesBeforeProactiveRenewal * 60000;
private _config: RestApiClient.TransportConfig;
private _accessToken: AccessToken;
private _http: HttpBase;
private _userAgent: string;
constructor(config: RestApiClient.TransportConfig, userAgent: string, httpRequestBuilder?: HttpBase) {
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_001: [The `RestApiClient` constructor shall throw a `ReferenceError` if config is falsy.]*/
if (!config) throw new ReferenceError('config cannot be \'' + config + '\'');
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_002: [The `RestApiClient` constructor shall throw an `ArgumentError` if config is missing a `host` property.]*/
if (!config.host) throw new errors.ArgumentError('config.host cannot be \'' + config.host + '\'');
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_18_001: [The `RestApiClient` constructor shall throw a `ReferenceError` if `userAgent` is falsy.]*/
if (!userAgent) throw new ReferenceError('userAgent cannot be \'' + userAgent + '\'');
if (config.tokenCredential && !config.tokenScope) throw new errors.ArgumentError('config.tokenScope must be defined if config.tokenCredential is defined');
this._config = config;
this._userAgent = userAgent;
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_003: [The `RestApiClient` constructor shall use `azure-iot-common.Http` as the internal HTTP client if the `httpBase` argument is `undefined`.]*/
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_004: [The `RestApiClient` constructor shall use the value of the `httpBase` argument as the internal HTTP client if present.]*/
this._http = httpRequestBuilder || new HttpBase();
}
/**
* @method module:azure-iothub.RestApiClient.executeApiCall
* @description Creates an HTTP request, sends it and parses the response, then call the callback with the resulting object.
*
* @param {Function} method The HTTP method that should be used.
* @param {Function} path The path for the HTTP request.
* @param {Function} headers Headers to add to the request on top of the defaults (Authorization, Request-Id and User-Agent will be populated automatically).
* @param {Function} requestBody Body of the HTTP request.
* @param {Function} timeout [optional] Custom timeout value.
* @param {Function} done Called when a response has been received or if an error happened.
*
* @throws {ReferenceError} If the method or path arguments are falsy.
* @throws {TypeError} If the type of the requestBody is not a string when Content-Type is text/plain
*/
executeApiCall(
method: HttpMethodVerb,
path: string,
headers: { [key: string]: any },
requestBody: any,
timeout?: number | HttpRequestOptions | RestApiClient.ResponseCallback,
requestOptions?: HttpRequestOptions | number | RestApiClient.ResponseCallback,
done?: RestApiClient.ResponseCallback): void {
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_005: [The `executeApiCall` method shall throw a `ReferenceError` if the `method` argument is falsy.]*/
if (!method) throw new ReferenceError('method cannot be \'' + method + '\'');
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_006: [The `executeApiCall` method shall throw a `ReferenceError` if the `path` argument is falsy.]*/
if (!path) throw new ReferenceError('path cannot be \'' + path + '\'');
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_029: [If `done` is `undefined` and the `timeout` argument is a function, `timeout` should be used as the callback and mark `requestOptions` and `timeout` as `undefined`.]*/
if (done === undefined && typeof(timeout) === 'function') {
done = timeout;
requestOptions = timeout = undefined;
}
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_13_001: [** If `done` is `undefined` and the `requestOptions` argument is a function, then `requestOptions` should be used as the callback and mark `requestOptions` as `undefined`.*/
if (done === undefined && typeof(requestOptions) === 'function') {
done = requestOptions;
requestOptions = undefined;
}
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_13_002: [** If `timeout` is an object and `requestOptions` is `undefined`, then assign `timeout` to `requestOptions` and mark `timeout` as `undefined`.*/
if (typeof(timeout) === 'object' && requestOptions === undefined) {
requestOptions = timeout;
timeout = undefined;
}
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_007: [The `executeApiCall` method shall add the following headers to the request:
- Authorization: <this.sharedAccessSignature>
- Request-Id: <guid>
- User-Agent: <version string>]*/
const httpHeaders: any = headers || {};
if (this._config.tokenCredential) {
this.getToken().then((accessToken) => {
httpHeaders.Authorization = accessToken;
this.executeBody(requestBody, httpHeaders, headers, method, path, timeout, requestOptions, done);
}).catch((err) => {
done(err);
});
} else {
if (this._config.sharedAccessSignature) {
httpHeaders.Authorization = (typeof(this._config.sharedAccessSignature) === 'string') ? this._config.sharedAccessSignature as string : (this._config.sharedAccessSignature as SharedAccessSignature).extend(anHourFromNow());
}
this.executeBody(requestBody, httpHeaders, headers, method, path, timeout, requestOptions, done);
}
}
/**
* @method module:azure-iothub.RestApiClient.updateSharedAccessSignature
* @description Updates the shared access signature used to authenticate API calls.
*
* @param {string} sharedAccessSignature The new shared access signature that should be used.
*
* @throws {ReferenceError} If the new sharedAccessSignature is falsy.
*/
updateSharedAccessSignature(sharedAccessSignature: string | SharedAccessSignature): void {
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_034: [The `updateSharedAccessSignature` method shall throw a `ReferenceError` if the `sharedAccessSignature` argument is falsy.]*/
if (!sharedAccessSignature) throw new ReferenceError('sharedAccessSignature cannot be \'' + sharedAccessSignature + '\'');
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_028: [The `updateSharedAccessSignature` method shall update the `sharedAccessSignature` configuration parameter that is used in the `Authorization` header of all HTTP requests.]*/
this._config.sharedAccessSignature = sharedAccessSignature;
}
/**
* @private
*/
setOptions(options: any): void {
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_18_003: [ `setOptions` shall call `this._http.setOptions` passing the same parameters ]*/
this._http.setOptions(options);
}
/**
* @private
* Calculates if the AccessToken's remaining time to live
* is shorter than the proactive renewal time.
* @param accessToken The AccessToken.
* @returns {Boolean} True if the token's remaining time is shorter than the
* proactive renewal time, false otherwise.
*/
isAccessTokenCloseToExpiry(accessToken: AccessToken): boolean {
const remainingTimeToLive = accessToken.expiresOnTimestamp - Date.now();
return remainingTimeToLive <= this._MillisecsBeforeProactiveRenewal;
}
/**
* @private
* Returns the current AccessToken if it is still valid
* or a new AccessToken if the current token is close to expire.
* @returns {Promise<string>} The access token string.
*/
async getToken(): Promise<string> {
if ((!this._accessToken) || this.isAccessTokenCloseToExpiry(this._accessToken)) {
this._accessToken = await this._config.tokenCredential.getToken(this._config.tokenScope) as any;
}
if (!this._accessToken) {
throw new Error('AccessToken creation failed');
}
return this._BearerTokenPrefix + this._accessToken.token;
}
private executeBody(
requestBody: any,
httpHeaders: any,
headers: { [key: string]: any },
method: HttpMethodVerb,
path: string,
timeout?: number | HttpRequestOptions | RestApiClient.ResponseCallback,
requestOptions?: HttpRequestOptions | number | RestApiClient.ResponseCallback,
done?: RestApiClient.ResponseCallback): void {
httpHeaders['Request-Id'] = uuid.v4();
httpHeaders['User-Agent'] = this._userAgent;
let requestBodyString: string;
if (requestBody) {
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_035: [If there's is a `Content-Type` header and its value is `application/json; charset=utf-8` and the `requestBody` argument is a `string` it shall be used as is as the body of the request.]*/
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_031: [If there's is a `Content-Type` header and its value is `application/json; charset=utf-8` and the `requestBody` argument is not a `string`, the body of the request shall be stringified using `JSON.stringify()`.]*/
if (!!headers['Content-Type'] && headers['Content-Type'].indexOf('application/json') >= 0) {
if (typeof requestBody === 'string') {
requestBodyString = requestBody;
} else {
requestBodyString = JSON.stringify(requestBody);
}
} else if (!!headers['Content-Type'] && headers['Content-Type'].indexOf('text/plain') >= 0) {
if (typeof requestBody !== 'string') {
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_033: [The `executeApiCall` shall throw a `TypeError` if there's is a `Content-Type` header and its value is `text/plain; charset=utf-8` and the `body` argument is not a string.]*/
throw new TypeError('requestBody must be a string');
} else {
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_032: [If there's is a `Content-Type` header and its value is `text/plain; charset=utf-8`, the `requestBody` argument shall be used.]*/
requestBodyString = requestBody;
}
}
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_036: [The `executeApiCall` shall set the `Content-Length` header to the length of the serialized value of `requestBody` if it is truthy.]*/
const requestBodyStringSizeInBytes = Buffer.byteLength(requestBodyString, 'utf8');
headers['Content-Length'] = requestBodyStringSizeInBytes;
}
const requestCallback = (err, responseBody, response) => {
debug(method + ' call to ' + path + ' returned ' + (err ? err : 'success'));
if (err) {
if (response) {
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_010: [If the HTTP request fails with an error code >= 300 the `executeApiCall` method shall translate the HTTP error into a transport-agnostic error using the `translateError` method and call the `done` callback with the resulting error as the only argument.]*/
done(RestApiClient.translateError(responseBody, response));
} else {
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_011: [If the HTTP request fails without an HTTP error code the `executeApiCall` shall call the `done` callback with the error itself as the only argument.]*/
done(err);
}
} else {
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_009: [If the HTTP request is successful and the content-type header contains `application/json` the `executeApiCall` method shall parse the JSON response received and call the `done` callback with a `null` first argument, the parsed result as a second argument and the HTTP response object itself as a third argument.]*/
let result = '';
let parseError = null;
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_037: [If parsing the body of the HTTP response as JSON fails, the `done` callback shall be called with the SyntaxError thrown as a first argument, an `undefined` second argument, and the HTTP response object itself as a third argument.]*/
const expectJson = response.headers && response.headers['content-type'] && response.headers['content-type'].indexOf('application/json') >= 0;
if (responseBody && expectJson) {
try {
result = JSON.parse(responseBody);
} catch (ex) {
if (ex instanceof SyntaxError) {
parseError = ex;
result = undefined;
} else {
throw ex;
}
}
}
done(parseError, result, response);
}
};
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_008: [The `executeApiCall` method shall build the HTTP request using the arguments passed by the caller.]*/
let request: ClientRequest;
if (this._config.x509) {
/* Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_18_002: [ If an `x509` cert was passed into the constructor via the `config` object, `executeApiCall` shall use it to establish the TLS connection. ] */
request = this._http.buildRequest(method, path, httpHeaders, this._config.host, this._config.x509, requestCallback);
} else {
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_13_003: [** If `requestOptions` is not falsy then it shall be passed to the `buildRequest` function.*/
if (requestOptions) {
request = this._http.buildRequest(
method, path, httpHeaders, this._config.host,
requestOptions as HttpRequestOptions, requestCallback
);
} else {
request = this._http.buildRequest(method, path, httpHeaders, this._config.host, requestCallback);
}
}
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_030: [If `timeout` is defined and is not a function, the HTTP request timeout shall be adjusted to match the value of the argument.]*/
if (timeout) {
request.setTimeout(timeout as number);
}
debug('sending ' + method + ' call to ' + path);
if (requestBodyString) {
debug('with body ' + requestBodyString);
request.write(requestBodyString);
}
request.end();
}
/**
* @method module:azure-iothub.RestApiClient.translateError
* @description Translates an HTTP error into a transport-agnostic error.
*
* @param {string} body The HTTP error response body.
* @param {string} response The HTTP response itself.
*
*/
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_027: [`translateError` shall accept 2 arguments:
- the body of the HTTP response, containing the explanation of why the request failed.
- the HTTP response object itself.]*/
static translateError(body: any, response: any): HttpTransportError {
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_012: [Any error object returned by `translateError` shall inherit from the generic `Error` Javascript object and have 3 properties:
- `response` shall contain the `IncomingMessage` object returned by the HTTP layer.
- `responseBody` shall contain the content of the HTTP response.
- `message` shall contain a human-readable error message.]*/
let error: HttpTransportError;
const errorContent = HttpBase.parseErrorBody(body);
const message = errorContent ? errorContent.message : 'Error: ' + body;
switch (response.statusCode) {
case 400:
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_014: [`translateError` shall return an `ArgumentError` if the HTTP response status code is `400`.]*/
error = new errors.ArgumentError(message);
break;
case 401:
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_015: [`translateError` shall return an `UnauthorizedError` if the HTTP response status code is `401`.]*/
error = new errors.UnauthorizedError(message);
break;
case 403:
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_016: [`translateError` shall return an `TooManyDevicesError` if the HTTP response status code is `403`.]*/
error = new errors.TooManyDevicesError(message);
break;
case 404:
if (errorContent && errorContent.code === 'DeviceNotFound') {
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_018: [`translateError` shall return an `DeviceNotFoundError` if the HTTP response status code is `404` and if the error code within the body of the error response is `DeviceNotFound`.]*/
error = new errors.DeviceNotFoundError(message);
} else if (errorContent && errorContent.code === 'IotHubNotFound') {
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_017: [`translateError` shall return an `IotHubNotFoundError` if the HTTP response status code is `404` and if the error code within the body of the error response is `IotHubNotFound`.]*/
error = new errors.IotHubNotFoundError(message);
} else {
error = new Error('Not found');
}
break;
case 408:
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_019: [`translateError` shall return a `DeviceTimeoutError` if the HTTP response status code is `408`.]*/
error = new errors.DeviceTimeoutError(message);
break;
case 409:
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_020: [`translateError` shall return an `DeviceAlreadyExistsError` if the HTTP response status code is `409`.]*/
error = new errors.DeviceAlreadyExistsError(message);
break;
case 412:
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_021: [`translateError` shall return an `InvalidEtagError` if the HTTP response status code is `412`.]*/
error = new errors.InvalidEtagError(message);
break;
case 429:
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_022: [`translateError` shall return an `ThrottlingError` if the HTTP response status code is `429`.]*/
error = new errors.ThrottlingError(message);
break;
case 500:
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_023: [`translateError` shall return an `InternalServerError` if the HTTP response status code is `500`.]*/
error = new errors.InternalServerError(message);
break;
case 502:
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_024: [`translateError` shall return a `BadDeviceResponseError` if the HTTP response status code is `502`.]*/
error = new errors.BadDeviceResponseError(message);
break;
case 503:
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_025: [`translateError` shall return an `ServiceUnavailableError` if the HTTP response status code is `503`.]*/
error = new errors.ServiceUnavailableError(message);
break;
case 504:
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_026: [`translateError` shall return a `GatewayTimeoutError` if the HTTP response status code is `504`.]*/
error = new errors.GatewayTimeoutError(message);
break;
default:
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_16_013: [If the HTTP error code is unknown, `translateError` should return a generic Javascript `Error` object.]*/
error = new Error(message);
}
error.response = response;
error.responseBody = body;
return error;
}
}
export namespace RestApiClient {
export interface TransportConfig {
host: string | { socketPath: string };
sharedAccessSignature?: string | SharedAccessSignature;
x509?: X509;
tokenCredential?: TokenCredential;
tokenScope?: string | string[];
}
export type ResponseCallback = (err: Error, responseBody?: any, response?: any) => void;
}