Building URL Shortener with MongoDB, Express Framework And TypeScript

Hi, in the last post I published, I talked about Express Framework and TypeScript. In this post, I’ll use that structure.

So, I won’t talk about what structure we will use.

Before Starting

Before we starting, we’ll use MongoDB for this project and to get environment variable values, we’ll use dotenv package.

nodemon: Nick Taylor suggested to me. Using nodemon you don’t need to stop-start your applications. It’s already doing this for you.

mongoose: A driver to connect MongoDB.

dotenv: A package to get environment variable values.

Install Packages

npm i typescript nodemon express mongoose pug ts-node dotenv @types/node @types/mongoose @types/express

Let’s edit the scripts section in the package.json file.

"scripts": {
   "dev": "nodemon src/server.ts",
   "start": "ts-node dist/server.js",
   "build": "tsc -p ."
}

tsconfig.json

{
    "compilerOptions": {
        "sourceMap": true,
        "target": "es6",
        "module": "commonjs",
        "outDir": "./dist",
        "baseUrl": "./src"
    },
    "include": [
        "src/**/*.ts"
    ],
    "exclude": [
        "node_modules"
    ]
}

Let’s create a project structure

public

css

In this folder, we will have two CSS files named bootstrap.css and app.css. In bootstrap.css file, we’ll be used bootstrap 4.x. And the app.css file we’ll be used for custom styles.

app.css

.right {
    float: inline-end;
}
js

In this folder, we will have a file named app.js. Client-side operations will be here.

app.js

const btnShort = document.getElementById('btn-short')
const url = document.getElementById('url')
const urlAlert = document.getElementById('url-alert')
const urlAlertText = document.getElementById('url-alert-text')

const validURL = (str) => {
    const pattern = new RegExp('^(https?:\\/\\/)?'+
      '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|'+
      '((\\d{1,3}\\.){3}\\d{1,3}))'+
      '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'+
      '(\\?[;&a-z\\d%_.~+=-]*)?'+
      '(\\#[-a-z\\d_]*)?$','i');

    return !!pattern.test(str);
}

function saveClipBoard(data) {
    var dummy = document.createElement('input');
    var text = data;

    document.body.appendChild(dummy);
    dummy.value = text;
    dummy.select();
    var success = document.execCommand('copy');
    document.body.removeChild(dummy);

    return success;
}

const shortenerResponse = (isValidUrl, serverMessage) => {

    let message = ''

    if (isValidUrl) {
        urlAlert.classList.remove('alert-danger')
        urlAlert.classList.add('alert-success')
        urlAlert.classList.remove('invisible')

        message = `
            <strong>Your URL:</strong> 
            <a id="shorted-url" href="${serverMessage}" target="_blank">${serverMessage}</a>
            <button class="btn btn-sm btn-primary right" id="btn-copy-link">Copy</button>
            <span class="mr-2 right d-none" id="copied">Copied</span>

        `
    } else {
        urlAlert.classList.remove('alert-success')
        urlAlert.classList.add('alert-danger')
        urlAlert.classList.remove('invisible')

        message = `<strong>Warning:</strong> ${serverMessage}`
    }

    urlAlertText.innerHTML = message
}

url.addEventListener('keypress', (e) => {
    if (e.which == 13 || e.keyCode == 13 || e.key == 'Enter') {
        btnShort.click()
    }
})

btnShort.addEventListener('click', async () => {

    const longUrl = url.value

    const isValidUrl = validURL(longUrl)

    if(isValidUrl) {
        const response = await fetch('/create', {
            method: 'POST',
            body: JSON.stringify({
                url: longUrl
            }),
            headers: {
                'Content-Type': 'application/json'
            }
        }).then(resp => resp.json())

        let success = response.success
        let message = '' 

        if(success) {
            const { url } = response
            message = `${window.location.origin}/${url}`
        } else {
            message = `URL couldn't shortened`
        }

        shortenerResponse(success, message)

    } else {
        shortenerResponse(isValidUrl, 'Please enter a correct URL')
    }    
})

