Quick recap: this is a soup to nuts series covering implementing a full-featured REST API on AWS from the perspective of a long-time infrastructure engineer with a historical preference for Java-based solutions.
In the previous parts of this series, we (a) set up our build environment; (b) registered our AWS account; (c) created our Administrative users; and (d) stubbed out our default Serverless service.
We'll now cover (I) the various file types we will be using; and (II) our directory structure; while endeavoring to explain the design decisions behind why we are proceeding in the way we are.
I. Various File Types
A. Serverless / CloudFormation Configuration Files
At its core, Serverless Framework is an extremely powerful javascript module that translates a YAML configuration file named serverless.yml
into vendor-specific cloud service commands. In the case of AWS, these are AWS CloudFormation commands such as create-function
for AWS Lambda or create-table
for DynamoDB.
But why introduce this intermediate Serverless Framework instead of just using the AWS CLI? A number of reasons, including: (1) the veneer of avoiding vendor lock-in; (2) the additional variables; and (3) the ability to develop and debug our code offline without using AWS resources.
With that, let's look at the default serverless.yml
file that we created in the last part of this series. Switch to your working directory and type code serverless.yml
to open the file in VS Code.
1) Initial Serverless.yml Skeleton
Full contents of the default serverless.yml
# Welcome to Serverless!
#
# This file is the main config file for your service.
# It's very minimal at this point and uses default values.
# You can always add more config options for more control.
# We've included some commented out config examples here.
# Just uncomment any of them to get that config option.
#
# For full config options, check the docs:
# docs.serverless.com
#
# Happy Coding!
service: myrestproject
# app and org for use with dashboard.serverless.com
#app: your-app-name
#org: your-org-name
# You can pin your service to only deploy with a specific Serverless version
# Check out our docs for more details
frameworkVersion: '2'
provider:
name: aws
runtime: nodejs12.x
lambdaHashingVersion: 20201221
# you can overwrite defaults here
# stage: dev
# region: us-east-1
# you can add statements to the Lambda function's IAM Role here
# iamRoleStatements:
# - Effect: "Allow"
# Action:
# - "s3:ListBucket"
# Resource: { "Fn::Join" : ["", ["arn:aws:s3:::", { "Ref" : "ServerlessDeploymentBucket" } ] ] }
# - Effect: "Allow"
# Action:
# - "s3:PutObject"
# Resource:
# Fn::Join:
# - ""
# - - "arn:aws:s3:::"
# - "Ref" : "ServerlessDeploymentBucket"
# - "/*"
# you can define service wide environment variables here
# environment:
# variable1: value1
# you can add packaging information here
#package:
# include:
# - include-me.js
# - include-me-dir/**
# exclude:
# - exclude-me.js
# - exclude-me-dir/**
functions:
hello:
handler: handler.hello
# The following are a few example events you can configure
# NOTE: Please make sure to change your handler code to work with those events
# Check the event documentation for details
# events:
# - httpApi:
# path: /users/create
# method: get
# - websocket: $connect
# - s3: ${env:BUCKET}
# - schedule: rate(10 minutes)
# - sns: greeter-topic
# - stream: arn:aws:dynamodb:region:XXXXXX:table/foo/stream/1970-01-01T00:00:00.000
# - alexaSkill: amzn1.ask.skill.xx-xx-xx-xx
# - alexaSmartHome: amzn1.ask.skill.xx-xx-xx-xx
# - iot:
# sql: "SELECT * FROM 'some_topic'"
# - cloudwatchEvent:
# event:
# source:
# - "aws.ec2"
# detail-type:
# - "EC2 Instance State-change Notification"
# detail:
# state:
# - pending
# - cloudwatchLog: '/aws/lambda/hello'
# - cognitoUserPool:
# pool: MyUserPool
# trigger: PreSignUp
# - alb:
# listenerArn: arn:aws:elasticloadbalancing:us-east-1:XXXXXX:listener/app/my-load-balancer/50dc6c495c0c9188/
# priority: 1
# conditions:
# host: example.com
# path: /hello
# Define function environment variables here
# environment:
# variable2: value2
# you can add CloudFormation resource templates here
#resources:
# Resources:
# NewResource:
# Type: AWS::S3::Bucket
# Properties:
# BucketName: my-new-bucket
# Outputs:
# NewOutput:
# Description: "Description for the output"
# Value: "Some output value"
You'll see that most of the default file consists of commented out formatted examples, with only 9 lines of actual configuration in YAML format:
service: myrestproject
frameworkVersion: '2'
provider:
name: aws
runtime: nodejs12.x
lambdaHashingVersion: 20201221
functions:
hello:
handler: handler.hello
Each of these is a key/value pair. An important point to remember is that the key names, like variable names, will be constants that cannot be dynamically set. Rather, the names of the constants will--depending on the situation--be set by either: (a) serverless; (b) AWS; or (c) you the end user. The values, on the other hand, will often be dynamically set.
Let's cover each of the nine functional lines in the default configuration file:
service: myrestproject
a) service
The value of the service
key will be used to generate the name your AWS deployment bundle. AWS refers to these--unsurprisingly--as a 'stack'.
Serverless follows the recommended practice of appending a development stage modifier to the base stack name. By default, this value is 'dev', giving us a full stack name of 'myrestproject-dev'.
You can see the list of deployed stacks through both the web UI (be sure your region in the top right is set correctly):
https://console.aws.amazon.com/cloudformation/
and the AWS CLI:
$ aws cloudformation list-stacks
frameworkVersion: '2'
b) frameworkVersion
The version of the Serverless Framework. The supplied value here is pinned to any version 2 of the Framework. No need to change it.
provider:
name: aws
runtime: nodejs12.x
lambdaHashingVersion: 20201221
c) provider
Provider is our first structured entry. All of our vendor-specific entries will be children below this key. There are a number of additional ones, but we are only covering the basic ones for now:
i) name
The name of our cloud service vendor: 'aws'. Other options would include 'azure' or whatever that thing is that Google offers.
ii) runtime
The default version of Node.js needed to run our functions. (We can override this on a per-function basis on the off-chance it is necessary). AWS claims to support Node.js version 14, but at least some features were not working correctly last time I checked in March 2021 (e.g. nullish coalescing). Go ahead and keep it as nodejs12.x
for the time being.
iii) lambdaHashingVersion
The Serverless Framework occasionally deprecates and changes underlying functionality. In this instance, they apparently are transitioning the underlying hash function used to determine if a function has been altered. Since we are just starting out, we should switch right now to what will be the default in Serverless Framework 3.0 by setting this value to 20201221
as indicated in the default serverless file.
functions:
hello:
handler: handler.hello
d) functions
Back up to the top level, functions
contains a structured description of our lambda functions. Each function will be its own entry.
i) hello
This is a developer-selected name used for referencing the function elsewhere in our serverless.yml and AWS CloudFormation configuration files. Here, the provided name is hello
, but it could be yolo
or mydogspot
or something else, subject to the caveat noted before that key names are required to be constants (e.g. it cannot be named ${self.custom.myLambdaName}
).
(1) handler
The javascript function that will process executions of this lambda function. The format is <filename>.<function name> (e.g. handler.hello points to the hello function in handler.js within the base directory, whereas ./src/aws/AWSController.findOne points at the findOne function in AWSController.js within the ./src/aws/ directory).
2) Modified Serverless.yml
With this basic understanding of our serverless.yml configuration file out of the way, let's start putting together a working REST template.
Full contents of the modified serverless.yml
# app and org for integration with dashboard.serverless.com
# Note that using this will cause Serverless to inject a logging handler proxy
# before the calls to your lambda functions, resulting in the renaming of your handler
# in the AWS interface
#org: <your_org_name_here>
#app: <your_dashboard_appname_here>
# name of our microservice
service: ${self:custom.resourceName}Service
frameworkVersion: '>=2.0'
variablesResolutionMode: 20210219
#############################################################################################
# CUSTOM VARIABLES - all variables necessary to get up and running should be in this section
#############################################################################################
custom:
resourceName: teams
# Email address of primary contact; embedded into internal "Tags" to aid in
# internal maintenance
primaryContact: <YOUR EMAIL ADDRESS>
region: <YOUR AWS REGION>
dynamodb:
# For tables, I have adopted a naming convention of <SERVICE>-Table-<EntityName>-<Stage>; e.g. SportsApp-Table-Players-dev
resourceTable: ${self:service}-Table-${self:custom.resourceName}-${self:provider.stage}
# For indexes, I have adopted a naming convention of <SERVICE>-TableIdx-<EntityName>-<Constraint/SearchKey>-<Stage>; e.g. SportsApp-TableIdx-Players-LastName-dev
resourceTableIdx: ${self:service}-TableIdx-${self:custom.resourceName}-Name-${self:provider.stage}
provider:
name: aws
# AWS claims they support NodeJS14, but as of March 2021, certain Node14 features don't appear to function correctly, e.g. nullish coalescing
runtime: nodejs14.x
lambdaHashingVersion: 20201221
stage: dev
region: ${self:custom.region}
apiGateway:
# setting required for the transition from Serverless Framework 2.0 to 3.0+
shouldStartNameWithService: true
# we can have AWS API Gateway validate incoming requests prior to sending it to our lambda,
# saving us the processing cost for malformed requests
request:
schemas:
resource-create-model:
name: ${self:custom.resourceName}CreateModel
schema: ${file(src/schema/createResourceSchema.json)}
description: "A Model validation for creating ${self:custom.resourceName}"
# Environment variables our Javascript code relies on
environment:
NODE_ENV: ${self:provider.stage}
TABLE_NAME_TEAM: ${self:custom.dynamodb.resourceTable}
TABLE_IDX_TEAM_NAME: ${self:custom.dynamodb.resourceTableIdx}
# Base IAm role that the lambdas will execute with
iam: ${file(./resources/aws/iam/LambdaRole.yml)}
# Serverless Framework plugins
plugins:
# support for typescript
- serverless-plugin-typescript
# Allows us to use a local instance during development, eliminating need
# to publish to cloud to see changes to functions and allowing use of a debugger
- serverless-offline
# define all of our REST microservice endpoints
functions: ${file(./resources/aws/RESTFunctions.yml)}
# Settings relating to how our module will be bundled for execution
package:
# Don't include node_modules like typescript and unit testing when
# publishing our bundle to the cloud; results in notably smaller upload bundle
excludeDevDependencies: true
resources:
# Values that we want to export out of this service for use in other services
# Including an export name pollutes the global namespace and is only necessary
# when you don't know this Stack's name, as you can otherwise obtain the value
# using the key name you chose below, e.g. 'ApiGatewayRestApiExport'
Outputs:
ApiGatewayRestApiExport:
Value: !Ref 'ApiGatewayRestApi'
# Export:
# Name: ${self:service}-${self:provider.stage}-api
# The various resources can be given custom developer-friendly names, as shown below.
# What the resource consists of is determined by the 'type' sub-property found in the files
Resources:
############################################################
# DATABASE RESOURCES
############################################################
# Data table for this microserver
ResourceTable: ${file(./resources/aws/dynamodb/ResourceTable.yml)}
a) Change our service name
Change the service value from myrestproject
to
service: ${self:custom.resourceName}Service
Note that for this value, we have introduced for the first time the use of a variable value. These come in the format: ${source:key}
. For references to the present serverless file, the source is self
. So the value for our service
key is now going to consist of the value located at custom.resourceName
concatenated with 'Service'. Note that we are referencing a value that has not yet been defined at this point in the file--it is okay if it is defined further down in the file. So let's get to it.
b) 'custom' top level entry
The Serverless Framework defines at top level of our YAML hierarchy a custom
key, which can be used to hold many of our installation-specific values. The various key-value pairs in this custom
section are user-defined however we see fit.
We'll go ahead and add the following:
#############################################################################################
# CUSTOM VARIABLES - all variables necessary to get up and running should be in this section
#############################################################################################
custom:
# Name of our REST resource, e.g. 'players', 'team', 'league', etc.
resourceName: teams
# Email address of primary contact; embedded into internal "Tags" to aid in
# internal maintenance
primaryContact: <YOUR EMAIL ADDRESS>
region: <YOUR AWS REGION>
dynamodb:
# For tables, I have adopted a naming convention of <SERVICE>-Table-<EntityName>-<Stage>; e.g. SportsApp-Table-Players-dev
resourceTable: ${self:service}-Table-${self:custom.resourceName}-${self:provider.stage}
# For indexes, I have adopted a naming convention of <SERVICE>-TableIdx-<EntityName>-<Constraint/SearchKey>-<Stage>; e.g. SportsApp-TableIdx-Players-LastName-dev
resourceTableIdx: ${self:service}-TableIdx-${self:custom.resourceName}-Name-${self:provider.stage}
For this example, we'll create a RESTful API for a 'teams' resource. (Note the use of plural and lowercase). This means the service key we defined earlier will be set to 'teamsService'.
Be sure to put in your email address and your AWS region (e.g. us-east-2
).
We have also defined a key-value branch called dynamodb
(again, we could have called it anything, like myNotSqlParameters
) with two child values: (1) the eventual name of our database "table"; and (2) the eventual name of our "table"'s uniqueness index (we'll explain this later when discussion data storage).
You'll recall that ${self:service} = teamsService
and ${self:custom.resourceName} = teams
and the default stage is dev
so:
resourceTable: ${self:service}-Table-${self:custom.resourceName}-${self:provider.stage}
will become
resourceTable: teamsService-Table-teams-dev
By including the stage (dev
) in name of all deployed resource such as the table and index names, we ensure our development environment won't interfere with our production (prod
) environment.
c) 'provider'
We are now going to add a few more keys to the provider section:
provider:
name: aws
runtime: nodejs14.x
lambdaHashingVersion: 20201221
stage: dev
region: ${self:custom.region}
apiGateway:
# setting required for the transition from Serverless Framework 2.0 to 3.0+
shouldStartNameWithService: true
# Environment variables our Javascript code relies on
environment:
NODE_ENV: ${self:provider.stage}
TABLE_NAME_TEAM: ${self:custom.dynamodb.resourceTable}
TABLE_IDX_TEAM_NAME: ${self:custom.dynamodb.resourceTableIdx}
# Base IAm role that the lambdas will execute with
iam: ${file(./resources/aws/iam/LambdaRole.yml)}
To start, we now explicitly set the stage to dev
,
stage: dev
which you will recall is the default. We can override this during deployment using command line parameters:
$ serverless deploy --stage prod
We next introduce for the first time the re-use of our custom parameters from higher in the file by setting region
using the previously defined ${self.custom.region}
.
region: ${self:custom.region}
Again, this lets us keep most of our deployment configuration in one place under custom
.
This is followed by another Serverless transition variable.
apiGateway:
shouldStartNameWithService: true
Starting in 3.0, the naming convention in the deployments will change from {stage}-{service}
to {service}-{stage}
. We should just adopt this approach from the get-go.
Next we set environment variables that will be passed into the Node.js runtime environment. This is a means for communicating our configuration to our javascript code
environment:
NODE_ENV: ${self:provider.stage}
TABLE_NAME_TEAM: ${self:custom.dynamodb.resourceTable}
TABLE_IDX_TEAM_NAME: ${self:custom.dynamodb.resourceTableIdx}
Our javascript code will be able to look these up by examining the runtime environment variables. We pass: (1) the deployment stage; (2) our "table" name; and (3) our "table" uniqueness index name.
Finally, we add our first IAm configuration.
# Base IAm role that the lambdas will execute with
iam: ${file(./resources/aws/iam/LambdaRole.yml)}
Notably, we do this by introducing the file import concept. Serverless allows us to break individual portions of our base configuration file into discrete files. Separating our configuration file into subfiles provides a number of benefits, including: (1) more granular version control; (2) smaller more readable files; and (3) a consistent, known location for finding settings in any given module.
We'll go over the contents of this file in a bit, but first let's stay focused on our serverless.yml
.
d) plugins
We are next going to add a new top-level entry called plugins
. This key contains a yaml
list of Serverless Framework extensions used by our project. I generally try to keep these to a minimum due to inconsistency in the maintenance frequency of the various plugins. The two I find absolutely essential are (a) serverless-plugin-typescript
, which provides TypeScript support on top of Javascript; and (b) serverless-offline
, which allows offline development of our lambda modules.
# Serverless Framework plugins
plugins:
# support for typescript
- serverless-plugin-typescript
# Allows us to use a local instance during development, eliminating need
# to publish to cloud to see changes to functions and allowing use of a debugger
- serverless-offline
e) functions
You'll recall this previously consisted of a structured key-value tree listing the base hello
function. We are going to replace this with a file import.
# define all of our REST microservice endpoints
functions: ${file(./resources/aws/RESTFunctions.yml)}
f) package
Another new top-level key. This defines setting relating to the packaging of our stack for deployment. The only value we will include is excludeDevDependencies
, which will filter out of our deployment modules relating to our precompiler (Typescript) and our testing harness (Jest). (Note that this value is true
by default, so this value is functionally useless as set.)
# Settings relating to how our module will be bundled for execution
package:
# Don't include node_modules like typescript and unit testing when
# publishing our bundle to the cloud; results in notably smaller upload bundle
excludeDevDependencies: true
g) resources
Finally, our last top level key. Various catch-all resource creation happens here, including the definition of our "table". (Later on, we are going to define our user authentication here as well but we'll skip this for now).
resources:
# Values that we want to export out of this service for use in other services
# Including an export name pollutes the global namespace and is only necessary
# when you don't know this Stack's name, as you can otherwise obtain the value
# using the key name you chose below, e.g. 'ApiGatewayRestApiExport'
Outputs:
ApiGatewayRestApiExport:
Value: !Ref 'ApiGatewayRestApi'
# Export:
# Name: ${self:service}-${self:provider.stage}-api
# The various resources can be given custom developer-friendly names, as shown below.
# What the resource consists of is determined by the 'type' sub-property found in the files
Resources:
############################################################
# DATABASE RESOURCES
############################################################
# Data table for this microserver
ResourceTable: ${file(./resources/aws/dynamodb/ResourceTable.yml)}
i) Outputs
Here we instruct AWS what values should be "exported" from our stack. This is done in either of two ways: (1) we can make a value available in our AWS global namespace; or (2) we can make a value available as a parameter attached to our stack name. I personally hate polluting the global namespace and see little value to it, so I take the second approach (but leave an example of the 1st approach commented out in the code).
Outputs:
ApiGatewayRestApiExport:
Value: !Ref 'ApiGatewayRestApi'
Here, we define an export key named ApiGatewayRestApiExport
--this could have been anything we wanted, such as MyDogSpot
. For the value, we for the first time use the !Ref
command. The !Ref command instructs AWS to inject the as-deployed unique name of our ApiGatewayRestAPI object.
Now, if you've been paying attention, you might be saying right now "WHAT ApiGatewayRestApi object!??" When deploying our stack, Serverless creates a resource in AWS's system named 'ApiGatewayRestApi' that routes requests to our lambda. By exporting its value, we will be able to get a handle to it at a future stage.
i) Resources
Somewhat confusingly, the resources
key has a child named Resources
.
Resources:
############################################################
# DATABASE RESOURCES
############################################################
# Data table for this microserver
ResourceTable: ${file(./resources/aws/dynamodb/ResourceTable.yml)}
As noted in the comments, each 'Resources' entry will be of a particular type as indicated by a Type
key with a value such as AWS::DynamoDB::Table
. These would normally appear here, but because we are using file imports, they will instead be in our sub-files.
With that, our final serverless.yml
file looks like this for the time being:
# app and org for integration with dashboard.serverless.com
# Note that using this will cause Serverless to inject a logging handler proxy
# before the calls to your lambda functions, resulting in the renaming of your handler
# in the AWS interface
#org: <your_org_name_here>
#app: <your_dashboard_appname_here>
# name of our microservice
service: ${self:custom.resourceName}Service
frameworkVersion: '>=2.0'
variablesResolutionMode: 20210219
#############################################################################################
# CUSTOM VARIABLES - all variables necessary to get up and running should be in this section
#############################################################################################
custom:
resourceName: teams
# Email address of primary contact; embedded into internal "Tags" to aid in
# internal maintenance
primaryContact: <YOUR EMAIL ADDRESS>
region: <YOUR AWS REGION>
dynamodb:
# For tables, I have adopted a naming convention of <SERVICE>-Table-<EntityName>-<Stage>; e.g. SportsApp-Table-Players-dev
resourceTable: ${self:service}-Table-${self:custom.resourceName}-${self:provider.stage}
# For indexes, I have adopted a naming convention of <SERVICE>-TableIdx-<EntityName>-<Constraint/SearchKey>-<Stage>; e.g. SportsApp-TableIdx-Players-LastName-dev
resourceTableIdx: ${self:service}-TableIdx-${self:custom.resourceName}-Name-${self:provider.stage}
provider:
name: aws
# AWS claims they support NodeJS14, but as of March 2021, certain Node14 features don't appear to function correctly, e.g. nullish coalescing
runtime: nodejs14.x
lambdaHashingVersion: 20201221
stage: dev
region: ${self:custom.region}
apiGateway:
# setting required for the transition from Serverless Framework 2.0 to 3.0+
shouldStartNameWithService: true
# we can have AWS API Gateway validate incoming requests prior to sending it to our lambda,
# saving us the processing cost for malformed requests
request:
schemas:
resource-create-model:
name: ${self:custom.resourceName}CreateModel
schema: ${file(src/schema/createResourceSchema.json)}
description: "A Model validation for creating ${self:custom.resourceName}"
# Environment variables our Javascript code relies on
environment:
NODE_ENV: ${self:provider.stage}
TABLE_NAME_TEAM: ${self:custom.dynamodb.resourceTable}
TABLE_IDX_TEAM_NAME: ${self:custom.dynamodb.resourceTableIdx}
# Base IAm role that the lambdas will execute with
iam: ${file(./resources/aws/iam/LambdaRole.yml)}
# Serverless Framework plugins
plugins:
# support for typescript
- serverless-plugin-typescript
# Allows us to use a local instance during development, eliminating need
# to publish to cloud to see changes to functions and allowing use of a debugger
- serverless-offline
# define all of our REST microservice endpoints
functions: ${file(./resources/aws/RESTFunctions.yml)}
# Settings relating to how our module will be bundled for execution
package:
# Don't include node_modules like typescript and unit testing when
# publishing our bundle to the cloud; results in notably smaller upload bundle
excludeDevDependencies: true
resources:
# Values that we want to export out of this service for use in other services
# Including an export name pollutes the global namespace and is only necessary
# when you don't know this Stack's name, as you can otherwise obtain the value
# using the key name you chose below, e.g. 'ApiGatewayRestApiExport'
Outputs:
ApiGatewayRestApiExport:
Value: !Ref 'ApiGatewayRestApi'
# Export:
# Name: ${self:service}-${self:provider.stage}-api
# The various resources can be given custom developer-friendly names, as shown below.
# What the resource consists of is determined by the 'type' sub-property found in the files
Resources:
############################################################
# DATABASE RESOURCES
############################################################
# Data table for this microserver
ResourceTable: ${file(./resources/aws/dynamodb/ResourceTable.yml)}
Before we go on to define the subfiles of our configuration, let's first talk about some of the other file types we will be dealing with.
B. Dependencies / Package.json
As discussed in the previous post, npm
uses a JSON-based package.json
manifest file to track information about our project dependencies. This file will be located in our root directory. The key parts are as follows:
1) name
, version
, description
These are self-explanatory, being (a) the name of our application; (b) a version identifier; and (c) a textual description.
2) main
The primary entry point of our module.
3) scripts
Now things begin to get interesting. scripts
provides us a way to define a list of commands that we can run using the command npm run <command>
. For example, npm run coverage
or npm run deploy
.
4) devDependencies
A list of dependencies that are necessary during the compilation and testing phases of development, such as modules to support Typescript, Serverless Framework offline testing, and unit tests.
Although we can add modules to this list by hand, the normal method is through the command:
$ npm i --save-dev <module>
Note the use of the flag --save-dev
. This tells NPM that the module is a development dependency, rather than a runtime deployment dependency. If you forget to use the flag, just go ahead and run the command again with the flag and NPM will move the module to the appropriate part of the configuration file.
NPM will install each of these modules in the "node_modules" subdirectory of our project.
I use the following as a baseline:
a) Serverless modules
The following adds Serverless Framework support, offline Serverless Framework testing and debugging, and Serverless Framework Typescript support.
$ npm i --save-dev serverless
$ npm i --save-dev serverless-offline
$ npm i --save-dev serverless-plugin-typescript
b) Typescript modules
The first of the following adds Typescript support. The rest provide Typescript "definitions" for various Javascript modules that do not necessarily have Typescript-support built into them.
$ npm i --save-dev typescript
$ npm i --save-dev @types/aws-lambda
$ npm i --save-dev @types/jest
$ npm i --save-dev @types/node
$ npm i --save-dev @types/uuid
c) Jest Unit Testing modules
The first includes Jest as our unit tester. The second adds dynamodb support to Jest. The rest allow Jest to use the "babel" javascript compiler, along with babel support for environment variables and typescript.
$ npm i --save-dev jest
$ npm i --save-dev @shelf/jest-dynamodb
$ npm i --save-dev babel-jest
$ npm i --save-dev @babel/core
$ npm i --save-dev @babel/preset-env
$ npm i --save-dev @babel/preset-typescript
5) dependencies
A list of dependencies required by our module at runtime. We ideally want to keep this list small. We add items to this list by running the npm i
command without the --save-dev
flag. e.g.
$ npm i dynamoose
I include only dynamoose (a framework for interacting with DynamoDB) and uuid (a library for generating unique identifiers).
$ npm i dynamoose
$ npm i uuid
6) Final package.json file
Our package.json file ends up looking like:
{
"name": "teamMicroservice",
"version": "1.0.0",
"description": "This is the Team Microservice",
"main": "index.js",
"scripts": {
"lint": "tslint -p tsconfig.json -c tslint.json",
"local": "serverless offline",
"deploy": "serverless deploy",
"test": "jest",
"coverage": "jest --coverage",
"clean": "git clean -fXd -e \\!node_modules -e \\!node_modules/**/*"
},
"devDependencies": {
"@babel/core": "^7.13.10",
"@babel/preset-env": "^7.13.12",
"@babel/preset-typescript": "^7.13.0",
"@shelf/jest-dynamodb": "github:shelfio/jest-dynamodb",
"@types/aws-lambda": "^8.10.51",
"@types/jest": "^26.0.21",
"@types/node": "^14.0.23",
"@types/uuid": "^8.3.0",
"babel-jest": "^26.6.3",
"jest": "^26.6.3",
"serverless": "^2.30.3",
"serverless-offline": "^6.8.0",
"serverless-plugin-typescript": "^1.1.9",
"typescript": "^3.8.3"
},
"dependencies": {
"dynamoose": "^2.7.3",
"uuid": "^8.3.2"
}
}
C. launch.json
We'll next briefly touch on this file, which does not yet exist. The launch.json
file is used to configure the Visual Studio Code debugger. Later on, we will define multiple debug configurations, including ones to launch (1) Serverless offline in the debugger; (2) all of our Jest unit tests; and (3) debugging our current Jest file. For now, just know that it will exist and we will customize it when we get into our debugger configuration.
D. jest.config.js
Jest is our unit test harness. This file will contain its configuration settings. To create it, we run:
$ jest --init
The following questions will help Jest to create a suitable configuration for your project
? Would you like to use Jest when running "test" script in "package.json"? › (Y/n) Y
? Would you like to use Typescript for the configuration file? › (y/N) N
? Choose the test environment that will be used for testing › - Use arrow-keys. Return to submit.
❯ node
jsdom (browser-like)
? Do you want Jest to add coverage reports? (y/N) N
? Which provider should be used to instrument code for coverage? › - Use arrow-keys. Return to submit.
❯ v8
babel
? Automatically clear mock calls and instances between every test? › (y/N) Y
Modified <MYPATH>/MyRESTProject/package.json
Configuration file created at <MYPATH>/MyRESTProject/jest.config.js
We'll configure it further later to add DynamoDB support. For now, let's move on.
II. Our Directory Structure
Like those of you coming from a Spring Maven world, I generally prefer a project hierarchy consisting of:
|____src
| |____main
| | |____resources
| | | | . . .
| | |____java
| | | | . . .
| |____test
| | | . . .
|____target
| | . . .
Although we will try to keep the source/resource directory distinction, we will be leaving behind the distinct test tree. The reason behind this is that with a very concise microservice, we will be placing the unit test for each file in the same directory as the source. Later on, we will introduce a separate test
tree that will contain only our integration tests.
Our new directory structure will be akin to:
.
|____resources
| |____aws
| | |____dynamodb
| | | |____ResourceTable.yml
| | |____RESTFunctions.yml
| | |____iam
| | | |____LambdaRole.yml
|____src
| |____utils
| | |____Response.ts
| | |____rest
| | | |____responses
| | | | |____ . . . .
| | | |____exceptions
| | | | |____ . . . .
| |____schema
| | |____ . . . .
| |____model
| | |____Team.unit.test.ts
| | |____Team.ts
| |____aws
| | |____AWSRestController.ts
| | |____AWSTeamController.ts
| | |____AWSTeamController.unit.test.ts
| |____service
| | |____TeamService.unit.test.ts
| | |____TeamService.ts
A. src
Folder
We will store our various typescript files in a src
folder. I'm still somewhat wed to the model/view/controller structure, which influences my tree selection. From our base project folder:
$ mkdir -p src/aws/
$ mkdir -p src/model/
$ mkdir -p src/schema/
$ mkdir -p src/service/
$ mkdir -p src/utils/rest/responses
$ mkdir -p src/utils/rest/exceptions
We'll discuss our various source files and their contents in the next post. For now, let's finish our initial resources files.
B. resources
Folder
We will be storing our various serverless.yml
configuration files in a resources
folder. Under this folder, we'll be creating subfolders for our various AWS components. From our base project folder:
$ mkdir -p resources/aws/dynamodb
$ mkdir -p resources/aws/iam
1. ResourceTable.yml
You'll recall that in our serverless.yml
file, we declared our DynamoDB resource as being defined in the file resources/aws/dynamodb/ResourceTable.yml
. Let's go ahead and create this file and set its contents as:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:custom.dynamodb.resourceTable}
ProvisionedThroughput:
ReadCapacityUnits: 1
WriteCapacityUnits: 1
AttributeDefinitions:
- AttributeName: id
AttributeType: S
- AttributeName: name
AttributeType: S
- AttributeName: sortField
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
- AttributeName: sortField
KeyType: RANGE
LocalSecondaryIndexes:
- IndexName: ${self:custom.dynamodb.resourceTableIdx}
KeySchema:
- AttributeName: id
KeyType: HASH
- AttributeName: name
KeyType: RANGE
Projection:
ProjectionType: KEYS_ONLY
We'll go into detail on this later.
2. LambdaRole.yml
Next, we create our resources/aws/iam/LambdaRole.yml
file:
# This is the permissions that our lambda functions need
role:
# grant access to dynamoDB table and associated table index
statements:
- Effect: "Allow"
Action:
- "dynamodb:Query"
- "dynamodb:PutItem"
- "dynamodb:Scan"
- "dynamodb:DeleteItem"
- "dynamodb:UpdateItem"
Resource:
- !GetAtt ResourceTable.Arn
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${self:custom.dynamodb.resourceTable}/index/*
3. RestFunctions.yml
Followed by our resources/aws/RestFunctions.yml
# For example purposes, finding all entities is an unrestricted operation open to the entire world
findAll:
handler: ./src/aws/AWSTeamController.findAll
events:
- http:
path: /${self:custom.resourceName}
method: get
cors: true
# solely for creating a pretty label; sets OperationName in ApiGateway, useful for e.g. Swagger API documentation
operationId: getTeams
# For example purposes, looking up the details on a single entity requires obtaining authorization from IAM
findOne:
handler: ./src/aws/AWSTeamController.findOne
events:
- http:
path: /${self:custom.resourceName}/{id}
method: get
cors: true
# authorization provided by IAm, rather than a custom authorizer or the COGNITO user pool
# even though there is a Cognito UserPool at the bottom of this all
# this AWS_IAM approach allows the restrictions to be based on a IAm Policy
# and we previously handed out policies through our Cognito Identity Pool based on a user's
# highest priority UserPool group; This also avoids a lambda execution in order to run a custom authorizer
# authorizer:
# type: AWS_IAM
# Generally, deleting items should always require authorization
deleteOne:
handler: ./src/aws/AWSTeamController.deleteOne
events:
- http:
path: /${self:custom.resourceName}/{id}
method: delete
cors: true
# Again we use a AWS_IAM authorizer and not a Cognito User Pool or custom authorizer
#authorizer:
# type: AWS_IAM
# Generally, updating items should always require authorization
update:
handler: ./src/aws/AWSTeamController.update
events:
- http:
path: /${self:custom.resourceName}/{id}
method: put
cors: true
#authorizer:
# type: AWS_IAM
# Listing a schema here will have AWS validate the request prior to calling our
# lambda, saving us execution costs for invalid requests; note that the schema for this
# extremely simplistic model is the same as for creates, so we re-use the create request schema
#request:
# schemas:
# application/json: resource-create-model
# Generally, creating items should always require authorization
create:
handler: ./src/aws/AWSTeamController.create
events:
- http:
path: /${self:custom.resourceName}
method: post
cors: true
#authorizer:
# type: AWS_IAM
# Listing a schema here will have AWS validate the request prior to calling our
# lambda, saving us execution costs for invalid requests
#request:
# schemas:
# application/json: resource-create-model
Note that we currently have our authorizers disabled, as well as our schema validators. We'll cover those plus the other various parameters listed above later. For now, its time to wrap up this post before it gets any longer!
Top comments (0)