Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions apps/api/src/routes/__tests__/query-engine-batch.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { assert, describe, it } from "@effect/vitest"
import type { QueryEngineResult } from "@maple/query-engine"
import { Effect, Schema } from "effect"
import { Effect } from "effect"
import { QueryEngineExecutionError } from "@maple/domain/http"
import { runQueryEngineBatch } from "@/routes/query-engine-batch"

class StubError extends Schema.TaggedError<StubError>()("@maple/http/errors/QueryEngineExecutionError", {
message: Schema.String,
}) {}

const countResult = (total: number): QueryEngineResult => ({
kind: "count",
source: "logs",
Expand Down Expand Up @@ -38,7 +35,7 @@ describe("runQueryEngineBatch", () => {
requests: [1, 2, 3],
execute: (n: number) =>
n === 2
? Effect.fail(new StubError({ message: "boom" }))
? Effect.fail(new QueryEngineExecutionError({ message: "boom" }))
: Effect.succeed(countResult(n)),
})

Expand All @@ -48,9 +45,10 @@ describe("runQueryEngineBatch", () => {
)
const failed = outcomes[1]
assert.ok(failed !== undefined && failed.outcome === "failure")
// The original tag rides along so the client keeps its specific copy.
// The complete public body rides alongside the successful siblings.
assert.strictEqual(failed.error._tag, "@maple/http/errors/QueryEngineExecutionError")
assert.strictEqual(failed.error.message, "boom")
assert.strictEqual(failed.error.title, "Query failed")
assert.strictEqual(failed.error.message, "The aggregation query could not be completed.")
}),
)

Expand Down
13 changes: 4 additions & 9 deletions apps/api/src/routes/query-engine-batch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { QueryEngineBatchOutcome, QueryEngineResult } from "@maple/query-engine"
import { Clock, Duration, Effect } from "effect"
import { QueryEngineTimeoutError, type SelfDescribingHttpError } from "@maple/domain/http"

