diff --git a/docs/site/Controllers.md b/docs/site/Controllers.md index 0c3fa5a875ef..47872f8a81f5 100644 --- a/docs/site/Controllers.md +++ b/docs/site/Controllers.md @@ -214,6 +214,40 @@ export class HelloController { - `@param.query.number` specifies in the spec being generated that the route takes a parameter via query which will be a number. +## Custom Response Writing + +By default, LoopBack serialize the return value from the controller methods into +HTTP response based on the data type. For example, a string return value will be +written to HTTP response as follows: + +status code: `200` Content-Type header: `text/plain` body: `the string value` + +In some cases, it's desirable for a controller method to customize how to +produce the HTTP response. For example, we want to set the content type as +`text/html`. + +```ts +import {get} from '@loopback/openapi-v3'; +import {inject} from '@loopback/context'; +import {RestBindings, Response} from '@loopback/rest'; + +export class HomePageController { + // ... + constructor(@inject(RestBindings.Http.RESPONSE) private res: Response) { + // ... + } + @get('/') + homePage() { + const homePage = '

Hello

'; + this.res + .status(200) + .contentType('html') + .send(homePage); + return this.res; // or return ByPassResponse; + } +} +``` + ## Handling Errors in Controllers In order to specify errors for controller methods to throw, the class diff --git a/packages/rest/src/types.ts b/packages/rest/src/types.ts index cebcbaabc649..5bb9e425cbd4 100644 --- a/packages/rest/src/types.ts +++ b/packages/rest/src/types.ts @@ -51,8 +51,13 @@ export type InvokeMethod = ( * * @param response The response the response to send to. * @param result The operation result to send. + * @param contentType The content type to send */ -export type Send = (response: Response, result: OperationRetval) => void; +export type Send = ( + response: Response, + result: OperationRetval, + contentType?: string, +) => void; /** * Reject the request with an error. @@ -88,5 +93,12 @@ export type OperationArgs = any[]; export type OperationRetval = any; // tslint:enable:no-any +/** + * A special return value that can be used by controller methods to bypass + * response writing as the controller has sent the response directly using + * injected `Response` object. + */ +export const BypassResponse = Symbol('Bypass HTTP response writing'); + export type GetFromContext = (key: string) => Promise; export type BindElement = (key: string) => Binding; diff --git a/packages/rest/src/writer.ts b/packages/rest/src/writer.ts index 2c3de9002de0..a67781c9a563 100644 --- a/packages/rest/src/writer.ts +++ b/packages/rest/src/writer.ts @@ -3,7 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {OperationRetval, Response} from './types'; +import {OperationRetval, Response, BypassResponse} from './types'; import {Readable} from 'stream'; /** @@ -12,21 +12,37 @@ import {Readable} from 'stream'; * * @param response HTTP Response * @param result Result from the API to write into HTTP Response + * @param contentType Optional content type for the response */ export function writeResultToResponse( // not needed and responsibility should be in the sequence.send response: Response, // result returned back from invoking controller method result: OperationRetval, + // content type for the response + contentType?: string, ): void { - if (!result) { - response.statusCode = 204; + // Check if response writing should be bypassed when the return value + // is ByPassResponse or the response object + if (result === BypassResponse || result === response) return; + + if (result === undefined) { + response.status(204); response.end(); return; } - if (result instanceof Readable || typeof result.pipe === 'function') { - response.setHeader('Content-Type', 'application/octet-stream'); + function setContentType(defaultType: string = 'application/json') { + if (response.getHeader('Content-Type') == null) { + response.setHeader('Content-Type', contentType || defaultType); + } + } + + if ( + result instanceof Readable || + typeof (result && result.pipe) === 'function' + ) { + setContentType('application/octet-stream'); // Stream result.pipe(response); return; @@ -37,17 +53,17 @@ export function writeResultToResponse( case 'number': if (Buffer.isBuffer(result)) { // Buffer for binary data - response.setHeader('Content-Type', 'application/octet-stream'); + setContentType('application/octet-stream'); } else { // TODO(ritch) remove this, should be configurable // See https://github.com/strongloop/loopback-next/issues/436 - response.setHeader('Content-Type', 'application/json'); + setContentType(); // TODO(bajtos) handle errors - JSON.stringify can throw result = JSON.stringify(result); } break; default: - response.setHeader('Content-Type', 'text/plain'); + setContentType('text/plain'); result = result.toString(); break; } diff --git a/packages/rest/test/unit/writer.unit.ts b/packages/rest/test/unit/writer.unit.ts index e8048dd99b6a..0ea81a56a9fe 100644 --- a/packages/rest/test/unit/writer.unit.ts +++ b/packages/rest/test/unit/writer.unit.ts @@ -6,6 +6,7 @@ import {Response, writeResultToResponse} from '../..'; import {Duplex} from 'stream'; import {expect, ObservedResponse, stubExpressContext} from '@loopback/testlab'; +import {BypassResponse} from '../../src'; describe('writer', () => { let response: Response; @@ -23,6 +24,16 @@ describe('writer', () => { expect(result.payload).to.equal('Joe'); }); + it('writes string result to response as html', async () => { + writeResultToResponse(response, 'Joe', 'text/html'); + const result = await observedResponse; + + // content-type should be 'application/json' since it's set + // into the response in writer.writeResultToResponse() + expect(result.headers['content-type']).to.eql('text/html'); + expect(result.payload).to.equal('Joe'); + }); + it('writes object result to response as JSON', async () => { writeResultToResponse(response, {name: 'Joe'}); const result = await observedResponse; @@ -31,6 +42,14 @@ describe('writer', () => { expect(result.payload).to.equal('{"name":"Joe"}'); }); + it('writes null object result to response as JSON', async () => { + writeResultToResponse(response, null); + const result = await observedResponse; + + expect(result.headers['content-type']).to.eql('application/json'); + expect(result.payload).to.equal('null'); + }); + it('writes boolean result to response as json', async () => { writeResultToResponse(response, true); const result = await observedResponse; @@ -77,6 +96,51 @@ describe('writer', () => { expect(result.payload).to.equal(''); }); + it('skips response writing when the return value is ByPassResponse', async () => { + response + .status(200) + .contentType('text/html; charset=utf-8') + .send('Hi'); + writeResultToResponse(response, BypassResponse); + const result = await observedResponse; + + expect(result.statusCode).to.equal(200); + expect(result.headers).to.have.property( + 'content-type', + 'text/html; charset=utf-8', + ); + expect(result.payload).to.equal('Hi'); + }); + + it('skips response writing when the return value is a Response', async () => { + response + .status(200) + .contentType('text/html; charset=utf-8') + .send('Hi'); + writeResultToResponse(response, response); + const result = await observedResponse; + + expect(result.statusCode).to.equal(200); + expect(result.headers).to.have.property( + 'content-type', + 'text/html; charset=utf-8', + ); + expect(result.payload).to.equal('Hi'); + }); + + it('does not override content type', async () => { + response.status(200).contentType('text/html; charset=utf-8'); + writeResultToResponse(response, 'Hi'); + const result = await observedResponse; + + expect(result.statusCode).to.equal(200); + expect(result.headers).to.have.property( + 'content-type', + 'text/html; charset=utf-8', + ); + expect(result.payload).to.equal('Hi'); + }); + function setupResponseMock() { const responseMock = stubExpressContext(); response = responseMock.response;