| 1 | /** Git's native Effect HTTP contracts, group registration, and server assembly. */ |
| 2 | import * as Git from "@/Git/index.ts"; |
| 3 | import { RuntimeContext } from "@/RuntimeContext.ts"; |
| 4 | import { describe, expect, it } from "alchemy-test"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Schema from "effect/Schema"; |
| 8 | import * as HttpServerError from "effect/http/HttpServerError"; |
| 9 | import * as HttpServerRequest from "effect/http/HttpServerRequest"; |
| 10 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 11 | import * as HttpApi from "effect/http-api/HttpApi"; |
| 12 | import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint"; |
| 13 | import * as HttpApiGroup from "effect/http-api/HttpApiGroup"; |
| 14 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 15 | import * as HttpRouter from "effect/http/HttpRouter"; |
| 16 | import * as Http from "@/Http/index.ts"; |
| 17 | import { HASH_ROUTE } from "@/Git/Hasher/Protocol.ts"; |
| 18 | |
| 19 | const oid = "a".repeat(40) as Git.Oid; |
| 20 | const unexpected = () => Effect.die("Unexpected handler invocation"); |
| 21 | const 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. |
| 31 | const 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 | |
| 88 | const 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 | ); |
| 102 | class AppRoutes extends HttpApiGroup.make("app").add( |
| 103 | HttpApiEndpoint.get("health", "/health", { success: Schema.String }), |
| 104 | ) {} |
| 105 | class AppApi extends HttpApi.make("app").add(AppRoutes) {} |
| 106 | const AppApiLive = HttpApiBuilder.layer(AppApi).pipe( |
| 107 | Layer.provide( |
| 108 | HttpApiBuilder.group(AppApi, "app", (h) => |
| 109 | h.handle("health", () => Effect.succeed("ok")), |
| 110 | ), |
| 111 | ), |
| 112 | ); |
| 113 | const 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 | ); |
| 122 | const OpenRoutes = Git.ApiLive.pipe( |
| 123 | Layer.provide(FakeHandlers), |
| 124 | Layer.provide(Http.Platform), |
| 125 | ); |
| 126 | const 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)); |
| 135 | class PrefixedApi extends HttpApi.make("custom-git") |
| 136 | .add(Git.Refs) |
| 137 | .prefix("/git") {} |
| 138 | const 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 | |
| 153 | const 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 | |
| 179 | describe("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 | }); |