1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment";
3import { createInternalTags, hasTags } from "@/Tags.ts";
4import * as Test from "./Test.ts";
5import * as ag from "@distilled.cloud/aws/api-gateway";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11import * as Stream from "effect/Stream";
12import * as HttpClient from "effect/http/HttpClient";
13import * as HttpClientRequest from "effect/http/HttpClientRequest";
14
15import RestApiEventSourceFunctionLive, {
16 RestApiEventSourceFunction,
17} from "./fixtures/rest-api-event-source-handler.ts";
18
19const { test } = Test.make({ providers: AWS.providers() });
20
21// Allow bounded propagation of function URLs, stages, and invoke permissions.
22const readinessSchedule = Schedule.max([
23 Schedule.exponential(500).pipe(
24 Schedule.modifyDelay(({ duration: d }) =>
25 Effect.succeed(
26 Duration.isGreaterThan(d, Duration.seconds(5))
27 ? Duration.seconds(5)
28 : d,
29 ),
30 ),
31 ),
32 Schedule.recurs(10),
33]);
34
35const getJson = (url: string) =>
36 HttpClient.get(url).pipe(
37 Effect.flatMap((response) =>
38 response.status === 200
39 ? response.json
40 : Effect.fail(new Error(`GET ${url} returned ${response.status}`)),
41 ),
42 );
43
44// Ownership tags survive name truncation and isolate concurrent test stages.
45const findRestApis = Effect.fn(function* (logicalId: string) {
46 const tags = yield* createInternalTags(logicalId);
47 return yield* ag.getRestApis.pages({}).pipe(
48 Stream.runCollect,
49 Effect.map((pages) =>
50 Array.from(pages).flatMap((page) =>
51 (page.items ?? []).filter(
52 (api): api is ag.RestApi & { id: string } =>
53 api.id != null && hasTags(tags, api.tags),
54 ),
55 ),
56 ),
57 );
58});
59
60class RestApiNotVisible extends Data.TaggedError("RestApiNotVisible") {}
61
62test.provider.skipIf(!!process.env.FAST)(
63 "onRestApiRoute serves REST routes from the hosting Lambda",
64 (stack) =>
65 Effect.gen(function* () {
66 yield* stack.destroy();
67 const { region } = yield* AWSEnvironment.current;
68
69 const { functionUrl } = yield* stack.deploy(
70 Effect.gen(function* () {
71 return yield* RestApiEventSourceFunction;
72 }).pipe(Effect.provide(RestApiEventSourceFunctionLive)),
73 );
74 expect(functionUrl).toBeTruthy();
75 const baseUrl = functionUrl!.replace(/\/+$/, "");
76
77 // Wait for the function URL to serve — the readiness signal that the
78 // Lambda (and everything deployed with it) is live.
79 yield* HttpClient.get(`${baseUrl}/`).pipe(
80 Effect.flatMap((response): Effect.Effect<void, Error> =>
81 response.status === 200
82 ? Effect.void
83 : Effect.fail(new Error(`probe returned ${response.status}`)),
84 ),
85 Effect.retry({ schedule: readinessSchedule }),
86 );
87
88 // Discover the REST API without awaiting outputs inside its host.
89 const apis = yield* findRestApis("AgEsApi").pipe(
90 Effect.filterOrFail(
91 (found) => found.length === 1,
92 () => new RestApiNotVisible(),
93 ),
94 Effect.retry({
95 while: (e): boolean => e._tag === "RestApiNotVisible",
96 schedule: Schedule.spaced("3 seconds"),
97 times: 10,
98 }),
99 );
100 const invokeUrl = `https://${apis[0].id}.execute-api.${region}.amazonaws.com/test`;
101
102 // GET /items — the event source dispatched the proxy event to the
103 // registered handler.
104 const items = (yield* getJson(`${invokeUrl}/items`).pipe(
105 Effect.retry({ schedule: readinessSchedule }),
106 )) as { items: string[] };
107 expect(items.items).toEqual(["alpha", "beta"]);
108
109 // POST /echo — method, resource path, and body arrive intact.
110 const echoed = (yield* HttpClient.execute(
111 HttpClientRequest.post(`${invokeUrl}/echo`).pipe(
112 HttpClientRequest.bodyJsonUnsafe({ hello: "world" }),
113 ),
114 ).pipe(
115 Effect.flatMap((response) =>
116 response.status === 200
117 ? response.json
118 : Effect.fail(new Error(`POST /echo returned ${response.status}`)),
119 ),
120 Effect.retry({ schedule: readinessSchedule }),
121 )) as { method: string; resource: string; echoed: { hello: string } };
122 expect(echoed.method).toBe("POST");
123 expect(echoed.resource).toBe("/echo");
124 expect(echoed.echoed).toEqual({ hello: "world" });
125
126 // Unregistered verb on a registered path is rejected by API Gateway
127 // itself (403 MissingAuthenticationToken) rather than the handler.
128 const missing = yield* HttpClient.execute(
129 HttpClientRequest.make("DELETE")(`${invokeUrl}/items`),
130 );
131 expect(missing.status).toBeGreaterThanOrEqual(400);
132
133 yield* stack.destroy();
134
135 const deleted = yield* ag
136 .getRestApi({ restApiId: apis[0].id })
137 .pipe(
138 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
139 );
140 expect(deleted).toBeUndefined();
141 const leftover = yield* findRestApis("AgEsApi");
142 expect(leftover).toHaveLength(0);
143 }),
144 {
145 tags: [
146 "provider:aws",
147 "provider:aws:apigateway",
148 "provider:aws:lambda",
149 "live",
150 ],
151 timeout: 120_000,
152 },
153);