1import * as agw2 from "@distilled.cloud/aws/apigatewayv2";
2import * as Effect from "effect/Effect";
3import { deepEqual, isResolved } from "../../Diff.ts";
4import { createPhysicalName } from "../../PhysicalName.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { createInternalTags } from "../../Tags.ts";
8import { AWSEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 apiArn,
12 collectAllPages,
13 retryOnTooManyRequests,
14 syncTags,
15 tagRecord,
16} from "./common.ts";
17
18export interface ApiProps {
19 /**
20 * Name of the API. If omitted, Alchemy generates a deterministic
21 * physical name from the app, stage, and logical ID.
22 */
23 name?: string;
24 /**
25 * The API protocol. `HTTP` is the modern Lambda front door; `WEBSOCKET`
26 * is the real-time two-way protocol. Changing this triggers a
27 * replacement.
28 * @default "HTTP"
29 */
30 protocolType?: "HTTP" | "WEBSOCKET";
31 /**
32 * Description of the API.
33 */
34 description?: string;
35 /**
36 * The route selection expression. Required by AWS for WebSocket APIs;
37 * defaults to `$request.body.action` when `protocolType` is `WEBSOCKET`.
38 * HTTP APIs must use `$request.method $request.path` (the AWS default).
39 */
40 routeSelectionExpression?: string;
41 /**
42 * An API key selection expression (WebSocket APIs only).
43 */
44 apiKeySelectionExpression?: string;
45 /**
46 * CORS configuration (HTTP APIs only). Synced in place; removing it
47 * deletes the CORS configuration from the API.
48 */
49 corsConfiguration?: agw2.Cors;
50 /**
51 * Disable the default `execute-api` endpoint so the API is reachable
52 * only through custom domain names.
53 * @default false
54 */
55 disableExecuteApiEndpoint?: boolean;
56 /**
57 * Avoid validating models when creating a deployment (WebSocket APIs
58 * only).
59 */
60 disableSchemaValidation?: boolean;
61 /**
62 * The IP address types that can invoke the API.
63 * @default "ipv4"
64 */
65 ipAddressType?: agw2.IpAddressType;
66 /**
67 * A version identifier for the API.
68 */
69 version?: string;
70 /**
71 * User-defined tags (Alchemy internal tags are merged automatically).
72 */
73 tags?: Record<string, string>;
74}
75
76export interface Api extends Resource<
77 "AWS.ApiGatewayV2.Api",
78 ApiProps,
79 {
80 /** The API identifier. */
81 apiId: string;
82 /** The default endpoint, e.g. `https://{apiId}.execute-api.{region}.amazonaws.com` (or `wss://...` for WebSocket APIs). */
83 apiEndpoint: string;
84 /** The API name. */
85 name: string;
86 /** The API protocol. */
87 protocolType: string;
88 /** The route selection expression. */
89 routeSelectionExpression: string | undefined;
90 description: string | undefined;
91 apiKeySelectionExpression: string | undefined;
92 corsConfiguration: agw2.Cors | undefined;
93 disableExecuteApiEndpoint: boolean | undefined;
94 disableSchemaValidation: boolean | undefined;
95 ipAddressType: string | undefined;
96 version: string | undefined;
97 tags: Record<string, string>;
98 },
99 never,
100 Providers
101> {}
102
103/**
104 * An Amazon API Gateway v2 API — the root of an HTTP API or WebSocket API.
105 *
106 * HTTP APIs are the modern, cheaper, faster front door for Lambda functions
107 * (compared to REST v1). WebSocket APIs provide two-way real-time messaging
108 * backed by Lambda route handlers. Child resources (`Integration`, `Route`,
109 * `Stage`, `Authorizer`) reference the API by passing `api` in their props.
110 * ### HTTP APIs
111 * For the common "HTTP API in front of a Lambda function" case, prefer the
112 * high-level {@link HttpApi} helper which wires up the integration, route,
113 * stage, and invoke permission in one call.
114 *
115 * **Example:** Minimal HTTP API
116 * ```typescript
117 * import * as ApiGatewayV2 from "alchemy/AWS/ApiGatewayV2";
118 *
119 * const api = yield* ApiGatewayV2.Api("Api", {});
120 * ```
121 *
122 * **Example:** HTTP API with CORS
123 * ```typescript
124 * const api = yield* ApiGatewayV2.Api("Api", {
125 * corsConfiguration: {
126 * AllowOrigins: ["https://example.com"],
127 * AllowMethods: ["GET", "POST"],
128 * AllowHeaders: ["content-type"],
129 * MaxAge: 3600,
130 * },
131 * });
132 * ```
133 *
134 * ### WebSocket APIs
135 * **Example:** WebSocket API
136 * ```typescript
137 * const api = yield* ApiGatewayV2.Api("WsApi", {
138 * protocolType: "WEBSOCKET",
139 * routeSelectionExpression: "$request.body.action",
140 * });
141 * ```
142 *
143 * ### Endpoint hardening
144 * **Example:** Disable the default execute-api endpoint
145 * ```typescript
146 * const api = yield* ApiGatewayV2.Api("Api", {
147 * disableExecuteApiEndpoint: true,
148 * });
149 * ```
150 *
151 * @resource
152 */
153export const Api = Resource<Api>("AWS.ApiGatewayV2.Api");
154
155const generatedName = (id: string, props: ApiProps) =>
156 props.name
157 ? Effect.succeed(props.name)
158 : createPhysicalName({ id, maxLength: 128 });
159
160const defaultRouteSelectionExpression = (props: ApiProps) =>
161 props.routeSelectionExpression ??
162 (props.protocolType === "WEBSOCKET" ? "$request.body.action" : undefined);
163
164const snapshotFromApi = (api: agw2.GetApiResponse): Api["Attributes"] => ({
165 apiId: api.ApiId!,
166 apiEndpoint: api.ApiEndpoint ?? "",
167 name: api.Name ?? "",
168 protocolType: api.ProtocolType ?? "HTTP",
169 routeSelectionExpression: api.RouteSelectionExpression,
170 description: api.Description,
171 apiKeySelectionExpression: api.ApiKeySelectionExpression,
172 corsConfiguration: api.CorsConfiguration,
173 disableExecuteApiEndpoint: api.DisableExecuteApiEndpoint,
174 disableSchemaValidation: api.DisableSchemaValidation,
175 ipAddressType: api.IpAddressType,
176 version: api.Version,
177 tags: tagRecord(api.Tags),
178});
179
180export const ApiProvider = () =>
181 Provider.effect(
182 Api,
183 Effect.gen(function* () {
184 const getApiSafe = (apiId: string) =>
185 agw2
186 .getApi({ ApiId: apiId })
187 .pipe(
188 Effect.catchTag("NotFoundException", () =>
189 Effect.succeed(undefined),
190 ),
191 );
192
193 return Api.Provider.of({
194 stables: ["apiId", "apiEndpoint"],
195
196 list: () =>
197 Effect.gen(function* () {
198 const items = yield* collectAllPages((NextToken) =>
199 agw2.getApis({ NextToken }),
200 );
201 return items
202 .filter((api) => api.ApiId != null)
203 .map((api) => snapshotFromApi(api));
204 }),
205
206 read: Effect.fn(function* ({ output }) {
207 if (!output?.apiId) return undefined;
208 const api = yield* getApiSafe(output.apiId);
209 if (!api?.ApiId) return undefined;
210 return snapshotFromApi(api);
211 }),
212
213 diff: Effect.fn(function* ({ news, olds }) {
214 if (!isResolved(news)) return undefined;
215 const oldProtocol = olds.protocolType ?? "HTTP";
216 const newProtocol = news.protocolType ?? "HTTP";
217 if (oldProtocol !== newProtocol) {
218 return { action: "replace" } as const;
219 }
220 }),
221
222 reconcile: Effect.fn(function* ({ id, news, output, session }) {
223 const { region } = yield* AWSEnvironment.current;
224 const name = yield* generatedName(id, news);
225 const internalTags = yield* createInternalTags(id);
226 const desiredTags = { ...news.tags, ...internalTags };
227 const routeSelectionExpression =
228 defaultRouteSelectionExpression(news);
229
230 // 1. OBSERVE — output is only a cache for the stable id; the
231 // cloud is authoritative.
232 let observed = output?.apiId
233 ? yield* getApiSafe(output.apiId)
234 : undefined;
235
236 // 2. ENSURE — create if missing (greenfield or deleted
237 // out-of-band).
238 if (!observed?.ApiId) {
239 observed = yield* retryOnTooManyRequests(
240 agw2.createApi({
241 Name: name,
242 ProtocolType: news.protocolType ?? "HTTP",
243 Description: news.description,
244 RouteSelectionExpression: routeSelectionExpression,
245 ApiKeySelectionExpression: news.apiKeySelectionExpression,
246 CorsConfiguration: news.corsConfiguration,
247 DisableExecuteApiEndpoint: news.disableExecuteApiEndpoint,
248 DisableSchemaValidation: news.disableSchemaValidation,
249 IpAddressType: news.ipAddressType,
250 Version: news.version,
251 Tags: desiredTags,
252 }),
253 );
254 yield* session.note(`Created API ${observed.ApiId}`);
255 }
256 const apiId = observed.ApiId!;
257 const snapshot = snapshotFromApi(observed);
258
259 // 3. SYNC — diff observed against desired per mutable aspect and
260 // apply only the delta.
261 const drift =
262 snapshot.name !== name ||
263 snapshot.description !== news.description ||
264 (routeSelectionExpression !== undefined &&
265 snapshot.routeSelectionExpression !== routeSelectionExpression) ||
266 snapshot.apiKeySelectionExpression !==
267 news.apiKeySelectionExpression ||
268 (news.corsConfiguration !== undefined &&
269 !deepEqual(snapshot.corsConfiguration, news.corsConfiguration)) ||
270 (snapshot.disableExecuteApiEndpoint ?? false) !==
271 (news.disableExecuteApiEndpoint ?? false) ||
272 (news.disableSchemaValidation !== undefined &&
273 snapshot.disableSchemaValidation !==
274 news.disableSchemaValidation) ||
275 (news.ipAddressType !== undefined &&
276 snapshot.ipAddressType !== news.ipAddressType) ||
277 snapshot.version !== news.version;
278 if (drift) {
279 yield* retryOnTooManyRequests(
280 agw2.updateApi({
281 ApiId: apiId,
282 Name: name,
283 Description: news.description,
284 RouteSelectionExpression: routeSelectionExpression,
285 ApiKeySelectionExpression: news.apiKeySelectionExpression,
286 CorsConfiguration: news.corsConfiguration,
287 DisableExecuteApiEndpoint: news.disableExecuteApiEndpoint,
288 DisableSchemaValidation: news.disableSchemaValidation,
289 IpAddressType: news.ipAddressType,
290 Version: news.version,
291 }),
292 );
293 }
294
295 // CORS removal is a separate API call — `updateApi` cannot clear
296 // an existing configuration.
297 if (
298 news.corsConfiguration === undefined &&
299 snapshot.corsConfiguration !== undefined
300 ) {
301 yield* agw2
302 .deleteCorsConfiguration({ ApiId: apiId })
303 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
304 }
305
306 // 3b. SYNC TAGS — diff against OBSERVED cloud tags.
307 if (!deepEqual(snapshot.tags, desiredTags)) {
308 yield* syncTags({
309 resourceArn: apiArn(region, apiId),
310 oldTags: snapshot.tags,
311 newTags: desiredTags,
312 });
313 }
314
315 // 4. RETURN — re-read so attributes reflect actual cloud state.
316 const final = yield* agw2.getApi({ ApiId: apiId });
317 yield* session.note(`Reconciled API ${apiId}`);
318 return snapshotFromApi(final);
319 }),
320
321 delete: Effect.fn(function* ({ output, session }) {
322 yield* retryOnTooManyRequests(
323 agw2
324 .deleteApi({ ApiId: output.apiId })
325 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
326 );
327 yield* session.note(`Deleted API ${output.apiId}`);
328 }),
329 });
330 }),
331 );