1import { EventBus } from "@/AWS/EventBridge";
2import * as Lambda from "@/AWS/Lambda";
3import * as Schemas from "@/AWS/Schemas";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Stream from "effect/Stream";
8import { HttpServerRequest } from "effect/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/http/HttpServerResponse";
10import path from "pathe";
11
12const main = path.resolve(import.meta.dirname, "handler.ts");
13
14export class SchemasTestFunction extends Lambda.Function<Lambda.Function>()(
15 "SchemasTestFunction",
16) {}
17
18/** The OpenAPI 3 document registered as the fixture schema. */
19const ORDER_CREATED_CONTENT = JSON.stringify({
20 openapi: "3.0.0",
21 info: { version: "1.0.0", title: "OrderCreated" },
22 paths: {},
23 components: {
24 schemas: {
25 OrderCreated: {
26 type: "object",
27 properties: {
28 orderId: { type: "string" },
29 amount: { type: "number" },
30 },
31 },
32 },
33 },
34});
35
36/** A full AWS event envelope for GetDiscoveredSchema inference. */
37const SAMPLE_EVENT = JSON.stringify({
38 version: "0",
39 id: "12345678-1234-1234-1234-123456789012",
40 "detail-type": "OrderCreated",
41 source: "alchemy.test",
42 account: "123456789012",
43 time: "2026-01-01T00:00:00Z",
44 region: "us-east-1",
45 resources: [],
46 detail: { orderId: "abc", amount: 42 },
47});
48
49const CODE_BINDING_LANGUAGE = "Python36";
50
51export default SchemasTestFunction.make(
52 {
53 main,
54 functionUrl: true,
55 timeout: Duration.seconds(30),
56 // The default 128 MB sits at ~114 MB used; the last routes in a test run
57 // OOM the instance (platform-level 500 with no handler log).
58 memorySize: 512,
59 },
60 Effect.gen(function* () {
61 const registry = yield* Schemas.Registry("BindingsRegistry", {
62 description: "alchemy schemas bindings fixture registry",
63 });
64
65 const schema = yield* Schemas.Schema("BindingsSchema", {
66 registryName: registry.registryName,
67 type: "OpenApi3",
68 content: ORDER_CREATED_CONTENT,
69 description: "alchemy schemas bindings fixture schema",
70 });
71
72 // The discoverer the start/stop bindings are bound to — on a dedicated
73 // bus that carries no traffic, so it never publishes junk schemas.
74 const bus = yield* EventBus("BindingsBus", {});
75 const discoverer = yield* Schemas.Discoverer("BindingsDiscoverer", {
76 sourceArn: bus.eventBusArn,
77 description: "alchemy schemas bindings fixture discoverer",
78 });
79
80 // Event source: subscribe the host to schema-registry notifications.
81 // The deploy proves the EventBridge rule + invoke permission wiring.
82 yield* Schemas.consumeSchemaEvents(
83 { kinds: ["schema-created", "schema-version-created"] },
84 (events) =>
85 Stream.runForEach(events, (event) =>
86 Effect.log(
87 `schema event: ${event.detail.RegistryName}/${event.detail.SchemaName} v${event.detail.SchemaVersion}`,
88 ),
89 ),
90 );
91
92 const describeSchema = yield* Schemas.DescribeSchema(schema);
93 const exportSchema = yield* Schemas.ExportSchema(schema);
94 const listSchemaVersions = yield* Schemas.ListSchemaVersions(schema);
95 const searchSchemas = yield* Schemas.SearchSchemas(registry);
96 const getDiscoveredSchema = yield* Schemas.GetDiscoveredSchema();
97 const putCodeBinding = yield* Schemas.PutCodeBinding(schema);
98 const describeCodeBinding = yield* Schemas.DescribeCodeBinding(schema);
99 const getCodeBindingSource = yield* Schemas.GetCodeBindingSource(schema);
100 const startDiscoverer = yield* Schemas.StartDiscoverer(discoverer);
101 const stopDiscoverer = yield* Schemas.StopDiscoverer(discoverer);
102
103 const bound = {
104 describeSchema,
105 exportSchema,
106 listSchemaVersions,
107 searchSchemas,
108 getDiscoveredSchema,
109 putCodeBinding,
110 describeCodeBinding,
111 getCodeBindingSource,
112 startDiscoverer,
113 stopDiscoverer,
114 };
115
116 return {
117 fetch: Effect.gen(function* () {
118 const request = yield* HttpServerRequest;
119 const url = new URL(request.originalUrl);
120 const pathname = url.pathname;
121
122 if (request.method === "GET" && pathname === "/bindings") {
123 return yield* HttpServerResponse.json({
124 bound: Object.keys(bound),
125 });
126 }
127
128 // Schema-scoped read: registry + schema names are injected.
129 if (request.method === "GET" && pathname === "/schema") {
130 const response = yield* describeSchema();
131 return yield* HttpServerResponse.json({
132 name: response.SchemaName,
133 type: response.Type,
134 version: response.SchemaVersion,
135 hasContent: !!response.Content,
136 });
137 }
138
139 // Export the schema as JSONSchemaDraft4. AWS only allows exporting
140 // discovered / AWS-managed schemas, so on this custom-registry
141 // schema the call surfaces the typed ForbiddenException — the route
142 // reports which outcome occurred so the test can assert the typed
143 // error (proving the binding + IAM wiring end-to-end).
144 if (request.method === "GET" && pathname === "/export") {
145 const response = yield* exportSchema({
146 Type: "JSONSchemaDraft4",
147 }).pipe(
148 Effect.map((r) => ({
149 outcome: "exported" as const,
150 hasContent: !!r.Content,
151 })),
152 Effect.catchTag("ForbiddenException", (e) =>
153 Effect.succeed({
154 outcome: "forbidden" as const,
155 message: e.message ?? "",
156 }),
157 ),
158 );
159 return yield* HttpServerResponse.json(response);
160 }
161
162 if (request.method === "GET" && pathname === "/versions") {
163 const response = yield* listSchemaVersions();
164 return yield* HttpServerResponse.json({
165 versions: (response.SchemaVersions ?? []).map(
166 (v) => v.SchemaVersion,
167 ),
168 });
169 }
170
171 // Registry-scoped search: the registry name is injected.
172 if (request.method === "GET" && pathname === "/search") {
173 const response = yield* searchSchemas({ Keywords: "order" });
174 return yield* HttpServerResponse.json({
175 names: (response.Schemas ?? []).map((s) => s.SchemaName),
176 });
177 }
178
179 // Account-level inference from sample events.
180 if (request.method === "POST" && pathname === "/discover") {
181 const response = yield* getDiscoveredSchema({
182 Type: "OpenApi3",
183 Events: [SAMPLE_EVENT],
184 });
185 return yield* HttpServerResponse.json({
186 hasContent: !!response.Content,
187 });
188 }
189
190 // Kick off async code-binding generation. Re-running against an
191 // already-generated binding returns the typed ConflictException —
192 // treat it as "generation already complete" so the route is
193 // idempotent across test re-runs.
194 if (request.method === "POST" && pathname === "/codebinding") {
195 const response = yield* putCodeBinding({
196 Language: CODE_BINDING_LANGUAGE,
197 }).pipe(
198 Effect.catchTag("ConflictException", () =>
199 Effect.succeed({ Status: "CREATE_COMPLETE" as const }),
200 ),
201 );
202 return yield* HttpServerResponse.json({ status: response.Status });
203 }
204
205 // Poll generation status (NOT_FOUND until PutCodeBinding ran).
206 if (request.method === "GET" && pathname === "/codebinding") {
207 const response = yield* describeCodeBinding({
208 Language: CODE_BINDING_LANGUAGE,
209 }).pipe(
210 Effect.catchTag("NotFoundException", () =>
211 Effect.succeed({ Status: "NOT_FOUND" as const }),
212 ),
213 );
214 return yield* HttpServerResponse.json({ status: response.Status });
215 }
216
217 // Download the generated package (a zip streamed as Body).
218 if (request.method === "GET" && pathname === "/codebinding/source") {
219 const bytes = yield* getCodeBindingSource({
220 Language: CODE_BINDING_LANGUAGE,
221 }).pipe(
222 Effect.flatMap((response) =>
223 response.Body === undefined
224 ? Effect.succeed(0)
225 : Stream.runFold(
226 response.Body,
227 () => 0,
228 (n, chunk) => n + chunk.length,
229 ),
230 ),
231 Effect.catchTag("NotFoundException", () => Effect.succeed(-1)),
232 );
233 return yield* HttpServerResponse.json({ bytes });
234 }
235
236 // Discoverer-scoped control plane: the discoverer id is injected.
237 if (request.method === "POST" && pathname === "/discoverer/stop") {
238 const response = yield* stopDiscoverer();
239 return yield* HttpServerResponse.json({ state: response.State });
240 }
241 if (request.method === "POST" && pathname === "/discoverer/start") {
242 const response = yield* startDiscoverer();
243 return yield* HttpServerResponse.json({ state: response.State });
244 }
245
246 return yield* HttpServerResponse.json(
247 { error: "Not found", method: request.method, pathname },
248 { status: 404 },
249 );
250 }).pipe(Effect.orDie),
251 };
252 }).pipe(
253 Effect.provide(
254 Layer.mergeAll(
255 Lambda.EventSource,
256 Schemas.DescribeSchemaHttp,
257 Schemas.ExportSchemaHttp,
258 Schemas.ListSchemaVersionsHttp,
259 Schemas.SearchSchemasHttp,
260 Schemas.GetDiscoveredSchemaHttp,
261 Schemas.PutCodeBindingHttp,
262 Schemas.DescribeCodeBindingHttp,
263 Schemas.GetCodeBindingSourceHttp,
264 Schemas.StartDiscovererHttp,
265 Schemas.StopDiscovererHttp,
266 ),
267 ),
268 ),
269);