1import * as ag from "@distilled.cloud/aws/api-gateway";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { deepEqual, isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { createInternalTags, tagRecord } from "../../Tags.ts";
9import type { Providers } from "../Providers.ts";
10
11import { AWSEnvironment } from "../Environment.ts";
12import { syncTags, usagePlanArn } from "./common.ts";
13
14export interface UsagePlanProps {
15 /**
16 * Friendly name for the usage plan.
17 *
18 * If omitted, Alchemy generates a deterministic physical name.
19 */
20 name?: string;
21 /**
22 * Human-readable description for operators.
23 */
24 description?: string;
25 /**
26 * API stages associated with this plan.
27 */
28 apiStages?: ag.ApiStage[];
29 /**
30 * Default request throttle applied by the plan.
31 */
32 throttle?: ag.ThrottleSettings;
33 /**
34 * Quota limit and period applied by the plan.
35 */
36 quota?: ag.QuotaSettings;
37 /**
38 * User-defined tags. Alchemy internal tags are merged automatically.
39 */
40 tags?: Record<string, string>;
41}
42
43/** @resource */
44export interface UsagePlan extends Resource<
45 "AWS.ApiGateway.UsagePlan",
46 UsagePlanProps,
47 {
48 id: string;
49 name: string | undefined;
50 description: string | undefined;
51 apiStages: ag.ApiStage[] | undefined;
52 throttle: ag.ThrottleSettings | undefined;
53 quota: ag.QuotaSettings | undefined;
54 tags: Record<string, string>;
55 },
56 never,
57 Providers
58> {}
59
60/**
61 * Usage plan for API stages, throttling, and quotas.
62 *
63 * ### Usage plans
64 * **Example:** Usage plan with stage
65 * ```typescript
66 * const plan = yield* ApiGateway.UsagePlan("Standard", {
67 * apiStages: [{ apiId: api.restApiId, stage: stage.stageName }],
68 * });
69 * ```
70 *
71 * **Example:** Throttled plan with a quota and an enrolled API key
72 * ```typescript
73 * const plan = yield* ApiGateway.UsagePlan("Partner", {
74 * throttle: { rateLimit: 10, burstLimit: 20 },
75 * quota: { limit: 10_000, period: "MONTH" },
76 * });
77 *
78 * const key = yield* ApiGateway.ApiKey("PartnerKey", {
79 * generateDistinctId: true,
80 * });
81 *
82 * yield* ApiGateway.UsagePlanKey("PartnerLink", {
83 * usagePlanId: plan.id,
84 * keyId: key.id,
85 * });
86 * ```
87 */
88const UsagePlanResource = Resource<UsagePlan>("AWS.ApiGateway.UsagePlan");
89
90export { UsagePlanResource as UsagePlan };
91
92const generatedName = (id: string, props: UsagePlanProps) =>
93 props.name
94 ? Effect.succeed(props.name)
95 : createPhysicalName({
96 id,
97 maxLength: 128,
98 });
99
100const encodeJsonPointerSegment = (s: string) =>
101 s.replace(/~/g, "~0").replace(/\//g, "~1");
102
103const apiStageKey = (stage: ag.ApiStage) => `${stage.apiId}:${stage.stage}`;
104
105const parseThrottleKey = (key: string) => {
106 const idx = key.lastIndexOf("/");
107 if (idx <= 0) return { resourcePath: key, httpMethod: "*" };
108 return {
109 resourcePath: key.slice(0, idx),
110 httpMethod: key.slice(idx + 1),
111 };
112};
113
114const buildApiStageThrottlePatches = (
115 stageKey: string,
116 prev: { [key: string]: ag.ThrottleSettings | undefined } | undefined,
117 next: { [key: string]: ag.ThrottleSettings | undefined } | undefined,
118): ag.PatchOperation[] => {
119 const keys = new Set([
120 ...Object.keys(prev ?? {}),
121 ...Object.keys(next ?? {}),
122 ]);
123 const patches: ag.PatchOperation[] = [];
124 for (const key of keys) {
125 const old = prev?.[key];
126 const current = next?.[key];
127 const { resourcePath, httpMethod } = parseThrottleKey(key);
128 const base = `/apiStages/${encodeJsonPointerSegment(stageKey)}/throttle/${encodeJsonPointerSegment(resourcePath)}/${encodeJsonPointerSegment(httpMethod)}`;
129 if (current?.burstLimit !== old?.burstLimit) {
130 patches.push(
131 current?.burstLimit === undefined
132 ? { op: "remove", path: `${base}/burstLimit` }
133 : {
134 op: "replace",
135 path: `${base}/burstLimit`,
136 value: String(current.burstLimit),
137 },
138 );
139 }
140 if (current?.rateLimit !== old?.rateLimit) {
141 patches.push(
142 current?.rateLimit === undefined
143 ? { op: "remove", path: `${base}/rateLimit` }
144 : {
145 op: "replace",
146 path: `${base}/rateLimit`,
147 value: String(current.rateLimit),
148 },
149 );
150 }
151 }
152 return patches;
153};
154
155const buildApiStagePatches = (
156 prev: ag.ApiStage[] | undefined,
157 next: ag.ApiStage[] | undefined,
158): ag.PatchOperation[] => {
159 const prevMap = new Map((prev ?? []).map((s) => [apiStageKey(s), s]));
160 const nextMap = new Map((next ?? []).map((s) => [apiStageKey(s), s]));
161 const patches: ag.PatchOperation[] = [];
162 for (const [key] of prevMap) {
163 if (!nextMap.has(key)) {
164 patches.push({ op: "remove", path: "/apiStages", value: key });
165 }
166 }
167 for (const [key, stage] of nextMap) {
168 const old = prevMap.get(key);
169 if (!old) {
170 patches.push({ op: "add", path: "/apiStages", value: key });
171 patches.push(
172 ...buildApiStageThrottlePatches(key, undefined, stage.throttle),
173 );
174 continue;
175 }
176 if (!deepEqual(stage.throttle, old.throttle)) {
177 patches.push(
178 ...buildApiStageThrottlePatches(key, old.throttle, stage.throttle),
179 );
180 }
181 }
182 return patches;
183};
184
185const buildThrottlePatches = (
186 prev: ag.ThrottleSettings | undefined,
187 next: ag.ThrottleSettings | undefined,
188) => {
189 const patches: ag.PatchOperation[] = [];
190 if (next?.burstLimit !== prev?.burstLimit) {
191 patches.push(
192 next?.burstLimit === undefined
193 ? { op: "remove", path: "/throttle/burstLimit" }
194 : {
195 op: "replace",
196 path: "/throttle/burstLimit",
197 value: String(next.burstLimit),
198 },
199 );
200 }
201 if (next?.rateLimit !== prev?.rateLimit) {
202 patches.push(
203 next?.rateLimit === undefined
204 ? { op: "remove", path: "/throttle/rateLimit" }
205 : {
206 op: "replace",
207 path: "/throttle/rateLimit",
208 value: String(next.rateLimit),
209 },
210 );
211 }
212 return patches;
213};
214
215const buildQuotaPatches = (
216 prev: ag.QuotaSettings | undefined,
217 next: ag.QuotaSettings | undefined,
218) => {
219 const patches: ag.PatchOperation[] = [];
220 if (next?.limit !== prev?.limit) {
221 patches.push(
222 next?.limit === undefined
223 ? { op: "remove", path: "/quota/limit" }
224 : {
225 op: "replace",
226 path: "/quota/limit",
227 value: String(next.limit),
228 },
229 );
230 }
231 if (next?.offset !== prev?.offset) {
232 patches.push(
233 next?.offset === undefined
234 ? { op: "remove", path: "/quota/offset" }
235 : {
236 op: "replace",
237 path: "/quota/offset",
238 value: String(next.offset),
239 },
240 );
241 }
242 if (next?.period !== prev?.period) {
243 patches.push(
244 next?.period === undefined
245 ? { op: "remove", path: "/quota/period" }
246 : { op: "replace", path: "/quota/period", value: next.period },
247 );
248 }
249 return patches;
250};
251
252export const UsagePlanProvider = () =>
253 Provider.effect(
254 UsagePlanResource,
255 Effect.gen(function* () {
256 return {
257 stables: ["id"] as const,
258 diff: Effect.fn(function* ({ news: newsIn, olds }) {
259 if (!isResolved(newsIn)) return;
260 const news = newsIn as UsagePlanProps;
261 if (news.name !== undefined && news.name !== olds.name) {
262 return { action: "replace" } as const;
263 }
264 }),
265 read: Effect.fn(function* ({ output }) {
266 if (!output?.id) return undefined;
267 const p = yield* ag
268 .getUsagePlan({ usagePlanId: output.id })
269 .pipe(
270 Effect.catchTag("NotFoundException", () =>
271 Effect.succeed(undefined),
272 ),
273 );
274 if (!p?.id) return undefined;
275 return {
276 id: p.id,
277 name: p.name,
278 description: p.description,
279 apiStages: p.apiStages,
280 throttle: p.throttle,
281 quota: p.quota,
282 tags: tagRecord(p.tags),
283 };
284 }),
285 list: () =>
286 ag.getUsagePlans.pages({}).pipe(
287 Stream.runCollect,
288 Effect.map((chunk) =>
289 Array.from(chunk).flatMap((page) =>
290 (page.items ?? [])
291 .filter(
292 (p): p is ag.UsagePlan & { id: string } => p.id != null,
293 )
294 .map((p) => ({
295 id: p.id,
296 name: p.name,
297 description: p.description,
298 apiStages: p.apiStages,
299 throttle: p.throttle,
300 quota: p.quota,
301 tags: tagRecord(p.tags),
302 })),
303 ),
304 ),
305 ),
306 reconcile: Effect.fn(function* ({ id, news: newsIn, output, session }) {
307 const { region } = yield* AWSEnvironment.current;
308 if (!isResolved(newsIn)) {
309 return yield* Effect.die("UsagePlan props were not resolved");
310 }
311 const news = newsIn as UsagePlanProps;
312 const name = yield* generatedName(id, news);
313 const internalTags = yield* createInternalTags(id);
314 const desiredTags = { ...news.tags, ...internalTags };
315
316 // Observe — fetch live usage plan if we have a cached id. We
317 // never trust `output.apiStages`/`output.throttle`/etc. for
318 // diffing; we re-read every reconcile so adoption converges.
319 let observed = output?.id
320 ? yield* ag
321 .getUsagePlan({ usagePlanId: output.id })
322 .pipe(
323 Effect.catchTag("NotFoundException", () =>
324 Effect.succeed(undefined),
325 ),
326 )
327 : undefined;
328
329 // Ensure — create the usage plan if missing.
330 if (!observed?.id) {
331 const created = yield* ag.createUsagePlan({
332 name,
333 description: news.description,
334 apiStages: news.apiStages,
335 throttle: news.throttle,
336 quota: news.quota,
337 tags: desiredTags,
338 });
339 if (!created.id)
340 return yield* Effect.die("createUsagePlan missing id");
341 yield* session.note(`Created usage plan ${created.id}`);
342 observed = yield* ag.getUsagePlan({ usagePlanId: created.id });
343 }
344
345 const planId = observed.id!;
346
347 // Sync mutable plan fields — diff observed cloud state against
348 // desired and emit only the delta as PATCH operations.
349 const patches: ag.PatchOperation[] = [];
350 if (news.description !== observed.description) {
351 patches.push({
352 op: "replace",
353 path: "/description",
354 value: news.description ?? "",
355 });
356 }
357 patches.push(
358 ...buildApiStagePatches(observed.apiStages, news.apiStages),
359 );
360 patches.push(
361 ...buildThrottlePatches(observed.throttle, news.throttle),
362 );
363 patches.push(...buildQuotaPatches(observed.quota, news.quota));
364 if (patches.length > 0) {
365 yield* ag.updateUsagePlan({
366 usagePlanId: planId,
367 patchOperations: patches,
368 });
369 }
370
371 // Sync tags — observed ↔ desired.
372 const observedTags = tagRecord(observed.tags);
373 if (!deepEqual(observedTags, desiredTags)) {
374 yield* syncTags({
375 resourceArn: usagePlanArn(region, planId),
376 oldTags: observedTags,
377 newTags: desiredTags,
378 });
379 }
380
381 yield* session.note(`Reconciled usage plan ${planId}`);
382 const final = yield* ag.getUsagePlan({ usagePlanId: planId });
383 return {
384 id: planId,
385 name: final.name,
386 description: final.description,
387 apiStages: final.apiStages,
388 throttle: final.throttle,
389 quota: final.quota,
390 tags: tagRecord(final.tags),
391 };
392 }),
393 delete: Effect.fn(function* ({ output, session }) {
394 yield* ag
395 .deleteUsagePlan({ usagePlanId: output.id })
396 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
397 yield* session.note(`Deleted usage plan ${output.id}`);
398 }),
399 };
400 }),
401 );