1import * as eventbridge from "@distilled.cloud/aws/eventbridge";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../../Diff.ts";
4import { createPhysicalName } from "../../PhysicalName.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { AccountID } from "../Environment.ts";
8import type { Providers } from "../Providers.ts";
9import type { RegionID } from "../Region.ts";
10
11export type {
12 ApiDestinationHttpMethod,
13 ApiDestinationState,
14} from "@distilled.cloud/aws/eventbridge";
15
16export type ApiDestinationName = string;
17export type ApiDestinationArn =
18 `arn:aws:events:${RegionID}:${AccountID}:api-destination/${ApiDestinationName}/${string}`;
19
20export interface ApiDestinationProps {
21 /**
22 * Name of the API destination. Must match [\.\-_A-Za-z0-9]+, 1-64
23 * characters. If omitted, a unique name will be generated.
24 */
25 name?: ApiDestinationName;
26
27 /**
28 * Description of the API destination. Max 512 characters.
29 */
30 description?: string;
31
32 /**
33 * ARN of the {@link Connection} providing the endpoint's authorization.
34 */
35 connectionArn: string;
36
37 /**
38 * The HTTPS endpoint EventBridge invokes for events routed to this API
39 * destination.
40 */
41 invocationEndpoint: string;
42
43 /**
44 * The HTTP method used against the endpoint.
45 */
46 httpMethod: eventbridge.ApiDestinationHttpMethod;
47
48 /**
49 * Maximum invocations per second EventBridge sends to the endpoint. The
50 * unit (per second) is part of the AWS field's semantics — this is a rate
51 * limit, not a duration.
52 * @default 300
53 */
54 invocationRateLimitPerSecond?: number;
55}
56
57/**
58 * An Amazon EventBridge API destination — an HTTPS endpoint configured as an
59 * event target, invoked with the authorization held by a {@link Connection}.
60 *
61 * API destinations do not support tags, so ownership is tracked by the
62 * deterministic physical name.
63 * ### Connecting to APIs
64 * **Example:** Webhook API Destination
65 * ```typescript
66 * const destination = yield* AWS.EventBridge.ApiDestination("Webhook", {
67 * connectionArn: connection.connectionArn,
68 * invocationEndpoint: "https://hooks.example.com/events",
69 * httpMethod: "POST",
70 * });
71 * ```
72 *
73 * **Example:** Rate-Limited API Destination as a Rule Target
74 * ```typescript
75 * const destination = yield* AWS.EventBridge.ApiDestination("SlowApi", {
76 * connectionArn: connection.connectionArn,
77 * invocationEndpoint: "https://api.example.com/ingest",
78 * httpMethod: "POST",
79 * invocationRateLimitPerSecond: 10,
80 * });
81 *
82 * const rule = yield* AWS.EventBridge.Rule("ToApi", {
83 * eventPattern: { source: ["my.app"] },
84 * targets: [{
85 * Id: "Api",
86 * Arn: destination.apiDestinationArn,
87 * RoleArn: role.roleArn,
88 * }],
89 * });
90 * ```
91 *
92 * @resource
93 */
94export interface ApiDestination extends Resource<
95 "AWS.EventBridge.ApiDestination",
96 ApiDestinationProps,
97 {
98 /** The name of the API destination. */
99 apiDestinationName: ApiDestinationName;
100 /** The ARN of the API destination. */
101 apiDestinationArn: ApiDestinationArn;
102 /** The state of the API destination (`ACTIVE` or `INACTIVE`). */
103 apiDestinationState: eventbridge.ApiDestinationState;
104 },
105 never,
106 Providers
107> {}
108export const ApiDestination = Resource<ApiDestination>(
109 "AWS.EventBridge.ApiDestination",
110);
111
112export const ApiDestinationProvider = () =>
113 Provider.effect(
114 ApiDestination,
115 Effect.gen(function* () {
116 const createDestinationName = (
117 id: string,
118 props: { name?: string } = {},
119 ) =>
120 props.name
121 ? Effect.succeed(props.name)
122 : createPhysicalName({
123 id,
124 maxLength: 64,
125 });
126
127 return {
128 stables: ["apiDestinationName", "apiDestinationArn"],
129 diff: Effect.fn(function* ({ id, news, olds }) {
130 if (!isResolved(news)) return;
131 const oldName = yield* createDestinationName(id, olds);
132 const newName = yield* createDestinationName(id, news);
133 if (oldName !== newName) {
134 return { action: "replace" } as const;
135 }
136 }),
137 read: Effect.fn(function* ({ id, olds, output }) {
138 // API destinations don't support tags; the deterministic physical
139 // name is the ownership signal (it embeds app/stage/logical id).
140 const name =
141 output?.apiDestinationName ??
142 (yield* createDestinationName(id, olds ?? {}));
143 const described = yield* eventbridge
144 .describeApiDestination({ Name: name })
145 .pipe(
146 Effect.catchTag("ResourceNotFoundException", () =>
147 Effect.succeed(undefined),
148 ),
149 );
150 if (!described?.Name || !described.ApiDestinationArn) {
151 return undefined;
152 }
153 return {
154 apiDestinationName: described.Name,
155 apiDestinationArn: described.ApiDestinationArn as ApiDestinationArn,
156 apiDestinationState: described.ApiDestinationState ?? "ACTIVE",
157 };
158 }),
159 list: () =>
160 Effect.gen(function* () {
161 const attrs: {
162 apiDestinationName: ApiDestinationName;
163 apiDestinationArn: ApiDestinationArn;
164 apiDestinationState: eventbridge.ApiDestinationState;
165 }[] = [];
166 let nextToken: string | undefined;
167 do {
168 const page = yield* eventbridge.listApiDestinations({
169 NextToken: nextToken,
170 });
171 for (const destination of page.ApiDestinations ?? []) {
172 if (!destination.Name || !destination.ApiDestinationArn) {
173 continue;
174 }
175 attrs.push({
176 apiDestinationName: destination.Name,
177 apiDestinationArn:
178 destination.ApiDestinationArn as ApiDestinationArn,
179 apiDestinationState:
180 destination.ApiDestinationState ?? "ACTIVE",
181 });
182 }
183 nextToken = page.NextToken;
184 } while (nextToken);
185 return attrs;
186 }),
187 reconcile: Effect.fn(function* ({ id, news, output, session }) {
188 const name =
189 output?.apiDestinationName ??
190 (yield* createDestinationName(id, news));
191
192 // Observe — live cloud state is authoritative; a vanished
193 // destination falls through to create.
194 const observed = yield* eventbridge
195 .describeApiDestination({ Name: name })
196 .pipe(
197 Effect.catchTag("ResourceNotFoundException", () =>
198 Effect.succeed(undefined),
199 ),
200 );
201
202 if (!observed?.ApiDestinationArn) {
203 // Ensure — create the destination; tolerate an AlreadyExists
204 // race with a peer reconciler and converge via the update below.
205 yield* eventbridge
206 .createApiDestination({
207 Name: name,
208 Description: news.description,
209 ConnectionArn: news.connectionArn,
210 InvocationEndpoint: news.invocationEndpoint,
211 HttpMethod: news.httpMethod,
212 InvocationRateLimitPerSecond: news.invocationRateLimitPerSecond,
213 })
214 .pipe(
215 Effect.catchTag(
216 "ResourceAlreadyExistsException",
217 () => Effect.void,
218 ),
219 );
220 } else {
221 // Sync — updateApiDestination overwrites the mutable aspects
222 // (description, connection, endpoint, method, rate limit) in one
223 // shot (idempotent on matching values).
224 yield* eventbridge.updateApiDestination({
225 Name: name,
226 Description: news.description,
227 ConnectionArn: news.connectionArn,
228 InvocationEndpoint: news.invocationEndpoint,
229 HttpMethod: news.httpMethod,
230 InvocationRateLimitPerSecond: news.invocationRateLimitPerSecond,
231 });
232 }
233
234 const settled = yield* eventbridge.describeApiDestination({
235 Name: name,
236 });
237 const apiDestinationArn =
238 settled.ApiDestinationArn as ApiDestinationArn;
239
240 yield* session.note(apiDestinationArn);
241 return {
242 apiDestinationName: name,
243 apiDestinationArn,
244 apiDestinationState: settled.ApiDestinationState ?? "ACTIVE",
245 };
246 }),
247 delete: Effect.fn(function* ({ output }) {
248 yield* eventbridge
249 .deleteApiDestination({ Name: output.apiDestinationName })
250 .pipe(
251 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
252 );
253 }),
254 };
255 }),
256 );