diff --git a/templates/kubernetes/overlays/staging/kustomization.yml b/templates/kubernetes/overlays/staging/kustomization.yml index e2e073c..8da4339 100644 --- a/templates/kubernetes/overlays/staging/kustomization.yml +++ b/templates/kubernetes/overlays/staging/kustomization.yml @@ -17,4 +17,4 @@ configMapGenerator: literals: - ENVIRONMENT=staging - DOMAIN=<% index .Params `stagingHostRoot` %> - - S3_BUCKET=files.<% index .Params `stagingHostRoot` %> + - S3_BUCKET=files.<% index .Params `stagingHostRoot` %> \ No newline at end of file diff --git a/templates/package.json b/templates/package.json index 92e7f68..6748ec4 100644 --- a/templates/package.json +++ b/templates/package.json @@ -5,7 +5,7 @@ "engines": { "node": "12" }, - "main": "src/app.js", + "main": <%if eq (index .Params `apiType`) "rest" %>"src/app.js", <% else %>"src/graphql.js",<% end %> "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "lint": "eslint src", @@ -15,6 +15,13 @@ "author": "", "license": "ISC", "dependencies": { + <%if eq (index .Params `apiType`) "graphql" %> + "apollo-datasource": "^0.7.3", + "apollo-datasource-rest": "^0.9.7", + "apollo-server-express": "^2.19.2", + "graphql": "^15.5.0", + "graphql-combine": "^1.0.1", + <% end %> "aws-cloudfront-sign": "^2.2.0", "aws-sdk": "^2.744.0", "dotenv": "^8.2.0", diff --git a/templates/src/app.js b/templates/src/app.js index 6fc09f2..042dc11 100644 --- a/templates/src/app.js +++ b/templates/src/app.js @@ -1,31 +1,38 @@ -var dotenv = require("dotenv"); -var express = require("express"); -var morgan = require("morgan"); - -var { connect } = require("./db"); +const dotenv = require("dotenv"); +const express = require("express"); +const morgan = require("morgan"); +const dbDatasource = require("./db"); +<%if eq (index .Params `fileUploads`) "yes" %>const fileRoutes = require("./app/file");<% end %> +<%if eq (index .Params `userAuth`) "yes" %>const authRoutes = require("./app/auth"); +const { authMiddleware } = require("./middleware/auth");<% end %> const statusRoutes = require("./app/status"); -<%if eq (index .Params `fileUploads`) "yes" %>const fileRoutes = require("./app/file"); -<% end %><%if eq (index .Params `userAuth`) "yes" %>const authRoutes = require("./app/auth"); -<% end %> + dotenv.config(); -var app = express(); +const app = express(); app.use(morgan("combined")); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +<%if eq (index .Params `userAuth`) "yes" %>app.use(authMiddleware); +app.use("/auth", authRoutes);<% end %> + +<%if eq (index .Params `fileUploads`) "yes" %>app.use("/file", fileRoutes);<% end %> app.use("/status", statusRoutes); -<%if eq (index .Params `userAuth`) "yes" %>app.use("/auth", authRoutes); -<% end %><%if eq (index .Params `fileUploads`) "yes" %>app.use("/file", fileRoutes); -<% end %> -var port = process.env.SERVER_PORT; + +const port = process.env.SERVER_PORT; if (!port) { port = 3000; } -async function main() { - const database = await connect(); - +const main = async () => { // remove this block for development, just for verifying DB try { - const res = await database.query("SELECT 1"); + await dbDatasource.authenticate(); + console.log("Connection has been established successfully."); + await dbDatasource.sync( {alter: true} ); + console.log("Created or altered tables"); + const res = await dbDatasource.query("SELECT 1"); console.log(`Query successful, returned ${res[0].length} rows.`); } catch (e) { console.error(e); @@ -34,6 +41,7 @@ async function main() { app.listen(port, () => { console.log(`Example app listening at http://localhost:${port}`); }); -} +}; main(); + diff --git a/templates/src/app/auth/index.js b/templates/src/app/auth/index.js index 067a802..81b8c97 100644 --- a/templates/src/app/auth/index.js +++ b/templates/src/app/auth/index.js @@ -1,10 +1,8 @@ -var { Router } = require("express"); +const { Router } = require("express"); -var { authMiddleware } = require("../../middleware/auth"); +const router = Router(); -var router = Router() - -router.get("/userInfo", authMiddleware, (req, res) => { +router.get("/userInfo", (req, res) => { res.json(req.user); }); diff --git a/templates/src/app/file/index.js b/templates/src/app/file/index.js index 8fd02b5..987b903 100644 --- a/templates/src/app/file/index.js +++ b/templates/src/app/file/index.js @@ -1,43 +1,17 @@ -var { Router } = require("express"); -var aws = require("aws-sdk"); -var cfsign = require("aws-cloudfront-sign"); +const { Router } = require("express"); +const FileService = require("../../service/file"); -var router = Router() -var s3 = new aws.S3(); +const router = Router(); +const fileService = new FileService(); -router.get("/presigned/:key", (req, res) => { - var params = { - Bucket: process.env.S3_BUCKET, - Fields: { - key: req.params.key, - }, - }; - - s3.createPresignedPost(params, (err, data) => { - if (err) { - console.error(err); - res.sendStatus(500); - } else { - console.log(data); - res.send(data); - } - }); +router.get("/presigned", (req, res) => { + let key = req.query.key; + return res.json(fileService.getUploadSignedUrl( key )); }); -router.get("/:key", (req, res) => { - var params = { - keypairId: process.env.CF_KEYPAIR_ID, - privateKeyString: process.env.CF_KEYPAIR_SECRET_KEY, - expireTime: new Date().getTime() + 30000, // defaults to 30s - }; - - var url = cfsign.getSignedUrl( - `https://files.${process.env.DOMAIN}/${req.params.key}`, - params - ); - - console.log(url); - res.redirect(url); +router.get("/",(req, res) => { + let key = req.query.key; + return res.json(fileService.getDownloadSignedUrl( key )); }); module.exports = router; diff --git a/templates/src/app/status/index.js b/templates/src/app/status/index.js index bb1b41e..3f55cd8 100644 --- a/templates/src/app/status/index.js +++ b/templates/src/app/status/index.js @@ -1,18 +1,19 @@ -var { Router } = require("express"); +const { Router } = require("express"); -var router = Router() +const router = Router(); router.get("/ready", (req, res) => { - res.send("OK"); + res.json({ ready: "OK" }); }); router.get("/alive", (req, res) => { - res.send("OK"); + res.json( {alive: "OK"} ); }); router.get("/about", (req, res) => { - res.send({ - podName: process.env.POD_NAME, + var podName = (process.env.POD_NAME)?process.env.POD_NAME:"zero-node-backend"; + res.json({ + podName: podName, }); }); diff --git a/templates/src/conf/index.js b/templates/src/conf/index.js new file mode 100644 index 0000000..2b8fcf1 --- /dev/null +++ b/templates/src/conf/index.js @@ -0,0 +1,7 @@ +const conf = { + jwtSecret: "SecretfromIdentityProvider", + s3PresignedExpires: 60 * 5, //5 minutes + cfSignerExpires: 1000 * 60 * 5 //5 minutes +} + +module.exports = conf; \ No newline at end of file diff --git a/templates/src/db/index.js b/templates/src/db/index.js index c19112a..74089df 100644 --- a/templates/src/db/index.js +++ b/templates/src/db/index.js @@ -11,7 +11,7 @@ const { DATABASE_NAME, } = process.env; -const database = new Sequelize( +const datasource = new Sequelize( DATABASE_NAME, DATABASE_USERNAME, DATABASE_PASSWORD, @@ -22,16 +22,4 @@ const database = new Sequelize( } ); -const connect = async () => { - try { - await database.authenticate(); - console.log("Connection has been established successfully."); - return database; - } catch (error) { - console.error("Unable to connect to the database:", error); - } -}; - -module.exports = { - connect, -}; +module.exports = datasource; \ No newline at end of file diff --git a/templates/src/db/model/Trip.js b/templates/src/db/model/Trip.js new file mode 100644 index 0000000..25561a6 --- /dev/null +++ b/templates/src/db/model/Trip.js @@ -0,0 +1,21 @@ +const {Model, DataTypes} = require("sequelize"); +const datasource = require("../index"); + +class Trip extends Model{} + +Trip.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + launchId: DataTypes.INTEGER, + userId: DataTypes.INTEGER, +},{ + sequelize: datasource, + tableName: "trip" +}); + +module.exports = Trip; \ No newline at end of file diff --git a/templates/src/graphql.js b/templates/src/graphql.js new file mode 100644 index 0000000..91d2b34 --- /dev/null +++ b/templates/src/graphql.js @@ -0,0 +1,59 @@ +const dotenv = require("dotenv"); +const express = require("express"); +const morgan = require("morgan"); +const { ApolloServer } = require("apollo-server-express"); + +const combine = require("graphql-combine"); +const path = require("path"); +const dbDatasource = require("./db"); +<%if eq (index .Params `userAuth`) "yes" %>const { authMiddleware } = require("./middleware/auth");<% end %> + +dotenv.config(); +const app = express(); +app.use(morgan("combined")); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +app.use(authMiddleware); + +const {typeDefs, resolvers} = combine({ + typeDefs: path.join(__dirname, "graphql/*.graphql"), + resolvers: path.join(__dirname, "graphql/*_resolver.js") +}); + +const server = new ApolloServer({ + context: async ( {req} ) => { + if(req.user){ + return { user: {id: req.user.id, email: req.user.email} }; + } + }, + typeDefs, + resolvers +}); +server.applyMiddleware({ app }); + +const port = process.env.SERVER_PORT; +if (!port) { + port = 3000; +} + +const main = async () => { + // remove this block for development, just for verifying DB + try { + await dbDatasource.authenticate(); + console.log("Connection has been established successfully."); + await dbDatasource.sync( {alter: true} ); + console.log("Created or altered tables"); + const res = await dbDatasource.query("SELECT 1"); + console.log(`Query successful, returned ${res[0].length} rows.`); + } catch (e) { + console.error(e); + } + + app.listen(port, () => { + console.log(`Example app listening at http://localhost:${port}`); + }); +}; + +main(); + diff --git a/templates/src/graphql/default.graphql b/templates/src/graphql/default.graphql new file mode 100755 index 0000000..89118af --- /dev/null +++ b/templates/src/graphql/default.graphql @@ -0,0 +1,38 @@ +""" +This schema is the default graphql schema, please don't remove it. +""" + +type User { + "The user id from the authentication middleware" + id: ID! + email: String! +} +"Presigned url object" +type SignedUrl { + """ + A presigned url generated by a cloud storage points to a specific file + that has been stored or will be stored on a cloud storage + """ + url: String + "The http request method such as GET, PUT, POST" + method: String +} + +""" +Type Query defines all query / read methods that clients can call. +These methods must be implemented in resolver as well. +""" +type Query { + """ + Get download signed urls for a specific file + that has been stored or will be stored on a cloud storage + """ + downloadSignedUrl(key: String!): SignedUrl + """ + Get upload signed urls for a specific file + that has been stored or will be stored on a cloud storage + """ + uploadSignedUrl(key: String!): SignedUrl + "Get user object from the authentication middleware" + userInfo: User, +} diff --git a/templates/src/graphql/default_resolver.js b/templates/src/graphql/default_resolver.js new file mode 100755 index 0000000..4dc7e7a --- /dev/null +++ b/templates/src/graphql/default_resolver.js @@ -0,0 +1,16 @@ +const FileService = require("../service/file"); +const fileService = new FileService(); + +module.exports = { + Query: { + downloadSignedUrl: (_, { key }, context) => { + return fileService.getDownloadSignedUrl(key); + }, + uploadSignedUrl: (_, { key }, context) => { + return fileService.getUploadSignedUrl(key); + }, + userInfo: (_, { }, context) => { + return context.user; + } + }, +}; diff --git a/templates/src/graphql/example_trip.graphql b/templates/src/graphql/example_trip.graphql new file mode 100755 index 0000000..1ff9a28 --- /dev/null +++ b/templates/src/graphql/example_trip.graphql @@ -0,0 +1,30 @@ +""" +This is an example for showing how to use type Query and Mutation. +you can remove it if you don't want it. +""" +type Trip { + "The serials number for a trip" + id: ID! + "The flight_number listed in https://api.spacexdata.com/v2/launches" + launchId: Int + "The user id that Identity Provider assigned." + userId: Int +} + +type Query { + "List all booked trips. This is an query example, you can remove it. " + bookedTrips: [Trip] +} + +type Mutation { + "Book trips. This is an mutation example, you can remove it." + bookTrips(launchIds: [ID]!): TripUpdateResponse! + "Cancel a trip. This is an mutation example, you can remove it." + cancelTrip(launchId: ID!): TripUpdateResponse! +} + +"The response body when bookTrips or cancelTrip method is called" +type TripUpdateResponse { + success: Boolean! + message: String +} \ No newline at end of file diff --git a/templates/src/graphql/example_trip_resolver.js b/templates/src/graphql/example_trip_resolver.js new file mode 100755 index 0000000..3283a81 --- /dev/null +++ b/templates/src/graphql/example_trip_resolver.js @@ -0,0 +1,38 @@ +const TripService = require("../service/trip"); +const tripService = new TripService(); + +module.exports = { + Query: { + bookedTrips: (_, { }, context) => { + return tripService.getBookedTrips( {userId: context.user.id} ); + } + }, + + Mutation: { + bookTrips: async (_, { launchIds }, context) => { + const results = await tripService.bookTrips({ userId: context.user.id , launchIds }); + return { + success: results && results.length === launchIds.length, + message: + results.length === launchIds.length + ? 'trips booked successfully' + : `the following launches couldn't be booked: ${launchIds.filter( + id => !results.includes(id), + )}` + }; + }, + cancelTrip: async (_, { launchId }, context) => { + const result = await tripService.cancelTrip({ userId: context.user.id, launchId }); + if (!result) + return { + success: false, + message: 'failed to cancel trip', + }; + return { + success: true, + message: 'trip cancelled', + }; + }, + + }, +}; diff --git a/templates/src/middleware/auth/index.js b/templates/src/middleware/auth/index.js index 3d16e6e..4cd0325 100644 --- a/templates/src/middleware/auth/index.js +++ b/templates/src/middleware/auth/index.js @@ -4,22 +4,34 @@ const authMiddleware = (req, res, next) => { * 1. X-User-Id * 2. X-User-Email * */ - const hasUserData = req.headers["x-user-id"] && req.headers["x-user-email"]; - if (!hasUserData) { - res.status(401); - res.json({ - success: false, - message: "unauthenticated", - }); - } else { - req.user = { - id: req.headers["x-user-id"], - email: req.headers["x-user-email"], - }; + if (inAllowlist(req.path)) { next(); + } else { + const hasUserData = req.headers["x-user-id"] && req.headers["x-user-email"]; + if (!hasUserData) { + res.status(401); + res.json({ + success: false, + message: "unauthenticated", + }); + } else { + req.user = { + id: req.headers["x-user-id"], + email: req.headers["x-user-email"], + }; + next(); + } } + }; +//authAllowlist defines the paths that will not pass the authentication middleware +const authAllowlist = ["/status/ready", "/status/alive", "/status/about"]; + +const inAllowlist = (path) => { + return authAllowlist.find(element => element === path); +} + module.exports = { authMiddleware, }; diff --git a/templates/src/service/file.js b/templates/src/service/file.js new file mode 100644 index 0000000..a58100d --- /dev/null +++ b/templates/src/service/file.js @@ -0,0 +1,41 @@ +const aws = require("aws-sdk"); +const conf = require("../conf"); + +const s3 = new aws.S3(); + +class FileService { + constructor(){} + + getUploadSignedUrl(key){ + const params = { + Bucket: process.env.S3_BUCKET, + Key: key, + Expires: conf.s3PresignedExpires, + }; + const url = s3.getSignedUrl('putObject',params); + return { + url: url, + method: "put" + }; + } + + getDownloadSignedUrl(key){ + const cloudfrontAccessKeyId = process.env.CF_KEYPAIR_ID; + const cloudFrontPrivateKey = process.env.CF_KEYPAIR_SECRET_KEY; + const cloudFrontDomain = process.env.CF_DOMAIN; + const signer = new aws.CloudFront.Signer(cloudfrontAccessKeyId, cloudFrontPrivateKey); + key = (key.substring(0,1)=='/') ? key : "/"+key + + const url = signer.getSignedUrl({ + url: cloudFrontDomain+key, + expires: new Date().getTime() + conf.cfSignerExpires + }); + + return { + url: url, + method: "get" + }; + } +} + +module.exports = FileService; \ No newline at end of file diff --git a/templates/src/service/trip.js b/templates/src/service/trip.js new file mode 100755 index 0000000..bc3cf21 --- /dev/null +++ b/templates/src/service/trip.js @@ -0,0 +1,36 @@ +const Trip = require("../db/model/Trip"); + +class TripService { + constructor() { + } + + async bookTrips({ userId, launchIds }) { + if (!userId) return; + let results = []; + for (const launchId of launchIds) { + const res = await this.bookTrip({ userId, launchId }); + if (res) results.push(res); + } + return results; + } + + async bookTrip({ userId, launchId }) { + const res = await Trip.findOrCreate({ + where: { userId, launchId }, + }); + return res && res.length ? res[0].get() : false; + } + + async cancelTrip({ userId, launchId }) { + return !!Trip.destroy({ where: { userId, launchId } }); + } + + async getBookedTrips({ userId }) { + const found = await Trip.findAll({ + where: { userId }, + }); + return found; + } +} + +module.exports = TripService; diff --git a/zero-module.yml b/zero-module.yml index 89046ff..b65f6bb 100644 --- a/zero-module.yml +++ b/zero-module.yml @@ -90,6 +90,12 @@ parameters: options: - "yes" - "no" + - field: apiType + label: What type of API do you want to expose? + default: rest + options: + - "rest" + - "graphql" - field: CIVendor label: Using either circleCI or github Actions to build / test your repository default: "circleci" @@ -108,9 +114,24 @@ conditions: data: - src/middleware/auth - src/app/auth + - src/mockauth.js - kubernetes/base/auth.yml - kubernetes/overlays/staging/auth.yml - kubernetes/overlays/production/auth.yml + - action: ignoreFile + matchField: apiType + whenValue: "rest" + data: + - src/graphql + - src/graphql.js + - src/db/model/Trip.js + - src/service/trip.js + - action: ignoreFile + matchField: apiType + whenValue: "graphql" + data: + - src/app + - src/app.js - action: ignoreFile matchField: CIVendor whenValue: "circleci"