Skip to content

Commit 49f67bf

Browse files
authored
Feat/add redirect url (#8)
Redirect users when they hit the shorten url to the original url
1 parent 572cd4b commit 49f67bf

15 files changed

+201
-128
lines changed

api/src/infrastructure/repository/in-memory-url.repository.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import { Injectable, Logger } from '@nestjs/common';
55
export class InMemoryUrlRepository implements ShortenUrlRepository {
66
private urls: Map<string, string> = new Map();
77

8-
create(url: string, shortenedUrl: string): Promise<void> {
9-
this.urls.set(url, shortenedUrl);
8+
create(url: string, encodedUrl: string): Promise<void> {
9+
this.urls.set(url, encodedUrl);
1010
return Promise.resolve(undefined);
1111
}
1212

13-
findURL(url: string): Promise<string | null> {
14-
const shortenedUrl = this.urls.get(url);
15-
return Promise.resolve(shortenedUrl);
13+
findOriginalURL(encodedUrl: string): Promise<string | null> {
14+
const originalUrl = Array.from(this.urls.keys()).find(
15+
(key) => this.urls.get(key) === encodedUrl,
16+
);
17+
return Promise.resolve(originalUrl || null);
1618
}
1719
}

api/src/infrastructure/repository/redis-url.repository.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ export class RedisUrlRepository
1414
{
1515
private readonly DEFAULT_TTL = 100;
1616

17-
private redisClient: RedisClientType;
17+
private readonly redisTTL: number;
1818

19-
private redisTTL: number;
19+
private redisClient: RedisClientType;
2020

2121
constructor(private readonly configService: ConfigService) {
2222
const redisUrl = this.configService.get<string>('REDIS_URL');
@@ -30,12 +30,18 @@ export class RedisUrlRepository
3030
);
3131
}
3232

33-
async create(url: string, shortenedUrl: string): Promise<void> {
34-
await this.redisClient.set(url, shortenedUrl, { EX: this.redisTTL });
33+
async create(url: string, encodedUrl: string): Promise<void> {
34+
await this.redisClient.set(
35+
`encodedUrl: ${encodedUrl}`,
36+
`originalUrl: ${url}`,
37+
{
38+
EX: this.redisTTL,
39+
},
40+
);
3541
}
3642

37-
async findURL(url: string): Promise<string | null> {
38-
return await this.redisClient.get(url);
43+
async findOriginalURL(encodedUrl: string): Promise<string | null> {
44+
return await this.redisClient.get(`encodedUrl:${encodedUrl}`);
3945
}
4046

4147
async onModuleInit() {

api/src/infrastructure/repository/repository.provider.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { RedisUrlRepository } from './redis-url.repository';
99
*/
1010
export const RepositoryProvider: Provider = {
1111
// TODO: Use a constant for the key
12-
//provide: 'ShortenUrlRepository',
1312
provide: ShortenUrlRepository,
1413
useFactory: (configService: ConfigService) => {
1514
const persistenceType = configService.get<string>(

api/src/shorten-url/shorten-url.controller.spec.ts renamed to api/src/shorten-url/create-shorten-url/create-shorten-url.controller.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import { Test, TestingModule } from '@nestjs/testing';
2-
import { ShortenUrlController } from './shorten-url.controller';
2+
import { CreateShortenUrlController } from './create-shorten-url.controller';
33
import { ConfigModule } from '@nestjs/config';
4-
import { ShortenUrlModule } from './shorten-url.module';
4+
import { ShortenUrlModule } from '../shorten-url.module';
55

66
describe('ShortenUrl controller', () => {
7-
let underTest: ShortenUrlController;
7+
let underTest: CreateShortenUrlController;
88

99
beforeEach(async () => {
1010
const app: TestingModule = await Test.createTestingModule({
1111
imports: [await ConfigModule.forRoot(), ShortenUrlModule],
1212
}).compile();
1313

14-
underTest = app.get<ShortenUrlController>(ShortenUrlController);
14+
underTest = app.get<CreateShortenUrlController>(CreateShortenUrlController);
1515
});
1616

1717
describe('shortenUrl', () => {
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import { Body, Controller, Logger, Post } from '@nestjs/common';
2-
import { ShortenUrlUsecase } from './shorten-url.usecase';
2+
import { CreateShortenUrlUsecase } from './create-shorten-url.usecase';
33
import { CreateShortenUrlDto } from './create-shorten-url.dto';
44

55
@Controller('/shorten-url')
6-
export class ShortenUrlController {
7-
constructor(private readonly shortenUrlUsecase: ShortenUrlUsecase) {}
6+
export class CreateShortenUrlController {
7+
constructor(private readonly shortenUrlUsecase: CreateShortenUrlUsecase) {}
88

99
@Post()
1010
async shortenUrl(
1111
@Body() request: CreateShortenUrlDto,
1212
): Promise<{ shortenedUrl: string }> {
13-
Logger.log(`Received url ${request.url} to shorten`);
1413
// TODO perhaps it is worth converting the URL from string to URL object
15-
const shortenedUrl = await this.shortenUrlUsecase.shortenUrl(request.url);
16-
return { shortenedUrl };
14+
const url = new URL(request.url);
15+
const shortenedUrl = await this.shortenUrlUsecase.shortenUrl(url);
16+
return { shortenedUrl: shortenedUrl.toString() };
1717
}
1818
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Inject, Injectable } from '@nestjs/common';
2+
import { ShortenUrlIdGeneratorService } from './shorten-url.id-generator.service';
3+
import { ShortenUrlRepository } from '../shorten-url.repository';
4+
import { ConfigService } from '@nestjs/config';
5+
6+
@Injectable()
7+
export class CreateShortenUrlUsecase {
8+
private static BASE62_CHARACTERS =
9+
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
10+
11+
private static BASE62 = CreateShortenUrlUsecase.BASE62_CHARACTERS.length;
12+
13+
private readonly shortenedBaseUrl: string;
14+
15+
constructor(
16+
private readonly configService: ConfigService,
17+
private readonly idGenerator: ShortenUrlIdGeneratorService,
18+
@Inject('ShortenUrlRepository')
19+
private readonly shortenUrlRepository: ShortenUrlRepository,
20+
) {
21+
if (!this.configService.get<number>('SHORTENED_BASE_URL'))
22+
throw new Error(
23+
'SHORTENED_BASE_URL is not defined in the environment variables',
24+
);
25+
this.shortenedBaseUrl =
26+
this.configService.get<string>('SHORTENED_BASE_URL');
27+
}
28+
29+
async shortenUrl(originalURL: URL): Promise<URL> {
30+
const id = this.idGenerator.generateId();
31+
const encodedUrl = this.encodeBase62(Number(id));
32+
await this.shortenUrlRepository.create(originalURL.toString(), encodedUrl);
33+
const shortenedUrl = new URL(encodedUrl, this.shortenedBaseUrl);
34+
return shortenedUrl;
35+
}
36+
37+
private encodeBase62(id: number): string {
38+
if (id === 0) return CreateShortenUrlUsecase.BASE62_CHARACTERS[0];
39+
40+
let encoded = '';
41+
while (id > 0) {
42+
const remainder = id % CreateShortenUrlUsecase.BASE62;
43+
encoded = CreateShortenUrlUsecase.BASE62_CHARACTERS[remainder] + encoded;
44+
id = Math.floor(id / CreateShortenUrlUsecase.BASE62);
45+
}
46+
return encoded;
47+
}
48+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { CreateShortenUrlController } from '../create-shorten-url/create-shorten-url.controller';
2+
import { RedirectToOriginalUrlController } from './redirect-to-original-url.controller';
3+
import { Test, TestingModule } from '@nestjs/testing';
4+
import { ConfigModule } from '@nestjs/config';
5+
import { ShortenUrlModule } from '../shorten-url.module';
6+
7+
describe('Redirect to original url controller', () => {
8+
let shortenUrlController: CreateShortenUrlController;
9+
let underTest: RedirectToOriginalUrlController;
10+
11+
beforeEach(async () => {
12+
const app: TestingModule = await Test.createTestingModule({
13+
imports: [await ConfigModule.forRoot(), ShortenUrlModule],
14+
}).compile();
15+
16+
shortenUrlController = app.get<CreateShortenUrlController>(
17+
CreateShortenUrlController,
18+
);
19+
underTest = app.get<RedirectToOriginalUrlController>(
20+
RedirectToOriginalUrlController,
21+
);
22+
});
23+
24+
describe('Redirect to original url', () => {
25+
it('should return the original URL for a given shortened URL', async () => {
26+
const longUrl =
27+
'https://zapper.xyz/very-long-url/very-long-url/very-long-url';
28+
29+
// Use the shorten URL controller to create the shortened URL
30+
const shortenedResponse = await shortenUrlController.shortenUrl({
31+
url: longUrl,
32+
});
33+
34+
// Extract the slug from the shortened URL
35+
const shortenedUrl = shortenedResponse.shortenedUrl;
36+
const slug = shortenedUrl.split('/').pop();
37+
38+
// Now retrieve the original URL using GetOriginalUrlController
39+
const response = await underTest.redirectToOriginalUrl(slug);
40+
41+
// TODO check with an integration / e2e test the redirection code (302)
42+
expect(response).toEqual({ url: longUrl });
43+
});
44+
});
45+
46+
it('should return 404 if the shortened URL is not found', async () => {
47+
const slug = 'does-not-exist';
48+
await expect(underTest.redirectToOriginalUrl(slug)).rejects.toThrow(
49+
'Shortened URL "does-not-exist" not found',
50+
);
51+
});
52+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import {
2+
Controller,
3+
Get,
4+
NotFoundException,
5+
Param,
6+
Redirect,
7+
} from '@nestjs/common';
8+
import { RedirectToOriginalUrlUsecase } from './redirect-to-original-url.usecase';
9+
10+
@Controller()
11+
export class RedirectToOriginalUrlController {
12+
constructor(
13+
private readonly getOriginalUrlUsecase: RedirectToOriginalUrlUsecase,
14+
) {}
15+
16+
@Get(':slug')
17+
@Redirect(undefined, 302)
18+
async redirectToOriginalUrl(@Param('slug') slug: string) {
19+
const originalUrl = await this.getOriginalUrlUsecase.redirectToOriginalUrl(
20+
slug,
21+
);
22+
if (!originalUrl) {
23+
throw new NotFoundException(`Shortened URL "${slug}" not found`);
24+
}
25+
// Use temporary redirect to original URL so that we can capture analytics
26+
return { url: originalUrl.toString() };
27+
}
28+
}

0 commit comments

Comments
 (0)