1import * as ag from "@distilled.cloud/aws/api-gateway";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { createHash } from "node:crypto";
5import { deepEqual, isResolved } from "../../Diff.ts";
6import type { Input } from "../../Input.ts";
7import * as Output from "../../Output.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { Stack } from "../../Stack.ts";
11import type { Providers } from "../Providers.ts";
12import { retryOnApiStatusUpdating } from "./common.ts";
13import type { RestApi, RestApiBinding } from "./RestApi.ts";
14
15export interface DeploymentProps {
16 /**
17 * The `RestApi` to deploy. When supplied, the Deployment reads every
18 * `RestApiBinding` already registered on the API (each `Method`,
19 * `Resource`, etc. that was declared with `restApi: api`) and pulls
20 * those into its dependency graph. Adding a new `Method` automatically
21 * causes the next apply to produce a fresh deployment — no user-authored
22 * `DependsOn` or `triggers` hash required.
23 */
24 restApi?: RestApi;
25 /**
26 * ID of the REST API. Usually derived from `restApi.restApiId`; supply
27 * explicitly only when not using `restApi`.
28 */
29 restApiId?: Input<string>;
30 /** Description of the deployment. */
31 description?: string;
32 /** Name of a stage API Gateway creates alongside the deployment (legacy pattern — prefer a separate `Stage`). */
33 stageName?: string;
34 /** Description of the stage created via `stageName`. */
35 stageDescription?: string;
36 /** Enable a cache cluster on the stage created via `stageName`. */
37 cacheClusterEnabled?: boolean;
38 /** Cache cluster size for the stage created via `stageName`. */
39 cacheClusterSize?: ag.CacheClusterSize;
40 /** Stage variables for the stage created via `stageName`. */
41 variables?: { [key: string]: string | undefined };
42 /** Canary settings applied to the deployment. */
43 canarySettings?: ag.DeploymentCanarySettings;
44 /** Enable X-Ray tracing on the stage created via `stageName`. */
45 tracingEnabled?: boolean;
46 /**
47 * Opaque key/value map; when any value changes, a replacement deployment
48 * is planned. Each value may be a literal string or an `Input<string>`
49 * (such as a resource output) — output references create real dependency
50 * edges, which is how Alchemy ensures a new deployment runs after the
51 * resources it references are updated.
52 *
53 * You rarely need to set this manually: when you pass `restApi`, the
54 * deployment already depends on every bound `Method` and redeploys
55 * automatically when any of them change. Reach for `triggers` only when
56 * you want to force a redeploy on some other signal (e.g. a config hash,
57 * a manual version bump).
58 */
59 triggers?: Record<string, Input<string>>;
60}
61
62export interface DeploymentType extends Resource<
63 "AWS.ApiGateway.Deployment",
64 DeploymentProps,
65 {
66 deploymentId: string;
67 restApiId: string;
68 description: string | undefined;
69 },
70 never,
71 Providers
72> {}
73
74/**
75 * A point-in-time snapshot of a REST API, ready to be served through a
76 * `Stage`.
77 * ### Creating a deployment
78 * A Deployment captures whatever methods, integrations, resources, and
79 * authorizers currently exist on the REST API and produces an immutable
80 * `deploymentId` that a `Stage` can point at. Pass the `RestApi` value on
81 * `restApi` and Alchemy handles all the ordering for you — the deployment
82 * will run after every method bound to the API.
83 *
84 * **Example:** Deployment of a REST API
85 * ```typescript
86 * const api = yield* ApiGateway.RestApi("Api", {
87 * endpointConfiguration: { types: ["REGIONAL"] },
88 * });
89 *
90 * yield* ApiGateway.Method("GetRoot", {
91 * restApi: api,
92 * httpMethod: "GET",
93 * authorizationType: "NONE",
94 * integration: { type: "MOCK" },
95 * });
96 *
97 * const deployment = yield* ApiGateway.Deployment("Release", {
98 * restApi: api,
99 * description: "v1",
100 * });
101 * ```
102 *
103 * ### Forcing a redeploy
104 * Usually you do not have to: `restApi` already makes the Deployment
105 * depend on every method, so any change to a method re-plans a new
106 * deployment. Use `triggers` when you want to couple the deployment to a
107 * signal Alchemy cannot see — for example, a manual version bump or a
108 * hash of configuration computed outside the stack.
109 *
110 * **Example:** Force redeploy on a version bump
111 * ```typescript
112 * const deployment = yield* ApiGateway.Deployment("Release", {
113 * restApi: api,
114 * triggers: { version: "2026-05-01" },
115 * });
116 * ```
117 *
118 * ### Why no DependsOn?
119 * CloudFormation's `AWS::ApiGateway::Deployment` famously requires a
120 * hand-written `DependsOn: [Method1, Method2, ...]` listing every method.
121 * Alchemy derives that list automatically from the bindings registered on
122 * the `RestApi`, so adding a method never requires editing the deployment.
123 *
124 * @resource
125 */
126export const DeploymentResource = Resource<DeploymentType>(
127 "AWS.ApiGateway.Deployment",
128);
129
130interface DeploymentInputProps {
131 restApi?: RestApi;
132 restApiId?: Input<string>;
133 description?: Input<string>;
134 stageName?: Input<string>;
135 stageDescription?: Input<string>;
136 cacheClusterEnabled?: Input<boolean>;
137 cacheClusterSize?: Input<ag.CacheClusterSize>;
138 variables?: Input<{ [key: string]: string | undefined }>;
139 canarySettings?: Input<ag.DeploymentCanarySettings>;
140 tracingEnabled?: Input<boolean>;
141 triggers?: Record<string, Input<string>>;
142}
143
144/**
145 * User-facing wrapper that adds a dependency edge from the Deployment to
146 * every resource bound to the supplied `restApi`. Implementation detail:
147 * each binding's output references get copied into the deployment's
148 * `triggers` map so `resolveUpstream` sees them as real upstream dependencies.
149 */
150export const Deployment = (id: string, props: DeploymentInputProps) =>
151 Effect.gen(function* () {
152 const { restApi, ...rest } = props;
153 const restApiId = rest.restApiId ?? restApi?.restApiId;
154 if (!restApiId) {
155 return yield* Effect.die(
156 "Deployment requires either `restApi` (preferred) or explicit " +
157 "`restApiId`.",
158 );
159 }
160 let triggers: Record<string, Input<string>> | undefined = rest.triggers;
161 if (restApi) {
162 const stack = yield* Stack;
163 const bindings = stack.bindings[restApi.FQN] ?? [];
164 const autoTriggers: Record<string, Input<string>> = {};
165 for (const b of bindings) {
166 autoTriggers[`@alchemy:binding:${b.sid}`] = bindingDigest(b.data);
167 }
168 triggers = { ...autoTriggers, ...triggers };
169 }
170 return yield* DeploymentResource(id, {
171 ...rest,
172 restApiId,
173 triggers,
174 } as any);
175 });
176
177/**
178 * Serializes a binding payload down to a deterministic `Input<string>` so
179 * it can ride along as a trigger value. The inner `Output.all` is what
180 * surfaces the underlying resource FQNs to the dependency resolver.
181 */
182const bindingDigest = (data: RestApiBinding): Input<string> => {
183 const entries = Object.entries(data as Record<string, unknown>).filter(
184 ([k]) => k !== "kind",
185 );
186 const values = entries.map(([, v]) =>
187 Output.asOutput(v as string | Output.Output<string>),
188 );
189 return Output.map(Output.all(...values), (parts) =>
190 [data.kind, ...(parts as unknown as unknown[]).map(String)].join("|"),
191 );
192};
193
194const embedTriggers = (
195 description: string | undefined,
196 triggers?: Record<string, string>,
197) =>
198 Effect.gen(function* () {
199 if (!triggers || Object.keys(triggers).length === 0) {
200 return description;
201 }
202 const fp = yield* Effect.sync(() =>
203 createHash("sha256")
204 .update(JSON.stringify(triggers))
205 .digest("hex")
206 .slice(0, 24),
207 );
208 const suffix = `@alchemy:triggers:${fp}`;
209 return description ? `${description}\n${suffix}` : suffix;
210 });
211
212export const DeploymentProvider = () =>
213 Provider.effect(
214 DeploymentResource,
215 Effect.gen(function* () {
216 return {
217 stables: ["deploymentId", "restApiId"] as const,
218 // Deployments are sub-resources keyed by `restApiId`. Enumerate every
219 // RestApi in the account/region, then page every deployment under each
220 // one and flatten — yielding the same Attributes shape `read` returns.
221 list: () =>
222 Effect.gen(function* () {
223 const restApiIds = yield* ag.getRestApis.pages({}).pipe(
224 Stream.runCollect,
225 Effect.map((chunk) =>
226 Array.from(chunk).flatMap((page) =>
227 (page.items ?? [])
228 .map((api) => api.id)
229 .filter((id): id is string => id != null),
230 ),
231 ),
232 );
233 const rows = yield* Effect.forEach(
234 restApiIds,
235 (restApiId) =>
236 ag.getDeployments.pages({ restApiId }).pipe(
237 Stream.runCollect,
238 Effect.map((chunk) =>
239 Array.from(chunk).flatMap((page) =>
240 (page.items ?? [])
241 .filter(
242 (d): d is ag.Deployment & { id: string } =>
243 d.id != null,
244 )
245 .map((d) => ({
246 deploymentId: d.id,
247 restApiId,
248 description: d.description,
249 })),
250 ),
251 ),
252 ),
253 { concurrency: 10 },
254 );
255 return rows.flat();
256 }),
257 diff: Effect.fn(function* ({ news: newsIn, olds }) {
258 if (!isResolved(newsIn)) return;
259 const news = newsIn as Input.ResolveProps<DeploymentProps>;
260 const oldsP = olds as Input.ResolveProps<DeploymentProps>;
261 if (news.restApiId !== oldsP.restApiId) {
262 return { action: "replace" } as const;
263 }
264 if (!deepEqual(news.triggers, oldsP.triggers)) {
265 return { action: "replace" } as const;
266 }
267 if (
268 news.stageName !== oldsP.stageName ||
269 news.stageDescription !== oldsP.stageDescription ||
270 news.cacheClusterEnabled !== oldsP.cacheClusterEnabled ||
271 news.cacheClusterSize !== oldsP.cacheClusterSize ||
272 !deepEqual(news.variables, oldsP.variables) ||
273 !deepEqual(news.canarySettings, oldsP.canarySettings) ||
274 news.tracingEnabled !== oldsP.tracingEnabled
275 ) {
276 return { action: "replace" } as const;
277 }
278 if (news.description !== oldsP.description) {
279 return { action: "update" } as const;
280 }
281 }),
282 read: Effect.fn(function* ({ output }) {
283 if (!output?.deploymentId) return undefined;
284 const d = yield* ag
285 .getDeployment({
286 restApiId: output.restApiId,
287 deploymentId: output.deploymentId,
288 })
289 .pipe(
290 Effect.catchTag("NotFoundException", () =>
291 Effect.succeed(undefined),
292 ),
293 );
294 if (!d?.id) return undefined;
295 return {
296 deploymentId: d.id,
297 restApiId: output.restApiId,
298 description: d.description,
299 };
300 }),
301 reconcile: Effect.fn(function* ({ news: newsIn, output, session }) {
302 if (!isResolved(newsIn)) {
303 return yield* Effect.die("Deployment props were not resolved");
304 }
305 const news = newsIn as Input.ResolveProps<DeploymentProps>;
306 const description = yield* embedTriggers(
307 news.description,
308 news.triggers as Record<string, string> | undefined,
309 );
310 const restApiId = (output?.restApiId ?? news.restApiId) as string;
311
312 // Observe — fetch the live deployment if we have a cached id.
313 // A Deployment is immutable apart from `description`, so we never
314 // trust a stale `output.description` for diffing.
315 let observed = output?.deploymentId
316 ? yield* ag
317 .getDeployment({
318 restApiId,
319 deploymentId: output.deploymentId,
320 })
321 .pipe(
322 Effect.catchTag("NotFoundException", () =>
323 Effect.succeed(undefined),
324 ),
325 )
326 : undefined;
327
328 // Ensure — create a new deployment if there isn't one yet.
329 // `createDeployment` always produces a fresh deploymentId, so we
330 // only call it when missing; any change that should produce a new
331 // deployment is modeled as `replace` in `diff`.
332 if (!observed?.id) {
333 const created = yield* retryOnApiStatusUpdating(
334 ag.createDeployment({
335 restApiId: news.restApiId as string,
336 stageName: news.stageName,
337 stageDescription: news.stageDescription,
338 description,
339 cacheClusterEnabled: news.cacheClusterEnabled,
340 cacheClusterSize: news.cacheClusterSize,
341 variables: news.variables,
342 canarySettings: news.canarySettings,
343 tracingEnabled: news.tracingEnabled,
344 }),
345 );
346 if (!created.id)
347 return yield* Effect.die("createDeployment missing id");
348 yield* session.note(`Created deployment ${created.id}`);
349 observed = yield* ag.getDeployment({
350 restApiId: news.restApiId as string,
351 deploymentId: created.id,
352 });
353 }
354
355 const deploymentId = observed.id!;
356
357 // Sync description — observed ↔ desired.
358 if (description !== observed.description) {
359 yield* retryOnApiStatusUpdating(
360 ag.updateDeployment({
361 restApiId,
362 deploymentId,
363 patchOperations: description
364 ? [
365 {
366 op: "replace",
367 path: "/description",
368 value: description,
369 },
370 ]
371 : [{ op: "remove", path: "/description" }],
372 }),
373 );
374 }
375
376 yield* session.note(`Reconciled deployment ${deploymentId}`);
377 const final = yield* ag.getDeployment({
378 restApiId,
379 deploymentId,
380 });
381 return {
382 deploymentId,
383 restApiId,
384 description: final?.description,
385 };
386 }),
387 delete: Effect.fn(function* ({ output, session }) {
388 yield* retryOnApiStatusUpdating(
389 ag
390 .deleteDeployment({
391 restApiId: output.restApiId,
392 deploymentId: output.deploymentId,
393 })
394 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
395 );
396 yield* session.note(`Deleted deployment ${output.deploymentId}`);
397 }),
398 };
399 }),
400 );