Building a link shortener using Typescript

593
0

The goal of this project is to teach you the basics of writing a Typescript backend app (using Node with the Express framework) alongside a NoSQL database (we’ll be using AWS DynamoDB).

By the end of this project you will have learned:

  • Start a node project from scratch using Typescript
  • Use strongly-typed variables to reduce the chance of buggy code
  • Structure a project in a way that makes it easy to read, maintain, test and debug
  • Build endpoints for writing and reading data from our database
  • Use a NoSQL database
  • Use an ORM to query and write to the NoSQL database
  • How to build a simple link shortening API

Pre-requisites (before you start, you must have the following pieces of software installed on your computer):

  • Node
  • A code editor of your choice (VS Code is a great free option)
  • Docker
  • Postman

Project set-up

In order to do anything at all, we will need an up-and-running instance of DynamoDB on our local computer where we are going to be doing all of the development before we push our app to production. The easiest wat to have DynamoDB running on your local computer is using Docker. Use the following command to pull the docker image and start the container:

docker run -p 8000:8000 amazon/dynamodb-local -jar DynamoDBLocal.jar -inMemory -sharedDb                                                                                        

Once the command finishes executing, you should have DynamoDB running locally on port 8000. As long as you’re developing your app, keep this process running (don’t kill it). If you kill the process you’r local DynamoDB won’t be running, but you can always just run the same command again to start it up again.

The next step is to create your project from scratch. To do this we need to perform the following steps:

  1. Create a new folder where our project files will live
  2. Initialise NPM in the new project folder
  3. Add Typescript to the project

Create a new folder where our project files will live:

mkdir <project name of your choice>
cd <project name of your choice>

The above 2 commands created a folder and then stepped into the folder you just created.

Now we need to initialise NPM inside of the project folder:

npm init -y

The above command will simply initialise a new NPM (Node) project inside of your folder

Finally, to add Typescript to your project we need to run some more commands:

npm install --save-dev typescript ts-node

Next, create a new file called tsconfig.json in your project folder and add the following content to it:

{
  "compilerOptions": {
    "target": "es5",                                     /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
    "module": "commonjs",                                /* Specify what module code is generated. */
    "esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
    "forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */
    "strict": true,                                      /* Enable all strict type-checking options. */
    "skipLibCheck": true,                                /* Skip type checking all .d.ts files. */
    "outDir": "dist/"                                    /* Where to place the compiled JavaScript code */
  }
}

Now we have a project with TypeScript set-up. The last thing we need to do before we can start coding our app is to install the different libraries we’ll be using for the project:

npm install --save express dynamoose nanoid

The above line installs the 3 libraries we need to develop our link shortener project. I’ll explain what they are and what they do further along.

npm install --save-dev @types/express @types/node nodemon

We’re almost there! Just one last thing we need to configure before we can start.

Open your package.json file and make sure there the scripts portion of the file contains the following

"scripts": {
    "dev": "nodemon src/app.ts",
    "tsc": "tsc",
    "test": "echo \"Error: no test specified\" && exit 1"
  }

Structure of the Link Shortener app

First thing you need to do is to create a folder structure that will allow you to organise your code in a way that’s easy to read, maintain and keep developing as your application grows. I’m going to show you a somewhat standard way of organising your code which is a great starting point.

Create a folder called src inside your project. This is were we’ll be storing all of our application code. Your project folder should look something like this (don’t worry of you don’t have a yarn.lock file or Dockerfile, ignore this):

Now, inside the src folder, create all of the following folders and files:

app.ts file (just leave it empty for now)

routes folder: we’ll store code that routes our API endpoints to controllers that manage each incoming request

controllers folder: we’ll store code that receives and validates incoming requests to our API and calls different other pieces of code such as services to read and write data from our database

services folder: we’ll add code here which has a very specific task, such as generating new shortened versions of links our users will be sending us

models folder: this code will be in charge of modelling our database structure in our code, so we can easily read, write and manipulate data in our app

infra folder: this code will be the actual link between our code and the database

Coding the app

The app.ts file is the main entry-point of our API. This is the file we need to start in order for our API to start running, so let’s create some initial code here and check if our app is working:

import express, { Application } from 'express'

const app: Application = express()
const port: number = 3001

app.use(express.json())
app.use(express.urlencoded())

app.use('/', (req, res) => {
  res.send('Hello, world!')
})

app.listen(port, function () {
  console.log(`App is listening on port ${port}`)
})

