1import * as GCP from "alchemy/GCP";
2import * as Drizzle from "alchemy/Drizzle/Postgres";
3import { eq } from "drizzle-orm";
4import * as Effect from "effect/Effect";
5import * as Schema from "effect/Schema";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import { database } from "./database.ts";
9import { migrate } from "./migrate.ts";
10import { todos } from "./schema.ts";
11
12const Id = Schema.String.check(Schema.isUUID());
13
14/**
15 * A todo API on Cloud Run backed by Cloud SQL for PostgreSQL.
16 *
17 * `GCP.SQL.Connect` mounts the instance's Unix socket into the revision
18 * and grants the runtime service account `roles/cloudsql.client` on this
19 * instance only; Drizzle then talks Postgres over
20 * `/cloudsql/{connectionName}`.
21 */
22export default class Api extends GCP.Function<Api>()(
23 "Api",
24 Effect.gen(function* () {
25 // Depending on the migration output makes the service deploy after
26 // the schema exists, and roll a new revision when it changes.
27 const schemaVersion = yield* migrate;
28 return {
29 main: import.meta.url,
30 invokerIamDisabled: true,
31 env: { SCHEMA_VERSION: schemaVersion },
32 };
33 }),
34 Effect.gen(function* () {
35 const { instance, db: appDb, user, passwordSecret } = yield* database;
36 const connect = yield* GCP.SQL.Connect(instance, {
37 database: appDb,
38 user,
39 passwordSecret,
40 });
41 const db = yield* Drizzle.Postgres(
42 connect.pipe(Effect.map((info) => info.url)),
43 );
44
45 return {
46 fetch: Effect.gen(function* () {
47 const request = yield* HttpServerRequest;
48 const url = new URL(request.originalUrl, "http://localhost");
49
50 if (request.method === "GET" && url.pathname === "/") {
51 return HttpServerResponse.text("ok");
52 }
53 if (url.pathname === "/todos" && request.method === "GET") {
54 return yield* HttpServerResponse.json(
55 yield* db.select().from(todos).orderBy(todos.title),
56 );
57 }
58 if (url.pathname === "/todos" && request.method === "POST") {
59 const input = yield* request.json.pipe(
60 Effect.flatMap(
61 Schema.decodeUnknownEffect(
62 Schema.Struct({ id: Id, title: Schema.NonEmptyString }),
63 ),
64 ),
65 );
66 return yield* HttpServerResponse.json(
67 yield* db.insert(todos).values(input).returning(),
68 { status: 201 },
69 );
70 }
71 if (url.pathname.startsWith("/todos/")) {
72 const id = yield* Schema.decodeUnknownEffect(Id)(
73 url.pathname.slice("/todos/".length),
74 );
75 if (request.method === "GET") {
76 const [todo] = yield* db
77 .select()
78 .from(todos)
79 .where(eq(todos.id, id));
80 return todo
81 ? yield* HttpServerResponse.json(todo)
82 : HttpServerResponse.text("Not found", { status: 404 });
83 }
84 if (request.method === "PATCH") {
85 const input = yield* request.json.pipe(
86 Effect.flatMap(
87 Schema.decodeUnknownEffect(
88 Schema.Struct({ done: Schema.Boolean }),
89 ),
90 ),
91 );
92 const [todo] = yield* db
93 .update(todos)
94 .set(input)
95 .where(eq(todos.id, id))
96 .returning();
97 return todo
98 ? yield* HttpServerResponse.json(todo)
99 : HttpServerResponse.text("Not found", { status: 404 });
100 }
101 if (request.method === "DELETE") {
102 yield* db.delete(todos).where(eq(todos.id, id));
103 return HttpServerResponse.empty({ status: 204 });
104 }
105 }
106 return HttpServerResponse.text("Not found", { status: 404 });
107 }).pipe(
108 Effect.catchTag("SchemaError", () =>
109 Effect.succeed(
110 HttpServerResponse.text("Invalid request", { status: 400 }),
111 ),
112 ),
113 Effect.orDie,
114 ),
115 };
116 }).pipe(Effect.provide(GCP.SQL.ConnectHttp)),
117) {}