Skip to content

Latest commit

 

History

History
384 lines (286 loc) · 10.5 KB

File metadata and controls

384 lines (286 loc) · 10.5 KB

BACKEND COVERAGE NPM Version

A lightweight Express.js dependency injection & route abstraction library.

Table of Contents

Why?

  1. Express is a fantastic way to build server-side apps in JavaScript, but wiring can get messy very quickly. Sapling abstracts away complicated wiring of controllers & routes, allowing you to focus on business logic & write unit tests in a painless way.

  2. Sapling is inspired by Spring, but without losing the developer experience, speed & simplicity of Express.js / TypeScript.

    • The best reason to use Sapling is that you can opt-in or opt-out as much as you would like; run any traditional & functional Express.js without having to hack around the library.
  3. Sapling DI & routing is designed to be very light. This may be preferable to other libraries like Nest.js that provide a much heavier abstraction. Get what would be helpful to your improve development speed, ignore anything else that may get in your way.

Examples

Check the /example folder for a basic todo app with database integration.

Sapling is also powering one of my more complex projects with 660+ users in production, which you can view at instalock-web/apps/server.

Install

# we <3 pnpm
pnpm install @tahminator/sapling

Quick Start

import express from "express";
import { Sapling, Controller, GET, POST, ResponseEntity, Class, HttpStatus, MiddlewareClass, Middleware } from "@tahminator/sapling";

@MiddlewareClass()
class LoggingMiddleware {
  @Middleware()
  loggingMiddleware(
    request: express.Request,
    response: express.Response,
    next: express.NextFunction,
  ): void {
    console.log(request.path);
    next();
  }
}

@Controller({ prefix: "/api" })
class HelloController {
  @GET()
  getHello(): ResponseEntity<string> {
    return ResponseEntity.ok().body("Hello world");
  }
}

@Controller({ prefix: "/api/users" })
class UserController {
  @GET()
  getUsers(): ResponseEntity<string[]> {
    return ResponseEntity.ok().body(["Alice", "Bob"]);
  }

  @POST()
  createUser(): ResponseEntity<{ success: boolean }> {
    return ResponseEntity.status(HttpStatus.CREATED).body({ success: true });
  }
}

// you still have full access of app to do whatever you want!
const app = express();
Sapling.registerApp(app);

// @MiddlewareClass should be registered first before @Controller and should be registered in order
// @Injectable classes will automatically be formed into singletons by Sapling behind the scenes!
const middlewares: Class<any>[] = [LoggingMiddleware];
middlewares.map(Sapling.resolve).forEach((r) => app.use(r));

// @Controller can be registered in any order.
// @Injectable classes will automatically be formed into singletons by Sapling behind the scenes!
const controllers: Class<any>[] = [HelloController, UserController];
controllers.map(Sapling.resolve).forEach((r) => app.use(r));

app.listen(3000);

Hit GET /api for "Hello world" or GET /api/users for the user list.

Features

Controllers

Use @Controller() to mark a class as a controller. Routes inside get a shared prefix if you want:

@Controller({ prefix: "/users" })
class UserController {
  @GET("/:id")
  getUser() {
    /* ... */
  }

  @POST()
  createUser() {
    /* ... */
  }
}

HTTP Methods

Sapling supports the usual suspects:

  • @GET(path?)
  • @POST(path?)
  • @PUT(path?)
  • @DELETE(path?)
  • @PATCH(path?)
  • @Middleware(path?) - for middleware

Path defaults to "/" if you don't pass one.

Responses

ResponseEntity gives you a builder pattern for responses:

@Controller({ prefix: "/users" })
class UserController {
  @GET("/:id")
  getUser(): ResponseEntity<User> {
    const user = findUser(id);
    return ResponseEntity.ok().setHeader("X-Custom", "value").body(user);
  }
}

For status codes you can use .ok() (200) or .status() to define a specific status with the HttpStatus enum.

You can also set custom status codes:

@Controller({ prefix: "/api" })
class CustomController {
  @GET("/teapot")
  teapot(): ResponseEntity<string> {
    return ResponseEntity.status(HttpStatus.I_AM_A_TEAPOT).body("I'm a teapot");
  }
}

Error Handling

Use ResponseStatusError to handle bad control paths:

