Skip to content

Controllers

Marcel Kloubert edited this page Mar 14, 2023 · 1 revision

Table of contents

About []

The module provides tools, like decorators, functions and classes, that helps to setup routes and their behavior in a quite simple and powerful way.

Getting started []

To create a small API from scratch, you can start to setup a project folder with the following structure:

.
├── controllers
│   ├── index.ts
└── index.ts

The /index.ts (in the root folder of the project), will initialize and start your app / server instance.

Everything inside /controllers sub folder, will contain all controllers.

Open the /controllers/index.ts file and use the following skeleton to start:

import {
  Controller,
  ControllerBase,
  GET,
  IHttpRequest,
  IHttpResponse,
} from "@egomobile/http-server";

@Controller() // [1]
export default class IndexController extends ControllerBase {
  @GET() // [2]
  async index(request: IHttpRequest, response: IHttpResponse) {
    // [3]
    response.write("Hello, world!");
  }
}

[1] Any controller must be exported as default class, which uses the Controller() decorator, that marks classes as "controller classes".
The base path of the controller's URL is /, because its file is /controllers/index.ts and index as name will not be handled as suffix for paths.

[2] The @GET() decorator sets up a route handling a GET request.
The full path of the route will be /, because we do not define an explicit sub path here and index as method name will also be handled as empty string.

[3] The structure of each controller method is:

type HttpRequestHandler = (request: IHttpRequest<TBody extends any = any>, response: IHttpResponse) => any

The last thing you have to do, is to fill the /index.ts file with the following code, which uses the controllers() method of the server instance in app:

import createServer from "@egomobile/http-server";

async function main() {
  const app = createServer();

  app.controllers(__dirname + "/controllers");

  await app.listen(8080);
}

main().catch(console.error);

That's all!

After the time, you start the app, you should be able to open the URL localhost:8080 in your browser, which outputs Hello, world!, the result of index() method, of your IndexController class, in /controllers/index.ts.

Path mapping []

The most important thing, is to understand, how path mapping works.

The formular is quite simple:

  [1] relative path inside the root directory of your controllers
+ [2] name (without extension) of the controller's file
+ [3] name of the method or explicit path in decorator

[2] If the name of the file is index, it will be handled as empty string and not be used as part of a route path.

[3] If the name of the method is index, it will be handled as empty string and not be used as part of a route path. On the other hand: If a decorator defines a path explicitly, this will be used as name suffix for the full path.

Also important: Any file and method, which starts with a _, will be ignored.

The following code examples will demonstrate this:

Example 1 - Simple index.ts []

// path inside project folder: /controllers/index.ts

import {
  Controller,
  ControllerBase,
  GET,
  IHttpRequest,
  IHttpResponse,
} from "@egomobile/http-server";

// base path: /
//            'index' as base name of the file will be handled as empty string
@Controller()
export default class IndexController extends ControllerBase {
  @GET() // full path is: /
  //
  // 'index' as name of the method will also be handled an empty string
  // and we do not specify an explicit (sub)path in '@GET()'
  async index(request: IHttpRequest, response: IHttpResponse) {
    // ...
  }

  @GET() // full path is: /foo
  //
  // the name of method ('foo') is different to 'index'
  // and we do not specify an explicit (sub)path in '@GET()'
  async foo(request: IHttpRequest, response: IHttpResponse) {
    // ...
  }

  @GET("/baz") // full path is: /baz
  //
  // the name of method ('bar') is ignored here
  // because we specified an explicit (sub)path in '@GET()'
  //
  // this will also happen, even if the method name is 'index'
  async bar(request: IHttpRequest, response: IHttpResponse) {
    // ...
  }
}

Example 2 - File with custom name []

// path inside project folder: /controllers/abc.ts

import {
  Controller,
  ControllerBase,
  GET,
  IHttpRequest,
  IHttpResponse,
} from "@egomobile/http-server";

// base path: /abc
//            'abc' as base name of the file will be handled as prefix
@Controller()
export default class AbcController extends ControllerBase {
  @GET() // full path is: /abc
  //
  // 'index' as name of the method will also be handled an empty string
  // and we do not specify an explicit (sub)path in '@GET()'
  async index(request: IHttpRequest, response: IHttpResponse) {
    // ...
  }

  @GET() // full path is: /abc/foo
  //
  // the name of method ('foo') is different to 'index'
  // and we do not specify an explicit (sub)path in '@GET()'
  async foo(request: IHttpRequest, response: IHttpResponse) {
    // ...
  }

