@nacelle/lambda-tools

Re-usable utility functions useful when working with Serverless lambdas

Usage no npm install needed!

<script type="module">
  import nacelleLambdaTools from 'https://cdn.skypack.dev/@nacelle/lambda-tools';
</script>

README

Nacelle Lambda Tools

A collection of useful functions for building Serverless lambdas.

What's Included?

  • AWS DynamoDB Tools
    • Wraps Dynamo methods and builds query expressions (more info below)
  • AWS S3 Tools
    • Utility functions for saving & loading JSON files in S3 buckets
  • AWS CloudFront Tools
    • Utility functions for invalidating cache for a distribution.
  • AWS SES Tools
    • Utility functions for sending emails via SES
  • Lambda Logger
  • Nacelle Models & Mocks
  • Utility Functions for filtering, handling URLs, etc

Installation

npm i @nacelle/lambda-tools -S

Usage

DynamoTools

import { DynamoDB } from 'aws-sdk'
import { dynamoTools } from '@nacelle/lambda-tools'

export const dynamoDb = dynamoTools.init(
  new DynamoDB.DocumentClient({ convertEmptyValues: true })
)

S3 Tools

import { S3 } from 'aws-sdk'
import { s3Tools } from '@nacelle/lambda-tools'

export const s3 = s3Tools.init(new S3({ apiVersion: '2006-03-01' }))

CloudFront Tools

params:

  • distributionId(required): String. Id of the CloudFront distribution you want to invalidate the cache of.

  • quantity(required): Int. The number of invalidation paths specified for the objects that you want to invalidate.

  • spaceId(optional): String[]. Array of paths to invalidate.

import { CloudFront } from 'aws-sdk'
import { cloudfrontTools } from '@nacelle/lambda-tools'

export const cloudfront = cloudfrontTools.init(new CloudFront({
  apiVersion: '2020-05-31'
  })
)

const distributionId = 'E1FLDI5PK7XUGF'
const quantity = 1
const paths = ['/*']
const response: CloudFront.CreateInvalidationResult = await cloudfront.invalidateCache(
    distributionId,
    quantity,
    paths
  )

SES Tools

import { SES } from 'aws-sdk'
import { sesTools } from '@nacelle/lambda-tools'

export const ses = sesTools.init(new SES(), 'Your Team <yourteam@example.com>')

Lambda Logger

Debug logging is enabled, and development mode is enabled in any environment that's not production or test.

import { logger } from '@nacelle/lambda-tools'

logger.info('Log something')

Querying DynamoDB

Querying DynamoDB requires creation of an object outlining different expressions depending on the type of query and fields being requested. This can be tedious and error prone, so createQueryExpression() is provided for convenience.

Creating Query Conditions

createQueryExpression requires 2 arguments -- the name of the table being queried and the query conditions. Query conditions is an object that outlines the type of query you want to make, and the values and columns to filter on. It's (very) loosely based on a similar concept in Sequelize.

const queryCondition = {
  where: {
    id: 'my-id'
  },
  fields?: ['columnOne', 'columnTwo'],
  update?: {
    stringColumn: 'value',
    objectColumn: { x: 5, y: 'seven', index?: 2 },
    arrayColumn: [{ z: 8 }],
    returnValues?: 'ALL_NEW'
  },
  remove?: {
    stringColumn: null,
    objectColumn: { index: 2 },
    returnValues?: 'ALL_NEW'
  },
  type: Update | Query | Scan
}

Above is an example of the query condition object with all possible values and variations. Here are definitions for each of the fields:

  • queryCondition.where (required): This field is used to determine how to filter the query. Multiple properties and values here are converted to AND statements. Example: where: { x: 5, y: 7 } is converted to where x = 5 AND y = 7. There is currently not support for OR clauses, but it can be added in a future update. Note that for the Query type, id is required.

  • queryCondition.fields (optional): This field is used to filter the fields included in the query result. If this property is included, only fields included will be contained in the query result

  • queryCondition.update (optional): This field is used to specify column names and their updated value(s). Note that several types of data value are supported, but each accomplishes a specific task.

If the value is an object and does include an index property, it will be used to update a list in place. The index should correspond with the item's position in that list. If it does not have an index property, it will overwrite the column value in the database with whatever the value is in that object. Null values are acceptable here as long as they are not part of a primary index key.

If the value is an array, the value within the list will be appended to the list that is in the database for that column.

  • queryCondition.remove (optional): Similar to update, but any value for the column will be removed. If the value is an object with an index property, then it will remove the item at that index position from that column in the db.

  • queryCondition.operation (required): The type of query being made -- Update, Query or Scan

