1import * as ag from "@distilled.cloud/aws/api-gateway";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import type { Input } from "../../Input.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9
10const normalizeBasePath = (basePath: string | undefined) =>
11 basePath === undefined || basePath === "" ? "(none)" : basePath;
12
13export interface BasePathMappingProps {
14 /** The custom domain name to map (e.g. `api.example.com`). */
15 domainName: string;
16 /** Domain name ID, required for private custom domain names. */
17 domainNameId?: string;
18 /**
19 * Base path segment; omit or empty string for root mapping (`(none)` in API Gateway).
20 */
21 basePath?: string;
22 /** ID of the REST API the path maps to. */
23 restApiId: Input<string>;
24 /** Name of the API stage requests are routed to. */
25 stage?: string;
26}
27
28/** @resource */
29export interface BasePathMapping extends Resource<
30 "AWS.ApiGateway.BasePathMapping",
31 BasePathMappingProps,
32 {
33 domainName: string;
34 domainNameId: string | undefined;
35 basePath: string;
36 restApiId: string;
37 stage: string | undefined;
38 },
39 never,
40 Providers
41> {}
42
43/**
44 * Maps a custom domain name path to a REST API stage.
45 *
46 * ### Custom domain
47 * **Example:** Root mapping
48 * ```typescript
49 * yield* ApiGateway.BasePathMapping("Root", {
50 * domainName: domain.domainName,
51 * restApiId: api.restApiId,
52 * stage: stage.stageName,
53 * });
54 * ```
55 */
56const BasePathMappingResource = Resource<BasePathMapping>(
57 "AWS.ApiGateway.BasePathMapping",
58);
59
60export { BasePathMappingResource as BasePathMapping };
61
62export const BasePathMappingProvider = () =>
63 Provider.effect(
64 BasePathMappingResource,
65 Effect.gen(function* () {
66 return {
67 stables: ["domainName", "domainNameId", "basePath"] as const,
68 diff: Effect.fn(function* ({ news: newsIn, olds }) {
69 if (!isResolved(newsIn)) return;
70 const news = newsIn as Input.ResolveProps<BasePathMappingProps>;
71 if (
72 news.domainName !== olds.domainName ||
73 normalizeBasePath(news.basePath) !==
74 normalizeBasePath(olds.basePath)
75 ) {
76 return { action: "replace" } as const;
77 }
78 }),
79 read: Effect.fn(function* ({ output }) {
80 if (!output) return undefined;
81 const b = yield* ag
82 .getBasePathMapping({
83 domainName: output.domainName,
84 basePath: output.basePath,
85 domainNameId: output.domainNameId,
86 })
87 .pipe(
88 Effect.catchTag("NotFoundException", () =>
89 Effect.succeed(undefined),
90 ),
91 );
92 if (!b?.restApiId) return undefined;
93 return {
94 domainName: output.domainName,
95 basePath: output.basePath,
96 domainNameId: output.domainNameId,
97 restApiId: b.restApiId,
98 stage: b.stage,
99 };
100 }),
101 reconcile: Effect.fn(function* ({ news: newsIn, output, session }) {
102 if (!isResolved(newsIn)) {
103 return yield* Effect.die("BasePathMapping props were not resolved");
104 }
105 const news = newsIn as Input.ResolveProps<BasePathMappingProps>;
106 const domainName = output?.domainName ?? news.domainName;
107 const domainNameId = output?.domainNameId ?? news.domainNameId;
108 const basePath = output?.basePath ?? normalizeBasePath(news.basePath);
109
110 // Observe — basePathmappings are keyed by (domainName, basePath).
111 // We never trust `output.restApiId`/`output.stage` for diffing; we
112 // re-read the live mapping every time.
113 let observed = yield* ag
114 .getBasePathMapping({
115 domainName,
116 basePath,
117 domainNameId,
118 })
119 .pipe(
120 Effect.catchTag("NotFoundException", () =>
121 Effect.succeed(undefined),
122 ),
123 );
124
125 // Ensure — create the mapping if it isn't there.
126 if (!observed?.restApiId) {
127 yield* ag.createBasePathMapping({
128 domainName: news.domainName,
129 domainNameId: news.domainNameId,
130 basePath: basePath === "(none)" ? undefined : news.basePath,
131 restApiId: news.restApiId as string,
132 stage: news.stage,
133 });
134 yield* session.note(
135 `Created base path mapping ${news.domainName} / ${basePath}`,
136 );
137 observed = yield* ag.getBasePathMapping({
138 domainName,
139 basePath,
140 domainNameId,
141 });
142 }
143
144 // Sync mutable fields — diff observed cloud state against desired.
145 const desiredRestApiId = news.restApiId as string;
146 const patches: ag.PatchOperation[] = [];
147 if (desiredRestApiId !== observed.restApiId) {
148 patches.push({
149 op: "replace" as const,
150 path: "/restApiId",
151 value: desiredRestApiId,
152 });
153 }
154 if ((news.stage ?? "") !== (observed.stage ?? "")) {
155 patches.push({
156 op: "replace" as const,
157 path: "/stage",
158 value: news.stage ?? "",
159 });
160 }
161 if (patches.length > 0) {
162 yield* ag.updateBasePathMapping({
163 domainName,
164 basePath,
165 domainNameId,
166 patchOperations: patches,
167 });
168 }
169
170 yield* session.note(`Reconciled base path mapping`);
171 const final = yield* ag.getBasePathMapping({
172 domainName,
173 basePath,
174 domainNameId,
175 });
176 return {
177 domainName,
178 domainNameId,
179 basePath,
180 restApiId: final.restApiId!,
181 stage: final.stage,
182 };
183 }),
184 // BasePathMappings are sub-resources keyed by their parent domain
185 // name. There is no account-wide list, so enumerate every custom
186 // domain name first (getDomainNames) and then list the mappings under
187 // each (getBasePathMappings), flattening to the full Attributes shape.
188 list: () =>
189 Effect.gen(function* () {
190 const domains = yield* ag.getDomainNames.pages({}).pipe(
191 Stream.runCollect,
192 Effect.map((chunk) =>
193 Array.from(chunk).flatMap((page) =>
194 (page.items ?? []).filter(
195 (d): d is ag.DomainName & { domainName: string } =>
196 d.domainName != null,
197 ),
198 ),
199 ),
200 );
201 const rows = yield* Effect.forEach(
202 domains,
203 (domain) =>
204 ag.getBasePathMappings
205 .pages({
206 domainName: domain.domainName,
207 domainNameId: domain.domainNameId,
208 })
209 .pipe(
210 Stream.runCollect,
211 Effect.map((chunk) =>
212 Array.from(chunk).flatMap((page) =>
213 (page.items ?? [])
214 .filter(
215 (
216 m,
217 ): m is ag.BasePathMapping & {
218 restApiId: string;
219 } => m.restApiId != null,
220 )
221 .map((m) => ({
222 domainName: domain.domainName,
223 domainNameId: domain.domainNameId,
224 basePath: normalizeBasePath(m.basePath),
225 restApiId: m.restApiId,
226 stage: m.stage,
227 })),
228 ),
229 ),
230 // A domain can disappear between enumeration and listing.
231 Effect.catchTag("NotFoundException", () =>
232 Effect.succeed(
233 [] as {
234 domainName: string;
235 domainNameId: string | undefined;
236 basePath: string;
237 restApiId: string;
238 stage: string | undefined;
239 }[],
240 ),
241 ),
242 ),
243 { concurrency: 10 },
244 );
245 return rows.flat();
246 }),
247 delete: Effect.fn(function* ({ output, session }) {
248 yield* ag
249 .deleteBasePathMapping({
250 domainName: output.domainName,
251 basePath: output.basePath,
252 domainNameId: output.domainNameId,
253 })
254 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
255 yield* session.note(`Deleted base path mapping`);
256 }),
257 };
258 }),
259 );