1import * as agw2 from "@distilled.cloud/aws/apigatewayv2";
2import * as Effect from "effect/Effect";
3import { deepEqual, isResolved } from "../../Diff.ts";
4import type { Input } from "../../Input.ts";
5import * as Output from "../../Output.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import type { Api } from "./Api.ts";
10import { collectAllPages, retryOnTooManyRequests } from "./common.ts";
11import type { IntegrationType } from "./Integration.ts";
12
13export interface RouteProps {
14 /**
15 * ID of the API this route belongs to. Usually derived from `api.apiId`
16 * by the {@link Route} wrapper.
17 */
18 apiId: string;
19 /**
20 * The route key:
21 *
22 * - HTTP APIs — `"GET /items"`, `"ANY /{proxy+}"`, or `"$default"`.
23 * - WebSocket APIs — `"$connect"`, `"$disconnect"`, `"$default"`, or a
24 * custom action name matched by the API's route selection expression.
25 */
26 routeKey: string;
27 /**
28 * The route target, usually `integrations/{integrationId}`. Prefer the
29 * `integration` prop on the {@link Route} wrapper which formats this for
30 * you.
31 */
32 target?: string;
33 /**
34 * The authorization type: `NONE`, `AWS_IAM`, `JWT` (HTTP APIs), or
35 * `CUSTOM` (Lambda authorizer).
36 * @default "NONE"
37 */
38 authorizationType?: agw2.AuthorizationType;
39 /**
40 * The `Authorizer` id when `authorizationType` is `JWT` or `CUSTOM`.
41 */
42 authorizerId?: string;
43 /**
44 * Authorization scopes for JWT authorization.
45 */
46 authorizationScopes?: string[];
47 /** Whether an API key is required (WebSocket APIs only). */
48 apiKeyRequired?: boolean;
49 /** Operation name for the route. */
50 operationName?: string;
51 /** The model selection expression (WebSocket APIs only). */
52 modelSelectionExpression?: string;
53 /** Request models (WebSocket APIs only). */
54 requestModels?: { [key: string]: string | undefined };
55 /** Request parameter constraints (WebSocket APIs only). */
56 requestParameters?: {
57 [key: string]: agw2.ParameterConstraints | undefined;
58 };
59 /**
60 * The route response selection expression (WebSocket APIs only). Set to
61 * `$default` to enable two-way (request/response) routes.
62 */
63 routeResponseSelectionExpression?: string;
64}
65
66export interface RouteType extends Resource<
67 "AWS.ApiGatewayV2.Route",
68 RouteProps,
69 {
70 /** The API this route belongs to. */
71 apiId: string;
72 /** The route identifier. */
73 routeId: string;
74 /** The route key. */
75 routeKey: string;
76 target: string | undefined;
77 authorizationType: agw2.AuthorizationType | undefined;
78 authorizerId: string | undefined;
79 authorizationScopes: string[] | undefined;
80 apiKeyRequired: boolean | undefined;
81 operationName: string | undefined;
82 modelSelectionExpression: string | undefined;
83 requestModels: { [key: string]: string | undefined } | undefined;
84 requestParameters:
85 | { [key: string]: agw2.ParameterConstraints | undefined }
86 | undefined;
87 routeResponseSelectionExpression: string | undefined;
88 },
89 never,
90 Providers
91> {}
92
93/**
94 * An API Gateway v2 Route — matches incoming requests (or WebSocket
95 * messages) and forwards them to an Integration.
96 * ### HTTP API routes
97 * **Example:** Catch-all $default route
98 * ```typescript
99 * yield* ApiGatewayV2.Route("Default", {
100 * api,
101 * routeKey: "$default",
102 * integration,
103 * });
104 * ```
105 *
106 * **Example:** Method + path route
107 * ```typescript
108 * yield* ApiGatewayV2.Route("ListItems", {
109 * api,
110 * routeKey: "GET /items",
111 * integration,
112 * });
113 * ```
114 *
115 * ### WebSocket routes
116 * **Example:** $connect route
117 * ```typescript
118 * yield* ApiGatewayV2.Route("Connect", {
119 * api,
120 * routeKey: "$connect",
121 * integration,
122 * });
123 * ```
124 *
125 * ### Securing routes
126 * **Example:** JWT-protected route
127 * ```typescript
128 * yield* ApiGatewayV2.Route("Secure", {
129 * api,
130 * routeKey: "GET /me",
131 * integration,
132 * authorizationType: "JWT",
133 * authorizerId: authorizer.authorizerId,
134 * });
135 * ```
136 *
137 * @resource
138 */
139export const RouteResource = Resource<RouteType>("AWS.ApiGatewayV2.Route");
140
141export interface RouteInputProps extends Omit<
142 {
143 [K in keyof RouteProps]?: Input<RouteProps[K]>;
144 },
145 "apiId" | "routeKey"
146> {
147 /**
148 * The `Api` this route belongs to (preferred). Alternatively pass a raw
149 * `apiId`.
150 */
151 api?: Api;
152 apiId?: Input<string>;
153 routeKey: Input<string>;
154 /**
155 * The `Integration` this route targets. Formats `target` as
156 * `integrations/{integrationId}` for you. Alternatively pass a raw
157 * `target`.
158 */
159 integration?: IntegrationType;
160}
161
162/**
163 * User-facing wrapper for the Route resource. Accepts `api: Api` and
164 * `integration: Integration` as the idiomatic way to wire a route.
165 */
166export const Route = (id: string, props: RouteInputProps) =>
167 Effect.gen(function* () {
168 const { api, integration, ...rest } = props;
169 const apiId = rest.apiId ?? api?.apiId;
170 if (!apiId) {
171 return yield* Effect.die(
172 "Route requires either `api` (preferred) or an explicit `apiId`.",
173 );
174 }
175 const target =
176 rest.target ??
177 (integration
178 ? Output.map(integration.integrationId, (id) => `integrations/${id}`)
179 : undefined);
180 return yield* RouteResource(id, { ...rest, apiId, target } as any);
181 });
182
183const snapshotFromRoute = (
184 apiId: string,
185 route: agw2.GetRouteResult,
186): RouteType["Attributes"] => ({
187 apiId,
188 routeId: route.RouteId!,
189 routeKey: route.RouteKey ?? "",
190 target: route.Target,
191 authorizationType: route.AuthorizationType,
192 authorizerId: route.AuthorizerId,
193 authorizationScopes: route.AuthorizationScopes,
194 apiKeyRequired: route.ApiKeyRequired,
195 operationName: route.OperationName,
196 modelSelectionExpression: route.ModelSelectionExpression,
197 requestModels: route.RequestModels,
198 requestParameters: route.RequestParameters,
199 routeResponseSelectionExpression: route.RouteResponseSelectionExpression,
200});
201
202const desiredRequest = (news: RouteProps) => ({
203 RouteKey: news.routeKey,
204 Target: news.target,
205 AuthorizationType: news.authorizationType,
206 AuthorizerId: news.authorizerId,
207 AuthorizationScopes: news.authorizationScopes,
208 ApiKeyRequired: news.apiKeyRequired,
209 OperationName: news.operationName,
210 ModelSelectionExpression: news.modelSelectionExpression,
211 RequestModels: news.requestModels,
212 RequestParameters: news.requestParameters,
213 RouteResponseSelectionExpression: news.routeResponseSelectionExpression,
214});
215
216export const RouteProvider = () =>
217 Provider.effect(
218 RouteResource,
219 Effect.gen(function* () {
220 const getRouteSafe = (apiId: string, routeId: string) =>
221 agw2
222 .getRoute({ ApiId: apiId, RouteId: routeId })
223 .pipe(
224 Effect.catchTag("NotFoundException", () =>
225 Effect.succeed(undefined),
226 ),
227 );
228
229 const findRouteByKey = (apiId: string, routeKey: string) =>
230 collectAllPages((NextToken) =>
231 agw2.getRoutes({ ApiId: apiId, NextToken }),
232 ).pipe(
233 Effect.map((routes) =>
234 routes.find((route) => route.RouteKey === routeKey),
235 ),
236 );
237
238 return RouteResource.Provider.of({
239 stables: ["apiId", "routeId"],
240
241 list: () =>
242 Effect.gen(function* () {
243 const apis = yield* collectAllPages((NextToken) =>
244 agw2.getApis({ NextToken }),
245 );
246 const perApi = yield* Effect.forEach(
247 apis.filter((api) => api.ApiId != null),
248 (api) =>
249 collectAllPages((NextToken) =>
250 agw2.getRoutes({ ApiId: api.ApiId!, NextToken }),
251 ).pipe(
252 Effect.map((routes) =>
253 routes
254 .filter((route) => route.RouteId != null)
255 .map((route) => snapshotFromRoute(api.ApiId!, route)),
256 ),
257 Effect.catchTag("NotFoundException", () =>
258 Effect.succeed([] as RouteType["Attributes"][]),
259 ),
260 ),
261 { concurrency: 5 },
262 );
263 return perApi.flat();
264 }),
265
266 read: Effect.fn(function* ({ output }) {
267 if (!output?.apiId || !output.routeId) return undefined;
268 const route = yield* getRouteSafe(output.apiId, output.routeId);
269 if (!route?.RouteId) return undefined;
270 return snapshotFromRoute(output.apiId, route);
271 }),
272
273 diff: Effect.fn(function* ({ news, olds }) {
274 if (!isResolved(news)) return undefined;
275 if (news.apiId !== olds.apiId) {
276 return { action: "replace" } as const;
277 }
278 }),
279
280 reconcile: Effect.fn(function* ({ news, output, session }) {
281 const apiId = output?.apiId ?? news.apiId;
282
283 // 1. OBSERVE — by cached route id, falling back to a lookup by
284 // route key (covers a crash after create but before persist).
285 let observed = output?.routeId
286 ? yield* getRouteSafe(apiId, output.routeId)
287 : yield* findRouteByKey(apiId, news.routeKey);
288
289 // 2. ENSURE — create if missing; a ConflictException means the
290 // route key was created concurrently, so adopt it by key.
291 if (!observed?.RouteId) {
292 observed = yield* retryOnTooManyRequests(
293 agw2.createRoute({ ApiId: apiId, ...desiredRequest(news) }),
294 ).pipe(
295 Effect.catchTag("BadRequestException", (error) =>
296 // AWS reports a duplicate route key as a BadRequestException
297 // ("Route with key ... already exists") — a race with a
298 // concurrent reconciler. Adopt the existing route.
299 findRouteByKey(apiId, news.routeKey).pipe(
300 Effect.flatMap((existing) =>
301 existing ? Effect.succeed(existing) : Effect.fail(error),
302 ),
303 ),
304 ),
305 );
306 yield* session.note(
307 `Created route ${news.routeKey} (${observed.RouteId})`,
308 );
309 }
310 const routeId = observed.RouteId!;
311 const snapshot = snapshotFromRoute(apiId, observed);
312
313 // 3. SYNC — update in place on drift (route key is mutable).
314 const desired = desiredRequest(news);
315 const drift =
316 snapshot.routeKey !== desired.RouteKey ||
317 snapshot.target !== desired.Target ||
318 (snapshot.authorizationType ?? "NONE") !==
319 (desired.AuthorizationType ?? "NONE") ||
320 snapshot.authorizerId !== desired.AuthorizerId ||
321 (desired.AuthorizationScopes !== undefined &&
322 !deepEqual(
323 snapshot.authorizationScopes,
324 desired.AuthorizationScopes,
325 )) ||
326 (desired.ApiKeyRequired !== undefined &&
327 snapshot.apiKeyRequired !== desired.ApiKeyRequired) ||
328 snapshot.operationName !== desired.OperationName ||
329 snapshot.modelSelectionExpression !==
330 desired.ModelSelectionExpression ||
331 (desired.RequestModels !== undefined &&
332 !deepEqual(snapshot.requestModels, desired.RequestModels)) ||
333 (desired.RequestParameters !== undefined &&
334 !deepEqual(
335 snapshot.requestParameters,
336 desired.RequestParameters,
337 )) ||
338 snapshot.routeResponseSelectionExpression !==
339 desired.RouteResponseSelectionExpression;
340 if (drift) {
341 const updated = yield* retryOnTooManyRequests(
342 agw2.updateRoute({ ApiId: apiId, RouteId: routeId, ...desired }),
343 );
344 yield* session.note(`Updated route ${news.routeKey}`);
345 return snapshotFromRoute(apiId, updated);
346 }
347
348 return snapshot;
349 }),
350
351 delete: Effect.fn(function* ({ output, session }) {
352 yield* retryOnTooManyRequests(
353 agw2
354 .deleteRoute({ ApiId: output.apiId, RouteId: output.routeId })
355 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
356 );
357 yield* session.note(`Deleted route ${output.routeKey}`);
358 }),
359 });
360 }),
361 );