Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up

Highly testable dead simple web server written in Typescript 🚀

NotificationsYou must be signed in to change notification settings

BoostIO/tachijs

Repository files navigation

Build StatuscodecovNPM downloadSupported by BoostIO

Tachi(太刀) https://en.wikipedia.org/wiki/Tachi

Highly testable dead simple web server written in Typescript

  • 🏁Highly testable. (all props inreq andres are injectable so you don't have to mock at all.)
  • 🔧Highly customizable.
  • 💉Simple dependency injection.
  • async/await request handler. (like Koa without any configurations.)
  • 🏭Based on expressjs. (You can benefit from using this mature library)
  • Built-in request body validator.
  • 📐Written in Typescript.

Why?

Nest.js looks nice. But its learning curve is too stiff.(TBH, I still don't know how to redirect dynamically.) Most of people probably do not need to know howInterceptor,Pipe and other things work. It might be good for some enterprize level projects.

But using rawexpressjs is also quite painful. To test express apps, you have to usesupertest orchai-http things. If you use them, you will lose debugging and error stack while testing because they send actual http request internally. Otherwise, you have to mock up all params,req,res andnext, of RequestHandler of express.js.

To deal with the testing problem,inversify-express-utils could be a solution. But it does not support many decorators. To render with view engine like pug, we need to useres.render method. But the only solution is using@response decorator. It means you have to mock upResponse in your test. So technically it is super hard to test routes rendering view engine.

Luckily, TachiJS tackles those problems. If you have other ideas, please create an issue!!

How to use

Install tachijs

npm i tachijs reflect-metadata

Add two compiler options,experimentalDecorators andemitDecoratorMetadata, totsconfig.json.

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

Quick start

importtachijs,{controller,httpGet}from'tachijs'@controller('/')classHomeController(){// Define when this method should be used.  @httpGet('/')index(){return{message:'Hello, world!'}}}// Register `HomeController`constapp=tachijs({controllers:[HomeController]})// `app` is just an express application instanceapp.listen(8000)

Now you can accesshttp://localhost:8000/.

For other http methods, tachijs provides@httpPost,@httpPut,@httpPatch,@httpDelete,@httpOptions,@httpHead and@httpAll.

Configuring express app(Middlewares)

There are lots of ways to implement express middlewares.

Usebefore andafter options

importbodyParserfrom'body-parser'import{ConfigSetter,NotFoundException}from'tachijs'constbefore:ConfigSetter=app=>{app.use(bodyParser())}constafter:ConfigSetter=app=>{app.use('*',(req,res,next)=>{next(newNotFoundException('Page does not exist.'))})consterrorHandler:ErrorRequestHandler=(error,req,res,next)=>{const{ status=500, message}=errorres.status(status).json({      status,      message})}app.use(errorHandler)}constapp=tachijs({  before,  after})app.listen(8000)

Withoutbefore orafter options

Identically same to the above example.

importexpressfrom'express'importbodyParserfrom'body-parser'import{ConfigSetter,NotFoundException}from'tachijs'constapp=express()app.use(bodyParser())tachijs({  app})app.use('*',(req,res,next)=>{next(newNotFoundException('Page does not exist.'))})consterrorHandler:ErrorRequestHandler=(error,req,res,next)=>{const{ status=500, message}=errorres.status(status).json({    status,    message})}app.use(errorHandler)app.listen(8000)

Apply middlewares to controllers and methods

Sometimes, you might want to apply middlewares to several methods only.

import{controller,httpGet,ForbiddenException}from'tachijs'importcorsfrom'cors'import{RequestHandler}from'express'constonlyAdmin:RequestHandler=(req,res,next)=>{if(!req.user.admin){next(newForbiddenException('Only admin users can access this api'))return}next()}// Apply `cors()` to controller. Now all methods will use the middleware.@controller('/',[cors()])classHomeController(){  @httpGet('/')index(){return{message:'Hello, world!'}}// Apply `onlyAdmin` to `admin` method. This middleware will be applied to this method only.  @httpGet('/',[onlyAdmin])admin(){return{message:'Hello, world!'}}}

Configure router options

Tachijs will create and register a router for each controller.

So you can provide router options via@controller decorator.

@controller('/:name',[],{// Provide mergeParams option to express router.mergeParams:true})classHomeController{  @httpGet('/hello')// Now routes in the controller can access params.index(@reqParams('name')name:string){return`Hello,${name}`}}

Accessreq.params,req.query andreq.body via decorators

You can access them via@reqParams,@reqQuery and@reqBody.(Don't forget to applybody-parser middleware)

import{controller,httpGet,httpPost,reqParams,reqQuery,reqBody}from'tachijs'@controller('/posts')classPostController(){  @httpGet('/:postId')// `req.params.postId`asyncshow(@reqParams('postId')postId:string){constpost=awaitPost.findById(postId)return{      post}}  @httpGet('/search')// `req.query.title`asyncsearch(@reqQuery('title')title:string=''){constposts=awaitPost.find({      title})return{      posts}}  @httpPost('/')// `req.body` (`@reqBody` does not accept property keys.)asynccreate(@reqBody()body:unknown){constvalidatedBody=validate(body)constpost=awaitPost.create({      ...validatedBody})return{      post}}}

We also providereqHeaders,reqCookies andreqSession forreq.headers,req.cookies andreq.session. To know more, see our api documentation below.

Body validation

@reqBody supports validation viaclass-validator.

Please installclass-validator package first.

npm install class-validator
import{IsString}from'class-validator'classPostDTO{  @IsString()title:string  @IsString()content:string}@controller('/posts')classPostController(){  @httpPost('/')// Tachijs can access `PostDTO` via reflect-metadata.asynccreate(@reqBody()body:PostDTO){// `body` is already validated and transformed into an instance of `PostDTO`.// So we don't need any extra validation.constpost=awaitPost.create({      ...body})return{      post}}}

Custom parameter decorators!

If you're usingpassport, you should want to access user data fromreq.user.@handlerParam decorator make it possible. The decorator gets a selector which accepts express'sreq,res andnext. So all you need to do is decide what to return from thoes three parameters.

import{controller,httpGet,handlerParam}from'tachijs'@controller('/')classHomeController{  @httpGet('/')asyncshowId(@handlerParam((req,res,next)=>req.user)user:any){doSomethingWithUser(user)return{      ...}}}

If you want reusable code, please try like the below.

import{controller,httpGet,handlerParam}from'tachijs'functionreqUser(){// You can omit other next params, `res` and `next`, if you don't need for your selector.returnhandlerParam(req=>req.user)}@controller('/')classHomeController{  @httpGet('/')asyncshowId(@reqUser()user:any){doSomethingWithUser(user)return{      ...}}}
Bind methods ofreq orres before exposing

You can also pass methods ofreq orres which are augmented by express module.Some of them might need the context of them.So please bind methods before exposing like the below example.

exportfunctioncookieSetter(){returnhandlerParam((req,res)=>res.cookie.bind(res))}
design:paramtype

Moreover, tachijs exposes metadata of parameters to forth argument. So you can make your custom validator for query withclass-transformer-validator like below. (req.body is also using this.)

import{controller,httpGet,handlerParam}from'tachijs'import{IsString}from'class-validator'import{transformAndValidate}from'class-transformer-validator'functionvalidatedQuery(){returnhandlerParam((req,res,next,meta)=>{// meta.paramType is from `design:paramtypes`.// It is `Object` if the param type is unknown or any.returnmeta.paramType!==Object      ?transformAndValidate(meta.paramType,req.query)      :req.query})}// Validator classclassSearchQuery{  @IsString()title:string}@controller('/')classPostController{  @httpGet('/search')// Provide the validator class to param type.// tachijs can access it via `reflect-metadata`.search(@validatedQuery()query:SearchQuery){// Now `query` is type-safe// because it has been validated and transformed into an instance of SearchQuery.const{ title}=queryreturn{      ...}}}

To know more, see@handlerParam api documentation below.

Redirection, Rendering via pug and others...

Techinically, you don't have to accessres to response data.But, if you want to redirect or render page via pug, you need to accessres.redirect orres.render.Sadly, if you do, you have make mockup forres.

But, with tachijs, you can tackle this problem.

import{controller,httpGet,RedirectResult}from'tachijs'@controller('/')classHomeController{  @httpGet('/redirect')redirectToHome(){returnnewRedirectResult('/')}}

Now, you can test your controller like the below example.

describe('HomeController#redirectToHome',()=>{it('redirects to `/`',async()=>{// Givenconstcontroller=newHomeController()// Whenconstresult=controller.redirectToHome()// Thenexpect(result).toBeInstanceOf(RedirectResult)expect(result).toMatchObject({location:'/'})})})

There are other results too,EndResult,JSONResult,RenderResult,SendFileResult,SendResult, andSendStatusResult. Please see our api documentation below.

BaseController

If you need to use many types of result, you probably wantBaseController.Just import it once, and your controller can instantiate results easily.

import{controller,httpGet,BaseController}from'tachijs'@controller('/')// You have to extend your controller from `BaseController`classHomeControllerextendsBaseController{  @httpGet('/redirect')redirectToHome(){// This is identically same to `return new RedirectResult('/')`returnthis.redirect('/')}}

BaseController has methods for all build-in results, Please see our api documentation below.

BaseController#context

You may want to share some common methods via your own base controller. But, sadly, it is not possible to use decorators to get objects fromreq orres and services provided by@inject.

To make it possible, we introducecontext. Which exposereq,res andinject method viacontext if your controller is extended fromBaseController.

interfaceContext{req:express.Requestres:express.Responseinject<S>(key:string):S}
import{BaseController,controller,httpPost}from'tachijs'classMyBaseControllerextendsBaseController{asyncgetUserConfig(){// When unit testing, `context` is not defined.if(this.context==null){returnnewUserConfig()}const{ req, inject}=this.context// Now we can get the current user from `req`constcurrentUser=req.user// And inject any services from the container.constuserConfigService=inject<UserConfigService>(ServiceTypes.UserConfigService)returnuserConfigService.findByUserId(userId)}}@controller('/')classHomeController{  @httpGet('/settings')settings(){constuserConfig=awaitthis.getUserConfig()returnthis.render('settings',{      userConfig})}}

#httpContext,#inject and#injector will be deprecated from v1.0.0. Please use#context

Customize result

If you want to have customized result behavior, you can do it withBaseResult.BaseResult is an abstract class which coerce you to define how to end the route by providingexecute method.(Every built-in result is extended fromBaseResult.)

Let's see our implementation ofRedirectResult.

importexpressfrom'express'import{BaseResult}from'./BaseResult'exportclassRedirectResultextendsBaseResult{constructor(publicreadonlylocation:string,publicreadonlystatus?:number){super()}// tachijs will provide all what you need and execute this method.asyncexecute(req:express.Request,res:express.Response,next:express.NextFunction){if(this.status!=null)returnres.redirect(this.status,this.location)returnres.redirect(this.location)}}

Dependency injection

To make controllers more testable, tachijs provides dependency injection.

Let's think we have some mailing service,MailerService.While developing or testing, we probably don't want our server to send real e-mail everytime.

importtachijs,{controller,httpGet,httpPost,reqBody,inject,BaseController}from'tachijs'// Create enum for service typesenumServiceTypes{EmailService='EmailService',NotificationService='NotificationService'}// Abstract class coerce MailerService must have `sendEmail` method.abstractclassMailerService{abstractsendEmail(content:string):Promise<void>}// Mockup service for development and testing.classMockEmailServiceextendsMailerService{asyncsendEmail(content:string){console.log(`Not sending email.... content:${content}`)}}classEmailServiceextendsMailerService{asyncsendEmail(content:string){console.log(`Sending email.... content:${content}`)}}interfaceContainer{[ServiceTypes.EmailService]:typeofMailerService}constenvIsDev=process.env.NODE_ENV==='development'// Swapping container depends on the current environment.constcontainer:Container=envIsDev  ?{// In development env, don't send real e-mail because we use mockup.[ServiceTypes.EmailService]:MockEmailService}  :{[ServiceTypes.EmailService]:EmailService}@controller('/')classHomeControllerextendsBaseController{constructor(// Inject MailerService. The controller will get the one registered to the current container.    @inject(ServiceTypes.EmailService)privatemailer:MailerService){super()}  @httpGet('/')home(){return`<form action='/notify' method='post'><input type='text' name='message'><button>Notify</button></form>`}  @httpPost('/email')asyncsendEmail(@reqBody()body:any){awaitthis.mailer.sendEmail(body.message)returnthis.redirect('/')}}constserver=tachijs({controllers:[HomeController],// Register container  container})

So you can testHomeController#sendEmail like the below example.

describe('HomeController#sendEmail',()=>{it('sends email',async()=>{// GivenconstspyFn=jest.fn()classTestEmailServiceextendsMailerService{asyncsendEmail(content:string):Promise<void>{spyFn(content)}}constcontroller=newHomeController(newTestEmailService())// Whenconstresult=controller.sendEmail('hello')// Thenexpect(spyFn).toBeCalledWith('hello')})})

Now we don't have to worry that our controller sending e-mail for each testing.

Furthermore, you can inject other services to your service as long as they exist in the container.

classNotificationService{constructor(// When NotificationService is instantiated, MailerService will be instantiated also by tachijs.    @inject(ServiceTypes.EmailService)privatemailer:MailerService){}asyncnotifyWelcome(){awaitthis.mailer.sendEmail('Welcome!')}}
DI withouttachijs

When some testing or just writing scripts using services, you might want to use DI withouttachijs function.So we exposedInjector class which is used bytachijs.

enumServiceTypes{NameService='NameService',MyService='MyService'}classNameService{getName(){return'Test'}}classMyService{constructor(    @inject(ServiceTypes.NameService)privatenameService:NameService){}sayHello(){return`Hello,${this.nameService.getName()}`}}constcontainer={[ServiceTypes.NameService]:NameService,[ServiceTypes.MyService]:MyService}// Create injectorconstinjector=newInjector(container)// Instantiate by a keyconstmyService=injector.inject<MyService>(ServiceTypes.MyService)// Instantiate by a constructorconstmyService=injector.instantiate(MyService)

Bad practices

Please check this section too to keep your controllers testable.

Executeres.send ornext inside of controllers or@handlerParam

Please don't do that. It just make your controller untestable. If you want some special behaviors after your methods are executed, please try to implement them withBaseResult.

Do

classHelloResultextendsBaseResult{asyncexecute(req:express.Request,res:express.Response,next:express.NextFunction){res.send('Hello')}}classHomePageControllerextendsBaseController{  @httpGet('/')index(){// Now we can test it by just checking the method returns an instance of `HelloResult`.returnnewHelloResult()}}

Don't

classHomePageController{  @httpGet('/')index(@handlerParam((req,res)=>res)res:expressResponse){// We have to make mock-up for express.Response to testres.send('Hello')}}

AccessBaseController#context in your descendant controllers

It is designed to be used inside of your base controller to make unit testing easy.

Do

classMyBaseControllerextendsBaseController{doSomethingWithContext(){if(this.context==null){// on unit testingreturn}// on live}}

Don't

classHomePageControllerextendsMyBaseController{  @httpGet('/')index(){// We have to make mock-up everything to testthis.context!.req....}}

APIs

tachijs(options: TachiJSOptions): express.Application

Create and configure an express app.

TachiJSOptions

interfaceTachiJSOptions<C={}>{app?:express.Applicationbefore?:ConfigSetterafter?:ConfigSettercontrollers?:any[]container?:C}typeConfigSetter=(app:express.Application)=>void
  • app Optional. If you provide this option, tachijs will use it rather than creating new one.
  • before Optional. You can configure express app before registering controllers for applying middlewares.
  • after Optional. You can configure express app before registering controllers for error handling.
  • controllers Optional. Array of controller classes.
  • container Optional. A place for registered services.If you want to use DI, you have to register services to here first.

@controller(path: string, middlewares: RequestHandler[] = [], routerOptions: RouterOptions = {})

It marks class as a controller.

  • path Target path.
  • middlewares Optional. Array of middlewares.
  • routerOptions Optional. Express router options.

@httpMethod(method: string, path: string, middlewares: RequestHandler[] = [])

It marks method as a request handler.

  • method Target http methods,'get','post','put','patch','delete','options','head' or'all' are available. ('all' means any methods.)
  • path Target path.
  • middlewares Optional. Array of middlewares.

tachijs also provides shortcuts for@httpMethod.

  • @httpGet(path: string, middlewares: RequestHandler[] = [])
  • @httpPost(path: string, middlewares: RequestHandler[] = [])
  • @httpPut(path: string, middlewares: RequestHandler[] = [])
  • @httpPatch(path: string, middlewares: RequestHandler[] = [])
  • @httpDelete(path: string, middlewares: RequestHandler[] = [])
  • @httpOptions(path: string, middlewares: RequestHandler[] = [])
  • @httpHead(path: string, middlewares: RequestHandler[] = [])
  • @httpAll(path: string, middlewares: RequestHandler[] = [])

@handlerParam<T>(selector: HandlerParamSelector<T>)

  • selector selects a property fromreq,res,next or even ourmeta
exporttypeHandlerParamSelector<T>=(req:express.Request,res:express.Response,next:express.NextFunction,meta:HandlerParamMeta<T>)=>T
interfaceHandlerParamMeta<T>{index:numberselector:HandlerParamSelector<T>paramType:any}
  • index Number index of the parameter.
  • selector Its selector.
  • paramType metadata fromdesign:paramtypes.

@reqBody(validator?: any)

Injectreq.body.

  • validator Optional. A class with decorators ofclass-validator. tachijs will validatereq.body with it and transformreq.body into the validator class. Ifvalidator is not given but the parameter has a class validator as its param type, tachijs will use it viareflect-metadata.
import{controller,httpPost,reqBody}from'tachijs'@controller('/post')classPostController{  @httpPost('/')// Identically same to `create(@reqBody(PostDTO) post: PostDTO)`create(@reqBody()post:PostDTO){    ...}}

@reqParams(paramName?: string)

Injectreq.params or its property.

  • paramName If it is given,req.params[paramName] will be injected.

@reqQuery(paramName?: string)

Injectreq.query or its property.

  • paramName If it is given,req.query[paramName] will be injected.

@reqHeaders(paramName?: string)

Injectreq.headers or its property.

  • paramName If it is given,req.headers[paramName] will be injected.

@reqCookies(paramName?: string)

Injectreq.cookies or its property.

  • paramName If it is given,req.cookies[paramName] will be injected.

@reqSignedCookies(paramName?: string)

Injectreq.signedCookies or its property.

  • paramName If it is given,req.signedCookies[paramName] will be injected.

@cookieSetter()

Injectres.cookie method to set cookie.

@cookieClearer()

Injectres.clearCookie method to clear cookie.

@reqSession(paramName?: string)

Injectreq.session.

BaseController

A base for controller which have lots of helper methods for returning built-in results. Also, it allows another way to access properties ofreq,res andinject without any decorators.

  • #context tachijs will setreq,res andinject method to this property. So, when unit testing, it is not defined.
    • #context.req Raw express request instance
    • #context.req Raw express response instance
    • #inject<S>(key: string): S A method to access a registered service by the given key. It is almost same to@inject decorator. (@inject<ServiceTypes.SomeService> someService: SomeService =>const someService = this.inject<SomeService>(ServiceTypes.SomeService))
  • #end(data: any, encoding?: string, status?: number): EndResult
  • #json(data: any, status?: number): JSONResult
  • #redirect(location: string, status?: number): RedirectResult
  • #render(view: string, locals?: any, callback?: RenderResultCallback, status?: number): RenderResult
  • #sendFile(filePath: string, options?: any, callback?: SendFileResultCallback, status?: number): SendFileResult
  • #send(data: any, status?: number): SendResult
  • #sendStatus(status: number): SendStatusResult

Results

BaseResult

All of result classes must be extended fromBaseResult because tachijs can recognize results byinstanceof BaseResult.

It has only one abstract method which must be defined by descendant classes.

  • execute(req: express.Request, res: express.Response, next: express.NextFunction): Promise<any> tachijs will use this method to finalize response.

new EndResult(data: any, encoding?: string, status: number = 200)

tachijs will finalize response withres.status(status).end(data, encoding).

new JSONResult(data: any, status: number = 200)

tachijs will finalize response withres.status(status).json(data).

new NextResult(error?: any)

tachijs will finalize response withnext(error).

new RedirectResult(location: string, status?: number)

tachijs will finalize response withres.redirect(location) (orres.redirect(status, location) if the status is given).

new RenderResult(view: string, locals?: any, callback?: RenderResultCallback, status: number = 200)

tachijs will finalize response withres.status(status).render(view, locals, (error, html) => callback(error, html, req, res, next))

typeRenderResultCallback=(error:Error|null,html:string|null,req:express.Request,res:express.Response,next:express.NextFunction)=>void

new SendFileResult(filePath: string, options: any, callback?: SendFileResultCallback, status: number = 200)

tachijs will finalize response withres.status(status).sendFile(filePath, options, (error) => callback(error, req, res, next))

typeSendFileResultCallback=(error:Error|null,req:express.Request,res:express.Response,next:express.NextFunction)=>void

new SendResult(data: any, status: number = 200)

tachijs will finalize response withres.status(status).send(data).

new SendStatusResult(status: number)

tachijs will finalize response withres.sendStatus(status).

@inject(key: string)

Inject a registered service in container by the givenkey.

class Injector

new Injector<C>(container: C)

Instantiate an injector with container

#instantiate(Constructor: any): any

Instantiate a service constructor. If the constructor has injected services, this method instantiate and inject them by#inject method.

#inject<S = any>(key: string): S

Instantiate a service by a key from Container. If there is no service for the given key, it will throws an error.

License

MIT © Junyoung Choi


[8]ページ先頭

©2009-2025 Movatter.jp