1import * as IAM from "@/AWS/IAM";
2import * as Lambda from "@/AWS/Lambda";
3import * as MedicalImaging from "@/AWS/MedicalImaging";
4import * as S3 from "@/AWS/S3";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import { HttpServerRequest } from "effect/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/http/HttpServerResponse";
10import path from "pathe";
11
12const main = path.resolve(import.meta.dirname, "handler.ts");
13
14/** S3 prefix the gated test uploads DICOM input to before calling `/import`. */
15export const IMPORT_PREFIX = "dicom-input/";
16
17export class MedicalImagingTestFunction extends Lambda.Function<Lambda.Function>()(
18 "MedicalImagingTestFunction",
19) {}
20
21/**
22 * Every route answers `{ …fields }` on success or `{ errorTag }` when the
23 * operation fails with a TYPED error — the test asserts on concrete fields
24 * (or a typed tag), which proves the binding wiring, the identifier/role
25 * injection, and the IAM grants (a missing grant surfaces as
26 * `AccessDeniedException`, not `ResourceNotFoundException`). An untyped
27 * error crashes into a 500.
28 */
29const errorTagged = <A, E extends { _tag: string }, R>(
30 effect: Effect.Effect<A, E, R>,
31): Effect.Effect<A | { errorTag: string }, never, R> =>
32 effect.pipe(
33 Effect.map((a): A | { errorTag: string } => a),
34 Effect.catch((e) => Effect.succeed({ errorTag: e._tag })),
35 );
36
37// A well-formed (32 hex chars) but nonexistent image set id — image-set
38// routes prove wiring + IAM by observing the typed not-found tag.
39const NONEXISTENT_IMAGE_SET = "0123456789abcdef0123456789abcdef";
40
41/**
42 * Data-store-scoped binding fixture: deploys a real HealthImaging data
43 * store (provisions in a few minutes — gated behind
44 * AWS_TEST_MEDICAL_IMAGING) plus the import supporting cast (S3 bucket,
45 * HealthImaging data-access role) and a Lambda bound to all eleven
46 * data-plane bindings.
47 */
48export default MedicalImagingTestFunction.make(
49 {
50 main,
51 functionUrl: true,
52 timeout: Duration.seconds(30),
53 },
54 Effect.gen(function* () {
55 const bucket = yield* S3.Bucket("MedicalImagingBindingsBucket", {
56 forceDestroy: true,
57 });
58
59 // The data-access role HealthImaging assumes to read the DICOM P10
60 // input and write the import manifests. Fixture-only wide grants; the
61 // interesting IAM (the scoped medical-imaging:* + iam:PassRole
62 // statements) is what the bindings attach to the Lambda itself.
63 const role = yield* IAM.Role("MedicalImagingBindingsRole", {
64 assumeRolePolicyDocument: {
65 Version: "2012-10-17",
66 Statement: [
67 {
68 Effect: "Allow",
69 Principal: { Service: "medical-imaging.amazonaws.com" },
70 Action: ["sts:AssumeRole"],
71 },
72 ],
73 },
74 inlinePolicies: {
75 DataAccess: {
76 Version: "2012-10-17",
77 Statement: [
78 {
79 Effect: "Allow",
80 Action: ["s3:GetObject", "s3:PutObject", "s3:ListBucket"],
81 Resource: ["*"],
82 },
83 ],
84 },
85 },
86 });
87
88 const datastore = yield* MedicalImaging.Datastore("BindingsStore", {
89 tags: { fixture: "medical-imaging-bindings" },
90 });
91
92 const startImport = yield* MedicalImaging.StartDICOMImportJob(
93 datastore,
94 role,
95 );
96 const getImportJob = yield* MedicalImaging.GetDICOMImportJob(datastore);
97 const listImportJobs = yield* MedicalImaging.ListDICOMImportJobs(datastore);
98 const searchImageSets = yield* MedicalImaging.SearchImageSets(datastore);
99 const getImageSet = yield* MedicalImaging.GetImageSet(datastore);
100 const getImageSetMetadata =
101 yield* MedicalImaging.GetImageSetMetadata(datastore);
102 const getImageFrame = yield* MedicalImaging.GetImageFrame(datastore);
103 const listImageSetVersions =
104 yield* MedicalImaging.ListImageSetVersions(datastore);
105 const updateImageSetMetadata =
106 yield* MedicalImaging.UpdateImageSetMetadata(datastore);
107 const copyImageSet = yield* MedicalImaging.CopyImageSet(datastore);
108 const deleteImageSet = yield* MedicalImaging.DeleteImageSet(datastore);
109
110 const bound = {
111 startImport,
112 getImportJob,
113 listImportJobs,
114 searchImageSets,
115 getImageSet,
116 getImageSetMetadata,
117 getImageFrame,
118 listImageSetVersions,
119 updateImageSetMetadata,
120 copyImageSet,
121 deleteImageSet,
122 };
123
124 const BucketName = yield* bucket.bucketName;
125
126 return {
127 fetch: Effect.gen(function* () {
128 const request = yield* HttpServerRequest;
129 const url = new URL(request.originalUrl);
130 const pathname = url.pathname;
131 const bucketName = yield* BucketName;
132 const imageSetId =
133 url.searchParams.get("imageSetId") ?? NONEXISTENT_IMAGE_SET;
134
135 if (request.method === "GET" && pathname === "/bindings") {
136 return yield* HttpServerResponse.json({
137 bound: Object.keys(bound),
138 });
139 }
140
141 if (request.method === "GET" && pathname === "/config") {
142 return yield* HttpServerResponse.json({ bucketName });
143 }
144
145 // StartDICOMImportJob — proves datastoreId + dataAccessRoleArn
146 // injection and the iam:PassRole grant.
147 if (request.method === "GET" && pathname === "/import") {
148 const clientToken = yield* Effect.sync(() => crypto.randomUUID());
149 const result = yield* errorTagged(
150 startImport({
151 clientToken,
152 inputS3Uri: `s3://${bucketName}/${IMPORT_PREFIX}`,
153 outputS3Uri: `s3://${bucketName}/dicom-output/`,
154 }),
155 );
156 return yield* HttpServerResponse.json(
157 "errorTag" in result
158 ? result
159 : { jobId: result.jobId, status: result.jobStatus },
160 );
161 }
162
163 if (request.method === "GET" && pathname === "/describe-import") {
164 const jobId = url.searchParams.get("jobId") ?? "";
165 const result = yield* errorTagged(getImportJob({ jobId }));
166 return yield* HttpServerResponse.json(
167 "errorTag" in result
168 ? result
169 : { status: result.jobProperties.jobStatus },
170 );
171 }
172
173 if (request.method === "GET" && pathname === "/list-imports") {
174 const result = yield* errorTagged(listImportJobs());
175 return yield* HttpServerResponse.json(
176 "errorTag" in result
177 ? result
178 : { count: result.jobSummaries.length },
179 );
180 }
181
182 if (request.method === "GET" && pathname === "/search") {
183 const result = yield* errorTagged(searchImageSets());
184 return yield* HttpServerResponse.json(
185 "errorTag" in result
186 ? result
187 : { count: result.imageSetsMetadataSummaries.length },
188 );
189 }
190
191 if (request.method === "GET" && pathname === "/get-image-set") {
192 const result = yield* errorTagged(getImageSet({ imageSetId }));
193 return yield* HttpServerResponse.json(
194 "errorTag" in result
195 ? result
196 : { state: result.imageSetState, versionId: result.versionId },
197 );
198 }
199
200 if (request.method === "GET" && pathname === "/get-metadata") {
201 const result = yield* errorTagged(
202 getImageSetMetadata({ imageSetId }),
203 );
204 return yield* HttpServerResponse.json(
205 "errorTag" in result
206 ? result
207 : { contentType: result.contentType ?? "unknown" },
208 );
209 }
210
211 if (request.method === "GET" && pathname === "/get-frame") {
212 const result = yield* errorTagged(
213 getImageFrame({
214 imageSetId,
215 imageFrameInformation: {
216 imageFrameId: NONEXISTENT_IMAGE_SET,
217 },
218 }),
219 );
220 return yield* HttpServerResponse.json(
221 "errorTag" in result
222 ? result
223 : { contentType: result.contentType ?? "unknown" },
224 );
225 }
226
227 if (request.method === "GET" && pathname === "/versions") {
228 const result = yield* errorTagged(
229 listImageSetVersions({ imageSetId }),
230 );
231 return yield* HttpServerResponse.json(
232 "errorTag" in result
233 ? result
234 : { count: result.imageSetPropertiesList.length },
235 );
236 }
237
238 if (request.method === "GET" && pathname === "/update-metadata") {
239 const result = yield* errorTagged(
240 updateImageSetMetadata({
241 imageSetId,
242 latestVersionId: "1",
243 updateImageSetMetadataUpdates: { revertToVersionId: "1" },
244 }),
245 );
246 return yield* HttpServerResponse.json(
247 "errorTag" in result
248 ? result
249 : { versionId: result.latestVersionId },
250 );
251 }
252
253 if (request.method === "GET" && pathname === "/copy") {
254 const result = yield* errorTagged(
255 copyImageSet({
256 sourceImageSetId: imageSetId,
257 copyImageSetInformation: {
258 sourceImageSet: { latestVersionId: "1" },
259 },
260 }),
261 );
262 return yield* HttpServerResponse.json(
263 "errorTag" in result
264 ? result
265 : {
266 imageSetId: result.destinationImageSetProperties.imageSetId,
267 },
268 );
269 }
270
271 if (request.method === "GET" && pathname === "/delete-image-set") {
272 const result = yield* errorTagged(deleteImageSet({ imageSetId }));
273 return yield* HttpServerResponse.json(
274 "errorTag" in result
275 ? result
276 : { status: result.imageSetWorkflowStatus },
277 );
278 }
279
280 return yield* HttpServerResponse.json(
281 { error: "Not found", method: request.method, pathname },
282 { status: 404 },
283 );
284 }).pipe(Effect.orDie),
285 };
286 }).pipe(
287 Effect.provide(
288 Layer.mergeAll(
289 MedicalImaging.StartDICOMImportJobHttp,
290 MedicalImaging.GetDICOMImportJobHttp,
291 MedicalImaging.ListDICOMImportJobsHttp,
292 MedicalImaging.SearchImageSetsHttp,
293 MedicalImaging.GetImageSetHttp,
294 MedicalImaging.GetImageSetMetadataHttp,
295 MedicalImaging.GetImageFrameHttp,
296 MedicalImaging.ListImageSetVersionsHttp,
297 MedicalImaging.UpdateImageSetMetadataHttp,
298 MedicalImaging.CopyImageSetHttp,
299 MedicalImaging.DeleteImageSetHttp,
300 ),
301 ),
302 ),
303);