Skip to content

Commit

Permalink
Upgrade to Ion (#761)
Browse files Browse the repository at this point in the history
* Ion edit

* Edits

* Edits
  • Loading branch information
jayair committed Jul 25, 2024
1 parent cb6166b commit f23dc17
Show file tree
Hide file tree
Showing 73 changed files with 986 additions and 1,223 deletions.
9 changes: 0 additions & 9 deletions _chapters/what-is-aws-cdk.md → _archives/what-is-aws-cdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,3 @@ A CDK app is made up of multiple stacks. Or more specifically, multiple instance
When you run `cdk synth`, it converts these stacks into CloudFormation templates. And when you run `cdk deploy`, it'll submit these to CloudFormation. CloudFormation creates these stacks and all the resources that are defined in them.

It's fairly straightforward. The key bit here is that even though we are using CloudFormation internally, we are not working directly with the YAML or JSON templates anymore.

### CDK and SST

[SST]({{ site.sst_github_repo }}){:target="_blank"} comes with a list of [higher-level CDK constructs]({{ site.docs_url }}/constructs){:target="_blank"} designed to make it easy to build serverless apps. They are easy to get started with, but also allow you to customize them. It also comes with a local development environment that we will be relying on through this guide. So when you run:

- `sst build`, it runs `cdk synth` internally
- `pnpm sst dev` or `pnpm sst deploy`, it runs `cdk deploy`

Now we are ready to create our first SST app.
226 changes: 88 additions & 138 deletions _chapters/add-an-api-to-create-a-note.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,87 +12,51 @@ Let's get started by creating the API for our notes app.

We'll first add an API to create a note. This API will take the note object as the input and store it in the database with a new id. The note object will contain the `content` field (the content of the note) and an `attachment` field (the URL to the uploaded file).

### Creating the API Stack
### Creating the API

{%change%} Create a new file in `stacks/ApiStack.ts` and add the following.
{%change%} Replace the `infra/api.ts` with the following.

```typescript
import { Api, StackContext, use } from "sst/constructs";
import { StorageStack } from "./StorageStack";
```ts
import { table } from "./storage";

export function ApiStack({ stack }: StackContext) {
const { table } = use(StorageStack);

// Create the API
const api = new Api(stack, "Api", {
defaults: {
function: {
bind: [table],
// Create the API
export const api = new sst.aws.ApiGatewayV2("Api", {
transform: {
route: {
handler: {
link: [table],
},
},
routes: {
"POST /notes": "packages/functions/src/create.main",
},
});

// Show the API endpoint in the output
stack.addOutputs({
ApiEndpoint: api.url,
});
}
}
});

// Return the API resource
return {
api,
};
}
api.route("POST /notes", "packages/functions/src/create.main");
```

We are doing a couple of things of note here.

- We are creating a new stack for our API. We could've used the stack we had previously created for DynamoDB and S3. But this is a good way to talk about how to share resources between stacks.

- This new `ApiStack` references the `table` resource from the `StorageStack` that we created previously.
- We are creating an API using SST's [`Api`]({{ site.ion_url }}/docs/component/apigatewayv2/){:target="_blank"} component. It creates an [Amazon API Gateway HTTP API](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api.html){:target="_blank"}.

- We are creating an API using SST's [`Api`]({{ site.docs_url }}/constructs/Api){:target="_blank"} construct.

- We are [binding]({{ site.docs_url }}/resource-binding){:target="_blank"} our DynamoDB table to our API using the `bind` prop. This will allow our API to access our table.
- We are [linking]({{ site.ion_url }}/docs/linking/){:target="_blank"} our DynamoDB table to our API using the `link` prop. This will allow our API to access our table.

- The first route we are adding to our API is the `POST /notes` route. It'll be used to create a note.

- Finally, we are printing out the URL of our API as an output by calling `stack.addOutputs`. We are also exposing the API publicly so we can refer to it in other stacks.

### Adding to the App

Let's add this new stack to the rest of our app.

{%change%} In `sst.config.ts`, replace the `stacks` function with.

```typescript
stacks(app) {
app.stack(StorageStack).stack(ApiStack);
},
```

{%change%} And, import the API stack at the top.
```typescript
import { ApiStack } from "./stacks/ApiStack";
```

- By using the `transform` prop we are telling the API that we want the given props to be applied to all the routes in our API.

### Add the Function

Now let's add the function that'll be creating our note.

{%change%} Create a new file in `packages/functions/src/create.ts` with the following.

```typescript
import AWS from "aws-sdk";
```ts
import * as uuid from "uuid";
import { Resource } from "sst";
import { APIGatewayProxyEvent } from "aws-lambda";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { PutCommand, DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";

import { Table } from "sst/node/table";

const dynamoDb = new AWS.DynamoDB.DocumentClient();
const dynamoDb = DynamoDBDocumentClient.from(new DynamoDBClient({}));

export async function main(event: APIGatewayProxyEvent) {
let data, params;
Expand All @@ -101,7 +65,7 @@ export async function main(event: APIGatewayProxyEvent) {
if (event.body) {
data = JSON.parse(event.body);
params = {
TableName: Table.Notes.tableName,
TableName: Resource.Notes.name,
Item: {
// The attributes of the item to be created
userId: "123", // The id of the author
Expand All @@ -119,7 +83,7 @@ export async function main(event: APIGatewayProxyEvent) {
}

try {
await dynamoDb.put(params).promise();
await dynamoDb.send(new PutCommand(params));

return {
statusCode: 200,
Expand All @@ -145,7 +109,7 @@ There are some helpful comments in the code but let's go over them quickly.
- Parse the input from the `event.body`. This represents the HTTP request body.
- It contains the contents of the note, as a string — `content`.
- It also contains an `attachment`, if one exists. It's the filename of a file that will be uploaded to [our S3 bucket]({% link _chapters/create-an-s3-bucket-in-sst.md %}).
- We can access our DynamoDB table through `Table.Notes.tableName` from the `sst/node/table`, the [SST Node.js client]({{ site.docs_url }}/clients){:target="_blank"}. Here `Notes` in `Table.Notes` is the name of our Table construct from the [Create a DynamoDB Table in SST]({% link _chapters/create-a-dynamodb-table-in-sst.md %}) chapter. By doing `bind: [table]` earlier in this chapter, we are allowing our API to access our table.
- We can access our linked DynamoDB table through `Resource.Notes.name` using the [SST SDK]({{ site.ion_url }}/docs/reference/sdk/){:target="_blank"}. Here, `Notes` in `Resource.Notes`, is the name of our Table component from the [Create a DynamoDB Table in SST]({% link _chapters/create-a-dynamodb-table-in-sst.md %}) chapter. By doing `link: [table]` earlier in this chapter, we are allowing our API to access our table.
- The `userId` is the id for the author of the note. For now we are hardcoding it to `123`. Later we'll be setting this based on the authenticated user.
- Make a call to DynamoDB to put a new object with a generated `noteId` and the current date as the `createdAt`.
- And if the DynamoDB call fails then return an error with the HTTP status code `500`.
Expand All @@ -158,37 +122,32 @@ Let's go ahead and install the packages that we are using here.
$ cd packages/functions
```

{%change%} Then, run the following **in the `packages/functions/` folder** (Not in root).
{%change%} Then, run the following **in the `packages/functions/` folder**.

```bash
$ pnpm add --save aws-sdk aws-lambda uuid
$ pnpm add --save-dev @types/uuid @types/aws-lambda
$ npm install uuid @aws-sdk/lib-dynamodb @aws-sdk/client-dynamodb
$ npm install -D @types/uuid @types/aws-lambda
```

- **aws-sdk** allows us to talk to the various AWS services.
- **aws-lambda**
- **uuid** generates unique ids.
- **@types/aws-lambda** & **@types/uuid** provides the TypeScript types.
- **@aws-sdk/lib-dynamodb @aws-sdk/client-dynamodb** allows us to talk to DynamoDB

### Deploy Our Changes

If you switch over to your terminal, you will notice that your changes are being deployed.

{%caution%}
You’ll need to have `sst dev` running for this to happen. If you had previously stopped it, then running `pnpm sst dev` will deploy your changes again.
You’ll need to have `sst dev` running for this to happen. If you had previously stopped it, then running `npx sst dev` will deploy your changes again.
{%endcaution%}

You should see that the new API stack has been deployed.
Once complete, you should see.

```bash
✓ Deployed:
StorageStack
ApiStack
ApiEndpoint: https://5bv7x0iuga.execute-api.us-east-1.amazonaws.com
+ Complete
Api: https://5bv7x0iuga.execute-api.us-east-1.amazonaws.com
```

It includes the API endpoint that we created.

### Test the API

Now we are ready to test our new API.
Expand All @@ -199,10 +158,10 @@ Now we are ready to test our new API.
curl -X POST \
-H 'Content-Type: application/json' \
-d '{"content":"Hello World","attachment":"hello.jpg"}' \
<YOUR_ApiEndpoint>/notes
<YOUR_Api>/notes
```

Replace `<YOUR_ApiEndpoint>` with the `ApiEndpoint` from the output above. For example, our command will look like:
Replace `<YOUR_Api>` with the `Api` from the output above. For example, our command will look like:

```bash
curl -X POST \
Expand All @@ -227,17 +186,20 @@ Make a note of the `noteId`. We are going to use this newly created note in the

### Refactor Our Code

Before we move on to the next chapter, let's refactor this code. Since we'll be doing the same basic actions for all of our APIs, it makes sense to [DRY our code](https://blog.boot.dev/clean-code/dry-code/){:target="_blank"} to create reusable shared behaviors for both application reliability and maintainability.
Before we move on to the next chapter, let's refactor this code. Since we'll be doing the same basic actions for all of our APIs, it makes sense to move this into our `core` package.

{%change%} Start by replacing our `create.ts` with the following.

```typescript
```ts
import * as uuid from "uuid";
import { Table } from "sst/node/table";
import handler from "@notes/core/handler";
import dynamoDb from "@notes/core/dynamodb";
import { Resource } from "sst";
import { Util } from "@notes/core/util";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { PutCommand, DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";

const dynamoDb = DynamoDBDocumentClient.from(new DynamoDBClient({}));

export const main = handler(async (event) => {
export const main = Util.handler(async (event) => {
let data = {
content: "",
attachment: "",
Expand All @@ -248,7 +210,7 @@ export const main = handler(async (event) => {
}

const params = {
TableName: Table.Notes.tableName,
TableName: Resource.Notes.name,
Item: {
// The attributes of the item to be created
userId: "123", // The id of the author
Expand All @@ -259,7 +221,7 @@ export const main = handler(async (event) => {
},
};

await dynamoDb.put(params);
await dynamoDb.send(new PutCommand(params));

return JSON.stringify(params.Item);
});
Expand All @@ -268,70 +230,47 @@ export const main = handler(async (event) => {
This code doesn't work just yet but it shows you what we want to accomplish:

- We want to make our Lambda function `async`, and simply return the results.
- We want to simplify how we make calls to DynamoDB. We don't want to have to create a `new AWS.DynamoDB.DocumentClient()`.
- We want to centrally handle any errors in our Lambda functions.
- Finally, since all of our Lambda functions will be handling API endpoints, we want to handle our HTTP responses in one place.

Let's start by creating a `dynamodb` util that we can share across all our functions. We'll place this in the `packages/core` directory. This is where we'll be putting all our business logic.

{%change%} Create a `packages/core/src/dynamodb.ts` file with:

```typescript
import AWS from "aws-sdk";
import { DocumentClient } from "aws-sdk/lib/dynamodb/document_client";

const client = new AWS.DynamoDB.DocumentClient();
{%change%} Create a `packages/core/src/util/index.ts` file with the following.

export default {
get: (params: DocumentClient.GetItemInput) => client.get(params).promise(),
put: (params: DocumentClient.PutItemInput) => client.put(params).promise(),
query: (params: DocumentClient.QueryInput) => client.query(params).promise(),
update: (params: DocumentClient.UpdateItemInput) =>
client.update(params).promise(),
delete: (params: DocumentClient.DeleteItemInput) =>
client.delete(params).promise(),
};
```

Here we are creating a convenience object that exposes the DynamoDB client methods that we are going to need in this guide.

{%change%} Also create a `packages/core/src/handler.ts` file with the following.

```typescript
```ts
import { Context, APIGatewayProxyEvent } from "aws-lambda";

export default function handler(
lambda: (evt: APIGatewayProxyEvent, context: Context) => Promise<string>
) {
return async function (event: APIGatewayProxyEvent, context: Context) {
let body, statusCode;

try {
// Run the Lambda
body = await lambda(event, context);
statusCode = 200;
} catch (error) {
statusCode = 500;
body = JSON.stringify({
error: error instanceof Error ? error.message : String(error),
});
}

// Return HTTP response
return {
body,
statusCode,
export module Util {
export function handler(
lambda: (evt: APIGatewayProxyEvent, context: Context) => Promise<string>
) {
return async function(event: APIGatewayProxyEvent, context: Context) {
let body: string, statusCode: number;

try {
// Run the Lambda
body = await lambda(event, context);
statusCode = 200;
} catch (error) {
statusCode = 500;
body = JSON.stringify({
error: error instanceof Error ? error.message : String(error),
});
}

// Return HTTP response
return {
body,
statusCode,
};
};
};
}
}
```

{%change%} We are now using the aws-sdk to `core` as well. Run the following **in the `packages/core/` directory**.
{%change%} We are now using the Lambda types in `core` as well. Run the following **in the `packages/core/` directory**.


```bash
$ pnpm add --save aws-sdk aws-lambda
$ pnpm add --save-dev @types/aws-lambda
$ npm install -D @types/aws-lambda
```

Let's go over this in detail.
Expand All @@ -341,11 +280,22 @@ Let's go over this in detail.
- We then run the Lambda function in a `try/catch` block.
- On success, we take the result and return it with a `200` status code.
- If there is an error then we return the error message with a `500` status code.
- Exporting the whole thing inside a `Util` module allows us import it as `Util.handler`. It also lets us put other util functions in this module in the future.

{%caution%}
You’ll need to have `sst dev` running for this to happen. If you had previously stopped it, then running `pnpm sst dev` will deploy your changes again.
You’ll need to have `sst dev` running for this to happen. If you had previously stopped it, then running `npx sst dev` will deploy your changes again.
{%endcaution%}

### Remove Template Files

The template we are using comes with some example files that we can now remove.

{%change%} Run the following from the **project root**.

```bash
$ rm -rf packages/core/src/example packages/functions/src/api.ts
```

Next, we are going to add the API to get a note given its id.

---
Expand All @@ -354,11 +304,11 @@ Next, we are going to add the API to get a note given its id.

- path received type undefined

Restarting `pnpm sst dev` should pick up the new type information and resolve this error.
Restarting `npx sst dev` should pick up the new type information and resolve this error.

- Response `statusCode: 500`

If you see a `statusCode: 500` response when you invoke your function, the error has been reported by our code in the `catch` block. You'll see a `console.error` is included in our `handler.ts` code above. Incorporating logs like these can help give you insight on issues and how to resolve them.
If you see a `statusCode: 500` response when you invoke your function, the error has been reported by our code in the `catch` block. You'll see a `console.error` is included in our `util/index.ts` code above. Adding logs like these can help give you insight on issues and how to resolve them.

```typescript
} catch (e) {
Expand Down
Loading

0 comments on commit f23dc17

Please sign in to comment.