Now let’s run our API and see if it works. In your terminal use the following command:

npm run dev

If you open up your browser and go to http://localhost:3001 you should see the text “Hello, word!” which we have typed on line 10 of the app.ts file.

Now that we have verified that our project is set-up correctly, let’s start by coding all of the different bits and pieces.

Infra

We’ll be building our API in layers of code. Each layer will be build inside of its corresponding folder. This way we can structure our application in such a way that it’s easy for us (and potentially others if we’re working in a team) to read, maintain and test our code.

Infrastructure is what we refer to as the building blocks we have access to, on AWS for example (bot not only), to make our app work. These could be things like databases, emailing services, etc.

The infra folder will contain code that ties different infrastructures to the app its self, such as the database. Hence, in this folder we will write code that connects our app to the DynamoDB database, for example.

Since a project can make use of multiple pieces of infrastructure, I like to create a folder structure within Infra that allows me to easily add and expand the infrastructure I want to tap into further along.

This is why I like to create the following folder structure:

Create the above set of folders.

Inside the DynamoDB folder, create a file called dynamos.client.ts

Dynamoose is the ORM library we will use to make our life much easier when reading and writing data to and from DynamoDB.

The only thing we really need to do in this file is to configure the access to our DynamoDB:

import * as dynamoose from "dynamoose";

if (process.env.NODE_ENV === 'production') {
  console.log('We'll add the production config after we're done testing locally ...')
} else {
  dynamoose.aws.sdk.config.update({
    "accessKeyId": "AKID",
    "secretAccessKey": "SECRET",
    "region": "eu-central-1"
  });
  dynamoose.aws.ddb.local();
}

export default dynamoose

Models

In models we’re going to write code that uses the Dynamoose library to define the structure of the data we’re going to be reading and writing to the database.

I’ve created a links folder inside models, so if in the future we want to create models for other things, we can just add a new folder and like this we can keep everything nicely organised.

Inside the links folder, we create 2 files:

  1. link.model.ts
  2. link.schema.ts

link.schema.ts is where we will define the schema, or the structure of data that we’re gong to be using to create shortened links. We will keep is extremely simple:

import dynamoose from '../../infra/AWS/DynamoDB/dynamoose.client'

const linkSchema = new dynamoose.Schema({
  link_key: {
    type: String,
    hashKey: true
  },
  original_link: {
    type: String,
    required: true
  }
})

export default linkSchema

link.model.ts is where we will create a Model (somewhat equivalent of a table inside our database) using the schema we just defined. This is also extremely simple:

import dynamoose from '../../infra/AWS/DynamoDB/dynamoose.client'
import linkSchema from "./link.schema";

const linksModel = dynamoose.model("Links", linkSchema)

export default linksModel

Services

Services are the pieces of code that link everything together. They will receive input from the controllers that are called by external users and process it by calling models or other services.

Our link shortening app is very simple, so we only have 2 services:

Again, let’s create a folder for the services that will process stuff for links, and inside create 2 files:

  1. createLink.service.ts
  2. redirectLink.service.ts

These names reflect what each service does. This way we have an intuition of what kind of code each file will hold.

In createLink.service.ts we will write code that creates a new shortened version of a link a user will send to us through our API:

import {nanoid} from "nanoid";
import linksModel from "../../models/links/link.model";
import {Document} from "dynamoose/dist/Document";

const createLinkService = async (originalURL: string): Promise<Document> => {
  const keyLength: number = 8
  let keepCheckingForDuplicateKeys: Boolean = true
  let newKey: string = nanoid(keyLength)

  while(keepCheckingForDuplicateKeys) {
    let linkFoundWithKey
    try {
     linkFoundWithKey = await linksModel.get({link_key: newKey})
    } catch(error) {
      throw new Error('DynamoDB Error')
    }

    if (!linkFoundWithKey) {
      keepCheckingForDuplicateKeys = false
    } else {
      newKey = nanoid(keyLength)
    }
  }

  try {
    return await linksModel.create({
      link_key: newKey,
      original_link: originalURL
    })
  } catch(error) {
    throw new Error('DynamoDB Error')
  }
}

export default createLinkService

There’s 3 main things going on in this service:

  1. Variable setup and new short link ID creation
  2. Check if we already have a link with the automatically generated ID
  3. Store the new ID mapped to the link passed to the service function

In essence, this service will take care of creating a short (8 character long) ID which will be mapped to the link sent to us by the user which wants to create a shortened version of it.

