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 * as Stream from "effect/Stream";
8import { HttpServerRequest } from "effect/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/http/HttpServerResponse";
10
11export class VersionedObjectFunction extends Lambda.Function<VersionedObjectFunction>()(
12 "VersionedObjectFunction",
13) {}
14
15const bucket = (name: string) =>
16 S3.Bucket(name, {
17 versioning: "Enabled",
18 forceDestroy: true,
19 });
20
21const Tags = Schema.Array(
22 Schema.Struct({ Key: Schema.String, Value: Schema.String }),
23);
24const Objects = Schema.Array(
25 Schema.Struct({
26 Key: Schema.String,
27 VersionId: Schema.optional(Schema.String),
28 }),
29);
30
31export default VersionedObjectFunction.make(
32 {
33 main: import.meta.url,
34 functionUrl: true,
35 timeout: Duration.seconds(30),
36 memorySize: 512,
37 },
38 Effect.gen(function* () {
39 const buckets = yield* Effect.all({
40 GetObject: bucket("GetObjectBucket"),
41 PutObject: bucket("PutObjectBucket"),
42 HeadObject: bucket("HeadObjectBucket"),
43 GetObjectAttributes: bucket("GetObjectAttributesBucket"),
44 CopyObject: bucket("CopyObjectBucket"),
45 CopySource: bucket("CopySourceBucket"),
46 UnboundSource: bucket("UnboundSourceBucket"),
47 DeleteObject: bucket("DeleteObjectBucket"),
48 DeleteObjects: bucket("DeleteObjectsBucket"),
49 ListObjectsV2: bucket("ListObjectsV2Bucket"),
50 ListObjectVersions: bucket("ListObjectVersionsBucket"),
51 GetObjectTagging: bucket("GetObjectTaggingBucket"),
52 PutObjectTagging: bucket("PutObjectTaggingBucket"),
53 DeleteObjectTagging: bucket("DeleteObjectTaggingBucket"),
54 PresignPutObject: bucket("PresignPutObjectBucket"),
55 });
56 const info = yield* Effect.forEach(
57 Object.entries(buckets),
58 ([name, resource]) =>
59 Effect.gen(function* () {
60 const bucketName = yield* resource.bucketName;
61 const bucketArn = yield* resource.bucketArn;
62 return Effect.gen(function* () {
63 return [
64 name,
65 { bucketName: yield* bucketName, bucketArn: yield* bucketArn },
66 ] as const;
67 });
68 }),
69 );
70 const get = yield* S3.GetObject(buckets.GetObject);
71 const put = yield* S3.PutObject(buckets.PutObject);
72 const head = yield* S3.HeadObject(buckets.HeadObject);
73 const attributes = yield* S3.GetObjectAttributes(
74 buckets.GetObjectAttributes,
75 );
76 const copy = yield* S3.CopyObject(buckets.CopyObject);
77 yield* S3.GetObject(buckets.CopySource);
78 const remove = yield* S3.DeleteObject(buckets.DeleteObject);
79 const removeMany = yield* S3.DeleteObjects(buckets.DeleteObjects);
80 const list = yield* S3.ListObjectsV2(buckets.ListObjectsV2);
81 const versions = yield* S3.ListObjectVersions(buckets.ListObjectVersions);
82 const getTags = yield* S3.GetObjectTagging(buckets.GetObjectTagging);
83 const putTags = yield* S3.PutObjectTagging(buckets.PutObjectTagging);
84 const deleteTags = yield* S3.DeleteObjectTagging(
85 buckets.DeleteObjectTagging,
86 );
87 const presignPut = yield* S3.PresignPutObject(buckets.PresignPutObject);
88
89 return {
90 fetch: Effect.gen(function* () {
91 const request = yield* HttpServerRequest;
92 const url = yield* Effect.sync(() => new URL(request.originalUrl));
93 const operation = url.pathname.slice(1);
94 if (operation === "info") {
95 return yield* HttpServerResponse.json(
96 Object.fromEntries(yield* Effect.all(info)),
97 );
98 }
99 const Key = url.searchParams.get("key") ?? "versions.txt";
100 const VersionId = url.searchParams.get("versionId") ?? undefined;
101 switch (operation) {
102 case "GetObject":
103 return yield* get({ Key, VersionId }).pipe(
104 Effect.flatMap((result) =>
105 Effect.gen(function* () {
106 const body = yield* Stream.mkString(
107 Stream.decodeText(result.Body!),
108 );
109 return yield* HttpServerResponse.json({
110 body,
111 versionId: result.VersionId,
112 });
113 }),
114 ),
115 Effect.catchTag(
116 ["NoSuchKey", "NoSuchVersion", "MethodNotAllowed"],
117 (error) =>
118 HttpServerResponse.json(
119 { tag: error._tag },
120 { status: error._tag === "MethodNotAllowed" ? 405 : 404 },
121 ),
122 ),
123 );
124 case "PutObject":
125 return yield* HttpServerResponse.json(
126 yield* put({ Key, Body: yield* request.text }),
127 );
128 case "HeadObject":
129 return yield* head({ Key, VersionId }).pipe(
130 Effect.flatMap((result) =>
131 HttpServerResponse.json({
132 versionId: result.VersionId,
133 length: result.ContentLength,
134 }),
135 ),
136 Effect.catchTag("NotFound", (error) =>
137 HttpServerResponse.json({ tag: error._tag }, { status: 404 }),
138 ),
139 Effect.catchTag("MethodNotAllowed", (error) =>
140 HttpServerResponse.json(
141 {
142 tag: error._tag,
143 deleteMarker: error.DeleteMarker,
144 lastModified: error.LastModified,
145 },
146 { status: 405 },
147 ),
148 ),
149 );
150 case "GetObjectAttributes":
151 return yield* attributes({
152 Key,
153 VersionId,
154 ObjectAttributes: ["ETag", "ObjectSize"],
155 }).pipe(
156 Effect.flatMap((result) => HttpServerResponse.json(result)),
157 Effect.catchTag(["NoSuchVersion", "MethodNotAllowed"], (error) =>
158 HttpServerResponse.json(
159 { tag: error._tag },
160 { status: error._tag === "MethodNotAllowed" ? 405 : 404 },
161 ),
162 ),
163 );
164 case "CopyObject":
165 return yield* copy({
166 Key,
167 CopySource: url.searchParams.get("source")!,
168 }).pipe(
169 Effect.flatMap((result) => HttpServerResponse.json(result)),
170 Effect.catchTag("AccessDeniedException", (error) =>
171 HttpServerResponse.json(
172 { tag: error._tag, message: error.message },
173 { status: 403 },
174 ),
175 ),
176 Effect.catchTag(
177 ["NoSuchVersion", "NoSuchKey", "InvalidRequest"],
178 (error) =>
179 HttpServerResponse.json(
180 { tag: error._tag },
181 { status: error._tag === "InvalidRequest" ? 400 : 404 },
182 ),
183 ),
184 );
185 case "DeleteObject":
186 return yield* HttpServerResponse.json(
187 yield* remove({ Key, VersionId }),
188 );
189 case "DeleteObjects": {
190 const objects = yield* request.json.pipe(
191 Effect.flatMap(Schema.decodeUnknownEffect(Objects)),
192 );
193 return yield* HttpServerResponse.json(
194 yield* removeMany({ Delete: { Objects: [...objects] } }),
195 );
196 }
197 case "ListObjectsV2":
198 return yield* HttpServerResponse.json(
199 yield* list({
200 Prefix: url.searchParams.get("prefix") ?? undefined,
201 MaxKeys: 1,
202 ContinuationToken: url.searchParams.get("token") ?? undefined,
203 }),
204 );
205 case "ListObjectVersions":
206 return yield* HttpServerResponse.json(
207 yield* versions({
208 Prefix: url.searchParams.get("prefix") ?? undefined,
209 MaxKeys: 1,
210 KeyMarker: url.searchParams.get("keyMarker") ?? undefined,
211 VersionIdMarker:
212 url.searchParams.get("versionMarker") ?? undefined,
213 }),
214 );
215 case "GetObjectTagging":
216 return yield* getTags({ Key, VersionId }).pipe(
217 Effect.flatMap((result) => HttpServerResponse.json(result)),
218 Effect.catchTag(
219 [
220 "NoSuchVersion",
221 "NoSuchKey",
222 "MethodNotAllowed",
223 "AccessDeniedException",
224 ],
225 (error) =>
226 HttpServerResponse.json(
227 { tag: error._tag },
228 {
229 status:
230 error._tag === "MethodNotAllowed"
231 ? 405
232 : error._tag === "AccessDeniedException"
233 ? 403
234 : 404,
235 },
236 ),
237 ),
238 );
239 case "PutObjectTagging": {
240 const tags = yield* request.json.pipe(
241 Effect.flatMap(Schema.decodeUnknownEffect(Tags)),
242 );
243 return yield* putTags({
244 Key,
245 VersionId,
246 Tagging: { TagSet: [...tags] },
247 }).pipe(
248 Effect.flatMap((result) => HttpServerResponse.json(result)),
249 Effect.catchTag(
250 [
251 "NoSuchVersion",
252 "NoSuchKey",
253 "MethodNotAllowed",
254 "AccessDeniedException",
255 ],
256 (error) =>
257 HttpServerResponse.json(
258 { tag: error._tag },
259 {
260 status:
261 error._tag === "MethodNotAllowed"
262 ? 405
263 : error._tag === "AccessDeniedException"
264 ? 403
265 : 404,
266 },
267 ),
268 ),
269 );
270 }
271 case "DeleteObjectTagging":
272 return yield* deleteTags({ Key, VersionId }).pipe(
273 Effect.flatMap((result) => HttpServerResponse.json(result)),
274 Effect.catchTag(
275 [
276 "NoSuchVersion",
277 "NoSuchKey",
278 "MethodNotAllowed",
279 "AccessDeniedException",
280 ],
281 (error) =>
282 HttpServerResponse.json(
283 { tag: error._tag },
284 {
285 status:
286 error._tag === "MethodNotAllowed"
287 ? 405
288 : error._tag === "AccessDeniedException"
289 ? 403
290 : 404,
291 },
292 ),
293 ),
294 );
295 case "PresignPutObject":
296 return yield* HttpServerResponse.json({
297 url: yield* presignPut({ key: Key, contentType: "text/plain" }),
298 });
299 default:
300 return HttpServerResponse.text("Not found", { status: 404 });
301 }
302 }).pipe(Effect.orDie),
303 };
304 }).pipe(
305 Effect.provide(
306 Layer.mergeAll(
307 S3.GetObjectHttp,
308 S3.PutObjectHttp,
309 S3.HeadObjectHttp,
310 S3.GetObjectAttributesHttp,
311 S3.CopyObjectHttp,
312 S3.DeleteObjectHttp,
313 S3.DeleteObjectsHttp,
314 S3.ListObjectsV2Http,
315 S3.ListObjectVersionsHttp,
316 S3.GetObjectTaggingHttp,
317 S3.PutObjectTaggingHttp,
318 S3.DeleteObjectTaggingHttp,
319 S3.PresignPutObjectHttp,
320 ),
321 ),
322 ),
323);