1import * as AWS from "alchemy/AWS";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { HttpServerRequest } from "effect/http/HttpServerRequest";
5import * as HttpServerResponse from "effect/http/HttpServerResponse";
6import {
7 OrdersCluster,
8 OrdersIngress,
9 OrdersNetwork,
10 OrdersTable,
11} from "./infra.ts";
12import SeedTask from "./SeedTask.ts";
13
14/**
15 * The orders API: an `AWS.ECS.Service` in the TAGGED form — the class
16 * declares the service identity, and the default export is
17 * `Api.make(props, impl)`: a Layer pairing the props with an init Effect
18 * whose impl returns `{ fetch }`, bundled into a generated image
19 * (`main: import.meta.url`). The Service synthesizes its own task
20 * definition (task + execution roles, log group, ECR repository) from the
21 * same surface as `AWS.ECS.Task`.
22 *
23 * The stack provides the Layer and yields the class (see `alchemy.run.ts`):
24 *
25 * ```typescript
26 * const api = yield* Api; // with Effect.provide(ApiLive)
27 * ```
28 *
29 * Ingress is the SHARED listener composed at the stack level (see
30 * `src/infra.ts`): this service only adds its own target group and a
31 * `path: "/api/*"` listener rule. The explicit `priority: 10` orders it
32 * ahead of `Web`'s catch-all `/*` rule.
33 *
34 * Bindings work exactly as on Lambda:
35 * - `AWS.DynamoDB.GetItem` / `Scan` grant read access to the orders table
36 * and inject its name into the container environment.
37 * - `AWS.ECS.RunTask(cluster, SeedTask)` grants `ecs:RunTask` on the seed
38 * task's definition (plus `iam:PassRole` on its roles), so
39 * `POST /api/seed` can launch the one-shot seeding task on Fargate. The
40 * cluster is explicit at the binding — a task definition is
41 * cluster-independent.
42 */
43export class Api extends AWS.ECS.Service<Api>()("Api") {}
44
45export default Api.make(
46 // Props are themselves an Effect so they can reference shared resources.
47 Effect.gen(function* () {
48 const cluster = yield* OrdersCluster;
49 const network = yield* OrdersNetwork;
50 const { listener } = yield* OrdersIngress;
51 return {
52 cluster,
53 main: import.meta.url,
54 image: "oven/bun:1",
55 port: 3000,
56 cpu: 256,
57 memory: 512,
58 desiredCount: 1,
59 // Build/run on ARM64 so an image built on an Apple Silicon host
60 // matches the Fargate runtime architecture (Graviton).
61 runtimePlatform: {
62 cpuArchitecture: "ARM64",
63 operatingSystemFamily: "LINUX",
64 },
65 vpcId: network.vpcId,
66 subnets: network.publicSubnetIds,
67 // Public subnets, no NAT: a public IP is required to pull the image.
68 assignPublicIp: true,
69 loadBalancer: {
70 listener,
71 rules: [{ path: "/api/*", priority: 10 }],
72 },
73 };
74 }),
75 Effect.gen(function* () {
76 const table = yield* OrdersTable;
77 const network = yield* OrdersNetwork;
78 const cluster = yield* OrdersCluster;
79 const seedTask = yield* SeedTask;
80
81 const scan = yield* AWS.DynamoDB.Scan(table);
82 const getItem = yield* AWS.DynamoDB.GetItem(table);
83 // The launch cluster is explicit at the binding — the task definition
84 // itself is cluster-independent.
85 const runSeedTask = yield* AWS.ECS.RunTask(cluster, seedTask);
86
87 // First public subnet, bound into the environment so the runtime can
88 // build the seed task's awsvpc network configuration.
89 const SubnetId = yield* network.publicSubnetIds[0]!;
90
91 return {
92 fetch: Effect.gen(function* () {
93 const request = yield* HttpServerRequest;
94 const url = new URL(request.originalUrl);
95
96 // GET /api/orders — list everything in the table.
97 if (request.method === "GET" && url.pathname === "/api/orders") {
98 const result = yield* scan({});
99 const orders = (result.Items ?? []).map((item) => ({
100 id: item.pk?.S?.replace(/^order#/, ""),
101 customer: item.customer?.S,
102 total: item.total?.N ? Number(item.total.N) : undefined,
103 }));
104 return yield* HttpServerResponse.json({
105 count: orders.length,
106 orders,
107 });
108 }
109
110 // GET /api/orders/<id> — read one order.
111 const match = url.pathname.match(/^\/api\/orders\/([^/]+)$/);
112 if (request.method === "GET" && match) {
113 const result = yield* getItem({
114 Key: { pk: { S: `order#${match[1]}` } },
115 });
116 if (!result.Item) {
117 return yield* HttpServerResponse.json(
118 { error: "not found" },
119 { status: 404 },
120 );
121 }
122 return yield* HttpServerResponse.json({
123 id: match[1],
124 customer: result.Item.customer?.S,
125 total: result.Item.total?.N
126 ? Number(result.Item.total.N)
127 : undefined,
128 });
129 }
130
131 // POST /api/seed — launch the one-shot SeedTask on Fargate via the
132 // RunTask binding (cluster + task definition ARNs are injected).
133 if (request.method === "POST" && url.pathname === "/api/seed") {
134 const subnetId = yield* SubnetId;
135 const response = yield* runSeedTask({
136 launchType: "FARGATE",
137 count: 1,
138 startedBy: "orders-api-seed",
139 networkConfiguration: {
140 awsvpcConfiguration: {
141 subnets: [subnetId],
142 // No securityGroups: the VPC default security group applies
143 // (all egress — enough to pull from ECR and reach DynamoDB).
144 assignPublicIp: "ENABLED",
145 },
146 },
147 });
148 return yield* HttpServerResponse.json({
149 taskArn: response.tasks?.[0]?.taskArn,
150 failures: response.failures ?? [],
151 });
152 }
153
154 // Everything else — including the ALB health check on "/" (health
155 // checks hit the container directly, not through listener rules).
156 return yield* HttpServerResponse.json({
157 ok: true,
158 service: "orders-api",
159 });
160 }).pipe(Effect.orDie),
161 };
162 }).pipe(
163 Effect.provide(
164 Layer.mergeAll(
165 AWS.DynamoDB.ScanHttp,
166 AWS.DynamoDB.GetItemHttp,
167 AWS.ECS.RunTaskHttp,
168 ),
169 ),
170 ),
171);