1import * as GCP from "alchemy/GCP";
2import * as Effect from "effect/Effect";
3import { HttpServerRequest } from "effect/http/HttpServerRequest";
4import * as HttpServerResponse from "effect/http/HttpServerResponse";
5import Quotes from "./Quotes.ts";
6
7/**
8 * The public front door. It forwards `GET /quote` to the private
9 * {@link Quotes} service.
10 *
11 * `GCP.Run.InvokeService(quotes)` does both halves of the wiring: at
12 * deploy time it grants `roles/run.invoker` on the Quotes service (and
13 * only that service) to the Gateway's runtime service account and binds
14 * Quotes' URL into the Gateway; at runtime it mints a Google-signed ID
15 * token for that URL from the metadata server and sends it as
16 * `Authorization: Bearer`.
17 */
18export default class Gateway extends GCP.Function<Gateway>()(
19 "Gateway",
20 {
21 main: import.meta.url,
22 invokerIamDisabled: true,
23 },
24 Effect.gen(function* () {
25 const quotes = yield* GCP.Run.InvokeService(Quotes);
26
27 return {
28 fetch: Effect.gen(function* () {
29 const request = yield* HttpServerRequest;
30 const url = new URL(request.originalUrl, "http://localhost");
31
32 if (request.method === "GET" && url.pathname === "/") {
33 return HttpServerResponse.text("ok");
34 }
35
36 if (request.method === "GET" && url.pathname === "/quote") {
37 const response = yield* quotes.fetch(`/quote${url.search}`).pipe(
38 // Treat an unreachable backend as a 502, not a crash.
39 Effect.catchTag("GCP.Run.InvokeServiceError", (error) =>
40 Effect.succeed({
41 status: 502,
42 text: Effect.succeed(JSON.stringify({ error: error.message })),
43 }),
44 ),
45 );
46 return HttpServerResponse.text(yield* response.text, {
47 status: response.status,
48 contentType: "application/json",
49 });
50 }
51
52 return yield* HttpServerResponse.json(
53 { error: "not found" },
54 { status: 404 },
55 );
56 }),
57 };
58 }).pipe(Effect.provide(GCP.Run.InvokeServiceHttp)),
59) {}