1import * as agw2 from "@distilled.cloud/aws/apigatewayv2";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import { deepEqual, isResolved } from "../../Diff.ts";
5import type { Input } from "../../Input.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { toWireMillis } from "../../Util/Duration.ts";
9import type { Providers } from "../Providers.ts";
10import type { Api } from "./Api.ts";
11import { collectAllPages, retryOnTooManyRequests } from "./common.ts";
12
13export interface IntegrationProps {
14 /**
15 * ID of the API this integration belongs to. Usually derived from
16 * `api.apiId` by the {@link Integration} wrapper.
17 */
18 apiId: string;
19 /**
20 * The integration type. `AWS_PROXY` (Lambda proxy) is the common case
21 * for both HTTP and WebSocket APIs; `HTTP_PROXY` forwards to an HTTP
22 * endpoint; `MOCK` (WebSocket only) returns a static response.
23 */
24 integrationType: agw2.IntegrationType;
25 /**
26 * The integration endpoint:
27 *
28 * - `AWS_PROXY` on an HTTP API — the Lambda function ARN.
29 * - `AWS_PROXY` on a WebSocket API — the full invocation URI
30 * (`arn:aws:apigateway:{region}:lambda:path/2015-03-31/functions/{functionArn}/invocations`).
31 * - `HTTP_PROXY` — the HTTP URL.
32 * - private integrations — the ELB listener / Cloud Map service ARN.
33 */
34 integrationUri?: string;
35 /**
36 * The HTTP method the integration uses when calling the backend
37 * (`POST` for Lambda; for `HTTP_PROXY` routes usually `ANY`).
38 */
39 integrationMethod?: string;
40 /**
41 * The Lambda event payload format for HTTP API `AWS_PROXY` integrations.
42 * `2.0` is the modern shape (same as Lambda Function URLs).
43 * @default "2.0" for HTTP AWS_PROXY (AWS defaults to "1.0" — the wrapper does not override; set explicitly)
44 */
45 payloadFormatVersion?: string;
46 /**
47 * `INTERNET` (default) or `VPC_LINK` for private integrations.
48 */
49 connectionType?: agw2.ConnectionType;
50 /**
51 * The VPC link ID when `connectionType` is `VPC_LINK`.
52 */
53 connectionId?: string;
54 /**
55 * IAM credentials ARN for the integration (or
56 * `arn:aws:iam::*:user/*` to use caller credentials).
57 */
58 credentialsArn?: string;
59 /** Description of the integration. */
60 description?: string;
61 /**
62 * AWS service integration subtype (e.g. `SQS-SendMessage`) for
63 * first-class AWS service integrations on HTTP APIs.
64 */
65 integrationSubtype?: string;
66 /** Passthrough behavior (WebSocket APIs only). */
67 passthroughBehavior?: agw2.PassthroughBehavior;
68 /** Request parameter mappings. */
69 requestParameters?: { [key: string]: string | undefined };
70 /** Request templates (WebSocket APIs only). */
71 requestTemplates?: { [key: string]: string | undefined };
72 /** Response parameter mappings (HTTP APIs). */
73 responseParameters?: {
74 [key: string]: { [key: string]: string | undefined } | undefined;
75 };
76 /** Template selection expression (WebSocket APIs only). */
77 templateSelectionExpression?: string;
78 /**
79 * Integration timeout (e.g. `"29 seconds"` or `Duration.seconds(29)`;
80 * a bare number is milliseconds). 50–29000 ms for WebSocket APIs,
81 * 50–30000 ms for HTTP APIs. Sent to the API in whole milliseconds
82 * (`TimeoutInMillis`).
83 */
84 timeout?: Duration.Input;
85 /** TLS configuration for private integrations. */
86 tlsConfig?: agw2.TlsConfigInput;
87 /** Content handling strategy (WebSocket APIs only). */
88 contentHandlingStrategy?: agw2.ContentHandlingStrategy;
89}
90
91export interface IntegrationType extends Resource<
92 "AWS.ApiGatewayV2.Integration",
93 IntegrationProps,
94 {
95 /** The API this integration belongs to. */
96 apiId: string;
97 /** The integration identifier. */
98 integrationId: string;
99 integrationType: agw2.IntegrationType;
100 integrationUri: string | undefined;
101 integrationMethod: string | undefined;
102 payloadFormatVersion: string | undefined;
103 connectionType: agw2.ConnectionType | undefined;
104 connectionId: string | undefined;
105 credentialsArn: string | undefined;
106 description: string | undefined;
107 integrationSubtype: string | undefined;
108 passthroughBehavior: agw2.PassthroughBehavior | undefined;
109 requestParameters: { [key: string]: string | undefined } | undefined;
110 requestTemplates: { [key: string]: string | undefined } | undefined;
111 responseParameters:
112 | { [key: string]: { [key: string]: string | undefined } | undefined }
113 | undefined;
114 templateSelectionExpression: string | undefined;
115 timeoutInMillis: number | undefined;
116 contentHandlingStrategy: agw2.ContentHandlingStrategy | undefined;
117 },
118 never,
119 Providers
120> {}
121
122/**
123 * An API Gateway v2 Integration — the backend target a Route forwards to.
124 *
125 * For HTTP APIs the common integration is `AWS_PROXY` with payload format
126 * `2.0`, pointing directly at a Lambda function ARN. For WebSocket APIs the
127 * `integrationUri` must be the full Lambda invocation URI.
128 * ### Lambda proxy integration (HTTP API)
129 * **Example:** AWS_PROXY integration with payload 2.0
130 * ```typescript
131 * const integration = yield* ApiGatewayV2.Integration("Fn", {
132 * api,
133 * integrationType: "AWS_PROXY",
134 * integrationUri: fn.functionArn,
135 * payloadFormatVersion: "2.0",
136 * });
137 * ```
138 *
139 * ### HTTP proxy integration
140 * **Example:** Forward to an external HTTP endpoint
141 * ```typescript
142 * const integration = yield* ApiGatewayV2.Integration("Upstream", {
143 * api,
144 * integrationType: "HTTP_PROXY",
145 * integrationUri: "https://example.com/{proxy}",
146 * integrationMethod: "ANY",
147 * payloadFormatVersion: "1.0",
148 * });
149 * ```
150 *
151 * @resource
152 */
153export const IntegrationResource = Resource<IntegrationType>(
154 "AWS.ApiGatewayV2.Integration",
155);
156
157export interface IntegrationInputProps extends Omit<
158 {
159 [K in keyof IntegrationProps]?: Input<IntegrationProps[K]>;
160 },
161 "apiId" | "integrationType"
162> {
163 /**
164 * The `Api` this integration belongs to (preferred). Alternatively pass
165 * a raw `apiId`.
166 */
167 api?: Api;
168 apiId?: Input<string>;
169 integrationType: Input<agw2.IntegrationType>;
170}
171
172/**
173 * User-facing wrapper for the Integration resource. Accepts `api: Api` as
174 * the idiomatic way to attach an integration to an API.
175 */
176export const Integration = (id: string, props: IntegrationInputProps) =>
177 Effect.gen(function* () {
178 const { api, ...rest } = props;
179 const apiId = rest.apiId ?? api?.apiId;
180 if (!apiId) {
181 return yield* Effect.die(
182 "Integration requires either `api` (preferred) or an explicit `apiId`.",
183 );
184 }
185 return yield* IntegrationResource(id, { ...rest, apiId } as any);
186 });
187
188const snapshotFromIntegration = (
189 apiId: string,
190 integ: agw2.GetIntegrationResult,
191): IntegrationType["Attributes"] => ({
192 apiId,
193 integrationId: integ.IntegrationId!,
194 integrationType: integ.IntegrationType ?? "AWS_PROXY",
195 integrationUri: integ.IntegrationUri,
196 integrationMethod: integ.IntegrationMethod,
197 payloadFormatVersion: integ.PayloadFormatVersion,
198 connectionType: integ.ConnectionType,
199 connectionId: integ.ConnectionId,
200 credentialsArn: integ.CredentialsArn,
201 description: integ.Description,
202 integrationSubtype: integ.IntegrationSubtype,
203 passthroughBehavior: integ.PassthroughBehavior,
204 requestParameters: integ.RequestParameters,
205 requestTemplates: integ.RequestTemplates,
206 responseParameters: integ.ResponseParameters,
207 templateSelectionExpression: integ.TemplateSelectionExpression,
208 timeoutInMillis: integ.TimeoutInMillis,
209 contentHandlingStrategy: integ.ContentHandlingStrategy,
210});
211
212const desiredRequest = (news: IntegrationProps) => ({
213 IntegrationType: news.integrationType,
214 IntegrationUri: news.integrationUri,
215 IntegrationMethod: news.integrationMethod,
216 PayloadFormatVersion: news.payloadFormatVersion,
217 ConnectionType: news.connectionType,
218 ConnectionId: news.connectionId,
219 CredentialsArn: news.credentialsArn,
220 Description: news.description,
221 IntegrationSubtype: news.integrationSubtype,
222 PassthroughBehavior: news.passthroughBehavior,
223 RequestParameters: news.requestParameters,
224 RequestTemplates: news.requestTemplates,
225 ResponseParameters: news.responseParameters,
226 TemplateSelectionExpression: news.templateSelectionExpression,
227 TimeoutInMillis: toWireMillis(news.timeout),
228 TlsConfig: news.tlsConfig,
229 ContentHandlingStrategy: news.contentHandlingStrategy,
230});
231
232export const IntegrationProvider = () =>
233 Provider.effect(
234 IntegrationResource,
235 Effect.gen(function* () {
236 const getIntegrationSafe = (apiId: string, integrationId: string) =>
237 agw2
238 .getIntegration({ ApiId: apiId, IntegrationId: integrationId })
239 .pipe(
240 Effect.catchTag("NotFoundException", () =>
241 Effect.succeed(undefined),
242 ),
243 );
244
245 return IntegrationResource.Provider.of({
246 stables: ["apiId", "integrationId"],
247
248 list: () =>
249 Effect.gen(function* () {
250 const apis = yield* collectAllPages((NextToken) =>
251 agw2.getApis({ NextToken }),
252 );
253 const perApi = yield* Effect.forEach(
254 apis.filter((api) => api.ApiId != null),
255 (api) =>
256 collectAllPages((NextToken) =>
257 agw2.getIntegrations({ ApiId: api.ApiId!, NextToken }),
258 ).pipe(
259 Effect.map((items) =>
260 items
261 .filter((integ) => integ.IntegrationId != null)
262 .map((integ) =>
263 snapshotFromIntegration(api.ApiId!, integ),
264 ),
265 ),
266 // API deleted between list and getIntegrations — skip it.
267 Effect.catchTag("NotFoundException", () =>
268 Effect.succeed([] as IntegrationType["Attributes"][]),
269 ),
270 ),
271 { concurrency: 5 },
272 );
273 return perApi.flat();
274 }),
275
276 read: Effect.fn(function* ({ output }) {
277 if (!output?.apiId || !output.integrationId) return undefined;
278 const integ = yield* getIntegrationSafe(
279 output.apiId,
280 output.integrationId,
281 );
282 if (!integ?.IntegrationId) return undefined;
283 return snapshotFromIntegration(output.apiId, integ);
284 }),
285
286 diff: Effect.fn(function* ({ news, olds }) {
287 if (!isResolved(news)) return undefined;
288 if (news.apiId !== olds.apiId) {
289 return { action: "replace" } as const;
290 }
291 }),
292
293 reconcile: Effect.fn(function* ({ news, output, session }) {
294 const apiId = output?.apiId ?? news.apiId;
295
296 // 1. OBSERVE
297 let observed = output?.integrationId
298 ? yield* getIntegrationSafe(apiId, output.integrationId)
299 : undefined;
300
301 // 2. ENSURE
302 if (!observed?.IntegrationId) {
303 observed = yield* retryOnTooManyRequests(
304 agw2.createIntegration({ ApiId: apiId, ...desiredRequest(news) }),
305 );
306 yield* session.note(
307 `Created integration ${observed.IntegrationId}`,
308 );
309 return snapshotFromIntegration(apiId, observed);
310 }
311
312 // 3. SYNC — compare the observed snapshot against desired and
313 // update only on drift.
314 const snapshot = snapshotFromIntegration(apiId, observed);
315 const desired = desiredRequest(news);
316 const drift =
317 snapshot.integrationType !== desired.IntegrationType ||
318 snapshot.integrationUri !== desired.IntegrationUri ||
319 snapshot.integrationMethod !== desired.IntegrationMethod ||
320 (desired.PayloadFormatVersion !== undefined &&
321 snapshot.payloadFormatVersion !== desired.PayloadFormatVersion) ||
322 (desired.ConnectionType !== undefined &&
323 snapshot.connectionType !== desired.ConnectionType) ||
324 snapshot.connectionId !== desired.ConnectionId ||
325 snapshot.credentialsArn !== desired.CredentialsArn ||
326 snapshot.description !== desired.Description ||
327 snapshot.integrationSubtype !== desired.IntegrationSubtype ||
328 (desired.PassthroughBehavior !== undefined &&
329 snapshot.passthroughBehavior !== desired.PassthroughBehavior) ||
330 (desired.RequestParameters !== undefined &&
331 !deepEqual(
332 snapshot.requestParameters,
333 desired.RequestParameters,
334 )) ||
335 (desired.RequestTemplates !== undefined &&
336 !deepEqual(
337 snapshot.requestTemplates,
338 desired.RequestTemplates,
339 )) ||
340 (desired.ResponseParameters !== undefined &&
341 !deepEqual(
342 snapshot.responseParameters,
343 desired.ResponseParameters,
344 )) ||
345 snapshot.templateSelectionExpression !==
346 desired.TemplateSelectionExpression ||
347 (desired.TimeoutInMillis !== undefined &&
348 snapshot.timeoutInMillis !== desired.TimeoutInMillis) ||
349 (desired.ContentHandlingStrategy !== undefined &&
350 snapshot.contentHandlingStrategy !==
351 desired.ContentHandlingStrategy);
352 if (drift) {
353 const updated = yield* retryOnTooManyRequests(
354 agw2.updateIntegration({
355 ApiId: apiId,
356 IntegrationId: snapshot.integrationId,
357 ...desired,
358 }),
359 );
360 yield* session.note(
361 `Updated integration ${snapshot.integrationId}`,
362 );
363 return snapshotFromIntegration(apiId, updated);
364 }
365
366 return snapshot;
367 }),
368
369 delete: Effect.fn(function* ({ output, session }) {
370 yield* retryOnTooManyRequests(
371 agw2
372 .deleteIntegration({
373 ApiId: output.apiId,
374 IntegrationId: output.integrationId,
375 })
376 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
377 );
378 yield* session.note(`Deleted integration ${output.integrationId}`);
379 }),
380 });
381 }),
382 );