1/** Git's native Effect HTTP contracts, group registration, and server assembly. */
2import * as Git from "@/Git/index.ts";
3import { RuntimeContext } from "@/RuntimeContext.ts";
4import { describe, expect, it } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Schema from "effect/Schema";
8import * as HttpServerError from "effect/http/HttpServerError";
9import * as HttpServerRequest from "effect/http/HttpServerRequest";
10import * as HttpServerResponse from "effect/http/HttpServerResponse";
11import * as HttpApi from "effect/http-api/HttpApi";
12import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint";
13import * as HttpApiGroup from "effect/http-api/HttpApiGroup";
14import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
15import * as HttpRouter from "effect/http/HttpRouter";
16import * as Http from "@/Http/index.ts";
17import { HASH_ROUTE } from "@/Git/Hasher/Protocol.ts";
18
19const oid = "a".repeat(40) as Git.Oid;
20const unexpected = () => Effect.die("Unexpected handler invocation");
21const echoBody = () =>
22 Effect.gen(function* () {
23 const request = yield* HttpServerRequest.HttpServerRequest;
24 const bytes = yield* request.arrayBuffer.pipe(Effect.orDie);
25 return HttpServerResponse.uint8Array(new Uint8Array(bytes), {
26 contentType: "application/x-git-upload-pack-result",
27 });
28 });
29
30// The actual ApiLive registers every group against these handler implementations.
31const FakeHandlers = Layer.succeed(Git.Handlers, {
32 repos: {
33 create: unexpected,
34 get: ({ params }) => Effect.fail(new Git.RepoNotFound(params)),
35 update: unexpected,
36 list: unexpected,
37 delete: unexpected,
38 fork: unexpected,
39 compact: unexpected,
40 import: unexpected,
41 },
42 refs: {
43 list: unexpected,
44 get: ({ query }) => Effect.succeed(new Git.Ref({ name: query.name, oid })),
45 update: unexpected,
46 remove: unexpected,
47 },
48 objects: {
49 commit: unexpected,
50 log: unexpected,
51 tree: unexpected,
52 blob: unexpected,
53 diff: unexpected,
54 compare: unexpected,
55 blobRaw: unexpected,
56 file: unexpected,
57 },
58 pulls: {
59 create: unexpected,
60 list: unexpected,
61 get: unexpected,
62 update: unexpected,
63 merge: unexpected,
64 },
65 protocol: {
66 infoRefs: unexpected,
67 uploadPack: echoBody,
68 receivePack: echoBody,
69 },
70 github: {
71 user: () =>
72 Effect.succeed(HttpServerResponse.jsonUnsafe({ login: "default" })),
73 repo: unexpected,
74 branches: unexpected,
75 commits: unexpected,
76 commit: unexpected,
77 contents: unexpected,
78 pulls: unexpected,
79 createPull: unexpected,
80 pull: unexpected,
81 updatePull: unexpected,
82 mergePull: unexpected,
83 pullFiles: unexpected,
84 },
85 internal: { hashPart: echoBody },
86});
87
88const Authentication = HttpRouter.middleware((httpEffect) =>
89 Effect.gen(function* () {
90 const request = yield* HttpServerRequest.HttpServerRequest;
91 if (request.headers.authorization !== "Bearer test") {
92 return HttpServerResponse.empty({ status: 401 });
93 }
94 const runtime = yield* RuntimeContext;
95 return HttpServerResponse.setHeader(
96 yield* httpEffect,
97 "x-runtime-id",
98 runtime.id,
99 );
100 }).pipe(Effect.provide(RuntimeContext.phantom)),
101);
102class AppRoutes extends HttpApiGroup.make("app").add(
103 HttpApiEndpoint.get("health", "/health", { success: Schema.String }),
104) {}
105class AppApi extends HttpApi.make("app").add(AppRoutes) {}
106const AppApiLive = HttpApiBuilder.layer(AppApi).pipe(
107 Layer.provide(
108 HttpApiBuilder.group(AppApi, "app", (h) =>
109 h.handle("health", () => Effect.succeed("ok")),
110 ),
111 ),
112);
113const GitHubLive = HttpApiBuilder.group(Git.Api, "github", (h) =>
114 Effect.map(Git.Handlers, (git) =>
115 h.handleAll({
116 ...git.github,
117 user: () =>
118 Effect.succeed(HttpServerResponse.jsonUnsafe({ login: "application" })),
119 }),
120 ),
121);
122const OpenRoutes = Git.ApiLive.pipe(
123 Layer.provide(FakeHandlers),
124 Layer.provide(Http.Platform),
125);
126const ProtectedRoutes = Layer.mergeAll(
127 Layer.mergeAll(
128 AppApiLive,
129 HttpApiBuilder.layer(Git.Api).pipe(
130 Layer.provide(Layer.mergeAll(Git.GroupsLive, GitHubLive)),
131 ),
132 ).pipe(Layer.provide(Authentication.layer)),
133 Git.InternalApiLive,
134).pipe(Layer.provide(FakeHandlers), Layer.provide(Http.Platform));
135class PrefixedApi extends HttpApi.make("custom-git")
136 .add(Git.Refs)
137 .prefix("/git") {}
138const PrefixedRoutes = Layer.mergeAll(
139 AppApiLive,
140 HttpApiBuilder.layer(PrefixedApi).pipe(
141 Layer.provide(
142 HttpApiBuilder.group(PrefixedApi, "refs", (h) =>
143 Effect.map(Git.Handlers, (git) => h.handleAll(git.refs)),
144 ),
145 ),
146 ),
147).pipe(
148 Layer.provide(Authentication.layer),
149 Layer.provide(FakeHandlers),
150 Layer.provide(Http.Platform),
151);
152
153const request = (
154 fetch: Http.HttpEffect<RuntimeContext>,
155 path: string,
156 init?: RequestInit,
157) =>
158 fetch.pipe(
159 Effect.provideService(
160 HttpServerRequest.HttpServerRequest,
161 HttpServerRequest.fromWeb(new Request(`http://git.test${path}`, init)),
162 ),
163 Effect.provideService(RuntimeContext, {
164 Type: "test",
165 id: "request-runtime",
166 env: {},
167 get: () => Effect.succeed(undefined),
168 set: () => Effect.succeed(""),
169 }),
170 Effect.catchCause((cause) =>
171 HttpServerError.causeResponse(cause).pipe(
172 Effect.map(([response]) => response),
173 ),
174 ),
175 Effect.map(HttpServerResponse.toWeb),
176 Effect.scoped,
177 );
178
179describe("Git HTTP composition", { tags: ["unit", "local"] }, () => {
180 it.effect(
181 "registers every default group and preserves decoded requests and typed errors",
182 () =>
183 Effect.gen(function* () {
184 const server = yield* HttpRouter.toHttpEffect(OpenRoutes);
185 const ref = yield* request(
186 server,
187 "/api/v1/repos/acme/repo/ref?name=refs%2Fheads%2Fmain",
188 );
189 expect(ref.status).toBe(200);
190 expect(yield* Effect.promise(() => ref.json())).toEqual({
191 name: "refs/heads/main",
192 oid,
193 });
194
195 const missing = yield* request(server, "/api/v1/repos/acme/missing");
196 expect(missing.status).toBe(404);
197 expect(yield* Effect.promise(() => missing.json())).toMatchObject({
198 _tag: "RepoNotFound",
199 owner: "acme",
200 repo: "missing",
201 });
202
203 const invalid = yield* request(server, "/api/v1/repos/acme/repo/ref");
204 expect(invalid.status).toBe(400);
205 }).pipe(Effect.scoped),
206 );
207
208 it.effect("preserves binary protocol responses", () =>
209 Effect.gen(function* () {
210 const server = yield* HttpRouter.toHttpEffect(OpenRoutes);
211 const bytes = new Uint8Array([0, 255, 1, 128, 10]);
212 const response = yield* request(
213 server,
214 "/acme/repo.git/git-upload-pack",
215 { method: "POST", body: bytes },
216 );
217 expect(response.status).toBe(200);
218 expect(response.headers.get("content-type")).toBe(
219 "application/x-git-upload-pack-result",
220 );
221 expect(
222 new Uint8Array(yield* Effect.promise(() => response.arrayBuffer())),
223 ).toEqual(bytes);
224 }).pipe(Effect.scoped),
225 );
226
227 it.effect(
228 "applies app middleware and handler overrides while keeping internal routes separate",
229 () =>
230 Effect.gen(function* () {
231 const server = yield* HttpRouter.toHttpEffect(ProtectedRoutes);
232 const denied = yield* request(server, "/api/v3/user");
233 expect(denied.status).toBe(401);
234 const wireDenied = yield* request(
235 server,
236 "/acme/repo.git/git-upload-pack",
237 { method: "POST" },
238 );
239 expect(wireDenied.status).toBe(401);
240 const allowed = yield* request(server, "/api/v3/user", {
241 headers: { authorization: "Bearer test" },
242 });
243 expect(allowed.status).toBe(200);
244 expect(allowed.headers.get("x-runtime-id")).toBe("request-runtime");
245 expect(yield* Effect.promise(() => allowed.json())).toEqual({
246 login: "application",
247 });
248 const health = yield* request(server, "/health", {
249 headers: { authorization: "Bearer test" },
250 });
251 expect(health.status).toBe(200);
252 expect(yield* Effect.promise(() => health.json())).toBe("ok");
253 const internal = yield* request(server, HASH_ROUTE, {
254 method: "POST",
255 body: "hash input",
256 });
257 expect(internal.status).toBe(200);
258 expect(yield* Effect.promise(() => internal.text())).toBe("hash input");
259 }).pipe(Effect.scoped),
260 );
261 it.effect(
262 "adds app endpoints beside prefixed Git defaults on a subset API",
263 () =>
264 Effect.gen(function* () {
265 const server = yield* HttpRouter.toHttpEffect(PrefixedRoutes);
266 const init = { headers: { authorization: "Bearer test" } };
267 const denied = yield* request(
268 server,
269 "/git/api/v1/repos/acme/repo/ref?name=refs/heads/main",
270 );
271 expect(denied.status).toBe(401);
272 const ref = yield* request(
273 server,
274 "/git/api/v1/repos/acme/repo/ref?name=refs/heads/main",
275 init,
276 );
277 expect(ref.status).toBe(200);
278 expect(yield* Effect.promise(() => ref.json())).toEqual({
279 name: "refs/heads/main",
280 oid,
281 });
282 expect(ref.headers.get("x-runtime-id")).toBe("request-runtime");
283 const health = yield* request(server, "/health", init);
284 expect(health.status).toBe(200);
285 expect(yield* Effect.promise(() => health.json())).toBe("ok");
286 const unprefixed = yield* request(
287 server,
288 "/api/v1/repos/acme/repo/ref?name=refs/heads/main",
289 init,
290 );
291 expect(unprefixed.status).toBe(404);
292 }).pipe(Effect.scoped),
293 );
294});