  @GET("/baz") // full path is: /abc/baz
  //
  // the name of method ('bar') is ignored here
  // because we specified an explicit (sub)path in '@GET()'
  //
  // this will also happen, even if the method name is 'index'
  async bar(request: IHttpRequest, response: IHttpResponse) {
    // ...
  }
}

Example 3 - Sub folder []

// path inside project folder: /controllers/abc/xyz/index.ts

import {
  Controller,
  ControllerBase,
  GET,
  IHttpRequest,
  IHttpResponse,
} from "@egomobile/http-server";

// base path: /abc/xyz
//            we are in subfolder '/abc/xyz' and 'index', which is file's base name,
//            will be handled as empty string
@Controller()
export default class AbcXyzController extends ControllerBase {
  @GET() // full path is: /abc/xyz
  //
  // 'index' as name of the method will also be handled an empty string
  // and we do not specify an explicit (sub)path in '@GET()'
  async index(request: IHttpRequest, response: IHttpResponse) {
    // ...
  }

  @GET() // full path is: /abc/xyz/foo
  //
  // the name of method ('foo') is different to 'index'
  // and we do not specify an explicit (sub)path in '@GET()'
  async foo(request: IHttpRequest, response: IHttpResponse) {
    // ...
  }

  @GET("/baz") // full path is: /abc/xyz/baz
  //
  // the name of method ('bar') is ignored here
  // because we specified an explicit (sub)path in '@GET()'
  //
  // this will also happen, even if the method name is 'index'
  async bar(request: IHttpRequest, response: IHttpResponse) {
    // ...
  }
}

Example 4 - Parameters []

// path inside project folder: /controllers/@abc/index.ts

import {
  Controller,
  ControllerBase,
  GET,
  IHttpRequest,
  IHttpResponse,
} from "@egomobile/http-server";

// base path: /:abc
//            each @ prefix in a directory's base name will be converted to a parameter
//            which can be accessed via 'params' prop of a 'request' context instance
//            of a method
@Controller()
export default class WithParamsController extends ControllerBase {
  @GET() // full path is: /:abc
  //
  // 'index' as name of the method will also be handled an empty string
  // and we do not specify an explicit (sub)path in '@GET()'
  async index(request: IHttpRequest, response: IHttpResponse) {
    response.write("value of 'abc' is: " + request.params!.abc);
  }

  @GET() // full path is: /:abc/foo
  //
  // the name of method ('foo') is different to 'index'
  // and we do not specify an explicit (sub)path in '@GET()'
  async foo(request: IHttpRequest, response: IHttpResponse) {
    response.write("value of 'abc' is: " + request.params!.abc);
  }

  @GET("/baz/:buzz") // full path is: /:abc/baz/:buzz
  //
  // the name of method ('bar') is ignored here
  // because we specified an explicit (sub)path in '@GET()'
  //
  // this will also happen, even if the method name is 'index'
  async bar(request: IHttpRequest, response: IHttpResponse) {
    response.write("value of 'abc' is: " + request.params!.abc);
    response.write("value of 'buzz' is: " + request.params!.buzz);
  }
}

Validate input []

Joi schemas []

The library includes the module joi by Sideway as exported namespace.

To get started, first import the namespace schema:

// ... other imports
import { schema } from "@egomobile/http-server";

schema is an alias for the joi module.

You can setup your definition as described in the offical documentation.

const signUpBodySchema = schema.object({
  email: schema.string().strict().email().optional(),
  username: schema.string().strict().required(),
  password1: schema.string().strict().required(),
  password2: schema.string().required().equal(schema.ref("password1")),
});

To use this definition in a controller method, simply submit it as option parameter to a decorator like @PATCH(), @POST() or @PUT():

import {
  Controller,
  ControllerBase,
  POST,
  IHttpRequest,
  IHttpResponse,
} from "@egomobile/http-server";

interface ISignUpBody {
  email?: string;
  username: string;
  password1: string;
  password2: string;
}

const signUpBodySchema = schema.object({
  email: schema.string().strict().email().optional(),
  username: schema.string().strict().required(),
  password1: schema.string().strict().required(),
  password2: schema.string().required().equal(schema.ref("password1")),
});

@Controller()
export default class SignUpController extends ControllerBase {
  @POST({
    path: "/",
    schema: signUpSchema,
  })
  async signUp(request: IHttpRequest, response: IHttpResponse) {
    // the body is a valid plain object
    // which matches all criteria in 'signUpSchema'
    const body = request.body as ISignUpBody;

    // do something with 'body'
  }
}

If a validation fails, the server simply returns a 400 status without any information.

