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
79 changes: 79 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: Tiny URL CI Pipeline

on:
push:
branches:
- main
- 'feature/**' # Trigger workflow for feature branches
- 'feat/**' # Trigger workflow for feature branches
- 'doc/**' # Trigger workflow for feature branches
- 'chore/**' # Trigger workflow for feature branches
- 'fix/**' # Trigger workflow for feature branches
pull_request:
branches:
- main

jobs:

api:
name: API Build & Test
runs-on: ubuntu-latest

steps:
- name: Checkout Repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
cache: yarn
cache-dependency-path: api/yarn-lock.json # Specify the path to your yarn.lock

- name: Install Dependencies (API)
working-directory: api
run: yarn install --frozen-lockfile

#- name: Lint API
# run: npm run lint

- name: Run Tests (API)
run: yarn test

# Webapp (Next.js) Build & Test
webapp:
name: Webapp Build & Test
runs-on: ubuntu-latest
defaults:

steps:
- name: Checkout Repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
cache: yarn
cache-dependency-path: webapp/yarn-lock.json

- name: Install Dependencies (Webapp)
run: yarn install --frozen-lockfile

- name: Lint Webapp
run: yarn lint

#- name: Run Tests (Webapp)
# run: npm test

- name: Build Webapp
run: yarn build

# Merge Blocker: Ensure Both Builds Pass
merge_guard:
name: Ensure All Jobs Passed
needs: [api, webapp]
runs-on: ubuntu-latest
steps:
- name: Check for Failures
run: echo "All jobs passed!"
22 changes: 0 additions & 22 deletions api/src/app.controller.spec.ts

This file was deleted.

13 changes: 0 additions & 13 deletions api/src/app.controller.ts

This file was deleted.

10 changes: 4 additions & 6 deletions api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AppGlobalIdGeneratorService } from './app.global-id-generator.service';
import { ShortenUrlModule } from './shorten-url/shorten-url.module';

@Module({
imports: [ConfigModule.forRoot()], // Load environment variables
controllers: [AppController],
providers: [AppGlobalIdGeneratorService, AppService],
imports: [ConfigModule.forRoot(), ShortenUrlModule], // Load environment variables
controllers: [],
providers: [],
})
export class AppModule {}
35 changes: 35 additions & 0 deletions api/src/shorten-url/shorten-url.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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';

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

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

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