queryCondition.returnValues (optional): Allows you to specify what type of response you'd like from Dynamo. Options can be found here

There are several examples in src/db/dynamo/helpers.test.ts for reference.

The Dynamo Query Expression Object

Before moving on, it may be beneficial to see the the output of createQueryExpression to better understand how a queryCondition object relates to the output.

const queryExpression = {
  TableName: 'my-table',
  FilterExpression?: '#email = :email',
  ProjectionExpression?: '#id, #email',
  Key?: { id: 'user-id' },
  KeyConditionExpression?: '#id = :id',
  ExpressionAttributeNames?: { '#email': 'email', '#id': 'id' },
  ExpressionAttributeValues?: { ':email': 'me@example.com', ':id': 'user-id' },
  UpdateExpression?: 'SET #email = :email',
  ReturnValues?: 'ALL_NEW'
}

Note that not all of these properties will be present every time and that it is highly dependent on the type of query and values provided in the query conditions.

  • TableName: The name of the Dyanamo Table being queried
  • FilterExpression: A string used to determine additional filter fields for dynamo. Think of these as AND filters
  • ProjectionExpression: The fields to be returned in the query response. Note that these can only be used in Query and Scan operations
  • Key: The primary key field (this is only used in the Update type)
  • KeyConditionExpression: The primary key field to filter on (this is only used in the Query type)
  • ExpressionAttributeNames: Alias values and their associated column names
  • ExpressionAttributeValues: Column values for the aliased names
  • UpdateExpression: Any column values that need to be updated
  • ReturnValues: The type of response

Examples

Query By Id, Email and Role

// input
const queryConditions = {
  tableName: 'Users',
  where: {
    id: { primary: true, value: userId },
    email: { value: userEmail },
    role: { value: userRole },
  },
  operation: DBOperations.Query,
}

// output
const queryExpression = {
  TableName: 'Users',
  FilterExpression: '#email = :email and #role = :role',
  KeyConditionExpression: '#id = :id',
  ExpressionAttributeNames: {
    '#id': 'id',
    '#email': 'email',
    '#role': 'role',
  },
  ExpressionAttributeValues: {
    ':id': userId,
    ':email': userEmail,
    ':role': userRole,
  },
}

Find By UserId

// input
const queryConditions = {
  tableName: 'Users',
  where: {
    id: { primary: true, value: userId },
  },
  fields: ['id', 'email'],
  operation: DBOperations.Query,
}

// output
const queryExpression = {
  TableName: 'Users',
  KeyConditionExpression: '#id = :id',
  ProjectionExpression: '#id, #email',
  ExpressionAttributeNames: { '#email': 'email', '#id': 'id' },
  ExpressionAttributeValues: { ':id': userId },
}

Handle Paginated Scans

// input
const conditions = {
  tableName: 'Users',
  where: {
    id: { value: userId },
  },
  operation: DBOperations.Scan,
  lastEvaluatedKey: { id: 'abc123' },
}

//output
const queryExpression = {
  TableName: 'Users',
  FilterExpression: '#id = :id',
  ExpressionAttributeNames: {
    '#id': 'id',
  },
  ExpressionAttributeValues: {
    ':id': userId,
  },
  ExclusiveStartKey: {
    id: 'abc123',
  },
}

// example code
async function scanDb(conditions, results = []) {
  try {
    const result = await dynamoDb.scan(conditions)
    const combinedResults = results.concat(result.Items)

    if (result.LastEvaluatedKey) {
      return await scanDb(
        {
          ...conditions,
          lastEvaluatedKey: result.LastEvaluatedKey,
        },
        combinedResults
      )
    }

    return combinedResults
  } catch (err) {
    console.error(err)
  }
}

Update User Info

// input
const queryConditions = {
  tablename: 'Users',
  where: {
    id: { primary: true, value: userId },
  },
  update: {
    user: { value: { name: 'Bruce', email: userEmail } },
  },
  returnValues: 'ALL_NEW',
  operation: DBOperations.Update,
}

// output
const queryExpression = {
  TableName: 'Users',
  Key: { id: userId },
  ExpressionAttributeNames: { '#user': 'user' },
  ExpressionAttributeValues: {
    ':user': { name: 'Bruce', email: userEmail },
  },
  UpdateExpression: 'SET #user = :user',
  ReturnValues: 'ALL_NEW',
}

Update Item In a List

