From 02746d56ed9df9fb16d695ca440ff6eb795539a5 Mon Sep 17 00:00:00 2001 From: Harsh Patel Date: Fri, 2 Aug 2024 12:41:30 +0530 Subject: [PATCH] feat: add createLink route --- bruno/Create New Link.bru | 18 +++ bruno/bruno.json | 9 ++ src/controllers/links-controller.ts | 48 ++++++- src/models/links.ts | 29 +++++ src/routes/index.ts | 10 +- src/routes/link-router.ts | 14 ++ src/routes/links-router.ts | 7 - src/services/link-service.ts | 46 +++++++ src/utils/errors.ts | 192 +++++++++++++--------------- src/utils/links.ts | 14 ++ src/utils/server.ts | 18 +-- 11 files changed, 282 insertions(+), 123 deletions(-) create mode 100644 bruno/Create New Link.bru create mode 100644 bruno/bruno.json create mode 100644 src/models/links.ts create mode 100644 src/routes/link-router.ts delete mode 100644 src/routes/links-router.ts create mode 100644 src/services/link-service.ts create mode 100644 src/utils/links.ts diff --git a/bruno/Create New Link.bru b/bruno/Create New Link.bru new file mode 100644 index 0000000..8b67900 --- /dev/null +++ b/bruno/Create New Link.bru @@ -0,0 +1,18 @@ +meta { + name: Create New Link + type: http + seq: 2 +} + +post { + url: http://localhost:5050/api/v1/links + body: json + auth: none +} + +body:json { + { + "longUrl": "http://github.com/HelloWorld", + "customCode": "hiii" + } +} diff --git a/bruno/bruno.json b/bruno/bruno.json new file mode 100644 index 0000000..b0733dc --- /dev/null +++ b/bruno/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "kzilla.xyz", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/src/controllers/links-controller.ts b/src/controllers/links-controller.ts index 7a132af..29bae19 100644 --- a/src/controllers/links-controller.ts +++ b/src/controllers/links-controller.ts @@ -1 +1,47 @@ -// controller functions +import type { Context } from "hono"; +import { createLinkSchema } from "../models/links"; +import { createLink, fetchLink } from "../services/link-service"; +import { generateRandomCode } from "../utils/links"; +import { BackendError } from "../utils/errors"; +import { getConnInfo } from "@hono/node-server/conninfo"; + +/** +@summary Validates the request body and creates a new link +*/ +export async function handleCreateLink(c: Context) { + const ipAddress = getConnInfo(c).remote.address || "::1"; + const reqBody = await c.req.json(); + const { longUrl, customCode } = await createLinkSchema.parseAsync(reqBody); + + let shortCode = customCode ?? generateRandomCode(6); + let analyticsCode = generateRandomCode(6); + let linkId = generateRandomCode(12); + + const shortCodeConflict = await fetchLink(shortCode, analyticsCode, linkId); + + if (shortCodeConflict) { + if (customCode && shortCodeConflict.customCode === customCode) { + throw new BackendError("CONFLICT", { + message: "CUSTOM_CODE_CONFLICT", + details: "The custom url you provided already exists", + }); + } + + // TODO: possiblity of bad practice here... check later + console.log("DEBUG: Conflicts occured, retrying"); + shortCode = generateRandomCode(6); + analyticsCode = generateRandomCode(6); + linkId = generateRandomCode(12); + } + + const createLinkRes = await createLink( + longUrl, + shortCode, + analyticsCode, + linkId, + ipAddress, + ); + + console.log(createLinkRes); + return c.json({ message: "Link created successfully" }); +} diff --git a/src/models/links.ts b/src/models/links.ts new file mode 100644 index 0000000..6bed20d --- /dev/null +++ b/src/models/links.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +export const linkSchema = z.object({ + linkId: z.string().min(10), + clicks: z.number().default(0), + analyticsCode: z.string().trim().min(5), + longUrl: z.string().url().trim().min(5), + customCode: z.string().trim().min(4).max(25).optional(), + creatorIpAddress: z.string().ip().optional().default("::1"), + logs: z + .array( + z.object({ + ipAddress: z.string().ip(), + timestamp: z.number(), + }), + ) + .optional(), + // * This naming is followed to keep it backward compatible don't try to improve it Lol' + enabled: z.boolean().default(true), + timestamp: z.date().default(new Date()), +}); + +export const createLinkSchema = linkSchema.pick({ + longUrl: true, + customCode: true, +}); + +export type LinkSchemaType = z.infer; +export type CreateLinkSchemaType = z.infer; diff --git a/src/routes/index.ts b/src/routes/index.ts index 42b58f7..9208679 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,8 +1,12 @@ -import { Hono } from 'hono'; -import linkRouter from './links-router'; +import { Hono } from "hono"; +import linkRouter from "./link-router"; const appRouter = new Hono(); -appRouter.route('/links', linkRouter); +appRouter.get("/", (c) => { + return c.text("Hello World!"); +}); + +appRouter.route("/links", linkRouter); export default appRouter; diff --git a/src/routes/link-router.ts b/src/routes/link-router.ts new file mode 100644 index 0000000..60730c2 --- /dev/null +++ b/src/routes/link-router.ts @@ -0,0 +1,14 @@ +import { Hono } from "hono"; +import { handleCreateLink } from "../controllers/links-controller"; + +const linkRouter = new Hono(); + +// linkRouter.get("/me", handleFetchMyLinks); +// linkRouter.get("/:shortCode", handleFetchLink); + +linkRouter.post("/", handleCreateLink); + +// linkRouter.put("/", handleUpdateLink); +// linkRouter.put("/flush", handleFlushLinks); + +export default linkRouter; diff --git a/src/routes/links-router.ts b/src/routes/links-router.ts deleted file mode 100644 index 3c6202e..0000000 --- a/src/routes/links-router.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Hono } from 'hono'; - -const linkRouter = new Hono(); - -linkRouter.get('/:id', c => c.json(`test get ${c.req.param('id')}`)); - -export default linkRouter; diff --git a/src/services/link-service.ts b/src/services/link-service.ts new file mode 100644 index 0000000..3917b4e --- /dev/null +++ b/src/services/link-service.ts @@ -0,0 +1,46 @@ +import type { LinkSchemaType } from "../models/links"; +import db from "../utils/db"; +import { BackendError } from "../utils/errors"; + +export async function fetchLink( + shortCode: string, + analyticsCode?: string, + linkId?: string, +) { + try { + const linksCollection = (await db()).collection("links"); + + return await linksCollection.findOne({ + shortCode, + analyticsCode, + linkId, + }); + } catch (err) { + throw new BackendError("INTERNAL_ERROR", { + message: "Error while fetch a link", + details: err, + }); + } +} + +export async function createLink( + longUrl: string, + shortCode: string, + analyticsCode: string, + linkId: string, + ipAddress: string, +) { + const linksCollection = (await db()).collection("links"); + + return await linksCollection.insertOne({ + linkId, + longUrl, + customCode: shortCode, + analyticsCode, + clicks: 0, + creatorIpAddress: ipAddress, + enabled: true, + timestamp: new Date(), + logs: [], + }); +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 78d8ff8..0fefdf2 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -1,71 +1,70 @@ -import consola from 'consola'; -import type { NextFunction, Request, Response } from 'express'; -import postgres from 'postgres'; -import { ZodError } from 'zod'; +import type { Context, Next } from "hono"; +import type { StatusCode } from "hono/utils/http-status"; +import { ZodError } from "zod"; type HttpErrorCode = - | 'BAD_REQUEST' - | 'UNAUTHORIZED' - | 'NOT_FOUND' - | 'METHOD_NOT_ALLOWED' - | 'NOT_ACCEPTABLE' - | 'REQUEST_TIMEOUT' - | 'CONFLICT' - | 'GONE' - | 'LENGTH_REQUIRED' - | 'PRECONDITION_FAILED' - | 'PAYLOAD_TOO_LARGE' - | 'URI_TOO_LONG' - | 'UNSUPPORTED_MEDIA_TYPE' - | 'RANGE_NOT_SATISFIABLE' - | 'EXPECTATION_FAILED' - | 'TEAPOT'; + | "BAD_REQUEST" + | "UNAUTHORIZED" + | "NOT_FOUND" + | "METHOD_NOT_ALLOWED" + | "NOT_ACCEPTABLE" + | "REQUEST_TIMEOUT" + | "CONFLICT" + | "GONE" + | "LENGTH_REQUIRED" + | "PRECONDITION_FAILED" + | "PAYLOAD_TOO_LARGE" + | "URI_TOO_LONG" + | "UNSUPPORTED_MEDIA_TYPE" + | "RANGE_NOT_SATISFIABLE" + | "EXPECTATION_FAILED" + | "TEAPOT"; type BackendErrorCode = - | 'VALIDATION_ERROR' - | 'USER_NOT_FOUND' - | 'INVALID_PASSWORD'; + | "VALIDATION_ERROR" + | "USER_NOT_FOUND" + | "INVALID_PASSWORD"; -type ErrorCode = HttpErrorCode | BackendErrorCode | 'INTERNAL_ERROR'; +type ErrorCode = HttpErrorCode | BackendErrorCode | "INTERNAL_ERROR"; export function getStatusFromErrorCode(code: ErrorCode): number { switch (code) { - case 'BAD_REQUEST': - case 'VALIDATION_ERROR': + case "BAD_REQUEST": + case "VALIDATION_ERROR": return 400; - case 'UNAUTHORIZED': - case 'INVALID_PASSWORD': + case "UNAUTHORIZED": + case "INVALID_PASSWORD": return 401; - case 'NOT_FOUND': - case 'USER_NOT_FOUND': + case "NOT_FOUND": + case "USER_NOT_FOUND": return 404; - case 'METHOD_NOT_ALLOWED': + case "METHOD_NOT_ALLOWED": return 405; - case 'NOT_ACCEPTABLE': + case "NOT_ACCEPTABLE": return 406; - case 'REQUEST_TIMEOUT': + case "REQUEST_TIMEOUT": return 408; - case 'CONFLICT': + case "CONFLICT": return 409; - case 'GONE': + case "GONE": return 410; - case 'LENGTH_REQUIRED': + case "LENGTH_REQUIRED": return 411; - case 'PRECONDITION_FAILED': + case "PRECONDITION_FAILED": return 412; - case 'PAYLOAD_TOO_LARGE': + case "PAYLOAD_TOO_LARGE": return 413; - case 'URI_TOO_LONG': + case "URI_TOO_LONG": return 414; - case 'UNSUPPORTED_MEDIA_TYPE': + case "UNSUPPORTED_MEDIA_TYPE": return 415; - case 'RANGE_NOT_SATISFIABLE': + case "RANGE_NOT_SATISFIABLE": return 416; - case 'EXPECTATION_FAILED': + case "EXPECTATION_FAILED": return 417; - case 'TEAPOT': + case "TEAPOT": return 418; // I'm a teapot - case 'INTERNAL_ERROR': + case "INTERNAL_ERROR": return 500; default: return 500; @@ -74,43 +73,51 @@ export function getStatusFromErrorCode(code: ErrorCode): number { export function getMessageFromErrorCode(code: ErrorCode): string { switch (code) { - case 'BAD_REQUEST': - return 'The request is invalid.'; - case 'VALIDATION_ERROR': - return 'The request contains invalid or missing fields.'; - case 'UNAUTHORIZED': - return 'You are not authorized to access this resource.'; - case 'NOT_FOUND': - return 'The requested resource was not found.'; - case 'USER_NOT_FOUND': - return 'The user was not found.'; - case 'INTERNAL_ERROR': - return 'An internal server error occurred.'; - case 'CONFLICT': - return 'The request conflicts with the current state of the server.'; - case 'INVALID_PASSWORD': - return 'The password is incorrect.'; + case "BAD_REQUEST": + return "The request is invalid."; + case "VALIDATION_ERROR": + return "The request contains invalid or missing fields."; + case "UNAUTHORIZED": + return "You are not authorized to access this resource."; + case "NOT_FOUND": + return "The requested resource was not found."; + case "USER_NOT_FOUND": + return "The user was not found."; + case "INTERNAL_ERROR": + return "An internal server error occurred."; + case "CONFLICT": + return "The request conflicts with the current state of the server."; + case "INVALID_PASSWORD": + return "The password is incorrect."; default: - return 'An internal server error occurred.'; + return "An internal server error occurred."; } } export function handleValidationError(err: ZodError): { invalidFields: string[]; requiredFields: string[]; + rawError: JSON; } { const invalidFields = []; const requiredFields = []; + console.log(err.errors); for (const error of err.errors) { - if (error.code === 'invalid_type') invalidFields.push(error.path.join('.')); - else if (error.message === 'Required') - requiredFields.push(error.path.join('.')); + if (error.message === "Required") { + requiredFields.push(error.path.join(".")); + } else if ( + error.code === "invalid_type" || + error.code === "invalid_string" + ) { + invalidFields.push(error.path.join(".")); + } } return { invalidFields, requiredFields, + rawError: JSON.parse(err.message), }; } @@ -133,24 +140,14 @@ export class BackendError extends Error { } } -export function errorHandler( - error: unknown, - req: Request, - res: Response<{ - code: ErrorCode; - message: string; - details?: unknown; - }>, - _next: NextFunction, -) { +export function errorHandler(error: unknown, c: Context) { let statusCode = 500; let code: ErrorCode | undefined; let message: string | undefined; let details: unknown | undefined; - const ip = req.ip; - const url = req.originalUrl; - const method = req.method; + const url = c.req.url; + const method = c.req.method; if (error instanceof BackendError) { message = error.message; @@ -159,44 +156,31 @@ export function errorHandler( statusCode = getStatusFromErrorCode(code); } - if (error instanceof postgres.PostgresError) { - code = 'INTERNAL_ERROR'; - message = 'The DB crashed maybe because they dont like you :p'; - statusCode = getStatusFromErrorCode(code); - details = error; - } - if (error instanceof ZodError) { - code = 'VALIDATION_ERROR'; + code = "VALIDATION_ERROR"; message = getMessageFromErrorCode(code); details = handleValidationError(error); statusCode = getStatusFromErrorCode(code); } - if ((error as { code: string }).code === 'ECONNREFUSED') { - code = 'INTERNAL_ERROR'; - message = 'The DB crashed maybe because they dont like you :p'; + if ((error as { code: string }).code === "ECONNREFUSED") { + code = "INTERNAL_ERROR"; + message = "The DB crashed maybe because they dont like you :p"; details = error; } - code = code ?? 'INTERNAL_ERROR'; + code = code ?? "INTERNAL_ERROR"; message = message ?? getMessageFromErrorCode(code); details = details ?? error; - consola.error(`${ip} [${method}] ${url} ${code} - ${message}`); + console.error(`$ [${method}] ${url} ${code} - ${message}`); - res.status(statusCode).json({ - code, - message, - details, - }); -} - -export function handle404Error(_req: Request, res: Response) { - const code: ErrorCode = 'NOT_FOUND'; - res.status(getStatusFromErrorCode(code)).json({ - code, - message: 'Route not found', - details: 'The route you are trying to access does not exist', - }); + return c.json( + { + code, + message, + details, + }, + statusCode as StatusCode, + ); } diff --git a/src/utils/links.ts b/src/utils/links.ts new file mode 100644 index 0000000..0715d4e --- /dev/null +++ b/src/utils/links.ts @@ -0,0 +1,14 @@ +/** + * Generates a random code of given length + * @param length length of random code + */ +export const generateRandomCode = (length = 5) => { + let result = ""; + const characters = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890"; + const charactersLength = characters.length; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +}; diff --git a/src/utils/server.ts b/src/utils/server.ts index c7bdbd1..b07caab 100644 --- a/src/utils/server.ts +++ b/src/utils/server.ts @@ -1,14 +1,16 @@ -import type { Hono } from 'hono'; -import appRouter from '../routes'; -import InitDatabase from './db'; +import type { Context, Hono } from "hono"; +import appRouter from "../routes"; +import InitDatabase from "./db"; +import { appendTrailingSlash, trimTrailingSlash } from "hono/trailing-slash"; +import { errorHandler } from "./errors"; export async function BootstrapServer(app: Hono) { await InitDatabase(); - console.log('✅ Database Connected!'); + console.log("✅ Database Connected!"); - app.get('/', c => { - return c.text('Hello Hono!'); - }); + app.route("/api/v1", appRouter); - app.route('api', appRouter); + app.onError((err: Error, c: Context) => { + return errorHandler(err, c); + }); }