@Controller({ prefix: "/users" })
class UserController {
  constructor(private readonly userService: UserService) {}

  @GET("/:id")
  async getUser(request: Request): ResponseEntity<User> {
    const user = await this.userService.findById(request.params.id);

    if (!user) {
      throw new ResponseStatusError(HttpStatus.NOT_FOUND, "User not found");
    }

    return ResponseEntity.ok().body(user);
  }
}

Make sure to register an error handler middleware:

Sapling.loadResponseStatusErrorMiddleware(app, (err, req, res, next) => {
  res.status(err.status).json({ error: err.message });
});

Middleware

Load Express middleware plugins using @Middleware():

import { MiddlewareClass, Middleware } from "@tahminator/sapling";
import cookieParser from "cookie-parser";
import { NextFunction, Request, Response } from "express";

@MiddlewareClass() // @MiddlewareClass is an alias of @Controller, provides better semantics
class CookieParserMiddleware {
  private readonly plugin: ReturnType<typeof cookieParser>;

  constructor() {
    this.plugin = cookieParser();
  }

  @Middleware()
  register(request: Request, response: Response, next: NextFunction) {
    return this.plugin(request, response, next);
  }
}

// Register it like any controller
app.use(Sapling.resolve(CookieParserMiddleware));

// You can also still choose to load plugins the Express.js way
app.use(cookieParser());

Redirects

@Controller({ prefix: "/api" })
class RedirectController {
  @GET("/old-route")
  redirect() {
    return RedirectView.redirect("/new-route");
  }
}

Dependency Injection

Mark services with @Injectable() and inject them into controllers:

@Injectable()
class UserService {
  getUsers() { ... }
}

@Controller({
  prefix: "/users",
  deps: [UserService]
})
class UserController {
  constructor(private readonly userService: UserService) {}

  @GET()
  getAll() {
    return ResponseEntity.ok().body(this.userService.getUsers());
  }
}

Injectables can also depend on other injectables:

@Injectable()
class Database {
  query() { ... }
}

@Injectable([Database])
class UserRepository {
  constructor(private readonly db: Database) {}

  findAll() {
    return this.db.query("SELECT * FROM users");
  }
}

Custom Serialization

By default, Sapling uses JSON.stringify and JSON.parse for serialization. You can override these with custom serializers like superjson to automatically handle Dates, BigInts, and more:

import superjson from "superjson";

Sapling.setSerializeFn(superjson.stringify);
Sapling.setDeserializeFn(superjson.parse);

This affects how ResponseEntity serializes response bodies and how request bodies are deserialized.

Advanced Setup

Automatically import controllers

Note

You need ESLint (or some alternative build step that has glob-import support)

Controllers can be automatically imported via a glob-import if you ensure that all controller files are:

  • export default (so one controller per file)
  • all controller files are marked as *.controller.ts

. The steps below indicate a working example inside of my webapp, instalock-web/apps/server.

  1. Create a bootstrap file that will glob-import all controller files and export them

example file

// file will automatically import any controller files
// it will pull out default exports, so ensure
// 1. one class per file
// 2. `export default XyzController`

// q: wont this break ordering of controller imports?
// a: yes but that's ok - controllers are the last in the dependency graph.
// they import, but are never imported themselves.

/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck

import { modules } from "./controller/**/{controller,*.controller}.ts#default";

export const getControllers = (): Class<unknown>[] => {
  return modules.map((mod) => mod.default as Class<unknown>);
};
  1. Point Sapling.resolve to getControllers

example file

const controllers = getControllers();
console.log(`${controllers.length} controllers resolved`);
controllers.map(Sapling.resolve).forEach((r) => app.use(r));
  1. Configure your ESBuild process to use the esbuild-plugin-import-pattern plugin

example file

import * as esbuild from "esbuild";
// @ts-expect-error no types
import { importPatternPlugin } from "esbuild-plugin-import-pattern";

async function main() {
  const ctx = await esbuild.context({
    entryPoints: ["src/index.ts"],
    bundle: true,
    sourcemap: true,
    platform: "node",
    outfile: "src/index.js",
    logLevel: "info",
    format: "cjs",
    plugins: [importPatternPlugin()],
  });

  await ctx.rebuild();
  await ctx.dispose();
}

void main();

License

MIT