diff --git a/appengine/cloudsql/README.md b/appengine/cloudsql/README.md index d5e5ad6622..3c5c14ed4b 100644 --- a/appengine/cloudsql/README.md +++ b/appengine/cloudsql/README.md @@ -1,101 +1,11 @@ -# Node.js Cloud SQL sample on Google App Engine +# Cloud SQL for MySQL Node.js sample on App Engine flexible environment -This sample demonstrates how to use [Google Cloud SQL][sql] (or any other SQL -server) on [Google App Engine Flexible][flexible]. +This sample demonstrates how to use [Google Cloud SQL][sql] for +[MySQL][mysql] on [Google App Engine Flexible][flexible]. -This sample has instructions for both [MySQL][mysql] and [Postgres][postgres]. - -## Setup - -### General steps -Before you can run or deploy the sample, you will need to do the following: - -1. In order for some of the commands below to work, you need to enable the -[Cloud SQL Admin API](https://console.cloud.google.com/apis/api/sqladmin-json.googleapis.com/overview). -1. Create a [Second Generation Cloud SQL][gen] instance. You can do this from -the [Cloud Console][console] or via the [Cloud SDK][sdk]. To create it via the -SDK use the following command: - - gcloud sql instances create [YOUR_INSTANCE_NAME] \ - --activation-policy=ALWAYS \ - --tier=db-n1-standard-1 - - where `[YOUR_INSTANCE_NAME]` is a name of your choice. - -1. Set the root password on your Cloud SQL instance: - - gcloud sql instances set-root-password [YOUR_INSTANCE_NAME] --password [YOUR_INSTANCE_ROOT_PASSWORD] - - where `[YOUR_INSTANCE_NAME]` is the name you chose in step 1 and - `[YOUR_INSTANCE_ROOT_PASSWORD]` is a password of your choice. - -1. Using the [Cloud SQL console][sql_console], select your Cloud SQL instance. -Then, create a [user][user] (using the button in the *Access Control* > *Users* tab) and a -[database][database] (using the button in the *Databases* tab). - -1. Create and download a [Service Account][service] for your project. You will -use this service account to connect to your Cloud SQL instance locally. - -1. Download and install the [Cloud SQL Proxy][proxy]. - -1. [Start the proxy][start] to allow connecting to your instance from your local -machine: - - ./cloud_sql_proxy \ - -instances=[YOUR_INSTANCE_CONNECTION_NAME]=tcp:[PORT] \ - -credential_file=PATH_TO_YOUR_SERVICE_ACCOUNT_JSON_FILE - - where `[YOUR_INSTANCE_CONNECTION_NAME]` is the connection name of your - instance on its Overview page in the Google Cloud Platform Console, or use - `[YOUR_PROJECT_ID]:[YOUR_REGION]:[YOUR_INSTANCE_NAME]`. If you're using - MySQL, `[PORT]` will be `3306`; for Postgres, it will be `5432`. - -1. In a separate terminal, set the `SQL_USER`, `SQL_PASSWORD`, and `SQL_DATABASE` environment -variables to their respective values. This allows your local app to connect to your Cloud SQL -instance through the proxy. - - export SQL_USER="..." - export SQL_PASSWORD="..." - export SQL_DATABASE="..." - -### Choosing a SQL client -Choose which database connector to use via the `SQL_CLIENT` environment variable. - -To use MySQL, set it to `mysql`: - - export SQL_CLIENT="mysql" - -To use Postgres, set it to `pg`: - - export SQL_CLIENT="pg" - -### Final setup steps -1. Update the values in `app.yaml` with your instance configuration. - -1. Finally, run `createTables.js` to ensure that the database is properly -configured and to create the tables needed for the sample. - -### Running locally - -Refer to the [top-level README](../README.md) for instructions on running and deploying. - -It's recommended to follow the instructions above to run the Cloud SQL proxy. -You will need to set the appropriate environment variables (as shown above) and -run the following commands via your shell to run the sample: - - npm install - npm start +To run the sample, see [the tutorial][tutorial]. [sql]: https://cloud.google.com/sql/ [flexible]: https://cloud.google.com/appengine -[gen]: https://cloud.google.com/sql/docs/create-instance -[console]: https://console.developers.google.com -[sql_console]: https://console.developers.google.com/sql/instances/ -[sdk]: https://cloud.google.com/sdk -[service]: https://cloud.google.com/sql/docs/external#createServiceAccount -[proxy]: https://cloud.google.com/sql/docs/external#install -[start]: https://cloud.google.com/sql/docs/external#6_start_the_proxy -[user]: https://cloud.google.com/sql/docs/create-user -[database]: https://cloud.google.com/sql/docs/create-database [mysql]: https://www.mysql.com/downloads/ -[postgres]: https://www.postgresql.org/download/ +[tutorial]: https://cloud.google.com/appengine/docs/flexible/nodejs/using-cloud-sql diff --git a/appengine/cloudsql/app.yaml b/appengine/cloudsql/app.yaml index ac188039bb..b02543d6c2 100644 --- a/appengine/cloudsql/app.yaml +++ b/appengine/cloudsql/app.yaml @@ -17,12 +17,11 @@ env: flex # [START env] env_variables: - SQL_USER: YOUR_USER - SQL_PASSWORD: YOUR_PASSWORD - SQL_DATABASE: YOUR_DATABASE + SQL_USER: YOUR_SQL_USER + SQL_PASSWORD: YOUR_SQL_PASSWORD + SQL_DATABASE: YOUR_SQL_DATABASE # e.g. my-awesome-project:us-central1:my-cloud-sql-instance INSTANCE_CONNECTION_NAME: YOUR_INSTANCE_CONNECTION_NAME - SQL_CLIENT: YOUR_SQL_CLIENT # either 'pg' or 'mysql' (all lowercase) # [END env] # [START cloudsql_settings] diff --git a/appengine/cloudsql/createTables.js b/appengine/cloudsql/createTables.js index 819452df8e..37ccbcb1c7 100644 --- a/appengine/cloudsql/createTables.js +++ b/appengine/cloudsql/createTables.js @@ -15,84 +15,39 @@ 'use strict'; -// Require process, so we can mock environment variables -const process = require('process'); - // [START createTables] -// [START setup] const Knex = require('knex'); const prompt = require('prompt'); -// [END setup] -// [START createTable] -/** - * Create the "visits" table. - * - * @param {object} knex A Knex client object. - */ -function createTable (knex) { - return knex.schema.createTable('visits', (table) => { +const FIELDS = ['user', 'password', 'database']; + +prompt.start(); + +// Prompt the user for connection details +prompt.get(FIELDS, (err, config) => { + if (err) { + console.error(err); + return; + } + + // Connect to the database + const knex = Knex({ client: 'mysql', connection: config }); + + // Create the "visits" table + knex.schema.createTable('visits', (table) => { table.increments(); table.timestamp('timestamp'); table.string('userIp'); }) .then(() => { console.log(`Successfully created 'visits' table.`); - return knex; + return knex.destroy(); }) .catch((err) => { console.error(`Failed to create 'visits' table:`, err); - return knex; + if (knex) { + knex.destroy(); + } }); -} -// [END createTable] - -// [START getConnection] -/** - * Ask the user for connection configuration and create a new connection. - */ -function getConnection () { - const FIELDS = ['user', 'password', 'database']; - return new Promise((resolve, reject) => { - prompt.start(); - prompt.get(FIELDS, (err, config) => { - if (err) { - return reject(err); - } - - // Connect to the database - return resolve(Knex({ - client: process.env.SQL_CLIENT, - connection: config - })); - }); - }); -} -// [END getConnection] - -exports.main = function () { - // [START main] - getConnection() - .then((knex) => { - return createTable(knex); - }) - .then((knex) => { - return knex.destroy(); - }) - .catch((err, knex) => { - console.error(`Failed to create database connection:`, err); - if (knex) { - knex.destroy(); - } - }); - // [END main] -}; +}); // [END createTables] - -// Get type of SQL client to use -const sqlClient = process.env.SQL_CLIENT; -if (sqlClient === 'pg' || sqlClient === 'mysql') { - exports.main(); -} else { - throw new Error(`The SQL_CLIENT environment variable must be set to lowercase 'pg' or 'mysql'.`); -} diff --git a/appengine/cloudsql/package.json b/appengine/cloudsql/package.json index 3afea948c0..4ec82752e3 100644 --- a/appengine/cloudsql/package.json +++ b/appengine/cloudsql/package.json @@ -1,6 +1,6 @@ { - "name": "appengine-cloudsql", - "description": "Sample for Google Cloud SQL on Google App Engine Flexible Environment.", + "name": "appengine-cloudsql-mysql", + "description": "Node.js MySQL sample for Cloud SQL on App Engine flexible environment.", "version": "0.0.1", "private": true, "license": "Apache-2.0", @@ -14,7 +14,6 @@ }, "scripts": { "deploy": "gcloud app deploy", - "start": "node server.js", "lint": "samples lint", "pretest": "npm run lint", "unit-test": "ava --verbose test/*.test.js", @@ -30,7 +29,6 @@ "express": "4.15.4", "knex": "0.13.0", "mysql": "2.14.1", - "pg": "7.2.0", "prompt": "1.0.0" }, "devDependencies": { @@ -43,7 +41,6 @@ "test": { "app": { "requiredEnvVars": [ - "SQL_CLIENT", "SQL_USER", "SQL_PASSWORD", "SQL_DATABASE", @@ -51,14 +48,13 @@ "INSTANCE_CONNECTION_NAME" ], "msg": "Last 10 visits:", - "substitutions": "YOUR_SQL_CLIENT=$SQL_CLIENT,YOUR_USER=$SQL_USER,YOUR_PASSWORD=$SQL_PASSWORD,YOUR_DATABASE=$SQL_DATABASE,YOUR_INSTANCE_CONNECTION_NAME=$INSTANCE_CONNECTION_NAME", + "substitutions": "YOUR_SQL_USER=$SQL_USER,YOUR_SQL_PASSWORD=$SQL_PASSWORD,YOUR_SQL_DATABASE=$SQL_DATABASE,YOUR_INSTANCE_CONNECTION_NAME=$INSTANCE_CONNECTION_NAME", "args": [ "server.js" ] }, "build": { "requiredEnvVars": [ - "SQL_CLIENT", "SQL_USER", "SQL_PASSWORD", "SQL_DATABASE", diff --git a/appengine/cloudsql/server.js b/appengine/cloudsql/server.js index 1ae0eec385..fc7e674beb 100644 --- a/appengine/cloudsql/server.js +++ b/appengine/cloudsql/server.js @@ -19,15 +19,14 @@ const process = require('process'); // [START app] -// [START setup] const express = require('express'); const Knex = require('knex'); const crypto = require('crypto'); const app = express(); app.enable('trust proxy'); -// [END setup] -let knex; + +const knex = connect(); function connect () { // [START connect] @@ -38,16 +37,12 @@ function connect () { }; if (process.env.INSTANCE_CONNECTION_NAME && process.env.NODE_ENV === 'production') { - if (process.env.SQL_CLIENT === 'mysql') { - config.socketPath = `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}`; - } else if (process.env.SQL_CLIENT === 'pg') { - config.host = `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}`; - } + config.socketPath = `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}`; } // Connect to the database const knex = Knex({ - client: process.env.SQL_CLIENT, + client: 'mysql', connection: config }); // [END connect] @@ -55,22 +50,24 @@ function connect () { return knex; } -// [START insertVisit] /** * Insert a visit record into the database. * + * @param {object} knex The Knex connection object. * @param {object} visit The visit record to insert. + * @returns {Promise} */ -function insertVisit (visit) { +function insertVisit (knex, visit) { return knex('visits').insert(visit); } -// [END insertVisit] -// [START getVisits] /** * Retrieve the latest 10 visit records from the database. + * + * @param {object} knex The Knex connection object. + * @returns {Promise} */ -function getVisits () { +function getVisits (knex) { return knex.select('timestamp', 'userIp') .from('visits') .orderBy('timestamp', 'desc') @@ -79,7 +76,6 @@ function getVisits () { return results.map((visit) => `Time: ${visit.timestamp}, AddrHash: ${visit.userIp}`); }); } -// [END getVisits] app.get('/', (req, res, next) => { // Create a visit record to be stored in the database @@ -89,11 +85,9 @@ app.get('/', (req, res, next) => { userIp: crypto.createHash('sha256').update(req.ip).digest('hex').substr(0, 7) }; - insertVisit(visit) - .then(() => { - // Query the last 10 visits from the database. - return getVisits(); - }) + insertVisit(knex, visit) + // Query the last 10 visits from the database. + .then(() => getVisits(knex)) .then((visits) => { res .status(200) @@ -106,21 +100,11 @@ app.get('/', (req, res, next) => { }); }); -// Get type of SQL client to use -const sqlClient = process.env.SQL_CLIENT; -if (sqlClient === 'pg' || sqlClient === 'mysql') { - knex = connect(); -} else { - throw new Error(`The SQL_CLIENT environment variable must be set to lowercase 'pg' or 'mysql'.`); -} - -// [START listen] const PORT = process.env.PORT || 8080; app.listen(PORT, () => { console.log(`App listening on port ${PORT}`); console.log('Press Ctrl+C to quit.'); }); -// [END listen] // [END app] module.exports = app; diff --git a/appengine/cloudsql/test/createTables.test.js b/appengine/cloudsql/test/createTables.test.js index 7002834cc1..1ec9bdfbf6 100644 --- a/appengine/cloudsql/test/createTables.test.js +++ b/appengine/cloudsql/test/createTables.test.js @@ -29,7 +29,7 @@ const exampleConfig = [ `database` ]; -function getSample (sqlClient) { +function getSample () { const configMock = exampleConfig; const promptMock = { start: sinon.stub(), @@ -42,49 +42,34 @@ function getSample (sqlClient) { }; const knexMock = { schema: { - createTable: sinon.stub().returns(Promise.resolve(this)).yields(tableMock) + createTable: sinon.stub() }, destroy: sinon.stub().returns(Promise.resolve()) }; + knexMock.schema.createTable.returns(Promise.resolve(knexMock)).yields(tableMock); const KnexMock = sinon.stub().returns(knexMock); - const processMock = { - env: { - SQL_CLIENT: sqlClient - } - }; - return { mocks: { Knex: KnexMock, knex: knexMock, config: configMock, - prompt: promptMock, - process: processMock + prompt: promptMock } }; } -function doProxiquire (sample) { - proxyquire(SAMPLE_PATH, { - knex: sample.mocks.Knex, - prompt: sample.mocks.prompt, - process: sample.mocks.process - }); -} - test.beforeEach(tools.stubConsole); test.afterEach.always(tools.restoreConsole); -test.cb.serial(`should create a table in MySQL`, (t) => { - const sample = getSample('mysql'); +test.cb.serial(`should create a table`, (t) => { + const sample = getSample(); const expectedResult = `Successfully created 'visits' table.`; proxyquire(SAMPLE_PATH, { knex: sample.mocks.Knex, - prompt: sample.mocks.prompt, - process: sample.mocks.process + prompt: sample.mocks.prompt }); t.true(sample.mocks.prompt.start.calledOnce); @@ -107,50 +92,19 @@ test.cb.serial(`should create a table in MySQL`, (t) => { }, 10); }); -test.cb.serial(`should create a table in Postgres`, (t) => { - const sample = getSample('pg'); - const expectedResult = `Successfully created 'visits' table.`; - - proxyquire(SAMPLE_PATH, { - knex: sample.mocks.Knex, - prompt: sample.mocks.prompt, - process: sample.mocks.process - }); - - t.true(sample.mocks.prompt.start.calledOnce); - t.true(sample.mocks.prompt.get.calledOnce); - t.deepEqual(sample.mocks.prompt.get.firstCall.args[0], exampleConfig); - - setTimeout(() => { - t.true(sample.mocks.Knex.calledOnce); - t.deepEqual(sample.mocks.Knex.firstCall.args, [{ - client: 'pg', - connection: exampleConfig - }]); - - t.true(sample.mocks.knex.schema.createTable.calledOnce); - t.is(sample.mocks.knex.schema.createTable.firstCall.args[0], 'visits'); - - t.true(console.log.calledWith(expectedResult)); - t.true(sample.mocks.knex.destroy.calledOnce); - t.end(); - }, 10); -}); - test.cb.serial(`should handle prompt error`, (t) => { const error = new Error(`error`); - const sample = getSample('mysql'); + const sample = getSample(); sample.mocks.prompt.get = sinon.stub().yields(error); proxyquire(SAMPLE_PATH, { knex: sample.mocks.Knex, - prompt: sample.mocks.prompt, - process: sample.mocks.process + prompt: sample.mocks.prompt }); setTimeout(() => { t.true(console.error.calledOnce); - t.true(console.error.calledWith(`Failed to create database connection:`, error)); + t.true(console.error.calledWith(error)); t.true(sample.mocks.Knex.notCalled); t.end(); }, 10); @@ -158,13 +112,12 @@ test.cb.serial(`should handle prompt error`, (t) => { test.cb.serial(`should handle knex creation error`, (t) => { const error = new Error(`error`); - const sample = getSample('mysql'); + const sample = getSample(); sample.mocks.knex.schema.createTable = sinon.stub().returns(Promise.reject(error)); proxyquire(SAMPLE_PATH, { knex: sample.mocks.Knex, - prompt: sample.mocks.prompt, - process: sample.mocks.process + prompt: sample.mocks.prompt }); setTimeout(() => { @@ -174,12 +127,3 @@ test.cb.serial(`should handle knex creation error`, (t) => { t.end(); }, 10); }); - -test(`should validate SQL_CLIENT env var`, (t) => { - const expected = `The SQL_CLIENT environment variable must be set to lowercase 'pg' or 'mysql'.`; - t.throws(() => { doProxiquire(getSample(null)); }, expected); - t.throws(() => { doProxiquire(getSample('foo')); }, expected); - - t.notThrows(() => { doProxiquire(getSample('mysql')); }); - t.notThrows(() => { doProxiquire(getSample('pg')); }); -}); diff --git a/appengine/cloudsql/test/server.test.js b/appengine/cloudsql/test/server.test.js index ff5553779c..a800c34713 100644 --- a/appengine/cloudsql/test/server.test.js +++ b/appengine/cloudsql/test/server.test.js @@ -25,7 +25,7 @@ const tools = require(`@google-cloud/nodejs-repo-tools`); const SAMPLE_PATH = path.join(__dirname, `../server.js`); -function getSample (sqlClient) { +function getSample () { const testApp = express(); sinon.stub(testApp, `listen`).yields(); const expressMock = sinon.stub().returns(testApp); @@ -50,7 +50,6 @@ function getSample (sqlClient) { const processMock = { env: { - SQL_CLIENT: sqlClient, SQL_USER: 'user', SQL_PASSWORD: 'password', SQL_DATABASE: 'database' @@ -78,28 +77,13 @@ function getSample (sqlClient) { test.beforeEach(tools.stubConsole); test.afterEach.always(tools.restoreConsole); -test(`should set up sample in MySQL`, (t) => { - const sample = getSample('mysql'); - - t.true(sample.mocks.express.calledOnce); - t.true(sample.mocks.Knex.calledOnce); - t.deepEqual(sample.mocks.Knex.firstCall.args, [{ - client: 'mysql', - connection: { - user: sample.mocks.process.env.SQL_USER, - password: sample.mocks.process.env.SQL_PASSWORD, - database: sample.mocks.process.env.SQL_DATABASE - } - }]); -}); - test(`should set up sample in Postgres`, (t) => { - const sample = getSample('pg'); + const sample = getSample(); t.true(sample.mocks.express.calledOnce); t.true(sample.mocks.Knex.calledOnce); t.deepEqual(sample.mocks.Knex.firstCall.args, [{ - client: 'pg', + client: 'mysql', connection: { user: sample.mocks.process.env.SQL_USER, password: sample.mocks.process.env.SQL_PASSWORD, @@ -108,17 +92,8 @@ test(`should set up sample in Postgres`, (t) => { }]); }); -test(`should validate SQL_CLIENT env var`, (t) => { - const expected = `The SQL_CLIENT environment variable must be set to lowercase 'pg' or 'mysql'.`; - t.throws(() => { getSample(null); }, expected); - t.throws(() => { getSample('foo'); }, expected); - - t.notThrows(() => { getSample('mysql'); }); - t.notThrows(() => { getSample('pg'); }); -}); - -test.cb(`should record a visit in mysql`, (t) => { - const sample = getSample('mysql'); +test.cb(`should record a visit`, (t) => { + const sample = getSample(); const expectedResult = `Last 10 visits:\nTime: 1234, AddrHash: abcd`; request(sample.app) @@ -131,7 +106,7 @@ test.cb(`should record a visit in mysql`, (t) => { }); test.cb(`should handle insert error`, (t) => { - const sample = getSample('mysql'); + const sample = getSample(); const expectedResult = `insert_error`; sample.mocks.knex.limit.returns(Promise.reject(expectedResult)); @@ -146,7 +121,7 @@ test.cb(`should handle insert error`, (t) => { }); test.cb(`should handle read error`, (t) => { - const sample = getSample('mysql'); + const sample = getSample(); const expectedResult = `read_error`; sample.mocks.knex.limit.returns(Promise.reject(expectedResult)); diff --git a/appengine/cloudsql_postgresql/README.md b/appengine/cloudsql_postgresql/README.md new file mode 100644 index 0000000000..e1981e39fa --- /dev/null +++ b/appengine/cloudsql_postgresql/README.md @@ -0,0 +1,11 @@ +# Cloud SQL for Postgres Node.js sample on App Engine flexible environment + +This sample demonstrates how to use [Google Cloud SQL][sql] for +[Postgres][postgres] on [Google App Engine Flexible][flexible]. + +To run the sample, see [the tutorial][tutorial]. + +[sql]: https://cloud.google.com/sql/ +[flexible]: https://cloud.google.com/appengine +[postgres]: https://www.postgresql.org/download/ +[tutorial]: https://cloud.google.com/appengine/docs/flexible/nodejs/using-cloud-sql-postgres diff --git a/appengine/cloudsql_postgresql/app.yaml b/appengine/cloudsql_postgresql/app.yaml new file mode 100644 index 0000000000..b02543d6c2 --- /dev/null +++ b/appengine/cloudsql_postgresql/app.yaml @@ -0,0 +1,34 @@ +# Copyright 2017, Google, Inc. +# 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. + +# [START app_yaml] +runtime: nodejs +env: flex + +# [START env] +env_variables: + SQL_USER: YOUR_SQL_USER + SQL_PASSWORD: YOUR_SQL_PASSWORD + SQL_DATABASE: YOUR_SQL_DATABASE + # e.g. my-awesome-project:us-central1:my-cloud-sql-instance + INSTANCE_CONNECTION_NAME: YOUR_INSTANCE_CONNECTION_NAME +# [END env] + +# [START cloudsql_settings] +beta_settings: + # The connection name of your instance, available by using + # 'gcloud beta sql instances describe [INSTANCE_NAME]' or from + # the Instance details page in the Google Cloud Platform Console. + cloud_sql_instances: YOUR_INSTANCE_CONNECTION_NAME +# [END cloudsql_settings] +# [END app_yaml] diff --git a/appengine/cloudsql_postgresql/createTables.js b/appengine/cloudsql_postgresql/createTables.js new file mode 100644 index 0000000000..cf6f69c929 --- /dev/null +++ b/appengine/cloudsql_postgresql/createTables.js @@ -0,0 +1,53 @@ +/** + * Copyright 2017, Google, Inc. + * 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'; + +// [START createTables] +const Knex = require('knex'); +const prompt = require('prompt'); + +const FIELDS = ['user', 'password', 'database']; + +prompt.start(); + +// Prompt the user for connection details +prompt.get(FIELDS, (err, config) => { + if (err) { + console.error(err); + return; + } + + // Connect to the database + const knex = Knex({ client: 'pg', connection: config }); + + // Create the "visits" table + knex.schema.createTable('visits', (table) => { + table.increments(); + table.timestamp('timestamp'); + table.string('userIp'); + }) + .then(() => { + console.log(`Successfully created 'visits' table.`); + return knex.destroy(); + }) + .catch((err) => { + console.error(`Failed to create 'visits' table:`, err); + if (knex) { + knex.destroy(); + } + }); +}); +// [END createTables] diff --git a/appengine/cloudsql_postgresql/package.json b/appengine/cloudsql_postgresql/package.json new file mode 100644 index 0000000000..296ee7fbf2 --- /dev/null +++ b/appengine/cloudsql_postgresql/package.json @@ -0,0 +1,67 @@ +{ + "name": "appengine-cloudsql-postgres", + "description": "Node.js PostgreSQL sample for Cloud SQL on App Engine flexible environment.", + "version": "0.0.1", + "private": true, + "license": "Apache-2.0", + "author": "Google Inc.", + "repository": { + "type": "git", + "url": "https://github.com/GoogleCloudPlatform/nodejs-docs-samples.git" + }, + "engines": { + "node": ">=4.3.2" + }, + "scripts": { + "deploy": "gcloud app deploy", + "lint": "samples lint", + "pretest": "npm run lint", + "unit-test": "ava --verbose test/*.test.js", + "start-proxy": "! pgrep cloud_sql_proxy > /dev/null && cloud_sql_proxy -instances=$INSTANCE_CONNECTION_NAME=tcp:$SQL_PORT &", + "system-test": "samples test app", + "system-test-proxy": "npm run start-proxy; npm run system-test", + "all-test": "npm run unit-test && npm run system-test", + "test": "samples test run --cmd npm -- run all-test", + "e2e-test": "samples test deploy" + }, + "dependencies": { + "async": "2.5.0", + "express": "4.15.4", + "knex": "0.13.0", + "pg": "7.3.0", + "prompt": "1.0.0" + }, + "devDependencies": { + "@google-cloud/nodejs-repo-tools": "1.4.17", + "ava": "0.22.0" + }, + "cloud-repo-tools": { + "requiresKeyFile": true, + "requiresProjectId": true, + "test": { + "app": { + "requiredEnvVars": [ + "SQL_USER", + "SQL_PASSWORD", + "SQL_DATABASE", + "SQL_PORT", + "INSTANCE_CONNECTION_NAME" + ], + "msg": "Last 10 visits:", + "substitutions": "YOUR_SQL_USER=$SQL_USER,YOUR_SQL_PASSWORD=$SQL_PASSWORD,YOUR_SQL_DATABASE=$SQL_DATABASE,YOUR_INSTANCE_CONNECTION_NAME=$INSTANCE_CONNECTION_NAME", + "args": [ + "server.js" + ] + }, + "build": { + "requiredEnvVars": [ + "SQL_USER", + "SQL_PASSWORD", + "SQL_DATABASE", + "SQL_PORT", + "INSTANCE_CONNECTION_NAME" + ] + } + } + } +} diff --git a/appengine/cloudsql_postgresql/server.js b/appengine/cloudsql_postgresql/server.js new file mode 100644 index 0000000000..1a2343d11f --- /dev/null +++ b/appengine/cloudsql_postgresql/server.js @@ -0,0 +1,110 @@ +/** + * Copyright 2017, Google, Inc. + * 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'; + +// Require process, so we can mock environment variables +const process = require('process'); + +// [START app] +const express = require('express'); +const Knex = require('knex'); +const crypto = require('crypto'); + +const app = express(); +app.enable('trust proxy'); + +const knex = connect(); + +function connect () { + // [START connect] + const config = { + user: process.env.SQL_USER, + password: process.env.SQL_PASSWORD, + database: process.env.SQL_DATABASE + }; + + if (process.env.INSTANCE_CONNECTION_NAME && process.env.NODE_ENV === 'production') { + config.host = `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}`; + } + + // Connect to the database + const knex = Knex({ + client: 'pg', + connection: config + }); + // [END connect] + + return knex; +} + +/** + * Insert a visit record into the database. + * + * @param {object} knex The Knex connection object. + * @param {object} visit The visit record to insert. + * @returns {Promise} + */ +function insertVisit (knex, visit) { + return knex('visits').insert(visit); +} + +/** + * Retrieve the latest 10 visit records from the database. + * + * @param {object} knex The Knex connection object. + * @returns {Promise} + */ +function getVisits (knex) { + return knex.select('timestamp', 'userIp') + .from('visits') + .orderBy('timestamp', 'desc') + .limit(10) + .then((results) => { + return results.map((visit) => `Time: ${visit.timestamp}, AddrHash: ${visit.userIp}`); + }); +} + +app.get('/', (req, res, next) => { + // Create a visit record to be stored in the database + const visit = { + timestamp: new Date(), + // Store a hash of the visitor's ip address + userIp: crypto.createHash('sha256').update(req.ip).digest('hex').substr(0, 7) + }; + + insertVisit(knex, visit) + // Query the last 10 visits from the database. + .then(() => getVisits(knex)) + .then((visits) => { + res + .status(200) + .set('Content-Type', 'text/plain') + .send(`Last 10 visits:\n${visits.join('\n')}`) + .end(); + }) + .catch((err) => { + next(err); + }); +}); + +const PORT = process.env.PORT || 8080; +app.listen(PORT, () => { + console.log(`App listening on port ${PORT}`); + console.log('Press Ctrl+C to quit.'); +}); +// [END app] + +module.exports = app; diff --git a/appengine/cloudsql_postgresql/test/createTables.test.js b/appengine/cloudsql_postgresql/test/createTables.test.js new file mode 100644 index 0000000000..0d43f8add7 --- /dev/null +++ b/appengine/cloudsql_postgresql/test/createTables.test.js @@ -0,0 +1,129 @@ +/** + * Copyright 2017, Google, Inc. + * 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 test = require(`ava`); +const path = require(`path`); +const proxyquire = require(`proxyquire`).noPreserveCache(); +const sinon = require(`sinon`); +const tools = require(`@google-cloud/nodejs-repo-tools`); + +const SAMPLE_PATH = path.join(__dirname, `../createTables.js`); + +const exampleConfig = [ + `user`, + `password`, + `database` +]; + +function getSample () { + const configMock = exampleConfig; + const promptMock = { + start: sinon.stub(), + get: sinon.stub().yields(null, configMock) + }; + const tableMock = { + increments: sinon.stub(), + timestamp: sinon.stub(), + string: sinon.stub() + }; + const knexMock = { + schema: { + createTable: sinon.stub() + }, + destroy: sinon.stub().returns(Promise.resolve()) + }; + + knexMock.schema.createTable.returns(Promise.resolve(knexMock)).yields(tableMock); + const KnexMock = sinon.stub().returns(knexMock); + + return { + mocks: { + Knex: KnexMock, + knex: knexMock, + config: configMock, + prompt: promptMock + } + }; +} + +test.beforeEach(tools.stubConsole); +test.afterEach.always(tools.restoreConsole); + +test.cb.serial(`should create a table`, (t) => { + const sample = getSample(); + const expectedResult = `Successfully created 'visits' table.`; + + proxyquire(SAMPLE_PATH, { + knex: sample.mocks.Knex, + prompt: sample.mocks.prompt + }); + + t.true(sample.mocks.prompt.start.calledOnce); + t.true(sample.mocks.prompt.get.calledOnce); + t.deepEqual(sample.mocks.prompt.get.firstCall.args[0], exampleConfig); + + setTimeout(() => { + t.true(sample.mocks.Knex.calledOnce); + t.deepEqual(sample.mocks.Knex.firstCall.args, [{ + client: 'pg', + connection: exampleConfig + }]); + + t.true(sample.mocks.knex.schema.createTable.calledOnce); + t.is(sample.mocks.knex.schema.createTable.firstCall.args[0], 'visits'); + + t.true(console.log.calledWith(expectedResult)); + t.true(sample.mocks.knex.destroy.calledOnce); + t.end(); + }, 10); +}); + +test.cb.serial(`should handle prompt error`, (t) => { + const error = new Error(`error`); + const sample = getSample(); + sample.mocks.prompt.get = sinon.stub().yields(error); + + proxyquire(SAMPLE_PATH, { + knex: sample.mocks.Knex, + prompt: sample.mocks.prompt + }); + + setTimeout(() => { + t.true(console.error.calledOnce); + t.true(console.error.calledWith(error)); + t.true(sample.mocks.Knex.notCalled); + t.end(); + }, 10); +}); + +test.cb.serial(`should handle knex creation error`, (t) => { + const error = new Error(`error`); + const sample = getSample(); + sample.mocks.knex.schema.createTable = sinon.stub().returns(Promise.reject(error)); + + proxyquire(SAMPLE_PATH, { + knex: sample.mocks.Knex, + prompt: sample.mocks.prompt + }); + + setTimeout(() => { + t.true(console.error.calledOnce); + t.true(console.error.calledWith(`Failed to create 'visits' table:`, error)); + t.true(sample.mocks.knex.destroy.calledOnce); + t.end(); + }, 10); +}); diff --git a/appengine/cloudsql_postgresql/test/server.test.js b/appengine/cloudsql_postgresql/test/server.test.js new file mode 100644 index 0000000000..94b49af475 --- /dev/null +++ b/appengine/cloudsql_postgresql/test/server.test.js @@ -0,0 +1,136 @@ +/** + * Copyright 2017, Google, Inc. + * 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 path = require(`path`); +const proxyquire = require(`proxyquire`).noCallThru(); +const request = require(`supertest`); +const sinon = require(`sinon`); +const test = require(`ava`); +const tools = require(`@google-cloud/nodejs-repo-tools`); + +const SAMPLE_PATH = path.join(__dirname, `../server.js`); + +function getSample () { + const testApp = express(); + sinon.stub(testApp, `listen`).yields(); + const expressMock = sinon.stub().returns(testApp); + const resultsMock = [ + { + timestamp: `1234`, + userIp: `abcd` + } + ]; + + const knexMock = sinon.stub().returns({ + insert: sinon.stub().returns(Promise.resolve()) + }); + Object.assign(knexMock, { + select: sinon.stub().returnsThis(), + from: sinon.stub().returnsThis(), + orderBy: sinon.stub().returnsThis(), + limit: sinon.stub().returns(Promise.resolve(resultsMock)) + }); + + const KnexMock = sinon.stub().returns(knexMock); + + const processMock = { + env: { + SQL_USER: 'user', + SQL_PASSWORD: 'password', + SQL_DATABASE: 'database' + } + }; + + const app = proxyquire(SAMPLE_PATH, { + knex: KnexMock, + express: expressMock, + process: processMock + }); + + return { + app: app, + mocks: { + express: expressMock, + results: resultsMock, + knex: knexMock, + Knex: KnexMock, + process: processMock + } + }; +} + +test.beforeEach(tools.stubConsole); +test.afterEach.always(tools.restoreConsole); + +test(`should set up sample in Postgres`, (t) => { + const sample = getSample(); + + t.true(sample.mocks.express.calledOnce); + t.true(sample.mocks.Knex.calledOnce); + t.deepEqual(sample.mocks.Knex.firstCall.args, [{ + client: 'pg', + connection: { + user: sample.mocks.process.env.SQL_USER, + password: sample.mocks.process.env.SQL_PASSWORD, + database: sample.mocks.process.env.SQL_DATABASE + } + }]); +}); + +test.cb(`should record a visit`, (t) => { + const sample = getSample(); + const expectedResult = `Last 10 visits:\nTime: 1234, AddrHash: abcd`; + + request(sample.app) + .get(`/`) + .expect(200) + .expect((response) => { + t.is(response.text, expectedResult); + }) + .end(t.end); +}); + +test.cb(`should handle insert error`, (t) => { + const sample = getSample(); + const expectedResult = `insert_error`; + + sample.mocks.knex.limit.returns(Promise.reject(expectedResult)); + + request(sample.app) + .get(`/`) + .expect(500) + .expect((response) => { + t.is(response.text.includes(expectedResult), true); + }) + .end(t.end); +}); + +test.cb(`should handle read error`, (t) => { + const sample = getSample(); + const expectedResult = `read_error`; + + sample.mocks.knex.limit.returns(Promise.reject(expectedResult)); + + request(sample.app) + .get(`/`) + .expect(500) + .expect((response) => { + t.is(response.text.includes(expectedResult), true); + }) + .end(t.end); +}); diff --git a/circle.yml b/circle.yml index 4c423f8c61..2f18d06496 100644 --- a/circle.yml +++ b/circle.yml @@ -75,8 +75,8 @@ deployment: owner: GoogleCloudPlatform commands: - node scripts/build "appengine/analytics" - - export SQL_CLIENT=mysql SQL_PORT=3306; export INSTANCE_CONNECTION_NAME=$INSTANCE_CONNECTION_PREFIX-$SQL_CLIENT; node scripts/build "appengine/cloudsql" -- --test-args "run system-test-proxy" - - export SQL_CLIENT=pg SQL_PORT=5432; export INSTANCE_CONNECTION_NAME=$INSTANCE_CONNECTION_PREFIX-$SQL_CLIENT; node scripts/build "appengine/cloudsql" -- --test-args "run system-test-proxy" + - export SQL_PORT=3306; export INSTANCE_CONNECTION_NAME=$INSTANCE_CONNECTION_PREFIX-mysql; node scripts/build "appengine/cloudsql" -- --test-args "run system-test-proxy" + - export SQL_PORT=5432; export INSTANCE_CONNECTION_NAME=$INSTANCE_CONNECTION_PREFIX-pg; node scripts/build "appengine/cloudsql_postgresql" -- --test-args "run system-test-proxy" - node scripts/build "appengine/datastore" - node scripts/build "appengine/endpoints" - node scripts/build "appengine/errorreporting"