Dev Journal #5: A UI to list links

The next step is to create the first UI page to display the list of existing links. Even though I followed SST’s docs for creating a NextJS project, linking NextJS, SST, and MUI together was fraught.

Bring NextJS and MUI Online

To get it all in sync, I ended up cloning the SST NextJS quickstart example and copying over files until I was able to see Next’s Hello World UI:

NextJS hello world screen

Then, I did the same with MUI’s NextJS (Typescript) example until I could that Hello World UI:

MUI hello world screen

You can view these changes in the Next UI works in dev and Add MUI PRs.

I’m eternally grateful to the devs that have written countless examples for the MUI and SST projects; without those examples, I don’t think I would’ve been able to move forward on this project with these tools.

Finally, let’s connect the UI to the API data! First, we’ll expose the api construct in stacks/API.ts so that we can bind it to the UI:

  // ...
  stack.addOutputs({
    ApiEndpoint: api.url,
  });

  return { api };
}

Then bind the api to the site under stacks/Site.ts:

export function Site({ stack }: StackContext) {
  const { api } = use(API);

  const site = new NextjsSite(stack, "site", {
    bind: [api],
  });

On the index page (src/pages/index.tsx), we can create a Link type and use getServerSideProps to fetch data from the API:

type Link = {
  shortPath: string;
  url: string;
  uid: string;
};

export const getServerSideProps: GetServerSideProps<{
  links: Link[];
}> = async (context) => {
  const endpoint = `${Api.api.url}/links`;
  const res = await fetch(endpoint);
  const data = await res.json();

  return { props: data.body };
};

There’s a possible breakdown between the UI and API here if the Link type skews from what the API returns, so I created an issue to revisit this in the future. In that same file, we’ll add some basic p tags for each link we receive:

export default function Home({ links }: { links: Link[] }) {
  return (
    <Container maxWidth="lg">
      {/* ... */}
        {links &&
          links.map((link) => (
            <Typography
              variant="body1"
              component="p"
              gutterBottom
              key={link.uid}
            >
              {link.shortPath} - {link.url}
            </Typography>
          ))}

One last thing: NextJS requires an experimental flag in next.config.js to make this work. I think it’s to allow async calls inside getServerProps:

const nextConfig = {
  reactStrictMode: true,
  webpack(config) {
    config.experiments = { ...config.experiments, topLevelAwait: true };
    return config;
  },
};

And there we go, links! You can view this code on the Index shows list of links PR.

NextJS hello world screen

Dev Journal #4: API patch links and redirect

Today we’ll round out the first version of the API by:

  • Adding create and update timestamps to Link entities
  • Un-hardcoding the link create function
  • Adding a way to edit links
  • Adding a way to redirect to a URL from a short path

ElectroDB lets us add createdAt and updatedAt timestamps very easily, thanks to the ability to set readOnly: true and/or a watch property to modify attributes when the entity is already being edited. We’ll lift straight from their example by editing the LinkEntity in packages/core/src/link.ts:

export const LinkEntity = new Entity(
  {
    model: {
      entity: "links",
      version: "1",
      service: "cowlinks",
    },
    attributes: {
      // ...
      createdAt: {
        type: "number",
        readOnly: true,
        set: () => Date.now(),
      },
      updatedAt: {
        type: "number",
        readOnly: true,
        watch: "*",
        set: () => Date.now(),
      },

Now, ElectroDB will automatically set createdAt whenever a new entity is created (and only the model can set this, not the user), and it will update updatedAt whenever any other property on the entity changes as well.

Earlier, we hardcoded each link that was created to redirect to Google. Let’s update packages/functions/src/link/create.ts to use the url and shortPath parameters from the POST body:

export const handler = ApiHandler(async (_evt) => {
  const { url, shortPath } = useJsonBody();
  const newLink = await Link.create(shortPath, url);

  return {
    body: JSON.stringify({
      link: newLink,
    },
    }),
  };
});

Here, useBody is an SST builtin from the api client.

Originally, I was going to use the verb PUT instead of PATCH to edit links; however, ElectroDB’s put makes you provide all required attributes, and I prefer to only pass in the properties I wish to change. (Or at least, I like having the option to only provide the changed values.)

So I went with patch instead, especially since it ensures you’re not creating a new link accidentally:

In DynamoDB, update operations by default will create an item if record being updated does not exist. Alternatively, the patch method will utilize the attribute_exists() parameter dynamically to ensure records are only “patched” and not created when updating items in your table.

Adding the patch and list API endpoints is pretty straightforward. We’ll update stacks/API.ts to add two new functions:

  const api = new Api(stack, "api", {
    // ...
    routes: {
      // ...
      "PATCH /link/{uid}": {
        function: {
          functionName: nameFor("LinkPatch"),
          handler: "packages/functions/src/link/patch.handler",
        },
      },
      "GET /links": {
        function: {
          functionName: nameFor("LinkList"),
          handler: "packages/functions/src/link/list.handler",
        },
      },

Expose methods in packages/core/src/link.ts to patch and list LinkEntitys:

export async function getByShortPath(shortPath: string) {
  const result = await LinkEntity.query.byShortPath({ shortPath }).go();
  return result.data;
}

type PatchAttributes = UpdateEntityItem<typeof LinkEntity>;

export async function patch(uid: string, newAttributes: PatchAttributes) {
  const result = await LinkEntity.patch({ uid }).set(newAttributes).go({
    response: "all_new",
  });
  console.log({ result });
  return result.data;
}

export async function list() {
  const result = await LinkEntity.query.byUid({}).go();
  return result.data;
}

We use response: "all_new" so that Dynamo returns the whole updated object in the response instead of an empty object. In a heavy-scale production system, I’d more strongly consider using the default response to use eventual consistency over strong consistency. However, I expect to save a lot more debugging pain by grabbing the entire object in the short term.

I found the UpdateEntityItem party trick from ElectroDB’s Typescript guide. It has a lot of nice helpers for the static types, although I had to hunt around for a while to find it, and it was unclear which type I should use for .patch() without some experimentation.

Write the patch handler at packages/functions/src/link/patch.ts:

export const handler = ApiHandler(async (_evt) => {
  const linkUid = usePathParam("uid");
  const newAttributes = useJsonBody();
  if (!linkUid) {
    return {
      statusCode: 400,
      body: "link uid is required",
    };
  }

  const foundLink = await Link.get(linkUid);
  console.log({ foundLink });
  if (!foundLink) {
    return {
      statusCode: 404,
      body: "not found",
    };
  }

  const updatedLink = await Link.patch(foundLink.uid, newAttributes);
  console.log({ updatedLink });

  return {
    statusCode: 200,
    body: JSON.stringify({
      link: updatedLink,
    }),
  };
});

And the list handler at packages/functions/src/link/list.ts:

export const handler = ApiHandler(async (_evt) => {
  const result = await Link.list();
  return {
    body: {
      links: result,
    },
  };
});

The Redirect Endpoint

Finally, the last and most important piece of the URL shortener: the redirect. I decided to use /s to designate shortened URLs (s for shorten), so API_URL/s/foo will look up a LinkEntity with a shortPath of foo and redirect to the entity’s url.

Let’s fix a bug in the LinkEntity under packages/core/src/link.ts so that we can find links by their shortPath:

export const LinkEntity = new Entity(
  {
    // ...
    indexes: {
      // ...
      byShortPath: {
        index: "gsi1",

Then we’ll add a new route in stacks/API.ts:

"GET /s/{shortPath}": {
  function: {
    functionName: nameFor("Redirect"),
    handler: "packages/functions/src/redirect.handler",
  },
},

And the corresponding handler in packages/functions/src/redirect.ts:

export const handler = ApiHandler(async (_evt) => {
  const shortPath = usePathParam("shortPath");

  if (!shortPath) {
    return {
      statusCode: 400,
      body: "shortPath is required",
    };
  }

  const foundLinks = await Link.getByShortPath(shortPath);
  console.log({ foundLinks });

  if (foundLinks.length === 0) {
    return {
      statusCode: 404,
      body: "not found",
    };
  }

  return {
    statusCode: 301,
    headers: {
      Location: foundLinks[0].url,
    },
  };
});

And there we go! A finished v0 API. You can view this code in this git compare.

Dev Journal #3: API list links

Let’s add an endpoint to get a list of all links that we’ve created. Start with adding a list function to packages/core/src/link.ts:

export async function list() {
  const result = await LinkEntity.query.byUid({}).go();
  return result.data;
}

Add the LinkList lambda function to the API stack in stacks/API.ts as well:

  const api = new Api(stack, "api", {
    routes: {
      "GET /links": {
        function: {
          functionName: nameFor("LinkList"),
          handler: "packages/functions/src/link/list.handler",
        },
      // ...
    }
  }

And there we go! You can view this code in the Add link list commit.