Skip to content

Commit 40440fb

Browse files
rkodevbaywet
andauthored
feat: request body compression (#1358)
* adds compression middleware * format code * fix build * feat: Handle compression differently in node and browser * feat: adds compression middleware * Fix build * Fix response * Fix build * chore: code linting Signed-off-by: Vincent Biret <vibiret@microsoft.com> * chore: linting Signed-off-by: Vincent Biret <vibiret@microsoft.com> * ci: adds unit test debug configuration Signed-off-by: Vincent Biret <vibiret@microsoft.com> * chore: linting Signed-off-by: Vincent Biret <vibiret@microsoft.com> * chore: doc linting Signed-off-by: Vincent Biret <vibiret@microsoft.com> * fix: aligns header mapping to a record Signed-off-by: Vincent Biret <vibiret@microsoft.com> * chore: linting Signed-off-by: Vincent Biret <vibiret@microsoft.com> * fix: release-please * fix: await promise --------- Signed-off-by: Vincent Biret <vibiret@microsoft.com> Co-authored-by: Vincent Biret <vibiret@microsoft.com>
1 parent bc3779e commit 40440fb

19 files changed

+572
-36
lines changed

.vscode/launch.json

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
3+
"version": "0.2.0",
4+
"configurations": [
5+
{
6+
"type": "node",
7+
"request": "launch",
8+
"name": "Debug Current Test File",
9+
"autoAttachChildProcesses": true,
10+
"skipFiles": ["<node_internals>/**", "**/node_modules/**"],
11+
"program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
12+
"args": ["run", "${relativeFile}"],
13+
"smartStep": true,
14+
"console": "integratedTerminal"
15+
},
16+
{
17+
"type": "node",
18+
"request": "launch",
19+
"name": "Run Vitest Browser",
20+
"program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
21+
"console": "integratedTerminal",
22+
"args": ["--inspect-brk", "--browser", "--no-file-parallelism"]
23+
},
24+
{
25+
"type": "chrome",
26+
"request": "attach",
27+
"name": "Attach to Vitest Browser",
28+
"port": 9229
29+
}
30+
],
31+
"compounds": [
32+
{
33+
"name": "Debug Vitest Browser",
34+
"configurations": ["Attach to Vitest Browser", "Run Vitest Browser"],
35+
"stopAll": true
36+
}
37+
]
38+
}

packages/abstractions/src/utils/enumUtils.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,19 @@
44
* See License in the project root for license information.
55
* -------------------------------------------------------------------------------------------
66
*/
7-
function reverseRecord(input: Record<PropertyKey, PropertyKey>): Record<PropertyKey, PropertyKey> {
7+
const reverseRecord = (input: Record<PropertyKey, PropertyKey>): Record<PropertyKey, PropertyKey> => {
88
const entries = Object.entries(input).map(([key, value]) => [value, key]);
99
return Object.fromEntries(entries) as Record<PropertyKey, PropertyKey>;
10-
}
10+
};
1111

1212
/**
1313
* Factory to create an UntypedString from a string.
1414
* @param stringValue The string value to lookup the enum value from.
1515
* @param originalType The type definition of the enum.
16-
* @return The enu value.
16+
* @typeparam T The type of the enum.
17+
* @return The enum value.
1718
*/
18-
export function getEnumValueFromStringValue<T>(stringValue: string, originalType: Record<PropertyKey, PropertyKey>): T | undefined {
19+
export const getEnumValueFromStringValue = <T>(stringValue: string, originalType: Record<PropertyKey, PropertyKey>): T | undefined => {
1920
const reversed: Record<PropertyKey, PropertyKey> = reverseRecord(originalType);
2021
return originalType[reversed[stringValue]] as T;
21-
}
22+
};

