1import * as ag from "@distilled.cloud/aws/api-gateway";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { deepEqual, isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10import { createInternalTags, tagRecord } from "../../Tags.ts";
11
12import { syncTags } from "./common.ts";
13
14export interface DomainNameProps {
15 /** The custom domain name (e.g. `api.example.com`). */
16 domainName: string;
17 /** Display name of a self-managed edge certificate. */
18 certificateName?: string;
19 /** PEM body of a self-managed edge certificate. */
20 certificateBody?: string;
21 /**
22 * The private key of the server certificate (self-managed certificates).
23 * Secret key material — wrap with `Redacted.make` so state encoding
24 * preserves redaction.
25 */
26 certificatePrivateKey?: Redacted.Redacted<string>;
27 /** PEM chain of a self-managed edge certificate. */
28 certificateChain?: string;
29 /** ARN of an ACM certificate for EDGE endpoints (must live in us-east-1). */
30 certificateArn?: string;
31 /** Display name of the regional certificate. */
32 regionalCertificateName?: string;
33 /** ARN of an ACM certificate for REGIONAL endpoints (same region as the API). */
34 regionalCertificateArn?: string;
35 /** Endpoint type for the domain (EDGE, REGIONAL, or PRIVATE). */
36 endpointConfiguration?: ag.EndpointConfiguration;
37 /**
38 * Minimum TLS version served (e.g. `TLS_1_2`).
39 * @default "TLS_1_2"
40 */
41 securityPolicy?: ag.SecurityPolicy;
42 /** Access mode of the domain name endpoint. */
43 endpointAccessMode?: ag.EndpointAccessMode;
44 /** Mutual TLS (client certificate) authentication configuration. */
45 mutualTlsAuthentication?: ag.MutualTlsAuthenticationInput;
46 /** ARN of the certificate proving domain ownership when mutual TLS is enabled. */
47 ownershipVerificationCertificateArn?: string;
48 /** Resource policy (JSON) for private custom domain names. */
49 policy?: string;
50 /** Routing mode of the domain name (base-path vs routing-rule based). */
51 routingMode?: ag.RoutingMode;
52 /** User-defined tags for the domain name. */
53 tags?: Record<string, string>;
54}
55
56/** @resource */
57export interface DomainName extends Resource<
58 "AWS.ApiGateway.DomainName",
59 DomainNameProps,
60 {
61 domainName: string;
62 regionalDomainName: string | undefined;
63 regionalHostedZoneId: string | undefined;
64 distributionDomainName: string | undefined;
65 distributionHostedZoneId: string | undefined;
66 domainNameArn: string | undefined;
67 tags: Record<string, string>;
68 },
69 never,
70 Providers
71> {}
72
73/**
74 * Custom domain name for an Amazon API Gateway REST API.
75 *
76 * ### Custom domain
77 * **Example:** Regional custom domain
78 * ```typescript
79 * const domain = yield* ApiGateway.DomainName("ApiDomain", {
80 * domainName: "api.example.com",
81 * regionalCertificateArn: cert.certificateArn,
82 * endpointConfiguration: { types: ["REGIONAL"] },
83 * securityPolicy: "TLS_1_2",
84 * });
85 * ```
86 */
87const DomainNameResource = Resource<DomainName>("AWS.ApiGateway.DomainName");
88
89export { DomainNameResource as DomainName };
90
91/** Unwrap an optional redacted secret for the wire / diff comparison. */
92const redactedValue = (value: Redacted.Redacted<string> | undefined) =>
93 value === undefined ? undefined : Redacted.value(value);
94
95const retryDomainNameMutation = Effect.retry({
96 while: (e: any) =>
97 e._tag === "ConflictException" || e._tag === "TooManyRequestsException",
98 schedule: Schedule.spaced("1 second"),
99 times: 8,
100});
101
102export const DomainNameProvider = () =>
103 Provider.effect(
104 DomainNameResource,
105 Effect.gen(function* () {
106 return {
107 stables: ["domainName"] as const,
108 diff: Effect.fn(function* ({ news: newsIn, olds }) {
109 if (!isResolved(newsIn)) return;
110 const news = newsIn as DomainNameProps;
111 if (news.domainName !== olds.domainName) {
112 return { action: "replace" } as const;
113 }
114 if (
115 !deepEqual(news.endpointConfiguration, olds.endpointConfiguration)
116 ) {
117 return { action: "replace" } as const;
118 }
119 if (news.endpointAccessMode !== olds.endpointAccessMode) {
120 return { action: "replace" } as const;
121 }
122 if (news.certificateBody !== olds.certificateBody) {
123 return { action: "replace" } as const;
124 }
125 if (
126 redactedValue(news.certificatePrivateKey) !==
127 redactedValue(olds.certificatePrivateKey)
128 ) {
129 return { action: "replace" } as const;
130 }
131 if (news.certificateChain !== olds.certificateChain) {
132 return { action: "replace" } as const;
133 }
134 if (news.certificateName !== olds.certificateName) {
135 return { action: "replace" } as const;
136 }
137 if (news.regionalCertificateName !== olds.regionalCertificateName) {
138 return { action: "replace" } as const;
139 }
140 if (
141 !deepEqual(
142 news.mutualTlsAuthentication,
143 olds.mutualTlsAuthentication,
144 )
145 ) {
146 return { action: "replace" } as const;
147 }
148 if (news.policy !== olds.policy) {
149 return { action: "replace" } as const;
150 }
151 if (news.routingMode !== olds.routingMode) {
152 return { action: "replace" } as const;
153 }
154 if (
155 news.ownershipVerificationCertificateArn !==
156 olds.ownershipVerificationCertificateArn
157 ) {
158 return { action: "replace" } as const;
159 }
160 }),
161 list: () =>
162 ag.getDomainNames.pages({}).pipe(
163 Stream.runCollect,
164 Effect.map((chunk) =>
165 Array.from(chunk).flatMap((page) =>
166 (page.items ?? [])
167 .filter(
168 (d): d is ag.DomainName & { domainName: string } =>
169 d.domainName != null,
170 )
171 .map((d) => ({
172 domainName: d.domainName,
173 regionalDomainName: d.regionalDomainName,
174 regionalHostedZoneId: d.regionalHostedZoneId,
175 distributionDomainName: d.distributionDomainName,
176 distributionHostedZoneId: d.distributionHostedZoneId,
177 domainNameArn: d.domainNameArn,
178 tags: tagRecord(d.tags),
179 })),
180 ),
181 ),
182 ),
183 read: Effect.fn(function* ({ output }) {
184 if (!output?.domainName) return undefined;
185 const d = yield* ag
186 .getDomainName({ domainName: output.domainName })
187 .pipe(
188 Effect.catchTag("NotFoundException", () =>
189 Effect.succeed(undefined),
190 ),
191 );
192 if (!d?.domainName) return undefined;
193 return {
194 domainName: d.domainName,
195 regionalDomainName: d.regionalDomainName,
196 regionalHostedZoneId: d.regionalHostedZoneId,
197 distributionDomainName: d.distributionDomainName,
198 distributionHostedZoneId: d.distributionHostedZoneId,
199 domainNameArn: d.domainNameArn,
200 tags: tagRecord(d.tags),
201 };
202 }),
203 reconcile: Effect.fn(function* ({ id, news: newsIn, output, session }) {
204 if (!isResolved(newsIn)) {
205 return yield* Effect.die("DomainName props were not resolved");
206 }
207 const news = newsIn as DomainNameProps;
208 const domainName = output?.domainName ?? news.domainName;
209 const internalTags = yield* createInternalTags(id);
210 const desiredTags = { ...news.tags, ...internalTags };
211
212 // Observe — fetch the live domain name. Domain names are
213 // user-supplied physical names so the lookup key is stable; we
214 // never trust `output.tags`/`output.securityPolicy` etc. for
215 // diffing, only re-read the cloud state.
216 let observed = yield* ag
217 .getDomainName({ domainName })
218 .pipe(
219 Effect.catchTag("NotFoundException", () =>
220 Effect.succeed(undefined),
221 ),
222 );
223
224 // Ensure — create the domain name if it's missing.
225 if (!observed?.domainName) {
226 yield* ag.createDomainName({
227 domainName: news.domainName,
228 certificateName: news.certificateName,
229 certificateBody: news.certificateBody,
230 certificatePrivateKey: redactedValue(news.certificatePrivateKey),
231 certificateChain: news.certificateChain,
232 certificateArn: news.certificateArn,
233 regionalCertificateName: news.regionalCertificateName,
234 regionalCertificateArn: news.regionalCertificateArn,
235 endpointConfiguration: news.endpointConfiguration,
236 tags: desiredTags,
237 securityPolicy: news.securityPolicy,
238 endpointAccessMode: news.endpointAccessMode,
239 mutualTlsAuthentication: news.mutualTlsAuthentication,
240 ownershipVerificationCertificateArn:
241 news.ownershipVerificationCertificateArn,
242 policy: news.policy,
243 routingMode: news.routingMode,
244 });
245 yield* session.note(`Created domain name ${news.domainName}`);
246 observed = yield* ag.getDomainName({ domainName });
247 }
248
249 // Sync mutable scalar fields — observed ↔ desired patch list.
250 const patches: ag.PatchOperation[] = [];
251 if (news.securityPolicy !== observed.securityPolicy) {
252 patches.push({
253 op: news.securityPolicy === undefined ? "remove" : "replace",
254 path: "/securityPolicy",
255 value: news.securityPolicy,
256 });
257 }
258 if (news.regionalCertificateArn !== observed.regionalCertificateArn) {
259 patches.push({
260 op:
261 news.regionalCertificateArn === undefined
262 ? "remove"
263 : "replace",
264 path: "/regionalCertificateArn",
265 value: news.regionalCertificateArn,
266 });
267 }
268 if (news.certificateArn !== observed.certificateArn) {
269 patches.push({
270 op: news.certificateArn === undefined ? "remove" : "replace",
271 path: "/certificateArn",
272 value: news.certificateArn,
273 });
274 }
275 if (patches.length > 0) {
276 // Domain name mutations can briefly conflict while API Gateway
277 // propagates certificate, policy, or routing changes.
278 yield* ag
279 .updateDomainName({
280 domainName,
281 patchOperations: patches,
282 })
283 .pipe(retryDomainNameMutation);
284 }
285
286 // Sync tags — diff observed cloud tags against desired so adoption
287 // converges without fighting whatever was already there.
288 const observedTags = tagRecord(observed.tags);
289 if (!deepEqual(observedTags, desiredTags) && observed.domainNameArn) {
290 yield* syncTags({
291 resourceArn: observed.domainNameArn,
292 oldTags: observedTags,
293 newTags: desiredTags,
294 });
295 }
296
297 yield* session.note(`Reconciled domain name ${domainName}`);
298 const final = yield* ag.getDomainName({ domainName });
299 return {
300 domainName: final.domainName!,
301 regionalDomainName: final.regionalDomainName,
302 regionalHostedZoneId: final.regionalHostedZoneId,
303 distributionDomainName: final.distributionDomainName,
304 distributionHostedZoneId: final.distributionHostedZoneId,
305 domainNameArn: final.domainNameArn,
306 tags: tagRecord(final.tags),
307 };
308 }),
309 delete: Effect.fn(function* ({ output, session }) {
310 yield* ag.deleteDomainName({ domainName: output.domainName }).pipe(
311 Effect.catchTag("NotFoundException", () => Effect.void),
312 retryDomainNameMutation,
313 );
314 yield* session.note(`Deleted domain name ${output.domainName}`);
315 }),
316 };
317 }),
318 );