@varld/warp

Minimalist framework for building APIs in TypeScript.

Usage no npm install needed!

<script type="module">
  import varldWarp from 'https://cdn.skypack.dev/@varld/warp';
</script>

README

Warp logo

Minimalist framework for building APIs in TypeScript.

Getting Started     Examples     Authentication     Validation     Dependency Injection     Testing


Features

  • 🔋 Batteries includes: Cors, dependency injection and body parsing are already set up.
  • 🚀 Written in Typescript: No need to worry about inconsistent types.
  • 🤷‍♂️ Unopinionated: We don't force you to do anything.
  • 🏭 Built on Express: Warp is compatible with all existing Express packages.
  • Support for async/await: Warp helps you escape the callback hell.
  • 🔥 Easy to get started with: One file with a few lines of code is all you need.
  • 👋 Built in authentication: Warp has build in support for token authentication.

Why

About a year ago I fell in love with Nest.js, however after building a couple of bigger projects with it I noticed, that it forces me to do things in counterintuitive ways while offering features that I hardly ever used. On the other hand, Express is great but really barebones. You have to set up body parsing, authentication and routing for every project.

Warp aims to be a combination of the great API that Nest.js offers while maintaining the simplicity of Express. Warp is a clever combination of a few standard packages, which together offer controller based routing, authentication, dependency injection, validation and reduce code duplication.

Getting Started

Using Warp CLI

Warp CLI creates a simple TypeScript project with a very basic Warp API and test.

# Create warp project in current directory
npx create-warp-app

# Create warp project in (./my-api)
npx create-warp-app ./my-api

Custom Setup

You can easily set up your own warp project. This guide assumes that you already have a Node.js project with TypeScript set up.

Install Warp

# Using npm
npm install @varld/warp

# Using yarn
yarn add @varld/warp

Setting up TypeScript

Warp uses decorators and reflection, those two features have to be enabled in the tsconfig.json file.

