Skip to content

Commit 07191e2

Browse files
committed
add dynamodb persistence
1 parent 00bd252 commit 07191e2

13 files changed

+1252
-95
lines changed

api/.env-dev

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
MACHINE_ID=1
2-
PORT = 3000
2+
PORT=3000
33
# Perhpas we should only use a domain name here instead of a full URL (with the port as it is error prone)
4-
SHORTENED_BASE_URL = http://localhost:3000
5-
# redis | memory
6-
CACHE_PERSISTENCE= memory
7-
REDIS_URL = redis://localhost:6379
4+
SHORTENED_BASE_URL=http://localhost:3000
5+
# For testing purposes
6+
USE_PERSISTENT_STORAGE=false
7+
REDIS_URL=redis://localhost:6379
88
# 10 minutes
9-
REDIS_TTL = 600
9+
REDIS_TTL=600
10+
AWS_REGION=us-east-1
11+
DYNAMO_TABLE=shortUrls
12+
# for local development only
13+
DYNAMO_ENDPOINT=http://localhost:8000
14+
AWS_ACCESS_KEY_ID=dummy
15+
AWS_SECRET_ACCESS_KEY=dummy

api/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
"test:e2e": "jest --config ./test/jest-e2e.json"
2222
},
2323
"dependencies": {
24+
"@aws-sdk/client-dynamodb": "3.775.0",
25+
"@aws-sdk/credential-providers": "3.775.0",
26+
"@aws-sdk/lib-dynamodb": "3.775.0",
2427
"@nestjs/common": "10.0.0",
2528
"@nestjs/config": "4.0.0",
2629
"@nestjs/core": "10.0.0",
Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
import { Module, DynamicModule } from '@nestjs/common';
2-
import { ConfigModule, ConfigService } from '@nestjs/config';
2+
import { ConfigModule } from '@nestjs/config';
33
import { ShortenUrlRepository } from '../shorten-url/shorten-url.repository';
4-
import { RepositoryProvider } from './repository/repository.provider';
4+
import { ShortenUrlRepositoryFactory } from './repository/shorten-url/shorten-url-repository.factory';
5+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
6+
import { DynamoClientProvider } from './provider/dynamodb-client.provider';
7+
import { RedisClientProvider } from './provider/redis-client.provider';
58

69
@Module({
7-
imports: [ConfigModule], // ✅ Ensure ConfigModule is loaded
10+
imports: [ConfigModule],
811
})
912
export class InfrastructureModule {
1013
static register(): DynamicModule {
1114
return {
1215
module: InfrastructureModule,
13-
providers: [RepositoryProvider],
14-
exports: [ShortenUrlRepository], // ✅ Export repository so other modules can use it
16+
providers: [
17+
RedisClientProvider,
18+
DynamoClientProvider,
19+
ShortenUrlRepositoryFactory,
20+
],
21+
exports: [RedisClientProvider, DynamoDBClient, ShortenUrlRepository],
1522
};
1623
}
1724
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// infrastructure/providers/dynamodb-client.provider.ts
2+
import { Logger, Provider } from '@nestjs/common';
3+
import { ConfigService } from '@nestjs/config';
4+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
5+
import { fromEnv } from '@aws-sdk/credential-providers';
6+
7+
export const DynamoClientProvider: Provider = {
8+
provide: DynamoDBClient,
9+
useFactory: (configService: ConfigService): DynamoDBClient => {
10+
const usePersistent = configService.get<string>(
11+
'USE_PERSISTENT_STORAGE',
12+
'false',
13+
);
14+
15+
if (usePersistent === 'false') {
16+
Logger.log('Skipping DynamoDB client creation (non-persistent mode)');
17+
return null;
18+
}
19+
20+
return new DynamoDBClient({
21+
region: configService.get('AWS_REGION', 'local'),
22+
endpoint: configService.get('DYNAMO_ENDPOINT', 'http://localhost:8000'),
23+
credentials: fromEnv({}),
24+
});
25+
},
26+
inject: [ConfigService],
27+
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import {
2+
Injectable,
3+
Logger,
4+
OnModuleInit,
5+
OnModuleDestroy,
6+
} from '@nestjs/common';
7+
import { ConfigService } from '@nestjs/config';
8+
import { createClient, RedisClientType } from 'redis';
9+
10+
@Injectable()
11+
export class RedisClientProvider implements OnModuleInit, OnModuleDestroy {
12+
private client: RedisClientType | null = null;
13+
14+
constructor(private readonly configService: ConfigService) {}
15+
16+
async get(key: string): Promise<string | null> {
17+
return this.client.get(key);
18+
}
19+
20+
async set(key: string, value: string, ttlInSeconds: number): Promise<void> {
21+
await this.client.set(key, value, { EX: ttlInSeconds });
22+
}
23+
24+
async onModuleInit() {
25+
const usePersistent = this.configService.get<string>(
26+
'USE_PERSISTENT_STORAGE',
27+
'false',
28+
);
29+
30+
if (usePersistent === 'false') {
31+
Logger.log('🧪 Skipping Redis connection (non-persistent mode)');
32+
return;
33+
}
34+
35+
const redisUrl = this.configService.get<string>('REDIS_URL');
36+
this.client = createClient({ url: redisUrl });
37+
38+
this.client.on('error', (err) =>
39+
Logger.error('Redis client error', err, 'REDIS_CLIENT'),
40+
);
41+
42+
await this.client.connect();
43+
Logger.log('Connected to Redis');
44+
}
45+
46+
async onModuleDestroy() {
47+
if (this.client) {
48+
await this.client.quit();
49+
Logger.log('Redis connection closed');
50+
}
51+
}
52+
}

api/src/infrastructure/repository/redis-url.repository.ts

Lines changed: 0 additions & 56 deletions
This file was deleted.

api/src/infrastructure/repository/repository.provider.ts

Lines changed: 0 additions & 26 deletions
This file was deleted.

api/src/infrastructure/repository/in-memory-url.repository.ts renamed to api/src/infrastructure/repository/shorten-url/in-memory-url-storage.repository.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { ShortenUrlRepository } from '../../shorten-url/shorten-url.repository';
1+
import { ShortenUrlRepository } from '../../../shorten-url/shorten-url.repository';
22
import { Injectable, Logger } from '@nestjs/common';
33

44
@Injectable()
5-
export class InMemoryUrlRepository implements ShortenUrlRepository {
5+
export class InMemoryUrlStorageRepository implements ShortenUrlRepository {
66
private urls: Map<string, string> = new Map();
77

88
create(url: string, encodedUrl: string): Promise<void> {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { ShortenUrlRepository } from '../../../shorten-url/shorten-url.repository';
2+
import { Inject, Injectable, Logger } from '@nestjs/common';
3+
import { ConfigService } from '@nestjs/config';
4+
import {
5+
DynamoDBClient,
6+
PutItemCommand,
7+
GetItemCommand,
8+
} from '@aws-sdk/client-dynamodb';
9+
import { unmarshall } from '@aws-sdk/util-dynamodb';
10+
import { RedisClientProvider } from '../../provider/redis-client.provider';
11+
12+
@Injectable()
13+
export class PersistentUrlStorageRepository implements ShortenUrlRepository {
14+
private readonly DEFAULT_TTL = 100;
15+
private readonly redisTTL: number;
16+
private readonly tableName: string;
17+
18+
constructor(
19+
private readonly configService: ConfigService,
20+
private readonly redisClient: RedisClientProvider | null,
21+
@Inject(DynamoDBClient)
22+
private readonly dynamoDbClient: DynamoDBClient | null,
23+
) {
24+
this.redisTTL = this.configService.get<number>(
25+
'REDIS_TTL',
26+
this.DEFAULT_TTL,
27+
);
28+
this.tableName = configService.get('DYNAMO_TABLE', 'shortUrls');
29+
}
30+
31+
async create(originalUrl: string, shortId: string): Promise<void> {
32+
const now = new Date().toISOString();
33+
34+
// Write to DynamoDB
35+
await this.dynamoDbClient.send(
36+
new PutItemCommand({
37+
TableName: this.tableName,
38+
Item: {
39+
shortId: { S: shortId },
40+
originalUrl: { S: originalUrl },
41+
createdAt: { S: now },
42+
},
43+
}),
44+
);
45+
46+
// Cache in Redis
47+
await this.redisClient.set(`short:${shortId}`, originalUrl, this.redisTTL);
48+
}
49+
50+
async findOriginalURL(shortId: string): Promise<string | null> {
51+
// Try cache first
52+
const cached = await this.redisClient.get(`short:${shortId}`);
53+
if (cached) {
54+
Logger.debug(`Cache hit for ${shortId}`);
55+
return cached;
56+
}
57+
58+
Logger.debug(`Cache miss for ${shortId}. Fetching from DynamoDB...`);
59+
60+
// Fall back to DynamoDB
61+
const result = await this.dynamoDbClient.send(
62+
new GetItemCommand({
63+
TableName: this.tableName,
64+
Key: { shortId: { S: shortId } },
65+
}),
66+
);
67+
68+
if (!result.Item) return null;
69+
70+
const item = unmarshall(result.Item);
71+
const originalUrl = item.originalUrl;
72+
73+
// Cache for future
74+
await this.redisClient.set(`short:${shortId}`, originalUrl, this.redisTTL);
75+
76+
return originalUrl;
77+
}
78+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { ConfigService } from '@nestjs/config';
2+
import { Provider } from '@nestjs/common';
3+
import { InMemoryUrlStorageRepository } from './in-memory-url-storage.repository';
4+
import { ShortenUrlRepository } from '../../../shorten-url/shorten-url.repository';
5+
import { PersistentUrlStorageRepository } from './persistent-url-storage.repository';
6+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
7+
import { RedisClientProvider } from '../../provider/redis-client.provider';
8+
9+
/**
10+
* Factory provider to select the appropriate repository implementation based on configuration.
11+
*/
12+
export const ShortenUrlRepositoryFactory: Provider = {
13+
// TODO: Use a constant for the key
14+
provide: ShortenUrlRepository,
15+
useFactory: (
16+
configService: ConfigService,
17+
redisClientService: RedisClientProvider | null,
18+
dynamoDbClient: DynamoDBClient | null,
19+
) => {
20+
const usePersistenceStorage = configService.get<string>(
21+
'USE_PERSISTENT_STORAGE',
22+
'true',
23+
);
24+
25+
if (usePersistenceStorage === 'true') {
26+
return new PersistentUrlStorageRepository(
27+
configService,
28+
redisClientService,
29+
dynamoDbClient,
30+
);
31+
}
32+
33+
return new InMemoryUrlStorageRepository();
34+
},
35+
inject: [ConfigService, RedisClientProvider, DynamoDBClient],
36+
};

0 commit comments

Comments
 (0)