From d264ab91d0eee48f07171e83941a3e65b1ffe8db Mon Sep 17 00:00:00 2001 From: Swopnil Dangol Date: Mon, 15 Sep 2025 09:46:51 +0100 Subject: [PATCH 01/17] Added compress middleware --- packages/event-handler/src/rest/constants.ts | 14 ++ .../src/rest/middleware/compress.ts | 60 +++++++ .../src/rest/middleware/index.ts | 1 + packages/event-handler/src/types/rest.ts | 12 +- .../event-handler/tests/unit/rest/helpers.ts | 5 +- .../unit/rest/middleware/compress.test.ts | 167 ++++++++++++++++++ 6 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 packages/event-handler/src/rest/middleware/compress.ts create mode 100644 packages/event-handler/src/rest/middleware/index.ts create mode 100644 packages/event-handler/tests/unit/rest/middleware/compress.test.ts diff --git a/packages/event-handler/src/rest/constants.ts b/packages/event-handler/src/rest/constants.ts index 5267b5a2f4..b42fa0e965 100644 --- a/packages/event-handler/src/rest/constants.ts +++ b/packages/event-handler/src/rest/constants.ts @@ -87,3 +87,17 @@ export const PARAM_PATTERN = /:([a-zA-Z_]\w*)(?=\/|$)/g; export const SAFE_CHARS = "-._~()'!*:@,;=+&$"; export const UNSAFE_CHARS = '%<> \\[\\]{}|^'; + +/** + * Match for compressible content type. + */ +export const COMPRESSIBLE_CONTENT_TYPE_REGEX = + /^\s*(?:text\/(?!event-stream(?:[;\s]|$))[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i; + +export const CACHE_CONTROL_NO_TRANSFORM_REGEX = + /(?:^|,)\s*?no-transform\s*?(?:,|$)/i; + +export const COMPRESSION_ENCODING_TYPES = { + GZIP: 'gzip', + DEFLATE: 'deflate', +} as const; diff --git a/packages/event-handler/src/rest/middleware/compress.ts b/packages/event-handler/src/rest/middleware/compress.ts new file mode 100644 index 0000000000..595c3489d4 --- /dev/null +++ b/packages/event-handler/src/rest/middleware/compress.ts @@ -0,0 +1,60 @@ +import type { Middleware } from '@aws-lambda-powertools/event-handler/types'; +import type { CompressionOptions } from 'src/types/rest.js'; +import { + CACHE_CONTROL_NO_TRANSFORM_REGEX, + COMPRESSIBLE_CONTENT_TYPE_REGEX, + COMPRESSION_ENCODING_TYPES, +} from '../constants.js'; + +const compress = (options?: CompressionOptions): Middleware => { + const threshold = options?.threshold ?? 1024; + + return async (_, reqCtx, next) => { + await next(); + + const contentLength = reqCtx.res.headers.get('Content-Length'); + + // Check if response should be compressed + if ( + reqCtx.res.headers.has('Content-Encoding') || // already encoded + reqCtx.res.headers.has('Transfer-Encoding') || // already encoded or chunked + reqCtx.request.method === 'HEAD' || // HEAD request + (contentLength && Number(contentLength) < threshold) || // content-length below threshold + !shouldCompress(reqCtx.res) || // not compressible type + !shouldTransform(reqCtx.res) // cache-control: no-transform + ) { + return; + } + + const acceptedEncoding = reqCtx.request.headers.get('Accept-Encoding'); + const encoding = + options?.encoding ?? + Object.values(COMPRESSION_ENCODING_TYPES).find((encoding) => + acceptedEncoding?.includes(encoding) + ) ?? + COMPRESSION_ENCODING_TYPES.GZIP; + if (!encoding || !reqCtx.res.body) { + return; + } + + // Compress the response + const stream = new CompressionStream(encoding); + reqCtx.res = new Response(reqCtx.res.body.pipeThrough(stream), reqCtx.res); + reqCtx.res.headers.delete('Content-Length'); + reqCtx.res.headers.set('Content-Encoding', encoding); + }; +}; + +const shouldCompress = (res: Response) => { + const type = res.headers.get('Content-Type'); + return type && COMPRESSIBLE_CONTENT_TYPE_REGEX.test(type); +}; + +const shouldTransform = (res: Response) => { + const cacheControl = res.headers.get('Cache-Control'); + // Don't compress for Cache-Control: no-transform + // https://tools.ietf.org/html/rfc7234#section-5.2.2.4 + return !cacheControl || !CACHE_CONTROL_NO_TRANSFORM_REGEX.test(cacheControl); +}; + +export { compress }; diff --git a/packages/event-handler/src/rest/middleware/index.ts b/packages/event-handler/src/rest/middleware/index.ts new file mode 100644 index 0000000000..2b65a8ee8e --- /dev/null +++ b/packages/event-handler/src/rest/middleware/index.ts @@ -0,0 +1 @@ +export { compress } from './compress.js'; diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index e2005d250d..45c67d3143 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -3,7 +3,11 @@ import type { JSONObject, } from '@aws-lambda-powertools/commons/types'; import type { APIGatewayProxyEvent, Context } from 'aws-lambda'; -import type { HttpErrorCodes, HttpVerbs } from '../rest/constants.js'; +import type { + COMPRESSION_ENCODING_TYPES, + HttpErrorCodes, + HttpVerbs, +} from '../rest/constants.js'; import type { Route } from '../rest/Route.js'; import type { Router } from '../rest/Router.js'; import type { ResolveOptions } from './common.js'; @@ -111,6 +115,11 @@ type ValidationResult = { issues: string[]; }; +type CompressionOptions = { + encoding?: (typeof COMPRESSION_ENCODING_TYPES)[keyof typeof COMPRESSION_ENCODING_TYPES]; + threshold?: number; +}; + export type { CompiledRoute, DynamicRoute, @@ -131,4 +140,5 @@ export type { RestRouteHandlerOptions, RouteRegistryOptions, ValidationResult, + CompressionOptions, }; diff --git a/packages/event-handler/tests/unit/rest/helpers.ts b/packages/event-handler/tests/unit/rest/helpers.ts index 4c846d3e81..b010a2efc7 100644 --- a/packages/event-handler/tests/unit/rest/helpers.ts +++ b/packages/event-handler/tests/unit/rest/helpers.ts @@ -3,11 +3,12 @@ import type { Middleware } from '../../../src/types/rest.js'; export const createTestEvent = ( path: string, - httpMethod: string + httpMethod: string, + headers: Record = {} ): APIGatewayProxyEvent => ({ path, httpMethod, - headers: {}, + headers, body: null, multiValueHeaders: {}, isBase64Encoded: false, diff --git a/packages/event-handler/tests/unit/rest/middleware/compress.test.ts b/packages/event-handler/tests/unit/rest/middleware/compress.test.ts new file mode 100644 index 0000000000..f1855246da --- /dev/null +++ b/packages/event-handler/tests/unit/rest/middleware/compress.test.ts @@ -0,0 +1,167 @@ +import context from '@aws-lambda-powertools/testing-utils/context'; +import { Router } from 'src/rest/Router.js'; +import { describe, expect, it } from 'vitest'; +import { compress } from '../../../../src/rest/middleware/compress.js'; +import { createTestEvent } from '../helpers.js'; + +describe('Compress Middleware', () => { + it('compresses response when conditions are met', async () => { + // Prepare + const event = createTestEvent('/test', 'GET'); + const app = new Router(); + app.get('/test', [compress()], async () => { + return { test: 'x'.repeat(2000) }; + }); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.headers?.['content-encoding']).toBe('gzip'); + expect(result.headers?.['content-length']).toBeUndefined(); + }); + + it('skips compression when content is below threshold', async () => { + // Prepare + const event = createTestEvent('/test', 'GET'); + const app = new Router(); + app.get('/test', [compress({ threshold: 1024 })], async () => { + return { test: 'small' }; + }); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.headers?.['content-encoding']).toBeUndefined(); + }); + + it('skips compression for HEAD requests', async () => { + // Prepare + const event = createTestEvent('/test', 'HEAD'); + const app = new Router(); + app.head('/test', [compress()], async () => { + return { test: 'x'.repeat(2000) }; + }); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.headers?.['content-encoding']).toBeUndefined(); + }); + + it('skips compression when already encoded', async () => { + // Prepare + const event = createTestEvent('/test', 'HEAD'); + const app = new Router(); + app.get('/test', [compress()], async () => { + return { test: 'x'.repeat(2000) }; + }); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.headers?.['content-encoding']).toBeUndefined(); + }); + + it('skips compression for non-compressible content types', async () => { + // Prepare + const event = createTestEvent('/test', 'GET', { + 'Content-Type': 'image/png', + }); + const app = new Router(); + app.get('/test', [compress()], async () => { + return 'x'.repeat(2000); + }); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.headers?.['content-encoding']).toBeUndefined(); + }); + + it('skips compression when cache-control no-transform is set', async () => { + // Prepare + const event = createTestEvent('/test', 'GET'); + const app = new Router(); + app.get('/test', [compress()], async (_, reqCtx) => { + reqCtx.res.headers.set('Cache-Control', 'no-transform'); + return 'x'.repeat(2000); + }); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.headers?.['content-encoding']).toBeUndefined(); + }); + + it('uses specified encoding when provided', async () => { + // Prepare + const event = createTestEvent('/test', 'GET', { + 'Accept-Encoding': 'deflate, gzip', + }); + const app = new Router(); + app.get('/test', [compress({ encoding: 'deflate' })], async () => { + return 'x'.repeat(2000); + }); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.headers?.['content-encoding']).toBe('deflate'); + }); + + it('infers encoding from Accept-Encoding header', async () => { + // Prepare + const event = createTestEvent('/test', 'GET', { + 'Accept-Encoding': 'deflate', + }); + const app = new Router(); + app.get('/test', [compress()], async () => { + return 'x'.repeat(2000); + }); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.headers?.['content-encoding']).toBe('deflate'); + }); + + it('defaults to gzip when no encoding specified', async () => { + // Prepare + const event = createTestEvent('/test', 'GET', { + 'Accept-Encoding': 'br', + }); + const app = new Router(); + app.get('/test', [compress()], async () => { + return 'x'.repeat(2000); + }); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.headers?.['content-encoding']).toBe('gzip'); + }); + + it('skips compression when no body present', async () => { + // Prepare + const event = createTestEvent('/test', 'GET'); + const app = new Router(); + app.get('/test', [compress()], async () => { + return new Response(null); + }); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.headers?.['content-encoding']).toBeUndefined(); + }); +}); From 6c477abb7e23a5df04239112c493a672e469d83d Mon Sep 17 00:00:00 2001 From: Swopnil Dangol Date: Mon, 15 Sep 2025 11:29:16 +0100 Subject: [PATCH 02/17] Added tests for the middleware --- .../src/rest/middleware/compress.ts | 16 +-- .../unit/rest/middleware/compress.test.ts | 128 ++++++++++++------ 2 files changed, 91 insertions(+), 53 deletions(-) diff --git a/packages/event-handler/src/rest/middleware/compress.ts b/packages/event-handler/src/rest/middleware/compress.ts index 595c3489d4..3e7d4da870 100644 --- a/packages/event-handler/src/rest/middleware/compress.ts +++ b/packages/event-handler/src/rest/middleware/compress.ts @@ -12,12 +12,12 @@ const compress = (options?: CompressionOptions): Middleware => { return async (_, reqCtx, next) => { await next(); - const contentLength = reqCtx.res.headers.get('Content-Length'); + const contentLength = reqCtx.res.headers.get('content-length'); // Check if response should be compressed if ( - reqCtx.res.headers.has('Content-Encoding') || // already encoded - reqCtx.res.headers.has('Transfer-Encoding') || // already encoded or chunked + reqCtx.res.headers.has('content-encoding') || // already encoded + reqCtx.res.headers.has('transfer-encoding') || // already encoded or chunked reqCtx.request.method === 'HEAD' || // HEAD request (contentLength && Number(contentLength) < threshold) || // content-length below threshold !shouldCompress(reqCtx.res) || // not compressible type @@ -26,7 +26,7 @@ const compress = (options?: CompressionOptions): Middleware => { return; } - const acceptedEncoding = reqCtx.request.headers.get('Accept-Encoding'); + const acceptedEncoding = reqCtx.request.headers.get('accept-encoding'); const encoding = options?.encoding ?? Object.values(COMPRESSION_ENCODING_TYPES).find((encoding) => @@ -40,18 +40,18 @@ const compress = (options?: CompressionOptions): Middleware => { // Compress the response const stream = new CompressionStream(encoding); reqCtx.res = new Response(reqCtx.res.body.pipeThrough(stream), reqCtx.res); - reqCtx.res.headers.delete('Content-Length'); - reqCtx.res.headers.set('Content-Encoding', encoding); + reqCtx.res.headers.delete('content-length'); + reqCtx.res.headers.set('content-encoding', encoding); }; }; const shouldCompress = (res: Response) => { - const type = res.headers.get('Content-Type'); + const type = res.headers.get('content-type'); return type && COMPRESSIBLE_CONTENT_TYPE_REGEX.test(type); }; const shouldTransform = (res: Response) => { - const cacheControl = res.headers.get('Cache-Control'); + const cacheControl = res.headers.get('cache-control'); // Don't compress for Cache-Control: no-transform // https://tools.ietf.org/html/rfc7234#section-5.2.2.4 return !cacheControl || !CACHE_CONTROL_NO_TRANSFORM_REGEX.test(cacheControl); diff --git a/packages/event-handler/tests/unit/rest/middleware/compress.test.ts b/packages/event-handler/tests/unit/rest/middleware/compress.test.ts index f1855246da..3296bc945b 100644 --- a/packages/event-handler/tests/unit/rest/middleware/compress.test.ts +++ b/packages/event-handler/tests/unit/rest/middleware/compress.test.ts @@ -1,5 +1,6 @@ import context from '@aws-lambda-powertools/testing-utils/context'; import { Router } from 'src/rest/Router.js'; +import type { Middleware } from 'src/types/index.js'; import { describe, expect, it } from 'vitest'; import { compress } from '../../../../src/rest/middleware/compress.js'; import { createTestEvent } from '../helpers.js'; @@ -25,9 +26,19 @@ describe('Compress Middleware', () => { // Prepare const event = createTestEvent('/test', 'GET'); const app = new Router(); - app.get('/test', [compress({ threshold: 1024 })], async () => { - return { test: 'small' }; - }); + app.get( + '/test', + [ + compress({ threshold: 1024 }), + (): Middleware => async (_, reqCtx, next) => { + await next(); + reqCtx.res.headers.set('content-length', '1'); + }, + ], + async () => { + return { test: 'x' }; + } + ); // Act const result = await app.resolve(event, context); @@ -53,28 +64,47 @@ describe('Compress Middleware', () => { it('skips compression when already encoded', async () => { // Prepare - const event = createTestEvent('/test', 'HEAD'); + const event = createTestEvent('/test', 'GET'); const app = new Router(); - app.get('/test', [compress()], async () => { - return { test: 'x'.repeat(2000) }; - }); + app.get( + '/test', + [ + compress({ + encoding: 'deflate', + }), + compress({ + encoding: 'gzip', + }), + ], + async () => { + return { test: 'x'.repeat(2000) }; + } + ); // Act const result = await app.resolve(event, context); // Assess - expect(result.headers?.['content-encoding']).toBeUndefined(); + expect(result.headers?.['content-encoding']).toEqual('gzip'); }); it('skips compression for non-compressible content types', async () => { // Prepare - const event = createTestEvent('/test', 'GET', { - 'Content-Type': 'image/png', - }); + const event = createTestEvent('/test', 'GET'); const app = new Router(); - app.get('/test', [compress()], async () => { - return 'x'.repeat(2000); - }); + app.get( + '/test', + [ + compress(), + (): Middleware => async (_, reqCtx, next) => { + await next(); + reqCtx.res.headers.set('content-type', 'image/jpeg'); + }, + ], + async () => { + return {}; + } + ); // Act const result = await app.resolve(event, context); @@ -87,10 +117,19 @@ describe('Compress Middleware', () => { // Prepare const event = createTestEvent('/test', 'GET'); const app = new Router(); - app.get('/test', [compress()], async (_, reqCtx) => { - reqCtx.res.headers.set('Cache-Control', 'no-transform'); - return 'x'.repeat(2000); - }); + app.get( + '/test', + [ + compress(), + (): Middleware => async (_, reqCtx, next) => { + await next(); + reqCtx.res.headers.set('cache-control', 'no-transform'); + }, + ], + async () => { + return {}; + } + ); // Act const result = await app.resolve(event, context); @@ -101,13 +140,19 @@ describe('Compress Middleware', () => { it('uses specified encoding when provided', async () => { // Prepare - const event = createTestEvent('/test', 'GET', { - 'Accept-Encoding': 'deflate, gzip', - }); + const event = createTestEvent('/test', 'GET'); const app = new Router(); - app.get('/test', [compress({ encoding: 'deflate' })], async () => { - return 'x'.repeat(2000); - }); + app.get( + '/test', + [ + compress({ + encoding: 'deflate', + }), + ], + async () => { + return { test: 'x'.repeat(2000) }; + } + ); // Act const result = await app.resolve(event, context); @@ -123,7 +168,7 @@ describe('Compress Middleware', () => { }); const app = new Router(); app.get('/test', [compress()], async () => { - return 'x'.repeat(2000); + return { test: 'x'.repeat(2000) }; }); // Act @@ -133,30 +178,23 @@ describe('Compress Middleware', () => { expect(result.headers?.['content-encoding']).toBe('deflate'); }); - it('defaults to gzip when no encoding specified', async () => { - // Prepare - const event = createTestEvent('/test', 'GET', { - 'Accept-Encoding': 'br', - }); - const app = new Router(); - app.get('/test', [compress()], async () => { - return 'x'.repeat(2000); - }); - - // Act - const result = await app.resolve(event, context); - - // Assess - expect(result.headers?.['content-encoding']).toBe('gzip'); - }); - it('skips compression when no body present', async () => { // Prepare const event = createTestEvent('/test', 'GET'); const app = new Router(); - app.get('/test', [compress()], async () => { - return new Response(null); - }); + app.get( + '/test', + [ + compress(), + (): Middleware => async (_, reqCtx, next) => { + await next(); + reqCtx.res = new Response(null); + }, + ], + async () => { + return {}; + } + ); // Act const result = await app.resolve(event, context); From aa76a427910105316899bfbc04a928221b6cb1b6 Mon Sep 17 00:00:00 2001 From: Swopnil Dangol Date: Mon, 15 Sep 2025 12:52:52 +0100 Subject: [PATCH 03/17] added export for middleware --- packages/event-handler/package.json | 10 +++++++ .../src/rest/middleware/compress.ts | 6 ++--- .../unit/rest/middleware/compress.test.ts | 27 +------------------ 3 files changed, 13 insertions(+), 30 deletions(-) diff --git a/packages/event-handler/package.json b/packages/event-handler/package.json index 90a85b5d68..81f96069b5 100644 --- a/packages/event-handler/package.json +++ b/packages/event-handler/package.json @@ -77,6 +77,16 @@ "types": "./lib/esm/rest/index.d.ts", "default": "./lib/esm/rest/index.js" } + }, + "./experimental-rest/middleware": { + "require": { + "types": "./lib/cjs/rest/middleware/index.d.ts", + "default": "./lib/cjs/rest/middleware/index.js" + }, + "import": { + "types": "./lib/esm/rest/middleware/index.d.ts", + "default": "./lib/esm/rest/middleware/index.js" + } } }, "typesVersions": { diff --git a/packages/event-handler/src/rest/middleware/compress.ts b/packages/event-handler/src/rest/middleware/compress.ts index 3e7d4da870..05036ddf4f 100644 --- a/packages/event-handler/src/rest/middleware/compress.ts +++ b/packages/event-handler/src/rest/middleware/compress.ts @@ -21,7 +21,8 @@ const compress = (options?: CompressionOptions): Middleware => { reqCtx.request.method === 'HEAD' || // HEAD request (contentLength && Number(contentLength) < threshold) || // content-length below threshold !shouldCompress(reqCtx.res) || // not compressible type - !shouldTransform(reqCtx.res) // cache-control: no-transform + !shouldTransform(reqCtx.res) || // cache-control: no-transform + !reqCtx.res.body ) { return; } @@ -33,9 +34,6 @@ const compress = (options?: CompressionOptions): Middleware => { acceptedEncoding?.includes(encoding) ) ?? COMPRESSION_ENCODING_TYPES.GZIP; - if (!encoding || !reqCtx.res.body) { - return; - } // Compress the response const stream = new CompressionStream(encoding); diff --git a/packages/event-handler/tests/unit/rest/middleware/compress.test.ts b/packages/event-handler/tests/unit/rest/middleware/compress.test.ts index 3296bc945b..38e60f4492 100644 --- a/packages/event-handler/tests/unit/rest/middleware/compress.test.ts +++ b/packages/event-handler/tests/unit/rest/middleware/compress.test.ts @@ -2,7 +2,7 @@ import context from '@aws-lambda-powertools/testing-utils/context'; import { Router } from 'src/rest/Router.js'; import type { Middleware } from 'src/types/index.js'; import { describe, expect, it } from 'vitest'; -import { compress } from '../../../../src/rest/middleware/compress.js'; +import { compress } from '../../../../src/rest/middleware/index.js'; import { createTestEvent } from '../helpers.js'; describe('Compress Middleware', () => { @@ -177,29 +177,4 @@ describe('Compress Middleware', () => { // Assess expect(result.headers?.['content-encoding']).toBe('deflate'); }); - - it('skips compression when no body present', async () => { - // Prepare - const event = createTestEvent('/test', 'GET'); - const app = new Router(); - app.get( - '/test', - [ - compress(), - (): Middleware => async (_, reqCtx, next) => { - await next(); - reqCtx.res = new Response(null); - }, - ], - async () => { - return {}; - } - ); - - // Act - const result = await app.resolve(event, context); - - // Assess - expect(result.headers?.['content-encoding']).toBeUndefined(); - }); }); From ea9cc34472dd0c29f65c2fab90b20711dee273b9 Mon Sep 17 00:00:00 2001 From: Swopnil Dangol Date: Mon, 15 Sep 2025 16:29:16 +0100 Subject: [PATCH 04/17] fixed tests and improved coverage --- .../event-handler/tests/unit/rest/helpers.ts | 11 +++ .../unit/rest/middleware/compress.test.ts | 82 +++++++++++++------ 2 files changed, 68 insertions(+), 25 deletions(-) diff --git a/packages/event-handler/tests/unit/rest/helpers.ts b/packages/event-handler/tests/unit/rest/helpers.ts index b010a2efc7..b3cac18d5b 100644 --- a/packages/event-handler/tests/unit/rest/helpers.ts +++ b/packages/event-handler/tests/unit/rest/helpers.ts @@ -66,3 +66,14 @@ export const createNoNextMiddleware = ( // Intentionally doesn't call next() }; }; + +export const createSettingHeadersMiddleware = (headers: { + [key: string]: string; +}): Middleware => { + return async (_params, _options, next) => { + await next(); + Object.entries(headers).map(([key, value]) => + _options.res.headers.set(key, value) + ); + }; +}; diff --git a/packages/event-handler/tests/unit/rest/middleware/compress.test.ts b/packages/event-handler/tests/unit/rest/middleware/compress.test.ts index 38e60f4492..0a93941a8a 100644 --- a/packages/event-handler/tests/unit/rest/middleware/compress.test.ts +++ b/packages/event-handler/tests/unit/rest/middleware/compress.test.ts @@ -1,18 +1,27 @@ import context from '@aws-lambda-powertools/testing-utils/context'; import { Router } from 'src/rest/Router.js'; import type { Middleware } from 'src/types/index.js'; -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { compress } from '../../../../src/rest/middleware/index.js'; -import { createTestEvent } from '../helpers.js'; +import { createSettingHeadersMiddleware, createTestEvent } from '../helpers.js'; describe('Compress Middleware', () => { it('compresses response when conditions are met', async () => { // Prepare const event = createTestEvent('/test', 'GET'); const app = new Router(); - app.get('/test', [compress()], async () => { - return { test: 'x'.repeat(2000) }; - }); + app.get( + '/test', + [ + compress(), + createSettingHeadersMiddleware({ + 'content-length': '2000', + }), + ], + async () => { + return { test: 'x'.repeat(2000) }; + } + ); // Act const result = await app.resolve(event, context); @@ -30,10 +39,9 @@ describe('Compress Middleware', () => { '/test', [ compress({ threshold: 1024 }), - (): Middleware => async (_, reqCtx, next) => { - await next(); - reqCtx.res.headers.set('content-length', '1'); - }, + createSettingHeadersMiddleware({ + 'content-length': '1', + }), ], async () => { return { test: 'x' }; @@ -51,9 +59,18 @@ describe('Compress Middleware', () => { // Prepare const event = createTestEvent('/test', 'HEAD'); const app = new Router(); - app.head('/test', [compress()], async () => { - return { test: 'x'.repeat(2000) }; - }); + app.head( + '/test', + [ + compress(), + createSettingHeadersMiddleware({ + 'content-length': '2000', + }), + ], + async () => { + return { test: 'x'.repeat(2000) }; + } + ); // Act const result = await app.resolve(event, context); @@ -75,6 +92,9 @@ describe('Compress Middleware', () => { compress({ encoding: 'gzip', }), + createSettingHeadersMiddleware({ + 'content-length': '2000', + }), ], async () => { return { test: 'x'.repeat(2000) }; @@ -96,13 +116,13 @@ describe('Compress Middleware', () => { '/test', [ compress(), - (): Middleware => async (_, reqCtx, next) => { - await next(); - reqCtx.res.headers.set('content-type', 'image/jpeg'); - }, + createSettingHeadersMiddleware({ + 'content-length': '2000', + 'content-type': 'image/jpeg', + }), ], async () => { - return {}; + return { test: 'x'.repeat(2000) }; } ); @@ -121,13 +141,13 @@ describe('Compress Middleware', () => { '/test', [ compress(), - (): Middleware => async (_, reqCtx, next) => { - await next(); - reqCtx.res.headers.set('cache-control', 'no-transform'); - }, + createSettingHeadersMiddleware({ + 'content-length': '2000', + 'cache-control': 'no-transform', + }), ], async () => { - return {}; + return { test: 'x'.repeat(2000) }; } ); @@ -148,6 +168,9 @@ describe('Compress Middleware', () => { compress({ encoding: 'deflate', }), + createSettingHeadersMiddleware({ + 'content-length': '2000', + }), ], async () => { return { test: 'x'.repeat(2000) }; @@ -167,9 +190,18 @@ describe('Compress Middleware', () => { 'Accept-Encoding': 'deflate', }); const app = new Router(); - app.get('/test', [compress()], async () => { - return { test: 'x'.repeat(2000) }; - }); + app.get( + '/test', + [ + compress(), + createSettingHeadersMiddleware({ + 'content-length': '2000', + }), + ], + async () => { + return { test: 'x'.repeat(2000) }; + } + ); // Act const result = await app.resolve(event, context); From 6670badbda65aea4b26f7c7a76ac51c3a50471a9 Mon Sep 17 00:00:00 2001 From: Swopnil Dangol Date: Mon, 15 Sep 2025 18:11:53 +0100 Subject: [PATCH 05/17] Added docstring comment for the compress function --- .../src/rest/middleware/compress.ts | 65 +++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/event-handler/src/rest/middleware/compress.ts b/packages/event-handler/src/rest/middleware/compress.ts index 05036ddf4f..a44a743ee8 100644 --- a/packages/event-handler/src/rest/middleware/compress.ts +++ b/packages/event-handler/src/rest/middleware/compress.ts @@ -1,11 +1,66 @@ -import type { Middleware } from '@aws-lambda-powertools/event-handler/types'; -import type { CompressionOptions } from 'src/types/rest.js'; +import type { Middleware } from '../../types/index.js'; +import type { CompressionOptions } from '../../types/rest.js'; import { CACHE_CONTROL_NO_TRANSFORM_REGEX, COMPRESSIBLE_CONTENT_TYPE_REGEX, COMPRESSION_ENCODING_TYPES, } from '../constants.js'; +/** + * Compresses HTTP response bodies using standard compression algorithms. + * + * This middleware automatically compresses response bodies when they exceed + * a specified threshold and the client supports compression. It respects + * cache-control directives and only compresses appropriate content types. + * + * The middleware checks several conditions before compressing: + * - Response is not already encoded or chunked + * - Request method is not HEAD + * - Content length exceeds the threshold + * - Content type is compressible + * - Cache-Control header doesn't contain no-transform + * - Response has a body + * + * **Basic compression with default settings** + * + * @example + * ```typescript + * import { Router } from '@aws-lambda-powertools/event-handler'; + * import { compress } from '@aws-lambda-powertools/event-handler/rest/middleware'; + * + * const app = new Router(); + * + * app.use(compress()); + * + * app.get('/api/data', async () => { + * return { data: 'large response body...' }; + * }); + * ``` + * + * **Custom compression settings** + * + * @example + * ```typescript + * import { Router } from '@aws-lambda-powertools/event-handler'; + * import { compress } from '@aws-lambda-powertools/event-handler/rest/middleware'; + * + * const app = new Router(); + * + * app.use(compress({ + * threshold: 2048, + * encoding: 'deflate' + * })); + * + * app.get('/api/large-data', async () => { + * return { data: 'very large response...' }; + * }); + * ``` + * + * @param options - Configuration options for compression behavior + * @param options.threshold - Minimum response size in bytes to trigger compression (default: 1024) + * @param options.encoding - Preferred compression encoding to use when client supports multiple formats + */ + const compress = (options?: CompressionOptions): Middleware => { const threshold = options?.threshold ?? 1024; @@ -13,11 +68,13 @@ const compress = (options?: CompressionOptions): Middleware => { await next(); const contentLength = reqCtx.res.headers.get('content-length'); + const isEncodedOrChunked = + reqCtx.res.headers.has('content-encoding') || + reqCtx.res.headers.has('transfer-encoding'); // Check if response should be compressed if ( - reqCtx.res.headers.has('content-encoding') || // already encoded - reqCtx.res.headers.has('transfer-encoding') || // already encoded or chunked + isEncodedOrChunked || reqCtx.request.method === 'HEAD' || // HEAD request (contentLength && Number(contentLength) < threshold) || // content-length below threshold !shouldCompress(reqCtx.res) || // not compressible type From aecd5bcbf13422baa8f371b59b7b8955ab3497fc Mon Sep 17 00:00:00 2001 From: Swopnil Dangol Date: Mon, 15 Sep 2025 18:16:24 +0100 Subject: [PATCH 06/17] Changed map to foreach --- packages/event-handler/tests/unit/rest/helpers.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/event-handler/tests/unit/rest/helpers.ts b/packages/event-handler/tests/unit/rest/helpers.ts index b3cac18d5b..efdb9813d1 100644 --- a/packages/event-handler/tests/unit/rest/helpers.ts +++ b/packages/event-handler/tests/unit/rest/helpers.ts @@ -72,8 +72,8 @@ export const createSettingHeadersMiddleware = (headers: { }): Middleware => { return async (_params, _options, next) => { await next(); - Object.entries(headers).map(([key, value]) => - _options.res.headers.set(key, value) - ); + Object.entries(headers).forEach(([key, value]) => { + _options.res.headers.set(key, value); + }); }; }; From 3bfccbc132f6808e7a6e253d712e05a6d19d01ac Mon Sep 17 00:00:00 2001 From: Swopnil Dangol Date: Mon, 15 Sep 2025 18:19:10 +0100 Subject: [PATCH 07/17] Removed unused imports --- .../event-handler/tests/unit/rest/middleware/compress.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/event-handler/tests/unit/rest/middleware/compress.test.ts b/packages/event-handler/tests/unit/rest/middleware/compress.test.ts index 0a93941a8a..5919b90723 100644 --- a/packages/event-handler/tests/unit/rest/middleware/compress.test.ts +++ b/packages/event-handler/tests/unit/rest/middleware/compress.test.ts @@ -1,7 +1,6 @@ import context from '@aws-lambda-powertools/testing-utils/context'; import { Router } from 'src/rest/Router.js'; -import type { Middleware } from 'src/types/index.js'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { compress } from '../../../../src/rest/middleware/index.js'; import { createSettingHeadersMiddleware, createTestEvent } from '../helpers.js'; From 2627663be1fdb690a7d0f0834ca7d4636a303f55 Mon Sep 17 00:00:00 2001 From: Swopnil Dangol Date: Tue, 16 Sep 2025 00:41:13 +0100 Subject: [PATCH 08/17] Addressed the feedbacks --- .../src/rest/middleware/compress.ts | 8 +-- .../event-handler/tests/unit/rest/helpers.ts | 4 +- .../unit/rest/middleware/compress.test.ts | 68 ++++++++++++------- 3 files changed, 50 insertions(+), 30 deletions(-) diff --git a/packages/event-handler/src/rest/middleware/compress.ts b/packages/event-handler/src/rest/middleware/compress.ts index a44a743ee8..54c2c5997f 100644 --- a/packages/event-handler/src/rest/middleware/compress.ts +++ b/packages/event-handler/src/rest/middleware/compress.ts @@ -75,10 +75,10 @@ const compress = (options?: CompressionOptions): Middleware => { // Check if response should be compressed if ( isEncodedOrChunked || - reqCtx.request.method === 'HEAD' || // HEAD request - (contentLength && Number(contentLength) < threshold) || // content-length below threshold - !shouldCompress(reqCtx.res) || // not compressible type - !shouldTransform(reqCtx.res) || // cache-control: no-transform + reqCtx.request.method === 'HEAD' || + (contentLength && Number(contentLength) < threshold) || + !shouldCompress(reqCtx.res) || + !shouldTransform(reqCtx.res) || !reqCtx.res.body ) { return; diff --git a/packages/event-handler/tests/unit/rest/helpers.ts b/packages/event-handler/tests/unit/rest/helpers.ts index efdb9813d1..93255f79c1 100644 --- a/packages/event-handler/tests/unit/rest/helpers.ts +++ b/packages/event-handler/tests/unit/rest/helpers.ts @@ -70,10 +70,10 @@ export const createNoNextMiddleware = ( export const createSettingHeadersMiddleware = (headers: { [key: string]: string; }): Middleware => { - return async (_params, _options, next) => { + return async (_params, options, next) => { await next(); Object.entries(headers).forEach(([key, value]) => { - _options.res.headers.set(key, value); + options.res.headers.set(key, value); }); }; }; diff --git a/packages/event-handler/tests/unit/rest/middleware/compress.test.ts b/packages/event-handler/tests/unit/rest/middleware/compress.test.ts index 5919b90723..4e23a90d13 100644 --- a/packages/event-handler/tests/unit/rest/middleware/compress.test.ts +++ b/packages/event-handler/tests/unit/rest/middleware/compress.test.ts @@ -107,30 +107,50 @@ describe('Compress Middleware', () => { expect(result.headers?.['content-encoding']).toEqual('gzip'); }); - it('skips compression for non-compressible content types', async () => { - // Prepare - const event = createTestEvent('/test', 'GET'); - const app = new Router(); - app.get( - '/test', - [ - compress(), - createSettingHeadersMiddleware({ - 'content-length': '2000', - 'content-type': 'image/jpeg', - }), - ], - async () => { - return { test: 'x'.repeat(2000) }; - } - ); - - // Act - const result = await app.resolve(event, context); - - // Assess - expect(result.headers?.['content-encoding']).toBeUndefined(); - }); + it.each([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'audio/mpeg', + 'audio/mp4', + 'audio/ogg', + 'video/mp4', + 'video/mpeg', + 'video/webm', + 'application/zip', + 'application/gzip', + 'application/x-gzip', + 'application/octet-stream', + 'application/pdf', + 'application/msword', + 'text/event-stream', + ])( + 'skips compression for non-compressible content types', + async (contentType) => { + // Prepare + const event = createTestEvent('/test', 'GET'); + const app = new Router(); + app.get( + '/test', + [ + compress(), + createSettingHeadersMiddleware({ + 'content-length': '2000', + 'content-type': contentType, + }), + ], + async () => { + return { test: 'x'.repeat(2000) }; + } + ); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.headers?.['content-encoding']).toBeUndefined(); + } + ); it('skips compression when cache-control no-transform is set', async () => { // Prepare From 980c4f29d8fd0bac0842d3998a28ee4c17f5fe4b Mon Sep 17 00:00:00 2001 From: Swopnil Dangol Date: Tue, 16 Sep 2025 11:07:43 +0100 Subject: [PATCH 09/17] Broke down the regex into chunks and optimized tests --- packages/event-handler/src/rest/constants.ts | 8 +- .../src/rest/middleware/compress.ts | 7 +- .../unit/rest/middleware/compress.test.ts | 116 +++++++----------- 3 files changed, 59 insertions(+), 72 deletions(-) diff --git a/packages/event-handler/src/rest/constants.ts b/packages/event-handler/src/rest/constants.ts index b42fa0e965..e8ef6bfc49 100644 --- a/packages/event-handler/src/rest/constants.ts +++ b/packages/event-handler/src/rest/constants.ts @@ -91,8 +91,12 @@ export const UNSAFE_CHARS = '%<> \\[\\]{}|^'; /** * Match for compressible content type. */ -export const COMPRESSIBLE_CONTENT_TYPE_REGEX = - /^\s*(?:text\/(?!event-stream(?:[;\s]|$))[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i; +export const COMPRESSIBLE_CONTENT_TYPE_REGEX = { + COMMON: /^\s*application\/json(?:[;\s]|$)/i, + OCCASIONAL: + /^\s*(?:text\/(?:html|plain|css)|application\/(?:xml|javascript)|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i, + RARE: /^\s*(?:text\/(?!event-stream(?:[;\s]|$))[^;\s]+|application\/(?:xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex)(?:[;\s]|$)/i, +}; export const CACHE_CONTROL_NO_TRANSFORM_REGEX = /(?:^|,)\s*?no-transform\s*?(?:,|$)/i; diff --git a/packages/event-handler/src/rest/middleware/compress.ts b/packages/event-handler/src/rest/middleware/compress.ts index 54c2c5997f..c59c5b64ba 100644 --- a/packages/event-handler/src/rest/middleware/compress.ts +++ b/packages/event-handler/src/rest/middleware/compress.ts @@ -102,7 +102,12 @@ const compress = (options?: CompressionOptions): Middleware => { const shouldCompress = (res: Response) => { const type = res.headers.get('content-type'); - return type && COMPRESSIBLE_CONTENT_TYPE_REGEX.test(type); + return ( + type && + (COMPRESSIBLE_CONTENT_TYPE_REGEX.COMMON.test(type) || + COMPRESSIBLE_CONTENT_TYPE_REGEX.OCCASIONAL.test(type) || + COMPRESSIBLE_CONTENT_TYPE_REGEX.RARE.test(type)) + ); }; const shouldTransform = (res: Response) => { diff --git a/packages/event-handler/tests/unit/rest/middleware/compress.test.ts b/packages/event-handler/tests/unit/rest/middleware/compress.test.ts index 4e23a90d13..6b128064ba 100644 --- a/packages/event-handler/tests/unit/rest/middleware/compress.test.ts +++ b/packages/event-handler/tests/unit/rest/middleware/compress.test.ts @@ -1,26 +1,29 @@ import context from '@aws-lambda-powertools/testing-utils/context'; import { Router } from 'src/rest/Router.js'; -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { compress } from '../../../../src/rest/middleware/index.js'; import { createSettingHeadersMiddleware, createTestEvent } from '../helpers.js'; describe('Compress Middleware', () => { + const event = createTestEvent('/test', 'GET'); + let app: Router; + const body = { test: 'x'.repeat(2000) }; + + beforeEach(() => { + app = new Router(); + app.use(compress()); + app.use( + createSettingHeadersMiddleware({ + 'content-length': '2000', + }) + ); + }); + it('compresses response when conditions are met', async () => { // Prepare - const event = createTestEvent('/test', 'GET'); - const app = new Router(); - app.get( - '/test', - [ - compress(), - createSettingHeadersMiddleware({ - 'content-length': '2000', - }), - ], - async () => { - return { test: 'x'.repeat(2000) }; - } - ); + app.get('/test', async () => { + return body; + }); // Act const result = await app.resolve(event, context); @@ -32,9 +35,8 @@ describe('Compress Middleware', () => { it('skips compression when content is below threshold', async () => { // Prepare - const event = createTestEvent('/test', 'GET'); - const app = new Router(); - app.get( + const application = new Router(); + application.get( '/test', [ compress({ threshold: 1024 }), @@ -48,7 +50,7 @@ describe('Compress Middleware', () => { ); // Act - const result = await app.resolve(event, context); + const result = await application.resolve(event, context); // Assess expect(result.headers?.['content-encoding']).toBeUndefined(); @@ -56,23 +58,13 @@ describe('Compress Middleware', () => { it('skips compression for HEAD requests', async () => { // Prepare - const event = createTestEvent('/test', 'HEAD'); - const app = new Router(); - app.head( - '/test', - [ - compress(), - createSettingHeadersMiddleware({ - 'content-length': '2000', - }), - ], - async () => { - return { test: 'x'.repeat(2000) }; - } - ); + const headEvent = createTestEvent('/test', 'HEAD'); + app.head('/test', async () => { + return body; + }); // Act - const result = await app.resolve(event, context); + const result = await app.resolve(headEvent, context); // Assess expect(result.headers?.['content-encoding']).toBeUndefined(); @@ -80,9 +72,8 @@ describe('Compress Middleware', () => { it('skips compression when already encoded', async () => { // Prepare - const event = createTestEvent('/test', 'GET'); - const app = new Router(); - app.get( + const application = new Router(); + application.get( '/test', [ compress({ @@ -96,12 +87,12 @@ describe('Compress Middleware', () => { }), ], async () => { - return { test: 'x'.repeat(2000) }; + return body; } ); // Act - const result = await app.resolve(event, context); + const result = await application.resolve(event, context); // Assess expect(result.headers?.['content-encoding']).toEqual('gzip'); @@ -128,9 +119,8 @@ describe('Compress Middleware', () => { 'skips compression for non-compressible content types', async (contentType) => { // Prepare - const event = createTestEvent('/test', 'GET'); - const app = new Router(); - app.get( + const application = new Router(); + application.get( '/test', [ compress(), @@ -140,12 +130,12 @@ describe('Compress Middleware', () => { }), ], async () => { - return { test: 'x'.repeat(2000) }; + return body; } ); // Act - const result = await app.resolve(event, context); + const result = await application.resolve(event, context); // Assess expect(result.headers?.['content-encoding']).toBeUndefined(); @@ -154,9 +144,8 @@ describe('Compress Middleware', () => { it('skips compression when cache-control no-transform is set', async () => { // Prepare - const event = createTestEvent('/test', 'GET'); - const app = new Router(); - app.get( + const application = new Router(); + application.get( '/test', [ compress(), @@ -166,12 +155,12 @@ describe('Compress Middleware', () => { }), ], async () => { - return { test: 'x'.repeat(2000) }; + return body; } ); // Act - const result = await app.resolve(event, context); + const result = await application.resolve(event, context); // Assess expect(result.headers?.['content-encoding']).toBeUndefined(); @@ -179,9 +168,8 @@ describe('Compress Middleware', () => { it('uses specified encoding when provided', async () => { // Prepare - const event = createTestEvent('/test', 'GET'); - const app = new Router(); - app.get( + const application = new Router(); + application.get( '/test', [ compress({ @@ -192,12 +180,12 @@ describe('Compress Middleware', () => { }), ], async () => { - return { test: 'x'.repeat(2000) }; + return body; } ); // Act - const result = await app.resolve(event, context); + const result = await application.resolve(event, context); // Assess expect(result.headers?.['content-encoding']).toBe('deflate'); @@ -205,25 +193,15 @@ describe('Compress Middleware', () => { it('infers encoding from Accept-Encoding header', async () => { // Prepare - const event = createTestEvent('/test', 'GET', { + const deflateCompressionEvent = createTestEvent('/test', 'GET', { 'Accept-Encoding': 'deflate', }); - const app = new Router(); - app.get( - '/test', - [ - compress(), - createSettingHeadersMiddleware({ - 'content-length': '2000', - }), - ], - async () => { - return { test: 'x'.repeat(2000) }; - } - ); + app.get('/test', async () => { + return body; + }); // Act - const result = await app.resolve(event, context); + const result = await app.resolve(deflateCompressionEvent, context); // Assess expect(result.headers?.['content-encoding']).toBe('deflate'); From 67339fbe85099c20b41b24b1f20ce8c1e75ef1e4 Mon Sep 17 00:00:00 2001 From: Swopnil Dangol Date: Tue, 16 Sep 2025 11:51:44 +0100 Subject: [PATCH 10/17] Broke down the regex further --- packages/event-handler/src/rest/constants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/event-handler/src/rest/constants.ts b/packages/event-handler/src/rest/constants.ts index e8ef6bfc49..fd7088ff8e 100644 --- a/packages/event-handler/src/rest/constants.ts +++ b/packages/event-handler/src/rest/constants.ts @@ -94,8 +94,8 @@ export const UNSAFE_CHARS = '%<> \\[\\]{}|^'; export const COMPRESSIBLE_CONTENT_TYPE_REGEX = { COMMON: /^\s*application\/json(?:[;\s]|$)/i, OCCASIONAL: - /^\s*(?:text\/(?:html|plain|css)|application\/(?:xml|javascript)|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i, - RARE: /^\s*(?:text\/(?!event-stream(?:[;\s]|$))[^;\s]+|application\/(?:xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex)(?:[;\s]|$)/i, + /^\s*(?:text\/(?!event-stream(?:[;\s]|$))[^;\s]+|application\/(?:xml|javascript|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|x-www-form-urlencoded)|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i, + RARE: /^\s*(?:application\/(?:vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex)(?:[;\s]|$)/i, }; export const CACHE_CONTROL_NO_TRANSFORM_REGEX = From 87058a29c44dff0ed0f53104b058e8c8a334ee05 Mon Sep 17 00:00:00 2001 From: Swopnil Dangol Date: Tue, 16 Sep 2025 11:56:09 +0100 Subject: [PATCH 11/17] Updated the regex --- packages/event-handler/src/rest/constants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/event-handler/src/rest/constants.ts b/packages/event-handler/src/rest/constants.ts index fd7088ff8e..3288f841ab 100644 --- a/packages/event-handler/src/rest/constants.ts +++ b/packages/event-handler/src/rest/constants.ts @@ -94,8 +94,8 @@ export const UNSAFE_CHARS = '%<> \\[\\]{}|^'; export const COMPRESSIBLE_CONTENT_TYPE_REGEX = { COMMON: /^\s*application\/json(?:[;\s]|$)/i, OCCASIONAL: - /^\s*(?:text\/(?!event-stream(?:[;\s]|$))[^;\s]+|application\/(?:xml|javascript|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|x-www-form-urlencoded)|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i, - RARE: /^\s*(?:application\/(?:vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex)(?:[;\s]|$)/i, + /^\s*(?:text\/(?!event-stream(?:[;\s]|$))[^;\s]+|application\/(?:xml|javascript)|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i, + RARE: /^\s*(?:application\/(?:xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex)(?:[;\s]|$)/i, }; export const CACHE_CONTROL_NO_TRANSFORM_REGEX = From 4f071ce09beaa0c5e45de32be37ff456318513d4 Mon Sep 17 00:00:00 2001 From: Swopnil Dangol Date: Wed, 17 Sep 2025 17:34:54 +0100 Subject: [PATCH 12/17] Refactored the logic to remove checks for content types --- packages/event-handler/src/rest/constants.ts | 12 +-- packages/event-handler/src/rest/converters.ts | 31 ++++++- .../src/rest/middleware/compress.ts | 84 +++++++++---------- packages/event-handler/src/types/rest.ts | 2 +- .../unit/rest/middleware/compress.test.ts | 54 ++---------- 5 files changed, 81 insertions(+), 102 deletions(-) diff --git a/packages/event-handler/src/rest/constants.ts b/packages/event-handler/src/rest/constants.ts index 3288f841ab..b647b78fb9 100644 --- a/packages/event-handler/src/rest/constants.ts +++ b/packages/event-handler/src/rest/constants.ts @@ -88,15 +88,7 @@ export const SAFE_CHARS = "-._~()'!*:@,;=+&$"; export const UNSAFE_CHARS = '%<> \\[\\]{}|^'; -/** - * Match for compressible content type. - */ -export const COMPRESSIBLE_CONTENT_TYPE_REGEX = { - COMMON: /^\s*application\/json(?:[;\s]|$)/i, - OCCASIONAL: - /^\s*(?:text\/(?!event-stream(?:[;\s]|$))[^;\s]+|application\/(?:xml|javascript)|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i, - RARE: /^\s*(?:application\/(?:xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex)(?:[;\s]|$)/i, -}; +export const DEFAULT_COMPRESSION_RESPONSE_THRESHOLD = 1024; export const CACHE_CONTROL_NO_TRANSFORM_REGEX = /(?:^|,)\s*?no-transform\s*?(?:,|$)/i; @@ -104,4 +96,6 @@ export const CACHE_CONTROL_NO_TRANSFORM_REGEX = export const COMPRESSION_ENCODING_TYPES = { GZIP: 'gzip', DEFLATE: 'deflate', + IDENTITY: 'identity', + ANY: '*', } as const; diff --git a/packages/event-handler/src/rest/converters.ts b/packages/event-handler/src/rest/converters.ts index de12ad74df..809381a00a 100644 --- a/packages/event-handler/src/rest/converters.ts +++ b/packages/event-handler/src/rest/converters.ts @@ -1,5 +1,6 @@ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; -import type { HandlerResponse } from '../types/rest.js'; +import type { CompressionOptions, HandlerResponse } from '../types/rest.js'; +import { COMPRESSION_ENCODING_TYPES } from './constants.js'; import { isAPIGatewayProxyResult } from './utils.js'; /** @@ -89,11 +90,35 @@ export const webResponseToProxyResult = async ( } } + // Check if response contains compressed/binary content + const contentEncoding = response.headers.get( + 'content-encoding' + ) as CompressionOptions['encoding']; + let body: string; + let isBase64Encoded = false; + + if ( + contentEncoding && + [ + COMPRESSION_ENCODING_TYPES.GZIP, + COMPRESSION_ENCODING_TYPES.DEFLATE, + ].includes(contentEncoding) + ) { + // For compressed content, get as buffer and encode to base64 + const buffer = await response.arrayBuffer(); + body = Buffer.from(buffer).toString('base64'); + isBase64Encoded = true; + } else { + // For text content, use text() + body = await response.text(); + isBase64Encoded = false; + } + const result: APIGatewayProxyResult = { statusCode: response.status, headers, - body: await response.text(), - isBase64Encoded: false, + body, + isBase64Encoded, }; if (Object.keys(multiValueHeaders).length > 0) { diff --git a/packages/event-handler/src/rest/middleware/compress.ts b/packages/event-handler/src/rest/middleware/compress.ts index c59c5b64ba..831590fe43 100644 --- a/packages/event-handler/src/rest/middleware/compress.ts +++ b/packages/event-handler/src/rest/middleware/compress.ts @@ -2,8 +2,8 @@ import type { Middleware } from '../../types/index.js'; import type { CompressionOptions } from '../../types/rest.js'; import { CACHE_CONTROL_NO_TRANSFORM_REGEX, - COMPRESSIBLE_CONTENT_TYPE_REGEX, COMPRESSION_ENCODING_TYPES, + DEFAULT_COMPRESSION_RESPONSE_THRESHOLD, } from '../constants.js'; /** @@ -25,8 +25,8 @@ import { * * @example * ```typescript - * import { Router } from '@aws-lambda-powertools/event-handler'; - * import { compress } from '@aws-lambda-powertools/event-handler/rest/middleware'; + * import { Router } from '@aws-lambda-powertools/event-handler/experimental-rest'; + * import { compress } from '@aws-lambda-powertools/event-handler/experimental-rest/middleware'; * * const app = new Router(); * @@ -41,8 +41,8 @@ import { * * @example * ```typescript - * import { Router } from '@aws-lambda-powertools/event-handler'; - * import { compress } from '@aws-lambda-powertools/event-handler/rest/middleware'; + * import { Router } from '@aws-lambda-powertools/event-handler/experimental-rest'; + * import { compress } from '@aws-lambda-powertools/event-handler/experimental-rest/middleware'; * * const app = new Router(); * @@ -62,59 +62,59 @@ import { */ const compress = (options?: CompressionOptions): Middleware => { - const threshold = options?.threshold ?? 1024; + const preferredEncoding = + options?.encoding ?? COMPRESSION_ENCODING_TYPES.GZIP; + const threshold = + options?.threshold ?? DEFAULT_COMPRESSION_RESPONSE_THRESHOLD; return async (_, reqCtx, next) => { await next(); - const contentLength = reqCtx.res.headers.get('content-length'); - const isEncodedOrChunked = - reqCtx.res.headers.has('content-encoding') || - reqCtx.res.headers.has('transfer-encoding'); - - // Check if response should be compressed if ( - isEncodedOrChunked || - reqCtx.request.method === 'HEAD' || - (contentLength && Number(contentLength) < threshold) || - !shouldCompress(reqCtx.res) || - !shouldTransform(reqCtx.res) || - !reqCtx.res.body + !shouldCompress(reqCtx.request, reqCtx.res, preferredEncoding, threshold) ) { return; } - const acceptedEncoding = reqCtx.request.headers.get('accept-encoding'); - const encoding = - options?.encoding ?? - Object.values(COMPRESSION_ENCODING_TYPES).find((encoding) => - acceptedEncoding?.includes(encoding) - ) ?? - COMPRESSION_ENCODING_TYPES.GZIP; - // Compress the response - const stream = new CompressionStream(encoding); + const stream = new CompressionStream(preferredEncoding); reqCtx.res = new Response(reqCtx.res.body.pipeThrough(stream), reqCtx.res); reqCtx.res.headers.delete('content-length'); - reqCtx.res.headers.set('content-encoding', encoding); + reqCtx.res.headers.set('content-encoding', preferredEncoding); }; }; -const shouldCompress = (res: Response) => { - const type = res.headers.get('content-type'); - return ( - type && - (COMPRESSIBLE_CONTENT_TYPE_REGEX.COMMON.test(type) || - COMPRESSIBLE_CONTENT_TYPE_REGEX.OCCASIONAL.test(type) || - COMPRESSIBLE_CONTENT_TYPE_REGEX.RARE.test(type)) - ); -}; +const shouldCompress = ( + request: Request, + response: Response, + preferredEncoding: NonNullable, + threshold: NonNullable +): response is Response & { body: NonNullable } => { + const acceptedEncoding = + request.headers.get('accept-encoding') ?? COMPRESSION_ENCODING_TYPES.ANY; + const contentLength = response.headers.get('content-length'); + const cacheControl = response.headers.get('cache-control'); + + const isEncodedOrChunked = + response.headers.has('content-encoding') || + response.headers.has('transfer-encoding'); + + const shouldEncode = + !acceptedEncoding.includes(COMPRESSION_ENCODING_TYPES.IDENTITY) && + (acceptedEncoding.includes(preferredEncoding) || + acceptedEncoding.includes(COMPRESSION_ENCODING_TYPES.ANY)); -const shouldTransform = (res: Response) => { - const cacheControl = res.headers.get('cache-control'); - // Don't compress for Cache-Control: no-transform - // https://tools.ietf.org/html/rfc7234#section-5.2.2.4 - return !cacheControl || !CACHE_CONTROL_NO_TRANSFORM_REGEX.test(cacheControl); + if ( + !shouldEncode || + isEncodedOrChunked || + request.method === 'HEAD' || + (contentLength && Number(contentLength) < threshold) || + (cacheControl && CACHE_CONTROL_NO_TRANSFORM_REGEX.test(cacheControl)) || + !response.body + ) { + return false; + } + return true; }; export { compress }; diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index 45c67d3143..43d549ef17 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -116,7 +116,7 @@ type ValidationResult = { }; type CompressionOptions = { - encoding?: (typeof COMPRESSION_ENCODING_TYPES)[keyof typeof COMPRESSION_ENCODING_TYPES]; + encoding?: 'gzip' | 'deflate'; threshold?: number; }; diff --git a/packages/event-handler/tests/unit/rest/middleware/compress.test.ts b/packages/event-handler/tests/unit/rest/middleware/compress.test.ts index 6b128064ba..7a36a83303 100644 --- a/packages/event-handler/tests/unit/rest/middleware/compress.test.ts +++ b/packages/event-handler/tests/unit/rest/middleware/compress.test.ts @@ -1,3 +1,4 @@ +import { gzipSync } from 'node:zlib'; import context from '@aws-lambda-powertools/testing-utils/context'; import { Router } from 'src/rest/Router.js'; import { beforeEach, describe, expect, it } from 'vitest'; @@ -31,6 +32,9 @@ describe('Compress Middleware', () => { // Assess expect(result.headers?.['content-encoding']).toBe('gzip'); expect(result.headers?.['content-length']).toBeUndefined(); + expect(result.body).toEqual( + gzipSync(JSON.stringify(body)).toString('base64') + ); }); it('skips compression when content is below threshold', async () => { @@ -98,50 +102,6 @@ describe('Compress Middleware', () => { expect(result.headers?.['content-encoding']).toEqual('gzip'); }); - it.each([ - 'image/jpeg', - 'image/png', - 'image/gif', - 'audio/mpeg', - 'audio/mp4', - 'audio/ogg', - 'video/mp4', - 'video/mpeg', - 'video/webm', - 'application/zip', - 'application/gzip', - 'application/x-gzip', - 'application/octet-stream', - 'application/pdf', - 'application/msword', - 'text/event-stream', - ])( - 'skips compression for non-compressible content types', - async (contentType) => { - // Prepare - const application = new Router(); - application.get( - '/test', - [ - compress(), - createSettingHeadersMiddleware({ - 'content-length': '2000', - 'content-type': contentType, - }), - ], - async () => { - return body; - } - ); - - // Act - const result = await application.resolve(event, context); - - // Assess - expect(result.headers?.['content-encoding']).toBeUndefined(); - } - ); - it('skips compression when cache-control no-transform is set', async () => { // Prepare const application = new Router(); @@ -191,10 +151,10 @@ describe('Compress Middleware', () => { expect(result.headers?.['content-encoding']).toBe('deflate'); }); - it('infers encoding from Accept-Encoding header', async () => { + it('does not compress if Accept-Encoding is set to identity', async () => { // Prepare const deflateCompressionEvent = createTestEvent('/test', 'GET', { - 'Accept-Encoding': 'deflate', + 'Accept-Encoding': 'identity', }); app.get('/test', async () => { return body; @@ -204,6 +164,6 @@ describe('Compress Middleware', () => { const result = await app.resolve(deflateCompressionEvent, context); // Assess - expect(result.headers?.['content-encoding']).toBe('deflate'); + expect(result.headers?.['content-encoding']).not.toBeDefined; }); }); From 558caf5da63e51a133b0e5e21aa32adce75f60f5 Mon Sep 17 00:00:00 2001 From: Swopnil Dangol Date: Wed, 17 Sep 2025 18:27:45 +0100 Subject: [PATCH 13/17] Added tests for the converter --- .../event-handler/tests/unit/rest/converters.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/event-handler/tests/unit/rest/converters.test.ts b/packages/event-handler/tests/unit/rest/converters.test.ts index 435ac6d167..3f2d368e49 100644 --- a/packages/event-handler/tests/unit/rest/converters.test.ts +++ b/packages/event-handler/tests/unit/rest/converters.test.ts @@ -396,6 +396,18 @@ describe('Converters', () => { expect(result.statusCode).toBe(204); expect(result.body).toBe(''); }); + + it('handles compressed response body', async () => { + const response = new Response('Hello World', { + status: 200, + headers: { + 'content-encoding': 'gzip', + }, + }); + + const result = await webResponseToProxyResult(response); + expect(result.isBase64Encoded).toBe(true); + }); }); describe('handlerResultToProxyResult', () => { From b74783e5f723940c7781b4b38bdff52fa799e03d Mon Sep 17 00:00:00 2001 From: Swopnil Dangol Date: Wed, 17 Sep 2025 18:30:42 +0100 Subject: [PATCH 14/17] Removed unused imports and unnecessary assignments --- packages/event-handler/src/rest/converters.ts | 1 - packages/event-handler/src/types/rest.ts | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/event-handler/src/rest/converters.ts b/packages/event-handler/src/rest/converters.ts index 809381a00a..0eed7f92e6 100644 --- a/packages/event-handler/src/rest/converters.ts +++ b/packages/event-handler/src/rest/converters.ts @@ -111,7 +111,6 @@ export const webResponseToProxyResult = async ( } else { // For text content, use text() body = await response.text(); - isBase64Encoded = false; } const result: APIGatewayProxyResult = { diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index 43d549ef17..0ba650c0dc 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -3,11 +3,7 @@ import type { JSONObject, } from '@aws-lambda-powertools/commons/types'; import type { APIGatewayProxyEvent, Context } from 'aws-lambda'; -import type { - COMPRESSION_ENCODING_TYPES, - HttpErrorCodes, - HttpVerbs, -} from '../rest/constants.js'; +import type { HttpErrorCodes, HttpVerbs } from '../rest/constants.js'; import type { Route } from '../rest/Route.js'; import type { Router } from '../rest/Router.js'; import type { ResolveOptions } from './common.js'; From 70dae88093adee7d48641cfcdb49859ace5bb819 Mon Sep 17 00:00:00 2001 From: Swopnil Dangol Date: Wed, 17 Sep 2025 19:55:46 +0100 Subject: [PATCH 15/17] Simplified the shouldCompress check and added base64encoded checks --- .../src/rest/middleware/compress.ts | 19 ++++++++----------- .../unit/rest/middleware/compress.test.ts | 13 ++++++++++--- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/event-handler/src/rest/middleware/compress.ts b/packages/event-handler/src/rest/middleware/compress.ts index 831590fe43..70bffebd30 100644 --- a/packages/event-handler/src/rest/middleware/compress.ts +++ b/packages/event-handler/src/rest/middleware/compress.ts @@ -104,17 +104,14 @@ const shouldCompress = ( (acceptedEncoding.includes(preferredEncoding) || acceptedEncoding.includes(COMPRESSION_ENCODING_TYPES.ANY)); - if ( - !shouldEncode || - isEncodedOrChunked || - request.method === 'HEAD' || - (contentLength && Number(contentLength) < threshold) || - (cacheControl && CACHE_CONTROL_NO_TRANSFORM_REGEX.test(cacheControl)) || - !response.body - ) { - return false; - } - return true; + return ( + shouldEncode && + !isEncodedOrChunked && + request.method !== 'HEAD' && + (!contentLength || Number(contentLength) > threshold) && + (!cacheControl || !CACHE_CONTROL_NO_TRANSFORM_REGEX.test(cacheControl)) && + response.body !== null + ); }; export { compress }; diff --git a/packages/event-handler/tests/unit/rest/middleware/compress.test.ts b/packages/event-handler/tests/unit/rest/middleware/compress.test.ts index 7a36a83303..056b04219a 100644 --- a/packages/event-handler/tests/unit/rest/middleware/compress.test.ts +++ b/packages/event-handler/tests/unit/rest/middleware/compress.test.ts @@ -32,6 +32,7 @@ describe('Compress Middleware', () => { // Assess expect(result.headers?.['content-encoding']).toBe('gzip'); expect(result.headers?.['content-length']).toBeUndefined(); + expect(result.isBase64Encoded).toBe(true); expect(result.body).toEqual( gzipSync(JSON.stringify(body)).toString('base64') ); @@ -58,6 +59,7 @@ describe('Compress Middleware', () => { // Assess expect(result.headers?.['content-encoding']).toBeUndefined(); + expect(result.isBase64Encoded).toBe(false); }); it('skips compression for HEAD requests', async () => { @@ -72,6 +74,7 @@ describe('Compress Middleware', () => { // Assess expect(result.headers?.['content-encoding']).toBeUndefined(); + expect(result.isBase64Encoded).toBe(false); }); it('skips compression when already encoded', async () => { @@ -100,6 +103,7 @@ describe('Compress Middleware', () => { // Assess expect(result.headers?.['content-encoding']).toEqual('gzip'); + expect(result.isBase64Encoded).toBe(true); }); it('skips compression when cache-control no-transform is set', async () => { @@ -124,6 +128,7 @@ describe('Compress Middleware', () => { // Assess expect(result.headers?.['content-encoding']).toBeUndefined(); + expect(result.isBase64Encoded).toBe(false); }); it('uses specified encoding when provided', async () => { @@ -149,11 +154,12 @@ describe('Compress Middleware', () => { // Assess expect(result.headers?.['content-encoding']).toBe('deflate'); + expect(result.isBase64Encoded).toBe(true); }); it('does not compress if Accept-Encoding is set to identity', async () => { // Prepare - const deflateCompressionEvent = createTestEvent('/test', 'GET', { + const noCompressionEvent = createTestEvent('/test', 'GET', { 'Accept-Encoding': 'identity', }); app.get('/test', async () => { @@ -161,9 +167,10 @@ describe('Compress Middleware', () => { }); // Act - const result = await app.resolve(deflateCompressionEvent, context); + const result = await app.resolve(noCompressionEvent, context); // Assess - expect(result.headers?.['content-encoding']).not.toBeDefined; + expect(result.headers?.['content-encoding']).toBeUndefined(); + expect(result.isBase64Encoded).toBe(false); }); }); From 4458a36bb381bf98cece96062403f8693ee774aa Mon Sep 17 00:00:00 2001 From: Swopnil Dangol Date: Thu, 18 Sep 2025 09:14:22 +0100 Subject: [PATCH 16/17] Added check for base64 encoding when the response is compressed --- packages/event-handler/tests/unit/rest/converters.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/event-handler/tests/unit/rest/converters.test.ts b/packages/event-handler/tests/unit/rest/converters.test.ts index 3f2d368e49..a56aaa3c48 100644 --- a/packages/event-handler/tests/unit/rest/converters.test.ts +++ b/packages/event-handler/tests/unit/rest/converters.test.ts @@ -407,6 +407,9 @@ describe('Converters', () => { const result = await webResponseToProxyResult(response); expect(result.isBase64Encoded).toBe(true); + expect(result.body).toEqual( + Buffer.from('Hello World').toString('base64') + ); }); }); From f6967a5f5da1f417fa833064996bc65e24d4cfdb Mon Sep 17 00:00:00 2001 From: Swopnil Dangol Date: Thu, 18 Sep 2025 09:24:39 +0100 Subject: [PATCH 17/17] Fixed the tests structure --- .../tests/unit/rest/converters.test.ts | 125 +++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/packages/event-handler/tests/unit/rest/converters.test.ts b/packages/event-handler/tests/unit/rest/converters.test.ts index a56aaa3c48..5bac1f13b9 100644 --- a/packages/event-handler/tests/unit/rest/converters.test.ts +++ b/packages/event-handler/tests/unit/rest/converters.test.ts @@ -38,8 +38,10 @@ describe('Converters', () => { }; it('converts basic GET request', () => { + // Prepare & Act const request = proxyEventToWebRequest(baseEvent); + // Assess expect(request).toBeInstanceOf(Request); expect(request.method).toBe('GET'); expect(request.url).toBe('https://api.example.com/test'); @@ -47,28 +49,37 @@ describe('Converters', () => { }); it('uses Host header over domainName', () => { + // Prepare const event = { ...baseEvent, headers: { Host: 'custom.example.com' }, }; + // Act const request = proxyEventToWebRequest(event); + + // Assess expect(request).toBeInstanceOf(Request); expect(request.url).toBe('https://custom.example.com/test'); }); it('uses X-Forwarded-Proto header for protocol', () => { + // Prepare const event = { ...baseEvent, headers: { 'X-Forwarded-Proto': 'https' }, }; + // Act const request = proxyEventToWebRequest(event); + + // Assess expect(request).toBeInstanceOf(Request); expect(request.url).toBe('https://api.example.com/test'); }); it('handles null values in multiValueHeaders arrays', () => { + // Prepare const event = { ...baseEvent, multiValueHeaders: { @@ -77,13 +88,17 @@ describe('Converters', () => { }, }; + // Act const request = proxyEventToWebRequest(event); + + // Assess expect(request).toBeInstanceOf(Request); expect(request.headers.get('Accept')).toBe(null); expect(request.headers.get('Custom-Header')).toBe('value1'); }); it('handles null values in multiValueQueryStringParameters arrays', () => { + // Prepare const event = { ...baseEvent, multiValueQueryStringParameters: { @@ -92,7 +107,10 @@ describe('Converters', () => { }, }; + // Act const request = proxyEventToWebRequest(event); + + // Assess expect(request).toBeInstanceOf(Request); const url = new URL(request.url); expect(url.searchParams.has('filter')).toBe(false); @@ -100,6 +118,7 @@ describe('Converters', () => { }); it('handles POST request with string body', async () => { + // Prepare const event = { ...baseEvent, httpMethod: 'POST', @@ -107,7 +126,10 @@ describe('Converters', () => { headers: { 'Content-Type': 'application/json' }, }; + // Act const request = proxyEventToWebRequest(event); + + // Assess expect(request).toBeInstanceOf(Request); expect(request.method).toBe('POST'); expect(request.text()).resolves.toBe('{"key":"value"}'); @@ -115,9 +137,9 @@ describe('Converters', () => { }); it('decodes base64 encoded body', async () => { + // Prepare const originalText = 'Hello World'; const base64Text = Buffer.from(originalText).toString('base64'); - const event = { ...baseEvent, httpMethod: 'POST', @@ -125,12 +147,16 @@ describe('Converters', () => { isBase64Encoded: true, }; + // Act const request = proxyEventToWebRequest(event); + + // Assess expect(request).toBeInstanceOf(Request); expect(request.text()).resolves.toBe(originalText); }); it('handles single-value headers', () => { + // Prepare const event = { ...baseEvent, headers: { @@ -139,13 +165,17 @@ describe('Converters', () => { }, }; + // Act const request = proxyEventToWebRequest(event); + + // Assess expect(request).toBeInstanceOf(Request); expect(request.headers.get('Authorization')).toBe('Bearer token123'); expect(request.headers.get('User-Agent')).toBe('test-agent'); }); it('handles multiValueHeaders', () => { + // Prepare const event = { ...baseEvent, multiValueHeaders: { @@ -154,13 +184,17 @@ describe('Converters', () => { }, }; + // Act const request = proxyEventToWebRequest(event); + + // Assess expect(request).toBeInstanceOf(Request); expect(request.headers.get('Accept')).toBe('application/json, text/html'); expect(request.headers.get('Custom-Header')).toBe('value1, value2'); }); it('handles both single and multi-value headers', () => { + // Prepare const event = { ...baseEvent, headers: { @@ -171,13 +205,17 @@ describe('Converters', () => { }, }; + // Act const request = proxyEventToWebRequest(event); + + // Assess expect(request).toBeInstanceOf(Request); expect(request.headers.get('Authorization')).toBe('Bearer token123'); expect(request.headers.get('Accept')).toBe('application/json, text/html'); }); it('deduplicates headers when same header exists in both headers and multiValueHeaders', () => { + // Prepare const event = { ...baseEvent, headers: { @@ -190,7 +228,10 @@ describe('Converters', () => { }, }; + // Act const request = proxyEventToWebRequest(event); + + // Assess expect(request).toBeInstanceOf(Request); expect(request.headers.get('Host')).toBe( 'abcd1234.execute-api.eu-west-1.amazonaws.com' @@ -199,6 +240,7 @@ describe('Converters', () => { }); it('appends unique values from multiValueHeaders when header already exists', () => { + // Prepare const event = { ...baseEvent, headers: { @@ -209,12 +251,16 @@ describe('Converters', () => { }, }; + // Act const request = proxyEventToWebRequest(event); + + // Assess expect(request).toBeInstanceOf(Request); expect(request.headers.get('Accept')).toBe('application/json, text/html'); }); it('handles queryStringParameters', () => { + // Prepare const event = { ...baseEvent, queryStringParameters: { @@ -223,7 +269,10 @@ describe('Converters', () => { }, }; + // Act const request = proxyEventToWebRequest(event); + + // Assess expect(request).toBeInstanceOf(Request); const url = new URL(request.url); expect(url.searchParams.get('name')).toBe('john'); @@ -231,6 +280,7 @@ describe('Converters', () => { }); it('handles multiValueQueryStringParameters', () => { + // Prepare const event = { ...baseEvent, multiValueQueryStringParameters: { @@ -239,7 +289,10 @@ describe('Converters', () => { }, }; + // Act const request = proxyEventToWebRequest(event); + + // Assess expect(request).toBeInstanceOf(Request); const url = new URL(request.url); expect(url.searchParams.getAll('filter')).toEqual(['name', 'age']); @@ -247,6 +300,7 @@ describe('Converters', () => { }); it('handles both queryStringParameters and multiValueQueryStringParameters', () => { + // Prepare const event = { ...baseEvent, queryStringParameters: { @@ -257,7 +311,10 @@ describe('Converters', () => { }, }; + // Act const request = proxyEventToWebRequest(event); + + // Assess expect(request).toBeInstanceOf(Request); const url = new URL(request.url); expect(url.searchParams.get('single')).toBe('value'); @@ -265,6 +322,7 @@ describe('Converters', () => { }); it('skips null queryStringParameter values', () => { + // Prepare const event = { ...baseEvent, queryStringParameters: { @@ -273,7 +331,10 @@ describe('Converters', () => { }, }; + // Act const request = proxyEventToWebRequest(event); + + // Assess expect(request).toBeInstanceOf(Request); const url = new URL(request.url); expect(url.searchParams.get('valid')).toBe('value'); @@ -281,6 +342,7 @@ describe('Converters', () => { }); it('skips null header values', () => { + // Prepare const event = { ...baseEvent, headers: { @@ -289,13 +351,17 @@ describe('Converters', () => { }, }; + // Act const request = proxyEventToWebRequest(event); + + // Assess expect(request).toBeInstanceOf(Request); expect(request.headers.get('Valid-Header')).toBe('value'); expect(request.headers.get('Null-Header')).toBe(null); }); it('handles null/undefined collections', () => { + // Prepare const event = { ...baseEvent, headers: null as any, @@ -304,7 +370,10 @@ describe('Converters', () => { multiValueQueryStringParameters: null as any, }; + // Act const request = proxyEventToWebRequest(event); + + // Assess expect(request).toBeInstanceOf(Request); expect(request.method).toBe('GET'); expect(request.url).toBe('https://api.example.com/test'); @@ -313,6 +382,7 @@ describe('Converters', () => { describe('responseToProxyResult', () => { it('converts basic Response to API Gateway result', async () => { + // Prepare const response = new Response('Hello World', { status: 200, headers: { @@ -320,8 +390,10 @@ describe('Converters', () => { }, }); + // Act const result = await webResponseToProxyResult(response); + // Assess expect(result.statusCode).toBe(200); expect(result.body).toBe('Hello World'); expect(result.isBase64Encoded).toBe(false); @@ -329,13 +401,16 @@ describe('Converters', () => { }); it('handles single-value headers', async () => { + // Prepare const response = new Response('Hello', { status: 201, headers: { 'content-type': 'text/plain', 'x-custom': 'value' }, }); + // Act const result = await webResponseToProxyResult(response); + // Assess expect(result.statusCode).toBe(201); expect(result.headers).toEqual({ 'content-type': 'text/plain', @@ -344,6 +419,7 @@ describe('Converters', () => { }); it('handles multi-value headers', async () => { + // Prepare const response = new Response('Hello', { status: 200, headers: { @@ -352,8 +428,10 @@ describe('Converters', () => { }, }); + // Act const result = await webResponseToProxyResult(response); + // Assess expect(result.headers).toEqual({ 'content-type': 'application/json' }); expect(result.multiValueHeaders).toEqual({ 'set-cookie': ['cookie1=value1', 'cookie2=value2'], @@ -361,6 +439,7 @@ describe('Converters', () => { }); it('handles mixed single and multi-value headers', async () => { + // Prepare const response = new Response('Hello', { status: 200, headers: { @@ -369,8 +448,10 @@ describe('Converters', () => { }, }); + // Act const result = await webResponseToProxyResult(response); + // Assess expect(result.headers).toEqual({ 'content-type': 'application/json', }); @@ -380,10 +461,13 @@ describe('Converters', () => { }); it('handles different status codes', async () => { + // Prepare const response = new Response('Not Found', { status: 404 }); + // Act const result = await webResponseToProxyResult(response); + // Assess expect(result.statusCode).toBe(404); expect(result.body).toBe('Not Found'); }); @@ -391,13 +475,16 @@ describe('Converters', () => { it('handles empty response body', async () => { const response = new Response(null, { status: 204 }); + // Act const result = await webResponseToProxyResult(response); + // Assess expect(result.statusCode).toBe(204); expect(result.body).toBe(''); }); it('handles compressed response body', async () => { + // Prepare const response = new Response('Hello World', { status: 200, headers: { @@ -405,7 +492,10 @@ describe('Converters', () => { }, }); + // Act const result = await webResponseToProxyResult(response); + + // Assess expect(result.isBase64Encoded).toBe(true); expect(result.body).toEqual( Buffer.from('Hello World').toString('base64') @@ -415,6 +505,7 @@ describe('Converters', () => { describe('handlerResultToProxyResult', () => { it('returns APIGatewayProxyResult as-is', async () => { + // Prepare const proxyResult = { statusCode: 200, body: 'test', @@ -422,26 +513,34 @@ describe('Converters', () => { isBase64Encoded: false, }; + // Act const result = await handlerResultToProxyResult(proxyResult); + // Assess expect(result).toBe(proxyResult); }); it('converts Response object', async () => { + // Prepare const response = new Response('Hello', { status: 201 }); + // Act const result = await handlerResultToProxyResult(response); + // Assess expect(result.statusCode).toBe(201); expect(result.body).toBe('Hello'); expect(result.isBase64Encoded).toBe(false); }); it('converts plain object to JSON', async () => { + // Prepare const obj = { message: 'success', data: [1, 2, 3] }; + // Act const result = await handlerResultToProxyResult(obj); + // Assess expect(result.statusCode).toBe(200); expect(result.body).toBe(JSON.stringify(obj)); expect(result.headers).toEqual({ 'content-type': 'application/json' }); @@ -451,14 +550,18 @@ describe('Converters', () => { describe('handlerResultToResponse', () => { it('returns Response object as-is', () => { + // Prepare const response = new Response('Hello', { status: 201 }); + // Act const result = handlerResultToWebResponse(response); + // Assess expect(result).toBe(response); }); it('converts APIGatewayProxyResult to Response', async () => { + // Prepare const proxyResult = { statusCode: 201, body: 'Hello World', @@ -466,8 +569,10 @@ describe('Converters', () => { isBase64Encoded: false, }; + // Act const result = handlerResultToWebResponse(proxyResult); + // Assess expect(result).toBeInstanceOf(Response); expect(result.status).toBe(201); expect(await result.text()).toBe('Hello World'); @@ -475,6 +580,7 @@ describe('Converters', () => { }); it('converts APIGatewayProxyResult with multiValueHeaders', async () => { + // Prepare const proxyResult = { statusCode: 200, body: 'test', @@ -485,8 +591,10 @@ describe('Converters', () => { isBase64Encoded: false, }; + // Act const result = handlerResultToWebResponse(proxyResult); + // Assess expect(result.headers.get('content-type')).toBe('application/json'); expect(result.headers.get('Set-Cookie')).toBe( 'cookie1=value1, cookie2=value2' @@ -494,10 +602,13 @@ describe('Converters', () => { }); it('converts plain object to JSON Response with default headers', async () => { + // Prepare const obj = { message: 'success' }; + // Act const result = handlerResultToWebResponse(obj); + // Assess expect(result).toBeInstanceOf(Response); expect(result.status).toBe(200); expect(result.text()).resolves.toBe(JSON.stringify(obj)); @@ -505,16 +616,20 @@ describe('Converters', () => { }); it('uses provided headers for plain object', async () => { + // Prepare const obj = { message: 'success' }; const headers = new Headers({ 'x-custom': 'value' }); + // Act const result = handlerResultToWebResponse(obj, headers); + // Assess expect(result.headers.get('Content-Type')).toBe('application/json'); expect(result.headers.get('x-custom')).toBe('value'); }); it('handles APIGatewayProxyResult with undefined headers', async () => { + // Prepare const proxyResult = { statusCode: 200, body: 'test', @@ -522,13 +637,16 @@ describe('Converters', () => { isBase64Encoded: false, }; + // Act const result = handlerResultToWebResponse(proxyResult); + // Assess expect(result).toBeInstanceOf(Response); expect(result.status).toBe(200); }); it('handles APIGatewayProxyResult with undefined multiValueHeaders', async () => { + // Prepare const proxyResult = { statusCode: 200, body: 'test', @@ -537,12 +655,15 @@ describe('Converters', () => { isBase64Encoded: false, }; + // Act const result = handlerResultToWebResponse(proxyResult); + // Assess expect(result.headers.get('content-type')).toBe('text/plain'); }); it('handles APIGatewayProxyResult with undefined values in multiValueHeaders', async () => { + // Prepare const proxyResult = { statusCode: 200, body: 'test', @@ -551,8 +672,10 @@ describe('Converters', () => { isBase64Encoded: false, }; + // Act const result = handlerResultToWebResponse(proxyResult); + // Assess expect(result.headers.get('content-type')).toBe('text/plain'); }); });