├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── backend └── link-shortener │ ├── .eslintrc.js │ ├── .gitignore │ ├── .prettierrc │ ├── Dockerfile │ ├── README.md │ ├── nest-cli.json │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── app.controller.spec.ts │ ├── app.controller.ts │ ├── app.module.ts │ ├── app.repository.hashmap.ts │ ├── app.repository.redis.ts │ ├── app.repository.ts │ ├── app.service.spec.ts │ ├── app.service.ts │ └── main.ts │ ├── test │ ├── app.e2e-spec.ts │ └── jest-e2e.json │ ├── tsconfig.build.json │ └── tsconfig.json ├── docker-compose.yaml └── typescript_blog.jpg /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contribution Guidelines 2 | 3 | 4 | Since this project is intended to support a specific use case guide, contributions are limited to bug fixes or security issues. If you have a question, feel free to open an issue! 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Docker Samples 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Example URL Shortener 2 | 3 | 4 | 5 | 6 | [![URL Shortener](https://img.youtube.com/vi/RqL24zTrIlM/hqdefault.jpg)](https://www.youtube.com/embed/RqL24zTrIlM) 7 | 8 | 9 | Building a sample URL shortener using [TypeScript](https://www.typescriptlang.org/), [Nest](https://nestjs.com/) and Docker Desktop 10 | 11 | 12 | ## Getting started 13 | 14 | Download Docker Desktop for [Mac](https://desktop.docker.com/mac/main/amd64/Docker.dmg?utm_source=docker&utm_medium=webreferral&utm_campaign=dd-smartbutton&utm_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&utm_medium=webreferral&utm_campaign=dd-smartbutton&utm_location=header). Docker Compose will be automatically installed. 15 | 16 | If you're using Docker Desktop on Windows, you can run the Linux version by switching to Linux containers, or run the Windows containers version. 17 | 18 | 19 | ## Running the application 20 | 21 | ``` 22 | docker compose up -d –build 23 | ``` 24 | 25 | ## Shortening the new link 26 | 27 | You can use ```curl``` command to shorten the link: 28 | 29 | 30 | ``` 31 | curl -XPOST -d "url=https://docker.com" localhost:3000/shorten 32 | ``` 33 | 34 | Here's the response: 35 | 36 | 37 | ``` 38 | {"hash":"l6r71d"} 39 | ``` 40 | 41 | The 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. 42 | 43 | 44 | ## Build From Scratch 45 | 46 | ### Create Nest.js Project 47 | To install Nest.js CLI globally using NPM: 48 | 49 | ```bash 50 | npm install -g @nestjs/cli 51 | ``` 52 | 53 | Create a directory named `backend` and get into it: 54 | 55 | ```bash 56 | mkdir -p backend 57 | cd backend 58 | ``` 59 | 60 | Now, create a Nest.js project there: 61 | 62 | ```bash 63 | nest new link-shortener 64 | ``` 65 | 66 | Then, when asked to pick a package manager, pick `npm` just by pressing enter. 67 | 68 | A git repo is created under `link-shortener` with everything included in it. 69 | As we are already inside a git repo, let's remove the `.git` directory in the Nest.js 70 | project and commit the whole project instead. 71 | 72 | ```bash 73 | cd link-shortener 74 | rm -rf .git 75 | git add . 76 | git commit -m "Create Nest.js project" 77 | ``` 78 | 79 | ### Add Shortening Logic 80 | 81 | Let's take a look into the `src` directory: 82 | 83 | ``` 84 | src 85 | ├── app.controller.spec.ts 86 | ├── app.controller.ts 87 | ├── app.module.ts 88 | ├── app.service.ts 89 | └── main.ts 90 | ``` 91 | 92 | - `app.controller.ts` is the HTTP controller. 93 | - `app.controller.spec.ts` is where the tests for the controller reside. 94 | - `app.module.ts` is the module definitions (for dependency injection, etc). 95 | - `app.service.ts` is where the service resides. 96 | 97 | Some context: The business logic should reside in the service layer, 98 | and the controller in charge of serving the logic to I/O device, namely HTTP. 99 | 100 | As we want to create an endpoint that shortens URLs, let's create it in the controller, `app.controller.ts`: 101 | 102 | ```typescript 103 | import { Controller, Get, Post, Query } from '@nestjs/common'; 104 | import { AppService } from './app.service'; 105 | import { Observable, of } from "rxjs"; 106 | 107 | @Controller() 108 | export class AppController { 109 | constructor(private readonly appService: AppService) {} 110 | 111 | @Get() 112 | getHello(): string { 113 | return this.appService.getHello(); 114 | } 115 | 116 | @Post('shorten') 117 | shorten(@Query('url') url: string): Observable { 118 | // TODO implement 119 | return of(undefined); 120 | } 121 | } 122 | ``` 123 | 124 | Some explanation: 125 | - The function is mapped to the POST requests to the URL `/shorten`, 126 | - The variable `url` is a parameter that we expect is going to be sent with the request, 127 | - The parameter `url` is expected to have the type of `string`, 128 | - The function is async and returns an observable. 129 | 130 | To learn more about observables, take a look [RxJS.dev](https://rxjs.dev/). 131 | 132 | Now that we have an empty function, let's write a test for it, in `app.controller.spec.ts`: 133 | 134 | ```typescript 135 | import { Test, TestingModule } from '@nestjs/testing'; 136 | import { AppController } from './app.controller'; 137 | import { AppService } from './app.service'; 138 | import { tap } from "rxjs"; 139 | 140 | describe('AppController', () => { 141 | let appController: AppController; 142 | 143 | beforeEach(async () => { 144 | const app: TestingModule = await Test.createTestingModule({ 145 | controllers: [AppController], 146 | providers: [AppService], 147 | }).compile(); 148 | 149 | appController = app.get(AppController); 150 | }); 151 | 152 | describe('root', () => { 153 | it('should return "Hello World!"', () => { 154 | expect(appController.getHello()).toBe('Hello World!'); 155 | }); 156 | }); 157 | 158 | // here 159 | describe('shorten', () => { 160 | it('should return a valid string', done => { 161 | const url = 'aerabi.com'; 162 | appController 163 | .shorten(url) 164 | .pipe(tap(hash => expect(hash).toBeTruthy())) 165 | .subscribe({ complete: done }); 166 | }) 167 | }); 168 | }); 169 | ``` 170 | 171 | Run the tests to make sure it fails: 172 | 173 | ```bash 174 | npm test 175 | ``` 176 | 177 | Now, let's create a function in the service layer, `app.service.ts`: 178 | 179 | ```typescript 180 | import { Injectable } from '@nestjs/common'; 181 | import { Observable, of } from "rxjs"; 182 | 183 | @Injectable() 184 | export class AppService { 185 | getHello(): string { 186 | return 'Hello World!'; 187 | } 188 | 189 | shorten(url: string): Observable { 190 | const hash = Math.random().toString(36).slice(7); 191 | return of(hash); 192 | } 193 | } 194 | ``` 195 | 196 | And let's call it in the controller, `app.controller.ts`: 197 | 198 | ```typescript 199 | import { Controller, Get, Post, Query } from '@nestjs/common'; 200 | import { AppService } from './app.service'; 201 | import { Observable } from "rxjs"; 202 | 203 | @Controller() 204 | export class AppController { 205 | constructor(private readonly appService: AppService) {} 206 | 207 | @Get() 208 | getHello(): string { 209 | return this.appService.getHello(); 210 | } 211 | 212 | @Post('shorten') 213 | shorten(@Query('url') url: string): Observable { 214 | return this.appService.shorten(url); 215 | } 216 | } 217 | ``` 218 | 219 | Let's run the tests once more: 220 | 221 | ```bash 222 | npm test 223 | ``` 224 | 225 | A few points to clear here: 226 | - The function `shorten` in the service layer is sync, why did we wrap into an observable? 227 | 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. 228 | - Why does the function `shorten` get an argument but never uses it? Again, for the DB. 229 | 230 | ### Add a Repository 231 | 232 | A repository is a layer that is in charge of storing stuff. 233 | Here, we would want a repository layer to store the mapping between the hashes and their original URLs. 234 | 235 | Let's first create an interface for the repository. Create a file named `app.repository.ts` and fill it up as follows: 236 | 237 | ```typescript 238 | import { Observable } from 'rxjs'; 239 | 240 | export interface AppRepository { 241 | put(hash: string, url: string): Observable; 242 | get(hash: string): Observable; 243 | } 244 | 245 | export const AppRepositoryTag = 'AppRepository'; 246 | ``` 247 | 248 | Now, let's create a simple repository that stores the mappings in a hashmap in the memory. 249 | Create a file named `app.repository.hashmap.ts`: 250 | 251 | ```typescript 252 | import { AppRepository } from './app.repository'; 253 | import { Observable, of } from 'rxjs'; 254 | 255 | export class AppRepositoryHashmap implements AppRepository { 256 | private readonly hashMap: Map; 257 | 258 | constructor() { 259 | this.hashMap = new Map(); 260 | } 261 | 262 | get(hash: string): Observable { 263 | return of(this.hashMap.get(hash)); 264 | } 265 | 266 | put(hash: string, url: string): Observable { 267 | return of(this.hashMap.set(hash, url).get(hash)); 268 | } 269 | } 270 | ``` 271 | 272 | Now, let's instruct Nest.js that if one asked for `AppRepositoryTag` provide them with `AppRepositoryHashMap`. 273 | First, let's do it in the `app.module.ts`: 274 | 275 | ```typescript 276 | import { Module } from '@nestjs/common'; 277 | import { AppController } from './app.controller'; 278 | import { AppService } from './app.service'; 279 | import { AppRepositoryTag } from './app.repository'; 280 | import { AppRepositoryHashmap } from './app.repository.hashmap'; 281 | 282 | @Module({ 283 | imports: [], 284 | controllers: [AppController], 285 | providers: [ 286 | AppService, 287 | { provide: AppRepositoryTag, useClass: AppRepositoryHashmap }, // <-- here 288 | ], 289 | }) 290 | export class AppModule {} 291 | ``` 292 | 293 | Let's do the same in the test, `app.controller.spec.ts`: 294 | 295 | ```typescript 296 | import { Test, TestingModule } from '@nestjs/testing'; 297 | import { AppController } from './app.controller'; 298 | import { AppService } from './app.service'; 299 | import { tap } from 'rxjs'; 300 | import { AppRepositoryTag } from './app.repository'; 301 | import { AppRepositoryHashmap } from './app.repository.hashmap'; 302 | 303 | describe('AppController', () => { 304 | let appController: AppController; 305 | 306 | beforeEach(async () => { 307 | const app: TestingModule = await Test.createTestingModule({ 308 | controllers: [AppController], 309 | providers: [ 310 | AppService, 311 | { provide: AppRepositoryTag, useClass: AppRepositoryHashmap }, // <-- here 312 | ], 313 | }).compile(); 314 | 315 | appController = app.get(AppController); 316 | }); 317 | 318 | . . . 319 | }); 320 | ``` 321 | 322 | Now, let's go the service layer, `app.service.ts`, and create a `retrieve` function: 323 | 324 | ```typescript 325 | . . . 326 | 327 | @Injectable() 328 | export class AppService { 329 | . . . 330 | 331 | retrieve(hash: string): Observable { 332 | return of(undefined); 333 | } 334 | } 335 | ``` 336 | 337 | And then create a test in `app.service.spec.ts`: 338 | 339 | ```typescript 340 | import { Test, TestingModule } from "@nestjs/testing"; 341 | import { AppService } from "./app.service"; 342 | import { AppRepositoryTag } from "./app.repository"; 343 | import { AppRepositoryHashmap } from "./app.repository.hashmap"; 344 | import { mergeMap, tap } from "rxjs"; 345 | 346 | describe('AppService', () => { 347 | let appService: AppService; 348 | 349 | beforeEach(async () => { 350 | const app: TestingModule = await Test.createTestingModule({ 351 | providers: [ 352 | { provide: AppRepositoryTag, useClass: AppRepositoryHashmap }, 353 | AppService, 354 | ], 355 | }).compile(); 356 | 357 | appService = app.get(AppService); 358 | }); 359 | 360 | describe('retrieve', () => { 361 | it('should retrieve the saved URL', done => { 362 | const url = 'aerabi.com'; 363 | appService.shorten(url) 364 | .pipe(mergeMap(hash => appService.retrieve(hash))) 365 | .pipe(tap(retrieved => expect(retrieved).toEqual(url))) 366 | .subscribe({ complete: done }) 367 | }); 368 | }); 369 | }); 370 | ``` 371 | 372 | Run the tests so that they fail: 373 | 374 | ```bash 375 | npm test 376 | ``` 377 | 378 | And then implement the function to make them pass, in `app.service.ts`: 379 | 380 | ```typescript 381 | import { Inject, Injectable } from '@nestjs/common'; 382 | import { map, Observable } from 'rxjs'; 383 | import { AppRepository, AppRepositoryTag } from './app.repository'; 384 | 385 | @Injectable() 386 | export class AppService { 387 | constructor( 388 | @Inject(AppRepositoryTag) private readonly appRepository: AppRepository, 389 | ) {} 390 | 391 | getHello(): string { 392 | return 'Hello World!'; 393 | } 394 | 395 | shorten(url: string): Observable { 396 | const hash = Math.random().toString(36).slice(7); 397 | return this.appRepository.put(hash, url).pipe(map(() => hash)); // <-- here 398 | } 399 | 400 | retrieve(hash: string): Observable { 401 | return this.appRepository.get(hash); // <-- and here 402 | } 403 | } 404 | ``` 405 | 406 | Run the tests again, and they pass. :muscle: 407 | 408 | ### Add a Real Database 409 | 410 | So far, we created the repositories that store the mappings in memory. 411 | That's okay for testing, but not suitable for production, as we'll lose the mappings when the server stops. 412 | 413 | Redis is an appropriate database for the job because it is/has a persistent key-value store. 414 | 415 | To add Redis to the stack, let's create a Docker-Compose file with Redis on it. 416 | Create a file named `docker-compose.yaml` in the root of the project: 417 | 418 | ```yaml 419 | services: 420 | redis: 421 | image: 'redis/redis-stack' 422 | ports: 423 | - '6379:6379' 424 | - '8001:8001' 425 | dev: 426 | image: 'node:16' 427 | command: bash -c "cd /app && npm run start:dev" 428 | environment: 429 | REDIS_HOST: redis 430 | REDIS_PORT: 6379 431 | volumes: 432 | - './backend/link-shortener:/app' 433 | ports: 434 | - '3000:3000' 435 | depends_on: 436 | - redis 437 | ``` 438 | 439 | Install Redis package (run this command inside `backend/link-shortener`): 440 | 441 | ```bash 442 | npm install redis@4.1.0 --save 443 | ``` 444 | 445 | Inside `src`, create a repository that uses Redis, `app.repository.redis.ts`: 446 | 447 | ```typescript 448 | import { AppRepository } from './app.repository'; 449 | import { Observable, from, mergeMap } from 'rxjs'; 450 | import { createClient, RedisClientType } from 'redis'; 451 | 452 | export class AppRepositoryRedis implements AppRepository { 453 | private readonly redisClient: RedisClientType; 454 | 455 | constructor() { 456 | const host = process.env.REDIS_HOST || 'redis'; 457 | const port = +process.env.REDIS_PORT || 6379; 458 | this.redisClient = createClient({ 459 | url: `redis://${host}:${port}`, 460 | }); 461 | from(this.redisClient.connect()).subscribe({ error: console.error }); 462 | this.redisClient.on('connect', () => console.log('Redis connected')); 463 | this.redisClient.on('error', console.error); 464 | } 465 | 466 | get(hash: string): Observable { 467 | return from(this.redisClient.get(hash)); 468 | } 469 | 470 | put(hash: string, url: string): Observable { 471 | return from(this.redisClient.set(hash, url)).pipe( 472 | mergeMap(() => from(this.redisClient.get(hash))), 473 | ); 474 | } 475 | } 476 | ``` 477 | 478 | And finally change the provider in `app.module.ts` so that the service uses Redis repository instead of the hashmap one: 479 | 480 | ```typescript 481 | import { Module } from '@nestjs/common'; 482 | import { AppController } from './app.controller'; 483 | import { AppService } from './app.service'; 484 | import { AppRepositoryTag } from './app.repository'; 485 | import { AppRepositoryRedis } from "./app.repository.redis"; 486 | 487 | @Module({ 488 | imports: [], 489 | controllers: [AppController], 490 | providers: [ 491 | AppService, 492 | { provide: AppRepositoryTag, useClass: AppRepositoryRedis }, // <-- here 493 | ], 494 | }) 495 | export class AppModule {} 496 | ``` 497 | 498 | ### Finalize the Backend 499 | 500 | Now, head back to `app.controller.ts` and create another endpoint for redirect: 501 | 502 | ```typescript 503 | import { Body, Controller, Get, Param, Post, Redirect } from '@nestjs/common'; 504 | import { AppService } from './app.service'; 505 | import { map, Observable, of } from 'rxjs'; 506 | 507 | interface ShortenResponse { 508 | hash: string; 509 | } 510 | 511 | interface ErrorResponse { 512 | error: string; 513 | code: number; 514 | } 515 | 516 | @Controller() 517 | export class AppController { 518 | constructor(private readonly appService: AppService) {} 519 | 520 | @Get() 521 | getHello(): string { 522 | return this.appService.getHello(); 523 | } 524 | 525 | @Post('shorten') 526 | shorten(@Body('url') url: string): Observable { 527 | if (!url) { 528 | return of({ error: `No url provided. Please provide in the body. E.g. {'url':'https://google.com'}`, code: 400 }); 529 | } 530 | return this.appService.shorten(url).pipe(map(hash => ({ hash }))); 531 | } 532 | 533 | @Get(':hash') 534 | @Redirect() 535 | retrieveAndRedirect(@Param('hash') hash): Observable<{ url: string }> { 536 | return this.appService.retrieve(hash).pipe(map(url => ({ url }))); 537 | } 538 | } 539 | ``` 540 | 541 | Run the whole application using Docker Compose: 542 | 543 | ```bash 544 | docker-cmpose up -d 545 | ``` 546 | 547 | Then visit the application at [`localhost:3000`](http://localhost:3000) and you should see a "Hello World!" message. 548 | To shorten a new link, use the following cURL command: 549 | 550 | ```bash 551 | curl -XPOST -d "url1=https://aerabi.com" localhost:3000/shorten 552 | ``` 553 | 554 | Take a look at the response: 555 | 556 | ```json 557 | {"hash":"350fzr"} 558 | ``` 559 | 560 | The hash differs on your machine. You can use it to redirect to the original link. 561 | Open a web browser and visit [`localhost:3000/350fzr`](http://localhost:3000/350fzr). 562 | -------------------------------------------------------------------------------- /backend/link-shortener/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir : __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /backend/link-shortener/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json -------------------------------------------------------------------------------- /backend/link-shortener/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /backend/link-shortener/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16 2 | 3 | COPY . . 4 | WORKDIR /app 5 | 6 | RUN ["npm", "install"] 7 | 8 | EXPOSE 3000 9 | 10 | CMD ["npm", "run", "start:dev"] 11 | -------------------------------------------------------------------------------- /backend/link-shortener/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ npm run start 40 | 41 | # watch mode 42 | $ npm run start:dev 43 | 44 | # production mode 45 | $ npm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ npm run test 53 | 54 | # e2e tests 55 | $ npm run test:e2e 56 | 57 | # test coverage 58 | $ npm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | -------------------------------------------------------------------------------- /backend/link-shortener/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /backend/link-shortener/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "link-shortener", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json" 22 | }, 23 | "dependencies": { 24 | "@nestjs/common": "^8.0.0", 25 | "@nestjs/core": "^8.0.0", 26 | "@nestjs/platform-express": "^8.0.0", 27 | "redis": "^4.1.0", 28 | "reflect-metadata": "^0.1.13", 29 | "rimraf": "^3.0.2", 30 | "rxjs": "^7.2.0" 31 | }, 32 | "devDependencies": { 33 | "@nestjs/cli": "^8.0.0", 34 | "@nestjs/schematics": "^8.0.0", 35 | "@nestjs/testing": "^8.0.0", 36 | "@types/express": "^4.17.13", 37 | "@types/jest": "27.5.0", 38 | "@types/node": "^16.0.0", 39 | "@types/supertest": "^2.0.11", 40 | "@typescript-eslint/eslint-plugin": "^5.0.0", 41 | "@typescript-eslint/parser": "^5.0.0", 42 | "eslint": "^8.0.1", 43 | "eslint-config-prettier": "^8.3.0", 44 | "eslint-plugin-prettier": "^4.0.0", 45 | "jest": "28.0.3", 46 | "prettier": "^2.3.2", 47 | "source-map-support": "^0.5.20", 48 | "supertest": "^6.1.3", 49 | "ts-jest": "28.0.1", 50 | "ts-loader": "^9.2.3", 51 | "ts-node": "^10.0.0", 52 | "tsconfig-paths": "4.0.0", 53 | "typescript": "^4.3.5" 54 | }, 55 | "jest": { 56 | "moduleFileExtensions": [ 57 | "js", 58 | "json", 59 | "ts" 60 | ], 61 | "rootDir": "src", 62 | "testRegex": ".*\\.spec\\.ts$", 63 | "transform": { 64 | "^.+\\.(t|j)s$": "ts-jest" 65 | }, 66 | "collectCoverageFrom": [ 67 | "**/*.(t|j)s" 68 | ], 69 | "coverageDirectory": "../coverage", 70 | "testEnvironment": "node" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /backend/link-shortener/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { tap } from 'rxjs'; 5 | import { AppRepositoryTag } from './app.repository'; 6 | import { AppRepositoryHashmap } from './app.repository.hashmap'; 7 | 8 | describe('AppController', () => { 9 | let appController: AppController; 10 | 11 | beforeEach(async () => { 12 | const app: TestingModule = await Test.createTestingModule({ 13 | controllers: [AppController], 14 | providers: [ 15 | AppService, 16 | { provide: AppRepositoryTag, useClass: AppRepositoryHashmap }, 17 | ], 18 | }).compile(); 19 | 20 | appController = app.get(AppController); 21 | }); 22 | 23 | describe('root', () => { 24 | it('should return "Hello World!"', () => { 25 | expect(appController.getHello()).toBe('Hello World!'); 26 | }); 27 | }); 28 | 29 | describe('shorten', () => { 30 | it('should return a valid string', (done) => { 31 | const url = 'aerabi.com'; 32 | appController 33 | .shorten(url) 34 | .pipe(tap((hash) => expect(hash).toBeTruthy())) 35 | .subscribe({ complete: done }); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /backend/link-shortener/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Param, Post, Redirect } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | import { map, Observable, of } from 'rxjs'; 4 | 5 | interface ShortenResponse { 6 | hash: string; 7 | } 8 | 9 | interface ErrorResponse { 10 | error: string; 11 | code: number; 12 | } 13 | 14 | @Controller() 15 | export class AppController { 16 | constructor(private readonly appService: AppService) {} 17 | 18 | @Get() 19 | getHello(): string { 20 | return this.appService.getHello(); 21 | } 22 | 23 | @Post('shorten') 24 | shorten( 25 | @Body('url') url: string, 26 | ): Observable { 27 | if (!url) { 28 | return of({ 29 | error: `No url provided. Please provide in the body. E.g. {'url':'https://google.com'}`, 30 | code: 400, 31 | }); 32 | } 33 | return this.appService.shorten(url).pipe(map((hash) => ({ hash }))); 34 | } 35 | 36 | @Get(':hash') 37 | @Redirect() 38 | retrieveAndRedirect(@Param('hash') hash): Observable<{ url: string }> { 39 | return this.appService.retrieve(hash).pipe(map((url) => ({ url }))); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/link-shortener/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { AppRepositoryTag } from './app.repository'; 5 | import { AppRepositoryRedis } from './app.repository.redis'; 6 | 7 | @Module({ 8 | imports: [], 9 | controllers: [AppController], 10 | providers: [ 11 | AppService, 12 | { provide: AppRepositoryTag, useClass: AppRepositoryRedis }, 13 | ], 14 | }) 15 | export class AppModule {} 16 | -------------------------------------------------------------------------------- /backend/link-shortener/src/app.repository.hashmap.ts: -------------------------------------------------------------------------------- 1 | import { AppRepository } from './app.repository'; 2 | import { Observable, of } from 'rxjs'; 3 | 4 | export class AppRepositoryHashmap implements AppRepository { 5 | private readonly hashMap: Map; 6 | 7 | constructor() { 8 | this.hashMap = new Map(); 9 | } 10 | 11 | get(hash: string): Observable { 12 | return of(this.hashMap.get(hash)); 13 | } 14 | 15 | put(hash: string, url: string): Observable { 16 | return of(this.hashMap.set(hash, url).get(hash)); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /backend/link-shortener/src/app.repository.redis.ts: -------------------------------------------------------------------------------- 1 | import { AppRepository } from './app.repository'; 2 | import { Observable, from, mergeMap } from 'rxjs'; 3 | import { createClient, RedisClientType } from 'redis'; 4 | 5 | export class AppRepositoryRedis implements AppRepository { 6 | private readonly redisClient: RedisClientType; 7 | 8 | constructor() { 9 | const host = process.env.REDIS_HOST || 'redis'; 10 | const port = +process.env.REDIS_PORT || 6379; 11 | this.redisClient = createClient({ 12 | url: `redis://${host}:${port}`, 13 | }); 14 | from(this.redisClient.connect()).subscribe({ error: console.error }); 15 | this.redisClient.on('connect', () => console.log('Redis connected')); 16 | this.redisClient.on('error', console.error); 17 | } 18 | 19 | get(hash: string): Observable { 20 | return from(this.redisClient.get(hash)); 21 | } 22 | 23 | put(hash: string, url: string): Observable { 24 | return from(this.redisClient.set(hash, url)).pipe( 25 | mergeMap(() => from(this.redisClient.get(hash))), 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /backend/link-shortener/src/app.repository.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | 3 | export interface AppRepository { 4 | put(hash: string, url: string): Observable; 5 | get(hash: string): Observable; 6 | } 7 | 8 | export const AppRepositoryTag = 'AppRepository'; 9 | -------------------------------------------------------------------------------- /backend/link-shortener/src/app.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppService } from './app.service'; 3 | import { AppRepositoryTag } from './app.repository'; 4 | import { AppRepositoryHashmap } from './app.repository.hashmap'; 5 | import { mergeMap, tap } from 'rxjs'; 6 | 7 | describe('AppService', () => { 8 | let appService: AppService; 9 | 10 | beforeEach(async () => { 11 | const app: TestingModule = await Test.createTestingModule({ 12 | providers: [ 13 | { provide: AppRepositoryTag, useClass: AppRepositoryHashmap }, 14 | AppService, 15 | ], 16 | }).compile(); 17 | 18 | appService = app.get(AppService); 19 | }); 20 | 21 | describe('retrieve', () => { 22 | it('should retrieve the saved URL', (done) => { 23 | const url = 'aerabi.com'; 24 | appService 25 | .shorten(url) 26 | .pipe(mergeMap((hash) => appService.retrieve(hash))) 27 | .pipe(tap((retrieved) => expect(retrieved).toEqual(url))) 28 | .subscribe({ complete: done }); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /backend/link-shortener/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { map, Observable } from 'rxjs'; 3 | import { AppRepository, AppRepositoryTag } from './app.repository'; 4 | 5 | @Injectable() 6 | export class AppService { 7 | constructor( 8 | @Inject(AppRepositoryTag) private readonly appRepository: AppRepository, 9 | ) {} 10 | 11 | getHello(): string { 12 | return 'Hello World!'; 13 | } 14 | 15 | shorten(url: string): Observable { 16 | const hash = Math.random().toString(36).slice(7); 17 | return this.appRepository.put(hash, url).pipe(map(() => hash)); 18 | } 19 | 20 | retrieve(hash: string): Observable { 21 | return this.appRepository.get(hash); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/link-shortener/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | 4 | async function bootstrap() { 5 | const app = await NestFactory.create(AppModule); 6 | await app.listen(3000); 7 | } 8 | bootstrap(); 9 | -------------------------------------------------------------------------------- /backend/link-shortener/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /backend/link-shortener/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/link-shortener/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /backend/link-shortener/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | redis: 3 | image: 'redis/redis-stack' 4 | ports: 5 | - '6379:6379' 6 | - '8001:8001' 7 | networks: 8 | - urlnet 9 | dev: 10 | build: 11 | context: ./backend/link-shortener 12 | dockerfile: Dockerfile 13 | environment: 14 | REDIS_HOST: redis 15 | REDIS_PORT: 6379 16 | ports: 17 | - '3000:3000' 18 | volumes: 19 | - './backend/link-shortener:/app' 20 | depends_on: 21 | - redis 22 | networks: 23 | - urlnet 24 | 25 | networks: 26 | urlnet: 27 | -------------------------------------------------------------------------------- /typescript_blog.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dockersamples/link-shortener-typescript/b1cfa036f0475e56230563e271f027f901811c35/typescript_blog.jpg --------------------------------------------------------------------------------