document.addEventListener('click', (e) => {
    if (e.target && e.target.id == 'btn-copy-link') {
        const shortedUrl = document.getElementById("shorted-url")

        const isCopied = saveClipBoard(shortedUrl.href)

        if (isCopied) {
            document.getElementById('copied').classList.remove('d-none')
        }

    }

})

src

controllers

In this folder, we’ll have controllers and their model and interface files.

controllers/shortener.controller.ts

In this controller, we will insert a long URL to the Mongo Database. By the way, we didn’t have a MongoDB connection yet.

generateRandomUrl: A private method to generate random characters. It expects a character length number.

index: An async method to show index page.

get: An async method to get short URL information. It expects shortcode as a parameter. Like: http://example.com/abc12

create: An async method to short long URL. Firstly, it looks up the long URL. If it exists, it will show the shortcode in the MongoDB.

Using shortenerModel we can save documents to MongoDB and search in MongoDB.

import * as express from 'express'
import { Request, Response } from 'express'
import IControllerBase from 'interfaces/IControllerBase.interface'

import shortenerModel from './shortener.model'
import IShortener from './shortener.interface';

class ShortenerController implements IControllerBase {
    public path = '/'
    public router = express.Router()

    constructor() {
        this.initRoutes()
    }

    public initRoutes() {
        this.router.get('/', this.index)
        this.router.get('/:shortcode', this.get)
        this.router.post('/create', this.create)
    }

    private generateRandomUrl(length: Number) {

        const possibleChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";

        let urlChars = "";

        for (var i = 0; i < length; i++) {
            urlChars += possibleChars.charAt(Math.floor(Math.random() * possibleChars.length));
        }

        return urlChars;
    }

    index = async(req: Request, res: Response) => {

        res.render('home/index')
    }

    get = async(req: Request, res: Response) => {

        const { shortcode } = req.params

        const data: IShortener = {
            shortUrl: shortcode
        }

        const urlInfo = await shortenerModel.findOne(data)

        if (urlInfo != null) {
            res.redirect(302, urlInfo.longUrl)
        } else {
            res.render('home/not-found')
        }
    }

    create = async(req: express.Request, res: express.Response) => {

        const { url } = req.body

        const data: IShortener = {
            longUrl: url
        }

        let urlInfo = await shortenerModel.findOne(data)

        if (urlInfo == null) {
            const shortCode = this.generateRandomUrl(5)

            const shortData: IShortener = {
                longUrl: url,
                shortUrl: shortCode
            }

            const shortenerData = new shortenerModel(shortData)

            urlInfo = await shortenerData.save()
        }

        res.json({
            success: true,
            message: 'URL Shortened',
            url: urlInfo.shortUrl
        })

    }
}

export default ShortenerController
controllers/shortener.interface.ts

In this interface, we’re using an interface named ISHortener. It has two optional parameters.

interface IShortener {
    longUrl?: string,
    shortUrl?: string
}

export default IShortener
controllers/shortener.model.ts

In this file, we’re building a mongoose schema. It has two optional parameters such as shortener.interface.ts. Also, this model expects IShortener.

import * as mongoose from 'mongoose'
import IShortener from './shortener.interface'

const shortenerSchema = new mongoose.Schema({
    longUrl: String,
    shortUrl: String
})

const shortenerModel = mongoose.model<IShortener & mongoose.Document>('Shortener', shortenerSchema);

export default shortenerModel;

interfaces

In this folder, we’ll only have one interface file. That will be IControllerBase.

interfaces/IControllerBase.interface.ts
interface IControllerBase {
    initRoutes(): any
}

export default IControllerBase

middleware

There is nothing here, we have created this folder, in case you need middleware.

src/app.ts

In this file, we’ll connect to the MongoDB. We’re also using dotenv to get environment variables.

initDatabase: We’re connecting MongoDB here.

import * as express from 'express'
import { Application } from 'express'
import * as mongoose from 'mongoose';
import 'dotenv/config';

