{"id":20408862,"url":"https://github.com/dockersamples/link-shortener-typescript","last_synced_at":"2025-04-12T15:40:53.977Z","repository":{"id":42376392,"uuid":"510691805","full_name":"dockersamples/link-shortener-typescript","owner":"dockersamples","description":"A Simple URL Shortener built using TypeScript and Nest.js powered with Docker","archived":false,"fork":false,"pushed_at":"2024-11-27T18:34:41.000Z","size":353,"stargazers_count":22,"open_issues_count":0,"forks_count":8,"subscribers_count":3,"default_branch":"main","last_synced_at":"2025-03-26T10:11:21.198Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/dockersamples.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2022-07-05T10:38:44.000Z","updated_at":"2025-03-14T08:15:03.000Z","dependencies_parsed_at":"2023-01-25T22:00:37.513Z","dependency_job_id":null,"html_url":"https://github.com/dockersamples/link-shortener-typescript","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dockersamples%2Flink-shortener-typescript","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dockersamples%2Flink-shortener-typescript/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dockersamples%2Flink-shortener-typescript/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dockersamples%2Flink-shortener-typescript/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/dockersamples","download_url":"https://codeload.github.com/dockersamples/link-shortener-typescript/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":248590793,"owners_count":21129891,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-11-15T05:37:31.187Z","updated_at":"2025-04-12T15:40:53.944Z","avatar_url":"https://github.com/dockersamples.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Example URL Shortener\n\n\n\n\n[![URL Shortener](https://img.youtube.com/vi/RqL24zTrIlM/hqdefault.jpg)](https://www.youtube.com/embed/RqL24zTrIlM)\n\n\nBuilding a sample URL shortener using [TypeScript](https://www.typescriptlang.org/), [Nest](https://nestjs.com/) and Docker Desktop\n\n\n## Getting started\n\nDownload Docker Desktop for [Mac](https://desktop.docker.com/mac/main/amd64/Docker.dmg?utm_source=docker\u0026utm_medium=webreferral\u0026utm_campaign=dd-smartbutton\u0026utm_location=module) , [Linux](https://docs.docker.com/desktop/linux/install/) and [Windows](https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe?utm_source=docker\u0026utm_medium=webreferral\u0026utm_campaign=dd-smartbutton\u0026utm_location=header). Docker Compose will be automatically installed. \n\nIf you're using Docker Desktop on Windows, you can run the Linux version by switching to Linux containers, or run the Windows containers version.\n\n\n## Running the application\n\n```\n docker compose up -d –build\n```\n\n## Shortening the new link\n\nYou can use ```curl``` command to shorten the link:\n\n\n```\n curl -XPOST -d \"url=https://docker.com\" localhost:3000/shorten\n```\n\nHere's the response:\n\n\n```\n {\"hash\":\"l6r71d\"}\n```\n\nThe hash might differ on your machine. You can use it to redirect to the original link. Open a web browser and visit ```http://localhost:3000/l6r71d``` to access the official Docker website.\n\n\n## Build From Scratch\n\n### Create Nest.js Project\nTo install Nest.js CLI globally using NPM:\n\n```bash\nnpm install -g @nestjs/cli\n```\n\nCreate a directory named `backend` and get into it:\n\n```bash\nmkdir -p backend\ncd backend\n```\n\nNow, create a Nest.js project there:\n\n```bash\nnest new link-shortener\n```\n\nThen, when asked to pick a package manager, pick `npm` just by pressing enter.\n\nA git repo is created under `link-shortener` with everything included in it.\nAs we are already inside a git repo, let's remove the `.git` directory in the Nest.js\nproject and commit the whole project instead.\n\n```bash\ncd link-shortener\nrm -rf .git\ngit add .\ngit commit -m \"Create Nest.js project\"\n```\n\n### Add Shortening Logic\n\nLet's take a look into the `src` directory:\n\n```\nsrc\n├── app.controller.spec.ts\n├── app.controller.ts\n├── app.module.ts\n├── app.service.ts\n└── main.ts\n```\n\n- `app.controller.ts` is the HTTP controller.\n- `app.controller.spec.ts` is where the tests for the controller reside.\n- `app.module.ts` is the module definitions (for dependency injection, etc).\n- `app.service.ts` is where the service resides.\n\nSome context: The business logic should reside in the service layer,\nand the controller in charge of serving the logic to I/O device, namely HTTP.\n\nAs we want to create an endpoint that shortens URLs, let's create it in the controller, `app.controller.ts`:\n\n```typescript\nimport { Controller, Get, Post, Query } from '@nestjs/common';\nimport { AppService } from './app.service';\nimport { Observable, of } from \"rxjs\";\n\n@Controller()\nexport class AppController {\n  constructor(private readonly appService: AppService) {}\n\n  @Get()\n  getHello(): string {\n    return this.appService.getHello();\n  }\n\n  @Post('shorten')\n  shorten(@Query('url') url: string): Observable\u003cstring\u003e {\n    // TODO implement\n    return of(undefined);\n  }\n}\n```\n\nSome explanation:\n- The function is mapped to the POST requests to the URL `/shorten`,\n- The variable `url` is a parameter that we expect is going to be sent with the request,\n- The parameter `url` is expected to have the type of `string`,\n- The function is async and returns an observable.\n\nTo learn more about observables, take a look [RxJS.dev](https://rxjs.dev/).\n\nNow that we have an empty function, let's write a test for it, in `app.controller.spec.ts`:\n\n```typescript\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { AppController } from './app.controller';\nimport { AppService } from './app.service';\nimport { tap } from \"rxjs\";\n\ndescribe('AppController', () =\u003e {\n  let appController: AppController;\n\n  beforeEach(async () =\u003e {\n    const app: TestingModule = await Test.createTestingModule({\n      controllers: [AppController],\n      providers: [AppService],\n    }).compile();\n\n    appController = app.get\u003cAppController\u003e(AppController);\n  });\n\n  describe('root', () =\u003e {\n    it('should return \"Hello World!\"', () =\u003e {\n      expect(appController.getHello()).toBe('Hello World!');\n    });\n  });\n\n  // here\n  describe('shorten', () =\u003e {\n    it('should return a valid string', done =\u003e {\n      const url = 'aerabi.com';\n      appController\n        .shorten(url)\n        .pipe(tap(hash =\u003e expect(hash).toBeTruthy()))\n        .subscribe({ complete: done });\n    })\n  });\n});\n```\n\nRun the tests to make sure it fails:\n\n```bash\nnpm test\n```\n\nNow, let's create a function in the service layer, `app.service.ts`:\n\n```typescript\nimport { Injectable } from '@nestjs/common';\nimport { Observable, of } from \"rxjs\";\n\n@Injectable()\nexport class AppService {\n  getHello(): string {\n    return 'Hello World!';\n  }\n\n  shorten(url: string): Observable\u003cstring\u003e {\n    const hash = Math.random().toString(36).slice(7);\n    return of(hash);\n  }\n}\n```\n\nAnd let's call it in the controller, `app.controller.ts`:\n\n```typescript\nimport { Controller, Get, Post, Query } from '@nestjs/common';\nimport { AppService } from './app.service';\nimport { Observable } from \"rxjs\";\n\n@Controller()\nexport class AppController {\n  constructor(private readonly appService: AppService) {}\n\n  @Get()\n  getHello(): string {\n    return this.appService.getHello();\n  }\n\n  @Post('shorten')\n  shorten(@Query('url') url: string): Observable\u003cstring\u003e {\n    return this.appService.shorten(url);\n  }\n}\n```\n\nLet's run the tests once more:\n\n```bash\nnpm test\n```\n\nA few points to clear here:\n- The function `shorten` in the service layer is sync, why did we wrap into an observable? \n  It's because of being future-proof. In the next stages we're going to save the hash into a DB and that's not sync anymore.\n- Why does the function `shorten` get an argument but never uses it? Again, for the DB.\n\n### Add a Repository\n\nA repository is a layer that is in charge of storing stuff.\nHere, we would want a repository layer to store the mapping between the hashes and their original URLs.\n\nLet's first create an interface for the repository. Create a file named `app.repository.ts` and fill it up as follows:\n\n```typescript\nimport { Observable } from 'rxjs';\n\nexport interface AppRepository {\n  put(hash: string, url: string): Observable\u003cstring\u003e;\n  get(hash: string): Observable\u003cstring\u003e;\n}\n\nexport const AppRepositoryTag = 'AppRepository';\n```\n\nNow, let's create a simple repository that stores the mappings in a hashmap in the memory.\nCreate a file named `app.repository.hashmap.ts`:\n\n```typescript\nimport { AppRepository } from './app.repository';\nimport { Observable, of } from 'rxjs';\n\nexport class AppRepositoryHashmap implements AppRepository {\n  private readonly hashMap: Map\u003cstring, string\u003e;\n\n  constructor() {\n    this.hashMap = new Map\u003cstring, string\u003e();\n  }\n\n  get(hash: string): Observable\u003cstring\u003e {\n    return of(this.hashMap.get(hash));\n  }\n\n  put(hash: string, url: string): Observable\u003cstring\u003e {\n    return of(this.hashMap.set(hash, url).get(hash));\n  }\n}\n```\n\nNow, let's instruct Nest.js that if one asked for `AppRepositoryTag` provide them with `AppRepositoryHashMap`.\nFirst, let's do it in the `app.module.ts`:\n\n```typescript\nimport { Module } from '@nestjs/common';\nimport { AppController } from './app.controller';\nimport { AppService } from './app.service';\nimport { AppRepositoryTag } from './app.repository';\nimport { AppRepositoryHashmap } from './app.repository.hashmap';\n\n@Module({\n  imports: [],\n  controllers: [AppController],\n  providers: [\n    AppService,\n    { provide: AppRepositoryTag, useClass: AppRepositoryHashmap }, // \u003c-- here\n  ],\n})\nexport class AppModule {}\n```\n\nLet's do the same in the test, `app.controller.spec.ts`:\n\n```typescript\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { AppController } from './app.controller';\nimport { AppService } from './app.service';\nimport { tap } from 'rxjs';\nimport { AppRepositoryTag } from './app.repository';\nimport { AppRepositoryHashmap } from './app.repository.hashmap';\n\ndescribe('AppController', () =\u003e {\n  let appController: AppController;\n\n  beforeEach(async () =\u003e {\n    const app: TestingModule = await Test.createTestingModule({\n      controllers: [AppController],\n      providers: [\n        AppService,\n        { provide: AppRepositoryTag, useClass: AppRepositoryHashmap }, // \u003c-- here\n      ],\n    }).compile();\n\n    appController = app.get\u003cAppController\u003e(AppController);\n  });\n\n  . . .\n});\n```\n\nNow, let's go the service layer, `app.service.ts`, and create a `retrieve` function:\n\n```typescript\n. . .\n\n@Injectable()\nexport class AppService {\n  . . .\n\n  retrieve(hash: string): Observable\u003cstring\u003e {\n    return of(undefined);\n  }\n}\n```\n\nAnd then create a test in `app.service.spec.ts`:\n\n```typescript\nimport { Test, TestingModule } from \"@nestjs/testing\";\nimport { AppService } from \"./app.service\";\nimport { AppRepositoryTag } from \"./app.repository\";\nimport { AppRepositoryHashmap } from \"./app.repository.hashmap\";\nimport { mergeMap, tap } from \"rxjs\";\n\ndescribe('AppService', () =\u003e {\n  let appService: AppService;\n\n  beforeEach(async () =\u003e {\n    const app: TestingModule = await Test.createTestingModule({\n      providers: [\n        { provide: AppRepositoryTag, useClass: AppRepositoryHashmap },\n        AppService,\n      ],\n    }).compile();\n\n    appService = app.get\u003cAppService\u003e(AppService);\n  });\n\n  describe('retrieve', () =\u003e {\n    it('should retrieve the saved URL', done =\u003e {\n      const url = 'aerabi.com';\n      appService.shorten(url)\n        .pipe(mergeMap(hash =\u003e appService.retrieve(hash)))\n        .pipe(tap(retrieved =\u003e expect(retrieved).toEqual(url)))\n        .subscribe({ complete: done })\n    });\n  });\n});\n```\n\nRun the tests so that they fail:\n\n```bash\nnpm test\n```\n\nAnd then implement the function to make them pass, in `app.service.ts`:\n\n```typescript\nimport { Inject, Injectable } from '@nestjs/common';\nimport { map, Observable } from 'rxjs';\nimport { AppRepository, AppRepositoryTag } from './app.repository';\n\n@Injectable()\nexport class AppService {\n  constructor(\n    @Inject(AppRepositoryTag) private readonly appRepository: AppRepository,\n  ) {}\n\n  getHello(): string {\n    return 'Hello World!';\n  }\n\n  shorten(url: string): Observable\u003cstring\u003e {\n    const hash = Math.random().toString(36).slice(7);\n    return this.appRepository.put(hash, url).pipe(map(() =\u003e hash)); // \u003c-- here\n  }\n\n  retrieve(hash: string): Observable\u003cstring\u003e {\n    return this.appRepository.get(hash); // \u003c-- and here\n  }\n}\n```\n\nRun the tests again, and they pass. :muscle:\n\n### Add a Real Database\n\nSo far, we created the repositories that store the mappings in memory.\nThat's okay for testing, but not suitable for production, as we'll lose the mappings when the server stops.\n\nRedis is an appropriate database for the job because it is/has a persistent key-value store.\n\nTo add Redis to the stack, let's create a Docker-Compose file with Redis on it.\nCreate a file named `docker-compose.yaml` in the root of the project:\n\n```yaml\nservices:\n  redis:\n    image: 'redis/redis-stack'\n    ports:\n      - '6379:6379'\n      - '8001:8001'\n  dev:\n    image: 'node:16'\n    command: bash -c \"cd /app \u0026\u0026 npm run start:dev\"\n    environment:\n      REDIS_HOST: redis\n      REDIS_PORT: 6379\n    volumes:\n      - './backend/link-shortener:/app'\n    ports:\n      - '3000:3000'\n    depends_on:\n      - redis\n```\n\nInstall Redis package (run this command inside `backend/link-shortener`):\n\n```bash\nnpm install redis@4.1.0 --save\n```\n\nInside `src`, create a repository that uses Redis, `app.repository.redis.ts`:\n\n```typescript\nimport { AppRepository } from './app.repository';\nimport { Observable, from, mergeMap } from 'rxjs';\nimport { createClient, RedisClientType } from 'redis';\n\nexport class AppRepositoryRedis implements AppRepository {\n  private readonly redisClient: RedisClientType;\n\n  constructor() {\n    const host = process.env.REDIS_HOST || 'redis';\n    const port = +process.env.REDIS_PORT || 6379;\n    this.redisClient = createClient({\n      url: `redis://${host}:${port}`,\n    });\n    from(this.redisClient.connect()).subscribe({ error: console.error });\n    this.redisClient.on('connect', () =\u003e console.log('Redis connected'));\n    this.redisClient.on('error', console.error);\n  }\n\n  get(hash: string): Observable\u003cstring\u003e {\n    return from(this.redisClient.get(hash));\n  }\n\n  put(hash: string, url: string): Observable\u003cstring\u003e {\n    return from(this.redisClient.set(hash, url)).pipe(\n            mergeMap(() =\u003e from(this.redisClient.get(hash))),\n    );\n  }\n}\n```\n\nAnd finally change the provider in `app.module.ts` so that the service uses Redis repository instead of the hashmap one:\n\n```typescript\nimport { Module } from '@nestjs/common';\nimport { AppController } from './app.controller';\nimport { AppService } from './app.service';\nimport { AppRepositoryTag } from './app.repository';\nimport { AppRepositoryRedis } from \"./app.repository.redis\";\n\n@Module({\n  imports: [],\n  controllers: [AppController],\n  providers: [\n    AppService,\n    { provide: AppRepositoryTag, useClass: AppRepositoryRedis }, // \u003c-- here\n  ],\n})\nexport class AppModule {}\n```\n\n### Finalize the Backend\n\nNow, head back to `app.controller.ts` and create another endpoint for redirect:\n\n```typescript\nimport { Body, Controller, Get, Param, Post, Redirect } from '@nestjs/common';\nimport { AppService } from './app.service';\nimport { map, Observable, of } from 'rxjs';\n\ninterface ShortenResponse {\n  hash: string;\n}\n\ninterface ErrorResponse {\n  error: string;\n  code: number;\n}\n\n@Controller()\nexport class AppController {\n  constructor(private readonly appService: AppService) {}\n\n  @Get()\n  getHello(): string {\n    return this.appService.getHello();\n  }\n\n  @Post('shorten')\n  shorten(@Body('url') url: string): Observable\u003cShortenResponse | ErrorResponse\u003e {\n    if (!url) {\n      return of({ error: `No url provided. Please provide in the body. E.g. {'url':'https://google.com'}`, code: 400 });\n    }\n    return this.appService.shorten(url).pipe(map(hash =\u003e ({ hash })));\n  }\n\n  @Get(':hash')\n  @Redirect()\n  retrieveAndRedirect(@Param('hash') hash): Observable\u003c{ url: string }\u003e {\n    return this.appService.retrieve(hash).pipe(map(url =\u003e ({ url })));\n  }\n}\n```\n\nRun the whole application using Docker Compose:\n\n```bash\ndocker-cmpose up -d\n```\n\nThen visit the application at [`localhost:3000`](http://localhost:3000) and you should see a \"Hello World!\" message.\nTo shorten a new link, use the following cURL command:\n\n```bash\ncurl -XPOST -d \"url1=https://aerabi.com\" localhost:3000/shorten\n```\n\nTake a look at the response:\n\n```json\n{\"hash\":\"350fzr\"}\n```\n\nThe hash differs on your machine. You can use it to redirect to the original link.\nOpen a web browser and visit [`localhost:3000/350fzr`](http://localhost:3000/350fzr).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdockersamples%2Flink-shortener-typescript","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdockersamples%2Flink-shortener-typescript","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdockersamples%2Flink-shortener-typescript/lists"}