Since we create a random ID each time, we cannot be sure that the new ID we have created hasn’t already been used before (although the probability of that is extremely small), so we need to check each time if we can find a record in our database with the newly generated ID. If we can’t, then we’re sure it hasn’t been used before. If we didn’t perform this check, even though the probability of creating the same ID twice is extremely low, we could eventually overwrite a previously stored link with a new one and this could lead our users into problems.

Finally, we store the link together with the ID, and it’s this ID that we will use to perform the inverse operation of when users use our short links, we will fetch the link associated to the ID and redirect them to the original link.

Speaking of the redirect service:

import linksModel from "../../models/links/link.model";

type Link = {
  link_key: string,
  original_link: string
}

const redirectLinkService = async (id: string): Promise<Link> => {
  try {
    const link = await linksModel.get({link_key: id})
    return link.toJSON() as Link
  } catch(err) {
    throw err
  }
}

export default redirectLinkService
export {Link}

This service is much simpler. It only needs to take in a string where we expect to receive the short link ID from the controller, and then fetches the link by this ID which we have stores in DynamoDB.

Once it finds the link, it simply returns it to the controller and the controller will take care of the rest.

Controllers

Controllers are the outer-most layer of our application and will listen to incoming requests, and send back the responses to the user calling our API.

As usual, we will create a links folder to store all controllers related to links.

createLink.controller.ts is the controller that will listen to inbound requests with long links, to which we will then call the createLink service and create a short ID which will be mapped to the link sent to us. Since the service takes care of that, the controller will just listen into the requests and pass the data to the service.

import {NextFunction, Request, Response} from "express";
import createLinkService from "../../services/links/createLink.service";

const createLink = async (req: Request, res: Response, next: NextFunction) => {
  const originalURL: string = req.body.original_url
  const baseUrl: string = process.env.NODE_ENV === 'production' ? 'https://littl.link/' : 'http://localhost:3001/'

  try {
    new URL(originalURL)
  } catch(err) {
    const error = new Error('Invalid Original URL')
    return next(error)
  }

  try {
    const newLink = await createLinkService(originalURL)
    const linkObject = newLink.toJSON()

    res.json({
      shortened_url: `${baseUrl}${linkObject.link_key}`,
      original_url: linkObject.original_link
    })
  } catch(error) {
    next(error)
  }
}

export default createLink

This controller is doing 3 things mainly:

  1. Fetching data sent to the API by the user (original_url)
  2. Validating the information they send is valid (it’s an actual URL)
  3. Storing the URL sent to us in the database together with the short link ID

Once it finishes those 3 things, it responds back with the short URL we have just created for the link they have sent to us.

The redirectLink.controller.ts does the inverse operation. It listens to the ID passed in the short link the user is calling, fetches the original link from the database and redirects the user to the original link.

import {NextFunction, Request, Response} from "express";
import redirectLinkService from "../../services/links/redirectLink.service";

const redirectLink = async (req: Request, res: Response, next: NextFunction) => {
  const id: string = req.params.id

  if (!id) {
    const error = new Error('Missing ID')
    return next(error)
  }

  let link
  try {
    link = await redirectLinkService(id)
  } catch(error) {
    res.status(404).send()
  }

  if (!link) {
    res.status(404).send()
  } else {
    res.status(301).redirect(link.original_link)
  }
}

export default redirectLink

Routes

Finally, Routes are simply mappings of endpoints in our API to controllers.

I’ve just created a link.routes.ts file where I will add 2 endpoints. One to create a new short link calling the createLink controller, and another to use the short link ID to redirect the user to the original one.

import { Router } from "express";

import redirectLink from "../controllers/links/redirectLink.controller";
import createLink from "../controllers/links/createLink.controller";

const linkRoutes = Router()

linkRoutes.get('/:id', redirectLink)
linkRoutes.post('/', createLink)

export default linkRoutes

Finishing up

The last thing we need to do is to add the link routes to the app.ts file. It should now look like this:

import express, { Application } from 'express'
import linkRoutes from "./routes/link.routes";

const app: Application = express()
const port: number = 3001

app.use(express.json())
app.use(express.urlencoded())
app.use(linkRoutes)

app.listen(port, function () {
  console.log(`App is listening on port ${port}`)
})

You can now call your API with a tool like Postman to create new links, and call the redirect endpoint to see that the redirection works well too.

Leave a Reply

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