Skip to content

Create a Shopping List application using the AWS PDK

Warning

The code shown on this page is intended for demonstration purposes only and does not handle edge cases, perform error handling, etc. As such this code should never be used in a production setting.

Some of the steps in this workshop will create resources that may bill your account. If you do not complete the workshop, you may still have AWS resources that are unknowingly charging your account. To ensure your account is clean after completing this workshop, check out how to cleanup deployed resources.

This workshop will build upon the concepts introduced in the build your first AWS PDK project and dive a little deeper into what a typical development workflow looks like once you set up your core project structure within the AWS PDK.

You will create a Shopping List application that allows you to create & delete shopping lists along with the ability to add, remove and re-order items within those lists.

As part of this workshop, you will learn the following:

  • Understand how to make changes to your projenrc definition.
  • How to define new API operations in Smithy.
  • Implement business logic for each of your defined API's.
  • Set up CDK infrastructure to manage your API and Website.
  • Build frontend code using AWS Cloudscape.
  • Call your deployed API's using generated type-safe hooks.

Prerequisites

This is a continuation from build your first AWS PDK project and as such must be completed prior to starting this workshop.

Submodule 1: Defining your API in Smithy

In order to define our API, we need to write some Smithy code which is contained within packages/api/model/src/main/smithy.

Info

More detailed information on how to use smithy can be found in the Type Safe API Developer Guide

Definining our types

Firstly, let's define some types for our Shopping List application by creating the following files:

$version: "2"
namespace com.aws

/// Identifier for a Shopping List
string ShoppingListId

/// A Shopping Item is just a string literal
string ShoppingItem

/// Defines the id attribute of the Shopping List
@mixin
structure ShoppingListIdMixin {
    @required
    shoppingListId: ShoppingListId
}

/// Defines the core attributes of a Shopping List
@mixin
structure ShoppingListMixin {
    @required
    name: String

    shoppingItems: ShoppingItems
}

/// A Shopping List is a union of these Mixins
structure ShoppingList with [ShoppingListIdMixin, ShoppingListMixin] {}

/// A collection of Shopping List
list ShoppingLists {
    member: ShoppingList
}

/// A collection of items within a Shopping List
list ShoppingItems {
    member: ShoppingItem
}
$version: "2"

namespace com.aws

/// Extends inputs for "list" type operations to accept pagination details
@mixin
structure PaginatedInputMixin {
    /// A token for an additional page of results
    @httpQuery("nextToken")
    nextToken: String

    /// The number of results to return in a page
    @httpQuery("pageSize")
    pageSize: Integer
}

/// Extends outputs for "list" type operations to return pagination details
@mixin
structure PaginatedOutputMixin {
    /// Pass this in the next request for another page of results
    nextToken: String
}

Define our operations

Now let's define our operations. For this application we need 3 operations as follows:

$version: "2"
namespace com.aws

/// Handles upserting of a Shopping List
@http(method: "POST", uri: "/shopping-list")
@handler(language: "typescript")
operation PutShoppingList {
    input := with [ShoppingListMixin] {
        shoppingListId: ShoppingListId
    }
    output := with [ShoppingListIdMixin] {}
    errors: [BadRequestError]
}
$version: "2"
namespace com.aws

/// Handles deletion of a Shopping List
@http(method: "DELETE", uri: "/shopping-list/{shoppingListId}")
@handler(language: "typescript")
operation DeleteShoppingList {
    input := {
        @required
        @httpLabel
        shoppingListId: ShoppingListId
    }
    output := with [ShoppingListIdMixin] {}
    errors: [NotFoundError]
}
$version: "2"
namespace com.aws

/// Handles fetching of Shopping List(s)
@readonly
@http(method: "GET", uri: "/shopping-list")
@paginated(inputToken: "nextToken", outputToken: "nextToken", pageSize: "pageSize", items: "shoppingLists")
@handler(language: "typescript")
operation GetShoppingLists {
    input := with [PaginatedInputMixin] {
        @httpQuery("shoppingListId")
        shoppingListId: ShoppingListId
    }
    output := with [PaginatedOutputMixin] {
        /// List of Shopping List
        @required
        shoppingLists: ShoppingLists
    }
}

Tip

The @handler trait is used to automatically generate lambda handler stubs within api/handlers/src, pre-configured with AWS Powertools and all type-safe bindings.

You can generate handlers in either typescript, python or java by simply changing the language attribute.

Expose our operations to the API

The final step is the ensure our operations are exposed as part of the API by listing them in the operations field as per the below snippet.

$version: "2"
namespace com.aws

use aws.protocols#restJson1

/// My Shopping List API
@restJson1
service MyApi {
    version: "1.0"
    operations: [
        GetShoppingLists
        PutShoppingList
        DeleteShoppingList
    ]
    errors: [
        BadRequestError
        NotAuthorizedError
        InternalFailureError
    ]
}

