Effect HTTP API on Cloud Run
HttpApi gives you real REST endpoints, with paths, query strings,
and typed bodies, behind a typed client. Use it when the API’s callers
aren’t all Effect, or aren’t TypeScript. The concept lives at
Effect HTTP. This page wires it onto Cloud Run.
Define the schema
Section titled “Define the schema”import * as Schema from "effect/Schema";
export const JobId = Schema.String;
export class Job extends Schema.Class<Job>("Job")({ id: JobId, content: Schema.String,}) {}
export class JobNotFound extends Schema.TaggedErrorClass<JobNotFound>()( "JobNotFound", { id: Schema.String }, { httpApiStatus: 404 },) {}httpApiStatus: 404 sets the status the error is sent with.
Declare the API
Section titled “Declare the API”import * as Schema from "effect/Schema";import * as HttpApi from "effect/http-api/HttpApi";import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint";import * as HttpApiGroup from "effect/http-api/HttpApiGroup";import { Job, JobId, JobNotFound } from "./Job.ts";
export const getJob = HttpApiEndpoint.get("getJob", "/", { success: Job, error: JobNotFound, query: { jobId: JobId },});
export const createJob = HttpApiEndpoint.post("createJob", "/", { success: JobId, payload: Schema.Struct({ content: Schema.String }),});
export const JobApi = HttpApi.make("JobApi").add( HttpApiGroup.make("Jobs").add(getJob, createJob),);JobApi is a plain value. The server and the client both import it.
Implement it on Cloud Run
Section titled “Implement it on Cloud Run”Bind a Firestore database, implement the handlers, and turn the API
into a fetch handler:
import * as GCP from "@alchemy.run/gcp";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Etag from "effect/http/Etag";import * as HttpPlatform from "effect/http/HttpPlatform";import * as HttpRouter from "effect/http/HttpRouter";import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";import { Job, JobNotFound } from "./Job.ts";import { JobApi } from "./JobApi.ts";
export default class JobService extends GCP.Function<JobService>()( "JobService", { main: import.meta.url, invokerIamDisabled: true }, Effect.gen(function* () { const jobs = yield* GCP.Firestore.Database("Jobs", { type: "FIRESTORE_NATIVE", }); const db = yield* GCP.Firestore.ReadWriteDatabase(jobs);
const jobsGroup = HttpApiBuilder.group(JobApi, "Jobs", (handlers) => handlers .handle("getJob", ({ query }) => db.get(`jobs/${query.jobId}`).pipe( Effect.orDie, Effect.flatMap((doc) => doc ? Effect.succeed( new Job({ id: query.jobId, content: String(doc.fields.content ?? ""), }), ) : Effect.fail(new JobNotFound({ id: query.jobId })), ), ), ) .handle("createJob", ({ payload }) => Effect.gen(function* () { const id = crypto.randomUUID(); yield* db.set(`jobs/${id}`, { content: payload.content }); return id; }).pipe(Effect.orDie), ), );
return { fetch: yield* HttpRouter.toHttpEffect( HttpApiBuilder.layer(JobApi).pipe(Layer.provide(jobsGroup)), ), }; }).pipe( Effect.provide( Layer.mergeAll( GCP.Firestore.ReadWriteDatabaseHttp, HttpPlatform.layer, Etag.layer, ), ), ),) {}Handlers get typed input and must return the declared types. Firestore
errors become 500s through Effect.orDie. JobNotFound stays typed
and is sent as a 404.
Add it to the Stack
Section titled “Add it to the Stack”import * as Alchemy from "alchemy";import * as GCP from "@alchemy.run/gcp";import * as Effect from "effect/Effect";import JobService from "./src/JobService.ts";
export default Alchemy.Stack( "JobApi", { providers: GCP.providers(), state: GCP.state() }, Effect.gen(function* () { const service = yield* JobService; return { url: service.uri }; }),);bun alchemy deployInvalid payloads get a 400 with the validation errors.
Call it from a typed test
Section titled “Call it from a typed test”The same JobApi value builds a typed client:
import * as Alchemy from "alchemy";import * as GCP from "@alchemy.run/gcp";import * as Test from "alchemy/Test/Bun";import { expect } from "bun:test";import * as Effect from "effect/Effect";import * as Schedule from "effect/Schedule";import * as HttpApiClient from "effect/http-api/HttpApiClient";import Stack from "../alchemy.run.ts";import { JobApi } from "../src/JobApi.ts";
const { test, beforeAll, deploy } = Test.make({ providers: GCP.providers(), state: GCP.state(),});
const stack = beforeAll(deploy(Stack), { timeout: 600_000 });
test( "create and fetch a job", Effect.gen(function* () { const { url } = yield* stack; const client = yield* HttpApiClient.make(JobApi, { baseUrl: url });
const jobId = yield* client.Jobs.createJob({ payload: { content: "Ship the docs" }, }).pipe( Effect.retry({ schedule: Schedule.exponential("1 second"), times: 8 }), );
const job = yield* client.Jobs.getJob({ query: { jobId } }); expect(job.content).toBe("Ship the docs");
const missing = yield* client.Jobs.getJob({ query: { jobId: "nope" }, }).pipe(Effect.flip); expect(missing._tag).toBe("JobNotFound"); }), { timeout: 120_000 },);The first call retries while the new Firestore grant reaches the service.
Where next
Section titled “Where next”- Effect HTTP — endpoints, groups, and clients in depth.
- Effect RPC on Cloud Run — when every caller is Effect.
- Firestore — the client used above.