Building a Serverless Application with AWS Cloud Development Kit (CDK): API Gateway, Lambda, Layers and DynamoDB

Day 007 – 100DaysAWSIaCDevopsChallenge

Today in my series of the 100 Days of Code challenge, I’m going to create another infrastructure using the Cloud Development Kit (CDK) containing AWS services such as API Gateway, Lambda Functions, Layers, and DynamoDB. The project involves creating an infrastructure that exposes APIs using API Gateway. These APIs will be deployed in a Lambda function, and we will use DynamoDB to persist data. To achieve this, we will follow the steps below:

  • Set up the CDK project: Initialize the CDK project and configure it for your preferred programming language.
  • Define the API Gateway: Create an API Gateway to handle the HTTP requests and route them to the Lambda function.
  • Create Lambda function: Write and deploy Lambda function to handle the business logic for the APIs.
  • Add Lambda Layers: Define and add Lambda Layers to share common code among multiple Lambda function.
  • Configure DynamoDB: Set up a DynamoDB table to store and retrieve the data needed by the APIs.
  • Integrate services: Connect the API Gateway to the Lambda function and configure the Lambda function to interact with DynamoDB.
  • Deploy the infrastructure: Deploy the CDK stack to AWS and test the end-to-end functionality of the APIs.
Prerequises
  • AWS Cloud Developement Kit (CDK)
  • AWS SDK (Javascript version)
  • API Gateway, Lambda, IAM and DynamoDB
  • Typescript
  • esbuild

Infrastructure Diagram

Diagram

Set up the CDK Project

First we need to install at least Nodejs 18.x↗ or newer and configure an aws profile↗. Additionally, we need to have Docker installed on our host machine, as the NodejsFunction CDK construct requires it to build functions written in TypeScript into JavaScript using esbuild.

npm install -g aws-cdk
mdkir day_007 && cd day_007
cdk init app --language typescript
cdk bootstrap --profile cdk-user
Enter fullscreen mode Exit fullscreen mode

for more details about CDK set up, go to the Docs↗

Configure DynamoDB Table

DynamoDB is a managed NoSQL database service by Amazon Web Services (AWS). We will use it to persist our data because it is very simple to handle and easy to use compared to RDS or Aurora. The table we need will have a composite key (a primary key and a sort key). Since DynamoDB is a NoSQL database, it is not necessary to specify other attributes initially beyond the key attributes; additional table attributes can be added over time as needed.

As mentioned above, our table will have one composite keys split in two key : hash key known as the partition key (primary key) and range key known as sort key(second part of primary key).
| Hash Key | Range Key | Others attributes
|———–|————–|—————————–|
|ID | TodoName | CreatedAt, UpdatedAt, Owner

CDK

Let now create the table using CDK

import { aws_dynamodb as dynamodb } from "aws-cdk-lib"
/*
const props = { ... tableName: "TodoApp"
}
*/ const table = new dynamodb.CfnTable(this, 'DynamoDBTableResource', { tableName: props.tableName, keySchema: [{ keyType: 'HASH', attributeName: 'ID' }, { keyType: 'RANGE', attributeName: 'TodoName' }], attributeDefinitions: [{ attributeName: 'ID', attributeType: dynamodb.AttributeType.STRING }, { attributeName: 'TodoName', attributeType: dynamodb.AttributeType.STRING }], billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, tableClass: dynamodb.TableClass.STANDARD, tags: [{ key: 'Name', value: `DynamoDB-Table-${props.tableName}` }]
})
// day_007/lib/cdk-stack.ts
Enter fullscreen mode Exit fullscreen mode

As you can see, we don’t need to specify the _CreatedAt, _UpdatedAt, and Owner attributes in the attributeDefinitions property.

Create Lambda function

Inside the Lambda function, the code handles creating, updating, and listing the TodoList items in DynamoDB.

The Function Code

Below is the structure of Code:

 apps ├── domain │   ├── abstract.domain.ts │   ├── task-status.enum.ts │   ├── task.domain.ts │   └── todo-list.domain.ts ├── index.ts ├── layer │   └── nodejs │      ├── package-lock.json │      └── package.json ├── lib │   └── utils.ts ├── package-lock.json ├── package.json ├── src │   └── infra │   ├── dto │   │   ├── lambda.response.ts │   │   └── page.request.ts │   └── storage │   └── dynamodb │   ├── abstract.repository.ts │   └── todo-list.repository.ts └── tsconfig.json

