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