To customize the response, create a handler and mark it with @ValidationErrorHandler() decorator:

import {
  Controller,
  ControllerBase,
  IHttpRequest,
  IHttpResponse,
  JoiValidationError,
  POST,
  ValidationErrorHandler,
} from "@egomobile/http-server";

// ...

@Controller()
export default class SignUpController extends ControllerBase {
  // ...

  @ValidationErrorHandler()
  public async handleValidationError(
    error: JoiValidationError,
    request: IHttpRequest,
    response: IHttpResponse
  ) {
    const errorMessage = Buffer.from(
      "VALIDATION ERROR: " + error.message,
      "utf8"
    );

    response.writeHead(400, {
      "Content-Length": String(errorMessage.length),
      "Content-Type": "text/plain; charset=utf-8",
    });
    response.write(errorMessage);
  }
}

KEEP IN MIND: If you submit invalid data, which cannot be parsed with JSON.parse(), you always get an "empty" 400 response and handleValidationError() would not be invoked.

Swagger schemas []

Another way to verify request inputs, is to use Swagger / Open API schemas.

One big benefit is, that you directly can combine it with build-in documentation feature:

import { Controller, ControllerBase, GET, IHttpRequest, IHttpResponse } from "@egomobile/http-server";

@Controller()
export default class TestSwaggerController extends ControllerBase {
    @GET({
        "path": "/test1",
        "documentation": {
            "parameters": [
                {
                    "in": "query",
                    "name": "foo",
                    "required": true
                }
            ],
            "responses": {}
        },
        "validateWithDocumentation": true
    })
    async test1(request: IHttpRequest, response: IHttpResponse) {
        response.write("ok");
    }
}

Middlewares []

Middlewares in controller context, can also be setuped with decorators.

It is possible to do it globally or method specific:

import {
  Controller,
  ControllerBase,
  IHttpRequest,
  IHttpResponse,
  json,
  PATCH,
  POST,
  schema,
  Use,
  validate,
} from "@egomobile/http-server";

const fooSchema = schema.object({
  // schema definition for /foo
});

const barSchema = schema.object({
  // schema definition for /bar
});

@Controller()
@Use(json()) // use JSON parser for any method ("globally")
export default class MyController extends ControllerBase {
  @POST({
    // execution order: json(), validate()
    use: [validate(fooSchema)], // additional, "method specific" middleware
    // validating input with "fooSchema"
  })
  async foo(request: IHttpRequest, response: IHttpResponse) {
    // request.body should now be a plain object
    // created from JSON string input
    // and validated with fooSchema
  }

  @PATCH({
    // execution order: json(), validate()
    use: [validate(barSchema)], // additional, "method specific" middleware
    // validating input with "barSchema"
  })
  async bar(request: IHttpRequest, response: IHttpResponse) {
    // request.body should now also be a plain object
    // created from JSON string input
    // and validated with barSchema
  }
}

Import / inject values []

The @Import() decorator is able to inject or import values into a controller instance.

First, you have to setup properties, which should be provide imported or injected values:

import createServer, {
  Controller,
  ControllerBase,
  IHttpRequest,
  IHttpResponse,
  Import,
} from "@egomobile/http-server";

@Controller()
export default class MyController extends ControllerBase {
  @Import() // [1]
  public foo!: string;

  @Import("the-answer-of-everything") // [2]
  public getAnswer!: () => number;

  @Import("currentTime") // [3]
  public now!: Date;

  // ...
}

Then, in the code, that initializes the server, you have to define the values (or the getters), grouped by keys:

function getAnswer(): number {
  return 42;
}

app.controllers(__dirname + "/controllers", {
  // [1] no function => static value for MyController.foo property
  foo: "bar",

  // [2] functions have to be wraped into a getter (MyController.getAnswer property)
  "the-answer-of-everything": () => getAnswer,

  // [3] function => dynamic value for MyController.now property
  currentTime: () => new Date(),
});

Import parameters []

The @Parameter() decorator helps to import data from a request into custom parameters / arguments of a handler.

By default, it imports data from url parameters:

import {
  Controller,
  ControllerBase,
  GET,
  IHttpRequest,
  IHttpResponse,
  Parameter
} from "@egomobile/http-server";

@Controller()
export default class TestController extends ControllerBase {
  @GET('/test/:foo')
  async fooParameterTest(
    request: IHttpRequest, response: IHttpResponse,
 
    // import 'foo' value from 'request.params'
    @Parameter() foo: string
  ) {
    // example: /test/bar
    //
    // 'foo' should have the value of 'bar'

    response.write(`foo: ${foo} (${typeof foo})\n`);
  }
}

