1import * as agw2 from "@distilled.cloud/aws/apigatewayv2";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import { pipe } from "effect/Function";
5import * as Schedule from "effect/Schedule";
6import { diffTags, normalizeTags } from "../../Tags.ts";
7
8/**
9 * API Gateway v2 ARN helpers. Unlike most services the resource ARNs used
10 * for tagging omit the account id (`arn:aws:apigateway:{region}::/apis/{id}`).
11 */
12export const apiArn = (region: string, apiId: string) =>
13 `arn:aws:apigateway:${region}::/apis/${apiId}`;
14
15export const stageArn = (region: string, apiId: string, stageName: string) =>
16 `arn:aws:apigateway:${region}::/apis/${apiId}/stages/${stageName}`;
17
18export const domainNameArn = (region: string, domainName: string) =>
19 `arn:aws:apigateway:${region}::/domainnames/${domainName}`;
20
21export const vpcLinkArn = (region: string, vpcLinkId: string) =>
22 `arn:aws:apigateway:${region}::/vpclinks/${vpcLinkId}`;
23
24/**
25 * The `execute-api` ARN that IAM policies (Lambda resource policies,
26 * `execute-api:Invoke`/`ManageConnections` statements) use to scope access
27 * to an API. Wildcards match across path segments.
28 */
29export const executeApiArn = (
30 region: string,
31 accountId: string,
32 apiId: string,
33 suffix = "/*",
34) => `arn:aws:execute-api:${region}:${accountId}:${apiId}${suffix}`;
35
36/**
37 * `Create/Update/Delete` operations across API Gateway v2 share an
38 * account-wide throttle; parallel test suites and deploys routinely see
39 * `TooManyRequestsException` outlast the blanket SDK retry budget. The
40 * schedule below (exponential base 1s capped at 20s, 10 attempts, ~60s
41 * total) rides out the throttle window without hiding real failures.
42 *
43 * The helper carries an EXPLICIT return annotation so the conditional type
44 * of `Effect.retry` never leaks into declaration emit (which would widen the
45 * provider layer to `unknown` for every consumer of `AWS.providers()`).
46 */
47export const retryOnTooManyRequests = <A, E extends { _tag: string }, R>(
48 effect: Effect.Effect<A, E, R>,
49): Effect.Effect<A, E, R> =>
50 Effect.retry(effect, {
51 while: (error: E) =>
52 error._tag === "TooManyRequestsException" ||
53 error._tag === "ConflictException",
54 schedule: Schedule.max([
55 pipe(
56 Schedule.exponential(Duration.seconds(1), 2),
57 Schedule.modifyDelay(({ duration }) =>
58 Effect.succeed(
59 Duration.isGreaterThan(duration, Duration.seconds(20))
60 ? Duration.seconds(20)
61 : duration,
62 ),
63 ),
64 ),
65 Schedule.recurs(10),
66 ]),
67 }) as Effect.Effect<A, E, R>;
68
69/**
70 * Normalize the wire tag map (values may be `undefined`) to a plain record.
71 */
72export const tagRecord = (
73 tags: { [key: string]: string | undefined } | undefined,
74): Record<string, string> =>
75 Object.fromEntries(
76 Object.entries(tags ?? {}).filter(
77 (entry): entry is [string, string] => entry[1] !== undefined,
78 ),
79 );
80
81/**
82 * API Gateway v2 collection operations (`getApis`, `getRoutes`, `getStages`,
83 * …) return `{ Items, NextToken }` pages without a Smithy pagination trait,
84 * so distilled exposes no `.pages` stream for them. Collect every page
85 * manually, bounded at 100 pages so a misbehaving token can never hang the
86 * engine.
87 */
88export const collectAllPages = Effect.fn(function* <A, E, R>(
89 fetchPage: (
90 nextToken: string | undefined,
91 ) => Effect.Effect<{ Items?: readonly A[] | A[]; NextToken?: string }, E, R>,
92) {
93 const items: A[] = [];
94 let nextToken: string | undefined = undefined;
95 for (let page = 0; page < 100; page++) {
96 const result: { Items?: readonly A[] | A[]; NextToken?: string } =
97 yield* fetchPage(nextToken);
98 items.push(...(result.Items ?? []));
99 nextToken = result.NextToken;
100 if (!nextToken) break;
101 }
102 return items;
103});
104
105/**
106 * Diff observed tags against desired tags and apply only the delta via the
107 * v2 `tagResource`/`untagResource` operations.
108 */
109export const syncTags = Effect.fn(function* ({
110 resourceArn,
111 oldTags,
112 newTags,
113}: {
114 resourceArn: string;
115 oldTags: Record<string, string>;
116 newTags: Record<string, string>;
117}) {
118 const { removed, upsert } = diffTags(oldTags, newTags);
119 if (removed.length > 0) {
120 yield* agw2
121 .untagResource({ ResourceArn: resourceArn, TagKeys: removed })
122 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
123 }
124 if (upsert.length > 0) {
125 yield* agw2.tagResource({
126 ResourceArn: resourceArn,
127 Tags: normalizeTags(upsert),
128 });
129 }
130});