docs, formatting, tests

This commit is contained in:
Sanjib Sen 2025-01-21 12:18:03 +06:00
parent 1da74a833a
commit 42bff39895
12 changed files with 386 additions and 139 deletions

183
README.md
View file

@ -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 ```bash
bun create elysia ./elysia-example git clone <your-repository-url>
cd microservice-start
``` ```
## Development 2. Set up environment files:
To start the development server run:
```bash ```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=<your-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]

View file

@ -140,12 +140,8 @@
"tableFrom": "account", "tableFrom": "account",
"tableTo": "user", "tableTo": "user",
"schemaTo": "auth", "schemaTo": "auth",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -337,12 +333,8 @@
"tableFrom": "session", "tableFrom": "session",
"tableTo": "user", "tableTo": "user",
"schemaTo": "auth", "schemaTo": "auth",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -352,9 +344,7 @@
"session_token_unique": { "session_token_unique": {
"name": "session_token_unique", "name": "session_token_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["token"]
"token"
]
} }
}, },
"policies": {}, "policies": {},
@ -415,9 +405,7 @@
"user_email_unique": { "user_email_unique": {
"name": "user_email_unique", "name": "user_email_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["email"]
"email"
]
} }
}, },
"policies": {}, "policies": {},
@ -584,12 +572,8 @@
"name": "note_attachments_note_id_note_id_fk", "name": "note_attachments_note_id_note_id_fk",
"tableFrom": "note_attachments", "tableFrom": "note_attachments",
"tableTo": "note", "tableTo": "note",
"columnsFrom": [ "columnsFrom": ["note_id"],
"note_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -701,12 +685,8 @@
"tableFrom": "note", "tableFrom": "note",
"tableTo": "user", "tableTo": "user",
"schemaTo": "auth", "schemaTo": "auth",
"columnsFrom": [ "columnsFrom": ["owner_id"],
"owner_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
} }

View file

@ -2,18 +2,20 @@
"name": "app", "name": "app",
"version": "1.0.50", "version": "1.0.50",
"scripts": { "scripts": {
"test": "bun test api", "test": "bun run format && bun test api",
"format": "prettier . --write", "format": "prettier . --write",
"dev": "bun run --watch src/index.ts", "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", "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:studio": "drizzle-kit studio",
"db:check": "drizzle-kit check", "db:check": "drizzle-kit check",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:pull": "drizzle-kit pull", "db:pull": "drizzle-kit pull",
"db:push": "drizzle-kit push", "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": { "dependencies": {
"@elysiajs/cors": "^1.2.0", "@elysiajs/cors": "^1.2.0",

View file

@ -4,15 +4,21 @@ import { attachment, note } from "../../../../db/schema/note";
import { CreateAttachmentType } from "./attachment.model"; import { CreateAttachmentType } from "./attachment.model";
import { NoteController } from "../note.controller"; import { NoteController } from "../note.controller";
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
import { getSignedUrl, uploadFileAndGetUrl } from "../../../../lib/storage/storage"; import {
getSignedUrl,
uploadFileAndGetUrl,
} from "../../../../lib/storage/storage";
import { error } from "elysia"; import { error } from "elysia";
export class AttachmentController { export class AttachmentController {
async createAttachment(new_attachment: CreateAttachmentType, ownerId:string) { async createAttachment(
new_attachment: CreateAttachmentType,
ownerId: string,
) {
const note = new NoteController(); const note = new NoteController();
const existingNote = await note.getNoteById(new_attachment.noteId, ownerId); const existingNote = await note.getNoteById(new_attachment.noteId, ownerId);
if (existingNote.data.length===0){ if (existingNote.data.length === 0) {
throw error(403, { throw error(403, {
success: false, success: false,
data: [], data: [],
@ -20,9 +26,22 @@ export class AttachmentController {
error: "FORBIDDEN", error: "FORBIDDEN",
}); });
} }
const filePath = "attachments/"+existingNote.data[0].id+"/file_"+createId()+"-"+new_attachment.file.name; const filePath =
const attachmentUrl = await uploadFileAndGetUrl(filePath, new_attachment.file) "attachments/" +
const new_attachment_data = { ...new_attachment, noteId: new_attachment.noteId, filePath:filePath }; 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 const result = await db
.insert(attachment) .insert(attachment)
.values(new_attachment_data) .values(new_attachment_data)
@ -30,14 +49,14 @@ export class AttachmentController {
id: attachment.id, id: attachment.id,
title: attachment.title, title: attachment.title,
noteId: attachment.noteId, noteId: attachment.noteId,
createdAt: attachment.createdAt createdAt: attachment.createdAt,
}) })
.execute(); .execute();
const resultWithAttachment = { const resultWithAttachment = {
attachmentUrl:attachmentUrl, attachmentUrl: attachmentUrl,
...result[0] ...result[0],
} };
return { return {
success: true, success: true,
data: [resultWithAttachment], 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 const result = await db
.select({ .select({
id: attachment.id, id: attachment.id,
@ -57,22 +81,28 @@ export class AttachmentController {
}) })
.from(attachment) .from(attachment)
.leftJoin(note, eq(note.id, attachment.noteId)) .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) .limit(limit)
.offset(offset) .offset(offset)
.execute(); .execute();
const allAttachments = []; const allAttachments = [];
for (let i = 0; i<result.length; i++){ for (let i = 0; i < result.length; i++) {
if (!result[0].filePath){ if (!result[0].filePath) {
continue; continue;
} }
const attachmentUrl = await getSignedUrl(result[i].filePath as string) const attachmentUrl = await getSignedUrl(result[i].filePath as string);
const resultWithAttachment = { const resultWithAttachment = {
attachmentUrl:attachmentUrl, attachmentUrl: attachmentUrl,
...result[i] ...result[i],
} };
allAttachments.push(resultWithAttachment) allAttachments.push(resultWithAttachment);
} }
return { return {
success: true, success: true,
@ -109,11 +139,11 @@ export class AttachmentController {
error: null, error: null,
}; };
} }
const attachmentUrl = await getSignedUrl(result[0].filePath) const attachmentUrl = await getSignedUrl(result[0].filePath);
const resultWithAttachment = { const resultWithAttachment = {
attachmentUrl:attachmentUrl, attachmentUrl: attachmentUrl,
...result[0] ...result[0],
} };
return { return {
success: true, success: true,
data: [resultWithAttachment], data: [resultWithAttachment],
@ -123,19 +153,17 @@ export class AttachmentController {
} }
async deleteAttachmentById(attachmentId: string, ownerId: string) { async deleteAttachmentById(attachmentId: string, ownerId: string) {
const existingAttachment = await this.getAttachmentById(attachmentId, ownerId) const existingAttachment = await this.getAttachmentById(
if (existingAttachment.data.length===0){ attachmentId,
ownerId,
);
if (existingAttachment.data.length === 0) {
throw error(403); throw error(403);
} }
await db await db
.update(attachment) .update(attachment)
.set({ deletedAt: new Date() }) .set({ deletedAt: new Date() })
.where( .where(and(eq(attachment.id, attachmentId), isNull(attachment.deletedAt)))
and(
eq(attachment.id, attachmentId),
isNull(attachment.deletedAt),
),
)
.execute(); .execute();
return { return {
success: true, success: true,
@ -146,11 +174,10 @@ export class AttachmentController {
} }
async deleteAllAttachmentsByNoteId(noteId: string, ownerId: string) { async deleteAllAttachmentsByNoteId(noteId: string, ownerId: string) {
const note = new NoteController();
const note = new NoteController()
const existingNote = await note.getNoteById(noteId, ownerId); const existingNote = await note.getNoteById(noteId, ownerId);
if (existingNote.data.length===0){ if (existingNote.data.length === 0) {
throw error(403); throw error(403);
} }

View file

@ -5,21 +5,26 @@ import { commonResponses } from "../../../../lib/utils/common";
export const _AttachmentSchema = createSelectSchema(attachment); export const _AttachmentSchema = createSelectSchema(attachment);
export const AttachmentSchema = t.Omit(_AttachmentSchema, ["deletedAt", "filePath"]); export const AttachmentSchema = t.Omit(_AttachmentSchema, [
"deletedAt",
"filePath",
]);
export const AttachmentWithUrlSchema = t.Composite([ AttachmentSchema,t.Object({ export const AttachmentWithUrlSchema = t.Composite([
attachmentUrl: t.String({default:"http://example.com/attachment_abcd"}), AttachmentSchema,
})]) t.Object({
attachmentUrl: t.String({ default: "http://example.com/attachment_abcd" }),
}),
]);
export const createAttachmentSchema = t.Object({ export const createAttachmentSchema = t.Object({
title: t.Optional(t.String()), title: t.Optional(t.String()),
noteId: t.String(), noteId: t.String(),
file: t.File(), file: t.File(),
}) });
export type CreateAttachmentType = typeof createAttachmentSchema.static; export type CreateAttachmentType = typeof createAttachmentSchema.static;
export const getAttachmentResponses = { export const getAttachmentResponses = {
200: t.Object( 200: t.Object(
{ {

View file

@ -25,7 +25,7 @@ export const attachmentRouter = new Elysia({
.onError(({ path, error, code }) => { .onError(({ path, error, code }) => {
console.error(error); console.error(error);
return { return {
message: path+" Error:"+code, message: path + " Error:" + code,
success: false, success: false,
data: null, data: null,
error: code.toString(), error: code.toString(),
@ -34,11 +34,16 @@ export const attachmentRouter = new Elysia({
.get( .get(
"", "",
async ({ attachment, user, query }) => { 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({ query: t.Object({
noteId:t.String(), noteId: t.String(),
limit: t.Optional(t.Number()), limit: t.Optional(t.Number()),
offset: t.Optional(t.Number()), offset: t.Optional(t.Number()),
}), }),
@ -98,11 +103,14 @@ export const attachmentRouter = new Elysia({
.delete( .delete(
"", "",
async ({ attachment, user, query }) => { async ({ attachment, user, query }) => {
return await attachment.deleteAllAttachmentsByNoteId(query.noteId, user.id); return await attachment.deleteAllAttachmentsByNoteId(
query.noteId,
user.id,
);
}, },
{ {
query: t.Object({ query: t.Object({
noteId:t.String(), noteId: t.String(),
}), }),
response: deleteAttachmentResponses, response: deleteAttachmentResponses,
detail: { detail: {

View file

@ -47,7 +47,7 @@ describe("Attachment", () => {
query: { query: {
noteId: noteId, noteId: noteId,
limit: 10, limit: 10,
offset: 0 offset: 0,
}, },
}); });
expect(data?.success).toBe(true); expect(data?.success).toBe(true);
@ -57,7 +57,9 @@ describe("Attachment", () => {
// Get single attachment // Get single attachment
it("Get Created Attachment", async () => { 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?.success).toBe(true);
expect(data?.data[0].id).toBe(attachmentId); expect(data?.data[0].id).toBe(attachmentId);
expect(data?.data[0].noteId).toBe(noteId); expect(data?.data[0].noteId).toBe(noteId);
@ -80,7 +82,9 @@ describe("Attachment", () => {
const deleteAttachmentId = createData?.data[0].id; const deleteAttachmentId = createData?.data[0].id;
if (!deleteAttachmentId) { 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 // Delete the attachment
@ -114,9 +118,12 @@ describe("Attachment", () => {
}); });
// Delete all attachments for the note // Delete all attachments for the note
const { data: deleteData } = await testClientApp.api.attachment.delete({},{ const { data: deleteData } = await testClientApp.api.attachment.delete(
query: { noteId: noteId } {},
}); {
query: { noteId: noteId },
},
);
expect(deleteData?.success).toBe(true); expect(deleteData?.success).toBe(true);
// Verify all attachments are deleted // Verify all attachments are deleted
@ -124,18 +131,18 @@ describe("Attachment", () => {
query: { query: {
noteId: noteId, noteId: noteId,
limit: 10, limit: 10,
offset: 0 offset: 0,
}, },
}); });
expect(verifyData?.data).toHaveLength(0); expect(verifyData?.data).toHaveLength(0);
}); });
// Error cases // Error cases
it("Should handle invalid attachment ID", async () => { it("Should handle invalid attachment ID", async () => {
const invalidId = "invalid-id"; 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); expect(data?.data?.length).toBe(0);
}); });

View file

@ -13,7 +13,9 @@ export type CreateNoteType = Pick<
"title" | "content" "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 = { export const getNoteResponses = {
200: t.Object( 200: t.Object(

View file

@ -25,7 +25,6 @@ export const note = pgTable(
], ],
); );
export const attachment = pgTable( export const attachment = pgTable(
"note_attachments", "note_attachments",
{ {

View file

@ -2,24 +2,76 @@ import { treaty } from "@elysiajs/eden";
import { app } from "../src"; import { app } from "../src";
import { getAuthConfig } from "../src/lib/utils/env"; 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 authUrl = getAuthConfig().BETTER_AUTH_URL;
const response = await fetch(`${authUrl}/api/auth/sign-in/email`, {
// Try to sign in first
try {
const signInResponse = await fetch(`${authUrl}/api/auth/sign-in/email`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
email: "test@test.com", email: TEST_USER.email,
password: "testpass123", password: TEST_USER.password,
}), }),
}); });
const cookies = response.headers.getSetCookie()[0];
const sessionToken = cookies.split(";")[0].split("=")[1]; // If sign in successful, return the session token
return sessionToken; 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, { export const testClientApp = treaty(app, {
headers: { headers: {
Cookie: `better-auth.session_token=${token}`, Cookie: `better-auth.session_token=${token}`,