authui-container/server/utils/http-server-request-handler.ts (128 lines of code) (raw):

/* * Copyright 2020 Google Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either * express or implied. See the License for the specific language governing permissions and * limitations under the License. */ import requestPromise = require('request-promise'); import { deepCopy, deepExtend } from '../../common/deep-copy'; import { isNonNullObject } from '../../common/validator'; import { addReadonlyGetter, formatString } from '../../common/index'; import { URL } from 'url'; /** HTTP method type definition. */ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'; /** Default error message to shown when an expected non-200 response is returned. */ const DEFAULT_ERROR_MESSAGE = 'Unexpected error occurred.' // Google Cloud standard error response: // https://cloud.google.com/apis/design/errors interface ErrorResponse { error?: { code?: number; message?: string; status?: string; details?: {[key: string]: string}[]; }; } /** Interface defining the base request options for an HttpServerRequest. */ export interface BaseRequestOptions { method: HttpMethod; url: string; headers?: {[key: string]: any}; // Request timeout is defined in milliseconds. timeout?: number; } /** Interface defining the variable HTTP request options to append to request. */ export interface RequestOptions { urlParams?: {[key: string]: string}; headers?: {[key: string]: any}; body?: {[key: string]: any}; } /** Interface defining the options passed to a request-promise call. */ interface RequestPromiseOptions extends BaseRequestOptions, RequestOptions { json: boolean; resolveWithFullResponse: boolean; simple: boolean, } /** Interface defining the response returned by an HttpServerRequest. */ export interface HttpResponse { statusCode: number; body: any; } /** Defines a utility for sending server side HTTP requests. */ export class HttpServerRequestHandler { private baseRequestPromiseOptions: RequestPromiseOptions; /** * Instantiates an HttpServerRequest instance used for sending server side HTTP requests * using the provided base configuration. * @param baseOptions The base options for the request. * @param logger The optional logging function used to log request information for debugging purposes. * This can be accessed via Cloud Run LOGS tab. */ constructor(baseOptions: BaseRequestOptions, private readonly logger?: (...args: any[]) => void) { this.baseRequestPromiseOptions = deepExtend({ // Send request in JSON format. json: true, // Resolve promise with full response. resolveWithFullResponse: true, // This will resolve promise with full response even for non 2xx http status codes. simple: false, }, baseOptions); } /** * Sends the specified request options to the underlying endpoint. * @param requestOptions The variable request options to append to base config options. * @param defaultMessage The default error message if none is available in the response. * @return A promise that resolves with the full response. */ send( requestOptions?: RequestOptions | null, defaultMessage: string = DEFAULT_ERROR_MESSAGE): Promise<HttpResponse> { const requestPromiseOptions: RequestPromiseOptions = deepCopy(this.baseRequestPromiseOptions); // Replace placeholders in the URL with their values if available. if (requestOptions && requestOptions.urlParams) { requestPromiseOptions.url = formatString(requestPromiseOptions.url, requestOptions.urlParams); } if (requestOptions && requestOptions.body) { if (requestPromiseOptions.method === 'GET' || requestPromiseOptions.method === 'HEAD') { if (!isNonNullObject(requestOptions.body)) { return Promise.reject(new Error('Invalid GET request data')); } // Parse URL and append data to query string. const parsedUrl = new URL(requestPromiseOptions.url); const dataObj = requestOptions.body; for (const key in dataObj) { if (dataObj.hasOwnProperty(key)) { parsedUrl.searchParams.append(key, dataObj[key]); } } requestPromiseOptions.url = parsedUrl.toString(); } else { requestPromiseOptions.body = requestOptions.body; } } if (requestOptions && requestOptions.headers) { requestPromiseOptions.headers = requestPromiseOptions.headers || {}; for (const key in requestOptions.headers) { if (requestOptions.headers.hasOwnProperty(key)) { requestPromiseOptions.headers[key] = requestOptions.headers[key]; } } } // Log requests. Do not log headers as they can contain sensitive OAuth access tokens. this.log(`${requestPromiseOptions.method} to ${requestPromiseOptions.url}`); if (requestPromiseOptions.body) { this.log('Request body:', requestPromiseOptions.body) } return Promise.resolve(requestPromise(requestPromiseOptions)) .catch((reason) => { this.log('Error encountered:', reason.error); throw reason.error; }) .then((httpResponse) => { // To be safe, we will not log successful responses as they may contain // sensitive information like OAuth client secrets, hashing secret keys, etc. // Logging is mainly needed for debugging issues. if (httpResponse.statusCode !== 200) { this.log(`${httpResponse.statusCode} Response:`, httpResponse.body); const parsedError = this.getError(httpResponse, defaultMessage); throw parsedError; } else { this.log(`${httpResponse.statusCode} response`); } return httpResponse; }); } /** * Logs the network request operation if a logger is available. * @param args The list of arguments to log. */ private log(...args: any[]) { if (this.logger) { this.logger(...args); } } /** * Returns the Error objects from the non-200 HTTP response. * @param httpResponse The non-200 HTTP response. * @param defaultMessage The default error message if none is available in the response. * @return The corresponding Error object. */ private getError(httpResponse: HttpResponse, defaultMessage: string): Error { let jsonResponse: ErrorResponse; let error: Error; try { jsonResponse = typeof httpResponse.body === 'object' ? httpResponse.body : JSON.parse(httpResponse.body); error = new Error( (jsonResponse && jsonResponse.error && jsonResponse.error.message && jsonResponse.error.message.toString()) || defaultMessage); } catch (e) { // If the error response body is a string. Use the string as the error message. // This is the case for GCS: // response.body === 'No such object: gcip-iap-bucket-625969875839/config.json' // response.body === 'Not found' error = new Error(typeof httpResponse.body === 'string' ? httpResponse.body : defaultMessage); } if (jsonResponse && jsonResponse.error && jsonResponse.error.message && jsonResponse.error.code) { addReadonlyGetter(error, 'cloudCompliant', true); } else { addReadonlyGetter(error, 'cloudCompliant', false); } addReadonlyGetter(error, 'rawResponse', httpResponse.body); // Append status code as it is more reliable than error messages. addReadonlyGetter(error, 'statusCode', httpResponse.statusCode); return error; } }