1import * as Lambda from "@/AWS/Lambda";
2import * as S3 from "@/AWS/S3";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { HttpServerRequest } from "effect/http/HttpServerRequest";
6import * as HttpServerResponse from "effect/http/HttpServerResponse";
7
8export class S3PresignTestFunction extends Lambda.Function<S3PresignTestFunction>()(
9 "S3PresignTestFunction",
10) {}
11
12export default S3PresignTestFunction.make(
13 {
14 main: import.meta.url,
15 functionUrl: true,
16 },
17 Effect.gen(function* () {
18 const bucket = yield* S3.Bucket("PresignBucket", {
19 forceDestroy: true,
20 });
21
22 const presignGetObject = yield* S3.PresignGetObject(bucket);
23 const presignPutObject = yield* S3.PresignPutObject(bucket);
24 const deleteObjects = yield* S3.DeleteObjects(bucket);
25 const getObjectTagging = yield* S3.GetObjectTagging(bucket);
26 const putObjectTagging = yield* S3.PutObjectTagging(bucket);
27 const deleteObjectTagging = yield* S3.DeleteObjectTagging(bucket);
28 const getObjectAttributes = yield* S3.GetObjectAttributes(bucket);
29 const listObjectVersions = yield* S3.ListObjectVersions(bucket);
30 const createMultipartUpload = yield* S3.CreateMultipartUpload(bucket);
31 const uploadPart = yield* S3.UploadPart(bucket);
32 const uploadPartCopy = yield* S3.UploadPartCopy(bucket);
33 const listParts = yield* S3.ListParts(bucket);
34 const listMultipartUploads = yield* S3.ListMultipartUploads(bucket);
35 const completeMultipartUpload = yield* S3.CompleteMultipartUpload(bucket);
36 const abortMultipartUpload = yield* S3.AbortMultipartUpload(bucket);
37 const restoreObject = yield* S3.RestoreObject(bucket);
38 const getObjectRetention = yield* S3.GetObjectRetention(bucket);
39 const putObjectRetention = yield* S3.PutObjectRetention(bucket);
40 const getObjectLegalHold = yield* S3.GetObjectLegalHold(bucket);
41 const putObjectLegalHold = yield* S3.PutObjectLegalHold(bucket);
42 const BucketName = yield* bucket.bucketName;
43
44 return {
45 fetch: Effect.gen(function* () {
46 const request = yield* HttpServerRequest;
47 const url = new URL(request.originalUrl);
48 const pathname = url.pathname;
49
50 // The first event after a cold start can observe not-yet-hydrated
51 // resource Outputs (they resolve to `undefined` until the runtime
52 // finishes hydrating resource state). Answer 503 so callers retry
53 // instead of receiving presigned URLs for a bucket named
54 // "undefined".
55 const bucketName = yield* BucketName;
56 if (!bucketName) {
57 return HttpServerResponse.text("Outputs not hydrated yet", {
58 status: 503,
59 });
60 }
61
62 const key = url.searchParams.get("key");
63 const requireKey = () =>
64 HttpServerResponse.text("Missing key", { status: 400 });
65
66 if (request.method === "GET" && pathname === "/bucket-name") {
67 return yield* HttpServerResponse.json({ bucketName });
68 }
69
70 if (request.method === "GET" && pathname === "/presign-get") {
71 if (!key) return requireKey();
72 const expiresIn = url.searchParams.get("expiresIn");
73 const contentType = url.searchParams.get("contentType");
74 const presignedUrl = yield* presignGetObject({
75 key,
76 expiresIn: expiresIn ? Number(expiresIn) : undefined,
77 contentType: contentType ?? undefined,
78 });
79 return yield* HttpServerResponse.json({ url: presignedUrl });
80 }
81
82 if (request.method === "GET" && pathname === "/presign-put") {
83 if (!key) return requireKey();
84 const expiresIn = url.searchParams.get("expiresIn");
85 const contentType = url.searchParams.get("contentType");
86 const presignedUrl = yield* presignPutObject({
87 key,
88 expiresIn: expiresIn ? Number(expiresIn) : undefined,
89 contentType: contentType ?? undefined,
90 });
91 return yield* HttpServerResponse.json({ url: presignedUrl });
92 }
93
94 if (request.method === "GET" && pathname === "/delete-objects") {
95 const keys = (url.searchParams.get("keys") ?? "")
96 .split(",")
97 .filter((k) => k.length > 0);
98 const result = yield* deleteObjects({
99 Delete: { Objects: keys.map((Key) => ({ Key })) },
100 });
101 return yield* HttpServerResponse.json({
102 deleted: (result.Deleted ?? []).map((d) => d.Key),
103 errors: (result.Errors ?? []).map((e) => e.Code),
104 });
105 }
106
107 if (request.method === "GET" && pathname === "/put-tagging") {
108 if (!key) return requireKey();
109 const tagKey = url.searchParams.get("tagKey") ?? "status";
110 const tagValue = url.searchParams.get("tagValue") ?? "test";
111 yield* putObjectTagging({
112 Key: key,
113 Tagging: { TagSet: [{ Key: tagKey, Value: tagValue }] },
114 });
115 return yield* HttpServerResponse.json({ ok: true });
116 }
117
118 if (request.method === "GET" && pathname === "/get-tagging") {
119 if (!key) return requireKey();
120 const result = yield* getObjectTagging({ Key: key });
121 return yield* HttpServerResponse.json({
122 tags: Object.fromEntries(
123 (result.TagSet ?? []).map((t) => [t.Key, t.Value]),
124 ),
125 });
126 }
127
128 if (request.method === "GET" && pathname === "/delete-tagging") {
129 if (!key) return requireKey();
130 yield* deleteObjectTagging({ Key: key });
131 return yield* HttpServerResponse.json({ ok: true });
132 }
133
134 if (request.method === "GET" && pathname === "/attributes") {
135 if (!key) return requireKey();
136 const result = yield* getObjectAttributes({
137 Key: key,
138 ObjectAttributes: ["ObjectSize", "ETag", "StorageClass"],
139 });
140 return yield* HttpServerResponse.json({
141 size: result.ObjectSize,
142 etag: result.ETag,
143 storageClass: result.StorageClass ?? "STANDARD",
144 });
145 }
146
147 if (request.method === "GET" && pathname === "/versions") {
148 const prefix = url.searchParams.get("prefix") ?? undefined;
149 const result = yield* listObjectVersions({ Prefix: prefix });
150 return yield* HttpServerResponse.json({
151 versions: (result.Versions ?? []).map((v) => v.Key),
152 deleteMarkers: (result.DeleteMarkers ?? []).length,
153 });
154 }
155
156 if (request.method === "GET" && pathname === "/multipart-list") {
157 if (!key) return requireKey();
158 // create → upload one part → observe via ListParts and
159 // ListMultipartUploads → abort (leave nothing behind).
160 const created = yield* createMultipartUpload({ Key: key });
161 const uploadId = created.UploadId!;
162 yield* uploadPart({
163 Key: key,
164 UploadId: uploadId,
165 PartNumber: 1,
166 Body: "multipart list probe",
167 });
168 const parts = yield* listParts({ Key: key, UploadId: uploadId });
169 const uploads = yield* listMultipartUploads({ Prefix: key });
170 yield* abortMultipartUpload({ Key: key, UploadId: uploadId });
171 return yield* HttpServerResponse.json({
172 parts: (parts.Parts ?? []).map((p) => p.PartNumber),
173 uploads: (uploads.Uploads ?? []).map((u) => u.Key),
174 });
175 }
176
177 if (request.method === "GET" && pathname === "/multipart-copy") {
178 const src = url.searchParams.get("src");
179 const dest = url.searchParams.get("dest");
180 if (!src || !dest) {
181 return HttpServerResponse.text("Missing src/dest", {
182 status: 400,
183 });
184 }
185 // single part sourced via UploadPartCopy (the only part may be
186 // any size), then complete.
187 const created = yield* createMultipartUpload({ Key: dest });
188 const uploadId = created.UploadId!;
189 const copied = yield* uploadPartCopy({
190 Key: dest,
191 UploadId: uploadId,
192 PartNumber: 1,
193 CopySource: `${bucketName}/${src}`,
194 });
195 const completed = yield* completeMultipartUpload({
196 Key: dest,
197 UploadId: uploadId,
198 MultipartUpload: {
199 Parts: [{ ETag: copied.CopyPartResult!.ETag!, PartNumber: 1 }],
200 },
201 });
202 return yield* HttpServerResponse.json({ etag: completed.ETag });
203 }
204
205 // The routes below exercise bindings whose success path needs an
206 // archived object or an Object Lock bucket. They return the typed
207 // error tag so tests can assert the binding + IAM wiring works and
208 // the failure is the *expected, typed* platform rejection.
209 if (request.method === "GET" && pathname === "/restore") {
210 if (!key) return requireKey();
211 const outcome = yield* restoreObject({
212 Key: key,
213 RestoreRequest: {
214 Days: 1,
215 GlacierJobParameters: { Tier: "Standard" },
216 },
217 }).pipe(
218 Effect.map((): { tag: string } => ({ tag: "success" })),
219 Effect.catch((e) =>
220 Effect.succeed<{ tag: string }>({ tag: e._tag }),
221 ),
222 );
223 return yield* HttpServerResponse.json(outcome);
224 }
225
226 if (request.method === "GET" && pathname === "/retention") {
227 if (!key) return requireKey();
228 const outcome = yield* getObjectRetention({ Key: key }).pipe(
229 Effect.map((): { tag: string } => ({ tag: "success" })),
230 Effect.catch((e) =>
231 Effect.succeed<{ tag: string }>({ tag: e._tag }),
232 ),
233 );
234 return yield* HttpServerResponse.json(outcome);
235 }
236
237 if (request.method === "GET" && pathname === "/retention-put") {
238 if (!key) return requireKey();
239 const outcome = yield* putObjectRetention({
240 Key: key,
241 Retention: {
242 Mode: "GOVERNANCE",
243 RetainUntilDate: new Date(Date.now() + 60_000),
244 },
245 }).pipe(
246 Effect.map((): { tag: string } => ({ tag: "success" })),
247 Effect.catch((e) =>
248 Effect.succeed<{ tag: string }>({ tag: e._tag }),
249 ),
250 );
251 return yield* HttpServerResponse.json(outcome);
252 }
253
254 if (request.method === "GET" && pathname === "/legal-hold") {
255 if (!key) return requireKey();
256 const outcome = yield* getObjectLegalHold({ Key: key }).pipe(
257 Effect.map((): { tag: string } => ({ tag: "success" })),
258 Effect.catch((e) =>
259 Effect.succeed<{ tag: string }>({ tag: e._tag }),
260 ),
261 );
262 return yield* HttpServerResponse.json(outcome);
263 }
264
265 if (request.method === "GET" && pathname === "/legal-hold-put") {
266 if (!key) return requireKey();
267 const outcome = yield* putObjectLegalHold({
268 Key: key,
269 LegalHold: { Status: "ON" },
270 }).pipe(
271 Effect.map((): { tag: string } => ({ tag: "success" })),
272 Effect.catch((e) =>
273 Effect.succeed<{ tag: string }>({ tag: e._tag }),
274 ),
275 );
276 return yield* HttpServerResponse.json(outcome);
277 }
278
279 return yield* HttpServerResponse.json(
280 {
281 error: "Not found",
282 method: request.method,
283 pathname,
284 },
285 { status: 404 },
286 );
287 }).pipe(Effect.orDie),
288 };
289 }).pipe(
290 Effect.provide(
291 Layer.mergeAll(
292 S3.PresignGetObjectHttp,
293 S3.PresignPutObjectHttp,
294 S3.DeleteObjectsHttp,
295 S3.GetObjectTaggingHttp,
296 S3.PutObjectTaggingHttp,
297 S3.DeleteObjectTaggingHttp,
298 S3.GetObjectAttributesHttp,
299 S3.ListObjectVersionsHttp,
300 S3.CreateMultipartUploadHttp,
301 S3.UploadPartHttp,
302 S3.UploadPartCopyHttp,
303 S3.ListPartsHttp,
304 S3.ListMultipartUploadsHttp,
305 S3.CompleteMultipartUploadHttp,
306 S3.AbortMultipartUploadHttp,
307 S3.RestoreObjectHttp,
308 S3.GetObjectRetentionHttp,
309 S3.PutObjectRetentionHttp,
310 S3.GetObjectLegalHoldHttp,
311 S3.PutObjectLegalHoldHttp,
312 ),
313 ),
314 ),
315);