/* eslint-disable camelcase */
// noinspection JSValidateJSDoc
'use strict';
/**
* Node.js Express application common shared variables and functions.
*
* @module common
* @requires @google-cloud/firestore
* @requires @google-cloud/error-reporting
* @requires debug
* @requires gax
*/
// const Bynder = require('@bynder/bynder-js-sdk');
const {debug} = require('debug');
const {ErrorReporting} = require('@google-cloud/error-reporting');
const {Firestore} = require('@google-cloud/firestore');
const firestore = new Firestore();
const fs = require('fs');
const gax = require('google-gax');
// node-fetch from v3 is an ESM-only module
const fetch = require('node-fetch');
// you can use the async import() function from CommonJS to load node-fetch asynchronously
// const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
const path = require('path');
const moment = require('moment');
const monitoring = require('@google-cloud/monitoring');
const metricServiceClient = new monitoring.MetricServiceClient();
const wait = (ms) => new Promise((res) => setTimeout(res, ms));
const {v4: uuidv4} = require('uuid');
/**
* Encapsulates the overridable settings for a particular API call.
*
* ``CallOptions`` is an optional arg for all GAX API calls. It is used to
* configure the settings of a specific API call.
*
* When provided, its values override the GAX service defaults for that
* particular call.
*
* Typically, the API clients will accept this as the second to the last
* argument. See the examples below.
* @typedef {object} CallOptions
* @property {number} timeout - The client-side timeout for API calls.
* @property {object} retry - determines whether and how to retry
* on transient errors. When set to null, the call will not retry.
* @property {boolean} autoPaginate - If set to false and the call is
* configured for paged iteration, page unrolling is not performed, instead
* the callback will be called with the response object.
* @property {object} pageToken - If set and the call is configured for
* paged iteration, paged iteration is not performed and requested with this
* pageToken.
* @property {number} maxResults - If set and the call is configured for
* paged iteration, the call will stop when the number of response elements
* reaches to the specified size. By default, it will unroll the page to
* the end of the list.
* @property {boolean} isBundling - If set to false and the call is configured
* for bundling, bundling is not performed.
* @property {BackoffSettings} longrunning - BackoffSettings used for polling.
* @example
* // suppress bundling for bundled method.
* api.bundlingMethod(
* param, {optParam: aValue, isBundling: false}, function(err, response) {
* // handle response.
* });
* @example
* // suppress streaming for page-streaming method.
* api.pageStreamingMethod(
* param, {optParam: aValue, autoPaginate: false}, function(err, page) {
* // not returning a stream, but callback is called with the paged response.
* });
*/
/**
* Configure call CallSettings object.
* @param {object} settings - An object containing parameter settings.
* @param {number} settings.timeout - The client-side timeout for API calls.
* This parameter is ignored for retrying calls.
* @param {object} settings.retry - The configuration for retrying upon
* transient error. If set to null, this call will not retry.
* @param {boolean} settings.autoPaginate - If there is no `pageDescriptor`,
* this attribute has no meaning. Otherwise, determines whether a page
* streamed response should make the page structure transparent to the user by
* flattening the repeated field in the returned generator.
* @param {number} settings.pageToken - If there is no `pageDescriptor`,
* this attribute has no meaning. Otherwise, determines the page token used
* in the page streaming request.
* @param {object} settings.otherArgs - Additional arguments to be passed to
* the API calls.
* @param {Function=} settings.promise - A constructor for a promise that
* implements the ES6 specification of promise. If not provided, native
* promises will be used.
*/
const gaxOptions = new gax.CallSettings();
/**
* Per-call configurable settings for retrying upon transient failure.
*
* @param {number[]} retryCodes - a list of Google API canonical error codes
* upon which a retry should be attempted.
* @param {BackoffSettings} backoffSettings - configures the retry
* exponential backoff algorithm.
* @return {object} A new RetryOptions object.
*
*/
gaxOptions.retry = gax.createRetryOptions(
[gax.Status.DEADLINE_EXCEEDED, gax.Status.UNAVAILABLE],
/**
* Parameters to the exponential backoff algorithm for retrying.
* @typedef {object} BackoffSettings
* @property {number} initialRetryDelayMillis - the initial delay time,
* in milliseconds, between the completion of the first failed request and the
* initiation of the first retrying request.
* @property {number} retryDelayMultiplier - the multiplier by which to
* increase the delay time between the completion of failed requests, and the
* initiation of the subsequent retrying request.
* @property {number} maxRetryDelayMillis - the maximum delay time, in
* milliseconds, between requests. When this value is reached,
* ``retryDelayMultiplier`` will no longer be used to increase delay time.
* @property {number} initialRpcTimeoutMillis - the initial timeout parameter
* to the request.
* @property {number} rpcTimeoutMultiplier - the multiplier by which to
* increase the timeout parameter between failed requests.
* @property {number} maxRpcTimeoutMillis - the maximum timeout parameter, in
* milliseconds, for a request. When this value is reached,
* ``rpcTimeoutMultiplier`` will no longer be used to increase the timeout.
* @property {number} totalTimeoutMillis - the total time, in milliseconds,
* starting from when the initial request is sent, after which an error will
* be returned, regardless of the retrying attempts made meanwhile.
*/
gax.createBackoffSettings(100, 1.2, 1000, 300, 1.3, 3000, 30000)
);
// wait 30 seconds before timing out
gaxOptions.timeout = 30000;
/**
* Control stderr logging using DEBUG command line flag.
* set DEBUG=app:error on the command line to enable stderr logging.
* @example
* DEBUG=app:error node index.js
* DEBUG=app:* node index.js
*
* when DEBUG=app:error command line flag is not set stderr logging is disabled.
* @example node index.js
* @name logError
* @function
* @memberof module:common
* @inner
*/
const logError = debug('app:error');
/**
* Control stderr logging using DEBUG command line flag.
* set DEBUG=app:log on the command line to enable stderr logging.
* @example
* DEBUG=app:error node index.js
* DEBUG=app:* node index.js
*
* when DEBUG=app:log command line flag is not set stderr logging is disabled.
* @example node index.js
* @name logInfo
* @function
* @memberof module:common
* @inner
*/
const logInfo = debug('app:log');
/**
* Gax retry options.
*
* Request configuration options, outlined here:
* @see https://googleapis.github.io/gax-nodejs/global.html#CallOptions
* @type {{gaxOptions: CallSettings}}
*/
const options = {
gaxOptions: gaxOptions,
};
/**
* Common constant values and functions.
*
* @type {object}
*/
const commonObjects = {
/**
* API Key used to verify request.
*/
apiKey: process.env.API_KEY,
/**
* True/False flag, when True app error logging is enabled.
*
* @const {boolean}
*/
appErrorLoggingEnabled: debug.enabled('app:error'),
/**
* Get properties for one or more products from Google Firestore and return an object
* populated with Bynder Asset MetaProperties.
* @param {string} id - Bynder Asset media id.
* @param {string[]} productIds - Array of one or more Product id(s).
* @param {string} salesOrg - YETI Sales Organization, valid values: yeti.com, www.yeti.com, yeti.ca, www.yeti.ca
* @param {string} distributionChannel - YETI Distribution Channel, valid value: 10 which is Domestic
* @param {object} globalLogFields - Object used to add log correlation which nest all log messages for a request
* beneath the request log in Log Viewer.
* @return {Promise<*>}
*/
assetMetaProperties: (id, productIds, salesOrg, distributionChannel, globalLogFields) => {
const component = `${process.env.BACKEND_SERVICE_NAME}.common.assetMetaProperties`;
const promises = productIds.map(async (p) => {
// Obtain a document reference.
const documentPath = `sales_org/${salesOrg}/distribution_channel/${distributionChannel}/product/${p}`;
const document = firestore.doc(documentPath);
return await document
.get()
.then((response) => {
const product = commonObjects.firestoreConvertResponse(documentPath, response, 'product', 'id', component, globalLogFields);
return Object.keys(product).length > 0 ? {product, product_id: p} : {product_id: p};
})
.catch((err) => {
commonObjects.logAppError(err, component, globalLogFields);
// logError(JSON.stringify(err, null, 2));
throw err;
});
});
// Promise.allSettled - wait for array of all promises to resolve or reject.
return Promise.allSettled(promises)
.then((results) => {
const productColor = [];
const productName = [];
const category = [];
const categoryType = [];
const masterSKU = [];
const rejections = [];
let fulfilledCount = 0;
commonObjects.logAppInfo(`Firestore Response length: ${results.length}`, component, globalLogFields);
// iterate firestore results populating arrays of product attribute values
results.forEach((result) => {
// commonObjects.logInfo('assetMetaProperties.Promise.allSettled:');
// commonObjects.logInfo(JSON.stringify(result, null, 2));
const {reason, status, value} = result;
if (status === 'rejected') {
commonObjects.logAppInfo(`firestore.rejected.reason: ${reason}`, component, globalLogFields);
} else if (value.product.found === false) {
commonObjects.logAppInfo(`firestore.product.not.found: ${value.product_id}`, component, globalLogFields);
rejections.push(`firestore.product.not.found: ${value.product_id}`);
} else {
const {product, product_id} = value;
// parse metaproperties when firestore promise fulfills and returns data (value is not undefined)
if (product) {
fulfilledCount++;
const {category_type, color, master_sku, product_category, product_description} = product;
if (product_category !== '(not set)') category.push(product_category);
if (category_type !== '(not set)') categoryType.push(category_type);
if (master_sku !== '(not set)') masterSKU.push(master_sku);
if (color !== '(not set)') productColor.push(color);
const name = product_description.split(color || '').join('');
if (name !== '(not set)') productName.push(name);
} else {
commonObjects.logAppInfo(`invalid product_id: ${product_id}`, component, globalLogFields);
rejections.push(`invalid product_id: ${product_id}`);
}
}
});
// join product attribute values into csv strings
commonObjects.logAppInfo(
`Firestore query results
fulfilledCount: ${fulfilledCount}
rejectedCount: ${rejections.length}
category: ${category.join(',')}
categoryType: ${categoryType.join(',')}
color: ${productColor.join(',')}
masterSKU: ${masterSKU.join(',')}
name: ${productName.join(',')}
productIds: ${productIds.join(',')}`,
component,
globalLogFields
);
// if all the firestore request were rejected ( fulfilledCount = 0 ), do not return any metaproperties.
const params =
fulfilledCount === 0
? {id}
: {
id,
// [`metaproperty.${process.env.BYNDER_PRODUCT_COLOR_ID}`]: productColor.join(','),
// [`metaproperty.${process.env.BYNDER_PRODUCT_NAME_ID}`]: productName.join(','),
[`metaproperty.${process.env.BYNDER_PRODUCT_CATEGORY_ID}`]: category.join(','),
[`metaproperty.${process.env.BYNDER_PRODUCT_SUB_CATEGORY_ID}`]: categoryType.join(','),
[`metaproperty.${process.env.SAP_MASTER_SKU}`]: masterSKU.join(','),
};
// commonObjects.logAppInfo(`bynder.asset.assetMetaProperties:`, component, globalLogFields);
// commonObjects.logAppInfo(JSON.stringify(params, null, 2), component, globalLogFields);
return {fulfilledCount, params, rejections};
})
.catch((err) => err);
},
/**
* Returns JSON base64 encoded X-Endpoint-API-UserInfo.
*
* @name authInfoHandler
* @function
* @memberof module:common
* @inner
* @param {object} req - The req object represents the HTTP request and has properties for the request query string,
* parameters, body, HTTP headers, and so on. In this documentation and by convention, the object is always
* referred to as req (and the HTTP response is res) but its actual name is determined by the parameters to the
* callback function in which you are working.
* @param {object} res - The res object represents the HTTP response that an Express app sends when it gets an
* HTTP request. In this documentation and by convention, the object is always referred to as res
* (and the HTTP request is req) but its actual name is determined by the parameters to the callback function in
* which you are working.
* The res object is an enhanced version of Node’s own response object and supports all
* @see [built-in fields and methods](https://nodejs.org/api/http.html#http_class_http_serverresponse).
*/
authInfoHandler: (req, res) => {
let authUser;
let statusCode = 200;
const encodedInfo = req.get('X-Endpoint-API-UserInfo');
if (encodedInfo) {
try {
authUser = JSON.parse(Buffer.from(encodedInfo, 'base64').toString());
} catch (e) {
statusCode = 400;
authUser = {id: 'anonymous', err: 'X-Endpoint-API-UserInfo header invalid JSON.'};
}
} else {
authUser = {id: 'anonymous'};
}
res.status(statusCode).json(authUser).end();
},
/**
* Backend Node.js Express application service domain.
*
* @const {string}
*/
backendServiceDomain: process.env.BACKEND_SERVICE_DOMAIN,
/**
* Backend Node.js Express application service name.
*
* @const {string}
*/
backendServiceName: process.env.BACKEND_SERVICE_NAME,
/**
* Express application API version base path.
*
* @const {string}
*/
basePath: process.env.BASE_PATH,
/**
* Bynder API base URL.
*
* @const {string}
*/
bynderBaseUrl: 'https://assets.yeti.com/api',
/**
* Bynder API permanent token.
*
* @const {string}
*/
bynderPermanentToken: process.env.BYNDER_SECRET,
/**
* Product Enrichment API POST Bynder Application Webhook Endpoint Path.
*
* @const {string}
*/
bynderWebhookPath: `${process.env.BASE_PATH}/bynder-webhook`,
/**
* Time in ms (via setTimeout) after which the value will be removed from the cache.
* 1 hour (1 minute = 60 seconds = 60 × 1000 milliseconds = 60,000 ms)
*
* @const {number}
*/
cacheDuration: 60000 * 2,
/**
* Generic async function used to retry a function until the supplied max depth.
*
* @name callWithRetry
* @function
* @memberof module:common
* @inner
* @param {function} fn - Name of the async function to execute.
* @param {number} depth - max number of times to retry the supplied async function.
*/
callWithRetry: async (fn, depth = 0) => {
try {
return await fn();
} catch (err) {
if (depth > 7) {
throw err;
}
// 408, 502, 503 and 504
if (err.code === 403) {
// Bynder API request limit / quota exceeded, Bynder allows
// 4500 requests in any five-minute time frame from a single IP address.
// Request must wait for 5+ minutes before being retried.
// see: https://bynder.docs.apiary.io/#introduction/limit-on-requests
// 300000 ms = 5 min
// 306000 ms = 5.1 min
await wait(300000 ** depth * 10);
} else {
await wait(2 ** depth * 10);
}
return commonObjects.callWithRetry(fn, depth + 1);
}
},
/**
* Product Registration API code coverage GET endpoint path.
*
* @const {string}
*/
coveragePath: `${process.env.BASE_PATH}/coverage/lcov-report`,
/**
* Product Registration API documentation GET endpoint path.
*
* @const {string}
*/
docsPath: `${process.env.BASE_PATH}/docs`,
/**
* Response error message returned when request X-API-Key header value is invalid.
*
* @const {string}
*/
errMsgApiKeyInvalid: 'Unauthorized: Invalid X-API-Key header or key query string argument value.',
/**
* Response error message returned when request X-API-Key header is not set.
*
* @const {string}
*/
errMsgApiKeyMissing: 'Unauthorized: Missing required X-API-Key header and missing key query string argument.',
/**
* Stderr message returned when Bynder API editMedia method fails.
*
* @const {string}
*/
errMsgBynderEditMediaFailed: 'error: Bynder API editMedia failed.',
/**
* Stderr message returned when Bynder API getMediaInfo fails.
*
* @const {string}
*/
errMsgBynderGetMediaInfoFailed: 'error: Bynder API getMediaInfo failed.',
/**
* Stderr message returned when Bynder API GET Meta Properties fails.
*
* @const {string}
*/
errMsgBynderGetMetaPropertiesFailed: 'error: Bynder API getMetaproperties failed.',
/**
* Stderr message returned when a Bynder Webhook SubscriptionConfirmation message is invalid.
*
* @const {string}
*/
errMsgBynderSubscriptionConfirmationInvalid: 'error: SubscriptionConfirmation message invalid.',
/**
* Stderr message returned when a Bynder Webhook SubscriptionConfirmation message is missing required SubscribeURL.
*
* @const {string}
*/
errMsgBynderSubscribeURLMissing: 'error: SubscriptionConfirmation message missing required SubscribeURL.',
/**
* Stderr message returned when a Bynder Webhook request is missing required messageType header x-amz-sns-message-type.
*
* @const {string}
*/
errMsgBynderMessageTypeMissing: 'error: Missing required messageType header x-amz-sns-message-type.',
/**
* Stderr message returned when a Bynder Webhook Notification message failed to process.
*
* @const {string}
*/
errMsgBynderNotificationFailed: 'error: Bynder Webhook Notification failed to process.',
/**
* Stderr message returned when CLI command syntax is missing one or more required parameters.
*
* @const {string}
*/
errMsgBynderWebHookNotificationInvalid: 'error: missing required media.property_ProductSKU argument.',
/**
* Stderr message returned when SNS Notification Subject does not equal asset_bank.media.create.
*
* @const {string}
*/
errMsgBynderWebHookNotificationSubjectInvalid: 'error: invalid SNS Notification Subject, valid values: asset_bank.media.create.',
/**
* Stderr message returned when Bynder Webhook Firestore request does not match any products.
*
* @const {string}
*/
errMsgBynderWebhookProductsInvalid: 'error: Bynder Webhook invalid media.property_ProductSKU, no product matches found.',
/**
* Stderr message returned when CLI command syntax is missing one or more required parameters.
*
* @const {string}
*/
errMsgCliCommandInvalid: "error: missing required argument, either 'key' or 'data' must be supplied.",
/**
* Stderr message returned when auth createJwt CLI command syntax is missing one or more required parameters.
*
* @const {string}
*/
errMsgCliCreateJwtCommandInvalid: "error: missing required argument, 'path' must be supplied as a parameter" + ' or set as GOOGLE_APPLICATION_CREDENTIALS environment variable.',
/**
* Stderr message returned when auth createTokenId CLI command syntax is missing one or more required parameters.
*
* @const {string}
*/
errMsgCliCreateTokenIdCommandInvalid:
"error: missing required argument, 'url' must be supplied as a parameter" + ' or set as BACKEND_SERVICE_DOMAIN|FRONTEND_SERVICE_DOMAIN environment variable.',
/**
* Response error message returned when invalid Firestore request is rejected.
*
* @const {string}
*/
errMsgFirestoreQueryInvalid: 'Bad Request: Invalid Firestore request.',
/**
* Response error message returned when request query string is missing required product_id parameter.
*
* @const {string}
*/
errMsgQueryProductIdMissing: 'Bad Request: Invalid request query string, product_id parameter is required.',
/**
* Response error message returned when request query string site_id parameter value is invalid.
*
* @const {string}
*/
errMsgQuerySiteIdInvalid:
'Bad Request: Invalid request query string, site_id parameter value is invalid. site_id must one of' + ' be yeti.ca, yeti.com, www.yeti.ca or www.yeti.com.',
/**
* Response error message returned when request query string is missing required site_id parameter.
*
* @const {string}
*/
errMsgQuerySiteIdMissing: 'Bad Request: Invalid request query string, site_id parameter is required.',
/**
* ErrorReporting instance used to submit Google Error Reporting issues.
*/
errors: new ErrorReporting({
projectId: process.env.GCP_PROJECT_ID,
// Specifies when errors are reported to the Error Reporting Console.
// production (default): Only report errors if the NODE_ENV environment variable is set to "production".
// always: Always report errors regardless of the value of NODE_ENV.
// never: Never report errors regardless of the value of NODE_ENV.
reportMode: 'production',
// Catch and Report Application-wide Uncaught Errors
reportUnhandledRejections: true,
// Determines the logging level internal to the library; levels range 0-5
// where 0 indicates no logs, 1 errors, 2 warnings ... 5 all logs should be reported.
logLevel: 1,
serviceContext: {
service: `${process.env.BACKEND_SERVICE_NAME}`,
version: `${process.env.GIT_RELEASE_TAG}`,
},
}),
/**
* Converts a Firestore response payload to clean JSON object.
*
* @name firestoreConvertResponse
* @function
* @memberof module:common
* @inner
* @param {string} documentPath - Firestore document path for the document.get() operation.
* @param {object} response - Parsed Firestore javascript object.
* @param {string} resource - Resource name (aka table name) of the object - so for example 'orders' or 'customers'.
* @param {string} keyName - Name of the primary key of the resource - so for example 'orderId' or 'customerId'.
* @param {string} component - Name of the component the custom metric is for. Component is used to group together all the log entries related to a request.
* @param {object} globalLogFields - Object used to add log correlation which nest all log messages for a request beneath the request log in Log Viewer.
* @return {object} Converted resource object.
*/
firestoreConvertResponse: (documentPath, response, resource, keyName, component, globalLogFields) => {
const responseObject = {};
responseObject[resource] = [];
try {
if (response) {
const convertedRecord = {};
// see: [projects.databases.document.get](https://firebase.google.com/docs/firestore/reference/rest/v1/projects.databases.documents/get)
// handle document:get document not found (aka missing)
// commonObjects.logInfo('firestoreConvertResponse:');
// commonObjects.logInfo(JSON.stringify(response, null, 2));
if (Object.prototype.hasOwnProperty.call(response, '_fieldsProto') && response._fieldsProto !== undefined) {
convertedRecord.found = true;
convertedRecord[keyName] = documentPath.split('/').slice(-1)[0];
const record = response._fieldsProto;
Object.keys(record).forEach((key) => {
if (!['_createTime', '_readTime', '_ref', '_serializer', '_updateTime'].includes(key)) {
const value = commonObjects.firestoreGetValue(record[key]);
// commonObjects.logInfo(`record: key = '${key}', value = '${value}'`);
// commonObjects.logInfo(JSON.stringify(record, null, 2));
if (value !== undefined) {
convertedRecord[key] = value;
}
}
});
} else {
convertedRecord[keyName] = documentPath;
convertedRecord.found = false;
}
responseObject[resource].push(convertedRecord);
}
} catch (err) {
commonObjects.logAppError(err, component, globalLogFields);
}
return responseObject[resource][0];
},
/**
* Retrieves a Firestore value from different data type objects (String, Boolean, Geo, Timestamp)
*
* @name firestoreGetValue
* @function
* @memberof module:common
* @inner
* @param {object} node - The Firestore input object.
* @return {object} - The string value of hte data type object.
*/
firestoreGetValue: (node) => {
// commonObjects.logInfo('firestoreGetValue.node:');
// commonObjects.logInfo(JSON.stringify(node, null, 2));
let result;
if (node && node.arrayValue) {
const arrayValues = [];
const values = node.arrayValue.values;
values.forEach((valueObj) => {
const fields = commonObjects.firestoreGetValue(values[valueObj]);
arrayValues.push(fields);
});
result = arrayValues;
} else if (node && Object.prototype.hasOwnProperty.call(node, 'booleanValue')) {
result = node.booleanValue;
} else if (node && node.doubleValue) {
result = node.doubleValue;
} else if (node && node.geoPointValue) {
result = node.geoPointValue.latitude + ', ' + node.geoPointValue.longitude;
} else if (node && node.integerValue) {
result = node.integerValue;
} else if (node && node.mapValue) {
const mapValues = {};
if (node.mapValue.fields) {
const mapValueFields = node.mapValue.fields;
Object.keys(mapValueFields).forEach((key) => {
const value = commonObjects.firestoreGetValue(mapValueFields[key]);
if (value !== undefined) {
mapValues[key] = value;
}
});
}
result = mapValues;
} else if (node && node.stringValue) {
result = node.stringValue;
} else if (node && node.timestampValue) {
// extract the seconds and nanos values from your Firestore timestamp object
const {seconds, nanos} = node.timestampValue;
// combine the seconds and nanos values into a single timestamp in milliseconds
const milliseconds = seconds * 1000 + nanos / 1e6;
// use Moment.js to convert the timestamp to a date
return moment(milliseconds).format();
}
return result;
},
/**
* Frontend Node.js Express application service domain.
*
* @const {string}
*/
frontendServiceDomain: process.env.FRONTEND_SERVICE_DOMAIN,
/**
* Add log correlation to nest all log messages beneath request log in Log Viewer.
*
* @name getGlobalLogFields
* @function
* @memberof module:common
* @inner
* @param {object} req - The req object represents the HTTP request and has properties for the request query string,
* parameters, body, HTTP headers, and so on. In this documentation and by convention, the object is always
* referred to as req (and the HTTP response is res) but its actual name is determined by the parameters to the
* callback function in which you are working.
*/
getGlobalLogFields: async (req) => {
let globalLogFields;
const cloudTraceContext = req.get('x-cloud-trace-context');
if (cloudTraceContext) {
logInfo(`traceHeader: ${cloudTraceContext}`);
const [trace] = cloudTraceContext.split('/');
globalLogFields = {
'logging.googleapis.com/trace': `projects/${process.env.GCP_PROJECT_ID}/traces/${trace}`,
};
} else {
globalLogFields = {};
}
return globalLogFields;
},
/**
* Return Bynder media asset object.
*
* @name getMediaInfo
* @function
* @memberof module:common
* @inner
* @async
* @param {string} id - Bynder asset unique identifier.
* @param {string} component - Name of the component the custom metric is for. Component is used to group together
* all the log entries related to a request.
* @param {object} globalLogFields - Object used to add log correlation which nest all log messages for a request
* beneath the request log in Log Viewer.
* @return {Promise<*>}
*/
getMediaInfo: async (id, component, globalLogFields) => {
// noinspection JSCheckFunctionSignatures
// const bynder = new Bynder(bynderOptions);
// // ensure Bynder API Call does not leave any open handles
// await process.nextTick(() => {});
// // .send('GET', `v4/media/${id}/`, common.bynderOptions)
// // noinspection JSUnresolvedFunction
// return await bynder
// .getMediaInfo({id})
// .then((data) => data)
// .catch((err) => err);
// ensure fetch API Call does not leave any open handles
await process.nextTick(() => {});
const url = `${commonObjects.bynderBaseUrl}/v4/media/${id}/`;
return await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${commonObjects.bynderPermanentToken}`,
'User-Agent': `${commonObjects.name}/${commonObjects.version}`,
},
})
.then((res) => res.json())
.catch((err) => {
commonObjects.logAppError(err, component, globalLogFields);
});
},
/**
* Return all Bynder metaproperties.
*
* @name getMetaproperties
* @function
* @memberof module:common
* @inner
* @async
* @param {string} component - Name of the component the custom metric is for. Component is used to group together
* all the log entries related to a request.
* @param {object} globalLogFields - Object used to add log correlation which nest all log messages for a request
* beneath the request log in Log Viewer.
* @return {Promise<*>}
*/
getMetaproperties: async (component, globalLogFields) => {
// noinspection JSCheckFunctionSignatures
// const bynder = new Bynder(bynderOptions);
// ensure Bynder API Call does not leave any open handles
// await process.nextTick(() => {});
// .send('GET', `v4/metaproperties/`, options)
// noinspection JSUnresolvedFunction
// return bynder
// .getMetaproperties()
// .then((data) => data)
// .catch((err) => err);
// ensure fetch API Call does not leave any open handles
await process.nextTick(() => {});
const url = `${commonObjects.bynderBaseUrl}/v4/metaproperties/`;
return await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${commonObjects.bynderPermanentToken}`,
'User-Agent': `${commonObjects.name}/${commonObjects.version}`,
},
})
.then((res) => res.json())
.catch((err) => {
commonObjects.logAppError(err, component, globalLogFields);
});
},
/**
* In production, creates JSON structured Google Stackdriver log ERROR entry.
* Log viewer accesses 'component' as 'jsonPayload.component'.
* Otherwise, logs message to stderr.
*
* @name logAppError
* @function
* @memberof module:common
* @inner
* @param {string} message - Error message description.
* @param {string} component - Name of the component that generated the error.
* @param {object} globalLogFields - Global logging.googleapis.com/trace object.
*/
logAppError: (message, component, globalLogFields) => {
const entry = Object.assign({severity: 'ERROR', message: message, component: component}, globalLogFields);
if (process.env.NODE_ENV === 'production') {
// Create JSON structured Google Stackdriver log entry in production.
// Log viewer accesses 'component' as 'jsonPayload.component'.
console.error(JSON.stringify(entry));
} else {
// Otherwise, log message to stderr.
logError(message);
}
if (debug.enabled('app:error')) {
console.trace(component);
} else {
logError(JSON.stringify(entry));
}
},
/**
* In production, creates JSON structured Google Stackdriver log INFO entry.
* Log viewer accesses 'component' as 'jsonPayload.component'.
* Otherwise, logs message to stderr.
*
* @name logAppInfo
* @function
* @memberof module:common
* @inner
* @param {string} message - Info message description.
* @param {string} component - Name of the component that log message is for.
* @param {object} globalLogFields - Global logging.googleapis.com/trace object.
*/
logAppInfo: (message, component, globalLogFields) => {
const entry = Object.assign({severity: 'INFO', message: message, component: component}, globalLogFields);
if (process.env.NODE_ENV === 'production') {
console.info(JSON.stringify(entry));
} else {
// Otherwise, log message to stderr.
logInfo(message);
}
},
/**
* Control stderr logging using DEBUG command line flag.
* set DEBUG=app:error on the command line to enable stderr logging.
* @example
* DEBUG=app:error node index.js
* DEBUG=app:* node index.js
*
* when DEBUG=app:error command line flag is not set stderr logging is disabled.
* @example node index.js
* @name logError
* @function
* @memberof module:common
* @inner
*/
logError,
/**
* Control stdout logging using DEBUG command line flag.
* set DEBUG=app:log on the command line to enable stdout logging.
* @example
* DEBUG=app:log node index.js
* DEBUG=app:* node index.js
*
* when DEBUG=app:log command line flag is not set stdout logging is disabled.
* @example node index.js
* @name logInfo
* @function
* @memberof module:common
* @inner
*/
logInfo,
/**
* Application name.
*
* @const {string}
*/
name: process.env.GIT_PROJECT_NAME,
/**
* Child process stderr, stdout and exit event listener.
* @name onExit
* @function
* @memberof module:common
* @inner
* @param {object} childProcess - The child process object to listen to events for.
* @return {Promise<*>}
*/
onExit: (childProcess) => {
return new Promise((resolve, reject) => {
childProcess.stderr.once('data', (data) => reject(data));
childProcess.stdout.once('data', (data) => resolve(data));
childProcess.once('exit', (code, signal) => {
if (code === 0) {
resolve(undefined);
} else {
reject(new Error(`Exit with error code: ${code}, signal: ${signal}`));
}
});
childProcess.once('error', (err) => {
reject(err);
});
});
},
/**
* Google Cloud Firestore Node.js Client options.
*
* @const {object}
*/
options,
/**
* Port the Express application listens on.
*
* @const {string}
*/
port: process.env.PORT || 8080,
/**
* Product Enrichment API GET Product endpoint path.
*
* @const {string}
*/
productPath: `${process.env.BASE_PATH}/product`,
/**
* Google Cloud Platform project id.
*
* @const {string}
*/
projectId: process.env.GCP_PROJECT_ID,
/**
* Array of one or more supported website id(s).
*
* @const {array}
*/
siteIdArray: ['yeti.ca', 'yeti.com', 'www.yeti.ca', 'www.yeti.com'],
/**
* Unique identifier for a Bynder asset uploaded for testing.
*/
testBynderAssetId: '06D7138F-FCD3-41CF-A344680A52A99264',
/**
* Node.js Express application and CLI version.
*
* @const {string}
*/
version: process.env.GIT_RELEASE_TAG,
/**
* Write custom metric time series data to track request count by endpoint and requestor IP.
*
* @name writeEndpointRequestCountTimeSeriesDataPoint
* @function
* @memberof module:common
* @inner
* @param {object} req - The req object represents the HTTP request and has properties for the request query string,
* parameters, body, HTTP headers, and so on. In this documentation and by convention, the object is always
* referred to as req (and the HTTP response is res) but its actual name is determined by the parameters to the
* callback function in which you are working.
* @param {string} endpoint - Name of the API Endpoint.
* @param {string} component - Name of the component the custom metric is for. Component is used to group together
* all the log entries related to a request.
* @param {object} globalLogFields - Object used to add log correlation which nest all log messages for a request
* beneath the request log in Log Viewer.
* @param {string} productIds - One or more product ids the request is for.
* @param {string} subject - SNS Notification subject or GET request header.
*/
writeEndpointRequestCountTimeSeriesDataPoint: async (req, endpoint, component, globalLogFields, productIds, subject) => {
try {
const metricType = 'custom.googleapis.com/endpoints/request_count';
commonObjects.logAppInfo(`Begin writing ${metricType} time series metric data.`, component, globalLogFields);
const projectId = process.env.GCP_PROJECT_ID;
const requestorIp = req.headers['x-envoy-external-address'] || req.headers['x-forwarded-for'] || req.ip;
const dataPoint = {
interval: {
endTime: {
seconds: Date.now() / 1000,
},
},
value: {
int64Value: 1,
},
};
// noinspection JSCheckFunctionSignatures
const timeSeriesData = {
metric: {
type: metricType,
// label key naming convention
// - Use lower-case letters (a-z), digits (0-9), and underscores (_).
// - You must start label keys with a letter or underscore.
// - The maximum length of a label key is 100 characters.
// - Each key must be unique within the metric type.
// - You can have no more than 30 labels per metric type.
// see: https://cloud.google.com/monitoring/api/v3/naming-conventions#naming-types-and-labels
labels: {
component,
endpoint,
product_ids: productIds,
requestor_ip: requestorIp,
subject: subject.substring(0, 1023),
},
},
resource: {
type: 'generic_task',
labels: {
project_id: projectId,
location: 'us-central1',
namespace: process.env.FRONTEND_SERVICE_DOMAIN,
job: endpoint,
task_id: uuidv4(),
},
},
points: [dataPoint],
};
const request = {
name: metricServiceClient.projectPath(projectId),
timeSeries: [timeSeriesData],
};
// Writes time series data
await metricServiceClient.createTimeSeries(request);
commonObjects.logAppInfo(`Done writing ${metricType} time series metric data.`, component, globalLogFields);
} catch (err) {
commonObjects.logAppError(err, component, globalLogFields);
}
},
/**
* Write JSON data to file system.
* @name writeFile
* @function
* @memberof module:common
* @inner
* @param {string} filePath - File systems path for the file to create.
* @param {object} data - JSON object to write to a file.
*/
writeFile: (filePath, data) => {
// path.resolve - resolves a sequence of paths or path segments into an absolute path.
const fp = path.resolve(filePath);
logInfo(`writing data to: ${fp}`);
fs.writeFile(fp, JSON.stringify(data, null, 2), (err) => {
if (err) {
logError(err);
return err;
}
});
},
};
module.exports = commonObjects;