diff --git a/packages/event-handler/package.json b/packages/event-handler/package.json index 563e200734..49510b81c9 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/constants.ts b/packages/event-handler/src/rest/constants.ts index 5267b5a2f4..b647b78fb9 100644 --- a/packages/event-handler/src/rest/constants.ts +++ b/packages/event-handler/src/rest/constants.ts @@ -87,3 +87,15 @@ export const PARAM_PATTERN = /:([a-zA-Z_]\w*)(?=\/|$)/g; export const SAFE_CHARS = "-._~()'!*:@,;=+&$"; export const UNSAFE_CHARS = '%<> \\[\\]{}|^'; + +export const DEFAULT_COMPRESSION_RESPONSE_THRESHOLD = 1024; + +export const CACHE_CONTROL_NO_TRANSFORM_REGEX = + /(?:^|,)\s*?no-transform\s*?(?:,|$)/i; + +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..0eed7f92e6 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,34 @@ 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(); + } + 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 new file mode 100644 index 0000000000..70bffebd30 --- /dev/null +++ b/packages/event-handler/src/rest/middleware/compress.ts @@ -0,0 +1,117 @@ +import type { Middleware } from '../../types/index.js'; +import type { CompressionOptions } from '../../types/rest.js'; +import { + CACHE_CONTROL_NO_TRANSFORM_REGEX, + COMPRESSION_ENCODING_TYPES, + DEFAULT_COMPRESSION_RESPONSE_THRESHOLD, +} 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/experimental-rest'; + * import { compress } from '@aws-lambda-powertools/event-handler/experimental-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/experimental-rest'; + * import { compress } from '@aws-lambda-powertools/event-handler/experimental-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 preferredEncoding = + options?.encoding ?? COMPRESSION_ENCODING_TYPES.GZIP; + const threshold = + options?.threshold ?? DEFAULT_COMPRESSION_RESPONSE_THRESHOLD; + + return async (_, reqCtx, next) => { + await next(); + + if ( + !shouldCompress(reqCtx.request, reqCtx.res, preferredEncoding, threshold) + ) { + return; + } + + // Compress the response + 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', preferredEncoding); + }; +}; + +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)); + + 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/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..0ba650c0dc 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -111,6 +111,11 @@ type ValidationResult = { issues: string[]; }; +type CompressionOptions = { + encoding?: 'gzip' | 'deflate'; + threshold?: number; +}; + export type { CompiledRoute, DynamicRoute, @@ -131,4 +136,5 @@ export type { RestRouteHandlerOptions, RouteRegistryOptions, ValidationResult, + CompressionOptions, }; diff --git a/packages/event-handler/tests/unit/rest/converters.test.ts b/packages/event-handler/tests/unit/rest/converters.test.ts index 435ac6d167..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,15 +475,37 @@ 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: { + 'content-encoding': 'gzip', + }, + }); + + // Act + const result = await webResponseToProxyResult(response); + + // Assess + expect(result.isBase64Encoded).toBe(true); + expect(result.body).toEqual( + Buffer.from('Hello World').toString('base64') + ); + }); }); describe('handlerResultToProxyResult', () => { it('returns APIGatewayProxyResult as-is', async () => { + // Prepare const proxyResult = { statusCode: 200, body: 'test', @@ -407,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' }); @@ -436,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', @@ -451,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'); @@ -460,6 +580,7 @@ describe('Converters', () => { }); it('converts APIGatewayProxyResult with multiValueHeaders', async () => { + // Prepare const proxyResult = { statusCode: 200, body: 'test', @@ -470,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' @@ -479,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)); @@ -490,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', @@ -507,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', @@ -522,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', @@ -536,8 +672,10 @@ describe('Converters', () => { isBase64Encoded: false, }; + // Act const result = handlerResultToWebResponse(proxyResult); + // Assess expect(result.headers.get('content-type')).toBe('text/plain'); }); }); diff --git a/packages/event-handler/tests/unit/rest/helpers.ts b/packages/event-handler/tests/unit/rest/helpers.ts index 4c846d3e81..93255f79c1 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, @@ -65,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).forEach(([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 new file mode 100644 index 0000000000..056b04219a --- /dev/null +++ b/packages/event-handler/tests/unit/rest/middleware/compress.test.ts @@ -0,0 +1,176 @@ +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'; +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 + app.get('/test', async () => { + return body; + }); + + // Act + const result = await app.resolve(event, context); + + // 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') + ); + }); + + it('skips compression when content is below threshold', async () => { + // Prepare + const application = new Router(); + application.get( + '/test', + [ + compress({ threshold: 1024 }), + createSettingHeadersMiddleware({ + 'content-length': '1', + }), + ], + async () => { + return { test: 'x' }; + } + ); + + // Act + const result = await application.resolve(event, context); + + // Assess + expect(result.headers?.['content-encoding']).toBeUndefined(); + expect(result.isBase64Encoded).toBe(false); + }); + + it('skips compression for HEAD requests', async () => { + // Prepare + const headEvent = createTestEvent('/test', 'HEAD'); + app.head('/test', async () => { + return body; + }); + + // Act + const result = await app.resolve(headEvent, context); + + // Assess + expect(result.headers?.['content-encoding']).toBeUndefined(); + expect(result.isBase64Encoded).toBe(false); + }); + + it('skips compression when already encoded', async () => { + // Prepare + const application = new Router(); + application.get( + '/test', + [ + compress({ + encoding: 'deflate', + }), + compress({ + encoding: 'gzip', + }), + createSettingHeadersMiddleware({ + 'content-length': '2000', + }), + ], + async () => { + return body; + } + ); + + // Act + const result = await application.resolve(event, context); + + // Assess + expect(result.headers?.['content-encoding']).toEqual('gzip'); + expect(result.isBase64Encoded).toBe(true); + }); + + it('skips compression when cache-control no-transform is set', async () => { + // Prepare + const application = new Router(); + application.get( + '/test', + [ + compress(), + createSettingHeadersMiddleware({ + 'content-length': '2000', + 'cache-control': 'no-transform', + }), + ], + async () => { + return body; + } + ); + + // Act + const result = await application.resolve(event, context); + + // Assess + expect(result.headers?.['content-encoding']).toBeUndefined(); + expect(result.isBase64Encoded).toBe(false); + }); + + it('uses specified encoding when provided', async () => { + // Prepare + const application = new Router(); + application.get( + '/test', + [ + compress({ + encoding: 'deflate', + }), + createSettingHeadersMiddleware({ + 'content-length': '2000', + }), + ], + async () => { + return body; + } + ); + + // Act + const result = await application.resolve(event, context); + + // 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 noCompressionEvent = createTestEvent('/test', 'GET', { + 'Accept-Encoding': 'identity', + }); + app.get('/test', async () => { + return body; + }); + + // Act + const result = await app.resolve(noCompressionEvent, context); + + // Assess + expect(result.headers?.['content-encoding']).toBeUndefined(); + expect(result.isBase64Encoded).toBe(false); + }); +});