1import type lambda from "aws-lambda";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4
5import type { Input } from "../../Input.ts";
6import * as Namespace from "../../Namespace.ts";
7import * as Output from "../../Output.ts";
8import {
9 type ApiGatewayResource,
10 GatewayResource,
11} from "../ApiGateway/GatewayResource.ts";
12import { MethodResource } from "../ApiGateway/Method.ts";
13import type { RestApi } from "../ApiGateway/RestApi.ts";
14import {
15 RestApiEventSource as AGRestApiEventSource,
16 type RestApiEvent,
17 type RestApiEventSourceService,
18 type RestApiResult,
19 type RestApiRouteProps,
20} from "../ApiGateway/RestApiEventSource.ts";
21import * as Lambda from "./Function.ts";
22import { Permission as LambdaPermission } from "./Permission.ts";
23
24export const isRestApiEvent = (
25 event: any,
26): event is lambda.APIGatewayProxyEvent =>
27 typeof event?.httpMethod === "string" &&
28 typeof event?.resource === "string" &&
29 typeof event?.requestContext?.apiId === "string" &&
30 // WebSocket proxy events carry `messageDirection`; REST v1 events don't.
31 event?.requestContext?.messageDirection === undefined;
32
33/** `/items/{proxy+}` → `itemsproxy` for stable, readable logical ids. */
34const sanitizePathKey = (path: string, httpMethod: string) =>
35 `${path}-${httpMethod}`.replace(/[^A-Za-z0-9]+/g, "") || "root";
36
37/**
38 * Connects a REST API (v1) route to the current Lambda function.
39 *
40 * At deploy time this layer materializes the path `Resource` chain, the
41 * `Method` with an `AWS_PROXY` integration, and the API Gateway invoke
42 * `Permission` for each registered route — and registers each child as a
43 * `RestApiBinding` on the API so any `Deployment` of the same API is
44 * ordered after them. At runtime it dispatches matching REST proxy events
45 * to the registered handler.
46 * ### Handling REST API routes
47 * **Example:** JSON endpoint
48 * ```typescript
49 * yield* AWS.ApiGateway.onRestApiRoute(
50 * api,
51 * { path: "/items", httpMethod: "GET" },
52 * () =>
53 * Effect.succeed({
54 * statusCode: 200,
55 * body: JSON.stringify({ items: [] }),
56 * }),
57 * );
58 * ```
59 *
60 * @binding
61 */
62export const RestApiEventSource = Layer.effect(
63 AGRestApiEventSource,
64 Effect.gen(function* () {
65 // this layer can only be used in a Lambda Function
66 const host = yield* Lambda.Function;
67 const Resource = yield* GatewayResource;
68 const Method = yield* MethodResource;
69 const Permission = yield* LambdaPermission;
70
71 return Effect.fn(function* <Req = never>(
72 api: RestApi,
73 props: RestApiRouteProps,
74 handler: (
75 event: RestApiEvent,
76 ) => Effect.Effect<RestApiResult, never, Req>,
77 ) {
78 const path = props.path ?? "/";
79 const httpMethod = (props.httpMethod ?? "ANY").toUpperCase();
80
81 // Deploy-time: materialize the path Resource chain + Method +
82 // invoke Permission for this route. Skipped once running inside
83 // the deployed Function (the global guard), where the only work is
84 // registering the runtime dispatcher below. Namespaced under the
85 // host for stable logical identity.
86 if (!globalThis.__ALCHEMY_RUNTIME__) {
87 yield* Namespace.push(
88 host.LogicalId,
89 Effect.gen(function* () {
90 const key = sanitizePathKey(path, httpMethod);
91
92 // Build the Resource chain for each path segment; methods on
93 // the root path attach directly to `api.rootResourceId`.
94 let parentId: Input<string> = api.rootResourceId;
95 let prefix = "";
96 for (const segment of path.split("/").filter(Boolean)) {
97 prefix += `/${segment}`;
98 const resourceKey =
99 prefix.replace(/[^A-Za-z0-9]+/g, "") || "root";
100 // Explicit annotation: `resource` feeds `parentId` for the next
101 // loop iteration's `Resource(...)` call, which TS 7 otherwise
102 // flags as a self-referential inference cycle (TS7022).
103 const resource: ApiGatewayResource = yield* Resource(
104 `${api.LogicalId}-${resourceKey}-Resource`,
105 {
106 restApiId: api.restApiId,
107 parentId,
108 pathPart: segment,
109 },
110 );
111 yield* api.bind`${resource}`({
112 kind: "resource",
113 resourceId: resource.resourceId,
114 parentId: resource.parentId,
115 pathPart: resource.pathPart,
116 });
117 parentId = resource.resourceId;
118 }
119
120 const method = yield* Method(`${api.LogicalId}-${key}-Method`, {
121 restApiId: api.restApiId,
122 resourceId: parentId,
123 httpMethod,
124 authorizationType: "NONE",
125 integration: {
126 type: "AWS_PROXY",
127 integrationHttpMethod: "POST",
128 // AWS_PROXY integrations require the full Lambda invocation
129 // URI. Region is derived from the function ARN so the
130 // binding needs no ambient environment.
131 uri: Output.map(
132 host.functionArn,
133 (arn: string) =>
134 `arn:aws:apigateway:${arn.split(":")[3]}:lambda:path/2015-03-31/functions/${arn}/invocations`,
135 ),
136 },
137 });
138 yield* api.bind`${method}`({
139 kind: "method",
140 methodId: method.LogicalId,
141 restApiId: method.restApiId,
142 resourceId: method.resourceId,
143 httpMethod: method.httpMethod,
144 });
145
146 yield* Permission(`${api.LogicalId}-${key}-Permission`, {
147 action: "lambda:InvokeFunction",
148 functionName: host.functionName,
149 principal: "apigateway.amazonaws.com",
150 sourceArn: Output.map(
151 Output.all(host.functionArn, api.restApiId),
152 ([fnArn, apiId]: [string, string]) => {
153 const [, , , region, accountId] = fnArn.split(":");
154 return `arn:aws:execute-api:${region}:${accountId}:${apiId}/*`;
155 },
156 ),
157 });
158 }),
159 );
160 }
161
162 yield* host.listen(
163 Effect.gen(function* () {
164 return (event: any) => {
165 if (
166 isRestApiEvent(event) &&
167 event.resource === path &&
168 (httpMethod === "ANY" || event.httpMethod === httpMethod)
169 ) {
170 return handler(event).pipe(
171 Effect.map((result) => result ?? { statusCode: 200, body: "" }),
172 Effect.orDie,
173 );
174 }
175 };
176 }),
177 );
178 }) as RestApiEventSourceService;
179 }),
180);