bcgov/common-object-management-service

View on GitHub
app/app.js

Summary

Maintainability
A
3 hrs
Test Coverage
const Problem = require('api-problem');
const compression = require('compression');
const config = require('config');
const cors = require('cors');
const express = require('express');
const helmet = require('helmet');

const { name: appName, version: appVersion } = require('./package.json');
const { AuthMode, DEFAULTCORS } = require('./src/components/constants');
const log = require('./src/components/log')(module.filename);
const httpLogger = require('./src/components/log').httpLogger;
const QueueManager = require('./src/components/queueManager');
const { getAppAuthMode, getConfigBoolean, getGitRevision } = require('./src/components/utils');
const DataConnection = require('./src/db/dataConnection');
const v1Router = require('./src/routes/v1');
const { readUnique } = require('./src/services/bucket');

const dataConnection = new DataConnection();
const queueManager = new QueueManager();

const apiRouter = express.Router();
const state = {
  authMode: getAppAuthMode(),
  connections: {},
  gitRev: getGitRevision(),
  ready: false,
  shutdown: false
};

let probeId;
let queueId;

const app = express();
app.use(compression());
app.use(cors(DEFAULTCORS));
app.use(express.urlencoded({ extended: true }));
app.use(helmet());

// Skip if running tests
if (process.env.NODE_ENV !== 'test') {
  // Initialize connections and exit if unsuccessful
  initializeConnections();
  app.use(httpLogger);
}

// Application authentication modes
switch (state.authMode) {
  case AuthMode.NOAUTH:
    log.info('Running COMS in public no-auth mode');
    break;
  case AuthMode.BASICAUTH:
    log.info('Running COMS in basic auth mode');
    break;
  case AuthMode.OIDCAUTH:
    log.info('Running COMS in oidc auth mode');
    break;
  case AuthMode.FULLAUTH:
    log.info('Running COMS in full (basic + oidc) auth mode');
    break;
}
if (state.authMode === AuthMode.OIDCAUTH || state.authMode === AuthMode.FULLAUTH) {
  if (!config.has('keycloak.publicKey')) {
    log.error('OIDC environment variable KC_PUBLICKEY or keycloak.publicKey must be defined');
    process.exitCode = 1;
    shutdown();
  }
}

// Application privacy Mode mode
if (getConfigBoolean('server.privacyMask')) {
  log.info('Running COMS with strict content privacy masking');
} else {
  log.info('Running COMS with permissive content privacy masking');
}

// Block requests until service is ready
app.use((_req, _res, next) => {
  if (state.shutdown) {
    throw new Problem(503, { detail: 'Server is shutting down' });
  } else if (!state.ready) {
    throw new Problem(503, { detail: 'Server is not ready' });
  } else {
    next();
  }
});

// Base API Directory
apiRouter.get('/', (_req, res) => {
  if (state.shutdown) {
    throw new Error('Server shutting down');
  } else {
    res.status(200).json({
      app: {
        authMode: state.authMode,
        gitRev: state.gitRev,
        name: appName,
        nodeVersion: process.version,
        privacyMask: getConfigBoolean('server.privacyMask'),
        version: appVersion
      },
      endpoints: ['/api/v1'],
      versions: [1]
    });
  }
});

// v1 Router
apiRouter.use('/v1', v1Router);

// Root level Router
app.use(/(\/api)?/, apiRouter);

// Handle 404
app.use((req, _res) => { // eslint-disable-line no-unused-vars
  throw new Problem(404, { instance: req.originalUrl });
});

// Handle Problem Responses
app.use((err, req, res, _next) => { // eslint-disable-line no-unused-vars
  if (err instanceof Problem) {
    err.send(res);
  } else {
    if (err.stack) log.error(err); // Only log unexpected errors
    new Problem(500, { detail: err.message ?? err, instance: req.originalUrl }).send(res);
  }
});

