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 type { Input } from "../../Input.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, tagRecord } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11
12import { AWSEnvironment } from "../Environment.ts";
13import {
14 deleteRestApiAndWait,
15 restApiArn,
16 retryOnApiStatusUpdating,
17 syncTags,
18} from "./common.ts";
19
20export interface RestApiProps {
21 /**
22 * Name of the REST API.
23 *
24 * If omitted, Alchemy generates a deterministic physical name.
25 */
26 name?: string;
27 /** Description of the REST API. */
28 description?: string;
29 /** A version identifier for the API. */
30 version?: string;
31 /** ID of an existing REST API to clone the new API from. */
32 cloneFrom?: string;
33 /** Media types treated as binary (e.g. `image/png`; a star-slash-star entry covers all types). */
34 binaryMediaTypes?: string[];
35 /** Minimum response size in bytes that triggers compression (0-10485760); unset disables compression. */
36 minimumCompressionSize?: number;
37 /**
38 * Where API Gateway reads the API key on requests (`HEADER` or `AUTHORIZER`).
39 * @default "HEADER"
40 */
41 apiKeySource?: ag.ApiKeySourceType;
42 /** Endpoint type for the API (EDGE, REGIONAL, or PRIVATE). */
43 endpointConfiguration?: ag.EndpointConfiguration;
44 /** Resource policy document as a JSON string. */
45 policy?: string;
46 /**
47 * Disable the default `execute-api` endpoint (serve only via custom domains).
48 * @default false
49 */
50 disableExecuteApiEndpoint?: boolean;
51 /** Minimum TLS version served by the API endpoint. */
52 securityPolicy?: ag.SecurityPolicy;
53 /** Access mode of the API endpoint. */
54 endpointAccessMode?: ag.EndpointAccessMode;
55 /** User-defined tags (Alchemy internal tags are merged automatically). */
56 tags?: Record<string, string>;
57}
58
59/**
60 * Structured metadata a child resource attaches to its RestApi via `.bind`.
61 *
62 * These bindings are the mechanism by which Methods, Resources, Authorizers,
63 * and other REST-API-scoped children declare a reverse dependency on the API
64 * itself. The presence of any such binding is what forces `RestApi.create` to
65 * run *after* every child has been created — which is exactly the ordering
66 * CloudFormation asks users to write manually via `DependsOn`.
67 *
68 * Consumers should not construct these directly; the child resource
69 * constructors (e.g. `Method`, `Resource`) handle binding on behalf of the
70 * user.
71 */
72export type RestApiBinding =
73 | {
74 kind: "method";
75 methodId: Input<string>;
76 restApiId: Input<string>;
77 resourceId: Input<string>;
78 httpMethod: Input<string>;
79 }
80 | {
81 kind: "resource";
82 resourceId: Input<string>;
83 parentId: Input<string>;
84 pathPart: Input<string>;
85 }
86 | {
87 kind: "authorizer";
88 authorizerId: Input<string>;
89 };
90
91export interface RestApi extends Resource<
92 "AWS.ApiGateway.RestApi",
93 RestApiProps,
94 {
95 restApiId: string;
96 rootResourceId: string;
97 name: string;
98 description: string | undefined;
99 version: string | undefined;
100 binaryMediaTypes: string[] | undefined;
101 minimumCompressionSize: number | undefined;
102 apiKeySource: ag.ApiKeySourceType | undefined;
103 endpointConfiguration: ag.EndpointConfiguration | undefined;
104 policy: string | undefined;
105 disableExecuteApiEndpoint: boolean | undefined;
106 securityPolicy: ag.SecurityPolicy | undefined;
107 endpointAccessMode: ag.EndpointAccessMode | undefined;
108 tags: Record<string, string>;
109 },
110 RestApiBinding,
111 Providers
112> {}
113
114/**
115 * An Amazon API Gateway REST API (v1).
116 *
117 * `RestApi` is the root of an API Gateway v1 stack. Every other ApiGateway
118 * resource — `Resource`, `Method`, `Authorizer`, `Deployment`, `Stage` —
119 * hangs off a `RestApi`. The only identity you need to thread through your
120 * stack is the `RestApi` value itself: child resources accept `restApi: api`
121 * and register themselves back onto the API so that deployments and stages
122 * wait for them without any user-authored dependency lists.
123 * ### Getting started
124 * A minimal API Gateway stack is four pieces: the `RestApi`, one or more
125 * `Method`s, a `Deployment` that snapshots those methods, and a `Stage` that
126 * exposes the deployment at a URL.
127 *
128 * **Example:** Mock HTTP GET on the root path
129 * ```typescript
130 * import * as ApiGateway from "alchemy/AWS/ApiGateway";
131 *
132 * const api = yield* ApiGateway.RestApi("Api", {
133 * endpointConfiguration: { types: ["REGIONAL"] },
134 * });
135 *
136 * yield* ApiGateway.Method("GetRoot", {
137 * restApi: api,
138 * httpMethod: "GET",
139 * authorizationType: "NONE",
140 * integration: { type: "MOCK" },
141 * });
142 *
143 * const deployment = yield* ApiGateway.Deployment("Release", {
144 * restApi: api,
145 * });
146 *
147 * const stage = yield* ApiGateway.Stage("Prod", {
148 * restApi: api,
149 * stageName: "prod",
150 * deploymentId: deployment.deploymentId,
151 * });
152 * ```
153 *
154 * ### How dependencies flow
155 * Writing `restApi: api` on a child (rather than `restApiId: api.restApiId`)
156 * does two things: it threads the restApi id through, and it registers a
157 * `RestApiBinding` back onto the API. The Alchemy scheduler sees those
158 * bindings as reverse edges from children into the API, and `Deployment`
159 * reads them to express a transitive dependency on every child. You never
160 * have to write a `DependsOn` list or a `triggers` hash — adding a new
161 * `Method` automatically orders it before the next `Deployment`.
162 *
163 * ### Private REST APIs
164 * **Example:** Private REST API
165 * ```typescript
166 * const api = yield* ApiGateway.RestApi("PrivateApi", {
167 * endpointConfiguration: {
168 * types: ["PRIVATE"],
169 * vpcEndpointIds: [endpoint.vpcEndpointId],
170 * },
171 * policy: JSON.stringify({
172 * Version: "2012-10-17",
173 * Statement: [{
174 * Effect: "Allow",
175 * Principal: "*",
176 * Action: "execute-api:Invoke",
177 * Resource: "*",
178 * }],
179 * }),
180 * });
181 * ```
182 *
183 * ### Binary payloads
184 * **Example:** Enable binary media types
185 * ```typescript
186 * const api = yield* ApiGateway.RestApi("BinaryApi", {
187 * binaryMediaTypes: ["application/octet-stream", "image/png"],
188 * minimumCompressionSize: 1024,
189 * });
190 * ```
191 *
192 * ### Endpoint hardening
193 * **Example:** Disable the default execute-api endpoint
194 * ```typescript
195 * const api = yield* ApiGateway.RestApi("CustomDomainOnlyApi", {
196 * endpointConfiguration: { types: ["REGIONAL"] },
197 * disableExecuteApiEndpoint: true,
198 * });
199 * ```
200 *
201 * @resource
202 */
203export const RestApi = Resource<RestApi>("AWS.ApiGateway.RestApi");
204
205const generatedName = (id: string, props: RestApiProps) =>
206 props.name
207 ? Effect.succeed(props.name)
208 : createPhysicalName({
209 id,
210 maxLength: 128,
211 });
212
213const snapshotFromApi = (api: ag.RestApi) => ({
214 restApiId: api.id!,
215 rootResourceId: api.rootResourceId!,
216 name: api.name ?? "",
217 description: api.description,
218 version: api.version,
219 binaryMediaTypes: api.binaryMediaTypes,
220 minimumCompressionSize: api.minimumCompressionSize,
221 apiKeySource: api.apiKeySource,
222 endpointConfiguration: api.endpointConfiguration,
223 policy: api.policy,
224 disableExecuteApiEndpoint: api.disableExecuteApiEndpoint,
225 securityPolicy: api.securityPolicy,
226 endpointAccessMode: api.endpointAccessMode,
227 tags: tagRecord(api.tags),
228});
229
230const patchReplace = (path: string, value: string): ag.PatchOperation => ({
231 op: "replace",
232 path,
233 value,
234});
235
236const encodeJsonPointerSegment = (s: string) =>
237 s.replace(/~/g, "~0").replace(/\//g, "~1");
238
239const binaryMediaTypePath = (mediaType: string) =>
240 `/binaryMediaTypes/${encodeJsonPointerSegment(mediaType)}`;
241
242const buildBinaryMediaTypePatches = (
243 prev: string[] | undefined,
244 next: string[] | undefined,
245): ag.PatchOperation[] => {
246 const prevSet = [...new Set(prev ?? [])];
247 const nextSet = [...new Set(next ?? [])];
248 const patches: ag.PatchOperation[] = [];
249 for (const m of prevSet) {
250 if (!nextSet.includes(m)) {
251 patches.push({ op: "remove", path: binaryMediaTypePath(m) });
252 }
253 }
254 for (const m of nextSet) {
255 if (!prevSet.includes(m)) {
256 patches.push({
257 op: "add",
258 path: binaryMediaTypePath(m),
259 value: m,
260 });
261 }
262 }
263 return patches;
264};
265
266const buildUpdatePatches = (
267 news: RestApiProps,
268 prev: RestApi["Attributes"],
269): ag.PatchOperation[] => {
270 const patches: ag.PatchOperation[] = [];
271 if (news.name !== undefined && news.name !== prev.name) {
272 patches.push(patchReplace("/name", news.name));
273 }
274 if (news.description !== prev.description) {
275 patches.push(patchReplace("/description", news.description ?? ""));
276 }
277 if (news.version !== prev.version) {
278 patches.push(patchReplace("/version", news.version ?? ""));
279 }
280 patches.push(
281 ...buildBinaryMediaTypePatches(
282 prev.binaryMediaTypes,
283 news.binaryMediaTypes,
284 ),
285 );
286 if (news.minimumCompressionSize !== prev.minimumCompressionSize) {
287 patches.push(
288 patchReplace(
289 "/minimumCompressionSize",
290 String(news.minimumCompressionSize ?? ""),
291 ),
292 );
293 }
294 if (news.apiKeySource !== prev.apiKeySource) {
295 patches.push(patchReplace("/apiKeySource", news.apiKeySource ?? "HEADER"));
296 }
297 if (news.policy !== prev.policy) {
298 patches.push(patchReplace("/policy", news.policy ?? ""));
299 }
300 if (news.disableExecuteApiEndpoint !== prev.disableExecuteApiEndpoint) {
301 patches.push(
302 patchReplace(
303 "/disableExecuteApiEndpoint",
304 String(!!news.disableExecuteApiEndpoint),
305 ),
306 );
307 }
308 if (news.securityPolicy !== prev.securityPolicy) {
309 patches.push(
310 patchReplace("/securityPolicy", news.securityPolicy ?? "TLS_1_0"),
311 );
312 }
313 if (news.endpointAccessMode !== prev.endpointAccessMode) {
314 patches.push(
315 patchReplace("/endpointAccessMode", news.endpointAccessMode ?? ""),
316 );
317 }
318 return patches;
319};
320
321export const RestApiProvider = () =>
322 Provider.effect(
323 RestApi,
324 Effect.gen(function* () {
325 return {
326 stables: ["restApiId", "rootResourceId"] as const,
327 diff: Effect.fn(function* ({ news: newsIn, olds }) {
328 if (!isResolved(newsIn)) return;
329 const news = newsIn as RestApiProps;
330 if (
331 // Endpoint type, private endpoint IDs, and IP address type are part
332 // of the REST API endpoint shape; replacing avoids partial endpoint
333 // drift that API Gateway cannot consistently patch in place.
334 !deepEqual(
335 news.endpointConfiguration?.types,
336 olds.endpointConfiguration?.types,
337 ) ||
338 !deepEqual(
339 news.endpointConfiguration?.vpcEndpointIds,
340 olds.endpointConfiguration?.vpcEndpointIds,
341 ) ||
342 !deepEqual(
343 news.endpointConfiguration?.ipAddressType,
344 olds.endpointConfiguration?.ipAddressType,
345 )
346 ) {
347 return { action: "replace" } as const;
348 }
349 }),
350 // Enumerate every REST API in the account/region. `getRestApis` is a
351 // paginated collection op (items field "items"); collect every page and
352 // map each item through the same snapshot helper `read` uses so each
353 // element is a complete `Attributes` shape.
354 list: () =>
355 ag.getRestApis.pages({}).pipe(
356 Stream.runCollect,
357 Effect.map((chunk) =>
358 Array.from(chunk).flatMap((page) =>
359 (page.items ?? [])
360 .filter(
361 (api): api is ag.RestApi & { id: string } => api.id != null,
362 )
363 .map((api) => snapshotFromApi(api)),
364 ),
365 ),
366 ),
367 read: Effect.fn(function* ({ output }) {
368 if (!output?.restApiId) return undefined;
369 const api = yield* ag
370 .getRestApi({ restApiId: output.restApiId })
371 .pipe(
372 Effect.catchTag("NotFoundException", () =>
373 Effect.succeed(undefined),
374 ),
375 );
376 if (!api?.id) return undefined;
377 return snapshotFromApi(api);
378 }),
379 // The REST API is brought up in two phases. `precreate` creates the
380 // REST API in full so child resources (`Method`, `Resource`, etc.)
381 // can resolve `api.restApiId` and start their own creates — this is
382 // the cycle-breaker that lets children register themselves back onto
383 // the API via `restApi.bind`. `create` then runs last — after every
384 // bound child — and is effectively a no-op that re-reads the API to
385 // publish the final snapshot. The key property is that any
386 // `Deployment` referencing `api.*` outputs will see a value produced
387 // only after every bound child has settled, because the bindings on
388 // the API force `create` to wait for them before returning.
389 precreate: Effect.fn(function* ({ id, news: newsIn, session }) {
390 if (!isResolved(newsIn)) {
391 return yield* Effect.die("RestApi props were not resolved");
392 }
393 const news = newsIn as RestApiProps;
394 const name = yield* generatedName(id, news);
395 const internalTags = yield* createInternalTags(id);
396 const allTags = { ...news.tags, ...internalTags };
397 const created = yield* retryOnApiStatusUpdating(
398 ag.createRestApi({
399 name,
400 description: news.description,
401 version: news.version,
402 cloneFrom: news.cloneFrom,
403 binaryMediaTypes: news.binaryMediaTypes,
404 minimumCompressionSize: news.minimumCompressionSize,
405 apiKeySource: news.apiKeySource,
406 endpointConfiguration: news.endpointConfiguration,
407 policy: news.policy,
408 tags: allTags,
409 disableExecuteApiEndpoint: news.disableExecuteApiEndpoint,
410 securityPolicy: news.securityPolicy,
411 endpointAccessMode: news.endpointAccessMode,
412 }),
413 );
414 if (!created.id || !created.rootResourceId) {
415 return yield* Effect.die(
416 "createRestApi missing id or rootResourceId",
417 );
418 }
419 yield* session.note(`Created REST API ${created.id}`);
420 const full = yield* ag.getRestApi({ restApiId: created.id });
421 return snapshotFromApi(full);
422 }),
423 reconcile: Effect.fn(function* ({ id, news: newsIn, output, session }) {
424 const { region } = yield* AWSEnvironment.current;
425 if (!isResolved(newsIn)) {
426 return yield* Effect.die("RestApi props were not resolved");
427 }
428 const news = newsIn as RestApiProps;
429
430 // RestApi has a `precreate` that always runs before `reconcile`
431 // for greenfield deployments — it creates the REST API in full
432 // so that `restApi.restApiId` is resolvable and child resources
433 // (Method, Resource, Authorizer) can register themselves back on
434 // the API via `restApi.bind`. By the time `reconcile` runs the id
435 // is populated; we never expect `output === undefined` here, but
436 // we still handle it defensively.
437 if (!output?.restApiId) {
438 return yield* Effect.die(
439 "RestApi reconcile reached without a precreate output",
440 );
441 }
442
443 // Observe — fetch live cloud state. `output` is treated as a
444 // cache for the stable id only.
445 const observed = yield* ag
446 .getRestApi({ restApiId: output.restApiId })
447 .pipe(
448 Effect.catchTag("NotFoundException", () =>
449 Effect.succeed(undefined),
450 ),
451 );
452 if (!observed?.id) {
453 return yield* Effect.die(
454 `RestApi ${output.restApiId} disappeared between precreate and reconcile`,
455 );
456 }
457 const observedSnapshot = snapshotFromApi(observed);
458
459 // Sync mutable scalar fields — diff observed cloud state against
460 // desired and emit only the delta as PATCH operations.
461 const patches = buildUpdatePatches(news, observedSnapshot);
462 if (patches.length > 0) {
463 yield* retryOnApiStatusUpdating(
464 ag.updateRestApi({
465 restApiId: output.restApiId,
466 patchOperations: patches,
467 }),
468 );
469 }
470
471 // Sync tags — observed ↔ desired so adoption converges without
472 // fighting the existing tag set.
473 const internalTags = yield* createInternalTags(id);
474 const desiredTags = { ...news.tags, ...internalTags };
475 if (!deepEqual(observedSnapshot.tags, desiredTags)) {
476 const arn = restApiArn(region, output.restApiId);
477 yield* syncTags({
478 resourceArn: arn,
479 oldTags: observedSnapshot.tags,
480 newTags: desiredTags,
481 });
482 }
483
484 yield* session.note(`Reconciled REST API ${output.restApiId}`);
485
486 // Re-read so the returned attributes reflect what's actually in
487 // the cloud after all sync steps.
488 const final = yield* ag.getRestApi({ restApiId: output.restApiId });
489 if (!final.id) {
490 return yield* Effect.die("getRestApi missing id after reconcile");
491 }
492 return snapshotFromApi(final);
493 }),
494 delete: Effect.fn(function* ({ output, session }) {
495 yield* deleteRestApiAndWait(output.restApiId);
496 yield* session.note(`Deleted REST API ${output.restApiId}`);
497 }),
498 };
499 }),
500 );