DEV Community

Cover image for Building URL Shortener with MongoDB, Express Framework And TypeScript
Itachi Uchiha
Itachi Uchiha

Posted on

Building URL Shortener with MongoDB, Express Framework And TypeScript

This post was first published on my blog.

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 the 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
Enter fullscreen mode Exit fullscreen mode

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 ."
}
Enter fullscreen mode Exit fullscreen mode

tsconfig.json

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

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;
}
Enter fullscreen mode Exit fullscreen mode
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')
        }

    }

})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

MongoDB

To connect MongoDB, we need to have a MongoDB server. Instead of install 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
Enter fullscreen mode Exit fullscreen mode

That's all. Let's run the application :)

npm run dev
Enter fullscreen mode Exit fullscreen mode

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

Latest comments (5)

Collapse
 
karfau profile image
Christian Bewernitz

Thx for sharing.

I would say nowadays there is a lot of potential for reducing the amount of resources required for such a service.
Especially when "going cloud" any services that need to run 24/7 are expensive and a proper production MongoDB setup (in case sth needs to scale) costs quite an amount of money in the cloud.

Collapse
 
fcpauldiaz profile image
Pablo Díaz

URL collision should be checked ?

Collapse
 
itachiuchiha profile image
Itachi Uchiha

Yes, I think it must be checked. But in this example, I didn’t because I didn’t want to long post 🤓

Collapse
 
idangozlan profile image
Idan Gozlan

It's just another 4 lines of code.... I think you should add it

Thread Thread
 
itachiuchiha profile image
Itachi Uchiha

Ah, I didn't update the post. Someone sent a pull request to repo. I will add :)