| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import { strictEqual } from "@effect/vitest/utils" |
| 3 | import { Cause, Effect, Exit, Schema, Stream } from "effect" |
| 4 | import { Sse } from "effect/encoding" |
| 5 | import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http" |
| 6 | import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/http-api" |
| 7 | |
| 8 | describe("HttpApiClient", () => { |
| 9 | describe("ParseOptions", () => { |
| 10 | const Fields = { firstName: Schema.String, lastName: Schema.String } |
| 11 | const Person = Schema.Struct(Fields) |
| 12 | |
| 13 | const SseApi = HttpApi.make("SseApi").add( |
| 14 | HttpApiGroup.make("test").add(HttpApiEndpoint.get("events", "/events", { |
| 15 | success: HttpApiSchema.StreamSse({ data: Person, error: Schema.Struct({ reason: Schema.String }) }) |
| 16 | })) |
| 17 | ) |
| 18 | |
| 19 | it.effect("strict events-mode decoding omits an absent event ID", () => |
| 20 | Effect.gen(function*() { |
| 21 | const decode = (wire: string) => |
| 22 | HttpApiClient.makeWith( |
| 23 | StreamingApi.annotate(HttpApi.ParseOptions, { onExcessProperty: "error" }), |
| 24 | { baseUrl: "http://test", httpClient: clientFromResponse(() => new Response(textStream([wire]))) } |
| 25 | ).pipe(Effect.flatMap((client) => client.test.events({})), Effect.flatMap(Stream.runCollect)) |
| 26 | |
| 27 | const error = yield* Effect.flip(decode("id: 1\nevent: person\ndata: hello\n\n")) |
| 28 | assert.ok(Schema.isSchemaError(error)) |
| 29 | assert.include(error.message, "[\"id\"]") |
| 30 | assert.deepStrictEqual(yield* decode("event: person\ndata: hello\n\n"), [{ event: "person", data: "hello" }]) |
| 31 | })) |
| 32 | |
| 33 | it.effect("strict SSE decoding accepts valid user data and framework event metadata", () => |
| 34 | Effect.gen(function*() { |
| 35 | const client = yield* HttpApiClient.makeWith( |
| 36 | SseApi.annotate(HttpApi.ParseOptions, { onExcessProperty: "error" }), |
| 37 | { |
| 38 | baseUrl: "http://test", |
| 39 | httpClient: clientFromResponse(() => |
| 40 | new Response( |
| 41 | textStream([ |
| 42 | "id: 1\nevent: person\ndata: {\"firstName\":\"Ada\",\"lastName\":\"Lovelace\"}\n\n" |
| 43 | ]), |
| 44 | { headers: { "content-type": "text/event-stream" } } |
| 45 | ) |
| 46 | ) |
| 47 | } |
| 48 | ) |
| 49 | const events = yield* client.test.events({}).pipe(Effect.flatMap(Stream.runCollect)) |
| 50 | assert.deepStrictEqual(events, [{ firstName: "Ada", lastName: "Lovelace" }]) |
| 51 | })) |
| 52 | |
| 53 | it.effect("strict SSE decoding preserves reserved failure causes", () => |
| 54 | Effect.gen(function*() { |
| 55 | const expected = Cause.fail({ reason: "boom" }) |
| 56 | const FailureSchema = Schema.fromJsonString(Schema.toCodecJson(Schema.Cause(StreamError, Schema.Defect()))) |
| 57 | const data = yield* Schema.encodeEffect(FailureSchema)(expected) |
| 58 | const client = yield* HttpApiClient.makeWith( |
| 59 | SseApi.annotate(HttpApi.ParseOptions, { onExcessProperty: "error" }), |
| 60 | { |
| 61 | baseUrl: "http://test", |
| 62 | httpClient: clientFromResponse(() => |
| 63 | new Response( |
| 64 | textStream([ |
| 65 | Sse.encoder.write({ _tag: "Event", event: "effect/http-api/stream/failure", id: undefined, data }) |
| 66 | ]), |
| 67 | { headers: { "content-type": "text/event-stream" } } |
| 68 | ) |
| 69 | ) |
| 70 | } |
| 71 | ) |
| 72 | const exit = yield* client.test.events({}).pipe(Effect.flatMap(Stream.runCollect), Effect.exit) |
| 73 | assert.strictEqual(exit._tag, "Failure") |
| 74 | if (exit._tag === "Failure") { |
| 75 | const error = Cause.squash(exit.cause) |
| 76 | assert.isFalse(Schema.isSchemaError(error), error instanceof Error ? error.message : undefined) |
| 77 | assert.deepStrictEqual(exit.cause, expected) |
| 78 | } |
| 79 | })) |
| 80 | |
| 81 | it.effect("strict SSE decoding rejects excess properties in user event data", () => |
| 82 | Effect.gen(function*() { |
| 83 | const client = yield* HttpApiClient.makeWith( |
| 84 | SseApi.annotate(HttpApi.ParseOptions, { onExcessProperty: "error" }), |
| 85 | { |
| 86 | baseUrl: "http://test", |
| 87 | httpClient: clientFromResponse(() => |
| 88 | new Response( |
| 89 | textStream([ |
| 90 | "data: {\"firstName\":\"Ada\",\"lastName\":\"Lovelace\",\"extra\":true}\n\n" |
| 91 | ]), |
| 92 | { headers: { "content-type": "text/event-stream" } } |
| 93 | ) |
| 94 | ) |
| 95 | } |
| 96 | ) |
| 97 | const exit = yield* client.test.events({}).pipe(Effect.flatMap(Stream.runCollect), Effect.exit) |
| 98 | assert.strictEqual(exit._tag, "Failure") |
| 99 | if (exit._tag === "Failure") { |
| 100 | const error = Cause.squash(exit.cause) |
| 101 | assert.ok(Schema.isSchemaError(error)) |
| 102 | assert.include(error.message, "Expected no excess property") |
| 103 | assert.include(error.message, "[\"data\"][\"extra\"]") |
| 104 | assert.notInclude(error.message, "[\"_tag\"]") |
| 105 | } |
| 106 | })) |
| 107 | |
| 108 | it.effect("client payload encoding collects all issues", () => |
| 109 | Effect.gen(function*() { |
| 110 | const Api = HttpApi.make("Api").add( |
| 111 | HttpApiGroup.make("users").add(HttpApiEndpoint.post("create", "/users", { payload: Person })) |
| 112 | ).annotate(HttpApi.ParseOptions, { errors: "all" }) |
| 113 | let requests = 0 |
| 114 | const client = yield* HttpApiClient.makeWith(Api, { |
| 115 | baseUrl: "http://test", |
| 116 | httpClient: clientFromResponse(() => { |
| 117 | requests++ |
| 118 | return new Response(null, { status: 204 }) |
| 119 | }) |
| 120 | }) |
| 121 | const exit = yield* Effect.exit(client.users.create({ payload: {} as typeof Person.Type })) |
| 122 | assert.strictEqual(requests, 0) |
| 123 | assert.strictEqual(exit._tag, "Failure") |
| 124 | if (exit._tag === "Failure") { |
| 125 | const error = Cause.squash(exit.cause) |
| 126 | assert.ok(Schema.isSchemaError(error)) |
| 127 | assert.include(error.message, "firstName") |
| 128 | assert.include(error.message, "lastName") |
| 129 | } |
| 130 | })) |
| 131 | |
| 132 | it.effect("client response decoding collects all issues", () => |
| 133 | Effect.gen(function*() { |
| 134 | const Api = HttpApi.make("Api").add( |
| 135 | HttpApiGroup.make("users").add(HttpApiEndpoint.get("get", "/users", { success: Person })) |
| 136 | ).annotate(HttpApi.ParseOptions, { errors: "all" }) |
| 137 | const client = yield* HttpApiClient.makeWith(Api, { |
| 138 | baseUrl: "http://test", |
| 139 | httpClient: clientFromResponse(() => new Response("{}", { headers: { "content-type": "application/json" } })) |
| 140 | }) |
| 141 | const exit = yield* Effect.exit(client.users.get({})) |
| 142 | assert.strictEqual(exit._tag, "Failure") |
| 143 | if (exit._tag === "Failure") { |
| 144 | const error = Cause.squash(exit.cause) |
| 145 | assert.ok(Schema.isSchemaError(error)) |
| 146 | assert.include(error.message, "firstName") |
| 147 | assert.include(error.message, "lastName") |
| 148 | } |
| 149 | })) |
| 150 | |
| 151 | it("urlBuilder uses group options over API options", () => { |
| 152 | const Api = HttpApi.make("Api").add( |
| 153 | HttpApiGroup.make("users").add(HttpApiEndpoint.get("get", "/users", { query: Person })) |
| 154 | .annotate(HttpApi.ParseOptions, { errors: "all" }) |
| 155 | ).annotate(HttpApi.ParseOptions, { errors: "first" }) |
| 156 | const urls = HttpApiClient.urlBuilder(Api) |
| 157 | assert.throws(() => urls.users.get({ query: {} as typeof Person.Type }), /firstName[\s\S]*lastName/) |
| 158 | }) |
| 159 | }) |
| 160 | |
| 161 | describe("slot ParseOptions", () => { |
| 162 | const Strict = { onExcessProperty: "error" } as const |
| 163 | const Person = Schema.Struct({ name: Schema.String }) |
| 164 | const adaWithExtra = { name: "Ada", extra: true } |
| 165 | |
| 166 | it.effect("API slot annotations replace endpoint ParseOptions for request codecs", () => |
| 167 | Effect.gen(function*() { |
| 168 | const Group = HttpApiGroup.make("test").add( |
| 169 | HttpApiEndpoint.post("create", "/users/:id", { |
| 170 | params: { id: Schema.String }, |
| 171 | query: { q: Schema.String }, |
| 172 | headers: { "x-api-key": Schema.String }, |
| 173 | payload: Person |
| 174 | }).annotate(HttpApi.ParseOptions, Strict) |
| 175 | ) |
| 176 | const request = { |
| 177 | params: { id: "1", extraParam: "p" }, |
| 178 | query: { q: "x", extraQuery: "q" }, |
| 179 | headers: { "x-api-key": "key", "x-extra": "h" }, |
| 180 | payload: { name: "Ada", extraPayload: true } |
| 181 | } |
| 182 | const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = [] |
| 183 | const create = (api: HttpApi.HttpApi<"Api", typeof Group>) => |
| 184 | HttpApiClient.makeWith(api, { |
| 185 | baseUrl: "http://test", |
| 186 | httpClient: HttpClient.make((request, url) => |
| 187 | Effect.sync(() => { |
| 188 | requests.push({ url: url.toString(), headers: request.headers }) |
| 189 | return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })) |
| 190 | }) |
| 191 | ) |
| 192 | }).pipe(Effect.flatMap((client) => Effect.exit(client.test.create(request))), Effect.map(String)) |
| 193 | |
| 194 | // Encoded in order: params, payload, headers, query. |
| 195 | let api = HttpApi.make("Api").add(Group) |
| 196 | assert.include(yield* create(api), "extraParam") |
| 197 | api = api.annotate(HttpApi.ParamsParseOptions, {}) |
| 198 | assert.include(yield* create(api), "extraPayload") |
| 199 | // Replaces ParseOptions: onExcessProperty is not merged in. |
| 200 | api = api.annotate(HttpApi.PayloadParseOptions, { errors: "first" }) |
| 201 | assert.include(yield* create(api), "x-extra") |
| 202 | api = api.annotate(HttpApi.HeadersParseOptions, {}) |
| 203 | assert.include(yield* create(api), "extraQuery") |
| 204 | api = api.annotate(HttpApi.QueryParseOptions, {}) |
| 205 | yield* create(api) |
| 206 | assert.strictEqual(requests.length, 1) |
| 207 | assert.strictEqual(requests[0]!.url, "http://test/users/1?q=x") |
| 208 | assert.strictEqual(requests[0]!.headers["x-api-key"], "key") |
| 209 | assert.isUndefined(requests[0]!.headers["x-extra"]) |
| 210 | strictEqual(HttpApiClient.urlBuilder(api).test.create(request), "/users/1?q=x") |
| 211 | })) |
| 212 | |
| 213 | it.effect("buffered responses decode headers, success and error bodies with their own slots", () => |
| 214 | Effect.gen(function*() { |
| 215 | const Group = HttpApiGroup.make("test").add( |
| 216 | HttpApiEndpoint.get("get", "/user", { |
| 217 | success: HttpApiSchema.WithHeaders(Person, { "x-count": Schema.Int }), |
| 218 | error: Person.pipe(HttpApiSchema.status(400)) |
| 219 | }).annotate(HttpApi.ParseOptions, Strict) |
| 220 | ) |
| 221 | const get = (api: HttpApi.HttpApi<"Api", typeof Group>, status: number) => |
| 222 | HttpApiClient.makeWith(api, { |
| 223 | baseUrl: "http://test", |
| 224 | httpClient: clientFromResponse(() => |
| 225 | new Response(JSON.stringify(adaWithExtra), { |
| 226 | status, |
| 227 | headers: { "content-type": "application/json", "server": "test", "x-count": "1" } |
| 228 | }) |
| 229 | ) |
| 230 | }).pipe(Effect.flatMap((client) => Effect.exit(client.test.get({})))) |
| 231 | |
| 232 | let api = HttpApi.make("Api").add(Group).annotate(HttpApi.SuccessParseOptions, {}) |
| 233 | const headersError = yield* get(api, 200) |
| 234 | assert.strictEqual(headersError._tag, "Failure") |
| 235 | assert.include(String(headersError), `["content-type"]`) |
| 236 | |
| 237 | api = api.annotate(HttpApi.HeadersParseOptions, {}) |
| 238 | const value = yield* get(api, 200) |
| 239 | assert.deepStrictEqual( |
| 240 | value, |
| 241 | Exit.succeed(HttpApiSchema.withHeaders({ body: { name: "Ada" }, headers: { "x-count": 1 } })) |
| 242 | ) |
| 243 | |
| 244 | const strictError = yield* get(api, 400) |
| 245 | assert.ok(strictError._tag === "Failure" && HttpClientError.isHttpClientError(Cause.squash(strictError.cause))) |
| 246 | assert.deepStrictEqual(yield* get(api.annotate(HttpApi.ErrorParseOptions, {}), 400), Exit.fail({ name: "Ada" })) |
| 247 | })) |
| 248 | |
| 249 | it.effect("streamed WithHeaders responses decode headers and events with their own slots", () => |
| 250 | Effect.gen(function*() { |
| 251 | const Group = HttpApiGroup.make("test").add( |
| 252 | HttpApiEndpoint.get("events", "/events", { |
| 253 | success: HttpApiSchema.WithHeaders( |
| 254 | HttpApiSchema.StreamSse({ data: Person, error: StreamError }), |
| 255 | { "x-count": Schema.Int } |
| 256 | ) |
| 257 | }) |
| 258 | ) |
| 259 | const events = (api: HttpApi.HttpApi<"Api", typeof Group>) => |
| 260 | HttpApiClient.makeWith(api, { |
| 261 | baseUrl: "http://test", |
| 262 | httpClient: clientFromResponse(() => |
| 263 | new Response(textStream([`data: ${JSON.stringify(adaWithExtra)}\n\n`]), { |
| 264 | headers: { "content-type": "text/event-stream", "x-count": "1" } |
| 265 | }) |
| 266 | ) |
| 267 | }).pipe( |
| 268 | Effect.flatMap((client) => client.test.events({})), |
| 269 | Effect.flatMap(({ body, headers }) => |
| 270 | Effect.map(Stream.runCollect(body), (events) => ({ events, headers })) |
| 271 | ), |
| 272 | Effect.exit |
| 273 | ) |
| 274 | |
| 275 | const api = HttpApi.make("Api").add(Group) |
| 276 | .annotate(HttpApi.ParseOptions, Strict) |
| 277 | .annotate(HttpApi.HeadersParseOptions, {}) |
| 278 | const strictEvents = yield* events(api) |
| 279 | assert.include(String(strictEvents), `["data"]["extra"]`) |
| 280 | assert.deepStrictEqual( |
| 281 | yield* events(api.annotate(HttpApi.SuccessParseOptions, {})), |
| 282 | Exit.succeed({ events: [{ name: "Ada" }], headers: { "x-count": 1 } }) |
| 283 | ) |
| 284 | })) |
| 285 | |
| 286 | it.effect("encodeToWithHeaders decodes header transformations with HeadersParseOptions", () => |
| 287 | Effect.gen(function*() { |
| 288 | class Invalid extends Schema.TaggedError<Invalid>()("Invalid", { a: Schema.Finite, b: Schema.Finite }) {} |
| 289 | const InvalidResponse = Invalid.pipe(HttpApiSchema.encodeToWithHeaders({ |
| 290 | body: Schema.String.pipe(HttpApiSchema.status(400), HttpApiSchema.asText()), |
| 291 | headers: { "x-a": Schema.FiniteFromString, "x-b": Schema.FiniteFromString } |
| 292 | }, { |
| 293 | decode: ({ headers }) => new Invalid({ a: headers["x-a"], b: headers["x-b"] }), |
| 294 | encode: (error) => ({ body: "invalid", headers: { "x-a": error.a, "x-b": error.b } }) |
| 295 | })) |
| 296 | const Api = HttpApi.make("Api").add( |
| 297 | HttpApiGroup.make("test").add(HttpApiEndpoint.get("get", "/invalid", { error: InvalidResponse })) |
| 298 | ) |
| 299 | .annotate(HttpApi.ParseOptions, { errors: "first" }) |
| 300 | .annotate(HttpApi.HeadersParseOptions, { errors: "all" }) |
| 301 | const client = yield* HttpApiClient.makeWith(Api, { |
| 302 | baseUrl: "http://test", |
| 303 | httpClient: clientFromResponse(() => |
| 304 | new Response("invalid", { |
| 305 | status: 400, |
| 306 | headers: { "content-type": "text/plain", "x-a": "bad", "x-b": "bad" } |
| 307 | }) |
| 308 | ) |
| 309 | }) |
| 310 | |
| 311 | const exit = String(yield* Effect.exit(client.test.get({}))) |
| 312 | assert.include(exit, `["x-a"]`) |
| 313 | assert.include(exit, `["x-b"]`) |
| 314 | })) |
| 315 | |
| 316 | it.effect("encodeToWithHeaders validates the mapped response value", () => |
| 317 | Effect.gen(function*() { |
| 318 | const Positive = Schema.Number.check(Schema.isGreaterThan(0)) |
| 319 | const ResponseSchema = Positive.pipe(HttpApiSchema.encodeToWithHeaders({ |
| 320 | body: Schema.String.pipe(HttpApiSchema.asText()), |
| 321 | headers: { "x-value": Schema.FiniteFromString } |
| 322 | }, { |
| 323 | decode: ({ headers }) => headers["x-value"], |
| 324 | encode: (value) => ({ body: "value", headers: { "x-value": value } }) |
| 325 | })) |
| 326 | const Api = HttpApi.make("Api").add( |
| 327 | HttpApiGroup.make("test").add(HttpApiEndpoint.get("get", "/value", { success: ResponseSchema })) |
| 328 | ) |
| 329 | const client = yield* HttpApiClient.makeWith(Api, { |
| 330 | baseUrl: "http://test", |
| 331 | httpClient: clientFromResponse(() => |
| 332 | new Response("value", { headers: { "content-type": "text/plain", "x-value": "-1" } }) |
| 333 | ) |
| 334 | }) |
| 335 | |
| 336 | const exit = yield* Effect.exit(client.test.get({})) |
| 337 | assert.strictEqual(exit._tag, "Failure") |
| 338 | assert.include(String(exit), "greater than 0") |
| 339 | })) |
| 340 | }) |
| 341 | |
| 342 | describe("literal action suffixes", () => { |
| 343 | const Api = HttpApi.make("Api").add( |
| 344 | HttpApiGroup.make("operations").add( |
| 345 | HttpApiEndpoint.post("wait", "/operations/:id:wait", { |
| 346 | params: Schema.Struct({ id: Schema.String }) |
| 347 | }) |
| 348 | ) |
| 349 | ) |
| 350 | const expectedUrl = "https://api.example.com/operations/op_1:wait" |
| 351 | |
| 352 | it("urlBuilder preserves the undeclared action suffix", () => { |
| 353 | const urls = HttpApiClient.urlBuilder(Api, { baseUrl: "https://api.example.com" }) |
| 354 | |
| 355 | strictEqual(urls.operations.wait({ params: { id: "op_1" } }), expectedUrl) |
| 356 | }) |
| 357 | |
| 358 | it.effect("make sends the request with the literal action suffix", () => |
| 359 | Effect.gen(function*() { |
| 360 | let requestUrl: string | undefined |
| 361 | const httpClient = HttpClient.make((request, url) => |
| 362 | Effect.sync(() => { |
| 363 | requestUrl = url.toString() |
| 364 | return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })) |
| 365 | }) |
| 366 | ) |
| 367 | const client = yield* HttpApiClient.make(Api, { baseUrl: "https://api.example.com" }).pipe( |
| 368 | Effect.provideService(HttpClient.HttpClient, httpClient) |
| 369 | ) |
| 370 | |
| 371 | yield* client.operations.wait({ params: { id: "op_1" } }) |
| 372 | |
| 373 | strictEqual(requestUrl, expectedUrl) |
| 374 | })) |
| 375 | }) |
| 376 | |
| 377 | describe("streaming responses", () => { |
| 378 | it.effect("decodes StreamSse events incrementally", () => |
| 379 | Effect.gen(function*() { |
| 380 | const client = yield* HttpApiClient.makeWith(StreamingApi, { |
| 381 | baseUrl: "http://test", |
| 382 | httpClient: clientFromResponse(() => |
| 383 | new Response( |
| 384 | textStream([ |
| 385 | "event: first\ndata: one\n\n", |
| 386 | "event: second\ndata: two\n\n" |
| 387 | ]), |
| 388 | { status: 200 } |
| 389 | ) |
| 390 | ) |
| 391 | }) |
| 392 | |
| 393 | const stream = yield* client.test.events({}) |
| 394 | const first = yield* stream.pipe(Stream.take(1), Stream.runCollect) |
| 395 | assert.deepStrictEqual(first, [{ event: "first", data: "one" }]) |
| 396 | })) |
| 397 | |
| 398 | it.effect("keeps per-call SSE decode options isolated", () => |
| 399 | Effect.gen(function*() { |
| 400 | const client = yield* HttpApiClient.makeWith(StreamingApi, { |
| 401 | baseUrl: "http://test", |
| 402 | httpClient: clientFromResponse(() => new Response(textStream(["data: ", "hello\n\n"]), { status: 200 })) |
| 403 | }) |
| 404 | |
| 405 | const limitedStream = yield* client.test.events({ sseOptions: { maxEventSize: 4 } }) |
| 406 | const defaultStream = yield* client.test.events({}) |
| 407 | const [error, events] = yield* Effect.all([ |
| 408 | limitedStream.pipe(Stream.runCollect, Effect.flip), |
| 409 | Stream.runCollect(defaultStream) |
| 410 | ], { concurrency: "unbounded" }) |
| 411 | |
| 412 | assert.instanceOf(error, Sse.SseError) |
| 413 | assert.instanceOf(error.reason, Sse.EventTooLarge) |
| 414 | assert.strictEqual(error.reason.maxEventSize, 4) |
| 415 | assert.deepStrictEqual(events, [{ event: "message", data: "hello" }]) |
| 416 | })) |
| 417 | |
| 418 | it.effect("keeps StreamSse parser state isolated between responses", () => |
| 419 | Effect.gen(function*() { |
| 420 | const bodies = [ |
| 421 | "event: first\ndata: one\n\n", |
| 422 | "event: second\ndata: two\n\n" |
| 423 | ] |
| 424 | let index = 0 |
| 425 | const client = yield* HttpApiClient.makeWith(StreamingApi, { |
| 426 | baseUrl: "http://test", |
| 427 | httpClient: clientFromResponse(() => new Response(textStream([bodies[index++]!]), { status: 200 })) |
| 428 | }) |
| 429 | |
| 430 | const first = yield* client.test.events({}).pipe(Effect.flatMap(Stream.runCollect)) |
| 431 | const second = yield* client.test.events({}).pipe(Effect.flatMap(Stream.runCollect)) |
| 432 | |
| 433 | assert.deepStrictEqual(first, [{ event: "first", data: "one" }]) |
| 434 | assert.deepStrictEqual(second, [{ event: "second", data: "two" }]) |
| 435 | })) |
| 436 | |
| 437 | it.effect("decodes StreamSse reserved failure events as full causes", () => |
| 438 | Effect.gen(function*() { |
| 439 | const expectedCause = Cause.fail({ reason: "boom" }) |
| 440 | const FailureSchema = Schema.toCodecJson(Schema.Cause(StreamError, Schema.Defect())) |
| 441 | const encodeCause = Schema.encodeUnknownEffect(Schema.fromJsonString(FailureSchema)) |
| 442 | const encodedCause = yield* encodeCause(expectedCause) |
| 443 | const failureEvent = Sse.encoder.write({ |
| 444 | _tag: "Event", |
| 445 | event: "effect/http-api/stream/failure", |
| 446 | id: undefined, |
| 447 | data: encodedCause |
| 448 | }) |
| 449 | |
| 450 | const client = yield* HttpApiClient.makeWith(StreamingApi, { |
| 451 | baseUrl: "http://test", |
| 452 | httpClient: clientFromResponse(() => new Response(textStream([failureEvent]), { status: 200 })) |
| 453 | }) |
| 454 | |
| 455 | const stream = yield* client.test.events({}) |
| 456 | const exit = yield* Effect.exit(Stream.runCollect(stream)) |
| 457 | |
| 458 | assert.strictEqual(exit._tag, "Failure") |
| 459 | if (exit._tag === "Failure") { |
| 460 | assert.deepStrictEqual(exit.cause, expectedCause) |
| 461 | } |
| 462 | })) |
| 463 | |
| 464 | it.effect("emits StreamSse reserved names with non-Cause data as user events", () => |
| 465 | Effect.gen(function*() { |
| 466 | const failureEvent = Sse.encoder.write({ |
| 467 | _tag: "Event", |
| 468 | event: "effect/http-api/stream/failure", |
| 469 | id: undefined, |
| 470 | data: "not-json" |
| 471 | }) |
| 472 | |
| 473 | const client = yield* HttpApiClient.makeWith(StreamingApi, { |
| 474 | baseUrl: "http://test", |
| 475 | httpClient: clientFromResponse(() => new Response(textStream([failureEvent]), { status: 200 })) |
| 476 | }) |
| 477 | |
| 478 | const stream = yield* client.test.events({}) |
| 479 | const events = yield* Stream.runCollect(stream) |
| 480 | |
| 481 | assert.deepStrictEqual(events, [{ |
| 482 | event: "effect/http-api/stream/failure", |
| 483 | data: "not-json" |
| 484 | }]) |
| 485 | })) |
| 486 | |
| 487 | it.effect("returns StreamUint8Array response bytes incrementally", () => |
| 488 | Effect.gen(function*() { |
| 489 | const client = yield* HttpApiClient.makeWith(StreamingApi, { |
| 490 | baseUrl: "http://test", |
| 491 | httpClient: clientFromResponse(() => |
| 492 | new Response(byteStream([new Uint8Array([1, 2]), new Uint8Array([3])]), { status: 200 }) |
| 493 | ) |
| 494 | }) |
| 495 | |
| 496 | const stream = yield* client.test.download({}) |
| 497 | const first = yield* stream.pipe(Stream.take(1), Stream.runCollect) |
| 498 | assert.deepStrictEqual(first.map((chunk) => Array.from(chunk)), [[1, 2]]) |
| 499 | })) |
| 500 | |
| 501 | it.effect("decodes WithHeaders StreamUint8Array bodies and headers", () => |
| 502 | Effect.gen(function*() { |
| 503 | const Api = HttpApi.make("Api").add( |
| 504 | HttpApiGroup.make("test").add( |
| 505 | HttpApiEndpoint.get("download", "/download", { |
| 506 | success: HttpApiSchema.WithHeaders( |
| 507 | HttpApiSchema.StreamUint8Array(), |
| 508 | { "x-count": Schema.Int } |
| 509 | ) |
| 510 | }) |
| 511 | ) |
| 512 | ) |
| 513 | const client = yield* HttpApiClient.makeWith(Api, { |
| 514 | baseUrl: "http://test", |
| 515 | httpClient: clientFromResponse(() => |
| 516 | new Response(byteStream([new Uint8Array([1, 2]), new Uint8Array([3])]), { |
| 517 | status: 200, |
| 518 | headers: { "x-count": "2" } |
| 519 | }) |
| 520 | ) |
| 521 | }) |
| 522 | |
| 523 | const value = yield* client.test.download({}) |
| 524 | const first = yield* value.body.pipe(Stream.take(1), Stream.runCollect) |
| 525 | |
| 526 | assert.deepStrictEqual(value.headers, { "x-count": 2 }) |
| 527 | assert.deepStrictEqual(first.map((chunk) => Array.from(chunk)), [[1, 2]]) |
| 528 | })) |
| 529 | |
| 530 | it.effect("decodes WithHeaders StreamSse bodies and headers", () => |
| 531 | Effect.gen(function*() { |
| 532 | const Api = HttpApi.make("Api").add( |
| 533 | HttpApiGroup.make("test").add( |
| 534 | HttpApiEndpoint.get("events", "/events", { |
| 535 | success: HttpApiSchema.WithHeaders( |
| 536 | HttpApiSchema.StreamSse({ |
| 537 | data: Schema.Struct({ text: Schema.String }), |
| 538 | error: StreamError |
| 539 | }), |
| 540 | { "x-count": Schema.Int } |
| 541 | ) |
| 542 | }) |
| 543 | ) |
| 544 | ) |
| 545 | const client = yield* HttpApiClient.makeWith(Api, { |
| 546 | baseUrl: "http://test", |
| 547 | httpClient: clientFromResponse(() => |
| 548 | new Response(textStream([`data: {"text":"hello"}\n\n`]), { |
| 549 | status: 200, |
| 550 | headers: { |
| 551 | "content-type": "text/event-stream", |
| 552 | "x-count": "1" |
| 553 | } |
| 554 | }) |
| 555 | ) |
| 556 | }) |
| 557 | |
| 558 | const value = yield* client.test.events({}) |
| 559 | const events = yield* Stream.runCollect(value.body) |
| 560 | |
| 561 | assert.deepStrictEqual(value.headers, { "x-count": 1 }) |
| 562 | assert.deepStrictEqual(events, [{ text: "hello" }]) |
| 563 | })) |
| 564 | |
| 565 | it.effect("fails invalid WithHeaders stream headers before returning the body", () => |
| 566 | Effect.gen(function*() { |
| 567 | const Api = HttpApi.make("Api").add( |
| 568 | HttpApiGroup.make("test").add( |
| 569 | HttpApiEndpoint.get("download", "/download", { |
| 570 | success: HttpApiSchema.WithHeaders( |
| 571 | HttpApiSchema.StreamUint8Array(), |
| 572 | { "x-count": Schema.Int } |
| 573 | ) |
| 574 | }) |
| 575 | ) |
| 576 | ) |
| 577 | const client = yield* HttpApiClient.makeWith(Api, { |
| 578 | baseUrl: "http://test", |
| 579 | httpClient: clientFromResponse(() => |
| 580 | new Response(byteStream([new Uint8Array([1])]), { |
| 581 | status: 200, |
| 582 | headers: { "x-count": "invalid" } |
| 583 | }) |
| 584 | ) |
| 585 | }) |
| 586 | |
| 587 | const exit = yield* Effect.exit(client.test.download({})) |
| 588 | |
| 589 | assert.strictEqual(exit._tag, "Failure") |
| 590 | if (exit._tag === "Failure") { |
| 591 | assert.strictEqual((Cause.squash(exit.cause) as { readonly _tag?: string })._tag, "SchemaError") |
| 592 | } |
| 593 | })) |
| 594 | |
| 595 | it.effect("forwards SSE options through mixed WithHeaders content-type selection", () => |
| 596 | Effect.gen(function*() { |
| 597 | const Api = HttpApi.make("Api").add( |
| 598 | HttpApiGroup.make("test").add( |
| 599 | HttpApiEndpoint.get("chat", "/chat", { |
| 600 | success: [ |
| 601 | Schema.Struct({ message: Schema.String }), |
| 602 | HttpApiSchema.WithHeaders( |
| 603 | HttpApiSchema.StreamSse({ data: Schema.Struct({ text: Schema.String }) }), |
| 604 | { "x-count": Schema.Int } |
| 605 | ) |
| 606 | ] |
| 607 | }) |
| 608 | ) |
| 609 | ) |
| 610 | const client = yield* HttpApiClient.makeWith(Api, { |
| 611 | baseUrl: "http://test", |
| 612 | httpClient: clientFromResponse(() => |
| 613 | new Response(textStream(["data: ", `{"text":"hello"}\n\n`]), { |
| 614 | status: 200, |
| 615 | headers: { |
| 616 | "content-type": "text/event-stream; charset=utf-8", |
| 617 | "x-count": "1" |
| 618 | } |
| 619 | }) |
| 620 | ) |
| 621 | }) |
| 622 | |
| 623 | const value = yield* client.test.chat({ sseOptions: { maxEventSize: 4 } }) |
| 624 | if (!(HttpApiSchema.WithHeadersValueTypeId in value)) { |
| 625 | throw new Error("Expected WithHeaders response") |
| 626 | } |
| 627 | const error = yield* value.body.pipe(Stream.runCollect, Effect.flip) |
| 628 | |
| 629 | assert.deepStrictEqual(value.headers, { "x-count": 1 }) |
| 630 | assert.instanceOf(error, Sse.SseError) |
| 631 | assert.instanceOf(error.reason, Sse.EventTooLarge) |
| 632 | assert.strictEqual(error.reason.maxEventSize, 4) |
| 633 | |
| 634 | const defaultValue = yield* client.test.chat({}) |
| 635 | if (!(HttpApiSchema.WithHeadersValueTypeId in defaultValue)) { |
| 636 | throw new Error("Expected WithHeaders response") |
| 637 | } |
| 638 | const events = yield* Stream.runCollect(defaultValue.body) |
| 639 | assert.deepStrictEqual(defaultValue.headers, { "x-count": 1 }) |
| 640 | assert.deepStrictEqual(events, [{ text: "hello" }]) |
| 641 | })) |
| 642 | |
| 643 | it.effect("decodes StreamSse successes at the annotated status", () => |
| 644 | Effect.gen(function*() { |
| 645 | const client = yield* HttpApiClient.makeWith(AnnotatedStreamingApi, { |
| 646 | baseUrl: "http://test", |
| 647 | httpClient: clientFromResponse(() => |
| 648 | new Response(textStream(["event: annotated\ndata: ok\n\n"]), { status: 202 }) |
| 649 | ) |
| 650 | }) |
| 651 | |
| 652 | const stream = yield* client.test.events({}) |
| 653 | const events = yield* Stream.runCollect(stream) |
| 654 | assert.deepStrictEqual(events, [{ event: "annotated", data: "ok" }]) |
| 655 | })) |
| 656 | |
| 657 | it.effect("decodes StreamUint8Array successes at the annotated status", () => |
| 658 | Effect.gen(function*() { |
| 659 | const client = yield* HttpApiClient.makeWith(AnnotatedStreamingApi, { |
| 660 | baseUrl: "http://test", |
| 661 | httpClient: clientFromResponse(() => new Response(byteStream([new Uint8Array([4, 5])]), { status: 206 })) |
| 662 | }) |
| 663 | |
| 664 | const stream = yield* client.test.download({}) |
| 665 | const chunks = yield* Stream.runCollect(stream) |
| 666 | assert.deepStrictEqual(chunks.map((chunk) => Array.from(chunk)), [[4, 5]]) |
| 667 | })) |
| 668 | |
| 669 | it.effect("decodes non-success responses through endpoint error schemas before returning a stream", () => |
| 670 | Effect.gen(function*() { |
| 671 | const client = yield* HttpApiClient.makeWith(StreamingApi, { |
| 672 | baseUrl: "http://test", |
| 673 | httpClient: clientFromResponse(() => |
| 674 | new Response(JSON.stringify({ _tag: "EndpointError", message: "bad request" }), { |
| 675 | status: 400, |
| 676 | headers: { "content-type": "application/json" } |
| 677 | }) |
| 678 | ) |
| 679 | }) |
| 680 | |
| 681 | const error = yield* Effect.flip(client.test.events({})) |
| 682 | assert.deepStrictEqual(error, new EndpointError({ message: "bad request" })) |
| 683 | })) |
| 684 | |
| 685 | it.effect("preserves response-only raw response stream access", () => |
| 686 | Effect.gen(function*() { |
| 687 | const client = yield* HttpApiClient.makeWith(StreamingApi, { |
| 688 | baseUrl: "http://test", |
| 689 | httpClient: clientFromResponse(() => |
| 690 | new Response( |
| 691 | byteStream([ |
| 692 | new Uint8Array([1]), |
| 693 | new Uint8Array([2, 3]) |
| 694 | ]), |
| 695 | { status: 200 } |
| 696 | ) |
| 697 | ) |
| 698 | }) |
| 699 | |
| 700 | const response = yield* client.test.download({ responseMode: "response-only" }) |
| 701 | const chunks = yield* Stream.runCollect(response.stream) |
| 702 | assert.deepStrictEqual(chunks.map((chunk) => Array.from(chunk)), [[1], [2, 3]]) |
| 703 | })) |
| 704 | |
| 705 | it.effect("selects a buffered response by content type when a stream uses the same status", () => |
| 706 | Effect.gen(function*() { |
| 707 | const client = yield* HttpApiClient.makeWith(MixedSuccessApi, { |
| 708 | baseUrl: "http://test", |
| 709 | httpClient: clientFromResponse(() => |
| 710 | new Response(JSON.stringify({ message: "done" }), { |
| 711 | status: 200, |
| 712 | headers: { "content-type": "application/json" } |
| 713 | }) |
| 714 | ) |
| 715 | }) |
| 716 | |
| 717 | const response = yield* client.test.chat({}) |
| 718 | assert.deepStrictEqual(response, { message: "done" }) |
| 719 | })) |
| 720 | |
| 721 | it.effect("selects a stream response by content type when buffered success uses the same status", () => |
| 722 | Effect.gen(function*() { |
| 723 | const client = yield* HttpApiClient.makeWith(MixedSuccessApi, { |
| 724 | baseUrl: "http://test", |
| 725 | httpClient: clientFromResponse(() => |
| 726 | new Response(textStream([`event: token\ndata: {"text":"hello"}\n\n`]), { |
| 727 | status: 200, |
| 728 | headers: { "content-type": "text/event-stream; charset=utf-8" } |
| 729 | }) |
| 730 | ) |
| 731 | }) |
| 732 | |
| 733 | const stream = yield* client.test.chat({}) |
| 734 | if (!Stream.isStream(stream)) { |
| 735 | throw new Error("Expected stream response") |
| 736 | } |
| 737 | const events = yield* Stream.runCollect(stream) |
| 738 | assert.deepStrictEqual(events, [{ text: "hello" }]) |
| 739 | })) |
| 740 | }) |
| 741 | |
| 742 | describe("error responses", () => { |
| 743 | const makeClient = (response: () => Response) => |
| 744 | HttpApiClient.makeWith(ErrorContentTypeApi, { |
| 745 | baseUrl: "http://test", |
| 746 | httpClient: clientFromResponse(response) |
| 747 | }) |
| 748 | |
| 749 | it.effect("selects schemas by normalized content type regardless of declaration order", () => |
| 750 | Effect.gen(function*() { |
| 751 | for (const endpoint of ["textFirst", "jsonFirst"] as const) { |
| 752 | const jsonClient = yield* makeClient(() => |
| 753 | new Response(JSON.stringify({ _tag: "JsonError", message: "bad request" }), { |
| 754 | status: 400, |
| 755 | headers: { "content-type": "Application/JSON; charset=utf-8" } |
| 756 | }) |
| 757 | ) |
| 758 | const jsonError = yield* Effect.flip(jsonClient.test[endpoint]({})) |
| 759 | assert.deepStrictEqual(jsonError, { _tag: "JsonError", message: "bad request" }) |
| 760 | |
| 761 | const textClient = yield* makeClient(() => |
| 762 | new Response("bad request", { |
| 763 | status: 400, |
| 764 | headers: { "content-type": "text/plain" } |
| 765 | }) |
| 766 | ) |
| 767 | const textError = yield* Effect.flip(textClient.test[endpoint]({})) |
| 768 | assert.strictEqual(textError, "bad request") |
| 769 | } |
| 770 | })) |
| 771 | |
| 772 | it.effect("reports unsupported error response content types", () => |
| 773 | Effect.gen(function*() { |
| 774 | const client = yield* makeClient(() => |
| 775 | new Response("<error />", { |
| 776 | status: 400, |
| 777 | headers: { "content-type": "application/xml" } |
| 778 | }) |
| 779 | ) |
| 780 | |
| 781 | const exit = yield* Effect.exit(client.test.textFirst({})) |
| 782 | assert.strictEqual(exit._tag, "Failure") |
| 783 | if (exit._tag === "Failure") { |
| 784 | const errors: Array<unknown> = [] |
| 785 | for (const reason of exit.cause.reasons) { |
| 786 | if (Cause.isFailReason(reason)) { |
| 787 | errors.push(reason.error) |
| 788 | } |
| 789 | } |
| 790 | assert.ok( |
| 791 | errors.some((error) => HttpClientError.isHttpClientError(error) && error.reason._tag === "StatusCodeError") |
| 792 | ) |
| 793 | const decodeError = errors.find((error) => |
| 794 | HttpClientError.isHttpClientError(error) && error.reason._tag === "DecodeError" |
| 795 | ) |
| 796 | assert.ok(HttpClientError.isHttpClientError(decodeError)) |
| 797 | assert.strictEqual(decodeError.reason._tag, "DecodeError") |
| 798 | assert.ok(decodeError.reason.description?.includes("Unsupported response content-type")) |
| 799 | } |
| 800 | })) |
| 801 | |
| 802 | it.effect("decodes no-content errors without a content-type header", () => |
| 803 | Effect.gen(function*() { |
| 804 | const client = yield* makeClient(() => new Response(null, { status: 400 })) |
| 805 | |
| 806 | const error = yield* Effect.flip(client.test.noContent({})) |
| 807 | assert.strictEqual(error, "NoContentError") |
| 808 | })) |
| 809 | |
| 810 | it.effect("groups schemas by normalized declared content type", () => |
| 811 | Effect.gen(function*() { |
| 812 | const client = yield* makeClient(() => |
| 813 | new Response(JSON.stringify({ _tag: "SecondJsonError", message: "bad request" }), { |
| 814 | status: 400, |
| 815 | headers: { "content-type": "application/problem+json" } |
| 816 | }) |
| 817 | ) |
| 818 | |
| 819 | const error = yield* Effect.flip(client.test.equivalentJson({})) |
| 820 | assert.deepStrictEqual(error, { _tag: "SecondJsonError", message: "bad request" }) |
| 821 | })) |
| 822 | }) |
| 823 | |
| 824 | it.effect("decodes form-urlencoded responses", () => |
| 825 | Effect.gen(function*() { |
| 826 | const Api = HttpApi.make("Api").add( |
| 827 | HttpApiGroup.make("test").add( |
| 828 | HttpApiEndpoint.get("form", "/form", { |
| 829 | success: Schema.Struct({ name: Schema.String }).pipe(HttpApiSchema.asFormUrlEncoded()) |
| 830 | }) |
| 831 | ) |
| 832 | ) |
| 833 | const client = yield* HttpApiClient.makeWith(Api, { |
| 834 | baseUrl: "https://example.test", |
| 835 | httpClient: clientFromResponse(() => |
| 836 | new Response("name=Ada", { |
| 837 | status: 200, |
| 838 | headers: { "content-type": "application/x-www-form-urlencoded" } |
| 839 | }) |
| 840 | ) |
| 841 | }) |
| 842 | |
| 843 | const value = yield* client.test.form({}) |
| 844 | |
| 845 | assert.deepStrictEqual(value, { name: "Ada" }) |
| 846 | })) |
| 847 | |
| 848 | describe("response headers", () => { |
| 849 | it.effect("fails response decoding when a declared header is invalid", () => |
| 850 | Effect.gen(function*() { |
| 851 | const Api = HttpApi.make("Api").add( |
| 852 | HttpApiGroup.make("test").add( |
| 853 | HttpApiEndpoint.get("created", "/created", { |
| 854 | success: HttpApiSchema.WithHeaders( |
| 855 | Schema.Struct({ id: Schema.Int }), |
| 856 | { "x-count": Schema.Int } |
| 857 | ) |
| 858 | }) |
| 859 | ) |
| 860 | ) |
| 861 | const decodeFailure = Effect.fnUntraced(function*(body: unknown, count: string) { |
| 862 | const client = yield* HttpApiClient.makeWith(Api, { |
| 863 | baseUrl: "http://test", |
| 864 | httpClient: clientFromResponse(() => |
| 865 | new Response(JSON.stringify(body), { |
| 866 | status: 200, |
| 867 | headers: { |
| 868 | "content-type": "application/json", |
| 869 | "x-count": count |
| 870 | } |
| 871 | }) |
| 872 | ) |
| 873 | }) |
| 874 | const exit = yield* Effect.exit(client.test.created({})) |
| 875 | assert.strictEqual(exit._tag, "Failure") |
| 876 | if (exit._tag === "Success") { |
| 877 | throw new Error("Expected response decoding to fail") |
| 878 | } |
| 879 | return Cause.squash(exit.cause) as { readonly _tag?: string } |
| 880 | }) |
| 881 | |
| 882 | const bodyError = yield* decodeFailure({ id: "invalid" }, "1") |
| 883 | const headerError = yield* decodeFailure({ id: 1 }, "invalid") |
| 884 | |
| 885 | assert.strictEqual(bodyError._tag, "SchemaError") |
| 886 | assert.strictEqual(headerError._tag, bodyError._tag) |
| 887 | })) |
| 888 | }) |
| 889 | |
| 890 | describe("urlBuilder", () => { |
| 891 | const Api = HttpApi.make("Api") |
| 892 | .add( |
| 893 | HttpApiGroup.make("users") |
| 894 | .add( |
| 895 | HttpApiEndpoint.get("getUser", "/users/:id", { |
| 896 | params: { |
| 897 | id: Schema.Finite |
| 898 | }, |
| 899 | query: { |
| 900 | page: Schema.Finite, |
| 901 | tags: Schema.Array(Schema.Finite) |
| 902 | } |
| 903 | }), |
| 904 | HttpApiEndpoint.get("health", "/health") |
| 905 | ) |
| 906 | ) |
| 907 | |
| 908 | it("builds urls using endpoint schemas", () => { |
| 909 | const builder = HttpApiClient.urlBuilder(Api, { |
| 910 | baseUrl: "https://api.example.com" |
| 911 | }) |
| 912 | |
| 913 | strictEqual( |
| 914 | builder.users.getUser({ |
| 915 | params: { |
| 916 | id: 123 |
| 917 | }, |
| 918 | query: { |
| 919 | page: 1, |
| 920 | tags: [1, 2] |
| 921 | } |
| 922 | }), |
| 923 | "https://api.example.com/users/123?page=1&tags=1&tags=2" |
| 924 | ) |
| 925 | }) |
| 926 | |
| 927 | it("preserves a base URL pathname", () => { |
| 928 | const builder = HttpApiClient.urlBuilder(Api, { |
| 929 | baseUrl: "https://api.example.com/v1" |
| 930 | }) |
| 931 | |
| 932 | strictEqual(builder.users.health(), "https://api.example.com/v1/health") |
| 933 | }) |
| 934 | |
| 935 | it("encodes path parameters", () => { |
| 936 | const Api = HttpApi.make("Api") |
| 937 | .add( |
| 938 | HttpApiGroup.make("stacks") |
| 939 | .add( |
| 940 | HttpApiEndpoint.get("listResources", "/state/stacks/:stack/stages/:stage/resources", { |
| 941 | params: { |
| 942 | stack: Schema.String, |
| 943 | stage: Schema.String |
| 944 | } |
| 945 | }) |
| 946 | ) |
| 947 | ) |
| 948 | const builder = HttpApiClient.urlBuilder(Api, { |
| 949 | baseUrl: "https://api.example.com" |
| 950 | }) |
| 951 | |
| 952 | strictEqual( |
| 953 | builder.stacks.listResources({ |
| 954 | params: { |
| 955 | stack: "a/b", |
| 956 | stage: "prod/blue" |
| 957 | } |
| 958 | }), |
| 959 | "https://api.example.com/state/stacks/a%2Fb/stages/prod%2Fblue/resources" |
| 960 | ) |
| 961 | }) |
| 962 | |
| 963 | it("omits missing optional path parameters", () => { |
| 964 | const Api = HttpApi.make("Api") |
| 965 | .add( |
| 966 | HttpApiGroup.make("files") |
| 967 | .add( |
| 968 | HttpApiEndpoint.get("download", "/files/:path?", { |
| 969 | params: { |
| 970 | path: Schema.optional(Schema.String) |
| 971 | } |
| 972 | }) |
| 973 | ) |
| 974 | ) |
| 975 | const builder = HttpApiClient.urlBuilder(Api, { |
| 976 | baseUrl: "https://api.example.com" |
| 977 | }) |
| 978 | |
| 979 | strictEqual( |
| 980 | builder.files.download({ params: {} }), |
| 981 | "https://api.example.com/files" |
| 982 | ) |
| 983 | strictEqual( |
| 984 | builder.files.download({ params: { path: "a/b" } }), |
| 985 | "https://api.example.com/files/a%2Fb" |
| 986 | ) |
| 987 | }) |
| 988 | |
| 989 | it("returns relative urls when baseUrl is omitted", () => { |
| 990 | const builder = HttpApiClient.urlBuilder(Api) |
| 991 | |
| 992 | strictEqual(builder.users.health(), "/health") |
| 993 | }) |
| 994 | |
| 995 | it("supports top-level endpoints", () => { |
| 996 | const TopLevelApi = HttpApi.make("Api") |
| 997 | .add( |
| 998 | HttpApiGroup.make("top", { topLevel: true }) |
| 999 | .add( |
| 1000 | HttpApiEndpoint.get("health", "/health") |
| 1001 | ) |
| 1002 | ) |
| 1003 | .prefix("/v1") |
| 1004 | |
| 1005 | const builder = HttpApiClient.urlBuilder(TopLevelApi, { |
| 1006 | baseUrl: "https://api.example.com" |
| 1007 | }) |
| 1008 | |
| 1009 | strictEqual(builder.health(), "https://api.example.com/v1/health") |
| 1010 | }) |
| 1011 | |
| 1012 | it("stores __proto__ identifiers as own properties", () => { |
| 1013 | const Api = HttpApi.make("Api").add( |
| 1014 | HttpApiGroup.make("__proto__").add( |
| 1015 | HttpApiEndpoint.get("__proto__", "/proto") |
| 1016 | ) |
| 1017 | ) |
| 1018 | const builder = HttpApiClient.urlBuilder(Api) |
| 1019 | |
| 1020 | assert.isTrue(Object.hasOwn(builder, "__proto__")) |
| 1021 | assert.isTrue(Object.hasOwn(builder["__proto__"], "__proto__")) |
| 1022 | strictEqual(builder["__proto__"]["__proto__"](), "/proto") |
| 1023 | }) |
| 1024 | }) |
| 1025 | |
| 1026 | it.effect("stores __proto__ client identifiers as own properties", () => |
| 1027 | Effect.gen(function*() { |
| 1028 | const Api = HttpApi.make("Api").add( |
| 1029 | HttpApiGroup.make("__proto__").add( |
| 1030 | HttpApiEndpoint.get("__proto__", "/proto") |
| 1031 | ) |
| 1032 | ) |
| 1033 | const httpClient = clientFromResponse(() => new Response(null, { status: 204 })) |
| 1034 | const client = yield* HttpApiClient.makeWith(Api, { httpClient }) |
| 1035 | const groupClient = yield* HttpApiClient.group(Api, { |
| 1036 | group: "__proto__", |
| 1037 | httpClient |
| 1038 | }) |
| 1039 | |
| 1040 | assert.isTrue(Object.hasOwn(client, "__proto__")) |
| 1041 | assert.isTrue(Object.hasOwn(client["__proto__"], "__proto__")) |
| 1042 | assert.strictEqual(typeof client["__proto__"]["__proto__"], "function") |
| 1043 | assert.isTrue(Object.hasOwn(groupClient, "__proto__")) |
| 1044 | assert.strictEqual(typeof groupClient["__proto__"], "function") |
| 1045 | })) |
| 1046 | |
| 1047 | it.effect("applies transformClient to endpoint clients exactly once", () => |
| 1048 | Effect.gen(function*() { |
| 1049 | const Api = HttpApi.make("Api").add( |
| 1050 | HttpApiGroup.make("test").add(HttpApiEndpoint.get("health", "/health")) |
| 1051 | ) |
| 1052 | let transformations = 0 |
| 1053 | const httpClient = HttpClient.make((request, url) => |
| 1054 | Effect.sync(() => { |
| 1055 | strictEqual(url.toString(), "https://api.example.com/health") |
| 1056 | return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })) |
| 1057 | }) |
| 1058 | ) |
| 1059 | const health = yield* HttpApiClient.endpoint(Api, { |
| 1060 | group: "test", |
| 1061 | endpoint: "health", |
| 1062 | httpClient, |
| 1063 | transformClient: (client) => { |
| 1064 | transformations++ |
| 1065 | return client.pipe( |
| 1066 | HttpClient.mapRequest(HttpClientRequest.prependUrl("https://api.example.com")) |
| 1067 | ) |
| 1068 | } |
| 1069 | }) |
| 1070 | |
| 1071 | yield* health({ responseMode: "response-only" }) |
| 1072 | yield* health({ responseMode: "response-only" }) |
| 1073 | |
| 1074 | strictEqual(transformations, 1) |
| 1075 | })) |
| 1076 | |
| 1077 | it.effect("encodes path parameters when executing requests", () => |
| 1078 | Effect.gen(function*() { |
| 1079 | const Api = HttpApi.make("Api") |
| 1080 | .add( |
| 1081 | HttpApiGroup.make("stacks") |
| 1082 | .add( |
| 1083 | HttpApiEndpoint.get("listResources", "/state/stacks/:stack/stages/:stage/resources", { |
| 1084 | params: { |
| 1085 | stack: Schema.String, |
| 1086 | stage: Schema.String |
| 1087 | } |
| 1088 | }) |
| 1089 | ) |
| 1090 | ) |
| 1091 | const httpClient = HttpClient.make((request, url) => |
| 1092 | Effect.sync(() => { |
| 1093 | strictEqual(url.toString(), "https://api.example.com/state/stacks/a%2Fb/stages/prod%2Fblue/resources") |
| 1094 | return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })) |
| 1095 | }) |
| 1096 | ) |
| 1097 | const client = yield* HttpApiClient.makeWith(Api, { |
| 1098 | httpClient, |
| 1099 | baseUrl: "https://api.example.com" |
| 1100 | }) |
| 1101 | |
| 1102 | yield* client.stacks.listResources({ |
| 1103 | params: { |
| 1104 | stack: "a/b", |
| 1105 | stage: "prod/blue" |
| 1106 | }, |
| 1107 | responseMode: "response-only" |
| 1108 | }) |
| 1109 | })) |
| 1110 | |
| 1111 | it.effect("omits optional path parameters when executing requests", () => |
| 1112 | Effect.gen(function*() { |
| 1113 | const Api = HttpApi.make("Api") |
| 1114 | .add( |
| 1115 | HttpApiGroup.make("files") |
| 1116 | .add( |
| 1117 | HttpApiEndpoint.get("download", "/files/:path?", { |
| 1118 | params: { |
| 1119 | path: Schema.optional(Schema.String) |
| 1120 | } |
| 1121 | }) |
| 1122 | ) |
| 1123 | ) |
| 1124 | const urls: Array<string> = [] |
| 1125 | const httpClient = HttpClient.make((request, url) => |
| 1126 | Effect.sync(() => { |
| 1127 | urls.push(url.toString()) |
| 1128 | return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })) |
| 1129 | }) |
| 1130 | ) |
| 1131 | const client = yield* HttpApiClient.makeWith(Api, { |
| 1132 | httpClient, |
| 1133 | baseUrl: "https://api.example.com" |
| 1134 | }) |
| 1135 | |
| 1136 | yield* client.files.download({ |
| 1137 | params: {}, |
| 1138 | responseMode: "response-only" |
| 1139 | }) |
| 1140 | yield* client.files.download({ |
| 1141 | params: { path: "a/b" }, |
| 1142 | responseMode: "response-only" |
| 1143 | }) |
| 1144 | |
| 1145 | strictEqual(urls[0], "https://api.example.com/files") |
| 1146 | strictEqual(urls[1], "https://api.example.com/files/a%2Fb") |
| 1147 | })) |
| 1148 | }) |
| 1149 | |
| 1150 | const textEncoder = new TextEncoder() |
| 1151 | |
| 1152 | const StreamError = Schema.Struct({ reason: Schema.String }) |
| 1153 | |
| 1154 | const Events = Schema.Struct({ |
| 1155 | event: Schema.String, |
| 1156 | data: Schema.String |
| 1157 | }) |
| 1158 | |
| 1159 | class EndpointError extends Schema.TaggedError<EndpointError>()("EndpointError", { |
| 1160 | message: Schema.String |
| 1161 | }, { httpApiStatus: 400 }) {} |
| 1162 | |
| 1163 | const MixedSuccess = Schema.Struct({ |
| 1164 | message: Schema.String |
| 1165 | }) |
| 1166 | |
| 1167 | const MixedEventData = Schema.Struct({ |
| 1168 | text: Schema.String |
| 1169 | }) |
| 1170 | |
| 1171 | const StreamingApi = HttpApi.make("StreamingApi").add( |
| 1172 | HttpApiGroup.make("test") |
| 1173 | .add( |
| 1174 | HttpApiEndpoint.get("events", "/events", { |
| 1175 | success: HttpApiSchema.StreamSse({ events: Events, error: StreamError }), |
| 1176 | error: EndpointError |
| 1177 | }), |
| 1178 | HttpApiEndpoint.get("download", "/download", { |
| 1179 | success: HttpApiSchema.StreamUint8Array(), |
| 1180 | error: EndpointError |
| 1181 | }) |
| 1182 | ) |
| 1183 | ) |
| 1184 | |
| 1185 | const AnnotatedStreamingApi = HttpApi.make("AnnotatedStreamingApi").add( |
| 1186 | HttpApiGroup.make("test") |
| 1187 | .add( |
| 1188 | HttpApiEndpoint.get("events", "/events", { |
| 1189 | success: HttpApiSchema.status(202)(HttpApiSchema.StreamSse({ events: Events, error: StreamError })) |
| 1190 | }), |
| 1191 | HttpApiEndpoint.get("download", "/download", { |
| 1192 | success: HttpApiSchema.status(206)(HttpApiSchema.StreamUint8Array()) |
| 1193 | }) |
| 1194 | ) |
| 1195 | ) |
| 1196 | |
| 1197 | const MixedSuccessApi = HttpApi.make("MixedSuccessApi").add( |
| 1198 | HttpApiGroup.make("test") |
| 1199 | .add( |
| 1200 | HttpApiEndpoint.get("chat", "/chat", { |
| 1201 | success: [ |
| 1202 | MixedSuccess, |
| 1203 | HttpApiSchema.StreamSse({ data: MixedEventData, error: StreamError }) |
| 1204 | ] |
| 1205 | }) |
| 1206 | ) |
| 1207 | ) |
| 1208 | |
| 1209 | const JsonResponseError = Schema.Struct({ |
| 1210 | _tag: Schema.Literal("JsonError"), |
| 1211 | message: Schema.String |
| 1212 | }).pipe(HttpApiSchema.status(400)) |
| 1213 | |
| 1214 | const TextResponseError = Schema.String.pipe( |
| 1215 | HttpApiSchema.asText(), |
| 1216 | HttpApiSchema.status(400) |
| 1217 | ) |
| 1218 | |
| 1219 | const NoContentResponseError = Schema.Literal("NoContentError").pipe( |
| 1220 | HttpApiSchema.asNoContent({ decode: () => "NoContentError" as const }), |
| 1221 | HttpApiSchema.status(400) |
| 1222 | ) |
| 1223 | |
| 1224 | const FirstJsonResponseError = Schema.Struct({ |
| 1225 | _tag: Schema.Literal("FirstJsonError"), |
| 1226 | code: Schema.Number |
| 1227 | }).pipe( |
| 1228 | HttpApiSchema.asJson({ contentType: "Application/Problem+JSON" }), |
| 1229 | HttpApiSchema.status(400) |
| 1230 | ) |
| 1231 | |
| 1232 | const SecondJsonResponseError = Schema.Struct({ |
| 1233 | _tag: Schema.Literal("SecondJsonError"), |
| 1234 | message: Schema.String |
| 1235 | }).pipe( |
| 1236 | HttpApiSchema.asJson({ contentType: "application/problem+json; charset=utf-8" }), |
| 1237 | HttpApiSchema.status(400) |
| 1238 | ) |
| 1239 | |
| 1240 | const ErrorContentTypeApi = HttpApi.make("ErrorContentTypeApi").add( |
| 1241 | HttpApiGroup.make("test") |
| 1242 | .add( |
| 1243 | HttpApiEndpoint.get("textFirst", "/text-first", { |
| 1244 | error: [TextResponseError, JsonResponseError] |
| 1245 | }), |
| 1246 | HttpApiEndpoint.get("jsonFirst", "/json-first", { |
| 1247 | error: [JsonResponseError, TextResponseError] |
| 1248 | }), |
| 1249 | HttpApiEndpoint.get("noContent", "/no-content", { |
| 1250 | error: [NoContentResponseError, TextResponseError] |
| 1251 | }), |
| 1252 | HttpApiEndpoint.get("equivalentJson", "/equivalent-json", { |
| 1253 | error: [FirstJsonResponseError, SecondJsonResponseError] |
| 1254 | }) |
| 1255 | ) |
| 1256 | ) |
| 1257 | |
| 1258 | const clientFromResponse = (response: () => Response): HttpClient.HttpClient => |
| 1259 | HttpClient.make((request): Effect.Effect<HttpClientResponse.HttpClientResponse, never, never> => |
| 1260 | Effect.succeed(HttpClientResponse.fromWeb(request, response())) |
| 1261 | ) |
| 1262 | |
| 1263 | const textStream = (chunks: ReadonlyArray<string>): ReadableStream<Uint8Array> => { |
| 1264 | let index = 0 |
| 1265 | return new ReadableStream<Uint8Array>({ |
| 1266 | pull(controller) { |
| 1267 | if (index === chunks.length) { |
| 1268 | controller.close() |
| 1269 | } else { |
| 1270 | controller.enqueue(textEncoder.encode(chunks[index++]!)) |
| 1271 | } |
| 1272 | } |
| 1273 | }) |
| 1274 | } |
| 1275 | |
| 1276 | const byteStream = (chunks: ReadonlyArray<Uint8Array>): ReadableStream<Uint8Array> => { |
| 1277 | let index = 0 |
| 1278 | return new ReadableStream<Uint8Array>({ |
| 1279 | pull(controller) { |
| 1280 | if (index === chunks.length) { |
| 1281 | controller.close() |
| 1282 | } else { |
| 1283 | controller.enqueue(chunks[index++]!) |
| 1284 | } |
| 1285 | } |
| 1286 | }) |
| 1287 | } |