Back

Creating an API with Bun

Creating an API with Bun

Bun is the new JavaScript runtime that competes with Node and Deno, offering greater speed plus several other features. This article will show how to use Bun to create a full API.

Bun.js is a new JavaScript runtime environment similar to Node.js and Deno but faster and more self-contained. It is written in Zig, a fast low-level language, and utilizes the JavaScriptCore Engine, which powers Webkit browsers like Safari. Combining Zig and the JavaScriptCore engine has made Bun one of the fastest JavaScript runtimes.

Additionally, Bun is not limited to being just a runtime. It serves as a package manager, a test runner, and a bundler. In this tutorial, you will learn how to create a simple recipe-sharing API with Bun using Elysia, PostgreSQL, and Prisma.

Setting up your Development Environment

To use Bun, you have to install it on your system. Run the command below to install Bun on macOS, Linux, or Windows Subsystem for Linux (WSL).

curl -fsSL https://bun.sh/install | bash

Currently, Bun only has an experimental build for Windows that only supports the runtime.

After installing Bun, run the command below to create and cd into your project directory:

mkdir recipe-sharing-api && cd recipe-sharing-api

Next, run the command below to initialize a new Bun app:

bun init

The command above will prompt you to enter your application’s package name and entry point. You can press the ENTER key to choose the defaults, as shown in the image below.

Initialize new Bun Application

Your current directory should look like the image below.

New Bun app project structure

Next, install the required dependencies by running the command below:

bun add elysia @elysiajs/cookie prisma @prisma/client dotenv pg jsonwebtoken@8.5.1

Install their corresponding types by running:

bun add -d @types/jsonwebtoken

The dependencies you installed are:

  • Elysia: Elysia is a web framework for Bun that simplifies working with Bun, similar to what Express does for Node.js.
  • Prisma: Prisma is an object-relational mapper (ORM) for JavaScript and TypeScript.
  • Dotenv: Dotenv is an NPM package that loads environmental variables from a .env file into process.env.
  • PG: PG is the native driver for PostgreSQL.
  • jsonwebtoken@8.5.1: A package that implements the JWT standard (version 8.5.1).

Setting up your Database

The Recipe Sharing API will involve three tables: Recipes, Users, and Comments. Users can create and share recipes, view recipes from others, and comment on recipes.

Run the command below to initialize Prisma with PostgreSQL in your application:

bunx prisma init --datasource-provider postgresql

The command above creates a .env file and a Prisma folder. You will find a schema.prisma file in the Prisma folder. This file contains the configuration for your database connection.

Next, open your .env file and replace the dummy database connection URI with your database’s connection URI.

Creating Prisma Models

Prisma models represent tables in your database. Each model in the Prisma schema corresponds to a table in the database, defining its structure.

Open your schema.prisma file and add the code block below to create the User model.

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  password  String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
 
  recipies  Recipe[]
  
  comments  Comment[]
}

The code block above represents your User model. It contains all the user information necessary for your application, such as the email, name, password, recipes, and comments.

When a new user signs up, you will create a new instance of the User model, and when they try to log in, you will fetch the instance and compare the stored information with the information sent in the login request.

Next, add the code block below to your schema.prisma file to create the Recipe model:

model Recipe {
  id        Int      @id @default(autoincrement())
  title     String
  body      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  user      User     @relation(fields: [userId], references: [id])
  userId    Int

  comments  Comment[]
}

The code block above represents your Recipe model. It contains all the recipe information necessary for your application, such as the title, body, and information about the user who created the recipe.

When a user creates a recipe, you create a new instance of the Recipe model.

Then, add the code block below to your schema.prisma file to create the Comment model:

model Comment {
  id        Int      @id @default(autoincrement())
  body      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  
  user      User     @relation(fields: [userId], references: [id])
  userId    Int
  
  recipe   Recipe  @relation(fields: [recipeId], references: [id])
  recipeId Int
}