// input
const queryConditions = {
  tableName: 'Users',
  where: {
    id: { primary: true, value: userId },
  },
  update: {
    spaces: { value: { id: spaceId, role: userRole, index: 2 } },
  },
  operation: DBOperations.Update,
}

// output
const queryExpression = {
  TableName: 'Users',
  Key: { id: userId },
  ExpressionAttributeNames: { '#spaces': 'spaces' },
  ExpressionAttributeValues: {
    ':spaces': { id: spaceId, role: userRole },
  },
  UpdateExpression: 'SET #spaces[2] = :spaces',
}

Append Item to List

// input
const queryConditions = {
  tableName: 'Users',
  where: {
    id: { primary: true, value: userId },
  },
  update: {
    spaces: { append: true, value: [{ id: spaceId, role: userRole }] },
  },
  operation: DBOperations.Update,
}

// output
const queryExpression = {
  TableName: 'Users',
  Key: { id: userId },
  ExpressionAttributeNames: { '#spaces': 'spaces' },
  ExpressionAttributeValues: {
    ':emptyList': [],
    ':spaces': [{ id: spaceId, role: userRole }],
  },
  UpdateExpression:
    'SET #spaces = list_append(if_not_exists(#spaces, :emptyList), :spaces)',
}

Updating Multiple Values

// input
const queryConditions = {
  tableName: 'Users',
  where: {
    id: { primary: true, value: userId },
  },
  update: {
    email: { value: userEmail },
    domain: { value: 'www.me.com' },
  },
  returnValues: 'ALL_NEW',
  operation: DBOperations.Update,
}

// output
const queryExpression = {
  TableName: 'Users',
  Key: { id: userId },
  ExpressionAttributeNames: { '#email': 'email', '#domain': 'domain' },
  ExpressionAttributeValues: {
    ':email': userEmail,
    ':domain': 'www.me.com',
  },
  UpdateExpression: 'SET #email = :email, #domain = :domain',
  ReturnValues: 'ALL_NEW',
}

Delete a Value

// input
const conditions = {
  tableName: 'Users',
  where: {
    id: { primary: true, value: userId },
  },
  remove: {
    email: null,
  },
  operation: DBOperations.Update,
}

// output
const queryExpression = {
  TableName: 'Users',
  Key: { id: userId },
  ExpressionAttributeNames: { '#email': 'email' },
  UpdateExpression: 'REMOVE #email',
}

Delete Item In a List

// input
const queryConditions = {
  where: {
    id: { primary: true, value: userId },
  },
  remove: {
    spaces: { index: 3 },
  },
  operation: DBOperations.Update,
}

// output
const queryExpression = {
  TableName: 'Users',
  Key: { id: userId },
  ExpressionAttributeNames: { '#spaces': 'spaces' },
  UpdateExpression: 'REMOVE #spaces[3]',
}

Delete and Update in One Query

// input
const queryConditions = {
  where: {
    id: { primary: true, value: userId },
  },
  update: {
    email: { value: userEmail },
    domain: { value: 'www.me.com' },
  },
  remove: {
    spaces: { index: 3 },
  },
  operation: DBOperations.Update,
}

// output
const queryExpression = {
  TableName: 'Users',
  Key: { id: userId },
  ExpressionAttributeNames: {
    '#spaces': 'spaces',
    '#email': 'email',
    '#domain': 'domain',
  },
  ExpressionAttributeValues: {
    ':email': userEmail,
    ':domain': 'www.me.com',
  },
  UpdateExpression: 'SET #email = :email, #domain = :domain REMOVE #spaces[3]',
}

isFeatureAllowed

Utility function used to determine whether or not a feature is allowed.

params:

  • featureName(required): Name of the feature flag as it is on the space object

  • space(optional): Space. Must be used for non-lambda projects for db permissions reasons. Will check this before fetching the space data from dynamo if provided.

  • spaceId(optional): If space param is not provided, the space data will be fetched from dynamoDB using this id.

  • envOverride(optional): If the environment var is set to 'true', this will override the space's featureFlags.

Note: One of the space, spaceId, or envOverride params must be provided, or the result will automatically be false.

import { isFeatureAllowed } from '@nacelle/lambda-tools'

const isExampleFeatureAllowed = await isFeatureAllowed({
  featureName: 'exampleFeature',
  space: exampleSpace,
  spaceId: exampleSpaceId,
  envOverride: process.env.FEATURE_EXAMPLE === 'true',
})

if (isExampleFeatureAllowed) {
  // do something for spaces with the example featureFlag
}