The files we need here at the moment are index.ts, todo-list.repository.ts, and all files inside the domain directory. I will provide the link to the complete GitHub code at the end of this tutorial.

apps/package.json

{ "main": "index.ts", "type": "commonjs", "dependencies": { "aws-lambda": "^1.0.7", "@aws-sdk/client-dynamodb": "^3.620.0", "@aws-sdk/lib-dynamodb": "^3.620.0", "@aws-lambda/http": "^1.0.1", "uuid": "^10.0.0", "moment": "^2.30.1" }, "devDependencies": { "@types/node": "^22.0.0", "@types/uuid": "^10.0.0", "@types/aws-lambda": "^8.10.142", "@types/moment": "^2.13.0" }
}
Enter fullscreen mode Exit fullscreen mode

apps/domain/*.ts

// apps/domain/abstract.domain.ts
export interface IDomain { id?: string; createdAt?: string; updatedAt?: string;
}
// apps/domain/todo-list.domain.ts
import { IDomain } from './abstract.domain'
export interface TodoList extends IDomain { name: string; owner?: { fullName: string, email: string }, tasks?: any[]
}
Enter fullscreen mode Exit fullscreen mode

apps/src/infra/storage/dynamodb/todo-list.repository.ts

import { AbstractRepository } from './abstract.repository'
import { DeleteCommand, DynamoDBDocumentClient, GetCommand, GetCommandOutput, PutCommand, PutCommandOutput, QueryCommandOutput, ScanCommand, UpdateCommand
} from '@aws-sdk/lib-dynamodb'
import { v4 } from 'uuid'
import moment from 'moment'
import { TodoList } from '../../../../domain/todo-list.domain' export class TodoListRepository implements AbstractRepository<TodoList> { constructor(private client: DynamoDBDocumentClient, private tableName: string) { } async create({ name, owner }: TodoList): Promise<TodoList> { const item = { ID: v4(), CreatedAt: Math.floor((new Date().getTime()) / 1000), UpdatedAt: Math.floor((new Date().getTime()) / 1000), TodoName: name, Owner: { Fullname: owner?.fullName, Email: owner?.email } } let response: PutCommandOutput try { response = await this.client.send(new PutCommand({ TableName: this.tableName, Item: item, ExpressionAttributeNames: { '#nameAttr': 'TodoName' }, ExpressionAttributeValues: { ':nameValue': { 'S': name } }, ConditionExpression: '#nameAttr <> :nameValue' })) } catch (e) { console.error(e) throw new Error('DataStorageException: ' + e.message) } console.log('PutCommandResponse', response) if (response.$metadata.httpStatusCode !== 200) { throw new Error('DataStorage: Something wrong, please try again') } return { id: item.ID, createdAt: this.convertTimestampToDate(item.CreatedAt), updatedAt: this.convertTimestampToDate(item.UpdatedAt), owner, name: item.TodoName } } async list(query: any): Promise<TodoList[]> { let response: QueryCommandOutput try { response = await this.client.send(new ScanCommand({ TableName: this.tableName, Limit: 25, Select: 'ALL_ATTRIBUTES' })) } catch (e) { console.error(e) throw new Error('DataStorageException: ' + e.message) } if (response.$metadata.httpStatusCode === 200 && response.Count! > 0) { return [...response.Items || []].map((value: any) => { return { id: value.ID, name: value.TodoName, createdAt: this.convertTimestampToDate(value.CreatedAt), updatedAt: this.convertTimestampToDate(value.UpdatedAt), owner: { fullName: value.Owner.Fullname, email: value.Owner.Email }, tasks: value.Tasks! } }) } return [] } async update(id: { pk: string, sk?: string }, toUpdate: TodoList): Promise<TodoList> { let response: PutCommandOutput const updateDate = Math.floor((new Date().getTime()) / 1000) try { response = await this.client.send(new UpdateCommand({ TableName: this.tableName, Key: { ID: id.pk, TodoName: id.sk }, UpdateExpression: 'SET #name=:newOrOldName, #owner.#email = :newOrOldEmail, #owner.#fullname = :newOrOldFullname', ExpressionAttributeNames: { '#name': 'TodoName', '#owner': 'Owner', '#fullname': 'FullName', '#Email': 'Email' }, ExpressionAttributeValues: { ':newOrOldName': { 'S': toUpdate.name }, ':newOrOldEmail': { 'S': toUpdate.owner?.email }, ':newOrOldFullname': { 'S': toUpdate.owner?.fullName } } })) } catch (e) { console.error(e) throw new Error('DataStorageException: ' + e.message) } console.debug('PutCommandOutput4Update', response) if (response.$metadata.httpStatusCode !== 200) { throw new Error('DataStorageException: something wrong during update !!') } return { ...toUpdate, id: id.pk, updatedAt: this.convertTimestampToDate(updateDate) } } async get(id: { pk: string, sk?: string }): Promise<TodoList | null> { let response: GetCommandOutput try { response = await this.client.send(new GetCommand({ TableName: this.tableName, Key: { ID: id.pk, TodoName: id.sk } })) } catch (e) { console.error(e) throw new Error('DataStorageException: ' + e.message) } console.debug('GetCommandOutput', response) if (response.$metadata.httpStatusCode !== 200) { throw new Error('DataStorageException: something wrong during update !!') } const value: any = response.Item return !value ? null : { id: value.ID, name: value.TodoName, createdAt: this.convertTimestampToDate(value.CreatedAt), updatedAt: this.convertTimestampToDate(value.UpdatedAt), owner: { fullName: value.Owner.Fullname, email: value.Owner.Email }, tasks: value.Tasks! } } private convertTimestampToDate = (time: number): string => moment.unix(time).format('YYYY-MM-DD HH:mm:ss')
}
Enter fullscreen mode Exit fullscreen mode

apps/index.ts

import { APIGatewayProxyEvent } from 'aws-lambda'
import { isNull } from 'utils'
import { LambdaResponse } from './src/infra/dto/lambda.response'
import { TodoListRepository } from './src/infra/storage/dynamodb/todo-list.repository'
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'
import { DynamoDBClient } from '@aws-sdk/client-dynamodb'
import { AbstractRepository } from './src/infra/storage/dynamodb/abstract.repository'
import { TodoList } from './domain/todo-list.domain' const tableName = <string>process.env.TABLE_NAME
const region = process.env.REGION ?? 'us-east-1' const dynamodbClient = DynamoDBDocumentClient.from(new DynamoDBClient({ region: region
})) export const handler = async (event: APIGatewayProxyEvent, context: any) => { const method = event.httpMethod const path = event.path console.log('incoming request', { ...event }) if (isNull(method) && isNull(path)) { return { statusCode: 500, isBase64Encoded: false, body: JSON.stringify({ message: 'Something wrong !!' }) } } const repo: AbstractRepository<TodoList> = new TodoListRepository(dynamodbClient, tableName) const requestBody = (() => { if (event.isBase64Encoded) { return /* global atob */ atob(event.body!) } return typeof event.body === 'string' ? JSON.parse(event.body) : event.body })() let response: LambdaResponse if (method === 'POST' && path.endsWith('/create-todolist')) { // const existing = await repo.get?.() const result = await repo.create({ name: requestBody.name, owner: { fullName: 'Kevin L.', email: '[email protected]' } }) response = { isBase64Encoded: false, statusCode: 200, body: JSON.stringify(result, null, -2) } } else if (path === '/todo-app-api/todolists') { const todos = await repo.list('') response = { isBase64Encoded: false, statusCode: 200, body: JSON.stringify(todos, null, -2) } } else if (new RegExp('^/todo-app-api/todolists/[a-z0-9-]{36}/update-todolist$', 'gi').test(path)) { const old = await repo.get?.({ pk: event.pathParameters?.todoListId!, sk: requestBody.name }) if (!old) { response = { isBase64Encoded: false, statusCode: 404, body: JSON.stringify({ message: `The TodoList ${event.pathParameters?.todoListId!} doesn't exists` }, null, -2) } } else { const updated = await repo.update?.({ pk: old.id!, sk: old.name }, { name: requestBody.name ?? old.name, owner: { fullName: requestBody.owner?.fullName ?? old.owner?.fullName, email: requestBody.owner?.email ?? old.owner?.email } }) response = { isBase64Encoded: false, statusCode: 200, body: JSON.stringify(updated, null, -2) } } } else { return { isBase64Encoded: false, statusCode: 403, body: JSON.stringify({ message: `The API ${method} ${path} is no yet implemented in lambda side` }, null, -2) } } return { ...response, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } }
}
Enter fullscreen mode Exit fullscreen mode
Add Lambda Layer