The code block above represents your Comment model. It contains all the comment information necessary for your application, including the body, date, the user that created the comment, and the recipe the comment is for.

When a user comments on a recipe, you create a new instance of the Comment model.

Finally, run the command below to generate and run a migration:

bunx prisma migrate dev --name init

You can replace init with any name of your choice.

Running the command above will ensure that your Prisma schema is in sync with your database schema.

Creating Services and Controllers

Creating services and controllers in your project helps organize the code and make it more maintainable.

Create a controllers and a services folder in your project’s root directory by running the command below:

mkdir controllers && mkdir services

Next, create the following files in your services folder:

  • user.service.ts: This file contains all the logic related to signing up and signing in as a user.
  • recipe.service.ts: This file contains all the logic for creating and viewing recipes.
  • comment.service.ts: This file contains all the logic for commenting on recipes.
  • auth.service.ts: This file contains the logic for authenticating a user.

Then, create the following files in your controllers folder:

  • comments.controller.ts: This file contains all the controller logic for comments.
  • recipe.controller.ts: This file contains all the recipe controller logic.
  • user.controller.ts: This file contains all the controller logic for user authentication.

Implementing Service Logic

Services are distinct units of functionality or logic designed to perform specific tasks.

To implement this, open your auth.service.ts file and add the code block below.

//auth.service.ts
import jwt from "jsonwebtoken";

export const verifyToken = (token: string) => {
  let payload: any;

  //Verify the JWT token
  jwt.verify(token, process.env.JWT_SECRET as string, (error, decoded) => {
    if (error) {
      throw new Error("Invalid token");
    }

    payload = decoded;
  });

  return payload;
};

export const signUserToken = (data: { id: number; email: string }) => {
  //Sign the JWT token
  const token = jwt.sign(
    {
      id: data.id,
      email: data.email,
    },
    process.env.JWT_SECRET as string,
    { expiresIn: "1d" }
  );

  return token;
};

The code block above exports two functions: verifyToken and signUserToken. The verifyToken function takes a user’s access token and checks its validity. If it is valid, it decodes the token and returns the information contained in the token else, it throws an error.

The signUserToken function takes a user’s data as a payload, creates, and returns a JWT, valid for one day.

Next, open your user.service.ts file and add the code block below to it:

//user.service.ts
import { prisma } from "../index";
import { signUserToken } from "./auth.service";

export const createNewUser = async (data: {
  name: string;
  email: string;
  password: string;
}) => {
  try {
    const { name, email, password } = data;

    //Hash the password using the Bun package and bcrypt algorithm
    const hashedPassword = await Bun.password.hash(password, {
      algorithm: "bcrypt",
    });

    //Create the user
    const user = await prisma.user.create({
      data: {
        name,
        email,
        password: hashedPassword,
      },
    });

    return user;
  } catch (error) {
    throw error;
  }
};

export const login = async (data: { email: string; password: string }) => {
  try {
    const { email, password } = data;

    //Find the user
    const user = await prisma.user.findUnique({
      where: {
        email,
      },
    });

    if (!user) {
      throw new Error("User not found");
    }

    //Verify the password
    const valid = await Bun.password.verify(password, user.password);

    if (!valid) {
      throw new Error("Invalid credentials");
    }

    //Sign the JWT token
    const token = signUserToken({
      id: user.id,
      email: user.email,
    });

    return {
      message: "User logged in successfully",
      token,
    };
  } catch (error) {
    throw error;
  }
};

The code block above exports two functions, createNewUser and login. The createNewUser function accepts a user’s name, email, and password. It hashes the password using Bun’s built-in password module and creates a new user with the provided information.

The login function takes a user’s credentials and validates them against the records stored in the database. If they match, it creates an access token for the user; otherwise, it throws an error.

Next, open your recipe.service.ts file and add the code block below.

//recipe.service.ts
import { prisma } from "../index";

