Dev Journal #2: API get links

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

export async function get(uid: string) {
  const result = await LinkEntity.get({ uid }).go();
  return result.data;
}

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

  const api = new Api(stack, "api", {
    routes: {
      "GET /link/{id}": {
        function: {
          functionName: `${stack.stackName}-LinkGet`,
          handler: "packages/functions/src/link/get.handler",
          bind: [table],
        },
      },
      // ...
    }
  }

You can view this code in the Add link get commit.

Dev Journal #1: API create links

Let’s set up some backend API functions, but first we need a project we can code against. SST’s docs recommend starting with a clean NextJS app then add sst on top by running these scripts in the root folder:

npx create-next-app@latest

npx create-sst@latest
npm install

We’ll use SST to create a new DynamoDb table in stacks/Database.ts. Because we’re going to have two primary access patterns on the table immediately–GET by URL and GET by unique id–we’ll add one global index to handle the second access pattern:

export function Database({ stack }: StackContext) {
  const table = new Table(stack, "table", {
    fields: {
      pk: "string",
      sk: "string",
      gsi1pk: "string",
      gsi1sk: "string",
    },
    primaryIndex: {
      partitionKey: "pk",
      sortKey: "sk",
    },
    globalIndexes: {
      gsi1: {
        partitionKey: "gsi1pk",
        sortKey: "gsi1sk",
      },
    },
  });

  return { table };
}

And we’ll similarly create an API in stacks/API.ts to create a link as POST /link backed by a lambda function:

function nameFor(shortName: string) {
  const nameGenerator = (props: FunctionNameProps): string => {
    return `${props.stack.stackName}-${shortName}`;
  };
  return nameGenerator;
}

export function API({ stack }: StackContext) {
  const { table } = use(Database);

  const api = new Api(stack, "api", {
    defaults: {
      function: {
        bind: [table],
      },
    },
    routes: {
      "POST /link": {
        function: {
          functionName: nameFor("LinkCreate"),
          handler: "packages/functions/src/link/create.handler",
        },
      },
    },
  });
  stack.addOutputs({
    ApiEndpoint: api.url,
  });

  return { api };
}

Here, nameFor is my shorthand to create nicer function names. By default, SST will let CloudFormation auto-generate the function names, and they’re pretty unreadable. nameFor will pass a name generator into SST as it creates functions so that the function names are human readable.

Let’s also update sst.config.ts with the Database and API stacks:

export default {
  config(_input) {
    return {
      name: "cow-link",
      region: "us-east-1",
    };
  },
  stacks(app) {
    app.stack(Database);
    app.stack(API);
    app.stack(Site);
  },
} satisfies SSTConfig;

We’re following recommendations in SST’s documentation to use ElectroDB as our DynamoDB interface, so we add ulid, electrodb, and the AWS DynamoDB client to our dependencies in package.json:

  "dependencies": {
    "ulid": "^2.3.0",
    "electrodb": "^2.5.1",
    "@aws-sdk/client-dynamodb": "^3.332.0"
  }

We’ll need to configure ElectroDB with the details about the DynamoDB table, so let’s add that in packages/core/src/dynamo.ts:

export const Client = new DynamoDBClient({});

export const Configuration: EntityConfiguration = {
  table: Table.table.tableName,
  client: Client,
};

And we’ll need a LinkEntity with a corresponding create function in packages/core/src/link.ts:

export const LinkEntity = new Entity(
  {
    model: {
      entity: "links",
      version: "1",
      service: "cowlinks",
    },
    attributes: {
      uid: {
        type: "string",
        required: true,
      },
      shortPath: {
        type: "string",
        required: true,
      },
      url: {
        type: "string",
        required: true,
      },
    },
    indexes: {
      byUid: {
        pk: {
          field: "pk",
          composite: [],
        },
        sk: {
          field: "sk",
          composite: ["uid"],
        },
      },
      byShortPath: {
        index: "gsi1pk-gsi1sk-index",
        pk: {
          field: "gsi1pk",
          composite: ["shortPath"],
        },
        sk: {
          field: "gsi1sk",
          composite: [],
        },
      },
    },
  },
  Dynamo.Configuration
);

export async function create(shortPath: string, url: string) {
  const result = await LinkEntity.create({
    uid: ulid(),
    shortPath,
    url,
  }).go();

  return result.data;
}

Last but certainly not least, we’ll write the code backing the lambda function to actually create links. To start, let’s create a hardcoded link to test under packages/functions/link/create.ts:

export const handler = ApiHandler(async (_evt) => {
  const newLink = await Link.create("test", "https://google.com");

  return {
    body: {
      link: newLink,
    },
  };
});

You can view this code in the Add link create commit.

Building a Full-Stack Serverless URL Shortener Cover Image

Building a Full-Stack Serverless URL Shortener

I’m going to strengthen my fullstack serverless developer skills by building a clone of bitly.

This idea came from a conversation I had with a friend of mine, who hosts parties often and manage the invite lists with a Telegram chat. He wanted to create fancy invitations by embossing a link to the chat as a QR code, and to do that he needed to create a rubber stamp for the QR code.

Custom rubber stamps are pretty expensive, and so I suggested that the QR code point to a shortened URL. That way, he can change the URL later without creating a new stamp. To which he replied:

“I’ll use bitly to create the invite link, of course.”

Now don’t get me wrong, bitly is great for non-technical folks to create shortened URLs. However, especially after Heroku ended their free tier, I’ve learned that there’s no such thing as a free lunch. I’m immediately leery of any free service that services a lot of people without a very obvious market strategy behind it.

So, it’s about time I build my own URL shortener! I have plenty of use cases for it, it’s a great side project to learn how to write fullstack apps, and I can always open it up to my friends to use as well.

A Foray into Developer Journals

Inspired by Juraj Majerik’s diary of an Uber clone and David Smith’s design diary, my plan is to write short blog posts on the days I work on this project. In a perfect world, I’d like to devote at least an hour daily to this, but I’m aiming to make a post at least once a week until completion.

Architecture Overview

Before I get started, I drafted what I expect my tech stack to look like in broad strokes. Additional context I used:

  • Unlike most side projects, this is one I hope to use personally going forward.
  • At work, our stack is AWS and SST for infrastructure, and React and MUI for the frontend.

Here’s my plan for the architecture:

Architecture diagram of the URL shortener

I’m going to use SST to manage the infrastructure and stitching all the pieces together. For the API backend, the data store will be a DynamnoDB mono-table, and the compute will be AWS Lambda functions behind an API Gateway. AWS Cognito will manage the user authentication and authorization.

I’m really excited to embark on this journey. By documenting my progress in short blog posts, I hope to share my experiences and insights with others who may be interested in similar projects. Stay tuned for future updates as I delve into the development process and bring this project to life. Let’s build something amazing together!