DEV Community

Cover image for Warlock.js From Nodejs Course Into a fine grained framework
Hasan Zohdy
Hasan Zohdy

Posted on

Warlock.js From Nodejs Course Into a fine grained framework

Introduction

It all started in the beginnings of 2023, where I started a new course here on dev Titled with: Nodejs Course 2023 that we start building the entire project from scratch.

Every single file there were written during the articles writing time, after achieving a very good progress, "that would be enough" I said to myself for the course.

But it didn't stop there, I started working on imporving the source code by using it in a real project, which was actually my own project mentoor.io that's were things were taken to the next level.

After spending hundrueds of hours on developing the code, It has became more sable, rich with features and blazingly fast (thanks to Fastify) for handling http requests, at that point I decided to move on from being just a good project core to use to be a fully functional framework.

Warlock.js Framework πŸ§™β€β™‚οΈ

Warlock.js is a nodejs framework that's built on top of Fastify, it's designed to be a very simple, fast and easy to use framework, It's main use is for building from small to large API applications.

Features 🌟

  • Blazing fast performance πŸš€
  • Hot Module Reload (MHR) for an incredibly fast development experience πŸ”„
  • Module-Based Architecture πŸ“‚
  • Entry Point (main.ts) for each module πŸ—‚οΈ
  • Event-Driven Architecture πŸ“…
  • Auto Import for Events, Routes, Localization, and Configurations βš™οΈ
  • Comprehensive request validation βœ”οΈ
  • Mail Support with React Components πŸ“§
  • +6 Cache Drivers, including Redis πŸ—„οΈ
  • Grouped Routes by prefix or middleware πŸ›€οΈ
  • Internationalization Support (i18n) 🌍
  • File Uploads with image compression and resizing πŸ“Έ
  • AWS Upload Support ☁️
  • Postman Generator for API Documentation πŸ“œ
  • Repositories using the Repository Design Pattern for managing database models πŸ—ƒοΈ
  • RESTful API support with a single base class for each module 🌐
  • User Management and Auth using JWT πŸ”
  • Model Data Mapping when sending responses using Outputs πŸ—ΊοΈ
  • Full Support for MongoDB backed by Cascade
  • VS Code Extension for generating modules, request handlers, response outputs, database models, RESTful classes, and repositories πŸ› οΈ
  • Unit Testing Support for testing application API endpoints and modules πŸ§ͺ
  • Auto Generate incremental id for each model πŸ†”

And much more, you can check the full documentation here πŸ“š.

Getting Started πŸŽ‰

To start a new project, just run the following command:

npx create-warlock

Then follow the instructions, and you will have a new project ready to go.

Project Structure πŸ—

Now let's have a quick look at the project structure:

β”œβ”€β”€ src
β”‚   β”œβ”€β”€ app
β”‚   β”‚   β”œβ”€β”€ home
β”‚   β”‚   β”‚   β”œβ”€β”€ controllers
β”‚   β”‚   β”‚   β”œβ”€β”€ routes.ts
β”‚   β”‚   β”œβ”€β”€ uploads
β”‚   β”‚   β”‚   β”œβ”€β”€ routes.ts
β”‚   β”‚   β”œβ”€β”€ users
β”‚   β”‚   β”‚   β”œβ”€β”€ controllers
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ auth
β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ social
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ profile
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ restful-users.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ events
β”‚   β”‚   β”‚   β”œβ”€β”€ mail
β”‚   β”‚   β”‚   β”œβ”€β”€ models
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ user
β”‚   β”‚   β”‚   β”œβ”€β”€ output
β”‚   β”‚   β”‚   β”œβ”€β”€ repositories
β”‚   β”‚   β”‚   β”œβ”€β”€ utils
β”‚   β”‚   β”‚   β”œβ”€β”€ validation
β”‚   β”‚   β”‚   β”œβ”€β”€ routes.ts
β”‚   β”‚   β”œβ”€β”€ utils
β”‚   β”‚   β”œβ”€β”€ main.ts
β”‚   β”œβ”€β”€ config
β”‚   β”‚   β”œβ”€β”€ app.ts
β”‚   β”‚   β”œβ”€β”€ auth.ts
β”‚   β”‚   β”œβ”€β”€ cache.ts
β”‚   β”‚   β”œβ”€β”€ cors.ts
β”‚   β”‚   β”œβ”€β”€ database.ts
β”‚   β”‚   β”œβ”€β”€ http.ts
β”‚   β”‚   β”œβ”€β”€ index.ts
β”‚   β”‚   β”œβ”€β”€ mail.ts
β”‚   β”‚   β”œβ”€β”€ upload.ts
β”‚   β”‚   β”œβ”€β”€ validation.ts
β”‚   β”œβ”€β”€ main.ts
β”œβ”€β”€ storage
β”œβ”€β”€ .env
β”œβ”€β”€ warlock.config.ts
Enter fullscreen mode Exit fullscreen mode