export const createRecipe = async (data: {
  title: string;
  body: string;
  userId: number;
}) => {
  const { title, body, userId } = data;

  //Create the recipe
  const recipe = await prisma.recipe.create({
    data: {
      title,
      body,
      userId,
    },
  });

  return recipe;
};

export const getAllRecipes = async () => {
  //Get all recipes
  const recipes = await prisma.recipe.findMany({
    include: {
      user: true,
      comments: true,
    },
  });

  return recipes;
};

export const getRecipeById = async (id: number) => {
  //Get recipe by id and include the user
  const recipe = await prisma.recipe.findUnique({
    where: {
      id,
    },
    include: {
      user: true,
    },
  });

  return recipe;
};

The code block above exports three functions: createRecipe, getAllRecipies, and getRecipeById.

The createRecipe function creates a new recipe from the data passed as an argument and returns it. The getAllRecipies function retrieves and returns all the recipes in the database. The getRecipeById function gets a recipe based on the id passed as an argument and returns it.

Next, open your comments.service.ts file and add the code block below.

//comments.service.ts
import { prisma } from "../index";

export const createComment = async (data: {
  body: string;
  recipeId: number;
  userId: number;
}) => {
  try {
    const { body, recipeId, userId } = data;

    //Create the comment for the recipe with the given id
    const comment = await prisma.comment.create({
      data: {
        body,
        userId,
        recipeId: recipeId,
      },
    });

    return comment;
  } catch (error: any) {
    throw error;
  }
};

export const getAllCommentsForRecipe = async (recipeId: number) => {
  //Get all comments for the recipe with the given id
  const comments = await prisma.comment.findMany({
    where: {
      recipeId,
    },
    include: {
      user: true,
    },
  });

  return comments;
};

The code block above exports two functions, createComment and getAllCommentsForRecipe. createComment creates a new comment for a particular recipe, while getAllCommentsForRecipe returns all the comments for a particular recipe.

Implementing Controller Logic

Unlike Express.js, which uses a request and response object to handle requests, Elysia uses a context object.

The context object provides methods similar to Express’s request and response objects. Additionally, Elysia automatically maps the return value of your controller function into a response and returns it to your client.

To implement your controller logic, open your user.controller.ts file and add the code block below.

//user.controller.ts
import Elysia from "elysia";
import { createNewUser, login } from "../services/user.service";

export const userController = (app: Elysia) => {
  app.post("/signup", async (context) => {
    try {
      const userData: any = context.body;

      const newUser = await createNewUser({
        name: userData.name,
        email: userData.email,
        password: userData.password,
      });

      return {
        user: newUser,
      };
    } catch (error: any) {
      return {
        error: error.message,
      };
    }
  });

  app.post("/login", async (context) => {
    try {
      const userData: any = context.body;

      const loggedInUser = await login({
        email: userData.email,
        password: userData.password,
      });

      return loggedInUser;
    } catch (error: any) {
      console.log(error);
      return {
        error: error.message,
      };
    }
  });
};

The code block above implements the controller logic for /signup and /login.

When a user makes a POST request to /signup, your controller will extract the request body from the context object (context.body) and pass it to the createNewUser function you created in your users.service.ts file.

When a user makes a POST request to /login, your controller will extract the request body from the context body and pass the email and password to the login function. If the user details are correct, the controller returns a success message and the access token.

Next, open your recipe.controller.ts file and add the code block below.

//recipe.controller.ts
import Elysia from "elysia";
import { createRecipe, getAllRecipes } from "../services/recipe.service";
import { verifyToken } from "../services/auth.service";

