1import * as Lambda from "@/AWS/Lambda";
2import * as Personalize from "@/AWS/Personalize";
3import * as Duration from "effect/Duration";
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
12export class PersonalizeTestFunction extends Lambda.Function<Lambda.Function>()(
13 "PersonalizeTestFunction",
14) {}
15
16const INTERACTIONS_SCHEMA = JSON.stringify({
17 type: "record",
18 name: "Interactions",
19 namespace: "com.amazonaws.personalize.schema",
20 fields: [
21 { name: "USER_ID", type: "string" },
22 { name: "ITEM_ID", type: "string" },
23 { name: "TIMESTAMP", type: "long" },
24 ],
25 version: "1.0",
26});
27
28const ITEMS_SCHEMA = JSON.stringify({
29 type: "record",
30 name: "Items",
31 namespace: "com.amazonaws.personalize.schema",
32 fields: [
33 { name: "ITEM_ID", type: "string" },
34 { name: "CATEGORY", type: "string", categorical: true },
35 ],
36 version: "1.0",
37});
38
39const USERS_SCHEMA = JSON.stringify({
40 type: "record",
41 name: "Users",
42 namespace: "com.amazonaws.personalize.schema",
43 fields: [
44 { name: "USER_ID", type: "string" },
45 { name: "MEMBERSHIP", type: "string", categorical: true },
46 ],
47 version: "1.0",
48});
49
50/**
51 * Personalize bindings fixture: deploys the cheap definition resources (three
52 * schemas, a dataset group, Interactions/Items/Users datasets, and an event
53 * tracker) plus a Lambda bound to all nineteen Personalize bindings.
54 *
55 * The event-ingestion routes (`/put-events`, `/put-items`, `/put-users`)
56 * exercise the data plane for real against the deployed datasets/tracker.
57 * The campaign/solution routes are probes: training a solution takes ~an hour
58 * of paid compute, so they drive well-formed-but-nonexistent ARNs (passed by
59 * the test as a query parameter) and return the typed error tag — proving the
60 * deploy-time IAM bind, the runtime call plumbing, and the typed error decode.
61 */
62export default PersonalizeTestFunction.make(
63 {
64 main,
65 functionUrl: true,
66 timeout: Duration.seconds(30),
67 },
68 Effect.gen(function* () {
69 const interactionsSchema = yield* Personalize.Schema("InteractionsSchema", {
70 schema: INTERACTIONS_SCHEMA,
71 });
72 const itemsSchema = yield* Personalize.Schema("ItemsSchema", {
73 schema: ITEMS_SCHEMA,
74 });
75 const usersSchema = yield* Personalize.Schema("UsersSchema", {
76 schema: USERS_SCHEMA,
77 });
78 const group = yield* Personalize.DatasetGroup("BindingsGroup", {});
79 const interactions = yield* Personalize.Dataset("Interactions", {
80 schemaArn: interactionsSchema.schemaArn,
81 datasetGroupArn: group.datasetGroupArn,
82 datasetType: "Interactions",
83 });
84 const items = yield* Personalize.Dataset("Items", {
85 schemaArn: itemsSchema.schemaArn,
86 datasetGroupArn: group.datasetGroupArn,
87 datasetType: "Items",
88 });
89 const users = yield* Personalize.Dataset("Users", {
90 schemaArn: usersSchema.schemaArn,
91 datasetGroupArn: group.datasetGroupArn,
92 datasetType: "Users",
93 });
94 // Depend on the Interactions dataset (events are recorded into it) so the
95 // tracker is created after it and deleted before it.
96 const tracker = yield* Personalize.EventTracker("Tracker", {
97 datasetGroupArn: interactions.datasetGroupArn,
98 });
99
100 const putEvents = yield* Personalize.PutEvents(tracker);
101 const putActionInteractions =
102 yield* Personalize.PutActionInteractions(tracker);
103 const putItems = yield* Personalize.PutItems(items);
104 const putUsers = yield* Personalize.PutUsers(users);
105 // Bound to the Items dataset on purpose: without an Actions dataset the
106 // route is a typed-error probe for the PutActions plumbing.
107 const putActions = yield* Personalize.PutActions(items);
108 const getRecommendations = yield* Personalize.GetRecommendations();
109 const getPersonalizedRanking = yield* Personalize.GetPersonalizedRanking();
110 const getActionRecommendations =
111 yield* Personalize.GetActionRecommendations();
112 const createDatasetImportJob = yield* Personalize.CreateDatasetImportJob();
113 const describeDatasetImportJob =
114 yield* Personalize.DescribeDatasetImportJob();
115 const createSolutionVersion = yield* Personalize.CreateSolutionVersion();
116 const describeSolutionVersion =
117 yield* Personalize.DescribeSolutionVersion();
118 const updateCampaign = yield* Personalize.UpdateCampaign();
119 const describeCampaign = yield* Personalize.DescribeCampaign();
120 const createSolution = yield* Personalize.CreateSolution();
121 const createCampaign = yield* Personalize.CreateCampaign();
122 const getSolutionMetrics = yield* Personalize.GetSolutionMetrics();
123 const createBatchInferenceJob =
124 yield* Personalize.CreateBatchInferenceJob();
125 const describeBatchInferenceJob =
126 yield* Personalize.DescribeBatchInferenceJob();
127
128 const bound = {
129 putEvents,
130 putActionInteractions,
131 putItems,
132 putUsers,
133 putActions,
134 getRecommendations,
135 getPersonalizedRanking,
136 getActionRecommendations,
137 createDatasetImportJob,
138 describeDatasetImportJob,
139 createSolutionVersion,
140 describeSolutionVersion,
141 updateCampaign,
142 describeCampaign,
143 createSolution,
144 createCampaign,
145 getSolutionMetrics,
146 createBatchInferenceJob,
147 describeBatchInferenceJob,
148 };
149
150 return {
151 fetch: Effect.gen(function* () {
152 const request = yield* HttpServerRequest;
153 const url = new URL(request.originalUrl);
154 const pathname = url.pathname;
155 const arn = url.searchParams.get("arn") ?? "";
156 const roleArn =
157 url.searchParams.get("role") ??
158 "arn:aws:iam::000000000000:role/alchemy_probe";
159
160 if (pathname === "/bindings") {
161 return yield* HttpServerResponse.json({ bound: Object.keys(bound) });
162 }
163
164 if (pathname === "/put-events") {
165 const tag = yield* putEvents({
166 sessionId: "alchemy-session-1",
167 userId: "alchemy-user-1",
168 eventList: [
169 {
170 eventType: "click",
171 itemId: "alchemy-item-1",
172 sentAt: new Date(),
173 },
174 ],
175 }).pipe(
176 Effect.map(() => "Recorded"),
177 Effect.catch((e) => Effect.succeed(e._tag)),
178 );
179 return yield* HttpServerResponse.json({ tag });
180 }
181
182 if (pathname === "/put-items") {
183 const tag = yield* putItems({
184 items: [
185 {
186 itemId: "alchemy-item-1",
187 properties: JSON.stringify({ category: "books" }),
188 },
189 ],
190 }).pipe(
191 Effect.map(() => "Recorded"),
192 Effect.catch((e) => Effect.succeed(e._tag)),
193 );
194 return yield* HttpServerResponse.json({ tag });
195 }
196
197 if (pathname === "/put-users") {
198 const tag = yield* putUsers({
199 users: [
200 {
201 userId: "alchemy-user-1",
202 properties: JSON.stringify({ membership: "gold" }),
203 },
204 ],
205 }).pipe(
206 Effect.map(() => "Recorded"),
207 Effect.catch((e) => Effect.succeed(e._tag)),
208 );
209 return yield* HttpServerResponse.json({ tag });
210 }
211
212 if (pathname === "/put-actions-probe") {
213 const tag = yield* putActions({
214 actions: [{ actionId: "alchemy-action-1" }],
215 }).pipe(
216 Effect.map(() => "Recorded"),
217 Effect.catch((e) => Effect.succeed(e._tag)),
218 );
219 return yield* HttpServerResponse.json({ tag });
220 }
221
222 if (pathname === "/put-action-interactions-probe") {
223 const tag = yield* putActionInteractions({
224 actionInteractions: [
225 {
226 actionId: "alchemy-action-1",
227 userId: "alchemy-user-1",
228 sessionId: "alchemy-session-1",
229 eventType: "Taken",
230 timestamp: new Date(),
231 },
232 ],
233 }).pipe(
234 Effect.map(() => "Recorded"),
235 Effect.catch((e) => Effect.succeed(e._tag)),
236 );
237 return yield* HttpServerResponse.json({ tag });
238 }
239
240 if (pathname === "/recommendations-probe") {
241 const tag = yield* getRecommendations({
242 campaignArn: arn,
243 userId: "alchemy-user-1",
244 }).pipe(
245 Effect.map(() => "Recommended"),
246 Effect.catch((e) => Effect.succeed(e._tag)),
247 );
248 return yield* HttpServerResponse.json({ tag });
249 }
250
251 if (pathname === "/ranking-probe") {
252 const tag = yield* getPersonalizedRanking({
253 campaignArn: arn,
254 userId: "alchemy-user-1",
255 inputList: ["alchemy-item-1", "alchemy-item-2"],
256 }).pipe(
257 Effect.map(() => "Ranked"),
258 Effect.catch((e) => Effect.succeed(e._tag)),
259 );
260 return yield* HttpServerResponse.json({ tag });
261 }
262
263 if (pathname === "/action-recommendations-probe") {
264 const tag = yield* getActionRecommendations({
265 campaignArn: arn,
266 userId: "alchemy-user-1",
267 }).pipe(
268 Effect.map(() => "Recommended"),
269 Effect.catch((e) => Effect.succeed(e._tag)),
270 );
271 return yield* HttpServerResponse.json({ tag });
272 }
273
274 if (pathname === "/import-create-probe") {
275 const tag = yield* createDatasetImportJob({
276 jobName: "alchemy-import-probe",
277 datasetArn: arn,
278 dataSource: {
279 dataLocation: "s3://alchemy-nonexistent-probe-bucket/data.csv",
280 },
281 roleArn,
282 }).pipe(
283 Effect.map(() => "Created"),
284 Effect.catch((e) => Effect.succeed(e._tag)),
285 );
286 return yield* HttpServerResponse.json({ tag });
287 }
288
289 if (pathname === "/import-probe") {
290 const tag = yield* describeDatasetImportJob({
291 datasetImportJobArn: arn,
292 }).pipe(
293 Effect.map(() => "Found"),
294 Effect.catch((e) => Effect.succeed(e._tag)),
295 );
296 return yield* HttpServerResponse.json({ tag });
297 }
298
299 if (pathname === "/solution-version-create-probe") {
300 const tag = yield* createSolutionVersion({
301 solutionArn: arn,
302 }).pipe(
303 Effect.map(() => "Created"),
304 Effect.catch((e) => Effect.succeed(e._tag)),
305 );
306 return yield* HttpServerResponse.json({ tag });
307 }
308
309 if (pathname === "/solution-version-probe") {
310 const tag = yield* describeSolutionVersion({
311 solutionVersionArn: arn,
312 }).pipe(
313 Effect.map(() => "Found"),
314 Effect.catch((e) => Effect.succeed(e._tag)),
315 );
316 return yield* HttpServerResponse.json({ tag });
317 }
318
319 if (pathname === "/campaign-update-probe") {
320 const tag = yield* updateCampaign({ campaignArn: arn }).pipe(
321 Effect.map(() => "Updated"),
322 Effect.catch((e) => Effect.succeed(e._tag)),
323 );
324 return yield* HttpServerResponse.json({ tag });
325 }
326
327 if (pathname === "/campaign-probe") {
328 const tag = yield* describeCampaign({ campaignArn: arn }).pipe(
329 Effect.map(() => "Found"),
330 Effect.catch((e) => Effect.succeed(e._tag)),
331 );
332 return yield* HttpServerResponse.json({ tag });
333 }
334
335 if (pathname === "/solution-create-probe") {
336 const tag = yield* createSolution({
337 name: "alchemy-solution-probe",
338 recipeArn: "arn:aws:personalize:::recipe/aws-user-personalization",
339 datasetGroupArn: arn,
340 }).pipe(
341 Effect.map(() => "Created"),
342 Effect.catch((e) => Effect.succeed(e._tag)),
343 );
344 return yield* HttpServerResponse.json({ tag });
345 }
346
347 if (pathname === "/campaign-create-probe") {
348 const tag = yield* createCampaign({
349 name: "alchemy-campaign-probe",
350 solutionVersionArn: arn,
351 }).pipe(
352 Effect.map(() => "Created"),
353 Effect.catch((e) => Effect.succeed(e._tag)),
354 );
355 return yield* HttpServerResponse.json({ tag });
356 }
357
358 if (pathname === "/solution-metrics-probe") {
359 const tag = yield* getSolutionMetrics({
360 solutionVersionArn: arn,
361 }).pipe(
362 Effect.map(() => "Found"),
363 Effect.catch((e) => Effect.succeed(e._tag)),
364 );
365 return yield* HttpServerResponse.json({ tag });
366 }
367
368 if (pathname === "/batch-create-probe") {
369 const tag = yield* createBatchInferenceJob({
370 jobName: "alchemy-batch-probe",
371 solutionVersionArn: arn,
372 jobInput: {
373 s3DataSource: {
374 path: "s3://alchemy-nonexistent-probe-bucket/users.json",
375 },
376 },
377 jobOutput: {
378 s3DataDestination: {
379 path: "s3://alchemy-nonexistent-probe-bucket/scores/",
380 },
381 },
382 roleArn,
383 }).pipe(
384 Effect.map(() => "Created"),
385 Effect.catch((e) => Effect.succeed(e._tag)),
386 );
387 return yield* HttpServerResponse.json({ tag });
388 }
389
390 if (pathname === "/batch-probe") {
391 const tag = yield* describeBatchInferenceJob({
392 batchInferenceJobArn: arn,
393 }).pipe(
394 Effect.map(() => "Found"),
395 Effect.catch((e) => Effect.succeed(e._tag)),
396 );
397 return yield* HttpServerResponse.json({ tag });
398 }
399
400 return yield* HttpServerResponse.json(
401 { error: "Not found", pathname },
402 { status: 404 },
403 );
404 }).pipe(Effect.orDie),
405 };
406 }).pipe(
407 Effect.provide(
408 Layer.mergeAll(
409 Personalize.PutEventsHttp,
410 Personalize.PutActionInteractionsHttp,
411 Personalize.PutItemsHttp,
412 Personalize.PutUsersHttp,
413 Personalize.PutActionsHttp,
414 Personalize.GetRecommendationsHttp,
415 Personalize.GetPersonalizedRankingHttp,
416 Personalize.GetActionRecommendationsHttp,
417 Personalize.CreateDatasetImportJobHttp,
418 Personalize.DescribeDatasetImportJobHttp,
419 Personalize.CreateSolutionVersionHttp,
420 Personalize.DescribeSolutionVersionHttp,
421 Personalize.UpdateCampaignHttp,
422 Personalize.DescribeCampaignHttp,
423 Personalize.CreateSolutionHttp,
424 Personalize.CreateCampaignHttp,
425 Personalize.GetSolutionMetricsHttp,
426 Personalize.CreateBatchInferenceJobHttp,
427 Personalize.DescribeBatchInferenceJobHttp,
428 ),
429 ),
430 ),
431);