Note

Given we just removed the SayHello operation, we will also need to delete the following files given they are no longer used:

  • packages/api/model/src/main/smithy/operations/say-hello.smithy
  • packages/api/handlers/typescript/src/say-hello.ts

Build the API

Now that we have our API defined, the final step is to build our code which will generate all of our type-safe bindings. To do so, run npx projen build from the root of your PDK project.

Take some time now to inspect the code that was generated for you in the following locations:

  • packages/api/generated/*: You never need to modify code in the subdirectory, althrough you will reference code within here from your infrastructure and client code (i.e: generated hooks).
  • packages/api/handlers: All handler stubs are generated in here, based on the presense of the @handler trait on your API operations. You will typically write all of your API operation handlers (business logic) within this location.

Submodule 2: Implement your API operation handlers (business logic)

Having built our project, we should now have access to 3 handler stubs which will be located in the /packages/api/handlers/typescript/src directory as follows:

import {
    putShoppingListHandler,
    PutShoppingListChainedHandlerFunction,
    INTERCEPTORS,
    Response,
    LoggingInterceptor,
} from 'myapi-typescript-runtime';

/**
 * Type-safe handler for the PutShoppingList operation
 */
export const putShoppingList: PutShoppingListChainedHandlerFunction = async (request) => {
    LoggingInterceptor.getLogger(request).info('Start PutShoppingList Operation');

    // TODO: Implement PutShoppingList Operation. `input` contains the request input.
    const { input } = request;

    return Response.internalFailure({
        message: 'Not Implemented!',
    });
};

/**
 * Entry point for the AWS Lambda handler for the PutShoppingList operation.
 * The putShoppingListHandler method wraps the type-safe handler and manages marshalling inputs and outputs
 */
export const handler = putShoppingListHandler(...INTERCEPTORS, putShoppingList);
import {
    deleteShoppingListHandler,
    DeleteShoppingListChainedHandlerFunction,
    INTERCEPTORS,
    Response,
    LoggingInterceptor,
} from 'myapi-typescript-runtime';

/**
 * Type-safe handler for the DeleteShoppingList operation
 */
export const deleteShoppingList: DeleteShoppingListChainedHandlerFunction = async (request) => {
    LoggingInterceptor.getLogger(request).info('Start DeleteShoppingList Operation');

    // TODO: Implement DeleteShoppingList Operation. `input` contains the request input.
    const { input } = request;

    return Response.internalFailure({
        message: 'Not Implemented!',
    });
};

/**
 * Entry point for the AWS Lambda handler for the DeleteShoppingList operation.
 * The deleteShoppingListHandler method wraps the type-safe handler and manages marshalling inputs and outputs
 */
export const handler = deleteShoppingListHandler(...INTERCEPTORS, deleteShoppingList);
import {
    getShoppingListsHandler,
    GetShoppingListsChainedHandlerFunction,
    INTERCEPTORS,
    Response,
    LoggingInterceptor,
} from 'myapi-typescript-runtime';

/**
 * Type-safe handler for the GetShoppingLists operation
 */
export const getShoppingLists: GetShoppingListsChainedHandlerFunction = async (request) => {
    LoggingInterceptor.getLogger(request).info('Start GetShoppingLists Operation');

    // TODO: Implement GetShoppingLists Operation. `input` contains the request input.
    const { input } = request;

    return Response.internalFailure({
        message: 'Not Implemented!',
    });
};

/**
 * Entry point for the AWS Lambda handler for the GetShoppingLists operation.
 * The getShoppingListsHandler method wraps the type-safe handler and manages marshalling inputs and outputs
 */
export const handler = getShoppingListsHandler(...INTERCEPTORS, getShoppingLists);

As you can see, these stubs are already configured using the type-safe bindings and all that is left to do is implement them!

Install any additional dependencies

Before we can start implementing, we need to first add a new dependency on @aws-sdk/client-dynamodb as we will be using DynamoDB to store our Shopping Lists. To do this, modify your .projenrc.ts to include the dependency from the handlers package as follows:

const api = new TypeSafeApiProject({
    ...
});

api.handlers.typescript?.addDeps("@aws-sdk/client-dynamodb");

Once you have saved your .projenrc.ts file, run pdk from the root to synthesize and install your package.

Implement the handlers

We now have everything we need to start implementing our handlers.

Let's first by creating a shared file called dynamo-client.ts within the handlers src directory as follows:

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';

export const ddbClient = new DynamoDBClient({ region: process.env.AWS_REGION });

Now modify the respective handler files with the contents of the following:

