diff --git a/api/src/infrastructure/repository/in-memory-url.repository.ts b/api/src/infrastructure/repository/in-memory-url.repository.ts index 0a2c5e7..009dfa0 100644 --- a/api/src/infrastructure/repository/in-memory-url.repository.ts +++ b/api/src/infrastructure/repository/in-memory-url.repository.ts @@ -5,13 +5,15 @@ import { Injectable, Logger } from '@nestjs/common'; export class InMemoryUrlRepository implements ShortenUrlRepository { private urls: Map = new Map(); - create(url: string, shortenedUrl: string): Promise { - this.urls.set(url, shortenedUrl); + create(url: string, encodedUrl: string): Promise { + this.urls.set(url, encodedUrl); return Promise.resolve(undefined); } - findURL(url: string): Promise { - const shortenedUrl = this.urls.get(url); - return Promise.resolve(shortenedUrl); + findOriginalURL(encodedUrl: string): Promise { + const originalUrl = Array.from(this.urls.keys()).find( + (key) => this.urls.get(key) === encodedUrl, + ); + return Promise.resolve(originalUrl || null); } } diff --git a/api/src/infrastructure/repository/redis-url.repository.ts b/api/src/infrastructure/repository/redis-url.repository.ts index c177a5f..7ebc2ba 100644 --- a/api/src/infrastructure/repository/redis-url.repository.ts +++ b/api/src/infrastructure/repository/redis-url.repository.ts @@ -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('REDIS_URL'); @@ -30,12 +30,18 @@ export class RedisUrlRepository ); } - async create(url: string, shortenedUrl: string): Promise { - await this.redisClient.set(url, shortenedUrl, { EX: this.redisTTL }); + async create(url: string, encodedUrl: string): Promise { + await this.redisClient.set( + `encodedUrl: ${encodedUrl}`, + `originalUrl: ${url}`, + { + EX: this.redisTTL, + }, + ); } - async findURL(url: string): Promise { - return await this.redisClient.get(url); + async findOriginalURL(encodedUrl: string): Promise { + return await this.redisClient.get(`encodedUrl:${encodedUrl}`); } async onModuleInit() { diff --git a/api/src/infrastructure/repository/repository.provider.ts b/api/src/infrastructure/repository/repository.provider.ts index 5f583e4..918f2f0 100644 --- a/api/src/infrastructure/repository/repository.provider.ts +++ b/api/src/infrastructure/repository/repository.provider.ts @@ -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( diff --git a/api/src/shorten-url/shorten-url.controller.spec.ts b/api/src/shorten-url/create-shorten-url/create-shorten-url.controller.spec.ts similarity index 79% rename from api/src/shorten-url/shorten-url.controller.spec.ts rename to api/src/shorten-url/create-shorten-url/create-shorten-url.controller.spec.ts index f9b67ff..5c558a3 100644 --- a/api/src/shorten-url/shorten-url.controller.spec.ts +++ b/api/src/shorten-url/create-shorten-url/create-shorten-url.controller.spec.ts @@ -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); + underTest = app.get(CreateShortenUrlController); }); describe('shortenUrl', () => { diff --git a/api/src/shorten-url/shorten-url.controller.ts b/api/src/shorten-url/create-shorten-url/create-shorten-url.controller.ts similarity index 59% rename from api/src/shorten-url/shorten-url.controller.ts rename to api/src/shorten-url/create-shorten-url/create-shorten-url.controller.ts index 0a45113..9c19a90 100644 --- a/api/src/shorten-url/shorten-url.controller.ts +++ b/api/src/shorten-url/create-shorten-url/create-shorten-url.controller.ts @@ -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() }; } } diff --git a/api/src/shorten-url/create-shorten-url.dto.ts b/api/src/shorten-url/create-shorten-url/create-shorten-url.dto.ts similarity index 100% rename from api/src/shorten-url/create-shorten-url.dto.ts rename to api/src/shorten-url/create-shorten-url/create-shorten-url.dto.ts diff --git a/api/src/shorten-url/create-shorten-url/create-shorten-url.usecase.ts b/api/src/shorten-url/create-shorten-url/create-shorten-url.usecase.ts new file mode 100644 index 0000000..c9e2585 --- /dev/null +++ b/api/src/shorten-url/create-shorten-url/create-shorten-url.usecase.ts @@ -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('SHORTENED_BASE_URL')) + throw new Error( + 'SHORTENED_BASE_URL is not defined in the environment variables', + ); + this.shortenedBaseUrl = + this.configService.get('SHORTENED_BASE_URL'); + } + + async shortenUrl(originalURL: URL): Promise { + 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; + } +} diff --git a/api/src/shorten-url/shorten-url.id-generator.service.ts b/api/src/shorten-url/create-shorten-url/shorten-url.id-generator.service.ts similarity index 100% rename from api/src/shorten-url/shorten-url.id-generator.service.ts rename to api/src/shorten-url/create-shorten-url/shorten-url.id-generator.service.ts diff --git a/api/src/shorten-url/redirect-to-original-url/redirect-to-original-url-controller.spec.ts b/api/src/shorten-url/redirect-to-original-url/redirect-to-original-url-controller.spec.ts new file mode 100644 index 0000000..46043e5 --- /dev/null +++ b/api/src/shorten-url/redirect-to-original-url/redirect-to-original-url-controller.spec.ts @@ -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, + ); + underTest = app.get( + 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', + ); + }); +}); diff --git a/api/src/shorten-url/redirect-to-original-url/redirect-to-original-url.controller.ts b/api/src/shorten-url/redirect-to-original-url/redirect-to-original-url.controller.ts new file mode 100644 index 0000000..f1af2bf --- /dev/null +++ b/api/src/shorten-url/redirect-to-original-url/redirect-to-original-url.controller.ts @@ -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() }; + } +} diff --git a/api/src/shorten-url/redirect-to-original-url/redirect-to-original-url.usecase.ts b/api/src/shorten-url/redirect-to-original-url/redirect-to-original-url.usecase.ts new file mode 100644 index 0000000..9c2856b --- /dev/null +++ b/api/src/shorten-url/redirect-to-original-url/redirect-to-original-url.usecase.ts @@ -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 { + const originalUrl = await this.shortenUrlRepository.findOriginalURL(slug); + return originalUrl ? new URL(originalUrl) : null; + } +} diff --git a/api/src/shorten-url/shorten-url.module.ts b/api/src/shorten-url/shorten-url.module.ts index 28167d8..387bb03 100644 --- a/api/src/shorten-url/shorten-url.module.ts +++ b/api/src/shorten-url/shorten-url.module.ts @@ -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 {} diff --git a/api/src/shorten-url/shorten-url.repository.ts b/api/src/shorten-url/shorten-url.repository.ts index efa6921..332fd05 100644 --- a/api/src/shorten-url/shorten-url.repository.ts +++ b/api/src/shorten-url/shorten-url.repository.ts @@ -1,5 +1,5 @@ export abstract class ShortenUrlRepository { - abstract findURL(url: string): Promise; + abstract create(url: string, encodedUrl: string): Promise; - abstract create(url: string, shortenedUrl: string): Promise; + abstract findOriginalURL(encodedUrl: string): Promise; } diff --git a/api/src/shorten-url/shorten-url.usecase.ts b/api/src/shorten-url/shorten-url.usecase.ts deleted file mode 100644 index 4fcdf4e..0000000 --- a/api/src/shorten-url/shorten-url.usecase.ts +++ /dev/null @@ -1,64 +0,0 @@ -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 ShortenUrlUsecase { - private static BASE62_CHARACTERS = - '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; - - private static BASE62 = ShortenUrlUsecase.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('SHORTENED_BASE_URL')) - throw new Error( - 'SHORTENED_BASE_URL is not defined in the environment variables', - ); - this.shortenedBaseUrl = - this.configService.get('SHORTENED_BASE_URL'); - } - - async shortenUrl(originalURL: string): Promise { - const existingShortenedUrl = await this.shortenUrlRepository.findURL( - originalURL, - ); - - if (existingShortenedUrl) { - return existingShortenedUrl; - } - const id = this.idGenerator.generateId(); - const encodedId = this.encodeBase62(Number(id)); - const shortenedUrl = this.shortenedBaseUrl + '/' + encodedId; - await this.shortenUrlRepository.create(originalURL, shortenedUrl); - return shortenedUrl; - } - - private encodeBase62(id: number): string { - if (id === 0) return ShortenUrlUsecase.BASE62_CHARACTERS[0]; - - let encoded = ''; - while (id > 0) { - const remainder = id % ShortenUrlUsecase.BASE62; - encoded = ShortenUrlUsecase.BASE62_CHARACTERS[remainder] + encoded; - id = Math.floor(id / ShortenUrlUsecase.BASE62); - } - return encoded; - } - - private getBaseUrl(url: string): string { - try { - const parsedUrl = new URL(url); - return `${parsedUrl.protocol}//${parsedUrl.hostname}`; - } catch (error) { - throw new Error(`Invalid URL: ${url}`); - } - } -} diff --git a/webapp/app/api/url-shortener/route.ts b/webapp/app/api/url-shortener/route.ts index b419887..780c3ab 100644 --- a/webapp/app/api/url-shortener/route.ts +++ b/webapp/app/api/url-shortener/route.ts @@ -1,43 +1,27 @@ import { NextRequest, NextResponse } from 'next/server'; -// Function to handle POST requests export async function POST(request: NextRequest) { - try { - const { url } = await request.json(); - if (!url || typeof url !== 'string') { - throw new Error(`Invalid URL format ${url}`); - } + const { url } = await request.json().catch(() => ({})); // ✅ Simplified JSON parsing - const shortenedUrl = await shortenUrl(url); - return NextResponse.json({ shortenedUrl }, { status: 200 }); - } catch (error) { - console.error('Failed to shorten URL:', error); - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Invalid request' }, - { status: 400 } - ); + if (typeof url !== 'string' || !url.trim()) { + return NextResponse.json({ error: 'Invalid URL format' }, { status: 400 }); } -} -// Function to call the backend API -async function shortenUrl(url: string): Promise { - try { - const requestBody = { url }; - const response = await fetch('http://localhost:3000/shorten-url', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody), + return fetch('http://localhost:3000/shorten-url', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + }) + .then(async (res) => { + if (!res.ok) + throw new Error((await res.json()).error || 'Failed to shorten URL'); + return res.json(); + }) + .then((data) => + NextResponse.json({ shortenedUrl: data.shortenedUrl }, { status: 200 }) + ) + .catch((error) => { + console.error('Error shortening URL:', error); + return NextResponse.json({ error: error.message }, { status: 400 }); }); - - if (!response.ok) { - const errorResponse = await response.json(); - throw new Error(errorResponse.error || 'Failed to shorten URL'); - } - - const data = await response.json(); - return data; - } catch (error) { - console.error('Error shortening URL:', error); - throw error; - } }