// Ensure unhandled errors gracefully shut down the application
process.on('unhandledRejection', err => {
  log.error(`Unhandled Rejection: ${err.message ?? err}`, { function: 'onUnhandledRejection' });
  fatalErrorHandler();
});
process.on('uncaughtException', err => {
  log.error(`Unhandled Exception: ${err.message ?? err}`, { function: 'onUncaughtException' });
  fatalErrorHandler();
});

// Graceful shutdown support
['SIGHUP', 'SIGINT', 'SIGTERM', 'SIGQUIT', 'SIGUSR1', 'SIGUSR2']
  .forEach(signal => process.on(signal, shutdown));
process.on('exit', code => {
  log.info(`Exiting with code ${code}`, { function: 'onExit' });
});

/**
 * @function cleanup
 * Cleans up connections in this application.
 */
function cleanup() {
  log.info('Cleaning up', { function: 'cleanup' });
  // Set 10 seconds max deadline before hard exiting
  setTimeout(process.exit, 10000).unref(); // Prevents the timeout from registering on event loop

  clearInterval(probeId);
  clearInterval(queueId);
  queueManager.close(() => setTimeout(() => {
    dataConnection.close(process.exit);
  }, 3000));
}

/**
 * @function checkConnections
 * Checks Database connectivity
 * This may force the application to exit if a connection fails
 */
function checkConnections() {
  const wasReady = state.ready;
  if (!state.shutdown) {
    dataConnection.checkConnection().then(results => {
      state.connections.data = results;
      state.ready = Object.values(state.connections).every(x => x);
      if (!wasReady && state.ready) log.info('Application ready to accept traffic', {
        function: 'checkConnections'
      });
      if (wasReady && !state.ready) log.warn('Application not ready to accept traffic', {
        function: 'checkConnections'
      });
      log.silly('App state', { function: 'checkConnections', state: state });
      if (!state.ready) notReadyHandler();
    });
  }
}

/**
 * @function fatalErrorHandler
 * Forces the application to shutdown
 */
function fatalErrorHandler() {
  process.exitCode = 1;
  shutdown();
}

/**
 * @function initializeConnections
 * Initializes the database connections
 * This may force the application to exit if it fails
 */
function initializeConnections() {
  // Initialize connections and exit if unsuccessful
  dataConnection.checkAll()
    .then(results => {
      state.connections.data = results;

      if (state.connections.data) {
        log.info('DataConnection Reachable', { function: 'initializeConnections' });
      }
      if (getConfigBoolean('objectStorage.enabled')) {
        readUnique(config.get('objectStorage')).then(() => {
          log.error('Default bucket cannot also exist in database', { function: 'initializeConnections' });
          fatalErrorHandler();
        }).catch(() => { });
      }
    })
    .catch(error => {
      log.error(`Initialization failed: Database OK = ${state.connections.data}`, {
        function: 'initializeConnections'
      });
      log.error('Connection initialization failure', error.message, { function: 'initializeConnections' });
      if (!state.ready) notReadyHandler();
    })
    .finally(() => {
      state.ready = Object.values(state.connections).every(x => x);
      if (state.ready) log.info('Application ready to accept traffic', { function: 'initializeConnections' });

      // Start periodic 10 second connection probes
      probeId = setInterval(checkConnections, 10000);
      queueId = setInterval(() => {
        if (state.ready) queueManager.checkQueue();
      }, 10000);
    });
}

/**
 * @function notReadyHandler
 * Forces an application shutdown if `server.hardReset` is defined.
 * Otherwise will flush and attempt to reset the connection pool.
 */
function notReadyHandler() {
  if (config.has('server.hardReset')) fatalErrorHandler();
  else dataConnection.resetConnection();
}

/**
 * @function shutdown
 * Begins the shutdown procedure for this application
 */
function shutdown() {
  log.info('Shutting down', { function: 'shutdown' });
  if (!state.shutdown) {
    state.shutdown = true;
    log.info('Application no longer accepting traffic', { function: 'shutdown' });
    cleanup();
  }
}

module.exports = app;