1import * as ag from "@distilled.cloud/aws/api-gateway";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { deepEqual, isResolved } from "../../Diff.ts";
6import type { Input } from "../../Input.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { toWireMillis } from "../../Util/Duration.ts";
10import type { Providers } from "../Providers.ts";
11import type { RestApi } from "./RestApi.ts";
12
13/**
14 * Integration configuration for an API Gateway method (passed to `putIntegration`).
15 */
16export interface MethodIntegrationProps {
17 /** Integration type: `AWS`, `AWS_PROXY`, `HTTP`, `HTTP_PROXY`, or `MOCK`. */
18 type: ag.IntegrationType;
19 /** HTTP method used to call the backend (always `POST` for Lambda integrations). */
20 integrationHttpMethod?: string;
21 /** Integration endpoint URI (Lambda invocation ARN or HTTP URL). */
22 uri?: Input<string>;
23 /** `VPC_LINK` for private integrations, otherwise `INTERNET`. */
24 connectionType?: ag.ConnectionType;
25 /** ID of the `VpcLink` when `connectionType` is `VPC_LINK`. */
26 connectionId?: string;
27 /**
28 * IAM role ARN used by API Gateway for integration credentials.
29 *
30 * This is not a secret value; API Gateway stores an ARN or passthrough marker.
31 */
32 credentials?: string;
33 /** Maps integration request parameters from method request parameters. */
34 requestParameters?: { [key: string]: string | undefined };
35 /** Request body mapping templates keyed by content type. */
36 requestTemplates?: { [key: string]: string | undefined };
37 /** How unmapped content types pass through (`WHEN_NO_MATCH`, `WHEN_NO_TEMPLATES`, `NEVER`). */
38 passthroughBehavior?: string;
39 /** Cache namespace for integration responses. */
40 cacheNamespace?: string;
41 /** Request parameters whose values form the cache key. */
42 cacheKeyParameters?: string[];
43 /** Payload encoding conversion (`CONVERT_TO_BINARY` or `CONVERT_TO_TEXT`). */
44 contentHandling?: ag.ContentHandlingStrategy;
45 /**
46 * Integration timeout (e.g. `"29 seconds"` or `Duration.seconds(29)`;
47 * a bare number is milliseconds). Sent to the API in milliseconds
48 * (`timeoutInMillis`).
49 */
50 timeout?: Duration.Input;
51 /** TLS settings for HTTP integrations (e.g. `insecureSkipVerification`). */
52 tlsConfig?: ag.TlsConfig;
53 /** How the integration response is transferred to the client. */
54 responseTransferMode?: ag.ResponseTransferMode;
55 /** Target resource of the integration, where the API distinguishes one from `uri`. */
56 integrationTarget?: string;
57}
58
59export interface MethodProps {
60 /**
61 * The `RestApi` this method lives on. When supplied, the method auto-binds
62 * itself to the API so that any `Deployment` of this API waits for the
63 * method to be created before snapshotting.
64 *
65 * When `restApi` is provided, `resourceId` defaults to `restApi.rootResourceId`,
66 * which is the common case for methods on the API root (`/`). Pass an
67 * explicit `resourceId` when targeting a sub-path defined by
68 * `ApiGateway.Resource`.
69 *
70 * Passing a raw `restApiId` instead is still supported but opts out of
71 * automatic deployment ordering — you must then manage `Deployment.triggers`
72 * yourself.
73 */
74 restApi?: RestApi;
75 /**
76 * ID of the REST API. Usually derived from `restApi.restApiId`; supply
77 * explicitly only when not using `restApi`.
78 */
79 restApiId?: Input<string>;
80 /**
81 * ID of the API Gateway Resource this method attaches to. Defaults to
82 * `restApi.rootResourceId` when `restApi` is provided.
83 */
84 resourceId?: Input<string>;
85 /** HTTP verb, e.g. `GET`, `POST`, `ANY`. */
86 httpMethod: string;
87 /**
88 * Authorization type (`NONE`, `IAM`, `CUSTOM`, `COGNITO_USER_POOLS`, etc.).
89 * @default "NONE"
90 */
91 authorizationType?: string;
92 /** ID of the `Authorizer` when `authorizationType` is `CUSTOM` or `COGNITO_USER_POOLS`. */
93 authorizerId?: string;
94 /** Require callers to present an API key. */
95 apiKeyRequired?: boolean;
96 /** Friendly operation name (e.g. for SDK generation). */
97 operationName?: string;
98 /** Accepted request parameters; `true` marks a parameter required. */
99 requestParameters?: { [key: string]: boolean | undefined };
100 /** Request body models keyed by content type. */
101 requestModels?: { [key: string]: string | undefined };
102 /** ID of a request validator applied to this method. */
103 requestValidatorId?: string;
104 /** OAuth scopes for `COGNITO_USER_POOLS` authorization. */
105 authorizationScopes?: string[];
106 /** When set, `putIntegration` is applied after `putMethod`. */
107 integration?: MethodIntegrationProps;
108}
109
110export interface MethodType extends Resource<
111 "AWS.ApiGateway.Method",
112 MethodProps,
113 {
114 restApiId: string;
115 resourceId: string;
116 httpMethod: string;
117 authorizationType: string;
118 authorizerId: string | undefined;
119 apiKeyRequired: boolean | undefined;
120 operationName: string | undefined;
121 requestParameters: { [key: string]: boolean | undefined } | undefined;
122 requestModels: { [key: string]: string | undefined } | undefined;
123 requestValidatorId: string | undefined;
124 authorizationScopes: string[] | undefined;
125 integration: MethodIntegrationProps | undefined;
126 },
127 never,
128 Providers
129> {}
130
131/**
132 * An HTTP method on an API Gateway Resource.
133 *
134 * A `Method` is a single HTTP verb (`GET`, `POST`, `ANY`, …) attached to a
135 * REST API resource path. Most methods also carry an `integration` — the
136 * downstream target that actually handles the request (a Lambda function,
137 * an HTTP endpoint, a mock response, etc.).
138 * ### Binding to a RestApi
139 * Pass the `RestApi` value on `restApi`. This threads the API id through
140 * and registers the method as a `RestApiBinding` on the API, so that any
141 * `Deployment` of the same API is automatically ordered after this method
142 * completes. You do not need to manage `Deployment.triggers` yourself.
143 *
144 * **Example:** GET on the API root with a mock integration
145 * ```typescript
146 * yield* ApiGateway.Method("GetRoot", {
147 * restApi: api,
148 * httpMethod: "GET",
149 * authorizationType: "NONE",
150 * integration: { type: "MOCK" },
151 * });
152 * ```
153 *
154 * ### Lambda proxy integration
155 * For Lambda-backed APIs, the integration `uri` follows the
156 * `arn:aws:apigateway:<region>:lambda:path/2015-03-31/functions/<function-arn>/invocations`
157 * shape. Use `Output.map` to resolve the function ARN before building the
158 * URI, since the function's ARN is only known at deploy time.
159 *
160 * **Example:** ANY method with Lambda AWS_PROXY integration
161 * ```typescript
162 * import * as Output from "alchemy/Output";
163 *
164 * const invokeUri = Output.map(
165 * fn.functionArn,
166 * (arn) =>
167 * `arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${arn}/invocations`,
168 * );
169 *
170 * yield* ApiGateway.Method("RootAny", {
171 * restApi: api,
172 * httpMethod: "ANY",
173 * authorizationType: "NONE",
174 * integration: {
175 * type: "AWS_PROXY",
176 * integrationHttpMethod: "POST",
177 * uri: invokeUri,
178 * },
179 * });
180 * ```
181 *
182 * ### Methods on sub-paths
183 * Attach a method to a nested path by creating an `ApiGateway.Resource` and
184 * passing its `resourceId` explicitly. `restApi` is still required so the
185 * method binds for deployment ordering.
186 *
187 * **Example:** Method on `/items`
188 * ```typescript
189 * const items = yield* ApiGateway.Resource("Items", {
190 * restApi: api,
191 * parentId: api.rootResourceId,
192 * pathPart: "items",
193 * });
194 *
195 * yield* ApiGateway.Method("ListItems", {
196 * restApi: api,
197 * resourceId: items.resourceId,
198 * httpMethod: "GET",
199 * authorizationType: "NONE",
200 * integration: { type: "MOCK" },
201 * });
202 * ```
203 *
204 * @resource
205 */
206export const MethodResource = Resource<MethodType>("AWS.ApiGateway.Method");
207
208/**
209 * `Input`-accepting mirror of {@link MethodProps} used by the `Method`
210 * wrapper function — see {@link MethodProps} for per-field documentation.
211 */
212export interface MethodInputProps {
213 restApi?: RestApi;
214 restApiId?: Input<string>;
215 resourceId?: Input<string>;
216 httpMethod: Input<string>;
217 authorizationType?: Input<string>;
218 authorizerId?: Input<string>;
219 apiKeyRequired?: Input<boolean>;
220 operationName?: Input<string>;
221 requestParameters?: Input<MethodProps["requestParameters"]>;
222 requestModels?: Input<MethodProps["requestModels"]>;
223 requestValidatorId?: Input<string>;
224 authorizationScopes?: Input<string[]>;
225 integration?: Input<MethodIntegrationProps>;
226}
227
228const MethodImpl = (id: string, props: MethodInputProps) =>
229 Effect.gen(function* () {
230 const { restApi, ...rest } = props;
231 const restApiId = rest.restApiId ?? restApi?.restApiId;
232 const resourceId = rest.resourceId ?? restApi?.rootResourceId;
233 if (!restApiId || !resourceId) {
234 return yield* Effect.die(
235 "Method requires either `restApi` (preferred) or explicit " +
236 "`restApiId` and `resourceId`.",
237 );
238 }
239 const method = yield* MethodResource(id, {
240 ...rest,
241 restApiId,
242 resourceId,
243 } as any);
244 if (restApi) {
245 yield* restApi.bind`${method}`({
246 kind: "method",
247 methodId: method.LogicalId,
248 restApiId: method.restApiId,
249 resourceId: method.resourceId,
250 httpMethod: method.httpMethod,
251 });
252 }
253 return method;
254 });
255
256/**
257 * User-facing wrapper for the Method resource. Accepts `restApi: RestApi`
258 * as the idiomatic way to attach a method — this both forwards the API id
259 * and registers the method as a binding on the RestApi so the scheduler
260 * orders `Deployment` after it.
261 */
262export const Method = MethodImpl;
263
264const putIntegrationRequest = (
265 restApiId: string,
266 resourceId: string,
267 httpMethod: string,
268 integration: MethodIntegrationProps,
269): ag.PutIntegrationRequest => ({
270 restApiId,
271 resourceId,
272 httpMethod,
273 type: integration.type,
274 integrationHttpMethod: integration.integrationHttpMethod,
275 uri: integration.uri as string,
276 connectionType: integration.connectionType,
277 connectionId: integration.connectionId,
278 credentials: integration.credentials,
279 requestParameters: integration.requestParameters,
280 requestTemplates: integration.requestTemplates,
281 passthroughBehavior: integration.passthroughBehavior,
282 cacheNamespace: integration.cacheNamespace,
283 cacheKeyParameters: integration.cacheKeyParameters,
284 contentHandling: integration.contentHandling,
285 timeoutInMillis: toWireMillis(integration.timeout),
286 tlsConfig: integration.tlsConfig,
287 responseTransferMode: integration.responseTransferMode,
288 integrationTarget: integration.integrationTarget,
289});
290
291const putMethod = (news: Input.ResolveProps<MethodProps>) =>
292 ag.putMethod({
293 restApiId: news.restApiId as string,
294 resourceId: news.resourceId as string,
295 httpMethod: news.httpMethod,
296 authorizationType: news.authorizationType ?? "NONE",
297 authorizerId: news.authorizerId,
298 apiKeyRequired: news.apiKeyRequired,
299 operationName: news.operationName,
300 requestParameters: news.requestParameters,
301 requestModels: news.requestModels,
302 requestValidatorId: news.requestValidatorId,
303 authorizationScopes: news.authorizationScopes,
304 });
305
306const deleteIntegrationSafe = (p: {
307 restApiId: string;
308 resourceId: string;
309 httpMethod: string;
310}) =>
311 ag
312 .deleteIntegration({
313 restApiId: p.restApiId,
314 resourceId: p.resourceId,
315 httpMethod: p.httpMethod,
316 })
317 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
318
319const deleteMethodSafe = (p: {
320 restApiId: string;
321 resourceId: string;
322 httpMethod: string;
323}) =>
324 ag
325 .deleteMethod({
326 restApiId: p.restApiId,
327 resourceId: p.resourceId,
328 httpMethod: p.httpMethod,
329 })
330 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
331
332const readMethodSnapshot = (p: {
333 restApiId: string;
334 resourceId: string;
335 httpMethod: string;
336}) =>
337 Effect.gen(function* () {
338 const method = yield* ag
339 .getMethod({
340 restApiId: p.restApiId,
341 resourceId: p.resourceId,
342 httpMethod: p.httpMethod,
343 })
344 .pipe(
345 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
346 );
347 if (!method?.httpMethod) return undefined;
348
349 const integ = yield* ag
350 .getIntegration({
351 restApiId: p.restApiId,
352 resourceId: p.resourceId,
353 httpMethod: p.httpMethod,
354 })
355 .pipe(
356 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
357 );
358
359 const integration: MethodIntegrationProps | undefined = integ?.type
360 ? {
361 type: integ.type!,
362 integrationHttpMethod: integ.httpMethod,
363 uri: integ.uri,
364 connectionType: integ.connectionType,
365 connectionId: integ.connectionId,
366 credentials: integ.credentials,
367 requestParameters: integ.requestParameters,
368 requestTemplates: integ.requestTemplates,
369 passthroughBehavior: integ.passthroughBehavior,
370 cacheNamespace: integ.cacheNamespace,
371 cacheKeyParameters: integ.cacheKeyParameters,
372 contentHandling: integ.contentHandling,
373 // Wire milliseconds — a plain number is a valid `Duration.Input`,
374 // so the observed snapshot stays directly comparable to a desired
375 // spec normalized via `toWireMillis`.
376 timeout: integ.timeoutInMillis,
377 tlsConfig: integ.tlsConfig,
378 responseTransferMode: integ.responseTransferMode,
379 integrationTarget: integ.integrationTarget,
380 }
381 : undefined;
382
383 return {
384 restApiId: p.restApiId,
385 resourceId: p.resourceId,
386 httpMethod: p.httpMethod,
387 authorizationType: method.authorizationType ?? "NONE",
388 authorizerId: method.authorizerId,
389 apiKeyRequired: method.apiKeyRequired,
390 operationName: method.operationName,
391 requestParameters: method.requestParameters,
392 requestModels: method.requestModels,
393 requestValidatorId: method.requestValidatorId,
394 authorizationScopes: method.authorizationScopes,
395 integration,
396 };
397 });
398
399export const MethodProvider = () =>
400 Provider.effect(
401 MethodResource,
402 Effect.gen(function* () {
403 return {
404 stables: ["restApiId", "resourceId", "httpMethod"] as const,
405 diff: Effect.fn(function* ({ news: newsIn, olds }) {
406 if (!isResolved(newsIn)) return;
407 const news = newsIn as Input.ResolveProps<MethodProps>;
408 if (
409 news.restApiId !== olds.restApiId ||
410 news.resourceId !== olds.resourceId ||
411 news.httpMethod !== olds.httpMethod
412 ) {
413 return { action: "replace" } as const;
414 }
415 }),
416 read: Effect.fn(function* ({ output }) {
417 if (!output) return undefined;
418 return yield* readMethodSnapshot({
419 restApiId: output.restApiId,
420 resourceId: output.resourceId,
421 httpMethod: output.httpMethod,
422 });
423 }),
424 // Methods are sub-resources keyed by (restApiId, resourceId,
425 // httpMethod) with no dedicated list-methods API. Enumerate every
426 // RestApi -> each Resource (embedding its method verbs) -> read each
427 // method snapshot so every item matches the exact `read` Attributes
428 // shape (including integration), directly usable by `delete`.
429 list: () =>
430 Effect.gen(function* () {
431 const restApis = yield* ag.getRestApis.pages({}).pipe(
432 Stream.runCollect,
433 Effect.map((chunk) =>
434 Array.from(chunk).flatMap((page) =>
435 (page.items ?? []).filter(
436 (api): api is ag.RestApi & { id: string } => api.id != null,
437 ),
438 ),
439 ),
440 );
441
442 const perApi = yield* Effect.forEach(
443 restApis,
444 (api) =>
445 Effect.gen(function* () {
446 const resources = yield* ag.getResources
447 .pages({ restApiId: api.id, embed: ["methods"] })
448 .pipe(
449 Stream.runCollect,
450 Effect.map((chunk) =>
451 Array.from(chunk).flatMap((page) => page.items ?? []),
452 ),
453 );
454
455 const keys = resources.flatMap((resource) =>
456 resource.id
457 ? Object.keys(resource.resourceMethods ?? {}).map(
458 (httpMethod) => ({
459 resourceId: resource.id as string,
460 httpMethod,
461 }),
462 )
463 : [],
464 );
465
466 const snaps = yield* Effect.forEach(
467 keys,
468 (key) =>
469 readMethodSnapshot({
470 restApiId: api.id,
471 resourceId: key.resourceId,
472 httpMethod: key.httpMethod,
473 }),
474 { concurrency: 10 },
475 );
476
477 return snaps.filter(
478 (snap): snap is NonNullable<typeof snap> =>
479 snap !== undefined,
480 );
481 }),
482 { concurrency: 5 },
483 );
484
485 return perApi.flat();
486 }),
487 reconcile: Effect.fn(function* ({ news: newsIn, output, session }) {
488 if (!isResolved(newsIn)) {
489 return yield* Effect.die("Method props were not resolved");
490 }
491 const news = newsIn as Input.ResolveProps<MethodProps>;
492 const authType = news.authorizationType ?? "NONE";
493 // Methods are keyed by (restApiId, resourceId, httpMethod). All
494 // three are stable across the resource's lifetime, so we read
495 // them out of `output` when present and fall back to `news`.
496 const restApiId = (output?.restApiId ?? news.restApiId) as string;
497 const resourceId = (output?.resourceId ?? news.resourceId) as string;
498 const httpMethod = output?.httpMethod ?? news.httpMethod;
499
500 // Observe — read live method + integration as a single snapshot.
501 // `output.*` fields are never trusted as proxies for cloud state;
502 // they're at most a cache for the natural-key tuple above.
503 let observed = yield* readMethodSnapshot({
504 restApiId,
505 resourceId,
506 httpMethod,
507 });
508
509 // Ensure — putMethod creates the method shape (auth type,
510 // request models, scopes, etc.). Some of those fields cannot be
511 // patched in place by `updateMethod`, so on a "recreate-needed"
512 // diff we drop the existing method and re-put. This is also the
513 // path used on first reconcile when nothing exists.
514 const needsRecreate =
515 observed !== undefined &&
516 (news.operationName !== observed.operationName ||
517 !deepEqual(news.requestParameters, observed.requestParameters) ||
518 !deepEqual(news.requestModels, observed.requestModels) ||
519 news.requestValidatorId !== observed.requestValidatorId ||
520 !deepEqual(
521 news.authorizationScopes,
522 observed.authorizationScopes,
523 ));
524
525 if (observed === undefined || needsRecreate) {
526 if (needsRecreate) {
527 yield* deleteIntegrationSafe({
528 restApiId,
529 resourceId,
530 httpMethod,
531 });
532 yield* deleteMethodSafe({ restApiId, resourceId, httpMethod });
533 }
534 yield* putMethod({
535 ...news,
536 restApiId,
537 resourceId,
538 httpMethod,
539 });
540 if (news.integration) {
541 yield* ag.putIntegration(
542 putIntegrationRequest(
543 restApiId,
544 resourceId,
545 httpMethod,
546 news.integration,
547 ),
548 );
549 }
550 yield* session.note(
551 needsRecreate
552 ? `Recreated method ${httpMethod}`
553 : `Put method ${httpMethod} on resource ${resourceId}`,
554 );
555 // Re-read to get the canonical observed state for the rest of
556 // the reconciler. After a recreate everything matches `news`,
557 // so the patch step below becomes a no-op.
558 observed = yield* readMethodSnapshot({
559 restApiId,
560 resourceId,
561 httpMethod,
562 });
563 if (!observed) {
564 return yield* Effect.die("getMethod missing after put");
565 }
566 }
567
568 // Sync patchable scalar fields — observed ↔ desired.
569 const patches: ag.PatchOperation[] = [];
570 if (authType !== observed.authorizationType) {
571 patches.push({
572 op: "replace",
573 path: "/authorizationType",
574 value: authType,
575 });
576 }
577 if (news.authorizerId !== observed.authorizerId) {
578 patches.push({
579 op: "replace",
580 path: "/authorizerId",
581 value: news.authorizerId ?? "",
582 });
583 }
584 if (news.apiKeyRequired !== observed.apiKeyRequired) {
585 patches.push({
586 op: "replace",
587 path: "/apiKeyRequired",
588 value: String(news.apiKeyRequired ?? false),
589 });
590 }
591 if (patches.length > 0) {
592 yield* ag.updateMethod({
593 restApiId,
594 resourceId,
595 httpMethod,
596 patchOperations: patches,
597 });
598 }
599
600 // Sync integration — putIntegration is an upsert; deleteIntegration
601 // tolerates missing integrations. Normalize the desired timeout to
602 // wire milliseconds so it compares against the observed snapshot.
603 const desiredIntegration = news.integration && {
604 ...news.integration,
605 timeout: toWireMillis(news.integration.timeout),
606 };
607 if (!deepEqual(desiredIntegration, observed.integration)) {
608 if (news.integration) {
609 yield* ag.putIntegration(
610 putIntegrationRequest(
611 restApiId,
612 resourceId,
613 httpMethod,
614 news.integration,
615 ),
616 );
617 } else {
618 yield* deleteIntegrationSafe({
619 restApiId,
620 resourceId,
621 httpMethod,
622 });
623 }
624 }
625
626 yield* session.note(`Reconciled method ${httpMethod}`);
627 const snap = yield* readMethodSnapshot({
628 restApiId,
629 resourceId,
630 httpMethod,
631 });
632 if (!snap) {
633 return yield* Effect.die("getMethod missing after reconcile");
634 }
635 return snap;
636 }),
637 delete: Effect.fn(function* ({ output, session }) {
638 yield* deleteIntegrationSafe({
639 restApiId: output.restApiId,
640 resourceId: output.resourceId,
641 httpMethod: output.httpMethod,
642 });
643 yield* deleteMethodSafe({
644 restApiId: output.restApiId,
645 resourceId: output.resourceId,
646 httpMethod: output.httpMethod,
647 });
648 yield* session.note(`Deleted method ${output.httpMethod}`);
649 }),
650 };
651 }),
652 );