1import * as GCP from "@/GCP";
2import * as Effect from "effect/Effect";
3import { HttpServerRequest } from "effect/http/HttpServerRequest";
4import * as HttpServerResponse from "effect/http/HttpServerResponse";
5import SmokeJob from "./job.ts";
6import {
7 Jobs,
8 Store,
9 Uploads,
10 type JobMessage,
11} from "./serverless-resources.ts";
12
13const 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 */
31export 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) {}