1import * as ApiGatewayV2 from "@/AWS/ApiGatewayV2";
2import * as Lambda from "@/AWS/Lambda";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Stream from "effect/Stream";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "bindings-handler.ts");
11
12export class ApiGatewayV2BindingsFunction extends Lambda.Function<Lambda.Function>()(
13 "ApiGatewayV2BindingsFunction",
14) {}
15
16/**
17 * Fixture Lambda for the ApiGatewayV2 runtime bindings. It owns a minimal
18 * HTTP API (one `HTTP_PROXY` route + an auto-deployed `$default` stage) so
19 * the control-plane bindings have a live target, and exposes one route per
20 * binding:
21 *
22 * - `GET /export` → `ExportApi` (OpenAPI 3.0 export)
23 * - `POST /reset-authorizers` → `ResetAuthorizersCache`
24 */
25export default ApiGatewayV2BindingsFunction.make(
26 {
27 main,
28 functionUrl: true,
29 },
30 Effect.gen(function* () {
31 // Minimal HTTP API + route + stage the bindings target.
32 const api = yield* ApiGatewayV2.Api("AgV2BindingsApi", {
33 protocolType: "HTTP",
34 });
35 const integration = yield* ApiGatewayV2.Integration(
36 "AgV2BindingsUpstream",
37 {
38 api,
39 integrationType: "HTTP_PROXY",
40 integrationUri: "https://example.com",
41 integrationMethod: "ANY",
42 payloadFormatVersion: "1.0",
43 },
44 );
45 yield* ApiGatewayV2.Route("AgV2BindingsPing", {
46 api,
47 routeKey: "GET /ping",
48 integration,
49 });
50 const stage = yield* ApiGatewayV2.Stage("AgV2BindingsStage", {
51 api,
52 autoDeploy: true,
53 });
54
55 const exportApi = yield* ApiGatewayV2.ExportApi(api);
56 const resetAuthorizersCache =
57 yield* ApiGatewayV2.ResetAuthorizersCache(stage);
58
59 return {
60 fetch: Effect.gen(function* () {
61 const request = yield* HttpServerRequest;
62 const url = new URL(request.originalUrl);
63 const pathname = url.pathname;
64
65 if (request.method === "GET" && pathname === "/export") {
66 const exported = yield* exportApi({ OutputType: "JSON" }).pipe(
67 Effect.orDie,
68 );
69 const document = exported.body
70 ? yield* Stream.mkString(Stream.decodeText(exported.body)).pipe(
71 Effect.orDie,
72 )
73 : "";
74 const spec = yield* Effect.try(
75 () =>
76 JSON.parse(document) as {
77 openapi?: string;
78 paths?: Record<string, unknown>;
79 },
80 ).pipe(Effect.orDie);
81 return yield* HttpServerResponse.json({
82 openapi: spec.openapi,
83 paths: Object.keys(spec.paths ?? {}),
84 });
85 }
86
87 if (request.method === "POST" && pathname === "/reset-authorizers") {
88 // The stage has no Lambda-authorizer cache, so API Gateway may
89 // answer with a typed NotFound — the IAM grant is still
90 // exercised (a missing grant surfaces as an auth error instead,
91 // failing the route with a 500).
92 const flushed = yield* resetAuthorizersCache().pipe(
93 Effect.map(() => true),
94 Effect.catchTag("NotFoundException", () => Effect.succeed(false)),
95 Effect.orDie,
96 );
97 return yield* HttpServerResponse.json({ flushed });
98 }
99
100 return yield* HttpServerResponse.json(
101 { error: "Not found", method: request.method, path: pathname },
102 { status: 404 },
103 );
104 }).pipe(Effect.orDie),
105 };
106 }).pipe(
107 Effect.provide(
108 Layer.mergeAll(
109 ApiGatewayV2.ExportApiHttp,
110 ApiGatewayV2.ResetAuthorizersCacheHttp,
111 ),
112 ),
113 ),
114);