{
  "compilerOptions": {
    ...
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Creating a Simple API

import warp, { Controller, Get, Param } from '@varld/warp';

@Controller('/')
class GreetingController {
  @Get('/:name')
  greet(@Param('name') name: string) {
    return `Hello ${name}`;
  }
}

let app = warp({
  controllers: [GreetingController]
});

app.listen(3000);

That's it! 🎉 Go ahead and visit http://localhost:3000/your-name in a browser.

Now let's take a look at what the code above does exactly.

  1. On the first line, we import warp and a few decorators, which we are using later.
  2. Nest, we create a controller-class. All controllers must have the @Controller('/...') decorator, with the base-path of the controller as the first argument.
  3. Within the controller class we create an HTTP-Handler (called greet) using the @Get('/...') decorator. This decorator indicates, that the handler listens to GET request under the path supplied in the first argument. Of course there are decorators for all other HTTP-Methods as well.
  4. The greet has a single argument decorated with @Param('name'). This indicates, that we want to extract a path-param called name from the url.
  5. Withing the greet method we return a string containing the supplied name.
  6. Now we set up a warp-instance and tell it about our controller. The warp function returns a standard Express app.
  7. Finally we tell the warp-instance to listen to port 3000.

Pretty simple, right. ✌️

Usage

File Structure

While Warp does not force you do adhere to a specific file structure, there is one that we think works very well.

/src
  package.json
  index.ts
  /controllers
    controllerName.ts
  /services
    serviceName.ts
/tests
  testName.spec.ts

In the root of the project, there is an index.ts-file, which create a Warp instance and sets everything up. The controllers directory houses all controllers. Each controller should have its own file named accordingly. The services directory contains all services. Each file should contain just one service.

If you are using authentication, you could extract the authenticator into its own file.

Handler Methods

Warp has decorators for all popular HTTP-Methods. They all basically work the same.

@Controller('/')
class MyController {
  @Get('/') // Listens to GET requests
  getSomething() {
    /*...*/
  }

  @Post('/') // Listens to POST requests
  createSomething() {
    /*...*/
  }

  @Put('/') // Listens to PUT requests
  overrideSomething() {
    /*...*/
  }

  @Patch('/') // Listens to PATCH requests
  updateSomething() {
    /*...*/
  }

  @Delete('/') // Listens to DELETE requests
  deleteSomething() {
    /*...*/
  }

  @Head('/') // Listens to HEAD requests
  headRequest() {
    /*...*/
  }

  @Options('/') // Listens to OPTIONS requests
  optionsRequest() {
    /*...*/
  }
}

Middleware

Warp is compatible with all existing Express middleware, however installing it is a bit different.

Global Middleware

Global middleware is executed on every request. You can optionally specify if it should be executed before or after the request handler methods.

// Default behavior
let app = warp({
  controllers: [
    /*...*/
  ],
  middleware: [
    aMiddlewareFunction,
    anotherOne
    // ...
  ]
});

// Specify when the middleware should be executed
let app = warp({
  controllers: [
    /*...*/
  ],
  middleware: [
    {
      // Will be executed before request handlers
      execution: 'before',
      middleware: setupMiddleware
    },
    {
      // Will be executed after request handlers
      execution: 'after',
      middleware: cleanupMiddleware
    }
    // ...
  ]
});

Controller-based middleware

Controller-based middleware will be executed before every handler method within a controller. This is useful if you want to perform an action for every handler of a controller.

@Controller('/', [
  aMiddlewareFunction,
  anotherOne
  // ...
])
class MyController {
  // ...
}

Handler-based middleware

Handler-based middleware is specific to selected request handlers. Handler-based middleware can be specified using the second parameter of @Get or any other method decorator or using the @Middleware() decorator. You can also use bot variant together.

@Get('/:name', [
  aMiddlewareFunction,
  // ...
])
@Middleware(anotherMiddlewareFunction)
@Middleware([
  aMiddlewareFunctionInAnArray,
  oneMore,
  // ...
])
handler() {
  // ...
}

Guards

As the name suggests, guards are used to protect handlers. Guards are decorators, which receive a function that returns either true or false. If true is returned, the handler will be executed normally. If false is returned, the handler will not be executed and a Forbidden-Error will sent to the client instead. The function in the guard receives the request object as its first argument.

Guards are especially useful if you want to make a handler only accessible to users with special permissions.

@Get('/')
@Guard((req) => false)
handler() {
  return `This will not be executed`;
}

Responses

By default, everything you return in a handler function will be sent to client with the 200 status code. If you return a string, that string will sent to the client as is. If you return an object, that object will be converted to JSON. Depending on the return type a matching Content-Type-header will be chosen. You can use responses to manipulate this behavior.

JSON Response

Everything you provide to the JSON-Response will be converted to JSON. In addition to the response data you can also specify a custom status code and additional http-headers.

@Get('/')
handler() {
  return new JSONResponse({
    /* some data */
  });
}

// With a statuscode and custom headers
@Get('/')
handler() {
  return new JSONResponse({
    /* some data */
  }, 202, {
    'Custom': 'Header'
  });
}

File Response

File responses can be used to serve local files.

new FileResponse(filePath, [expressSendFileOptions, status, headers])

Next Response

The next response can be used to call Express's next() function. Which tells Express to continue to the next middleware.

new NextResponse()

Redirect Response

Using the redirect response, you can redirect the client to a different url.

new RedirectResponse(location, [status, headers])

Render Response

The render response can be used to access Express's internal template rendering. For this to work you must configure a view engine first.

import warp, { Controller, Get, Param } from '@varld/warp';

@Controller('/')
class MyController {
  @Get('/')
  render() {
    return new RenderResponse('view name', {
      /* view data */
    });
  }
}

let app = warp({
  controllers: [MyController]
});

app.set('view engine', 'a view engine');

app.listen(3000);

Simple Response

The simple response behaves similar to just returning the value, however using the simple response you can optionally add a status code and custom headers.

new SimpleResponse(data, [status, headers])

Accessing request data

Warp offers a variety of decorators you can use to get request data, like path parameters, query, cookies and more.

Query parameters

You can access query parameters using the @Query() decorator. If you want to get a specific value from the query object you can also pass the value's name as a parameter.

@Controller('/')
class MyController {
  // Get all query parameters as an object
  @Get('/all')
  handler(@Query() everything: any) {
    // ...
  }

  // Get a single query parameter called name
  @Get('/one')
  handler(@Query('name') name: string) {
    // ...
  }
}

Headers

The header parameter behaves similar to the @Query(). When @Header() receives no argument you will get an object containing all header values, however you can optionally specify a header name as the first parameter.

@Controller('/')
class MyController {
  // Get all headers as an object
  @Get('/all')
  handler(@Header() everything: any) {
    // ...
  }

  // Get the "Content-Type" header
  @Get('/one')
  handler(@Header('Content-Type') contentType: string) {
    // ...
  }
}

Cookies

Using the cookie parameter, you can easily access cookies. When no parameter is specified, you will get an array containing all cookies. Optionally you can specify a cookie's name in the first parameter.

@Controller('/')
class MyController {
  // Get all cookies as an object
  @Get('/all')
  handler(@Cookie() everything: any) {
    // ...
  }

  // Get a single cookie called token
  @Get('/one')
  handler(@Cookie('token') token: string) {
    // ...
  }
}

Warp also has decorators for setting and clearing cookies.

@Controller('/')
class MyController {
  @Get('/')
  handler(@SetCookie() setCookie: CookieSetter, @ClearCookie() clearCookie: CookieClearer) {
    setCookie('name', 'value', {
      /* options */
    });

    clearCookie('name');
  }
}

You can use setCookie to set cookies. The first param must be the cookies name, the second param is the cookies value using the third param, you can optionally specify additional options, like an expiration date.

clearCookie can be used to remove a cookie. The first parameter is the cookies name.

Path Parameters

Warp inherits support for parameters in the URL path from Express. You can use the @Param() decorator to access all or one specify url parameter.

@Controller('/')
class MyController {
  // Get all params as an object
  @Get('/users/:userId/task/:taskId')
  handler(@Param() everything: any) {
    // ...
  }

  // Get a single param called userId
  @Get('/users/:userId')
  handler(@Param('userId') userId: string) {
    // ...
  }
}

Request Object

In some cases you might want to access Express's native request object. You can do this using the @Req() decorator. You should avoid using @Req() if possible and use the decorators listed above instead.

@Controller('/')
class MyController {
  @Get('/')
  handler(@Req() req: Request) {
    // ...
  }
}

Response Object

You might need to access Express's native response object. You can do this using the @Res() decorator. In most cases you should use Warp's response classes instead.

@Controller('/')
class MyController {
  @Get('/')
  handler(@Res() res: Response) {
    // ...
  }
}

Custom Parameters

In addition to the built in parameter decorators, Warp also offers the ability to create custom parameters.

import { BaseParam /*...*/ } from '@varld/warp';

let CustomParam = () => {
  BaseParam((req, res) => req.ip);
};

@Controller('/')
class MyController {
  @Get('/')
  handler(@CustomParam() ip: string) {
    return `Your IP-Address is: ${ip}`;
  }
}

Enabling CORS

Warp includes native support for cors. You can enable cors when creating a new Warp instance.

let app = warp({
  controllers: [
    /*...*/
  ],
  cors: true
});

// ...

cors is optional and can be a boolean or a cors-options-object. By default the cors value is false and hance cors is disabled.

Accessing the Request Body

Warp has support for accessing, validating and transforming the request body using the @Body() decorator.

@Controller('/')
class MyController {
  @Post('/')
  handler(@Body() body: any) {
    // do something with the body
  }
}

Validating The Request Body

Request body validation is achieved using class validator. Class validator makes it easy to specify validation rules using decorators.

class MyBody {
  @Length(5, 25)
  title: string;

  @Contains('hello')
  text: string;

  @IsInt()
  @Min(0)
  @Max(5)
  rating: number;
}

@Controller('/')
class MyController {
  @Post('/')
  handler(@Body() body: MyBody) {
    // The body has already been validated
    // and can be used now.
  }
}

If the body is not valid, a not acceptable error, including an array of validation-errors will be sent to the client. In this case the handler will not be executed.

Authentication

Authentication is important for many APIs. Warp includes support for standard token authentication using a header or a query parameter. By default warp is configured to support bearer authentication.

The Authenticator

Warp automatically extracts a token from the request object. If a token exists, the token will be handed to an async authenticator function, which you have to implement.

Warp expects you to return a user. This user can be of any type.

If no user is returned, warp will not execute any handlers and send an unauthorized error to the client.

let app = warp({
  controllers: [
    /*...*/
  ],
  authenticator: async (token: string, req: Request) => {
    // fetch user by token from database
    let user = await db.getUserByToken(token);

    return user;
  }
});

Enabling Authentication

Warp supports two types of authentication: global authentication, which protects every route of the warp instance and handler based authentication, using the @Authenticated() decorator.

Global authentication:

let app = warp({
  // ...
  authentication: {
    global: true
  }
});

Handler based authentication:

@Controller('/')
class MyController {
  @Get('/')
  @Authenticated()
  handler() {
    // The user is authenticated
  }
}

Accessing The User

In authenticated routes, the user (from the authenticator function) can be accessed using the @User() decorator.

@Controller('/')
class MyController {
  @Get('/')
  @Authenticated()
  handler(@User() user: MyUser) {
    // Do something with the user
  }
}

Authentication Options

By default, Warp will check the Authorization header and the access_token query parameter. Warp expects tokens in the Authorization header to be prefixed with the bearer keyword. This behavior can be altered using authentication object when creating a warp instance.

let app = warp({
  // ...
  authentication: {
    global: false,
    header: 'Authorization',
    headerScheme: 'bearer',
    query: 'access_token'
  }
});

Logging and Error Handling

You can override Warp's default error logger by providing a logger function when creating a Wrap instance. The function receives the error as its first parameter.

let app = warp({
  controllers: [...],
  logger: (error: Error | HTTPException) => {
    // do something with the error
  }
});

Dependency Injection

Warp has support for dependency injection in controllers using typedi. Injectable classes must be marked using the @Service() decorator.

@Service()
class MyService {
  doSomething() {
    return 'did something';
  }
}

@Controller('/')
class MyController {
  constructor(private myService: MyService) {}

  @Get('/')
  handler() {
    return this.myService.doSomething();
  }
}

HTTP Errors

Warp automatically catches errors thrown in middleware, param functions and http handlers. If the exception is unknown, Warp will return an internal server error. However, you can use Warp's builtin HTTP-Exceptions to specify which error should be returned to the client. Warp has an exception for all standard http errors.

import { GoneException } from '@varld/warp';

@Controller('/')
class MyController {
  @Get('/')
  gone() {
    throw new GoneException();
  }
}

The response sent to the client looks like this:

{
  "status": "410",
  "code": "gone"
}

The code can be customized using the exceptions first parameter: throw new GoneException('It is gone!').

Custom HTTP Errors

For some usecases you might need custom HTTP-Exceptions. You can create those by extending the the HttpException class.

import { HttpException } from '@varld/warp';

export class CustomException extends HttpException {
  constructor() {
    super(
      {
        code: 'A custom error',
        additionalField: 'something'
      },
      418
    );
  }
}

As you can see, the super function expects two parameters, the first one can be a string or an object which must contain the code property, but can also contain additional properties. The second parameters is an HTTP-status-code.

Testing

Testing a warp app is very simple, since warp apps are basically just Express apps. The simples way to test an api built with warp is using supertest. Supertest has also been used to test the warp library itself, take a look at the tests to learn more.

test('serves basic api', async () => {
  @Controller('/')
  class IndexController {
    @Get('/')
    sendHello() {
      return 'hello';
    }
  }

  let app = warp({
    controllers: [IndexController]
  });

  let response = await request(app).get('/');

  expect(response.status).toEqual(200);
  expect(response.text).toEqual('hello');
});

Examples

Questions and Answers

What was the inspiration for Warp?

Warp was mostly inspired by Nest.js, Tachijs and Express. All of those libraries are amazing and I greatly appreciate their maintainers! Warp would not exist without those libraries.

Can't I just use Nest.js?

Yeah! Nest.js is great, but it includes a lot of bloat that most people don't need or don't even know about, like Interceptors, or built in support for microservice. Warp aims be much simple, while offering the features most APIs need.

If you need a bunch of abstractions and enterprise-level features, then Nest.js is great. If you want to build an API (big or small) but don't need all of those features, then Warp is a great choice.

Can't I just use Express?

Sure! Warp is built on Express. Express is battle tested and very unopinionated, so you can build basically anything with Express. Since Express does not do that much by itself you will have to write a lot of boilerplate the get started. Warp comes will all of the basics, like body parsing and cors, already setup and extends Express with controllers, useful decorators and authentication.

Is Warp safe to use?

Honestly, Warp is a really simple library. All it does is glueing a few other libraries together. All of those libraries are battle tested and used by thousands (sometimes even millions) of developers. In addition to that Warp is very well tested (99% coverage). So it is safe to say that you can use Warp in production. However, if you do encounter any problems or inconveniences feel free to open an issue or poll request on Warp's GitHub.

License

MIT © Tobias Herber