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.
- package/.prettierrc +8 -0
- package/AGENTS.md +7 -0
- package/bun.lock +477 -0
- package/eslint.config.ts +31 -0
- package/examples/cdk-stack.ts +20 -0
- package/examples/health.ts +7 -0
- package/examples/items.ts +25 -0
- package/examples/users-get.ts +20 -0
- package/examples/users-post.ts +26 -0
- package/lefthook.yml +16 -0
- package/package.json +35 -0
- package/src/build-handlers.ts +30 -0
- package/src/dispatch.ts +91 -0
- package/src/env.ts +23 -0
- package/src/handler.ts +58 -0
- package/src/logger.ts +19 -0
- package/src/method.ts +92 -0
- package/src/middleware.ts +41 -0
- package/src/response.ts +86 -0
- package/src/route-handler.ts +28 -0
- package/src/router-class.ts +98 -0
- package/src/router.ts +30 -0
- package/tests/api-get-methods.test.ts +14 -0
- package/tests/api-router-factory.test.ts +46 -0
- package/tests/api-router.test.ts +72 -0
- package/tests/config-cors.test.ts +50 -0
- package/tests/config.test.ts +79 -0
- package/tests/dispatch-error-logging.test.ts +61 -0
- package/tests/env.test.ts +38 -0
- package/tests/handler-error-logging.test.ts +61 -0
- package/tests/handler-routing-validation.test.ts +86 -0
- package/tests/handler-routing.test.ts +37 -0
- package/tests/handler.test.ts +48 -0
- package/tests/method.test.ts +40 -0
- package/tests/response.test.ts +29 -0
- package/tsconfig.build.json +26 -0
- package/tsconfig.json +3 -0
- package/tsconfig.node.json +24 -0
- package/vitest.config.ts +21 -0
- package/vitest.setup.ts +2 -0
|
@@ -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
|
+
})
|