| 1 | import * as Comprehend from "@/AWS/Comprehend"; |
| 2 | import * as IAM from "@/AWS/IAM"; |
| 3 | import * as Lambda from "@/AWS/Lambda"; |
| 4 | import * as S3 from "@/AWS/S3"; |
| 5 | import * as Duration from "effect/Duration"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Schedule from "effect/Schedule"; |
| 9 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 10 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 11 | import path from "pathe"; |
| 12 | |
| 13 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 14 | |
| 15 | // Deterministic fixture names (bucket names are account-global; this suite |
| 16 | // owns them in the testing account). |
| 17 | export const FIXTURE_BUCKET_NAME = "alchemy-test-comprehend-bindings"; |
| 18 | |
| 19 | // Well-formed-but-nonexistent job id used to drive the typed |
| 20 | // JobNotFoundException paths. An IAM gap would surface AccessDeniedException |
| 21 | // (a 500 through the handler's orDie), so a typed not-found tag proves the |
| 22 | // grant end-to-end. |
| 23 | const BOGUS_JOB_ID = "00000000000000000000000000000000"; |
| 24 | |
| 25 | // An S3 URI that fails Comprehend's server-side pattern validation — drives |
| 26 | // the Start*Job bindings through their typed ValidationException path |
| 27 | // without actually starting (and paying for) an async job per family. |
| 28 | const INVALID_S3_URI = "invalid-uri"; |
| 29 | |
| 30 | export class ComprehendTestFunction extends Lambda.Function<Lambda.Function>()( |
| 31 | "ComprehendTestFunction", |
| 32 | ) {} |
| 33 | |
| 34 | export default ComprehendTestFunction.make( |
| 35 | { |
| 36 | main, |
| 37 | functionUrl: true, |
| 38 | // The sentiment job lifecycle route retries StartSentimentDetectionJob |
| 39 | // through fresh-role IAM propagation (bounded ~40s). |
| 40 | timeout: Duration.seconds(120), |
| 41 | }, |
| 42 | Effect.gen(function* () { |
| 43 | const bucket = yield* S3.Bucket("ComprehendInputBucket", { |
| 44 | bucketName: FIXTURE_BUCKET_NAME, |
| 45 | forceDestroy: true, |
| 46 | }); |
| 47 | // The role Amazon Comprehend assumes to read input documents and write |
| 48 | // job results for the Start*Job bindings. |
| 49 | const dataAccessRole = yield* IAM.Role("ComprehendDataAccessRole", { |
| 50 | assumeRolePolicyDocument: { |
| 51 | Version: "2012-10-17", |
| 52 | Statement: [ |
| 53 | { |
| 54 | Effect: "Allow", |
| 55 | Principal: { Service: "comprehend.amazonaws.com" }, |
| 56 | Action: ["sts:AssumeRole"], |
| 57 | }, |
| 58 | ], |
| 59 | }, |
| 60 | inlinePolicies: { |
| 61 | s3Access: { |
| 62 | Version: "2012-10-17", |
| 63 | Statement: [ |
| 64 | { |
| 65 | Effect: "Allow", |
| 66 | Action: ["s3:GetObject", "s3:ListBucket", "s3:PutObject"], |
| 67 | Resource: [ |
| 68 | `arn:aws:s3:::${FIXTURE_BUCKET_NAME}`, |
| 69 | `arn:aws:s3:::${FIXTURE_BUCKET_NAME}/*`, |
| 70 | ], |
| 71 | }, |
| 72 | ], |
| 73 | }, |
| 74 | }, |
| 75 | }); |
| 76 | // Used by /classify-document to build a well-formed same-account |
| 77 | // endpoint ARN (a cross-account ARN returns NotAuthorizedException |
| 78 | // instead of the typed ResourceUnavailableException). |
| 79 | const roleArn = yield* dataAccessRole.roleArn; |
| 80 | |
| 81 | const putObject = yield* S3.PutObject(bucket); |
| 82 | |
| 83 | // --- real-time analysis bindings --- |
| 84 | const detectDominantLanguage = yield* Comprehend.DetectDominantLanguage(); |
| 85 | const detectEntities = yield* Comprehend.DetectEntities(); |
| 86 | const detectKeyPhrases = yield* Comprehend.DetectKeyPhrases(); |
| 87 | const detectPiiEntities = yield* Comprehend.DetectPiiEntities(); |
| 88 | const detectSentiment = yield* Comprehend.DetectSentiment(); |
| 89 | const detectSyntax = yield* Comprehend.DetectSyntax(); |
| 90 | const detectTargetedSentiment = yield* Comprehend.DetectTargetedSentiment(); |
| 91 | const detectToxicContent = yield* Comprehend.DetectToxicContent(); |
| 92 | const containsPiiEntities = yield* Comprehend.ContainsPiiEntities(); |
| 93 | const classifyDocument = yield* Comprehend.ClassifyDocument(); |
| 94 | |
| 95 | // --- batch real-time analysis bindings --- |
| 96 | const batchDetectDominantLanguage = |
| 97 | yield* Comprehend.BatchDetectDominantLanguage(); |
| 98 | const batchDetectEntities = yield* Comprehend.BatchDetectEntities(); |
| 99 | const batchDetectKeyPhrases = yield* Comprehend.BatchDetectKeyPhrases(); |
| 100 | const batchDetectSentiment = yield* Comprehend.BatchDetectSentiment(); |
| 101 | const batchDetectSyntax = yield* Comprehend.BatchDetectSyntax(); |
| 102 | const batchDetectTargetedSentiment = |
| 103 | yield* Comprehend.BatchDetectTargetedSentiment(); |
| 104 | |
| 105 | // --- async job bindings (role injection + PassRole grant) --- |
| 106 | const startDocumentClassificationJob = |
| 107 | yield* Comprehend.StartDocumentClassificationJob(dataAccessRole); |
| 108 | const startDominantLanguageDetectionJob = |
| 109 | yield* Comprehend.StartDominantLanguageDetectionJob(dataAccessRole); |
| 110 | const startEntitiesDetectionJob = |
| 111 | yield* Comprehend.StartEntitiesDetectionJob(dataAccessRole); |
| 112 | const startEventsDetectionJob = |
| 113 | yield* Comprehend.StartEventsDetectionJob(dataAccessRole); |
| 114 | const startKeyPhrasesDetectionJob = |
| 115 | yield* Comprehend.StartKeyPhrasesDetectionJob(dataAccessRole); |
| 116 | const startPiiEntitiesDetectionJob = |
| 117 | yield* Comprehend.StartPiiEntitiesDetectionJob(dataAccessRole); |
| 118 | const startSentimentDetectionJob = |
| 119 | yield* Comprehend.StartSentimentDetectionJob(dataAccessRole); |
| 120 | const startTargetedSentimentDetectionJob = |
| 121 | yield* Comprehend.StartTargetedSentimentDetectionJob(dataAccessRole); |
| 122 | const startTopicsDetectionJob = |
| 123 | yield* Comprehend.StartTopicsDetectionJob(dataAccessRole); |
| 124 | |
| 125 | const describeDocumentClassificationJob = |
| 126 | yield* Comprehend.DescribeDocumentClassificationJob(); |
| 127 | const describeDominantLanguageDetectionJob = |
| 128 | yield* Comprehend.DescribeDominantLanguageDetectionJob(); |
| 129 | const describeEntitiesDetectionJob = |
| 130 | yield* Comprehend.DescribeEntitiesDetectionJob(); |
| 131 | const describeEventsDetectionJob = |
| 132 | yield* Comprehend.DescribeEventsDetectionJob(); |
| 133 | const describeKeyPhrasesDetectionJob = |
| 134 | yield* Comprehend.DescribeKeyPhrasesDetectionJob(); |
| 135 | const describePiiEntitiesDetectionJob = |
| 136 | yield* Comprehend.DescribePiiEntitiesDetectionJob(); |
| 137 | const describeSentimentDetectionJob = |
| 138 | yield* Comprehend.DescribeSentimentDetectionJob(); |
| 139 | const describeTargetedSentimentDetectionJob = |
| 140 | yield* Comprehend.DescribeTargetedSentimentDetectionJob(); |
| 141 | const describeTopicsDetectionJob = |
| 142 | yield* Comprehend.DescribeTopicsDetectionJob(); |
| 143 | |
| 144 | const listDocumentClassificationJobs = |
| 145 | yield* Comprehend.ListDocumentClassificationJobs(); |
| 146 | const listDominantLanguageDetectionJobs = |
| 147 | yield* Comprehend.ListDominantLanguageDetectionJobs(); |
| 148 | const listEntitiesDetectionJobs = |
| 149 | yield* Comprehend.ListEntitiesDetectionJobs(); |
| 150 | const listEventsDetectionJobs = yield* Comprehend.ListEventsDetectionJobs(); |
| 151 | const listKeyPhrasesDetectionJobs = |
| 152 | yield* Comprehend.ListKeyPhrasesDetectionJobs(); |
| 153 | const listPiiEntitiesDetectionJobs = |
| 154 | yield* Comprehend.ListPiiEntitiesDetectionJobs(); |
| 155 | const listSentimentDetectionJobs = |
| 156 | yield* Comprehend.ListSentimentDetectionJobs(); |
| 157 | const listTargetedSentimentDetectionJobs = |
| 158 | yield* Comprehend.ListTargetedSentimentDetectionJobs(); |
| 159 | const listTopicsDetectionJobs = yield* Comprehend.ListTopicsDetectionJobs(); |
| 160 | |
| 161 | const stopDominantLanguageDetectionJob = |
| 162 | yield* Comprehend.StopDominantLanguageDetectionJob(); |
| 163 | const stopEntitiesDetectionJob = |
| 164 | yield* Comprehend.StopEntitiesDetectionJob(); |
| 165 | const stopEventsDetectionJob = yield* Comprehend.StopEventsDetectionJob(); |
| 166 | const stopKeyPhrasesDetectionJob = |
| 167 | yield* Comprehend.StopKeyPhrasesDetectionJob(); |
| 168 | const stopPiiEntitiesDetectionJob = |
| 169 | yield* Comprehend.StopPiiEntitiesDetectionJob(); |
| 170 | const stopSentimentDetectionJob = |
| 171 | yield* Comprehend.StopSentimentDetectionJob(); |
| 172 | const stopTargetedSentimentDetectionJob = |
| 173 | yield* Comprehend.StopTargetedSentimentDetectionJob(); |
| 174 | |
| 175 | const PII_TEXT = |
| 176 | "My name is Jane Doe and my email address is jane@example.com."; |
| 177 | const REVIEW_TEXT = "I love this product, it works wonderfully!"; |
| 178 | const BATCH_TEXTS = [ |
| 179 | "I love this product, it works wonderfully!", |
| 180 | "The delivery was late and the box arrived damaged.", |
| 181 | ]; |
| 182 | |
| 183 | const jobInput = { |
| 184 | InputDataConfig: { |
| 185 | S3Uri: `s3://${FIXTURE_BUCKET_NAME}/comprehend-input/`, |
| 186 | InputFormat: "ONE_DOC_PER_LINE" as const, |
| 187 | }, |
| 188 | OutputDataConfig: { |
| 189 | S3Uri: `s3://${FIXTURE_BUCKET_NAME}/comprehend-output/`, |
| 190 | }, |
| 191 | }; |
| 192 | const invalidJobInput = { |
| 193 | InputDataConfig: { S3Uri: INVALID_S3_URI }, |
| 194 | OutputDataConfig: { S3Uri: INVALID_S3_URI }, |
| 195 | }; |
| 196 | |
| 197 | return { |
| 198 | fetch: Effect.gen(function* () { |
| 199 | const request = yield* HttpServerRequest; |
| 200 | const url = new URL(request.originalUrl); |
| 201 | const pathname = url.pathname; |
| 202 | |
| 203 | // Cheap readiness route — no Comprehend call. |
| 204 | if (request.method === "GET" && pathname === "/ping") { |
| 205 | return yield* HttpServerResponse.json({ ok: true }); |
| 206 | } |
| 207 | |
| 208 | // One route drives every single-document real-time binding. |
| 209 | if (request.method === "GET" && pathname === "/detect-all") { |
| 210 | const language = yield* detectDominantLanguage({ |
| 211 | Text: "Bob ordered two sandwiches yesterday.", |
| 212 | }); |
| 213 | const entities = yield* detectEntities({ |
| 214 | Text: "Bob moved to Seattle in 2017.", |
| 215 | LanguageCode: "en", |
| 216 | }); |
| 217 | const keyPhrases = yield* detectKeyPhrases({ |
| 218 | Text: "The quarterly earnings report exceeded analyst expectations.", |
| 219 | LanguageCode: "en", |
| 220 | }); |
| 221 | const pii = yield* detectPiiEntities({ |
| 222 | Text: PII_TEXT, |
| 223 | LanguageCode: "en", |
| 224 | }); |
| 225 | const sentiment = yield* detectSentiment({ |
| 226 | Text: REVIEW_TEXT, |
| 227 | LanguageCode: "en", |
| 228 | }); |
| 229 | const syntax = yield* detectSyntax({ |
| 230 | Text: "The cat sat on the mat.", |
| 231 | LanguageCode: "en", |
| 232 | }); |
| 233 | const targeted = yield* detectTargetedSentiment({ |
| 234 | Text: "The screen is gorgeous but the battery is disappointing.", |
| 235 | LanguageCode: "en", |
| 236 | }); |
| 237 | const toxic = yield* detectToxicContent({ |
| 238 | TextSegments: [{ Text: "You are a wonderful person." }], |
| 239 | LanguageCode: "en", |
| 240 | }); |
| 241 | const piiLabels = yield* containsPiiEntities({ |
| 242 | Text: PII_TEXT, |
| 243 | LanguageCode: "en", |
| 244 | }); |
| 245 | return yield* HttpServerResponse.json({ |
| 246 | languageCode: language.Languages?.[0]?.LanguageCode, |
| 247 | entityTypes: (entities.Entities ?? []).map((e) => e.Type), |
| 248 | keyPhraseCount: (keyPhrases.KeyPhrases ?? []).length, |
| 249 | piiTypes: (pii.Entities ?? []).map((e) => e.Type), |
| 250 | sentiment: sentiment.Sentiment, |
| 251 | syntaxTags: (syntax.SyntaxTokens ?? []).map( |
| 252 | (t) => t.PartOfSpeech?.Tag, |
| 253 | ), |
| 254 | targetedEntityCount: (targeted.Entities ?? []).length, |
| 255 | toxicity: toxic.ResultList?.[0]?.Toxicity, |
| 256 | piiLabels: (piiLabels.Labels ?? []).map((l) => l.Name), |
| 257 | }); |
| 258 | } |
| 259 | |
| 260 | // One route drives every batch real-time binding. |
| 261 | if (request.method === "GET" && pathname === "/batch-all") { |
| 262 | const language = yield* batchDetectDominantLanguage({ |
| 263 | TextList: BATCH_TEXTS, |
| 264 | }); |
| 265 | const entities = yield* batchDetectEntities({ |
| 266 | TextList: ["Bob moved to Seattle in 2017."], |
| 267 | LanguageCode: "en", |
| 268 | }); |
| 269 | const keyPhrases = yield* batchDetectKeyPhrases({ |
| 270 | TextList: BATCH_TEXTS, |
| 271 | LanguageCode: "en", |
| 272 | }); |
| 273 | const sentiment = yield* batchDetectSentiment({ |
| 274 | TextList: BATCH_TEXTS, |
| 275 | LanguageCode: "en", |
| 276 | }); |
| 277 | const syntax = yield* batchDetectSyntax({ |
| 278 | TextList: ["The cat sat on the mat."], |
| 279 | LanguageCode: "en", |
| 280 | }); |
| 281 | const targeted = yield* batchDetectTargetedSentiment({ |
| 282 | TextList: ["The screen is gorgeous but the battery is bad."], |
| 283 | LanguageCode: "en", |
| 284 | }); |
| 285 | return yield* HttpServerResponse.json({ |
| 286 | languageResults: (language.ResultList ?? []).length, |
| 287 | firstLanguage: |
| 288 | language.ResultList?.[0]?.Languages?.[0]?.LanguageCode, |
| 289 | entityResults: (entities.ResultList ?? []).length, |
| 290 | keyPhraseResults: (keyPhrases.ResultList ?? []).length, |
| 291 | sentimentResults: (sentiment.ResultList ?? []).length, |
| 292 | firstSentiment: sentiment.ResultList?.[0]?.Sentiment, |
| 293 | syntaxResults: (syntax.ResultList ?? []).length, |
| 294 | targetedResults: (targeted.ResultList ?? []).length, |
| 295 | }); |
| 296 | } |
| 297 | |
| 298 | // ClassifyDocument requires a trained custom endpoint; a well-formed |
| 299 | // same-account ARN that doesn't exist drives the typed |
| 300 | // ResourceUnavailableException path end-to-end (proving IAM + call |
| 301 | // plumbing without a multi-hour classifier training run). |
| 302 | if (request.method === "GET" && pathname === "/classify-document") { |
| 303 | const region = yield* Effect.sync(() => process.env.AWS_REGION); |
| 304 | const account = (yield* roleArn).split(":")[4]; |
| 305 | const tag = yield* classifyDocument({ |
| 306 | Text: "Subject: your invoice for March is attached", |
| 307 | EndpointArn: `arn:aws:comprehend:${region}:${account}:document-classifier-endpoint/alchemy-nonexistent`, |
| 308 | }).pipe( |
| 309 | Effect.map(() => "Classified"), |
| 310 | Effect.catchTag( |
| 311 | [ |
| 312 | "ResourceUnavailableException", |
| 313 | "InvalidRequestException", |
| 314 | "NotAuthorizedException", |
| 315 | ], |
| 316 | (e) => Effect.succeed(e._tag), |
| 317 | ), |
| 318 | ); |
| 319 | return yield* HttpServerResponse.json({ tag }); |
| 320 | } |
| 321 | |
| 322 | // One route drives all nine List*Jobs bindings. |
| 323 | if (request.method === "GET" && pathname === "/jobs/list-all") { |
| 324 | const counts = { |
| 325 | documentClassification: ( |
| 326 | (yield* listDocumentClassificationJobs({ MaxResults: 5 })) |
| 327 | .DocumentClassificationJobPropertiesList ?? [] |
| 328 | ).length, |
| 329 | dominantLanguage: ( |
| 330 | (yield* listDominantLanguageDetectionJobs({ MaxResults: 5 })) |
| 331 | .DominantLanguageDetectionJobPropertiesList ?? [] |
| 332 | ).length, |
| 333 | entities: ( |
| 334 | (yield* listEntitiesDetectionJobs({ MaxResults: 5 })) |
| 335 | .EntitiesDetectionJobPropertiesList ?? [] |
| 336 | ).length, |
| 337 | events: ( |
| 338 | (yield* listEventsDetectionJobs({ MaxResults: 5 })) |
| 339 | .EventsDetectionJobPropertiesList ?? [] |
| 340 | ).length, |
| 341 | keyPhrases: ( |
| 342 | (yield* listKeyPhrasesDetectionJobs({ MaxResults: 5 })) |
| 343 | .KeyPhrasesDetectionJobPropertiesList ?? [] |
| 344 | ).length, |
| 345 | piiEntities: ( |
| 346 | (yield* listPiiEntitiesDetectionJobs({ MaxResults: 5 })) |
| 347 | .PiiEntitiesDetectionJobPropertiesList ?? [] |
| 348 | ).length, |
| 349 | sentiment: ( |
| 350 | (yield* listSentimentDetectionJobs({ MaxResults: 5 })) |
| 351 | .SentimentDetectionJobPropertiesList ?? [] |
| 352 | ).length, |
| 353 | targetedSentiment: ( |
| 354 | (yield* listTargetedSentimentDetectionJobs({ MaxResults: 5 })) |
| 355 | .TargetedSentimentDetectionJobPropertiesList ?? [] |
| 356 | ).length, |
| 357 | topics: ( |
| 358 | (yield* listTopicsDetectionJobs({ MaxResults: 5 })) |
| 359 | .TopicsDetectionJobPropertiesList ?? [] |
| 360 | ).length, |
| 361 | }; |
| 362 | return yield* HttpServerResponse.json(counts); |
| 363 | } |
| 364 | |
| 365 | // One route drives all nine Describe*Job bindings through their |
| 366 | // typed JobNotFoundException path. |
| 367 | if ( |
| 368 | request.method === "GET" && |
| 369 | pathname === "/jobs/describe-not-found-all" |
| 370 | ) { |
| 371 | const probe = { JobId: BOGUS_JOB_ID }; |
| 372 | const tags = { |
| 373 | documentClassification: yield* describeDocumentClassificationJob( |
| 374 | probe, |
| 375 | ).pipe( |
| 376 | Effect.map(() => "Found"), |
| 377 | Effect.catchTag("JobNotFoundException", (e) => |
| 378 | Effect.succeed(e._tag), |
| 379 | ), |
| 380 | ), |
| 381 | dominantLanguage: yield* describeDominantLanguageDetectionJob( |
| 382 | probe, |
| 383 | ).pipe( |
| 384 | Effect.map(() => "Found"), |
| 385 | Effect.catchTag("JobNotFoundException", (e) => |
| 386 | Effect.succeed(e._tag), |
| 387 | ), |
| 388 | ), |
| 389 | entities: yield* describeEntitiesDetectionJob(probe).pipe( |
| 390 | Effect.map(() => "Found"), |
| 391 | Effect.catchTag("JobNotFoundException", (e) => |
| 392 | Effect.succeed(e._tag), |
| 393 | ), |
| 394 | ), |
| 395 | events: yield* describeEventsDetectionJob(probe).pipe( |
| 396 | Effect.map(() => "Found"), |
| 397 | Effect.catchTag( |
| 398 | ["JobNotFoundException", "NotAuthorizedException"], |
| 399 | (e) => Effect.succeed(e._tag), |
| 400 | ), |
| 401 | ), |
| 402 | keyPhrases: yield* describeKeyPhrasesDetectionJob(probe).pipe( |
| 403 | Effect.map(() => "Found"), |
| 404 | Effect.catchTag("JobNotFoundException", (e) => |
| 405 | Effect.succeed(e._tag), |
| 406 | ), |
| 407 | ), |
| 408 | piiEntities: yield* describePiiEntitiesDetectionJob(probe).pipe( |
| 409 | Effect.map(() => "Found"), |
| 410 | Effect.catchTag("JobNotFoundException", (e) => |
| 411 | Effect.succeed(e._tag), |
| 412 | ), |
| 413 | ), |
| 414 | sentiment: yield* describeSentimentDetectionJob(probe).pipe( |
| 415 | Effect.map(() => "Found"), |
| 416 | Effect.catchTag("JobNotFoundException", (e) => |
| 417 | Effect.succeed(e._tag), |
| 418 | ), |
| 419 | ), |
| 420 | targetedSentiment: yield* describeTargetedSentimentDetectionJob( |
| 421 | probe, |
| 422 | ).pipe( |
| 423 | Effect.map(() => "Found"), |
| 424 | Effect.catchTag("JobNotFoundException", (e) => |
| 425 | Effect.succeed(e._tag), |
| 426 | ), |
| 427 | ), |
| 428 | topics: yield* describeTopicsDetectionJob(probe).pipe( |
| 429 | Effect.map(() => "Found"), |
| 430 | Effect.catchTag("JobNotFoundException", (e) => |
| 431 | Effect.succeed(e._tag), |
| 432 | ), |
| 433 | ), |
| 434 | }; |
| 435 | return yield* HttpServerResponse.json(tags); |
| 436 | } |
| 437 | |
| 438 | // One route drives all seven Stop*Job bindings through their typed |
| 439 | // JobNotFoundException path. |
| 440 | if ( |
| 441 | request.method === "POST" && |
| 442 | pathname === "/jobs/stop-not-found-all" |
| 443 | ) { |
| 444 | const probe = { JobId: BOGUS_JOB_ID }; |
| 445 | const tags = { |
| 446 | dominantLanguage: yield* stopDominantLanguageDetectionJob( |
| 447 | probe, |
| 448 | ).pipe( |
| 449 | Effect.map(() => "Stopped"), |
| 450 | Effect.catchTag("JobNotFoundException", (e) => |
| 451 | Effect.succeed(e._tag), |
| 452 | ), |
| 453 | ), |
| 454 | entities: yield* stopEntitiesDetectionJob(probe).pipe( |
| 455 | Effect.map(() => "Stopped"), |
| 456 | Effect.catchTag("JobNotFoundException", (e) => |
| 457 | Effect.succeed(e._tag), |
| 458 | ), |
| 459 | ), |
| 460 | // Events detection is closed to new customers — the account |
| 461 | // surfaces the (patched) typed NotAuthorizedException instead of |
| 462 | // JobNotFoundException. |
| 463 | events: yield* stopEventsDetectionJob(probe).pipe( |
| 464 | Effect.map(() => "Stopped"), |
| 465 | Effect.catchTag( |
| 466 | ["JobNotFoundException", "NotAuthorizedException"], |
| 467 | (e) => Effect.succeed(e._tag), |
| 468 | ), |
| 469 | ), |
| 470 | keyPhrases: yield* stopKeyPhrasesDetectionJob(probe).pipe( |
| 471 | Effect.map(() => "Stopped"), |
| 472 | Effect.catchTag("JobNotFoundException", (e) => |
| 473 | Effect.succeed(e._tag), |
| 474 | ), |
| 475 | ), |
| 476 | piiEntities: yield* stopPiiEntitiesDetectionJob(probe).pipe( |
| 477 | Effect.map(() => "Stopped"), |
| 478 | Effect.catchTag("JobNotFoundException", (e) => |
| 479 | Effect.succeed(e._tag), |
| 480 | ), |
| 481 | ), |
| 482 | sentiment: yield* stopSentimentDetectionJob(probe).pipe( |
| 483 | Effect.map(() => "Stopped"), |
| 484 | Effect.catchTag("JobNotFoundException", (e) => |
| 485 | Effect.succeed(e._tag), |
| 486 | ), |
| 487 | ), |
| 488 | targetedSentiment: yield* stopTargetedSentimentDetectionJob( |
| 489 | probe, |
| 490 | ).pipe( |
| 491 | Effect.map(() => "Stopped"), |
| 492 | Effect.catchTag("JobNotFoundException", (e) => |
| 493 | Effect.succeed(e._tag), |
| 494 | ), |
| 495 | ), |
| 496 | }; |
| 497 | return yield* HttpServerResponse.json(tags); |
| 498 | } |
| 499 | |
| 500 | // Drives the eight non-sentiment Start*Job bindings through |
| 501 | // Comprehend's server-side ValidationException (invalid S3 URI) — |
| 502 | // a real runtime call proving role injection + IAM without paying |
| 503 | // for eight async jobs. Sentiment gets the real lifecycle below. |
| 504 | if ( |
| 505 | request.method === "POST" && |
| 506 | pathname === "/jobs/start-invalid-all" |
| 507 | ) { |
| 508 | const region = yield* Effect.sync(() => process.env.AWS_REGION); |
| 509 | const account = (yield* roleArn).split(":")[4]; |
| 510 | const tags = { |
| 511 | documentClassification: yield* startDocumentClassificationJob({ |
| 512 | ...invalidJobInput, |
| 513 | DocumentClassifierArn: `arn:aws:comprehend:${region}:${account}:document-classifier/alchemy-nonexistent`, |
| 514 | }).pipe( |
| 515 | Effect.map(() => "Started"), |
| 516 | Effect.catchTag( |
| 517 | [ |
| 518 | "ValidationException", |
| 519 | "InvalidRequestException", |
| 520 | "ResourceNotFoundException", |
| 521 | ], |
| 522 | (e) => Effect.succeed(e._tag), |
| 523 | ), |
| 524 | ), |
| 525 | dominantLanguage: yield* startDominantLanguageDetectionJob( |
| 526 | invalidJobInput, |
| 527 | ).pipe( |
| 528 | Effect.map(() => "Started"), |
| 529 | Effect.catchTag( |
| 530 | ["ValidationException", "InvalidRequestException"], |
| 531 | (e) => Effect.succeed(e._tag), |
| 532 | ), |
| 533 | ), |
| 534 | entities: yield* startEntitiesDetectionJob({ |
| 535 | ...invalidJobInput, |
| 536 | LanguageCode: "en", |
| 537 | }).pipe( |
| 538 | Effect.map(() => "Started"), |
| 539 | Effect.catchTag( |
| 540 | ["ValidationException", "InvalidRequestException"], |
| 541 | (e) => Effect.succeed(e._tag), |
| 542 | ), |
| 543 | ), |
| 544 | events: yield* startEventsDetectionJob({ |
| 545 | ...invalidJobInput, |
| 546 | LanguageCode: "en", |
| 547 | TargetEventTypes: ["BANKRUPTCY"], |
| 548 | }).pipe( |
| 549 | Effect.map(() => "Started"), |
| 550 | Effect.catchTag( |
| 551 | [ |
| 552 | "ValidationException", |
| 553 | "InvalidRequestException", |
| 554 | "NotAuthorizedException", |
| 555 | ], |
| 556 | (e) => Effect.succeed(e._tag), |
| 557 | ), |
| 558 | ), |
| 559 | keyPhrases: yield* startKeyPhrasesDetectionJob({ |
| 560 | ...invalidJobInput, |
| 561 | LanguageCode: "en", |
| 562 | }).pipe( |
| 563 | Effect.map(() => "Started"), |
| 564 | Effect.catchTag( |
| 565 | ["ValidationException", "InvalidRequestException"], |
| 566 | (e) => Effect.succeed(e._tag), |
| 567 | ), |
| 568 | ), |
| 569 | piiEntities: yield* startPiiEntitiesDetectionJob({ |
| 570 | ...invalidJobInput, |
| 571 | Mode: "ONLY_OFFSETS", |
| 572 | LanguageCode: "en", |
| 573 | }).pipe( |
| 574 | Effect.map(() => "Started"), |
| 575 | Effect.catchTag( |
| 576 | ["ValidationException", "InvalidRequestException"], |
| 577 | (e) => Effect.succeed(e._tag), |
| 578 | ), |
| 579 | ), |
| 580 | targetedSentiment: yield* startTargetedSentimentDetectionJob({ |
| 581 | ...invalidJobInput, |
| 582 | LanguageCode: "en", |
| 583 | }).pipe( |
| 584 | Effect.map(() => "Started"), |
| 585 | Effect.catchTag( |
| 586 | ["ValidationException", "InvalidRequestException"], |
| 587 | (e) => Effect.succeed(e._tag), |
| 588 | ), |
| 589 | ), |
| 590 | topics: yield* startTopicsDetectionJob(invalidJobInput).pipe( |
| 591 | Effect.map(() => "Started"), |
| 592 | Effect.catchTag( |
| 593 | ["ValidationException", "InvalidRequestException"], |
| 594 | (e) => Effect.succeed(e._tag), |
| 595 | ), |
| 596 | ), |
| 597 | }; |
| 598 | return yield* HttpServerResponse.json(tags); |
| 599 | } |
| 600 | |
| 601 | // Full real lifecycle for the sentiment job family: seed the input |
| 602 | // object, start a real async job, describe it, then stop it. |
| 603 | if ( |
| 604 | request.method === "POST" && |
| 605 | pathname === "/jobs/sentiment/lifecycle" |
| 606 | ) { |
| 607 | yield* putObject({ |
| 608 | Key: "comprehend-input/reviews.txt", |
| 609 | Body: "I love this product, it works wonderfully!\nThe delivery was late and the box arrived damaged.\n", |
| 610 | ContentType: "text/plain", |
| 611 | }); |
| 612 | // A freshly created data-access role can take a few seconds to |
| 613 | // become assumable by Comprehend — surfaced as |
| 614 | // InvalidRequestException. Bounded retry (8 × 5s). |
| 615 | const started = yield* startSentimentDetectionJob({ |
| 616 | ...jobInput, |
| 617 | JobName: "alchemy-comprehend-bindings-lifecycle", |
| 618 | LanguageCode: "en", |
| 619 | }).pipe( |
| 620 | Effect.retry({ |
| 621 | while: (e): boolean => e._tag === "InvalidRequestException", |
| 622 | schedule: Schedule.spaced("5 seconds"), |
| 623 | times: 8, |
| 624 | }), |
| 625 | ); |
| 626 | const jobId = started.JobId ?? ""; |
| 627 | const described = yield* describeSentimentDetectionJob({ |
| 628 | JobId: jobId, |
| 629 | }); |
| 630 | const stopped = yield* stopSentimentDetectionJob({ JobId: jobId }); |
| 631 | return yield* HttpServerResponse.json({ |
| 632 | jobId, |
| 633 | startStatus: started.JobStatus, |
| 634 | describedStatus: |
| 635 | described.SentimentDetectionJobProperties?.JobStatus, |
| 636 | stopStatus: stopped.JobStatus, |
| 637 | }); |
| 638 | } |
| 639 | |
| 640 | return yield* HttpServerResponse.json( |
| 641 | { error: "Not found", method: request.method, pathname }, |
| 642 | { status: 404 }, |
| 643 | ); |
| 644 | }).pipe(Effect.orDie), |
| 645 | }; |
| 646 | }).pipe( |
| 647 | Effect.provide( |
| 648 | Layer.mergeAll( |
| 649 | S3.PutObjectHttp, |
| 650 | Comprehend.DetectDominantLanguageHttp, |
| 651 | Comprehend.DetectEntitiesHttp, |
| 652 | Comprehend.DetectKeyPhrasesHttp, |
| 653 | Comprehend.DetectPiiEntitiesHttp, |
| 654 | Comprehend.DetectSentimentHttp, |
| 655 | Comprehend.DetectSyntaxHttp, |
| 656 | Comprehend.DetectTargetedSentimentHttp, |
| 657 | Comprehend.DetectToxicContentHttp, |
| 658 | Comprehend.ContainsPiiEntitiesHttp, |
| 659 | Comprehend.ClassifyDocumentHttp, |
| 660 | Comprehend.BatchDetectDominantLanguageHttp, |
| 661 | Comprehend.BatchDetectEntitiesHttp, |
| 662 | Comprehend.BatchDetectKeyPhrasesHttp, |
| 663 | Comprehend.BatchDetectSentimentHttp, |
| 664 | Comprehend.BatchDetectSyntaxHttp, |
| 665 | Comprehend.BatchDetectTargetedSentimentHttp, |
| 666 | Comprehend.StartDocumentClassificationJobHttp, |
| 667 | Comprehend.StartDominantLanguageDetectionJobHttp, |
| 668 | Comprehend.StartEntitiesDetectionJobHttp, |
| 669 | Comprehend.StartEventsDetectionJobHttp, |
| 670 | Comprehend.StartKeyPhrasesDetectionJobHttp, |
| 671 | Comprehend.StartPiiEntitiesDetectionJobHttp, |
| 672 | Comprehend.StartSentimentDetectionJobHttp, |
| 673 | Comprehend.StartTargetedSentimentDetectionJobHttp, |
| 674 | Comprehend.StartTopicsDetectionJobHttp, |
| 675 | Comprehend.DescribeDocumentClassificationJobHttp, |
| 676 | Comprehend.DescribeDominantLanguageDetectionJobHttp, |
| 677 | Comprehend.DescribeEntitiesDetectionJobHttp, |
| 678 | Comprehend.DescribeEventsDetectionJobHttp, |
| 679 | Comprehend.DescribeKeyPhrasesDetectionJobHttp, |
| 680 | Comprehend.DescribePiiEntitiesDetectionJobHttp, |
| 681 | Comprehend.DescribeSentimentDetectionJobHttp, |
| 682 | Comprehend.DescribeTargetedSentimentDetectionJobHttp, |
| 683 | Comprehend.DescribeTopicsDetectionJobHttp, |
| 684 | Comprehend.ListDocumentClassificationJobsHttp, |
| 685 | Comprehend.ListDominantLanguageDetectionJobsHttp, |
| 686 | Comprehend.ListEntitiesDetectionJobsHttp, |
| 687 | Comprehend.ListEventsDetectionJobsHttp, |
| 688 | Comprehend.ListKeyPhrasesDetectionJobsHttp, |
| 689 | Comprehend.ListPiiEntitiesDetectionJobsHttp, |
| 690 | Comprehend.ListSentimentDetectionJobsHttp, |
| 691 | Comprehend.ListTargetedSentimentDetectionJobsHttp, |
| 692 | Comprehend.ListTopicsDetectionJobsHttp, |
| 693 | Comprehend.StopDominantLanguageDetectionJobHttp, |
| 694 | Comprehend.StopEntitiesDetectionJobHttp, |
| 695 | Comprehend.StopEventsDetectionJobHttp, |
| 696 | Comprehend.StopKeyPhrasesDetectionJobHttp, |
| 697 | Comprehend.StopPiiEntitiesDetectionJobHttp, |
| 698 | Comprehend.StopSentimentDetectionJobHttp, |
| 699 | Comprehend.StopTargetedSentimentDetectionJobHttp, |
| 700 | ), |
| 701 | ), |
| 702 | ), |
| 703 | ); |