From 42bff3989550df75427ac8c1c205742bfee34517 Mon Sep 17 00:00:00 2001 From: Sanjib Sen Date: Tue, 21 Jan 2025 12:18:03 +0600 Subject: [PATCH] docs, formatting, tests --- README.md | 183 +++++++++++++++++- docker-compose.services.yaml | 20 +- drizzle/meta/0000_snapshot.json | 42 ++-- drizzle/meta/_journal.json | 2 +- package.json | 8 +- .../note/attachments/attachment.controller.ts | 99 ++++++---- .../note/attachments/attachment.model.ts | 17 +- .../note/attachments/attachment.route.ts | 20 +- .../note/attachments/attachment.test.ts | 47 +++-- src/api/routes/note/note.model.ts | 4 +- src/db/schema/note.ts | 1 - test/client.ts | 82 ++++++-- 12 files changed, 386 insertions(+), 139 deletions(-) diff --git a/README.md b/README.md index bebf061..7880e0b 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,184 @@ -# Elysia with Bun runtime +# Microservice Start -## Getting Started +A microservice starter template built with Bun, Elysia, PostgreSQL, and MinIO. -To get started with this template, simply paste this command into your terminal: +## Prerequisites + +- [Bun](https://bun.sh/) installed +- Docker and Docker Compose +- Node.js (for development tools) + +## Tech Stack + +- **Runtime:** Bun +- **Framework:** Elysia +- **Database:** PostgreSQL +- **Object Storage:** MinIO +- **Authentication:** better-auth +- **Email Templates:** React Email +- **Database Tools:** Drizzle ORM + +## Project Setup + +1. Clone the repository: ```bash -bun create elysia ./elysia-example +git clone +cd microservice-start ``` -## Development - -To start the development server run: +2. Set up environment files: ```bash -bun run dev +# Copy environment examples +cp .env.example .env +cp .env.services.example .env.services ``` -Open http://localhost:3000/ with your browser to see the result. +3. Configure the environment variables: + +**.env** + +```env +PORT=3000 +SERVICE_NAME= +DATABASE_URL="postgresql://..." +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= +MINIO_ENDPOINT_URL= +MINIO_PORT= +MINIO_BUCKET_NAME= +BETTER_AUTH_SECRET= + +# DO NOT CHANGE +BETTER_AUTH_URL=http://127.0.0.1:${PORT} +``` + +**.env.services** + +```env +DB_USER= +DB_PASSWORD= +DB_PORT= + +MINIO_ROOT_USER= +MINIO_ROOT_PASSWORD= +``` + +## Running the Project + +1. Start the required services (PostgreSQL, MinIO): + +```bash +bun run services:up +``` + +2. Configure MinIO: + + - Access MinIO console at `http://localhost:9000` + - Log in using the credentials set in `.env.services` + - Create an access key and secret key + - Update the `.env` file with the generated MinIO credentials: + - `MINIO_ACCESS_KEY` + - `MINIO_SECRET_KEY` + +3. Install dependencies and start the development server: + +```bash +bun install +bun dev +``` + +## Development Tools + +### Database Management + +- Open Database Studio: + +```bash +bun run db:studio +``` + +- Check database schema: + +```bash +bun run db:check +``` + +- Generate database migrations: + +```bash +bun run db:generate +``` + +- Run migrations: + +```bash +bun run db:migrate +``` + +- Pull database schema: + +```bash +bun run db:pull +``` + +- Push database changes: + +```bash +bun run db:push +``` + +### Other Commands + +- Run tests: + +```bash +bun test +``` + +- Format code: + +```bash +bun run format +``` + +- Generate auth configuration: + +```bash +bun run auth:generate +``` + +- Development email server: + +```bash +bun run email +``` + +- Build for production: + +```bash +bun run build +``` + +### Service Management + +- Start services: + +```bash +bun run services:up +``` + +- Stop services: + +```bash +bun run services:down +``` + +## Contributing + +[Add your contribution guidelines here] + +## License + +[Add your license information here] diff --git a/docker-compose.services.yaml b/docker-compose.services.yaml index 63f3920..b5f36b8 100644 --- a/docker-compose.services.yaml +++ b/docker-compose.services.yaml @@ -5,16 +5,16 @@ services: COLLECTOR_ZIPKIN_HOST_PORT: 9411 COLLECTOR_OTLP_ENABLED: true ports: - - "6831:6831/udp" # jaeger-agent UDP accept compact thrift protocol - - "6832:6832/udp" # jaeger-agent UDP accept binary thrift protocol - - "5778:5778" # jaeger-agent HTTP serve configs - - "16686:16686" # jaeger-query HTTP serve frontend/API - - "4317:4317" # OTLP gRPC - - "4318:4318" # OTLP HTTP - - "14250:14250" # jaeger-collector gRPC - - "14268:14268" # jaeger-collector HTTP accept spans - - "14269:14269" # jaeger-collector HTTP admin - - "9411:9411" # zipkin-collector HTTP accept spans + - "6831:6831/udp" # jaeger-agent UDP accept compact thrift protocol + - "6832:6832/udp" # jaeger-agent UDP accept binary thrift protocol + - "5778:5778" # jaeger-agent HTTP serve configs + - "16686:16686" # jaeger-query HTTP serve frontend/API + - "4317:4317" # OTLP gRPC + - "4318:4318" # OTLP HTTP + - "14250:14250" # jaeger-collector gRPC + - "14268:14268" # jaeger-collector HTTP accept spans + - "14269:14269" # jaeger-collector HTTP admin + - "9411:9411" # zipkin-collector HTTP accept spans db: image: postgres:latest diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 5ef8956..5e05570 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -140,12 +140,8 @@ "tableFrom": "account", "tableTo": "user", "schemaTo": "auth", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -337,12 +333,8 @@ "tableFrom": "session", "tableTo": "user", "schemaTo": "auth", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -352,9 +344,7 @@ "session_token_unique": { "name": "session_token_unique", "nullsNotDistinct": false, - "columns": [ - "token" - ] + "columns": ["token"] } }, "policies": {}, @@ -415,9 +405,7 @@ "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] } }, "policies": {}, @@ -584,12 +572,8 @@ "name": "note_attachments_note_id_note_id_fk", "tableFrom": "note_attachments", "tableTo": "note", - "columnsFrom": [ - "note_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["note_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -701,12 +685,8 @@ "tableFrom": "note", "tableTo": "user", "schemaTo": "auth", - "columnsFrom": [ - "owner_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -731,4 +711,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 336cc10..7eeb1df 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -10,4 +10,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/package.json b/package.json index 34c2c08..e19d0d3 100644 --- a/package.json +++ b/package.json @@ -2,18 +2,20 @@ "name": "app", "version": "1.0.50", "scripts": { - "test": "bun test api", + "test": "bun run format && bun test api", "format": "prettier . --write", "dev": "bun run --watch src/index.ts", + "services:up": "docker compose --env-file .env.services -f docker-compose.services.yaml up -d --build && bun run db:migrate", + "services:dowm": "docker compose --env-file .env.services -f docker-compose.services.yaml down", "email": "email dev --dir src/emails", - "auth:generate": "bun x @better-auth/cli generate --config src/lib/auth/auth.ts --output src/db/schema/auth.ts && drizzle-kit migrate", + "auth:generate": "bun x @better-auth/cli generate --config src/lib/auth/auth.ts", "db:studio": "drizzle-kit studio", "db:check": "drizzle-kit check", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:pull": "drizzle-kit pull", "db:push": "drizzle-kit push", - "build": "bun build --compile --minify-whitespace --minify-syntax --target bun --outfile server ./src/index.ts" + "build": "bun run test && bun build --compile --minify-whitespace --minify-syntax --target bun --outfile server ./src/index.ts" }, "dependencies": { "@elysiajs/cors": "^1.2.0", diff --git a/src/api/routes/note/attachments/attachment.controller.ts b/src/api/routes/note/attachments/attachment.controller.ts index 1daddfe..a8a4ea6 100644 --- a/src/api/routes/note/attachments/attachment.controller.ts +++ b/src/api/routes/note/attachments/attachment.controller.ts @@ -4,15 +4,21 @@ import { attachment, note } from "../../../../db/schema/note"; import { CreateAttachmentType } from "./attachment.model"; import { NoteController } from "../note.controller"; import { createId } from "@paralleldrive/cuid2"; -import { getSignedUrl, uploadFileAndGetUrl } from "../../../../lib/storage/storage"; +import { + getSignedUrl, + uploadFileAndGetUrl, +} from "../../../../lib/storage/storage"; import { error } from "elysia"; export class AttachmentController { - async createAttachment(new_attachment: CreateAttachmentType, ownerId:string) { + async createAttachment( + new_attachment: CreateAttachmentType, + ownerId: string, + ) { const note = new NoteController(); const existingNote = await note.getNoteById(new_attachment.noteId, ownerId); - if (existingNote.data.length===0){ + if (existingNote.data.length === 0) { throw error(403, { success: false, data: [], @@ -20,24 +26,37 @@ export class AttachmentController { error: "FORBIDDEN", }); } - const filePath = "attachments/"+existingNote.data[0].id+"/file_"+createId()+"-"+new_attachment.file.name; - const attachmentUrl = await uploadFileAndGetUrl(filePath, new_attachment.file) - const new_attachment_data = { ...new_attachment, noteId: new_attachment.noteId, filePath:filePath }; + const filePath = + "attachments/" + + existingNote.data[0].id + + "/file_" + + createId() + + "-" + + new_attachment.file.name; + const attachmentUrl = await uploadFileAndGetUrl( + filePath, + new_attachment.file, + ); + const new_attachment_data = { + ...new_attachment, + noteId: new_attachment.noteId, + filePath: filePath, + }; const result = await db .insert(attachment) .values(new_attachment_data) - .returning({ + .returning({ id: attachment.id, title: attachment.title, noteId: attachment.noteId, - createdAt: attachment.createdAt + createdAt: attachment.createdAt, }) .execute(); const resultWithAttachment = { - attachmentUrl:attachmentUrl, - ...result[0] - } + attachmentUrl: attachmentUrl, + ...result[0], + }; return { success: true, data: [resultWithAttachment], @@ -46,7 +65,12 @@ export class AttachmentController { }; } - async getAttachmentsByNoteId(noteId: string, ownerId: string, limit: number = 10, offset: number = 0) { + async getAttachmentsByNoteId( + noteId: string, + ownerId: string, + limit: number = 10, + offset: number = 0, + ) { const result = await db .select({ id: attachment.id, @@ -57,22 +81,28 @@ export class AttachmentController { }) .from(attachment) .leftJoin(note, eq(note.id, attachment.noteId)) - .where(and(eq(attachment.noteId, noteId), isNull(attachment.deletedAt), eq(note.ownerId, ownerId))) + .where( + and( + eq(attachment.noteId, noteId), + isNull(attachment.deletedAt), + eq(note.ownerId, ownerId), + ), + ) .limit(limit) .offset(offset) .execute(); - + const allAttachments = []; - for (let i = 0; i { console.error(error); return { - message: path+" Error:"+code, + message: path + " Error:" + code, success: false, data: null, error: code.toString(), @@ -34,11 +34,16 @@ export const attachmentRouter = new Elysia({ .get( "", async ({ attachment, user, query }) => { - return await attachment.getAttachmentsByNoteId(query.noteId, user.id, query.limit, query.offset); + return await attachment.getAttachmentsByNoteId( + query.noteId, + user.id, + query.limit, + query.offset, + ); }, { query: t.Object({ - noteId:t.String(), + noteId: t.String(), limit: t.Optional(t.Number()), offset: t.Optional(t.Number()), }), @@ -63,7 +68,7 @@ export const attachmentRouter = new Elysia({ description: "Get a attachment by Id", summary: "Get a attachment", }, - }, + }, ) .post( "", @@ -98,11 +103,14 @@ export const attachmentRouter = new Elysia({ .delete( "", async ({ attachment, user, query }) => { - return await attachment.deleteAllAttachmentsByNoteId(query.noteId, user.id); + return await attachment.deleteAllAttachmentsByNoteId( + query.noteId, + user.id, + ); }, { query: t.Object({ - noteId:t.String(), + noteId: t.String(), }), response: deleteAttachmentResponses, detail: { diff --git a/src/api/routes/note/attachments/attachment.test.ts b/src/api/routes/note/attachments/attachment.test.ts index b4f7704..4d3ba34 100644 --- a/src/api/routes/note/attachments/attachment.test.ts +++ b/src/api/routes/note/attachments/attachment.test.ts @@ -30,11 +30,11 @@ describe("Attachment", () => { file: testFile, noteId: noteId, }); - + if (!data?.data) { throw new Error("create attachment api did not return data"); } - + attachmentId = data.data[0].id; expect(data.data[0].title).toBe("test attachment"); expect(data.data[0].noteId).toBe(noteId); @@ -44,10 +44,10 @@ describe("Attachment", () => { // Get all attachments for a note it("Get All Attachments for a Note", async () => { const { data } = await testClientApp.api.attachment.get({ - query: { + query: { noteId: noteId, - limit: 10, - offset: 0 + limit: 10, + offset: 0, }, }); expect(data?.success).toBe(true); @@ -57,7 +57,9 @@ describe("Attachment", () => { // Get single attachment it("Get Created Attachment", async () => { - const { data } = await testClientApp.api.attachment({ id: attachmentId }).get(); + const { data } = await testClientApp.api + .attachment({ id: attachmentId }) + .get(); expect(data?.success).toBe(true); expect(data?.data[0].id).toBe(attachmentId); expect(data?.data[0].noteId).toBe(noteId); @@ -73,14 +75,16 @@ describe("Attachment", () => { file: testFile, noteId: noteId, }); - + if (!createData?.data) { - throw new Error("CreateData should not be null"); - } + throw new Error("CreateData should not be null"); + } const deleteAttachmentId = createData?.data[0].id; if (!deleteAttachmentId) { - throw new Error("Failed to receive attachmentId in delete attachment test"); + throw new Error( + "Failed to receive attachmentId in delete attachment test", + ); } // Delete the attachment @@ -114,28 +118,31 @@ describe("Attachment", () => { }); // Delete all attachments for the note - const { data: deleteData } = await testClientApp.api.attachment.delete({},{ - query: { noteId: noteId } - }); + const { data: deleteData } = await testClientApp.api.attachment.delete( + {}, + { + query: { noteId: noteId }, + }, + ); expect(deleteData?.success).toBe(true); // Verify all attachments are deleted const { data: verifyData } = await testClientApp.api.attachment.get({ - query: { + query: { noteId: noteId, - limit: 10, - offset: 0 + limit: 10, + offset: 0, }, }); expect(verifyData?.data).toHaveLength(0); }); - - // Error cases it("Should handle invalid attachment ID", async () => { const invalidId = "invalid-id"; - const { data, error } = await testClientApp.api.attachment({ id: invalidId }).get(); + const { data, error } = await testClientApp.api + .attachment({ id: invalidId }) + .get(); expect(data?.data?.length).toBe(0); }); @@ -155,4 +162,4 @@ describe("Attachment", () => { const { data } = await testClientApp.api.note({ id: noteId }).delete(); expect(data?.success).toBe(true); }); -}); \ No newline at end of file +}); diff --git a/src/api/routes/note/note.model.ts b/src/api/routes/note/note.model.ts index 1aea15b..5a5cca9 100644 --- a/src/api/routes/note/note.model.ts +++ b/src/api/routes/note/note.model.ts @@ -13,7 +13,9 @@ export type CreateNoteType = Pick< "title" | "content" >; -export const createNoteSchema =t.Partial(t.Pick(NoteSchema, ["title", "content"])) +export const createNoteSchema = t.Partial( + t.Pick(NoteSchema, ["title", "content"]), +); export const getNoteResponses = { 200: t.Object( diff --git a/src/db/schema/note.ts b/src/db/schema/note.ts index 0e1149b..329d0db 100644 --- a/src/db/schema/note.ts +++ b/src/db/schema/note.ts @@ -25,7 +25,6 @@ export const note = pgTable( ], ); - export const attachment = pgTable( "note_attachments", { diff --git a/test/client.ts b/test/client.ts index e2d6520..bf35c6d 100644 --- a/test/client.ts +++ b/test/client.ts @@ -2,24 +2,76 @@ import { treaty } from "@elysiajs/eden"; import { app } from "../src"; import { getAuthConfig } from "../src/lib/utils/env"; -async function getAuthToken() { +const TEST_USER = { + email: "test@test.com", + password: "testpass123", + name: "Test User", +}; + +async function createUserIfNotExists() { const authUrl = getAuthConfig().BETTER_AUTH_URL; - const response = await fetch(`${authUrl}/api/auth/sign-in/email`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - email: "test@test.com", - password: "testpass123", - }), - }); - const cookies = response.headers.getSetCookie()[0]; - const sessionToken = cookies.split(";")[0].split("=")[1]; - return sessionToken; + + // Try to sign in first + try { + const signInResponse = await fetch(`${authUrl}/api/auth/sign-in/email`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: TEST_USER.email, + password: TEST_USER.password, + }), + }); + + // If sign in successful, return the session token + if (signInResponse.ok) { + const cookies = signInResponse.headers.getSetCookie()[0]; + return cookies.split(";")[0].split("=")[1]; + } + } catch (error) { + console.log("Sign in failed, attempting to create user..."); + } + + // If sign in fails, try to create the user + try { + const signUpResponse = await fetch(`${authUrl}/api/auth/sign-up/email`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(TEST_USER), + }); + + if (!signUpResponse.ok) { + throw new Error(`Failed to create user: ${signUpResponse.statusText}`); + } + + // After creating user, sign in to get the token + const signInResponse = await fetch(`${authUrl}/api/auth/sign-in/email`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: TEST_USER.email, + password: TEST_USER.password, + }), + }); + + if (!signInResponse.ok) { + throw new Error("Failed to sign in after creating user"); + } + + const cookies = signInResponse.headers.getSetCookie()[0]; + return cookies.split(";")[0].split("=")[1]; + } catch (error) { + console.error("Error in user creation/authentication:", error); + throw error; + } } -const token = await getAuthToken(); +const token = await createUserIfNotExists(); export const testClientApp = treaty(app, { headers: { Cookie: `better-auth.session_token=${token}`,