| 1 | import * as OpenApiGenerator from "@effect/openapi-generator/OpenApiGenerator" |
| 2 | import { assert, describe, it } from "@effect/vitest" |
| 3 | import * as Data from "effect/Data" |
| 4 | import * as Effect from "effect/Effect" |
| 5 | import * as Sse from "effect/encoding/Sse" |
| 6 | import type { OpenAPISpec } from "effect/http-api/OpenApi" |
| 7 | import * as HttpClient from "effect/http/HttpClient" |
| 8 | import * as HttpClientError from "effect/http/HttpClientError" |
| 9 | import * as HttpClientRequest from "effect/http/HttpClientRequest" |
| 10 | import * as HttpClientResponse from "effect/http/HttpClientResponse" |
| 11 | import * as Schema from "effect/Schema" |
| 12 | import * as Stream from "effect/Stream" |
| 13 | import { rolldown } from "rolldown" |
| 14 | |
| 15 | const 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 | |
| 27 | type TransformClient = (client: HttpClient.HttpClient) => Effect.Effect<HttpClient.HttpClient> |
| 28 | |
| 29 | async 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 | |
| 64 | const 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 | |
| 72 | describe("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 | }) |