1import * as Lambda from "@/AWS/Lambda";
2import * as QuickSight from "@/AWS/QuickSight";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Stream from "effect/Stream";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import path from "pathe";
10
11const main = path.resolve(import.meta.dirname, "bindings-handler.ts");
12
13export class QuickSightBindingsFunction extends Lambda.Function<Lambda.Function>()(
14 "QuickSightBindingsFunction",
15) {}
16
17export default QuickSightBindingsFunction.make(
18 {
19 main,
20 functionUrl: true,
21 timeout: Duration.seconds(30),
22 },
23 Effect.gen(function* () {
24 const source = yield* QuickSight.DataSource("BindingsSource", {
25 name: "Alchemy QuickSight Bindings Source",
26 type: "ATHENA",
27 dataSourceParameters: { AthenaParameters: { WorkGroup: "primary" } },
28 });
29
30 const dataSet = yield* QuickSight.DataSet("BindingsDataSet", {
31 name: "Alchemy QuickSight Bindings DataSet",
32 importMode: "DIRECT_QUERY",
33 physicalTableMap: {
34 probe: {
35 CustomSql: {
36 DataSourceArn: source.arn,
37 Name: "probe",
38 SqlQuery: "SELECT 1 AS n",
39 Columns: [{ Name: "n", Type: "INTEGER" }],
40 },
41 },
42 },
43 });
44
45 const dashboard = yield* QuickSight.Dashboard("BindingsDashboard", {
46 name: "Alchemy QuickSight Bindings Dashboard",
47 definition: {
48 DataSetIdentifierDeclarations: [
49 { Identifier: "probe", DataSetArn: dataSet.arn },
50 ],
51 Sheets: [{ SheetId: "sheet1", Name: "Sheet 1" }],
52 },
53 });
54
55 const createIngestion = yield* QuickSight.CreateIngestion(dataSet);
56 const describeIngestion = yield* QuickSight.DescribeIngestion(dataSet);
57 const cancelIngestion = yield* QuickSight.CancelIngestion(dataSet);
58 const listIngestions = yield* QuickSight.ListIngestions(dataSet);
59 const startSnapshotJob =
60 yield* QuickSight.StartDashboardSnapshotJob(dashboard);
61 const describeSnapshotJob =
62 yield* QuickSight.DescribeDashboardSnapshotJob(dashboard);
63 const describeSnapshotJobResult =
64 yield* QuickSight.DescribeDashboardSnapshotJobResult(dashboard);
65 const generateEmbedUrlForRegisteredUser =
66 yield* QuickSight.GenerateEmbedUrlForRegisteredUser(dashboard);
67 const generateEmbedUrlForAnonymousUser =
68 yield* QuickSight.GenerateEmbedUrlForAnonymousUser(dashboard);
69
70 const bound = {
71 createIngestion,
72 describeIngestion,
73 cancelIngestion,
74 listIngestions,
75 startSnapshotJob,
76 describeSnapshotJob,
77 describeSnapshotJobResult,
78 generateEmbedUrlForRegisteredUser,
79 generateEmbedUrlForAnonymousUser,
80 };
81
82 return {
83 fetch: Effect.gen(function* () {
84 const request = yield* HttpServerRequest;
85 const url = new URL(request.originalUrl);
86 const pathname = url.pathname;
87
88 if (request.method === "GET" && pathname === "/bindings") {
89 return yield* HttpServerResponse.json({
90 bound: Object.keys(bound),
91 });
92 }
93
94 if (request.method === "GET" && pathname === "/ingestions") {
95 // DataSetId + AwsAccountId injection scope the call to the bound
96 // dataset.
97 const response = yield* listIngestions({ MaxResults: 10 });
98 return yield* HttpServerResponse.json({
99 count: (response.Ingestions ?? []).length,
100 });
101 }
102
103 if (request.method === "POST" && pathname === "/ingestion") {
104 // A DIRECT_QUERY dataset cannot be ingested into SPICE — the typed
105 // 400 proves the CreateIngestion grant + id injection end-to-end
106 // without mutating anything. An IAM gap would surface
107 // AccessDeniedException instead.
108 const ingestionId = yield* Effect.sync(() => crypto.randomUUID());
109 const created = yield* createIngestion({
110 IngestionId: ingestionId,
111 IngestionType: "FULL_REFRESH",
112 }).pipe(
113 Effect.map((r) => ({ started: true as const, id: r.IngestionId })),
114 Effect.catchTag(
115 ["InvalidParameterValueException", "ResourceNotFoundException"],
116 (e) => Effect.succeed({ started: false as const, error: e._tag }),
117 ),
118 );
119 if (!created.started) {
120 return yield* HttpServerResponse.json(created);
121 }
122 // SPICE dataset path: observe then cancel the refresh.
123 const status = yield* describeIngestion({
124 IngestionId: ingestionId,
125 }).pipe(
126 Effect.map((r) => r.Ingestion?.IngestionStatus),
127 Effect.catchTag("ResourceNotFoundException", () =>
128 Effect.succeed(undefined),
129 ),
130 );
131 yield* cancelIngestion({ IngestionId: ingestionId }).pipe(
132 Effect.catchTag(
133 ["InvalidParameterValueException", "ResourceNotFoundException"],
134 () => Effect.void,
135 ),
136 );
137 return yield* HttpServerResponse.json({ ...created, status });
138 }
139
140 if (
141 request.method === "GET" &&
142 pathname === "/snapshot-job/typed-not-found"
143 ) {
144 // Both describe ops on a nonexistent job id surface the typed
145 // not-found, proving grant + DashboardId injection.
146 const describe = yield* describeSnapshotJob({
147 SnapshotJobId: "alchemy-nonexistent-snapshot-job",
148 }).pipe(
149 Effect.map(() => false),
150 Effect.catchTag("ResourceNotFoundException", () =>
151 Effect.succeed(true),
152 ),
153 );
154 const result = yield* describeSnapshotJobResult({
155 SnapshotJobId: "alchemy-nonexistent-snapshot-job",
156 }).pipe(
157 Effect.map(() => false),
158 Effect.catchTag(
159 ["ResourceNotFoundException", "InvalidParameterValueException"],
160 () => Effect.succeed(true),
161 ),
162 );
163 return yield* HttpServerResponse.json({ describe, result });
164 }
165
166 if (request.method === "POST" && pathname === "/snapshot-job") {
167 // Snapshot export requires paginated-report entitlement on some
168 // accounts; either a started job or the typed rejection proves the
169 // grant.
170 const jobId = yield* Effect.sync(() => crypto.randomUUID());
171 const outcome = yield* startSnapshotJob({
172 SnapshotJobId: jobId,
173 UserConfiguration: { AnonymousUsers: [] },
174 SnapshotConfiguration: {
175 FileGroups: [
176 {
177 Files: [
178 {
179 FormatType: "PDF",
180 SheetSelections: [
181 { SheetId: "sheet1", SelectionScope: "ALL_VISUALS" },
182 ],
183 },
184 ],
185 },
186 ],
187 },
188 }).pipe(
189 Effect.map((r) => ({ started: true, jobId: r.SnapshotJobId })),
190 Effect.catchTag(
191 [
192 "InvalidParameterValueException",
193 "UnsupportedPricingPlanException",
194 "UnsupportedUserEditionException",
195 "LimitExceededException",
196 ],
197 (e) => Effect.succeed({ started: false, error: e._tag }),
198 ),
199 );
200 return yield* HttpServerResponse.json(outcome);
201 }
202
203 if (request.method === "GET" && pathname === "/embed-url") {
204 // A nonexistent user arn surfaces the typed user-not-found — the
205 // request reached the account-scoped embed API with the injected
206 // AwsAccountId.
207 const typed = yield* generateEmbedUrlForRegisteredUser({
208 UserArn: url.searchParams.get("userArn") ?? "arn:invalid",
209 }).pipe(
210 Effect.map(() => "EmbedUrl"),
211 Effect.catchTag(
212 [
213 "QuickSightUserNotFoundException",
214 "ResourceNotFoundException",
215 "InvalidParameterValueException",
216 ],
217 (e) => Effect.succeed(e._tag),
218 ),
219 );
220 return yield* HttpServerResponse.json({ typed });
221 }
222
223 if (request.method === "GET" && pathname === "/embed-url-anon") {
224 // Anonymous embedding needs session-capacity pricing; either the
225 // URL or the typed pricing-plan rejection proves the grant.
226 const outcome = yield* generateEmbedUrlForAnonymousUser().pipe(
227 Effect.map(() => ({ ok: true as const })),
228 Effect.catchTag(
229 [
230 "UnsupportedPricingPlanException",
231 "SessionLifetimeInMinutesInvalidException",
232 "InvalidParameterValueException",
233 ],
234 (e) => Effect.succeed({ ok: false as const, error: e._tag }),
235 ),
236 );
237 return yield* HttpServerResponse.json(outcome);
238 }
239
240 return yield* HttpServerResponse.json(
241 { error: "Not found", method: request.method, pathname },
242 { status: 404 },
243 );
244 }).pipe(Effect.orDie),
245 };
246 }).pipe(
247 Effect.provide(
248 Layer.mergeAll(
249 QuickSight.CreateIngestionHttp,
250 QuickSight.DescribeIngestionHttp,
251 QuickSight.CancelIngestionHttp,
252 QuickSight.ListIngestionsHttp,
253 QuickSight.StartDashboardSnapshotJobHttp,
254 QuickSight.DescribeDashboardSnapshotJobHttp,
255 QuickSight.DescribeDashboardSnapshotJobResultHttp,
256 QuickSight.GenerateEmbedUrlForRegisteredUserHttp,
257 QuickSight.GenerateEmbedUrlForAnonymousUserHttp,
258 ),
259 ),
260 ),
261);