From aa9068df56c2d4c2b90ee55dd42005e1d7c5cb4c Mon Sep 17 00:00:00 2001 From: Eric Jizba Date: Thu, 11 Jan 2024 12:40:07 -0800 Subject: [PATCH] Add support for request & response clone (#209) --- src/http/HttpRequest.ts | 51 ++++++++++++++++++++++------------ src/http/HttpResponse.ts | 32 ++++++++++++++++----- test/http/HttpRequest.test.ts | 31 +++++++++++++++++++++ test/http/HttpResponse.test.ts | 36 ++++++++++++++++++++++++ types/http.d.ts | 14 +++++++++- 5 files changed, 139 insertions(+), 25 deletions(-) create mode 100644 test/http/HttpResponse.test.ts diff --git a/src/http/HttpRequest.ts b/src/http/HttpRequest.ts index 8b304f7..76cd3be 100644 --- a/src/http/HttpRequest.ts +++ b/src/http/HttpRequest.ts @@ -12,31 +12,42 @@ import { fromNullableMapping } from '../converters/fromRpcNullable'; import { nonNullProp } from '../utils/nonNull'; import { extractHttpUserFromHeaders } from './extractHttpUserFromHeaders'; +interface InternalHttpRequestInit extends RpcHttpData { + undiciRequest?: uRequest; +} + export class HttpRequest implements types.HttpRequest { readonly query: URLSearchParams; readonly params: HttpRequestParams; #cachedUser?: HttpRequestUser | null; #uReq: uRequest; - #body?: Buffer | string; - - constructor(rpcHttp: RpcHttpData) { - const url = nonNullProp(rpcHttp, 'url'); - - if (rpcHttp.body?.bytes) { - this.#body = Buffer.from(rpcHttp.body?.bytes); - } else if (rpcHttp.body?.string) { - this.#body = rpcHttp.body.string; + #init: InternalHttpRequestInit; + + constructor(init: InternalHttpRequestInit) { + this.#init = init; + + if (init.undiciRequest) { + this.#uReq = init.undiciRequest; + } else { + const url = nonNullProp(init, 'url'); + + let body: Buffer | string | undefined; + if (init.body?.bytes) { + body = Buffer.from(init.body?.bytes); + } else if (init.body?.string) { + body = init.body.string; + } + + this.#uReq = new uRequest(url, { + body, + method: nonNullProp(init, 'method'), + headers: fromNullableMapping(init.nullableHeaders, init.headers), + }); } - this.#uReq = new uRequest(url, { - body: this.#body, - method: nonNullProp(rpcHttp, 'method'), - headers: fromNullableMapping(rpcHttp.nullableHeaders, rpcHttp.headers), - }); - - this.query = new URLSearchParams(fromNullableMapping(rpcHttp.nullableQuery, rpcHttp.query)); - this.params = fromNullableMapping(rpcHttp.nullableParams, rpcHttp.params); + this.query = new URLSearchParams(fromNullableMapping(init.nullableQuery, init.query)); + this.params = fromNullableMapping(init.nullableParams, init.params); } get url(): string { @@ -86,4 +97,10 @@ export class HttpRequest implements types.HttpRequest { async text(): Promise { return this.#uReq.text(); } + + clone(): HttpRequest { + const newInit = structuredClone(this.#init); + newInit.undiciRequest = this.#uReq.clone(); + return new HttpRequest(newInit); + } } diff --git a/src/http/HttpResponse.ts b/src/http/HttpResponse.ts index 4b134c2..120dc1a 100644 --- a/src/http/HttpResponse.ts +++ b/src/http/HttpResponse.ts @@ -8,22 +8,34 @@ import { ReadableStream } from 'stream/web'; import { FormData, Headers, Response as uResponse, ResponseInit as uResponseInit } from 'undici'; import { isDefined } from '../utils/nonNull'; +interface InternalHttpResponseInit extends HttpResponseInit { + undiciResponse?: uResponse; +} + export class HttpResponse implements types.HttpResponse { readonly cookies: types.Cookie[]; readonly enableContentNegotiation: boolean; #uRes: uResponse; + #init: InternalHttpResponseInit; + + constructor(init?: InternalHttpResponseInit) { + init ??= {}; + this.#init = init; - constructor(resInit?: HttpResponseInit) { - const uResInit: uResponseInit = { status: resInit?.status, headers: resInit?.headers }; - if (isDefined(resInit?.jsonBody)) { - this.#uRes = uResponse.json(resInit?.jsonBody, uResInit); + if (init.undiciResponse) { + this.#uRes = init.undiciResponse; } else { - this.#uRes = new uResponse(resInit?.body, uResInit); + const uResInit: uResponseInit = { status: init.status, headers: init.headers }; + if (isDefined(init.jsonBody)) { + this.#uRes = uResponse.json(init.jsonBody, uResInit); + } else { + this.#uRes = new uResponse(init.body, uResInit); + } } - this.cookies = resInit?.cookies || []; - this.enableContentNegotiation = !!resInit?.enableContentNegotiation; + this.cookies = init.cookies ?? []; + this.enableContentNegotiation = !!init.enableContentNegotiation; } get status(): number { @@ -61,4 +73,10 @@ export class HttpResponse implements types.HttpResponse { async text(): Promise { return this.#uRes.text(); } + + clone(): HttpResponse { + const newInit = structuredClone(this.#init); + newInit.undiciResponse = this.#uRes.clone(); + return new HttpResponse(newInit); + } } diff --git a/test/http/HttpRequest.test.ts b/test/http/HttpRequest.test.ts index 195b965..23cddba 100644 --- a/test/http/HttpRequest.test.ts +++ b/test/http/HttpRequest.test.ts @@ -11,6 +11,37 @@ import { HttpRequest } from '../../src/http/HttpRequest'; chai.use(chaiAsPromised); describe('HttpRequest', () => { + it('clone', async () => { + const req = new HttpRequest({ + method: 'POST', + url: 'http://localhost:7071/api/helloWorld', + body: { + string: 'body1', + }, + headers: { + a: 'b', + }, + params: { + c: 'd', + }, + query: { + e: 'f', + }, + }); + const req2 = req.clone(); + expect(await req.text()).to.equal('body1'); + expect(await req2.text()).to.equal('body1'); + + expect(req.headers).to.not.equal(req2.headers); + expect(req.headers).to.deep.equal(req2.headers); + + expect(req.params).to.not.equal(req2.params); + expect(req.params).to.deep.equal(req2.params); + + expect(req.query).to.not.equal(req2.query); + expect(req.query).to.deep.equal(req2.query); + }); + describe('formData', () => { const multipartContentType = 'multipart/form-data; boundary=----WebKitFormBoundaryeJGMO2YP65ZZXRmv'; function createFormRequest(data: string, contentType: string = multipartContentType): HttpRequest { diff --git a/test/http/HttpResponse.test.ts b/test/http/HttpResponse.test.ts new file mode 100644 index 0000000..492e003 --- /dev/null +++ b/test/http/HttpResponse.test.ts @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import 'mocha'; +import * as chai from 'chai'; +import { expect } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import { HttpResponse } from '../../src/http/HttpResponse'; + +chai.use(chaiAsPromised); + +describe('HttpResponse', () => { + it('clone', async () => { + const res = new HttpResponse({ + body: 'body1', + headers: { + a: 'b', + }, + cookies: [ + { + name: 'name1', + value: 'value1', + }, + ], + }); + const res2 = res.clone(); + expect(await res.text()).to.equal('body1'); + expect(await res2.text()).to.equal('body1'); + + expect(res.headers).to.not.equal(res2.headers); + expect(res.headers).to.deep.equal(res2.headers); + + expect(res.cookies).to.not.equal(res2.cookies); + expect(res.cookies).to.deep.equal(res2.cookies); + }); +}); diff --git a/types/http.d.ts b/types/http.d.ts index 74bf68a..e918ef1 100644 --- a/types/http.d.ts +++ b/types/http.d.ts @@ -145,6 +145,12 @@ export declare class HttpRequest { * Returns a promise fulfilled with the body as a string */ readonly text: () => Promise; + + /** + * Creates a copy of the request object, with special handling of the body. + * [Learn more here](https://developer.mozilla.org/docs/Web/API/Request/clone) + */ + readonly clone: () => HttpRequest; } /** @@ -293,6 +299,12 @@ export declare class HttpResponse { * Returns a promise fulfilled with the body as a string */ readonly text: () => Promise; + + /** + * Creates a copy of the response object, with special handling of the body. + * [Learn more here](https://developer.mozilla.org/docs/Web/API/Response/clone) + */ + readonly clone: () => HttpResponse; } /** @@ -368,7 +380,7 @@ export interface HttpRequestBodyInit { bytes?: Uint8Array; /** - * The body as a buffer. You only need to specify one of the `bytes` or `string` properties + * The body as a string. You only need to specify one of the `bytes` or `string` properties */ string?: string; }