| 1 | import * as Lambda from "@/AWS/Lambda"; |
| 2 | import * as S3 from "@/AWS/S3"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Schema from "effect/Schema"; |
| 7 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 8 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 9 | |
| 10 | export class S3VersionedMultipartFunction extends Lambda.Function<S3VersionedMultipartFunction>()( |
| 11 | "S3VersionedMultipartFunction", |
| 12 | ) {} |
| 13 | |
| 14 | const uploadRequest = Schema.Struct({ |
| 15 | Key: Schema.String, |
| 16 | UploadId: Schema.String, |
| 17 | }); |
| 18 | |
| 19 | export 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 | ); |