Table of contents
- Table of contents
- 1. Prelude
- Disclaimer
- 2. Enough intro - show me the code.
- 2.1. Configuring the server
- 2.2. Example
- 2.3. Controllers and routes
- 2.4. Error handling
- 3. Here's the repo
- 4. There is a lot more to do
- 5. Outro
1. Prelude
On my last job, I was handling a project consisting of a legacy backend + frontend codebase where I was constantly refactoring code - it was either unusable, totally unreadable, or both. To make things even worse, the backend was lacking any structure or goal - it was just code thrown around that somehow managed to work most of the time. In the midst of all the refactoring I found myself slowly adding some architectural elements to the codebase, since it was the only way any new code addition could be maintained later. The codebase didn't even separate the controller logic from service logic!
With time, things started to take shape, and all my architectural additions helped made the codebase kind of usable. Since moving away from Express wasn't a choice at the time, but numerous new features constantly asked for some new abstractions and design patterns just to keep things organized, I slowly found myself with a backend project that I tailored to my kind of thinking.
In the meantime, I left that job but all those concepts stuck with me, so as a learning project I decided to wrap them in a standalone package that can be used cleanly and simply. I never wrote a package before, but I thought this would be a short, fun and productive endeavour. Here I am sharing it with the world so we can discuss, learn and figure out what makes a good framework together. With that, I present to you: Kamiq!
⚠️ Disclaimer
Before you dive in:
- In this post I'll be talking about a personal learning project I've been working on. Expect a half-baked but interesting project and a discussion on what makes a fun to use web framework.
2. Enough intro - show me the code.
Short description first:
Kamiq is a TypeScript framework for building server-side applications with heavy decorator usage. It combines object-oriented and functional programming approaches to achieve it's minimal syntax design. Kamiq is built on top of Express.js and by design offers high interoperability with Express, enabling the user to easily port over their existing Express.js code including routes, middlewares and more.
I'm not treating this project as something I'd like to push through to being complete or production ready - it's merely a learning project and a discussion on node web frameworks in a codebase format. I'm always open to learning new stuff and one of the best ways to learn is to reinvent the wheel, which is what I'm doing here.
The following script covers the most important parts of the project and doesn't cover more specific features and possibilities. Check out the full README on the Github repository (link at the end).
Right, let's get to it.
2.1. Configuring the server
To configure your server, instantiate an object from the Server class. Use the public functions to set your configuration object and any other properties.
import "reflect-metadata";
import { Server } from "kamiq";
import { DefaultErrorHandler, DefaultRequestLogger } from "kamiq/middlewares";
import { SampleController } from "./controllers/sampleController";
const server = new Server();
server.setPort(8001);
server.useJsonBodyParser(true);
server.useController(SampleController);
server.useCors(true);
server.setGlobalRequestLogger(new DefaultRequestLogger());
server.setGlobalErrorHandler(new DefaultErrorHandler(true));
server.start();
2.2. Example
Here's an example of how a controller looks like:
import { BaseController } from "kamiq";
import { Middleware, Post, Req, Res } from "kamiq/decorators";
import { MySampleMiddleware } from "../middlewares/sampleMiddleware.middleware";
import { MySampleMiddleware2 } from "../middlewares/sampleMiddleware2.middleware";
export class SampleController extends BaseController {
path = "/users"; // Base path for the following routes.
@Guard(new AgencyAuthorizer(), {
ignore: true, // Optional way to ignore a guard (or middleware)
}) // Guards
@Middleware(new LogSignInEvent("user")) // Middlewares
@Post("/siginin") // Get controller registeres the route with a GET method and handles errors
signIn(
@Req() req: Request,
@Res() res: Response,
@Body() body: IUserSignIn,
@Param("userId") userId: string
) {
const { password } = body;
const signIn = AuthService.signIn(userId, password); // Kamiq operation
if (signIn.error) throw new AuthorizationError(signIn.error); // Picked up by global err handling middleware
res.json({ msg: "success" });
}
}
Now let's brake it down:
2.3. Controllers and routes
2.3.1. Controllers
Controllers are classes that extend the BaseController
class. In each controller class you provide the path
property - a route prefix for all routes defined in the class.
3.3.2. Routes
Each route is a function with the name of your choosing to which a decorator is attached with the HTTP method name: @Get()
for a GET method, @Post()
for POST etc. An HTTP decorator combined with a handler function forms a route. HTTP method decorators take in a string argument that is the route specific suffix. Note that this also supports dynamic routes by adding parameters to the route, as shown above in the example with :userId
.
2.3.3. Parameters
The route handler itself supports multiple decorators that you can inject to get access to various properties of the request lifecycle. Since Kamiq uses Express.js to process HTTP requests, you can inject Express Request
and Response
objects into the hander like shown above with the @Req()
and @Res()
decorators. Also, you have access to @Body
, @Query()
, @Param()
, which extract some specific data from the request as shown above.
2.3.4. Middlewares
Kamiq also supports middlewares, which you can attach to routes by using the @Middleware
decorator. Middlewares are classes that extend the KamiqMiddleware
interface, which is very similar to Nest's interface where the use()
function is really just a vanilla Express.js middleware function.
This also has a nice side-effect where middleware functions can be passed arguments which can alter their behaviour. Consider the middleware as shown:
export class MySampleMiddleware implements KamiqMiddleware {
private readonly someValue: boolean;
constructor(someValue: boolean) {
this.someValue = someValue;
}
async use(req: Request, res: Response, next: NextFunction): Promise<void> {
// Can use someValue to change behavior...
next()
}
}
// Route
@Middleware(new MySampleMiddleware(false))
@Middleware(new MySampleMiddleware2())
@Post('/users')
createUser(@Req() req: Request, @Res() res: Response) {
// @ts-ignore
res.send('user created.')
}
Note that if a route has multiple middlewares attached, the order of execution is respected.
Middlewares also accept a second argument, an options
object where you can specify middleware execution instructions like the ability to bypass execution while testing with the ignore
property.
2.3.5. Guards
Guards are special type of middlewares that follow the rule of single responsibility by only handlding authorization and authentication logic. They implement an interface similar to the middleware interface but they return a boolean
value, corresponds to a successful or a failed operation, where a successful one means the request proceeds to the next middleware in it's lifecycle and a failure results in an authentication error (or a custom error you can define).
2.4. Error handling
Error handling in routes is hidden in how they are registered on server start. When routes are being registered they are wrapped in an requestErrorHandler
middleware which wraps the route handler in a try/catch block. This ensures any error being thrown, by the application or the user, will be caught.
Catching errors is now taken care of, but we still need to handle them. Kamiq offers a public function you can access on the server object called setGlobalErrorHandler
which takes in a class that implements the KamiqErrorMiddleware
interface. This is a wrapper for an express error middleware that you can pass to the function which will register the middleware and process all caught errors.
Kamiq provides a default defaultErrorHandler
middleware you can use - or of course, you can easily write your own.
A BaseError
class is included in the package that extends the Node's Error
, making it trivial to write your own custom errors. Simply extend the BaseError
class, add your own logic and due to the fact that all route handlers are wrapped in a try/catch block, simply throw your custom error anywhere in the handlers:
@Get('/test')
ping(@Res() res: Response) {
throw new CustomAuthError("my error message!")
res.send('success')
}
and Kamiq will handle everything for you.
2.5. Operations
Operations are a special function type that aide with inter-layer communication within your codebase. Let's consider a simple backend architecture, consisting of three layers: presentation, service and data-access layer.
Errors should be handled at the controller level, making communication between the layers troublesome, considering any logic may error. This also raises the question of how to handle user-invoked errors at sub-controller levels.
Any general function can be an Operation by attaching the Operation()
decorator to it.
Operation functions are general functions that are wrapped in a try/catch block and have an OperationResult
return type:
export type OperationResult<T> =
| { success: true; data: T }
| { success: false; error: Error };
This ensures any Operation
function will return an object with the success
property set to true
if the operation was successful, together with the data
property. In case of failure, success
will be false
, and the error
property will be returned, making it very simple for the receiver of the result to conditionally handle errors:
// Operation function
class MyService {
@Operation
static myOperationFunction() {
throw new Error("oops!");
}
}
// Operation receiver
function mockControllerFunction() {
const operationResult = MyService.myOperationFunction();
// handling the result:
if (operationResult.error) {
// handle error case (throw the error and it will be caught)
}
const result = operationResult.success;
// continue...
}
Tip: Combine service level functions as Operations with the controller-level error handling to create bulletproof controller-service logic.
2.6. Kamiq errors
Kamiq offers descriptive, prettified framework-level error handling. If you make any configuration or definition errors at the framework level, Kamiq will throw it's custom error to help you resolve the problem. Here's an example of such error being thrown, invoked due to a misconfigured port
variable:
InvalidArgumentError:
┌ Kamiq encountered an error! ────────────────────────────────────────────┐
│ │
│ │
│ InvalidArgumentError: │
│ Port must be a number between 1 and 65535. Provided port is 5236203 │
│ │
│ Suggestion: Please check your server configuration. │
│ │
│ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
there's only a few of such errors as of now, but more checks can be easily added.
3. Here's the repo
You can visit the repository here, or visit the npm page here.
4. There is a lot more to do
It ain't much, but it's honest work, right?
There's a lot of features I'd like to add, and many bugs I'd like to fix. Many important functionalities are missing, such as:
- Handling cookies
- Handling content types
- File uploading and management
- Monitoring
- Input validation and sanitization
- Rendering templates
- Sessions
and more.
5. Outro
Thank you for reading this through. I'd like to hear your opinion on this project and can't wait to learn from your ideas and discussions. Check out the repository and feel free to voice your opinions. I know there's plenty wrong I did, but improving it is why I'm sharing this in the first place. Enjoy!
Cover image by Trina Snow (reinventing the wheel, get it?)
Top comments (0)