It is also possible to import data from HTTP headers or query search parameters:

// ...

@Controller()
export default class TestController extends ControllerBase {
  @GET('/test/:foo')
  async fooParameterTest(
    request: IHttpRequest, response: IHttpResponse,
 
    // import 'x-ego-1' value from 'request.headers'
    @Parameter('header', 'x-ego-1') xEgo1: string
    // import 'ego2' value from 'request.query'
    @Parameter('query') ego2: string
  ) {
    // example:
    //
    // GET /test?ego2=bar
    // X-Ego-1: buzz
    //
    // - 'xEgo1' should have the value of 'buzz'
    // - 'ego2' should have the value of 'bar'

    response.write(`xEgo1: ${xEgo1} (${typeof xEgo1})\n`);
    response.write(`ego2: ${ego2} (${typeof ego2})\n`);
  }
}

There is also a bunch of shorthand decorators, which is shown by the following example:

import {
  Body,
  Controller,
  ControllerBase,
  Headers,
  IHttpRequest,
  IHttpResponse,
  POST,
  Query,
  Request,
  Response,
  schema,
  Url
} from "@egomobile/http-server";

interface IBody {
  email?: string | null;
  name: string;
}

interface ISomeHeaders {
  'x-ego-1': string;
  'x-ego-2': string;
  'x-ego-3': string;
}

interface ISomeQueryParams {
  'test1': string;
  'test2': string;
  'test3': string;
}

interface ISomeUrlParams {
  'ego1': string;
  'ego2': string;
  'ego3': string;
}

const mySchema = schema.object({
  'email': schema.string().strict().trim().email().allow('', null).optional(),
  'name': schema.string().strict().trim().min(1).required(),
}).required();

@Controller()
export default class TestController extends ControllerBase {
  @POST({
    path: '/test/:ego1/:ego2/:ego3',
    schema: mySchema
  })
  async fooParameterTest(
    @Request() request: IHttpRequest, @Response() response: IHttpResponse,
 
    // import value from 'request.body'
    @Body() body: IBody,

    // all values from 'request.headers'
    @Headers() allHeaders: Record<string, string>,
    // a whitelist of values from 'request.headers'
    @Headers('x-ego-1', 'x-ego-2', 'x-ego-3') someHeaders: ISomeHeaders,

    // all values from 'request.params'
    @Url() allUrlParams: Record<string, string>,
    // a whitelist of values from 'request.params'
    @Url('ego1', 'ego2', 'ego3') someUrlParams: ISomeUrlParams,

    // all values from 'request.query'
    @Query() allQueryParams: Record<string, string>,
    // a whitelist of values from 'request.query'
    @Query('test1', 'test2', 'test3') someQueryParams: ISomeQueryParams
  ) {
    // ...
  }
}

Most of the decorators also support the transformation of the input data into a new format.

To realize this, you can define data transformers:

import {
  Controller,
  ControllerBase,
  IHttpRequest,
  IHttpResponse,
  GET,
  Parameter,
  ParameterDataTransformer,
  Url
} from "@egomobile/http-server";

interface ISomeUrlParams {
  'ego1': string;
  'ego2': boolean;
  'ego3': number;
}

const transformUrlParams: ParameterDataTransformer = async ({
  key, source
}) => {
  if (key === 'ego2') {
    return Boolean(source);
  } else if (key === 'ego3') {
    return Number(source);
  }

  return source;
};

@Controller()
export default class TestController extends ControllerBase {
  @GET('/test/:ego1/:ego2/:ego3')
  async fooParameterTest(
    request: IHttpRequest, response: IHttpResponse,

    @Url(transformUrlParams) allUrlParams: Record<string, string>,

    @Parameter({ 'transformTo': 'bool' }) ego2: boolean,
    @Parameter({ 'transformTo': 'float' }) ego3: number
  ) {
    // ...
  }
}

Authorization []

With the @Authorize() decorator and its corresponding authorize option in HTTP decorators, like @GET() or @POST, you can restrict access to controller methods.

The basic information you need, is, what current user wants to access a resource.

First, you have to setup a function, in the controllers() method, which tries to find an object, which represents such an user:

// ...

app.controllers({
  authorize: {
    // try find data for an existing user
    findAuthorizedUser: async (context) => {
      // if there is no matching user, return
      // a falsy value, like (null) or (undefined)

      return {
        roles: roles, // an array of roles
      };
    },
  },
});

/// ...

