1832 lines
60 KiB
TypeScript
1832 lines
60 KiB
TypeScript
import { NodeHttpServer } from "@effect/platform-node"
|
|
import { assert, describe, expect, it } from "@effect/vitest"
|
|
import {
|
|
Array,
|
|
Cause,
|
|
Context,
|
|
DateTime,
|
|
Effect,
|
|
Equal,
|
|
FileSystem,
|
|
Layer,
|
|
Redacted,
|
|
Ref,
|
|
Schema,
|
|
SchemaGetter,
|
|
SchemaTransformation,
|
|
Stream,
|
|
Struct
|
|
} from "effect"
|
|
import {
|
|
Cookies,
|
|
HttpBody,
|
|
HttpClient,
|
|
HttpClientRequest,
|
|
type HttpClientResponse,
|
|
HttpRouter,
|
|
HttpServer,
|
|
HttpServerRequest,
|
|
HttpServerResponse,
|
|
Multipart
|
|
} from "effect/unstable/http"
|
|
import {
|
|
HttpApi,
|
|
HttpApiBuilder,
|
|
HttpApiClient,
|
|
HttpApiEndpoint,
|
|
HttpApiError,
|
|
HttpApiGroup,
|
|
HttpApiMiddleware,
|
|
HttpApiSchema,
|
|
HttpApiSecurity,
|
|
HttpApiTest,
|
|
OpenApi
|
|
} from "effect/unstable/httpapi"
|
|
|
|
function* assertServerText(res: HttpClientResponse.HttpClientResponse, status: number, text: string) {
|
|
assert.strictEqual(res.status, status)
|
|
assert.strictEqual(yield* res.text, text)
|
|
}
|
|
|
|
function* assertServerJson(res: HttpClientResponse.HttpClientResponse, status: number, json: unknown) {
|
|
assert.strictEqual(res.status, status)
|
|
assert.deepStrictEqual(yield* res.json, json)
|
|
}
|
|
|
|
function* assertClientText<E, R>(res: Effect.Effect<string, E, R>, text: string) {
|
|
assert.strictEqual(yield* res, text)
|
|
}
|
|
|
|
function* assertClientJson<E, R>(res: Effect.Effect<unknown, E, R>, json: unknown) {
|
|
assert.deepStrictEqual(yield* res, json)
|
|
}
|
|
|
|
function* assertClientError<A, E, R>(res: Effect.Effect<A, E, R>, error: E) {
|
|
assert.ok(Equal.equals(yield* Effect.flip(res), error))
|
|
}
|
|
|
|
describe("HttpApi", () => {
|
|
it.effect("catch all path", () => {
|
|
const Api = HttpApi.make("api")
|
|
.add(
|
|
HttpApiGroup.make("group")
|
|
.add(
|
|
HttpApiEndpoint.get("catchAll", "*", {
|
|
success: Schema.String
|
|
})
|
|
)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) => handlers.handle("catchAll", (ctx) => Effect.succeed(ctx.request.url))
|
|
)
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return Effect.gen(function*() {
|
|
// server side
|
|
yield* assertServerText(yield* HttpClient.get(""), 200, `"/"`)
|
|
yield* assertServerText(yield* HttpClient.get("/"), 200, `"/"`)
|
|
yield* assertServerText(yield* HttpClient.get("/a/b/c"), 200, `"/a/b/c"`)
|
|
|
|
// client side
|
|
const client = yield* HttpApiClient.make(Api)
|
|
yield* assertClientText(client.group.catchAll(), "/*")
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
|
|
describe("middleware", () => {
|
|
it.effect("error", () => {
|
|
class M extends HttpApiMiddleware.Service<M>()("Http/Logger", {
|
|
error: Schema.String
|
|
.pipe(
|
|
HttpApiSchema.status("MethodNotAllowed"),
|
|
HttpApiSchema.asText()
|
|
)
|
|
}) {}
|
|
|
|
const Api = HttpApi.make("api")
|
|
.add(
|
|
HttpApiGroup.make("group")
|
|
.add(
|
|
HttpApiEndpoint.get("a", "/a", {
|
|
success: Schema.Finite
|
|
})
|
|
).middleware(M)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) => handlers.handle("a", () => Effect.succeed(1))
|
|
)
|
|
const MLive = Layer.succeed(
|
|
M,
|
|
() => Effect.fail("error")
|
|
)
|
|
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive), Layer.provide(MLive)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return Effect.gen(function*() {
|
|
// server side
|
|
yield* assertServerText(yield* HttpClient.get("/a"), 405, "error")
|
|
|
|
// client side
|
|
const client = yield* HttpApiClient.make(Api)
|
|
yield* assertClientError(client.group.a(), "error")
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
|
|
it.effect("error array", () => {
|
|
class M extends HttpApiMiddleware.Service<M>()("Http/Auth", {
|
|
error: [HttpApiError.UnauthorizedNoContent, HttpApiError.ForbiddenNoContent]
|
|
}) {}
|
|
|
|
const Api = HttpApi.make("api").add(
|
|
HttpApiGroup.make("group")
|
|
.add(
|
|
HttpApiEndpoint.get("unauthorized", "/unauthorized", {
|
|
success: Schema.String
|
|
}),
|
|
HttpApiEndpoint.get("forbidden", "/forbidden", {
|
|
success: Schema.String
|
|
})
|
|
)
|
|
.middleware(M)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) =>
|
|
handlers
|
|
.handle("unauthorized", () => Effect.succeed("ok"))
|
|
.handle("forbidden", () => Effect.succeed("ok"))
|
|
)
|
|
const MLive = Layer.succeed(
|
|
M,
|
|
(_, { endpoint }) =>
|
|
endpoint.identifier === "unauthorized"
|
|
? Effect.fail(new HttpApiError.Unauthorized({}))
|
|
: Effect.fail(new HttpApiError.Forbidden({}))
|
|
)
|
|
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive), Layer.provide(MLive)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return Effect.gen(function*() {
|
|
yield* assertServerText(yield* HttpClient.get("/unauthorized"), 401, "")
|
|
yield* assertServerText(yield* HttpClient.get("/forbidden"), 403, "")
|
|
|
|
const client = yield* HttpApiClient.make(Api)
|
|
yield* assertClientError(client.group.unauthorized(), new HttpApiError.Unauthorized({}))
|
|
yield* assertClientError(client.group.forbidden(), new HttpApiError.Forbidden({}))
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
|
|
it.effect("client middleware modifies request and receives metadata", () => {
|
|
class M extends HttpApiMiddleware.Service<M>()("Client/Metadata", {
|
|
requiredForClient: true
|
|
}) {}
|
|
|
|
const Api = HttpApi.make("api").add(
|
|
HttpApiGroup.make("group")
|
|
.add(
|
|
HttpApiEndpoint.get("a", "/a", {
|
|
success: Schema.String
|
|
})
|
|
)
|
|
.middleware(M)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) => handlers.handle("a", (ctx) => Effect.succeed(ctx.request.headers["x-client"] ?? "missing"))
|
|
)
|
|
const MLive = Layer.succeed(M, (effect) => effect)
|
|
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive), Layer.provide(MLive)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return Effect.gen(function*() {
|
|
const metadata = yield* Ref.make<{ readonly group: string; readonly endpoint: string } | undefined>(undefined)
|
|
|
|
const MClient = HttpApiMiddleware.layerClient(
|
|
M,
|
|
Effect.fnUntraced(function*({ endpoint, group, next, request }) {
|
|
yield* Ref.set(metadata, { group: group.identifier, endpoint: endpoint.identifier })
|
|
return yield* next(HttpClientRequest.setHeader(request, "x-client", "from-client"))
|
|
})
|
|
)
|
|
|
|
const client = yield* HttpApiClient.make(Api).pipe(
|
|
Effect.provide(MClient)
|
|
)
|
|
assert.strictEqual(yield* client.group.a(), "from-client")
|
|
assert.deepStrictEqual(yield* Ref.get(metadata), {
|
|
group: "group",
|
|
endpoint: "a"
|
|
})
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
|
|
it.effect("client middleware chain order is LIFO", () => {
|
|
class M1 extends HttpApiMiddleware.Service<M1>()("Client/Chain1", {
|
|
requiredForClient: true
|
|
}) {}
|
|
|
|
class M2 extends HttpApiMiddleware.Service<M2>()("Client/Chain2", {
|
|
requiredForClient: true
|
|
}) {}
|
|
|
|
const Api = HttpApi.make("api").add(
|
|
HttpApiGroup.make("group")
|
|
.add(
|
|
HttpApiEndpoint.get("a", "/a", {
|
|
success: Schema.String
|
|
})
|
|
.middleware(M1)
|
|
.middleware(M2)
|
|
)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) =>
|
|
handlers.handle(
|
|
"a",
|
|
(ctx) => Effect.succeed(`${ctx.request.headers["x-m1"] ?? "0"}${ctx.request.headers["x-m2"] ?? "0"}`)
|
|
)
|
|
)
|
|
const M1Live = Layer.succeed(M1, (effect) => effect)
|
|
const M2Live = Layer.succeed(M2, (effect) => effect)
|
|
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive), Layer.provide(M1Live), Layer.provide(M2Live)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return Effect.gen(function*() {
|
|
const order: Array<string> = []
|
|
|
|
const M1Client = HttpApiMiddleware.layerClient(
|
|
M1,
|
|
Effect.fnUntraced(function*({ next, request }) {
|
|
order.push("m1-before")
|
|
const response = yield* next(HttpClientRequest.setHeader(request, "x-m1", "1"))
|
|
order.push("m1-after")
|
|
return response
|
|
})
|
|
)
|
|
|
|
const M2Client = HttpApiMiddleware.layerClient(
|
|
M2,
|
|
Effect.fnUntraced(function*({ next, request }) {
|
|
order.push("m2-before")
|
|
const response = yield* next(HttpClientRequest.setHeader(request, "x-m2", "1"))
|
|
order.push("m2-after")
|
|
return response
|
|
})
|
|
)
|
|
|
|
const client = yield* HttpApiClient.make(Api).pipe(
|
|
Effect.provide([M1Client, M2Client])
|
|
)
|
|
|
|
assert.strictEqual(yield* client.group.a(), "11")
|
|
assert.deepStrictEqual(order, ["m2-before", "m1-before", "m1-after", "m2-after"])
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
|
|
it.effect("required client middleware can fail with typed clientError", () => {
|
|
class ClientFailure extends Schema.ErrorClass<ClientFailure>("ClientFailure")({
|
|
_tag: Schema.tag("ClientFailure")
|
|
}) {}
|
|
|
|
class M extends HttpApiMiddleware.Service<M, {
|
|
clientError: ClientFailure
|
|
}>()("Client/Failure", {
|
|
requiredForClient: true
|
|
}) {}
|
|
|
|
const Api = HttpApi.make("api").add(
|
|
HttpApiGroup.make("group")
|
|
.add(
|
|
HttpApiEndpoint.get("a", "/a", {
|
|
success: Schema.String
|
|
})
|
|
)
|
|
.middleware(M)
|
|
)
|
|
let handled = false
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) =>
|
|
handlers.handle("a", () =>
|
|
Effect.sync(() => {
|
|
handled = true
|
|
return "ok"
|
|
}))
|
|
)
|
|
const MLive = Layer.succeed(M, (effect) => effect)
|
|
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive), Layer.provide(MLive)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return Effect.gen(function*() {
|
|
const MClient = HttpApiMiddleware.layerClient(
|
|
M,
|
|
() => Effect.fail(new ClientFailure({}))
|
|
)
|
|
|
|
const client = yield* HttpApiClient.make(Api).pipe(
|
|
Effect.provide(MClient)
|
|
)
|
|
|
|
yield* assertClientError(client.group.a(), new ClientFailure({}))
|
|
assert.isFalse(handled)
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
|
|
it.effect("optional client middleware is skipped when not provided", () => {
|
|
class M extends HttpApiMiddleware.Service<M>()("Client/Optional") {}
|
|
|
|
const Api = HttpApi.make("api").add(
|
|
HttpApiGroup.make("group")
|
|
.add(
|
|
HttpApiEndpoint.get("a", "/a", {
|
|
success: Schema.String
|
|
})
|
|
)
|
|
.middleware(M)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) => handlers.handle("a", (ctx) => Effect.succeed(ctx.request.headers["x-optional"] ?? "missing"))
|
|
)
|
|
const MLive = Layer.succeed(M, (effect) => effect)
|
|
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive), Layer.provide(MLive)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return Effect.gen(function*() {
|
|
const client = yield* HttpApiClient.make(Api)
|
|
assert.strictEqual(yield* client.group.a(), "missing")
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
|
|
it.effect("layerClient supports effectful construction", () => {
|
|
class HeaderValue extends Context.Service<HeaderValue, string>()("HeaderValue") {}
|
|
|
|
class M extends HttpApiMiddleware.Service<M>()("Client/Effectful", {
|
|
requiredForClient: true
|
|
}) {}
|
|
|
|
const Api = HttpApi.make("api").add(
|
|
HttpApiGroup.make("group")
|
|
.add(
|
|
HttpApiEndpoint.get("a", "/a", {
|
|
success: Schema.String
|
|
})
|
|
)
|
|
.middleware(M)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) => handlers.handle("a", (ctx) => Effect.succeed(ctx.request.headers["x-effect"] ?? "missing"))
|
|
)
|
|
const MLive = Layer.succeed(M, (effect) => effect)
|
|
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive), Layer.provide(MLive)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return Effect.gen(function*() {
|
|
const MClient = HttpApiMiddleware.layerClient(
|
|
M,
|
|
Effect.gen(function*() {
|
|
const header = yield* HeaderValue
|
|
return ({ next, request }) => next(HttpClientRequest.setHeader(request, "x-effect", header))
|
|
})
|
|
)
|
|
|
|
const client = yield* HttpApiClient.make(Api).pipe(
|
|
Effect.provide(MClient),
|
|
Effect.provideService(HeaderValue, "from-effect")
|
|
)
|
|
|
|
assert.strictEqual(yield* client.group.a(), "from-effect")
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
|
|
it.effect("security middleware can be implemented with layerClient", () => {
|
|
class CurrentToken extends Context.Service<CurrentToken, string>()("CurrentToken") {}
|
|
|
|
class M extends HttpApiMiddleware.Service<M, {
|
|
provides: CurrentToken
|
|
}>()("Client/Security", {
|
|
security: {
|
|
apiKey: HttpApiSecurity.apiKey({
|
|
in: "header",
|
|
key: "authorization"
|
|
})
|
|
}
|
|
}) {}
|
|
|
|
const Api = HttpApi.make("api").add(
|
|
HttpApiGroup.make("group")
|
|
.add(HttpApiEndpoint.get("a", "/a", {
|
|
success: Schema.String
|
|
}))
|
|
.middleware(M)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) => handlers.handle("a", () => CurrentToken)
|
|
)
|
|
const MLive = Layer.succeed(M)({
|
|
apiKey: (effect, options) => Effect.provideService(effect, CurrentToken, Redacted.value(options.credential))
|
|
})
|
|
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive), Layer.provide(MLive)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return Effect.gen(function*() {
|
|
const client = yield* HttpApiClient.make(Api)
|
|
assert.strictEqual(yield* client.group.a(), "")
|
|
|
|
const MClient = HttpApiMiddleware.layerClient(
|
|
M,
|
|
({ next, request }) => next(HttpClientRequest.setHeader(request, "authorization", "token"))
|
|
)
|
|
const authedClient = yield* HttpApiClient.make(Api).pipe(Effect.provide(MClient))
|
|
|
|
assert.strictEqual(yield* authedClient.group.a(), "token")
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
|
|
it("security middleware cache does not reuse the first service impl", async () => {
|
|
class CurrentMarker extends Context.Service<CurrentMarker, string>()("CurrentMarker") {}
|
|
|
|
class M extends HttpApiMiddleware.Service<M, {
|
|
provides: CurrentMarker
|
|
}>()("Server/Security/Repro", {
|
|
security: {
|
|
apiKey: HttpApiSecurity.apiKey({
|
|
in: "header",
|
|
key: "authorization"
|
|
})
|
|
}
|
|
}) {}
|
|
|
|
const Api = HttpApi.make("api").add(
|
|
HttpApiGroup.make("group")
|
|
.add(HttpApiEndpoint.get("a", "/a", {
|
|
success: Schema.Struct({
|
|
marker: Schema.String
|
|
})
|
|
}))
|
|
.middleware(M)
|
|
)
|
|
|
|
const makeWebHandler = (marker: string) => {
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) => handlers.handle("a", () => Effect.map(CurrentMarker, (marker) => ({ marker })))
|
|
)
|
|
const MLive = Layer.succeed(M)({
|
|
apiKey: (effect) => Effect.provideService(effect, CurrentMarker, marker)
|
|
})
|
|
|
|
return HttpRouter.toWebHandler(
|
|
Layer.mergeAll(
|
|
HttpApiBuilder.layer(Api).pipe(
|
|
Layer.provide(GroupLive),
|
|
Layer.provide(MLive),
|
|
Layer.provide(HttpServer.layerServices)
|
|
)
|
|
),
|
|
{ disableLogger: true }
|
|
)
|
|
}
|
|
|
|
const first = makeWebHandler("first")
|
|
const second = makeWebHandler("second")
|
|
|
|
try {
|
|
const firstResponse = await first.handler(
|
|
new Request("http://localhost/a", {
|
|
headers: {
|
|
authorization: "token"
|
|
}
|
|
})
|
|
)
|
|
assert.strictEqual(firstResponse.status, 200)
|
|
assert.deepStrictEqual(await firstResponse.json(), { marker: "first" })
|
|
|
|
const secondResponse = await second.handler(
|
|
new Request("http://localhost/a", {
|
|
headers: {
|
|
authorization: "token"
|
|
}
|
|
})
|
|
)
|
|
assert.strictEqual(secondResponse.status, 200)
|
|
assert.deepStrictEqual(await secondResponse.json(), { marker: "second" })
|
|
} finally {
|
|
await first.dispose()
|
|
await second.dispose()
|
|
}
|
|
})
|
|
|
|
it.effect("addHttpApi + middleware works across merged groups", () => {
|
|
class M1 extends HttpApiMiddleware.Service<M1>()("Http/M1") {}
|
|
class M2 extends HttpApiMiddleware.Service<M2>()("Http/M2") {}
|
|
|
|
const calls: Array<string> = []
|
|
|
|
const V0 = HttpApi.make("v0").add(
|
|
HttpApiGroup.make("users").add(
|
|
HttpApiEndpoint.get("list", "/users", {
|
|
success: Schema.String
|
|
})
|
|
)
|
|
)
|
|
const Api = HttpApi.make("api")
|
|
.add(
|
|
HttpApiGroup.make("health").add(
|
|
HttpApiEndpoint.get("health", "/health", {
|
|
success: Schema.String
|
|
})
|
|
)
|
|
)
|
|
.addHttpApi(V0)
|
|
.middleware(M1)
|
|
.middleware(M2)
|
|
|
|
const HealthLive = HttpApiBuilder.group(
|
|
Api,
|
|
"health",
|
|
(handlers) => handlers.handle("health", () => Effect.succeed("ok"))
|
|
)
|
|
const UsersLive = HttpApiBuilder.group(
|
|
Api,
|
|
"users",
|
|
(handlers) => handlers.handle("list", () => Effect.succeed("ok"))
|
|
)
|
|
const M1Live = Layer.succeed(
|
|
M1,
|
|
(effect, { endpoint, group }) =>
|
|
Effect.sync(() => calls.push(`m1:${group.identifier}.${endpoint.identifier}`)).pipe(
|
|
Effect.flatMap(() => effect)
|
|
)
|
|
)
|
|
const M2Live = Layer.succeed(
|
|
M2,
|
|
(effect, { endpoint, group }) =>
|
|
Effect.sync(() => calls.push(`m2:${group.identifier}.${endpoint.identifier}`)).pipe(
|
|
Effect.flatMap(() => effect)
|
|
)
|
|
)
|
|
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(
|
|
Layer.provide(HealthLive),
|
|
Layer.provide(UsersLive),
|
|
Layer.provide(M1Live),
|
|
Layer.provide(M2Live)
|
|
),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return Effect.gen(function*() {
|
|
yield* assertServerJson(yield* HttpClient.get("/health"), 200, "ok")
|
|
yield* assertServerJson(yield* HttpClient.get("/users"), 200, "ok")
|
|
assert.deepStrictEqual(calls, [
|
|
"m2:health.health",
|
|
"m1:health.health",
|
|
"m2:users.list",
|
|
"m1:users.list"
|
|
])
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
|
|
it.effect("missing middleware layer fails with service not found error", () => {
|
|
class M extends HttpApiMiddleware.Service<M>()("Server/MissingMiddleware") {}
|
|
|
|
const Api = HttpApi.make("api").add(
|
|
HttpApiGroup.make("group")
|
|
.add(
|
|
HttpApiEndpoint.get("a", "/a", {
|
|
success: Schema.String
|
|
})
|
|
)
|
|
.middleware(M)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) => handlers.handle("a", () => Effect.succeed("ok"))
|
|
)
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return HttpClient.get("/a").pipe(
|
|
Effect.provide(ApiLive),
|
|
Effect.sandbox,
|
|
Effect.flip,
|
|
Effect.flatMap((cause) =>
|
|
Effect.sync(() => {
|
|
const defect = Cause.squash(cause)
|
|
assert.instanceOf(defect, Error)
|
|
assert.include(defect.message, "Service not found: Server/MissingMiddleware")
|
|
assert.isFalse(defect.message.includes("is not a function"))
|
|
})
|
|
)
|
|
) as Effect.Effect<void, HttpClientResponse.HttpClientResponse>
|
|
})
|
|
})
|
|
|
|
it.effect("missing addHttpApi group layer has actionable error", () => {
|
|
const HealthApi = HttpApiGroup.make("health").add(
|
|
HttpApiEndpoint.get("health", "/health", {
|
|
success: Schema.String
|
|
})
|
|
)
|
|
const V0 = HttpApi.make("v0").add(
|
|
HttpApiGroup.make("users").add(
|
|
HttpApiEndpoint.get("list", "/users", {
|
|
success: Schema.String
|
|
})
|
|
)
|
|
)
|
|
const Api = HttpApi.make("api")
|
|
.add(HealthApi)
|
|
.addHttpApi(V0)
|
|
|
|
const UsersLive = HttpApiBuilder.group(
|
|
Api,
|
|
"users",
|
|
(handlers) => handlers.handle("list", () => Effect.succeed("ok"))
|
|
)
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(UsersLive)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return HttpClient.get("/users").pipe(
|
|
Effect.provide(ApiLive),
|
|
Effect.sandbox,
|
|
Effect.flip,
|
|
Effect.flatMap((cause) =>
|
|
Effect.sync(() => {
|
|
const defect = Cause.squash(cause)
|
|
assert.strictEqual(typeof defect, "string")
|
|
assert.include(defect, "HttpApiGroup \"health\" not found")
|
|
assert.include(defect, "HttpApiBuilder.group(api, \"health\", ...)")
|
|
assert.include(defect, "Available groups: effect/httpapi/HttpApiGroup/users")
|
|
})
|
|
)
|
|
) as Effect.Effect<void, HttpClientResponse.HttpClientResponse>
|
|
})
|
|
|
|
describe("payload option", () => {
|
|
describe("GET", () => {
|
|
it.effect("query parameters", () => {
|
|
const Api = HttpApi.make("api")
|
|
.add(
|
|
HttpApiGroup.make("group")
|
|
.add(
|
|
HttpApiEndpoint.get("a", "/a", {
|
|
payload: {
|
|
required: Schema.FiniteFromString,
|
|
optionalKey: Schema.optionalKey(Schema.FiniteFromString),
|
|
optional: Schema.optional(Schema.FiniteFromString)
|
|
},
|
|
success: Schema.Any
|
|
})
|
|
)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) => handlers.handle("a", (ctx) => Effect.succeed(ctx.payload))
|
|
)
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return Effect.gen(function*() {
|
|
// server side
|
|
yield* assertServerText(yield* HttpClient.get("/a"), 400, "")
|
|
yield* assertServerJson(yield* HttpClient.get("/a?required=1"), 200, { required: 1 })
|
|
yield* assertServerJson(yield* HttpClient.get("/a?required=1&optionalKey=1"), 200, {
|
|
required: 1,
|
|
optionalKey: 1
|
|
})
|
|
yield* assertServerJson(yield* HttpClient.get("/a?required=1&optional=1"), 200, { required: 1, optional: 1 })
|
|
yield* assertServerJson(yield* HttpClient.get("/a?required=1"), 200, { required: 1 })
|
|
// TODO: fix this test
|
|
yield* assertServerJson(yield* HttpClient.get("/a?required=1&optional="), 200, { required: 1, optional: 0 })
|
|
|
|
// client side
|
|
const client = yield* HttpApiClient.make(Api)
|
|
yield* assertClientJson(client.group.a({ payload: { required: 1 } }), { required: 1 })
|
|
yield* assertClientJson(client.group.a({ payload: { required: 1, optionalKey: 1 } }), {
|
|
required: 1,
|
|
optionalKey: 1
|
|
})
|
|
yield* assertClientJson(client.group.a({ payload: { required: 1, optional: 1 } }), {
|
|
required: 1,
|
|
optional: 1
|
|
})
|
|
yield* assertClientJson(client.group.a({ payload: { required: 1, optional: undefined } }), { required: 1 })
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
})
|
|
|
|
it.effect("array of schemas with different encodings", () => {
|
|
const Api = HttpApi.make("api").add(
|
|
HttpApiGroup.make("group").add(
|
|
HttpApiEndpoint.post("a", "/a", {
|
|
payload: [
|
|
Schema.Struct({ a: Schema.String }), // application/json
|
|
Schema.String.pipe(HttpApiSchema.asText()), // text/plain
|
|
Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()) // application/octet-stream
|
|
],
|
|
success: Schema.Any
|
|
})
|
|
)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) => handlers.handle("a", (ctx) => Effect.succeed(ctx.payload))
|
|
)
|
|
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return Effect.gen(function*() {
|
|
// server side
|
|
yield* assertServerText(yield* HttpClient.post("/a"), 400, "")
|
|
yield* assertServerJson(yield* HttpClient.post("/a", { body: HttpBody.jsonUnsafe({ a: "text" }) }), 200, {
|
|
a: "text"
|
|
})
|
|
yield* assertServerJson(yield* HttpClient.post("/a", { body: HttpBody.text("text") }), 200, "text")
|
|
yield* assertServerJson(
|
|
yield* HttpClient.post("/a", { body: HttpBody.uint8Array(new Uint8Array([1, 2, 3])) }),
|
|
200,
|
|
{ 0: 1, 1: 2, 2: 3 }
|
|
)
|
|
|
|
// client side
|
|
const client = yield* HttpApiClient.make(Api)
|
|
yield* assertClientJson(client.group.a({ payload: { a: "text" } }), { a: "text" })
|
|
yield* assertClientJson(client.group.a({ payload: "text" }), "text")
|
|
yield* assertClientJson(client.group.a({ payload: new Uint8Array([1, 2, 3]) }), { 0: 1, 1: 2, 2: 3 })
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
})
|
|
|
|
describe("params option", () => {
|
|
it.effect("should accept a record of schemas", () => {
|
|
const Api = HttpApi.make("api").add(
|
|
HttpApiGroup.make("group").add(
|
|
HttpApiEndpoint.get("get", "/:id", {
|
|
params: {
|
|
id: Schema.FiniteFromString
|
|
},
|
|
success: Schema.String
|
|
})
|
|
)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) => handlers.handle("get", (ctx) => Effect.succeed(`User ${ctx.params.id}`))
|
|
)
|
|
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return Effect.gen(function*() {
|
|
// server side
|
|
yield* assertServerJson(yield* HttpClient.get("/1"), 200, "User 1")
|
|
|
|
// client side
|
|
const client = yield* HttpApiClient.make(Api)
|
|
yield* assertClientJson(client.group.get({ params: { id: 1 } }), "User 1")
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
})
|
|
|
|
describe("query option", () => {
|
|
it.effect("should accept a record of schemas", () => {
|
|
const Api = HttpApi.make("api").add(
|
|
HttpApiGroup.make("group").add(
|
|
HttpApiEndpoint.get("get", "/", {
|
|
query: {
|
|
id: Schema.FiniteFromString
|
|
},
|
|
success: Schema.String
|
|
})
|
|
)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) => handlers.handle("get", (ctx) => Effect.succeed(`User ${ctx.query.id}`))
|
|
)
|
|
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return Effect.gen(function*() {
|
|
// server side
|
|
yield* assertServerJson(yield* HttpClient.get("/?id=1"), 200, "User 1")
|
|
|
|
// client side
|
|
const client = yield* HttpApiClient.make(Api)
|
|
yield* assertClientJson(client.group.get({ query: { id: 1 } }), "User 1")
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
})
|
|
|
|
describe("success option", () => {
|
|
it.effect("no content", () => {
|
|
const Api = HttpApi.make("api")
|
|
.add(
|
|
HttpApiGroup.make("group")
|
|
.add(
|
|
HttpApiEndpoint.get("a", "/a", {
|
|
success: Schema.Void
|
|
}),
|
|
HttpApiEndpoint.get("b", "/b", {
|
|
success: HttpApiSchema.NoContent
|
|
}),
|
|
HttpApiEndpoint.get("c", "/c", {
|
|
success: Schema.String.pipe(HttpApiSchema.asNoContent({ decode: () => "c" }))
|
|
})
|
|
)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) =>
|
|
handlers
|
|
.handle("a", () => Effect.void)
|
|
.handle("b", () => Effect.succeed(HttpApiSchema.NoContent.make()))
|
|
.handle("c", () => Effect.succeed("-"))
|
|
)
|
|
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return Effect.gen(function*() {
|
|
// server side
|
|
yield* assertServerText(yield* HttpClient.get("/a"), 200, "")
|
|
yield* assertServerText(yield* HttpClient.get("/b"), 204, "")
|
|
yield* assertServerText(yield* HttpClient.get("/c"), 200, "")
|
|
|
|
// client side
|
|
const client = yield* HttpApiClient.make(Api)
|
|
yield* assertClientJson(client.group.a(), undefined)
|
|
yield* assertClientJson(client.group.b(), undefined)
|
|
yield* assertClientJson(client.group.c(), "c")
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
|
|
it("no content via toWebHandler", async () => {
|
|
const Api = HttpApi.make("api")
|
|
.add(
|
|
HttpApiGroup.make("group")
|
|
.add(HttpApiEndpoint.delete("remove", "/items/:id", { params: { id: Schema.String } }))
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) => handlers.handle("remove", () => Effect.void)
|
|
)
|
|
const ApiLive = HttpApiBuilder.layer(Api).pipe(
|
|
Layer.provide(GroupLive),
|
|
Layer.provide(HttpServer.layerServices)
|
|
)
|
|
const { handler, dispose } = HttpRouter.toWebHandler(
|
|
Layer.mergeAll(ApiLive),
|
|
{ disableLogger: true }
|
|
)
|
|
try {
|
|
const response = await handler(new Request("http://localhost/items/123", { method: "DELETE" }))
|
|
assert.strictEqual(response.status, 204)
|
|
assert.strictEqual(response.body, null)
|
|
} finally {
|
|
await dispose()
|
|
}
|
|
})
|
|
|
|
describe("encodings", () => {
|
|
describe("asJson", () => {
|
|
it.effect("custom contentType", () => {
|
|
const Api = HttpApi.make("api").add(
|
|
HttpApiGroup.make("group").add(
|
|
HttpApiEndpoint.get("a", "/a", {
|
|
success: Schema.String.pipe(HttpApiSchema.asJson({ contentType: "application/scim+json" }))
|
|
})
|
|
)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) => handlers.handle("a", () => Effect.succeed("a"))
|
|
)
|
|
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return Effect.gen(function*() {
|
|
// server side
|
|
const res = yield* HttpClient.get("/a")
|
|
yield* assertServerJson(res, 200, "a")
|
|
assert.strictEqual(res.headers["content-type"], "application/scim+json")
|
|
|
|
// client side
|
|
const client = yield* HttpApiClient.make(Api)
|
|
yield* assertClientJson(client.group.a(), "a")
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("error option", () => {
|
|
it.effect("no content", () => {
|
|
const Api = HttpApi.make("api")
|
|
.add(
|
|
HttpApiGroup.make("group")
|
|
.add(
|
|
HttpApiEndpoint.get("a", "/a", {
|
|
error: Schema.Void.pipe(HttpApiSchema.status("Forbidden"))
|
|
}),
|
|
HttpApiEndpoint.get("b", "/b", {
|
|
error: HttpApiSchema.NoContent,
|
|
success: Schema.String
|
|
}),
|
|
HttpApiEndpoint.get("c", "/c", {
|
|
error: Schema.String.pipe(
|
|
HttpApiSchema.asNoContent({ decode: () => "c" }),
|
|
HttpApiSchema.status("Forbidden")
|
|
)
|
|
}),
|
|
HttpApiEndpoint.get("d", "/d", {
|
|
error: HttpApiSchema.Empty(400)
|
|
}),
|
|
HttpApiEndpoint.get("e", "/e", {
|
|
error: HttpApiError.UnauthorizedNoContent
|
|
}),
|
|
HttpApiEndpoint.get("f", "/f", {
|
|
error: HttpApiError.BadRequestNoContent
|
|
})
|
|
)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) =>
|
|
handlers
|
|
.handle("a", () => Effect.fail(undefined))
|
|
.handle("b", () => Effect.fail(HttpApiSchema.NoContent.make()))
|
|
.handle("c", () => Effect.fail(""))
|
|
.handle("d", () => Effect.fail(void 0))
|
|
.handle("e", () => Effect.fail(new HttpApiError.Unauthorized({})))
|
|
.handle("f", () => Effect.fail(new HttpApiError.BadRequest({})))
|
|
)
|
|
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return Effect.gen(function*() {
|
|
// server side
|
|
yield* assertServerText(yield* HttpClient.get("/a"), 403, "")
|
|
yield* assertServerText(yield* HttpClient.get("/b"), 204, "")
|
|
yield* assertServerText(yield* HttpClient.get("/c"), 403, "")
|
|
yield* assertServerText(yield* HttpClient.get("/d"), 400, "")
|
|
yield* assertServerText(yield* HttpClient.get("/e"), 401, "")
|
|
yield* assertServerText(yield* HttpClient.get("/f"), 400, "")
|
|
|
|
// client side
|
|
const client = yield* HttpApiClient.make(Api)
|
|
yield* assertClientError(client.group.a(), undefined)
|
|
yield* assertClientError(client.group.b(), undefined)
|
|
yield* assertClientError(client.group.c(), "c")
|
|
yield* assertClientError(client.group.d(), undefined)
|
|
yield* assertClientError(client.group.e(), new HttpApiError.Unauthorized({}))
|
|
yield* assertClientError(client.group.f(), new HttpApiError.BadRequest({}))
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
})
|
|
|
|
describe("original tests", () => {
|
|
describe("payload", () => {
|
|
it.effect("is decoded / encoded", () =>
|
|
Effect.gen(function*() {
|
|
const expected = new User({
|
|
id: 123,
|
|
name: "Joe",
|
|
createdAt: DateTime.makeUnsafe(0)
|
|
})
|
|
const client = yield* HttpApiClient.make(Api)
|
|
const clientUsersGroup = yield* HttpApiClient.group(Api, {
|
|
httpClient: yield* HttpClient.HttpClient,
|
|
group: "users"
|
|
})
|
|
const clientUsersEndpointCreate = yield* HttpApiClient.endpoint(Api, {
|
|
httpClient: yield* HttpClient.HttpClient,
|
|
group: "users",
|
|
endpoint: "create"
|
|
})
|
|
|
|
const apiClientUser = yield* client.users.create({
|
|
query: { id: 123 },
|
|
payload: { name: "Joe" }
|
|
})
|
|
assert.deepStrictEqual(
|
|
apiClientUser,
|
|
expected
|
|
)
|
|
const groupClientUser = yield* clientUsersGroup.create({
|
|
query: { id: 123 },
|
|
payload: { name: "Joe" }
|
|
})
|
|
assert.deepStrictEqual(
|
|
groupClientUser,
|
|
expected
|
|
)
|
|
const endpointClientUser = yield* clientUsersEndpointCreate({
|
|
query: { id: 123 },
|
|
payload: { name: "Joe" }
|
|
})
|
|
assert.deepStrictEqual(
|
|
endpointClientUser,
|
|
expected
|
|
)
|
|
}).pipe(Effect.provide(HttpLive)))
|
|
|
|
it.live("multipart", () =>
|
|
Effect.gen(function*() {
|
|
const client = yield* HttpApiClient.make(Api)
|
|
const data = new FormData()
|
|
data.append("file", new Blob(["hello"], { type: "text/plain" }), "hello.txt")
|
|
const result = yield* client.users.upload({ payload: data, params: {} })
|
|
assert.deepStrictEqual(result, {
|
|
contentType: "text/plain",
|
|
length: 5
|
|
})
|
|
}).pipe(Effect.provide(HttpLive)))
|
|
|
|
it.live("multipart stream", () =>
|
|
Effect.gen(function*() {
|
|
const client = yield* HttpApiClient.make(Api)
|
|
const data = new FormData()
|
|
data.append("file", new Blob(["hello"], { type: "text/plain" }), "hello.txt")
|
|
const result = yield* client.users.uploadStream({ payload: data })
|
|
assert.deepStrictEqual(result, {
|
|
contentType: "text/plain",
|
|
length: 5
|
|
})
|
|
}).pipe(Effect.provide(HttpLive)))
|
|
})
|
|
|
|
describe("headers", () => {
|
|
it.effect("should accept a record of schemas", () => {
|
|
const Api = HttpApi.make("api").add(
|
|
HttpApiGroup.make("group").add(
|
|
HttpApiEndpoint.get("get", "/:id", {
|
|
headers: {
|
|
id: Schema.FiniteFromString
|
|
},
|
|
success: Schema.String
|
|
})
|
|
)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) => handlers.handle("get", (ctx) => Effect.succeed(`User ${ctx.headers.id}`))
|
|
)
|
|
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return Effect.gen(function*() {
|
|
const client = yield* HttpApiClient.make(Api)
|
|
const result = yield* client.group.get({ headers: { id: 1 } })
|
|
assert.strictEqual(result, "User 1")
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
|
|
it.effect("is decoded / encoded", () =>
|
|
Effect.gen(function*() {
|
|
const client = yield* HttpApiClient.make(Api)
|
|
const users = yield* client.users.list({
|
|
headers: { page: 1 },
|
|
query: {}
|
|
})
|
|
const user = users[0]
|
|
assert.deepStrictEqual(
|
|
user,
|
|
new User({
|
|
id: 1,
|
|
name: "page 1",
|
|
createdAt: DateTime.makeUnsafe(0)
|
|
})
|
|
)
|
|
}).pipe(Effect.provide(HttpLive)))
|
|
})
|
|
|
|
describe("errors", () => {
|
|
it.effect("empty errors have no body", () =>
|
|
Effect.gen(function*() {
|
|
const response = yield* HttpClient.get("/groups/0")
|
|
assert.strictEqual(response.status, 418)
|
|
const text = yield* response.text
|
|
assert.strictEqual(text, "")
|
|
}).pipe(Effect.provide(HttpLive)))
|
|
|
|
it.effect("empty errors decode", () =>
|
|
Effect.gen(function*() {
|
|
const client = yield* HttpApiClient.make(Api)
|
|
const error = yield* client.groups.findById({ params: { id: 0 } }).pipe(
|
|
Effect.flip
|
|
)
|
|
assert.deepStrictEqual(error, new GroupError({}))
|
|
}).pipe(Effect.provide(HttpLive)))
|
|
|
|
it.effect("default to 500 status code", () =>
|
|
Effect.gen(function*() {
|
|
const response = yield* HttpClientRequest.get("/users").pipe(
|
|
HttpClientRequest.setHeaders({ page: "0" }),
|
|
HttpClient.execute
|
|
)
|
|
assert.strictEqual(response.status, 500)
|
|
const body = yield* response.json
|
|
assert.deepStrictEqual(body, {
|
|
_tag: "NoStatusError"
|
|
})
|
|
}).pipe(Effect.provide(HttpLive)))
|
|
|
|
it.effect("class level annotations", () =>
|
|
Effect.gen(function*() {
|
|
const response = yield* HttpClientRequest.post("/users").pipe(
|
|
HttpClientRequest.setUrlParams({ id: "0" }),
|
|
HttpClientRequest.bodyJsonUnsafe({ name: "boom" }),
|
|
HttpClient.execute
|
|
)
|
|
assert.strictEqual(response.status, 400)
|
|
}).pipe(Effect.provide(HttpLive)))
|
|
|
|
it.effect("BadRequest", () =>
|
|
Effect.gen(function*() {
|
|
const client = yield* HttpApiClient.make(Api)
|
|
const error = yield* client.users.upload({ params: {}, payload: new FormData() }).pipe(
|
|
Effect.flip
|
|
)
|
|
assert(error._tag === "HttpClientError" && error.reason._tag === "DecodeError")
|
|
assert.strictEqual(error.reason.response.status, 400)
|
|
}).pipe(Effect.provide(HttpLive)))
|
|
})
|
|
|
|
it.effect("handler level context", () =>
|
|
Effect.gen(function*() {
|
|
const client = yield* HttpApiClient.make(Api)
|
|
const users = yield* client.users.list({ headers: { page: 1 }, query: {} })
|
|
const user = users[0]
|
|
assert.strictEqual(user.name, "page 1")
|
|
assert.deepStrictEqual(user.createdAt, DateTime.makeUnsafe(0))
|
|
}).pipe(Effect.provide(HttpLive)))
|
|
|
|
it.effect("custom client context", () =>
|
|
Effect.gen(function*() {
|
|
let tapped = false
|
|
const client = yield* HttpApiClient.makeWith(Api, {
|
|
httpClient: (yield* HttpClient.HttpClient).pipe(
|
|
HttpClient.tapRequest(Effect.fnUntraced(function*(_request) {
|
|
tapped = true
|
|
yield* CurrentUser
|
|
}))
|
|
)
|
|
})
|
|
const users = yield* client.users.list({ headers: { page: 1 }, query: {} }).pipe(
|
|
Effect.provideService(
|
|
CurrentUser,
|
|
new User({
|
|
id: 1,
|
|
name: "foo",
|
|
createdAt: DateTime.makeUnsafe(0)
|
|
})
|
|
)
|
|
)
|
|
const user = users[0]
|
|
assert.strictEqual(user.name, "page 1")
|
|
assert.isTrue(tapped)
|
|
}).pipe(Effect.provide(HttpLive)))
|
|
|
|
describe("security", () => {
|
|
it.effect("security middleware sets current user", () =>
|
|
Effect.gen(function*() {
|
|
const ref = yield* Ref.make(Cookies.empty.pipe(
|
|
Cookies.setUnsafe("token", "foo")
|
|
))
|
|
const client = yield* HttpApiClient.makeWith(Api, {
|
|
httpClient: HttpClient.withCookiesRef(yield* HttpClient.HttpClient, ref)
|
|
})
|
|
const user = yield* client.users.findById({ params: { id: -1 } })
|
|
assert.strictEqual(user.name, "foo")
|
|
}).pipe(Effect.provide(HttpLive)))
|
|
|
|
it.effect("apiKey header security", () =>
|
|
Effect.gen(function*() {
|
|
const decode = HttpApiBuilder.securityDecode(securityHeader).pipe(
|
|
Effect.provideService(
|
|
HttpServerRequest.HttpServerRequest,
|
|
HttpServerRequest.fromWeb(
|
|
new Request("http://localhost:3000/", {
|
|
headers: {
|
|
"x-api-key": "foo"
|
|
}
|
|
})
|
|
)
|
|
),
|
|
Effect.provideService(HttpServerRequest.ParsedSearchParams, {})
|
|
)
|
|
const redacted = yield* decode
|
|
assert.strictEqual(Redacted.value(redacted), "foo")
|
|
}).pipe(Effect.provide(HttpLive)))
|
|
|
|
it.effect("apiKey query security", () =>
|
|
Effect.gen(function*() {
|
|
const redacted = yield* HttpApiBuilder.securityDecode(securityQuery).pipe(
|
|
Effect.provideService(
|
|
HttpServerRequest.HttpServerRequest,
|
|
HttpServerRequest.fromWeb(new Request("http://localhost:3000/"))
|
|
),
|
|
Effect.provideService(HttpServerRequest.ParsedSearchParams, {
|
|
api_key: "foo"
|
|
})
|
|
)
|
|
assert.strictEqual(Redacted.value(redacted), "foo")
|
|
}).pipe(Effect.provide(HttpLive)))
|
|
})
|
|
|
|
it.effect("client responseMode decoded-and-response", () =>
|
|
Effect.gen(function*() {
|
|
const client = yield* HttpApiClient.make(Api)
|
|
const [users, response] = yield* client.users.list({
|
|
headers: { page: 1 },
|
|
query: {},
|
|
responseMode: "decoded-and-response"
|
|
})
|
|
assert.strictEqual(users[0].name, "page 1")
|
|
assert.strictEqual(response.status, 200)
|
|
}).pipe(Effect.provide(HttpLive)))
|
|
|
|
it.effect("client responseMode response-only skips decoding", () => {
|
|
const Api = HttpApi.make("api").add(
|
|
HttpApiGroup.make("group").add(
|
|
HttpApiEndpoint.get("bad", "/bad", {
|
|
success: Schema.Struct({
|
|
value: Schema.Finite
|
|
})
|
|
})
|
|
)
|
|
)
|
|
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) => handlers.handleRaw("bad", () => Effect.succeed(HttpServerResponse.text("not-json")))
|
|
)
|
|
|
|
const ApiLive = HttpRouter.serve(
|
|
HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive)),
|
|
{ disableListenLog: true, disableLogger: true }
|
|
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
|
|
|
return Effect.gen(function*() {
|
|
const client = yield* HttpApiClient.make(Api)
|
|
|
|
yield* Effect.flip(client.group.bad())
|
|
|
|
const response = yield* client.group.bad({
|
|
responseMode: "response-only"
|
|
})
|
|
assert.strictEqual(response.status, 200)
|
|
assert.strictEqual(yield* response.text, "not-json")
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
|
|
it.effect("multiple payload types", () =>
|
|
Effect.gen(function*() {
|
|
const client = yield* HttpApiClient.make(Api)
|
|
let [group, response] = yield* client.groups.create({
|
|
payload: { name: "Some group" },
|
|
responseMode: "decoded-and-response"
|
|
})
|
|
assert.deepStrictEqual(group, new Group({ id: 1, name: "Some group" }))
|
|
assert.strictEqual(response.status, 200)
|
|
|
|
const data = new FormData()
|
|
data.set("name", "Some group")
|
|
;[group, response] = yield* client.groups.create({
|
|
payload: data,
|
|
responseMode: "decoded-and-response"
|
|
})
|
|
assert.deepStrictEqual(group, new Group({ id: 1, name: "Some group" }))
|
|
assert.strictEqual(response.status, 200)
|
|
|
|
group = yield* client.groups.create({
|
|
payload: { foo: "Some group" }
|
|
})
|
|
assert.deepStrictEqual(group, new Group({ id: 1, name: "Some group" }))
|
|
}).pipe(Effect.provide(HttpLive)))
|
|
|
|
it.effect(".handle can return HttpServerResponse", () =>
|
|
Effect.gen(function*() {
|
|
const client = yield* HttpApiClient.make(Api)
|
|
const response = yield* client.groups.handle({
|
|
params: { id: 1 },
|
|
payload: { name: "Some group" }
|
|
})
|
|
assert.deepStrictEqual(response, {
|
|
id: 1,
|
|
name: "Some group"
|
|
})
|
|
}).pipe(Effect.provide(HttpLive)))
|
|
|
|
it.effect(".handleRaw can manually process body", () =>
|
|
Effect.gen(function*() {
|
|
const client = yield* HttpApiClient.make(Api)
|
|
const response = yield* client.groups.handleRaw({
|
|
params: { id: 1 },
|
|
payload: { name: "Some group" }
|
|
})
|
|
assert.deepStrictEqual(response, {
|
|
id: 1,
|
|
name: "Some group"
|
|
})
|
|
}).pipe(Effect.provide(HttpLive)))
|
|
|
|
describe("OpenAPI spec", () => {
|
|
it("fixture", () => {
|
|
const spec = OpenApi.fromApi(Api)
|
|
expect(spec).toMatchSnapshot()
|
|
})
|
|
})
|
|
|
|
it.effect("error from plain text", () => {
|
|
class RateLimitError extends Schema.ErrorClass<RateLimitError>("RateLimitError")({
|
|
_tag: Schema.tag("RateLimitError"),
|
|
message: Schema.String
|
|
}) {}
|
|
|
|
const RateLimitErrorSchema = Schema.String.pipe(
|
|
Schema.decodeTo(
|
|
RateLimitError,
|
|
SchemaTransformation.transform({
|
|
encode: ({ message }) => message,
|
|
decode: (message) => new RateLimitError({ message })
|
|
})
|
|
),
|
|
HttpApiSchema.status("TooManyRequests"),
|
|
HttpApiSchema.asText()
|
|
)
|
|
|
|
const Api = HttpApi.make("api").add(
|
|
HttpApiGroup.make("group").add(
|
|
HttpApiEndpoint.get("error", "/error", {
|
|
error: RateLimitErrorSchema
|
|
})
|
|
)
|
|
)
|
|
const ApiLive = HttpApiBuilder.layer(Api).pipe(
|
|
Layer.provide(
|
|
HttpApiBuilder.group(
|
|
Api,
|
|
"group",
|
|
(handlers) => handlers.handle("error", () => new RateLimitError({ message: "Rate limit exceeded" }))
|
|
)
|
|
),
|
|
HttpRouter.serve,
|
|
Layer.provideMerge(NodeHttpServer.layerTest)
|
|
)
|
|
return Effect.gen(function*() {
|
|
const client = yield* HttpApiClient.make(Api)
|
|
const response = yield* client.group.error().pipe(Effect.flip)
|
|
assert.deepStrictEqual(response, new RateLimitError({ message: "Rate limit exceeded" }))
|
|
}).pipe(Effect.provide(ApiLive))
|
|
})
|
|
})
|
|
|
|
describe("HttpApiTest", () => {
|
|
it.effect("works", () =>
|
|
Effect.gen(function*() {
|
|
const client = yield* HttpApiTest.groups(Api, ["groups"])
|
|
const result = yield* client.groups.findById({ params: { id: 0 } })
|
|
assert.deepStrictEqual(result, new Group({ id: 1, name: "foo" }))
|
|
}).pipe(
|
|
Effect.provide([
|
|
NodeHttpServer.layerHttpServices,
|
|
AuthorizationLive,
|
|
HttpApiBuilder.group(Api, "groups", (handlers) =>
|
|
handlers
|
|
.handle("findById", () => Effect.succeed(new Group({ id: 1, name: "foo" })))
|
|
.handle(
|
|
"handle",
|
|
() => Effect.die("unimplemented")
|
|
)
|
|
.handle("create", () => Effect.die("unimplemented"))
|
|
.handle("handleRaw", () => Effect.die("unimplemented")))
|
|
])
|
|
))
|
|
})
|
|
})
|
|
|
|
class UserError extends Schema.ErrorClass<UserError>("UserError")({
|
|
_tag: Schema.tag("UserError")
|
|
}, {
|
|
httpApiStatus: 400
|
|
}) {}
|
|
class GroupError extends Schema.ErrorClass<GroupError>("GroupError")({
|
|
_tag: Schema.tag("GroupError")
|
|
}, {
|
|
httpApiStatus: 418
|
|
}) {}
|
|
class NoStatusError extends Schema.ErrorClass<NoStatusError>("NoStatusError")({
|
|
_tag: Schema.tag("NoStatusError")
|
|
}) {}
|
|
|
|
class User extends Schema.Class<User>("User")({
|
|
id: Schema.Int,
|
|
uuid: Schema.optional(Schema.String),
|
|
name: Schema.String,
|
|
createdAt: Schema.DateTimeUtcFromString
|
|
}, {
|
|
description: "Some description for User"
|
|
}) {}
|
|
|
|
class Group extends Schema.Class<Group>("Group")({
|
|
id: Schema.Int,
|
|
name: Schema.String
|
|
}) {}
|
|
|
|
const securityHeader = HttpApiSecurity.apiKey({
|
|
in: "header",
|
|
key: "x-api-key"
|
|
})
|
|
|
|
const securityQuery = HttpApiSecurity.apiKey({
|
|
in: "query",
|
|
key: "api_key"
|
|
})
|
|
|
|
class CurrentUser extends Context.Service<CurrentUser, User>()("CurrentUser") {}
|
|
|
|
class Authorization extends HttpApiMiddleware.Service<Authorization, {
|
|
provides: CurrentUser
|
|
}>()("Authorization", {
|
|
security: {
|
|
cookie: HttpApiSecurity.apiKey({
|
|
in: "cookie",
|
|
key: "token"
|
|
})
|
|
}
|
|
}) {}
|
|
|
|
class GroupsApi extends HttpApiGroup.make("groups").add(
|
|
HttpApiEndpoint.get("findById", "/:id", {
|
|
params: {
|
|
id: Schema.FiniteFromString
|
|
},
|
|
success: Group,
|
|
error: GroupError.pipe(HttpApiSchema.asNoContent({
|
|
decode: () => new GroupError({})
|
|
}))
|
|
}),
|
|
HttpApiEndpoint.post("create", "/", {
|
|
payload: [
|
|
Schema.Struct(Struct.pick(Group.fields, ["name"])),
|
|
Schema.Struct({ foo: Schema.String }).pipe(
|
|
HttpApiSchema.asFormUrlEncoded()
|
|
),
|
|
Schema.Struct(Struct.pick(Group.fields, ["name"])).pipe(
|
|
HttpApiSchema.asMultipart()
|
|
)
|
|
],
|
|
success: Group
|
|
}),
|
|
HttpApiEndpoint.post("handle", "/handle/:id", {
|
|
params: {
|
|
id: Schema.FiniteFromString
|
|
},
|
|
payload: Schema.Struct({
|
|
name: Schema.String
|
|
}),
|
|
success: Schema.Struct({
|
|
id: Schema.Finite,
|
|
name: Schema.String
|
|
})
|
|
}),
|
|
HttpApiEndpoint.post("handleRaw", "/handleraw/:id", {
|
|
params: {
|
|
id: Schema.FiniteFromString
|
|
},
|
|
payload: Schema.Struct({
|
|
name: Schema.String
|
|
}),
|
|
success: Schema.Struct({
|
|
id: Schema.Finite,
|
|
name: Schema.String
|
|
})
|
|
})
|
|
).prefix("/groups") {}
|
|
|
|
class UsersApi extends HttpApiGroup.make("users")
|
|
.add(
|
|
HttpApiEndpoint.get("findById", "/:id", {
|
|
params: {
|
|
id: Schema.Finite
|
|
},
|
|
success: User,
|
|
error: UserError
|
|
}),
|
|
HttpApiEndpoint.post("create", "/", {
|
|
payload: Schema.Struct(Struct.omit(User.fields, ["id", "createdAt"])),
|
|
query: {
|
|
id: Schema.Finite
|
|
},
|
|
success: User,
|
|
error: [UserError, UserError]
|
|
}),
|
|
HttpApiEndpoint.get("list", "/", {
|
|
headers: {
|
|
page: Schema.Finite.pipe(
|
|
Schema.optionalKey,
|
|
Schema.decode({
|
|
decode: SchemaGetter.withDefault(Effect.succeed(1)),
|
|
encode: SchemaGetter.passthrough()
|
|
})
|
|
)
|
|
},
|
|
query: {
|
|
query: Schema.optional(Schema.String).annotate({ description: "search query" })
|
|
},
|
|
success: Schema.Array(User),
|
|
error: NoStatusError
|
|
})
|
|
.annotate(OpenApi.Deprecated, true)
|
|
.annotate(OpenApi.Summary, "test summary")
|
|
.annotateMerge(OpenApi.annotations({ identifier: "listUsers" })),
|
|
HttpApiEndpoint.post("upload", "/upload/:0?", {
|
|
params: {
|
|
0: Schema.optional(Schema.String)
|
|
},
|
|
payload: Schema.Struct({
|
|
file: Multipart.SingleFileSchema
|
|
}).pipe(HttpApiSchema.asMultipart()),
|
|
success: Schema.Struct({
|
|
contentType: Schema.String,
|
|
length: Schema.Int
|
|
})
|
|
}),
|
|
HttpApiEndpoint.post("uploadStream", `/uploadstream`, {
|
|
payload: Schema.Struct({
|
|
file: Multipart.SingleFileSchema
|
|
}).pipe(HttpApiSchema.asMultipartStream()),
|
|
success: Schema.Struct({
|
|
contentType: Schema.String,
|
|
length: Schema.Int
|
|
})
|
|
})
|
|
)
|
|
.middleware(Authorization)
|
|
.annotateMerge(OpenApi.annotations({ title: "Users API" }))
|
|
{}
|
|
|
|
class TopLevelApi extends HttpApiGroup.make("root", { topLevel: true })
|
|
.add(
|
|
HttpApiEndpoint.get("healthz", `/healthz`, {
|
|
success: HttpApiSchema.NoContent.annotate({ description: "Empty" })
|
|
})
|
|
)
|
|
{}
|
|
|
|
class AnotherApi extends HttpApi.make("another").add(GroupsApi) {}
|
|
|
|
class Api extends HttpApi.make("api")
|
|
.addHttpApi(AnotherApi)
|
|
.add(UsersApi.prefix("/users"))
|
|
.add(TopLevelApi)
|
|
.annotateMerge(OpenApi.annotations({
|
|
title: "API",
|
|
summary: "test api summary",
|
|
transform: (openApiSpec) => ({
|
|
...openApiSpec,
|
|
tags: [...openApiSpec.tags ?? [], {
|
|
name: "Tag from OpenApi.Transform annotation"
|
|
}]
|
|
})
|
|
}))
|
|
.annotate(
|
|
HttpApi.AdditionalSchemas,
|
|
[
|
|
Schema.Struct({
|
|
contentType: Schema.String,
|
|
length: Schema.Int
|
|
}).annotate({
|
|
identifier: "ComponentsSchema"
|
|
})
|
|
]
|
|
)
|
|
{}
|
|
|
|
// impl
|
|
|
|
class UserRepo extends Context.Service<UserRepo, {
|
|
readonly findById: (id: number) => Effect.Effect<User>
|
|
}>()("UserRepo") {
|
|
static Live = Layer.succeed(this)({
|
|
findById: (id) => Effect.map(DateTime.now, (now) => ({ id, name: "foo", createdAt: now }))
|
|
})
|
|
}
|
|
|
|
const AuthorizationLive = Layer.succeed(
|
|
Authorization
|
|
)({
|
|
cookie: (effect, opts) =>
|
|
Effect.provideService(
|
|
effect,
|
|
CurrentUser,
|
|
new User({
|
|
id: 1,
|
|
name: Redacted.value(opts.credential),
|
|
createdAt: DateTime.nowUnsafe()
|
|
})
|
|
)
|
|
})
|
|
|
|
const HttpUsersLive = HttpApiBuilder.group(
|
|
Api,
|
|
"users",
|
|
Effect.fnUntraced(function*(handlers) {
|
|
const fs = yield* FileSystem.FileSystem
|
|
const repo = yield* UserRepo
|
|
return handlers
|
|
.handle("findById", (_) => _.params.id === -1 ? CurrentUser : repo.findById(_.params.id))
|
|
.handle("create", (_) =>
|
|
_.payload.name === "boom"
|
|
? Effect.fail(new UserError({}))
|
|
: Effect.map(DateTime.now, (now) =>
|
|
new User({
|
|
id: _.query.id,
|
|
name: _.payload.name,
|
|
createdAt: now
|
|
})))
|
|
.handle("list", (_) =>
|
|
_.headers.page === 0
|
|
? Effect.fail(new NoStatusError({}))
|
|
// test handler level context
|
|
: Effect.map(DateTime.nowInCurrentZone, (now) => [
|
|
new User({
|
|
id: 1,
|
|
name: `page ${_.headers.page}`,
|
|
createdAt: DateTime.toUtc(now)
|
|
})
|
|
]))
|
|
.handle("upload", (_) =>
|
|
Effect.gen(function*() {
|
|
const stat = yield* fs.stat(_.payload.file.path).pipe(Effect.orDie)
|
|
return {
|
|
contentType: _.payload.file.contentType,
|
|
length: Number(stat.size)
|
|
}
|
|
}))
|
|
.handle("uploadStream", (_) =>
|
|
Effect.gen(function*() {
|
|
const { content, file } = yield* _.payload.pipe(
|
|
Stream.filter(Multipart.isFile),
|
|
Stream.mapEffect((file) =>
|
|
file.contentEffect.pipe(
|
|
Effect.map((content) => ({ file, content }))
|
|
)
|
|
),
|
|
Stream.runCollect,
|
|
Effect.flatMap((_) => Effect.fromOption(Array.head(_))),
|
|
Effect.orDie
|
|
)
|
|
return {
|
|
contentType: file.contentType,
|
|
length: content.length
|
|
}
|
|
}))
|
|
})
|
|
).pipe(
|
|
Layer.provide([
|
|
UserRepo.Live,
|
|
AuthorizationLive
|
|
])
|
|
)
|
|
|
|
const HttpGroupsLive = HttpApiBuilder.group(
|
|
Api,
|
|
"groups",
|
|
(handlers) =>
|
|
handlers
|
|
.handle("findById", ({ params }) =>
|
|
params.id === 0
|
|
? Effect.fail(new GroupError({}))
|
|
: Effect.succeed(new Group({ id: 1, name: "foo" })))
|
|
.handle("create", ({ payload }) =>
|
|
Effect.succeed(
|
|
new Group({
|
|
id: 1,
|
|
name: "foo" in payload ? payload.foo : payload.name
|
|
})
|
|
))
|
|
.handle(
|
|
"handle",
|
|
Effect.fnUntraced(function*({ params, payload }) {
|
|
return HttpServerResponse.jsonUnsafe({
|
|
id: params.id,
|
|
name: payload.name
|
|
})
|
|
})
|
|
)
|
|
.handleRaw(
|
|
"handleRaw",
|
|
Effect.fnUntraced(function*({ params, request }) {
|
|
const body = (yield* Effect.orDie(request.json)) as { name: string }
|
|
return HttpServerResponse.jsonUnsafe({
|
|
id: params.id,
|
|
name: body.name
|
|
})
|
|
})
|
|
)
|
|
)
|
|
|
|
const TopLevelLive = HttpApiBuilder.group(
|
|
Api,
|
|
"root",
|
|
(handlers) => handlers.handle("healthz", (_) => Effect.void)
|
|
)
|
|
|
|
const HttpApiLive = Layer.provide(HttpApiBuilder.layer(Api), [
|
|
HttpGroupsLive,
|
|
HttpUsersLive,
|
|
TopLevelLive
|
|
])
|
|
|
|
const HttpLive = HttpRouter.serve(HttpApiLive, {
|
|
disableListenLog: true,
|
|
disableLogger: true
|
|
}).pipe(
|
|
Layer.provideMerge(NodeHttpServer.layerTest),
|
|
Layer.provide(DateTime.layerCurrentZoneOffset(0))
|
|
)
|