| 1 | import * as GCP from "@/GCP"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 4 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 5 | import SmokeJob from "./job.ts"; |
| 6 | import { |
| 7 | Jobs, |
| 8 | Store, |
| 9 | Uploads, |
| 10 | type JobMessage, |
| 11 | } from "./serverless-resources.ts"; |
| 12 | |
| 13 | const notFound = () => |
| 14 | HttpServerResponse.json({ error: "not found" }, { status: 404 }); |
| 15 | |
| 16 | /** |
| 17 | * Public API Function. One route per behavior of the serverless story: |
| 18 | * |
| 19 | * - `GET /config` → resource identifiers (readiness + cross-checks) |
| 20 | * - `GET /ready` → touches Firestore and Storage (IAM propagation) |
| 21 | * - `PUT /todos/:id` → Firestore `set` |
| 22 | * - `GET /todos/:id` → Firestore `get` |
| 23 | * - `GET /todos` → Firestore `list` |
| 24 | * - `DELETE /todos/:id` → Firestore `delete` |
| 25 | * - `PUT /files/:name` → Storage `put` |
| 26 | * - `GET /files/:name` → Storage `get` (regular read) |
| 27 | * - `GET /files/:name/signed` → V4 signed download URL |
| 28 | * - `POST /jobs` → Pub/Sub publish to the worker |
| 29 | * - `POST /run-job` → Cloud Run Job execution |
| 30 | */ |
| 31 | export default class SmokeApi extends GCP.Function<SmokeApi>()( |
| 32 | "SmokeApi", |
| 33 | { |
| 34 | main: import.meta.url, |
| 35 | location: "us-central1", |
| 36 | invokerIamDisabled: true, |
| 37 | }, |
| 38 | Effect.gen(function* () { |
| 39 | const store = yield* Store; |
| 40 | const uploads = yield* Uploads; |
| 41 | const jobs = yield* Jobs; |
| 42 | const job = yield* SmokeJob; |
| 43 | |
| 44 | const db = yield* GCP.Firestore.ReadWriteDatabase(store); |
| 45 | const files = yield* GCP.Storage.ReadWriteBucket(uploads); |
| 46 | const signGetObjectUrl = yield* GCP.Storage.SignGetObjectUrl(uploads); |
| 47 | const publisher = yield* GCP.PubSub.WriteTopic(jobs); |
| 48 | const runJob = yield* GCP.Run.RunJob(job); |
| 49 | |
| 50 | const DatabaseName = yield* store.name; |
| 51 | const BucketName = yield* uploads.bucketName; |
| 52 | const TopicName = yield* jobs.name; |
| 53 | const JobName = yield* job.name; |
| 54 | |
| 55 | return { |
| 56 | fetch: Effect.gen(function* () { |
| 57 | const request = yield* HttpServerRequest; |
| 58 | const url = new URL(request.originalUrl, "http://api"); |
| 59 | const [head, id, tail, ...rest] = url.pathname |
| 60 | .split("/") |
| 61 | .filter(Boolean) |
| 62 | .map(decodeURIComponent); |
| 63 | if (rest.length > 0) return yield* notFound(); |
| 64 | |
| 65 | if (request.method === "GET" && head === "config") { |
| 66 | return yield* HttpServerResponse.json({ |
| 67 | databaseName: yield* DatabaseName, |
| 68 | bucketName: yield* BucketName, |
| 69 | topicName: yield* TopicName, |
| 70 | jobName: yield* JobName, |
| 71 | }); |
| 72 | } |
| 73 | |
| 74 | if (request.method === "GET" && head === "ready") { |
| 75 | yield* db.get("todos/ready-probe"); |
| 76 | yield* files.head("ready-probe"); |
| 77 | return HttpServerResponse.text("ready"); |
| 78 | } |
| 79 | |
| 80 | if (head === "todos" && id === undefined && request.method === "GET") { |
| 81 | const { documents } = yield* db.list("todos", { pageSize: 100 }); |
| 82 | return yield* HttpServerResponse.json({ |
| 83 | todos: documents.map((document) => document.fields), |
| 84 | }); |
| 85 | } |
| 86 | |
| 87 | if (head === "todos" && id !== undefined && tail === undefined) { |
| 88 | const path = `todos/${id}`; |
| 89 | if (request.method === "PUT") { |
| 90 | const body = (yield* request.json) as { text?: unknown }; |
| 91 | if (typeof body.text !== "string") { |
| 92 | return yield* HttpServerResponse.json( |
| 93 | { error: "text must be a string" }, |
| 94 | { status: 400 }, |
| 95 | ); |
| 96 | } |
| 97 | yield* db.set(path, { id, text: body.text }); |
| 98 | return yield* HttpServerResponse.json({ ok: true, id }); |
| 99 | } |
| 100 | if (request.method === "GET") { |
| 101 | const document = yield* db.get(path); |
| 102 | return document === undefined |
| 103 | ? yield* notFound() |
| 104 | : yield* HttpServerResponse.json({ item: document.fields }); |
| 105 | } |
| 106 | if (request.method === "DELETE") { |
| 107 | yield* db.delete(path); |
| 108 | return HttpServerResponse.empty({ status: 204 }); |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | if (head === "files" && id !== undefined) { |
| 113 | if (request.method === "PUT" && tail === undefined) { |
| 114 | const body = new Uint8Array(yield* request.arrayBuffer); |
| 115 | const object = yield* files.put(id, body, { |
| 116 | contentType: request.headers["content-type"] ?? "text/plain", |
| 117 | }); |
| 118 | return yield* HttpServerResponse.json({ |
| 119 | name: object.name, |
| 120 | generation: object.generation, |
| 121 | }); |
| 122 | } |
| 123 | if (request.method === "GET" && tail === undefined) { |
| 124 | const object = yield* files.get(id); |
| 125 | return object === undefined |
| 126 | ? yield* notFound() |
| 127 | : HttpServerResponse.uint8Array(object.body, { |
| 128 | contentType: object.contentType ?? "application/octet-stream", |
| 129 | }); |
| 130 | } |
| 131 | if (request.method === "GET" && tail === "signed") { |
| 132 | const signedUrl = yield* signGetObjectUrl({ |
| 133 | object: id, |
| 134 | expiresIn: 300, |
| 135 | }); |
| 136 | return yield* HttpServerResponse.json({ url: signedUrl }); |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | if (request.method === "POST" && head === "jobs") { |
| 141 | const body = (yield* request.json) as { payload?: unknown }; |
| 142 | if (typeof body.payload !== "string") { |
| 143 | return yield* HttpServerResponse.json( |
| 144 | { error: "payload must be a string" }, |
| 145 | { status: 400 }, |
| 146 | ); |
| 147 | } |
| 148 | const message: JobMessage = { |
| 149 | id: crypto.randomUUID(), |
| 150 | payload: body.payload, |
| 151 | }; |
| 152 | const messageId = yield* publisher.publish({ |
| 153 | data: JSON.stringify(message), |
| 154 | }); |
| 155 | return yield* HttpServerResponse.json({ id: message.id, messageId }); |
| 156 | } |
| 157 | |
| 158 | if (request.method === "POST" && head === "run-job") { |
| 159 | const operation = yield* runJob(); |
| 160 | return yield* HttpServerResponse.json({ |
| 161 | operation: operation.name, |
| 162 | execution: (operation.metadata as { name?: string } | undefined) |
| 163 | ?.name, |
| 164 | }); |
| 165 | } |
| 166 | |
| 167 | return yield* notFound(); |
| 168 | }).pipe(Effect.orDie), |
| 169 | }; |
| 170 | }).pipe( |
| 171 | Effect.provide([ |
| 172 | GCP.Firestore.ReadWriteDatabaseHttp, |
| 173 | GCP.Storage.ReadWriteBucketHttp, |
| 174 | GCP.Storage.SignGetObjectUrlHttp, |
| 175 | GCP.PubSub.WriteTopicHttp, |
| 176 | GCP.Run.RunJobHttp, |
| 177 | ]), |
| 178 | ), |
| 179 | ) {} |