Creates or updates a Shopping List based on the presense of a shoppingListId. If present, the operation will update the item matching that key otherwise one will be created.

import { randomUUID } from 'crypto';
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
import {
    putShoppingListHandler,
    PutShoppingListChainedHandlerFunction,
    INTERCEPTORS,
    Response,
    LoggingInterceptor,
} from 'myapi-typescript-runtime';
import { ddbClient } from './dynamo-client';

/**
 * Type-safe handler for the PutShoppingList operation
 */
export const putShoppingList: PutShoppingListChainedHandlerFunction = async (request) => {
    LoggingInterceptor.getLogger(request).info('Start PutShoppingList Operation');

    const shoppingListId = request.input.body.shoppingListId ?? randomUUID();
    await ddbClient.send(new PutItemCommand({
        TableName: 'shopping_list',
        Item: {
            shoppingListId: {
                S: shoppingListId,
            },
            name: {
                S: request.input.body.name,
            },
            shoppingItems: {
                S: JSON.stringify(request.input.body.shoppingItems || []),
            },
        },
    }));

    return Response.success({
        shoppingListId,
    });
};

/**
 * Entry point for the AWS Lambda handler for the PutShoppingList operation.
 * The putShoppingListHandler method wraps the type-safe handler and manages marshalling inputs and outputs
 */
export const handler = putShoppingListHandler(...INTERCEPTORS, putShoppingList);

Deletes a Shopping List given a shoppingListId.

import { DeleteItemCommand, DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
    deleteShoppingListHandler,
    DeleteShoppingListChainedHandlerFunction,
    INTERCEPTORS,
    Response,
    LoggingInterceptor,
} from 'myapi-typescript-runtime';
import { ddbClient } from './dynamo-client';

/**
 * Type-safe handler for the DeleteShoppingList operation
 */
export const deleteShoppingList: DeleteShoppingListChainedHandlerFunction = async (request) => {
    LoggingInterceptor.getLogger(request).info(
        'Start DeleteShoppingList Operation',
    );

    const shoppingListId = request.input.requestParameters.shoppingListId;
    await ddbClient.send(
    new DeleteItemCommand({
        TableName: 'shopping_list',
        Key: {
            shoppingListId: {
                S: shoppingListId,
            },
        },
    }),
    );

    return Response.success({
        shoppingListId,
    });
};

/**
 * Entry point for the AWS Lambda handler for the DeleteShoppingList operation.
 * The deleteShoppingListHandler method wraps the type-safe handler and manages marshalling inputs and outputs
 */
export const handler = deleteShoppingListHandler(
    ...INTERCEPTORS,
    deleteShoppingList,
);

This logic either calls the Scan or Query command depending on the presense of a shoppingListId request parameter. It also handles pagination based on the presense of a pageSize and/or nextToken. The shoppingItems are stored as a serialized JSON string in the table/

import { DynamoDBClient, QueryCommand, QueryCommandInput, ScanCommand, ScanCommandInput } from '@aws-sdk/client-dynamodb';
import {
    getShoppingListsHandler,
    GetShoppingListsChainedHandlerFunction,
    INTERCEPTORS,
    Response,
    LoggingInterceptor,
    ShoppingList,
} from 'myapi-typescript-runtime';
import { ddbClient } from './dynamo-client';

/**
 * Type-safe handler for the GetShoppingLists operation
 */
export const getShoppingLists: GetShoppingListsChainedHandlerFunction = async (request) => {
    LoggingInterceptor.getLogger(request).info('Start GetShoppingLists Operation');

    const nextToken = request.input.requestParameters.nextToken;
    const pageSize = request.input.requestParameters.pageSize;
    const shoppingListId = request.input.requestParameters.shoppingListId;
    const commandInput: ScanCommandInput | QueryCommandInput = {
        TableName: 'shopping_list',
        ConsistentRead: true,
        Limit: pageSize,
        ExclusiveStartKey: nextToken ? fromToken(nextToken) : undefined,
        ...(shoppingListId ? {
            KeyConditionExpression: 'shoppingListId = :shoppingListId',
            ExpressionAttributeValues: {
                ':shoppingListId': {
                    S: request.input.requestParameters.shoppingListId!,
                },
            },
        } : {}),
    };
    const response = await ddbClient.send(shoppingListId ? new QueryCommand(commandInput) : new ScanCommand(commandInput));

    return Response.success({
        shoppingLists: (response.Items || [])
            .map<ShoppingList>(item => ({
            shoppingListId: item.shoppingListId.S!,
            name: item.name.S!,
            shoppingItems: JSON.parse(item.shoppingItems.S || '[]'),
            })),
            nextToken: response.LastEvaluatedKey ? toToken(response.LastEvaluatedKey) : undefined,
        });
};

