1import * as ag from "@distilled.cloud/aws/api-gateway";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Stream from "effect/Stream";
5import { deepEqual, isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags, tagRecord } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11
12import { AWSEnvironment } from "../Environment.ts";
13import { apiKeyArn, syncTags } from "./common.ts";
14
15export interface ApiKeyProps {
16 /**
17 * Friendly name for the API key.
18 *
19 * If omitted, Alchemy generates a deterministic physical name from the
20 * stack, stage, logical ID, and instance ID.
21 */
22 name?: string;
23 /**
24 * Human-readable description shown in API Gateway.
25 */
26 description?: string;
27 /**
28 * Whether clients can use the key.
29 *
30 * @default true
31 */
32 enabled?: boolean;
33 /**
34 * Appends a distinct suffix to the generated key value when AWS generates it.
35 */
36 generateDistinctId?: boolean;
37 /**
38 * Write-only value when creating; never stored in resource state or outputs.
39 * Wrap with `Redacted.make` so state encoding preserves redaction.
40 */
41 value?: Redacted.Redacted<string>;
42 /**
43 * Stage associations to attach directly to this API key.
44 */
45 stageKeys?: ag.StageKey[];
46 /**
47 * External customer identifier associated with the key.
48 */
49 customerId?: string;
50 /**
51 * User-defined tags. Alchemy internal tags are merged automatically.
52 */
53 tags?: Record<string, string>;
54}
55
56/** @resource */
57export interface ApiKey extends Resource<
58 "AWS.ApiGateway.ApiKey",
59 ApiKeyProps,
60 {
61 id: string;
62 name: string | undefined;
63 enabled: boolean | undefined;
64 tags: Record<string, string>;
65 },
66 never,
67 Providers
68> {}
69
70/**
71 * API Gateway API key for usage plans and `apiKeyRequired` methods.
72 *
73 * ### API keys
74 * **Example:** Generated key
75 * ```typescript
76 * const key = yield* ApiGateway.ApiKey("PartnerKey", {
77 * generateDistinctId: true,
78 * });
79 * ```
80 */
81const ApiKeyResource = Resource<ApiKey>("AWS.ApiGateway.ApiKey");
82
83export { ApiKeyResource as ApiKey };
84
85const resolvedValue = (value: Redacted.Redacted<string> | undefined) =>
86 value ? Redacted.value(value) : undefined;
87
88const generatedName = (id: string, props: ApiKeyProps) =>
89 props.name
90 ? Effect.succeed(props.name)
91 : createPhysicalName({
92 id,
93 maxLength: 128,
94 });
95
96const readByName = Effect.fn(function* (id: string, name: string) {
97 const keys = yield* ag.getApiKeys
98 .items({ nameQuery: name, limit: 500, includeValues: false })
99 .pipe(Stream.runCollect);
100
101 for (const key of keys) {
102 if (key.name !== name || !key.id) continue;
103 if (yield* hasAlchemyTags(id, key.tags)) {
104 return key;
105 }
106 }
107 return undefined;
108});
109
110export const ApiKeyProvider = () =>
111 Provider.effect(
112 ApiKeyResource,
113 Effect.gen(function* () {
114 return {
115 stables: ["id"] as const,
116 diff: Effect.fn(function* ({ news: newsIn, olds }) {
117 if (!isResolved(newsIn)) return;
118 const news = newsIn as ApiKeyProps;
119 if (
120 // API Gateway never returns the key value after create, so rotating
121 // a user-supplied value is modeled as replacement instead of patch.
122 resolvedValue(news.value) !== resolvedValue(olds.value) ||
123 news.customerId !== olds.customerId
124 ) {
125 return { action: "replace" } as const;
126 }
127 }),
128 read: Effect.fn(function* ({ output }) {
129 if (!output?.id) return undefined;
130 const k = yield* ag
131 .getApiKey({ apiKey: output.id, includeValue: false })
132 .pipe(
133 Effect.catchTag("NotFoundException", () =>
134 Effect.succeed(undefined),
135 ),
136 );
137 if (!k?.id) return undefined;
138 return {
139 id: k.id,
140 name: k.name,
141 enabled: k.enabled,
142 tags: tagRecord(k.tags),
143 };
144 }),
145 list: () =>
146 ag.getApiKeys.pages({ limit: 500, includeValues: false }).pipe(
147 Stream.runCollect,
148 Effect.map((chunk) =>
149 Array.from(chunk).flatMap((page) =>
150 (page.items ?? [])
151 .filter((k): k is ag.ApiKey & { id: string } => !!k.id)
152 .map((k) => ({
153 id: k.id,
154 name: k.name,
155 enabled: k.enabled,
156 tags: tagRecord(k.tags),
157 })),
158 ),
159 ),
160 ),
161 reconcile: Effect.fn(function* ({ id, news: newsIn, output, session }) {
162 const { region } = yield* AWSEnvironment.current;
163 if (!isResolved(newsIn)) {
164 return yield* Effect.die("ApiKey props were not resolved");
165 }
166 const news = newsIn as ApiKeyProps;
167 const name = yield* generatedName(id, news);
168 const internalTags = yield* createInternalTags(id);
169 const desiredTags = { ...news.tags, ...internalTags };
170
171 // Observe — find the API key. We try the cached id first, fall
172 // back to scanning by name with the alchemy ownership filter so
173 // the reconciler converges after an out-of-band recreate or an
174 // adoption that bypassed our id cache.
175 let observed = output?.id
176 ? yield* ag
177 .getApiKey({ apiKey: output.id, includeValue: false })
178 .pipe(
179 Effect.catchTag("NotFoundException", () =>
180 Effect.succeed(undefined),
181 ),
182 )
183 : undefined;
184 if (!observed?.id) {
185 observed = yield* readByName(id, name);
186 }
187
188 // Ensure — create if missing. Tolerate `ConflictException` as a
189 // race with a peer reconciler or a stray key with the same name:
190 // re-look up by name and treat the alchemy-owned hit as observed.
191 if (!observed?.id) {
192 const created = yield* ag
193 .createApiKey({
194 name,
195 description: news.description,
196 enabled: news.enabled,
197 generateDistinctId: news.generateDistinctId,
198 // Distilled's CreateApiKeyRequest.value is SensitiveString —
199 // it accepts the Redacted directly and unwraps on encode, so
200 // the plaintext never passes through alchemy code.
201 value: news.value,
202 stageKeys: news.stageKeys,
203 customerId: news.customerId,
204 tags: desiredTags,
205 })
206 .pipe(
207 Effect.catchTag("ConflictException", () =>
208 Effect.gen(function* () {
209 const existing = yield* readByName(id, name);
210 if (existing) return existing;
211 return yield* Effect.fail(
212 new ag.ConflictException({
213 message: `API key '${name}' already exists and is not managed by alchemy`,
214 }),
215 );
216 }),
217 ),
218 );
219 if (!created.id)
220 return yield* Effect.die("createApiKey missing id");
221 yield* session.note(`Created API key ${created.id}`);
222 observed = yield* ag.getApiKey({
223 apiKey: created.id,
224 includeValue: false,
225 });
226 }
227
228 const apiKeyId = observed.id!;
229
230 // Sync mutable scalar fields — observed ↔ desired patch list.
231 const patches: ag.PatchOperation[] = [];
232 if (news.name !== undefined && news.name !== observed.name) {
233 patches.push({
234 op: "replace",
235 path: "/name",
236 value: news.name,
237 });
238 }
239 if (
240 news.description !== undefined &&
241 news.description !== observed.description
242 ) {
243 patches.push({
244 op: "replace",
245 path: "/description",
246 value: news.description,
247 });
248 }
249 if (news.enabled !== undefined && news.enabled !== observed.enabled) {
250 patches.push({
251 op: "replace",
252 path: "/enabled",
253 value: String(news.enabled),
254 });
255 }
256 if (patches.length > 0) {
257 yield* ag.updateApiKey({
258 apiKey: apiKeyId,
259 patchOperations: patches,
260 });
261 }
262
263 // Sync tags — diff observed cloud tags against desired so adoption
264 // converges without fighting whatever tag set was already there.
265 const observedTags = tagRecord(observed.tags);
266 if (!deepEqual(observedTags, desiredTags)) {
267 yield* syncTags({
268 resourceArn: apiKeyArn(region, apiKeyId),
269 oldTags: observedTags,
270 newTags: desiredTags,
271 });
272 }
273
274 yield* session.note(`Reconciled API key ${apiKeyId}`);
275 const final = yield* ag.getApiKey({
276 apiKey: apiKeyId,
277 includeValue: false,
278 });
279 if (!final.id) return yield* Effect.die("getApiKey missing id");
280 return {
281 id: final.id,
282 name: final.name,
283 enabled: final.enabled,
284 tags: tagRecord(final.tags),
285 };
286 }),
287 delete: Effect.fn(function* ({ output, session }) {
288 yield* ag
289 .deleteApiKey({ apiKey: output.id })
290 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
291 yield* session.note(`Deleted API key ${output.id}`);
292 }),
293 };
294 }),
295 );