lambda-reactor 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,98 @@
1
+ import {readFileSync} from "fs"
2
+ import {join} from "path"
3
+
4
+ import {buildHandlers, type FunctionFactory} from "#src/build-handlers"
5
+ import {getMethods} from "#src/router"
6
+ import {type CorsOptions, IRestApi, LambdaIntegration} from "aws-cdk-lib/aws-apigateway"
7
+ import {IFunction} from "aws-cdk-lib/aws-lambda"
8
+
9
+ /**
10
+ * Immutable CDK router that maps route paths to Lambda-backed API Gateway
11
+ * resources.
12
+ *
13
+ * ```ts
14
+ * const api = router()
15
+ * .cors({allowOrigins: ["*"]})
16
+ * .route("/users")
17
+ * .route("/items")
18
+ * .defineRestApi(restApi, factory)
19
+ * ```
20
+ *
21
+ * @typeParam TPaths - Union of all route path strings registered so far.
22
+ */
23
+ export class Router<TPaths extends string = never> {
24
+ srcDir: string
25
+ corsOptions?: CorsOptions
26
+ paths: TPaths[] = []
27
+
28
+ constructor(srcDir: string) {
29
+ this.srcDir = srcDir
30
+ }
31
+
32
+ /**
33
+ * Returns a new `Router` with CORS preflight options applied to every
34
+ * resource added via {@link Router.route}.
35
+ *
36
+ * @param options - CDK `CorsOptions` passed to `addCorsPreflight`.
37
+ */
38
+ cors(options: CorsOptions): Router<TPaths> {
39
+ const r = new Router<TPaths>(this.srcDir)
40
+ r.corsOptions = options
41
+ r.paths = this.paths
42
+ return r
43
+ }
44
+
45
+ /**
46
+ * Registers a new route path and returns a new `Router` with the path
47
+ * added to its path union.
48
+ *
49
+ * @param path - Route path string (e.g. `"/users"`). Must correspond to a
50
+ * handler file at `<srcDir>/<path>.ts`.
51
+ */
52
+ route<TPath extends string>(path: TPath): Router<TPaths | TPath> {
53
+ const r = new Router<TPaths | TPath>(this.srcDir)
54
+ if (this.corsOptions) r.corsOptions = this.corsOptions
55
+ r.paths = [...this.paths, path as unknown as TPaths | TPath]
56
+ return r
57
+ }
58
+
59
+ /**
60
+ * Wires all registered routes into the given API Gateway REST API.
61
+ *
62
+ * For each route path the method:
63
+ * 1. Creates (or reuses) nested API Gateway resources for each path segment.
64
+ * 2. Optionally calls `addCorsPreflight` when CORS options are configured.
65
+ * 3. Reads the handler source file and extracts HTTP method names via
66
+ * {@link getMethods}.
67
+ * 4. Adds an `addMethod` entry backed by a `LambdaIntegration` for each
68
+ * discovered method.
69
+ *
70
+ * @param api - The CDK `IRestApi` to attach resources and methods to.
71
+ * @param factory - {@link FunctionFactory} used to build Lambda functions.
72
+ * @returns A record mapping each route path to its {@link IFunction}.
73
+ */
74
+ defineRestApi<TApi extends IRestApi>(
75
+ api: TApi,
76
+ factory: FunctionFactory,
77
+ ): Record<TPaths, IFunction> {
78
+ const handlers = buildHandlers(this.paths, factory)
79
+ for (const path of this.paths) {
80
+ const resource = path
81
+ .split("/")
82
+ .filter(Boolean)
83
+ .reduce(
84
+ (r, part) => r.getResource(part) ?? r.addResource(part),
85
+ api.root,
86
+ )
87
+ if (this.corsOptions) resource.addCorsPreflight(this.corsOptions)
88
+ const src = readFileSync(
89
+ join(process.cwd(), this.srcDir, `${path}.ts`),
90
+ "utf-8",
91
+ )
92
+ for (const method of getMethods(src)) {
93
+ resource.addMethod(method, new LambdaIntegration(handlers[path]!))
94
+ }
95
+ }
96
+ return handlers
97
+ }
98
+ }
package/src/router.ts ADDED
@@ -0,0 +1,30 @@
1
+ import {Router} from "#src/router-class"
2
+
3
+ export {Router}
4
+
5
+ /**
6
+ * Extracts the HTTP method names declared inside a `createHandler({…})` call
7
+ * by statically scanning the source text of a handler file.
8
+ *
9
+ * @param src - Raw TypeScript source of a handler module.
10
+ * @returns Array of upper-case HTTP method names (e.g. `["GET", "POST"]`),
11
+ * or an empty array when no `createHandler` call is found.
12
+ */
13
+ export function getMethods(src: string): string[] {
14
+ const match = src.match(/createHandler\(\{([^}]*)\}\)/)
15
+ if (!match || !match[1]) return []
16
+ return match[1]
17
+ .split(",")
18
+ .map((s) => s.trim())
19
+ .filter(Boolean)
20
+ }
21
+
22
+ /**
23
+ * Creates a new, empty {@link Router} builder.
24
+ *
25
+ * @param srcDir - Directory (relative to `cwd`) where handler `.ts` files
26
+ * live. Defaults to `"src"`.
27
+ */
28
+ export function router(srcDir: string = "src"): Router {
29
+ return new Router(srcDir)
30
+ }
@@ -0,0 +1,14 @@
1
+ import {getMethods} from "#src/router"
2
+ import {describe, expect, it} from "vitest"
3
+
4
+ describe("getMethods", () => {
5
+ it("extracts HTTP method names from a lambda handler file", () => {
6
+ expect(
7
+ getMethods(`export const handler = createHandler({GET, POST, DELETE})`),
8
+ ).toEqual(["GET", "POST", "DELETE"])
9
+ })
10
+
11
+ it("returns empty array when no createHandler call is found", () => {
12
+ expect(getMethods(`export const handler = () => {}`)).toEqual([])
13
+ })
14
+ })
@@ -0,0 +1,46 @@
1
+ import {join} from "path"
2
+
3
+ import type {IRestApi} from "aws-cdk-lib/aws-apigateway"
4
+ import type {IFunction} from "aws-cdk-lib/aws-lambda"
5
+ import {beforeEach, describe, expect, it, vi} from "vitest"
6
+
7
+ vi.mock("fs", () => ({readFileSync: vi.fn()}))
8
+ vi.mock("aws-cdk-lib/aws-apigateway", () => ({
9
+ LambdaIntegration: vi.fn(),
10
+ }))
11
+
12
+ describe("router defineRestApi factory overload", () => {
13
+ beforeEach(async () => {
14
+ const {readFileSync} = await import("fs")
15
+ ;(readFileSync as ReturnType<typeof vi.fn>).mockReturnValue(
16
+ `export const handler = createHandler({GET})`,
17
+ )
18
+ })
19
+
20
+ it("accepts a factory and returns a record keyed by path", async () => {
21
+ const {router} = await import("#src/router")
22
+ const fakeResource = {
23
+ getResource: () => undefined,
24
+ addResource: () => fakeResource,
25
+ addMethod: vi.fn(),
26
+ }
27
+ const api = {root: fakeResource} as unknown as IRestApi
28
+ const factory = vi.fn(
29
+ (entry: string, id: string) => ({entry, id}) as unknown as IFunction,
30
+ )
31
+ const result = router()
32
+ .route("/user/{user_id}")
33
+ .route("/posts")
34
+ .defineRestApi(api, factory)
35
+ expect(factory).toHaveBeenCalledWith(
36
+ join(process.cwd(), "src", "/user/{user_id}.ts"),
37
+ "/user/{user_id}",
38
+ )
39
+ expect(factory).toHaveBeenCalledWith(
40
+ join(process.cwd(), "src", "/posts.ts"),
41
+ "/posts",
42
+ )
43
+ expect(Object.keys(result)).toEqual(["/user/{user_id}", "/posts"])
44
+ expect(result).not.toBe(api)
45
+ })
46
+ })
@@ -0,0 +1,72 @@
1
+ import {join} from "path"
2
+
3
+ import type {IRestApi} from "aws-cdk-lib/aws-apigateway"
4
+ import type {IFunction} from "aws-cdk-lib/aws-lambda"
5
+ import {beforeEach, describe, expect, it, vi} from "vitest"
6
+
7
+ vi.mock("fs", () => ({readFileSync: vi.fn()}))
8
+
9
+ vi.mock("aws-cdk-lib/aws-apigateway", () => ({
10
+ LambdaIntegration: vi.fn((handler: unknown) => ({handler})),
11
+ }))
12
+
13
+ class FakeResource {
14
+ children = new Map<string, FakeResource>()
15
+ methods: {method: string; integration: unknown}[] = []
16
+ getResource(part: string) {
17
+ return this.children.get(part)
18
+ }
19
+ addResource(part: string) {
20
+ const child = new FakeResource()
21
+ this.children.set(part, child)
22
+ return child
23
+ }
24
+ addMethod(method: string, value: unknown) {
25
+ this.methods.push({method, integration: value})
26
+ return this
27
+ }
28
+ }
29
+
30
+ describe("router defineRestApi", () => {
31
+ beforeEach(async () => {
32
+ const {LambdaIntegration} = await import("aws-cdk-lib/aws-apigateway")
33
+ ;(LambdaIntegration as ReturnType<typeof vi.fn>).mockClear()
34
+ const {readFileSync} = await import("fs")
35
+ ;(readFileSync as ReturnType<typeof vi.fn>).mockReturnValue(
36
+ `export const handler = createHandler({GET, POST})`,
37
+ )
38
+ })
39
+
40
+ it("defines nested rest resources on an existing api instance", async () => {
41
+ const {router} = await import("#src/router")
42
+ const root = new FakeResource()
43
+ const api = {root} as unknown as IRestApi
44
+ const getUser = {name: "getUser"} as unknown as IFunction
45
+ const createPost = {name: "createPost"} as unknown as IFunction
46
+ const factory = vi.fn((_entry: string, id: string) => {
47
+ if (id === "/user/{user_id}") return getUser
48
+ return createPost
49
+ })
50
+ router().route("/user/{user_id}").route("/posts").defineRestApi(api, factory)
51
+ expect(factory).toHaveBeenCalledWith(
52
+ join(process.cwd(), "src", "/user/{user_id}.ts"),
53
+ "/user/{user_id}",
54
+ )
55
+ expect(factory).toHaveBeenCalledWith(
56
+ join(process.cwd(), "src", "/posts.ts"),
57
+ "/posts",
58
+ )
59
+ const {LambdaIntegration} = await import("aws-cdk-lib/aws-apigateway")
60
+ expect(LambdaIntegration).toHaveBeenCalledWith(getUser)
61
+ expect(LambdaIntegration).toHaveBeenCalledWith(createPost)
62
+ const user = root.getResource("user")
63
+ expect(user?.getResource("{user_id}")?.methods).toEqual([
64
+ {method: "GET", integration: {handler: getUser}},
65
+ {method: "POST", integration: {handler: getUser}},
66
+ ])
67
+ expect(root.getResource("posts")?.methods).toEqual([
68
+ {method: "GET", integration: {handler: createPost}},
69
+ {method: "POST", integration: {handler: createPost}},
70
+ ])
71
+ })
72
+ })
@@ -0,0 +1,50 @@
1
+ import {dispatch} from "#src/dispatch"
2
+ import {method} from "#src/method"
3
+ import {cors} from "#src/middleware"
4
+ import type {APIGatewayProxyEvent, Context} from "aws-lambda"
5
+ import {describe, expect, it} from "vitest"
6
+
7
+ const context = {} as Context
8
+
9
+ const event = {
10
+ body: null,
11
+ headers: {},
12
+ multiValueHeaders: {},
13
+ httpMethod: "GET",
14
+ isBase64Encoded: false,
15
+ path: "/",
16
+ pathParameters: null,
17
+ queryStringParameters: null,
18
+ multiValueQueryStringParameters: null,
19
+ stageVariables: null,
20
+ resource: "/",
21
+ requestContext: {} as APIGatewayProxyEvent["requestContext"],
22
+ } satisfies APIGatewayProxyEvent
23
+
24
+ describe("cors middleware", () => {
25
+ it("adds Access-Control headers to response", async () => {
26
+ const route = method()
27
+ .use(cors({"Allow-Origin": "*", "Allow-Methods": "GET,POST"}))
28
+ .handle(async () => "ok")
29
+ const result = await dispatch(route, event, context)
30
+ expect(result.headers?.["Access-Control-Allow-Origin"]).toBe("*")
31
+ expect(result.headers?.["Access-Control-Allow-Methods"]).toBe("GET,POST")
32
+ })
33
+
34
+ it("does not add cors headers when no middleware used", async () => {
35
+ const route = method().handle(async () => "ok")
36
+ const result = await dispatch(route, event, context)
37
+ const keys = Object.keys(result.headers ?? {})
38
+ expect(keys.some((k) => k.startsWith("Access-Control"))).toBe(false)
39
+ })
40
+
41
+ it("applies multiple cors middlewares in order", async () => {
42
+ const route = method()
43
+ .use(cors({"Allow-Origin": "*"}))
44
+ .use(cors({"Allow-Methods": "GET"}))
45
+ .handle(async () => "ok")
46
+ const result = await dispatch(route, event, context)
47
+ expect(result.headers?.["Access-Control-Allow-Origin"]).toBe("*")
48
+ expect(result.headers?.["Access-Control-Allow-Methods"]).toBe("GET")
49
+ })
50
+ })
@@ -0,0 +1,79 @@
1
+ import {method} from "#src/method"
2
+ import {cors} from "#src/middleware"
3
+ import {router} from "#src/router"
4
+ import {describe, expect, it} from "vitest"
5
+ import {z} from "zod"
6
+
7
+ describe("method middlewares", () => {
8
+ it("has empty middlewares by default", () => {
9
+ expect(method().middlewares).toEqual([])
10
+ })
11
+
12
+ it("adds middleware via .use()", () => {
13
+ const m = cors({"Allow-Origin": "*"})
14
+ expect(method().use(m).middlewares).toHaveLength(1)
15
+ })
16
+
17
+ it("accumulates multiple middlewares", () => {
18
+ const m1 = cors({"Allow-Origin": "*"})
19
+ const m2 = cors({"Allow-Methods": "GET"})
20
+ expect(method().use(m1).use(m2).middlewares).toHaveLength(2)
21
+ })
22
+
23
+ it("propagates middlewares through input chain", () => {
24
+ const m = cors({"Allow-Origin": "*"})
25
+ expect(
26
+ method()
27
+ .use(m)
28
+ .input(z.object({x: z.string()})).middlewares,
29
+ ).toHaveLength(1)
30
+ })
31
+
32
+ it("propagates middlewares through output chain", () => {
33
+ const m = cors({"Allow-Origin": "*"})
34
+ expect(
35
+ method()
36
+ .use(m)
37
+ .output(z.object({x: z.string()})).middlewares,
38
+ ).toHaveLength(1)
39
+ })
40
+
41
+ it("propagates middlewares through handle chain", () => {
42
+ const m = cors({"Allow-Origin": "*"})
43
+ expect(
44
+ method()
45
+ .use(m)
46
+ .handle(async () => "ok").middlewares,
47
+ ).toHaveLength(1)
48
+ })
49
+ })
50
+
51
+ describe("router srcDir", () => {
52
+ it("defaults srcDir to src", () => {
53
+ expect(router().srcDir).toBe("src")
54
+ })
55
+
56
+ it("accepts custom srcDir", () => {
57
+ expect(router("src/api").srcDir).toBe("src/api")
58
+ })
59
+
60
+ it("propagates srcDir through route chain", () => {
61
+ expect(router("src/api").route("/health").srcDir).toBe("src/api")
62
+ })
63
+ })
64
+
65
+ describe("router cors", () => {
66
+ it("stores corsOptions via .cors()", () => {
67
+ const options = {allowOrigins: ["*"], allowMethods: ["GET"]}
68
+ expect(router().cors(options).corsOptions).toEqual(options)
69
+ })
70
+
71
+ it("propagates corsOptions through route chain", () => {
72
+ const options = {allowOrigins: ["*"], allowMethods: ["GET"]}
73
+ expect(router().cors(options).route("/health").corsOptions).toEqual(options)
74
+ })
75
+
76
+ it("has no corsOptions by default", () => {
77
+ expect(router().corsOptions).toBeUndefined()
78
+ })
79
+ })
@@ -0,0 +1,61 @@
1
+ import {dispatch} from "#src/dispatch"
2
+ import * as logger from "#src/logger"
3
+ import {method} from "#src/method"
4
+ import type {APIGatewayProxyEvent, Context} from "aws-lambda"
5
+ import {describe, expect, it, vi} from "vitest"
6
+ import {z} from "zod"
7
+
8
+ const context = {} as Context
9
+
10
+ const event = {
11
+ body: null,
12
+ headers: {},
13
+ multiValueHeaders: {},
14
+ httpMethod: "GET",
15
+ isBase64Encoded: false,
16
+ path: "/",
17
+ pathParameters: null,
18
+ queryStringParameters: null,
19
+ multiValueQueryStringParameters: null,
20
+ stageVariables: null,
21
+ resource: "/",
22
+ requestContext: {} as APIGatewayProxyEvent["requestContext"],
23
+ } satisfies APIGatewayProxyEvent
24
+
25
+ describe("dispatch error logging", () => {
26
+ it("logs error when route has no callback", async () => {
27
+ const logError = vi.spyOn(logger, "logError").mockImplementation(() => "")
28
+ const route = method()
29
+ await expect(dispatch(route, event, context)).resolves.toMatchObject({
30
+ statusCode: 500,
31
+ })
32
+ expect(logError).toHaveBeenCalledWith(expect.any(Error))
33
+ logError.mockRestore()
34
+ })
35
+
36
+ it("logs error when output schema validation fails on Response body", async () => {
37
+ const logError = vi.spyOn(logger, "logError").mockImplementation(() => "")
38
+ const route = method()
39
+ .output(z.object({n: z.number()}))
40
+ .handle(() => ({n: "not-a-number"}) as unknown as {n: number})
41
+ await expect(dispatch(route, event, context)).resolves.toMatchObject({
42
+ statusCode: 500,
43
+ })
44
+ expect(logError).toHaveBeenCalled()
45
+ logError.mockRestore()
46
+ })
47
+
48
+ it("logs error when output schema validation fails on plain result", async () => {
49
+ const logError = vi.spyOn(logger, "logError").mockImplementation(() => "")
50
+ const route = method()
51
+ .output(z.object({n: z.number()}))
52
+ .handle(() =>
53
+ Promise.resolve({n: "not-a-number"} as unknown as {n: number}),
54
+ )
55
+ await expect(dispatch(route, event, context)).resolves.toMatchObject({
56
+ statusCode: 500,
57
+ })
58
+ expect(logError).toHaveBeenCalled()
59
+ logError.mockRestore()
60
+ })
61
+ })
@@ -0,0 +1,38 @@
1
+ import {isProduction} from "#src/env"
2
+ import {afterEach, describe, expect, it} from "vitest"
3
+
4
+ const ENV_KEYS = ["NODE_ENV", "STAGE", "ENV", "ENVIRONMENT"] as const
5
+
6
+ describe("isProduction", () => {
7
+ afterEach(() => {
8
+ for (const key of ENV_KEYS) {
9
+ delete process.env[key]
10
+ }
11
+ })
12
+
13
+ it("returns false when no env vars are set", () => {
14
+ expect(isProduction()).toBe(false)
15
+ })
16
+
17
+ for (const key of ENV_KEYS) {
18
+ it(`returns true when ${key}=production`, () => {
19
+ process.env[key] = "production"
20
+ expect(isProduction()).toBe(true)
21
+ })
22
+
23
+ it(`returns true when ${key}=prod`, () => {
24
+ process.env[key] = "prod"
25
+ expect(isProduction()).toBe(true)
26
+ })
27
+
28
+ it(`returns true when ${key}=PRODUCTION (case-insensitive)`, () => {
29
+ process.env[key] = "PRODUCTION"
30
+ expect(isProduction()).toBe(true)
31
+ })
32
+
33
+ it(`returns false when ${key}=development`, () => {
34
+ process.env[key] = "development"
35
+ expect(isProduction()).toBe(false)
36
+ })
37
+ }
38
+ })
@@ -0,0 +1,61 @@
1
+ import {createHandler} from "#src/handler"
2
+ import {method} from "#src/method"
3
+ import type {APIGatewayProxyEvent, Context} from "aws-lambda"
4
+ import {afterEach, describe, expect, it, vi} from "vitest"
5
+
6
+ vi.mock("source-map-support", () => ({install: vi.fn()}))
7
+
8
+ const context = {} as Context
9
+
10
+ function event(httpMethod: string): APIGatewayProxyEvent {
11
+ return {
12
+ body: null,
13
+ headers: {},
14
+ multiValueHeaders: {},
15
+ httpMethod,
16
+ isBase64Encoded: false,
17
+ path: "/",
18
+ pathParameters: null,
19
+ queryStringParameters: null,
20
+ multiValueQueryStringParameters: null,
21
+ stageVariables: null,
22
+ resource: "/",
23
+ requestContext: {} as APIGatewayProxyEvent["requestContext"],
24
+ }
25
+ }
26
+
27
+ describe("createHandler error logging", () => {
28
+ afterEach(() => {
29
+ delete process.env["NODE_ENV"]
30
+ })
31
+
32
+ it("logs the error message and returns it in the body outside production", async () => {
33
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})
34
+ const boom = new Error("boom")
35
+ const handler = createHandler({
36
+ GET: method().handle(() => {
37
+ throw boom
38
+ }),
39
+ })
40
+ const result = await handler(event("GET"), context)
41
+ expect(result.statusCode).toBe(500)
42
+ expect(result.body).toContain("boom")
43
+ expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("boom"))
44
+ consoleError.mockRestore()
45
+ })
46
+
47
+ it("returns generic message in production", async () => {
48
+ process.env["NODE_ENV"] = "production"
49
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})
50
+ const handler = createHandler({
51
+ GET: method().handle(() => {
52
+ throw new Error("secret details")
53
+ }),
54
+ })
55
+ const result = await handler(event("GET"), context)
56
+ expect(result.statusCode).toBe(500)
57
+ expect(result.body).toBe("Internal Server Error")
58
+ expect(result.body).not.toContain("secret details")
59
+ consoleError.mockRestore()
60
+ })
61
+ })
@@ -0,0 +1,86 @@
1
+ import {createHandler} from "#src/handler"
2
+ import {method} from "#src/method"
3
+ import {Response} from "#src/response"
4
+ import type {APIGatewayProxyEvent, Context} from "aws-lambda"
5
+ import {describe, expect, it} from "vitest"
6
+ import {z} from "zod"
7
+
8
+ function event(httpMethod: string, body: unknown = undefined): APIGatewayProxyEvent {
9
+ return {
10
+ body:
11
+ body === undefined ? null
12
+ : typeof body === "string" ? body
13
+ : JSON.stringify(body),
14
+ headers: {},
15
+ multiValueHeaders: {},
16
+ httpMethod,
17
+ isBase64Encoded: false,
18
+ path: "/",
19
+ pathParameters: null,
20
+ queryStringParameters: null,
21
+ multiValueQueryStringParameters: null,
22
+ stageVariables: null,
23
+ resource: "/",
24
+ requestContext: {} as APIGatewayProxyEvent["requestContext"],
25
+ }
26
+ }
27
+
28
+ const context = {} as Context
29
+
30
+ describe("createHandler validation", () => {
31
+ it("validates input and returns 400 on bad input", async () => {
32
+ const handler = createHandler({
33
+ POST: method()
34
+ .input(z.object({username: z.string()}))
35
+ .handle(({body}) => ({username: body.username})),
36
+ })
37
+ const invalid = await handler(event("POST", {username: 7}), context)
38
+ expect(invalid).toMatchObject({
39
+ statusCode: 400,
40
+ headers: {"Content-Type": "text/plain; charset=utf-8"},
41
+ })
42
+ })
43
+
44
+ it("validates output schema and returns json on success", async () => {
45
+ const handler = createHandler({
46
+ POST: method()
47
+ .input(z.object({username: z.string()}))
48
+ .output(z.object({username: z.string()}))
49
+ .handle(({body}) => ({username: body.username})),
50
+ })
51
+ await expect(
52
+ handler(event("POST", {username: "neo"}), context),
53
+ ).resolves.toEqual({
54
+ statusCode: 200,
55
+ body: JSON.stringify({username: "neo"}),
56
+ headers: {"Content-Type": "application/json; charset=utf-8"},
57
+ })
58
+ })
59
+
60
+ it("validates output schema against raw Response body and returns 500 on mismatch", async () => {
61
+ const handler = createHandler({
62
+ POST: method()
63
+ .output(z.object({username: z.string()}))
64
+ .handle(() => Response.json(200, {username: 42})),
65
+ })
66
+ const result = await handler(event("POST"), context)
67
+ expect(result).toMatchObject({
68
+ statusCode: 500,
69
+ headers: {"Content-Type": "text/plain; charset=utf-8"},
70
+ })
71
+ })
72
+
73
+ it("validates output schema against raw Response body and passes on match", async () => {
74
+ const handler = createHandler({
75
+ POST: method()
76
+ .output(z.object({username: z.string()}))
77
+ .handle(() => Response.json(201, {username: "neo"})),
78
+ })
79
+ const result = await handler(event("POST"), context)
80
+ expect(result).toEqual({
81
+ statusCode: 201,
82
+ body: JSON.stringify({username: "neo"}),
83
+ headers: {"Content-Type": "application/json; charset=utf-8"},
84
+ })
85
+ })
86
+ })
@@ -0,0 +1,37 @@
1
+ import {createHandler} from "#src/handler"
2
+ import {method} from "#src/method"
3
+ import type {APIGatewayProxyEvent, Context} from "aws-lambda"
4
+ import {describe, expect, it} from "vitest"
5
+
6
+ function event(httpMethod: string, body: unknown = undefined): APIGatewayProxyEvent {
7
+ return {
8
+ body:
9
+ body === undefined ? null
10
+ : typeof body === "string" ? body
11
+ : JSON.stringify(body),
12
+ headers: {},
13
+ multiValueHeaders: {},
14
+ httpMethod,
15
+ isBase64Encoded: false,
16
+ path: "/",
17
+ pathParameters: null,
18
+ queryStringParameters: null,
19
+ multiValueQueryStringParameters: null,
20
+ stageVariables: null,
21
+ resource: "/",
22
+ requestContext: {} as APIGatewayProxyEvent["requestContext"],
23
+ }
24
+ }
25
+
26
+ const context = {} as Context
27
+
28
+ describe("createHandler routing", () => {
29
+ it("returns 405 with allow header for unsupported methods", async () => {
30
+ const handler = createHandler({GET: method().handle(() => ({ok: true}))})
31
+ await expect(handler(event("POST"), context)).resolves.toEqual({
32
+ statusCode: 405,
33
+ body: "Method Not Allowed",
34
+ headers: {"Content-Type": "text/plain; charset=utf-8", Allow: "GET"},
35
+ })
36
+ })
37
+ })