Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions api/src/infrastructure/repository/in-memory-url.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import { Injectable, Logger } from '@nestjs/common';
export class InMemoryUrlRepository implements ShortenUrlRepository {
private urls: Map<string, string> = new Map();

create(url: string, shortenedUrl: string): Promise<void> {
this.urls.set(url, shortenedUrl);
create(url: string, encodedUrl: string): Promise<void> {
this.urls.set(url, encodedUrl);
return Promise.resolve(undefined);
}

findURL(url: string): Promise<string | null> {
const shortenedUrl = this.urls.get(url);
return Promise.resolve(shortenedUrl);
findOriginalURL(encodedUrl: string): Promise<string | null> {
const originalUrl = Array.from(this.urls.keys()).find(
(key) => this.urls.get(key) === encodedUrl,
);
return Promise.resolve(originalUrl || null);
}
}
18 changes: 12 additions & 6 deletions api/src/infrastructure/repository/redis-url.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ export class RedisUrlRepository
{
private readonly DEFAULT_TTL = 100;

private redisClient: RedisClientType;
private readonly redisTTL: number;

private redisTTL: number;
private redisClient: RedisClientType;

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

async create(url: string, shortenedUrl: string): Promise<void> {
await this.redisClient.set(url, shortenedUrl, { EX: this.redisTTL });
async create(url: string, encodedUrl: string): Promise<void> {
await this.redisClient.set(
`encodedUrl: ${encodedUrl}`,
`originalUrl: ${url}`,
{
EX: this.redisTTL,
},
);
}

async findURL(url: string): Promise<string | null> {
return await this.redisClient.get(url);
async findOriginalURL(encodedUrl: string): Promise<string | null> {
return await this.redisClient.get(`encodedUrl:${encodedUrl}`);
}

async onModuleInit() {
Expand Down
1 change: 0 additions & 1 deletion api/src/infrastructure/repository/repository.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { RedisUrlRepository } from './redis-url.repository';
*/
export const RepositoryProvider: Provider = {
// TODO: Use a constant for the key
//provide: 'ShortenUrlRepository',
provide: ShortenUrlRepository,
useFactory: (configService: ConfigService) => {
const persistenceType = configService.get<string>(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ShortenUrlController } from './shorten-url.controller';
import { CreateShortenUrlController } from './create-shorten-url.controller';
import { ConfigModule } from '@nestjs/config';
import { ShortenUrlModule } from './shorten-url.module';
import { ShortenUrlModule } from '../shorten-url.module';

describe('ShortenUrl controller', () => {
let underTest: ShortenUrlController;
let underTest: CreateShortenUrlController;

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

underTest = app.get<ShortenUrlController>(ShortenUrlController);
underTest = app.get<CreateShortenUrlController>(CreateShortenUrlController);
});

describe('shortenUrl', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { Body, Controller, Logger, Post } from '@nestjs/common';
import { ShortenUrlUsecase } from './shorten-url.usecase';
import { CreateShortenUrlUsecase } from './create-shorten-url.usecase';
import { CreateShortenUrlDto } from './create-shorten-url.dto';

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

@Post()
async shortenUrl(
@Body() request: CreateShortenUrlDto,
): Promise<{ shortenedUrl: string }> {
Logger.log(`Received url ${request.url} to shorten`);
// TODO perhaps it is worth converting the URL from string to URL object
const shortenedUrl = await this.shortenUrlUsecase.shortenUrl(request.url);
return { shortenedUrl };
const url = new URL(request.url);
const shortenedUrl = await this.shortenUrlUsecase.shortenUrl(url);
return { shortenedUrl: shortenedUrl.toString() };
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Inject, Injectable } from '@nestjs/common';
import { ShortenUrlIdGeneratorService } from './shorten-url.id-generator.service';
import { ShortenUrlRepository } from '../shorten-url.repository';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class CreateShortenUrlUsecase {
private static BASE62_CHARACTERS =
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';

private static BASE62 = CreateShortenUrlUsecase.BASE62_CHARACTERS.length;

private readonly shortenedBaseUrl: string;

constructor(
private readonly configService: ConfigService,
private readonly idGenerator: ShortenUrlIdGeneratorService,
@Inject('ShortenUrlRepository')
private readonly shortenUrlRepository: ShortenUrlRepository,
) {
if (!this.configService.get<number>('SHORTENED_BASE_URL'))
throw new Error(
'SHORTENED_BASE_URL is not defined in the environment variables',
);
this.shortenedBaseUrl =
this.configService.get<string>('SHORTENED_BASE_URL');
}

async shortenUrl(originalURL: URL): Promise<URL> {
const id = this.idGenerator.generateId();
const encodedUrl = this.encodeBase62(Number(id));
await this.shortenUrlRepository.create(originalURL.toString(), encodedUrl);
const shortenedUrl = new URL(encodedUrl, this.shortenedBaseUrl);
return shortenedUrl;
}

private encodeBase62(id: number): string {
if (id === 0) return CreateShortenUrlUsecase.BASE62_CHARACTERS[0];

let encoded = '';
while (id > 0) {
const remainder = id % CreateShortenUrlUsecase.BASE62;
encoded = CreateShortenUrlUsecase.BASE62_CHARACTERS[remainder] + encoded;
id = Math.floor(id / CreateShortenUrlUsecase.BASE62);
}
return encoded;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { CreateShortenUrlController } from '../create-shorten-url/create-shorten-url.controller';
import { RedirectToOriginalUrlController } from './redirect-to-original-url.controller';
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { ShortenUrlModule } from '../shorten-url.module';

describe('Redirect to original url controller', () => {
let shortenUrlController: CreateShortenUrlController;
let underTest: RedirectToOriginalUrlController;

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

shortenUrlController = app.get<CreateShortenUrlController>(
CreateShortenUrlController,
);
underTest = app.get<RedirectToOriginalUrlController>(
RedirectToOriginalUrlController,
);
});

describe('Redirect to original url', () => {
it('should return the original URL for a given shortened URL', async () => {
const longUrl =
'https://zapper.xyz/very-long-url/very-long-url/very-long-url';

// Use the shorten URL controller to create the shortened URL
const shortenedResponse = await shortenUrlController.shortenUrl({
url: longUrl,
});

// Extract the slug from the shortened URL
const shortenedUrl = shortenedResponse.shortenedUrl;
const slug = shortenedUrl.split('/').pop();

// Now retrieve the original URL using GetOriginalUrlController
const response = await underTest.redirectToOriginalUrl(slug);

// TODO check with an integration / e2e test the redirection code (302)
expect(response).toEqual({ url: longUrl });
});
});

it('should return 404 if the shortened URL is not found', async () => {
const slug = 'does-not-exist';
await expect(underTest.redirectToOriginalUrl(slug)).rejects.toThrow(
'Shortened URL "does-not-exist" not found',
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
Controller,
Get,
NotFoundException,
Param,
Redirect,
} from '@nestjs/common';
import { RedirectToOriginalUrlUsecase } from './redirect-to-original-url.usecase';

@Controller()
export class RedirectToOriginalUrlController {
constructor(
private readonly getOriginalUrlUsecase: RedirectToOriginalUrlUsecase,
) {}

@Get(':slug')
@Redirect(undefined, 302)
async redirectToOriginalUrl(@Param('slug') slug: string) {
const originalUrl = await this.getOriginalUrlUsecase.redirectToOriginalUrl(
slug,
);
if (!originalUrl) {
throw new NotFoundException(`Shortened URL "${slug}" not found`);
}
// Use temporary redirect to original URL so that we can capture analytics
return { url: originalUrl.toString() };
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Inject, Injectable } from '@nestjs/common';
import { ShortenUrlRepository } from '../shorten-url.repository';

@Injectable()
export class RedirectToOriginalUrlUsecase {
constructor(
@Inject('ShortenUrlRepository')
private readonly shortenUrlRepository: ShortenUrlRepository,
) {}

async redirectToOriginalUrl(slug: string): Promise<URL | null> {
const originalUrl = await this.shortenUrlRepository.findOriginalURL(slug);
return originalUrl ? new URL(originalUrl) : null;
}
}
13 changes: 8 additions & 5 deletions api/src/shorten-url/shorten-url.module.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ShortenUrlController } from './shorten-url.controller';
import { ShortenUrlUsecase } from './shorten-url.usecase';
import { ShortenUrlIdGeneratorService } from './shorten-url.id-generator.service';
import { CreateShortenUrlController } from './create-shorten-url/create-shorten-url.controller';
import { CreateShortenUrlUsecase } from './create-shorten-url/create-shorten-url.usecase';
import { ShortenUrlIdGeneratorService } from './create-shorten-url/shorten-url.id-generator.service';
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
import { ShortenUrlRepository } from './shorten-url.repository';
import { RedirectToOriginalUrlUsecase } from './redirect-to-original-url/redirect-to-original-url.usecase';
import { RedirectToOriginalUrlController } from './redirect-to-original-url/redirect-to-original-url.controller';

@Module({
imports: [
ConfigModule, // ✅ Ensure ConfigModule is available
InfrastructureModule.register(), // ✅ Register InfrastructureModule dynamically
],
controllers: [ShortenUrlController],
controllers: [CreateShortenUrlController, RedirectToOriginalUrlController],
providers: [
{
provide: 'ShortenUrlRepository',
useExisting: ShortenUrlRepository, // ✅ Use injected provider
},
ShortenUrlIdGeneratorService,
ShortenUrlUsecase,
CreateShortenUrlUsecase,
RedirectToOriginalUrlUsecase,
],
})
export class ShortenUrlModule {}
4 changes: 2 additions & 2 deletions api/src/shorten-url/shorten-url.repository.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export abstract class ShortenUrlRepository {
abstract findURL(url: string): Promise<string | null>;
abstract create(url: string, encodedUrl: string): Promise<void>;

abstract create(url: string, shortenedUrl: string): Promise<void>;
abstract findOriginalURL(encodedUrl: string): Promise<string | null>;
}
64 changes: 0 additions & 64 deletions api/src/shorten-url/shorten-url.usecase.ts

This file was deleted.

Loading
Loading