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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ jobs:
- name: Create .env file
run: |
echo "MACHINE_ID=1" > .env
echo "PORT=3000" >> .env
echo "SHORTENED_BASE_URL=http://localhost:3000" >> .env
working-directory: api # Ensure it is created inside `api/`

- name: Run Tests (API)
Expand Down
2 changes: 2 additions & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"@nestjs/config": "4.0.0",
"@nestjs/core": "10.0.0",
"@nestjs/platform-express": "10.0.0",
"class-transformer": "0.5.1",
"class-validator": "0.14.1",
"reflect-metadata": "0.2.0",
"rxjs": "7.8.1"
},
Expand Down
3 changes: 2 additions & 1 deletion api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ShortenUrlModule } from './shorten-url/shorten-url.module';
import { InfrastructureModule } from './infrastructure/infrastructure.module';

@Module({
imports: [ConfigModule.forRoot(), ShortenUrlModule], // Load environment variables
imports: [ConfigModule.forRoot(), InfrastructureModule, ShortenUrlModule], // Load environment variables
controllers: [],
providers: [],
})
Expand Down
10 changes: 10 additions & 0 deletions api/src/infrastructure/infrastructure.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { InMemoryUrlRepository } from '../infrastructure/repository/in-memory-url.repository';

@Module({
imports: [ConfigModule.forRoot()], // Load environment variables
controllers: [],
providers: [InMemoryUrlRepository],
})
export class InfrastructureModule {}
17 changes: 17 additions & 0 deletions api/src/infrastructure/repository/in-memory-url.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ShortenUrlRepository } from '../../shorten-url/shorten-url.repository';
import { Injectable } from '@nestjs/common';

@Injectable()
export class InMemoryUrlRepository implements ShortenUrlRepository {
private urls: Map<string, string> = new Map();

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

findURL(url: string): Promise<string | null> {
const shortenedUrl = this.urls.get(url);
return Promise.resolve(shortenedUrl);
}
}
36 changes: 35 additions & 1 deletion api/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,42 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import {
BadRequestException,
Logger,
ValidationError,
ValidationPipe,
} from '@nestjs/common';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3001);

app.useGlobalPipes(
new ValidationPipe({
transform: true,
exceptionFactory: (validationErrors: ValidationError[] = []) => {
Logger.log('error', validationErrors);
const formatError = (error: ValidationError) => {
if (error.children?.length) {
return {
field: error.property,
errors: error.children.map(formatError),
};
}
return {
field: error.property,
errors: Object.values(error.constraints ?? {}),
};
};

return new BadRequestException(
validationErrors.map((error) => formatError(error)),
);
},
}),
);

const port = process.env.PORT || 3000;
await app.listen(port).then(() => Logger.log(`Server started on ${port}`));
}

bootstrap();
6 changes: 6 additions & 0 deletions api/src/shorten-url/create-shorten-url.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { IsString, IsUrl } from 'class-validator';

export class CreateShortenUrlDto {
@IsUrl()
url: string;
}
15 changes: 6 additions & 9 deletions api/src/shorten-url/shorten-url.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ShortenUrlController } from './shorten-url.controller';
import { ShortenUrlUsecase } from './shorten-url.usecase';
import { ShortenUrlIdGeneratorService } from './shorten-url.id-generator.service';
import { ConfigModule } from '@nestjs/config';
import { ShortenUrlModule } from './shorten-url.module';

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

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

