| 1 | import * as ag from "@distilled.cloud/aws/api-gateway"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Stream from "effect/Stream"; |
| 4 | import { deepEqual, isResolved } from "../../Diff.ts"; |
| 5 | import type { Input } from "../../Input.ts"; |
| 6 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { createInternalTags, tagRecord } from "../../Tags.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | |
| 12 | import { AWSEnvironment } from "../Environment.ts"; |
| 13 | import { |
| 14 | deleteRestApiAndWait, |
| 15 | restApiArn, |
| 16 | retryOnApiStatusUpdating, |
| 17 | syncTags, |
| 18 | } from "./common.ts"; |
| 19 | |
| 20 | export 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 | */ |
| 72 | export 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 | |
| 91 | export 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 | */ |
| 203 | export const RestApi = Resource<RestApi>("AWS.ApiGateway.RestApi"); |
| 204 | |
| 205 | const generatedName = (id: string, props: RestApiProps) => |
| 206 | props.name |
| 207 | ? Effect.succeed(props.name) |
| 208 | : createPhysicalName({ |
| 209 | id, |
| 210 | maxLength: 128, |
| 211 | }); |
| 212 | |
| 213 | const 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 | |
| 230 | const patchReplace = (path: string, value: string): ag.PatchOperation => ({ |
| 231 | op: "replace", |
| 232 | path, |
| 233 | value, |
| 234 | }); |
| 235 | |
| 236 | const encodeJsonPointerSegment = (s: string) => |
| 237 | s.replace(/~/g, "~0").replace(/\//g, "~1"); |
| 238 | |
| 239 | const binaryMediaTypePath = (mediaType: string) => |
| 240 | `/binaryMediaTypes/${encodeJsonPointerSegment(mediaType)}`; |
| 241 | |
| 242 | const 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 | |
| 266 | const 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 | |
| 321 | export 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 | ); |