All the dependencies (node_modules) are added in the Lambda Layer. The reason I chose to add dependencies inside the Layer is that the Layer:

  • Centralizes the dependencies
  • Reduces the function code size by simplifying the Lambda code
  • Allows for simplified update management

If we come back to the code structure, you can see there is file name apps/lib/utils.ts

import moment from 'moment'
export const DATE_FORMAT = 'YYYY-MM-DD'
export const TIME_FORMAT = 'HH:mm:ss'
export const DATETIME_FORMAT = `${DATE_FORMAT} ${TIME_FORMAT}`
export const isNull = (value: any) => value === null
export const nowDate = () => moment().format(DATETIME_FORMAT)
Enter fullscreen mode Exit fullscreen mode

We need to build this utility as the library for our Lambda.

cd apps/layer/nodejs npm install # install deps based apps/layer/nodejs/package.json 
# and then build the utils library using esbuild
esbuild --bundle --platform=node --sourcemap ../../lib/utils.ts --outdir=./node_modules "--external:moment"
Enter fullscreen mode Exit fullscreen mode

Let now configure the Lambda Layer cdk resource

import { aws_lambda as lambda, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib'
import { Construct } from 'constructs' interface LayerStackProps extends StackProps { layerName: string
}
export class LayerStack extends Stack { public readonly layerArn: string; constructor(scope: Construct, id: string, props?: LayerStackProps) { super(scope, id, props) const layer = new lambda.LayerVersion(this, 'LayerResource', { code: lambda.Code.fromAsset('./apps/layer'), compatibleArchitectures: [lambda.Architecture.X86_64], compatibleRuntimes: [lambda.Runtime.NODEJS_20_X, lambda.Runtime.NODEJS_18_X], layerVersionName: props?.layerName, removalPolicy: RemovalPolicy.DESTROY }); this.layerArn = layer.layerVersionArn; this.exportValue(layer.layerVersionArn, { name: 'layerArn', description: 'The Lambda Layer Version ARN Value which will be used by others stack as need' }); }
} // lib/layer-stack.ts
Enter fullscreen mode Exit fullscreen mode
Configure Lambda function
import * as cdk from 'aws-cdk-lib'
import {aws_lambda as lambda, Duration} from 'aws-cdk-lib'
import { NodejsFunction, SourceMapMode } from 'aws-cdk-lib/aws-lambda-nodejs'
import { dependencies } from '../apps/package.json'
....
private createLambdaFunction(props: CustomStackProps, dynamodbTableArn: string) { const lambdaRole = this.createLambdaRole(); const layerArn = cdk.Fn.importValue('layerArn') return new NodejsFunction(this, 'LambdaResource', { entry: './apps/index.ts', handler: 'index.handler', timeout: Duration.seconds(10), functionName: 'Todo-App-NodejsFunction', environment: { TABLE_NAME: props.tableName }, runtime: lambda.Runtime.NODEJS_20_X, memorySize: 128, role: lambdaRole, bundling: { externalModules: [ ...Object.keys(dependencies), 'utils' ], sourceMap: true, sourceMapMode: SourceMapMode.BOTH }, layers: [ lambda.LayerVersion.fromLayerVersionArn(this, 'UtilsAndNodeModulesResource', layerArn) ] }) }
Enter fullscreen mode Exit fullscreen mode

Important to know:

  • entry: points to the file that contains the handler function, here ./apps/index.ts. That file will be converted into a bundle file named index.js, located after the build stage at <rootProject>/cdk.out/asset.<hash>/index.ts
  • bundling: maps the configuration of esbuild. In our case, we need esbuild to generate the source map file and consider utils and dependencies (list) as external mobules. Remember that these external mobules is backed by our Layer, so we don’t need to bundled them in the function code.
  • layers: adds the Layer previously created.

API Gateway

MethodPathPayload
GET/todo-app-api/todolists
POST/todo-app-api/todolists/create-todolist{name: 'string'}
PUT/todo-app-api/todolists/{todoListId}/update-todolist{name: 'string', owner: {fullName: "string", email: "string"}}

We will create the above endpoints.
Lest start by creating the Rest Api Resource

 const restApi = new api.CfnRestApi(this, 'ApiGatewayResource', { name: 'ApiGateway', apiKeySourceType: api.ApiKeySourceType.HEADER, disableExecuteApiEndpoint: false, endpointConfiguration: { types: [api.EndpointType.REGIONAL], tags: [{ key: 'Name', value: 'Api Gateway' }] }
})
// lib/cdk-stack.ts
Enter fullscreen mode Exit fullscreen mode

We need to allow API Gateway to invoke Lambda function.

new lambda.CfnPermission(this, 'LambdaPermissionResource', { sourceAccount: props.env?.account, action: 'lambda:InvokeFunction', principal: 'apigateway.amazonaws.com', sourceArn: `arn:${Aws.PARTITION}:execute-api:${props.env?.region}:${props.env?.account}:${restApi.attrRestApiId}/*/*/*`, functionName: lambdaFunction.functionArn
})
// lib/cdk-stack.ts
Enter fullscreen mode Exit fullscreen mode

Alternativelly, it is also possible to attach the above permission directly to the Lambda function previously created. To do so, we just need to call addPermission(..) method provided by the NodejsFunction class.

lambdaFunction.addPermission("LambdaPermission", { action: 'lambda:InvokeFunction', principal: new iam.ServicePrincipal("apigateway.amazonaws.com"), sourceAccount: props.env?.account, sourceArn: `arn:${Aws.PARTITION}:execute-api:${props.env?.region}:${props.env?.account}:${restApi.attrRestApiId}/*/*/*`
})
// lib/cdk-stack.ts
Enter fullscreen mode Exit fullscreen mode
List TodoList method
const rootResource = new api.CfnResource(this, 'RestApiRootResource', { restApiId: restApi.attrRestApiId, parentId: restApi.attrRootResourceId, pathPart: 'todo-app-api'
}) const getTodoListsResource = new api.CfnResource(this, 'RestApiGetTodoListsResource', { restApiId: restApi.attrRestApiId, parentId: rootResource.attrResourceId, pathPart: 'todolists'
}) const getTodoListsMethod = new ApiMethod(this, 'ApiTodoListsMethodResource', { methodType: MediaType.GET, authType: api.AuthorizationType.NONE, restApiId: restApi.attrRestApiId, resourceId: getTodoListsResource.attrResourceId, operationName: 'GetAllTodoLists', integration: { connection: api.ConnectionType.INTERNET, type: api.IntegrationType.AWS_PROXY, httpMethod: MediaType.POST, uri: `arn:aws:apigateway:${props.env?.region}:lambda:path/2015-03-31/functions/${lambdaFunction.functionArn}/invocations` }
}).resource
// lib/cdk-stack.ts
Enter fullscreen mode Exit fullscreen mode
Create TodoList method
const createTodoListResource = new api.CfnResource(this, 'RestApiCreateTodoListResource', { restApiId: restApi.attrRestApiId, parentId: getTodoListsResource.attrResourceId, pathPart: 'create-todolist'
})
const createTodoListMethod = new ApiMethod(this, 'ApiCreateTodoListResource', { methodType: MediaType.POST, authType: api.AuthorizationType.NONE, restApiId: restApi.attrRestApiId, resourceId: createTodoListResource.attrResourceId, operationName: 'CreateTodoList', integration: { connection: api.ConnectionType.INTERNET, type: api.IntegrationType.AWS_PROXY, httpMethod: MediaType.POST, uri: `arn:aws:apigateway:${props.env?.region}:lambda:path/2015-03-31/functions/${lambdaFunction.functionArn}/invocations` }
}).resource
// lib/cdk-stack.ts
Enter fullscreen mode Exit fullscreen mode

You can notice that the parent resource of createTodoListMethod is getTodoListResource, because the path of createTodoListMethod starts with the path of getTodoListResource.

Update TodoList method
const pathVariableSegmentResource = new api.CfnResource(this, 'RestApiUpdateTodoListPathVariableResource', { restApiId: restApi.attrRestApiId, parentId: getTodoListsResource.attrResourceId, pathPart: '{todoListId}'
}) const updateTodoListLastSegmentResource = new api.CfnResource( this, 'RestApiUpdateTodoListResource', { restApiId: restApi.attrRestApiId, parentId: pathVariableSegmentResource.attrResourceId, pathPart: 'update-todolist'
})
const updateTodoListMethod = new ApiMethod(this, 'ApiUpdateTodoListResource', { methodType: MediaType.PUT, authType: api.AuthorizationType.NONE, restApiId: restApi.attrRestApiId, resourceId: updateTodoListResource.attrResourceId, operationName: 'UpdateTodoList', integration: { connection: api.ConnectionType.INTERNET, type: api.IntegrationType.AWS_PROXY, httpMethod: MediaType.POST, uri: `arn:aws:apigateway:${props.env?.region}:lambda:path/2015-03-31/functions/${lambdaFunction.functionArn}/invocations` }, requestParams: { paths: ['todoListId'] }
}).resource
// lib/cdk-stack.ts
Enter fullscreen mode Exit fullscreen mode

And the ApiMethod custom resource:

import { Construct } from 'constructs'
import { aws_apigateway as api, StackProps } from 'aws-cdk-lib' export enum MediaType { GET = 'GET', POST = 'POST', PUT = 'PUT', DELETE = 'DELETE', OPTIONS = 'OPTIONS'
} interface CustomMethodProps extends StackProps { restApiId: string; resourceId: string; methodType: MediaType; integration: { uri: string; connection: api.ConnectionType; httpMethod: MediaType type: api.IntegrationType }; authType?: api.AuthorizationType, operationName?: string requestParams?: { queries?: string[] paths?: string[] headers?: string[] }
} export class ApiMethod extends Construct { public readonly resource: api.CfnMethod constructor(scope: Construct, id: string, props: CustomMethodProps) { super(scope, id) const params = {} as Record<string, boolean> const integrationReqParams = {} as Record<string, string> const integrationResParams = {} as Record<string, string> if (props.requestParams?.paths) { props.requestParams.paths.forEach(value => { const p = `method.request.path.${value}` params[p] = true integrationReqParams[`integration.request.path.${value}`] = p }) } if (props.requestParams?.queries) { props.requestParams.queries.forEach(value => { params[`method.request.queryString.${value}`] = true integrationReqParams[`integration.request.queryString.${value}`] = `method.request.queryString.${value}` }) } if (props.requestParams?.headers) { props.requestParams.headers.forEach(value => { params[`method.request.header.${value}`] = true integrationReqParams[`integration.request.header.${value}`] = `method.request.header.${value}` }) } this.resource = new api.CfnMethod(this, 'ApiTodoListsMethodResource', { apiKeyRequired: false, restApiId: props.restApiId, resourceId: props.resourceId, httpMethod: props.methodType, operationName: props.operationName || new Crypto().randomUUID(), authorizationType: props.authType || api.AuthorizationType.NONE, integration: { connectionType: props.integration.connection, integrationHttpMethod: props.integration.httpMethod, type: props.integration.type, uri: props.integration.uri, integrationResponses: [{ statusCode: '200', responseParameters: { 'method.response.header.Access-Control-Allow-Headers': '\'Content-Type,X-Amz-Date,Authorization,X-Api-key,X-Amz-Security-Token\'', 'method.response.header.Access-Control-Allow-Methods': '\'GET,OPTIONS,POST,PUT\'', 'method.response.header.Access-Control-Allow-Origin': '\'*\'' } }, { statusCode: '500', responseParameters: integrationResParams }], requestParameters: integrationReqParams }, methodResponses: [{ statusCode: '200', responseParameters: { 'method.response.header.Access-Control-Allow-Headers': true, 'method.response.header.Access-Control-Allow-Methods': true, 'method.response.header.Access-Control-Allow-Origin': true } }], requestParameters: params }) }
}
Enter fullscreen mode Exit fullscreen mode

🥳✨woohaah!!!
We have reached the end of the article.
Thank you so much 🙂


Your can find the full source code on GitHub Repo


Discover more from Coursity

Subscribe to get the latest posts sent to your email.

Leave a Comment

Your email address will not be published. Required fields are marked *

Discover more from Coursity

Subscribe now to keep reading and get access to the full archive.

Continue reading

Scroll to Top