underTest = app.get<ShortenUrlController>(ShortenUrlController);
Expand All @@ -21,13 +18,13 @@ describe('ShortenUrl controller', () => {
it('should return a valid Base62 short URL with 10 chars length', async () => {
const longUrl =
'https://zapper.xyz/very-long-url/very-long-url/very-long-url';
const shortenedUrl = await underTest.shortenUrl(longUrl); // Assuming it's async
const response = await underTest.shortenUrl({ url: longUrl });

// Extract the unique ID part of the URL
const urlPattern = /^https:\/\/zapper\.xyz\/([A-Za-z0-9]{10})$/;
const match = shortenedUrl.match(urlPattern);
const urlPattern = /^http:\/\/localhost:3000\/([A-Za-z0-9]{10})$/;
const match = response?.shortenedUrl.match(urlPattern);

expect(shortenedUrl).not.toBeNull();
expect(response).not.toBeNull();
expect(match).not.toBeNull(); // Ensure it matches the pattern
expect(match![1].length).toBe(10); // Validate the ID part has 10 characters
});
Expand Down
13 changes: 10 additions & 3 deletions api/src/shorten-url/shorten-url.controller.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { Controller, Post } from '@nestjs/common';
import { Body, Controller, Logger, Post } from '@nestjs/common';
import { ShortenUrlUsecase } from './shorten-url.usecase';
import { CreateShortenUrlDto } from './create-shorten-url.dto';

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

@Post()
shortenUrl(url: string): string {
return this.shortenUrlUsecase.createTinyURL('https://zapper.xyz');
async shortenUrl(
@Body() request: CreateShortenUrlDto,
): Promise<{ shortenedUrl: string }> {
Logger.log(`Received url ${request.url} to shorten`);
const shortenedUrl = await this.shortenUrlUsecase.createTinyURL(
request.url,
);
return { shortenedUrl };
}
}
10 changes: 8 additions & 2 deletions api/src/shorten-url/shorten-url.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@ 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 { InMemoryUrlRepository } from '../infrastructure/repository/in-memory-url.repository';
import { InfrastructureModule } from '../infrastructure/infrastructure.module';

@Module({
imports: [ConfigModule.forRoot()], // Load environment variables
imports: [ConfigModule.forRoot(), InfrastructureModule], // Load environment variables
controllers: [ShortenUrlController],
providers: [ShortenUrlIdGeneratorService, ShortenUrlUsecase],
providers: [
{ provide: 'ShortenUrlRepository', useClass: InMemoryUrlRepository },
ShortenUrlIdGeneratorService,
ShortenUrlUsecase,
],
})
export class ShortenUrlModule {}
5 changes: 5 additions & 0 deletions api/src/shorten-url/shorten-url.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface ShortenUrlRepository {
findURL(url: string): Promise<string | null>;

create(url: string, shortenedUrl: string): Promise<void>;
}
45 changes: 38 additions & 7 deletions api/src/shorten-url/shorten-url.usecase.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common';
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 {
Expand All @@ -8,15 +10,35 @@ export class ShortenUrlUsecase {

private static BASE62 = ShortenUrlUsecase.BASE62_CHARACTERS.length;

constructor(private readonly idGenerator: ShortenUrlIdGeneratorService) {}
private readonly shortenedBaseUrl: string;

createTinyURL(originalURL: string): 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 createTinyURL(originalURL: string): Promise<string> {
const existingShortenedUrl = await this.shortenUrlRepository.findURL(
originalURL,
);

if (existingShortenedUrl) {
return existingShortenedUrl;
}
const id = this.idGenerator.generateId();
// encode the id to base 62
// return the original URL with the id appended
// write your code here
const encodedId = this.encodeBase62(Number(id));
return originalURL + '/' + encodedId;
const shortenedUrl = this.shortenedBaseUrl + '/' + encodedId;
await this.shortenUrlRepository.create(originalURL, shortenedUrl);
return shortenedUrl;
}

private encodeBase62(id: number): string {
Expand All @@ -30,4 +52,13 @@ export class ShortenUrlUsecase {
}
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}`);
}
}
}
29 changes: 29 additions & 0 deletions api/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,11 @@
"@types/methods" "*"
"@types/superagent" "*"

"@types/validator@^13.11.8":
version "13.12.2"
resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.12.2.tgz#760329e756e18a4aab82fc502b51ebdfebbe49f5"
integrity sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA==

"@types/yargs-parser@*":
version "21.0.3"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15"
Expand Down Expand Up @@ -1733,6 +1738,20 @@ cjs-module-lexer@^1.0.0:
resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz#707413784dbb3a72aa11c2f2b042a0bef4004170"
integrity sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==

[email protected]:
version "0.5.1"
resolved "https://registry.yarnpkg.com/class-transformer/-/class-transformer-0.5.1.tgz#24147d5dffd2a6cea930a3250a677addf96ab336"
integrity sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==

[email protected]:
version "0.14.1"
resolved "https://registry.yarnpkg.com/class-validator/-/class-validator-0.14.1.tgz#ff2411ed8134e9d76acfeb14872884448be98110"
integrity sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==
dependencies:
"@types/validator" "^13.11.8"
libphonenumber-js "^1.10.53"
validator "^13.9.0"

cli-cursor@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307"
Expand Down Expand Up @@ -3481,6 +3500,11 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"

libphonenumber-js@^1.10.53:
version "1.12.5"
resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.12.5.tgz#8e6043a67112d4beedb8627b359a613f04d88fba"
integrity sha512-DOjiaVjjSmap12ztyb4QgoFmUe/GbgnEXHu+R7iowk0lzDIjScvPAm8cK9RYTEobbRb0OPlwlZUGTTJPJg13Kw==

lines-and-columns@^1.1.6:
version "1.2.4"
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
Expand Down Expand Up @@ -4814,6 +4838,11 @@ v8-to-istanbul@^9.0.1:
"@types/istanbul-lib-coverage" "^2.0.1"
convert-source-map "^2.0.0"

validator@^13.9.0:
version "13.12.0"
resolved "https://registry.yarnpkg.com/validator/-/validator-13.12.0.tgz#7d78e76ba85504da3fee4fd1922b385914d4b35f"
integrity sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==

vary@^1, vary@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
Expand Down
33 changes: 18 additions & 15 deletions webapp/app/api/url-shortener/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,38 @@ import { NextRequest, NextResponse } from 'next/server';
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 shortenedUrl = await shortenUrl(url);
return NextResponse.json({ shortenedUrl }, { status: 200 });
} catch (error) {
// Handle errors and return a 400 status with an error message
console.error('Failed to shorten URL:', error);
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Invalid request' },
{ status: 400 }
);
}
}

// Example function to simulate URL shortening logic
// Function to call the backend API
async function shortenUrl(url: string): Promise<string> {
try {
const response = await fetch('http://localhost:3001/shorten-url', {
method: 'POST', // Use POST method
headers: {
'Content-Type': 'application/json', // Inform the server we are sending JSON
},
body: JSON.stringify({ url }), // Send URL in request body
const requestBody = { url };
const response = await fetch('http://localhost:3000/shorten-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});

console.log(response);

if (!response.ok) {
throw new Error(`Failed to shorten URL: ${response.statusText}`);
const errorResponse = await response.json();
throw new Error(errorResponse.error || 'Failed to shorten URL');
}

const data = await response.text(); // Assume server returns JSON
console.log('URL shortened successfully:', data);
return data; // Adjust this based on the API response structure
const data = await response.json();
return data;
} catch (error) {
console.error('Error shortening URL:', error);
throw error;
Expand Down
1 change: 0 additions & 1 deletion webapp/components/url-shortener/hooks/useURLShortener.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useState } from 'react';
import { shortenURL } from '../services/url-shorten-service';
import * as console from 'node:console';

export const useURLShortener = () => {
const shorten = async () => {
Expand All @@ -15,7 +14,7 @@
const shortenedUrl = await shortenURL(longUrl);
setShortUrl(shortenedUrl);
setMode('shortened');
} catch (error) {

Check warning on line 17 in webapp/components/url-shortener/hooks/useURLShortener.ts

View workflow job for this annotation

GitHub Actions / Webapp Build & Test

'error' is defined but never used

Check warning on line 17 in webapp/components/url-shortener/hooks/useURLShortener.ts

View workflow job for this annotation

GitHub Actions / Webapp Build & Test

'error' is defined but never used
setError('An error occurred while shortening the URL');
} finally {
setLoading(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export const shortenURL = async (longUrl: string): Promise<string> => {
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ longUrl }),
body: JSON.stringify({ url: longUrl }),
});

if (!response.ok) {
Expand Down
Loading
Loading