Merge commit '3c60637c1a27da8ba66888de518d58d5707801f2' as 'repos/effect-smol'
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { assert, describe, it } from "@effect/vitest"
|
||||
import { Context } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
describe("HttpApi", () => {
|
||||
it("stores the supplied identifier", () => {
|
||||
const api = HttpApi.make("Api")
|
||||
|
||||
assert.strictEqual(api.identifier, "Api")
|
||||
})
|
||||
|
||||
it("initializes groups as a readonly record", () => {
|
||||
const api = HttpApi.make("Api")
|
||||
|
||||
assert.deepStrictEqual(api.groups, {})
|
||||
})
|
||||
|
||||
it("stores __proto__ group and endpoint identifiers as own properties", () => {
|
||||
const group = HttpApiGroup.make("__proto__").add(
|
||||
HttpApiEndpoint.get("__proto__", "/proto")
|
||||
)
|
||||
const child = HttpApi.make("Child").add(group)
|
||||
const api = HttpApi.make("Api").addHttpApi(child)
|
||||
|
||||
assert.isTrue(Object.hasOwn(group.endpoints, "__proto__"))
|
||||
assert.isTrue(Object.hasOwn(child.groups, "__proto__"))
|
||||
assert.isTrue(Object.hasOwn(api.groups, "__proto__"))
|
||||
assert.isTrue(Object.hasOwn(api.groups["__proto__"].endpoints, "__proto__"))
|
||||
|
||||
const reflected: Array<string> = []
|
||||
HttpApi.reflect(api, {
|
||||
onGroup: ({ group }) => reflected.push(group.identifier),
|
||||
onEndpoint: ({ endpoint }) => reflected.push(endpoint.identifier)
|
||||
})
|
||||
assert.deepStrictEqual(reflected, ["__proto__", "__proto__"])
|
||||
})
|
||||
|
||||
it("does not mutate groups from the added API", () => {
|
||||
const group = HttpApiGroup.make("users").annotate(OpenApi.Title, "Users")
|
||||
const child = HttpApi.make("Child")
|
||||
.annotate(OpenApi.Description, "Child API")
|
||||
.add(group)
|
||||
const originalAnnotations = group.annotations
|
||||
|
||||
const parent = HttpApi.make("Parent").addHttpApi(child)
|
||||
|
||||
assert.strictEqual(group.annotations, originalAnnotations)
|
||||
assert.strictEqual(child.groups.users, group)
|
||||
assert.notStrictEqual(parent.groups.users, group)
|
||||
assert.strictEqual(Context.getUnsafe(parent.groups.users.annotations, OpenApi.Title), "Users")
|
||||
assert.strictEqual(Context.getUnsafe(parent.groups.users.annotations, OpenApi.Description), "Child API")
|
||||
})
|
||||
|
||||
it("keeps annotations from API variants isolated", () => {
|
||||
const group = HttpApiGroup.make("users").annotate(OpenApi.Title, "Users")
|
||||
const child = HttpApi.make("Child").add(group)
|
||||
const publicChild = child.annotate(OpenApi.Description, "Public API")
|
||||
const internalChild = child.annotate(OpenApi.Description, "Internal API")
|
||||
|
||||
const publicParent = HttpApi.make("PublicParent").addHttpApi(publicChild)
|
||||
const internalParent = HttpApi.make("InternalParent").addHttpApi(internalChild)
|
||||
|
||||
assert.strictEqual(Context.getUnsafe(publicParent.groups.users.annotations, OpenApi.Description), "Public API")
|
||||
assert.strictEqual(Context.getUnsafe(internalParent.groups.users.annotations, OpenApi.Description), "Internal API")
|
||||
assert.notStrictEqual(publicParent.groups.users, internalParent.groups.users)
|
||||
|
||||
const publicTag = OpenApi.fromApi(publicParent).tags.find((tag) => tag.name === "Users")
|
||||
const internalTag = OpenApi.fromApi(internalParent).tags.find((tag) => tag.name === "Users")
|
||||
assert.isDefined(publicTag)
|
||||
assert.isDefined(internalTag)
|
||||
assert.strictEqual(publicTag.description, "Public API")
|
||||
assert.strictEqual(internalTag.description, "Internal API")
|
||||
})
|
||||
|
||||
it("preserves group annotation precedence", () => {
|
||||
const group = HttpApiGroup.make("users").annotate(OpenApi.Title, "Group title")
|
||||
const child = HttpApi.make("Child")
|
||||
.annotate(OpenApi.Title, "API title")
|
||||
.add(group)
|
||||
|
||||
const parent = HttpApi.make("Parent").addHttpApi(child)
|
||||
|
||||
assert.strictEqual(Context.getUnsafe(parent.groups.users.annotations, OpenApi.Title), "Group title")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,530 @@
|
||||
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" }))
|
||||
}))
|
||||
})
|
||||
@@ -0,0 +1,683 @@
|
||||
import { assert, describe, it } from "@effect/vitest"
|
||||
import { strictEqual } from "@effect/vitest/utils"
|
||||
import { Cause, Effect, Schema, Stream } from "effect"
|
||||
import { Sse } from "effect/unstable/encoding"
|
||||
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
|
||||
describe("HttpApiClient", () => {
|
||||
describe("streaming responses", () => {
|
||||
it.effect("decodes StreamSse events incrementally", () =>
|
||||
Effect.gen(function*() {
|
||||
const client = yield* HttpApiClient.makeWith(StreamingApi, {
|
||||
baseUrl: "http://test",
|
||||
httpClient: clientFromResponse(() =>
|
||||
new Response(
|
||||
textStream([
|
||||
"event: first\ndata: one\n\n",
|
||||
"event: second\ndata: two\n\n"
|
||||
]),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const stream = yield* client.test.events({})
|
||||
const first = yield* stream.pipe(Stream.take(1), Stream.runCollect)
|
||||
assert.deepStrictEqual(first, [{ event: "first", data: "one" }])
|
||||
}))
|
||||
|
||||
it.effect("keeps StreamSse parser state isolated between responses", () =>
|
||||
Effect.gen(function*() {
|
||||
const bodies = [
|
||||
"event: first\ndata: one\n\n",
|
||||
"event: second\ndata: two\n\n"
|
||||
]
|
||||
let index = 0
|
||||
const client = yield* HttpApiClient.makeWith(StreamingApi, {
|
||||
baseUrl: "http://test",
|
||||
httpClient: clientFromResponse(() => new Response(textStream([bodies[index++]!]), { status: 200 }))
|
||||
})
|
||||
|
||||
const first = yield* client.test.events({}).pipe(Effect.flatMap(Stream.runCollect))
|
||||
const second = yield* client.test.events({}).pipe(Effect.flatMap(Stream.runCollect))
|
||||
|
||||
assert.deepStrictEqual(first, [{ event: "first", data: "one" }])
|
||||
assert.deepStrictEqual(second, [{ event: "second", data: "two" }])
|
||||
}))
|
||||
|
||||
it.effect("decodes StreamSse reserved failure events as full causes", () =>
|
||||
Effect.gen(function*() {
|
||||
const expectedCause = Cause.fail({ reason: "boom" })
|
||||
const FailureSchema = Schema.toCodecJson(Schema.Cause(StreamError, Schema.Defect()))
|
||||
const encodeCause = Schema.encodeUnknownEffect(Schema.fromJsonString(FailureSchema))
|
||||
const encodedCause = yield* encodeCause(expectedCause)
|
||||
const failureEvent = Sse.encoder.write({
|
||||
_tag: "Event",
|
||||
event: "effect/httpapi/stream/failure",
|
||||
id: undefined,
|
||||
data: encodedCause
|
||||
})
|
||||
|
||||
const client = yield* HttpApiClient.makeWith(StreamingApi, {
|
||||
baseUrl: "http://test",
|
||||
httpClient: clientFromResponse(() => new Response(textStream([failureEvent]), { status: 200 }))
|
||||
})
|
||||
|
||||
const stream = yield* client.test.events({})
|
||||
const exit = yield* Effect.exit(Stream.runCollect(stream))
|
||||
|
||||
assert.strictEqual(exit._tag, "Failure")
|
||||
if (exit._tag === "Failure") {
|
||||
assert.deepStrictEqual(exit.cause, expectedCause)
|
||||
}
|
||||
}))
|
||||
|
||||
it.effect("emits StreamSse reserved names with non-Cause data as user events", () =>
|
||||
Effect.gen(function*() {
|
||||
const failureEvent = Sse.encoder.write({
|
||||
_tag: "Event",
|
||||
event: "effect/httpapi/stream/failure",
|
||||
id: undefined,
|
||||
data: "not-json"
|
||||
})
|
||||
|
||||
const client = yield* HttpApiClient.makeWith(StreamingApi, {
|
||||
baseUrl: "http://test",
|
||||
httpClient: clientFromResponse(() => new Response(textStream([failureEvent]), { status: 200 }))
|
||||
})
|
||||
|
||||
const stream = yield* client.test.events({})
|
||||
const events = yield* Stream.runCollect(stream)
|
||||
|
||||
assert.deepStrictEqual(events, [{
|
||||
event: "effect/httpapi/stream/failure",
|
||||
data: "not-json"
|
||||
}])
|
||||
}))
|
||||
|
||||
it.effect("returns StreamUint8Array response bytes incrementally", () =>
|
||||
Effect.gen(function*() {
|
||||
const client = yield* HttpApiClient.makeWith(StreamingApi, {
|
||||
baseUrl: "http://test",
|
||||
httpClient: clientFromResponse(() =>
|
||||
new Response(byteStream([new Uint8Array([1, 2]), new Uint8Array([3])]), { status: 200 })
|
||||
)
|
||||
})
|
||||
|
||||
const stream = yield* client.test.download({})
|
||||
const first = yield* stream.pipe(Stream.take(1), Stream.runCollect)
|
||||
assert.deepStrictEqual(first.map((chunk) => Array.from(chunk)), [[1, 2]])
|
||||
}))
|
||||
|
||||
it.effect("decodes StreamSse successes at the annotated status", () =>
|
||||
Effect.gen(function*() {
|
||||
const client = yield* HttpApiClient.makeWith(AnnotatedStreamingApi, {
|
||||
baseUrl: "http://test",
|
||||
httpClient: clientFromResponse(() =>
|
||||
new Response(textStream(["event: annotated\ndata: ok\n\n"]), { status: 202 })
|
||||
)
|
||||
})
|
||||
|
||||
const stream = yield* client.test.events({})
|
||||
const events = yield* Stream.runCollect(stream)
|
||||
assert.deepStrictEqual(events, [{ event: "annotated", data: "ok" }])
|
||||
}))
|
||||
|
||||
it.effect("decodes StreamUint8Array successes at the annotated status", () =>
|
||||
Effect.gen(function*() {
|
||||
const client = yield* HttpApiClient.makeWith(AnnotatedStreamingApi, {
|
||||
baseUrl: "http://test",
|
||||
httpClient: clientFromResponse(() => new Response(byteStream([new Uint8Array([4, 5])]), { status: 206 }))
|
||||
})
|
||||
|
||||
const stream = yield* client.test.download({})
|
||||
const chunks = yield* Stream.runCollect(stream)
|
||||
assert.deepStrictEqual(chunks.map((chunk) => Array.from(chunk)), [[4, 5]])
|
||||
}))
|
||||
|
||||
it.effect("decodes non-success responses through endpoint error schemas before returning a stream", () =>
|
||||
Effect.gen(function*() {
|
||||
const client = yield* HttpApiClient.makeWith(StreamingApi, {
|
||||
baseUrl: "http://test",
|
||||
httpClient: clientFromResponse(() =>
|
||||
new Response(JSON.stringify({ _tag: "EndpointError", message: "bad request" }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const error = yield* Effect.flip(client.test.events({}))
|
||||
assert.deepStrictEqual(error, new EndpointError({ message: "bad request" }))
|
||||
}))
|
||||
|
||||
it.effect("preserves response-only raw response stream access", () =>
|
||||
Effect.gen(function*() {
|
||||
const client = yield* HttpApiClient.makeWith(StreamingApi, {
|
||||
baseUrl: "http://test",
|
||||
httpClient: clientFromResponse(() =>
|
||||
new Response(
|
||||
byteStream([
|
||||
new Uint8Array([1]),
|
||||
new Uint8Array([2, 3])
|
||||
]),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const response = yield* client.test.download({ responseMode: "response-only" })
|
||||
const chunks = yield* Stream.runCollect(response.stream)
|
||||
assert.deepStrictEqual(chunks.map((chunk) => Array.from(chunk)), [[1], [2, 3]])
|
||||
}))
|
||||
|
||||
it.effect("selects a buffered response by content type when a stream uses the same status", () =>
|
||||
Effect.gen(function*() {
|
||||
const client = yield* HttpApiClient.makeWith(MixedSuccessApi, {
|
||||
baseUrl: "http://test",
|
||||
httpClient: clientFromResponse(() =>
|
||||
new Response(JSON.stringify({ message: "done" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const response = yield* client.test.chat({})
|
||||
assert.deepStrictEqual(response, { message: "done" })
|
||||
}))
|
||||
|
||||
it.effect("selects a stream response by content type when buffered success uses the same status", () =>
|
||||
Effect.gen(function*() {
|
||||
const client = yield* HttpApiClient.makeWith(MixedSuccessApi, {
|
||||
baseUrl: "http://test",
|
||||
httpClient: clientFromResponse(() =>
|
||||
new Response(textStream([`event: token\ndata: {"text":"hello"}\n\n`]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream; charset=utf-8" }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const stream = yield* client.test.chat({})
|
||||
if (!Stream.isStream(stream)) {
|
||||
throw new Error("Expected stream response")
|
||||
}
|
||||
const events = yield* Stream.runCollect(stream)
|
||||
assert.deepStrictEqual(events, [{ text: "hello" }])
|
||||
}))
|
||||
})
|
||||
|
||||
describe("error responses", () => {
|
||||
const makeClient = (response: () => Response) =>
|
||||
HttpApiClient.makeWith(ErrorContentTypeApi, {
|
||||
baseUrl: "http://test",
|
||||
httpClient: clientFromResponse(response)
|
||||
})
|
||||
|
||||
it.effect("selects schemas by normalized content type regardless of declaration order", () =>
|
||||
Effect.gen(function*() {
|
||||
for (const endpoint of ["textFirst", "jsonFirst"] as const) {
|
||||
const jsonClient = yield* makeClient(() =>
|
||||
new Response(JSON.stringify({ _tag: "JsonError", message: "bad request" }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "Application/JSON; charset=utf-8" }
|
||||
})
|
||||
)
|
||||
const jsonError = yield* Effect.flip(jsonClient.test[endpoint]({}))
|
||||
assert.deepStrictEqual(jsonError, { _tag: "JsonError", message: "bad request" })
|
||||
|
||||
const textClient = yield* makeClient(() =>
|
||||
new Response("bad request", {
|
||||
status: 400,
|
||||
headers: { "content-type": "text/plain" }
|
||||
})
|
||||
)
|
||||
const textError = yield* Effect.flip(textClient.test[endpoint]({}))
|
||||
assert.strictEqual(textError, "bad request")
|
||||
}
|
||||
}))
|
||||
|
||||
it.effect("reports unsupported error response content types", () =>
|
||||
Effect.gen(function*() {
|
||||
const client = yield* makeClient(() =>
|
||||
new Response("<error />", {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/xml" }
|
||||
})
|
||||
)
|
||||
|
||||
const exit = yield* Effect.exit(client.test.textFirst({}))
|
||||
assert.strictEqual(exit._tag, "Failure")
|
||||
if (exit._tag === "Failure") {
|
||||
const errors: Array<unknown> = []
|
||||
for (const reason of exit.cause.reasons) {
|
||||
if (Cause.isFailReason(reason)) {
|
||||
errors.push(reason.error)
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
errors.some((error) => HttpClientError.isHttpClientError(error) && error.reason._tag === "StatusCodeError")
|
||||
)
|
||||
const decodeError = errors.find((error) =>
|
||||
HttpClientError.isHttpClientError(error) && error.reason._tag === "DecodeError"
|
||||
)
|
||||
assert.ok(HttpClientError.isHttpClientError(decodeError))
|
||||
assert.strictEqual(decodeError.reason._tag, "DecodeError")
|
||||
assert.ok(decodeError.reason.description?.includes("Unsupported response content-type"))
|
||||
}
|
||||
}))
|
||||
|
||||
it.effect("decodes no-content errors without a content-type header", () =>
|
||||
Effect.gen(function*() {
|
||||
const client = yield* makeClient(() => new Response(null, { status: 400 }))
|
||||
|
||||
const error = yield* Effect.flip(client.test.noContent({}))
|
||||
assert.strictEqual(error, "NoContentError")
|
||||
}))
|
||||
|
||||
it.effect("groups schemas by normalized declared content type", () =>
|
||||
Effect.gen(function*() {
|
||||
const client = yield* makeClient(() =>
|
||||
new Response(JSON.stringify({ _tag: "SecondJsonError", message: "bad request" }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/problem+json" }
|
||||
})
|
||||
)
|
||||
|
||||
const error = yield* Effect.flip(client.test.equivalentJson({}))
|
||||
assert.deepStrictEqual(error, { _tag: "SecondJsonError", message: "bad request" })
|
||||
}))
|
||||
})
|
||||
|
||||
describe("urlBuilder", () => {
|
||||
const Api = HttpApi.make("Api")
|
||||
.add(
|
||||
HttpApiGroup.make("users")
|
||||
.add(
|
||||
HttpApiEndpoint.get("getUser", "/users/:id", {
|
||||
params: {
|
||||
id: Schema.Finite
|
||||
},
|
||||
query: {
|
||||
page: Schema.Finite,
|
||||
tags: Schema.Array(Schema.Finite)
|
||||
}
|
||||
}),
|
||||
HttpApiEndpoint.get("health", "/health")
|
||||
)
|
||||
)
|
||||
|
||||
it("builds urls using endpoint schemas", () => {
|
||||
const builder = HttpApiClient.urlBuilder(Api, {
|
||||
baseUrl: "https://api.example.com"
|
||||
})
|
||||
|
||||
strictEqual(
|
||||
builder.users.getUser({
|
||||
params: {
|
||||
id: 123
|
||||
},
|
||||
query: {
|
||||
page: 1,
|
||||
tags: [1, 2]
|
||||
}
|
||||
}),
|
||||
"https://api.example.com/users/123?page=1&tags=1&tags=2"
|
||||
)
|
||||
})
|
||||
|
||||
it("encodes path parameters", () => {
|
||||
const Api = HttpApi.make("Api")
|
||||
.add(
|
||||
HttpApiGroup.make("stacks")
|
||||
.add(
|
||||
HttpApiEndpoint.get("listResources", "/state/stacks/:stack/stages/:stage/resources", {
|
||||
params: {
|
||||
stack: Schema.String,
|
||||
stage: Schema.String
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
const builder = HttpApiClient.urlBuilder(Api, {
|
||||
baseUrl: "https://api.example.com"
|
||||
})
|
||||
|
||||
strictEqual(
|
||||
builder.stacks.listResources({
|
||||
params: {
|
||||
stack: "a/b",
|
||||
stage: "prod/blue"
|
||||
}
|
||||
}),
|
||||
"https://api.example.com/state/stacks/a%2Fb/stages/prod%2Fblue/resources"
|
||||
)
|
||||
})
|
||||
|
||||
it("omits missing optional path parameters", () => {
|
||||
const Api = HttpApi.make("Api")
|
||||
.add(
|
||||
HttpApiGroup.make("files")
|
||||
.add(
|
||||
HttpApiEndpoint.get("download", "/files/:path?", {
|
||||
params: {
|
||||
path: Schema.optional(Schema.String)
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
const builder = HttpApiClient.urlBuilder(Api, {
|
||||
baseUrl: "https://api.example.com"
|
||||
})
|
||||
|
||||
strictEqual(
|
||||
builder.files.download({ params: {} }),
|
||||
"https://api.example.com/files"
|
||||
)
|
||||
strictEqual(
|
||||
builder.files.download({ params: { path: "a/b" } }),
|
||||
"https://api.example.com/files/a%2Fb"
|
||||
)
|
||||
})
|
||||
|
||||
it("returns relative urls when baseUrl is omitted", () => {
|
||||
const builder = HttpApiClient.urlBuilder(Api)
|
||||
|
||||
strictEqual(builder.users.health(), "/health")
|
||||
})
|
||||
|
||||
it("supports top-level endpoints", () => {
|
||||
const TopLevelApi = HttpApi.make("Api")
|
||||
.add(
|
||||
HttpApiGroup.make("top", { topLevel: true })
|
||||
.add(
|
||||
HttpApiEndpoint.get("health", "/health")
|
||||
)
|
||||
)
|
||||
.prefix("/v1")
|
||||
|
||||
const builder = HttpApiClient.urlBuilder(TopLevelApi, {
|
||||
baseUrl: "https://api.example.com"
|
||||
})
|
||||
|
||||
strictEqual(builder.health(), "https://api.example.com/v1/health")
|
||||
})
|
||||
|
||||
it("stores __proto__ identifiers as own properties", () => {
|
||||
const Api = HttpApi.make("Api").add(
|
||||
HttpApiGroup.make("__proto__").add(
|
||||
HttpApiEndpoint.get("__proto__", "/proto")
|
||||
)
|
||||
)
|
||||
const builder = HttpApiClient.urlBuilder(Api)
|
||||
|
||||
assert.isTrue(Object.hasOwn(builder, "__proto__"))
|
||||
assert.isTrue(Object.hasOwn(builder["__proto__"], "__proto__"))
|
||||
strictEqual(builder["__proto__"]["__proto__"](), "/proto")
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("stores __proto__ client identifiers as own properties", () =>
|
||||
Effect.gen(function*() {
|
||||
const Api = HttpApi.make("Api").add(
|
||||
HttpApiGroup.make("__proto__").add(
|
||||
HttpApiEndpoint.get("__proto__", "/proto")
|
||||
)
|
||||
)
|
||||
const httpClient = clientFromResponse(() => new Response(null, { status: 204 }))
|
||||
const client = yield* HttpApiClient.makeWith(Api, { httpClient })
|
||||
const groupClient = yield* HttpApiClient.group(Api, {
|
||||
group: "__proto__",
|
||||
httpClient
|
||||
})
|
||||
|
||||
assert.isTrue(Object.hasOwn(client, "__proto__"))
|
||||
assert.isTrue(Object.hasOwn(client["__proto__"], "__proto__"))
|
||||
assert.strictEqual(typeof client["__proto__"]["__proto__"], "function")
|
||||
assert.isTrue(Object.hasOwn(groupClient, "__proto__"))
|
||||
assert.strictEqual(typeof groupClient["__proto__"], "function")
|
||||
}))
|
||||
|
||||
it.effect("applies transformClient to endpoint clients exactly once", () =>
|
||||
Effect.gen(function*() {
|
||||
const Api = HttpApi.make("Api").add(
|
||||
HttpApiGroup.make("test").add(HttpApiEndpoint.get("health", "/health"))
|
||||
)
|
||||
let transformations = 0
|
||||
const httpClient = HttpClient.make((request, url) =>
|
||||
Effect.sync(() => {
|
||||
strictEqual(url.toString(), "https://api.example.com/health")
|
||||
return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))
|
||||
})
|
||||
)
|
||||
const health = yield* HttpApiClient.endpoint(Api, {
|
||||
group: "test",
|
||||
endpoint: "health",
|
||||
httpClient,
|
||||
transformClient: (client) => {
|
||||
transformations++
|
||||
return client.pipe(
|
||||
HttpClient.mapRequest(HttpClientRequest.prependUrl("https://api.example.com"))
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
yield* health({ responseMode: "response-only" })
|
||||
yield* health({ responseMode: "response-only" })
|
||||
|
||||
strictEqual(transformations, 1)
|
||||
}))
|
||||
|
||||
it.effect("encodes path parameters when executing requests", () =>
|
||||
Effect.gen(function*() {
|
||||
const Api = HttpApi.make("Api")
|
||||
.add(
|
||||
HttpApiGroup.make("stacks")
|
||||
.add(
|
||||
HttpApiEndpoint.get("listResources", "/state/stacks/:stack/stages/:stage/resources", {
|
||||
params: {
|
||||
stack: Schema.String,
|
||||
stage: Schema.String
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
const httpClient = HttpClient.make((request, url) =>
|
||||
Effect.sync(() => {
|
||||
strictEqual(url.toString(), "https://api.example.com/state/stacks/a%2Fb/stages/prod%2Fblue/resources")
|
||||
return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))
|
||||
})
|
||||
)
|
||||
const client = yield* HttpApiClient.makeWith(Api, {
|
||||
httpClient,
|
||||
baseUrl: "https://api.example.com"
|
||||
})
|
||||
|
||||
yield* client.stacks.listResources({
|
||||
params: {
|
||||
stack: "a/b",
|
||||
stage: "prod/blue"
|
||||
},
|
||||
responseMode: "response-only"
|
||||
})
|
||||
}))
|
||||
|
||||
it.effect("omits optional path parameters when executing requests", () =>
|
||||
Effect.gen(function*() {
|
||||
const Api = HttpApi.make("Api")
|
||||
.add(
|
||||
HttpApiGroup.make("files")
|
||||
.add(
|
||||
HttpApiEndpoint.get("download", "/files/:path?", {
|
||||
params: {
|
||||
path: Schema.optional(Schema.String)
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
const urls: Array<string> = []
|
||||
const httpClient = HttpClient.make((request, url) =>
|
||||
Effect.sync(() => {
|
||||
urls.push(url.toString())
|
||||
return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))
|
||||
})
|
||||
)
|
||||
const client = yield* HttpApiClient.makeWith(Api, {
|
||||
httpClient,
|
||||
baseUrl: "https://api.example.com"
|
||||
})
|
||||
|
||||
yield* client.files.download({
|
||||
params: {},
|
||||
responseMode: "response-only"
|
||||
})
|
||||
yield* client.files.download({
|
||||
params: { path: "a/b" },
|
||||
responseMode: "response-only"
|
||||
})
|
||||
|
||||
strictEqual(urls[0], "https://api.example.com/files")
|
||||
strictEqual(urls[1], "https://api.example.com/files/a%2Fb")
|
||||
}))
|
||||
})
|
||||
|
||||
const textEncoder = new TextEncoder()
|
||||
|
||||
const StreamError = Schema.Struct({ reason: Schema.String })
|
||||
|
||||
const Events = Schema.Struct({
|
||||
event: Schema.String,
|
||||
data: Schema.String
|
||||
})
|
||||
|
||||
class EndpointError extends Schema.TaggedErrorClass<EndpointError>()("EndpointError", {
|
||||
message: Schema.String
|
||||
}, { httpApiStatus: 400 }) {}
|
||||
|
||||
const MixedSuccess = Schema.Struct({
|
||||
message: Schema.String
|
||||
})
|
||||
|
||||
const MixedEventData = Schema.Struct({
|
||||
text: Schema.String
|
||||
})
|
||||
|
||||
const StreamingApi = HttpApi.make("StreamingApi").add(
|
||||
HttpApiGroup.make("test")
|
||||
.add(
|
||||
HttpApiEndpoint.get("events", "/events", {
|
||||
success: HttpApiSchema.StreamSse({ events: Events, error: StreamError }),
|
||||
error: EndpointError
|
||||
}),
|
||||
HttpApiEndpoint.get("download", "/download", {
|
||||
success: HttpApiSchema.StreamUint8Array(),
|
||||
error: EndpointError
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const AnnotatedStreamingApi = HttpApi.make("AnnotatedStreamingApi").add(
|
||||
HttpApiGroup.make("test")
|
||||
.add(
|
||||
HttpApiEndpoint.get("events", "/events", {
|
||||
success: HttpApiSchema.status(202)(HttpApiSchema.StreamSse({ events: Events, error: StreamError }))
|
||||
}),
|
||||
HttpApiEndpoint.get("download", "/download", {
|
||||
success: HttpApiSchema.status(206)(HttpApiSchema.StreamUint8Array())
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const MixedSuccessApi = HttpApi.make("MixedSuccessApi").add(
|
||||
HttpApiGroup.make("test")
|
||||
.add(
|
||||
HttpApiEndpoint.get("chat", "/chat", {
|
||||
success: [
|
||||
MixedSuccess,
|
||||
HttpApiSchema.StreamSse({ data: MixedEventData, error: StreamError })
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const JsonResponseError = Schema.Struct({
|
||||
_tag: Schema.Literal("JsonError"),
|
||||
message: Schema.String
|
||||
}).pipe(HttpApiSchema.status(400))
|
||||
|
||||
const TextResponseError = Schema.String.pipe(
|
||||
HttpApiSchema.asText(),
|
||||
HttpApiSchema.status(400)
|
||||
)
|
||||
|
||||
const NoContentResponseError = Schema.Literal("NoContentError").pipe(
|
||||
HttpApiSchema.asNoContent({ decode: () => "NoContentError" as const }),
|
||||
HttpApiSchema.status(400)
|
||||
)
|
||||
|
||||
const FirstJsonResponseError = Schema.Struct({
|
||||
_tag: Schema.Literal("FirstJsonError"),
|
||||
code: Schema.Number
|
||||
}).pipe(
|
||||
HttpApiSchema.asJson({ contentType: "Application/Problem+JSON" }),
|
||||
HttpApiSchema.status(400)
|
||||
)
|
||||
|
||||
const SecondJsonResponseError = Schema.Struct({
|
||||
_tag: Schema.Literal("SecondJsonError"),
|
||||
message: Schema.String
|
||||
}).pipe(
|
||||
HttpApiSchema.asJson({ contentType: "application/problem+json; charset=utf-8" }),
|
||||
HttpApiSchema.status(400)
|
||||
)
|
||||
|
||||
const ErrorContentTypeApi = HttpApi.make("ErrorContentTypeApi").add(
|
||||
HttpApiGroup.make("test")
|
||||
.add(
|
||||
HttpApiEndpoint.get("textFirst", "/text-first", {
|
||||
error: [TextResponseError, JsonResponseError]
|
||||
}),
|
||||
HttpApiEndpoint.get("jsonFirst", "/json-first", {
|
||||
error: [JsonResponseError, TextResponseError]
|
||||
}),
|
||||
HttpApiEndpoint.get("noContent", "/no-content", {
|
||||
error: [NoContentResponseError, TextResponseError]
|
||||
}),
|
||||
HttpApiEndpoint.get("equivalentJson", "/equivalent-json", {
|
||||
error: [FirstJsonResponseError, SecondJsonResponseError]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const clientFromResponse = (response: () => Response): HttpClient.HttpClient =>
|
||||
HttpClient.make((request): Effect.Effect<HttpClientResponse.HttpClientResponse, never, never> =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, response()))
|
||||
)
|
||||
|
||||
const textStream = (chunks: ReadonlyArray<string>): ReadableStream<Uint8Array> => {
|
||||
let index = 0
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (index === chunks.length) {
|
||||
controller.close()
|
||||
} else {
|
||||
controller.enqueue(textEncoder.encode(chunks[index++]!))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const byteStream = (chunks: ReadonlyArray<Uint8Array>): ReadableStream<Uint8Array> => {
|
||||
let index = 0
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (index === chunks.length) {
|
||||
controller.close()
|
||||
} else {
|
||||
controller.enqueue(chunks[index++]!)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { assert, describe, it } from "@effect/vitest"
|
||||
import { Effect, type Layer } from "effect"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiScalar, HttpApiSwagger, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
describe("HttpApiScalar", () => {
|
||||
it.effect("escapes OpenAPI metadata in its HTML contexts", () =>
|
||||
Effect.gen(function*() {
|
||||
const title = `Docs "title" </title><script>`
|
||||
const injectedTag = `<script id="script-data-injected">`
|
||||
const description = `"quoted" 'single' </script >${injectedTag}`
|
||||
const Api = HttpApi.make("Docs")
|
||||
.annotate(OpenApi.Title, title)
|
||||
.annotate(OpenApi.Description, description)
|
||||
|
||||
const html = yield* render(HttpApiScalar.layerCdn(Api))
|
||||
|
||||
assert.ok(html.includes(`<title>Docs "title" </title><script></title>`))
|
||||
const escapedDescription =
|
||||
`"quoted" 'single' </script ><script id="script-data-injected">`
|
||||
assert.ok(html.includes(`<meta name="description" content="${escapedDescription}"/>`))
|
||||
assert.ok(html.includes(`<meta name="og:description" content="${escapedDescription}"/>`))
|
||||
assert.ok(!html.includes(`</script >`))
|
||||
assert.deepStrictEqual(extractSpec(html), OpenApi.fromApi(Api))
|
||||
}))
|
||||
|
||||
it.effect("encodes CDN versions before interpolating the script source", () =>
|
||||
Effect.gen(function*() {
|
||||
const version = `1.2.3"></script><script id="injected">`
|
||||
const html = yield* render(HttpApiScalar.layerCdn(HttpApi.make("Docs"), { version }))
|
||||
|
||||
assert.ok(html.includes(
|
||||
`src="https://cdn.jsdelivr.net/npm/@scalar/api-reference@${
|
||||
encodeURIComponent(version)
|
||||
}/dist/browser/standalone.min.js"`
|
||||
))
|
||||
assert.strictEqual(html.match(/<script\b/g)?.length, 2)
|
||||
assert.ok(!html.includes(`id="injected"`))
|
||||
}))
|
||||
})
|
||||
|
||||
describe("HttpApiSwagger", () => {
|
||||
it.effect("escapes script end-tag variants in OpenAPI data", () =>
|
||||
Effect.gen(function*() {
|
||||
const injectedTag = `<script id="script-data-injected">`
|
||||
const Api = HttpApi.make("Docs")
|
||||
.annotate(OpenApi.Description, `</script/>${injectedTag}`)
|
||||
|
||||
const html = yield* render(HttpApiSwagger.layer(Api))
|
||||
|
||||
assert.ok(!html.includes(`</script/>`))
|
||||
assert.deepStrictEqual(extractSwaggerSpec(html), OpenApi.fromApi(Api))
|
||||
}))
|
||||
})
|
||||
|
||||
const render = (layer: Layer.Layer<never, never, HttpRouter.HttpRouter>) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => HttpRouter.toWebHandler(layer, { disableLogger: true })),
|
||||
({ handler }) =>
|
||||
Effect.flatMap(
|
||||
Effect.promise(() => handler(new Request("http://test/docs"))),
|
||||
(response) => Effect.promise(() => response.text())
|
||||
),
|
||||
({ dispose }) => Effect.promise(dispose)
|
||||
)
|
||||
|
||||
function extractSpec(html: string): unknown {
|
||||
const marker = " content: "
|
||||
const start = html.indexOf(marker)
|
||||
assert.notStrictEqual(start, -1)
|
||||
const end = html.indexOf("\n })", start)
|
||||
assert.notStrictEqual(end, -1)
|
||||
return JSON.parse(html.slice(start + marker.length, end))
|
||||
}
|
||||
|
||||
function extractSwaggerSpec(html: string): unknown {
|
||||
const marker = ` <script id="swagger-spec" type="application/json">\n `
|
||||
const start = html.indexOf(marker)
|
||||
assert.notStrictEqual(start, -1)
|
||||
const end = html.indexOf("\n </script>", start)
|
||||
assert.notStrictEqual(end, -1)
|
||||
return JSON.parse(html.slice(start + marker.length, end))
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { assert, describe, it } from "@effect/vitest"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
|
||||
const Events = Schema.Struct({
|
||||
event: Schema.Literal("user.created"),
|
||||
data: Schema.String
|
||||
})
|
||||
const StreamError = Schema.Struct({ reason: Schema.String })
|
||||
|
||||
const sse = () => HttpApiSchema.StreamSse({ events: Events, error: StreamError })
|
||||
|
||||
describe("HttpApiEndpoint", () => {
|
||||
it("stores the supplied identifier", () => {
|
||||
const endpoint = HttpApiEndpoint.get("getUser", "/users/:id")
|
||||
|
||||
assert.strictEqual(endpoint.identifier, "getUser")
|
||||
})
|
||||
|
||||
it("can be extended as a class", () => {
|
||||
const endpoint = HttpApiEndpoint.get("getUser", "/users/:id")
|
||||
class GetUser extends endpoint {}
|
||||
|
||||
assert.strictEqual(typeof endpoint, "function")
|
||||
assert.strictEqual(GetUser.name, "GetUser")
|
||||
assert.strictEqual(GetUser.identifier, "getUser")
|
||||
assert.isTrue(HttpApiEndpoint.isHttpApiEndpoint(GetUser))
|
||||
|
||||
const prefixed = GetUser.prefix("/v1")
|
||||
assert.strictEqual(prefixed.identifier, "getUser")
|
||||
assert.strictEqual(prefixed.path, "/v1/users/:id")
|
||||
})
|
||||
})
|
||||
|
||||
describe("HttpApiEndpoint payload schemas", () => {
|
||||
it("normalizes payload map keys while preserving the declared content type", () => {
|
||||
const contentType = "Application/Vnd.Effect+JSON; Charset=UTF-8"
|
||||
const endpoint = HttpApiEndpoint.post("create", "/", {
|
||||
payload: Schema.Struct({ name: Schema.String }).pipe(HttpApiSchema.asJson({ contentType }))
|
||||
})
|
||||
|
||||
const entry = endpoint.payload.get("application/vnd.effect+json")
|
||||
assert.isDefined(entry)
|
||||
assert.strictEqual(entry.encoding.contentType, contentType)
|
||||
})
|
||||
|
||||
it("rejects incompatible encodings for equivalent content types", () => {
|
||||
const JsonPayload = Schema.Struct({ name: Schema.String }).pipe(
|
||||
HttpApiSchema.asJson({ contentType: "Application/Vnd.Effect+Data; charset=utf-8" })
|
||||
)
|
||||
const TextPayload = Schema.String.pipe(
|
||||
HttpApiSchema.asText({ contentType: "application/vnd.effect+data" })
|
||||
)
|
||||
|
||||
assert.throws(
|
||||
() => HttpApiEndpoint.post("create", "/", { payload: [JsonPayload, TextPayload] }),
|
||||
/Multiple payload encodings/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("HttpApiEndpoint streaming success schemas", () => {
|
||||
it("GET endpoint accepts StreamSse success", () => {
|
||||
const stream = sse()
|
||||
const endpoint = HttpApiEndpoint.get("events", "/events", {
|
||||
success: stream
|
||||
})
|
||||
|
||||
assert.isTrue(endpoint.success.has(stream))
|
||||
})
|
||||
|
||||
it("GET endpoint accepts StreamUint8Array success", () => {
|
||||
const stream = HttpApiSchema.StreamUint8Array()
|
||||
const endpoint = HttpApiEndpoint.get("download", "/download", {
|
||||
success: stream
|
||||
})
|
||||
|
||||
assert.isTrue(endpoint.success.has(stream))
|
||||
})
|
||||
|
||||
it("streaming schema in error throws during endpoint construction", () => {
|
||||
assert.throws(() =>
|
||||
HttpApiEndpoint.get("events", "/events", {
|
||||
error: sse() as any
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("HEAD with streaming success throws", () => {
|
||||
assert.throws(() =>
|
||||
HttpApiEndpoint.head("events", "/events", {
|
||||
success: sse()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("streaming success mixed with NoContent at the same status throws", () => {
|
||||
assert.throws(() =>
|
||||
HttpApiEndpoint.get("events", "/events", {
|
||||
success: [
|
||||
sse(),
|
||||
HttpApiSchema.NoContent.pipe(HttpApiSchema.status(200))
|
||||
]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("streaming success mixed with a buffered success at the same status is allowed for distinct content types", () => {
|
||||
const stream = sse()
|
||||
const endpoint = HttpApiEndpoint.get("events", "/events", {
|
||||
success: [
|
||||
stream,
|
||||
Schema.Struct({ ok: Schema.Boolean })
|
||||
]
|
||||
})
|
||||
|
||||
assert.isTrue(endpoint.success.has(stream))
|
||||
})
|
||||
|
||||
it("streaming success mixed with a buffered success at the same content type throws", () => {
|
||||
assert.throws(() =>
|
||||
HttpApiEndpoint.get("events", "/events", {
|
||||
success: [
|
||||
HttpApiSchema.StreamSse({ contentType: "application/json", events: Events, error: StreamError }),
|
||||
Schema.Struct({ ok: Schema.Boolean })
|
||||
]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("streaming success mixed with a buffered success at content types differing only by parameters throws", () => {
|
||||
assert.throws(() =>
|
||||
HttpApiEndpoint.get("events", "/events", {
|
||||
success: [
|
||||
HttpApiSchema.StreamSse({
|
||||
contentType: "application/json; charset=utf-8",
|
||||
events: Events,
|
||||
error: StreamError
|
||||
}),
|
||||
Schema.Struct({ ok: Schema.Boolean })
|
||||
]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("streaming success mixed with a buffered success at distinct statuses is allowed", () => {
|
||||
const stream = HttpApiSchema.status(206)(sse())
|
||||
const endpoint = HttpApiEndpoint.get("events", "/events", {
|
||||
success: [
|
||||
stream,
|
||||
Schema.Struct({ ok: Schema.Boolean })
|
||||
]
|
||||
})
|
||||
|
||||
assert.isTrue(endpoint.success.has(stream))
|
||||
})
|
||||
|
||||
it("streaming success mixed with NoContent at distinct statuses is allowed", () => {
|
||||
const stream = HttpApiSchema.status(200)(sse())
|
||||
const endpoint = HttpApiEndpoint.get("events", "/events", {
|
||||
success: [
|
||||
stream,
|
||||
HttpApiSchema.NoContent
|
||||
]
|
||||
})
|
||||
|
||||
assert.isTrue(endpoint.success.has(stream))
|
||||
})
|
||||
|
||||
it("two streaming successes for the same status throw", () => {
|
||||
assert.throws(() =>
|
||||
HttpApiEndpoint.get("events", "/events", {
|
||||
success: [
|
||||
sse(),
|
||||
HttpApiSchema.StreamUint8Array({ contentType: "application/custom-stream" })
|
||||
]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("two streaming successes for distinct statuses are allowed", () => {
|
||||
const stream = HttpApiSchema.status(206)(sse())
|
||||
const bytes = HttpApiSchema.status(200)(
|
||||
HttpApiSchema.StreamUint8Array({ contentType: "application/custom-stream" })
|
||||
)
|
||||
const endpoint = HttpApiEndpoint.get("events", "/events", {
|
||||
success: [stream, bytes]
|
||||
})
|
||||
|
||||
assert.isTrue(endpoint.success.has(stream))
|
||||
assert.isTrue(endpoint.success.has(bytes))
|
||||
})
|
||||
|
||||
it("statically detectable SSE reserved failure event name throws", () => {
|
||||
const stream = HttpApiSchema.StreamSse({
|
||||
events: Schema.Struct({
|
||||
event: Schema.Literal("effect/httpapi/stream/failure"),
|
||||
data: Schema.String
|
||||
}),
|
||||
error: StreamError
|
||||
})
|
||||
|
||||
assert.throws(() =>
|
||||
HttpApiEndpoint.get("events", "/events", {
|
||||
success: stream
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { assert, describe, it } from "@effect/vitest"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiSchema } from "effect/unstable/httpapi"
|
||||
|
||||
const getStreamMetadata = (self: HttpApiSchema.StreamSchema) =>
|
||||
self._tag === "StreamSse" ?
|
||||
{
|
||||
mode: self.mode,
|
||||
sseMode: self.sseMode,
|
||||
contentType: self.contentType,
|
||||
events: self.events,
|
||||
error: self.error
|
||||
} :
|
||||
{
|
||||
mode: self.mode,
|
||||
contentType: self.contentType
|
||||
}
|
||||
|
||||
describe("HttpApiSchema", () => {
|
||||
describe("StreamSse", () => {
|
||||
it("stores default metadata", () => {
|
||||
const events = Schema.Struct({
|
||||
event: Schema.Literal("user.created"),
|
||||
data: Schema.String
|
||||
})
|
||||
const error = Schema.Struct({ reason: Schema.String })
|
||||
const stream = HttpApiSchema.StreamSse({ events, error })
|
||||
|
||||
assert.isTrue(Schema.isSchema(stream))
|
||||
assert.isTrue(HttpApiSchema.isStreamSchema(stream))
|
||||
assert.isTrue(HttpApiSchema.isStreamSse(stream))
|
||||
assert.isFalse(HttpApiSchema.isStreamUint8Array(stream))
|
||||
assert.strictEqual(stream.mode, "sse")
|
||||
assert.strictEqual(stream.sseMode, "events")
|
||||
assert.strictEqual(stream.contentType, "text/event-stream")
|
||||
assert.strictEqual(stream.events, events)
|
||||
assert.strictEqual(stream.error, error)
|
||||
|
||||
const metadata = getStreamMetadata(stream)
|
||||
assert.strictEqual(metadata.mode, "sse")
|
||||
assert.strictEqual(metadata.contentType, "text/event-stream")
|
||||
if (metadata.mode === "sse") {
|
||||
assert.strictEqual(metadata.sseMode, "events")
|
||||
assert.strictEqual(metadata.events, events)
|
||||
assert.strictEqual(metadata.error, error)
|
||||
}
|
||||
})
|
||||
|
||||
it("stores custom content type", () => {
|
||||
const events = Schema.Struct({
|
||||
event: Schema.Literal("custom"),
|
||||
data: Schema.String
|
||||
})
|
||||
const error = Schema.String
|
||||
const stream = HttpApiSchema.StreamSse({
|
||||
contentType: "text/event-stream; charset=utf-8",
|
||||
events,
|
||||
error
|
||||
})
|
||||
|
||||
assert.strictEqual(stream.contentType, "text/event-stream; charset=utf-8")
|
||||
})
|
||||
|
||||
it("defaults the stream error schema to Never", () => {
|
||||
const events = Schema.Struct({
|
||||
event: Schema.Literal("custom"),
|
||||
data: Schema.String
|
||||
})
|
||||
const stream = HttpApiSchema.StreamSse({ events })
|
||||
|
||||
assert.strictEqual(stream.error, Schema.Never)
|
||||
})
|
||||
|
||||
it("creates an event schema from a JSON data schema", () => {
|
||||
const Data = Schema.Struct({ id: Schema.String })
|
||||
const error = Schema.Struct({ reason: Schema.String })
|
||||
const stream = HttpApiSchema.StreamSse({ data: Data, error })
|
||||
const encode = Schema.encodeUnknownSync(stream.events)
|
||||
const decode = Schema.decodeUnknownSync(stream.events)
|
||||
|
||||
assert.strictEqual(stream.sseMode, "data")
|
||||
assert.deepStrictEqual(
|
||||
encode({ id: undefined, event: "user.created", data: { id: "123" } }),
|
||||
{ id: undefined, event: "user.created", data: "{\"id\":\"123\"}" }
|
||||
)
|
||||
assert.deepStrictEqual(
|
||||
decode({ id: undefined, event: "user.created", data: "{\"id\":\"123\"}" }),
|
||||
{ id: undefined, event: "user.created", data: { id: "123" } }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("StreamUint8Array", () => {
|
||||
it("stores default metadata", () => {
|
||||
const stream = HttpApiSchema.StreamUint8Array()
|
||||
|
||||
assert.isTrue(Schema.isSchema(stream))
|
||||
assert.isTrue(HttpApiSchema.isStreamSchema(stream))
|
||||
assert.isFalse(HttpApiSchema.isStreamSse(stream))
|
||||
assert.isTrue(HttpApiSchema.isStreamUint8Array(stream))
|
||||
assert.strictEqual(stream.mode, "uint8array")
|
||||
assert.strictEqual(stream.contentType, "application/octet-stream")
|
||||
assert.deepStrictEqual(getStreamMetadata(stream), {
|
||||
mode: "uint8array",
|
||||
contentType: "application/octet-stream"
|
||||
})
|
||||
})
|
||||
|
||||
it("stores custom content type", () => {
|
||||
const stream = HttpApiSchema.StreamUint8Array({
|
||||
contentType: "application/custom-binary"
|
||||
})
|
||||
|
||||
assert.strictEqual(stream.contentType, "application/custom-binary")
|
||||
})
|
||||
})
|
||||
|
||||
it("does not identify buffered schemas as stream schemas", () => {
|
||||
assert.isFalse(HttpApiSchema.isStreamSchema(Schema.String))
|
||||
assert.isFalse(HttpApiSchema.isStreamSchema(Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array())))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { assert, describe, it } from "@effect/vitest"
|
||||
import { Effect, Encoding, Redacted } from "effect"
|
||||
import { HttpClientRequest, HttpServerRequest } from "effect/unstable/http"
|
||||
import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi"
|
||||
|
||||
const decode = <Security extends HttpApiSecurity.HttpApiSecurity>(authorization: string, security: Security) =>
|
||||
HttpApiBuilder.securityDecode(security).pipe(
|
||||
Effect.provideService(
|
||||
HttpServerRequest.HttpServerRequest,
|
||||
HttpServerRequest.fromWeb(new Request("http://localhost/", { headers: { authorization } }))
|
||||
),
|
||||
Effect.provideService(HttpServerRequest.ParsedSearchParams, {})
|
||||
)
|
||||
|
||||
describe("HttpApiSecurity", () => {
|
||||
describe("securityDecode", () => {
|
||||
it.effect("decodes a bearer token without a leading space", () =>
|
||||
Effect.gen(function*() {
|
||||
const token = "abc123"
|
||||
const { headers } = HttpClientRequest.get("http://localhost/").pipe(
|
||||
HttpClientRequest.bearerToken(token)
|
||||
)
|
||||
const credential = yield* HttpApiBuilder.securityDecode(HttpApiSecurity.bearer).pipe(
|
||||
Effect.provideService(
|
||||
HttpServerRequest.HttpServerRequest,
|
||||
HttpServerRequest.fromWeb(new Request("http://localhost/", { headers }))
|
||||
),
|
||||
Effect.provideService(HttpServerRequest.ParsedSearchParams, {})
|
||||
)
|
||||
|
||||
assert.strictEqual(Redacted.value(credential), token)
|
||||
}))
|
||||
|
||||
it.effect("decodes a custom http scheme without a leading space", () =>
|
||||
Effect.gen(function*() {
|
||||
const credential = yield* decode("Token abc123", HttpApiSecurity.http({ scheme: "Token" }))
|
||||
|
||||
assert.strictEqual(Redacted.value(credential), "abc123")
|
||||
}))
|
||||
|
||||
it.effect("matches HTTP schemes case-insensitively", () =>
|
||||
Effect.gen(function*() {
|
||||
const credential = yield* decode("bEaReR abc123", HttpApiSecurity.bearer)
|
||||
|
||||
assert.strictEqual(Redacted.value(credential), "abc123")
|
||||
}))
|
||||
|
||||
it.effect("accepts multiple spaces before HTTP credentials", () =>
|
||||
Effect.gen(function*() {
|
||||
const credential = yield* decode("Bearer abc123", HttpApiSecurity.bearer)
|
||||
|
||||
assert.strictEqual(Redacted.value(credential), "abc123")
|
||||
}))
|
||||
|
||||
it.effect("rejects mismatched and malformed HTTP schemes", () =>
|
||||
Effect.gen(function*() {
|
||||
const mismatched = yield* decode("Basic abc123", HttpApiSecurity.bearer)
|
||||
const malformed = yield* decode("Bearerabc123", HttpApiSecurity.bearer)
|
||||
|
||||
assert.strictEqual(Redacted.value(mismatched), "")
|
||||
assert.strictEqual(Redacted.value(malformed), "")
|
||||
}))
|
||||
|
||||
it.effect("decodes Basic credentials using the first colon separator", () =>
|
||||
Effect.gen(function*() {
|
||||
const encoded = Encoding.encodeBase64("alice:secret:with:colons")
|
||||
const credential = yield* decode(`Basic ${encoded}`, HttpApiSecurity.basic)
|
||||
|
||||
assert.strictEqual(credential.username, "alice")
|
||||
assert.strictEqual(Redacted.value(credential.password), "secret:with:colons")
|
||||
}))
|
||||
|
||||
it.effect("rejects Basic credentials from a different scheme", () =>
|
||||
Effect.gen(function*() {
|
||||
const encoded = Encoding.encodeBase64("alice:secret")
|
||||
const credential = yield* decode(`Bearer ${encoded}`, HttpApiSecurity.basic)
|
||||
|
||||
assert.strictEqual(credential.username, "")
|
||||
assert.strictEqual(Redacted.value(credential.password), "")
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,292 @@
|
||||
import { assert, describe, it } from "@effect/vitest"
|
||||
import { type Context, Schema } from "effect"
|
||||
import {
|
||||
HttpApi,
|
||||
HttpApiEndpoint,
|
||||
HttpApiGroup,
|
||||
HttpApiMiddleware,
|
||||
HttpApiSchema,
|
||||
HttpApiSecurity,
|
||||
OpenApi
|
||||
} from "effect/unstable/httpapi"
|
||||
|
||||
class HeaderAuthA extends HttpApiMiddleware.Service<HeaderAuthA>()("HeaderAuthA", {
|
||||
security: {
|
||||
auth: HttpApiSecurity.apiKey({ key: "x-api-key" })
|
||||
}
|
||||
}) {}
|
||||
|
||||
class HeaderAuthB extends HttpApiMiddleware.Service<HeaderAuthB>()("HeaderAuthB", {
|
||||
security: {
|
||||
auth: HttpApiSecurity.apiKey({ key: "x-api-key" })
|
||||
}
|
||||
}) {}
|
||||
|
||||
class HeaderAuthUpper extends HttpApiMiddleware.Service<HeaderAuthUpper>()("HeaderAuthUpper", {
|
||||
security: {
|
||||
auth: HttpApiSecurity.apiKey({ key: "X-API-Key" })
|
||||
}
|
||||
}) {}
|
||||
|
||||
class QueryAuth extends HttpApiMiddleware.Service<QueryAuth>()("QueryAuth", {
|
||||
security: {
|
||||
auth: HttpApiSecurity.apiKey({ key: "api_key", in: "query" })
|
||||
}
|
||||
}) {}
|
||||
|
||||
class TokenAuthUpper extends HttpApiMiddleware.Service<TokenAuthUpper>()("TokenAuthUpper", {
|
||||
security: {
|
||||
auth: HttpApiSecurity.http({ scheme: "Token" })
|
||||
}
|
||||
}) {}
|
||||
|
||||
class TokenAuthLower extends HttpApiMiddleware.Service<TokenAuthLower>()("TokenAuthLower", {
|
||||
security: {
|
||||
auth: HttpApiSecurity.http({ scheme: "token" })
|
||||
}
|
||||
}) {}
|
||||
|
||||
class ProtoHeaderAuth extends HttpApiMiddleware.Service<ProtoHeaderAuth>()("ProtoHeaderAuth", {
|
||||
security: {
|
||||
["__proto__"]: HttpApiSecurity.apiKey({ key: "x-api-key" })
|
||||
}
|
||||
}) {}
|
||||
|
||||
class ProtoQueryAuth extends HttpApiMiddleware.Service<ProtoQueryAuth>()("ProtoQueryAuth", {
|
||||
security: {
|
||||
["__proto__"]: HttpApiSecurity.apiKey({ key: "api_key", in: "query" })
|
||||
}
|
||||
}) {}
|
||||
|
||||
const makeSecurityApi = (
|
||||
first: Context.Key<any, any>,
|
||||
second: Context.Key<any, any>
|
||||
) =>
|
||||
HttpApi.make("Api").add(
|
||||
HttpApiGroup.make("test").add(
|
||||
HttpApiEndpoint.get("first", "/first").middleware(first),
|
||||
HttpApiEndpoint.get("second", "/second").middleware(second)
|
||||
)
|
||||
)
|
||||
|
||||
describe("OpenApi", () => {
|
||||
it("preserves every declared payload content type for normalized equivalents", () => {
|
||||
const profileA = "Application/Vnd.Effect+JSON; Profile=A"
|
||||
const profileB = "application/vnd.effect+json; profile=b"
|
||||
const Api = HttpApi.make("Api").add(
|
||||
HttpApiGroup.make("test").add(
|
||||
HttpApiEndpoint.post("create", "/create", {
|
||||
payload: [
|
||||
Schema.Struct({ a: Schema.String }).pipe(HttpApiSchema.asJson({ contentType: profileA })),
|
||||
Schema.Struct({ b: Schema.String }).pipe(HttpApiSchema.asJson({ contentType: profileB }))
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const spec = OpenApi.fromApi(Api)
|
||||
const content = spec.paths["/create"]?.post?.requestBody?.content
|
||||
|
||||
assert.isDefined(content)
|
||||
assert.property(content, profileA)
|
||||
assert.property(content, profileB)
|
||||
assert.deepStrictEqual(content[profileA]?.schema, {
|
||||
type: "object",
|
||||
properties: { a: { type: "string" } },
|
||||
required: ["a"],
|
||||
additionalProperties: false
|
||||
})
|
||||
assert.deepStrictEqual(content[profileB]?.schema, {
|
||||
type: "object",
|
||||
properties: { b: { type: "string" } },
|
||||
required: ["b"],
|
||||
additionalProperties: false
|
||||
})
|
||||
})
|
||||
|
||||
it("emits buffered and stream successes with the same status", () => {
|
||||
const Api = HttpApi.make("Api").add(
|
||||
HttpApiGroup.make("test").add(
|
||||
HttpApiEndpoint.get("chat", "/chat", {
|
||||
success: [
|
||||
Schema.Struct({ message: Schema.String }),
|
||||
HttpApiSchema.StreamSse({
|
||||
events: Schema.Struct({ event: Schema.String, data: Schema.String }),
|
||||
error: Schema.Struct({ reason: Schema.String })
|
||||
})
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const spec = OpenApi.fromApi(Api)
|
||||
const content = spec.paths["/chat"]?.get?.responses[200]?.content
|
||||
|
||||
assert.isNotNull(content)
|
||||
assert.property(content, "application/json")
|
||||
assert.property(content, "text/event-stream")
|
||||
const streamExtension = content?.["text/event-stream"]?.["x-effect-stream"]
|
||||
assert.isNotNull(streamExtension)
|
||||
if (streamExtension?.encoding !== "sse") {
|
||||
throw new Error("Expected SSE stream extension")
|
||||
}
|
||||
assert.strictEqual(
|
||||
streamExtension.failureEvent,
|
||||
"effect/httpapi/stream/failure"
|
||||
)
|
||||
assert.property(streamExtension, "causeSchema")
|
||||
assert.property(streamExtension, "errorSchema")
|
||||
})
|
||||
|
||||
it("preserves the data schema identifier for SSE streams", () => {
|
||||
const Event = Schema.Struct({
|
||||
kind: Schema.String,
|
||||
payload: Schema.String
|
||||
}).annotate({ identifier: "MyEvent" })
|
||||
|
||||
const Api = HttpApi.make("Api").add(
|
||||
HttpApiGroup.make("test").add(
|
||||
HttpApiEndpoint.get("stream", "/stream", {
|
||||
success: [HttpApiSchema.StreamSse({ data: Event })]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const spec = OpenApi.fromApi(Api)
|
||||
const schemas = spec.components?.schemas
|
||||
|
||||
// The decoded data schema keeps its identifier.
|
||||
assert.deepStrictEqual(schemas?.MyEvent, {
|
||||
type: "object",
|
||||
properties: {
|
||||
kind: { type: "string" },
|
||||
payload: { type: "string" }
|
||||
},
|
||||
required: ["kind", "payload"],
|
||||
additionalProperties: false
|
||||
})
|
||||
})
|
||||
|
||||
it("rejects duplicate method and path pairs", () => {
|
||||
const Api = HttpApi.make("Api").add(
|
||||
HttpApiGroup.make("test").add(
|
||||
HttpApiEndpoint.get("first", "/shared"),
|
||||
HttpApiEndpoint.get("second", "/shared")
|
||||
)
|
||||
)
|
||||
|
||||
assert.throws(() => OpenApi.fromApi(Api), /Duplicate OpenAPI operation for GET \/shared/)
|
||||
})
|
||||
|
||||
it("rejects equivalent templated method and path pairs", () => {
|
||||
const Api = HttpApi.make("Api").add(
|
||||
HttpApiGroup.make("test").add(
|
||||
HttpApiEndpoint.get("first", "/users/:id"),
|
||||
HttpApiEndpoint.get("second", "/users/:userId")
|
||||
)
|
||||
)
|
||||
|
||||
assert.throws(() => OpenApi.fromApi(Api), /Duplicate OpenAPI operation for GET \/users\/\{userId\}/)
|
||||
})
|
||||
|
||||
it("rejects duplicate operation identifiers", () => {
|
||||
const Api = HttpApi.make("Api").add(
|
||||
HttpApiGroup.make("test").add(
|
||||
HttpApiEndpoint.get("first", "/first").annotate(OpenApi.Identifier, "shared"),
|
||||
HttpApiEndpoint.get("second", "/second").annotate(OpenApi.Identifier, "shared")
|
||||
)
|
||||
)
|
||||
|
||||
assert.throws(() => OpenApi.fromApi(Api), /Duplicate OpenAPI operationId: shared/)
|
||||
})
|
||||
|
||||
it("allows operations without identifiers", () => {
|
||||
const removeOperationId = (operation: Record<string, any>) => {
|
||||
const transformed = { ...operation }
|
||||
delete transformed.operationId
|
||||
return transformed
|
||||
}
|
||||
const Api = HttpApi.make("Api").add(
|
||||
HttpApiGroup.make("test").add(
|
||||
HttpApiEndpoint.get("first", "/first").annotate(OpenApi.Transform, removeOperationId),
|
||||
HttpApiEndpoint.get("second", "/second").annotate(OpenApi.Transform, removeOperationId)
|
||||
)
|
||||
)
|
||||
|
||||
const spec = OpenApi.fromApi(Api)
|
||||
const first = spec.paths["/first"]?.get
|
||||
const second = spec.paths["/second"]?.get
|
||||
|
||||
assert.isDefined(first)
|
||||
assert.isDefined(second)
|
||||
assert.isFalse(Object.hasOwn(first, "operationId"))
|
||||
assert.isFalse(Object.hasOwn(second, "operationId"))
|
||||
})
|
||||
|
||||
it("rejects incompatible security schemes with the same name", () => {
|
||||
const Api = makeSecurityApi(HeaderAuthA, QueryAuth)
|
||||
|
||||
assert.throws(() => OpenApi.fromApi(Api), /Conflicting OpenAPI security scheme: auth/)
|
||||
})
|
||||
|
||||
it("deduplicates equivalent security schemes with the same name", () => {
|
||||
const Api = makeSecurityApi(HeaderAuthA, HeaderAuthB)
|
||||
|
||||
const spec = OpenApi.fromApi(Api)
|
||||
|
||||
assert.deepStrictEqual(spec.components.securitySchemes.auth, {
|
||||
type: "apiKey",
|
||||
name: "x-api-key",
|
||||
in: "header"
|
||||
})
|
||||
})
|
||||
|
||||
it("deduplicates API key header names case-insensitively", () => {
|
||||
const Api = makeSecurityApi(HeaderAuthUpper, HeaderAuthA)
|
||||
|
||||
const spec = OpenApi.fromApi(Api)
|
||||
|
||||
assert.deepStrictEqual(spec.components.securitySchemes.auth, {
|
||||
type: "apiKey",
|
||||
name: "X-API-Key",
|
||||
in: "header"
|
||||
})
|
||||
})
|
||||
|
||||
it("deduplicates HTTP security schemes case-insensitively", () => {
|
||||
const Api = makeSecurityApi(TokenAuthUpper, TokenAuthLower)
|
||||
|
||||
const spec = OpenApi.fromApi(Api)
|
||||
|
||||
assert.deepStrictEqual(spec.components.securitySchemes.auth, {
|
||||
type: "http",
|
||||
scheme: "Token"
|
||||
})
|
||||
})
|
||||
|
||||
it("stores __proto__ security scheme names as own properties", () => {
|
||||
const Api = HttpApi.make("Api").add(
|
||||
HttpApiGroup.make("test").add(
|
||||
HttpApiEndpoint.get("first", "/first").middleware(ProtoHeaderAuth)
|
||||
)
|
||||
)
|
||||
|
||||
const spec = OpenApi.fromApi(Api)
|
||||
const schemes = spec.components.securitySchemes
|
||||
|
||||
assert.isTrue(Object.hasOwn(schemes, "__proto__"))
|
||||
assert.deepStrictEqual(Object.keys(schemes), ["__proto__"])
|
||||
assert.strictEqual(Object.getPrototypeOf(schemes), Object.prototype)
|
||||
assert.deepStrictEqual(schemes["__proto__"], {
|
||||
type: "apiKey",
|
||||
name: "x-api-key",
|
||||
in: "header"
|
||||
})
|
||||
})
|
||||
|
||||
it("rejects incompatible __proto__ security schemes", () => {
|
||||
const Api = makeSecurityApi(ProtoHeaderAuth, ProtoQueryAuth)
|
||||
|
||||
assert.throws(() => OpenApi.fromApi(Api), /Conflicting OpenAPI security scheme: __proto__/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user