/**
 * Decode a stringified token
 * @param token a token passed to the paginated request
 */
const fromToken = <T>(token?: string): T | undefined =>
    token ? (JSON.parse(Buffer.from(decodeURIComponent(token), 'base64').toString()) as T) : undefined;

/**
 * Encode pagination details into an opaque stringified token
 * @param paginationToken pagination token details
 */
const toToken = <T>(paginationToken?: T): string | undefined =>
    paginationToken ? encodeURIComponent(Buffer.from(JSON.stringify(paginationToken)).toString('base64')) : undefined;

/**
 * Entry point for the AWS Lambda handler for the GetShoppingLists operation.
 * The getShoppingListsHandler method wraps the type-safe handler and manages marshalling inputs and outputs
 */
export const handler = getShoppingListsHandler(...INTERCEPTORS, getShoppingLists);

Fantastic! We now have all of our API business logic implemented. Let's also update our unit tests:

import {
    PutShoppingListChainedRequestInput,
    PutShoppingListRequestParameters,
    PutShoppingListResponseContent,
} from 'myapi-typescript-runtime';
import { ddbClient } from '../src/dynamo-client';
import {
    putShoppingList,
} from '../src/put-shopping-list';

// Common request arguments
const requestArguments = {
    chain: undefined as never,
    event: {} as any,
    context: {} as any,
    interceptorContext: {
        logger: {
            info: jest.fn(),
        },
    },
} satisfies Omit<PutShoppingListChainedRequestInput, 'input'>;

jest.mock('../src/dynamo-client');

describe('PutShoppingList', () => {
    it('should put an item', async () => {
        (ddbClient.send as jest.Mock).mockResolvedValue({ });
        const response = await putShoppingList({
            ...requestArguments,
            input: {
                requestParameters: {} as PutShoppingListRequestParameters,
                body: {} as any,
            },
        });

        expect(response.statusCode).toBe(200);
        expect((response.body as PutShoppingListResponseContent).shoppingListId).toBeDefined();
    });
});
import {
    DeleteShoppingListChainedRequestInput,
    DeleteShoppingListResponseContent,
    DeleteShoppingListRequestParameters,
} from 'myapi-typescript-runtime';
import {
    deleteShoppingList,
} from '../src/delete-shopping-list';
import { ddbClient } from '../src/dynamo-client';

// Common request arguments
const requestArguments = {
    chain: undefined as never,
    event: {} as any,
    context: {} as any,
    interceptorContext: {
        logger: {
            info: jest.fn(),
        },
    },
} satisfies Omit<DeleteShoppingListChainedRequestInput, 'input'>;

jest.mock('../src/dynamo-client');

describe('DeleteShoppingList', () => {

    it('should delete an item', async () => {
        (ddbClient.send as jest.Mock).mockResolvedValue({ });

        const listToDelete = 'deleted';
        const response = await deleteShoppingList({
            ...requestArguments,
            input: {
                requestParameters: {
                    shoppingListId: listToDelete,
                } as DeleteShoppingListRequestParameters,
                body: {} as never,
            },
        });

        expect(response.statusCode).toBe(200);
        expect((response.body as DeleteShoppingListResponseContent).shoppingListId).toEqual(listToDelete);
    });
});
import {
    GetShoppingListsChainedRequestInput,
    GetShoppingListsRequestParameters,
    GetShoppingListsResponseContent,
} from 'myapi-typescript-runtime';
import { ddbClient } from '../src/dynamo-client';
import {
    getShoppingLists,
} from '../src/get-shopping-lists';

// Common request arguments
const requestArguments = {
    chain: undefined as never,
    event: {} as any,
    context: {} as any,
    interceptorContext: {
        logger: {
            info: jest.fn(),
        },
    },
} satisfies Omit<GetShoppingListsChainedRequestInput, 'input'>;

jest.mock('../src/dynamo-client');

const SHOPPING_LIST_ITEMS = [{
    shoppingListId: { S: '1' },
    name: { S: '1' },
    shoppingItems: { S: '["a","b","c"]' },
}];

describe('GetShoppingLists', () => {
    it('should return an item', async () => {
        (ddbClient.send as jest.Mock).mockResolvedValue({ Items: SHOPPING_LIST_ITEMS });
        const response = await getShoppingLists({
            ...requestArguments,
            input: {
                requestParameters: {} as GetShoppingListsRequestParameters,
                body: {} as never,
            },
        });
        const responseContent = response.body as GetShoppingListsResponseContent;
        expect(response.statusCode).toBe(200);
        expect(responseContent.shoppingLists.length).toEqual(1);
        expect(responseContent.shoppingLists[0].name).toEqual(SHOPPING_LIST_ITEMS[0].name.S);
        expect(responseContent.shoppingLists[0].shoppingListId).toEqual(SHOPPING_LIST_ITEMS[0].shoppingListId.S);
        expect(responseContent.shoppingLists[0].shoppingItems).toEqual(JSON.parse(SHOPPING_LIST_ITEMS[0].shoppingItems.S));
    });
});