class App {
    public app: Application
    public port: number

    constructor(appInit: { port: number; middleWares: any; controllers: any; }) {
        this.app = express()
        this.port = appInit.port

        this.initDatabase()
        this.middlewares(appInit.middleWares)
        this.routes(appInit.controllers)
        this.assets()
        this.template()
    }

    private middlewares(middleWares: { forEach: (arg0: (middleWare: any) => void) => void; }) {
        middleWares.forEach(middleWare => {
            this.app.use(middleWare)
        })
    }

    private routes(controllers: { forEach: (arg0: (controller: any) => void) => void; }) {
        controllers.forEach(controller => {
            this.app.use('/', controller.router)
        })
    }

    private initDatabase() {
        const {
            MONGO_USER,
            MONGO_PASSWORD,
            MONGO_PATH
        } = process.env

        mongoose.connect(`mongodb+srv://${MONGO_USER}:${MONGO_PASSWORD}${MONGO_PATH}`, { 
            useCreateIndex: true,
            useNewUrlParser: true,
            useFindAndModify: false, 
            useUnifiedTopology: true
        })
    }

    private assets() {
        this.app.use(express.static('public'))
        this.app.use(express.static('views'))
    }

    private template() {
        this.app.set('view engine', 'pug')
    }

    public listen() {
        this.app.listen(this.port, () => {
            console.log(`App listening on the http://localhost:${this.port}`)
        })
    }
}

export default App

src/server.ts

This is a file to serve the application.

import App from './app'
import * as bodyParser from 'body-parser'
import ShortenerController from './controllers/shortener/shortener.controller'

const app = new App({
    port: 5000,
    controllers: [
        new ShortenerController()
    ],
    middleWares: [
        bodyParser.json(),
        bodyParser.urlencoded({ extended: true }),
    ]
})

app.listen()

views

In this folder, we’ll have view files.

views/home/home.pug

<!DOCTYPE html>
html(lang="en")
    head
        meta(charset="UTF-8")
        meta(name="viewport", content="width=device-width, initial-scale=1.0")
        meta(http-equiv="X-UA-Compatible", content="ie=edge")
        link(rel="stylesheet", href="css/bootstrap.css")
        link(rel="stylesheet", href="css/app.css")
        title TypeScript URL Shortener!
    body
        main(class="container")
            div(class="jumbotron")
                div(class="row")
                    div(class="col-md-12 align-self-center")
                        h1(class="text-center") URL Shortener
                        label(for="url") URL
                        div(class="input-group")
                            input.form-control(type="text", id="url", role="url", aria-label="Short URL")
                            div(class="input-group-append")
                                button(class="btn btn-md btn-danger", id="btn-short", role="button", aria-label="Short URL Button") Short URL

                div(class="row")
                    div(class="col-md-12")
                        div(class="alert alert-danger invisible mt-3", id="url-alert" role="alert")
                            span(id="url-alert-text") URL shorthened

        footer(class="footer")
            div(class="container")
                span(class="text-muted") TypeScript URL Shortener!

        script(src="js/app.js")

MongoDB

To connect MongoDB, we need to have a MongoDB server. Instead of installing a new MongoDB server, we’ll use MongoDB Cloud. There is a Free Tier. You don’t need to pay for it.

After you created an account, your cluster will be preparing. There are somethings you have to do. The first one, you need to create a database user.

MongoDB Admin

The last thing you have to do, you need to give IP permission. In the MongoDB cloud, you have to do that.

MongoDB Network

.env

In this file, we’ll have MongoDB information;

MONGO_USER=YOUR MONGO USERNAME
MONGO_PASSWORD=YOUR MONGO PASSWORD
MONGO_PATH=YOUR MONGO DATABASE URL

That’s all. Let’s run the application 🙂

npm run dev

Screenshot

URL Shortener Screenshot

Conclusion

This was an excellent experience for me. I really loved TypeScript and Express with MongoDB.

GitHub: https://github.com/aligoren/ts-url-shortener