1import * as AWS from "@/AWS";
2import * as Test from "@/Test/Alchemy";
3import * as agw2 from "@distilled.cloud/aws/apigatewayv2";
4import { expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8
9const { test } = Test.make({ providers: AWS.providers() });
10
11class ApiStillExists extends Data.TaggedError("ApiStillExists")<{
12 readonly apiId: string;
13}> {}
14
15/** Poll until `getApi` returns the typed NotFoundException. */
16const assertApiDeleted = (apiId: string) =>
17 agw2.getApi({ ApiId: apiId }).pipe(
18 Effect.flatMap(() => Effect.fail(new ApiStillExists({ apiId }))),
19 Effect.retry({
20 while: (e) => e._tag === "ApiStillExists",
21 schedule: Schedule.max([
22 Schedule.fixed("2 seconds"),
23 Schedule.recurs(10),
24 ]),
25 }),
26 Effect.catchTag("NotFoundException", () => Effect.void),
27 );
28
29test.provider(
30 "create, update, delete HTTP API",
31 (stack) =>
32 Effect.gen(function* () {
33 yield* stack.destroy();
34
35 const api = yield* stack.deploy(
36 Effect.gen(function* () {
37 return yield* AWS.ApiGatewayV2.Api("LifecycleApi", {
38 description: "v1",
39 });
40 }),
41 );
42
43 expect(api.apiId).toBeTruthy();
44 expect(api.protocolType).toBe("HTTP");
45 expect(api.apiEndpoint).toContain("execute-api");
46
47 // Out-of-band verification via distilled.
48 const remote = yield* agw2.getApi({ ApiId: api.apiId });
49 expect(remote.Description).toBe("v1");
50 // Internal Alchemy tags brand the API.
51 expect(Object.keys(remote.Tags ?? {}).length).toBeGreaterThan(0);
52
53 // Update description + CORS in place (same apiId).
54 const updated = yield* stack.deploy(
55 Effect.gen(function* () {
56 return yield* AWS.ApiGatewayV2.Api("LifecycleApi", {
57 description: "v2",
58 corsConfiguration: {
59 AllowOrigins: ["https://example.com"],
60 AllowMethods: ["GET"],
61 },
62 });
63 }),
64 );
65 expect(updated.apiId).toBe(api.apiId);
66
67 const afterUpdate = yield* agw2.getApi({ ApiId: api.apiId });
68 expect(afterUpdate.Description).toBe("v2");
69 expect(afterUpdate.CorsConfiguration?.AllowOrigins).toEqual([
70 "https://example.com",
71 ]);
72
73 // Removing CORS deletes the configuration.
74 yield* stack.deploy(
75 Effect.gen(function* () {
76 return yield* AWS.ApiGatewayV2.Api("LifecycleApi", {
77 description: "v2",
78 });
79 }),
80 );
81 const afterCorsRemoval = yield* agw2.getApi({ ApiId: api.apiId });
82 expect(afterCorsRemoval.CorsConfiguration).toBeUndefined();
83
84 yield* stack.destroy();
85 yield* assertApiDeleted(api.apiId);
86 }),
87 {
88 tags: ["provider:aws", "provider:aws:apigatewayv2", "live"],
89 timeout: 240_000,
90 },
91);
92
93test.provider(
94 "protocolType change replaces the API",
95 (stack) =>
96 Effect.gen(function* () {
97 yield* stack.destroy();
98
99 const httpApi = yield* stack.deploy(
100 Effect.gen(function* () {
101 return yield* AWS.ApiGatewayV2.Api("ReplaceApi", {});
102 }),
103 );
104 expect(httpApi.protocolType).toBe("HTTP");
105
106 const wsApi = yield* stack.deploy(
107 Effect.gen(function* () {
108 return yield* AWS.ApiGatewayV2.Api("ReplaceApi", {
109 protocolType: "WEBSOCKET",
110 });
111 }),
112 );
113
114 expect(wsApi.protocolType).toBe("WEBSOCKET");
115 expect(wsApi.apiId).not.toBe(httpApi.apiId);
116 expect(wsApi.routeSelectionExpression).toBe("$request.body.action");
117
118 // The replaced HTTP API is deleted.
119 yield* assertApiDeleted(httpApi.apiId);
120
121 yield* stack.destroy();
122 yield* assertApiDeleted(wsApi.apiId);
123 }),
124 {
125 tags: ["provider:aws", "provider:aws:apigatewayv2", "live"],
126 timeout: 240_000,
127 },
128);
129
130test.provider(
131 "stage, integration, and route lifecycle on one API",
132 (stack) =>
133 Effect.gen(function* () {
134 yield* stack.destroy();
135
136 const deployPrimitives = (upstreamPath: string) =>
137 stack.deploy(
138 Effect.gen(function* () {
139 const api = yield* AWS.ApiGatewayV2.Api("PrimApi", {});
140 const integration = yield* AWS.ApiGatewayV2.Integration(
141 "PrimIntegration",
142 {
143 api,
144 integrationType: "HTTP_PROXY",
145 integrationUri: `https://checkip.amazonaws.com${upstreamPath}`,
146 integrationMethod: "GET",
147 payloadFormatVersion: "1.0",
148 },
149 );
150 const route = yield* AWS.ApiGatewayV2.Route("PrimRoute", {
151 api,
152 routeKey: "GET /ip",
153 integration,
154 });
155 const stage = yield* AWS.ApiGatewayV2.Stage("PrimStage", {
156 api,
157 autoDeploy: true,
158 description: "primitives",
159 });
160 return {
161 apiId: api.apiId,
162 integrationId: integration.integrationId,
163 routeId: route.routeId,
164 routeKey: route.routeKey,
165 stageName: stage.stageName,
166 invokeUrl: stage.invokeUrl,
167 };
168 }),
169 );
170
171 const out = yield* deployPrimitives("/");
172
173 expect(out.stageName).toBe("$default");
174 expect(out.invokeUrl).toContain(out.apiId);
175
176 // Out-of-band verification of each primitive via distilled.
177 const integ = yield* agw2.getIntegration({
178 ApiId: out.apiId,
179 IntegrationId: out.integrationId,
180 });
181 expect(integ.IntegrationType).toBe("HTTP_PROXY");
182 expect(integ.IntegrationUri).toBe("https://checkip.amazonaws.com/");
183
184 const route = yield* agw2.getRoute({
185 ApiId: out.apiId,
186 RouteId: out.routeId,
187 });
188 expect(route.RouteKey).toBe("GET /ip");
189 expect(route.Target).toBe(`integrations/${out.integrationId}`);
190
191 const stage = yield* agw2.getStage({
192 ApiId: out.apiId,
193 StageName: "$default",
194 });
195 expect(stage.AutoDeploy).toBe(true);
196 expect(stage.Description).toBe("primitives");
197
198 // Second deploy is a no-op / in-place sync (same physical ids).
199 const again = yield* deployPrimitives("/");
200 expect(again.apiId).toBe(out.apiId);
201 expect(again.integrationId).toBe(out.integrationId);
202 expect(again.routeId).toBe(out.routeId);
203
204 yield* stack.destroy();
205 yield* assertApiDeleted(out.apiId);
206 }),
207 {
208 tags: ["provider:aws", "provider:aws:apigatewayv2", "live"],
209 timeout: 240_000,
210 },
211);