Let's move on to configuring the API infrastructure and deploying what we have so far.

Submodule 3: Configure and deploy your API

Create a Database construct

We need to create a DynamoDB table so that we can perform CRUD operations on our Shopping List(s). To do this, create a new file packages/infra/src/constructs/database.ts as follows:

import { AttributeType, Table } from "aws-cdk-lib/aws-dynamodb";
import { Construct } from "constructs";

/**
 * Database construct to deploy a DynamoDB table.
 */
export class DatabaseConstruct extends Construct {
  public readonly shoppingListTable: Table;

  constructor(scope: Construct, id: string) {
    super(scope, id);

    this.shoppingListTable = new Table(this, "ShoppingList", {
      partitionKey: {
        name: "shoppingListId",
        type: AttributeType.STRING,
      },
      tableName: "shopping_list",
    });
  }
}

Modify API construct

We now need to wire up all of the handlers that we implemented into the existing MyApi construct. We additionally need to configure each of our operations specific permissions onto the dynamoDB table that we just configured. Perform the following highlighted changes to your packages/infra/src/constructs/apis/myapi.ts file:

import { UserIdentity } from "@aws/pdk/identity";
import { Authorizers, Integrations } from "@aws/pdk/type-safe-api";
import { Stack } from "aws-cdk-lib";
import { Cors } from "aws-cdk-lib/aws-apigateway";
import {
  AccountPrincipal,
  AnyPrincipal,
  Effect,
  PolicyDocument,
  PolicyStatement,
} from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";
import {
  Api,
  DeleteShoppingListFunction,
  GetShoppingListsFunction,
  PutShoppingListFunction,
} from "myapi-typescript-infra";
import { DatabaseConstruct } from "../database";

/**
 * Api construct props.
 */
export interface MyApiProps {
  /**
   * Instance of the UserIdentity.
   */
  readonly userIdentity: UserIdentity;

  /**
   * Instance of the DatabaseConstruct.
   */
  readonly databaseConstruct: DatabaseConstruct;
}

/**
 * Infrastructure construct to deploy a Type Safe API.
 */
export class MyApi extends Construct {
  /**
   * API instance
   */
  public readonly api: Api;

  constructor(scope: Construct, id: string, props?: MyApiProps) {
    super(scope, id);

    const putShoppingListFunction = new PutShoppingListFunction(
      this,
      "PutShoppingListFunction",
    );
    const deleteShoppingListFunction = new DeleteShoppingListFunction(
      this,
      "DeleteShoppingListFunction",
    );
    const getShoppingListsFunction = new GetShoppingListsFunction(
      this,
      "GetShoppingListsFunction",
    );

    this.api = new Api(this, id, {
      defaultAuthorizer: Authorizers.iam(),
      corsOptions: {
        allowOrigins: Cors.ALL_ORIGINS,
        allowMethods: Cors.ALL_METHODS,
      },
      integrations: {
        putShoppingList: {
          integration: Integrations.lambda(putShoppingListFunction),
        },
        deleteShoppingList: {
          integration: Integrations.lambda(deleteShoppingListFunction),
        },
        getShoppingLists: {
          integration: Integrations.lambda(getShoppingListsFunction),
        },
      },
      policy: new PolicyDocument({
        statements: [
          // Here we grant any AWS credentials from the account that the prototype is deployed in to call the api.
          // Machine to machine fine-grained access can be defined here using more specific principals (eg roles or
          // users) and resources (ie which api paths may be invoked by which principal) if required.
          // If doing so, the cognito identity pool authenticated role must still be granted access for cognito users to
          // still be granted access to the API.
          new PolicyStatement({
            effect: Effect.ALLOW,
            principals: [new AccountPrincipal(Stack.of(this).account)],
            actions: ["execute-api:Invoke"],
            resources: ["execute-api:/*"],
          }),
          // Open up OPTIONS to allow browsers to make unauthenticated preflight requests
          new PolicyStatement({
            effect: Effect.ALLOW,
            principals: [new AnyPrincipal()],
            actions: ["execute-api:Invoke"],
            resources: ["execute-api:/*/OPTIONS/*"],
          }),
        ],
      }),
    });

    // Grant our lambda functions scoped access to call Dynamo
    props?.databaseConstruct.shoppingListTable.grantReadData(
      getShoppingListsFunction,
    );
    [putShoppingListFunction, deleteShoppingListFunction].forEach((f) =>
      props?.databaseConstruct.shoppingListTable.grantWriteData(f),
    );

    // Grant authenticated users access to invoke the api
    props?.userIdentity.identityPool.authenticatedRole.addToPrincipalPolicy(
      new PolicyStatement({
        effect: Effect.ALLOW,
        actions: ["execute-api:Invoke"],
        resources: [this.api.api.arnForExecuteApi("*", "/*", "*")],
      }),
    );
  }
}

