diff --git a/src/adapter/aws-lambda/client.test.ts b/src/adapter/aws-lambda/client.test.ts new file mode 100644 index 000000000..d852a965f --- /dev/null +++ b/src/adapter/aws-lambda/client.test.ts @@ -0,0 +1,113 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { HttpResponse, http } from 'msw' +import { setupServer } from 'msw/node' +import { expect, beforeAll, afterAll, afterEach, describe, it } from 'vitest' +import { Hono } from '../../hono' +import { hlc, calculateSHA256 } from './client' + +const app = new Hono() + .post('/hash-check', async (c) => { + const payload = await c.req.json() + const sha256 = c.req.header('x-amz-content-sha256') + return c.json({ receivedHash: sha256, payload }, 200) + }) + .put('/hash-check', async (c) => { + const payload = await c.req.json() + const sha256 = c.req.header('x-amz-content-sha256') + return c.json({ receivedHash: sha256, payload }, 200) + }) + +const server = setupServer( + http.post('http://localhost/hash-check', async ({ request }) => { + const sha256 = request.headers.get('x-amz-content-sha256') + const payload = await request.json() + + const expectedHash = await calculateSHA256(JSON.stringify(payload)) + + if (sha256 === expectedHash) { + return HttpResponse.json({ result: 'ok', receivedHash: sha256, payload }) + } else { + return HttpResponse.json( + { result: 'mismatch', receivedHash: sha256, expectedHash }, + { status: 400 } + ) + } + }), + + http.put('http://localhost/hash-check', async ({ request }) => { + const sha256 = request.headers.get('x-amz-content-sha256') + const payload = await request.json() + + const expectedHash = await calculateSHA256(JSON.stringify(payload)) + if (sha256 === expectedHash) { + return HttpResponse.json({ result: 'ok', receivedHash: sha256, payload }) + } else { + return HttpResponse.json( + { result: 'mismatch', receivedHash: sha256, expectedHash }, + { status: 400 } + ) + } + }) +) + +beforeAll(() => server.listen()) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +describe('x-amz-content-sha256 header tests', () => { + type AppType = typeof app + const client = hlc('http://localhost') + + it('Should send correct x-amz-content-sha256 header on POST', async () => { + const payload = { name: 'Alice', message: 'Hello World' } + const res = await client['hash-check'].$post({ json: payload }) + expect(res.ok).toBe(true) + const data = await res.json() + expect(data.payload).toEqual(payload) + expect(data.receivedHash).toBeDefined() + + const expectedHash = await calculateSHA256(JSON.stringify(payload)) + expect(data.receivedHash).toBe(expectedHash) + }) + + it('Should send correct x-amz-content-sha256 header on PUT', async () => { + const payload = { user: 'Bob', comment: 'This is a test' } + const res = await client['hash-check'].$put({ json: payload }) + expect(res.ok).toBe(true) + const data = await res.json() + expect(data.payload).toEqual(payload) + expect(data.receivedHash).toBeDefined() + + const calculateSHA256 = async (message: string): Promise => { + const msgBuffer = new TextEncoder().encode(message) + const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer) + const hashBytes = new Uint8Array(hashBuffer) + let hashHex = '' + for (let i = 0; i < hashBytes.length; i++) { + const b = hashBytes[i] + hashHex += b < 16 ? '0' + b.toString(16) : b.toString(16) + } + return hashHex + } + + const expectedHash = await calculateSHA256(JSON.stringify(payload)) + expect(data.receivedHash).toBe(expectedHash) + }) + + it('Should fail if no JSON is provided on POST (no hash)', async () => { + const res = await client['hash-check'].$post() + expect(res.ok).toBe(false) + expect(res.status).toBe(500) + const data = await res.json() + expect(data.receivedHash).toBeUndefined() + }) + + it('Should fail if no JSON is provided on PUT (no hash)', async () => { + const res = await client['hash-check'].$put() + expect(res.ok).toBe(false) + expect(res.status).toBe(500) + const data = await res.json() + expect(data.receivedHash).toBeUndefined() + }) +}) diff --git a/src/adapter/aws-lambda/client.ts b/src/adapter/aws-lambda/client.ts new file mode 100644 index 000000000..6c5a855db --- /dev/null +++ b/src/adapter/aws-lambda/client.ts @@ -0,0 +1,209 @@ +import { createProxy } from '../../client/client' +import type { Client, ClientRequestOptions } from '../../client/types' +import { + buildSearchParams, + deepMerge, + mergePath, + removeIndexString, + replaceUrlParam, + replaceUrlProtocol, +} from '../../client/utils' +import type { Hono } from '../../hono' +import type { FormValue, ValidationTargets } from '../../types' +import { serialize } from '../../utils/cookie' +import type { UnionToIntersection } from '../../utils/types' + +export const calculateSHA256 = async (message: string): Promise => { + const msgBuffer = new TextEncoder().encode(message) + const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer) + const hashBytes = new Uint8Array(hashBuffer) + + let hashHex = '' + for (let i = 0; i < hashBytes.length; i++) { + const b = hashBytes[i] + hashHex += b < 16 ? '0' + b.toString(16) : b.toString(16) + } + + return hashHex +} + +class LambdaClientRequestImpl { + private url: string + private method: string + private queryParams: URLSearchParams | undefined = undefined + private pathParams: Record = {} + private rBody: BodyInit | undefined + private cType: string | undefined = undefined + + constructor(url: string, method: string) { + this.url = url + this.method = method + } + fetch = async ( + args?: ValidationTargets & { + param?: Record + }, + opt?: ClientRequestOptions + ) => { + if (args) { + if (args.query) { + this.queryParams = buildSearchParams(args.query) + } + + if (args.form) { + const form = new FormData() + for (const [k, v] of Object.entries(args.form)) { + if (Array.isArray(v)) { + for (const v2 of v) { + form.append(k, v2) + } + } else { + form.append(k, v) + } + } + this.rBody = form + } + + if (args.json) { + this.rBody = JSON.stringify(args.json) + this.cType = 'application/json' + } + + if (args.param) { + this.pathParams = args.param + } + } + + let methodUpperCase = this.method.toUpperCase() + + const headerValues: Record = { + ...args?.header, + ...(typeof opt?.headers === 'function' ? await opt.headers() : opt?.headers), + } + + if (args?.cookie) { + const cookies: string[] = [] + for (const [key, value] of Object.entries(args.cookie)) { + cookies.push(serialize(key, value, { path: '/' })) + } + headerValues['Cookie'] = cookies.join(',') + } + + if (this.cType) { + headerValues['Content-Type'] = this.cType + } + + if ( + (methodUpperCase === 'POST' || methodUpperCase === 'PUT') && + this.rBody && + typeof this.rBody === 'string' + ) { + const hash = await calculateSHA256(this.rBody) + headerValues['x-amz-content-sha256'] = hash + } + + const headers = new Headers(headerValues ?? undefined) + let url = this.url + + url = removeIndexString(url) + url = replaceUrlParam(url, this.pathParams) + + if (this.queryParams) { + url = url + '?' + this.queryParams.toString() + } + methodUpperCase = this.method.toUpperCase() + const setBody = !(methodUpperCase === 'GET' || methodUpperCase === 'HEAD') + + return (opt?.fetch || fetch)(url, { + body: setBody ? this.rBody : undefined, + method: methodUpperCase, + headers: headers, + ...opt?.init, + }) + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const hlc = >( + baseUrl: string, + options?: ClientRequestOptions +) => + createProxy(function proxyCallback(opts) { + const parts = [...opts.path] + + // allow calling .toString() and .valueOf() on the proxy + if (parts.at(-1) === 'toString') { + if (parts.at(-2) === 'name') { + // e.g. hc().somePath.name.toString() -> "somePath" + return parts.at(-3) || '' + } + // e.g. hc().somePath.toString() + return proxyCallback.toString() + } + + if (parts.at(-1) === 'valueOf') { + if (parts.at(-2) === 'name') { + // e.g. hc().somePath.name.valueOf() -> "somePath" + return parts.at(-3) || '' + } + // e.g. hc().somePath.valueOf() + return proxyCallback + } + + let method = '' + if (/^\$/.test(parts.at(-1) as string)) { + const last = parts.pop() + if (last) { + method = last.replace(/^\$/, '') + } + } + + const path = parts.join('/') + const url = mergePath(baseUrl, path) + if (method === 'url') { + let result = url + if (opts.args[0]) { + if (opts.args[0].param) { + result = replaceUrlParam(url, opts.args[0].param) + } + if (opts.args[0].query) { + result = result + '?' + buildSearchParams(opts.args[0].query).toString() + } + } + return new URL(result) + } + if (method === 'ws') { + const webSocketUrl = replaceUrlProtocol( + opts.args[0] && opts.args[0].param ? replaceUrlParam(url, opts.args[0].param) : url, + 'ws' + ) + const targetUrl = new URL(webSocketUrl) + + const queryParams: Record | undefined = opts.args[0]?.query + if (queryParams) { + Object.entries(queryParams).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((item) => targetUrl.searchParams.append(key, item)) + } else { + targetUrl.searchParams.set(key, value) + } + }) + } + const establishWebSocket = (...args: ConstructorParameters) => { + if (options?.webSocket !== undefined && typeof options.webSocket === 'function') { + return options.webSocket(...args) + } + return new WebSocket(...args) + } + + return establishWebSocket(targetUrl.toString()) + } + + const req = new LambdaClientRequestImpl(url, method) + if (method) { + options ??= {} + const args = deepMerge(options, { ...opts.args[1] }) + return req.fetch(opts.args[0], args) + } + return req + }, []) as UnionToIntersection> diff --git a/src/adapter/aws-lambda/index.ts b/src/adapter/aws-lambda/index.ts index 74d0a09ad..add124614 100644 --- a/src/adapter/aws-lambda/index.ts +++ b/src/adapter/aws-lambda/index.ts @@ -4,6 +4,7 @@ */ export { handle, streamHandle } from './handler' +export { hlc } from './client' export type { APIGatewayProxyResult, LambdaEvent } from './handler' export type { ApiGatewayRequestContext, diff --git a/src/client/client.ts b/src/client/client.ts index 95f3a6fd0..48e964741 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -12,7 +12,7 @@ import { replaceUrlProtocol, } from './utils' -const createProxy = (callback: Callback, path: string[]) => { +export const createProxy = (callback: Callback, path: string[]) => { const proxy: unknown = new Proxy(() => {}, { get(_obj, key) { if (typeof key !== 'string' || key === 'then') {