diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..2b2dc2e --- /dev/null +++ b/.babelrc @@ -0,0 +1,15 @@ +{ + "presets": [ + "@babel/typescript", + [ + "@babel/env", + { + "modules": false, + "targets": { + "node": "8.20" + } + } + ] + ], + "plugins": ["@babel/plugin-proposal-object-rest-spread"] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..16eff96 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +**Edit a file, create a new file, and clone from Bitbucket in under 2 minutes** + +When you're done, you can delete the content in this README and update the file with details for others getting started with your repository. + +*We recommend that you open this README in another tab as you perform the tasks below. You can [watch our video](https://youtu.be/0ocf7u76WSo) for a full demo of all the steps in this tutorial. Open the video in a new tab to avoid leaving Bitbucket.* + +--- + +## Edit a file + +You’ll start by editing this README file to learn how to edit a file in Bitbucket. + +1. Click **Source** on the left side. +2. Click the README.md link from the list of files. +3. Click the **Edit** button. +4. Delete the following text: *Delete this line to make a change to the README from Bitbucket.* +5. After making your change, click **Commit** and then **Commit** again in the dialog. The commit page will open and you’ll see the change you just made. +6. Go back to the **Source** page. + +--- + +## Create a file + +Next, you’ll add a new file to this repository. + +1. Click the **New file** button at the top of the **Source** page. +2. Give the file a filename of **contributors.txt**. +3. Enter your name in the empty file space. +4. Click **Commit** and then **Commit** again in the dialog. +5. Go back to the **Source** page. + +Before you move on, go ahead and explore the repository. You've already seen the **Source** page, but check out the **Commits**, **Branches**, and **Settings** pages. + +--- + +## Clone a repository + +Use these steps to clone from SourceTree, our client for using the repository command-line free. Cloning allows you to work on your files locally. If you don't yet have SourceTree, [download and install first](https://www.sourcetreeapp.com/). If you prefer to clone from the command line, see [Clone a repository](https://confluence.atlassian.com/x/4whODQ). + +1. You’ll see the clone button under the **Source** heading. Click that button. +2. Now click **Check out in SourceTree**. You may need to create a SourceTree account or log in. +3. When you see the **Clone New** dialog in SourceTree, update the destination path and name if you’d like to and then click **Clone**. +4. Open the directory you just created to see your repository’s files. + +Now that you're more familiar with your Bitbucket repository, go ahead and add a new file locally. You can [push your change back to Bitbucket with SourceTree](https://confluence.atlassian.com/x/iqyBMg), or you can [add, commit,](https://confluence.atlassian.com/x/8QhODQ) and [push from the command line](https://confluence.atlassian.com/x/NQ0zDQ). \ No newline at end of file diff --git a/dynamodb/index.js b/dynamodb/index.js new file mode 100644 index 0000000..f56ae76 --- /dev/null +++ b/dynamodb/index.js @@ -0,0 +1,11 @@ +import { DynamoDB } from "aws-sdk"; + +let dynamoDbClient = new DynamoDB.DocumentClient(); +if (process.env.IS_OFFLINE) { + dynamoDbClient = new DynamoDB.DocumentClient({ + endpoint: "http://localhost:8000", + region: "localhost", + }); +} + +export { dynamoDbClient }; diff --git a/env.yml b/env.yml new file mode 100644 index 0000000..e8e95f0 --- /dev/null +++ b/env.yml @@ -0,0 +1,16 @@ +# Add the environment variables for the various stages + +prod: + ENV_TYPE: "prod" +default: + ENV_TYPE: "dev" + ELASTICSEARCH_URL: + Fn::GetAtt: + - EventsGqlElasticSearch + - DomainEndpoint # get domain endpoitn attribute from elastic search resource +test: + ENV_TYPE: "test" + ELASTICSEARCH_URL: + Fn::GetAtt: + - EventsGqlElasticSearch + - DomainEndpoint \ No newline at end of file diff --git a/handler.ts b/handler.ts new file mode 100644 index 0000000..de08b2e --- /dev/null +++ b/handler.ts @@ -0,0 +1,24 @@ +import schema from "./schemas/index"; +import { graphql } from "graphql"; +import { APIGatewayProxyEvent } from "aws-lambda"; +// Highly scalable FaaS architecture :) +// Export a function which would be hooked up to the the λ node/ nodes as specified on serverless.yml template +export async function queryEvents( + event: APIGatewayProxyEvent, +) { + const parsedRequestBody = event && event.body ? JSON.parse(event.body) : {}; + try { + const graphQLResult = await graphql( + schema, + parsedRequestBody.query, + null, + null, + parsedRequestBody.variables, + parsedRequestBody.operationName, + ); + + return { statusCode: 200, body: JSON.stringify(graphQLResult) }; + } catch (error) { + throw error; + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..7e6996e --- /dev/null +++ b/package.json @@ -0,0 +1,72 @@ +{ + "name": "serverless-graphql-template", + "version": "1.0.0", + "author": { + "name": "Dasith kuruppu", + "email": "dasithkuruppu@gmail.com", + "url": "https://github.com/DasithKuruppu" + }, + "description": "Initial boilerplate for serverless lambda with dynamodb,elasticsearch and graphql - events booking app", + "keywords": [ + "FaaS", + "nodejs", + "dynamodb", + "lambda", + "serverless", + "graphql", + "elasticsearch" + ], + "main": "handler.js", + "scripts": { + "test-lambda": "serverless invoke test --stage default -f queryEvents", + "start": "serverless offline start", + "compile-debug": "serverless webpack --out dist", + "deploy-dev": "serverless deploy -v --stage=devdefault --force", + "deploy-test": "serverless deploy -v --stage=test", + "deploy-prod": "serverless deploy -v --stage=prod", + "deploy-to-bucket": "serverless s3sync", + "tslint": "tslint --project tsconfig.json --config tslint.json", + "compile-tests": "webpack --config configs/test.webpack.config.js", + "unit-tests": "jest --config jest.config.js --verbose", + "dynamodb-start": "serverless dynamodb start", + "dynamodb-install": "serverless dynamodb install" + }, + "license": "ISC", + "dependencies": { + "@elastic/elasticsearch": "^7.0.0-rc.2", + "aws-sdk": "^2.361.0", + "graphql": "^14.0.2", + "graphql-dynamodb-connections": "^1.0.2", + "graphql-iso-date": "^3.6.1", + "serverless-s3-sync": "^1.8.0", + "uuid": "^3.3.2" + }, + "devDependencies": { + "@babel/cli": "^7.1.5", + "@babel/core": "^7.1.6", + "@babel/plugin-proposal-object-rest-spread": "^7.4.4", + "@babel/preset-env": "^7.1.6", + "@babel/preset-typescript": "^7.1.0", + "@types/aws-lambda": "^8.10.15", + "@types/graphql": "^14.0.3", + "@types/jest": "^23.3.10", + "@types/node": "^10.12.11", + "babel-loader": "^8.0.4", + "jest": "^24.6.0", + "serverless": "^1.41.1", + "serverless-dynamodb-local": "^0.2.25", + "serverless-jest-plugin": "^0.2.1", + "serverless-offline": "^3.31.3", + "serverless-plugin-offline-dynamodb-stream": "^1.0.18", + "serverless-webpack": "^5.2.0", + "source-map-loader": "^0.2.4", + "ts-jest": "^23.10.5", + "tslint": "^5.11.0", + "tslint-eslint-rules": "^5.4.0", + "typescript": "^3.2.1", + "webpack": "^4.26.0", + "webpack-cli": "^3.1.2", + "webpack-node-externals": "^1.7.2", + "serverless-s3-deploy": "^0.8.0" + } +} diff --git a/resolvers/events/create.ts b/resolvers/events/create.ts new file mode 100644 index 0000000..fbcec8a --- /dev/null +++ b/resolvers/events/create.ts @@ -0,0 +1,27 @@ +import { IEvent } from "./typings"; +import { dynamoDbClient } from "../../dynamodb"; +import * as uuidv4 from "uuid/v4"; + +export function createParams(data: IEvent, TableName: string , uniqueID: string) { + return { + Item: { + name: data.name, + description: data.description, + id: uniqueID, + addedAt: Date.now(), + }, + TableName, + }; +} + +export default (data: IEvent) => { + const putParams = createParams(data, process.env.TABLE_NAME, uuidv4()); + return dynamoDbClient + .put(putParams) + .promise() + .then(() => { + return putParams.Item; + }).catch((err) => { + throw err; + }); +}; diff --git a/resolvers/events/list.ts b/resolvers/events/list.ts new file mode 100644 index 0000000..0c99895 --- /dev/null +++ b/resolvers/events/list.ts @@ -0,0 +1,10 @@ +import { DynamoDB } from "aws-sdk"; +import { dynamoDbClient } from "../../dynamodb"; +export default () => +dynamoDbClient + .scan({ TableName: process.env.TABLE_NAME }) + .promise() + .then((list: DynamoDB.DocumentClient.ScanOutput) => list.Items.map( + (Item) => { + return ({ ...Item, addedAt: new Date(Item.addedAt) }); + })); diff --git a/resolvers/events/remove.ts b/resolvers/events/remove.ts new file mode 100644 index 0000000..af52fdf --- /dev/null +++ b/resolvers/events/remove.ts @@ -0,0 +1,15 @@ +import { dynamoDbClient } from "../../dynamodb"; + +export default async (id: string) => { + const params = { + TableName: process.env.TABLE_NAME, + Key: { id }, + ReturnValues: "ALL_OLD", + }; + try { + const response = await dynamoDbClient.delete(params).promise(); + return response.Attributes; + } catch (error) { + throw error; + } +}; diff --git a/resolvers/events/typings.ts b/resolvers/events/typings.ts new file mode 100644 index 0000000..da8f1a0 --- /dev/null +++ b/resolvers/events/typings.ts @@ -0,0 +1,7 @@ +export interface IEvent { + id: string; + name: string; + description?: string; + addedAt: number; + startingOn?: number; +} diff --git a/resolvers/events/view.ts b/resolvers/events/view.ts new file mode 100644 index 0000000..0c740ff --- /dev/null +++ b/resolvers/events/view.ts @@ -0,0 +1,10 @@ +import { dynamoDbClient } from "../../dynamodb"; + +export default async (id: string) => { + const params = { + TableName: process.env.TABLE_NAME, + Key: { id }, + }; + const GetEvents = await dynamoDbClient.get(params).promise(); + return GetEvents.Item; +}; diff --git a/resources/dynamodb.yml b/resources/dynamodb.yml new file mode 100644 index 0000000..2a00545 --- /dev/null +++ b/resources/dynamodb.yml @@ -0,0 +1,20 @@ +Resources: + EventsGqlDynamoDbTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: ${self:custom.tableName} + AttributeDefinitions: + - AttributeName: id + AttributeType: S + KeySchema: + - AttributeName: id + KeyType: HASH + # Set the capacity based on the stage + ProvisionedThroughput: + ReadCapacityUnits: ${self:custom.tableThroughput} + WriteCapacityUnits: ${self:custom.tableThroughput} + StreamSpecification: + StreamViewType: NEW_AND_OLD_IMAGES + + + \ No newline at end of file diff --git a/schemas/index.ts b/schemas/index.ts new file mode 100644 index 0000000..e38e143 --- /dev/null +++ b/schemas/index.ts @@ -0,0 +1,84 @@ +/*import { + paginationToParams, + dataToConnection +} from "graphql-dynamodb-connections"; +*/ +import { + GraphQLSchema, + GraphQLObjectType, + GraphQLString, + GraphQLList, + GraphQLNonNull, + GraphQLBoolean, +} from "graphql"; + +import { + /*GraphQLDate, + GraphQLTime,*/ + GraphQLDateTime, +} from "graphql-iso-date"; + +import { IEvent } from "../resolvers/events/typings"; +import addEvent from "../resolvers/events/create"; +import viewEvent from "../resolvers/events/view"; +import listEvents from "../resolvers/events/list"; +import removeEvent from "../resolvers/events/remove"; + +const eventType = new GraphQLObjectType({ + name: "Event", + fields: { + id: { type: new GraphQLNonNull(GraphQLString) }, + name: { type: new GraphQLNonNull(GraphQLString) }, + description: { type: new GraphQLNonNull(GraphQLString) }, + addedAt: { type: new GraphQLNonNull(GraphQLDateTime) }, + }, +}); + +const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: "Query", + fields: { + listEvents: { + type: new GraphQLList(eventType), + resolve: (parent ) => { + return listEvents(); + }, + }, + viewEvent: { + args: { + id: { type: new GraphQLNonNull(GraphQLString) }, + }, + type: eventType, + resolve: (parent, args: { id: string }) => { + return viewEvent(args.id); + }, + }, + }, + }), + + mutation: new GraphQLObjectType({ + name: "Mutation", + fields: { + createEvent: { + args: { + name: { type: new GraphQLNonNull(GraphQLString) }, + description: { type: new GraphQLNonNull(GraphQLString) }, + }, + type: eventType, + resolve: (parent, args: IEvent) => { + return addEvent(args); + }, + }, + removeEvent: { + args: { + id: { type: new GraphQLNonNull(GraphQLString) }, + }, + type: eventType, + resolve: (parent, args: { id: string }) => { + return removeEvent(args.id); + }, + }, + }, + }), +}); +export default schema; diff --git a/serverless.yml b/serverless.yml new file mode 100644 index 0000000..ceb5766 --- /dev/null +++ b/serverless.yml @@ -0,0 +1,71 @@ +service: aws-nodejs +plugins: + - serverless-webpack + - serverless-dynamodb-local + - serverless-plugin-offline-dynamodb-stream + - serverless-offline +custom: + # Our stage is based on what is passed in when running serverless + # commands. Or fallsback to what we have set in the provider section. + stage: ${opt:stage, self:provider.stage} + # Set the table name here so we can use it while testing locally + tableName: ${self:custom.stage}-events + # Set our DynamoDB throughput for prod and all other non-prod stages. + tableThroughputs: + prod: 1 #more throughput on production env + default: 1 + tableThroughput: ${self:custom.tableThroughputs.${self:custom.stage}, self:custom.tableThroughputs.default} + webpack: + webpackConfig: './webpack.config.js' # Name of webpack configuration file + includeModules: true # Node modules configuration for packaging + packager: 'npm' # Packager that will be used to package your external module + dynamodb: + start: + port: 8000 + migrate: true + inMemory: true + noStart: true + environment: ${file(env.yml):${self:custom.stage}, file(env.yml):default} +provider: + name: aws + runtime: nodejs8.10 + stage: default + iamRoleStatements: + - Effect: Allow + Action: + - dynamodb:ListTables + - dynamodb:DescribeTable + - dynamodb:DescribeStream + - dynamodb:ListStreams + - dynamodb:GetShardIterator + - dynamodb:BatchGetItem + - dynamodb:GetItem + - dynamodb:PutItem + - dynamodb:UpdateItem + - dynamodb:DeleteItem + - dynamodb:Query + - dynamodb:Scan + - dynamodb:DescribeReservedCapacity + - dynamodb:DescribeReservedCapacityOfferings + - dynamodb:GetRecords + Resource: + Fn::Join: + - "" + - - "arn:aws:dynamodb:*:*:table/" + - Ref: EventsGqlDynamoDbTable +functions: + queryEvents: + handler: handler.queryEvents + events: + - http: + path: events + method: post + cors: true + environment: + TABLE_NAME: ${self:custom.tableName} + +resources: + # DynamoDB + - ${file(resources/dynamodb.yml)} + + diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..437208a --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,42 @@ +const path = require("path"); +const slsw = require("serverless-webpack"); +const nodeExternals = require("webpack-node-externals"); + +module.exports = { + entry: slsw.lib.entries, + target: "node", + mode: slsw.lib.webpack.isLocal ? "development" : "production", + optimization: { + // We no want to minimize our code. + minimize: false + }, + performance: { + // Turn off size warnings for entry points + hints: false + }, + devtool: 'cheap-module-source-map', + externals: [nodeExternals()], + module: { + rules: [ + { + test: /\.ts?$/, + use: 'babel-loader', + exclude: /node_modules/ + }, + { + test: /\.js$/, + use: ["source-map-loader"], + enforce: "pre" + }, + ] + }, + resolve: { + extensions: [ '.tsx', '.ts', '.js' ] + }, + output: { + libraryTarget: "commonjs2", + path: path.join(__dirname, ".webpack"), + filename: "[name].js", + sourceMapFilename: "[file].map" + } +};