Auto Import Routes πŸš—

So basically, the app is divided into modules, each module has its own folder, and inside it, there are controllers, routes, events, mail, models, output, repositories, utils, and validation.

The routes.ts file is a special file for each module, it is auto imported and called directly, so all you need is to define your routes there.

Example

Let's see an example of a simple route:

// src/app/categories/routes.ts
import { router } from "@warlock.js/core";
import { getCategories } from "./controllers/get-categories";

router.get("/categories", getCategories);
Enter fullscreen mode Exit fullscreen mode

Here we defined a simple route that listens to GET /categories and calls the getCategories controller.

Let's create our request handler:

// src/app/categories/controllers/get-categories.ts
import {
  type RequestHandler,
  type Request,
  type Response,
} from "@warlock.js/core";
import { categoriesRepository } from "./../repositories/categories-repository";

export const getCategories: RequestHandler = async (
  request: Request,
  response: Response
) => {
  const { documents: categories, paginationInfo } =
    await categoriesRepository.listActive(request.all());

  return response.send({
    categories,
    paginationInfo,
  });
};
Enter fullscreen mode Exit fullscreen mode

So basically we called the repository to list our active categories where isActive is set to true

You can change it in the repository settings to whatever field/value you use in database.

Request Validation

Validation is as much as simple as it should be, you can define your validation schema in the tail of the request handler

// src/app/categories/controllers/add-category.ts

import {
  type RequestHandler,
  type Request,
  type Response,
  ValidationSchema,
} from "@warlock.js/core";

import { categoriesRepository } from "./../repositories/categories-repository";

export const addCategory: RequestHandler = async (
  request: Request,
  response: Response
) => {
  // using request.validated will return the validated data only
  const { name, description } = request.validated();

  const category = await categoriesRepository.create({
    name,
    description,
  });

  return response.send(category);
};

addCategory.validation = {
  rules: new ValidationSchema({
    name: ["required", "string", "minLength:6"],
    description: ["required", "string"],
  }),
};
Enter fullscreen mode Exit fullscreen mode

If the validation fails on any rule, it won't reach the controller, and it will return a validation error response.

A look into the database model

So database models are easy to work with, just define the model, cast the data as it should be there and that's it!

// src/app/categories/models/category.ts
import { type Casts, Model } from "@warlock.js/cascade";

export class Category extends Model {
  public static collection = "categories";

  protected casts: Casts = {
    name: "string",
    description: "string",
    isActive: "boolean",
  };
}
Enter fullscreen mode Exit fullscreen mode

You don't need to define id as the model will auto generate one for each new saved document.

Data Syncing

One of the powered features of Cascade Is Syncing Models, what does that mean?

Consider we have an author for a post, that author's data is changed at some point, in that case we need to update the post's author data as well, and that's what syncing models do.

// src/app/posts/models/post.ts
import { type Casts, Model } from "@warlock.js/cascade";
import { User } from "app/users/models/user";

export class Post extends Model {
  public static collection = "posts";

  protected casts: Casts = {
    title: "string",
    content: "string",
    author: User,
  };
}
Enter fullscreen mode Exit fullscreen mode

Now let's define our User model, that's where the magic happens:

// src/app/users/models/user.ts
import { type Casts, Model } from "@warlock.js/cascade";
import { Post } from "app/posts/models/post";

export class User extends Model {
  public static collection = "users";

  /**
   * Sync the list of the given model when the user data is changed
   */
  public syncWith = [Post.sync("auth")];

  protected casts: Casts = {
    name: "string",
    email: "string",
  };
}
Enter fullscreen mode Exit fullscreen mode

