| 1 | import * as Lambda from "@/AWS/Lambda"; |
| 2 | import * as Rekognition from "@/AWS/Rekognition"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 7 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 8 | import path from "pathe"; |
| 9 | |
| 10 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 11 | |
| 12 | // Deterministic collection id — this suite owns it in the testing account and |
| 13 | // the lifecycle route deletes it on entry and exit, so reruns self-heal. |
| 14 | export const TEST_COLLECTION_ID = "alchemy-test-rekognition-bindings"; |
| 15 | |
| 16 | // A 100x100 RGB PNG (blue sky, green ground, yellow sun disc) generated once |
| 17 | // and checked in — big enough for Rekognition's 80px minimum dimension. It |
| 18 | // contains no faces, which the face routes exploit: IndexFaces succeeds with |
| 19 | // zero face records and SearchFacesByImage surfaces the typed |
| 20 | // InvalidParameterException ("no faces in image"), both proving IAM + call |
| 21 | // plumbing end-to-end. |
| 22 | const TEST_IMAGE_B64 = |
| 23 | "iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAABNElEQVR4nO3YUQ3CMABF0cpBBN/IQc5EoGkOcMAXCkjK2u6l60n6v+Tsvo5Qnq+3UylQSNULwII15m5RFixlxb9FZghLWWaYJ3BnwVJWnGC5GX72268Dq4qpO1lZQaqXV1lEqotXWUeq3assJdXoBevSWI1ZtcQFC5ayzNCd5YKPfwfNEJayLrPEw8+d70cprPO8Wl7SlGUd9mp84sRY/ikd5dXl3cxdVg1Zx6dcBOucAwuWsswwT+DOgqWsOIEZwlJWnMAMYSkrTmCGsJQVJzBDWMqKE5ghLGXNNMP79nAqBWD90QosWGPuFmXBUlb8q22GsJRlhnkCdxYsZcUJzBCWsuIEZghLWXECM4SlrDiBGcJSVpzADGEpK05ghrCUFScwQ1jKihOYISxlxQnMENYWLusLBGA4iOtydmQAAAAASUVORK5CYII="; |
| 24 | const imageBytes = Buffer.from(TEST_IMAGE_B64, "base64"); |
| 25 | |
| 26 | // Well-formed-but-nonexistent identifiers used to drive the typed not-found |
| 27 | // paths. An IAM gap would surface AccessDeniedException (a 500 through the |
| 28 | // handler's orDie), so a typed not-found/validation tag proves the grant |
| 29 | // end-to-end. |
| 30 | const BOGUS_JOB_ID = "0".repeat(64); |
| 31 | const BOGUS_FACE_ID = "00000000-0000-0000-0000-000000000000"; |
| 32 | const BOGUS_SESSION_ID = "00000000-0000-0000-0000-000000000000"; |
| 33 | const BOGUS_BUCKET = "alchemy-nonexistent-rekognition-test-bucket"; |
| 34 | const BOGUS_VIDEO = { |
| 35 | S3Object: { Bucket: BOGUS_BUCKET, Name: "video.mp4" }, |
| 36 | }; |
| 37 | |
| 38 | export class RekognitionTestFunction extends Lambda.Function<Lambda.Function>()( |
| 39 | "RekognitionTestFunction", |
| 40 | ) {} |
| 41 | |
| 42 | export default RekognitionTestFunction.make( |
| 43 | { |
| 44 | main, |
| 45 | functionUrl: true, |
| 46 | // The image-analysis route runs seven sequential inferences and the |
| 47 | // collection route ~15 sequential data-plane calls. |
| 48 | timeout: Duration.seconds(120), |
| 49 | // The bundled Rekognition schema graph is large — the 128 MB default |
| 50 | // leaves almost no headroom (observed Max Memory Used: 118 MB on the |
| 51 | // cheap routes alone). |
| 52 | memorySize: 512, |
| 53 | }, |
| 54 | Effect.gen(function* () { |
| 55 | // --- image analysis --- |
| 56 | const compareFaces = yield* Rekognition.CompareFaces(); |
| 57 | const detectFaces = yield* Rekognition.DetectFaces(); |
| 58 | const detectLabels = yield* Rekognition.DetectLabels(); |
| 59 | const detectModerationLabels = yield* Rekognition.DetectModerationLabels(); |
| 60 | const detectProtectiveEquipment = |
| 61 | yield* Rekognition.DetectProtectiveEquipment(); |
| 62 | const detectText = yield* Rekognition.DetectText(); |
| 63 | const recognizeCelebrities = yield* Rekognition.RecognizeCelebrities(); |
| 64 | const getCelebrityInfo = yield* Rekognition.GetCelebrityInfo(); |
| 65 | |
| 66 | // --- face collections --- |
| 67 | const createCollection = yield* Rekognition.CreateCollection(); |
| 68 | const deleteCollection = yield* Rekognition.DeleteCollection(); |
| 69 | const describeCollection = yield* Rekognition.DescribeCollection(); |
| 70 | const listCollections = yield* Rekognition.ListCollections(); |
| 71 | const indexFaces = yield* Rekognition.IndexFaces(); |
| 72 | const listFaces = yield* Rekognition.ListFaces(); |
| 73 | const deleteFaces = yield* Rekognition.DeleteFaces(); |
| 74 | const searchFaces = yield* Rekognition.SearchFaces(); |
| 75 | const searchFacesByImage = yield* Rekognition.SearchFacesByImage(); |
| 76 | |
| 77 | // --- user search --- |
| 78 | const createUser = yield* Rekognition.CreateUser(); |
| 79 | const deleteUser = yield* Rekognition.DeleteUser(); |
| 80 | const listUsers = yield* Rekognition.ListUsers(); |
| 81 | const associateFaces = yield* Rekognition.AssociateFaces(); |
| 82 | const disassociateFaces = yield* Rekognition.DisassociateFaces(); |
| 83 | const searchUsers = yield* Rekognition.SearchUsers(); |
| 84 | const searchUsersByImage = yield* Rekognition.SearchUsersByImage(); |
| 85 | |
| 86 | // --- face liveness --- |
| 87 | const createFaceLivenessSession = |
| 88 | yield* Rekognition.CreateFaceLivenessSession(); |
| 89 | const getFaceLivenessSessionResults = |
| 90 | yield* Rekognition.GetFaceLivenessSessionResults(); |
| 91 | |
| 92 | // --- video analysis --- |
| 93 | const startCelebrityRecognition = |
| 94 | yield* Rekognition.StartCelebrityRecognition(); |
| 95 | const getCelebrityRecognition = |
| 96 | yield* Rekognition.GetCelebrityRecognition(); |
| 97 | const startContentModeration = yield* Rekognition.StartContentModeration(); |
| 98 | const getContentModeration = yield* Rekognition.GetContentModeration(); |
| 99 | const startFaceDetection = yield* Rekognition.StartFaceDetection(); |
| 100 | const getFaceDetection = yield* Rekognition.GetFaceDetection(); |
| 101 | const startFaceSearch = yield* Rekognition.StartFaceSearch(); |
| 102 | const getFaceSearch = yield* Rekognition.GetFaceSearch(); |
| 103 | const startLabelDetection = yield* Rekognition.StartLabelDetection(); |
| 104 | const getLabelDetection = yield* Rekognition.GetLabelDetection(); |
| 105 | const startPersonTracking = yield* Rekognition.StartPersonTracking(); |
| 106 | const getPersonTracking = yield* Rekognition.GetPersonTracking(); |
| 107 | const startSegmentDetection = yield* Rekognition.StartSegmentDetection(); |
| 108 | const getSegmentDetection = yield* Rekognition.GetSegmentDetection(); |
| 109 | const startTextDetection = yield* Rekognition.StartTextDetection(); |
| 110 | const getTextDetection = yield* Rekognition.GetTextDetection(); |
| 111 | |
| 112 | // --- media analysis jobs --- |
| 113 | const startMediaAnalysisJob = yield* Rekognition.StartMediaAnalysisJob(); |
| 114 | const getMediaAnalysisJob = yield* Rekognition.GetMediaAnalysisJob(); |
| 115 | const listMediaAnalysisJobs = yield* Rekognition.ListMediaAnalysisJobs(); |
| 116 | |
| 117 | // --- stream processors --- |
| 118 | const startStreamProcessor = yield* Rekognition.StartStreamProcessor(); |
| 119 | const stopStreamProcessor = yield* Rekognition.StopStreamProcessor(); |
| 120 | const describeStreamProcessor = |
| 121 | yield* Rekognition.DescribeStreamProcessor(); |
| 122 | const listStreamProcessors = yield* Rekognition.ListStreamProcessors(); |
| 123 | |
| 124 | // --- custom labels --- |
| 125 | const detectCustomLabels = yield* Rekognition.DetectCustomLabels(); |
| 126 | const describeProjects = yield* Rekognition.DescribeProjects(); |
| 127 | const describeProjectVersions = |
| 128 | yield* Rekognition.DescribeProjectVersions(); |
| 129 | const startProjectVersion = yield* Rekognition.StartProjectVersion(); |
| 130 | const stopProjectVersion = yield* Rekognition.StopProjectVersion(); |
| 131 | |
| 132 | return { |
| 133 | fetch: Effect.gen(function* () { |
| 134 | const request = yield* HttpServerRequest; |
| 135 | const url = new URL(request.originalUrl); |
| 136 | const pathname = url.pathname; |
| 137 | |
| 138 | // Cheap readiness route — no Rekognition call. |
| 139 | if (request.method === "GET" && pathname === "/ping") { |
| 140 | return yield* HttpServerResponse.json({ ok: true }); |
| 141 | } |
| 142 | |
| 143 | // One route drives every synchronous image-analysis binding against |
| 144 | // the embedded test image (real inferences; the image has no faces, |
| 145 | // so CompareFaces surfaces its typed InvalidParameterException). |
| 146 | if (request.method === "GET" && pathname === "/analyze-image") { |
| 147 | const labels = yield* detectLabels({ |
| 148 | Image: { Bytes: imageBytes }, |
| 149 | MaxLabels: 10, |
| 150 | }); |
| 151 | const faces = yield* detectFaces({ Image: { Bytes: imageBytes } }); |
| 152 | const moderation = yield* detectModerationLabels({ |
| 153 | Image: { Bytes: imageBytes }, |
| 154 | }); |
| 155 | const text = yield* detectText({ Image: { Bytes: imageBytes } }); |
| 156 | const ppe = yield* detectProtectiveEquipment({ |
| 157 | Image: { Bytes: imageBytes }, |
| 158 | }); |
| 159 | const celebrities = yield* recognizeCelebrities({ |
| 160 | Image: { Bytes: imageBytes }, |
| 161 | }); |
| 162 | const compareTag = yield* compareFaces({ |
| 163 | SourceImage: { Bytes: imageBytes }, |
| 164 | TargetImage: { Bytes: imageBytes }, |
| 165 | }).pipe( |
| 166 | Effect.map(() => "Success"), |
| 167 | Effect.catchTag("InvalidParameterException", (e) => |
| 168 | Effect.succeed(e._tag), |
| 169 | ), |
| 170 | ); |
| 171 | const celebrityInfoTag = yield* getCelebrityInfo({ |
| 172 | Id: "0000000000", |
| 173 | }).pipe( |
| 174 | Effect.map(() => "Success"), |
| 175 | Effect.catchTag( |
| 176 | ["ResourceNotFoundException", "InvalidParameterException"], |
| 177 | (e) => Effect.succeed(e._tag), |
| 178 | ), |
| 179 | ); |
| 180 | return yield* HttpServerResponse.json({ |
| 181 | labelNames: (labels.Labels ?? []).map((l) => l.Name), |
| 182 | faceCount: (faces.FaceDetails ?? []).length, |
| 183 | moderationCount: (moderation.ModerationLabels ?? []).length, |
| 184 | textCount: (text.TextDetections ?? []).length, |
| 185 | ppePersons: (ppe.Persons ?? []).length, |
| 186 | celebrityCount: (celebrities.CelebrityFaces ?? []).length, |
| 187 | compareTag, |
| 188 | celebrityInfoTag, |
| 189 | }); |
| 190 | } |
| 191 | |
| 192 | // Full real lifecycle of the collection + user data plane: create a |
| 193 | // collection, describe/list it, index a (faceless) image, create and |
| 194 | // list a user, drive the face-id ops through their typed not-found |
| 195 | // paths, then tear everything down. |
| 196 | if (request.method === "POST" && pathname === "/collections") { |
| 197 | // Self-heal from a previous crashed run. |
| 198 | yield* deleteCollection({ CollectionId: TEST_COLLECTION_ID }).pipe( |
| 199 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 200 | ); |
| 201 | const created = yield* createCollection({ |
| 202 | CollectionId: TEST_COLLECTION_ID, |
| 203 | }).pipe( |
| 204 | Effect.map((r) => r.StatusCode ?? 200), |
| 205 | Effect.catchTag("ResourceAlreadyExistsException", () => |
| 206 | Effect.succeed(200), |
| 207 | ), |
| 208 | ); |
| 209 | const described = yield* describeCollection({ |
| 210 | CollectionId: TEST_COLLECTION_ID, |
| 211 | }); |
| 212 | const collections = yield* listCollections({ MaxResults: 100 }); |
| 213 | const indexed = yield* indexFaces({ |
| 214 | CollectionId: TEST_COLLECTION_ID, |
| 215 | Image: { Bytes: imageBytes }, |
| 216 | }); |
| 217 | const faces = yield* listFaces({ |
| 218 | CollectionId: TEST_COLLECTION_ID, |
| 219 | }); |
| 220 | yield* createUser({ |
| 221 | CollectionId: TEST_COLLECTION_ID, |
| 222 | UserId: "test-user", |
| 223 | }).pipe(Effect.catchTag("ConflictException", () => Effect.void)); |
| 224 | const users = yield* listUsers({ |
| 225 | CollectionId: TEST_COLLECTION_ID, |
| 226 | }); |
| 227 | const searchUsersTag = yield* searchUsers({ |
| 228 | CollectionId: TEST_COLLECTION_ID, |
| 229 | UserId: "test-user", |
| 230 | }).pipe( |
| 231 | Effect.map(() => "Success"), |
| 232 | Effect.catchTag( |
| 233 | ["InvalidParameterException", "ResourceNotFoundException"], |
| 234 | (e) => Effect.succeed(e._tag), |
| 235 | ), |
| 236 | ); |
| 237 | const associateTag = yield* associateFaces({ |
| 238 | CollectionId: TEST_COLLECTION_ID, |
| 239 | UserId: "test-user", |
| 240 | FaceIds: [BOGUS_FACE_ID], |
| 241 | }).pipe( |
| 242 | Effect.map(() => "Success"), |
| 243 | Effect.catchTag( |
| 244 | ["InvalidParameterException", "ResourceNotFoundException"], |
| 245 | (e) => Effect.succeed(e._tag), |
| 246 | ), |
| 247 | ); |
| 248 | const disassociateTag = yield* disassociateFaces({ |
| 249 | CollectionId: TEST_COLLECTION_ID, |
| 250 | UserId: "test-user", |
| 251 | FaceIds: [BOGUS_FACE_ID], |
| 252 | }).pipe( |
| 253 | Effect.map(() => "Success"), |
| 254 | Effect.catchTag( |
| 255 | ["InvalidParameterException", "ResourceNotFoundException"], |
| 256 | (e) => Effect.succeed(e._tag), |
| 257 | ), |
| 258 | ); |
| 259 | const searchFacesTag = yield* searchFaces({ |
| 260 | CollectionId: TEST_COLLECTION_ID, |
| 261 | FaceId: BOGUS_FACE_ID, |
| 262 | }).pipe( |
| 263 | Effect.map(() => "Success"), |
| 264 | Effect.catchTag( |
| 265 | ["InvalidParameterException", "ResourceNotFoundException"], |
| 266 | (e) => Effect.succeed(e._tag), |
| 267 | ), |
| 268 | ); |
| 269 | const searchFacesByImageTag = yield* searchFacesByImage({ |
| 270 | CollectionId: TEST_COLLECTION_ID, |
| 271 | Image: { Bytes: imageBytes }, |
| 272 | }).pipe( |
| 273 | Effect.map(() => "Success"), |
| 274 | Effect.catchTag("InvalidParameterException", (e) => |
| 275 | Effect.succeed(e._tag), |
| 276 | ), |
| 277 | ); |
| 278 | const searchUsersByImageTag = yield* searchUsersByImage({ |
| 279 | CollectionId: TEST_COLLECTION_ID, |
| 280 | Image: { Bytes: imageBytes }, |
| 281 | }).pipe( |
| 282 | Effect.map(() => "Success"), |
| 283 | Effect.catchTag("InvalidParameterException", (e) => |
| 284 | Effect.succeed(e._tag), |
| 285 | ), |
| 286 | ); |
| 287 | const deleteFacesTag = yield* deleteFaces({ |
| 288 | CollectionId: TEST_COLLECTION_ID, |
| 289 | FaceIds: [BOGUS_FACE_ID], |
| 290 | }).pipe( |
| 291 | Effect.map(() => "Success"), |
| 292 | Effect.catchTag("InvalidParameterException", (e) => |
| 293 | Effect.succeed(e._tag), |
| 294 | ), |
| 295 | ); |
| 296 | yield* deleteUser({ |
| 297 | CollectionId: TEST_COLLECTION_ID, |
| 298 | UserId: "test-user", |
| 299 | }).pipe( |
| 300 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 301 | ); |
| 302 | yield* deleteCollection({ CollectionId: TEST_COLLECTION_ID }); |
| 303 | return yield* HttpServerResponse.json({ |
| 304 | createStatus: created, |
| 305 | faceCountAtCreate: described.FaceCount ?? 0, |
| 306 | listedCollection: (collections.CollectionIds ?? []).includes( |
| 307 | TEST_COLLECTION_ID, |
| 308 | ), |
| 309 | indexedFaceRecords: (indexed.FaceRecords ?? []).length, |
| 310 | listedFaces: (faces.Faces ?? []).length, |
| 311 | listedUsers: (users.Users ?? []).map((u) => u.UserId), |
| 312 | searchUsersTag, |
| 313 | associateTag, |
| 314 | disassociateTag, |
| 315 | searchFacesTag, |
| 316 | searchFacesByImageTag, |
| 317 | searchUsersByImageTag, |
| 318 | deleteFacesTag, |
| 319 | }); |
| 320 | } |
| 321 | |
| 322 | // Real Face Liveness lifecycle: create a session, read its CREATED |
| 323 | // status back, and drive the typed SessionNotFoundException path. |
| 324 | if (request.method === "POST" && pathname === "/liveness") { |
| 325 | const session = yield* createFaceLivenessSession(); |
| 326 | const results = yield* getFaceLivenessSessionResults({ |
| 327 | SessionId: session.SessionId, |
| 328 | }); |
| 329 | const notFoundTag = yield* getFaceLivenessSessionResults({ |
| 330 | SessionId: BOGUS_SESSION_ID, |
| 331 | }).pipe( |
| 332 | Effect.map(() => "Found"), |
| 333 | Effect.catchTag("SessionNotFoundException", (e) => |
| 334 | Effect.succeed(e._tag), |
| 335 | ), |
| 336 | ); |
| 337 | return yield* HttpServerResponse.json({ |
| 338 | sessionId: session.SessionId, |
| 339 | status: results.Status, |
| 340 | notFoundTag, |
| 341 | }); |
| 342 | } |
| 343 | |
| 344 | // Drives all eight Start* video bindings through Rekognition's typed |
| 345 | // server-side S3 validation (nonexistent bucket) — real runtime calls |
| 346 | // proving IAM without paying for eight async video jobs. |
| 347 | if (request.method === "POST" && pathname === "/video/start-all") { |
| 348 | const tags = { |
| 349 | celebrityRecognition: yield* startCelebrityRecognition({ |
| 350 | Video: BOGUS_VIDEO, |
| 351 | }).pipe( |
| 352 | Effect.map(() => "Started"), |
| 353 | Effect.catch((e) => Effect.succeed(e._tag)), |
| 354 | ), |
| 355 | contentModeration: yield* startContentModeration({ |
| 356 | Video: BOGUS_VIDEO, |
| 357 | }).pipe( |
| 358 | Effect.map(() => "Started"), |
| 359 | Effect.catch((e) => Effect.succeed(e._tag)), |
| 360 | ), |
| 361 | faceDetection: yield* startFaceDetection({ |
| 362 | Video: BOGUS_VIDEO, |
| 363 | }).pipe( |
| 364 | Effect.map(() => "Started"), |
| 365 | Effect.catch((e) => Effect.succeed(e._tag)), |
| 366 | ), |
| 367 | // FaceSearch also validates the collection — it may surface |
| 368 | // ResourceNotFoundException before the S3 check. |
| 369 | faceSearch: yield* startFaceSearch({ |
| 370 | Video: BOGUS_VIDEO, |
| 371 | CollectionId: "alchemy-nonexistent-collection", |
| 372 | }).pipe( |
| 373 | Effect.map(() => "Started"), |
| 374 | Effect.catch((e) => Effect.succeed(e._tag)), |
| 375 | ), |
| 376 | labelDetection: yield* startLabelDetection({ |
| 377 | Video: BOGUS_VIDEO, |
| 378 | }).pipe( |
| 379 | Effect.map(() => "Started"), |
| 380 | Effect.catch((e) => Effect.succeed(e._tag)), |
| 381 | ), |
| 382 | personTracking: yield* startPersonTracking({ |
| 383 | Video: BOGUS_VIDEO, |
| 384 | }).pipe( |
| 385 | Effect.map(() => "Started"), |
| 386 | Effect.catch((e) => Effect.succeed(e._tag)), |
| 387 | ), |
| 388 | segmentDetection: yield* startSegmentDetection({ |
| 389 | Video: BOGUS_VIDEO, |
| 390 | SegmentTypes: ["SHOT"], |
| 391 | }).pipe( |
| 392 | Effect.map(() => "Started"), |
| 393 | Effect.catch((e) => Effect.succeed(e._tag)), |
| 394 | ), |
| 395 | textDetection: yield* startTextDetection({ |
| 396 | Video: BOGUS_VIDEO, |
| 397 | }).pipe( |
| 398 | Effect.map(() => "Started"), |
| 399 | Effect.catch((e) => Effect.succeed(e._tag)), |
| 400 | ), |
| 401 | }; |
| 402 | return yield* HttpServerResponse.json(tags); |
| 403 | } |
| 404 | |
| 405 | // Drives all eight Get* video bindings through their typed |
| 406 | // ResourceNotFoundException path with a well-formed bogus JobId. |
| 407 | if (request.method === "GET" && pathname === "/video/get-all") { |
| 408 | const probe = { JobId: BOGUS_JOB_ID }; |
| 409 | const tags = { |
| 410 | celebrityRecognition: yield* getCelebrityRecognition(probe).pipe( |
| 411 | Effect.map(() => "Found"), |
| 412 | Effect.catchTag( |
| 413 | [ |
| 414 | "ResourceNotFoundException", |
| 415 | "InvalidParameterException", |
| 416 | "AccessDeniedException", |
| 417 | ], |
| 418 | (e) => Effect.succeed(e._tag), |
| 419 | ), |
| 420 | ), |
| 421 | contentModeration: yield* getContentModeration(probe).pipe( |
| 422 | Effect.map(() => "Found"), |
| 423 | Effect.catchTag( |
| 424 | [ |
| 425 | "ResourceNotFoundException", |
| 426 | "InvalidParameterException", |
| 427 | "AccessDeniedException", |
| 428 | ], |
| 429 | (e) => Effect.succeed(e._tag), |
| 430 | ), |
| 431 | ), |
| 432 | faceDetection: yield* getFaceDetection(probe).pipe( |
| 433 | Effect.map(() => "Found"), |
| 434 | Effect.catchTag( |
| 435 | [ |
| 436 | "ResourceNotFoundException", |
| 437 | "InvalidParameterException", |
| 438 | "AccessDeniedException", |
| 439 | ], |
| 440 | (e) => Effect.succeed(e._tag), |
| 441 | ), |
| 442 | ), |
| 443 | faceSearch: yield* getFaceSearch(probe).pipe( |
| 444 | Effect.map(() => "Found"), |
| 445 | Effect.catchTag( |
| 446 | [ |
| 447 | "ResourceNotFoundException", |
| 448 | "InvalidParameterException", |
| 449 | "AccessDeniedException", |
| 450 | ], |
| 451 | (e) => Effect.succeed(e._tag), |
| 452 | ), |
| 453 | ), |
| 454 | labelDetection: yield* getLabelDetection(probe).pipe( |
| 455 | Effect.map(() => "Found"), |
| 456 | Effect.catchTag( |
| 457 | [ |
| 458 | "ResourceNotFoundException", |
| 459 | "InvalidParameterException", |
| 460 | "AccessDeniedException", |
| 461 | ], |
| 462 | (e) => Effect.succeed(e._tag), |
| 463 | ), |
| 464 | ), |
| 465 | personTracking: yield* getPersonTracking(probe).pipe( |
| 466 | Effect.map(() => "Found"), |
| 467 | Effect.catchTag( |
| 468 | [ |
| 469 | "ResourceNotFoundException", |
| 470 | "InvalidParameterException", |
| 471 | "AccessDeniedException", |
| 472 | ], |
| 473 | (e) => Effect.succeed(e._tag), |
| 474 | ), |
| 475 | ), |
| 476 | segmentDetection: yield* getSegmentDetection(probe).pipe( |
| 477 | Effect.map(() => "Found"), |
| 478 | Effect.catchTag( |
| 479 | [ |
| 480 | "ResourceNotFoundException", |
| 481 | "InvalidParameterException", |
| 482 | "AccessDeniedException", |
| 483 | ], |
| 484 | (e) => Effect.succeed(e._tag), |
| 485 | ), |
| 486 | ), |
| 487 | textDetection: yield* getTextDetection(probe).pipe( |
| 488 | Effect.map(() => "Found"), |
| 489 | Effect.catchTag( |
| 490 | [ |
| 491 | "ResourceNotFoundException", |
| 492 | "InvalidParameterException", |
| 493 | "AccessDeniedException", |
| 494 | ], |
| 495 | (e) => Effect.succeed(e._tag), |
| 496 | ), |
| 497 | ), |
| 498 | }; |
| 499 | return yield* HttpServerResponse.json(tags); |
| 500 | } |
| 501 | |
| 502 | // Media analysis: list for real, start/get through typed error paths. |
| 503 | if (request.method === "POST" && pathname === "/media-analysis") { |
| 504 | const jobCount = ( |
| 505 | (yield* listMediaAnalysisJobs({ MaxResults: 10 })) |
| 506 | .MediaAnalysisJobs ?? [] |
| 507 | ).length; |
| 508 | const startTag = yield* startMediaAnalysisJob({ |
| 509 | OperationsConfig: { |
| 510 | DetectModerationLabels: { MinConfidence: 60 }, |
| 511 | }, |
| 512 | Input: { |
| 513 | S3Object: { Bucket: BOGUS_BUCKET, Name: "manifest.jsonl" }, |
| 514 | }, |
| 515 | OutputConfig: { S3Bucket: BOGUS_BUCKET, S3KeyPrefix: "out/" }, |
| 516 | }).pipe( |
| 517 | Effect.map(() => "Started"), |
| 518 | Effect.catchTag( |
| 519 | [ |
| 520 | "InvalidS3ObjectException", |
| 521 | "InvalidManifestException", |
| 522 | "InvalidParameterException", |
| 523 | "ResourceNotFoundException", |
| 524 | ], |
| 525 | (e) => Effect.succeed(e._tag), |
| 526 | ), |
| 527 | ); |
| 528 | const getTag = yield* getMediaAnalysisJob({ |
| 529 | JobId: BOGUS_JOB_ID, |
| 530 | }).pipe( |
| 531 | Effect.map(() => "Found"), |
| 532 | Effect.catchTag( |
| 533 | ["ResourceNotFoundException", "InvalidParameterException"], |
| 534 | (e) => Effect.succeed(e._tag), |
| 535 | ), |
| 536 | ); |
| 537 | return yield* HttpServerResponse.json({ jobCount, startTag, getTag }); |
| 538 | } |
| 539 | |
| 540 | // Stream processors: list for real, control plane through the typed |
| 541 | // ResourceNotFoundException path. Rekognition Video stream processors |
| 542 | // are allow-listed per account: an unentitled account gets the typed |
| 543 | // AccessDeniedException from every op (the list included), which |
| 544 | // still proves the binding reached the service. |
| 545 | if (request.method === "GET" && pathname === "/stream-processors") { |
| 546 | const listed = yield* listStreamProcessors({ MaxResults: 10 }).pipe( |
| 547 | Effect.map( |
| 548 | (r) => ({ count: (r.StreamProcessors ?? []).length }) as const, |
| 549 | ), |
| 550 | Effect.catchTag("AccessDeniedException", (e) => |
| 551 | Effect.succeed({ count: -1, listTag: e._tag } as const), |
| 552 | ), |
| 553 | ); |
| 554 | const describeTag = yield* describeStreamProcessor({ |
| 555 | Name: "alchemy-nonexistent-processor", |
| 556 | }).pipe( |
| 557 | Effect.map(() => "Found"), |
| 558 | Effect.catchTag( |
| 559 | ["ResourceNotFoundException", "AccessDeniedException"], |
| 560 | (e) => Effect.succeed(e._tag), |
| 561 | ), |
| 562 | ); |
| 563 | const startTag = yield* startStreamProcessor({ |
| 564 | Name: "alchemy-nonexistent-processor", |
| 565 | }).pipe( |
| 566 | Effect.map(() => "Started"), |
| 567 | Effect.catchTag( |
| 568 | ["ResourceNotFoundException", "AccessDeniedException"], |
| 569 | (e) => Effect.succeed(e._tag), |
| 570 | ), |
| 571 | ); |
| 572 | const stopTag = yield* stopStreamProcessor({ |
| 573 | Name: "alchemy-nonexistent-processor", |
| 574 | }).pipe( |
| 575 | Effect.map(() => "Stopped"), |
| 576 | Effect.catchTag( |
| 577 | ["ResourceNotFoundException", "AccessDeniedException"], |
| 578 | (e) => Effect.succeed(e._tag), |
| 579 | ), |
| 580 | ); |
| 581 | return yield* HttpServerResponse.json({ |
| 582 | ...listed, |
| 583 | describeTag, |
| 584 | startTag, |
| 585 | stopTag, |
| 586 | }); |
| 587 | } |
| 588 | |
| 589 | // Custom Labels: DescribeProjects for real; the model-scoped ops run |
| 590 | // through their typed ResourceNotFoundException path with well-formed |
| 591 | // same-account ARNs (?account=… is passed by the test, parsed from the |
| 592 | // deployed function's ARN). Custom Labels is closed to new customers, |
| 593 | // so entitlement-gated accounts may surface the typed |
| 594 | // AccessDeniedException instead — the test accepts both. |
| 595 | if (request.method === "GET" && pathname === "/custom-labels") { |
| 596 | const account = url.searchParams.get("account") ?? "123456789012"; |
| 597 | const region = yield* Effect.sync( |
| 598 | () => process.env.AWS_REGION ?? "us-east-1", |
| 599 | ); |
| 600 | const projectArn = `arn:aws:rekognition:${region}:${account}:project/alchemy-nonexistent/1700000000000`; |
| 601 | const projectVersionArn = `arn:aws:rekognition:${region}:${account}:project/alchemy-nonexistent/version/alchemy-nonexistent/1700000000000`; |
| 602 | const projectCount = ( |
| 603 | (yield* describeProjects({ MaxResults: 10 })).ProjectDescriptions ?? |
| 604 | [] |
| 605 | ).length; |
| 606 | const describeVersionsTag = yield* describeProjectVersions({ |
| 607 | ProjectArn: projectArn, |
| 608 | }).pipe( |
| 609 | Effect.map(() => "Found"), |
| 610 | Effect.catchTag( |
| 611 | [ |
| 612 | "ResourceNotFoundException", |
| 613 | "InvalidParameterException", |
| 614 | "AccessDeniedException", |
| 615 | ], |
| 616 | (e) => Effect.succeed(e._tag), |
| 617 | ), |
| 618 | ); |
| 619 | const detectTag = yield* detectCustomLabels({ |
| 620 | ProjectVersionArn: projectVersionArn, |
| 621 | Image: { Bytes: imageBytes }, |
| 622 | }).pipe( |
| 623 | Effect.map(() => "Detected"), |
| 624 | Effect.catchTag( |
| 625 | [ |
| 626 | "ResourceNotFoundException", |
| 627 | "InvalidParameterException", |
| 628 | "AccessDeniedException", |
| 629 | ], |
| 630 | (e) => Effect.succeed(e._tag), |
| 631 | ), |
| 632 | ); |
| 633 | const startTag = yield* startProjectVersion({ |
| 634 | ProjectVersionArn: projectVersionArn, |
| 635 | MinInferenceUnits: 1, |
| 636 | }).pipe( |
| 637 | Effect.map(() => "Started"), |
| 638 | Effect.catchTag( |
| 639 | [ |
| 640 | "ResourceNotFoundException", |
| 641 | "InvalidParameterException", |
| 642 | "AccessDeniedException", |
| 643 | ], |
| 644 | (e) => Effect.succeed(e._tag), |
| 645 | ), |
| 646 | ); |
| 647 | const stopTag = yield* stopProjectVersion({ |
| 648 | ProjectVersionArn: projectVersionArn, |
| 649 | }).pipe( |
| 650 | Effect.map(() => "Stopped"), |
| 651 | Effect.catchTag( |
| 652 | [ |
| 653 | "ResourceNotFoundException", |
| 654 | "InvalidParameterException", |
| 655 | "AccessDeniedException", |
| 656 | ], |
| 657 | (e) => Effect.succeed(e._tag), |
| 658 | ), |
| 659 | ); |
| 660 | return yield* HttpServerResponse.json({ |
| 661 | projectCount, |
| 662 | describeVersionsTag, |
| 663 | detectTag, |
| 664 | startTag, |
| 665 | stopTag, |
| 666 | }); |
| 667 | } |
| 668 | |
| 669 | return yield* HttpServerResponse.json( |
| 670 | { error: "Not found", method: request.method, pathname }, |
| 671 | { status: 404 }, |
| 672 | ); |
| 673 | }).pipe(Effect.orDie), |
| 674 | }; |
| 675 | }).pipe( |
| 676 | Effect.provide( |
| 677 | Layer.mergeAll( |
| 678 | Rekognition.CompareFacesHttp, |
| 679 | Rekognition.DetectFacesHttp, |
| 680 | Rekognition.DetectLabelsHttp, |
| 681 | Rekognition.DetectModerationLabelsHttp, |
| 682 | Rekognition.DetectProtectiveEquipmentHttp, |
| 683 | Rekognition.DetectTextHttp, |
| 684 | Rekognition.RecognizeCelebritiesHttp, |
| 685 | Rekognition.GetCelebrityInfoHttp, |
| 686 | Rekognition.CreateCollectionHttp, |
| 687 | Rekognition.DeleteCollectionHttp, |
| 688 | Rekognition.DescribeCollectionHttp, |
| 689 | Rekognition.ListCollectionsHttp, |
| 690 | Rekognition.IndexFacesHttp, |
| 691 | Rekognition.ListFacesHttp, |
| 692 | Rekognition.DeleteFacesHttp, |
| 693 | Rekognition.SearchFacesHttp, |
| 694 | Rekognition.SearchFacesByImageHttp, |
| 695 | Rekognition.CreateUserHttp, |
| 696 | Rekognition.DeleteUserHttp, |
| 697 | Rekognition.ListUsersHttp, |
| 698 | Rekognition.AssociateFacesHttp, |
| 699 | Rekognition.DisassociateFacesHttp, |
| 700 | Rekognition.SearchUsersHttp, |
| 701 | Rekognition.SearchUsersByImageHttp, |
| 702 | Rekognition.CreateFaceLivenessSessionHttp, |
| 703 | Rekognition.GetFaceLivenessSessionResultsHttp, |
| 704 | Rekognition.StartCelebrityRecognitionHttp, |
| 705 | Rekognition.GetCelebrityRecognitionHttp, |
| 706 | Rekognition.StartContentModerationHttp, |
| 707 | Rekognition.GetContentModerationHttp, |
| 708 | Rekognition.StartFaceDetectionHttp, |
| 709 | Rekognition.GetFaceDetectionHttp, |
| 710 | Rekognition.StartFaceSearchHttp, |
| 711 | Rekognition.GetFaceSearchHttp, |
| 712 | Rekognition.StartLabelDetectionHttp, |
| 713 | Rekognition.GetLabelDetectionHttp, |
| 714 | Rekognition.StartPersonTrackingHttp, |
| 715 | Rekognition.GetPersonTrackingHttp, |
| 716 | Rekognition.StartSegmentDetectionHttp, |
| 717 | Rekognition.GetSegmentDetectionHttp, |
| 718 | Rekognition.StartTextDetectionHttp, |
| 719 | Rekognition.GetTextDetectionHttp, |
| 720 | Rekognition.StartMediaAnalysisJobHttp, |
| 721 | Rekognition.GetMediaAnalysisJobHttp, |
| 722 | Rekognition.ListMediaAnalysisJobsHttp, |
| 723 | Rekognition.StartStreamProcessorHttp, |
| 724 | Rekognition.StopStreamProcessorHttp, |
| 725 | Rekognition.DescribeStreamProcessorHttp, |
| 726 | Rekognition.ListStreamProcessorsHttp, |
| 727 | Rekognition.DetectCustomLabelsHttp, |
| 728 | Rekognition.DescribeProjectsHttp, |
| 729 | Rekognition.DescribeProjectVersionsHttp, |
| 730 | Rekognition.StartProjectVersionHttp, |
| 731 | Rekognition.StopProjectVersionHttp, |
| 732 | ), |
| 733 | ), |
| 734 | ), |
| 735 | ); |