describe('shortenUrl', () => {
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

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

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

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

@Post()
shortenUrl(url: string): string {
return this.shortenUrlUsecase.createTinyURL('https://zapper.xyz');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,43 +12,35 @@ import { ConfigService } from '@nestjs/config';
* The machineId ID is a custom value that should be unique for each instance of the generator.
*/
@Injectable()
export class AppGlobalIdGeneratorService {
export class ShortenUrlIdGeneratorService {
private static readonly MACHINE_ID_BITS = 10;
private static readonly SEQUENCE_BITS = 12;

// indexing starts at 0: 2^(MACHINE_ID_BITS) - 1
private static readonly MAX_MACHINE_ID =
(1 << ShortenUrlIdGeneratorService.MACHINE_ID_BITS) - 1;
// indexing starts at 0: 2^(SEQUENCE_BITS) - 1
private static readonly MAX_SEQUENCE =
(1 << ShortenUrlIdGeneratorService.SEQUENCE_BITS) - 1;
private readonly epoch: number = 1609459200000;
// TODO must be configurable
private readonly machineId: number = 1;
private sequence: number;
private lastTimestamp: number;

// indexing starts at 0: 2^(MACHINE_ID_BITS) - 1
private static readonly MAX_MACHINE_ID =
(1 << AppGlobalIdGeneratorService.MACHINE_ID_BITS) - 1;
// indexing starts at 0: 2^(SEQUENCE_BITS) - 1
private static readonly MAX_SEQUENCE =
(1 << AppGlobalIdGeneratorService.SEQUENCE_BITS) - 1;

constructor(private readonly configService: ConfigService) {
this.machineId = Number(this.configService.get<number>('MACHINE_ID'));
if (
this.machineId < 0 ||
this.machineId > AppGlobalIdGeneratorService.MAX_MACHINE_ID
this.machineId > ShortenUrlIdGeneratorService.MAX_MACHINE_ID
) {
throw new Error(
`Invalid MACHINE_ID. Must be between 0 and ${AppGlobalIdGeneratorService.MAX_MACHINE_ID}.`,
`Invalid MACHINE_ID. Must be between 0 and ${ShortenUrlIdGeneratorService.MAX_MACHINE_ID}.`,
);
}
this.sequence = 0;
this.lastTimestamp = -1;
}

private waitUntilNextMillisecond(): void {
while (this.lastTimestamp <= Date.now()) {
this.lastTimestamp = Date.now();
}
}

/**
* Generate a new unique ID within the current timestamp using the Snowflake algorithm. If the number
* of ids generated in the same millisecond exceeds the maximum sequence number, the function will wait
Expand All @@ -67,7 +59,7 @@ export class AppGlobalIdGeneratorService {

if (currentTimestamp === this.lastTimestamp) {
this.sequence =
(this.sequence + 1) & AppGlobalIdGeneratorService.MAX_SEQUENCE;
(this.sequence + 1) & ShortenUrlIdGeneratorService.MAX_SEQUENCE;
if (this.sequence == 0) {
this.waitUntilNextMillisecond();
}
Expand All @@ -84,15 +76,21 @@ export class AppGlobalIdGeneratorService {

// prettier-ignore
const id =
(BigInt(currentTimestamp - this.epoch) <<
BigInt(
AppGlobalIdGeneratorService.MACHINE_ID_BITS +
AppGlobalIdGeneratorService.SEQUENCE_BITS,
)) |
(BigInt(this.machineId) <<
BigInt(AppGlobalIdGeneratorService.SEQUENCE_BITS)) |
BigInt(this.sequence);
(BigInt(currentTimestamp - this.epoch) <<
BigInt(
ShortenUrlIdGeneratorService.MACHINE_ID_BITS +
ShortenUrlIdGeneratorService.SEQUENCE_BITS,
)) |
(BigInt(this.machineId) <<
BigInt(ShortenUrlIdGeneratorService.SEQUENCE_BITS)) |
BigInt(this.sequence);

return id;
}

private waitUntilNextMillisecond(): void {
while (this.lastTimestamp <= Date.now()) {
this.lastTimestamp = Date.now();
}
}
}
12 changes: 12 additions & 0 deletions api/src/shorten-url/shorten-url.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
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';

@Module({
imports: [ConfigModule.forRoot()], // Load environment variables
controllers: [ShortenUrlController],
providers: [ShortenUrlIdGeneratorService, ShortenUrlUsecase],
})
export class ShortenUrlModule {}
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { Injectable } from '@nestjs/common';
import { AppGlobalIdGeneratorService } from './app.global-id-generator.service';
import { ShortenUrlIdGeneratorService } from './shorten-url.id-generator.service';

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

private static BASE62 = AppService.BASE62_CHARACTERS.length;
private static BASE62 = ShortenUrlUsecase.BASE62_CHARACTERS.length;

constructor(private readonly idGenerator: AppGlobalIdGeneratorService) {}
constructor(private readonly idGenerator: ShortenUrlIdGeneratorService) {}

createTinyURL(originalURL: string): string {
const id = this.idGenerator.generateId();
Expand All @@ -20,13 +20,13 @@ export class AppService {
}

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

let encoded = '';
while (id > 0) {
const remainder = id % AppService.BASE62;
encoded = AppService.BASE62_CHARACTERS[remainder] + encoded;
id = Math.floor(id / AppService.BASE62);
const remainder = id % ShortenUrlUsecase.BASE62;
encoded = ShortenUrlUsecase.BASE62_CHARACTERS[remainder] + encoded;
id = Math.floor(id / ShortenUrlUsecase.BASE62);
}
return encoded;
}
Expand Down
29 changes: 21 additions & 8 deletions webapp/app/api/url-shortener/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,26 @@ export async function POST(request: NextRequest) {

// Example function to simulate URL shortening logic
async function shortenUrl(url: string): Promise<string> {
// Replace this with your actual URL shortening implementation
// call an http service, generate a random string, etc
const response = await fetch('http://localhost:3001');
if (!response.ok) {
throw new Error('Failed to shorten URL');
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
});

console.log(response);

if (!response.ok) {
throw new Error(`Failed to shorten URL: ${response.statusText}`);
}

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
} catch (error) {
console.error('Error shortening URL:', error);
throw error;
}
const data = await response.text();
console.log('URL shortened successfully:' + data);
return data as string;
}
2 changes: 1 addition & 1 deletion webapp/components/url-shortener/ui/URLShortener.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ describe('URLShortenerV2', () => {
});
});

it("clicking the 'Shorten' for an empty url triggers an error messahe", async () => {
it("clicking the 'Shorten' for an empty url triggers an error message", async () => {
render(<URLShortener />);

const shortenButton = screen.getByRole('button', { name: /shorten url/i });
Expand Down