| 1 | import type { WriteBucketClient } from "@/Prisma/WriteBucket.ts"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import type * as HttpServerRequest from "effect/http/HttpServerRequest"; |
| 4 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 5 | |
| 6 | /** |
| 7 | * Shared write-side routes so every method of {@link WriteBucketClient} is |
| 8 | * driven over `fetch`: |
| 9 | * |
| 10 | * - `PUT /put?key=` — `put(key, body)` (returns the object key to prove the |
| 11 | * call resolved a `BucketObject`). `contentType`, `metaKey` and `metaValue` |
| 12 | * travel as query params and are passed through as `PutOptions`, so the |
| 13 | * read side can observe them. |
| 14 | * - `DELETE /del?key=` — `delete(key)` (single). |
| 15 | * - `DELETE /del-many?keys=a,b,c` — `delete(keys)` (batch). Keys travel as a |
| 16 | * comma-separated query param rather than a request body because DELETE |
| 17 | * bodies are unreliable across `fetch`. |
| 18 | * - `GET /presign-put?key=` — `presignPut(key)`. |
| 19 | * |
| 20 | * Returns `undefined` when the path is not a write route so the caller can |
| 21 | * fall through (used by the read-write fixtures). |
| 22 | */ |
| 23 | export const writeRoutes = ( |
| 24 | store: WriteBucketClient, |
| 25 | request: HttpServerRequest.HttpServerRequest, |
| 26 | url: URL, |
| 27 | ) => |
| 28 | Effect.gen(function* () { |
| 29 | if (request.method === "PUT" && url.pathname === "/put") { |
| 30 | const key = url.searchParams.get("key") ?? ""; |
| 31 | const metaKey = url.searchParams.get("metaKey"); |
| 32 | const body = yield* request.text; |
| 33 | const object = yield* store |
| 34 | .put(key, body, { |
| 35 | contentType: url.searchParams.get("contentType") ?? undefined, |
| 36 | metadata: metaKey |
| 37 | ? { [metaKey]: url.searchParams.get("metaValue") ?? "" } |
| 38 | : undefined, |
| 39 | }) |
| 40 | .pipe(Effect.orDie); |
| 41 | return yield* HttpServerResponse.json({ ok: true, key: object.key }); |
| 42 | } |
| 43 | if (request.method === "DELETE" && url.pathname === "/del") { |
| 44 | const key = url.searchParams.get("key") ?? ""; |
| 45 | yield* store.delete(key).pipe(Effect.orDie); |
| 46 | return yield* HttpServerResponse.json({ ok: true }); |
| 47 | } |
| 48 | if (request.method === "DELETE" && url.pathname === "/del-many") { |
| 49 | const keys = (url.searchParams.get("keys") ?? "") |
| 50 | .split(",") |
| 51 | .filter((k) => k.length > 0); |
| 52 | yield* store.delete(keys).pipe(Effect.orDie); |
| 53 | return yield* HttpServerResponse.json({ ok: true }); |
| 54 | } |
| 55 | // The app mints the URL from its bound credentials and hands it back; the |
| 56 | // test then uploads to that URL directly, carrying no credentials of its |
| 57 | // own. |
| 58 | if (request.method === "GET" && url.pathname === "/presign-put") { |
| 59 | const key = url.searchParams.get("key") ?? ""; |
| 60 | const expiresIn = url.searchParams.get("expiresIn"); |
| 61 | const contentType = url.searchParams.get("contentType"); |
| 62 | const presigned = yield* store |
| 63 | .presignPut(key, { |
| 64 | expiresIn: expiresIn ? Number(expiresIn) : undefined, |
| 65 | contentType: contentType ?? undefined, |
| 66 | }) |
| 67 | .pipe(Effect.orDie); |
| 68 | return yield* HttpServerResponse.json({ url: presigned }); |
| 69 | } |
| 70 | return undefined; |
| 71 | }); |