Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(aws-lambda): Hono-Lambda Client #3740

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions src/adapter/aws-lambda/client.test.ts
Original file line number Diff line number Diff line change
@@ -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<AppType>('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<string> => {
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()
})
})
209 changes: 209 additions & 0 deletions src/adapter/aws-lambda/client.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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<string, string> = {}
private rBody: BodyInit | undefined
private cType: string | undefined = undefined

constructor(url: string, method: string) {
this.url = url
this.method = method
}
fetch = async (
args?: ValidationTargets<FormValue> & {
param?: Record<string, string>
},
opt?: ClientRequestOptions
) => {
if (args) {
if (args.query) {
this.queryParams = buildSearchParams(args.query)
}

Check warning on line 51 in src/adapter/aws-lambda/client.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/aws-lambda/client.ts#L50-L51

Added lines #L50 - L51 were not covered by tests

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
}

Check warning on line 65 in src/adapter/aws-lambda/client.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/aws-lambda/client.ts#L54-L65

Added lines #L54 - L65 were not covered by tests

if (args.json) {
this.rBody = JSON.stringify(args.json)
this.cType = 'application/json'
}

if (args.param) {
this.pathParams = args.param
}

Check warning on line 74 in src/adapter/aws-lambda/client.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/aws-lambda/client.ts#L73-L74

Added lines #L73 - L74 were not covered by tests
}

let methodUpperCase = this.method.toUpperCase()

const headerValues: Record<string, string> = {
...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(',')
}

Check warning on line 90 in src/adapter/aws-lambda/client.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/aws-lambda/client.ts#L85-L90

Added lines #L85 - L90 were not covered by tests

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()
}

Check warning on line 113 in src/adapter/aws-lambda/client.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/aws-lambda/client.ts#L112-L113

Added lines #L112 - L113 were not covered by tests
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 = <T extends Hono<any, any, any>>(
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') {

Check warning on line 136 in src/adapter/aws-lambda/client.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/aws-lambda/client.ts#L136

Added line #L136 was not covered by tests
// e.g. hc().somePath.name.toString() -> "somePath"
return parts.at(-3) || ''
}

Check warning on line 139 in src/adapter/aws-lambda/client.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/aws-lambda/client.ts#L138-L139

Added lines #L138 - L139 were not covered by tests
// e.g. hc().somePath.toString()
return proxyCallback.toString()
}

Check warning on line 142 in src/adapter/aws-lambda/client.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/aws-lambda/client.ts#L141-L142

Added lines #L141 - L142 were not covered by tests

if (parts.at(-1) === 'valueOf') {
if (parts.at(-2) === 'name') {

Check warning on line 145 in src/adapter/aws-lambda/client.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/aws-lambda/client.ts#L145

Added line #L145 was not covered by tests
// e.g. hc().somePath.name.valueOf() -> "somePath"
return parts.at(-3) || ''
}

Check warning on line 148 in src/adapter/aws-lambda/client.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/aws-lambda/client.ts#L147-L148

Added lines #L147 - L148 were not covered by tests
// e.g. hc().somePath.valueOf()
return proxyCallback
}

Check warning on line 151 in src/adapter/aws-lambda/client.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/aws-lambda/client.ts#L150-L151

Added lines #L150 - L151 were not covered by tests

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)
}

Check warning on line 174 in src/adapter/aws-lambda/client.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/aws-lambda/client.ts#L164-L174

Added lines #L164 - L174 were not covered by tests
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)

Check warning on line 180 in src/adapter/aws-lambda/client.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/aws-lambda/client.ts#L176-L180

Added lines #L176 - L180 were not covered by tests

const queryParams: Record<string, string | string[]> | 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<typeof WebSocket>) => {
if (options?.webSocket !== undefined && typeof options.webSocket === 'function') {
return options.webSocket(...args)
}
return new WebSocket(...args)
}

Check warning on line 197 in src/adapter/aws-lambda/client.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/aws-lambda/client.ts#L182-L197

Added lines #L182 - L197 were not covered by tests

return establishWebSocket(targetUrl.toString())
}

Check warning on line 200 in src/adapter/aws-lambda/client.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/aws-lambda/client.ts#L199-L200

Added lines #L199 - L200 were not covered by tests

const req = new LambdaClientRequestImpl(url, method)
if (method) {
options ??= {}
const args = deepMerge<ClientRequestOptions>(options, { ...opts.args[1] })
return req.fetch(opts.args[0], args)
}
return req

Check warning on line 208 in src/adapter/aws-lambda/client.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/aws-lambda/client.ts#L208

Added line #L208 was not covered by tests
}, []) as UnionToIntersection<Client<T>>
1 change: 1 addition & 0 deletions src/adapter/aws-lambda/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

export { handle, streamHandle } from './handler'
export { hlc } from './client'
export type { APIGatewayProxyResult, LambdaEvent } from './handler'
export type {
ApiGatewayRequestContext,
Expand Down
2 changes: 1 addition & 1 deletion src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Loading