From c3570bc96145735833175b8032464922816eb9a5 Mon Sep 17 00:00:00 2001 From: Kyrylo Avramenko <31928617+Foxy-Loxy@users.noreply.github.com> Date: Sat, 25 Nov 2023 13:26:32 +0100 Subject: [PATCH] Adequate support for stream responses (#326) * Implementation of StreamResponse * Comment type fix * Fixed type in `resolveMiddleware` method name * Updated README.md to include example usage of StreamResponse * ESLint fix * Changed import name for Node streams to be compatible with v12 * merge main --------- Co-authored-by: Podaru Dragos Co-authored-by: James Monger --- README.md | 28 +++++++++++++++++++ src/base_http_controller.ts | 13 +++++++-- src/content/httpContent.ts | 3 +- src/content/streamContent.ts | 13 +++++++++ src/decorators.ts | 2 +- src/results/StreamResult.ts | 23 +++++++++++++++ src/results/index.ts | 1 + src/server.ts | 10 ++++--- test/content/streamContent.test.ts | 45 ++++++++++++++++++++++++++++++ 9 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 src/content/streamContent.ts create mode 100644 src/results/StreamResult.ts create mode 100644 test/content/streamContent.test.ts diff --git a/README.md b/README.md index 0c64a625..1ca53834 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,7 @@ On the BaseHttpController, we provide a litany of helper methods to ease returni * InternalServerError * NotFoundResult * JsonResult +* StreamResult ```ts import { injectable, inject } from "inversify"; @@ -378,6 +379,33 @@ describe("ExampleController", () => { ``` *This example uses [Mocha](https://mochajs.org) and [Chai](http://www.chaijs.com) as a unit testing framework* +### StreamResult + +In some cases, you'll want to proxy data stream from remote resource in response. +This can be done by using the `stream` helper method provided by `BaseHttpController`. +Useful in cases when you need to return large data. + +```ts +import { inject } from "inversify"; +import { + controller, httpGet, BaseHttpController +} from "inversify-express-utils"; +import TYPES from "../constants"; +import { FileServiceInterface } from "../interfaces"; + +@controller("/cats") +export class CatController extends BaseHttpController { + @inject(TYPES.FileService) private fileService: FileServiceInterface; + + @httpGet("/image") + public async getCatImage() { + const readableStream = this.fileService.getFileStream("cat.jpeg"); + + return this.stream(content, "image/jpeg", 200); + } +} +``` + ## HttpContext The `HttpContext` property allow us to access the current request, diff --git a/src/base_http_controller.ts b/src/base_http_controller.ts index 468baf6e..cc58fca1 100644 --- a/src/base_http_controller.ts +++ b/src/base_http_controller.ts @@ -1,9 +1,10 @@ import { injectable } from 'inversify'; -import { URL } from 'url'; +import { URL } from 'node:url'; +import { Readable } from 'stream'; import { StatusCodes } from 'http-status-codes'; import { injectHttpContext } from './decorators'; import { HttpResponseMessage } from './httpResponseMessage'; -import { CreatedNegotiatedContentResult, ConflictResult, OkNegotiatedContentResult, OkResult, BadRequestErrorMessageResult, BadRequestResult, ExceptionResult, InternalServerErrorResult, NotFoundResult, RedirectResult, ResponseMessageResult, StatusCodeResult, JsonResult, } from './results'; +import { CreatedNegotiatedContentResult, ConflictResult, OkNegotiatedContentResult, OkResult, BadRequestErrorMessageResult, BadRequestResult, ExceptionResult, InternalServerErrorResult, NotFoundResult, RedirectResult, ResponseMessageResult, StatusCodeResult, JsonResult, StreamResult } from './results'; import type { HttpContext } from './interfaces'; @injectable() @@ -67,4 +68,12 @@ export class BaseHttpController { ): JsonResult { return new JsonResult(content, statusCode); } + + protected stream( + readableStream: Readable, + contentType: string, + statusCode: number = StatusCodes.OK + ): StreamResult { + return new StreamResult(readableStream, contentType, statusCode); + } } diff --git a/src/content/httpContent.ts b/src/content/httpContent.ts index 24da91c2..9b2b5b78 100644 --- a/src/content/httpContent.ts +++ b/src/content/httpContent.ts @@ -1,4 +1,5 @@ import type { OutgoingHttpHeaders } from 'node:http'; +import type { Readable } from 'stream'; export abstract class HttpContent { private _headers: OutgoingHttpHeaders = {}; @@ -8,6 +9,6 @@ export abstract class HttpContent { } public abstract readAsync(): Promise< - string | Record | Record[] + string | Record | Record[] | Readable >; } diff --git a/src/content/streamContent.ts b/src/content/streamContent.ts new file mode 100644 index 00000000..476ab139 --- /dev/null +++ b/src/content/streamContent.ts @@ -0,0 +1,13 @@ +import { Readable } from 'stream'; +import { HttpContent } from './httpContent'; + +export class StreamContent extends HttpContent { + constructor(private readonly content: Readable, private mediaType: string) { + super(); + + this.headers['content-type'] = mediaType; + } + readAsync(): Promise { + return Promise.resolve(this.content); + } +} diff --git a/src/decorators.ts b/src/decorators.ts index f23bd545..93a3a173 100644 --- a/src/decorators.ts +++ b/src/decorators.ts @@ -66,7 +66,7 @@ export function controller(path: string, ...middleware: Middleware[]) { // the controllers in the application, the metadata cannot be // attached to a controller. It needs to be attached to a global // We attach metadata to the Reflect object itself to avoid - // declaring additonal globals. Also, the Reflect is avaiable + // declaring additional globals. Also, the Reflect is available // in both node and web browsers. const previousMetadata: ControllerMetadata[] = Reflect.getMetadata( METADATA_KEY.controller, diff --git a/src/results/StreamResult.ts b/src/results/StreamResult.ts new file mode 100644 index 00000000..66509284 --- /dev/null +++ b/src/results/StreamResult.ts @@ -0,0 +1,23 @@ +import { Readable } from 'stream'; +import { IHttpActionResult } from '../interfaces'; +import { HttpResponseMessage } from '../httpResponseMessage'; +import { StreamContent } from '../content/streamContent'; + + +export class StreamResult implements IHttpActionResult { + constructor( + public readableStream: Readable, + public contentType: string, + public readonly statusCode: number, + ) { + } + + public async executeAsync(): Promise { + const response = new HttpResponseMessage(this.statusCode); + response.content = new StreamContent( + this.readableStream, + this.contentType, + ); + return Promise.resolve(response); + } +} diff --git a/src/results/index.ts b/src/results/index.ts index f2589d74..6577d292 100644 --- a/src/results/index.ts +++ b/src/results/index.ts @@ -11,3 +11,4 @@ export * from './ResponseMessageResult'; export * from './ConflictResult'; export * from './StatusCodeResult'; export * from './JsonResult'; +export * from './StreamResult'; diff --git a/src/server.ts b/src/server.ts index 2f4a5a46..795c32f2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -154,7 +154,7 @@ export class InversifyExpressServer { ); if (controllerMetadata && methodMetadata) { - const controllerMiddleware = this.resolveMidleware( + const controllerMiddleware = this.resolveMiddlewere( ...controllerMetadata.middleware, ); @@ -169,7 +169,9 @@ export class InversifyExpressServer { paramList, ); - const routeMiddleware = this.resolveMidleware(...metadata.middleware); + const routeMiddleware = this.resolveMiddlewere( + ...metadata.middleware + ); this._router[metadata.method]( `${controllerMetadata.path}${metadata.path}`, ...controllerMiddleware, @@ -183,7 +185,7 @@ export class InversifyExpressServer { this._app.use(this._routingConfig.rootPath, this._router); } - private resolveMidleware( + private resolveMiddlewere( ...middleware: Middleware[] ): RequestHandler[] { return middleware.map(middlewareItem => { @@ -405,4 +407,4 @@ export class InversifyExpressServer { private _getPrincipal(req: express.Request): Principal | null { return this._getHttpContext(req).user; } -} +} \ No newline at end of file diff --git a/test/content/streamContent.test.ts b/test/content/streamContent.test.ts new file mode 100644 index 00000000..9cc50c6e --- /dev/null +++ b/test/content/streamContent.test.ts @@ -0,0 +1,45 @@ +import { Readable, Writable } from 'stream'; +import { StreamContent } from '../../src/content/streamContent'; + +describe('StreamContent', () => { + it('should have text/plain as the set media type', () => { + const stream = new Readable(); + + const content = new StreamContent(stream, 'text/plain'); + + expect(content.headers['content-type']).toEqual('text/plain'); + }); + + it('should be able to pipe stream which was given to it', done => { + const stream = new Readable({ + read() { + this.push(Buffer.from('test')); + this.push(null); + }, + }); + + const content = new StreamContent(stream, 'text/plain'); + + void content.readAsync().then((readable: Readable) => { + const chunks: Array = []; + + let buffer: Buffer | null = null; + + readable.on('end', () => { + buffer = Buffer.concat(chunks); + + expect(buffer.toString()).toEqual('test'); + + done(); + }); + + const writableStream = new Writable({ + write(chunk) { + chunks.push(chunk as Buffer); + }, + }); + + readable.pipe(writableStream); + }); + }); +});