1import * as Lambda from "@/AWS/Lambda";
2import * as S3 from "@/AWS/S3";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Schema from "effect/Schema";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9
10export class S3VersionedMultipartFunction extends Lambda.Function<S3VersionedMultipartFunction>()(
11 "S3VersionedMultipartFunction",
12) {}
13
14const uploadRequest = Schema.Struct({
15 Key: Schema.String,
16 UploadId: Schema.String,
17});
18
19export default S3VersionedMultipartFunction.make(
20 {
21 main: import.meta.url,
22 functionUrl: true,
23 timeout: Duration.seconds(30),
24 memorySize: 512,
25 },
26 Effect.gen(function* () {
27 const props = { versioning: "Enabled", forceDestroy: true } as const;
28 const buckets = {
29 CreateMultipartUpload: yield* S3.Bucket(
30 "VersionedMultipartCreateBucket",
31 props,
32 ),
33 UploadPart: yield* S3.Bucket("VersionedMultipartUploadPartBucket", props),
34 UploadPartCopy: yield* S3.Bucket("VersionedMultipartCopyBucket", props),
35 UploadPartCopySource: yield* S3.Bucket(
36 "VersionedMultipartCopySourceBucket",
37 props,
38 ),
39 UploadPartCopyUnboundSource: yield* S3.Bucket(
40 "VersionedMultipartCopyUnboundSourceBucket",
41 props,
42 ),
43 ListParts: yield* S3.Bucket("VersionedMultipartListPartsBucket", props),
44 ListMultipartUploads: yield* S3.Bucket(
45 "VersionedMultipartListUploadsBucket",
46 props,
47 ),
48 CompleteMultipartUpload: yield* S3.Bucket(
49 "VersionedMultipartCompleteBucket",
50 props,
51 ),
52 AbortMultipartUpload: yield* S3.Bucket(
53 "VersionedMultipartAbortBucket",
54 props,
55 ),
56 };
57 const create = yield* S3.CreateMultipartUpload(
58 buckets.CreateMultipartUpload,
59 );
60 const upload = yield* S3.UploadPart(buckets.UploadPart);
61 const copy = yield* S3.UploadPartCopy(buckets.UploadPartCopy);
62 yield* S3.GetObject(buckets.UploadPartCopySource);
63 const parts = yield* S3.ListParts(buckets.ListParts);
64 const uploads = yield* S3.ListMultipartUploads(
65 buckets.ListMultipartUploads,
66 );
67 const complete = yield* S3.CompleteMultipartUpload(
68 buckets.CompleteMultipartUpload,
69 );
70 const abort = yield* S3.AbortMultipartUpload(buckets.AbortMultipartUpload);
71 const info = yield* Effect.forEach(
72 Object.entries(buckets),
73 ([binding, bucket]) =>
74 Effect.gen(function* () {
75 return {
76 binding,
77 bucketName: yield* bucket.bucketName,
78 bucketArn: yield* bucket.bucketArn,
79 };
80 }),
81 );
82
83 return {
84 fetch: Effect.gen(function* () {
85 const request = yield* HttpServerRequest;
86 const url = yield* Effect.sync(() => new URL(request.originalUrl));
87 if (url.pathname === "/info") {
88 const resolved = yield* Effect.forEach(info, (bucket) =>
89 Effect.gen(function* () {
90 return {
91 binding: bucket.binding,
92 bucketName: yield* bucket.bucketName,
93 bucketArn: yield* bucket.bucketArn,
94 };
95 }),
96 );
97 if (
98 resolved.some((bucket) => !bucket.bucketName || !bucket.bucketArn)
99 ) {
100 return HttpServerResponse.text("Outputs not hydrated yet", {
101 status: 503,
102 });
103 }
104 return yield* HttpServerResponse.json(resolved);
105 }
106 if (request.method !== "POST") {
107 return HttpServerResponse.text("Use POST", { status: 405 });
108 }
109 const body = yield* request.json;
110 switch (url.pathname) {
111 case "/create": {
112 const input = yield* Schema.decodeUnknownEffect(
113 Schema.Struct({
114 Key: Schema.String,
115 ContentType: Schema.String,
116 }),
117 )(body);
118 return yield* HttpServerResponse.json(yield* create(input));
119 }
120 case "/upload": {
121 const input = yield* Schema.decodeUnknownEffect(
122 Schema.Struct({
123 ...uploadRequest.fields,
124 PartNumber: Schema.Number,
125 Body: Schema.String,
126 Repeat: Schema.optional(Schema.Number),
127 }),
128 )(body);
129 // Expand fixture bytes in Lambda to stay below invocation payload limits.
130 const Body = yield* Effect.sync(() =>
131 input.Body.repeat(input.Repeat ?? 1),
132 );
133 return yield* upload({
134 Key: input.Key,
135 UploadId: input.UploadId,
136 PartNumber: input.PartNumber,
137 Body,
138 }).pipe(
139 Effect.flatMap(HttpServerResponse.json),
140 Effect.catchTag("NoSuchUpload", (error) =>
141 HttpServerResponse.json({ tag: error._tag }, { status: 404 }),
142 ),
143 );
144 }
145 case "/copy": {
146 const input = yield* Schema.decodeUnknownEffect(
147 Schema.Struct({
148 ...uploadRequest.fields,
149 PartNumber: Schema.Number,
150 CopySource: Schema.String,
151 }),
152 )(body);
153 return yield* copy(input).pipe(
154 Effect.flatMap(HttpServerResponse.json),
155 Effect.catchTag("AccessDeniedException", () =>
156 HttpServerResponse.json(
157 { tag: "AccessDeniedException" },
158 { status: 403 },
159 ),
160 ),
161 Effect.catchTag(
162 ["NoSuchUpload", "NoSuchVersion", "NoSuchKey"],
163 (error) =>
164 HttpServerResponse.json({ tag: error._tag }, { status: 404 }),
165 ),
166 Effect.catchTag("InvalidRequest", (error) =>
167 HttpServerResponse.json({ tag: error._tag }, { status: 400 }),
168 ),
169 );
170 }
171 case "/parts": {
172 const input = yield* Schema.decodeUnknownEffect(
173 Schema.Struct({
174 ...uploadRequest.fields,
175 MaxParts: Schema.optional(Schema.Number),
176 PartNumberMarker: Schema.optional(Schema.String),
177 }),
178 )(body);
179 return yield* parts(input).pipe(
180 Effect.flatMap(HttpServerResponse.json),
181 Effect.catchTag("NoSuchUpload", (error) =>
182 HttpServerResponse.json({ tag: error._tag }, { status: 404 }),
183 ),
184 );
185 }
186 case "/uploads": {
187 const input = yield* Schema.decodeUnknownEffect(
188 Schema.Struct({
189 Prefix: Schema.String,
190 KeyMarker: Schema.optional(Schema.String),
191 UploadIdMarker: Schema.optional(Schema.String),
192 MaxUploads: Schema.optional(Schema.Number),
193 }),
194 )(body);
195 return yield* HttpServerResponse.json(yield* uploads(input));
196 }
197 case "/complete": {
198 const input = yield* Schema.decodeUnknownEffect(
199 Schema.Struct({
200 ...uploadRequest.fields,
201 MultipartUpload: Schema.Struct({
202 Parts: Schema.Array(
203 Schema.Struct({
204 ETag: Schema.String,
205 PartNumber: Schema.Number,
206 }),
207 ),
208 }),
209 }),
210 )(body);
211 return yield* complete({
212 ...input,
213 MultipartUpload: { Parts: [...input.MultipartUpload.Parts] },
214 }).pipe(
215 Effect.flatMap(HttpServerResponse.json),
216 Effect.catchTag("NoSuchUpload", (error) =>
217 HttpServerResponse.json({ tag: error._tag }, { status: 404 }),
218 ),
219 );
220 }
221 case "/abort": {
222 const input =
223 yield* Schema.decodeUnknownEffect(uploadRequest)(body);
224 return yield* abort(input).pipe(
225 Effect.flatMap(() => HttpServerResponse.json({ aborted: true })),
226 Effect.catchTag("NoSuchUpload", () =>
227 HttpServerResponse.json(
228 { tag: "NoSuchUpload" },
229 { status: 404 },
230 ),
231 ),
232 );
233 }
234 default:
235 return HttpServerResponse.text("Not found", { status: 404 });
236 }
237 }).pipe(Effect.orDie),
238 };
239 }).pipe(
240 Effect.provide(
241 Layer.mergeAll(
242 S3.CreateMultipartUploadHttp,
243 S3.UploadPartHttp,
244 S3.UploadPartCopyHttp,
245 S3.GetObjectHttp,
246 S3.ListPartsHttp,
247 S3.ListMultipartUploadsHttp,
248 S3.CompleteMultipartUploadHttp,
249 S3.AbortMultipartUploadHttp,
250 ),
251 ),
252 ),
253);