1import * as agw2 from "@distilled.cloud/aws/apigatewayv2";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../../Diff.ts";
4import type { Input } from "../../Input.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8import type { Api } from "./Api.ts";
9import { collectAllPages, retryOnTooManyRequests } from "./common.ts";
10
11export interface ApiMappingProps {
12 /**
13 * ID of the API being mapped. Usually derived from `api.apiId` by the
14 * {@link ApiMapping} wrapper.
15 */
16 apiId: string;
17 /**
18 * The custom domain name to map the API onto. Changing this triggers a
19 * replacement.
20 */
21 domainName: string;
22 /**
23 * The stage to serve at the mapping, e.g. `$default`.
24 */
25 stage: string;
26 /**
27 * The base path under the domain, e.g. `v1` serves the API at
28 * `https://{domainName}/v1`. Omit to serve at the domain root.
29 */
30 apiMappingKey?: string;
31}
32
33export interface ApiMapping extends Resource<
34 "AWS.ApiGatewayV2.ApiMapping",
35 ApiMappingProps,
36 {
37 /** The mapping identifier. */
38 apiMappingId: string;
39 /** The mapped API. */
40 apiId: string;
41 /** The custom domain name. */
42 domainName: string;
43 /** The mapped stage. */
44 stage: string;
45 /** The base path key. */
46 apiMappingKey: string | undefined;
47 },
48 never,
49 Providers
50> {}
51
52/**
53 * An API Gateway v2 API mapping — serves an API stage under a custom
54 * {@link DomainName}, optionally at a base path.
55 * ### Mapping APIs onto a domain
56 * **Example:** Map an API at the domain root
57 * ```typescript
58 * yield* ApiGatewayV2.ApiMapping("Root", {
59 * api,
60 * domainName: domain.domainName,
61 * stage: stage.stageName,
62 * });
63 * ```
64 *
65 * **Example:** Map a second API under /v2
66 * ```typescript
67 * yield* ApiGatewayV2.ApiMapping("V2", {
68 * api: apiV2,
69 * domainName: domain.domainName,
70 * stage: "$default",
71 * apiMappingKey: "v2",
72 * });
73 * ```
74 *
75 * @resource
76 */
77export const ApiMappingResource = Resource<ApiMapping>(
78 "AWS.ApiGatewayV2.ApiMapping",
79);
80
81export interface ApiMappingInputProps extends Omit<
82 {
83 [K in keyof ApiMappingProps]?: Input<ApiMappingProps[K]>;
84 },
85 "apiId" | "domainName" | "stage"
86> {
87 /**
88 * The `Api` being mapped (preferred). Alternatively pass a raw `apiId`.
89 */
90 api?: Api;
91 apiId?: Input<string>;
92 domainName: Input<string>;
93 stage: Input<string>;
94}
95
96/**
97 * User-facing wrapper for the ApiMapping resource. Accepts `api: Api` as
98 * the idiomatic way to map an API onto a domain.
99 */
100export const ApiMapping = (id: string, props: ApiMappingInputProps) =>
101 Effect.gen(function* () {
102 const { api, ...rest } = props;
103 const apiId = rest.apiId ?? api?.apiId;
104 if (!apiId) {
105 return yield* Effect.die(
106 "ApiMapping requires either `api` (preferred) or an explicit `apiId`.",
107 );
108 }
109 return yield* ApiMappingResource(id, { ...rest, apiId } as any);
110 });
111
112const snapshotFromMapping = (
113 domainName: string,
114 mapping: agw2.GetApiMappingResponse,
115): ApiMapping["Attributes"] => ({
116 apiMappingId: mapping.ApiMappingId!,
117 apiId: mapping.ApiId ?? "",
118 domainName,
119 stage: mapping.Stage ?? "",
120 apiMappingKey:
121 mapping.ApiMappingKey === "" ? undefined : mapping.ApiMappingKey,
122});
123
124export const ApiMappingProvider = () =>
125 Provider.effect(
126 ApiMappingResource,
127 Effect.gen(function* () {
128 const getMappingSafe = (domainName: string, apiMappingId: string) =>
129 agw2
130 .getApiMapping({ DomainName: domainName, ApiMappingId: apiMappingId })
131 .pipe(
132 Effect.catchTag("NotFoundException", () =>
133 Effect.succeed(undefined),
134 ),
135 );
136
137 return ApiMappingResource.Provider.of({
138 stables: ["apiMappingId", "domainName"],
139
140 list: () =>
141 Effect.gen(function* () {
142 const domains = yield* collectAllPages((NextToken) =>
143 agw2.getDomainNames({ NextToken }),
144 );
145 const perDomain = yield* Effect.forEach(
146 domains.filter((domain) => domain.DomainName != null),
147 (domain) =>
148 collectAllPages((NextToken) =>
149 agw2.getApiMappings({
150 DomainName: domain.DomainName!,
151 NextToken,
152 }),
153 ).pipe(
154 Effect.map((items) =>
155 items
156 .filter((mapping) => mapping.ApiMappingId != null)
157 .map((mapping) =>
158 snapshotFromMapping(domain.DomainName!, mapping),
159 ),
160 ),
161 Effect.catchTag("NotFoundException", () =>
162 Effect.succeed([] as ApiMapping["Attributes"][]),
163 ),
164 ),
165 { concurrency: 5 },
166 );
167 return perDomain.flat();
168 }),
169
170 read: Effect.fn(function* ({ output }) {
171 if (!output?.domainName || !output.apiMappingId) return undefined;
172 const mapping = yield* getMappingSafe(
173 output.domainName,
174 output.apiMappingId,
175 );
176 if (!mapping?.ApiMappingId) return undefined;
177 return snapshotFromMapping(output.domainName, mapping);
178 }),
179
180 diff: Effect.fn(function* ({ news, olds }) {
181 if (!isResolved(news)) return undefined;
182 if (news.domainName !== olds.domainName) {
183 return { action: "replace" } as const;
184 }
185 }),
186
187 reconcile: Effect.fn(function* ({ news, output, session }) {
188 const domainName = output?.domainName ?? news.domainName;
189
190 // 1. OBSERVE
191 let observed = output?.apiMappingId
192 ? yield* getMappingSafe(domainName, output.apiMappingId)
193 : undefined;
194
195 // 2. ENSURE
196 if (!observed?.ApiMappingId) {
197 observed = yield* retryOnTooManyRequests(
198 agw2.createApiMapping({
199 DomainName: domainName,
200 ApiId: news.apiId,
201 Stage: news.stage,
202 ApiMappingKey: news.apiMappingKey,
203 }),
204 );
205 yield* session.note(`Created API mapping ${observed.ApiMappingId}`);
206 return snapshotFromMapping(domainName, observed);
207 }
208
209 // 3. SYNC
210 const snapshot = snapshotFromMapping(domainName, observed);
211 const drift =
212 snapshot.apiId !== news.apiId ||
213 snapshot.stage !== news.stage ||
214 snapshot.apiMappingKey !== news.apiMappingKey;
215 if (drift) {
216 yield* retryOnTooManyRequests(
217 agw2.updateApiMapping({
218 DomainName: domainName,
219 ApiMappingId: snapshot.apiMappingId,
220 ApiId: news.apiId,
221 Stage: news.stage,
222 ApiMappingKey: news.apiMappingKey,
223 }),
224 );
225 yield* session.note(`Updated API mapping ${snapshot.apiMappingId}`);
226 const final = yield* agw2.getApiMapping({
227 DomainName: domainName,
228 ApiMappingId: snapshot.apiMappingId,
229 });
230 return snapshotFromMapping(domainName, final);
231 }
232
233 return snapshot;
234 }),
235
236 delete: Effect.fn(function* ({ output, session }) {
237 yield* retryOnTooManyRequests(
238 agw2
239 .deleteApiMapping({
240 DomainName: output.domainName,
241 ApiMappingId: output.apiMappingId,
242 })
243 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
244 );
245 yield* session.note(`Deleted API mapping ${output.apiMappingId}`);
246 }),
247 });
248 }),
249 );