Skip to content

Commit 2e4885f

Browse files
siddsrivtrivikr
authored andcommitted
feat(middleware-compression): add middleware for request compression
1 parent 08f6904 commit 2e4885f

File tree

10 files changed

+222
-0
lines changed

10 files changed

+222
-0
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "middleware-compression",
3+
"version": "1.0.0",
4+
"description": "Package that implements the compression middleware for request bodies. ",
5+
"main": "index.js",
6+
"scripts": {
7+
"test": "jest"
8+
},
9+
"repository": {
10+
"type": "git",
11+
"url": "git+https://github.com/aws/aws-sdk-js-v3.git"
12+
},
13+
"keywords": [
14+
"middleware",
15+
"compression",
16+
"gzip"
17+
],
18+
"author": "AWS SDK for JavaScript Team",
19+
"license": "Apache-2.0",
20+
"bugs": {
21+
"url": "https://github.com/aws/aws-sdk-js-v3/issues"
22+
},
23+
"homepage": "https://github.com/aws/aws-sdk-js-v3#readme"
24+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { compress } from './compress';
2+
import * as zlib from 'zlib';
3+
import { promisify } from 'util';
4+
5+
jest.mock('zlib', () => ({
6+
gzip: jest.fn((buffer, options, callback) => callback(null, 'compressed data')),
7+
}));
8+
9+
jest.mock('util', () => ({
10+
promisify: jest.fn(),
11+
}));
12+
13+
describe('compress', () => {
14+
const mockZlibGzip = zlib.gzip as jest.MockedFunction<typeof zlib.gzip>;
15+
const mockZlibDeflate = zlib.deflate as jest.MockedFunction<typeof zlib.deflate>;
16+
const mockPromisify = promisify as jest.MockedFunction<typeof promisify>;
17+
18+
beforeEach(() => {
19+
jest.clearAllMocks();
20+
});
21+
22+
it('should compress data with gzip', async () => {
23+
const data = 'test data';
24+
const compressedData = Buffer.from('compressed data');
25+
mockZlibGzip.mockImplementation((buffer, callback) => callback(null, compressedData));
26+
mockPromisify.mockImplementation(() => Promise.resolve(compressedData));
27+
28+
const result = await compress(data, 'gzip');
29+
30+
expect(mockZlibGzip).toHaveBeenCalledWith(Buffer.from(data), expect.any(Function));
31+
expect(result).toEqual(compressedData);
32+
});
33+
34+
it('should throw an error for unsupported compression algorithm', async () => {
35+
const data = 'test data';
36+
37+
await expect(compress(data, 'unsupported' as any)).rejects.toThrow('Unsupported compression algorithm');
38+
});
39+
40+
it('should throw an error if body cannot be buffered', async () => {
41+
const data = {};
42+
43+
await expect(compress(data as any, 'gzip')).rejects.toThrow('Body cannot be buffered');
44+
});
45+
46+
it('should throw an error if compression fails', async () => {
47+
const data = 'test data';
48+
const error = new Error('Compression failed');
49+
mockZlibGzip.mockImplementation((buffer, callback) => callback(error));
50+
mockPromisify.mockImplementation(() => Promise.reject(error));
51+
52+
await expect(compress(data, 'gzip')).rejects.toThrow('Compression failed');
53+
});
54+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import zlib from 'zlib';
2+
import { promisify } from 'util';
3+
import { CompressionAlgorithm } from './constants';
4+
5+
// zlib.gzip( buffer, options, callback )
6+
7+
// reject stream if it goes into compress function
8+
export const compress = async (body: Uint8Array, algorithm: CompressionAlgorithm): Promise<Uint8Array> => {
9+
// switch-case for algorithms: default -> unsupported
10+
if (algorithm !== "gzip") {
11+
throw new Error('Unsupported compression algorithm');
12+
}
13+
try {
14+
const compressedData = zlib.gzipSync(body);
15+
return compressedData as Uint8Array; // should it be returned as buffer?
16+
} catch (err) {
17+
throw new Error('Compression failed: ' + err.message);
18+
}
19+
};
20+
21+
// type should be --> import type readable from stream and use readable as the type
22+
// export const compressStream = (body: NodeJS.ReadableStream, algorithm: string): NodeJS.ReadableStream => {
23+
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { HttpRequest } from "@smithy/protocol-http";
2+
import { BuildHandler, BuildHandlerArguments, BuildHandlerOptions, BuildHandlerOutput, BuildMiddleware, HandlerExecutionContext, MetadataBearer, } from "@smithy/types";
3+
import { CompressionResolvedConfig } from "./configuration"
4+
import { isStreaming } from "./utils";
5+
import { CLIENT_SUPPORTED_ALGORITHMS as supportedAlgorithms } from "./types";
6+
import { compress, compressStream } from "./compress";
7+
8+
/**
9+
* @internal
10+
*/
11+
export const compressionMiddleware = (config: CompressionResolvedConfig):
12+
BuildMiddleware<any, any> => {
13+
return <Output extends MetadataBearer>(next: BuildHandler<any, Output>,
14+
context: HandlerExecutionContext):
15+
BuildHandler<any, Output> =>
16+
async (args: BuildHandlerArguments<any>):
17+
Promise<BuildHandlerOutput<Output>> => {
18+
19+
if (!HttpRequest.isInstance(args.request) || config.disableRequestCompression) {
20+
return next(args);
21+
}
22+
23+
const { request } = args;
24+
const { body, headers } = request;
25+
26+
for (const algorithm of supportedAlgorithms) {
27+
// have to check for streaming length trait and @requiredLength trait; probably to be done in codegen part and not check in middleware
28+
if (isStreaming(body) && !isStreamingLengthRequired(body)) {
29+
// if isStreaming results in Transfer-Encoding: Chunked
30+
request.body = compressStream(body, algorithm);
31+
// body.length checks --> check for util body length in smithy pkg
32+
} else if (!isStreaming(body) && body.length >= config.minCompressionSizeInBytes) {
33+
request.body = await compress(body, algorithm);
34+
}
35+
if (headers["Content-Encoding"]) {
36+
headers["Content-Encoding"] += `,${algorithm}`;
37+
} else {
38+
headers["Content-Encoding"] = algorithm;
39+
}
40+
break;
41+
}
42+
43+
return next({ ...args, request });
44+
};
45+
46+
}
47+
48+
49+
export const compressionMiddlewareOptions: BuildHandlerOptions = {
50+
name: "compressionMiddleware",
51+
step: "build",
52+
tags: ["REQUEST_BODY_COMPRESSION", "GZIP"],
53+
override: true,
54+
};
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
2+
/**
3+
* @public
4+
*
5+
*/
6+
export interface CompressionInputConfig {
7+
/**
8+
* Whether to disable request compression.
9+
*/
10+
disableRequestCompression?: boolean;
11+
/**
12+
* Minimum size in bytes for compression. The value must be a non-negative integer value between 0 and 10485760 bytes inclusive.
13+
*/
14+
minCompressionSizeInBytes?: number;
15+
}
16+
17+
export interface CompressionResolvedConfig {
18+
disableRequestCompression: boolean;
19+
minCompressionSizeInBytes: number;
20+
}
21+
22+
23+
export const resolveCompressionConfig = <T>(input: T & CompressionInputConfig): T & CompressionResolvedConfig => {
24+
const minCompressionSizeInBytes = input.minCompressionSizeInBytes ?? 10240;
25+
const maxCompressionSizeInBytes = 10485760;
26+
// minCompressionSizeInBytes explanation
27+
if (minCompressionSizeInBytes < 0 || minCompressionSizeInBytes > maxCompressionSizeInBytes) {
28+
throw new Error('minCompressionSizeInBytes must be between 0 and 10485760 bytes inclusive');
29+
}
30+
return {
31+
...input,
32+
disableRequestCompression: input.disableRequestCompression ?? false,
33+
minCompressionSizeInBytes,
34+
};
35+
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/**
2+
* Compression algorithms supported by the SDK.
3+
*/
4+
export type CompressionAlgorithm = "gzip"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./compressionMiddleware";
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { CompressionAlgorithm } from "./constants";
2+
3+
export const CLIENT_SUPPORTED_ALGORITHMS: CompressionAlgorithm[] = [
4+
'gzip',
5+
// add more supported compression algorithms here
6+
];
7+
8+
export const PRIORITY_ORDER_ALGORITHMS: CompressionAlgorithm[] = [
9+
'gzip',
10+
// add more supported compression algorithms here in the order of their priority
11+
];
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { isArrayBuffer } from "@smithy/is-array-buffer";
2+
3+
/**
4+
* Returns true if the given value is a streaming response.
5+
*/
6+
export const isStreaming = (body: unknown) =>
7+
body !== undefined && typeof body !== "string" && !ArrayBuffer.isView(body) && !isArrayBuffer(body);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"compilerOptions": {
3+
"baseUrl": ".",
4+
"outDir": "dist-cjs",
5+
"rootDir": "src"
6+
},
7+
"extends": "../../tsconfig.cjs.json",
8+
"include": ["src/"]
9+
}

0 commit comments

Comments
 (0)