Initial release
This commit is contained in:
commit
721217a8e3
130 files changed
+7099
No files matched your search
@@ -0,0 +1,11 @@
|
||||
.git
|
||||
.gitignore
|
||||
.devcontainer
|
||||
.vscode
|
||||
.dockerignore
|
||||
node_modules
|
||||
dist
|
||||
Dockerfile*
|
||||
compose*
|
||||
README.md
|
||||
*.http
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Development",
|
||||
"type": "dockerfile",
|
||||
"request": "launch",
|
||||
"dockerfile": "Dockerfile",
|
||||
"contextPath": "${workspaceFolder}",
|
||||
"args": [
|
||||
{
|
||||
"name": "NODE_ENV",
|
||||
"value": "development"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"compounds": []
|
||||
}
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports.biome": "explicit",
|
||||
"source.fixAll.biome": "explicit"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
// "editor.quickSuggestions": {
|
||||
// "other": false,
|
||||
// "comments": false,
|
||||
// "strings": false
|
||||
// },
|
||||
// "editor.hover.enabled": false,
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
FROM oven/bun:1 AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update -y && apt-get install -y openssl curl
|
||||
|
||||
FROM base AS deps-dev
|
||||
|
||||
RUN mkdir -p /tmp/dev
|
||||
|
||||
COPY package.json bun.lock /tmp/dev/
|
||||
|
||||
RUN cd /tmp/dev && bun install --frozen-lockfile
|
||||
|
||||
FROM base AS test
|
||||
|
||||
COPY --from=deps-dev /tmp/dev/node_modules /app/node_modules
|
||||
COPY --from=deps-dev /tmp/dev/package.json /app/package.json
|
||||
COPY --from=deps-dev /tmp/dev/bun.lock /app/bun.lock
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN bun test
|
||||
|
||||
FROM deps-dev AS deps-prod
|
||||
|
||||
RUN mkdir -p /tmp/pro
|
||||
|
||||
COPY package.json bun.lock /tmp/pro/
|
||||
|
||||
RUN cd /tmp/pro && bun install --production --frozen-lockfile
|
||||
|
||||
FROM base AS runtime
|
||||
|
||||
ARG PORT
|
||||
|
||||
ENV PORT=${PORT:-3000}
|
||||
|
||||
COPY --from=deps-prod /tmp/pro/node_modules /app/node_modules
|
||||
COPY --from=deps-prod /tmp/pro/package.json /app/package.json
|
||||
COPY --from=deps-prod /tmp/pro/bun.lock /app/bun.lock
|
||||
|
||||
COPY . /app/
|
||||
|
||||
RUN bun prisma generate
|
||||
|
||||
RUN chown -R bun:bun /app
|
||||
|
||||
USER bun
|
||||
|
||||
EXPOSE ${PORT}
|
||||
|
||||
ENTRYPOINT ["sh", "-c", "bun prisma migrate deploy && bun start"]
|
||||
@@ -0,0 +1,100 @@
|
||||
# URL Shortener Backend
|
||||
|
||||
The backend service for the URL Shortener application, built with [Bun](https://bun.sh), [Prisma](https://www.prisma.io/), and [SQLite](https://www.sqlite.org/).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: [Bun](https://bun.sh) - A fast all-in-one JavaScript runtime.
|
||||
- **Language**: TypeScript
|
||||
- **ORM**: [Prisma](https://www.prisma.io/) - Next-generation Node.js and TypeScript ORM.
|
||||
- **Database**: SQLite (via Prisma)
|
||||
- **Validation**: [Zod](https://zod.dev/) - TypeScript-first schema declaration and validation.
|
||||
- **Authentication**: [Jose](https://github.com/panva/jose) - JSON Web Almost Everything (JWT, JWS, JWE, JWK, JWA).
|
||||
- **ID Generation**: [Nanoid](https://github.com/ai/nanoid) - A tiny, secure, URL-friendly, unique string ID generator.
|
||||
- **Tooling**: [Biome](https://biomejs.dev/) - Fast formatter and linter.
|
||||
|
||||
## Architecture
|
||||
|
||||
The backend follows a layered architecture to separate concerns and ensure maintainability:
|
||||
|
||||
```
|
||||
src/
|
||||
├── config/ # Configuration files
|
||||
├── controllers/ # Request handlers (input validation, response formatting)
|
||||
├── db/ # Database connection initialization
|
||||
├── generated/ # Generated Prisma client
|
||||
├── lib/ # Shared utilities and helpers
|
||||
├── routes/ # API route definitions
|
||||
├── schemas/ # Zod schemas for validation
|
||||
├── services/ # Business logic
|
||||
└── tests/ # Test files
|
||||
```
|
||||
|
||||
### Request Flow
|
||||
|
||||
1. **Routes**: Define the API endpoints and map them to controllers.
|
||||
2. **Controllers**: Validate the request input using Zod schemas and call the appropriate service.
|
||||
3. **Services**: Execute the business logic (e.g., creating a short URL, hashing passwords) and interact with the database via Prisma.
|
||||
4. **Database**: SQLite stores the data.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh) (latest version)
|
||||
|
||||
### Installation
|
||||
|
||||
Install the dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
|
||||
Ensure you have a `.env` file in the project root (or `backend` root if running standalone) with the necessary variables (see root `README.md`).
|
||||
|
||||
### Database Setup
|
||||
|
||||
Initialize the database and generate the Prisma client:
|
||||
|
||||
```bash
|
||||
# Run migrations
|
||||
bun run db:migrate
|
||||
|
||||
# Generate Prisma client
|
||||
bun run db:generate
|
||||
```
|
||||
|
||||
### Running the Server
|
||||
|
||||
Start the development server with hot reloading:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
The server will start on `http://0.0.0.0:3000` (default).
|
||||
|
||||
To run in production mode:
|
||||
|
||||
```bash
|
||||
bun start
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
- `bun run dev`: Start development server.
|
||||
- `bun start`: Start production server.
|
||||
- `bun run format`: Format code using Biome.
|
||||
- `bun run lint`: Lint code using Biome.
|
||||
- `bun run check`: Check code for formatting and linting errors.
|
||||
- `bun run db:studio`: Open Prisma Studio to view/edit data.
|
||||
- `bun run db:generate`: Generate Prisma client.
|
||||
- `bun run db:migrate`: Run database migrations.
|
||||
- `bun run db:deploy`: Deploy migrations (for production).
|
||||
|
||||
## API Documentation
|
||||
|
||||
The API specification is available in `openapi.json`.
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"semicolons": "asNeeded"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "url-shortener",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-libsql": "^6.19.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"jose": "^6.1.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"zod": "^4.1.12",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.5",
|
||||
"@types/bun": "latest",
|
||||
"prisma": "^6.19.0",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@prisma/engines",
|
||||
"prisma",
|
||||
"@prisma/client",
|
||||
],
|
||||
"packages": {
|
||||
"@biomejs/biome": ["@biomejs/biome@2.3.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.5", "@biomejs/cli-darwin-x64": "2.3.5", "@biomejs/cli-linux-arm64": "2.3.5", "@biomejs/cli-linux-arm64-musl": "2.3.5", "@biomejs/cli-linux-x64": "2.3.5", "@biomejs/cli-linux-x64-musl": "2.3.5", "@biomejs/cli-win32-arm64": "2.3.5", "@biomejs/cli-win32-x64": "2.3.5" }, "bin": { "biome": "bin/biome" } }, "sha512-HvLhNlIlBIbAV77VysRIBEwp55oM/QAjQEin74QQX9Xb259/XP/D5AGGnZMOyF1el4zcvlNYYR3AyTMUV3ILhg=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fLdTur8cJU33HxHUUsii3GLx/TR0BsfQx8FkeqIiW33cGMtUD56fAtrh+2Fx1uhiCsVZlFh6iLKUU3pniZREQw=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-qpT8XDqeUlzrOW8zb4k3tjhT7rmvVRumhi2657I2aGcY4B+Ft5fNwDdZGACzn8zj7/K1fdWjgwYE3i2mSZ+vOA=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-u/pybjTBPGBHB66ku4pK1gj+Dxgx7/+Z0jAriZISPX1ocTO8aHh8x8e7Kb1rB4Ms0nA/SzjtNOVJ4exVavQBCw=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-eGUG7+hcLgGnMNl1KHVZUYxahYAhC462jF/wQolqu4qso2MSk32Q+QrpN7eN4jAHAg7FUMIo897muIhK4hXhqg=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-XrIVi9YAW6ye0CGQ+yax0gLfx+BFOtKaNX74n+xHWla6Cl6huUmcKNO7HPx7BiKnJUzrxXY1qYlm7xMvi08X4g=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-awVuycTPpVTH/+WDVnEEYSf6nbCBHf/4wB3lquwT7puhNg8R4XvonWNZzUsfHZrCkjkLhFH/vCZK5jHatD9FEg=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-DlBiMlBZZ9eIq4H7RimDSGsYcOtfOIfZOaI5CqsWiSlbTfqbPVfWtCf92wNzx8GNMbu1s7/g3ZZESr6+GwM/SA=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-nUmR8gb6yvrKhtRgzwo/gDimPwnO5a4sCydf8ZS2kHIJhEmSmk+STsusr1LHTuM//wXppBawvSQi2xFXJCdgKQ=="],
|
||||
|
||||
"@libsql/client": ["@libsql/client@0.8.1", "", { "dependencies": { "@libsql/core": "^0.8.1", "@libsql/hrana-client": "^0.6.2", "js-base64": "^3.7.5", "libsql": "^0.3.10", "promise-limit": "^2.7.0" } }, "sha512-xGg0F4iTDFpeBZ0r4pA6icGsYa5rG6RAG+i/iLDnpCAnSuTqEWMDdPlVseiq4Z/91lWI9jvvKKiKpovqJ1kZWA=="],
|
||||
|
||||
"@libsql/core": ["@libsql/core@0.8.1", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-u6nrj6HZMTPsgJ9EBhLzO2uhqhlHQJQmVHV+0yFLvfGf3oSP8w7TjZCNUgu1G8jHISx6KFi7bmcrdXW9lRt++A=="],
|
||||
|
||||
"@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.3.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rmOqsLcDI65zzxlUOoEiPJLhqmbFsZF6p4UJQ2kMqB+Kc0Rt5/A1OAdOZ/Wo8fQfJWjR1IbkbpEINFioyKf+nQ=="],
|
||||
|
||||
"@libsql/darwin-x64": ["@libsql/darwin-x64@0.3.19", "", { "os": "darwin", "cpu": "x64" }, "sha512-q9O55B646zU+644SMmOQL3FIfpmEvdWpRpzubwFc2trsa+zoBlSkHuzU9v/C+UNoPHQVRMP7KQctJ455I/h/xw=="],
|
||||
|
||||
"@libsql/hrana-client": ["@libsql/hrana-client@0.6.2", "", { "dependencies": { "@libsql/isomorphic-fetch": "^0.2.1", "@libsql/isomorphic-ws": "^0.1.5", "js-base64": "^3.7.5", "node-fetch": "^3.3.2" } }, "sha512-MWxgD7mXLNf9FXXiM0bc90wCjZSpErWKr5mGza7ERy2FJNNMXd7JIOv+DepBA1FQTIfI8TFO4/QDYgaQC0goNw=="],
|
||||
|
||||
"@libsql/isomorphic-fetch": ["@libsql/isomorphic-fetch@0.2.5", "", {}, "sha512-8s/B2TClEHms2yb+JGpsVRTPBfy1ih/Pq6h6gvyaNcYnMVJvgQRY7wAa8U2nD0dppbCuDU5evTNMEhrQ17ZKKg=="],
|
||||
|
||||
"@libsql/isomorphic-ws": ["@libsql/isomorphic-ws@0.1.5", "", { "dependencies": { "@types/ws": "^8.5.4", "ws": "^8.13.0" } }, "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg=="],
|
||||
|
||||
"@libsql/linux-arm64-gnu": ["@libsql/linux-arm64-gnu@0.3.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-mgeAUU1oqqh57k7I3cQyU6Trpdsdt607eFyEmH5QO7dv303ti+LjUvh1pp21QWV6WX7wZyjeJV1/VzEImB+jRg=="],
|
||||
|
||||
"@libsql/linux-arm64-musl": ["@libsql/linux-arm64-musl@0.3.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-VEZtxghyK6zwGzU9PHohvNxthruSxBEnRrX7BSL5jQ62tN4n2JNepJ6SdzXp70pdzTfwroOj/eMwiPt94gkVRg=="],
|
||||
|
||||
"@libsql/linux-x64-gnu": ["@libsql/linux-x64-gnu@0.3.19", "", { "os": "linux", "cpu": "x64" }, "sha512-2t/J7LD5w2f63wGihEO+0GxfTyYIyLGEvTFEsMO16XI5o7IS9vcSHrxsvAJs4w2Pf907uDjmc7fUfMg6L82BrQ=="],
|
||||
|
||||
"@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.3.19", "", { "os": "linux", "cpu": "x64" }, "sha512-BLsXyJaL8gZD8+3W2LU08lDEd9MIgGds0yPy5iNPp8tfhXx3pV/Fge2GErN0FC+nzt4DYQtjL+A9GUMglQefXQ=="],
|
||||
|
||||
"@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.3.19", "", { "os": "win32", "cpu": "x64" }, "sha512-ay1X9AobE4BpzG0XPw1gplyLZPGHIgJOovvW23gUrukRegiUP62uzhpRbKNogLlUOynyXeq//prHgPXiebUfWg=="],
|
||||
|
||||
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
|
||||
|
||||
"@prisma/adapter-libsql": ["@prisma/adapter-libsql@6.19.0", "", { "dependencies": { "@libsql/client": "^0.3.5 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0", "@prisma/driver-adapter-utils": "6.19.0", "async-mutex": "0.5.0" } }, "sha512-IjyPlslIZD3OtiMn59GkUaI/dY7bhkR8tzStLCFGdotk7TONMavlkRebNygBqnZxvVaECZmhyELEgAwlnZvBIw=="],
|
||||
|
||||
"@prisma/client": ["@prisma/client@6.19.0", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g=="],
|
||||
|
||||
"@prisma/config": ["@prisma/config@6.19.0", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.18.4", "empathic": "2.0.0" } }, "sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg=="],
|
||||
|
||||
"@prisma/debug": ["@prisma/debug@6.19.0", "", {}, "sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA=="],
|
||||
|
||||
"@prisma/driver-adapter-utils": ["@prisma/driver-adapter-utils@6.19.0", "", { "dependencies": { "@prisma/debug": "6.19.0" } }, "sha512-VAC/wFebV569Jk7iEqzLxekM2A5toKYAr6cPM2KWVHiRHgyjsh/IHf++Xo67q8uor/JxY8mwOuyQyuxkstSf5w=="],
|
||||
|
||||
"@prisma/engines": ["@prisma/engines@6.19.0", "", { "dependencies": { "@prisma/debug": "6.19.0", "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", "@prisma/fetch-engine": "6.19.0", "@prisma/get-platform": "6.19.0" } }, "sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw=="],
|
||||
|
||||
"@prisma/engines-version": ["@prisma/engines-version@6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", "", {}, "sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ=="],
|
||||
|
||||
"@prisma/fetch-engine": ["@prisma/fetch-engine@6.19.0", "", { "dependencies": { "@prisma/debug": "6.19.0", "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", "@prisma/get-platform": "6.19.0" } }, "sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ=="],
|
||||
|
||||
"@prisma/get-platform": ["@prisma/get-platform@6.19.0", "", { "dependencies": { "@prisma/debug": "6.19.0" } }, "sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.4", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-tBFxBp9Nfyy5rsmefN+WXc1JeW/j2BpBHFdLZbEVfs9wn3E3NRFxwV0pJg8M1qQAexFpvz73hJXFofV0ZAu92A=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
|
||||
|
||||
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
|
||||
|
||||
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
|
||||
|
||||
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.0", "", {}, "sha512-si++xzRAY9iPp60roQiFta7OFbhrgvcthrhlNAGeQptSY25uJjkfUV8OArC3KLocB8JT8ohz+qgxWCmz8RhjIg=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||
|
||||
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
|
||||
|
||||
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||
|
||||
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
|
||||
|
||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"effect": ["effect@3.18.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA=="],
|
||||
|
||||
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
|
||||
|
||||
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
|
||||
|
||||
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||
|
||||
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
||||
|
||||
"jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
|
||||
|
||||
"jose": ["jose@6.1.2", "", {}, "sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ=="],
|
||||
|
||||
"js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
|
||||
|
||||
"libsql": ["libsql@0.3.19", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2", "libsql": "^0.3.15" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.3.19", "@libsql/darwin-x64": "0.3.19", "@libsql/linux-arm64-gnu": "0.3.19", "@libsql/linux-arm64-musl": "0.3.19", "@libsql/linux-x64-gnu": "0.3.19", "@libsql/linux-x64-musl": "0.3.19", "@libsql/win32-x64-msvc": "0.3.19" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-Aj5cQ5uk/6fHdmeW0TiXK42FqUlwx7ytmMLPSaUQPin5HKKKuUPD62MAbN4OEweGBBI7q1BekoEN4gPUEL6MZA=="],
|
||||
|
||||
"nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
|
||||
|
||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
|
||||
|
||||
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
||||
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
|
||||
|
||||
"prisma": ["prisma@6.19.0", "", { "dependencies": { "@prisma/config": "6.19.0", "@prisma/engines": "6.19.0" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw=="],
|
||||
|
||||
"promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="],
|
||||
|
||||
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
|
||||
|
||||
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
@baseUrl = http://localhost:3000
|
||||
@accessToken = {{login.response.body.accessToken}}
|
||||
@shortId = {{shortenUrl.response.body.shortId}}
|
||||
@urlId = {{shortenUrl.response.body.id}}
|
||||
|
||||
### Health Check
|
||||
GET {{baseUrl}}/api/health HTTP/1.1
|
||||
###
|
||||
|
||||
### Register a new user
|
||||
POST {{baseUrl}}/api/auth/signup HTTP/1.1
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "newuser@example.com",
|
||||
"password": "securepassword",
|
||||
"name": "New User"
|
||||
}
|
||||
###
|
||||
|
||||
# @name login
|
||||
POST {{baseUrl}}/api/auth/login HTTP/1.1
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "newuser@example.com",
|
||||
"password": "securepassword"
|
||||
}
|
||||
###
|
||||
|
||||
### Get user information
|
||||
GET {{baseUrl}}/api/user HTTP/1.1
|
||||
Authorization: Bearer {{accessToken}}
|
||||
###
|
||||
|
||||
### Update user profile
|
||||
PATCH {{baseUrl}}/api/user HTTP/1.1
|
||||
Authorization: Bearer {{accessToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Updated User Name"
|
||||
}
|
||||
###
|
||||
|
||||
### Update user password
|
||||
PATCH {{baseUrl}}/api/user/password HTTP/1.1
|
||||
Authorization: Bearer {{accessToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"password": "newsecurepassword"
|
||||
}
|
||||
###
|
||||
|
||||
# @name shortenUrl
|
||||
POST {{baseUrl}}/api/url/shorten HTTP/1.1
|
||||
Authorization: Bearer {{accessToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"url": "http://example.com/some/long/path"
|
||||
}
|
||||
###
|
||||
|
||||
### Shorten URL with Alias
|
||||
POST {{baseUrl}}/api/url/shorten HTTP/1.1
|
||||
Authorization: Bearer {{accessToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"url": "http://example.com/another/path",
|
||||
"alias": "myalias"
|
||||
}
|
||||
###
|
||||
|
||||
### Get user urls
|
||||
GET {{baseUrl}}/api/url HTTP/1.1
|
||||
Authorization: Bearer {{accessToken}}
|
||||
###
|
||||
|
||||
### Update URL Alias
|
||||
PATCH {{baseUrl}}/api/url/{{urlId}} HTTP/1.1
|
||||
Authorization: Bearer {{accessToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"alias": "updatedalias"
|
||||
}
|
||||
###
|
||||
|
||||
### Delete a url
|
||||
DELETE {{baseUrl}}/api/url/{{urlId}} HTTP/1.1
|
||||
Authorization: Bearer {{accessToken}}
|
||||
###
|
||||
|
||||
### Get a url redirection
|
||||
GET {{baseUrl}}/{{shortId}} HTTP/1.1
|
||||
###
|
||||
@@ -0,0 +1,23 @@
|
||||
import { errorResponse } from "@/lib/utils"
|
||||
import { apiRoutes } from "@/routes"
|
||||
import { config } from "@/config"
|
||||
|
||||
export const serverConfig = {
|
||||
port: config.port,
|
||||
routes: {
|
||||
...apiRoutes,
|
||||
},
|
||||
fetch() {
|
||||
return errorResponse("Not Found", 404)
|
||||
},
|
||||
error(err: Error) {
|
||||
console.error("🔥 Critical Error:", err)
|
||||
return errorResponse("Internal Server Error", 500)
|
||||
},
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const server = Bun.serve(serverConfig)
|
||||
console.info(`📑 Swagger UI is available at ${server.port}/api/docs`)
|
||||
console.log(`🚀 Server running at ${server.url}`)
|
||||
}
|
||||
@@ -0,0 +1,896 @@
|
||||
{
|
||||
"openapi": "3.1.1",
|
||||
"info": {
|
||||
"title": "URL Shortener API",
|
||||
"version": "1.0.0",
|
||||
"description": "API for URL Shortener service"
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT"
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"User": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"createdAt"
|
||||
]
|
||||
},
|
||||
"Url": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"shortId": {
|
||||
"type": "string"
|
||||
},
|
||||
"alias": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"clicks": {
|
||||
"type": "integer"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"userId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"url",
|
||||
"shortId",
|
||||
"clicks",
|
||||
"createdAt",
|
||||
"userId"
|
||||
]
|
||||
},
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
]
|
||||
},
|
||||
"AuthResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accessToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"refreshToken": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accessToken",
|
||||
"refreshToken"
|
||||
]
|
||||
},
|
||||
"MessageResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/api/health": {
|
||||
"get": {
|
||||
"summary": "Health Check",
|
||||
"tags": [
|
||||
"Health"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Server status",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/signup": {
|
||||
"post": {
|
||||
"summary": "Sign up a new user",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"minLength": 8,
|
||||
"maxLength": 100
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 2,
|
||||
"maxLength": 100
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email",
|
||||
"password",
|
||||
"name"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "User registered successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/User"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input data",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Registration is disabled",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "User with this email already exists",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/login": {
|
||||
"post": {
|
||||
"summary": "Login user",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 100
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email",
|
||||
"password"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful login",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input data",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Invalid email or password",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/url": {
|
||||
"get": {
|
||||
"summary": "Get all urls for the authenticated user",
|
||||
"tags": [
|
||||
"Url"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of user urls",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Url"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No urls found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/url/shorten": {
|
||||
"post": {
|
||||
"summary": "Create a shortened URL",
|
||||
"tags": [
|
||||
"Url"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"alias": {
|
||||
"type": "string",
|
||||
"maxLength": 30,
|
||||
"pattern": "^[a-zA-Z0-9]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"url"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "URL shortened successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Url"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Alias already in use",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/url/{id}": {
|
||||
"patch": {
|
||||
"summary": "Update a url alias",
|
||||
"tags": [
|
||||
"Url"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"alias": {
|
||||
"type": "string",
|
||||
"maxLength": 30,
|
||||
"pattern": "^[a-zA-Z0-9]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"alias"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Url Updated Successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MessageResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User or Url Not Found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Alias already in use",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"summary": "Delete a url",
|
||||
"tags": [
|
||||
"Url"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Url Deleted Successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MessageResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User or Url Not Found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/user": {
|
||||
"get": {
|
||||
"summary": "Get user details",
|
||||
"tags": [
|
||||
"User"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User details",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/User"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Failed to retrieve user",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"summary": "Update user details",
|
||||
"tags": [
|
||||
"User"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 2,
|
||||
"maxLength": 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User updated successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MessageResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input data",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Failed to update user",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/user/password": {
|
||||
"patch": {
|
||||
"summary": "Update user password",
|
||||
"tags": [
|
||||
"User"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string",
|
||||
"minLength": 8,
|
||||
"maxLength": 100
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"password"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Password updated successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MessageResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input data",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Failed to update password",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/{shortId}": {
|
||||
"get": {
|
||||
"summary": "Redirect to original URL",
|
||||
"tags": [
|
||||
"Url"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "shortId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"301": {
|
||||
"description": "Redirect to original URL"
|
||||
},
|
||||
"404": {
|
||||
"description": "Url not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/openapi.json": {
|
||||
"get": {
|
||||
"summary": "Get OpenAPI Specification",
|
||||
"tags": [
|
||||
"Documentation"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OpenAPI JSON",
|
||||
"content": {
|
||||
"application/json": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/docs": {
|
||||
"get": {
|
||||
"summary": "Get Swagger UI",
|
||||
"tags": [
|
||||
"Documentation"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Swagger UI HTML",
|
||||
"content": {
|
||||
"text/html": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "url-shortener",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun run --watch index.ts --host 0.0.0.0",
|
||||
"start": "bun run index.ts",
|
||||
"format": "bunx biome format --write",
|
||||
"lint": "bunx biome lint --write",
|
||||
"check": "bunx biome check --write",
|
||||
"db:studio": "prisma studio",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:deploy": "prisma migrate deploy"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.5",
|
||||
"@types/bun": "latest",
|
||||
"prisma": "^6.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-libsql": "^6.19.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"jose": "^6.1.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@prisma/client",
|
||||
"@prisma/engines",
|
||||
"prisma"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig, env } from "prisma/config"
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
// seed: "prisma/seed.ts",
|
||||
},
|
||||
engine: "classic",
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Url" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"url" TEXT NOT NULL,
|
||||
"shortId" TEXT NOT NULL,
|
||||
"alias" TEXT,
|
||||
"clicks" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"userId" TEXT NOT NULL,
|
||||
CONSTRAINT "Url_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Url_shortId_key" ON "Url"("shortId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Url_alias_key" ON "Url"("alias");
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
@@ -0,0 +1,38 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
engineType = "client"
|
||||
runtime = "bun"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
email String @unique
|
||||
password String
|
||||
urls Url[] @relation("UserUrls")
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Url {
|
||||
id String @id @default(cuid())
|
||||
url String
|
||||
shortId String @unique
|
||||
alias String? @unique
|
||||
clicks Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation("UserUrls", fields: [userId], references: [id])
|
||||
userId String
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const config = {
|
||||
port: Bun.env.PORT ?? 3000,
|
||||
signupEnabled: Bun.env.SIGNUP_ENABLED === "true",
|
||||
jwtSecret: Bun.env.JWT_SECRET ?? "your_jwt_secret",
|
||||
corsOrigin: Bun.env.CORS_ORIGIN ?? "*",
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { errorResponse, jsonResponse, parseBody } from "@/lib/utils"
|
||||
import { loginSchema, signupSchema } from "@/schemas"
|
||||
import {
|
||||
findUserByEmailService,
|
||||
loginUserService,
|
||||
signupUserService,
|
||||
} from "@/services"
|
||||
import { config } from "@/config"
|
||||
|
||||
/**
|
||||
* Signup a new user.
|
||||
*
|
||||
* @param {Request} req - The request object
|
||||
* @returns {Promise<Response>} - The response object
|
||||
*/
|
||||
export const handleSignup = async (req: Request): Promise<Response> => {
|
||||
const signupEnabled = config.signupEnabled
|
||||
|
||||
if (!signupEnabled) {
|
||||
return errorResponse("Signup is disabled", 403)
|
||||
}
|
||||
|
||||
const body = await parseBody(req)
|
||||
|
||||
const result = signupSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return errorResponse("Bad Request", 400)
|
||||
}
|
||||
|
||||
const { email, password, name } = result.data
|
||||
|
||||
const existingUser = await findUserByEmailService(email)
|
||||
|
||||
if (existingUser) {
|
||||
return errorResponse("User with this email already exists", 409)
|
||||
}
|
||||
|
||||
const user = await signupUserService({ email, password, name })
|
||||
|
||||
return jsonResponse(user, 201)
|
||||
}
|
||||
|
||||
/**
|
||||
* Login a user.
|
||||
*
|
||||
* @param {Request} req
|
||||
* @returns
|
||||
*/
|
||||
export const handleLogin = async (req: Request) => {
|
||||
const body = await parseBody(req)
|
||||
|
||||
const result = loginSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return errorResponse("Bad Request", 400)
|
||||
}
|
||||
|
||||
const { email, password } = result.data
|
||||
|
||||
try {
|
||||
const user = await loginUserService({ email, password })
|
||||
|
||||
return jsonResponse(user)
|
||||
} catch {
|
||||
return errorResponse("Invalid email or password", 401)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./auth.controller"
|
||||
export * from "./url.controller"
|
||||
export * from "./user.controller"
|
||||
@@ -0,0 +1,203 @@
|
||||
import { authenticateToken } from "@/lib/middleware"
|
||||
import {
|
||||
errorResponse,
|
||||
generateShortId,
|
||||
jsonResponse,
|
||||
parseBody,
|
||||
} from "@/lib/utils"
|
||||
import { urlShortenerSchema } from "@/schemas"
|
||||
import {
|
||||
createUrlService,
|
||||
deleteUrlService,
|
||||
findUrlByAliasService,
|
||||
findUrlService,
|
||||
getUrlsByUserService,
|
||||
incrementUrlClicksService,
|
||||
updateUrlService,
|
||||
} from "@/services"
|
||||
|
||||
/**
|
||||
* Shortens a given URL.
|
||||
*
|
||||
* @param {Request} req - The request object containing the URL to shorten.
|
||||
* @returns {Promise<Response>} The shortened URL.
|
||||
*/
|
||||
export const handleUrlShortening = async (req: Request): Promise<Response> => {
|
||||
const userId = await authenticateToken(req)
|
||||
|
||||
if (userId instanceof Response) {
|
||||
return userId
|
||||
}
|
||||
|
||||
const shortId = generateShortId()
|
||||
|
||||
const body = await parseBody(req)
|
||||
|
||||
const result = urlShortenerSchema.safeParse({ ...body, shortId })
|
||||
|
||||
if (!result.success) {
|
||||
return errorResponse("Bad Request", 400)
|
||||
}
|
||||
|
||||
const { url, alias } = result.data
|
||||
|
||||
const existingAlias = await findUrlByAliasService(alias || "")
|
||||
|
||||
if (existingAlias) {
|
||||
return errorResponse("Alias already in use", 409)
|
||||
}
|
||||
|
||||
const newUrl = await createUrlService(url, shortId, userId, alias)
|
||||
|
||||
return jsonResponse(newUrl, 201)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all urls associated with the authenticated user.
|
||||
*
|
||||
* @param {Request} req - The request object.
|
||||
* @returns {Promise<Response>} The user's urls.
|
||||
*/
|
||||
export const handleGetUrlsByUser = async (req: Request): Promise<Response> => {
|
||||
const userId = await authenticateToken(req)
|
||||
|
||||
if (userId instanceof Response) {
|
||||
return userId
|
||||
}
|
||||
|
||||
try {
|
||||
const userUrls = await getUrlsByUserService(userId)
|
||||
|
||||
if (!userUrls) {
|
||||
return errorResponse("No urls found", 404)
|
||||
}
|
||||
|
||||
return jsonResponse(userUrls, 200)
|
||||
} catch (_error) {
|
||||
return errorResponse("Internal Server Error", 500)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles redirection for a given short ID.
|
||||
*
|
||||
* @param {Object} param - The parameters object.
|
||||
* @param {string} param.shortId - The short ID of the url.
|
||||
* @returns {Promise<Response>} The redirection response.
|
||||
*/
|
||||
export const handleUrlRedirection = async (param: {
|
||||
shortId?: string
|
||||
}): Promise<Response> => {
|
||||
const { shortId } = param
|
||||
|
||||
if (!shortId) {
|
||||
return errorResponse("Bad Request", 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await findUrlService(shortId)
|
||||
|
||||
if (!url) {
|
||||
return errorResponse("Url Not Found", 404)
|
||||
}
|
||||
|
||||
await incrementUrlClicksService(url.id)
|
||||
|
||||
return Response.redirect(url.url, 301)
|
||||
} catch (_error) {
|
||||
return errorResponse("Internal Server Error", 500)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a url alias by its ID.
|
||||
*
|
||||
* @param {string} id - The ID of the url to update.
|
||||
* @returns {Promise<Response>} - A response indicating the result of the update.
|
||||
*/
|
||||
export const handleUrlUpdate = async (
|
||||
req: Request,
|
||||
id: string,
|
||||
): Promise<Response> => {
|
||||
const userId = await authenticateToken(req)
|
||||
|
||||
if (userId instanceof Response) {
|
||||
return userId
|
||||
}
|
||||
|
||||
try {
|
||||
const userUrls = await getUrlsByUserService(userId)
|
||||
|
||||
if (!userUrls) {
|
||||
return errorResponse("User Not Found", 404)
|
||||
}
|
||||
|
||||
const userOwnsUrl = userUrls.find((url) => url.id === id)
|
||||
|
||||
if (!userOwnsUrl) {
|
||||
return errorResponse("Url Not Found", 404)
|
||||
}
|
||||
|
||||
const body = await parseBody(req)
|
||||
|
||||
const result = urlShortenerSchema.partial().safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return errorResponse("Bad Request", 400)
|
||||
}
|
||||
|
||||
const { alias } = result.data
|
||||
|
||||
if (alias) {
|
||||
const existingAlias = await findUrlByAliasService(alias)
|
||||
|
||||
if (existingAlias && existingAlias.id !== id) {
|
||||
return errorResponse("Alias already in use", 409)
|
||||
}
|
||||
|
||||
await updateUrlService(userOwnsUrl.id, { alias })
|
||||
}
|
||||
|
||||
return jsonResponse({ message: "Url updated successfully" }, 200)
|
||||
} catch (_error) {
|
||||
return errorResponse("Internal Server Error", 500)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a url by its ID.
|
||||
*
|
||||
* @param {string} id - The ID of the url to delete.
|
||||
* @returns {Promise<Response>} - A response indicating the result of the deletion.
|
||||
*/
|
||||
export const handleUrlDeletion = async (
|
||||
req: Request,
|
||||
id: string,
|
||||
): Promise<Response> => {
|
||||
console.log("Deleting url with ID:", id)
|
||||
const userId = await authenticateToken(req)
|
||||
|
||||
if (userId instanceof Response) {
|
||||
return userId
|
||||
}
|
||||
|
||||
try {
|
||||
const userUrls = await getUrlsByUserService(userId)
|
||||
|
||||
if (!userUrls) {
|
||||
return errorResponse("Not Found", 404)
|
||||
}
|
||||
|
||||
const userOwnsUrl = userUrls.find((url) => url.id === id)
|
||||
|
||||
if (!userOwnsUrl) {
|
||||
return errorResponse("Not Found", 404)
|
||||
}
|
||||
|
||||
await deleteUrlService(userOwnsUrl.id)
|
||||
|
||||
return jsonResponse({ message: "Url deleted successfully" }, 200)
|
||||
} catch (_error) {
|
||||
return errorResponse("Internal Server Error", 500)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { authenticateToken } from "@/lib/middleware"
|
||||
import { errorResponse, hashPassword, jsonResponse } from "@/lib/utils"
|
||||
import { updateUserPasswordSchema, updateUserSchema } from "@/schemas"
|
||||
import {
|
||||
findUserByEmailService,
|
||||
findUserByIdService,
|
||||
updateUserService,
|
||||
} from "@/services"
|
||||
|
||||
export const handleGetUser = async (req: Request): Promise<Response> => {
|
||||
const userId = await authenticateToken(req)
|
||||
|
||||
if (userId instanceof Response) {
|
||||
return userId
|
||||
}
|
||||
try {
|
||||
const user = await findUserByIdService(userId)
|
||||
|
||||
if (!user) {
|
||||
return errorResponse("User not found", 404)
|
||||
}
|
||||
|
||||
const { id, name, email, createdAt } = user
|
||||
|
||||
return jsonResponse({ id, name, email, createdAt }, 200)
|
||||
} catch (_serror) {
|
||||
return errorResponse("Failed to retrieve user", 500)
|
||||
}
|
||||
}
|
||||
|
||||
export const handleUpdateUser = async (req: Request): Promise<Response> => {
|
||||
const userId = await authenticateToken(req)
|
||||
|
||||
if (userId instanceof Response) {
|
||||
return userId
|
||||
}
|
||||
|
||||
try {
|
||||
const user = findUserByIdService(userId)
|
||||
|
||||
if (!user) {
|
||||
return errorResponse("User not found", 404)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
|
||||
const response = updateUserSchema.safeParse(body)
|
||||
|
||||
if (!response.success) {
|
||||
return errorResponse("Invalid input data", 400)
|
||||
}
|
||||
|
||||
const { name, email } = response.data
|
||||
|
||||
if (email) {
|
||||
const existingUser = await findUserByEmailService(email)
|
||||
|
||||
if (existingUser && existingUser.id !== userId) {
|
||||
return errorResponse("Email already in use", 409)
|
||||
}
|
||||
}
|
||||
|
||||
await updateUserService(userId, { name, email })
|
||||
|
||||
return jsonResponse({ message: "User updated successfully" }, 200)
|
||||
} catch (_serror) {
|
||||
return errorResponse("Failed to update user", 500)
|
||||
}
|
||||
}
|
||||
|
||||
export const handleUpdateUserPassword = async (
|
||||
req: Request,
|
||||
): Promise<Response> => {
|
||||
const userId = await authenticateToken(req)
|
||||
|
||||
if (userId instanceof Response) {
|
||||
return userId
|
||||
}
|
||||
|
||||
try {
|
||||
const user = findUserByIdService(userId)
|
||||
|
||||
if (!user) {
|
||||
return errorResponse("User not found", 404)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
|
||||
const response = updateUserPasswordSchema.safeParse(body)
|
||||
|
||||
if (!response.success) {
|
||||
return errorResponse("Invalid input data", 400)
|
||||
}
|
||||
|
||||
const { password } = response.data
|
||||
|
||||
const hashedPassword = await hashPassword(password)
|
||||
|
||||
await updateUserService(userId, { password: hashedPassword })
|
||||
|
||||
return jsonResponse({ message: "Password updated successfully" }, 200)
|
||||
} catch (_serror) {
|
||||
return errorResponse("Failed to update password", 500)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PrismaLibSQL } from "@prisma/adapter-libsql"
|
||||
import { PrismaClient } from "@/generated/prisma/client"
|
||||
|
||||
const adapter = new PrismaLibSQL({ url: Bun.env.DATABASE_URL || "" })
|
||||
export const prisma = new PrismaClient({ adapter })
|
||||
@@ -0,0 +1,9 @@
|
||||
export const logger = (req: Request, status: number, duration: number) => {
|
||||
const color = status >= 400 ? "\x1b[31m" : "\x1b[32m" // Rojo para errores, Verde para OK
|
||||
const reset = "\x1b[0m"
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
console.log(
|
||||
`[${timestamp}] ${req.method} ${req.url} ${color}${status}${reset} - ${duration.toFixed(2)}ms`,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Handler } from "@/lib/types"
|
||||
import { errorResponse, extractUserIdFromToken } from "@/lib/utils"
|
||||
|
||||
/** * Middleware to authenticate JWT token from Authorization header
|
||||
* @param req
|
||||
* @returns userId or Response with error
|
||||
*/
|
||||
export const authenticateToken = async (
|
||||
req: Request,
|
||||
): Promise<string | Response> => {
|
||||
const authHeader = req.headers.get("Authorization")
|
||||
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
return errorResponse("Unauthorized", 401)
|
||||
}
|
||||
|
||||
const token = authHeader.split(" ")[1]
|
||||
|
||||
if (!token) {
|
||||
return errorResponse("Unauthorized", 401)
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = await extractUserIdFromToken(token)
|
||||
|
||||
if (!decoded) {
|
||||
return errorResponse("Unauthorized", 401)
|
||||
}
|
||||
|
||||
return decoded
|
||||
} catch (_error) {
|
||||
return errorResponse("Unauthorized", 401)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to log requests and responses
|
||||
* @param handler
|
||||
* @returns
|
||||
*/
|
||||
export const withLogger = (handler: Handler): Handler => {
|
||||
return async (req: Request) => {
|
||||
const start = performance.now()
|
||||
const url = new URL(req.url)
|
||||
|
||||
try {
|
||||
const response = await handler(req)
|
||||
|
||||
const duration = (performance.now() - start).toFixed(2)
|
||||
const color = response.status >= 400 ? "\x1b[31m" : "\x1b[32m"
|
||||
console.log(
|
||||
`[${req.method}] ${url.pathname} -> ${color}${response.status}\x1b[0m (${duration}ms)`,
|
||||
)
|
||||
|
||||
return response
|
||||
} catch (err) {
|
||||
console.error(`[ERROR] ${req.method} ${url.pathname}:`, err)
|
||||
return new Response("Internal Error", { status: 500 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Url, User } from "@/generated/prisma/client"
|
||||
|
||||
/**Enum for HTTP Methods
|
||||
*/
|
||||
export enum HttpMethod {
|
||||
GET = "GET",
|
||||
POST = "POST",
|
||||
PUT = "PUT",
|
||||
DELETE = "DELETE",
|
||||
PATCH = "PATCH",
|
||||
OPTIONS = "OPTIONS",
|
||||
}
|
||||
|
||||
/**Handler type for request handlers
|
||||
*/
|
||||
export type Handler = (req: Request) => Response | Promise<Response>
|
||||
/**Params interface to type route parameters
|
||||
*/
|
||||
export interface Params {
|
||||
[id: string]: string
|
||||
}
|
||||
/** RequestParams interface extending the global Request to include route parameters
|
||||
*/
|
||||
export interface RequestParams extends globalThis.Request {
|
||||
params: Params
|
||||
}
|
||||
/** Login Response Interface
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
}
|
||||
/** User Response Interface without password field
|
||||
*/
|
||||
export interface UserResponse extends Omit<User, "password"> {}
|
||||
/** Url Response Interface without userId and clicks fields
|
||||
*/
|
||||
export interface UrlResponse extends Omit<Url, "userId" | "clicks"> {}
|
||||
/** Url Response Interface without userId field
|
||||
*/
|
||||
export interface UrlResponseWithClicks extends Omit<Url, "userId"> {}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { BodyInit, ResponseInit } from "undici-types"
|
||||
import { HttpMethod } from "../types"
|
||||
import { config } from "@/config"
|
||||
|
||||
/**
|
||||
* * Custom Response class to include CORS headers
|
||||
*/
|
||||
export class ClientResponse extends Response {
|
||||
constructor(body?: BodyInit, init?: ResponseInit) {
|
||||
// @ts-expect-error Bun Response typing issue
|
||||
super(body, init)
|
||||
|
||||
this.headers.set(
|
||||
"Access-Control-Allow-Origin",
|
||||
config.corsOrigin || "null",
|
||||
)
|
||||
this.headers.set(
|
||||
"Access-Control-Allow-Methods",
|
||||
`${HttpMethod.GET}, ${HttpMethod.POST}, ${HttpMethod.PUT}, ${HttpMethod.DELETE}, ${HttpMethod.PATCH}, ${HttpMethod.OPTIONS}`,
|
||||
)
|
||||
this.headers.set(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type, Authorization, X-Requested-With",
|
||||
)
|
||||
this.headers.set("Access-Control-Allow-Credentials", "true")
|
||||
this.headers.set("Access-Control-Max-Age", "86400")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON response helper
|
||||
*
|
||||
* @param {any} data - Data to be sent in the response
|
||||
* @param {number} status - HTTP status code (default is 200)
|
||||
* @returns
|
||||
*/
|
||||
// biome-ignore lint: this is a simple utility function
|
||||
export const jsonResponse = (data: any, status: number = 200) => {
|
||||
return new ClientResponse(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Error response helper
|
||||
*
|
||||
* @param {string} message - Error message
|
||||
* @param {number} status - HTTP status code (default is 400)
|
||||
* @returns
|
||||
*/
|
||||
export const errorResponse = (
|
||||
message: string = "Bad Request",
|
||||
status: number = 400,
|
||||
) => {
|
||||
return new ClientResponse(JSON.stringify({ message }), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON body from Request
|
||||
*
|
||||
* @param {Request} req - Incoming request
|
||||
* @returns {Promise<object>} - Parsed JSON body
|
||||
*/
|
||||
export const parseBody = (req: Request): Promise<object> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const body = req.json()
|
||||
resolve(body)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { jwtVerify, SignJWT } from "jose"
|
||||
import { config } from "@/config"
|
||||
|
||||
const secretKey = new TextEncoder().encode(config.jwtSecret)
|
||||
|
||||
export const generateAccessToken = async (
|
||||
userId: string,
|
||||
email: string,
|
||||
): Promise<string> => {
|
||||
const token = await new SignJWT({ userId, email })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("2h")
|
||||
.sign(secretKey)
|
||||
return token
|
||||
}
|
||||
|
||||
export const generateRefreshToken = async (userId: string): Promise<string> => {
|
||||
const token = await new SignJWT({ userId })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("7d")
|
||||
.sign(secretKey)
|
||||
return token
|
||||
}
|
||||
|
||||
export const verifyToken = async (token: string) => {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, secretKey)
|
||||
return payload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const extractUserIdFromToken = async (
|
||||
token: string,
|
||||
): Promise<string | null> => {
|
||||
const payload = await verifyToken(token)
|
||||
if (payload && typeof payload.userId === "string") {
|
||||
return payload.userId
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { customAlphabet } from "nanoid"
|
||||
|
||||
export const generateShortId = (): string => {
|
||||
const alphabet =
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
const customNanoid = customAlphabet(alphabet, 6)
|
||||
return customNanoid()
|
||||
}
|
||||
|
||||
export * from "./api"
|
||||
export * from "./auth"
|
||||
export * from "./security"
|
||||
@@ -0,0 +1,15 @@
|
||||
export const hashPassword = async (password: string): Promise<string> => {
|
||||
const hashedPassword = await Bun.password.hash(password, {
|
||||
algorithm: "bcrypt",
|
||||
cost: 10,
|
||||
})
|
||||
return hashedPassword
|
||||
}
|
||||
|
||||
export const verifyPassword = async (
|
||||
password: string,
|
||||
hashedPassword: string,
|
||||
): Promise<boolean> => {
|
||||
const isValid = await Bun.password.verify(password, hashedPassword)
|
||||
return isValid
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { handleLogin, handleSignup } from "@/controllers/auth.controller"
|
||||
import { HttpMethod } from "@/lib/types"
|
||||
|
||||
export const authRoutes = {
|
||||
"/api/auth/signup": {
|
||||
[HttpMethod.POST]: handleSignup,
|
||||
},
|
||||
"/api/auth/login": {
|
||||
[HttpMethod.POST]: handleLogin,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { HttpMethod } from "@/lib/types"
|
||||
|
||||
const swaggerHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>URL Shortener API</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
window.onload = () => {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: '/api/openapi.json', // Apunta a tu ruta JSON anterior
|
||||
dom_id: '#swagger-ui',
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
export const docRoutes = {
|
||||
"/api/openapi.json": {
|
||||
[HttpMethod.GET]: () => new Response(Bun.file("./openapi.json")),
|
||||
},
|
||||
"/api/docs": {
|
||||
[HttpMethod.GET]: () =>
|
||||
new Response(swaggerHtml, { headers: { "Content-Type": "text/html" } }),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { handleUrlRedirection } from "@/controllers/url.controller"
|
||||
import { withLogger } from "@/lib/middleware"
|
||||
import type { RequestParams } from "@/lib/types"
|
||||
import { HttpMethod } from "@/lib/types"
|
||||
import { jsonResponse } from "@/lib/utils/api"
|
||||
import { authRoutes } from "./auth.routes"
|
||||
import { docRoutes } from "./doc.routes"
|
||||
import { urlRoutes } from "./url.routes"
|
||||
import { userRoutes } from "./user.routes"
|
||||
|
||||
const rawRoutes = {
|
||||
...authRoutes,
|
||||
...urlRoutes,
|
||||
...userRoutes,
|
||||
...docRoutes,
|
||||
// Link Redirection Handler
|
||||
"/:shortId": {
|
||||
[HttpMethod.GET]: async (req: RequestParams) => {
|
||||
const { shortId } = req.params
|
||||
return handleUrlRedirection({ shortId })
|
||||
},
|
||||
},
|
||||
// Root redirection to /docs
|
||||
"/api": {
|
||||
[HttpMethod.GET]: () => Response.redirect("/api/docs", 302),
|
||||
},
|
||||
// CORS Preflight Handler
|
||||
"/api/*": {
|
||||
OPTIONS: () => jsonResponse({}, 200),
|
||||
},
|
||||
// Health Check Endpoint
|
||||
"/api/health": {
|
||||
[HttpMethod.GET]: () => Response.json({ status: "ok" }),
|
||||
},
|
||||
}
|
||||
|
||||
const applyGlobalMiddleware = (routes: any) => {
|
||||
const processedRoutes: any = {}
|
||||
|
||||
for (const [path, methods] of Object.entries(routes)) {
|
||||
processedRoutes[path] = {}
|
||||
|
||||
for (const [method, handler] of Object.entries(methods as HttpMethod)) {
|
||||
// @ts-expect-error - TypeScript issue
|
||||
processedRoutes[path][method] = withLogger(handler)
|
||||
}
|
||||
}
|
||||
return processedRoutes
|
||||
}
|
||||
|
||||
export const apiRoutes = applyGlobalMiddleware(rawRoutes)
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
handleGetUrlsByUser,
|
||||
handleUrlDeletion,
|
||||
handleUrlShortening,
|
||||
handleUrlUpdate,
|
||||
} from "@/controllers"
|
||||
import { HttpMethod, type RequestParams } from "@/lib/types"
|
||||
|
||||
export const urlRoutes = {
|
||||
"/api/url": {
|
||||
[HttpMethod.GET]: async (req: Request) => {
|
||||
return handleGetUrlsByUser(req)
|
||||
},
|
||||
},
|
||||
"/api/url/:id": {
|
||||
[HttpMethod.PATCH]: async (req: RequestParams) => {
|
||||
const { id } = req.params
|
||||
if (!id) return
|
||||
return handleUrlUpdate(req, id)
|
||||
},
|
||||
[HttpMethod.DELETE]: async (req: RequestParams) => {
|
||||
const { id } = req.params
|
||||
if (!id) return
|
||||
return handleUrlDeletion(req, id)
|
||||
},
|
||||
},
|
||||
"/api/url/shorten": {
|
||||
[HttpMethod.POST]: async (req: Request) => {
|
||||
return handleUrlShortening(req)
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
handleGetUser,
|
||||
handleUpdateUser,
|
||||
handleUpdateUserPassword,
|
||||
} from "@/controllers/user.controller"
|
||||
import { HttpMethod } from "@/lib/types"
|
||||
|
||||
export const userRoutes = {
|
||||
"/api/user": {
|
||||
[HttpMethod.GET]: async (req: Request) => {
|
||||
return handleGetUser(req)
|
||||
},
|
||||
[HttpMethod.PATCH]: async (req: Request) => {
|
||||
return handleUpdateUser(req)
|
||||
},
|
||||
},
|
||||
"/api/user/password": {
|
||||
[HttpMethod.PATCH]: async (req: Request) => {
|
||||
return handleUpdateUserPassword(req)
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as z from "zod"
|
||||
|
||||
export const emailSchema = z.email()
|
||||
export const passwordSchema = z.string().min(8).max(100)
|
||||
|
||||
export const signupSchema = z.object({
|
||||
email: emailSchema,
|
||||
password: passwordSchema,
|
||||
name: z.string().min(2).max(100),
|
||||
})
|
||||
|
||||
export type RegisterInput = z.infer<typeof signupSchema>
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: emailSchema,
|
||||
password: z.string().min(1).max(100),
|
||||
})
|
||||
|
||||
export type LoginInput = z.infer<typeof loginSchema>
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./auth.schema"
|
||||
export * from "./url.schema"
|
||||
export * from "./user.schema"
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as z from "zod"
|
||||
|
||||
const urlSchema = z.url({
|
||||
protocol: /^https?$/,
|
||||
hostname: z.regexes.domain,
|
||||
})
|
||||
export const shortIdSchema = z.string().length(6)
|
||||
const aliasSchema = z
|
||||
.string()
|
||||
.max(30)
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine((val) => {
|
||||
if (val === null || val === undefined) return true
|
||||
return /^[a-zA-Z0-9]+$/.test(val)
|
||||
}, "Alias can only contain alphanumeric characters")
|
||||
|
||||
export const urlShortenerSchema = z.object({
|
||||
url: urlSchema,
|
||||
shortId: shortIdSchema,
|
||||
alias: aliasSchema,
|
||||
})
|
||||
|
||||
export type UrlShortenerInput = z.infer<typeof urlShortenerSchema>
|
||||
@@ -0,0 +1,12 @@
|
||||
import * as z from "zod"
|
||||
import { emailSchema, passwordSchema } from "./auth.schema"
|
||||
|
||||
export const updateUserSchema = z.object({
|
||||
email: emailSchema.optional(),
|
||||
name: z.string().min(2).max(100).optional(),
|
||||
password: passwordSchema.optional(),
|
||||
})
|
||||
|
||||
export const updateUserPasswordSchema = z.object({
|
||||
password: passwordSchema,
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { LoginResponse, UserResponse } from "@/lib/types"
|
||||
import {
|
||||
generateAccessToken,
|
||||
generateRefreshToken,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
} from "@/lib/utils"
|
||||
import { loginSchema, signupSchema } from "@/schemas"
|
||||
import { createUserService, findUserByEmailService } from "@/services"
|
||||
|
||||
/**
|
||||
* Signup a new user.
|
||||
*
|
||||
* @param {Object} data - Signup data
|
||||
* @param {string} data.email - User's email
|
||||
* @param {string} data.password - User's password
|
||||
* @param {string} data.name - User's name
|
||||
* @returns {Promise<UserResponse>} - The signed-up user without password
|
||||
*/
|
||||
export const signupUserService = async (data: {
|
||||
email: string
|
||||
password: string
|
||||
name: string
|
||||
}): Promise<UserResponse> => {
|
||||
const { email, password, name } = signupSchema.parse(data)
|
||||
|
||||
const existingUser = await findUserByEmailService(email)
|
||||
|
||||
if (existingUser) {
|
||||
throw new Error("User with this email already exists")
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password)
|
||||
|
||||
const user = await createUserService(email, hashedPassword, name)
|
||||
|
||||
const { password: _, ...userWithoutPassword } = user
|
||||
|
||||
return userWithoutPassword
|
||||
}
|
||||
|
||||
/**
|
||||
* Login a user.
|
||||
*
|
||||
* @param {Object} data - Login data
|
||||
* @param {string} data.email - User's email
|
||||
* @param {string} data.password - User's password
|
||||
* @returns {Promise<UserLoginResponses>} - The logged-in user without password
|
||||
*/
|
||||
export const loginUserService = async (data: {
|
||||
email: string
|
||||
password: string
|
||||
}): Promise<LoginResponse> => {
|
||||
const { email, password } = loginSchema.parse(data)
|
||||
|
||||
const user = await findUserByEmailService(email)
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Invalid email or password")
|
||||
}
|
||||
|
||||
const isValidPassword = await verifyPassword(password, user.password)
|
||||
|
||||
if (!isValidPassword) {
|
||||
throw new Error("Invalid email or password")
|
||||
}
|
||||
|
||||
const accessToken = await generateAccessToken(user.id, user.email)
|
||||
|
||||
const refreshToken = await generateRefreshToken(user.id)
|
||||
|
||||
return { accessToken, refreshToken }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./auth.service"
|
||||
export * from "./url.service"
|
||||
export * from "./user.service"
|
||||
@@ -0,0 +1,154 @@
|
||||
import { prisma as db } from "@/db"
|
||||
import type { UrlResponse, UrlResponseWithClicks } from "@/lib/types"
|
||||
|
||||
/**
|
||||
* Create a new url.
|
||||
*
|
||||
* @param {string} url - The original URL
|
||||
* @param {string} shortId - The short ID for the URL
|
||||
* @param {string} userId - The ID of the user creating the url
|
||||
* @param {string} alias - The alias for the URL
|
||||
* @returns {Promise<UrlResponse>} - The created url object
|
||||
*/
|
||||
export const createUrlService = async (
|
||||
url: string,
|
||||
shortId: string,
|
||||
userId: string,
|
||||
alias?: string | null,
|
||||
): Promise<UrlResponse> => {
|
||||
return await db.url.create({
|
||||
data: {
|
||||
url,
|
||||
shortId,
|
||||
alias: alias || null,
|
||||
user: { connect: { id: userId } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
shortId: true,
|
||||
alias: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a url by its short ID.
|
||||
*
|
||||
* @param {string} shortId - The short ID of the url
|
||||
* @returns {Promise<Url | null>} - The found url object or null if not found
|
||||
*/
|
||||
export const findUrlService = async (
|
||||
shortId: string,
|
||||
): Promise<UrlResponse | null> => {
|
||||
return await db.url.findFirst({
|
||||
where: {
|
||||
OR: [{ shortId }, { alias: shortId }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
shortId: true,
|
||||
alias: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** * Find a url by its alias.
|
||||
*
|
||||
* @param {string} alias - The alias of the url
|
||||
* @returns {Promise<Url | null>} - The found url object or null if not found
|
||||
*/
|
||||
export const findUrlByAliasService = async (
|
||||
alias: string,
|
||||
): Promise<UrlResponse | null> => {
|
||||
return await db.url.findUnique({
|
||||
where: { alias },
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
shortId: true,
|
||||
alias: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all urls for a user.
|
||||
*
|
||||
* @param {string} userId - The ID of the user
|
||||
* @returns {Promise<UrlResponseWithClicks[]>} - An array of url objects
|
||||
*/
|
||||
export const getUrlsByUserService = async (
|
||||
userId: string,
|
||||
): Promise<UrlResponseWithClicks[]> => {
|
||||
return await db.url.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
shortId: true,
|
||||
alias: true,
|
||||
createdAt: true,
|
||||
clicks: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a url by its ID.
|
||||
*
|
||||
* @param {string} urlId - The ID of the url to delete
|
||||
* @returns {Promise<void>} - A promise that resolves when the url is deleted
|
||||
*/
|
||||
export const deleteUrlService = async (urlId: string): Promise<void> => {
|
||||
await db.url.delete({
|
||||
where: { id: urlId },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a url's URL or alias.
|
||||
*
|
||||
* @param {string} urlId - The ID of the url to update
|
||||
* @param {Object} data - The data to update
|
||||
* @param {string} [data.url] - The new URL
|
||||
* @param {string} [data.alias] - The new alias
|
||||
* @returns {Promise<UrlResponse>} - The updated url object
|
||||
*/
|
||||
export const updateUrlService = async (
|
||||
urlId: string,
|
||||
data: { url?: string; alias?: string },
|
||||
): Promise<UrlResponse> => {
|
||||
return await db.url.update({
|
||||
where: { id: urlId },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
shortId: true,
|
||||
alias: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** * Increment the click count for a url by its short ID.
|
||||
*
|
||||
* @param {string} id - The ID of the url
|
||||
* @returns {Promise<void>} - A promise that resolves when the operation is complete
|
||||
*/
|
||||
export const incrementUrlClicksService = async (id: string): Promise<void> => {
|
||||
await db.url.updateMany({
|
||||
where: { id },
|
||||
data: {
|
||||
clicks: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { prisma as db } from "@/db"
|
||||
import type { User } from "@/generated/prisma/client"
|
||||
|
||||
/**
|
||||
* Create a new user.
|
||||
*
|
||||
* @param {string} email - User's email
|
||||
* @param {string} hashedPassword - User's hashed password
|
||||
* @returns {Promise<User>} - The created user object
|
||||
*/
|
||||
export const createUserService = async (
|
||||
email: string,
|
||||
hashedPassword: string,
|
||||
name: string,
|
||||
): Promise<User> => {
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: hashedPassword,
|
||||
name,
|
||||
},
|
||||
})
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a user by email.
|
||||
*
|
||||
* @param {string} email - User's email
|
||||
* @returns {Promise<User | null>} - The user object or null if not found
|
||||
*/
|
||||
export const findUserByEmailService = async (
|
||||
email: string,
|
||||
): Promise<User | null> => {
|
||||
const user = await db.user.findUnique({ where: { email } })
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a user by ID.
|
||||
*
|
||||
* @param {string} id - User's ID
|
||||
* @returns {Promise<User | null>} - The user object or null if not found
|
||||
*/
|
||||
export const findUserByIdService = async (id: string): Promise<User | null> => {
|
||||
const user = await db.user.findUnique({ where: { id } })
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a user's information.
|
||||
*
|
||||
* @param {string} id - User's ID
|
||||
* @param {Object} data - Data to update
|
||||
* @param {string} [data.email] - New email
|
||||
* @param {string} [data.name] - New name
|
||||
* @param {string} [data.password] - New password
|
||||
* @returns {Promise<User>} - The updated user object
|
||||
*/
|
||||
export const updateUserService = async (
|
||||
id: string,
|
||||
data: Partial<Pick<User, "email" | "name" | "password">>,
|
||||
): Promise<User> => {
|
||||
const user = await db.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
return user
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test"
|
||||
import { serverConfig } from "../../index"
|
||||
import { cleanTestDB, setupTestDB } from "./test-utils"
|
||||
|
||||
describe("URL Shortener API", () => {
|
||||
let BASE_URL: string
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDB()
|
||||
const server = Bun.serve({
|
||||
...serverConfig,
|
||||
port: 0,
|
||||
})
|
||||
|
||||
BASE_URL = `http://localhost:${server.port}`
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanTestDB()
|
||||
})
|
||||
|
||||
it("Register a new user", async () => {
|
||||
const req = await fetch(`${BASE_URL}/api/auth/signup`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
password: "password",
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
expect(req.status).toBe(201)
|
||||
const data = await req.json()
|
||||
expect(data).toHaveProperty("id")
|
||||
expect(data).toHaveProperty("email", "test@example.com")
|
||||
expect(data).toHaveProperty("name", "Test User")
|
||||
expect(data).toHaveProperty("createdAt")
|
||||
})
|
||||
|
||||
it("Register with existing email", async () => {
|
||||
const req = await fetch(`${BASE_URL}/api/auth/signup`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
password: "password",
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
expect(req.status).toBe(409)
|
||||
const data = await req.json()
|
||||
expect(data).toHaveProperty(
|
||||
"message",
|
||||
"User with this email already exists",
|
||||
)
|
||||
})
|
||||
|
||||
it("Invalid registration data", async () => {
|
||||
const req = await fetch(`${BASE_URL}/api/auth/signup`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: "test@example.com",
|
||||
password: "password",
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
expect(req.status).toBe(400)
|
||||
const data = await req.json()
|
||||
expect(data).toHaveProperty("message", "Bad Request")
|
||||
})
|
||||
|
||||
it("Login with valid credentials", async () => {
|
||||
const req = await fetch(`${BASE_URL}/api/auth/login`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "test@example.com", password: "password" }),
|
||||
})
|
||||
|
||||
expect(req.status).toBe(200)
|
||||
const data = await req.json()
|
||||
expect(data).toHaveProperty("accessToken")
|
||||
expect(data).toHaveProperty("refreshToken")
|
||||
})
|
||||
|
||||
it("Login with invalid credentials", async () => {
|
||||
const req = await fetch(`${BASE_URL}/api/auth/login`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "bad@email.com", password: "wrong" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
expect(req.status).not.toBe(200)
|
||||
})
|
||||
|
||||
it("Login with invalid email", async () => {
|
||||
const req = await fetch(`${BASE_URL}/api/auth/login`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "bad", password: "wrong" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
expect(req.status).toBe(400)
|
||||
const data = await req.json()
|
||||
expect(data).toHaveProperty("message", "Bad Request")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { beforeAll, describe, expect, it } from "bun:test"
|
||||
import { serverConfig } from "../.."
|
||||
|
||||
describe("Documentation API", () => {
|
||||
let BASE_URL: string
|
||||
|
||||
beforeAll(async () => {
|
||||
const server = Bun.serve({
|
||||
...serverConfig,
|
||||
port: 0,
|
||||
})
|
||||
|
||||
BASE_URL = `http://localhost:${server.port}`
|
||||
})
|
||||
|
||||
it("Get OpenAPI Specification", async () => {
|
||||
const req = await fetch(`${BASE_URL}/api/openapi.json`)
|
||||
expect(req.status).toBe(200)
|
||||
const data = await req.json()
|
||||
expect(data).toHaveProperty("openapi")
|
||||
expect(data).toHaveProperty("info")
|
||||
expect(data).toHaveProperty("paths")
|
||||
})
|
||||
|
||||
it("Get Swagger UI", async () => {
|
||||
const req = await fetch(`${BASE_URL}/api/docs`)
|
||||
expect(req.status).toBe(200)
|
||||
const text = await req.text()
|
||||
expect(text).toContain("<!DOCTYPE html>")
|
||||
expect(text).toContain("URL Shortener API")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { beforeAll, describe, expect, it } from "bun:test"
|
||||
import { serverConfig } from "../.."
|
||||
|
||||
describe("Main API", () => {
|
||||
let BASE_URL: string
|
||||
|
||||
beforeAll(async () => {
|
||||
const server = Bun.serve({
|
||||
...serverConfig,
|
||||
port: 0,
|
||||
})
|
||||
|
||||
BASE_URL = `http://localhost:${server.port}`
|
||||
})
|
||||
|
||||
it("Health check", async () => {
|
||||
const req = await fetch(`${BASE_URL}/api/health`)
|
||||
expect(req.status).toBe(200)
|
||||
const data = await req.json()
|
||||
expect(data).toEqual({ status: "ok" })
|
||||
})
|
||||
|
||||
it("Not Found", async () => {
|
||||
const req = await fetch(`${BASE_URL}/not-found-route`)
|
||||
expect(req.status).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { prisma } from "@/db"
|
||||
import {
|
||||
generateAccessToken,
|
||||
generateRefreshToken,
|
||||
hashPassword,
|
||||
} from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Execute migrations to set up the test database schema.
|
||||
* Use db push to avoid generating client code during tests.
|
||||
*/
|
||||
export const setupTestDB = async () => {
|
||||
try {
|
||||
const proc = Bun.spawn(["bunx", "--bun", "prisma", "migrate", "deploy"], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "test",
|
||||
DATABASE_URL: "file:./test.db",
|
||||
},
|
||||
})
|
||||
await proc.exited
|
||||
} catch (error) {
|
||||
console.error("Error ", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the test database by deleting all records from relevant tables.
|
||||
* ALERT: Order of deletions matters due to foreign key constraints.
|
||||
*/
|
||||
export const cleanTestDB = async () => {
|
||||
// Adjust these names to match your actual models
|
||||
const deleteUrls = prisma.url.deleteMany()
|
||||
const deleteUsers = prisma.user.deleteMany()
|
||||
|
||||
// Use a transaction to ensure complete cleanup
|
||||
await prisma.$transaction([deleteUrls, deleteUsers])
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user and generate an authentication token for testing.
|
||||
*
|
||||
* @returns {Promise<{ user: User; token: string }>} - The created user and auth token
|
||||
*/
|
||||
export const createAuthenticatedUser = async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `test_${Date.now()}@example.com`,
|
||||
password: await hashPassword("testpassword"),
|
||||
name: "Test User",
|
||||
},
|
||||
})
|
||||
|
||||
const accessToken = await generateAccessToken(user.id, user.email)
|
||||
|
||||
const refreshToken = await generateRefreshToken(user.id)
|
||||
|
||||
return { accessToken, refreshToken }
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test"
|
||||
import { serverConfig } from "../.."
|
||||
import { cleanTestDB, createAuthenticatedUser, setupTestDB } from "./test-utils"
|
||||
|
||||
describe("Url Endpoints (Protected)", () => {
|
||||
let accessToken = ""
|
||||
let BASE_URL: string
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDB()
|
||||
const server = Bun.serve({
|
||||
...serverConfig,
|
||||
port: 0,
|
||||
})
|
||||
|
||||
BASE_URL = `http://localhost:${server.port}`
|
||||
|
||||
const authData = await createAuthenticatedUser()
|
||||
accessToken = authData.accessToken
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanTestDB()
|
||||
})
|
||||
|
||||
it("Generate a short url", async () => {
|
||||
const response = await fetch(`${BASE_URL}/api/url/shorten`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: "https://google.com" }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty("id")
|
||||
expect(data).toHaveProperty("shortId")
|
||||
expect(data).toHaveProperty("url", "https://google.com")
|
||||
expect(data).toHaveProperty("alias", null)
|
||||
expect(data).toHaveProperty("createdAt")
|
||||
})
|
||||
|
||||
it("Generate a short url with alias", async () => {
|
||||
const response = await fetch(`${BASE_URL}/api/url/shorten`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: "https://google.com", alias: "google" }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty("shortId")
|
||||
expect(data).toHaveProperty("url", "https://google.com")
|
||||
expect(data).toHaveProperty("alias", "google")
|
||||
})
|
||||
|
||||
it("Generate a short url with invalid URL", async () => {
|
||||
const response = await fetch(`${BASE_URL}/api/url/shorten`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: "invalid-url" }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty("message", "Bad Request")
|
||||
})
|
||||
|
||||
it("Generate a short url with duplicate alias", async () => {
|
||||
await fetch(`${BASE_URL}/api/url/shorten`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: "https://example.com", alias: "example" }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
const response = await fetch(`${BASE_URL}/api/url/shorten`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: "https://another.com", alias: "example" }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty("message", "Alias already in use")
|
||||
})
|
||||
|
||||
it("Get urls for authenticated user", async () => {
|
||||
const response: Response | null = await fetch(`${BASE_URL}/api/url`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(Array.isArray(data)).toBe(true)
|
||||
// @ts-expect-error: unknown type
|
||||
expect(data.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("Update a url alias", async () => {
|
||||
const createResponse = await fetch(`${BASE_URL}/api/url/shorten`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
url: "https://update-test.com",
|
||||
alias: "firstAlias",
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
const createdUrl = await createResponse.json()
|
||||
|
||||
const updateResponse = await fetch(
|
||||
// @ts-expect-error: unknown type
|
||||
`${BASE_URL}/api/url/${createdUrl.id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ alias: "updateAlias" }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(updateResponse.status).toBe(200)
|
||||
const updatedUrl = await updateResponse.json()
|
||||
expect(updatedUrl).toHaveProperty("message", "Url updated successfully")
|
||||
})
|
||||
|
||||
it("Delete a url", async () => {
|
||||
// First, create a url to delete
|
||||
const createResponse = await fetch(`${BASE_URL}/api/url/shorten`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: "https://delete-test.com" }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
const createdUrl = await createResponse.json()
|
||||
|
||||
// Now, delete the url
|
||||
const deleteResponse = await fetch(
|
||||
// @ts-expect-error: unknown type
|
||||
`${BASE_URL}/api/url/${createdUrl.id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(deleteResponse.status).toBe(200)
|
||||
})
|
||||
|
||||
it("Redirect to original url", async () => {
|
||||
const createResponse = await fetch(`${BASE_URL}/api/url/shorten`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: "https://example.com" }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
const createdUrl = await createResponse.json()
|
||||
|
||||
const redirectResponse = await fetch(
|
||||
// @ts-expect-error: unknown type
|
||||
`${BASE_URL}/${createdUrl.shortId}`,
|
||||
{
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
},
|
||||
)
|
||||
|
||||
expect(redirectResponse.status).toBe(301)
|
||||
expect(redirectResponse.headers.get("Location")).toBe("https://example.com")
|
||||
})
|
||||
|
||||
it("Fail to update alias to existing one", async () => {
|
||||
await fetch(`${BASE_URL}/api/url/shorten`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: "https://one.com", alias: "alias1" }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
const createResponse = await fetch(`${BASE_URL}/api/url/shorten`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: "https://two.com", alias: "alias2" }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
const createdUrl = await createResponse.json()
|
||||
|
||||
// Try to update second url to alias1
|
||||
const updateResponse = await fetch(
|
||||
// @ts-expect-error: unknown type
|
||||
`${BASE_URL}/api/url/${createdUrl.id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ alias: "alias1" }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(updateResponse.status).toBe(409)
|
||||
})
|
||||
|
||||
it("Fail to delete non-existent url", async () => {
|
||||
const response = await fetch(`${BASE_URL}/api/url/nonexistentid`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test"
|
||||
import { serverConfig } from "../.."
|
||||
import { cleanTestDB, createAuthenticatedUser, setupTestDB } from "./test-utils"
|
||||
|
||||
describe("User Endpoints (Protected)", () => {
|
||||
let accessToken = ""
|
||||
let BASE_URL: string
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDB()
|
||||
const server = Bun.serve({
|
||||
...serverConfig,
|
||||
port: 0,
|
||||
})
|
||||
|
||||
BASE_URL = `http://localhost:${server.port}`
|
||||
|
||||
const authData = await createAuthenticatedUser()
|
||||
accessToken = authData.accessToken
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanTestDB()
|
||||
})
|
||||
|
||||
it("Get user info", async () => {
|
||||
const response = await fetch(`${BASE_URL}/api/user`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty("id")
|
||||
expect(data).toHaveProperty("name", "Test User")
|
||||
expect(data).toHaveProperty("email")
|
||||
expect(data).toHaveProperty("createdAt")
|
||||
})
|
||||
|
||||
it("Update user info", async () => {
|
||||
const newName = "Updated Test User"
|
||||
const newEmail = `updated_${Date.now()}@example.com`
|
||||
const response = await fetch(`${BASE_URL}/api/user`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: newName,
|
||||
email: newEmail,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty("message", "User updated successfully")
|
||||
})
|
||||
|
||||
it("Fail to update email to existing one", async () => {
|
||||
await fetch(`${BASE_URL}/api/auth/signup`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: "existing@example.com",
|
||||
name: "Existing User",
|
||||
password: "password",
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
const response = await fetch(`${BASE_URL}/api/user`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: "existing@example.com",
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty("message", "Email already in use")
|
||||
})
|
||||
|
||||
it("Update user password", async () => {
|
||||
const response = await fetch(`${BASE_URL}/api/user/password`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
password: "newtestpassword",
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty("message", "Password updated successfully")
|
||||
})
|
||||
|
||||
it("Fails to get user info without token", async () => {
|
||||
const response = await fetch(`${BASE_URL}/api/user`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty("message", "Unauthorized")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
|
||||
// Path aliases (uncomment if needed)
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user