If you find a valid user, based on the current request, you have to return it as object, with required information, like the list of its roles, it belongs to.

Then in the controllers, you can make use of @Authorize() decorator, for controller-wide setups, and/or the authorize option of a HTTP decorator:

import {
  Authorize,
  Controller,
  ControllerBase,
  GET,
  IHttpRequest,
  IHttpResponse,
} from "@egomobile/http-server";

@Controller()
@Authorize(["user"]) // requires 'user' role
export default class MySecuredController extends ControllerBase {
  // use global authorize
  @GET()
  async foo(request: IHttpRequest, response: IHttpResponse) {
    // you can access request.authorizedUser with authorized user
  }

  @GET({
    // define custom validator as filter expression
    //
    // s. https://github.com/m93a/filtrex
    // for more information
    authorize:
      'hasRole("admin") and hasHeader("x-my-header", "my-header-value")',
  })
  async bar(request: IHttpRequest, response: IHttpResponse) {
    // you can access request.authorizedUser with authorized user
  }
}

Example 1 - List of roles []

import {
  Authorize,
  Controller,
  ControllerBase,
  GET,
  IHttpRequest,
  IHttpResponse,
} from "@egomobile/http-server";

@Controller()
@Authorize(["user"]) // requires 'user' role
export default class MySecuredController extends ControllerBase {
  // uses global role list
  @GET()
  async fooForUser(request: IHttpRequest, response: IHttpResponse) {
    // ...
  }

  @GET({
    // uses custom list of roles
    authorize: ["admin", "superadmin"],
  })
  async fooForAdmins(request: IHttpRequest, response: IHttpResponse) {
    // ...
  }
}

This example shows, how to authorize an user, using a simple array.

Such arrays are converted into a validator function, which look like this:

async function validateRoles({ request, roles }) {
  if (request.authorizedUser) {
    // check if any role of authorizedUser
    // is part of 'roles'
    return request.authorizedUser.roles.some((ur) => roles.includes(ur));
  }

  return false;
}

Example 2 - Filter expression []

import {
  Authorize,
  Controller,
  ControllerBase,
  GET,
  IHttpRequest,
  IHttpResponse,
} from "@egomobile/http-server";

@Controller()
@Authorize('hasRole("user")') // global expression
export default class MySecuredController extends ControllerBase {
  // uses global expression
  @GET()
  async fooForUser(request: IHttpRequest, response: IHttpResponse) {
    // ...
  }

  @GET({
    // uses custom expression
    authorize: 'hasRole("admin") or hasRole("superadmin")',
  })
  async fooForAdmins(request: IHttpRequest, response: IHttpResponse) {
    // ...
  }
}

Authorization rules can also be defined as filter expressions.

Beside the included features of Filterex, this module extends it with the following functions and constants:

Functions

Name Description Example
getProp(value: any, propPath: string): any Gets the value of an object, by a prop path getProp(user, "roles.length")
hasHeader(name: string, value: any): boolean Checks if a HTTP header contains a specific value hasHeader("x-key", "secretKey")
hasRole(role: any): boolean Checks if authorizedUser contains the submitted role hasRole("admin")
log(value: any, returnValue: any = true): any Logs a value, using console.log() log(user)
str(value: any): string Returns the string representation of a value, which is not (null) and not (undefined) str(2) == "2"
trace(value: any, returnValue: any = true): any Logs a value, using console.trace() trace(user)

Constants

Name Description
request: IHttpRequest The current request context
roles: any[] The list of roles from 'authorize' settings (not from authorizedUser!)
user: IExistingAndAuthorizedUser The value of authorizedUser from request context

Example 3 - Validator function []

The following example shows how to use more complex validators:

import {
  Authorize,
  AuthorizeValidator,
  Controller,
  ControllerBase,
  GET,
  IHttpRequest,
  IHttpResponse,
} from "@egomobile/http-server";

function createAuthorizer(...requiredRoles: string[]): AuthorizeValidator {
  return async ({ request }) => {
    return request.authorizedUser?.roles.some((usersRole) =>
      requiredRoles.includes(usersRole)
    );
  };
}

@Controller()
@Authorize(createAuthorizer("user")) // global validator
export default class MySecuredController extends ControllerBase {
  // uses global validator
  @GET()
  async fooForUser(request: IHttpRequest, response: IHttpResponse) {
    // ...
  }

  @GET({
    // uses custom validator
    authorize: createAuthorizer("admin", "superadmin"),
  })
  async fooForAdmins(request: IHttpRequest, response: IHttpResponse) {
    // ...
  }
}