1import * as agw2 from "@distilled.cloud/aws/apigatewayv2";
2import * as Effect from "effect/Effect";
3import { deepEqual, isResolved } from "../../Diff.ts";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import { createInternalTags } from "../../Tags.ts";
7import { AWSEnvironment } from "../Environment.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 collectAllPages,
11 domainNameArn,
12 retryOnTooManyRequests,
13 syncTags,
14 tagRecord,
15} from "./common.ts";
16
17export interface DomainNameProps {
18 /**
19 * The custom domain name, e.g. `api.example.com`. Changing this
20 * triggers a replacement.
21 */
22 domainName: string;
23 /**
24 * Domain name configurations. Each entry references a validated ACM
25 * certificate (`CertificateArn`) in the API's region and an endpoint
26 * type (`REGIONAL`).
27 */
28 domainNameConfigurations?: agw2.DomainNameConfiguration[];
29 /**
30 * Mutual TLS authentication configuration (truststore S3 URI).
31 */
32 mutualTlsAuthentication?: agw2.MutualTlsAuthenticationInput;
33 /**
34 * The routing mode for the domain name.
35 */
36 routingMode?: agw2.RoutingMode;
37 /**
38 * User-defined tags (Alchemy internal tags are merged automatically).
39 */
40 tags?: Record<string, string>;
41}
42
43export interface DomainName extends Resource<
44 "AWS.ApiGatewayV2.DomainName",
45 DomainNameProps,
46 {
47 /** The custom domain name. */
48 domainName: string;
49 /** The domain name ARN. */
50 domainNameArn: string | undefined;
51 /**
52 * The configurations, including the API Gateway-managed target domain
53 * (`ApiGatewayDomainName`) and its Route 53 `HostedZoneId` for alias
54 * records.
55 */
56 domainNameConfigurations: agw2.DomainNameConfiguration[] | undefined;
57 mutualTlsAuthentication: agw2.MutualTlsAuthentication | undefined;
58 routingMode: agw2.RoutingMode | undefined;
59 apiMappingSelectionExpression: string | undefined;
60 tags: Record<string, string>;
61 },
62 never,
63 Providers
64> {}
65
66/**
67 * An API Gateway v2 custom domain name.
68 *
69 * Requires a validated ACM certificate in the same region. Point DNS
70 * (a Route 53 alias or CNAME) at the returned `ApiGatewayDomainName`
71 * target and map APIs onto the domain with {@link ApiMapping}.
72 * ### Custom domains
73 * **Example:** Regional custom domain
74 * ```typescript
75 * const domain = yield* ApiGatewayV2.DomainName("Domain", {
76 * domainName: "api.example.com",
77 * domainNameConfigurations: [{
78 * CertificateArn: certificate.certificateArn,
79 * EndpointType: "REGIONAL",
80 * SecurityPolicy: "TLS_1_2",
81 * }],
82 * });
83 *
84 * yield* ApiGatewayV2.ApiMapping("Mapping", {
85 * api,
86 * domainName: domain.domainName,
87 * stage: stage.stageName,
88 * });
89 * ```
90 *
91 * @resource
92 */
93export const DomainName = Resource<DomainName>("AWS.ApiGatewayV2.DomainName");
94
95const snapshotFromDomain = (
96 domain: agw2.GetDomainNameResponse,
97): DomainName["Attributes"] => ({
98 domainName: domain.DomainName!,
99 domainNameArn: domain.DomainNameArn,
100 domainNameConfigurations: domain.DomainNameConfigurations as
101 | agw2.DomainNameConfiguration[]
102 | undefined,
103 mutualTlsAuthentication: domain.MutualTlsAuthentication,
104 routingMode: domain.RoutingMode,
105 apiMappingSelectionExpression: domain.ApiMappingSelectionExpression,
106 tags: tagRecord(domain.Tags),
107});
108
109export const DomainNameProvider = () =>
110 Provider.effect(
111 DomainName,
112 Effect.gen(function* () {
113 const getDomainSafe = (domainName: string) =>
114 agw2
115 .getDomainName({ DomainName: domainName })
116 .pipe(
117 Effect.catchTag("NotFoundException", () =>
118 Effect.succeed(undefined),
119 ),
120 );
121
122 return DomainName.Provider.of({
123 stables: ["domainName", "domainNameArn"],
124
125 list: () =>
126 Effect.gen(function* () {
127 const items = yield* collectAllPages((NextToken) =>
128 agw2.getDomainNames({ NextToken }),
129 );
130 return items
131 .filter((domain) => domain.DomainName != null)
132 .map((domain) => snapshotFromDomain(domain));
133 }),
134
135 read: Effect.fn(function* ({ olds, output }) {
136 const name = output?.domainName ?? olds?.domainName;
137 if (!name) return undefined;
138 const domain = yield* getDomainSafe(name);
139 if (!domain?.DomainName) return undefined;
140 return snapshotFromDomain(domain);
141 }),
142
143 diff: Effect.fn(function* ({ news, olds }) {
144 if (!isResolved(news)) return undefined;
145 if (news.domainName !== olds.domainName) {
146 return { action: "replace" } as const;
147 }
148 }),
149
150 reconcile: Effect.fn(function* ({ id, news, output, session }) {
151 const { region } = yield* AWSEnvironment.current;
152 const name = output?.domainName ?? news.domainName;
153 const internalTags = yield* createInternalTags(id);
154 const desiredTags = { ...news.tags, ...internalTags };
155
156 // 1. OBSERVE — the domain name is the natural key.
157 let observed = yield* getDomainSafe(name);
158
159 // 2. ENSURE
160 if (!observed?.DomainName) {
161 observed = yield* retryOnTooManyRequests(
162 agw2.createDomainName({
163 DomainName: name,
164 DomainNameConfigurations: news.domainNameConfigurations,
165 MutualTlsAuthentication: news.mutualTlsAuthentication,
166 RoutingMode: news.routingMode,
167 Tags: desiredTags,
168 }),
169 );
170 yield* session.note(`Created domain name ${name}`);
171 }
172 const snapshot = snapshotFromDomain(observed);
173
174 // 3. SYNC — updateDomainName converges certificate/mTLS config.
175 const desiredCerts = (news.domainNameConfigurations ?? []).map(
176 (config) => ({
177 CertificateArn: config.CertificateArn,
178 EndpointType: config.EndpointType,
179 SecurityPolicy: config.SecurityPolicy,
180 }),
181 );
182 const observedCerts = (snapshot.domainNameConfigurations ?? []).map(
183 (config) => ({
184 CertificateArn: config.CertificateArn,
185 EndpointType: config.EndpointType,
186 SecurityPolicy: config.SecurityPolicy,
187 }),
188 );
189 const drift =
190 (news.domainNameConfigurations !== undefined &&
191 !deepEqual(observedCerts, desiredCerts)) ||
192 (news.routingMode !== undefined &&
193 snapshot.routingMode !== news.routingMode);
194 if (drift) {
195 yield* retryOnTooManyRequests(
196 agw2.updateDomainName({
197 DomainName: name,
198 DomainNameConfigurations: news.domainNameConfigurations,
199 MutualTlsAuthentication: news.mutualTlsAuthentication,
200 RoutingMode: news.routingMode,
201 }),
202 );
203 yield* session.note(`Updated domain name ${name}`);
204 }
205
206 // 3b. SYNC TAGS — diff against OBSERVED cloud tags.
207 if (!deepEqual(snapshot.tags, desiredTags)) {
208 yield* syncTags({
209 resourceArn:
210 snapshot.domainNameArn ?? domainNameArn(region, name),
211 oldTags: snapshot.tags,
212 newTags: desiredTags,
213 });
214 }
215
216 // 4. RETURN fresh state.
217 const final = yield* agw2.getDomainName({ DomainName: name });
218 yield* session.note(`Reconciled domain name ${name}`);
219 return snapshotFromDomain(final);
220 }),
221
222 delete: Effect.fn(function* ({ output, session }) {
223 yield* retryOnTooManyRequests(
224 agw2
225 .deleteDomainName({ DomainName: output.domainName })
226 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
227 );
228 yield* session.note(`Deleted domain name ${output.domainName}`);
229 }),
230 });
231 }),
232 );