Lightweight powerful implementation of MVC(Model-View-Controller) for Node servers. Inspired and a fork from inversify-controller.
node >= 7.10
typescript >= 2.4
- Install prerequire packages
npm i koa @koa/router ajv reflect-metadata @telar/mvc
-
Install IoC container
-
e.g. Inversify Conainter
npm i inversify
-
-
Make sure to import
reflect-metadata
before using@telar/mvc
:
import "reflect-metadata";
- Must add below options in your
tsconfig.json
:
{
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
- Class model validation Validate your request model at ease using decorators with AJV.
- Action results Return http response with action result and keep your code clean!
import { Controller, path } from '@telar/mvc';
@Path('/')
class HomeController extends Controller {
}
The bind()
helper correlates your app, your container and your controllers.
import { bind, IController, Controller } from '@telar/mvc';
import * as Koa from 'koa';
import { container } from './inversify.config';
import { HomeController, UserController, ProductController } from './controllers';
// NOTE: Make sure to decorate the `Controller` class from `tela-mvc` if you are using inversify.
// This line should run right before binding and only should run ONE time!!!
decorate(injectable(), Controller);
const identifiers = {
HomeController: Symbol('HomeController'),
UserController: Symbol('UserController'),
ProductController: Symbol('ProductController'),
};
this.container.bind<IController>(identifiers.HomeController).to(HomeController);
this.container.bind<IController>(identifiers.UserController).to(UserController);
this.container.bind<IController>(identifiers.ProductController).to(ProductController);
const app = new Koa();
/**
* app: Koa app
* container: Implementation of IContainer or any IoC Container implemented `get<T>(TYPE)` method like Inversify or TypeDI
* controller identifiers: A list of controller identifiers
* NOTE: This is required and must happen early in your application, ideally right after your create your app
*/
bind(app, container, [identifiers.HomeController, identifiers.UserController, identifiers.ProductController]);
This module encourages you to declare middlewares at the controller level (vs. at the app level). This gives you the same result as if you were using app.use()
, but keeps everything in the same place.
import { before, after } from '@telar/mvc';
import { bodyParser } from 'koa-bodyparser';
import { errorHandler } from '../error-handler';
@Before(bodyParser())
@After(errorHandler())
@Path('/')
class HomeController extends Controller {
}
Below we register a middleware that's specific to the UsersController.
@Path('/users')
@Before(logMiddleware) // Only /users routes (and descendants) will be affected
class UsersController extends Controller {
}
There are times where you need to inject some properties into a middleware, which properties are accessible in the controller itself. @BeforeFactory
and @AfterFactory
allow you to differ a middleware's creation.
@BeforeFactory(function(this: HomeController) { // Notice the usage of a regular function
return logMiddlewareFactory(this.logger);
})
class HomeController extends Controller {
@inject(Identifiers.LoggerService) public logger: ILoggerService;
}
Route level middlewares are declared the exact same way as controller middlewares, using @Before
, @After
and @BeforeFactory
. @AfterFactory
is currently not supported.
class UsersController extends Controller {
private foo: string = 'bar';
@Get('/')
@Before(queryParser())
@Before(async function(this: UsersController, ctx: RouterContext, next: Next) {
console.log(this.foo); // bar
await next();
})
public async list(ctx: RouterContext) {
}
}
This module offers http decorators for all HTTP verbs. Check each decorator's documentation for specific options.
class UsersController extends Controller {
@Get('/')
public async list(ctx: RouterContext) {
}
@Get('/:id')
public async getById(ctx: RouterContext) {
}
@Post('/')
public async add(ctx: RouterContext) {
}
@Del('/:id')
public async removeById(ctx: RouterContext) {
}
// ...
}
To make the code clean for proccessing http response, we provided some functions like jsonResult
return json body, contentResult
return string body, redirectResult
redirect response. You also can return string/json type in the action function following example below.
import { jsonResult, contentResult, redirectResult } '@telar/mvc';
class UsersController extends Contoller {
@Post('/:id')
public async action1(ctx: RouterContext) {
try {
return jsonResult({success: true})
} cache(error) {
return jsonResult(error, {status: 400})
}
}
@Post('/:id')
public async action1(ctx: RouterContext) {
try {
return contentResult('it was successful')
} cache(error) {
return contentResult('it was not successful', {status: 400})
}
}
@Post('/:id')
public async action1(ctx: RouterContext) {
try {
return redirectResult('/page/success')
} cache(error) {
return redirectResult('/page/not-success')
}
}
@Post('/:id')
public async action1(ctx: RouterContext) {
try {
return 'it was successful' // convert to content result
} cache(error) {
return {
body: 'it was not successful'
status: 400,
headers: []
} // response with in plain json is valid
}
}
}
You can use decorators for your class model(from MVC)
to validate your request body. We use ajv-class-validator to change conver json to object and validate. The model object is injected in the context.
Note: to use class model validation you need to add body parser middleware, if you are using for Koa you can install koa-bodyparser
import { ActionModel, jsonResult } '@telar/mvc';
import { MaxLength, Required } from 'ajv-class-validator';
export class User {
@MaxLength(15)
public name: string
constructor(
@Required()
public id: string,
) {
this.id = id
}
}
class UsersController extends Contoller {
@Get('/:id')
@ActionModel(User) // <---- Should define to access model in `ctx: RouterContext`
public async save({ model }: Context<User>) {
if (model.validate()) {
db.save(model);
return jsonResult({success: true})
} else {
console.log(model.errors()); // output errors - if options can passed to AJV `{allErrors: true}` you will have the list of errors
return jsonResult(error, {status: 400})
}
}
}
Path parameters have to be declared in your route's path. Additionally, this module offers validation and type coercion through JSON schemas and the @Params()
decorator.
import { object, integer, requireProperties } from '@bluejay/schema';
class UsersController extends Contoller {
@Get('/:id')
@Params(requireProperties(object({ id: integer() }), ['id']))
public async getById(ctx: RouterContext) {
const { id } = req.params;
console.log(typeof id); // number, thanks to Ajv's "coerceTypes" option
}
}
An instance of AJV is created by default, if you want to pass your own, provide a ajvFactory
to the options.
import { object, integer, requireProperties } from '@bluejay/schema';
import * as Ajv from 'ajv';
const idParamSchema = object({ id: integer() });
class UsersController extends Contoller {
@Get('/:id')
@Params({
jsonSchema: requireProperties(idParamSchema, ['id']),
ajvFactory: () => new Ajv({ coerceTypes: true })
})
public async getById(ctx: RouterContext) {
const { id } = req.params;
console.log(typeof id); // number
}
}
If the params don't match jsonSchema
, a BadRequest
error will be thrown and be handled by your error middleware, meaning that your handler will never be called.
Query parameters are validated through JSON schemas using the @Query()
decorator.
All HTTP method decorators also accept an optional query
option with the same signature as the decorator.
import { object, boolean } from '@bluejay/schema';
class UsersController extends Controller {
@Get('/')
@Query(object({ active: boolean() }))
public async list(ctx: RouterContext) {
const { active } = req.query;
console.log(typeof active); // boolean | undefined (since not required)
}
}
A BadRequest
error will be thrown in case the query doesn't match the described schema, in which case your handler will never be called.
Groups allow you to group properties from the query
object and are managed by a groups
hash of the form { [groupName]: groupProperties }
.
Those come handful if your application exposes complex query parameters to the end user, and you need to pass different properties to different parts of your application.
class UsersController extends Controller {
@Query({
jsonSchema: object({ active: boolean(), token: string() }),
groups: { filters: ['active'] }
})
@Get('/')
public async list(ctx: RouterContext) {
const { filters, token } = req.query;
console.log(typeof token); // string
console.log(typeof filters); // object
console.log(typeof filters.active); // boolean | undefined (since not required)
console.log(req.query.active); // undefined (grouped properties are removed from the root query)
}
}
transform
allows you to process and modify the query string before the groups are formed. This is useful if, for example the interface your application offers to its consumers differs from the interface used within the application.
Note: The transform
hook is called before parameters are grouped. Also note that you can use transform
without groups.
const queryTransformer = (query: object) => {
query.active = query.isActive;
delete query.isActive; // Clean
return query;
};
class UsersController extends Controller {
@Query({
jsonSchema: object({ isActive: boolean() }),
transform: queryTransformer
})
@Get('/')
public async list(ctx: RouterContext) {
const { active } = req.query;
console.log(typeof active); // boolean
console.log(typeof req.query.isActive); // undefined
}
}
We currently only offer validation for JSON body. You can declare bodies of another type, but you will need to handle the validation by yourself. Bodies are managed through the @Body()
decorator.
const userSchema = object({
email: email(),
password: string(),
first_name: string({ nullable: true }),
last_name: string({ nullable: true })
});
class UsersController extends Controller {
@Post('/')
@Body(requireProperties(userSchema, ['email', 'password']))
public async add(ctx: RouterContext) {
// req.body is guaranteed to match the described schema
}
}
A BadRequest
error will be thrown in case the body doesn't match the described schema, in which case your handler will never be called.
The only validation possible for now is the content type, and this is done via the @Is
decorator, which validates the content type of the body.
class UsersController extends Controller {
@Put('/:id/picture')
@Is('image/jpg')
@Before(multer.single('file')) // Just an example, see https://www.npmjs.com/package/@koa/multer
public async changePicture(ctx: RouterContext) {
}
}
TODO