cloud-sql/postgres/knex/index.js (192 lines of code) (raw):
// Copyright 2022 Google LLC
//
// 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.
'use strict';
const express = require('express');
const createConnectorIAMAuthnPool = require('./connect-connector-with-iam-authn.js');
const createConnectorPool = require('./connect-connector.js');
const createTcpPool = require('./connect-tcp.js');
const createUnixSocketPool = require('./connect-unix.js');
const app = express();
app.set('view engine', 'pug');
app.enable('trust proxy');
// Automatically parse request body as form data.
app.use(express.urlencoded({extended: false}));
// This middleware is available in Express v4.16.0 onwards
app.use(express.json());
// Set Content-Type for all responses for these routes.
app.use((req, res, next) => {
res.set('Content-Type', 'text/html');
next();
});
// Create a Winston logger that streams to Stackdriver Logging.
const winston = require('winston');
const {LoggingWinston} = require('@google-cloud/logging-winston');
const loggingWinston = new LoggingWinston();
const logger = winston.createLogger({
level: 'info',
transports: [new winston.transports.Console(), loggingWinston],
});
// Retrieve and return a specified secret from Secret Manager
const {SecretManagerServiceClient} = require('@google-cloud/secret-manager');
const client = new SecretManagerServiceClient();
async function accessSecretVersion(secretName) {
const [version] = await client.accessSecretVersion({name: secretName});
return version.payload.data;
}
// Set up a variable to hold our connection pool. It would be safe to
// initialize this right away, but we defer its instantiation to ease
// testing different configurations.
let pool;
app.use(async (req, res, next) => {
if (pool) {
return next();
}
try {
pool = await createPoolAndEnsureSchema();
next();
} catch (err) {
logger.error(err);
return next(err);
}
});
// Initialize Knex, a Node.js SQL query builder library with built-in connection pooling.
const createPool = async () => {
// Configure which instance and what database user to connect with.
// Remember - storing secrets in plaintext is potentially unsafe. Consider using
// something like https://cloud.google.com/kms/ to help keep secrets secret.
const config = {pool: {}};
// [START cloud_sql_postgres_knex_limit]
// 'max' limits the total number of concurrent connections this pool will keep. Ideal
// values for this setting are highly variable on app design, infrastructure, and database.
config.pool.max = 5;
// 'min' is the minimum number of idle connections Knex maintains in the pool.
// Additional connections will be established to meet this value unless the pool is full.
config.pool.min = 5;
// [END cloud_sql_postgres_knex_limit]
// [START cloud_sql_postgres_knex_timeout]
// 'acquireTimeoutMillis' is the number of milliseconds before a timeout occurs when acquiring a
// connection from the pool. This is slightly different from connectionTimeout, because acquiring
// a pool connection does not always involve making a new connection, and may include multiple retries.
// when making a connection
config.pool.acquireTimeoutMillis = 60000; // 60 seconds
// 'createTimeoutMillis` is the maximum number of milliseconds to wait trying to establish an
// initial connection before retrying.
// After acquireTimeoutMillis has passed, a timeout exception will be thrown.
config.pool.createTimeoutMillis = 30000; // 30 seconds
// 'idleTimeoutMillis' is the number of milliseconds a connection must sit idle in the pool
// and not be checked out before it is automatically closed.
config.pool.idleTimeoutMillis = 600000; // 10 minutes
// [END cloud_sql_postgres_knex_timeout]
// [START cloud_sql_postgres_knex_backoff]
// 'knex' uses a built-in retry strategy which does not implement backoff.
// 'createRetryIntervalMillis' is how long to idle after failed connection creation before trying again
config.pool.createRetryIntervalMillis = 200; // 0.2 seconds
// [END cloud_sql_postgres_knex_backoff]
// Check if a Secret Manager secret version is defined
// If a version is defined, retrieve the secret from Secret Manager and set as the DB_PASS
const {CLOUD_SQL_CREDENTIALS_SECRET} = process.env;
if (CLOUD_SQL_CREDENTIALS_SECRET) {
const secrets = await accessSecretVersion(CLOUD_SQL_CREDENTIALS_SECRET);
try {
process.env.DB_PASS = secrets.toString();
} catch (err) {
err.message = `Unable to parse secret from Secret Manager. Make sure that the secret is JSON formatted: \n ${err.message} `;
throw err;
}
}
if (process.env.INSTANCE_CONNECTION_NAME) {
// Uses the Cloud SQL Node.js Connector when INSTANCE_CONNECTION_NAME
// (e.g., project:region:instance) is defined
if (process.env.DB_IAM_USER) {
// Either a DB_USER or a DB_IAM_USER should be defined. If both are
// defined, DB_IAM_USER takes precedence
return createConnectorIAMAuthnPool(config);
} else {
return createConnectorPool(config);
}
} else if (process.env.INSTANCE_HOST) {
// Use a TCP socket when INSTANCE_HOST (e.g., 127.0.0.1) is defined
return createTcpPool(config);
} else if (process.env.INSTANCE_UNIX_SOCKET) {
// Use a Unix socket when INSTANCE_UNIX_SOCKET (e.g., /cloudsql/proj:region:instance) is defined.
return createUnixSocketPool(config);
} else {
throw 'One of INSTANCE_HOST or INSTANCE_UNIX_SOCKET` is required.';
}
};
const ensureSchema = async pool => {
const hasTable = await pool.schema.hasTable('votes');
if (!hasTable) {
return pool.schema.createTable('votes', table => {
table.increments('vote_id').primary();
table.timestamp('time_cast', 30).notNullable();
table.specificType('candidate', 'CHAR(6)').notNullable();
});
}
logger.info("Ensured that table 'votes' exists");
};
const createPoolAndEnsureSchema = async () =>
await createPool()
.then(async pool => {
await ensureSchema(pool);
return pool;
})
.catch(err => {
logger.error(err);
throw err;
});
// [START cloud_sql_postgres_knex_connection]
/**
* Insert a vote record into the database.
*
* @param {object} pool The Knex connection object.
* @param {object} vote The vote record to insert.
* @returns {Promise}
*/
const insertVote = async (pool, vote) => {
try {
return await pool('votes').insert(vote);
} catch (err) {
throw Error(err);
}
};
// [END cloud_sql_postgres_knex_connection]
/**
* Retrieve the latest 5 vote records from the database.
*
* @param {object} pool The Knex connection object.
* @returns {Promise}
*/
const getVotes = async pool => {
return await pool
.select('candidate', 'time_cast')
.from('votes')
.orderBy('time_cast', 'desc')
.limit(5);
};
/**
* Retrieve the total count of records for a given candidate
* from the database.
*
* @param {object} pool The Knex connection object.
* @param {object} candidate The candidate for which to get the total vote count
* @returns {Promise}
*/
const getVoteCount = async (pool, candidate) => {
return await pool('votes').count('vote_id').where('candidate', candidate);
};
const httpGet = async (req, res) => {
pool = pool || (await createPoolAndEnsureSchema());
try {
// Query the total count of "TABS" from the database.
const tabsResult = await getVoteCount(pool, 'TABS');
const tabsTotalVotes = parseInt(tabsResult[0].count);
// Query the total count of "SPACES" from the database.
const spacesResult = await getVoteCount(pool, 'SPACES');
const spacesTotalVotes = parseInt(spacesResult[0].count);
// Query the last 5 votes from the database.
const votes = await getVotes(pool);
// Calculate and set leader values.
let leadTeam = '';
let voteDiff = 0;
let leaderMessage = '';
if (tabsTotalVotes !== spacesTotalVotes) {
if (tabsTotalVotes > spacesTotalVotes) {
leadTeam = 'TABS';
voteDiff = tabsTotalVotes - spacesTotalVotes;
} else {
leadTeam = 'SPACES';
voteDiff = spacesTotalVotes - tabsTotalVotes;
}
leaderMessage =
`${leadTeam} are winning by ${voteDiff} vote` +
(voteDiff > 1 ? 's' : '');
} else {
leaderMessage = 'TABS and SPACES are evenly matched!';
}
res.render('index.pug', {
votes: votes,
tabsCount: tabsTotalVotes,
spacesCount: spacesTotalVotes,
leadTeam: leadTeam,
voteDiff: voteDiff,
leaderMessage: leaderMessage,
});
} catch (err) {
console.error(err);
res
.status(500)
.send('Unable to load page; see logs for more details.')
.end();
}
};
app.get('/', httpGet);
const httpPost = async (req, res) => {
pool = pool || (await createPoolAndEnsureSchema());
// Get the team from the request and record the time of the vote.
const {team} = req.body;
const timestamp = new Date();
if (!team || (team !== 'TABS' && team !== 'SPACES')) {
res.status(400).send('Invalid team specified.').end();
return;
}
// Create a vote record to be stored in the database.
const vote = {
candidate: team,
time_cast: timestamp,
};
// Save the data to the database.
try {
await insertVote(pool, vote);
} catch (err) {
logger.error(`Error while attempting to submit vote:${err}`);
res
.status(500)
.send('Unable to cast vote; see logs for more details.')
.end();
return;
}
res.status(200).send(`Successfully voted for ${team} at ${timestamp}`).end();
};
app.post('*', httpPost);
/**
* Responds to GET and POST requests for TABS vs SPACES sample app.
*
* @param {Object} req Cloud Function request context.
* @param {Object} res Cloud Function response context.
*/
exports.votes = (req, res) => {
switch (req.method) {
case 'GET':
httpGet(req, res);
break;
case 'POST':
httpPost(req, res);
break;
default:
res.status(405).send({error: 'Something blew up!'});
break;
}
};
module.exports.app = app;