From 525183055e8cd9f3eff523d46fc964c718e73c0a Mon Sep 17 00:00:00 2001 From: Thomas Cruveilher <38007824+Sorikairox@users.noreply.github.com> Date: Mon, 23 May 2022 16:11:27 +0200 Subject: [PATCH] Feat(renderer): support handlebar renderer engine (#18) * feat(renderer): add interfance and @Render decorator * feat(renderer): implement hbs renderer * docs(renderer): add small tutorial * fix(renderer): add file to partials folder so it is pushed * lint(*): deno fmt * refactor(view-rendere-test): remove console.log * docs(renderer): add small info * refactor(app): change actualRendererToRenderer --- README.md | 36 +++---- deno.json | 2 +- doc/renderer/README.md | 63 ++++++++++++ spec/exception-filter.test.ts | 134 ++++++++++++++------------ spec/view-renderer.test.ts | 35 +++++++ spec/views/index.hbs | 1 + spec/views/layouts/main.hbs | 1 + spec/views/partials/unusedpartial.hbs | 0 src/app.ts | 15 ++- src/renderer/decorator.ts | 20 ++++ src/renderer/handlebar.ts | 32 ++++++ src/renderer/interface.ts | 4 + src/router/router.ts | 17 +++- 13 files changed, 275 insertions(+), 85 deletions(-) create mode 100644 doc/renderer/README.md create mode 100644 spec/view-renderer.test.ts create mode 100644 spec/views/index.hbs create mode 100644 spec/views/layouts/main.hbs create mode 100644 spec/views/partials/unusedpartial.hbs create mode 100644 src/renderer/decorator.ts create mode 100644 src/renderer/handlebar.ts create mode 100644 src/renderer/interface.ts diff --git a/README.md b/README.md index f6f63c20..9b514fdb 100644 --- a/README.md +++ b/README.md @@ -22,24 +22,24 @@ Join [our discord](https://discord.gg/Q7ZHuDPgjA) ## Feature -| Task | State | -|----------------------------------------------------------------------------------|---------------------------------| -| Controllers and routes creation with Decorators (GET, PUT, DELETE, POST, PATCH) | Complete ✅ | -| Module with dependency injection | Complete ✅ | -| Request, Response and Body injection in Routes via decorators | Complete ✅ | -| Route parameter decorator factory to easily create param decorators (even async) | Complete ✅ | -| Url params injection in Controllers with Decorators | Complete ✅ | -| Guards implementations for controllers and methods | Complete ✅ | -| Global guard | Complete ✅ | -| `@Inject('token')` to use with interfaces | Complete ✅ | -| Exceptions Filters that catch all errors and `@UseFilters` decorator | Complete ✅ | -| Exceptions Filters that catch only a specific type of error | Complete ✅ | -| `OnAppClose` and `OnAppBootstrap` Lifecycle hook for injectables and controllers | Complete ✅ | -| Create documentation | Help wanted 🆘 | -| Logger | Complete ✅ | -| Starter repo | Help wanted 🆘 | -| Support Handlebars engine | Help wanted 🆘 | -| Anything we have in mind | Waiting | +| Task | State | +| -------------------------------------------------------------------------------- | ---------------------------------- | +| Controllers and routes creation with Decorators (GET, PUT, DELETE, POST, PATCH) | Complete ✅ | +| Module with dependency injection | Complete ✅ | +| Request, Response and Body injection in Routes via decorators | Complete ✅ | +| Route parameter decorator factory to easily create param decorators (even async) | Complete ✅ | +| Url params injection in Controllers with Decorators | Complete ✅ | +| Guards implementations for controllers and methods | Complete ✅ | +| Global guard | Complete ✅ | +| `@Inject('token')` to use with interfaces | Complete ✅ | +| Exceptions Filters that catch all errors and `@UseFilters` decorator | Complete ✅ | +| Exceptions Filters that catch only a specific type of error | Complete ✅ | +| `OnAppClose` and `OnAppBootstrap` Lifecycle hook for injectables and controllers | Complete ✅ | +| Create documentation | In progress ⌛ (does it even end ?) | +| Logger | Complete ✅ | +| Starter repo | Help wanted 🆘 | +| Support Handlebars engine | Help wanted 🆘 | +| Anything we have in mind | Waiting | ## Docs diff --git a/deno.json b/deno.json index 6015c862..0697838b 100644 --- a/deno.json +++ b/deno.json @@ -24,6 +24,6 @@ "emitDecoratorMetadata": true }, "tasks": { - "test": "NO_LOG=true deno test --allow-env --allow-net -A --unstable --coverage=coverage spec" + "test": "NO_LOG=true deno test --allow-env --allow-net --allow-read -A --unstable --coverage=coverage spec" } } diff --git a/doc/renderer/README.md b/doc/renderer/README.md new file mode 100644 index 00000000..5e8a7aea --- /dev/null +++ b/doc/renderer/README.md @@ -0,0 +1,63 @@ +# Rendering HTML + +Building API is cool, but sometime, we want to build a simple MVC app that will +render HTML. + +For this, Danet integrate the [Handlebars](https://handlebarsjs.com/) templating +engine. + +## Before writing any code + +#### Create the following directory at your project's root + +``` +/views +/views/layouts +/views/partials +``` + +!!!info Info +If you want to put these directory elsewhere, you can provide the path to `views` at runtime with `app.setViewEngineDir('my/path/to/views);` +!!! + +#### Create a default layout called `main.hbs` with the following content: + +```handlebars +{{{body}}} +``` + +## Let's render things now ! + +First, let's create your first template called `hello.hbs` in the `views` +directory. It will print 2 variables passed from your controller. + +```handlebars + + + + {{ title }} + + + Hello {{ name }}! + + +``` + +Now, let's tell your controller it has to render this view on a specific route: + +```ts +@Controller('nice-controller') +class MyController { + @Render('hello') + @Get('/') + renderANiceHTML() { + return { title: 'the page title', name: 'world' }; + } +} +``` + +We specify the template to use with the `@Render()` decorator, and the return +value of the route handler is passed to the template for rendering. + +Notice that the return value is an object with `title` and `name` properties, +matching `title` and `name` placeholders we used in the template. diff --git a/spec/exception-filter.test.ts b/spec/exception-filter.test.ts index f1eda52a..28808d67 100644 --- a/spec/exception-filter.test.ts +++ b/spec/exception-filter.test.ts @@ -7,99 +7,105 @@ import { Controller, Get } from '../src/router/controller/decorator.ts'; import { HttpContext } from '../src/router/router.ts'; class CustomException extends Error { - public customField = 'i am a custom field'; - constructor(text: string) { - super(text); - } + public customField = 'i am a custom field'; + constructor(text: string) { + super(text); + } } class ErrorFilter implements ExceptionFilter { - catch(exception: any, context: HttpContext) { - context.response.body = { - wePassedInFilterCatchingAllErrors: true, - }; - } + catch(exception: any, context: HttpContext) { + context.response.body = { + wePassedInFilterCatchingAllErrors: true, + }; + } } @Catch(CustomException) class CustomErrorFilter implements ExceptionFilter { - catch(exception: any, context: HttpContext) { - context.response.body = { - wePassedInFilterCatchingOnlySomeError: true, - }; - } + catch(exception: any, context: HttpContext) { + context.response.body = { + wePassedInFilterCatchingOnlySomeError: true, + }; + } } @UseFilter(ErrorFilter) @Controller('') class ControllerWithFilter { - - @Get('/') - simpleGet() { - throw Error('an error'); - } + @Get('/') + simpleGet() { + throw Error('an error'); + } } @Controller('custom-error') class ControllerWithCustomFilter { - @UseFilter(CustomErrorFilter) - @Get('') - customError() { - throw new CustomException('an error'); - } + @UseFilter(CustomErrorFilter) + @Get('') + customError() { + throw new CustomException('an error'); + } - @Get('unexpected-error') - unexpectedError() { - throw Error('unexpected'); - } + @Get('unexpected-error') + unexpectedError() { + throw Error('unexpected'); + } } @Module({ - controllers: [ControllerWithFilter, ControllerWithCustomFilter] + controllers: [ControllerWithFilter, ControllerWithCustomFilter], }) class ModuleWithFilter {} -for (const testName of ['Exception Filter with @Catch catch related errors', 'Method exception filter works']) { - Deno.test(testName, async () => { - const app = new DanetApplication(); - await app.init(ModuleWithFilter); - const port = (await app.listen(0)).port; +for ( + const testName of [ + 'Exception Filter with @Catch catch related errors', + 'Method exception filter works', + ] +) { + Deno.test(testName, async () => { + const app = new DanetApplication(); + await app.init(ModuleWithFilter); + const port = (await app.listen(0)).port; - const res = await fetch(`http://localhost:${port}/custom-error`, { - method: 'GET', - }); - const json = await res.json(); - assertEquals(json, { - wePassedInFilterCatchingOnlySomeError: true, - }); - await app.close(); - }); + const res = await fetch(`http://localhost:${port}/custom-error`, { + method: 'GET', + }); + const json = await res.json(); + assertEquals(json, { + wePassedInFilterCatchingOnlySomeError: true, + }); + await app.close(); + }); } Deno.test('Controller filter works', async () => { - const app = new DanetApplication(); - await app.init(ModuleWithFilter); - const port = (await app.listen(0)).port; + const app = new DanetApplication(); + await app.init(ModuleWithFilter); + const port = (await app.listen(0)).port; - const res = await fetch(`http://localhost:${port}`, { - method: 'GET', - }); - const json = await res.json(); - assertEquals(json, { - wePassedInFilterCatchingAllErrors: true, - }); - await app.close(); + const res = await fetch(`http://localhost:${port}`, { + method: 'GET', + }); + const json = await res.json(); + assertEquals(json, { + wePassedInFilterCatchingAllErrors: true, + }); + await app.close(); }); Deno.test('throw 500 on unexpected error', async () => { - const app = new DanetApplication(); - await app.init(ModuleWithFilter); - const port = (await app.listen(0)).port; + const app = new DanetApplication(); + await app.init(ModuleWithFilter); + const port = (await app.listen(0)).port; - const res = await fetch(`http://localhost:${port}/custom-error/unexpected-error`, { - method: 'GET', - }); - assertEquals(500, res.status); - await res.json(); - await app.close(); + const res = await fetch( + `http://localhost:${port}/custom-error/unexpected-error`, + { + method: 'GET', + }, + ); + assertEquals(500, res.status); + await res.json(); + await app.close(); }); - diff --git a/spec/view-renderer.test.ts b/spec/view-renderer.test.ts new file mode 100644 index 00000000..3e1e6b14 --- /dev/null +++ b/spec/view-renderer.test.ts @@ -0,0 +1,35 @@ +import { assertEquals } from 'https://deno.land/std@0.135.0/testing/asserts.ts'; +import * as path from 'https://deno.land/std@0.135.0/path/mod.ts'; +import { DanetApplication } from '../src/app.ts'; +import { Module } from '../src/module/decorator.ts'; +import { Render } from '../src/renderer/decorator.ts'; +import { Controller, Get } from '../src/router/controller/decorator.ts'; + +@Controller('nice-controller') +class SimpleController { + @Render('index') + @Get('/') + simpleGet() { + return { title: 'my title' }; + } +} + +@Module({ + controllers: [SimpleController], +}) +class MyModule {} + +Deno.test('Hbs renderer', async () => { + const app = new DanetApplication(); + await app.init(MyModule); + const viewPath = path.dirname(path.fromFileUrl(import.meta.url)) + '/views'; + app.setViewEngineDir(viewPath); + const port = (await app.listen(0)).port; + + const res = await fetch(`http://localhost:${port}/nice-controller`, { + method: 'GET', + }); + const text = await res.text(); + assertEquals(text.includes('my title'), true); + await app.close(); +}); diff --git a/spec/views/index.hbs b/spec/views/index.hbs new file mode 100644 index 00000000..ab17eedc --- /dev/null +++ b/spec/views/index.hbs @@ -0,0 +1 @@ +{{title}} diff --git a/spec/views/layouts/main.hbs b/spec/views/layouts/main.hbs new file mode 100644 index 00000000..38e54992 --- /dev/null +++ b/spec/views/layouts/main.hbs @@ -0,0 +1 @@ +{{{body}}} diff --git a/spec/views/partials/unusedpartial.hbs b/spec/views/partials/unusedpartial.hbs new file mode 100644 index 00000000..e69de29b diff --git a/src/app.ts b/src/app.ts index 7fa2ce52..cfa3a01c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,5 +1,7 @@ import { ApplicationListenEvent } from 'https://deno.land/x/oak@v10.5.1/application.ts'; import { Application, Router } from 'https://deno.land/x/oak@v10.5.1/mod.ts'; +import { FilterExecutor } from './exception/filter/executor.ts'; +import { GuardExecutor } from './guard/executor.ts'; import { HookExecutor } from './hook/executor.ts'; import { hookName } from './hook/interfaces.ts'; @@ -7,6 +9,7 @@ import { Injector } from './injector/injector.ts'; import { Logger } from './logger.ts'; import { MetadataHelper } from './metadata/helper.ts'; import { moduleMetadataKey, ModuleOptions } from './module/decorator.ts'; +import { HandlebarRenderer } from './renderer/handlebar.ts'; import { DanetRouter } from './router/router.ts'; import { Constructor } from './utils/constructor.ts'; @@ -14,7 +17,13 @@ export class DanetApplication { private app = new Application(); private injector = new Injector(); private hookExecutor = new HookExecutor(this.injector); - public danetRouter = new DanetRouter(this.injector); + private renderer = new HandlebarRenderer(); + public danetRouter = new DanetRouter( + this.injector, + new GuardExecutor(this.injector), + new FilterExecutor(), + this.renderer, + ); private controller: AbortController = new AbortController(); private logger: Logger = new Logger('DanetApplication'); @@ -69,4 +78,8 @@ export class DanetApplication { get router(): Router { return this.danetRouter.router; } + + setViewEngineDir(path: string) { + this.renderer.setRootDir(path); + } } diff --git a/src/renderer/decorator.ts b/src/renderer/decorator.ts new file mode 100644 index 00000000..7aa67250 --- /dev/null +++ b/src/renderer/decorator.ts @@ -0,0 +1,20 @@ +import { MetadataHelper } from '../metadata/helper.ts'; +import { ControllerConstructor } from '../router/controller/constructor.ts'; + +export const rendererViewFile = 'rendererViewFile'; +export const Render = (fileName: string) => + ( + // deno-lint-ignore ban-types + target: ControllerConstructor | Object, + propertyKey?: string | symbol, + // deno-lint-ignore no-explicit-any + descriptor?: TypedPropertyDescriptor, + ) => { + if (propertyKey && descriptor) { + MetadataHelper.setMetadata( + rendererViewFile, + fileName, + descriptor.value, + ); + } + }; diff --git a/src/renderer/handlebar.ts b/src/renderer/handlebar.ts new file mode 100644 index 00000000..c09ba302 --- /dev/null +++ b/src/renderer/handlebar.ts @@ -0,0 +1,32 @@ +import { Renderer } from './interface.ts'; +import { Handlebars } from 'https://deno.land/x/handlebars/mod.ts'; + +const defaultOption = { + baseDir: 'views', + extname: '.hbs', + layoutsDir: 'layouts/', + partialsDir: 'partials/', + cachePartials: true, + defaultLayout: 'main', + helpers: undefined, + compilerOptions: undefined, +}; + +export class HandlebarRenderer implements Renderer { + private hbs: Handlebars; + + constructor() { + this.hbs = new Handlebars(defaultOption); + } + + setRootDir(rootDirectory: string) { + this.hbs = new Handlebars({ + ...defaultOption, + baseDir: rootDirectory, + }); + } + + render(filename: string, data: Record): Promise { + return this.hbs.renderView(filename, data); + } +} diff --git a/src/renderer/interface.ts b/src/renderer/interface.ts new file mode 100644 index 00000000..9ee3756a --- /dev/null +++ b/src/renderer/interface.ts @@ -0,0 +1,4 @@ +export interface Renderer { + setRootDir(directory: string): void; + render(filename: string, data: unknown): Promise; +} diff --git a/src/router/router.ts b/src/router/router.ts index 15f6ddf3..9c70977e 100644 --- a/src/router/router.ts +++ b/src/router/router.ts @@ -9,6 +9,9 @@ import { hookName } from '../hook/interfaces.ts'; import { Injector } from '../injector/injector.ts'; import { Logger } from '../logger.ts'; import { MetadataHelper } from '../metadata/helper.ts'; +import { rendererViewFile } from '../renderer/decorator.ts'; +import { HandlebarRenderer } from '../renderer/handlebar.ts'; +import { Renderer } from '../renderer/interface.ts'; import { Constructor } from '../utils/constructor.ts'; import { ControllerConstructor } from './controller/constructor.ts'; import { @@ -30,6 +33,7 @@ export class DanetRouter { private injector: Injector, private guardExecutor: GuardExecutor = new GuardExecutor(injector), private filterExecutor: FilterExecutor = new FilterExecutor(), + private viewRenderer: Renderer = new HandlebarRenderer(), ) { } methodsMap = new Map([ @@ -110,7 +114,18 @@ export class DanetRouter { | Record | string; if (response) { - context.response.body = response; + const fileName = MetadataHelper.getMetadata( + rendererViewFile, + ControllerMethod, + ); + if (fileName) { + context.response.body = await this.viewRenderer.render( + fileName, + response, + ); + } else { + context.response.body = response; + } } } catch (error) { const errorIsCaught = await this.filterExecutor