1import * as BCMDataExports from "@/AWS/BCMDataExports";
2import * as Lambda from "@/AWS/Lambda";
3import { Bucket } from "@/AWS/S3/Bucket.ts";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "handler.ts");
11
12const bucketName = "alchemy-test-bcm-bindings-dest";
13export const fixtureExportName = "alchemy-test-bcm-bindings-export";
14
15// A well-formed-but-nonexistent execution id — drives the typed error path
16// for GetExecution (a fresh export has no executions to read yet).
17const NONEXISTENT_EXECUTION_ID = "00000000-0000-0000-0000-000000000000";
18
19const queryStatement =
20 "SELECT identity_line_item_id, identity_time_interval, line_item_unblended_cost FROM COST_AND_USAGE_REPORT";
21
22export class BCMDataExportsTestFunction extends Lambda.Function<Lambda.Function>()(
23 "BCMDataExportsTestFunction",
24) {}
25
26export default BCMDataExportsTestFunction.make(
27 {
28 main,
29 functionUrl: true,
30 },
31 Effect.gen(function* () {
32 // Destination bucket must let the Data Exports service principals write
33 // to it and read its policy.
34 const bucket = yield* Bucket("BindingsExportDest", {
35 bucketName,
36 forceDestroy: true,
37 policy: [
38 {
39 Sid: "EnableAWSDataExportsToWriteToS3AndCheckPolicy",
40 Effect: "Allow",
41 Principal: {
42 Service: [
43 "billingreports.amazonaws.com",
44 "bcm-data-exports.amazonaws.com",
45 ],
46 },
47 Action: ["s3:PutObject", "s3:GetBucketPolicy"],
48 Resource: [
49 `arn:aws:s3:::${bucketName}`,
50 `arn:aws:s3:::${bucketName}/*`,
51 ],
52 },
53 ],
54 });
55
56 const dataExport = yield* BCMDataExports.Export("BindingsExport", {
57 exportName: fixtureExportName,
58 description: "alchemy bcm-data-exports bindings fixture",
59 dataQuery: {
60 queryStatement,
61 tableConfigurations: {
62 COST_AND_USAGE_REPORT: {
63 TIME_GRANULARITY: "HOURLY",
64 INCLUDE_RESOURCES: "FALSE",
65 INCLUDE_MANUAL_DISCOUNT_COMPATIBILITY: "FALSE",
66 INCLUDE_SPLIT_COST_ALLOCATION_DATA: "FALSE",
67 },
68 },
69 },
70 s3Destination: {
71 s3Bucket: bucket.bucketName,
72 s3Prefix: "bindings",
73 s3Region: "us-east-1",
74 },
75 });
76
77 // --- export-scoped bindings ---
78 const getExport = yield* BCMDataExports.GetExport(dataExport);
79 const getExecution = yield* BCMDataExports.GetExecution(dataExport);
80 const listExecutions = yield* BCMDataExports.ListExecutions(dataExport);
81
82 // --- account-level bindings ---
83 const listExports = yield* BCMDataExports.ListExports();
84 const getTable = yield* BCMDataExports.GetTable();
85 const listTables = yield* BCMDataExports.ListTables();
86
87 const bound = {
88 getExport,
89 getExecution,
90 listExecutions,
91 listExports,
92 getTable,
93 listTables,
94 };
95
96 return {
97 fetch: Effect.gen(function* () {
98 const request = yield* HttpServerRequest;
99 const url = new URL(request.originalUrl);
100 const pathname = url.pathname;
101
102 if (request.method === "GET" && pathname === "/bindings") {
103 return yield* HttpServerResponse.json({
104 bound: Object.keys(bound),
105 });
106 }
107
108 if (request.method === "GET" && pathname === "/export") {
109 const result = yield* getExport();
110 return yield* HttpServerResponse.json({
111 name: result.Export?.Name ?? null,
112 prefix:
113 result.Export?.DestinationConfigurations.S3Destination.S3Prefix ??
114 null,
115 frequency: result.Export?.RefreshCadence.Frequency ?? null,
116 });
117 }
118
119 if (request.method === "GET" && pathname === "/exports") {
120 const result = yield* listExports({ MaxResults: 100 });
121 return yield* HttpServerResponse.json({
122 names: (result.Exports ?? []).map((e) => e.ExportName ?? null),
123 });
124 }
125
126 if (request.method === "GET" && pathname === "/executions") {
127 const result = yield* listExecutions({ MaxResults: 25 });
128 return yield* HttpServerResponse.json({
129 count: (result.Executions ?? []).length,
130 });
131 }
132
133 if (request.method === "GET" && pathname === "/execution-not-found") {
134 // Exercises `ExportArn` injection + the typed error path — a fresh
135 // export has no executions, so the nonexistent id must surface a
136 // typed tag (an IAM gap would surface AccessDeniedException and
137 // fail the route with an opaque 500 instead).
138 const tag = yield* getExecution({
139 ExecutionId: NONEXISTENT_EXECUTION_ID,
140 }).pipe(
141 Effect.map(() => "Found"),
142 Effect.catchTag(
143 ["ResourceNotFoundException", "ValidationException"],
144 (e) => Effect.succeed(e._tag),
145 ),
146 );
147 return yield* HttpServerResponse.json({ tag });
148 }
149
150 if (request.method === "GET" && pathname === "/table") {
151 const result = yield* getTable({
152 TableName: "COST_AND_USAGE_REPORT",
153 });
154 return yield* HttpServerResponse.json({
155 tableName: result.TableName ?? null,
156 columnCount: (result.Schema ?? []).length,
157 });
158 }
159
160 if (request.method === "GET" && pathname === "/tables") {
161 const result = yield* listTables({ MaxResults: 100 });
162 return yield* HttpServerResponse.json({
163 names: (result.Tables ?? []).map((t) => t.TableName ?? null),
164 });
165 }
166
167 return yield* HttpServerResponse.json(
168 { error: "Not found", method: request.method, pathname },
169 { status: 404 },
170 );
171 }).pipe(Effect.orDie),
172 };
173 }).pipe(
174 Effect.provide(
175 Layer.mergeAll(
176 BCMDataExports.GetExportHttp,
177 BCMDataExports.GetExecutionHttp,
178 BCMDataExports.ListExecutionsHttp,
179 BCMDataExports.ListExportsHttp,
180 BCMDataExports.GetTableHttp,
181 BCMDataExports.ListTablesHttp,
182 ),
183 ),
184 ),
185);