1import * as OpenApiGenerator from "@effect/openapi-generator/OpenApiGenerator"
2import { assert, describe, it } from "@effect/vitest"
3import * as Data from "effect/Data"
4import * as Effect from "effect/Effect"
5import * as Sse from "effect/encoding/Sse"
6import type { OpenAPISpec } from "effect/http-api/OpenApi"
7import * as HttpClient from "effect/http/HttpClient"
8import * as HttpClientError from "effect/http/HttpClientError"
9import * as HttpClientRequest from "effect/http/HttpClientRequest"
10import * as HttpClientResponse from "effect/http/HttpClientResponse"
11import * as Schema from "effect/Schema"
12import * as Stream from "effect/Stream"
13import { rolldown } from "rolldown"
14
15const modules = {
16 "effect/Data": Data,
17 "effect/Effect": Effect,
18 "effect/Schema": Schema,
19 "effect/Stream": Stream,
20 "effect/encoding/Sse": Sse,
21 "effect/http/HttpClient": HttpClient,
22 "effect/http/HttpClientError": HttpClientError,
23 "effect/http/HttpClientRequest": HttpClientRequest,
24 "effect/http/HttpClientResponse": HttpClientResponse
25}
26
27type TransformClient = (client: HttpClient.HttpClient) => Effect.Effect<HttpClient.HttpClient>
28
29async function loadClient<
30 Client = {
31 readStream: () => Stream.Stream<unknown, HttpClientError.HttpClientError>
32 readSse: () => Stream.Stream<unknown, HttpClientError.HttpClientError>
33 }
34>(source: string) {
35 const bundle = await rolldown({
36 input: "client.ts",
37 external: (id) => id !== "client.ts",
38 plugins: [{
39 name: "generated-client",
40 resolveId: (id) => id === "client.ts" ? id : undefined,
41 load: (id) => id === "client.ts" ? source : undefined
42 }]
43 })
44 try {
45 const { output } = await bundle.generate({ format: "cjs" })
46 const compiled = output[0]
47 assert.strictEqual(compiled.type, "chunk")
48 const exports = {} as {
49 make: (httpClient: HttpClient.HttpClient, options?: { transformClient: TransformClient }) => Client
50 }
51 new Function("require", "exports", compiled.code)(
52 (id: keyof typeof modules) => {
53 assert.property(modules, id)
54 return modules[id]
55 },
56 exports
57 )
58 return exports.make
59 } finally {
60 await bundle.close()
61 }
62}
63
64const paths = [
65 { format: "httpclient", encoding: "binary" },
66 { format: "httpclient", encoding: "sse-data" },
67 { format: "httpclient", encoding: "sse-event" },
68 { format: "httpclient-type-only", encoding: "binary" },
69 { format: "httpclient-type-only", encoding: "sse-data" }
70] as const
71
72describe("OpenApiTransformer", () => {
73 it.effect("returns only the body when a nullable response config is undefined", () =>
74 Effect.gen(function*() {
75 const spec: OpenAPISpec = {
76 openapi: "3.1.0",
77 info: { title: "Nullable include response", version: "1.0.0" },
78 components: { schemas: {}, securitySchemes: {} },
79 security: [],
80 tags: [],
81 paths: {
82 "/value": {
83 get: {
84 operationId: "getValue",
85 parameters: [],
86 tags: ["Value"],
87 security: [],
88 responses: {
89 "200": {
90 description: "Value",
91 content: { "application/json": { schema: { type: "string" } } }
92 }
93 }
94 }
95 }
96 }
97 }
98 for (const format of ["httpclient", "httpclient-type-only"] as const) {
99 const source = yield* Effect.gen(function*() {
100 const generator = yield* OpenApiGenerator.OpenApiGenerator
101 return yield* generator.generate(spec, { name: "TestClient", format })
102 }).pipe(Effect.provide(
103 format === "httpclient"
104 ? OpenApiGenerator.layerTransformerSchema
105 : OpenApiGenerator.layerTransformerTs
106 ))
107 const make = yield* Effect.promise(() =>
108 loadClient<{
109 getValue: (options?: { config?: { includeResponse?: boolean } | undefined }) => Effect.Effect<unknown>
110 }>(source)
111 )
112 const client = make(
113 HttpClient.make((request) =>
114 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(JSON.stringify("hello"))))
115 ).pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl("https://example.com")))
116 )
117
118 const body = yield* client.getValue({ config: undefined })
119 assert.strictEqual(body, "hello")
120
121 const withResponse = yield* client.getValue({ config: { includeResponse: true } })
122 if (!Array.isArray(withResponse)) throw new Error("Expected response tuple")
123 assert.strictEqual(withResponse[0], "hello")
124 assert.strictEqual((withResponse[1] as HttpClientResponse.HttpClientResponse).status, 200)
125 }
126 }))
127
128 describe("path parameters", () => {
129 const generate = Effect.gen(function*() {
130 const generator = yield* OpenApiGenerator.OpenApiGenerator
131 const source = yield* generator.generate({
132 openapi: "3.1.0",
133 info: { title: "Files", version: "1.0.0" },
134 components: { schemas: {}, securitySchemes: {} },
135 security: [],
136 tags: [],
137 paths: {
138 "/files/{id}/content": {
139 get: {
140 operationId: "read",
141 parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
142 tags: ["Files"],
143 security: [],
144 responses: {
145 "200": {
146 description: "File contents",
147 content: { "application/octet-stream": { schema: { type: "string" } } }
148 }
149 }
150 }
151 }
152 }
153 }, { name: "TestClient", format: "httpclient-type-only" })
154 return yield* Effect.promise(() =>
155 loadClient<{
156 read: (id: string) => Effect.Effect<unknown, HttpClientError.HttpClientError>
157 readStream: (id: string) => Stream.Stream<unknown, HttpClientError.HttpClientError>
158 }>(source)
159 )
160 }).pipe(Effect.provide(OpenApiGenerator.layerTransformerTs))
161
162 it.effect("percent-encodes each parameter as one path segment", () =>
163 Effect.gen(function*() {
164 const make = yield* generate
165 const urls: Array<string> = []
166 const client = make(
167 HttpClient.make((request, url) => {
168 urls.push(url.href)
169 return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("")))
170 }).pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl("https://example.com")))
171 )
172 for (
173 const [value, encoded] of [
174 ["alice", "alice"],
175 ["../a/b?c#d\\e%20", "..%2Fa%2Fb%3Fc%23d%5Ce%2520"],
176 ["hello world/é", "hello%20world%2F%C3%A9"]
177 ]
178 ) {
179 yield* client.read(value)
180 assert.strictEqual(urls.at(-1), `https://example.com/files/${encoded}/content`)
181 }
182 assert.strictEqual(urls.length, 3)
183 }))
184
185 it.effect("rejects empty and dot parameters with a typed error before sending", () =>
186 Effect.gen(function*() {
187 const make = yield* generate
188 let requests = 0
189 const client = make(
190 HttpClient.make(() => {
191 requests++
192 return Effect.die("Unexpected request")
193 }).pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl("https://example.com")))
194 )
195 for (const value of ["", ".", ".."]) {
196 const error = yield* Effect.flip(client.read(value))
197 assert.instanceOf(error, HttpClientError.HttpClientError)
198 assert.instanceOf(error.reason, HttpClientError.InvalidUrlError)
199 }
200 assert.strictEqual(requests, 0)
201 }))
202
203 it.effect("propagates path rejection through the stream error channel", () =>
204 Effect.gen(function*() {
205 const make = yield* generate
206 let requests = 0
207 const client = make(
208 HttpClient.make(() => {
209 requests++
210 return Effect.die("Unexpected request")
211 }).pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl("https://example.com")))
212 )
213 const error = yield* Effect.flip(Stream.runCollect(client.readStream("..")))
214 assert.instanceOf(error, HttpClientError.HttpClientError)
215 assert.instanceOf(error.reason, HttpClientError.InvalidUrlError)
216 assert.strictEqual(requests, 0)
217 }))
218 })
219
220 for (const { encoding, format } of paths) {
221 const binary = encoding === "binary"
222 const body = binary
223 ? "download"
224 : encoding === "sse-event"
225 ? "event: message\nid: 1\ndata: hello\n\n"
226 : "event: message\nid: 1\ndata: \"hello\"\n\n"
227 const expected = binary
228 ? [new TextEncoder().encode(body)]
229 : format === "httpclient-type-only"
230 ? ["hello"]
231 : [{ event: "message", id: "1", data: "hello" }]
232 const spec: OpenAPISpec = {
233 openapi: "3.1.0",
234 info: { title: "Streaming API", version: "1.0.0" },
235 components: { schemas: {}, securitySchemes: {} },
236 security: [],
237 tags: [],
238 paths: {
239 "/read": {
240 get: {
241 operationId: "read",
242 parameters: [],
243 tags: ["Streams"],
244 security: [],
245 responses: {
246 "200": {
247 description: "Success",
248 content: {
249 [binary ? "application/octet-stream" : "text/event-stream"]: {
250 schema: encoding === "sse-event"
251 ? {
252 type: "object",
253 properties: {
254 id: { anyOf: [{ type: "string" }, { type: "null" }] },
255 event: { const: "message" },
256 data: { type: "string" }
257 },
258 required: ["id", "event", "data"],
259 additionalProperties: false
260 }
261 : { type: "string" },
262 ...(encoding === "sse-event"
263 ? {
264 "x-effect-stream": {
265 encoding: "sse",
266 errorSchema: {},
267 causeSchema: {},
268 failureEvent: "effect/http-api/stream/failure"
269 }
270 }
271 : {})
272 }
273 }
274 }
275 }
276 }
277 }
278 }
279 }
280 const generate = Effect.gen(function*() {
281 const generator = yield* OpenApiGenerator.OpenApiGenerator
282 const source = yield* generator.generate(spec, { name: "TestClient", format })
283 return yield* Effect.promise(() => loadClient(source))
284 }).pipe(Effect.provide(
285 format === "httpclient" ? OpenApiGenerator.layerTransformerSchema : OpenApiGenerator.layerTransformerTs
286 ))
287
288 describe(`${format} ${encoding}`, () => {
289 it.effect("defers transformation and applies fresh headers on each consumption", () =>
290 Effect.gen(function*() {
291 const make = yield* generate
292 const requests: Array<HttpClientRequest.HttpClientRequest> = []
293 let transforms = 0
294 let transformEffects = 0
295 const httpClient = HttpClient.make((request) => {
296 requests.push(request)
297 return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body)))
298 }).pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl("https://example.com")))
299 const client = make(httpClient, {
300 transformClient: (client) => {
301 transforms++
302 return Effect.sync(() => {
303 transformEffects++
304 return HttpClient.mapRequest(client, HttpClientRequest.setHeader("x-transformed", String(transforms)))
305 })
306 }
307 })
308 const stream = binary ? client.readStream() : client.readSse()
309 assert.strictEqual(transforms, 0)
310 assert.strictEqual(transformEffects, 0)
311 assert.deepStrictEqual(requests, [])
312 for (let run = 1; run <= 2; run++) {
313 assert.deepStrictEqual(yield* Stream.runCollect(stream), expected)
314 assert.strictEqual(requests.length, run)
315 assert.strictEqual(requests[run - 1].headers["x-transformed"], String(run))
316 assert.strictEqual(transforms, run)
317 assert.strictEqual(transformEffects, run)
318 }
319 }))
320
321 it.effect("filters the response returned by the transformed client", () =>
322 Effect.gen(function*() {
323 const make = yield* generate
324 const httpClient = HttpClient.make((request) =>
325 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body)))
326 ).pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl("https://example.com")))
327 const client = make(httpClient, {
328 transformClient: (client) =>
329 Effect.succeed(HttpClient.transformResponse(
330 client,
331 Effect.map((response) =>
332 HttpClientResponse.fromWeb(response.request, new Response(body, { status: 403 }))
333 )
334 ))
335 })
336 const stream = binary ? client.readStream() : client.readSse()
337 const error = yield* Effect.flip(Stream.runCollect(stream))
338 assert.strictEqual(error._tag, "HttpClientError")
339 assert.strictEqual(error.reason._tag, "StatusCodeError")
340 if (error.reason._tag === "StatusCodeError") {
341 assert.strictEqual(error.reason.response.status, 403)
342 }
343 }))
344
345 for (const status of [200, 403]) {
346 it.effect(`preserves status ${status} handling without a transform`, () =>
347 Effect.gen(function*() {
348 const make = yield* generate
349 let requests = 0
350 const httpClient = HttpClient.make((request) => {
351 requests++
352 return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body, { status })))
353 }).pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl("https://example.com")))
354 const client = make(httpClient)
355 const stream = binary ? client.readStream() : client.readSse()
356 assert.strictEqual(requests, 0)
357 if (status === 200) {
358 assert.deepStrictEqual(yield* Stream.runCollect(stream), expected)
359 } else {
360 const error = yield* Effect.flip(Stream.runCollect(stream))
361 assert.strictEqual(error.reason._tag, "StatusCodeError")
362 }
363 assert.strictEqual(requests, 1)
364 }))
365 }
366 })
367 }
368})