@developers-joyride/shortify
Version:
High performance URL shortener library with multi-database support (MongoDB, SQLite, PostgreSQL, MySQL)
160 lines (135 loc) • 3.9 kB
text/typescript
import { IUrl, IDatabaseAdapter } from "../interfaces/database.interface";
import { URL } from "url";
import { generateId } from "../utils/id-generator";
export interface ShortifyOptions {
baseUrl: string;
urlLength?: number;
customUrlId?: string;
expiresInDays?: number;
}
export interface ShortifyResult {
originalUrl: string;
shortUrl: string;
urlId: string;
expiresAt?: Date;
}
export class ShortenerService {
private defaultUrlLength: number = 8;
private baseUrl: string;
private dbAdapter: IDatabaseAdapter;
constructor(baseUrl: string, dbAdapter: IDatabaseAdapter) {
// Ensure the base URL ends with a slash
this.baseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
this.dbAdapter = dbAdapter;
}
/**
* Validate if a URL is properly formatted
*/
private isValidUrl(urlString: string): boolean {
try {
const url = new URL(urlString);
return url.protocol === "http:" || url.protocol === "https:";
} catch (error) {
return false;
}
}
/**
* Generate a short URL ID
*/
private generateUrlId(length: number = this.defaultUrlLength): string {
return generateId(length);
}
/**
* Check if a URL ID already exists in the database
* For performance, we use this to avoid duplicate URL IDs
*/
private async findExistingUrlId(urlId: string): Promise<IUrl | null> {
return this.dbAdapter.findUrlByUrlId(urlId);
}
/**
* Shorten a URL
*/
async shorten(
originalUrl: string,
options: Partial<ShortifyOptions> = {}
): Promise<ShortifyResult> {
// Validate the original URL
if (!this.isValidUrl(originalUrl)) {
throw new Error("Invalid URL format");
}
// Apply options with defaults
const urlLength = options.urlLength || this.defaultUrlLength;
const baseUrl = options.baseUrl || this.baseUrl;
// Generate URL ID (either custom or generated)
let urlId = options.customUrlId || this.generateUrlId(urlLength);
// Check if the URL ID already exists and generate a new one if needed
let existingUrl = await this.findExistingUrlId(urlId);
while (existingUrl) {
urlId = this.generateUrlId(urlLength);
existingUrl = await this.findExistingUrlId(urlId);
}
// Calculate expiration date if provided
let expiresAt: Date | undefined = undefined;
if (options.expiresInDays) {
expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + options.expiresInDays);
}
// Create the short URL
const shortUrl = `${baseUrl}${urlId}`;
// Save the URL to database
await this.dbAdapter.createUrl({
urlId,
originalUrl,
shortUrl,
clicks: 0,
expiresAt,
});
return {
originalUrl,
shortUrl,
urlId,
expiresAt,
};
}
/**
* Resolve a short URL to its original URL
*/
async resolve(urlId: string): Promise<string | null> {
const url = await this.dbAdapter.findUrlByUrlId(urlId);
if (!url) {
return null;
}
// Check if URL has expired
if (url.expiresAt && url.expiresAt < new Date()) {
// URL has expired, delete it
await this.dbAdapter.deleteUrl(urlId);
return null;
}
// Increment click count
await this.dbAdapter.updateUrlClicks(urlId, url.clicks + 1);
return url.originalUrl;
}
/**
* Get URL stats
*/
async getUrlStats(urlId: string): Promise<Partial<IUrl> | null> {
const url = await this.dbAdapter.findUrlByUrlId(urlId);
if (!url) {
return null;
}
return {
urlId: url.urlId,
originalUrl: url.originalUrl,
shortUrl: url.shortUrl,
clicks: url.clicks,
createdAt: url.createdAt,
expiresAt: url.expiresAt,
};
}
/**
* Delete a shortened URL
*/
async deleteUrl(urlId: string): Promise<boolean> {
return this.dbAdapter.deleteUrl(urlId);
}
}