/**
* Fan-out for `POST /api/query-engine/execute-batch`.
Expand Down Expand Up @@ -29,10 +30,7 @@ export const QE_BATCH_DEADLINE_MS = 25_000

const timedOut = (): QueryEngineBatchOutcome => ({
outcome: "failure",
error: {
_tag: "@maple/http/errors/QueryEngineTimeoutError",
message: "Query exceeded the batch deadline.",
},
error: new QueryEngineTimeoutError({ message: "Query exceeded the batch deadline." }).error,
})

/**
Expand All @@ -41,10 +39,7 @@ const timedOut = (): QueryEngineBatchOutcome => ({
* as `failure` outcomes rather than failing the effect: one bad widget must not
* take down the others sharing its request.
*/
export const runQueryEngineBatch = <
Request,
Error extends { readonly _tag: string; readonly message: string },
>(options: {
export const runQueryEngineBatch = <Request, Error extends SelfDescribingHttpError>(options: {
readonly requests: ReadonlyArray<Request>
readonly execute: (request: Request) => Effect.Effect<QueryEngineResult, Error>
readonly deadlineMs?: number
Expand Down Expand Up @@ -72,7 +67,7 @@ export const runQueryEngineBatch = <
Effect.catch((error) =>
Effect.succeed({
outcome: "failure",
error: { _tag: error._tag, message: error.message },
error: error.error,
} satisfies QueryEngineBatchOutcome),
),
)
Expand Down
9 changes: 7 additions & 2 deletions apps/api/src/routes/v1/chat.http.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { describe, expect, it } from "@effect/vitest"
import { ChatApiGroup, CurrentTenant } from "@maple/domain/http"
import { ChatApiGroup, CurrentTenant, V1SchemaErrors, V1UnexpectedErrors } from "@maple/domain/http"
import { WorkerEnvironment } from "@maple/effect-cloudflare"
import { Context, Effect, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"
import { McpToolExecutor, type McpToolExecutorShape } from "@/mcp/dispatcher"
import type { TenantContext } from "@/services/auth/tenant-context"
import { HttpChatLive } from "./chat.http"
import { V1ErrorBoundaryLive } from "./error-boundary"

class ChatOnlyApi extends HttpApi.make("MapleApi").add(ChatApiGroup) {}
class ChatOnlyApi extends HttpApi.make("MapleApi")
.add(ChatApiGroup)
.middleware(V1SchemaErrors)
.middleware(V1UnexpectedErrors) {}

const TENANT = new CurrentTenant.TenantSchema({
orgId: "org_chat_approval" as CurrentTenant.TenantSchema["orgId"],
Expand All @@ -27,6 +31,7 @@ const AuthorizationStubLayer = Layer.succeed(
const makeHarness = (executor: McpToolExecutorShape) => {
const routes = HttpApiBuilder.layer(ChatOnlyApi).pipe(
Layer.provide(HttpChatLive),
Layer.provide(V1ErrorBoundaryLive),
Layer.provideMerge(AuthorizationStubLayer),
Layer.provideMerge(Layer.succeed(McpToolExecutor, executor)),
// No session identifiers are sent in these requests, so the route never
Expand Down
14 changes: 9 additions & 5 deletions apps/api/src/routes/v1/dashboards.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { afterEach, describe, expect, it } from "@effect/vitest"
import { ConfigProvider, Context, Effect, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"
import { CurrentTenant, DashboardsApiGroup } from "@maple/domain/http"
import { CurrentTenant, DashboardsApiGroup, V1SchemaErrors, V1UnexpectedErrors } from "@maple/domain/http"
import { Env } from "@/platform/Env"
import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite"
import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService"
import { HttpDashboardSchemaErrorsLive, HttpDashboardsLive } from "./dashboards.http"
import { HttpDashboardsLive } from "./dashboards.http"
import { V1ErrorBoundaryLive } from "./error-boundary"

const createdDbs: TestDb[] = []
afterEach(() => cleanupTestDbs(createdDbs))
Expand All @@ -17,7 +18,10 @@ afterEach(() => cleanupTestDbs(createdDbs))
* layer on `(apiId, groupIdentifier)`, so this is what lets the real
* `HttpDashboardsLive` satisfy it.
*/
class DashboardsOnlyApi extends HttpApi.make("MapleApi").add(DashboardsApiGroup) {}
class DashboardsOnlyApi extends HttpApi.make("MapleApi")
.add(DashboardsApiGroup)
.middleware(V1SchemaErrors)
.middleware(V1UnexpectedErrors) {}

const TENANT = new CurrentTenant.TenantSchema({
orgId: "org_dashboards_schema_errors" as CurrentTenant.TenantSchema["orgId"],
Expand Down Expand Up @@ -58,7 +62,7 @@ const makeHarness = () => {

const routes = HttpApiBuilder.layer(DashboardsOnlyApi).pipe(
Layer.provide(HttpDashboardsLive),
Layer.provide(HttpDashboardSchemaErrorsLive),
Layer.provide(V1ErrorBoundaryLive),
Layer.provideMerge(AuthorizationStubLayer),
Layer.provideMerge(servicesLive),
)
Expand Down Expand Up @@ -113,7 +117,7 @@ describe("v1 dashboards request-decode failures", () => {

expect(response.status).toBe(400)
expect(response.body).not.toBeNull()
expect(response.body._tag).toBe("@maple/http/errors/DashboardValidationError")
expect(response.body._tag).toBe("@maple/http/v1/V1RequestValidationError")
expect(response.body.message).toContain("payload is invalid")

const details: string[] = response.body.details
Expand Down
23 changes: 1 addition & 22 deletions apps/api/src/routes/v1/dashboards.http.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { HttpApiBuilder, HttpApiMiddleware } from "effect/unstable/httpapi"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import {
CurrentTenant,
DashboardSchemaErrors,
DashboardTemplateMetadata,
DashboardTemplateNotFoundError,
DashboardTemplatesListResponse,
Expand All @@ -11,31 +10,11 @@ import {
PortableDashboardDocument,
} from "@maple/domain/http"
import { Effect } from "effect"
import { describeSchemaIssue, summarizeSchemaError } from "@/routes/schema-error-detail"
import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService"
import { getTemplateById, listTemplateMetadata } from "@/dashboard-templates"
import type { TemplateParameterValues } from "@/dashboard-templates"
import { convertPersesDashboardToPortable } from "@/services/dashboards/perses-dashboard-import"

/**
* Renders a dashboard request-decode failure as a `DashboardValidationError`
* whose `details` name the widget and field at fault, instead of the runtime's
* default empty 400 (see `./schema-error-detail`).
*/
export const HttpDashboardSchemaErrorsLive = HttpApiMiddleware.layerSchemaErrorTransform(
DashboardSchemaErrors,
(schemaError) =>
Effect.suspend(() => {
const details = describeSchemaIssue(schemaError.cause.issue)
return Effect.fail(
new DashboardValidationError({
message: summarizeSchemaError(schemaError.kind, details),
details: details.map(({ line }) => line),
}),
)
}),
)

export const HttpDashboardsLive = HttpApiBuilder.group(MapleApi, "dashboards", (handlers) =>
Effect.gen(function* () {
const persistence = yield* DashboardPersistenceService
Expand Down
97 changes: 97 additions & 0 deletions apps/api/src/routes/v1/error-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, it } from "@effect/vitest"
import { Context, Effect, Layer, Schema } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import { V1SchemaErrors, V1UnexpectedErrors } from "@maple/domain/http"
import { V1ErrorBoundaryLive } from "./error-boundary"

const BoundaryGroup = HttpApiGroup.make("boundary")
.add(
HttpApiEndpoint.post("validate", "/validate", {
payload: Schema.Struct({ name: Schema.String.check(Schema.isMinLength(2)) }),
success: Schema.String,
}),
)
.add(HttpApiEndpoint.get("invalidResponse", "/invalid-response", { success: Schema.String }))
.add(HttpApiEndpoint.get("defect", "/defect", { success: Schema.String }))

class BoundaryApi extends HttpApi.make("BoundaryApi")
.add(BoundaryGroup)
.middleware(V1SchemaErrors)
.middleware(V1UnexpectedErrors) {}

const BoundaryHandlersLive = HttpApiBuilder.group(BoundaryApi, "boundary", (handlers) =>
Effect.succeed(
handlers
.handle("validate", ({ payload }) => Effect.succeed(payload.name))
.handle("invalidResponse", () => Effect.succeed(42 as never))
.handle("defect", () => Effect.die(new Error("database password must not cross the wire"))),
),
)

const makeHarness = () => {
const routes = HttpApiBuilder.layer(BoundaryApi).pipe(
Layer.provide(BoundaryHandlersLive),
Layer.provide(V1ErrorBoundaryLive),
)
const { handler, dispose } = HttpRouter.toWebHandler(routes, { disableLogger: true })
const request = async (method: string, path: string, body?: unknown) => {
const response = await handler(
new Request(`http://maple.test${path}`, {
method,
headers: body === undefined ? undefined : { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
}),
Context.empty() as never,
)
return { status: response.status, body: await response.json() }
}
return { request, dispose }
}

describe("v1 HTTP error boundary", () => {
it("returns a structured, path-anchored 400 for every request decode failure", async () => {
const harness = makeHarness()
try {
const response = await harness.request("POST", "/validate", { name: "" })
expect(response.status).toBe(400)
expect(response.body).toMatchObject({
_tag: "@maple/http/v1/V1RequestValidationError",
param: "name",
details: [expect.stringContaining("name")],
})
} finally {
await harness.dispose()
}
})

it("logs defects and returns a sanitized 500", async () => {
const harness = makeHarness()
try {
const response = await harness.request("GET", "/defect")
expect(response.status).toBe(500)
expect(response.body).toEqual({
_tag: "@maple/http/v1/V1UnexpectedError",
message: "An unexpected error occurred on our end.",
})
expect(JSON.stringify(response.body)).not.toContain("database password")
} finally {
await harness.dispose()
}
})

it("treats an invalid handler response as a sanitized 500, not a caller 400", async () => {
const harness = makeHarness()
try {
const response = await harness.request("GET", "/invalid-response")
expect(response.status).toBe(500)
expect(response.body).toEqual({
_tag: "@maple/http/v1/V1UnexpectedError",
message: "An unexpected error occurred on our end.",
})
expect(JSON.stringify(response.body)).not.toContain("42")
} finally {
await harness.dispose()
}
})
})
Loading
Loading