You will notice that we are importing DeleteShoppingListFunction, GetShoppingListsFunction & PutShoppingListFunction from the myapi-typescript-infra package. These are extensions to Function which come pre-configured with runtime, index handler and asset location. The function accepts an optional third parameter which allows you to adjust any settings you would find within the Function interface i.e: vpc, subnets, etc.

Wire in the DatabaseConstruct

The final change we need to make is that we need to instantiate the DatabaseConstruct and pass the instance to the MyApi construct. To do this, perform the following highlighted changes to packages/infra/src/stacks/application-stack.ts:

import { UserIdentity } from "@aws/pdk/identity";
import { Stack, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";
import { MyApi } from "../constructs/apis/myapi";
import { DatabaseConstruct } from "../constructs/database";
import { Website } from "../constructs/websites/website";

export class ApplicationStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const userIdentity = new UserIdentity(this, `${id}UserIdentity`);
    const databaseConstruct = new DatabaseConstruct(this, "Database");
    const myapi = new MyApi(this, "MyApi", {
      databaseConstruct,
      userIdentity,
    });
    new Website(this, "Website", {
      userIdentity,
      myapi,
    });
  }
}

Deploy our updated API

We are now ready to deploy our API. To do so, run the following steps:

npx projen build
cd packages/infra
npx projen deploy:dev

Once the deployment completes, we can test our API by navigating the the website (either via Cloudfront or locally) and trying out the API Explorer.

Tip

Ensure you have configured you local website to communicate with backend services, otherwise your website will not function correctly.

Submodule 4: Build a Shopping List UI

Now that we have tested our API and shown that it works, we can now start building out a UI to improve the user experience.

Since we will no longer be using the Home page, we can simplify delete it from pages/Home/index.tsx along with it's pages/Home folder.

Create new pages & components

Within the packages/website/src directory, create the following three files with content as follows:

This creates the UI do managing our shoppings lists.