So basically, here we are telling the model, when the user's info is updated, find all posts for that author (user) and update the author field with the new data.

We can also, conditionally tell the model when to sync the data, for example, we can sync the data only when the user's name is changed:

// src/app/users/models/user.ts
import { type Casts, Model } from "@warlock.js/cascade";
import { Post } from "app/posts/models/post";

export class User extends Model {
  public static collection = "users";

  /**
   * Sync the list of the given model when the user data is changed
   */
  public syncWith = [Post.sync("auth").updateWhenChange(["name"])];

  protected casts: Casts = {
    name: "string",
    email: "string",
  };
}
Enter fullscreen mode Exit fullscreen mode

Using updateWhenChange will only update the post's author when the user's name is changed.

Models Relationships

Now let's go to the relationships, assume we are going to fetch the post's author when we fetch the post, that's where the relationships come in.

// src/app/posts/models/post.ts

import { type Casts, Model } from "@warlock.js/cascade";
import { User } from "app/users/models/user";

export class Post extends Model {
  public static collection = "posts";

  public static relations = {
    author: User.joinable("author.id").single(),
  };

  protected casts: Casts = {
    title: "string",
    content: "string",
  };
}
Enter fullscreen mode Exit fullscreen mode

We told the model it is a single relation, and it's joinable by the author.id field.

Now when we fetch the post, the author's data will be fetched as well when calling with method.

const post = await Post.aggregate().where("id", 1).with("author").first();

const authorName = post.get("author.name");
Enter fullscreen mode Exit fullscreen mode

We may also make the relation as a list, for example, if we have a post that has many comments:

// src/app/posts/models/post.ts
import { type Casts, Model } from "@warlock.js/cascade";
import { Comment } from "app/comments/models/comment";

export class Post extends Model {
  public static collection = "posts";

  public static relations = {
    comments: Comment.joinable("post.id"),
  };

  protected casts: Casts = {
    title: "string",
    content: "string",
  };
}
Enter fullscreen mode Exit fullscreen mode

We can also filter comments by passing a query to the with method:

const post = await Post.aggregate()
  .where("id", 1)
  .with("comments", (query) => {
    query.where("isActive", true);
  })
  .first();
Enter fullscreen mode Exit fullscreen mode

This could be useful to filter the data based on the relation, maybe to get comments only for current user,

import { useRequestStore } from "@warlock.js/core";

const post = await Post.aggregate()
  .where("id", 1)
  .with("comments", (query) => {
    const { user } = useRequestStore();
    query.where("createdBy", user.id);
  })
  .first();
Enter fullscreen mode Exit fullscreen mode

Or even we can do it in the relation itself:

// src/app/posts/models/post.ts
import { type Casts, Model } from "@warlock.js/cascade";
import { Comment } from "app/comments/models/comment";

export class Post extends Model {
  public static collection = "posts";

  public static relations = {
    comments: Comment.joinable("post.id")
      .where("isActive", true)
      .select("id", "createdBy", "comment"),
  };

  protected casts: Casts = {
    title: "string",
    content: "string",
  };
}
Enter fullscreen mode Exit fullscreen mode

The data will be return as an array in comments field, to change the return field name, you can use the as method:

// src/app/posts/models/post.ts
import { type Casts, Model } from "@warlock.js/cascade";
import { Comment } from "app/comments/models/comment";

export class Post extends Model {
  public static collection = "posts";

  public static relations = {
    comments: Comment.joinable("post.id")
      .where("isActive", true)
      .select("id", "createdBy", "comment")
      .as("postComments"),
  };

  protected casts: Casts = {
    title: "string",
    content: "string",
  };
}
Enter fullscreen mode Exit fullscreen mode

To return the comment in a model, call inModel() method:

  public static relations = {
    comments: Comment.joinable("post.id").where("isActive", true).select('id', 'createdBy', 'comment').inModel(),
  };
Enter fullscreen mode Exit fullscreen mode

Conclusion πŸŽ‰

That's it for now, I hope you enjoyed the article, and I hope you will enjoy using the framework as well.

I'll post more articles about the framework, and how to use it in real-world applications.

Thank you for reading, and have a great day! 🌟

Top comments (0)