1import * as HealthLake from "@/AWS/HealthLake";
2import * as IAM from "@/AWS/IAM";
3import * as KMS from "@/AWS/KMS";
4import * as Lambda from "@/AWS/Lambda";
5import * as S3 from "@/AWS/S3";
6import * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import { HttpServerRequest } from "effect/http/HttpServerRequest";
10import * as HttpServerResponse from "effect/http/HttpServerResponse";
11import path from "pathe";
12
13const main = path.resolve(import.meta.dirname, "handler.ts");
14
15/** S3 prefix the gated test uploads FHIR NDJSON to before calling `/import`. */
16export const IMPORT_PREFIX = "import/";
17
18export class HealthLakeTestFunction extends Lambda.Function<Lambda.Function>()(
19 "HealthLakeTestFunction",
20) {}
21
22/**
23 * Every route answers `{ …fields }` on success or `{ errorTag }` when the
24 * operation fails with a TYPED error — the test asserts on concrete fields
25 * (or a typed tag), which proves the binding wiring, the identifier/role
26 * injection, and the IAM grants. An untyped error crashes into a 500.
27 */
28const errorTagged = <A, E extends { _tag: string }, R>(
29 effect: Effect.Effect<A, E, R>,
30): Effect.Effect<A | { errorTag: string }, never, R> =>
31 effect.pipe(
32 Effect.map((a): A | { errorTag: string } => a),
33 Effect.catch((e) => Effect.succeed({ errorTag: e._tag })),
34 );
35
36/**
37 * Data-store-scoped binding fixture: deploys a real FHIR R4 data store
38 * (~15-30 minutes to provision, billed while it exists — gated behind
39 * AWS_TEST_HEALTHLAKE) plus the import/export supporting cast (S3 bucket,
40 * KMS key, HealthLake data-access role) and a Lambda bound to all six job
41 * bindings.
42 */
43export default HealthLakeTestFunction.make(
44 {
45 main,
46 functionUrl: true,
47 timeout: Duration.seconds(30),
48 },
49 Effect.gen(function* () {
50 const bucket = yield* S3.Bucket("HealthLakeBindingsBucket", {
51 forceDestroy: true,
52 });
53
54 const key = yield* KMS.Key("HealthLakeBindingsKey", {
55 description: "healthlake bindings fixture output encryption key",
56 deletionWindow: "7 days",
57 });
58
59 // The data-access role HealthLake assumes to read the import NDJSON and
60 // write job output. Fixture-only wide grants; the interesting IAM (the
61 // scoped healthlake:* + iam:PassRole statements) is what the bindings
62 // attach to the Lambda itself.
63 const role = yield* IAM.Role("HealthLakeBindingsRole", {
64 assumeRolePolicyDocument: {
65 Version: "2012-10-17",
66 Statement: [
67 {
68 Effect: "Allow",
69 Principal: { Service: "healthlake.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 Effect: "Allow",
85 Action: [
86 "kms:DescribeKey",
87 "kms:GenerateDataKey",
88 "kms:GenerateDataKey*",
89 "kms:Decrypt",
90 "kms:Encrypt",
91 ],
92 Resource: ["*"],
93 },
94 ],
95 },
96 },
97 });
98
99 const datastore = yield* HealthLake.FHIRDatastore("BindingsStore", {
100 tags: { fixture: "healthlake-bindings" },
101 });
102
103 const startImport = yield* HealthLake.StartFHIRImportJob(datastore, role);
104 const describeImport = yield* HealthLake.DescribeFHIRImportJob(datastore);
105 const listImports = yield* HealthLake.ListFHIRImportJobs(datastore);
106 const startExport = yield* HealthLake.StartFHIRExportJob(datastore, role);
107 const describeExport = yield* HealthLake.DescribeFHIRExportJob(datastore);
108 const listExports = yield* HealthLake.ListFHIRExportJobs(datastore);
109
110 const bound = {
111 startImport,
112 describeImport,
113 listImports,
114 startExport,
115 describeExport,
116 listExports,
117 };
118
119 const BucketName = yield* bucket.bucketName;
120 const KeyArn = yield* key.keyArn;
121
122 return {
123 fetch: Effect.gen(function* () {
124 const request = yield* HttpServerRequest;
125 const url = new URL(request.originalUrl);
126 const pathname = url.pathname;
127 const bucketName = yield* BucketName;
128 const kmsKeyId = yield* KeyArn;
129
130 if (request.method === "GET" && pathname === "/bindings") {
131 return yield* HttpServerResponse.json({
132 bound: Object.keys(bound),
133 });
134 }
135
136 if (request.method === "GET" && pathname === "/config") {
137 return yield* HttpServerResponse.json({ bucketName });
138 }
139
140 if (request.method === "GET" && pathname === "/import") {
141 const result = yield* errorTagged(
142 startImport({
143 InputDataConfig: {
144 S3Uri: `s3://${bucketName}/${IMPORT_PREFIX}`,
145 },
146 JobOutputDataConfig: {
147 S3Configuration: {
148 S3Uri: `s3://${bucketName}/import-output/`,
149 KmsKeyId: kmsKeyId,
150 },
151 },
152 }),
153 );
154 return yield* HttpServerResponse.json(
155 "errorTag" in result
156 ? result
157 : { jobId: result.JobId, status: result.JobStatus },
158 );
159 }
160
161 if (request.method === "GET" && pathname === "/describe-import") {
162 const jobId = url.searchParams.get("jobId") ?? "";
163 const result = yield* errorTagged(describeImport({ JobId: jobId }));
164 return yield* HttpServerResponse.json(
165 "errorTag" in result
166 ? result
167 : { status: result.ImportJobProperties.JobStatus },
168 );
169 }
170
171 if (request.method === "GET" && pathname === "/list-imports") {
172 const result = yield* errorTagged(listImports());
173 return yield* HttpServerResponse.json(
174 "errorTag" in result
175 ? result
176 : { count: result.ImportJobPropertiesList.length },
177 );
178 }
179
180 if (request.method === "GET" && pathname === "/export") {
181 const result = yield* errorTagged(
182 startExport({
183 OutputDataConfig: {
184 S3Configuration: {
185 S3Uri: `s3://${bucketName}/export/`,
186 KmsKeyId: kmsKeyId,
187 },
188 },
189 }),
190 );
191 return yield* HttpServerResponse.json(
192 "errorTag" in result
193 ? result
194 : { jobId: result.JobId, status: result.JobStatus },
195 );
196 }
197
198 if (request.method === "GET" && pathname === "/describe-export") {
199 const jobId = url.searchParams.get("jobId") ?? "";
200 const result = yield* errorTagged(describeExport({ JobId: jobId }));
201 return yield* HttpServerResponse.json(
202 "errorTag" in result
203 ? result
204 : { status: result.ExportJobProperties.JobStatus },
205 );
206 }
207
208 if (request.method === "GET" && pathname === "/list-exports") {
209 const result = yield* errorTagged(listExports());
210 return yield* HttpServerResponse.json(
211 "errorTag" in result
212 ? result
213 : { count: result.ExportJobPropertiesList.length },
214 );
215 }
216
217 return yield* HttpServerResponse.json(
218 { error: "Not found", method: request.method, pathname },
219 { status: 404 },
220 );
221 }).pipe(Effect.orDie),
222 };
223 }).pipe(
224 Effect.provide(
225 Layer.mergeAll(
226 HealthLake.StartFHIRImportJobHttp,
227 HealthLake.DescribeFHIRImportJobHttp,
228 HealthLake.ListFHIRImportJobsHttp,
229 HealthLake.StartFHIRExportJobHttp,
230 HealthLake.DescribeFHIRExportJobHttp,
231 HealthLake.ListFHIRExportJobsHttp,
232 ),
233 ),
234 ),
235);