531 lines
18 KiB
TypeScript
531 lines
18 KiB
TypeScript
import { assert, it, vi } from "@effect/vitest"
|
|
import { Cause, Effect, FileSystem, Layer, Path, Redacted, Schema, Stream } from "effect"
|
|
import { Etag, HttpPlatform } from "effect/unstable/http"
|
|
import {
|
|
HttpApi,
|
|
HttpApiBuilder,
|
|
HttpApiEndpoint,
|
|
HttpApiGroup,
|
|
HttpApiMiddleware,
|
|
HttpApiSchema,
|
|
HttpApiSecurity,
|
|
HttpApiTest
|
|
} from "effect/unstable/httpapi"
|
|
|
|
const textDecoder = new TextDecoder()
|
|
|
|
const StreamError = Schema.Struct({ reason: Schema.String })
|
|
|
|
const TestServices = Layer.mergeAll(
|
|
Path.layer,
|
|
Etag.layerWeak,
|
|
HttpPlatform.layer
|
|
).pipe(Layer.provideMerge(FileSystem.layerNoop({})))
|
|
|
|
it.effect("reuses response schema transformations by source AST", () => {
|
|
const SharedSuccess = Schema.String.pipe(HttpApiSchema.asText())
|
|
const DistinctSuccess = Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/custom" }))
|
|
const Api = HttpApi.make("Api").add(
|
|
HttpApiGroup.make("test")
|
|
.add(HttpApiEndpoint.get("first", "/first", { success: SharedSuccess }))
|
|
.add(HttpApiEndpoint.get("second", "/second", { success: SharedSuccess }))
|
|
.add(HttpApiEndpoint.get("distinct", "/distinct", { success: DistinctSuccess }))
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"test",
|
|
(handlers) =>
|
|
handlers
|
|
.handle("first", () => Effect.succeed("first"))
|
|
.handle("second", () => Effect.succeed("second"))
|
|
.handle("distinct", () => Effect.succeed("distinct"))
|
|
)
|
|
|
|
return Effect.acquireUseRelease(
|
|
Effect.sync(() => vi.spyOn(Schema, "decodeTo")),
|
|
(decodeTo) =>
|
|
Effect.gen(function*() {
|
|
yield* Effect.scoped(Layer.build(GroupLive))
|
|
const responseSchemaCalls = decodeTo.mock.calls.filter(
|
|
([schema]) => schema === SharedSuccess || schema === DistinctSuccess
|
|
)
|
|
assert.strictEqual(responseSchemaCalls.length, 2)
|
|
}),
|
|
(decodeTo) => Effect.sync(() => decodeTo.mockRestore())
|
|
)
|
|
})
|
|
|
|
it.layer(TestServices)("HttpApiBuilder payload content types", (it) => {
|
|
it.effect("round trips mixed-case media types with declared and received parameters", () =>
|
|
Effect.gen(function*() {
|
|
const Payload = Schema.Struct({ name: Schema.String }).pipe(
|
|
HttpApiSchema.asJson({
|
|
contentType: "Application/Vnd.Effect+JSON; profile=declared"
|
|
})
|
|
)
|
|
const Api = HttpApi.make("Api").add(
|
|
HttpApiGroup.make("test").add(
|
|
HttpApiEndpoint.post("create", "/create", {
|
|
headers: {
|
|
"content-type": Schema.optional(Schema.String)
|
|
},
|
|
payload: Payload,
|
|
success: Schema.Struct({ name: Schema.String })
|
|
})
|
|
)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"test",
|
|
(handlers) => handlers.handle("create", ({ payload }) => Effect.succeed(payload))
|
|
)
|
|
|
|
const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive))
|
|
const declared = yield* client.test.create({
|
|
headers: {},
|
|
payload: { name: "Ada" }
|
|
})
|
|
const received = yield* client.test.create({
|
|
headers: {
|
|
"content-type": "application/vnd.effect+json; profile=received"
|
|
},
|
|
payload: { name: "Grace" }
|
|
})
|
|
|
|
assert.deepStrictEqual(declared, { name: "Ada" })
|
|
assert.deepStrictEqual(received, { name: "Grace" })
|
|
}))
|
|
|
|
it.effect("round trips custom form-urlencoded media types", () =>
|
|
Effect.gen(function*() {
|
|
const Payload = Schema.Struct({ name: Schema.String }).pipe(
|
|
HttpApiSchema.asFormUrlEncoded({ contentType: "application/vnd.effect.form" })
|
|
)
|
|
const Api = HttpApi.make("Api").add(
|
|
HttpApiGroup.make("test").add(
|
|
HttpApiEndpoint.post("create", "/create", {
|
|
payload: Payload,
|
|
success: Schema.Struct({ name: Schema.String })
|
|
})
|
|
)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"test",
|
|
(handlers) => handlers.handle("create", ({ payload }) => Effect.succeed(payload))
|
|
)
|
|
|
|
const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive))
|
|
const result = yield* client.test.create({ payload: { name: "Ada" } })
|
|
|
|
assert.deepStrictEqual(result, { name: "Ada" })
|
|
}))
|
|
})
|
|
|
|
it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => {
|
|
it.effect("emits StreamUint8Array handler responses as streamed bytes with the declared content type", () =>
|
|
Effect.gen(function*() {
|
|
const Api = HttpApi.make("Api").add(
|
|
HttpApiGroup.make("test").add(
|
|
HttpApiEndpoint.get("download", "/test", {
|
|
success: HttpApiSchema.status(206)(
|
|
HttpApiSchema.StreamUint8Array({ contentType: "application/custom-bytes" })
|
|
)
|
|
})
|
|
)
|
|
)
|
|
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"test",
|
|
(handlers) =>
|
|
handlers.handle("download", () =>
|
|
Effect.succeed(
|
|
Stream.make(new Uint8Array([1, 2]), new Uint8Array([3]))
|
|
))
|
|
)
|
|
|
|
const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive))
|
|
const response = yield* client.test.download({ responseMode: "response-only" })
|
|
const chunks = yield* response.stream.pipe(Stream.runCollect)
|
|
|
|
assert.strictEqual(response.status, 206)
|
|
assert.strictEqual(response.headers["content-type"], "application/custom-bytes")
|
|
assert.deepStrictEqual(Array.from(chunks, (chunk) => Array.from(chunk)), [[1, 2], [3]])
|
|
}))
|
|
|
|
it.effect("renders successful StreamSse events incrementally with the declared content type", () =>
|
|
Effect.gen(function*() {
|
|
const Events = Schema.Struct({
|
|
event: Schema.String,
|
|
data: Schema.String
|
|
})
|
|
|
|
const Api = HttpApi.make("Api").add(
|
|
HttpApiGroup.make("test").add(
|
|
HttpApiEndpoint.get("events", "/test", {
|
|
success: HttpApiSchema.status(202)(
|
|
HttpApiSchema.StreamSse({
|
|
contentType: "text/event-stream; charset=utf-8",
|
|
events: Events,
|
|
error: StreamError
|
|
})
|
|
)
|
|
})
|
|
)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"test",
|
|
(handlers) =>
|
|
handlers.handle("events", () =>
|
|
Effect.succeed(Stream.make(
|
|
{ event: "first" as const, data: "one" },
|
|
{ event: "second" as const, data: "two" }
|
|
)))
|
|
)
|
|
|
|
const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive))
|
|
const response = yield* client.test.events({ responseMode: "response-only" })
|
|
const chunks = yield* response.stream.pipe(Stream.runCollect)
|
|
|
|
assert.strictEqual(response.status, 202)
|
|
assert.strictEqual(response.headers["content-type"], "text/event-stream; charset=utf-8")
|
|
assert.deepStrictEqual(
|
|
Array.from(chunks, (chunk) => textDecoder.decode(chunk)),
|
|
["event: first\ndata: one\n\n", "event: second\ndata: two\n\n"]
|
|
)
|
|
}))
|
|
|
|
it.effect("renders StreamSse failures as one reserved event containing an encoded full cause", () =>
|
|
Effect.gen(function*() {
|
|
const Events = Schema.Struct({
|
|
event: Schema.Literal("message"),
|
|
data: Schema.String
|
|
})
|
|
|
|
const Api = HttpApi.make("Api").add(
|
|
HttpApiGroup.make("test").add(
|
|
HttpApiEndpoint.get("events", "/test", {
|
|
success: HttpApiSchema.StreamSse({ events: Events, error: StreamError })
|
|
})
|
|
)
|
|
)
|
|
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"test",
|
|
(handlers) =>
|
|
handlers.handle("events", () =>
|
|
Effect.succeed(
|
|
Stream.fail({ reason: "boom" })
|
|
))
|
|
)
|
|
|
|
const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive))
|
|
const response = yield* client.test.events({ responseMode: "response-only" })
|
|
const chunks = yield* response.stream.pipe(Stream.runCollect)
|
|
const rendered = Array.from(chunks, (chunk) => textDecoder.decode(chunk))
|
|
|
|
assert.strictEqual(response.headers["content-type"], "text/event-stream")
|
|
assert.strictEqual(rendered.length, 1)
|
|
assert.isTrue(rendered[0]!.startsWith("event: effect/httpapi/stream/failure\ndata: "))
|
|
assert.isTrue(rendered[0]!.endsWith("\n\n"))
|
|
|
|
const data = rendered[0]!.split("\n")[1]!.slice("data: ".length)
|
|
const FailureSchema = Schema.toCodecJson(Schema.Cause(StreamError, Schema.Defect()))
|
|
const cause = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(FailureSchema))(data)
|
|
assert.deepStrictEqual(cause, Cause.fail({ reason: "boom" }))
|
|
}))
|
|
|
|
it.effect("supports buffered and stream successes with the same status", () =>
|
|
Effect.gen(function*() {
|
|
const Buffered = Schema.Struct({ message: Schema.String })
|
|
const EventData = Schema.Struct({
|
|
text: Schema.String
|
|
})
|
|
|
|
const Api = HttpApi.make("Api").add(
|
|
HttpApiGroup.make("test").add(
|
|
HttpApiEndpoint.get("chat", "/test", {
|
|
query: {
|
|
stream: Schema.String
|
|
},
|
|
success: [
|
|
Buffered,
|
|
HttpApiSchema.StreamSse({ data: EventData, error: StreamError })
|
|
]
|
|
})
|
|
)
|
|
)
|
|
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"test",
|
|
(handlers) =>
|
|
handlers.handle("chat", ({ query }) =>
|
|
Effect.succeed(
|
|
query.stream === "true" ?
|
|
Stream.make({ text: "hello" }) :
|
|
{ message: "done" }
|
|
))
|
|
)
|
|
|
|
const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLive))
|
|
const bufferedResponse = yield* client.test.chat({ query: { stream: "false" }, responseMode: "response-only" })
|
|
assert.strictEqual(bufferedResponse.status, 200)
|
|
assert.strictEqual(bufferedResponse.headers["content-type"], "application/json")
|
|
|
|
const streamResponse = yield* client.test.chat({ query: { stream: "true" }, responseMode: "response-only" })
|
|
const chunks = yield* Stream.runCollect(streamResponse.stream)
|
|
|
|
assert.strictEqual(streamResponse.status, 200)
|
|
assert.strictEqual(streamResponse.headers["content-type"], "text/event-stream")
|
|
assert.deepStrictEqual(Array.from(chunks, (chunk) => textDecoder.decode(chunk)), [
|
|
`data: {"text":"hello"}\n\n`
|
|
])
|
|
}))
|
|
|
|
it.effect("registers handleAll handlers at runtime", () =>
|
|
Effect.gen(function*() {
|
|
const User = Schema.Struct({
|
|
id: Schema.String,
|
|
name: Schema.String
|
|
})
|
|
const CreateUser = Schema.Struct({
|
|
name: Schema.String
|
|
})
|
|
|
|
const Api = HttpApi.make("Api").add(
|
|
HttpApiGroup.make("users").add(
|
|
HttpApiEndpoint.get("getUser", "/users/:id", {
|
|
params: {
|
|
id: Schema.String
|
|
},
|
|
success: User
|
|
}),
|
|
HttpApiEndpoint.post("createUser", "/users", {
|
|
payload: CreateUser,
|
|
success: User
|
|
})
|
|
)
|
|
)
|
|
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"users",
|
|
(handlers) =>
|
|
handlers.handleAll({
|
|
getUser: ({ params }) =>
|
|
Effect.succeed({
|
|
id: params.id,
|
|
name: "Ada"
|
|
}),
|
|
createUser: {
|
|
handler: ({ payload }) =>
|
|
Effect.succeed({
|
|
id: "created",
|
|
name: payload.name
|
|
}),
|
|
options: { uninterruptible: true }
|
|
}
|
|
})
|
|
)
|
|
|
|
const client = yield* HttpApiTest.groups(Api, ["users"]).pipe(Effect.provide(GroupLive))
|
|
const getUser = yield* client.users.getUser({ params: { id: "user-1" } })
|
|
const createUser = yield* client.users.createUser({ payload: { name: "Grace" } })
|
|
|
|
assert.deepStrictEqual(getUser, { id: "user-1", name: "Ada" })
|
|
assert.deepStrictEqual(createUser, { id: "created", name: "Grace" })
|
|
}))
|
|
|
|
it.effect("rejects unknown endpoint identifiers", () =>
|
|
Effect.gen(function*() {
|
|
const Api = HttpApi.make("Api").add(
|
|
HttpApiGroup.make("users").add(
|
|
HttpApiEndpoint.get("getUser", "/users", { success: Schema.String })
|
|
)
|
|
)
|
|
|
|
for (const identifier of ["missing", "toString"] as const) {
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"users",
|
|
(handlers) => handlers.handle(identifier as "getUser", () => Effect.succeed("missing"))
|
|
)
|
|
const exit = yield* Effect.exit(Effect.scoped(Layer.build(GroupLive)))
|
|
|
|
assert.strictEqual(exit._tag, "Failure")
|
|
if (exit._tag === "Failure") {
|
|
const error = Cause.squash(exit.cause)
|
|
assert.instanceOf(error, Error)
|
|
assert.strictEqual(
|
|
error.message,
|
|
`HttpApiEndpoint "${identifier}" not found in HttpApiGroup "users"`
|
|
)
|
|
}
|
|
}
|
|
}))
|
|
|
|
it.effect("rejects duplicate handle and handleAll registrations", () =>
|
|
Effect.gen(function*() {
|
|
const Api = HttpApi.make("Api").add(
|
|
HttpApiGroup.make("users").add(
|
|
HttpApiEndpoint.get("getUser", "/users", { success: Schema.String })
|
|
)
|
|
)
|
|
const HandleLive = HttpApiBuilder.group(
|
|
Api,
|
|
"users",
|
|
(handlers) => {
|
|
handlers.handle("getUser", () => Effect.succeed("first"))
|
|
return handlers.handle("getUser", () => Effect.succeed("second"))
|
|
}
|
|
)
|
|
const HandleAllLive = HttpApiBuilder.group(
|
|
Api,
|
|
"users",
|
|
(handlers) => {
|
|
handlers.handleAll({ getUser: () => Effect.succeed("first") })
|
|
return handlers.handleAll({ getUser: () => Effect.succeed("second") })
|
|
}
|
|
)
|
|
|
|
for (const GroupLive of [HandleLive, HandleAllLive]) {
|
|
const exit = yield* Effect.exit(Effect.scoped(Layer.build(GroupLive)))
|
|
assert.strictEqual(exit._tag, "Failure")
|
|
if (exit._tag === "Failure") {
|
|
const error = Cause.squash(exit.cause)
|
|
assert.instanceOf(error, Error)
|
|
assert.strictEqual(
|
|
error.message,
|
|
"Handler for HttpApiEndpoint \"getUser\" is already registered in HttpApiGroup \"users\""
|
|
)
|
|
}
|
|
}
|
|
}))
|
|
|
|
it.effect("ignores inherited handleAll properties", () =>
|
|
Effect.gen(function*() {
|
|
const Api = HttpApi.make("Api").add(
|
|
HttpApiGroup.make("users").add(
|
|
HttpApiEndpoint.get("getUser", "/users", {
|
|
success: Schema.String
|
|
})
|
|
)
|
|
)
|
|
const implementations: {
|
|
readonly getUser: () => Effect.Effect<string>
|
|
} = Object.assign(
|
|
Object.create({
|
|
inherited: () => Effect.succeed("inherited")
|
|
}),
|
|
{
|
|
getUser: () => Effect.succeed("own")
|
|
}
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"users",
|
|
(handlers) => handlers.handleAll(implementations)
|
|
)
|
|
|
|
const client = yield* HttpApiTest.groups(Api, ["users"]).pipe(Effect.provide(GroupLive))
|
|
|
|
assert.strictEqual(yield* client.users.getUser(), "own")
|
|
}))
|
|
|
|
it.effect("executes effectful handler builders", () =>
|
|
Effect.gen(function*() {
|
|
const User = Schema.Struct({
|
|
id: Schema.String,
|
|
name: Schema.String
|
|
})
|
|
|
|
const Api = HttpApi.make("Api").add(
|
|
HttpApiGroup.make("users").add(
|
|
HttpApiEndpoint.get("getUser", "/users/:id", {
|
|
params: {
|
|
id: Schema.String
|
|
},
|
|
success: User
|
|
})
|
|
)
|
|
)
|
|
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"users",
|
|
(handlers) =>
|
|
Effect.succeed(
|
|
handlers.handle("getUser", ({ params }) =>
|
|
Effect.succeed({
|
|
id: params.id,
|
|
name: "Ada"
|
|
}))
|
|
)
|
|
)
|
|
|
|
const client = yield* HttpApiTest.groups(Api, ["users"]).pipe(Effect.provide(GroupLive))
|
|
const getUser = yield* client.users.getUser({ params: { id: "user-1" } })
|
|
|
|
assert.deepStrictEqual(getUser, { id: "user-1", name: "Ada" })
|
|
}))
|
|
|
|
it.effect("does not try another security scheme after the handler fails", () =>
|
|
Effect.gen(function*() {
|
|
class HandlerFailure extends Schema.TaggedErrorClass<HandlerFailure>()("HandlerFailure", {
|
|
message: Schema.String
|
|
}, { httpApiStatus: 418 }) {}
|
|
|
|
class M extends HttpApiMiddleware.Service<M>()("Security/HandlerFailure", {
|
|
error: Schema.String.pipe(
|
|
HttpApiSchema.status(401),
|
|
HttpApiSchema.asText()
|
|
),
|
|
security: {
|
|
first: HttpApiSecurity.apiKey({
|
|
in: "header",
|
|
key: "x-first"
|
|
}),
|
|
second: HttpApiSecurity.apiKey({
|
|
in: "header",
|
|
key: "x-second"
|
|
})
|
|
}
|
|
}) {}
|
|
|
|
const Api = HttpApi.make("Api").add(
|
|
HttpApiGroup.make("test").add(
|
|
HttpApiEndpoint.get("protected", "/protected", {
|
|
headers: {
|
|
"x-first": Schema.String
|
|
},
|
|
success: Schema.String,
|
|
error: HandlerFailure
|
|
}).middleware(M)
|
|
)
|
|
)
|
|
const GroupLive = HttpApiBuilder.group(
|
|
Api,
|
|
"test",
|
|
(handlers) => handlers.handle("protected", () => Effect.fail(new HandlerFailure({ message: "handler failed" })))
|
|
)
|
|
const MLive = Layer.succeed(M)({
|
|
first: (effect, { credential }) =>
|
|
Redacted.value(credential) === "ok" ? effect : Effect.fail("first unauthorized"),
|
|
second: (effect, { credential }) =>
|
|
Redacted.value(credential) === "ok" ? effect : Effect.fail("second unauthorized")
|
|
})
|
|
|
|
const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(
|
|
Effect.provide(GroupLive),
|
|
Effect.provide(MLive)
|
|
)
|
|
const error = yield* Effect.flip(client.test.protected({ headers: { "x-first": "ok" } }))
|
|
|
|
assert.deepStrictEqual(error, new HandlerFailure({ message: "handler failed" }))
|
|
}))
|
|
})
|