Skip to content

Commit

Permalink
feat(mvc): refactor to create rendering and content negotiation imple…
Browse files Browse the repository at this point in the history
…mentation

- adds `ObjectRenderer` interface/injectable, `@Renderer()` decorator, and basic renderer implementations

closes #24
  • Loading branch information
Daniel Schaffer committed Mar 1, 2019
1 parent 1c9b070 commit 9c5586b
Show file tree
Hide file tree
Showing 28 changed files with 1,121 additions and 112 deletions.
11 changes: 10 additions & 1 deletion packages/dandi/mvc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,23 @@ export * from './src/errors'
export * from './src/http.method'
export { HttpGet, HttpDelete, HttpOptions, HttpPatch, HttpPost, HttpPut } from './src/http.method.decorator'
export * from './src/http.status.code'
export * from './src/json.controller.result'
export * from './src/mime-type'
export * from './src/mime-type-info'
export * from './src/mime-types'
export * from './src/mvc.module'
export * from './src/mvc.request'
export * from './src/mvc.response'
export * from './src/mvc-response-renderer'
export * from './src/native-json-object-renderer'
export * from './src/object-renderer'
export * from './src/object-renderer-base'
export * from './src/path.param.decorator'
export * from './src/perf.record'
export * from './src/perf.recorder'
export * from './src/plain-text-object-renderer'
export * from './src/query.param.decorator'
export * from './src/renderer-decorator'
export * from './src/request-accept-types'
export * from './src/request.authorization.service'
export * from './src/request.body.decorator'
export * from './src/request.info'
Expand Down
8 changes: 3 additions & 5 deletions packages/dandi/mvc/src/controller.result.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
export interface ControllerResult {
readonly resultObject: object;
readonly contentType: string;
readonly headers?: { [key: string]: string };
readonly value: string | Promise<string>;
readonly data: object
readonly headers?: { [key: string]: string }
}

export function isControllerResult(obj: any): obj is ControllerResult {
return obj && typeof obj.value !== 'undefined' && typeof obj.contentType === 'string'
return obj && typeof obj.data !== 'undefined'
}
234 changes: 154 additions & 80 deletions packages/dandi/mvc/src/default.route.handler.spec.ts
Original file line number Diff line number Diff line change
@@ -1,123 +1,197 @@
import { Uuid } from '@dandi/common'
import { Container, NoopLogger, ResolverContext } from '@dandi/core'
import { Repository, ResolverContext } from '@dandi/core'
import { stubHarness } from '@dandi/core-testing'
import { ModelBuilderModule } from '@dandi/model-builder'
import {
HttpMethod,
JsonControllerResult,
DefaultRouteHandler,
HttpMethod, MimeTypes,
MissingPathParamError,
MvcRequest,
MvcResponse,
MvcResponseRenderer,
NativeJsonObjectRenderer,
parseMimeTypes,
PathParam,
RequestAcceptTypesProvider,
RequestController,
RequestInfo,
RequestPathParamMap,
Route,
} from '@dandi/mvc'

import { expect } from 'chai'
import { stub } from 'sinon'

import { DefaultRouteHandler } from './default.route.handler'

