Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adequate support for stream responses #326

Merged
merged 13 commits into from
Nov 25, 2023
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);
});
});
});