Skip to content
Source
Sign inSign up
Open History to see the last commit

Notes.ts

100 lines3.7 KB

1import * as GCP from "@alchemy.run/gcp";
2import * as Effect from "effect/Effect";
3import { HttpServerRequest } from "effect/http/HttpServerRequest";
4import * as HttpServerResponse from "effect/http/HttpServerResponse";
5import { NotesDb } from "./resources.ts";
6​
7interface Note {
8 id: string;
9 title: string;
10 body: string;
11 createdAt: string;
13​
14const toNote = (snapshot: { name: string; fields: Record<string, unknown> }): Note => ({
15 id: snapshot.name.split("/").pop()!,
16 title: String(snapshot.fields.title ?? ""),
17 body: String(snapshot.fields.body ?? ""),
18 createdAt:
19 snapshot.fields.createdAt instanceof Date
20 ? snapshot.fields.createdAt.toISOString()
21 : String(snapshot.fields.createdAt ?? ""),
22});
23​
24const notFound = () => HttpServerResponse.json({ error: "not found" }, { status: 404 });
25​
26/**
27 * A notes API on a 2nd-gen Cloud Function.
28 *
29 * Alchemy bundles this module for Node.js 22, uploads it, and serves
30 * `fetch` through the Functions Framework — no Dockerfile and no local
31 * image build. `GCP.Firestore.ReadWriteDatabase` grants the function's
32 * runtime service account `roles/datastore.user` and hands back a client
33 * that speaks plain JavaScript objects.
34 *
35 * - `POST /notes` — create a note from `{ title, body }`.
36 * - `GET /notes` — list notes.
37 * - `GET /notes/:id` — read one note.
38 * - `DELETE /notes/:id` — delete a note.
39 */
40export default class Notes extends GCP.CloudFunctions.Function<Notes>()(
41 "Notes",
42 { main: import.meta.url },
43 Effect.gen(function* () {
44 const db = yield* GCP.Firestore.ReadWriteDatabase(NotesDb);
45​
46 return {
47 fetch: Effect.gen(function* () {
48 const request = yield* HttpServerRequest;
49 const { pathname } = new URL(request.url, "http://localhost");
50 const [collection, id, ...rest] = pathname.split("/").filter(Boolean);
51​
52 if (collection !== "notes" || rest.length > 0) return yield* notFound();
53​
54 if (id === undefined && request.method === "POST") {
55 const input = (yield* request.json) as {
56 title?: unknown;
57 body?: unknown;
58 };
59 if (typeof input.title !== "string" || input.title === "") {
60 return yield* HttpServerResponse.json({ error: "title is required" }, { status: 400 });
61 }
62 const snapshot = yield* db.create(`notes/${crypto.randomUUID()}`, {
63 title: input.title,
64 body: typeof input.body === "string" ? input.body : "",
65 createdAt: new Date(),
66 });
67 return yield* HttpServerResponse.json(toNote(snapshot), {
68 status: 201,
69 });
70 }
71​
72 if (id === undefined && request.method === "GET") {
73 const { documents } = yield* db.list("notes", { pageSize: 100 });
74 return yield* HttpServerResponse.json({
75 notes: documents.map(toNote),
76 });
77 }
78​
79 if (id !== undefined && request.method === "GET") {
80 const snapshot = yield* db.get(`notes/${id}`);
81 if (snapshot === undefined) return yield* notFound();
82 return yield* HttpServerResponse.json(toNote(snapshot));
83 }
84​
85 if (id !== undefined && request.method === "DELETE") {
86 yield* db.delete(`notes/${id}`);
87 return HttpServerResponse.empty({ status: 204 });
88 }
89​
90 return yield* HttpServerResponse.json({ error: "method not allowed" }, { status: 405 });
91 }).pipe(
92 // Surface Firestore failures (e.g. IAM still propagating right after
93 // a deploy) as a 500 with the error tag instead of an opaque crash.
94 Effect.catch((error) =>
95 HttpServerResponse.json({ error: error._tag }, { status: 500 }).pipe(Effect.orDie),
96 ),
97 ),
98 };
99 }).pipe(Effect.provide(GCP.Firestore.ReadWriteDatabaseHttp)),
100) {}