export const recipeController = (app: Elysia) => {
  app.post("/create-recipe", async (context) => {
    try {
      const authHeader = context.headers["authorization"];
      const token = authHeader && authHeader.split(" ")[1];

      if (!token) {
        throw new Error("Invalid token");
      }

      const verifiedToken = verifyToken(token as string);

      const recipeData: any = context.body;

      const newRecipe = await createRecipe({
        title: recipeData.title,
        body: recipeData.body,
        userId: verifiedToken?.id,
      });

      return {
        recipe: newRecipe,
      };
    } catch (error: any) {
      return {
        error: error.message,
      };
    }
  });

  app.get("/recipes", async () => {
    try {
      const recipes = await getAllRecipes();

      return recipes;
    } catch (error: any) {
      return {
        error: error.message,
      };
    }
  });
};

The code block above implements the controller logic for /create-recipe and /recipes.

When a user makes a POST request to /create-recipe, your controller will check if the user has a valid access token (check if the user is logged in). If the user doesn’t have an access token or if the token is invalid, your controller will throw an error.

If the token is valid, your controller will extract the recipe details from the context object and pass it to your createRecipe function.

When a user makes a GET request to /recipes, your controller calls your getAllRecipes function and returns all the recipes.

Next, open your comments.controller.ts and add the code block below.

//comments.controller.ts
import Elysia from "elysia";
import {
  createComment,
  getAllCommentsForRecipe,
} from "../services/comments.service";
import { verifyToken } from "../services/auth.service";

export const commentController = (app: Elysia) => {
  app.post("/:recipeId/create-comment", async (context) => {
    try {
      const authHeader = context.headers["authorization"];
      const token = authHeader && authHeader.split(" ")[1];
      const recipeId = context.params.recipeId;

      if (!token) {
        throw new Error("Invalid token");
      }

      const verifiedToken = verifyToken(token as string);

      const commentData: any = context.body;

      const newComment = await createComment({
        body: commentData.body,
        recipeId: +recipeId,
        userId: verifiedToken?.id,
      });

      return newComment;
    } catch (error: any) {
      return {
        error: error.message,
      };
    }
  });

  app.get("/:recipeId/comments", async (context) => {
    try {
      const recipeId = context.params.recipeId;

      const comments = await getAllCommentsForRecipe(+recipeId);

      return {
        comments,
      };
    } catch (error: any) {
      return {
        error: error.message,
      };
    }
  });
};

The code block above implements the controller logic for /:recipeId/create-comment and /:recipeId/comments.

When a user makes a POST request to /:recipeId/create-comment, your controller checks if the user is logged in, if they are, it extracts the comment details from the context object and passes it to your createComment function.

When a user makes a GET request to /:recipeId/comments, your controller extracts the recipeId from the context object (context.params.recipeId) and passes it as an argument to your getAllCommentsForRecipe after converting it to a number using explicit type coercion.

Setting up your Bun-Elysia Server

After creating your services and controllers, you must set up a server to handle the incoming requests.

To create a Bun-Elysia server, open your index.ts file and add the code block below.

//index.ts
import Elysia from "elysia";
import { recipeController } from "./controllers/recipe.controller";
import { PrismaClient } from "@prisma/client";
import { userController } from "./controllers/user.controller";
import { commentController } from "./controllers/comments.controller";

//Create instances of prisma and Elysia
const prisma = new PrismaClient();
const app = new Elysia();

//Use controllers as middleware
app.use(userController as any);
app.use(recipeController as any);
app.use(commentController as any);

//Listen for traffic
app.listen(4040, () => {
  console.log("Server is running on port 4040");
});

export { app, prisma };

The code block above imports all your controllers, the elysia framework, and PrismaClient. It also creates instances of Prisma and Elysia and registers your controllers as middleware to route all incoming requests to the right handlers.

Then, it listens for incoming traffic on port 4040 and exports the Elysia and Prisma instances to the rest of your application.

Finally, run the command below to start up your application:

bun --watch index.ts

The command above starts up your Bun application in watch mode.

Conclusion

In this article, you explored how to build a simple API with Bun, Elysia, Prisma, and Postgres. You’ve learned to install and configure Bun, structure a database, and implement modular services and controllers for efficient code management. You can test the API you built using any API testing tool like Postman or Insomnia.

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay