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
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
registry=https://registry.npmjs.org/
18 changes: 12 additions & 6 deletions api/.env-dev
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
MACHINE_ID=1
PORT = 3000
PORT=3000
# Perhpas we should only use a domain name here instead of a full URL (with the port as it is error prone)
SHORTENED_BASE_URL = http://localhost:3000
# redis | memory
CACHE_PERSISTENCE= memory
REDIS_URL = redis://localhost:6379
SHORTENED_BASE_URL=http://localhost:3000
# You can set this property to false for rapid testing. It will use in-memory storage rather than pulling Redis and DynamoDB
USE_PERSISTENT_STORAGE=false
REDIS_URL=redis://localhost:6379
# 10 minutes
REDIS_TTL = 600
REDIS_TTL=600
AWS_REGION=us-east-1
DYNAMO_TABLE=shortUrls
# for local development only
DYNAMO_ENDPOINT=http://localhost:8000
AWS_ACCESS_KEY_ID=dummy
AWS_SECRET_ACCESS_KEY=dummy
1 change: 1 addition & 0 deletions api/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
registry=https://registry.npmjs.org/
3 changes: 3 additions & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-dynamodb": "3.775.0",
"@aws-sdk/credential-providers": "3.775.0",
"@aws-sdk/lib-dynamodb": "3.775.0",
"@nestjs/common": "10.0.0",
"@nestjs/config": "4.0.0",
"@nestjs/core": "10.0.0",
Expand Down
17 changes: 12 additions & 5 deletions api/src/infrastructure/infrastructure.module.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { Module, DynamicModule } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ConfigModule } from '@nestjs/config';
import { ShortenUrlRepository } from '../shorten-url/shorten-url.repository';
import { RepositoryProvider } from './repository/repository.provider';
import { ShortenUrlRepositoryFactory } from './repository/shorten-url/shorten-url-repository.factory';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoClientProvider } from './provider/dynamodb-client.provider';
import { RedisClientProvider } from './provider/redis-client.provider';

@Module({
imports: [ConfigModule], // ✅ Ensure ConfigModule is loaded
imports: [ConfigModule],
})
export class InfrastructureModule {
static register(): DynamicModule {
return {
module: InfrastructureModule,
providers: [RepositoryProvider],
exports: [ShortenUrlRepository], // ✅ Export repository so other modules can use it
providers: [
RedisClientProvider,
DynamoClientProvider,
ShortenUrlRepositoryFactory,
],
exports: [RedisClientProvider, DynamoDBClient, ShortenUrlRepository],
};
}
}
27 changes: 27 additions & 0 deletions api/src/infrastructure/provider/dynamodb-client.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// infrastructure/providers/dynamodb-client.provider.ts
import { Logger, Provider } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { fromEnv } from '@aws-sdk/credential-providers';

export const DynamoClientProvider: Provider = {
provide: DynamoDBClient,
useFactory: (configService: ConfigService): DynamoDBClient => {
const usePersistent = configService.get<string>(
'USE_PERSISTENT_STORAGE',
'false',
);

if (usePersistent === 'false') {
Logger.log('Skipping DynamoDB client creation (non-persistent mode)');
return null;
}

return new DynamoDBClient({
region: configService.get('AWS_REGION', 'local'),
endpoint: configService.get('DYNAMO_ENDPOINT', 'http://localhost:8000'),
credentials: fromEnv({}),
});
},
inject: [ConfigService],
};
52 changes: 52 additions & 0 deletions api/src/infrastructure/provider/redis-client.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {
Injectable,
Logger,
OnModuleInit,
OnModuleDestroy,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createClient, RedisClientType } from 'redis';

@Injectable()
export class RedisClientProvider implements OnModuleInit, OnModuleDestroy {
private client: RedisClientType | null = null;

constructor(private readonly configService: ConfigService) {}

async get(key: string): Promise<string | null> {
return this.client.get(key);
}

async set(key: string, value: string, ttlInSeconds: number): Promise<void> {
await this.client.set(key, value, { EX: ttlInSeconds });
}

async onModuleInit() {
const usePersistent = this.configService.get<string>(
'USE_PERSISTENT_STORAGE',
'false',
);

if (usePersistent === 'false') {
Logger.log('🧪 Skipping Redis connection (non-persistent mode)');
return;
}

const redisUrl = this.configService.get<string>('REDIS_URL');
this.client = createClient({ url: redisUrl });

this.client.on('error', (err) =>
Logger.error('Redis client error', err, 'REDIS_CLIENT'),
);

await this.client.connect();
Logger.log('Connected to Redis');
}

async onModuleDestroy() {
if (this.client) {
await this.client.quit();
Logger.log('Redis connection closed');
}
}
}
56 changes: 0 additions & 56 deletions api/src/infrastructure/repository/redis-url.repository.ts

This file was deleted.

26 changes: 0 additions & 26 deletions api/src/infrastructure/repository/repository.provider.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { ShortenUrlRepository } from '../../shorten-url/shorten-url.repository';
import { ShortenUrlRepository } from '../../../shorten-url/shorten-url.repository';
import { Injectable, Logger } from '@nestjs/common';

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

create(url: string, encodedUrl: string): Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { ShortenUrlRepository } from '../../../shorten-url/shorten-url.repository';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
DynamoDBClient,
PutItemCommand,
GetItemCommand,
} from '@aws-sdk/client-dynamodb';
import { unmarshall } from '@aws-sdk/util-dynamodb';
import { RedisClientProvider } from '../../provider/redis-client.provider';

@Injectable()
export class PersistentUrlStorageRepository implements ShortenUrlRepository {
private readonly DEFAULT_TTL = 100;
private readonly redisTTL: number;
private readonly tableName: string;

constructor(
private readonly configService: ConfigService,
private readonly redisClient: RedisClientProvider | null,
@Inject(DynamoDBClient)
private readonly dynamoDbClient: DynamoDBClient | null,
) {
this.redisTTL = this.configService.get<number>(
'REDIS_TTL',
this.DEFAULT_TTL,
);
this.tableName = configService.get('DYNAMO_TABLE', 'shortUrls');
}

async create(originalUrl: string, shortId: string): Promise<void> {
const now = new Date().toISOString();

// Write to DynamoDB
await this.dynamoDbClient.send(
new PutItemCommand({
TableName: this.tableName,
Item: {
shortId: { S: shortId },
originalUrl: { S: originalUrl },
createdAt: { S: now },
},
}),
);

// Cache in Redis
await this.redisClient.set(`short:${shortId}`, originalUrl, this.redisTTL);
}

async findOriginalURL(shortId: string): Promise<string | null> {
// Try cache first
const cached = await this.redisClient.get(`short:${shortId}`);
if (cached) {
Logger.debug(`Cache hit for ${shortId}`);
return cached;
}

Logger.debug(`Cache miss for ${shortId}. Fetching from DynamoDB...`);

// Fall back to DynamoDB
const result = await this.dynamoDbClient.send(
new GetItemCommand({
TableName: this.tableName,
Key: { shortId: { S: shortId } },
}),
);

if (!result.Item) return null;

const item = unmarshall(result.Item);
const originalUrl = item.originalUrl;

// Cache for future
await this.redisClient.set(`short:${shortId}`, originalUrl, this.redisTTL);

return originalUrl;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ConfigService } from '@nestjs/config';
import { Provider } from '@nestjs/common';
import { InMemoryUrlStorageRepository } from './in-memory-url-storage.repository';
import { ShortenUrlRepository } from '../../../shorten-url/shorten-url.repository';
import { PersistentUrlStorageRepository } from './persistent-url-storage.repository';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { RedisClientProvider } from '../../provider/redis-client.provider';

/**
* Factory provider to select the appropriate repository implementation based on configuration.
*/
export const ShortenUrlRepositoryFactory: Provider = {
// TODO: Use a constant for the key
provide: ShortenUrlRepository,
useFactory: (
configService: ConfigService,
redisClientService: RedisClientProvider | null,
dynamoDbClient: DynamoDBClient | null,
) => {
const usePersistenceStorage = configService.get<string>(
'USE_PERSISTENT_STORAGE',
'true',
);

if (usePersistenceStorage === 'true') {
return new PersistentUrlStorageRepository(
configService,
redisClientService,
dynamoDbClient,
);
}

return new InMemoryUrlStorageRepository();
},
inject: [ConfigService, RedisClientProvider, DynamoDBClient],
};
Loading
Loading