packages/http/fetch/src/fetchRequestAdapter.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,7 @@ export class FetchRequestAdapter implements RequestAdapter {
457457
if (response) {
458458
const responseContentLength = response.headers.get("Content-Length");
459459
if (responseContentLength) {
460-
spanForAttributes.setAttribute("http.response.body.size", parseInt(responseContentLength));
460+
spanForAttributes.setAttribute("http.response.body.size", parseInt(responseContentLength, 10));
461461
}
462462
const responseContentType = response.headers.get("Content-Type");
463463
if (responseContentType) {
@@ -527,13 +527,16 @@ export class FetchRequestAdapter implements RequestAdapter {
527527
}
528528
const requestContentLength = requestInfo.headers.tryGetValue("Content-Length");
529529
if (requestContentLength) {
530-
spanForAttributes.setAttribute("http.response.body.size", parseInt(requestContentLength[0]));
530+
spanForAttributes.setAttribute("http.response.body.size", parseInt(requestContentLength[0], 10));
531531
}
532532
const requestContentType = requestInfo.headers.tryGetValue("Content-Type");
533533
if (requestContentType) {
534534
spanForAttributes.setAttribute("http.request.header.content-type", requestContentType);
535535
}
536-
const headers: [string, string][] | undefined = requestInfo.headers ? Array.from(requestInfo.headers.keys()).map((key) => [key.toString().toLocaleLowerCase(), this.foldHeaderValue(requestInfo.headers.tryGetValue(key))]) : undefined;
536+
const headers: Record<string, string> | undefined = {};
537+
requestInfo.headers?.forEach((_, key) => {
538+
headers[key.toString().toLocaleLowerCase()] = this.foldHeaderValue(requestInfo.headers.tryGetValue(key));
539+
});
537540
const request = {
538541
method,
539542
headers,

packages/http/fetch/src/middlewares/browser/middlewareFactory.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Middleware } from "../middleware";
1111
import { ParametersNameDecodingHandler } from "../parametersNameDecodingHandler";
1212
import { RetryHandler } from "../retryHandler";
1313
import { UserAgentHandler } from "../userAgentHandler";
14+
import { CompressionHandler } from "../compressionHandler";
1415

1516
/**
1617
* @class
@@ -25,6 +26,6 @@ export class MiddlewareFactory {
2526
*/
2627
public static getDefaultMiddlewares(customFetch: (request: string, init: RequestInit) => Promise<Response> = (...args) => fetch(...args) as any): Middleware[] {
2728
// Browsers handles redirection automatically and do not require the redirectionHandler
28-
return [new RetryHandler(), new ParametersNameDecodingHandler(), new UserAgentHandler(), new HeadersInspectionHandler(), new CustomFetchHandler(customFetch)];
29+
return [new RetryHandler(), new ParametersNameDecodingHandler(), new UserAgentHandler(), new CompressionHandler(), new HeadersInspectionHandler(), new CustomFetchHandler(customFetch)];
2930
}
3031
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
/**
2+
* -------------------------------------------------------------------------------------------
3+
* Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License.
4+
* See License in the project root for license information.
5+
* -------------------------------------------------------------------------------------------
6+
*/
7+
8+
import { type RequestOption, inNodeEnv } from "@microsoft/kiota-abstractions";
9+
import { Span, trace } from "@opentelemetry/api";
10+
11+
import { getObservabilityOptionsFromRequest } from "../observabilityOptions";
12+
import type { Middleware } from "./middleware";
13+
import { CompressionHandlerOptions, CompressionHandlerOptionsKey } from "./options/compressionHandlerOptions";
14+
import type { FetchHeadersInit, FetchRequestInit } from "../utils/fetchDefinitions";
15+
import { deleteRequestHeader, getRequestHeader, setRequestHeader } from "../utils/headersUtil";
16+
17+
/**
18+
* Compress the url content.
19+
*/
20+
export class CompressionHandler implements Middleware {
21+
next: Middleware | undefined;
22+
23+
/**
24+
* @private
25+
* @static
26+
* A member holding the name of content range header
27+
*/
28+
private static readonly CONTENT_RANGE_HEADER = "Content-Range";
29+
30+
/**
31+
* @private
32+
* @static
33+
* A member holding the name of content encoding header
34+
*/
35+
private static readonly CONTENT_ENCODING_HEADER = "Content-Encoding";
36+
37+
/**
38+
* @public
39+
* @constructor
40+
* Creates a new instance of the CompressionHandler class
41+
* @param {CompressionHandlerOptions} handlerOptions The options for the compression handler.
42+
* @returns An instance of the CompressionHandler class
43+
*/
44+
public constructor(private readonly handlerOptions: CompressionHandlerOptions = new CompressionHandlerOptions()) {
45+
if (!handlerOptions) {
46+
throw new Error("handlerOptions cannot be undefined");
47+
}
48+
}
49+
50+
/**
51+
* @inheritdoc
52+
*/
53+
public execute(url: string, requestInit: RequestInit, requestOptions?: Record<string, RequestOption> | undefined): Promise<Response> {
54+
let currentOptions = this.handlerOptions;
55+
if (requestOptions?.[CompressionHandlerOptionsKey]) {
56+
currentOptions = requestOptions[CompressionHandlerOptionsKey] as CompressionHandlerOptions;
57+
}
58+
const obsOptions = getObservabilityOptionsFromRequest(requestOptions);
59+
if (obsOptions) {
60+
return trace.getTracer(obsOptions.getTracerInstrumentationName()).startActiveSpan("compressionHandler - execute", (span) => {
61+
try {
62+
span.setAttribute("com.microsoft.kiota.handler.compression.enable", currentOptions.ShouldCompress);
63+
return this.executeInternal(currentOptions, url, requestInit as FetchRequestInit, requestOptions, span);
64+
} finally {
65+
span.end();
66+
}
67+
});
68+
}
69+
return this.executeInternal(currentOptions, url, requestInit as FetchRequestInit, requestOptions);
70+
}
71+
72+
private async executeInternal(options: CompressionHandlerOptions, url: string, requestInit: FetchRequestInit, requestOptions?: Record<string, RequestOption>, span?: Span): Promise<Response> {
73+
if (!options.ShouldCompress || this.contentRangeBytesIsPresent(requestInit.headers) || this.contentEncodingIsPresent(requestInit.headers) || requestInit.body === null || requestInit.body === undefined) {
74+
return this.next?.execute(url, requestInit as RequestInit, requestOptions) ?? Promise.reject(new Error("Response is undefined"));
75+
}
76+
77+
span?.setAttribute("http.request.body.compressed", true);
78+
79+
const unCompressedBody = requestInit.body;
80+
const unCompressedBodySize = this.getRequestBodySize(unCompressedBody);
81+
82+
// compress the request body
83+
const compressedBody = await this.compressRequestBody(unCompressedBody);
84+
85+
// add Content-Encoding to request header
86+
setRequestHeader(requestInit, CompressionHandler.CONTENT_ENCODING_HEADER, "gzip");
87+
requestInit.body = compressedBody.compressedBody;
88+
89+
span?.setAttribute("http.request.body.size", compressedBody.size);
90+
91+
// execute the next middleware and check if the response code is 415
92+
let response = await this.next?.execute(url, requestInit as RequestInit, requestOptions);
93+
if (!response) {
94+
throw new Error("Response is undefined");
95+
}
96+
if (response.status === 415) {
97+
// remove the Content-Encoding header
98+
deleteRequestHeader(requestInit, CompressionHandler.CONTENT_ENCODING_HEADER);
99+
requestInit.body = unCompressedBody;
100+
span?.setAttribute("http.request.body.compressed", false);
101+
span?.setAttribute("http.request.body.size", unCompressedBodySize);
102+
103+
response = await this.next?.execute(url, requestInit as RequestInit, requestOptions);
104+
}
105+
return response !== undefined && response !== null ? Promise.resolve(response) : Promise.reject(new Error("Response is undefined"));
106+
}
107+
108+
private contentRangeBytesIsPresent(header: FetchHeadersInit | undefined): boolean {
109+
if (!header) {
110+
return false;
111+
}
112+
const contentRange = getRequestHeader(header, CompressionHandler.CONTENT_RANGE_HEADER);
113+
return contentRange?.toLowerCase().includes("bytes") ?? false;
114+
}
115+
116+
private contentEncodingIsPresent(header: FetchHeadersInit | undefined): boolean {
117+
if (!header) {
118+
return false;
119+
}
120+
return getRequestHeader(header, CompressionHandler.CONTENT_ENCODING_HEADER) !== undefined;
121+
}
122+
123+
private getRequestBodySize(body: unknown): number {
124+
if (!body) {
125+
return 0;
126+
}
127+
if (typeof body === "string") {
128+
return body.length;
129+
}
130+
if (body instanceof Blob) {
131+
return body.size;
132+
}
133+
if (body instanceof ArrayBuffer) {
134+
return body.byteLength;
135+
}
136+
if (ArrayBuffer.isView(body)) {
137+
return body.byteLength;
138+
}
139+
if (inNodeEnv() && Buffer.isBuffer(body)) {
140+
return body.byteLength;
141+
}
142+
throw new Error("Unsupported body type");
143+
}
144+
145+
private readBodyAsBytes(body: unknown): { stream: ReadableStream<Uint8Array>; size: number } {
146+
if (!body) {
147+
return { stream: new ReadableStream<Uint8Array>(), size: 0 };
148+
}
149+
150+
const uint8ArrayToStream = (uint8Array: Uint8Array): ReadableStream<Uint8Array> => {
151+
return new ReadableStream({
152+
start(controller) {
153+
controller.enqueue(uint8Array);
154+
controller.close();
155+
},
156+
});
157+
};
158+
159+
if (typeof body === "string") {
160+
return { stream: uint8ArrayToStream(new TextEncoder().encode(body)), size: body.length };
161+
}
162+
if (body instanceof Blob) {
163+
return { stream: body.stream(), size: body.size };
164+
}
165+
if (body instanceof ArrayBuffer) {
166+
return { stream: uint8ArrayToStream(new Uint8Array(body)), size: body.byteLength };
167+
}
168+
if (ArrayBuffer.isView(body)) {
169+
return { stream: uint8ArrayToStream(new Uint8Array(body.buffer, body.byteOffset, body.byteLength)), size: body.byteLength };
170+
}
171+
throw new Error("Unsupported body type");
172+
}
173+
174+
private async compressRequestBody(body: unknown): Promise<{
175+
compressedBody: ArrayBuffer | Buffer;
176+
size: number;
177+
}> {
178+
if (!inNodeEnv()) {
179+
// in browser
180+
const compressionData = this.readBodyAsBytes(body);
181+
const compressedBody = await this.compressUsingCompressionStream(compressionData.stream);
182+
return {
183+
compressedBody: compressedBody.body,
184+
size: compressedBody.size,
185+
};
186+
} else {
187+
// In Node.js
188+
const compressedBody = await this.compressUsingZlib(body);
189+
return {
190+
compressedBody,
191+
size: compressedBody.length,
192+
};
193+
}
194+
}
195+
196+
private async compressUsingZlib(body: unknown): Promise<Buffer> {
197+
// @ts-ignore
198+
const zlib = await import("zlib");
199+
return await new Promise((resolve, reject) => {
200+
zlib.gzip(body as string | ArrayBuffer | NodeJS.ArrayBufferView, (err, compressed) => {
201+
if (err) {
202+
reject(err);
203+
} else {
204+
resolve(compressed);
205+
}
206+
});
207+
});
208+
}
209+
210+
private async compressUsingCompressionStream(uint8ArrayStream: ReadableStream<Uint8Array>): Promise<{ body: ArrayBuffer; size: number }> {
211+
const compressionStream = new CompressionStream("gzip");
212+
213+
const compressedStream = uint8ArrayStream.pipeThrough<Uint8Array>(compressionStream);
214+
215+
const reader = compressedStream.getReader();
216+
const compressedChunks: Uint8Array[] = [];
217+
let totalLength = 0;
218+
219+
let result = await reader.read();
220+
while (!result.done) {
221+
const chunk = result.value;
222+
compressedChunks.push(chunk);
223+
totalLength += chunk.length;
224+
result = await reader.read();
225+
}
226+
227+
const compressedArray = new Uint8Array(totalLength);
228+
let offset = 0;
229+
for (const chunk of compressedChunks) {
230+
compressedArray.set(chunk, offset);
231+
offset += chunk.length;
232+
}
233+
234+
return {
235+
body: compressedArray.buffer,
236+
size: compressedArray.length,
237+
};
238+
}
239+
}

packages/http/fetch/src/middlewares/customFetchHandler.ts

+2-6
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,10 @@ export class CustomFetchHandler implements Middleware {
2424
*/
2525
next: Middleware | undefined;
2626

27-
constructor(private customFetch: (input: string, init: RequestInit) => Promise<Response>) {}
27+
constructor(private readonly customFetch: (input: string, init: RequestInit) => Promise<Response>) {}
2828

2929
/**
30-
* @public
31-
* @async
32-
* To execute the current middleware
33-
* @param {Context} context - The request context object
34-
* @returns A promise that resolves to nothing
30+
* @inheritdoc
3531
*/
3632
public async execute(url: string, requestInit: RequestInit): Promise<Response> {
3733
return await this.customFetch(url, requestInit);

packages/http/fetch/src/middlewares/middleware.ts

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export interface Middleware {
1212
next: Middleware | undefined;
1313

1414
/**
15+
* @public
16+
* @async
1517
* Main method of the middleware.
1618
* @param requestInit The Fetch RequestInit object.
1719
* @param url The URL of the request.

packages/http/fetch/src/middlewares/middlewareFactory.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ParametersNameDecodingHandler } from "./parametersNameDecodingHandler";
1212
import { RedirectHandler } from "./redirectHandler";
1313
import { RetryHandler } from "./retryHandler";
1414
import { UserAgentHandler } from "./userAgentHandler";
15+
import { CompressionHandler } from "./compressionHandler";
1516

1617
/**
1718
* @class
@@ -25,6 +26,6 @@ export class MiddlewareFactory {
2526
* @returns an array of the middleware handlers of the default middleware chain
2627
*/
2728
public static getDefaultMiddlewares(customFetch: (request: string, init: RequestInit) => Promise<Response> = (...args) => fetch(...args) as any): Middleware[] {
28-
return [new RetryHandler(), new RedirectHandler(), new ParametersNameDecodingHandler(), new UserAgentHandler(), new HeadersInspectionHandler(), new CustomFetchHandler(customFetch)];
29+
return [new RetryHandler(), new RedirectHandler(), new ParametersNameDecodingHandler(), new UserAgentHandler(), new CompressionHandler(), new HeadersInspectionHandler(), new CustomFetchHandler(customFetch)];
2930
}
3031
}

0 commit comments

Comments
 (0)