Skip to content

Commit

Permalink
Adequate support for stream responses (#326)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
Co-authored-by: James Monger <[email protected]>
  • Loading branch information
3 people authored Nov 25, 2023
1 parent ebb986f commit c3570bc
Show file tree
Hide file tree
Showing 9 changed files with 130 additions and 8 deletions.
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 11 additions & 2 deletions src/base_http_controller.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -67,4 +68,12 @@ export class BaseHttpController {
): JsonResult<T> {
return new JsonResult(content, statusCode);
}

protected stream(
readableStream: Readable,
contentType: string,
statusCode: number = StatusCodes.OK
): StreamResult {
return new StreamResult(readableStream, contentType, statusCode);
}
}
3 changes: 2 additions & 1 deletion src/content/httpContent.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { OutgoingHttpHeaders } from 'node:http';
import type { Readable } from 'stream';

export abstract class HttpContent {
private _headers: OutgoingHttpHeaders = {};
Expand All @@ -8,6 +9,6 @@ export abstract class HttpContent {
}

public abstract readAsync(): Promise<
string | Record<string, unknown> | Record<string, unknown>[]
string | Record<string, unknown> | Record<string, unknown>[] | Readable
>;
}
13 changes: 13 additions & 0 deletions src/content/streamContent.ts
Original file line number Diff line number Diff line change
@@ -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<Readable> {
return Promise.resolve(this.content);
}
}
2 changes: 1 addition & 1 deletion src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions src/results/StreamResult.ts
Original file line number Diff line number Diff line change
@@ -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<HttpResponseMessage> {
const response = new HttpResponseMessage(this.statusCode);
response.content = new StreamContent(
this.readableStream,
this.contentType,
);
return Promise.resolve(response);
}
}
1 change: 1 addition & 0 deletions src/results/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export * from './ResponseMessageResult';
export * from './ConflictResult';
export * from './StatusCodeResult';
export * from './JsonResult';
export * from './StreamResult';
10 changes: 6 additions & 4 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export class InversifyExpressServer {
);

if (controllerMetadata && methodMetadata) {
const controllerMiddleware = this.resolveMidleware(
const controllerMiddleware = this.resolveMiddlewere(
...controllerMetadata.middleware,
);

Expand All @@ -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,
Expand All @@ -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 => {
Expand Down Expand Up @@ -405,4 +407,4 @@ export class InversifyExpressServer {
private _getPrincipal(req: express.Request): Principal | null {
return this._getHttpContext(req).user;
}
}
}
45 changes: 45 additions & 0 deletions test/content/streamContent.test.ts
Original file line number Diff line number Diff line change
@@ -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<Buffer> = [];

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);
});
});
});

0 comments on commit c3570bc

Please sign in to comment.