Warlock.js From Nodejs Course Into a fine grained framework
Hasan Zohdy
Posted on June 20, 2024
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
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);
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,
});
};
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"],
}),
};
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",
};
}
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,
};
}
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",
};
}
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",
};
}
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",
};
}
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");
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",
};
}
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();
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();
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",
};
}
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",
};
}
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(),
};
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! π
Posted on June 20, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.