|
|
@@ -1,6 +1,6 @@
|
|
|
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
|
|
import { describe, expect } from "bun:test"
|
|
|
-import { Context, Effect, Layer, Queue, Ref } from "effect"
|
|
|
+import { Context, Effect, Layer, Queue, Ref, Schema } from "effect"
|
|
|
import {
|
|
|
FetchHttpClient,
|
|
|
HttpClient,
|
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
HttpServerResponse,
|
|
|
} from "effect/unstable/http"
|
|
|
import * as Socket from "effect/unstable/socket/Socket"
|
|
|
+import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
|
|
import Http from "node:http"
|
|
|
import { mkdir } from "node:fs/promises"
|
|
|
import path from "node:path"
|
|
|
@@ -20,10 +21,13 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
|
|
import { Workspace } from "../../src/control-plane/workspace"
|
|
|
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
|
|
import { Project } from "../../src/project/project"
|
|
|
+import { Session } from "../../src/session/session"
|
|
|
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
|
|
import {
|
|
|
+ WorkspaceRoutingMiddleware,
|
|
|
+ WorkspaceRoutingQuery,
|
|
|
WorkspaceRouteContext,
|
|
|
- workspaceRouterMiddleware,
|
|
|
+ workspaceRoutingLayer,
|
|
|
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
|
|
import { HEADER as FenceHeader } from "../../src/server/shared/fence"
|
|
|
import { Database } from "../../src/storage/db"
|
|
|
@@ -66,7 +70,7 @@ type TestHandler<E, R> = (
|
|
|
request: HttpServerRequest.HttpServerRequest,
|
|
|
) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>
|
|
|
|
|
|
-const workspaceRoutingTestLayer = workspaceRouterMiddleware.layer.pipe(
|
|
|
+const workspaceRoutingTestLayer = workspaceRoutingLayer.pipe(
|
|
|
Layer.provide([Socket.layerWebSocketConstructorGlobal, FetchHttpClient.layer]),
|
|
|
)
|
|
|
|
|
|
@@ -203,16 +207,45 @@ const echoWebSocket = (request: HttpServerRequest.HttpServerRequest) =>
|
|
|
return HttpServerResponse.empty()
|
|
|
})
|
|
|
|
|
|
-const serveRouteContextProbe = HttpRouter.add(
|
|
|
- "GET",
|
|
|
- "/probe",
|
|
|
- Effect.gen(function* () {
|
|
|
- // The fake route exposes the context installed by the middleware, so tests
|
|
|
- // can assert routing decisions without pulling in the production API tree.
|
|
|
- const route = yield* WorkspaceRouteContext
|
|
|
- return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
|
|
|
- }),
|
|
|
-).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
|
|
|
+const ProbeResult = Schema.Struct({
|
|
|
+ directory: Schema.String,
|
|
|
+ workspaceID: Schema.optional(Schema.String),
|
|
|
+})
|
|
|
+
|
|
|
+const ProbeApi = HttpApi.make("workspace-routing-probe").add(
|
|
|
+ HttpApiGroup.make("probe")
|
|
|
+ .add(
|
|
|
+ HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }),
|
|
|
+ HttpApiEndpoint.patch("patch", "/probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }),
|
|
|
+ HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }),
|
|
|
+ HttpApiEndpoint.get("workspace", WorkspacePaths.list, {
|
|
|
+ query: WorkspaceRoutingQuery,
|
|
|
+ success: ProbeResult,
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ .middleware(WorkspaceRoutingMiddleware),
|
|
|
+)
|
|
|
+
|
|
|
+const routeContextResponse = Effect.gen(function* () {
|
|
|
+ const route = yield* WorkspaceRouteContext
|
|
|
+ return { directory: route.directory, workspaceID: route.workspaceID }
|
|
|
+})
|
|
|
+
|
|
|
+const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
|
|
|
+ handlers
|
|
|
+ .handle("get", () => routeContextResponse)
|
|
|
+ .handle("patch", () => Effect.succeed(false))
|
|
|
+ .handle("session", () => routeContextResponse)
|
|
|
+ .handle("workspace", () => routeContextResponse),
|
|
|
+)
|
|
|
+
|
|
|
+const serveProbe = HttpApiBuilder.layer(ProbeApi).pipe(
|
|
|
+ Layer.provide(probeHandlers),
|
|
|
+ Layer.provide(workspaceRoutingTestLayer),
|
|
|
+ Layer.provide(Layer.mock(Session.Service)({})),
|
|
|
+ HttpRouter.serve,
|
|
|
+ Layer.build,
|
|
|
+)
|
|
|
|
|
|
describe("HttpApi workspace routing middleware", () => {
|
|
|
it.live("proxies remote workspace HTTP requests through the selected workspace target", () =>
|
|
|
@@ -250,11 +283,7 @@ describe("HttpApi workspace routing middleware", () => {
|
|
|
|
|
|
// The local /probe handler should not run. Selecting a remote workspace
|
|
|
// should make the middleware call HttpApiProxy.http instead.
|
|
|
- yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe(
|
|
|
- Layer.provide(workspaceRoutingTestLayer),
|
|
|
- HttpRouter.serve,
|
|
|
- Layer.build,
|
|
|
- )
|
|
|
+ yield* serveProbe
|
|
|
|
|
|
const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe(
|
|
|
HttpClientRequest.setHeaders({
|
|
|
@@ -325,9 +354,11 @@ describe("HttpApi workspace routing middleware", () => {
|
|
|
startWorkspaceSyncing: () => Effect.die("unused"),
|
|
|
})
|
|
|
|
|
|
- yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe(
|
|
|
+ yield* HttpApiBuilder.layer(ProbeApi).pipe(
|
|
|
+ Layer.provide(probeHandlers),
|
|
|
Layer.provide(workspaceRoutingTestLayer),
|
|
|
Layer.provide(Layer.succeed(Workspace.Service, workspace)),
|
|
|
+ Layer.provide(Layer.mock(Session.Service)({})),
|
|
|
HttpRouter.serve,
|
|
|
Layer.build,
|
|
|
)
|
|
|
@@ -351,11 +382,7 @@ describe("HttpApi workspace routing middleware", () => {
|
|
|
url: "http://127.0.0.1:1/base",
|
|
|
})
|
|
|
|
|
|
- yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
|
|
|
- Layer.provide(workspaceRoutingTestLayer),
|
|
|
- HttpRouter.serve,
|
|
|
- Layer.build,
|
|
|
- )
|
|
|
+ yield* serveProbe
|
|
|
|
|
|
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
|
|
|
|
|
|
@@ -378,11 +405,7 @@ describe("HttpApi workspace routing middleware", () => {
|
|
|
|
|
|
// The client connects to the local test server. The middleware should
|
|
|
// detect the WebSocket upgrade and proxy it to the remote /base/probe.
|
|
|
- yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
|
|
|
- Layer.provide(workspaceRoutingTestLayer),
|
|
|
- HttpRouter.serve,
|
|
|
- Layer.build,
|
|
|
- )
|
|
|
+ yield* serveProbe
|
|
|
|
|
|
const socket = yield* Socket.makeWebSocket(
|
|
|
`${(yield* serverUrl).replace(/^http/, "ws")}/probe?workspace=${workspace.id}`,
|
|
|
@@ -406,11 +429,7 @@ describe("HttpApi workspace routing middleware", () => {
|
|
|
const workspaceID = WorkspaceID.ascending("wrk_missing")
|
|
|
// If the middleware resolves the workspace first, this handler is never
|
|
|
// reached and the response should be the middleware error response.
|
|
|
- yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
|
|
|
- Layer.provide(workspaceRoutingTestLayer),
|
|
|
- HttpRouter.serve,
|
|
|
- Layer.build,
|
|
|
- )
|
|
|
+ yield* serveProbe
|
|
|
|
|
|
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
|
|
|
|
|
|
@@ -433,14 +452,7 @@ describe("HttpApi workspace routing middleware", () => {
|
|
|
|
|
|
// GET /session is a control-plane route: it lists sessions for the main
|
|
|
// process and should not be redirected into the selected workspace target.
|
|
|
- yield* HttpRouter.add(
|
|
|
- "GET",
|
|
|
- "/session",
|
|
|
- Effect.gen(function* () {
|
|
|
- const route = yield* WorkspaceRouteContext
|
|
|
- return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
|
|
|
- }),
|
|
|
- ).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
|
|
|
+ yield* serveProbe
|
|
|
|
|
|
const response = yield* HttpClient.get(`/session?workspace=${workspace.id}`)
|
|
|
|
|
|
@@ -463,14 +475,7 @@ describe("HttpApi workspace routing middleware", () => {
|
|
|
// Workspace CRUD/status routes manage the control plane itself. Selecting
|
|
|
// a workspace should preserve the selected id for handlers, but must not
|
|
|
// swap the route context to the workspace target directory.
|
|
|
- yield* HttpRouter.add(
|
|
|
- "GET",
|
|
|
- WorkspacePaths.list,
|
|
|
- Effect.gen(function* () {
|
|
|
- const route = yield* WorkspaceRouteContext
|
|
|
- return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
|
|
|
- }),
|
|
|
- ).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
|
|
|
+ yield* serveProbe
|
|
|
|
|
|
const response = yield* HttpClient.get(`${WorkspacePaths.list}?workspace=${workspace.id}`)
|
|
|
|
|
|
@@ -484,7 +489,7 @@ describe("HttpApi workspace routing middleware", () => {
|
|
|
const dir = yield* tmpdirScoped()
|
|
|
const queryDir = path.join(dir, "query-target")
|
|
|
const headerDir = path.join(dir, "header-target")
|
|
|
- yield* serveRouteContextProbe
|
|
|
+ yield* serveProbe
|
|
|
|
|
|
// Without a selected workspace, the middleware falls back to request
|
|
|
// directory hints before using the process cwd.
|
|
|
@@ -495,9 +500,9 @@ describe("HttpApi workspace routing middleware", () => {
|
|
|
)
|
|
|
|
|
|
expect(queryResponse.status).toBe(200)
|
|
|
- expect(yield* queryResponse.json).toEqual({ directory: queryDir })
|
|
|
+ expect(yield* queryResponse.json).toEqual({ directory: queryDir, workspaceID: null })
|
|
|
expect(headerResponse.status).toBe(200)
|
|
|
- expect(yield* headerResponse.json).toEqual({ directory: headerDir })
|
|
|
+ expect(yield* headerResponse.json).toEqual({ directory: headerDir, workspaceID: null })
|
|
|
}),
|
|
|
)
|
|
|
|
|
|
@@ -513,7 +518,7 @@ describe("HttpApi workspace routing middleware", () => {
|
|
|
directory: workspaceDir,
|
|
|
})
|
|
|
|
|
|
- yield* serveRouteContextProbe
|
|
|
+ yield* serveProbe
|
|
|
|
|
|
// /probe is not a control-plane route, so selecting a local workspace
|
|
|
// should swap the route context to the workspace target directory.
|