/* eslint-disable @typescript-eslint/no-floating-promises */
import {
  Button,
  Header,
  Link,
  SpaceBetween,
  TableProps,
} from "@cloudscape-design/components";
import {
  ShoppingList,
  usePutShoppingList,
  useDeleteShoppingList,
  useGetShoppingLists,
} from "myapi-typescript-react-query-hooks";
import { InfiniteQueryTable } from "@aws-northstar/ui/components";
import { useContext, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import CreateItem from "../../components/CreateItem";
import { AppLayoutContext } from "../../layouts/App";

const PAGE_SIZE = 50;

/**
 * Component to render the ShoppingLists "/" route.
 */
const ShoppingLists: React.FC = () => {
  const [visibleModal, setVisibleModal] = useState(false);
  const [selectedShoppingList, setSelectedShoppingList] = useState<
    ShoppingList[]
  >([]);
  const getShoppingLists = useGetShoppingLists({ pageSize: PAGE_SIZE });
  const putShoppingList = usePutShoppingList();
  const deleteShoppingList = useDeleteShoppingList();
  const navigate = useNavigate();
  const { setAppLayoutProps } = useContext(AppLayoutContext);

  useEffect(() => {
    setAppLayoutProps({
      contentType: "table",
    });
  }, [setAppLayoutProps]);

  const columnDefinitions = useMemo<
    TableProps.ColumnDefinition<ShoppingList>[]
  >(
    () => [
      {
        id: "shoppingListId",
        isRowHeader: true,
        header: "Shopping List Id",
        cell: (cell) => (
          <Link
            href={`/${cell.shoppingListId}`}
            onFollow={(e) => {
              e.preventDefault();
              navigate(`/${cell.shoppingListId}`);
            }}
          >
            {cell.shoppingListId}
          </Link>
        ),
      },
      {
        id: "name",
        header: "Name",
        cell: (cell) => cell.name,
      },
      {
        id: "shoppingItems",
        header: "Shopping Items",
        cell: (cell) => `${cell.shoppingItems?.length || 0} Items.`,
      },
    ],
    [navigate],
  );

  return (
    <>
      <CreateItem
        title="Create Shopping List"
        callback={async (item) => {
          await putShoppingList.mutateAsync({
            putShoppingListRequestContent: {
              name: item,
            },
          });
          getShoppingLists.refetch();
        }}
        isLoading={putShoppingList.isLoading}
        visibleModal={visibleModal}
        setVisibleModal={setVisibleModal}
      />
      <InfiniteQueryTable
        query={getShoppingLists}
        itemsKey="shoppingLists"
        pageSize={PAGE_SIZE}
        selectionType="single"
        stickyHeader={true}
        selectedItems={selectedShoppingList}
        onSelectionChange={(e) =>
          setSelectedShoppingList(e.detail.selectedItems)
        }
        header={
          <Header
            variant="awsui-h1-sticky"
            actions={
              <SpaceBetween size="xs" direction="horizontal">
                <Button
                  loading={deleteShoppingList.isLoading}
                  data-testid="header-btn-delete"
                  disabled={selectedShoppingList.length === 0}
                  onClick={async () => {
                    await deleteShoppingList.mutateAsync({
                      shoppingListId: selectedShoppingList![0].shoppingListId,
                    });
                    setSelectedShoppingList([]);
                    getShoppingLists.refetch();
                  }}
                >
                  Delete
                </Button>
                <Button
                  data-testid="header-btn-create"
                  variant="primary"
                  onClick={() => setVisibleModal(true)}
                >
                  Create Shopping List
                </Button>
              </SpaceBetween>
            }
          >
            Shopping Lists
          </Header>
        }
        variant="full-page"
        columnDefinitions={columnDefinitions}
      />
    </>
  );
};

export default ShoppingLists;

This creates the UI for managing a shopping list's items, re-arranging them, etc.

/* eslint-disable @typescript-eslint/no-floating-promises */
import {
  Board,
  BoardItem,
  BoardProps,
} from "@cloudscape-design/board-components";
import {
  Button,
  Container,
  ContentLayout,
  Header,
  SpaceBetween,
  Spinner,
} from "@cloudscape-design/components";
import {
  ShoppingList as _ShoppingList,
  usePutShoppingList,
  useGetShoppingLists,
} from "myapi-typescript-react-query-hooks";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import CreateItem from "../../components/CreateItem";

type ListItem = { name: string };

/**
 * Component to render a singular Shopping List "/:shoppingListId" route.
 */
const ShoppingList: React.FC = () => {
  const { shoppingListId } = useParams();
  const [visibleModal, setVisibleModal] = useState(false);
  const getShoppingLists = useGetShoppingLists({ shoppingListId });
  const putShoppingList = usePutShoppingList();
  const shoppingList: _ShoppingList | undefined =
    getShoppingLists.data?.pages[0].shoppingLists[0]!;
  const [shoppingItems, setShoppingItems] =
    useState<BoardProps.Item<ListItem>[]>();

  useEffect(() => {
    setShoppingItems(
      shoppingList?.shoppingItems?.map((i) => ({
        id: i,
        definition: { minColumnSpan: 4 },
        data: { name: i },
      })),
    );
  }, [shoppingList?.shoppingItems]);

  return (
    <ContentLayout
      header={
        <Header
          variant="awsui-h1-sticky"
          actions={
            <SpaceBetween size="xs" direction="horizontal">
              <Button
                data-testid="header-btn-create"
                variant="primary"
                onClick={() => setVisibleModal(true)}
              >
                Add Item
              </Button>
            </SpaceBetween>
          }
        >
          Shopping list: {shoppingList?.name}
        </Header>
      }
    >
      <CreateItem
        isLoading={false}
        title="Add Item"
        callback={async (item) => {
          const items = [
            ...(shoppingItems || []),
            {
              id: item,
              definition: { minColumnSpan: 4 },
              data: { name: item },
            },
          ];
          setShoppingItems(items);
          putShoppingList.mutate({
            putShoppingListRequestContent: {
              name: shoppingList.name,
              shoppingListId: shoppingList.shoppingListId,
              shoppingItems: items.map((i) => i.data.name),
            },
          });
        }}
        visibleModal={visibleModal}
        setVisibleModal={setVisibleModal}
      />
      <Container>
        {!shoppingList ? (
          <Spinner />
        ) : (
          <Board<ListItem>
            onItemsChange={(event) => {
              const items = event.detail.items as BoardProps.Item<ListItem>[];
              setShoppingItems(items);
              putShoppingList.mutate({
                putShoppingListRequestContent: {
                  name: shoppingList.name,
                  shoppingListId: shoppingList.shoppingListId,
                  shoppingItems: items.map((i) => i.data.name),
                },
              });
            }}
            items={shoppingItems || []}
            renderItem={(item, actions) => (
              <BoardItem
                header={item.data.name}
                settings={
                  <Button
                    iconName="close"
                    variant="icon"
                    onClick={actions.removeItem}
                  />
                }
                i18nStrings={{
                  dragHandleAriaLabel: "Drag handle",
                  dragHandleAriaDescription:
                    "Use Space or Enter to activate drag, arrow keys to move, Space or Enter to submit, or Escape to discard.",
                  resizeHandleAriaLabel: "Resize handle",
                  resizeHandleAriaDescription:
                    "Use Space or Enter to activate resize, arrow keys to move, Space or Enter to submit, or Escape to discard.",
                }}
              />
            )}
            i18nStrings={{
              liveAnnouncementDndCommitted: () => "",
              liveAnnouncementDndDiscarded: () => "",
              liveAnnouncementDndItemInserted: () => "",
              liveAnnouncementDndItemReordered: () => "",
              liveAnnouncementDndItemResized: () => "",
              liveAnnouncementDndStarted: () => "",
              liveAnnouncementItemRemoved: () => "",
              navigationAriaLabel: "",
              navigationItemAriaLabel: () => "",
            }}
            empty={<></>}
          />
        )}
      </Container>
    </ContentLayout>
  );
};

export default ShoppingList;

Abstract widget to 'add an item'.

/* eslint-disable @typescript-eslint/no-floating-promises */
import {
  Box,
  Button,
  FormField,
  Input,
  Modal,
  SpaceBetween,
} from "@cloudscape-design/components";
import { useState } from "react";

/**
 * Component to render a widget to add an item.
 */
const CreateItem: React.FC<{
  title: string;
  visibleModal: boolean;
  isLoading: boolean;
  setVisibleModal: (visible: boolean) => void;
  callback: (itemName: string) => Promise<void>;
}> = ({ title, visibleModal, setVisibleModal, callback, isLoading }) => {
  const [showValidation, setShowValidation] = useState(false);
  const [itemToCreate, setItemToCreate] = useState<string | undefined>();
  return (
    <Modal
      onDismiss={() => setVisibleModal(false)}
      visible={visibleModal}
      footer={
        <Box float="right">
          <SpaceBetween direction="horizontal" size="xs">
            <Button
              variant="link"
              onClick={() => {
                setVisibleModal(false);
                setItemToCreate(undefined);
                setShowValidation(false);
              }}
            >
              Cancel
            </Button>
            <Button
              variant="primary"
              loading={isLoading}
              onClick={async () => {
                setShowValidation(true);
                if (!itemToCreate) {
                  return;
                }
                await callback(itemToCreate);
                setVisibleModal(false);
                setItemToCreate(undefined);
                setShowValidation(false);
              }}
            >
              Create
            </Button>
          </SpaceBetween>
        </Box>
      }
      header={title}
    >
      <FormField
        label="Name"
        errorText={
          showValidation && !itemToCreate ? `Name must be provided` : undefined
        }
      >
        <Input
          value={itemToCreate || ""}
          onChange={({ detail }) => setItemToCreate(detail.value)}
        />
      </FormField>
    </Modal>
  );
};

export default CreateItem;

Wire in the new pages

Next we need to modify the following two files in order to instrument the new pages correctly:

Render the new pages as "Shopping Lists" in the nav bar.

import { SideNavigationProps } from "@cloudscape-design/components";

/**
 * Define your Navigation Items here
 */
export const NavItems: SideNavigationProps.Item[] = [
    { text: "Shopping Lists", type: "link", href: "/" },
    { type: "divider" },
    {
        text: "Developer Tools",
        type: "expandable-link-group",
        href: "#",
        defaultExpanded: false,
        items: [{ text: "API Explorer", type: "link", href: "/apiExplorer" }],
    },
];

Set up new routes (and remove old ones).

import * as React from "react";
import { Route, Routes as ReactRoutes } from "react-router-dom";
import ApiExplorer from "../../pages/ApiExplorer";
import ShoppingList from "../../pages/ShoppingList";
import ShoppingLists from "../../pages/ShoppingLists";

/**
 * Defines the Routes.
 */
const Routes: React.FC = () => {
return (
    <ReactRoutes>
        <Route key={0} path="/" element={<ShoppingLists />} />
        <Route key={1} path="/:shoppingListId" element={<ShoppingList />} />
        <Route key={2} path="/apiExplorer" element={<ApiExplorer />} />
    </ReactRoutes>
);
};

export default Routes;

Testing out our new UI

We can now test our UI locally by running the following command:

cd packages/website
npx projen dev

This will load a local server and open a browser showing your new application.

Have a play around with your website to ensure it is working as expected.

Deploying our website to Cloudfront

If you are happy with your website locally, you can go ahead and deploy it to AWS Cloudfront by performing the following steps from the root directory:

npx projen build
cd packages/infra
npx projen deploy:dev

Once the deployment completes, navigate to your cloudfront URL to play around with your deployed website.

Next Steps

Congratulations, you have now familiarized yourself with the PDK using a real-world example! Where do you go from here?


Last update: 2024-12-09