StingerLoom is a Server Framework for Node.js. StingerLoom is built on top of the Fastify framework and provides a set of tools and utilities to help you build your server applications.
To generate a new project, you can use the following command:
npx create-stingerloom@latest --name <my-app>
and then you can run the following command to install the dependencies:
cd <my-app>
yarn install
to start the server, you can run the following command:
yarn start:dev
- Key Features
- Authentication and Authorization
- Supported Decorators
- Controller
- Get
- Post
- Patch
- Delete
- Put
- InjectRepository
- Req
- Body
- Header
- ExceptionFilter
- Catch
- BeforeCatch
- AfterCatch
- Injectable
- Session
- Transactional
- TransactionalZone
- InjectQueryRunner
- UseGuard
- View
- Render
- Autowired
- BeforeTransaction
- AfterTransaction
- Commit
- Rollback
- Query
- Param
- Ip
- Cookie
- Column
- Entity
- Index
This server framework uses the following technologies:
Additionally, since it is based on Fastify, it has a strong dependency on Fastify.
- fastify
- typeorm
- typedi
- reflect-metadata
- mariadb
- class-transformer
- class-validator
- http-status
The ORM used is typeorm, and class-transformer and class-validator are used for serialization/deserialization of the Body decorator.
Also, reflect-metadata is used for metadata collection.
This framework supports the following decorators: Controller
, Get
, Post
, Patch
, Delete
, Put
, InjectRepository
, Req
, Body
, Header
, ExceptionFilter
, Catch
, BeforeCatch
, AfterCatch
, Injectable
, Session
, Transactional
, TransactionalZone
, InjectQueryRunner
, UseGuard
, View
, Render
, Autowired
,BeforeTransaction
, AfterTransaction
,Commit
,Rollback
, Query
, Param
, Ip
, Cookie
, Column
, Entity
, Index
.
- Controller
- Injectable
- Exception Filter and Execution Context
- Handling Transactions
- Authorization
- Custom Parameter Decorator
- Template Engine
Since this project is written in TypeScript, you need to enter the following command in the terminal to build it.
yarn build
Once the build is complete, the built files will be generated in the dist
folder, and you can run the server with the example included using the following command:
yarn start:dev
You can also install this library alone without the sample project and configure the server directly.
In the future, the default development environment will be reconfigured to exclude the sample project.
A controller is a class that handles and responds to client requests.
The @Controller
decorator collects metadata to route HTTP requests to the appropriate controller for a specific path.
@Controller("/user")
export class UserController {
@Autowired()
private readonly point: Point;
@Autowired()
private readonly userService!: UserService;
@Get("/:id")
public async getUserById(
@Param("id|8E1527BA-2C2A-4A6F-9C32-9567A867050A") id: string,
@Query("name") name: string,
) {
if (!name) {
throw new BadRequestException("The 'name' attribute is required.");
}
return await this.userService.findOneByPk(id);
}
@Get("/point")
async getPoint() {
this.point.move(5, 5);
return {
x: this.point.x,
y: this.point.y,
};
}
@Post()
public async create(@Body() createUserDto: CreateUserDto) {
return await this.userService.create(createUserDto);
}
@Header("Content-Type", "application/json")
@Get()
public async getUser(@Ip() ip: string) {
return await this.userService.getUser(ip);
}
}
The routing map is handled by StingerLoom, so users do not need to manually create the routing map.
The @Body()
decorator deserializes the request body and assigns it to createUserDto
, performing validation. If validation fails, a 400 error is usually thrown.
The @Req()
decorator injects an instance of FastifyRequest.
The @Header()
decorator sets the response headers. This decorator can only be applied to methods, and if omitted, the default Content-Type: application/json
header is set.
@Controller("/")
class AppController {
@Get("/blog/:id/:title")
async resolveIdAndTitle(
@Param("id|0") id: number,
@Param("title") title: string,
) {
return { id, title };
}
@Get("/point/:x")
async resolveNameAndTitle(@Param("x") point: Point) {
return point;
}
@Get("/user/:id")
async resolveUser(
@Param("id|8E1527BA-2C2A-4A6F-9C32-9567A867050A") id: string,
) {
return id;
}
@Get("/admin/:id")
async resolveAdmin(@Param("id") id: string) {
return id;
}
}
In StingerLoom, the @Param()
decorator makes it easy to retrieve path parameters and automatically casts them based on their type.
To inject a default value, use the type|default
format as an argument to the @Param()
decorator.
To create a custom type, define a transformation object that processes the string and returns it as a type.
class Point {
private x: number;
private y: number;
constructor(args: string) {
const [x, y] = args.split(",");
this.x = parseInt(x, 10);
this.y = parseInt(y, 10);
}
getX() {
return this.x;
}
getY() {
return this.y;
}
}
The same applies to @Query
, where if the type is specified as number
, the string is internally converted to a number and assigned.
@Controller("/")
class AppController {
@Get("/blog")
async resolveIdAndTitle(
@Query("id") id: number,
@Query("title") title: string,
) {
return { id, title };
}
@Get("/point")
async resolveNameAndTitle(@Query("point") point: Point) {
return { x: point.getX(), y: point.getY() };
}
}
One important thing to note in the StingerLoom server framework is the constructor part.
@Controller("/user")
export class UserController {
constructor(
// 1. Point is not an injectable class, so it is instantiated every time.
private readonly point: Point,
// 2. UserService is an injectable class, so it is managed as a singleton instance.
private readonly userService: UserService,
) {}
As explained in the @Injectable
chapter, the Point
class does not have the @Injectable
decorator, so it is not managed by the container. It is not per-request, and a new instance is created each time it is injected into a controller or an Injectable
class.
export class Point {
public x: number;
public y: number;
constructor() {
this.x = 0;
this.y = 0;
}
public move(x: number, y: number) {
this.x += x;
this.y += y;
}
}
Therefore, if you call /user/point
consecutively, it will output as follows:
{"x":5,"y":5}
{"x":10,"y":10}
On the other hand, an Injectable
class is managed as a singleton instance, so the same instance is injected each time it is injected into a controller or an Injectable
class.
For an example of this, refer to the next section, Injectable.
A class with the @Injectable
decorator can be injected into the constructor or property of another class. It also analyzes the type of the constructor parameter or property and creates a singleton instance managed by the server container.
However, even without the @Injectable
decorator, injection is still possible. But if the @Injectable
decorator is not marked, the class is simply instantiated through the default constructor each time and is not managed by the server container.
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly discoveryService: DiscoveryService,
) {}
async create(createUserDto: CreateUserDto) {
const safedUserDto = createUserDto as Record<string, any>;
if (safedUserDto.role) {
throw new BadRequestException(
"The 'role' attribute cannot be entered.",
);
}
const newUser = await this.userRepository.create(createUserDto);
const res = await this.userRepository.save(newUser);
return ResultUtils.success("User creation successful.", res);
}
async validateUser(loginUserDto: LoginUserDto): Promise<User> {
const { username, password } = loginUserDto;
const user = await this.userRepository
.createQueryBuilder("user")
.select()
.where("user.username = :username", {
username,
})
.getOne();
if (!user) {
throw new BadRequestException("User does not exist.");
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
throw new BadRequestException("Password does not match.");
}
return user;
}
async getUser(ip: string) {
const user = await this.userRepository.find();
return ResultUtils.success("User retrieval successful", {
user,
ip,
});
}
}
The emphasized singleton instance means that only one instance will be created. In other words, the exact same instance is injected each time it is injected into a controller or an Injectable
class.
Constructor-based injection is recommended by default, but if desired, property-based injection can also be used.
@Injectable()
export class UserService {
@Autowired()
private readonly discoveryService!: DiscoveryService;
}
However, property-based injection is injected later than constructor-based injection, so constructor-based injection is recommended.
Also, @InjectRepository
is currently only injected in the constructor, so be careful when using property-based injection.
Exception Filter is a decorator that allows you to handle and redefine errors. The @ExceptionFilter
decorator is attached, and the error class is specified as an argument to the decorator. After that, when an error corresponding to the error class occurs, the method with the @Catch
decorator is executed.
The method with the @BeforeCatch
decorator is executed before the method with the @Catch
decorator is executed, and the method with the @AfterCatch
decorator is executed after the method with the @Catch
decorator is executed.
@ExceptionFilter(InternalServerException)
export class InternalErrorFilter implements Filter {
private readonly logger = new Logger();
@BeforeCatch()
public beforeCatch() {
this.logger.info("before catch");
}
@Catch()
public catch(error: any) {
this.logger.info("[Internal Server Error] " + error.message);
return {
message: error.message,
status: error.status,
result: "failure",
};
}
@AfterCatch()
public afterCatch() {
this.logger.info("after catch");
}
}
This will output as follows:
The exception methods are executed in the order of @BeforeCatch -> @Catch -> @AfterCatch
. Each exception context is a shared instance that shares one instance per exception handling class.
Transactions are a feature that ensures the completeness and consistency of data. In other words, it is a feature that allows you to restore to the original state when a task is not processed perfectly.
StingerLoom supports the @Transactional
decorator for handling such transactions.
Inspired by Spring, the default transaction isolation level of this decorator is REPEATABLE READ
.
Transaction isolation level refers to the level at which a specific transaction can see the changes of other transactions when multiple transactions are processed simultaneously.
There are four main levels: READ UNCOMMITTED
, READ COMMITTED
, REPEATABLE READ
, and SERIALIZABLE
.
The @Transactional
feature currently only applies to classes with the @Injectable
decorator.
Also, for transaction processing, the @TransactionalZone
decorator must be marked on the class for efficient search.
The @TransactionalZone
decorator finds the methods to inject EntityManager
and QueryRunner
for transaction processing and performs transaction processing.
Here is a simple example of handling transactions.
@TransactionalZone()
@Injectable()
export class AuthService {
constructor(private readonly userService: UserService) {}
@Transactional()
async checkTransaction2() {
const users = await this.userService.findAll();
return ResultUtils.success("Transaction checked.", {
users: plainToClass(User, users),
});
}
@BeforeTransaction()
async beforeTransaction(txId: string) {
// This code is executed before the transaction starts.
}
@AfterTransaction()
async afterTransaction(txId: string) {
// This code is executed after the transaction ends.
}
@Commit()
async commit(txId: string) {
// This code is executed after the transaction is committed.
}
@Rollback()
async rollback(txId: string, error: any) {
// This code is executed after the transaction is rolled back.
// This method is only executed when an error occurs.
}
@Transactional({
rollback: () => new Exception("Transaction rolled back", 500),
})
async rollbackCheck() {
const user = await this.userService.findOneByPk("test");
return ResultUtils.success("Rollback test", {
user,
});
}
}
As you can see from the example, it is very simple. If no errors occur until the return, the transaction is successfully committed.
Here is another example, a user registration example.
@TransactionalZone()
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly discoveryService: DiscoveryService,
) {}
@Transactional()
async create(createUserDto: CreateUserDto) {
const safedUserDto = createUserDto as Record<string, any>;
if (safedUserDto.role) {
throw new BadRequestException(
"The 'role' attribute cannot be entered.",
);
}
const newUser = this.userRepository.create(createUserDto);
const res = await this.userRepository.save(newUser);
return ResultUtils.success("User creation successful.", res);
}
// Skip...
}
You will see an error handling logic in the middle. It can be thought of simply. If an error is thrown in the above code, the transaction is automatically rolled back.
If you want to execute specific code after the rollback, you can do it as follows.
@Rollback()
async rollback(txId: string, error: any) {
// This code is executed after the transaction is rolled back.
// This method is only executed when an error occurs.
}
Attach the @Rollback()
decorator, and the first argument of the method is the transaction ID, and the second argument is the error object.
Or, if you want to return a specific error when the transaction is rolled back, you can do it as follows.
@Transactional({
rollback: () => new Exception("Transaction rolled back", 500),
})
async rollbackCheck() {
const user = await this.userService.findOneByPk("test");
return ResultUtils.success("Rollback test", {
user,
});
}
The transaction ID is not the actual transaction ID but the transaction ID managed by the server.
@Injectable()
@TransactionalZone()
export class GameMapService {
constructor(
@InjectRepository(GameMap)
private readonly gameMapRepository: Repository<GameMap>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
@Transactional()
async createGameMap() {
await this.userRepository.clear();
const qb = this.gameMapRepository.createQueryBuilder("gameMap");
const maps = await qb
.select()
.leftJoinAndSelect("gameMap.users", "user")
.getMany();
return maps;
}
@Commit()
async commitOk(txId: string) {
console.log("Commit OK:", txId);
}
}
StingerLoom supports session-based authentication.
A class that inherits from SessionObject can be used as a session object.
@Controller("/auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post("/login")
async login(
@Session() session: SessionObject,
@Body() loginUserDto: LoginUserDto,
) {
return await this.authService.login(session, loginUserDto);
}
}
Authorization processing is not yet implemented in the example.
Authorization processing requires implementing the concept of an authentication guard (AuthGuard) and the concept of roles needed for authorization processing.
A more practical example is as follows.
@Injectable()
export class AuthService {
@Autowired()
userService!: UserService;
async login(session: SessionObject, loginUserDto: LoginUserDto) {
const user = await this.userService.validateUser(loginUserDto);
session.authenticated = true;
session.user = user;
return ResultUtils.successWrap({
message: "Login successful.",
result: "success",
data: session.user,
});
}
async checkSession(session: SessionObject) {
return ResultUtils.success("Session authentication successful", {
authenticated: session.authenticated,
user: session.user,
});
}
}
In the current version, you can implement authentication using the session object as shown above.
Session authentication can be processed by injecting the session object using the @Session()
decorator and adding a SessionGuard to handle session authentication.
The code is as follows.
@Injectable()
export class SessionGuard implements Guard {
canActivate(context: ServerContext): Promise<boolean> | boolean {
const req = context.req;
const session = req.session as SessionObject;
if (!session) {
return false;
}
if (!session.authenticated) {
return false;
}
return true;
}
}
Add the above guard to the providers and use it by attaching it to the controller or router as follows.
@Controller("/auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Get("/session-guard")
@UseGuard(SessionGuard)
async checkSessionGuard(@Session() session: SessionObject) {
return ResultUtils.success("Session guard passed", session);
}
}
This way, the router is executed only for logged-in users who have passed session authentication.
For users who are not authenticated, a 401 error occurs.
You can create your own ParameterDecorator
using the createCustomParamDecorator
function.
Here is an example of retrieving user information and user ID from the session.
export const User = createCustomParamDecorator((data, context) => {
const req = context.req;
const session = req.session as SessionObject;
if (!session) {
return null;
}
return session.user;
});
You can retrieve the user ID as follows.
export const UserId = createCustomParamDecorator((data, context) => {
const req = context.req;
const session = req.session as SessionObject;
if (!session) {
return null;
}
return session.user.id;
});
The final usage is as follows.
@Controller("/auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Get("/session-guard")
@UseGuard(SessionGuard)
async checkSessionGuard(
@Session() session: SessionObject,
@User() user: any,
@UserId() userId: string,
) {
return ResultUtils.success("Session guard passed", {
user,
userId,
});
}
}
When queried, the result is output as follows.
{
"message": "Session guard passed",
"result": "success",
"data": {
"user": {
"id": "4500949a-3855-42d4-a4d0-a7f0e81c4054",
"username": "abcd",
"role": "user",
"createdAt": "2023-08-28T09:22:37.144Z",
"updatedAt": "2023-08-28T09:22:37.144Z"
},
"userId": "4500949a-3855-42d4-a4d0-a7f0e81c4054"
}
}
The template engine allows you to render HTML files using the @View
decorator.
First, you need to install the necessary packages. Enter the following command in the terminal.
yarn add @fastify/view handlebars
If you register the template engine as middleware in the bootstrap.ts
file, you can use the template engine in all controllers.
/**
* Add middleware.
*
* @returns
*/
protected applyMiddlewares(): this {
const app = this.app;
app.register(fastifyCookie, {
secret: process.env.COOKIE_SECRET,
hook: "onRequest",
});
app.register(fastifyFormdody);
app.register(fastifySession, {
secret: process.env.SESSION_SECRET,
});
app.register(view, {
engine: {
handlebars,
},
root: `${__dirname}/views`,
includeViewExtension: true,
});
return this;
}
In the controller, you can map to the template using the @View
decorator.
@Controller("/")
export class AppController {
/**
* Display the login page.
*/
@View("login")
login() {
return {
username: "Username",
password: "Password",
};
}
/**
* This page is accessible only to logged-in users.
*/
@View("memberInfo")
@UseGuard(SessionGuard)
async memberInfo(@User() user: UserEntity) {
return {
username: user.username,
};
}
}
If the path of the view and the route are different, you can specify the path of the template resource using the @Render
decorator as follows.
@Controller("/")
export class AppController {
/**
* This page is accessible only to logged-in users.
*/
@Get("/info")
@Render("memberInfo")
@UseGuard(SessionGuard)
async memberInfo(@User() user: UserEntity) {
return {
username: user.username,
};
}
}
Return the necessary parameters, and each template engine can process them.
Here is a login example using the handlebars
template engine.
Here is an example of displaying session information.
ORM is a tool that supports mapping between objects and relational databases.
StingerLoom provides its own ORM, so you can access the database without third-party libraries.
You can define an entity using the @Entity
decorator. An entity is mapped to a table in the database. Using the synchronize option, you can synchronize the written entity with the database.
You can define an entity using the @Column
, @Entity
, and @Index
provided by the @stingerloom/orm/decorators
package.
@Entity()
class MyNode {
@PrimaryGeneratedColumn()
id!: number;
@Column({
length: 255,
nullable: false,
type: "varchar",
})
name!: string;
@Column({
length: 255,
nullable: false,
type: "varchar",
})
type!: string;
@Column({
length: 255,
nullable: false,
type: "varchar",
})
@Index()
description!: string;
}
You can access the database through the repository. There are two ways to create a repository: using the getRepository
method by injecting EntityManager
and using the @InjectRepository
decorator. The latter method is only supported in TypeORM and is not yet supported in the custom ORM. We will consider how to make it usable in both ORMs in the future.
@Injectable()
class MyNodeService {
constructor(
@InjectEntityManager()
private readonly entityManager: EntityManager,
)
async findOne(id: number): Promise<MyNode> {
// Get the repository for the MyNode entity
const myNodeRepository = this.entityManager.getRepository(MyNode);
// Find the node with id 1
const myNode = await myNodeRepository.findOne({
where: {
id
}
});
if (!myNode) {
throw new NotFoundException("Node not found.");
}
return myNode;
}
}
As shown above, you can inject EntityManager
using the @InjectEntityManager
decorator to get the repository.
You can access the database through the repository pattern.
StingerLoom supports interactive prompts. The interactive prompts make it easy to create module files. It's pretty limited at this point, I think you should add logic to read the module information using the Typescript compiler through further research.
to generate a new controller and service automatically, you can use the following command.
yarn cli