diff --git a/README.md b/README.md index e0cfbb8..3d76412 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ The all in one hono api template with prisma, postgresql and minio s3 bucket set │ │ └── s3-client.ts │ ├── routes │ │ └── auth +│ │ ├── index.ts +│ │ └── routes.ts │ └── utils │ └── passwords.ts └── tsconfig.json @@ -43,6 +45,55 @@ The all in one hono api template with prisma, postgresql and minio s3 bucket set ## Whats included +### Swagger Docs served at /docs + +1. Each route should be inside `/src/routes//` +2. Each `//` should include 2 files + + 1. `routes.ts` + + - here define the openapi specs for all your routes in this format + + ```ts + export const = createRoute({ + method: "", + path: "/", + request: { + query: z.object({ + }), + }, + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: z.object({}).openapi("Response"), + }, + }, + }, + ... + }, + }); + ``` + + 2. `index.ts` + + - import your route specs here and define functionality + + ```ts + const Router = new OpenAPIHono(); + + Router.openapi(, async (ctx) => { + const { } = ctx.req.valid("query"); + + return new Response("", { + status: , + }); + }); + ``` + +3. include defined router in `/src/index.ts` + ### Prisma Role Base Auth The project includes a pre-configured Prisma schema for role-based authentication and user management. The schema defines two main models: @@ -108,15 +159,17 @@ Retrieves a document from the MinIO server using its unique document ID. `docId (string)`: The unique ID of the document. - Returns: + - `Promise<{ data: Buffer; contentType: string; }>`: An object containing: + - `data (Buffer)`: The document's content. - `contentType (string)`: The MIME type of the document. -```ts -const doc = await readDocument(docId); -console.log("Document content type:", doc.contentType); -console.log("Document data:", doc.data.toString()); -``` + ```ts + const doc = await readDocument(docId); + console.log("Document content type:", doc.contentType); + console.log("Document data:", doc.data.toString()); + ``` 3. `deleteDocument(docId: string): Promise` @@ -127,12 +180,13 @@ Deletes a document from the MinIO server using its unique document ID. - `docId (string)`: The unique ID of the document to delete. - Returns: + - `Promise`: Resolves once the document is successfully deleted. -```ts -await deleteDocument(docId); -console.log("Document deleted successfully"); -``` + ```ts + await deleteDocument(docId); + console.log("Document deleted successfully"); + ``` ### Auth Routes @@ -143,15 +197,16 @@ The authentication module provides routes for user login and registration, utili Authenticates a user and returns a JWT for accessing protected resources. - **Request Body (JSON)** + - `email (string)`: The user's email. Must be a valid email address. - `password (string)`: The user's password. Must be at least 8 characters. -```json -{ - "email": "user@example.com", - "password": "securepassword" -} -``` + ```json + { + "email": "user@example.com", + "password": "securepassword" + } + ``` - **Responses** - 200 OK @@ -166,19 +221,20 @@ Authenticates a user and returns a JWT for accessing protected resources. Registers a new user by creating entries in the `Auth` and `User` models. - **Request Body (JSON)** + - `email (string)`: The user's email. Must be a valid email address. - `password (string)`: The user's password. Must be at least 8 characters. - `name (string)`: Full name of the user. - `role (string)`: Role of the user (ADMIN or USER). -```json -{ - "name": "John Doe", - "role": "USER", - "email": "user@example.com", - "password": "securepassword" -} -``` + ```json + { + "name": "John Doe", + "role": "USER", + "email": "user@example.com", + "password": "securepassword" + } + ``` - **Responses** - 201 Created diff --git a/package.json b/package.json index e9d995c..34f19b9 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "@hono/node-server": "^1.13.7", - "@hono/zod-validator": "^0.4.2", + "@hono/swagger-ui": "^0.5.0", + "@hono/zod-openapi": "^0.18.3", "@prisma/client": "^6.1.0", "hono": "^4.6.14", "jsonwebtoken": "^9.0.2", @@ -34,7 +35,6 @@ "tsx": "^4.19.2", "typescript": "^5.7.2", "typescript-eslint": "^8.18.1", - "uuid": "^11.0.3", - "zod": "^3.24.1" + "uuid": "^11.0.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e2c530..768b5eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,9 +10,12 @@ importers: "@hono/node-server": specifier: ^1.13.7 version: 1.13.7(hono@4.6.14) - "@hono/zod-validator": - specifier: ^0.4.2 - version: 0.4.2(hono@4.6.14)(zod@3.24.1) + "@hono/swagger-ui": + specifier: ^0.5.0 + version: 0.5.0(hono@4.6.14) + "@hono/zod-openapi": + specifier: ^0.18.3 + version: 0.18.3(hono@4.6.14)(zod@3.24.1) "@prisma/client": specifier: ^6.1.0 version: 6.1.0(prisma@6.1.0) @@ -77,11 +80,16 @@ importers: uuid: specifier: ^11.0.3 version: 11.0.3 - zod: - specifier: ^3.24.1 - version: 3.24.1 packages: + "@asteasolutions/zod-to-openapi@7.3.0": + resolution: + { + integrity: sha512-7tE/r1gXwMIvGnXVUdIqUhCU1RevEFC4Jk6Bussa0fk1ecbnnINkZzj1EOAJyE/M3AI25DnHT/zKQL1/FPFi8Q==, + } + peerDependencies: + zod: ^3.20.2 + "@esbuild/aix-ppc64@0.23.1": resolution: { @@ -365,6 +373,24 @@ packages: peerDependencies: hono: ^4 + "@hono/swagger-ui@0.5.0": + resolution: + { + integrity: sha512-MWYYSv9kC8IwFBLZdwgZZMT9zUq2C/4/ekuyEYOkHEgUMqu+FG3eebtBZ4ofMh60xYRxRR2BgQGoNIILys/PFg==, + } + peerDependencies: + hono: "*" + + "@hono/zod-openapi@0.18.3": + resolution: + { + integrity: sha512-bNlRDODnp7P9Fs13ZPajEOt13G0XwXKfKRHMEFCphQsFiD1Y+twzHaglpNAhNcflzR1DQwHY92ZS06b4LTPbIQ==, + } + engines: { node: ">=16.0.0" } + peerDependencies: + hono: ">=4.3.6" + zod: 3.* + "@hono/zod-validator@0.4.2": resolution: { @@ -1820,6 +1846,12 @@ packages: } engines: { node: ">=18" } + openapi3-ts@4.4.0: + resolution: + { + integrity: sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw==, + } + optionator@0.9.4: resolution: { @@ -2390,6 +2422,11 @@ packages: } snapshots: + "@asteasolutions/zod-to-openapi@7.3.0(zod@3.24.1)": + dependencies: + openapi3-ts: 4.4.0 + zod: 3.24.1 + "@esbuild/aix-ppc64@0.23.1": optional: true @@ -2507,6 +2544,17 @@ snapshots: dependencies: hono: 4.6.14 + "@hono/swagger-ui@0.5.0(hono@4.6.14)": + dependencies: + hono: 4.6.14 + + "@hono/zod-openapi@0.18.3(hono@4.6.14)(zod@3.24.1)": + dependencies: + "@asteasolutions/zod-to-openapi": 7.3.0(zod@3.24.1) + "@hono/zod-validator": 0.4.2(hono@4.6.14)(zod@3.24.1) + hono: 4.6.14 + zod: 3.24.1 + "@hono/zod-validator@0.4.2(hono@4.6.14)(zod@3.24.1)": dependencies: hono: 4.6.14 @@ -3386,6 +3434,10 @@ snapshots: dependencies: mimic-function: 5.0.1 + openapi3-ts@4.4.0: + dependencies: + yaml: 2.6.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 diff --git a/src/index.ts b/src/index.ts index 6a2989f..e8708b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,10 @@ import { serve } from "@hono/node-server"; -import { Hono } from "hono"; +import { OpenAPIHono } from "@hono/zod-openapi"; +import { swaggerUI } from "@hono/swagger-ui"; import authRouter from "./routes/auth/index.js"; -const app = new Hono(); +const app = new OpenAPIHono(); app.route("/auth", authRouter); @@ -14,6 +15,16 @@ app.get("/", (c) => { const port = 3000; console.log(`Server is running on http://localhost:${port}`); +app.doc("/openapi", { + openapi: "3.0.0", + info: { + version: "0.0.1", + title: "Backend for a hackathon management system", + }, +}); + +app.get("/docs", swaggerUI({ url: "/openapi" })); + serve({ fetch: app.fetch, port, diff --git a/src/routes/auth/index.ts b/src/routes/auth/index.ts index 9d1aa15..3135272 100644 --- a/src/routes/auth/index.ts +++ b/src/routes/auth/index.ts @@ -1,126 +1,103 @@ -import { z } from "zod"; -import { zValidator } from "@hono/zod-validator"; -import { Hono } from "hono"; +import { OpenAPIHono } from "@hono/zod-openapi"; import { v4 } from "uuid"; import jwt from "jsonwebtoken"; import prisma from "./../../lib/prisma-client.js"; import { checkPassword, hashPassword } from "./../../utils/passwords.js"; +import { login, register } from "./routes.js"; -const authRouter = new Hono(); - -authRouter.post( - "/login", - zValidator( - "json", - z.object({ - email: z.string().email(), - password: z.string().min(8), - }) - ), - async (ctx) => { - const { email, password } = ctx.req.valid("json"); - - const auth = await prisma.auth.findUnique({ - where: { - email, - }, - include: { - user: true, - }, - }); +const authRouter = new OpenAPIHono(); - if (!auth) { - return new Response("User not found", { - status: 404, - }); - } +authRouter.openapi(login, async (ctx) => { + const { email, password } = ctx.req.valid("query"); - const truePassword = await checkPassword(password, auth?.password); - - if (truePassword) { - if (!process.env.JWT_SECRET) { - return new Response("Internal server error", { status: 500 }); - } - - const token = jwt.sign( - { userId: auth.id, role: auth.user?.role }, - process.env.JWT_SECRET as string, - { expiresIn: "2h" } - ); - - return new Response( - JSON.stringify({ - token: token, - userId: auth.id, - }) - ); - } else { - return new Response("Invalid password", { - status: 403, - }); - } - } -); - -authRouter.post( - "/register", - zValidator( - "json", - z.object({ - name: z.string(), - role: z.enum(["ADMIN", "USER"]), - email: z.string().email(), - password: z.string().min(8), - }) - ), - async (ctx) => { - const { name, role, email, password } = ctx.req.valid("json"); - - const existingUser = await prisma.auth.findUnique({ - where: { - email, - }, + const auth = await prisma.auth.findUnique({ + where: { + email, + }, + include: { + user: true, + }, + }); + + if (!auth) { + return new Response("User not found", { + status: 404, }); + } + + const truePassword = await checkPassword(password, auth?.password); - if (existingUser) { - return new Response("User already exists", { - status: 409, - }); + if (truePassword) { + if (!process.env.JWT_SECRET) { + return new Response("Internal server error", { status: 500 }); } - const hashedPassword = await hashPassword(password); - - const auth = await prisma.$transaction(async (tx) => { - const auth = await tx.auth.create({ - data: { - id: v4(), - email, - password: hashedPassword, - }, - }); - - await tx.user.create({ - data: { - name, - role, - authId: auth.id, - }, - }); - - return auth; - }); + const token = jwt.sign( + { userId: auth.id, role: auth.user?.role }, + process.env.JWT_SECRET as string, + { expiresIn: "2h" } + ); return new Response( JSON.stringify({ - message: "User registered successfully", - user: auth, + token: token, + userId: auth.id, }), - { - status: 201, - } + { status: 200 } ); + } else { + return new Response("Invalid password", { + status: 403, + }); } -); +}); + +authRouter.openapi(register, async (ctx) => { + const { name, role, email, password } = ctx.req.valid("query"); + + const existingUser = await prisma.auth.findUnique({ + where: { + email, + }, + }); + + if (existingUser) { + return new Response("User already exists", { + status: 409, + }); + } + + const hashedPassword = await hashPassword(password); + + const auth = await prisma.$transaction(async (tx) => { + const auth = await tx.auth.create({ + data: { + id: v4(), + email, + password: hashedPassword, + }, + }); + + await tx.user.create({ + data: { + name, + role, + authId: auth.id, + }, + }); + + return auth; + }); + + return new Response( + JSON.stringify({ + userId: auth.id, + }), + { + status: 201, + } + ); +}); export default authRouter; diff --git a/src/routes/auth/routes.ts b/src/routes/auth/routes.ts new file mode 100644 index 0000000..87d48e1 --- /dev/null +++ b/src/routes/auth/routes.ts @@ -0,0 +1,77 @@ +import { z, createRoute } from "@hono/zod-openapi"; + +export const login = createRoute({ + method: "post", + path: "/login", + request: { + query: z.object({ + email: z.string().email().openapi({ example: "example@example.com" }), + password: z.string().min(8).openapi({ example: "password" }), + }), + }, + responses: { + 200: { + description: "User logged in successfully", + content: { + "application/json": { + schema: z + .object({ + token: z.string().openapi({ + example: + "eyJSb2xlIjoiQWRtaW4iLCJJc3N1ZXIiOiJJc3N1ZXIiLCJVc2VybmFtZSI6IkphdmFJblVzZSIsImV4cCI6MTczNTE0MDk3NiwiaWF0IjoxNzM1MTQwOTc2fQ", + }), + userId: z + .string() + .openapi({ example: "c9d2841e-7696-4360-bce4-9f9f3e2469cd" }), + }) + .openapi("LoginResponse"), + }, + }, + }, + 403: { + description: "Invalid password", + }, + 404: { + description: "User not found", + }, + 500: { + description: "JWT secret not set", + }, + }, +}); + +export const register = createRoute({ + method: "post", + path: "/register", + request: { + query: z.object({ + name: z.string().openapi({ example: "Example Name" }), + role: z.enum(["ADMIN", "USER"]).openapi({ example: "USER" }), + email: z.string().email().openapi({ example: "example@example.com" }), + password: z.string().min(8).openapi({ example: "password" }), + }), + }, + + responses: { + 201: { + description: "User registered successfully", + content: { + "application/json": { + schema: z + .object({ + userId: z.string().openapi({ + example: "c9d2841e-7696-4360-bce4-9f9f3e2469cd", + }), + }) + .openapi("RegisterResponse"), + }, + }, + }, + 409: { + description: "User already exists", + }, + 500: { + description: "JWT secret not set", + }, + }, +});