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;