1import * as BDA from "@/AWS/BedrockDataAutomation";
2import * as Lambda from "@/AWS/Lambda";
3import * as S3 from "@/AWS/S3";
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
14const INPUT_KEY = "inputs/hello.pdf";
15
16// Minimal single-page PDF, checked in as a constant (never generated at test
17// time). The async invoke only needs a readable S3 object — the job's final
18// verdict is irrelevant to the binding test.
19const TINY_PDF = `%PDF-1.4
201 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
212 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
223 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Resources<</Font<</F1 5 0 R>>>>/Contents 4 0 R>>endobj
234 0 obj<</Length 46>>stream
24BT /F1 24 Tf 72 720 Td (Hello Alchemy) Tj ET
25endstream
26endobj
275 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
28trailer<</Size 6/Root 1 0 R>>
29%%EOF
30`;
31
32// A deterministic, valid BDA blueprint schema (checked-in constant fixture).
33const INVOICE_SCHEMA = JSON.stringify({
34 $schema: "http://json-schema.org/draft-07/schema#",
35 description: "Extract invoice fields",
36 class: "invoice",
37 type: "object",
38 definitions: {},
39 properties: {
40 invoice_number: {
41 type: "string",
42 inferenceType: "explicit",
43 instruction: "The invoice number",
44 },
45 },
46});
47
48export class BdaTestFunction extends Lambda.Function<Lambda.Function>()(
49 "BdaTestFunction",
50) {}
51
52export default BdaTestFunction.make(
53 {
54 main,
55 functionUrl: true,
56 timeout: Duration.seconds(30),
57 },
58 Effect.gen(function* () {
59 const project = yield* BDA.DataAutomationProject("BindingsProject", {
60 projectDescription: "alchemy bindings test project",
61 standardOutputConfiguration: {
62 document: {
63 extraction: {
64 granularity: { types: ["DOCUMENT"] },
65 boundingBox: { state: "DISABLED" },
66 },
67 generativeField: { state: "DISABLED" },
68 outputFormat: {
69 textFormat: { types: ["MARKDOWN"] },
70 additionalFileFormat: { state: "DISABLED" },
71 },
72 },
73 },
74 });
75 const blueprint = yield* BDA.Blueprint("BindingsBlueprint", {
76 type: "DOCUMENT",
77 schema: INVOICE_SCHEMA,
78 });
79 const library = yield* BDA.DataAutomationLibrary("BindingsLibrary", {
80 libraryDescription: "alchemy bindings test library",
81 });
82 // Bedrock Data Automation reads the input and writes the output with the
83 // CALLER's S3 permissions (forward access sessions), so the Lambda role
84 // needs GetObject + PutObject on the working bucket.
85 const bucket = yield* S3.Bucket("BindingsBucket", { forceDestroy: true });
86 const bucketName = yield* bucket.bucketName;
87
88 const putObject = yield* S3.PutObject(bucket);
89 const getObject = yield* S3.GetObject(bucket);
90 const invokeDataAutomationAsync =
91 yield* BDA.InvokeDataAutomationAsync(project);
92 const invokeDataAutomation = yield* BDA.InvokeDataAutomation(project);
93 const getDataAutomationStatus = yield* BDA.GetDataAutomationStatus();
94 const ingestLibraryEntities =
95 yield* BDA.InvokeDataAutomationLibraryIngestionJob(library);
96 const getLibraryIngestionJob =
97 yield* BDA.GetDataAutomationLibraryIngestionJob(library);
98 const listLibraryIngestionJobs =
99 yield* BDA.ListDataAutomationLibraryIngestionJobs(library);
100 const getLibraryEntity = yield* BDA.GetDataAutomationLibraryEntity(library);
101 const listLibraryEntities =
102 yield* BDA.ListDataAutomationLibraryEntities(library);
103 const createBlueprintVersion = yield* BDA.CreateBlueprintVersion(blueprint);
104 const copyBlueprintStage = yield* BDA.CopyBlueprintStage(blueprint);
105 const invokeBlueprintOptimizationAsync =
106 yield* BDA.InvokeBlueprintOptimizationAsync(blueprint);
107 const getBlueprintOptimizationStatus =
108 yield* BDA.GetBlueprintOptimizationStatus();
109
110 // Deploy-time: creates the EventBridge rule (default bus, source
111 // aws.bedrock) targeting this Function. Runtime firing needs a settled
112 // job with eventBridgeEnabled, so the test only verifies the
113 // subscription deploys.
114 yield* BDA.consumeDataAutomationJobEvents(
115 { kinds: ["succeeded", "client-error", "service-error"] },
116 (events) =>
117 Stream.runForEach(events, (event) =>
118 Effect.log(
119 `data automation job ${event.detail.job_id}: ${event.detail.job_status}`,
120 ),
121 ),
122 );
123
124 const bound = {
125 putObject,
126 getObject,
127 invokeDataAutomationAsync,
128 invokeDataAutomation,
129 getDataAutomationStatus,
130 ingestLibraryEntities,
131 getLibraryIngestionJob,
132 listLibraryIngestionJobs,
133 getLibraryEntity,
134 listLibraryEntities,
135 createBlueprintVersion,
136 copyBlueprintStage,
137 invokeBlueprintOptimizationAsync,
138 getBlueprintOptimizationStatus,
139 };
140
141 return {
142 fetch: Effect.gen(function* () {
143 const request = yield* HttpServerRequest;
144 const url = new URL(request.originalUrl);
145 const pathname = url.pathname;
146
147 if (request.method === "GET" && pathname === "/bindings") {
148 return yield* HttpServerResponse.json({
149 bound: Object.keys(bound),
150 });
151 }
152
153 if (request.method === "POST" && pathname === "/invoke-async") {
154 // Uploads the fixture document, then submits an async job against
155 // the bound project. The response arrives before the job settles —
156 // the test asserts the invocation ARN shape and polls status once.
157 const profileArn = url.searchParams.get("profileArn")!;
158 // Double-yield: the init-scope yield gives an accessor; yielding
159 // it here resolves the physical bucket name at runtime.
160 const physicalBucket = yield* bucketName;
161 const result = yield* putObject({
162 Key: INPUT_KEY,
163 Body: TINY_PDF,
164 ContentType: "application/pdf",
165 }).pipe(
166 Effect.flatMap(() =>
167 invokeDataAutomationAsync({
168 inputConfiguration: {
169 s3Uri: `s3://${physicalBucket}/${INPUT_KEY}`,
170 },
171 outputConfiguration: {
172 s3Uri: `s3://${physicalBucket}/results/`,
173 },
174 dataAutomationProfileArn: profileArn,
175 notificationConfiguration: {
176 eventBridgeConfiguration: { eventBridgeEnabled: true },
177 },
178 }),
179 ),
180 Effect.map((r) => ({ invocationArn: r.invocationArn })),
181 // Surface the typed failure to the test instead of a bare 500 —
182 // the assertion prints the tag + message on mismatch.
183 Effect.catch((e) =>
184 Effect.succeed({
185 invocationArn: undefined,
186 error: e._tag,
187 message: String((e as { message?: unknown }).message ?? ""),
188 }),
189 ),
190 );
191 return yield* HttpServerResponse.json(result);
192 }
193
194 if (request.method === "GET" && pathname === "/status") {
195 const invocationArn = url.searchParams.get("invocationArn")!;
196 const result = yield* getDataAutomationStatus({ invocationArn });
197 return yield* HttpServerResponse.json({ status: result.status });
198 }
199
200 if (
201 request.method === "POST" &&
202 pathname === "/invoke-sync-validation"
203 ) {
204 // The sync API needs a SYNC project and a non-empty input; driving
205 // it through the typed ValidationException path proves the IAM
206 // grant + project injection end-to-end — an IAM gap would surface
207 // AccessDeniedException (a 500 through orDie) instead.
208 const profileArn = url.searchParams.get("profileArn")!;
209 const tag = yield* invokeDataAutomation({
210 inputConfiguration: {},
211 dataAutomationProfileArn: profileArn,
212 }).pipe(
213 Effect.map(() => "Invoked"),
214 Effect.catchTag("ValidationException", (e) =>
215 Effect.succeed(e._tag),
216 ),
217 );
218 return yield* HttpServerResponse.json({ tag });
219 }
220
221 if (request.method === "POST" && pathname === "/library-ingest") {
222 // Starts an inline UPSERT ingestion job against the bound library.
223 // Results land in the fixture bucket with the caller's S3
224 // permissions.
225 const physicalBucket = yield* bucketName;
226 const result = yield* ingestLibraryEntities({
227 entityType: "VOCABULARY",
228 operationType: "UPSERT",
229 inputConfiguration: {
230 inlinePayload: {
231 upsertEntitiesInfo: [
232 {
233 vocabulary: {
234 language: "EN",
235 phrases: [{ text: "Alchemy", displayAsText: "Alchemy" }],
236 },
237 },
238 ],
239 },
240 },
241 outputConfiguration: {
242 s3Uri: `s3://${physicalBucket}/library-results/`,
243 },
244 }).pipe(
245 Effect.map((r) => ({ jobArn: r.jobArn })),
246 // Surface the FULL cause (typed failure or defect) to the test —
247 // the assertion prints it on mismatch.
248 Effect.catchCause((cause) =>
249 Effect.succeed({
250 jobArn: undefined,
251 error: "Cause",
252 message: String(cause),
253 }),
254 ),
255 );
256 return yield* HttpServerResponse.json(result);
257 }
258
259 if (request.method === "GET" && pathname === "/library-ingestion-job") {
260 const jobArn = url.searchParams.get("jobArn")!;
261 const result = yield* getLibraryIngestionJob({ jobArn });
262 return yield* HttpServerResponse.json({
263 status: result.job?.jobStatus,
264 });
265 }
266
267 if (
268 request.method === "GET" &&
269 pathname === "/library-ingestion-jobs"
270 ) {
271 const result = yield* listLibraryIngestionJobs({ maxResults: 25 });
272 return yield* HttpServerResponse.json({
273 count: (result.jobs ?? []).length,
274 });
275 }
276
277 if (request.method === "GET" && pathname === "/library-entities") {
278 const result = yield* listLibraryEntities({
279 entityType: "VOCABULARY",
280 maxResults: 25,
281 });
282 return yield* HttpServerResponse.json({
283 count: (result.entities ?? []).length,
284 });
285 }
286
287 if (
288 request.method === "GET" &&
289 pathname === "/library-entity-missing"
290 ) {
291 // Proves bedrock:GetDataAutomationLibraryEntity + library injection
292 // via the typed not-found path — an IAM gap would surface
293 // AccessDeniedException (a 500 through orDie) instead of the tag.
294 const tag = yield* getLibraryEntity({
295 entityType: "VOCABULARY",
296 entityId: "nonexistent-alchemy-probe",
297 }).pipe(
298 Effect.map(() => "Found"),
299 Effect.catchTag(
300 ["ResourceNotFoundException", "ValidationException"],
301 (e) => Effect.succeed(e._tag),
302 ),
303 Effect.catchCause((cause) =>
304 Effect.succeed(`Cause: ${String(cause)}`),
305 ),
306 );
307 return yield* HttpServerResponse.json({ tag });
308 }
309
310 if (request.method === "POST" && pathname === "/blueprint-version") {
311 const result = yield* createBlueprintVersion({}).pipe(
312 Effect.map((r) => ({
313 version: r.blueprint.blueprintVersion,
314 arn: r.blueprint.blueprintArn,
315 })),
316 Effect.catch((e) =>
317 Effect.succeed({
318 version: undefined,
319 arn: undefined,
320 error: e._tag,
321 message: String((e as { message?: unknown }).message ?? ""),
322 }),
323 ),
324 );
325 return yield* HttpServerResponse.json(result);
326 }
327
328 if (
329 request.method === "POST" &&
330 pathname === "/copy-stage-validation"
331 ) {
332 // The fixture blueprint only exists in LIVE, so copying from the
333 // (absent) DEVELOPMENT stage drives the typed error path without
334 // creating a second stage copy the stack would have to clean up.
335 const tag = yield* copyBlueprintStage({
336 sourceStage: "DEVELOPMENT",
337 targetStage: "LIVE",
338 }).pipe(
339 Effect.map(() => "Copied"),
340 Effect.catchTag(
341 ["ResourceNotFoundException", "ValidationException"],
342 (e) => Effect.succeed(e._tag),
343 ),
344 Effect.catchCause((cause) =>
345 Effect.succeed(`Cause: ${String(cause)}`),
346 ),
347 );
348 return yield* HttpServerResponse.json({ tag });
349 }
350
351 if (request.method === "POST" && pathname === "/optimize-validation") {
352 // An empty samples list is invalid (min 1 labeled pair), so this
353 // drives the typed ValidationException path — proving IAM +
354 // blueprint injection without starting (and paying for) a real
355 // optimization job.
356 const profileArn = url.searchParams.get("profileArn")!;
357 const physicalBucket = yield* bucketName;
358 const tag = yield* invokeBlueprintOptimizationAsync({
359 samples: [],
360 outputConfiguration: {
361 s3Object: {
362 s3Uri: `s3://${physicalBucket}/optimization-results/`,
363 },
364 },
365 dataAutomationProfileArn: profileArn,
366 }).pipe(
367 Effect.map(() => "Invoked"),
368 Effect.catchTag("ValidationException", (e) =>
369 Effect.succeed(e._tag),
370 ),
371 Effect.catchCause((cause) =>
372 Effect.succeed(`Cause: ${String(cause)}`),
373 ),
374 );
375 return yield* HttpServerResponse.json({ tag });
376 }
377
378 if (
379 request.method === "GET" &&
380 pathname === "/optimization-status-missing"
381 ) {
382 // Proves bedrock:GetBlueprintOptimizationStatus via the typed
383 // not-found path on a well-formed but nonexistent invocation ARN
384 // supplied by the test.
385 const invocationArn = url.searchParams.get("invocationArn")!;
386 const tag = yield* getBlueprintOptimizationStatus({
387 invocationArn,
388 }).pipe(
389 Effect.map((r) => r.status ?? "Unknown"),
390 Effect.catchTag(
391 ["ResourceNotFoundException", "ValidationException"],
392 (e) => Effect.succeed(e._tag),
393 ),
394 Effect.catchCause((cause) =>
395 Effect.succeed(`Cause: ${String(cause)}`),
396 ),
397 );
398 return yield* HttpServerResponse.json({ tag });
399 }
400
401 return yield* HttpServerResponse.json(
402 { error: "Not found", method: request.method, pathname },
403 { status: 404 },
404 );
405 }).pipe(Effect.orDie),
406 };
407 }).pipe(
408 Effect.provide(
409 Layer.mergeAll(
410 Lambda.EventSource,
411 S3.PutObjectHttp,
412 S3.GetObjectHttp,
413 BDA.InvokeDataAutomationAsyncHttp,
414 BDA.InvokeDataAutomationHttp,
415 BDA.GetDataAutomationStatusHttp,
416 BDA.InvokeDataAutomationLibraryIngestionJobHttp,
417 BDA.GetDataAutomationLibraryIngestionJobHttp,
418 BDA.ListDataAutomationLibraryIngestionJobsHttp,
419 BDA.GetDataAutomationLibraryEntityHttp,
420 BDA.ListDataAutomationLibraryEntitiesHttp,
421 BDA.CreateBlueprintVersionHttp,
422 BDA.CopyBlueprintStageHttp,
423 BDA.InvokeBlueprintOptimizationAsyncHttp,
424 BDA.GetBlueprintOptimizationStatusHttp,
425 ),
426 ),
427 ),
428);