describe('DefaultRouteHandler', () => {
let container: Container
let handler: DefaultRouteHandler

let resolverContext: ResolverContext<any>
let controller: any
let route: Route
let requestInfo: RequestInfo
let req: any
let res: any

beforeEach(async () => {
route = {
controllerCtr: class TestClass {},
controllerMethod: 'method',
httpMethod: HttpMethod.get,
siblingMethods: new Set([HttpMethod.get]),
path: '/',
}
req = {
params: {},
query: {},
}
res = {
contentType: stub().returnsThis(),
json: stub().returnsThis(),
send: stub().returnsThis(),
setHeader: stub().returnsThis(),
status: stub().returnsThis(),
end: stub().returnsThis(),
}
requestInfo = {
requestId: new Uuid(),
performance: {
mark: stub(),
},
}
container = new Container({
providers: [
{
provide: RequestPathParamMap,
useFactory() {
return req.params
},
import { stub, createStubInstance } from 'sinon'

describe('DefaultRouteHandler', function() {

const harness = stubHarness(DefaultRouteHandler,
RequestAcceptTypesProvider,
ModelBuilderModule,
{
provide: Route,
useFactory: () => ({
controllerCtr: class TestClass {},
controllerMethod: 'method',
httpMethod: HttpMethod.get,
siblingMethods: new Set([HttpMethod.get]),
path: '/',
}),
singleton: true,
},
{
provide: MvcRequest,
useFactory: () => ({
params: {},
query: {},
get: stub().callsFake((key: string) => {
switch (key) {
case 'Accept': return MimeTypes.applicationJson
}
}),
}),
},
{
provide: MvcResponse,
useFactory: () => ({
contentType: stub().returnsThis(),
json: stub().returnsThis(),
send: stub().returnsThis(),
setHeader: stub().returnsThis(),
status: stub().returnsThis(),
end: stub().returnsThis(),
}),
},
{
provide: RequestInfo,
useFactory: () => ({
requestId: new Uuid(),
performance: {
mark: stub(),
},
ModelBuilderModule,
],
}),
singleton: true,
},
{
provide: RequestPathParamMap,
useFactory(req: MvcRequest) {
return req.params
},
deps: [MvcRequest],
},
{
provide: MvcResponseRenderer,
useFactory: () => createStubInstance(NativeJsonObjectRenderer),
},
)

beforeEach(async function() {
this.resolverContext = new ResolverContext(null, [], null, 'test')
this.handler = await harness.inject(DefaultRouteHandler)
this.route = await harness.inject(Route)
this.req = await harness.inject(MvcRequest)
this.res = await harness.inject(MvcResponse)
this.requestInfo = await harness.inject(RequestInfo)
this.renderer = await harness.inject(MvcResponseRenderer)
this.renderer.render.returns({
contentType: 'text/plain',
renderedOutput: '',
})
this.repo = Repository.for(this)
this.repo.registerProviders({
provide: ResolverContext,
useValue: this.resolverContext,
})
resolverContext = new ResolverContext(null, [], null, 'test')
handler = new DefaultRouteHandler(container, new NoopLogger())
await container.start()
this.registerController = (controller: any) => {
this.repo.registerProviders({
provide: RequestController,
useValue: controller,
})
this.route.controllerCtr = controller.constructor
}
this.invokeHandler = () => harness.invoke(this.handler, this.handler.handleRouteRequest, this.repo)
})
afterEach(() => {
handler = undefined
container = undefined
resolverContext = undefined
req = undefined
res = undefined
requestInfo = undefined
afterEach(function() {
this.repo.dispose('test over')
})

describe('handleRouteRequest', () => {
it('invokes the specified controller method', async () => {
describe('handleRouteRequest', function() {
it('invokes the specified controller method', async function() {
const spy = stub()
class TestController {
public async method(): Promise<any> {
spy()
}
}
controller = new TestController()
route.controllerCtr = TestController
await handler.handleRouteRequest(resolverContext, controller, route, req, res, requestInfo)
this.registerController(new TestController())

await this.invokeHandler()

expect(spy).to.have.been.called
})

it('calls res.send() with the result of the controller', async () => {
it('calls renderer.render() with the result of the controller', async function() {
const spy = stub()
class TestController {
public async method(): Promise<any> {
spy()
return { foo: 'yeah!' }
}
}
controller = new TestController()
await handler.handleRouteRequest(resolverContext, controller, route, req, res, requestInfo)
this.registerController(new TestController())

await this.invokeHandler()

expect(spy).to.have.been.called
expect(res.send).to.have.been.calledWith(JSON.stringify({ foo: 'yeah!' }))
expect(res.contentType).to.have.been.calledWith('application/json')
expect(this.renderer.render).to.have.been.calledWith(parseMimeTypes(MimeTypes.applicationJson), { data: { foo: 'yeah!' } })
})

it('adds any response headers specified by the controller result', async function() {
const result = { data: { foo: 'yeah!' }, headers: { 'x-fizzle-bizzle': 'okay' } }
class TestController {
public async method(): Promise<any> {
return result
}
}
this.registerController(new TestController())

await this.invokeHandler()

expect(this.res.setHeader).to.have.been.calledWith('x-fizzle-bizzle', 'okay')
})

it('adds any response headers specified by the controller result', async () => {
const result = new JsonControllerResult({ foo: 'yeah!' }, { 'x-fizzle-bizzle': 'okay' })
it('sets the contentType using the renderer result', async function() {
const result = { data: { foo: 'yeah!' }, headers: { 'x-fizzle-bizzle': 'okay' } }
class TestController {
public async method(): Promise<any> {
return result
}
}
controller = new TestController()
await handler.handleRouteRequest(resolverContext, controller, route, req, res, requestInfo)
this.registerController(new TestController())
this.renderer.render.returns({
contentType: MimeTypes.applicationJson,
})

await this.invokeHandler()

expect(this.res.contentType).to.have.been.calledWith(MimeTypes.applicationJson)
})

it('calls res.send() with the rendered output of the renderer result', async function() {

const result = { data: { foo: 'yeah!' }, headers: { 'x-fizzle-bizzle': 'okay' } }
class TestController {
public async method(): Promise<any> {
return result
}
}
this.registerController(new TestController())
this.renderer.render.returns({
renderedOutput: 'foo yeah!',
})

await this.invokeHandler()

expect(res.setHeader).to.have.been.calledWith('x-fizzle-bizzle', 'okay')
expect(this.res.send).to.have.been.calledWith('foo yeah!')
})

it('throws an error if one of the path params is missing', async function() {
Expand All @@ -127,9 +201,9 @@ describe('DefaultRouteHandler', () => {
return { message: 'OK' }
}
}
controller = new TestController()
this.registerController(new TestController())

expect(handler.handleRouteRequest(resolverContext, controller, route, req, res, requestInfo))
await expect(this.invokeHandler())
.to.be.rejectedWith(MissingPathParamError)

})
Expand Down
30 changes: 21 additions & 9 deletions packages/dandi/mvc/src/default.route.handler.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { Inject, Injectable, Logger, Optional, Resolver, ResolverContext } from '@dandi/core'

import { RequestController } from './tokens'
import { Route } from './route'
import { RouteHandler } from './route.handler'
import { ControllerResult, isControllerResult } from './controller.result'
import { ControllerResultTransformer } from './controller.result.transformer'
import { MvcRequest } from './mvc.request'
import { MvcResponse } from './mvc.response'
import { MvcResponseRenderer } from './mvc-response-renderer'
import { ObjectRenderer } from './object-renderer'
import { RequestAcceptTypes } from './request-accept-types'
import { RequestInfo } from './request.info'
import { ControllerResultTransformer } from './controller.result.transformer'
import { ControllerResult, isControllerResult } from './controller.result'
import { JsonControllerResult } from './json.controller.result'
import { Route } from './route'
import { RouteHandler } from './route.handler'
import { RequestController } from './tokens'

@Injectable(RouteHandler)
export class DefaultRouteHandler implements RouteHandler {
Expand All @@ -20,6 +22,8 @@ export class DefaultRouteHandler implements RouteHandler {
@Inject(Route) route: Route,
@Inject(MvcRequest) req: MvcRequest,
@Inject(MvcResponse) res: MvcResponse,
@Inject(RequestAcceptTypes) accept: RequestAcceptTypes,
@Inject(MvcResponseRenderer) renderer: ObjectRenderer,
@Inject(RequestInfo) requestInfo: RequestInfo,
@Inject(ControllerResultTransformer)
@Optional()
Expand All @@ -37,7 +41,11 @@ export class DefaultRouteHandler implements RouteHandler {
const result = await this.resolver.invokeInContext(resolverContext, controller, controller[route.controllerMethod])
requestInfo.performance.mark('DefaultRouteHandler.handleRouteRequest', 'afterInvokeController')

const initialResult: ControllerResult = isControllerResult(result) ? result : new JsonControllerResult(result)
const initialResult: ControllerResult = isControllerResult(result) ?
result :
{
data: result,
}
const controllerResult = await this.transformResult(initialResult, resultTransformers)

if (controllerResult.headers) {
Expand All @@ -46,10 +54,14 @@ export class DefaultRouteHandler implements RouteHandler {
})
}

requestInfo.performance.mark('DefaultRouteHandler.handleRouteRequest', 'beforeRender')
const renderResult = await renderer.render(accept, controllerResult)
requestInfo.performance.mark('DefaultRouteHandler.handleRouteRequest', 'afterRender')

requestInfo.performance.mark('DefaultRouteHandler.handleRouteRequest', 'beforeSendResponse')
res
.contentType(controllerResult.contentType)
.send(await controllerResult.value)
.contentType(renderResult.contentType)
.send(await renderResult.renderedOutput)
.end()
requestInfo.performance.mark('DefaultRouteHandler.handleRouteRequest', 'afterSendResponse')

Expand Down
Loading

0 comments on commit 9c5586b

Please sign in to comment.