diff --git a/.kokoro/cloudsql-mysql.cfg b/.kokoro/cloudsql-mysql.cfg new file mode 100644 index 0000000000..dcde25a50a --- /dev/null +++ b/.kokoro/cloudsql-mysql.cfg @@ -0,0 +1,13 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Set the folder in which the tests are run +env_vars: { + key: "PROJECT" + value: "cloudsql/mysql/mysql" +} + +# Tell the trampoline which build file to use. +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/nodejs-docs-samples/.kokoro/build.sh" +} diff --git a/cloud-sql/mysql/mysql/README.md b/cloud-sql/mysql/mysql/README.md new file mode 100644 index 0000000000..fb5ccd40b1 --- /dev/null +++ b/cloud-sql/mysql/mysql/README.md @@ -0,0 +1,92 @@ +# Connecting to Cloud SQL - MySQL + +## Before you begin + +1. If you haven't already, set up a Node.js Development Environment by following the [Node.js setup guide](https://cloud.google.com/nodejs/docs/setup) and +[create a project](https://cloud.google.com/resource-manager/docs/creating-managing-projects#creating_a_project). + +1. Create a 2nd Gen Cloud SQL Instance by following these +[instructions](https://cloud.google.com/sql/docs/mysql/create-instance). Note the connection string, +database user, and database password that you create. + +1. Create a database for your application by following these +[instructions](https://cloud.google.com/sql/docs/mysql/create-manage-databases). Note the database +name. + +1. Create a service account with the 'Cloud SQL Client' permissions by following these +[instructions](https://cloud.google.com/sql/docs/mysql/connect-external-app#4_if_required_by_your_authentication_method_create_a_service_account). +Download a JSON key to use to authenticate your connection. + + +1. Use the information noted in the previous steps: +```bash +export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service/account/key.json +export CLOUD_SQL_CONNECTION_NAME='::' +export DB_USER='my-db-user' +export DB_PASS='my-db-pass' +export DB_NAME='my_db' +``` +Note: Saving credentials in environment variables is convenient, but not secure - consider a more +secure solution such as [Cloud KMS](https://cloud.google.com/kms/) to help keep secrets safe. + +## Running locally + +To run this application locally, download and install the `cloud_sql_proxy` by +following the instructions [here](https://cloud.google.com/sql/docs/mysql/sql-proxy#install). + +Once the proxy is ready, use the following command to start the proxy in the +background: +```bash +./cloud_sql_proxy -dir=/cloudsql --instances=$CLOUD_SQL_CONNECTION_NAME --credential_file=$GOOGLE_APPLICATION_CREDENTIALS +``` +Note: Make sure to run the command under a user with write access in the +`/cloudsql` directory. This proxy will use this folder to create a unix socket +the application will use to connect to Cloud SQL. + +Next, setup install the requirements with `npm`: +```bash +npm install +``` + +Finally, start the application: +```bash +npm start +``` + +Navigate towards `http://127.0.0.1:8080` to verify your application is running correctly. + +## Google App Engine Standard + +To run on GAE-Standard, create an App Engine project by following the setup for these +[instructions](https://cloud.google.com/appengine/docs/standard/nodejs/quickstart#before-you-begin). + +First, update `app.standard.yaml` with the correct values to pass the environment +variables into the runtime. + +Next, the following command will deploy the application to your Google Cloud project: +```bash +gcloud app deploy app.standard.yaml +``` + +To launch your browser and view the app at https://[YOUR_PROJECT_ID].appspot.com, run the following +command: +```bash +gcloud app browse +``` + +## Deploy to Google App Engine Flexible + +First, update `app.flexible.yaml` with the correct values to pass the environment +variables into the runtime. + +Next, the following command will deploy the application to your Google Cloud project: +```bash +gcloud app deploy app.flexible.yaml +``` + +To launch your browser and view the app at https://[YOUR_PROJECT_ID].appspot.com, run the following +command: +```bash +gcloud app browse +``` + diff --git a/cloud-sql/mysql/mysql/app.flexible.yaml b/cloud-sql/mysql/mysql/app.flexible.yaml new file mode 100644 index 0000000000..22c02166cb --- /dev/null +++ b/cloud-sql/mysql/mysql/app.flexible.yaml @@ -0,0 +1,30 @@ +# Copyright 2019, 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. + +runtime: nodejs +env: flex + +# The following env variables may contain sensitive information that grants +# anyone access to your database. Do not add this file to your source control. +env_variables: + DB_USER: MY_DB_USER + DB_PASS: MY_DB_PASSWORD + DB_NAME: MY_DATABASE + # e.g. my-awesome-project:us-central1:my-cloud-sql-instance + CLOUD_SQL_CONNECTION_NAME: :: + +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: :: diff --git a/cloud-sql/mysql/mysql/app.standard.yaml b/cloud-sql/mysql/mysql/app.standard.yaml new file mode 100644 index 0000000000..a57d01a405 --- /dev/null +++ b/cloud-sql/mysql/mysql/app.standard.yaml @@ -0,0 +1,23 @@ +# Copyright 2019, 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. + +runtime: nodejs8 + +# The following env variables may contain sensitive information that grants +# anyone access to your database. Do not add this file to your source control. +env_variables: + DB_USER: MY_DB_USER + DB_PASS: MY_DB_PASSWORD + DB_NAME: MY_DATABASE + # e.g. my-awesome-project:us-central1:my-cloud-sql-instance + CLOUD_SQL_CONNECTION_NAME: :: diff --git a/cloud-sql/mysql/mysql/package.json b/cloud-sql/mysql/mysql/package.json new file mode 100644 index 0000000000..6f241896a6 --- /dev/null +++ b/cloud-sql/mysql/mysql/package.json @@ -0,0 +1,64 @@ +{ + "name": "cloudsql-mysql-mysql", + "description": "Node.js Cloud SQL MySQL Connectivity Sample", + "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": ">=8" + }, + "scripts": { + "unit-test": "ava --verbose test/*.test.js", + "start-proxy": "! pgrep cloud_sql_proxy > /dev/null && cloud_sql_proxy -dir=/cloudsql -instances=$CLOUD_SQL_INSTANCE_NAME &", + "system-test": "repo-tools test app -- server.js", + "system-test-proxy": "npm run start-proxy; npm run system-test", + "all-test": "npm run unit-test && npm run system-test", + "test": "repo-tools test run --cmd npm -- run all-test" + }, + "dependencies": { + "@google-cloud/logging-winston": "^0.10.2", + "body-parser": "1.18.3", + "express": "4.16.2", + "promise-mysql": "^3.3.1", + "prompt": "1.0.0", + "pug": "2.0.3", + "winston": "^3.1.0" + }, + "devDependencies": { + "@google-cloud/nodejs-repo-tools": "3.1.0", + "ava": "0.25.0", + "proxyquire": "^2.1.0", + "supertest": "^3.3.0", + "sinon": "^7.1.1" + }, + "cloud-repo-tools": { + "requiresKeyFile": true, + "requiresProjectId": true, + "test": { + "app": { + "requiredEnvVars": [ + "DB_USER", + "DB_PASS", + "DB_NAME", + "CLOUD_SQL_INSTANCE_NAME" + ], + "args": [ + "server.js" + ] + }, + "build": { + "requiredEnvVars": [ + "DB_USER", + "DB_PASS", + "DB_NAME", + "CLOUD_SQL_INSTANCE_NAME" + ] + } + } + } +} diff --git a/cloud-sql/mysql/mysql/server.js b/cloud-sql/mysql/mysql/server.js new file mode 100644 index 0000000000..50fd4e9ded --- /dev/null +++ b/cloud-sql/mysql/mysql/server.js @@ -0,0 +1,175 @@ +/** + * Copyright 2019 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'; + +// Require process, so we can mock environment variables. +const process = require('process'); + +const express = require('express'); +const mysql = require('promise-mysql'); +const bodyParser = require('body-parser'); + +const app = express(); +app.set('view engine', 'pug'); +app.enable('trust proxy'); + +// Automatically parse request body as form data. +app.use(bodyParser.urlencoded({extended: false})); +app.use(bodyParser.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], +}); + +// [START cloud_sql_mysql_mysql_create] +const pool = mysql.createPool({ + user: process.env.DB_USER, // e.g. 'my-db-user' + password: process.env.DB_PASS, // e.g. 'my-db-password' + database: process.env.DB_NAME, // e.g. 'my-database' + // If connecting via unix domain socket, specify the path + socketPath: `/cloudsql/${process.env.CLOUD_SQL_CONNECTION_NAME}`, + // If connecting via TCP, enter the IP and port instead + // host: 'localhost', + // port: 3306, + + //[START_EXCLUDE] + + // [START cloud_sql_mysql_mysql_limit] + // 'connectionLimit' is the maximum number of connections the pool is allowed + // to keep at once. + connectionLimit: 5, + // [END cloud_sql_mysql_mysql_limit] + + // [START cloud_sql_mysql_mysql_timeout] + // 'connectTimeout' is the maximum number of milliseconds before a timeout + // occurs during the initial connection to the database. + connectTimeout: 10000, // 10 seconds + // 'acquireTimeout' is the maximum number of milliseconds to wait when + // checking out a connection from the pool before a timeout error occurs. + acquireTimeout: 10000, // 10 seconds + // 'waitForConnections' determines the pool's action when no connections are + // free. If true, the request will queued and a connection will be presented + // when ready. If false, the pool will call back with an error. + waitForConnections: true, // Default: true + // 'queueLimit' is the maximum number of requests for connections the pool + // will queue at once before returning an error. If 0, there is no limit. + queueLimit: 0, // Default: 0 + // [END cloud_sql_mysql_mysql_timeout] + + // [START cloud_sql_mysql_mysql_backoff] + // The mysql module automatically uses exponential delays between failed + // connection attempts. + // [END cloud_sql_mysql_mysql_backoff] + + //[END_EXCLUDE] +}); +// [END cloud_sql_mysql_mysql_create] + +// When the server starts, check for tables in the database. +app.on('listening', async function() { + // Wait for tables to be created (if they don't already exist). + await pool.query( + `CREATE TABLE IF NOT EXISTS votes + ( vote_id SERIAL NOT NULL, time_cast timestamp NOT NULL, + candidate CHAR(6) NOT NULL, PRIMARY KEY (vote_id) );` + ); +}); + +// Serve the index page, showing vote tallies. +app.get('/', async (req, res) => { + // Get the 5 most recent votes. + const recentResultPromise = pool + .query( + 'SELECT candidate, time_cast FROM votes ORDER BY time_cast DESC LIMIT 5' + ) + .then(rows => { + return rows; + }); + + const stmt = 'SELECT COUNT(vote_id) as count FROM votes WHERE candidate=?'; + // Get the total number of "TABS" votes. + const tabsResultPromise = pool.query(stmt, ['TABS']).then(rows => { + return rows[0].count; + }); + // Get the total number of "SPACES" votes. + const spacesResultPromise = pool.query(stmt, ['SPACES']).then(rows => { + return rows[0].count; + }); + + res.render('index.pug', { + recentVotes: await recentResultPromise, + tabCount: await tabsResultPromise, + spaceCount: await spacesResultPromise, + }); +}); + +// Handle incoming vote requests and inserting them into the database. +app.post('/', async (req, res) => { + const team = req.body.team; + const timestamp = new Date(); + + if (!team || (team !== 'TABS' && team !== 'SPACES')) { + res + .status(400) + .send('Invalid team specified.') + .end(); + } + + // [START cloud_sql_mysql_mysql_connection] + try { + const stmt = 'INSERT INTO votes (time_cast, candidate) VALUES (?, ?)'; + // Pool.query automatically checks out, uses, and releases a connection + // back into the pool, ensuring it is always returned successfully. + await pool.query(stmt, [timestamp, team]); + } catch (err) { + // If something goes wrong, handle the error in this section. This might + // involve retrying or adjusting parameters depending on the situation. + // [START_EXCLUDE] + logger.err(err); + res + .status(500) + .send( + 'Unable to successfully cast vote! Please check the application logs for more details.' + ) + .end(); + // [END_EXCLUDE] + } + // [END cloud_sql_mysql_mysql_connection] + + res + .status(200) + .send('Successfully voted for ' + team + ' at ' + timestamp) + .end(); +}); + +const PORT = process.env.PORT || 8080; +app.listen(PORT, () => { + console.log(`App listening on port ${PORT}`); + console.log('Press Ctrl+C to quit.'); +}); + +module.exports = app; diff --git a/cloud-sql/mysql/mysql/test/server.test.js b/cloud-sql/mysql/mysql/test/server.test.js new file mode 100644 index 0000000000..effbac8bcf --- /dev/null +++ b/cloud-sql/mysql/mysql/test/server.test.js @@ -0,0 +1,77 @@ +/** + * Copyright 2019 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 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 timestamp = new Date(); + const resultsMock = [ + { + candidate: 'TABS', + time_cast: timestamp, + }, + ]; + + const processMock = { + env: { + DB_USER: 'user', + DB_PASS: 'password', + DB_NAME: 'database', + }, + }; + + const app = proxyquire(SAMPLE_PATH, { + express: expressMock, + process: processMock, + }); + + return { + app: app, + mocks: { + express: expressMock, + results: resultsMock, + process: processMock, + }, + }; +} + +test.beforeEach(tools.stubConsole); +test.afterEach.always(tools.restoreConsole); + +test.cb(`should display the default page`, t => { + const sample = getSample(); + const expectedResult = `Tabs VS Spaces`; + + request(sample.app) + .get(`/`) + .expect(200) + .expect(response => { + t.is(response.text, expectedResult); + }) + .end(t.end); +}); diff --git a/cloud-sql/mysql/mysql/views/index.pug b/cloud-sql/mysql/mysql/views/index.pug new file mode 100644 index 0000000000..d0a3762c5b --- /dev/null +++ b/cloud-sql/mysql/mysql/views/index.pug @@ -0,0 +1,70 @@ +doctype html +html(lang="en") + head + title Tabs VS Spaces + + link(rel="stylesheet", href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css") + link(rel="stylesheet", href="https://fonts.googleapis.com/icon?family=Material+Icons") + script(src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js") + body + + nav(class="red lighten-1") + div(class="nav-wrapper") + a(href="#" class="brand-logo center") Tabs VS Spaces + + div(class="section") + + div(class="center") + h4 + - var diff = tabCount - spaceCount + if tabCount > spaceCount + = 'TABS are winning by ' + (tabCount - spaceCount) + ' votes!' + else if spaceCount > tabCount + = 'SPACES are winning by ' + (spaceCount - tabCount) + ' votes!' + else + = 'TABS and SPACES are evenly matched!' + + div(class="row center") + div(class="col s6 m5 offset-m1") + div(class=(leadTeam === 'TABS') ? 'card-panel green lighten-3' : 'card-panel') + i(class="material-icons large") keyboard_tab + h3 #{tabCount} votes + button(id="voteTabs" class="btn green") Vote for TABS + div(class="col s6 m5") + div(class=(leadTeam === 'SPACES') ? 'card-panel green lighten-3' : 'card-panel') + i(class="material-icons large") space_bar + h3 #{spaceCount} votes + button(id="voteSpaces" class="btn blue") Vote for SPACES + + h4(class="header center") Recent Votes + ul(class="container collection center") + each vote in recentVotes + li(class="collection-item avatar") + if vote.candidate.trim() === 'TABS' + i(class="material-icons circle green") keyboard_tab + else + i(class="material-icons circle blue") space_bar + span(class="title") A vote for #{vote.candidate} + p was cast at #{vote.time_cast}. + + script. + function vote(team) { + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function () { + var msg = ""; + if (this.readyState == 4) { + if (!window.alert(this.responseText)) { + window.location.reload(); + } + } + }; + xhr.open("POST", "/", true); + xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + xhr.send("team=" + team); + } + document.getElementById("voteTabs").addEventListener("click", function () { + vote("TABS"); + }); + document.getElementById("voteSpaces").addEventListener("click", function () { + vote("SPACES"); + }); \ No newline at end of file