From dc13e23160b920b4a689b04ac8d4490831d9b0c4 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 4 Sep 2024 11:32:18 +1200 Subject: [PATCH] refactor /platform HttpClient (#3537) --- .changeset/tender-foxes-walk.md | 87 ++++ .../cluster-node/examples/sample-connect.ts | 36 +- .../cluster-node/examples/sample-manager.ts | 31 +- .../cluster-node/examples/sample-shard.ts | 36 +- .../platform-browser/src/BrowserHttpClient.ts | 17 +- .../src/internal/httpClient.ts | 23 +- .../test/BrowserHttpClient.test.ts | 103 +++-- packages/platform-bun/examples/http-client.ts | 11 +- packages/platform-bun/src/BunHttpServer.ts | 2 +- .../platform-bun/src/internal/httpServer.ts | 9 +- packages/platform-node/examples/api.ts | 4 +- .../platform-node/examples/http-client.ts | 9 +- packages/platform-node/src/NodeHttpClient.ts | 32 +- packages/platform-node/src/NodeHttpServer.ts | 2 +- .../platform-node/src/internal/httpClient.ts | 6 +- .../src/internal/httpClientUndici.ts | 32 +- packages/platform-node/test/HttpApi.test.ts | 2 +- .../platform-node/test/HttpClient.test.ts | 57 ++- .../platform-node/test/HttpServer.test.ts | 378 ++++++++---------- packages/platform/README.md | 368 ++++++++--------- packages/platform/src/FetchHttpClient.ts | 25 ++ packages/platform/src/HttpApiClient.ts | 10 +- packages/platform/src/HttpClient.ts | 126 +++--- packages/platform/src/HttpClientRequest.ts | 42 +- packages/platform/src/HttpClientResponse.ts | 166 +------- packages/platform/src/HttpIncomingMessage.ts | 43 -- packages/platform/src/HttpServer.ts | 2 +- packages/platform/src/index.ts | 5 + .../platform/src/internal/fetchHttpClient.ts | 56 +++ packages/platform/src/internal/httpClient.ts | 228 ++++++----- .../src/internal/httpClientRequest.ts | 24 +- .../src/internal/httpClientResponse.ts | 102 +---- packages/platform/test/HttpClient.test.ts | 80 ++-- packages/rpc-http/examples/client.ts | 28 +- packages/rpc-http/src/HttpRpcResolver.ts | 29 +- .../rpc-http/src/HttpRpcResolverNoStream.ts | 27 +- 36 files changed, 1029 insertions(+), 1209 deletions(-) create mode 100644 .changeset/tender-foxes-walk.md create mode 100644 packages/platform/src/FetchHttpClient.ts create mode 100644 packages/platform/src/internal/fetchHttpClient.ts diff --git a/.changeset/tender-foxes-walk.md b/.changeset/tender-foxes-walk.md new file mode 100644 index 00000000000..d7574348dcf --- /dev/null +++ b/.changeset/tender-foxes-walk.md @@ -0,0 +1,87 @@ +--- +"@effect/platform-browser": minor +"@effect/platform-node": minor +"@effect/platform-bun": minor +"@effect/platform": minor +"@effect/rpc-http": minor +--- + +refactor /platform HttpClient + +#### HttpClient.fetch removed + +The `HttpClient.fetch` client implementation has been removed. Instead, you can +access a `HttpClient` using the corresponding `Context.Tag`. + +```ts +import { FetchHttpClient, HttpClient } from "@effect/platform" +import { Effect } from "effect" + +Effect.gen(function* () { + const client = yield* HttpClient.HttpClient + + // make a get request + yield* client.get("https://jsonplaceholder.typicode.com/todos/1") +}).pipe( + Effect.scoped, + // the fetch client has been moved to the `FetchHttpClient` module + Effect.provide(FetchHttpClient.layer) +) +``` + +#### `HttpClient` interface now uses methods + +Instead of being a function that returns the response, the `HttpClient` +interface now uses methods to make requests. + +Some shorthand methods have been added to the `HttpClient` interface to make +less complex requests easier. + +```ts +import { + FetchHttpClient, + HttpClient, + HttpClientRequest +} from "@effect/platform" +import { Effect } from "effect" + +Effect.gen(function* () { + const client = yield* HttpClient.HttpClient + + // make a get request + yield* client.get("https://jsonplaceholder.typicode.com/todos/1") + // make a post request + yield* client.post("https://jsonplaceholder.typicode.com/todos") + + // execute a request instance + yield* client.execute( + HttpClientRequest.get("https://jsonplaceholder.typicode.com/todos/1") + ) +}) +``` + +#### Scoped `HttpClientResponse` helpers removed + +The `HttpClientResponse` helpers that also eliminated the `Scope` have been removed. + +Instead, you can use the `HttpClientResponse` methods directly, and explicitly +add a `Effect.scoped` to the pipeline. + +```ts +import { FetchHttpClient, HttpClient } from "@effect/platform" +import { Effect } from "effect" + +Effect.gen(function* () { + const client = yield* HttpClient.HttpClient + + yield* client.get("https://jsonplaceholder.typicode.com/todos/1").pipe( + Effect.flatMap((response) => response.json), + Effect.scoped // eliminate the `Scope` + ) +}) +``` + +#### Some apis have been renamed + +Including the `HttpClientRequest` body apis, which is to make them more +discoverable. diff --git a/packages/cluster-node/examples/sample-connect.ts b/packages/cluster-node/examples/sample-connect.ts index d030c849540..3aad0b4a602 100644 --- a/packages/cluster-node/examples/sample-connect.ts +++ b/packages/cluster-node/examples/sample-connect.ts @@ -30,25 +30,33 @@ const liveLayer = Effect.gen(function*() { Layer.effectDiscard, Layer.provide(Sharding.live), Layer.provide(StorageFile.storageFile), - Layer.provide(PodsRpc.podsRpc((podAddress) => - HttpRpcResolver.make( - HttpClient.fetchOk.pipe( - HttpClient.mapRequest( - HttpClientRequest.prependUrl(`http://${podAddress.host}:${podAddress.port}/api/rest`) - ) - ) - ).pipe(RpcResolver.toClient) - )), - Layer.provide(ShardManagerClientRpc.shardManagerClientRpc( - (shardManagerUri) => + Layer.provide(Layer.unwrapEffect(Effect.gen(function*() { + const client = yield* HttpClient.HttpClient + return PodsRpc.podsRpc((podAddress) => HttpRpcResolver.make( - HttpClient.fetchOk.pipe( + client.pipe( + HttpClient.filterStatusOk, HttpClient.mapRequest( - HttpClientRequest.prependUrl(shardManagerUri) + HttpClientRequest.prependUrl(`http://${podAddress.host}:${podAddress.port}/api/rest`) ) ) ).pipe(RpcResolver.toClient) - )), + ) + }))), + Layer.provide(Layer.unwrapEffect(Effect.gen(function*() { + const client = yield* HttpClient.HttpClient + return ShardManagerClientRpc.shardManagerClientRpc( + (shardManagerUri) => + HttpRpcResolver.make( + client.pipe( + HttpClient.filterStatusOk, + HttpClient.mapRequest( + HttpClientRequest.prependUrl(shardManagerUri) + ) + ) + ).pipe(RpcResolver.toClient) + ) + }))), Layer.provide(ShardingConfig.withDefaults({ shardingPort: 54322 })), Layer.provide(Serialization.json), Layer.provide(NodeHttpClient.layerUndici) diff --git a/packages/cluster-node/examples/sample-manager.ts b/packages/cluster-node/examples/sample-manager.ts index 01321024b01..571da95ae60 100644 --- a/packages/cluster-node/examples/sample-manager.ts +++ b/packages/cluster-node/examples/sample-manager.ts @@ -5,7 +5,14 @@ import * as StorageFile from "@effect/cluster-node/StorageFile" import * as ManagerConfig from "@effect/cluster/ManagerConfig" import * as PodsHealth from "@effect/cluster/PodsHealth" import * as ShardManager from "@effect/cluster/ShardManager" -import { HttpClient, HttpClientRequest, HttpMiddleware, HttpRouter, HttpServer } from "@effect/platform" +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpMiddleware, + HttpRouter, + HttpServer +} from "@effect/platform" import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" import { RpcResolver } from "@effect/rpc" import { HttpRpcResolver, HttpRpcRouter } from "@effect/rpc-http" @@ -34,17 +41,21 @@ const liveShardingManager = Effect.never.pipe( Layer.provide(ShardManager.live), Layer.provide(StorageFile.storageFile), Layer.provide(PodsHealth.local), - Layer.provide(PodsRpc.podsRpc((podAddress) => - HttpRpcResolver.make( - HttpClient.fetchOk.pipe( - HttpClient.mapRequest( - HttpClientRequest.prependUrl(`http://${podAddress.host}:${podAddress.port}/api/rest`) + Layer.provide(Layer.unwrapEffect(Effect.gen(function*() { + const client = yield* HttpClient.HttpClient + return PodsRpc.podsRpc((podAddress) => + HttpRpcResolver.make( + client.pipe( + HttpClient.filterStatusOk, + HttpClient.mapRequest( + HttpClientRequest.prependUrl(`http://${podAddress.host}:${podAddress.port}/api/rest`) + ) ) - ) - ).pipe(RpcResolver.toClient) - )), + ).pipe(RpcResolver.toClient) + ) + }))), Layer.provide(ManagerConfig.fromConfig), - Layer.provide(HttpClient.layer) + Layer.provide(FetchHttpClient.layer) ) Layer.launch(liveShardingManager).pipe( diff --git a/packages/cluster-node/examples/sample-shard.ts b/packages/cluster-node/examples/sample-shard.ts index 85854704a44..3bbfc38da32 100644 --- a/packages/cluster-node/examples/sample-shard.ts +++ b/packages/cluster-node/examples/sample-shard.ts @@ -62,25 +62,33 @@ const liveLayer = Sharding.registerEntity( Layer.provide(HttpLive), Layer.provideMerge(Sharding.live), Layer.provide(StorageFile.storageFile), - Layer.provide(PodsRpc.podsRpc((podAddress) => - HttpRpcResolver.make( - HttpClient.fetchOk.pipe( - HttpClient.mapRequest( - HttpClientRequest.prependUrl(`http://${podAddress.host}:${podAddress.port}/api/rest`) - ) - ) - ).pipe(RpcResolver.toClient) - )), - Layer.provide(ShardManagerClientRpc.shardManagerClientRpc( - (shardManagerUri) => + Layer.provide(Layer.unwrapEffect(Effect.gen(function*() { + const client = yield* HttpClient.HttpClient + return PodsRpc.podsRpc((podAddress) => HttpRpcResolver.make( - HttpClient.fetchOk.pipe( + client.pipe( + HttpClient.filterStatusOk, HttpClient.mapRequest( - HttpClientRequest.prependUrl(shardManagerUri) + HttpClientRequest.prependUrl(`http://${podAddress.host}:${podAddress.port}/api/rest`) ) ) ).pipe(RpcResolver.toClient) - )), + ) + }))), + Layer.provide(Layer.unwrapEffect(Effect.gen(function*() { + const client = yield* HttpClient.HttpClient + return ShardManagerClientRpc.shardManagerClientRpc( + (shardManagerUri) => + HttpRpcResolver.make( + client.pipe( + HttpClient.filterStatusOk, + HttpClient.mapRequest( + HttpClientRequest.prependUrl(shardManagerUri) + ) + ) + ).pipe(RpcResolver.toClient) + ) + }))), Layer.provide(Serialization.json), Layer.provide(NodeHttpClient.layerUndici), Layer.provide(ShardingConfig.fromConfig) diff --git a/packages/platform-browser/src/BrowserHttpClient.ts b/packages/platform-browser/src/BrowserHttpClient.ts index c633bf3407b..303f59a89c6 100644 --- a/packages/platform-browser/src/BrowserHttpClient.ts +++ b/packages/platform-browser/src/BrowserHttpClient.ts @@ -2,30 +2,27 @@ * @since 1.0.0 */ import type * as HttpClient from "@effect/platform/HttpClient" +import * as Context from "effect/Context" import type { Effect } from "effect/Effect" import type * as FiberRef from "effect/FiberRef" import type { LazyArg } from "effect/Function" import type * as Layer from "effect/Layer" import * as internal from "./internal/httpClient.js" -/** - * @since 1.0.0 - * @category clients - */ -export const xmlHttpRequest: HttpClient.HttpClient.Default = internal.makeXMLHttpRequest - /** * @since 1.0.0 * @category layers */ -export const layerXMLHttpRequest: Layer.Layer = - internal.layerXMLHttpRequest +export const layerXMLHttpRequest: Layer.Layer = internal.layerXMLHttpRequest /** * @since 1.0.0 - * @category fiber refs + * @category tags */ -export const currentXMLHttpRequest: FiberRef.FiberRef> = internal.currentXMLHttpRequest +export class XMLHttpRequest extends Context.Tag(internal.xhrTagKey)< + XMLHttpRequest, + LazyArg +>() {} /** * @since 1.0.0 diff --git a/packages/platform-browser/src/internal/httpClient.ts b/packages/platform-browser/src/internal/httpClient.ts index 89133041b9b..b8b4b96ca9e 100644 --- a/packages/platform-browser/src/internal/httpClient.ts +++ b/packages/platform-browser/src/internal/httpClient.ts @@ -6,21 +6,20 @@ import type * as ClientRequest from "@effect/platform/HttpClientRequest" import * as ClientResponse from "@effect/platform/HttpClientResponse" import * as IncomingMessage from "@effect/platform/HttpIncomingMessage" import * as UrlParams from "@effect/platform/UrlParams" +import * as Context from "effect/Context" import * as Effect from "effect/Effect" import * as FiberRef from "effect/FiberRef" import { type LazyArg } from "effect/Function" import { globalValue } from "effect/GlobalValue" import * as Inspectable from "effect/Inspectable" -import * as Layer from "effect/Layer" import * as Option from "effect/Option" import * as Stream from "effect/Stream" import * as HeaderParser from "multipasta/HeadersParser" /** @internal */ -export const currentXMLHttpRequest = globalValue( - "@effect/platform-browser/BrowserHttpClient/currentXMLHttpRequest", - () => FiberRef.unsafeMake>(() => new XMLHttpRequest()) -) +export const xhrTagKey = "@effect/platform-browser/BrowserHttpClient/XMLHttpRequest" + +const xhrTag = Context.GenericTag>(xhrTagKey) /** @internal */ export const currentXHRResponseType = globalValue( @@ -36,10 +35,15 @@ export const withXHRArrayBuffer = (effect: Effect.Effect): Eff "arraybuffer" ) -/** @internal */ -export const makeXMLHttpRequest = Client.makeDefault((request, url, signal, fiber) => +const makeXhr = () => new XMLHttpRequest() + +const makeXMLHttpRequest = Client.makeService((request, url, signal, fiber) => Effect.suspend(() => { - const xhr = fiber.getFiberRef(currentXMLHttpRequest)() + const xhr = Context.getOrElse( + fiber.getFiberRef(FiberRef.currentContext), + xhrTag, + () => makeXhr + )() signal.addEventListener("abort", () => { xhr.abort() xhr.onreadystatechange = null @@ -70,6 +74,7 @@ export const makeXMLHttpRequest = Client.makeDefault((request, url, signal, fibe )) } onChange() + return Effect.void }) ) }) @@ -332,4 +337,4 @@ class ClientResponseImpl extends IncomingMessageImpl implem } /** @internal */ -export const layerXMLHttpRequest = Layer.succeed(Client.HttpClient, makeXMLHttpRequest) +export const layerXMLHttpRequest = Client.layerMergedContext(Effect.succeed(makeXMLHttpRequest)) diff --git a/packages/platform-browser/test/BrowserHttpClient.test.ts b/packages/platform-browser/test/BrowserHttpClient.test.ts index 8e8367c278d..7dab7b9c5f0 100644 --- a/packages/platform-browser/test/BrowserHttpClient.test.ts +++ b/packages/platform-browser/test/BrowserHttpClient.test.ts @@ -1,39 +1,35 @@ -import { Cookies, HttpClientRequest, HttpClientResponse } from "@effect/platform" +import { Cookies, HttpClientRequest } from "@effect/platform" import { BrowserHttpClient } from "@effect/platform-browser" import { assert, describe, it } from "@effect/vitest" -import { Chunk, Effect, Stream } from "effect" +import { Chunk, Effect, Layer, Stream } from "effect" import * as MXHR from "mock-xmlhttprequest" +const layer = (...args: Parameters) => + Layer.unwrapEffect(Effect.sync(() => { + const server = MXHR.newServer(...args) + return BrowserHttpClient.layerXMLHttpRequest.pipe( + Layer.provide(Layer.succeed(BrowserHttpClient.XMLHttpRequest, server.xhrFactory)) + ) + })) + describe("BrowserHttpClient", () => { it.effect("json", () => - Effect.gen(function*(_) { - const server = MXHR.newServer({ - get: ["http://localhost:8080/my/url", { - headers: { "Content-Type": "application/json" }, - body: "{ \"message\": \"Success!\" }" - }] - }) - const body = yield* _( - HttpClientRequest.get("http://localhost:8080/my/url"), - BrowserHttpClient.xmlHttpRequest, + Effect.gen(function*() { + const body = yield* HttpClientRequest.get("http://localhost:8080/my/url").pipe( Effect.flatMap((_) => _.json), - Effect.scoped, - Effect.locally(BrowserHttpClient.currentXMLHttpRequest, server.xhrFactory) + Effect.scoped ) assert.deepStrictEqual(body, { message: "Success!" }) - })) + }).pipe(Effect.provide(layer({ + get: ["http://localhost:8080/my/url", { + headers: { "Content-Type": "application/json" }, + body: "{ \"message\": \"Success!\" }" + }] + })))) it.effect("stream", () => - Effect.gen(function*(_) { - const server = MXHR.newServer({ - get: ["http://localhost:8080/my/url", { - headers: { "Content-Type": "application/json" }, - body: "{ \"message\": \"Success!\" }" - }] - }) - const body = yield* _( - HttpClientRequest.get("http://localhost:8080/my/url"), - BrowserHttpClient.xmlHttpRequest, + Effect.gen(function*() { + const body = yield* HttpClientRequest.get("http://localhost:8080/my/url").pipe( Effect.map((_) => _.stream.pipe( Stream.decodeText(), @@ -41,47 +37,48 @@ describe("BrowserHttpClient", () => { ) ), Stream.unwrapScoped, - Stream.runCollect, - Effect.locally(BrowserHttpClient.currentXMLHttpRequest, server.xhrFactory) + Stream.runCollect ) assert.deepStrictEqual(Chunk.unsafeHead(body), "{ \"message\": \"Success!\" }") - })) + }).pipe(Effect.provide(layer({ + get: ["http://localhost:8080/my/url", { + headers: { "Content-Type": "application/json" }, + body: "{ \"message\": \"Success!\" }" + }] + })))) it.effect("cookies", () => - Effect.gen(function*(_) { - const server = MXHR.newServer({ - get: ["http://localhost:8080/my/url", { - headers: { "Content-Type": "application/json", "Set-Cookie": "foo=bar; HttpOnly; Secure" }, - body: "{ \"message\": \"Success!\" }" - }] - }) - const cookies = yield* _( - HttpClientRequest.get("http://localhost:8080/my/url"), - BrowserHttpClient.xmlHttpRequest, + Effect.gen(function*() { + const cookies = yield* HttpClientRequest.get("http://localhost:8080/my/url").pipe( Effect.map((res) => res.cookies), - Effect.scoped, - Effect.locally(BrowserHttpClient.currentXMLHttpRequest, server.xhrFactory) + Effect.scoped ) assert.deepStrictEqual(Cookies.toRecord(cookies), { foo: "bar" }) - })) + }).pipe( + Effect.provide(layer({ + get: ["http://localhost:8080/my/url", { + headers: { "Content-Type": "application/json", "Set-Cookie": "foo=bar; HttpOnly; Secure" }, + body: "{ \"message\": \"Success!\" }" + }] + })) + )) it.effect("arrayBuffer", () => - Effect.gen(function*(_) { - const server = MXHR.newServer({ + Effect.gen(function*() { + const body = yield* HttpClientRequest.get("http://localhost:8080/my/url").pipe( + Effect.flatMap((_) => _.arrayBuffer), + Effect.scoped, + BrowserHttpClient.withXHRArrayBuffer + ) + assert.strictEqual(new TextDecoder().decode(body), "{ \"message\": \"Success!\" }") + }).pipe( + Effect.provide(layer({ get: ["http://localhost:8080/my/url", { headers: { "Content-Type": "application/json" }, body: "{ \"message\": \"Success!\" }" }] - }) - const body = yield* _( - HttpClientRequest.get("http://localhost:8080/my/url"), - BrowserHttpClient.xmlHttpRequest, - HttpClientResponse.arrayBuffer, - BrowserHttpClient.withXHRArrayBuffer, - Effect.locally(BrowserHttpClient.currentXMLHttpRequest, server.xhrFactory) - ) - assert.strictEqual(new TextDecoder().decode(body), "{ \"message\": \"Success!\" }") - })) + })) + )) }) diff --git a/packages/platform-bun/examples/http-client.ts b/packages/platform-bun/examples/http-client.ts index 20d3866f72b..eeaab80b822 100644 --- a/packages/platform-bun/examples/http-client.ts +++ b/packages/platform-bun/examples/http-client.ts @@ -1,4 +1,4 @@ -import { HttpClient, HttpClientRequest, HttpClientResponse } from "@effect/platform" +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "@effect/platform" import type { HttpBody, HttpClientError } from "@effect/platform" import { BunRuntime } from "@effect/platform-bun" import type * as ParseResult from "@effect/schema/ParseResult" @@ -29,21 +29,22 @@ const makeTodoService = Effect.gen(function*() { HttpClient.mapRequest(HttpClientRequest.prependUrl("https://jsonplaceholder.typicode.com")) ) - const addTodoWithoutIdBody = HttpClientRequest.schemaBody(TodoWithoutId) + const addTodoWithoutIdBody = HttpClientRequest.schemaBodyJson(TodoWithoutId) const create = (todo: TodoWithoutId) => addTodoWithoutIdBody( HttpClientRequest.post("/todos"), todo ).pipe( - Effect.flatMap(clientWithBaseUrl), - HttpClientResponse.schemaBodyJsonScoped(Todo) + Effect.flatMap(clientWithBaseUrl.execute), + Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo)), + Effect.scoped ) return TodoService.of({ create }) }) const TodoServiceLive = Layer.effect(TodoService, makeTodoService).pipe( - Layer.provide(HttpClient.layer) + Layer.provide(FetchHttpClient.layer) ) Effect.flatMap( diff --git a/packages/platform-bun/src/BunHttpServer.ts b/packages/platform-bun/src/BunHttpServer.ts index 99e8b542393..913dec29725 100644 --- a/packages/platform-bun/src/BunHttpServer.ts +++ b/packages/platform-bun/src/BunHttpServer.ts @@ -46,7 +46,7 @@ export const layer: ( * @category layers */ export const layerTest: Layer.Layer< - | HttpClient.HttpClient.Default + | HttpClient.HttpClient.Service | Server.HttpServer | Platform.HttpPlatform | Etag.Generator diff --git a/packages/platform-bun/src/internal/httpServer.ts b/packages/platform-bun/src/internal/httpServer.ts index 731608133e9..f4169b4bfb8 100644 --- a/packages/platform-bun/src/internal/httpServer.ts +++ b/packages/platform-bun/src/internal/httpServer.ts @@ -2,10 +2,10 @@ import * as MultipartNode from "@effect/platform-node-shared/NodeMultipart" import * as Cookies from "@effect/platform/Cookies" import * as Etag from "@effect/platform/Etag" +import * as FetchHttpClient from "@effect/platform/FetchHttpClient" import type * as FileSystem from "@effect/platform/FileSystem" import * as Headers from "@effect/platform/Headers" import * as App from "@effect/platform/HttpApp" -import * as HttpClient from "@effect/platform/HttpClient" import * as IncomingMessage from "@effect/platform/HttpIncomingMessage" import type { HttpMethod } from "@effect/platform/HttpMethod" import * as Server from "@effect/platform/HttpServer" @@ -180,11 +180,8 @@ export const layer = ( /** @internal */ export const layerTest = Server.layerTestClient.pipe( - Layer.provide(Layer.succeed( - HttpClient.HttpClient, - HttpClient.fetch.pipe( - HttpClient.transformResponse(HttpClient.withFetchOptions({ keepalive: false })) - ) + Layer.provide(FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.RequestInit, { keepalive: false })) )), Layer.provideMerge(layer({ port: 0 })) ) diff --git a/packages/platform-node/examples/api.ts b/packages/platform-node/examples/api.ts index e754b46ccbd..31f9da08fdc 100644 --- a/packages/platform-node/examples/api.ts +++ b/packages/platform-node/examples/api.ts @@ -1,4 +1,5 @@ import { + FetchHttpClient, HttpApi, HttpApiBuilder, HttpApiClient, @@ -7,7 +8,6 @@ import { HttpApiSchema, HttpApiSecurity, HttpApiSwagger, - HttpClient, HttpMiddleware, HttpServer, OpenApi @@ -173,6 +173,6 @@ Effect.gen(function*() { const binary = yield* client.users.binary() console.log("binary", binary) }).pipe( - Effect.provide(HttpClient.layer), + Effect.provide(FetchHttpClient.layer), NodeRuntime.runMain ) diff --git a/packages/platform-node/examples/http-client.ts b/packages/platform-node/examples/http-client.ts index 840c493d450..abcaf808918 100644 --- a/packages/platform-node/examples/http-client.ts +++ b/packages/platform-node/examples/http-client.ts @@ -14,7 +14,7 @@ class Todo extends Schema.Class("Todo")({ title: Schema.String, completed: Schema.Boolean }) { - static decodeResponse = HttpClientResponse.schemaBodyJsonScoped(Todo) + static decodeResponse = HttpClientResponse.schemaBodyJson(Todo) } const TodoWithoutId = Schema.Struct(Todo.fields).pipe(Schema.omit("id")) @@ -34,14 +34,15 @@ const makeTodoService = Effect.gen(function*() { HttpClient.mapRequest(HttpClientRequest.prependUrl("https://jsonplaceholder.typicode.com")) ) - const addTodoWithoutIdBody = HttpClientRequest.schemaBody(TodoWithoutId) + const addTodoWithoutIdBody = HttpClientRequest.schemaBodyJson(TodoWithoutId) const create = (todo: TodoWithoutId) => addTodoWithoutIdBody( HttpClientRequest.post("/todos"), todo ).pipe( - Effect.flatMap(clientWithBaseUrl), - Todo.decodeResponse + Effect.flatMap(clientWithBaseUrl.execute), + Effect.flatMap(Todo.decodeResponse), + Effect.scoped ) return TodoService.of({ create }) diff --git a/packages/platform-node/src/NodeHttpClient.ts b/packages/platform-node/src/NodeHttpClient.ts index bbf5e64ba7f..d1d2657577e 100644 --- a/packages/platform-node/src/NodeHttpClient.ts +++ b/packages/platform-node/src/NodeHttpClient.ts @@ -2,9 +2,8 @@ * @since 1.0.0 */ import type * as Client from "@effect/platform/HttpClient" -import type * as Context from "effect/Context" +import * as Context from "effect/Context" import type * as Effect from "effect/Effect" -import type * as FiberRef from "effect/FiberRef" import type * as Layer from "effect/Layer" import type * as Scope from "effect/Scope" import type * as Http from "node:http" @@ -64,19 +63,19 @@ export const makeAgentLayer: (options?: Https.AgentOptions) => Layer.Layer = internal.make +export const make: Effect.Effect = internal.make /** * @since 1.0.0 * @category layers */ -export const layer: Layer.Layer = internal.layer +export const layer: Layer.Layer = internal.layer /** * @since 1.0.0 * @category layers */ -export const layerWithoutAgent: Layer.Layer = internal.layerWithoutAgent +export const layerWithoutAgent: Layer.Layer = internal.layerWithoutAgent /** * @since 1.0.0 @@ -114,35 +113,26 @@ export const dispatcherLayerGlobal: Layer.Layer = internalUndici.dis * @since 1.0.0 * @category undici */ -export const currentUndiciOptions: FiberRef.FiberRef> = - internalUndici.currentUndiciOptions - -/** - * @since 1.0.0 - * @category undici - */ -export const withUndiciOptions: { - ( - options: Partial - ): (effect: Effect.Effect) => Effect.Effect - (effect: Effect.Effect, options: Partial): Effect.Effect -} = internalUndici.withUndiciOptions +export class UndiciRequestOptions extends Context.Tag(internalUndici.undiciOptionsTagKey)< + UndiciRequestOptions, + Undici.Dispatcher.RequestOptions +>() {} /** * @since 1.0.0 * @category constructors */ -export const makeUndici: (dispatcher: Undici.Dispatcher) => Client.HttpClient.Default = internalUndici.make +export const makeUndici: (dispatcher: Undici.Dispatcher) => Client.HttpClient.Service = internalUndici.make /** * @since 1.0.0 * @category layers */ -export const layerUndici: Layer.Layer = internalUndici.layer +export const layerUndici: Layer.Layer = internalUndici.layer /** * @since 1.0.0 * @category layers */ -export const layerUndiciWithoutDispatcher: Layer.Layer = +export const layerUndiciWithoutDispatcher: Layer.Layer = internalUndici.layerWithoutDispatcher diff --git a/packages/platform-node/src/NodeHttpServer.ts b/packages/platform-node/src/NodeHttpServer.ts index c57f2e452e4..244ecc0ee59 100644 --- a/packages/platform-node/src/NodeHttpServer.ts +++ b/packages/platform-node/src/NodeHttpServer.ts @@ -105,7 +105,7 @@ export const layerConfig: ( * @category layers */ export const layerTest: Layer.Layer< - | HttpClient.HttpClient.Default + | HttpClient.HttpClient.Service | Server.HttpServer | Platform.HttpPlatform | Etag.Generator diff --git a/packages/platform-node/src/internal/httpClient.ts b/packages/platform-node/src/internal/httpClient.ts index 1d1187c8c6a..3b7104fc342 100644 --- a/packages/platform-node/src/internal/httpClient.ts +++ b/packages/platform-node/src/internal/httpClient.ts @@ -54,8 +54,8 @@ export const makeAgentLayer = (options?: Https.AgentOptions): Layer.Layer - Client.makeDefault((request, url, signal) => { +const fromAgent = (agent: NodeClient.HttpAgent): Client.HttpClient.Service => + Client.makeService((request, url, signal) => { const nodeRequest = url.protocol === "https:" ? Https.request(url, { agent: agent.https, @@ -254,7 +254,7 @@ class ClientResponseImpl extends HttpIncomingMessageImpl export const make = Effect.map(HttpAgent, fromAgent) /** @internal */ -export const layerWithoutAgent = Layer.effect(Client.HttpClient, make) +export const layerWithoutAgent = Client.layerMergedContext(make) /** @internal */ export const layer = Layer.provide(layerWithoutAgent, agentLayer) diff --git a/packages/platform-node/src/internal/httpClientUndici.ts b/packages/platform-node/src/internal/httpClientUndici.ts index d83aa640cf2..7574f3f7d3e 100644 --- a/packages/platform-node/src/internal/httpClientUndici.ts +++ b/packages/platform-node/src/internal/httpClientUndici.ts @@ -10,8 +10,6 @@ import * as UrlParams from "@effect/platform/UrlParams" import * as Context from "effect/Context" import * as Effect from "effect/Effect" import * as FiberRef from "effect/FiberRef" -import { dual } from "effect/Function" -import { globalValue } from "effect/GlobalValue" import * as Inspectable from "effect/Inspectable" import * as Layer from "effect/Layer" import * as Option from "effect/Option" @@ -39,31 +37,19 @@ export const dispatcherLayer = Layer.scoped(Dispatcher, makeDispatcher) export const dispatcherLayerGlobal = Layer.sync(Dispatcher, () => Undici.getGlobalDispatcher()) /** @internal */ -export const currentUndiciOptions = globalValue( - Symbol.for("@effect/platform-node/NodeHttpClient/currentUndici"), - () => FiberRef.unsafeMake>({}) -) - -/** @internal */ -export const withUndiciOptions = dual< - ( - options: Partial - ) => (effect: Effect.Effect) => Effect.Effect, - ( - effect: Effect.Effect, - options: Partial - ) => Effect.Effect ->(2, (self, options) => Effect.locally(self, currentUndiciOptions, options)) +export const undiciOptionsTagKey = "@effect/platform-node/NodeHttpClient/undiciOptions" /** @internal */ -export const make = (dispatcher: Undici.Dispatcher): Client.HttpClient.Default => - Client.makeDefault((request, url, signal, fiber) => - convertBody(request.body).pipe( +export const make = (dispatcher: Undici.Dispatcher): Client.HttpClient.Service => + Client.makeService((request, url, signal, fiber) => { + const context = fiber.getFiberRef(FiberRef.currentContext) + const options: Undici.Dispatcher.RequestOptions = context.unsafeMap.get(undiciOptionsTagKey) ?? {} + return convertBody(request.body).pipe( Effect.flatMap((body) => Effect.tryPromise({ try: () => dispatcher.request({ - ...(fiber.getFiberRef(currentUndiciOptions)), + ...options, signal, method: request.method, headers: request.headers, @@ -85,7 +71,7 @@ export const make = (dispatcher: Undici.Dispatcher): Client.HttpClient.Default = ), Effect.map((response) => new ClientResponseImpl(request, response)) ) - ) + }) function convertBody( body: Body.HttpBody @@ -240,7 +226,7 @@ class ClientResponseImpl extends Inspectable.Class implements ClientResponse.Htt } /** @internal */ -export const layerWithoutDispatcher = Layer.effect(Client.HttpClient, Effect.map(Dispatcher, make)) +export const layerWithoutDispatcher = Client.layerMergedContext(Effect.map(Dispatcher, make)) /** @internal */ export const layer = Layer.provide(layerWithoutDispatcher, dispatcherLayer) diff --git a/packages/platform-node/test/HttpApi.test.ts b/packages/platform-node/test/HttpApi.test.ts index 44551957599..847470279fe 100644 --- a/packages/platform-node/test/HttpApi.test.ts +++ b/packages/platform-node/test/HttpApi.test.ts @@ -103,7 +103,7 @@ describe("HttpApi", () => { it.scoped("class level annotations", () => Effect.gen(function*() { const response = yield* HttpClientRequest.post("/users").pipe( - HttpClientRequest.unsafeJsonBody({ name: "boom" }) + HttpClientRequest.bodyUnsafeJson({ name: "boom" }) ) assert.strictEqual(response.status, 400) }).pipe(Effect.provide(HttpLive))) diff --git a/packages/platform-node/test/HttpClient.test.ts b/packages/platform-node/test/HttpClient.test.ts index 628fdb347a5..574d4a279d0 100644 --- a/packages/platform-node/test/HttpClient.test.ts +++ b/packages/platform-node/test/HttpClient.test.ts @@ -20,7 +20,8 @@ const makeJsonPlaceholder = Effect.gen(function*(_) { HttpClient.mapRequest(HttpClientRequest.prependUrl("https://jsonplaceholder.typicode.com")) ) const todoClient = client.pipe( - HttpClient.mapEffectScoped(HttpClientResponse.schemaBodyJson(Todo)) + HttpClient.mapEffect(HttpClientResponse.schemaBodyJson(Todo)), + HttpClient.scoped ) const createTodo = HttpClient.schemaFunction( todoClient, @@ -48,10 +49,8 @@ const JsonPlaceholderLive = Layer.effect(JsonPlaceholder, makeJsonPlaceholder) describe(`NodeHttpClient - ${name}`, () => { it.effect("google", () => Effect.gen(function*(_) { - const client = yield* _(HttpClient.HttpClient) const response = yield* _( HttpClientRequest.get("https://www.google.com/"), - client, Effect.flatMap((_) => _.text), Effect.scoped ) @@ -59,13 +58,11 @@ const JsonPlaceholderLive = Layer.effect(JsonPlaceholder, makeJsonPlaceholder) }).pipe(Effect.provide(layer))) it.effect("google followRedirects", () => - Effect.gen(function*(_) { + Effect.gen(function*() { const client = (yield* HttpClient.HttpClient).pipe( HttpClient.followRedirects() ) - const response = yield* _( - HttpClientRequest.get("http://google.com/"), - client, + const response = yield* client.get("http://google.com/").pipe( Effect.flatMap((_) => _.text), Effect.scoped ) @@ -73,11 +70,9 @@ const JsonPlaceholderLive = Layer.effect(JsonPlaceholder, makeJsonPlaceholder) }).pipe(Effect.provide(layer))) it.effect("google stream", () => - Effect.gen(function*(_) { - const client = yield* _(HttpClient.HttpClient) - const response = yield* _( - HttpClientRequest.get("https://www.google.com/"), - client, + Effect.gen(function*() { + const client = yield* HttpClient.HttpClient + const response = yield* client.get("https://www.google.com/").pipe( Effect.map((_) => _.stream), Stream.unwrapScoped, Stream.runFold("", (a, b) => a + new TextDecoder().decode(b)) @@ -86,45 +81,45 @@ const JsonPlaceholderLive = Layer.effect(JsonPlaceholder, makeJsonPlaceholder) }).pipe(Effect.provide(layer))) it.effect("jsonplaceholder", () => - Effect.gen(function*(_) { - const jp = yield* _(JsonPlaceholder) - const response = yield* _(HttpClientRequest.get("/todos/1"), jp.todoClient) + Effect.gen(function*() { + const jp = yield* JsonPlaceholder + const response = yield* jp.todoClient.get("/todos/1") expect(response.id).toBe(1) }).pipe(Effect.provide(JsonPlaceholderLive.pipe( Layer.provide(layer) )))) it.effect("jsonplaceholder schemaFunction", () => - Effect.gen(function*(_) { - const jp = yield* _(JsonPlaceholder) - const response = yield* _(jp.createTodo({ + Effect.gen(function*() { + const jp = yield* JsonPlaceholder + const response = yield* jp.createTodo({ userId: 1, title: "test", completed: false - })) + }) expect(response.title).toBe("test") }).pipe(Effect.provide(JsonPlaceholderLive.pipe( Layer.provide(layer) )))) it.effect("head request with schemaJson", () => - Effect.gen(function*(_) { - const client = yield* _(HttpClient.HttpClient) - const response = yield* _( - HttpClientRequest.head("https://jsonplaceholder.typicode.com/todos"), - client, - HttpClientResponse.schemaJsonScoped(Schema.Struct({ status: Schema.Literal(200) })) + Effect.gen(function*() { + const client = yield* HttpClient.HttpClient + const response = yield* client.head("https://jsonplaceholder.typicode.com/todos").pipe( + Effect.flatMap( + HttpClientResponse.schemaJson(Schema.Struct({ status: Schema.Literal(200) })) + ), + Effect.scoped ) expect(response).toEqual({ status: 200 }) }).pipe(Effect.provide(layer))) it.live("interrupt", () => - Effect.gen(function*(_) { - const client = yield* _(HttpClient.HttpClient) - const response = yield* _( - HttpClientRequest.get("https://www.google.com/"), - client, - HttpClientResponse.text, + Effect.gen(function*() { + const client = yield* HttpClient.HttpClient + const response = yield* client.get("https://www.google.com/").pipe( + Effect.flatMap((_) => _.text), + Effect.scoped, Effect.timeout(1), Effect.asSome, Effect.catchTag("TimeoutException", () => Effect.succeedNone) diff --git a/packages/platform-node/test/HttpServer.test.ts b/packages/platform-node/test/HttpServer.test.ts index 4ff8f94e143..0afd0556c37 100644 --- a/packages/platform-node/test/HttpServer.test.ts +++ b/packages/platform-node/test/HttpServer.test.ts @@ -18,7 +18,7 @@ import { import { NodeHttpServer } from "@effect/platform-node" import * as Schema from "@effect/schema/Schema" import { assert, describe, expect, it } from "@effect/vitest" -import { Deferred, Duration, Fiber, Stream } from "effect" +import { Deferred, Duration, Fiber, flow, Stream } from "effect" import * as Effect from "effect/Effect" import * as Option from "effect/Option" import * as Tracer from "effect/Tracer" @@ -35,16 +35,16 @@ const todoResponse = HttpServerResponse.schemaJson(Todo) const makeTodoClient = Effect.map( HttpClient.HttpClient, - HttpClient.mapEffectScoped( - HttpClientResponse.schemaBodyJson(Todo) + flow( + HttpClient.mapEffect(HttpClientResponse.schemaBodyJson(Todo)), + HttpClient.scoped ) ) describe("HttpServer", () => { it.scoped("schema", () => - Effect.gen(function*(_) { - yield* _( - HttpRouter.empty, + Effect.gen(function*() { + yield* HttpRouter.empty.pipe( HttpRouter.get( "/todos/:id", Effect.flatMap( @@ -54,26 +54,25 @@ describe("HttpServer", () => { ), HttpServer.serveEffect() ) - const client = yield* _(makeTodoClient) - const todo = yield* _(client(HttpClientRequest.get("/todos/1"))) + const client = yield* makeTodoClient + const todo = yield* client.get("/todos/1") expect(todo).toEqual({ id: 1, title: "test" }) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scoped("formData", () => - Effect.gen(function*(_) { - yield* _( - HttpRouter.empty, + Effect.gen(function*() { + yield* HttpRouter.empty.pipe( HttpRouter.post( "/upload", - Effect.gen(function*(_) { - const request = yield* _(HttpServerRequest.HttpServerRequest) - const formData = yield* _(request.multipart) + Effect.gen(function*() { + const request = yield* HttpServerRequest.HttpServerRequest + const formData = yield* request.multipart const part = formData.file assert(typeof part !== "string") const file = part[0] expect(file.path.endsWith("/test.txt")).toEqual(true) expect(file.contentType).toEqual("text/plain") - return yield* _(HttpServerResponse.json({ ok: "file" in formData })) + return yield* HttpServerResponse.json({ ok: "file" in formData }) }) ), HttpServer.serveEffect() @@ -81,24 +80,23 @@ describe("HttpServer", () => { const client = yield* HttpClient.HttpClient const formData = new FormData() formData.append("file", new Blob(["test"], { type: "text/plain" }), "test.txt") - const result = yield* _( - client(HttpClientRequest.post("/upload", { body: HttpBody.formData(formData) })), - HttpClientResponse.json + const result = yield* client.post("/upload", { body: HttpBody.formData(formData) }).pipe( + Effect.flatMap((r) => r.json), + Effect.scoped ) expect(result).toEqual({ ok: true }) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scoped("schemaBodyForm", () => - Effect.gen(function*(_) { - yield* _( - HttpRouter.empty, + Effect.gen(function*() { + yield* HttpRouter.empty.pipe( HttpRouter.post( "/upload", - Effect.gen(function*(_) { - const files = yield* _(HttpServerRequest.schemaBodyForm(Schema.Struct({ + Effect.gen(function*() { + const files = yield* HttpServerRequest.schemaBodyForm(Schema.Struct({ file: Multipart.FilesSchema, test: Schema.String - }))) + })) expect(files).toHaveProperty("file") expect(files).toHaveProperty("test") return HttpServerResponse.empty() @@ -111,22 +109,20 @@ describe("HttpServer", () => { const formData = new FormData() formData.append("file", new Blob(["test"], { type: "text/plain" }), "test.txt") formData.append("test", "test") - const response = yield* _( - client(HttpClientRequest.post("/upload", { body: HttpBody.formData(formData) })), + const response = yield* client.post("/upload", { body: HttpBody.formData(formData) }).pipe( Effect.scoped ) expect(response.status).toEqual(204) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scoped("formData withMaxFileSize", () => - Effect.gen(function*(_) { - yield* _( - HttpRouter.empty, + Effect.gen(function*() { + yield* HttpRouter.empty.pipe( HttpRouter.post( "/upload", - Effect.gen(function*(_) { - const request = yield* _(HttpServerRequest.HttpServerRequest) - yield* _(request.multipart) + Effect.gen(function*() { + const request = yield* HttpServerRequest.HttpServerRequest + yield* request.multipart return HttpServerResponse.empty() }) ), @@ -141,22 +137,20 @@ describe("HttpServer", () => { const formData = new FormData() const data = new Uint8Array(1000) formData.append("file", new Blob([data], { type: "text/plain" }), "test.txt") - const response = yield* _( - client(HttpClientRequest.post("/upload", { body: HttpBody.formData(formData) })), + const response = yield* client.post("/upload", { body: HttpBody.formData(formData) }).pipe( Effect.scoped ) expect(response.status).toEqual(413) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scoped("formData withMaxFieldSize", () => - Effect.gen(function*(_) { - yield* _( - HttpRouter.empty, + Effect.gen(function*() { + yield* HttpRouter.empty.pipe( HttpRouter.post( "/upload", - Effect.gen(function*(_) { - const request = yield* _(HttpServerRequest.HttpServerRequest) - yield* _(request.multipart) + Effect.gen(function*() { + const request = yield* HttpServerRequest.HttpServerRequest + yield* request.multipart return HttpServerResponse.empty() }) ), @@ -171,51 +165,48 @@ describe("HttpServer", () => { const formData = new FormData() const data = new Uint8Array(1000).fill(1) formData.append("file", new TextDecoder().decode(data)) - const response = yield* _( - client(HttpClientRequest.post("/upload", { body: HttpBody.formData(formData) })), + const response = yield* client.post("/upload", { body: HttpBody.formData(formData) }).pipe( Effect.scoped ) expect(response.status).toEqual(413) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scoped("mount", () => - Effect.gen(function*(_) { + Effect.gen(function*() { const child = HttpRouter.empty.pipe( HttpRouter.get("/", Effect.map(HttpServerRequest.HttpServerRequest, (_) => HttpServerResponse.text(_.url))), HttpRouter.get("/:id", Effect.map(HttpServerRequest.HttpServerRequest, (_) => HttpServerResponse.text(_.url))) ) - yield* _( - HttpRouter.empty, + yield* HttpRouter.empty.pipe( HttpRouter.mount("/child", child), HttpServer.serveEffect() ) const client = yield* HttpClient.HttpClient - const todo = yield* _(client(HttpClientRequest.get("/child/1")), Effect.flatMap((_) => _.text), Effect.scoped) + const todo = yield* client.get("/child/1").pipe(Effect.flatMap((_) => _.text), Effect.scoped) expect(todo).toEqual("/1") - const root = yield* _(client(HttpClientRequest.get("/child")), Effect.flatMap((_) => _.text), Effect.scoped) + const root = yield* client.get("/child").pipe(Effect.flatMap((_) => _.text), Effect.scoped) expect(root).toEqual("/") }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scoped("mountApp", () => - Effect.gen(function*(_) { + Effect.gen(function*() { const child = HttpRouter.empty.pipe( HttpRouter.get("/", Effect.map(HttpServerRequest.HttpServerRequest, (_) => HttpServerResponse.text(_.url))), HttpRouter.get("/:id", Effect.map(HttpServerRequest.HttpServerRequest, (_) => HttpServerResponse.text(_.url))) ) - yield* _( - HttpRouter.empty, + yield* HttpRouter.empty.pipe( HttpRouter.mountApp("/child", child), HttpServer.serveEffect() ) const client = yield* HttpClient.HttpClient - const todo = yield* _(client(HttpClientRequest.get("/child/1")), HttpClientResponse.text) + const todo = yield* client.get("/child/1").pipe(Effect.flatMap((_) => _.text), Effect.scoped) expect(todo).toEqual("/1") - const root = yield* _(client(HttpClientRequest.get("/child")), HttpClientResponse.text) + const root = yield* client.get("/child").pipe(Effect.flatMap((_) => _.text), Effect.scoped) expect(root).toEqual("/") }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scoped("mountApp/includePrefix", () => - Effect.gen(function*(_) { + Effect.gen(function*() { const child = HttpRouter.empty.pipe( HttpRouter.get( "/child/", @@ -226,60 +217,56 @@ describe("HttpServer", () => { Effect.map(HttpServerRequest.HttpServerRequest, (_) => HttpServerResponse.text(_.url)) ) ) - yield* _( - HttpRouter.empty, + yield* HttpRouter.empty.pipe( HttpRouter.mountApp("/child", child, { includePrefix: true }), HttpServer.serveEffect() ) const client = yield* HttpClient.HttpClient - const todo = yield* _(client(HttpClientRequest.get("/child/1")), HttpClientResponse.text) + const todo = yield* client.get("/child/1").pipe(Effect.flatMap((_) => _.text), Effect.scoped) expect(todo).toEqual("/child/1") - const root = yield* _(client(HttpClientRequest.get("/child")), HttpClientResponse.text) + const root = yield* client.get("/child").pipe(Effect.flatMap((_) => _.text), Effect.scoped) expect(root).toEqual("/child") }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scoped("file", () => - Effect.gen(function*(_) { - yield* _( - yield* _( - HttpServerResponse.file(`${__dirname}/fixtures/text.txt`), - Effect.updateService( - HttpPlatform.HttpPlatform, - (_) => ({ - ..._, - fileResponse: (path, options) => - Effect.map( - _.fileResponse(path, options), - (res) => { - ;(res as any).headers.etag = "\"etag\"" - return res - } - ) - }) - ) - ), + Effect.gen(function*() { + yield* (yield* HttpServerResponse.file(`${__dirname}/fixtures/text.txt`).pipe( + Effect.updateService( + HttpPlatform.HttpPlatform, + (_) => ({ + ..._, + fileResponse: (path, options) => + Effect.map( + _.fileResponse(path, options), + (res) => { + ;(res as any).headers.etag = "\"etag\"" + return res + } + ) + }) + ) + )).pipe( Effect.tapErrorCause(Effect.logError), HttpServer.serveEffect() ) const client = yield* HttpClient.HttpClient - const res = yield* _(client(HttpClientRequest.get("/")), Effect.scoped) + const res = yield* client.get("/").pipe(Effect.scoped) expect(res.status).toEqual(200) expect(res.headers["content-type"]).toEqual("text/plain") expect(res.headers["content-length"]).toEqual("27") expect(res.headers.etag).toEqual("\"etag\"") - const text = yield* _(res.text) + const text = yield* res.text expect(text.trim()).toEqual("lorem ipsum dolar sit amet") }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scoped("fileWeb", () => - Effect.gen(function*(_) { + Effect.gen(function*() { const now = new Date() const file = new Buffer.File([new TextEncoder().encode("test")], "test.txt", { type: "text/plain", lastModified: now.getTime() }) - yield* _( - HttpServerResponse.fileWeb(file), + yield* HttpServerResponse.fileWeb(file).pipe( Effect.updateService( HttpPlatform.HttpPlatform, (_) => ({ @@ -294,20 +281,19 @@ describe("HttpServer", () => { HttpServer.serveEffect() ) const client = yield* HttpClient.HttpClient - const res = yield* _(client(HttpClientRequest.get("/")), Effect.scoped) + const res = yield* client.get("/").pipe(Effect.scoped) expect(res.status).toEqual(200) expect(res.headers["content-type"]).toEqual("text/plain") expect(res.headers["content-length"]).toEqual("4") expect(res.headers["last-modified"]).toEqual(now.toUTCString()) expect(res.headers.etag).toEqual("W/\"etag\"") - const text = yield* _(res.text) + const text = yield* res.text expect(text.trim()).toEqual("test") }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scoped("schemaBodyUrlParams", () => - Effect.gen(function*(_) { - yield* _( - HttpRouter.empty, + Effect.gen(function*() { + yield* HttpRouter.empty.pipe( HttpRouter.post( "/todos", Effect.flatMap( @@ -320,20 +306,18 @@ describe("HttpServer", () => { ), HttpServer.serveEffect() ) - const client = yield* _(makeTodoClient) - const todo = yield* _( - HttpClientRequest.post("/todos"), - HttpClientRequest.urlParamsBody({ id: "1", title: "test" }), - client, + const client = yield* makeTodoClient + const todo = yield* HttpClientRequest.post("/todos").pipe( + HttpClientRequest.bodyUrlParams({ id: "1", title: "test" }), + client.execute, Effect.scoped ) expect(todo).toEqual({ id: 1, title: "test" }) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scoped("schemaBodyUrlParams error", () => - Effect.gen(function*(_) { - yield* _( - HttpRouter.empty, + Effect.gen(function*() { + yield* HttpRouter.empty.pipe( HttpRouter.get( "/todos", Effect.flatMap( @@ -348,26 +332,19 @@ describe("HttpServer", () => { HttpServer.serveEffect() ) const client = yield* HttpClient.HttpClient - const response = yield* _( - HttpClientRequest.get("/todos"), - client, - Effect.scoped - ) + const response = yield* client.get("/todos").pipe(Effect.scoped) expect(response.status).toEqual(400) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scoped("schemaBodyFormJson", () => - Effect.gen(function*(_) { - yield* _( - HttpRouter.empty, + Effect.gen(function*() { + yield* HttpRouter.empty.pipe( HttpRouter.post( "/upload", - Effect.gen(function*(_) { - const result = yield* _( - HttpServerRequest.schemaBodyFormJson(Schema.Struct({ - test: Schema.String - }))("json") - ) + Effect.gen(function*() { + const result = yield* HttpServerRequest.schemaBodyFormJson(Schema.Struct({ + test: Schema.String + }))("json") expect(result.test).toEqual("content") return HttpServerResponse.empty() }) @@ -378,25 +355,20 @@ describe("HttpServer", () => { const client = yield* HttpClient.HttpClient const formData = new FormData() formData.append("json", JSON.stringify({ test: "content" })) - const response = yield* _( - client(HttpClientRequest.post("/upload", { body: HttpBody.formData(formData) })), - Effect.scoped - ) + const response = yield* client.post("/upload", { body: HttpBody.formData(formData) }).pipe(Effect.scoped) expect(response.status).toEqual(204) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scoped("schemaBodyFormJson file", () => - Effect.gen(function*(_) { - yield* _( - HttpRouter.empty, + Effect.gen(function*() { + yield* HttpRouter.empty.pipe( HttpRouter.post( "/upload", - Effect.gen(function*(_) { - const result = yield* _( - HttpServerRequest.schemaBodyFormJson(Schema.Struct({ - test: Schema.String - }))("json") - ) + Effect.gen(function*() { + const result = yield* HttpServerRequest.schemaBodyFormJson(Schema.Struct({ + test: Schema.String + }))("json") + expect(result.test).toEqual("content") return HttpServerResponse.empty() }) @@ -411,25 +383,19 @@ describe("HttpServer", () => { new Blob([JSON.stringify({ test: "content" })], { type: "application/json" }), "test.json" ) - const response = yield* _( - client(HttpClientRequest.post("/upload", { body: HttpBody.formData(formData) })), - Effect.scoped - ) + const response = yield* client.post("/upload", { body: HttpBody.formData(formData) }).pipe(Effect.scoped) expect(response.status).toEqual(204) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scoped("schemaBodyFormJson url encoded", () => - Effect.gen(function*(_) { - yield* _( - HttpRouter.empty, + Effect.gen(function*() { + yield* HttpRouter.empty.pipe( HttpRouter.post( "/upload", - Effect.gen(function*(_) { - const result = yield* _( - HttpServerRequest.schemaBodyFormJson(Schema.Struct({ - test: Schema.String - }))("json") - ) + Effect.gen(function*() { + const result = yield* HttpServerRequest.schemaBodyFormJson(Schema.Struct({ + test: Schema.String + }))("json") expect(result.test).toEqual("content") return HttpServerResponse.empty() }) @@ -438,23 +404,17 @@ describe("HttpServer", () => { HttpServer.serveEffect() ) const client = yield* HttpClient.HttpClient - const response = yield* _( - client( - HttpClientRequest.post("/upload", { - body: HttpBody.urlParams(UrlParams.fromInput({ - json: JSON.stringify({ test: "content" }) - })) - }) - ), - Effect.scoped - ) + const response = yield* client.post("/upload", { + body: HttpBody.urlParams(UrlParams.fromInput({ + json: JSON.stringify({ test: "content" }) + })) + }).pipe(Effect.scoped) expect(response.status).toEqual(204) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scoped("tracing", () => - Effect.gen(function*(_) { - yield* _( - HttpRouter.empty, + Effect.gen(function*() { + yield* HttpRouter.empty.pipe( HttpRouter.get( "/", Effect.flatMap( @@ -465,10 +425,10 @@ describe("HttpServer", () => { HttpServer.serveEffect() ) const client = yield* HttpClient.HttpClient - const requestSpan = yield* _(Effect.makeSpan("client request")) - const body = yield* _( - client(HttpClientRequest.get("/")), - HttpClientResponse.json, + const requestSpan = yield* Effect.makeSpan("client request") + const body = yield* client.get("/").pipe( + Effect.flatMap((r) => r.json), + Effect.scoped, Effect.withTracer(Tracer.make({ span(name, parent, _, __, ___, kind) { assert.strictEqual(name, "http.client GET") @@ -488,27 +448,25 @@ describe("HttpServer", () => { }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scopedLive("client abort", () => - Effect.gen(function*(_) { - const latch = yield* _(Deferred.make()) - yield* _( - HttpServerResponse.empty(), + Effect.gen(function*() { + const latch = yield* Deferred.make() + yield* HttpServerResponse.empty().pipe( Effect.delay(1000), Effect.interruptible, HttpServer.serveEffect((app) => Effect.onExit(app, (exit) => Deferred.complete(latch, exit))) ) const client = yield* HttpClient.HttpClient - const fiber = yield* _(client(HttpClientRequest.get("/")), Effect.scoped, Effect.fork) - yield* _(Effect.sleep(100)) - yield* _(Fiber.interrupt(fiber)) - const cause = yield* _(Deferred.await(latch), Effect.sandbox, Effect.flip) + const fiber = yield* client.get("/").pipe(Effect.scoped, Effect.fork) + yield* Effect.sleep(100) + yield* Fiber.interrupt(fiber) + const cause = yield* Deferred.await(latch).pipe(Effect.sandbox, Effect.flip) const [response] = HttpServerError.causeResponseStripped(cause) expect(response.status).toEqual(499) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scoped("multiplex", () => - Effect.gen(function*(_) { - yield* _( - HttpMultiplex.empty, + Effect.gen(function*() { + yield* HttpMultiplex.empty.pipe( HttpMultiplex.hostExact("a.example.com", HttpServerResponse.text("A")), HttpMultiplex.hostStartsWith("b.", HttpServerResponse.text("B")), HttpMultiplex.hostRegex(/^c\.example/, HttpServerResponse.text("C")), @@ -516,51 +474,50 @@ describe("HttpServer", () => { ) const client = yield* HttpClient.HttpClient expect( - yield* _( - client( - HttpClientRequest.get("/").pipe( - HttpClientRequest.setHeader("host", "a.example.com") - ) - ), - HttpClientResponse.text + yield* client.execute( + HttpClientRequest.get("/").pipe( + HttpClientRequest.setHeader("host", "a.example.com") + ) + ).pipe( + Effect.flatMap((r) => r.text), + Effect.scoped ) ).toEqual("A") expect( - yield* _( - client( - HttpClientRequest.get("/").pipe( - HttpClientRequest.setHeader("host", "b.example.com") - ) - ), - HttpClientResponse.text + yield* client.execute( + HttpClientRequest.get("/").pipe( + HttpClientRequest.setHeader("host", "b.example.com") + ) + ).pipe( + Effect.flatMap((r) => r.text), + Effect.scoped ) ).toEqual("B") expect( - yield* _( - client( - HttpClientRequest.get("/").pipe( - HttpClientRequest.setHeader("host", "b.org") - ) - ), - HttpClientResponse.text + yield* client.execute( + HttpClientRequest.get("/").pipe( + HttpClientRequest.setHeader("host", "b.org") + ) + ).pipe( + Effect.flatMap((r) => r.text), + Effect.scoped ) ).toEqual("B") expect( - yield* _( - client( - HttpClientRequest.get("/").pipe( - HttpClientRequest.setHeader("host", "c.example.com") - ) - ), - HttpClientResponse.text + yield* client.execute( + HttpClientRequest.get("/").pipe( + HttpClientRequest.setHeader("host", "c.example.com") + ) + ).pipe( + Effect.flatMap((r) => r.text), + Effect.scoped ) ).toEqual("C") }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scoped("html", () => - Effect.gen(function*(_) { - yield* _( - HttpRouter.empty, + Effect.gen(function*() { + yield* HttpRouter.empty.pipe( HttpRouter.get("/home", HttpServerResponse.html("")), HttpRouter.get( "/about", @@ -573,18 +530,17 @@ describe("HttpServer", () => { HttpServer.serveEffect() ) const client = yield* HttpClient.HttpClient - const home = yield* _(HttpClientRequest.get("/home"), client, HttpClientResponse.text) + const home = yield* client.get("/home").pipe(Effect.flatMap((r) => r.text), Effect.scoped) expect(home).toEqual("") - const about = yield* _(HttpClientRequest.get("/about"), client, HttpClientResponse.text) + const about = yield* client.get("/about").pipe(Effect.flatMap((r) => r.text), Effect.scoped) expect(about).toEqual("") - const stream = yield* _(HttpClientRequest.get("/stream"), client, HttpClientResponse.text) + const stream = yield* client.get("/stream").pipe(Effect.flatMap((r) => r.text), Effect.scoped) expect(stream).toEqual("123hello") }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scoped("setCookie", () => - Effect.gen(function*(_) { - yield* _( - HttpRouter.empty, + Effect.gen(function*() { + yield* HttpRouter.empty.pipe( HttpRouter.get( "/home", HttpServerResponse.empty().pipe( @@ -604,7 +560,7 @@ describe("HttpServer", () => { HttpServer.serveEffect() ) const client = yield* HttpClient.HttpClient - const res = yield* _(HttpClientRequest.get("/home"), client, Effect.scoped) + const res = yield* client.get("/home").pipe(Effect.scoped) assert.deepStrictEqual( res.cookies.toJSON(), Cookies.fromReadonlyRecord({ @@ -624,22 +580,21 @@ describe("HttpServer", () => { }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.scopedLive("uninterruptible routes", () => - Effect.gen(function*(_) { - yield* _( - HttpRouter.empty, + Effect.gen(function*() { + yield* HttpRouter.empty.pipe( HttpRouter.get( "/home", - Effect.gen(function*(_) { + Effect.gen(function*() { const fiber = Option.getOrThrow(Fiber.getCurrentFiber()) setTimeout(() => fiber.unsafeInterruptAsFork(fiber.id()), 10) - return yield* _(HttpServerResponse.empty(), Effect.delay(50)) + return yield* HttpServerResponse.empty().pipe(Effect.delay(50)) }), { uninterruptible: true } ), HttpServer.serveEffect() ) const client = yield* HttpClient.HttpClient - const res = yield* _(HttpClientRequest.get("/home"), client, Effect.scoped) + const res = yield* client.get("/home").pipe(Effect.scoped) assert.strictEqual(res.status, 204) }).pipe(Effect.provide(NodeHttpServer.layerTest))) @@ -648,7 +603,7 @@ describe("HttpServer", () => { Effect.gen(function*() { yield* HttpRouter.empty.pipe(HttpServer.serveEffect()) const client = yield* HttpClient.HttpClient - const res = yield* HttpClientRequest.get("/home").pipe(client, Effect.scoped) + const res = yield* client.get("/").pipe(Effect.scoped) assert.strictEqual(res.status, 404) }).pipe(Effect.provide(NodeHttpServer.layerTest))) @@ -666,7 +621,7 @@ describe("HttpServer", () => { HttpServer.serveEffect() ) const client = yield* HttpClient.HttpClient - const res = yield* HttpClientRequest.get("/home").pipe(client) + const res = yield* client.get("/home") assert.strictEqual(res.status, 599) const err = yield* HttpClientResponse.schemaBodyJson(CustomError)(res) assert.deepStrictEqual(err, new CustomError({ name: "test" })) @@ -686,7 +641,10 @@ describe("HttpServer", () => { HttpServer.serveEffect() ) const client = yield* HttpClient.HttpClient - const res = yield* HttpClientRequest.get("/user").pipe(client, HttpClientResponse.schemaBodyJsonScoped(User)) + const res = yield* client.get("/user").pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(User)), + Effect.scoped + ) assert.deepStrictEqual(res, new User({ name: "test" })) }).pipe(Effect.provide(NodeHttpServer.layerTest))) }) @@ -698,7 +656,7 @@ describe("HttpServer", () => { HttpServer.serveEffect(() => Effect.fail("boom")) ) const client = yield* HttpClient.HttpClient - const res = yield* HttpClientRequest.get("/").pipe(client) + const res = yield* client.get("/") assert.deepStrictEqual(res.status, 500) }).pipe(Effect.provide(NodeHttpServer.layerTest))) diff --git a/packages/platform/README.md b/packages/platform/README.md index 839734b5597..60876e03f28 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -564,42 +564,46 @@ Effect.gen(function* () { ## Overview -An `HttpClient` is a function that takes a request and produces a certain value `A` in an effectful way (possibly resulting in an error `E` and depending on some requirement `R`). +The `@effect/platform/HttpClient*` modules provide a way to send HTTP requests, +handle responses, and abstract over the differences between platforms. -```ts -type HttpClient = (request: HttpClientRequest): Effect -``` +The `HttpClient` interface has a set of methods for sending requests: -Generally, you'll deal with a specialization called `Default` where `A`, `E`, and `R` are predefined: +- `.execute` - takes a `HttpClientRequest` and returns a `HttpClientResponse` +- `.{get, post, ...}` - convenience methods for creating a request and + executing it in one step -```ts -type Default = (request: HttpClientRequest): Effect -``` - -The goal of `Default` is straightforward: transform a `HttpClientRequest` into a `HttpClientResponse`. +To access the `HttpClient`, you can use the `HttpClient.HttpClient` `Context.Tag`. +This will give you access to a `HttpClient.Service` instance, which is the default +type of the `HttpClient` interface. ### A First Example: Retrieving JSON Data (GET) Here's a simple example demonstrating how to retrieve JSON data using `HttpClient` from `@effect/platform`. ```ts -import { - HttpClient, - HttpClientRequest, - HttpClientResponse -} from "@effect/platform" +import { FetchHttpClient, HttpClient } from "@effect/platform" import { Effect } from "effect" -const req = HttpClientRequest.get( - "https://jsonplaceholder.typicode.com/posts/1" -) +const program = Effect.gen(function* () { + // access the HttpClient + const client = yield* HttpClient.HttpClient -// HttpClient.fetch is a Default -const res = HttpClient.fetch(req) + const response = yield* client.get( + "https://jsonplaceholder.typicode.com/posts/1" + ) -const json = HttpClientResponse.json(res) + const json = yield* response.json -Effect.runPromise(json).then(console.log) + console.log(json) +}).pipe( + // ensure the request is aborted if the program is interrupted + Effect.scoped, + // provide the HttpClient + Effect.provide(FetchHttpClient.layer) +) + +Effect.runPromise(program) /* Output: { @@ -614,29 +618,15 @@ Output: */ ``` -In this example: - -- `HttpClientRequest.get` creates a GET request to the specified URL. -- `HttpClient.fetch` executes the request. -- `HttpClientResponse.json` converts the response to JSON. -- `Effect.runPromise` runs the effect and logs the result. - -### Built-in Defaults +### Custom `HttpClient.Service`'s -| Default | Description | -| -------------------- | ------------------------------------------------------------------------- | -| `HttpClient.fetch` | Execute the request using the global `fetch` function | -| `HttpClient.fetchOk` | Same as `fetch` but ensures only `2xx` responses are treated as successes | - -### Custom Default - -You can create your own `Default` using the `HttpClient.makeDefault` constructor. +You can create your own `HttpClient.Service` using the `HttpClient.makeService` constructor. ```ts import { HttpClient, HttpClientResponse } from "@effect/platform" import { Effect } from "effect" -const myClient = HttpClient.makeDefault((req) => +const myClient = HttpClient.makeService((req) => Effect.succeed( HttpClientResponse.fromWeb( req, @@ -657,27 +647,25 @@ const myClient = HttpClient.makeDefault((req) => ## Tapping ```ts -import { - HttpClient, - HttpClientRequest, - HttpClientResponse -} from "@effect/platform" +import { FetchHttpClient, HttpClient } from "@effect/platform" import { Console, Effect } from "effect" -const req = HttpClientRequest.get( - "https://jsonplaceholder.typicode.com/posts/1" -) +const program = Effect.gen(function* () { + const client = (yield* HttpClient.HttpClient).pipe( + // Log the request before fetching + HttpClient.tapRequest(Console.log) + ) -// Log the request before fetching -const tapFetch: HttpClient.HttpClient.Default = HttpClient.fetch.pipe( - HttpClient.tapRequest(Console.log) -) + const response = yield* client.get( + "https://jsonplaceholder.typicode.com/posts/1" + ) -const res = tapFetch(req) + const json = yield* response.json -const json = HttpClientResponse.json(res) + console.log(json) +}).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer)) -Effect.runPromise(json).then(console.log) +Effect.runPromise(program) /* Output: { @@ -832,20 +820,21 @@ Output: To convert a GET response to JSON: ```ts -import { - HttpClient, - HttpClientRequest, - HttpClientResponse -} from "@effect/platform" +import { FetchHttpClient, HttpClient } from "@effect/platform" import { NodeRuntime } from "@effect/platform-node" import { Console, Effect } from "effect" -const getPostAsJson = HttpClientRequest.get( - "https://jsonplaceholder.typicode.com/posts/1" -).pipe(HttpClient.fetch, HttpClientResponse.json) +const getPostAsJson = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient + const response = yield* client.get( + "https://jsonplaceholder.typicode.com/posts/1" + ) + return yield* response.json +}).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer)) -NodeRuntime.runMain( - getPostAsJson.pipe(Effect.andThen((post) => Console.log(typeof post, post))) +getPostAsJson.pipe( + Effect.andThen((post) => Console.log(typeof post, post)), + NodeRuntime.runMain ) /* Output: @@ -866,20 +855,21 @@ object { To convert a GET response to text: ```ts -import { - HttpClient, - HttpClientRequest, - HttpClientResponse -} from "@effect/platform" +import { FetchHttpClient, HttpClient } from "@effect/platform" import { NodeRuntime } from "@effect/platform-node" import { Console, Effect } from "effect" -const getPostAsText = HttpClientRequest.get( - "https://jsonplaceholder.typicode.com/posts/1" -).pipe(HttpClient.fetch, HttpClientResponse.text) +const getPostAsJson = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient + const response = yield* client.get( + "https://jsonplaceholder.typicode.com/posts/1" + ) + return yield* response.text +}).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer)) -NodeRuntime.runMain( - getPostAsText.pipe(Effect.andThen((post) => Console.log(typeof post, post))) +getPostAsJson.pipe( + Effect.andThen((post) => Console.log(typeof post, post)), + NodeRuntime.runMain ) /* Output: @@ -899,14 +889,14 @@ string { Here are some APIs you can use to convert the response: -| API | Description | -| ---------------------------------- | ------------------------------------- | -| `HttpClientResponse.arrayBuffer` | Convert to `ArrayBuffer` | -| `HttpClientResponse.formData` | Convert to `FormData` | -| `HttpClientResponse.json` | Convert to JSON | -| `HttpClientResponse.stream` | Convert to a `Stream` of `Uint8Array` | -| `HttpClientResponse.text` | Convert to text | -| `HttpClientResponse.urlParamsBody` | Convert to `Http.urlParams.UrlParams` | +| API | Description | +| ------------------------ | ------------------------------------- | +| `response.arrayBuffer` | Convert to `ArrayBuffer` | +| `response.formData` | Convert to `FormData` | +| `response.json` | Convert to JSON | +| `response.stream` | Convert to a `Stream` of `Uint8Array` | +| `response.text` | Convert to text | +| `response.urlParamsBody` | Convert to `UrlParams` | ### Decoding Data with Schemas @@ -914,8 +904,8 @@ A common use case when fetching data is to validate the received format. For thi ```ts import { + FetchHttpClient, HttpClient, - HttpClientRequest, HttpClientResponse } from "@effect/platform" import { NodeRuntime } from "@effect/platform-node" @@ -931,17 +921,17 @@ const Post = Schema.Struct({ const getPostAndValidate: Effect.Effect<{ readonly id: number; readonly title: string; -}, Http.error.HttpClientError | ParseError, never> +}, HttpClientError | ParseError, never> */ -const getPostAndValidate = HttpClientRequest.get( - "https://jsonplaceholder.typicode.com/posts/1" -).pipe( - HttpClient.fetch, - Effect.andThen(HttpClientResponse.schemaBodyJson(Post)), - Effect.scoped -) +const getPostAndValidate = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient + const response = yield* client.get( + "https://jsonplaceholder.typicode.com/posts/1" + ) + return yield* HttpClientResponse.schemaBodyJson(Post)(response) +}).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer)) -NodeRuntime.runMain(getPostAndValidate.pipe(Effect.andThen(Console.log))) +getPostAndValidate.pipe(Effect.andThen(Console.log), NodeRuntime.runMain) /* Output: { @@ -964,19 +954,19 @@ You can use `HttpClient.filterStatusOk`, or `HttpClient.fetchOk` to ensure only In this example, we attempt to fetch a non-existent page and don't receive any error: ```ts -import { - HttpClient, - HttpClientRequest, - HttpClientResponse -} from "@effect/platform" +import { FetchHttpClient, HttpClient } from "@effect/platform" import { NodeRuntime } from "@effect/platform-node" import { Console, Effect } from "effect" -const getText = HttpClientRequest.get( - "https://jsonplaceholder.typicode.com/non-existing-page" -).pipe(HttpClient.fetch, HttpClientResponse.text) +const getText = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient + const response = yield* client.get( + "https://jsonplaceholder.typicode.com/non-existing-page" + ) + return yield* response.text +}).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer)) -NodeRuntime.runMain(getText.pipe(Effect.andThen(Console.log))) +getText.pipe(Effect.andThen(Console.log), NodeRuntime.runMain) /* Output: {} @@ -986,19 +976,19 @@ Output: However, if we use `HttpClient.filterStatusOk`, an error is logged: ```ts -import { - HttpClient, - HttpClientRequest, - HttpClientResponse -} from "@effect/platform" +import { FetchHttpClient, HttpClient } from "@effect/platform" import { NodeRuntime } from "@effect/platform-node" import { Console, Effect } from "effect" -const getText = HttpClientRequest.get( - "https://jsonplaceholder.typicode.com/non-existing-page" -).pipe(HttpClient.filterStatusOk(HttpClient.fetch), HttpClientResponse.text) +const getText = Effect.gen(function* () { + const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk) + const response = yield* client.get( + "https://jsonplaceholder.typicode.com/non-existing-page" + ) + return yield* response.text +}).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer)) -NodeRuntime.runMain(getText.pipe(Effect.andThen(Console.log))) +getText.pipe(Effect.andThen(Console.log), NodeRuntime.runMain) /* Output: timestamp=... level=ERROR fiber=#0 cause="ResponseError: StatusCode error (404 GET https://jsonplaceholder.typicode.com/non-existing-page): non 2xx status code @@ -1006,60 +996,36 @@ timestamp=... level=ERROR fiber=#0 cause="ResponseError: StatusCode error (404 G */ ``` -Note that you can use `HttpClient.fetchOk` as a shortcut for `HttpClient.filterStatusOk(HttpClient.fetch)`: - -```ts -const getText = HttpClientRequest.get( - "https://jsonplaceholder.typicode.com/non-existing-page" -).pipe(HttpClient.fetchOk, HttpClientResponse.text) -``` - -You can also create your own status-based filters. In fact, `HttpClient.filterStatusOk` is just a shortcut for the following filter: - -```ts -const getText = HttpClientRequest.get( - "https://jsonplaceholder.typicode.com/non-existing-page" -).pipe( - HttpClient.filterStatus( - HttpClient.fetch, - (status) => status >= 200 && status < 300 - ), - HttpClientResponse.text -) - -/* -Output: -timestamp=... level=ERROR fiber=#0 cause="ResponseError: StatusCode error (404 GET https://jsonplaceholder.typicode.com/non-existing-page): invalid status code - ... stack trace ... -*/ -``` - ## POST -To make a POST request, you can use the `HttpClientRequest.post` function provided by the `HttpClient` module. Here's an example of how to create and send a POST request: +To make a POST request, you can use the `HttpClientRequest.post` function provided by the `HttpClientRequest` module. Here's an example of how to create and send a POST request: ```ts import { + FetchHttpClient, HttpClient, - HttpClientRequest, - HttpClientResponse + HttpClientRequest } from "@effect/platform" import { NodeRuntime } from "@effect/platform-node" import { Console, Effect } from "effect" -const addPost = HttpClientRequest.post( - "https://jsonplaceholder.typicode.com/posts" -).pipe( - HttpClientRequest.jsonBody({ - title: "foo", - body: "bar", - userId: 1 - }), - Effect.andThen(HttpClient.fetch), - HttpClientResponse.json -) +const addPost = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient + return yield* HttpClientRequest.post( + "https://jsonplaceholder.typicode.com/posts" + ).pipe( + HttpClientRequest.bodyJson({ + title: "foo", + body: "bar", + userId: 1 + }), + Effect.flatMap(client.execute), + Effect.flatMap((res) => res.json), + Effect.scoped + ) +}).pipe(Effect.provide(FetchHttpClient.layer)) -NodeRuntime.runMain(addPost.pipe(Effect.andThen(Console.log))) +addPost.pipe(Effect.andThen(Console.log), NodeRuntime.runMain) /* Output: { title: 'foo', body: 'bar', userId: 1, id: 101 } @@ -1072,29 +1038,33 @@ In the following example, we send the data as text: ```ts import { + FetchHttpClient, HttpClient, - HttpClientRequest, - HttpClientResponse + HttpClientRequest } from "@effect/platform" import { NodeRuntime } from "@effect/platform-node" import { Console, Effect } from "effect" -const addPost = HttpClientRequest.post( - "https://jsonplaceholder.typicode.com/posts" -).pipe( - HttpClientRequest.textBody( - JSON.stringify({ - title: "foo", - body: "bar", - userId: 1 - }), - "application/json; charset=UTF-8" - ), - HttpClient.fetch, - HttpClientResponse.json -) +const addPost = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient + return yield* HttpClientRequest.post( + "https://jsonplaceholder.typicode.com/posts" + ).pipe( + HttpClientRequest.bodyText( + JSON.stringify({ + title: "foo", + body: "bar", + userId: 1 + }), + "application/json; charset=UTF-8" + ), + client.execute, + Effect.flatMap((res) => res.json), + Effect.scoped + ) +}).pipe(Effect.provide(FetchHttpClient.layer)) -NodeRuntime.runMain(Effect.andThen(addPost, Console.log)) +addPost.pipe(Effect.andThen(Console.log), NodeRuntime.runMain) /* Output: { title: 'foo', body: 'bar', userId: 1, id: 101 } @@ -1107,6 +1077,7 @@ A common use case when fetching data is to validate the received format. For thi ```ts import { + FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse @@ -1120,20 +1091,26 @@ const Post = Schema.Struct({ title: Schema.String }) -const addPost = HttpClientRequest.post( - "https://jsonplaceholder.typicode.com/posts" -).pipe( - HttpClientRequest.jsonBody({ - title: "foo", - body: "bar", - userId: 1 - }), - Effect.andThen(HttpClient.fetch), - Effect.andThen(HttpClientResponse.schemaBodyJson(Post)), - Effect.scoped -) +const addPost = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient + return yield* HttpClientRequest.post( + "https://jsonplaceholder.typicode.com/posts" + ).pipe( + HttpClientRequest.bodyText( + JSON.stringify({ + title: "foo", + body: "bar", + userId: 1 + }), + "application/json; charset=UTF-8" + ), + client.execute, + Effect.flatMap(HttpClientResponse.schemaBodyJson(Post)), + Effect.scoped + ) +}).pipe(Effect.provide(FetchHttpClient.layer)) -NodeRuntime.runMain(addPost.pipe(Effect.andThen(Console.log))) +addPost.pipe(Effect.andThen(Console.log), NodeRuntime.runMain) /* Output: { id: 101, title: 'foo' } @@ -1147,30 +1124,31 @@ Output: To test HTTP requests, you can inject a mock fetch implementation. ```ts -import { - HttpClient, - HttpClientRequest, - HttpClientResponse -} from "@effect/platform" +import { FetchHttpClient, HttpClient } from "@effect/platform" import { Effect, Layer } from "effect" import * as assert from "node:assert" // Mock fetch implementation -const FetchTest = Layer.succeed(HttpClient.Fetch, () => +const FetchTest = Layer.succeed(FetchHttpClient.Fetch, () => Promise.resolve(new Response("not found", { status: 404 })) ) -// Program to test -const program = HttpClientRequest.get("https://www.google.com/").pipe( - HttpClient.fetch, - HttpClientResponse.text -) +const TestLayer = FetchHttpClient.layer.pipe(Layer.provide(FetchTest)) + +const program = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient + + return yield* client.get("https://www.google.com/").pipe( + Effect.flatMap((res) => res.text), + Effect.scoped + ) +}) // Test Effect.gen(function* () { const response = yield* program assert.equal(response, "not found") -}).pipe(Effect.provide(FetchTest), Effect.runPromise) +}).pipe(Effect.provide(TestLayer), Effect.runPromise) ``` # HTTP Server diff --git a/packages/platform/src/FetchHttpClient.ts b/packages/platform/src/FetchHttpClient.ts new file mode 100644 index 00000000000..94ad39bcf8d --- /dev/null +++ b/packages/platform/src/FetchHttpClient.ts @@ -0,0 +1,25 @@ +/** + * @since 1.0.0 + */ +import * as Context from "effect/Context" +import type * as Layer from "effect/Layer" +import type { HttpClient } from "./HttpClient.js" +import * as internal from "./internal/fetchHttpClient.js" + +/** + * @since 1.0.0 + * @category tags + */ +export class Fetch extends Context.Tag(internal.fetchTagKey)() {} + +/** + * @since 1.0.0 + * @category tags + */ +export class RequestInit extends Context.Tag(internal.requestInitTagKey)() {} + +/** + * @since 1.0.0 + * @category layers + */ +export const layer: Layer.Layer = internal.layer diff --git a/packages/platform/src/HttpApiClient.ts b/packages/platform/src/HttpApiClient.ts index 74034d5bb43..a31c9cb8963 100644 --- a/packages/platform/src/HttpApiClient.ts +++ b/packages/platform/src/HttpApiClient.ts @@ -56,10 +56,10 @@ export type Client = [A] extends export const make = ( api: A, options?: { - readonly transformClient?: ((client: HttpClient.HttpClient.Default) => HttpClient.HttpClient.Default) | undefined + readonly transformClient?: ((client: HttpClient.HttpClient.Service) => HttpClient.HttpClient.Service) | undefined readonly baseUrl?: string | undefined } -): Effect.Effect>, never, HttpApi.HttpApi.Context | HttpClient.HttpClient.Default> => +): Effect.Effect>, never, HttpApi.HttpApi.Context | HttpClient.HttpClient.Service> => Effect.gen(function*() { const context = yield* Effect.context() const httpClient = (yield* HttpClient.HttpClient).pipe( @@ -165,13 +165,13 @@ export const make = ( const baseRequest = HttpClientRequest.make(endpoint.method)(url) return (isMultipart ? Effect.succeed(baseRequest.pipe( - HttpClientRequest.formDataBody(request.payload) + HttpClientRequest.bodyFormData(request.payload) )) : encodePayload._tag === "Some" ? encodePayload.value(request.payload).pipe( Effect.flatMap((payload) => HttpMethod.hasBody(endpoint.method) - ? HttpClientRequest.jsonBody(baseRequest, payload) + ? HttpClientRequest.bodyJson(baseRequest, payload) : Effect.succeed(HttpClientRequest.setUrlParams(baseRequest, payload as any)) ), Effect.orDie @@ -186,7 +186,7 @@ export const make = ( ) : identity, Effect.flatMap((request) => - Effect.flatMap(httpClient(request), (response) => + Effect.flatMap(httpClient.execute(request), (response) => response.status !== successStatus ? handleError(request, response) : Effect.succeed(response)) diff --git a/packages/platform/src/HttpClient.ts b/packages/platform/src/HttpClient.ts index 1240abcb730..45f8b3d8193 100644 --- a/packages/platform/src/HttpClient.ts +++ b/packages/platform/src/HttpClient.ts @@ -9,7 +9,7 @@ import type * as Effect from "effect/Effect" import type { RuntimeFiber } from "effect/Fiber" import type * as FiberRef from "effect/FiberRef" import type { Inspectable } from "effect/Inspectable" -import type * as Layer from "effect/Layer" +import type { Layer } from "effect/Layer" import type { Pipeable } from "effect/Pipeable" import type * as Predicate from "effect/Predicate" import type { Ref } from "effect/Ref" @@ -38,10 +38,16 @@ export type TypeId = typeof TypeId * @category models */ export interface HttpClient extends Pipeable, Inspectable { - (request: ClientRequest.HttpClientRequest): Effect.Effect readonly [TypeId]: TypeId - readonly preprocess: HttpClient.Preprocess - readonly execute: HttpClient.Execute + readonly execute: (request: ClientRequest.HttpClientRequest) => Effect.Effect + + readonly get: (url: string | URL, options?: ClientRequest.Options.NoBody) => Effect.Effect + readonly head: (url: string | URL, options?: ClientRequest.Options.NoBody) => Effect.Effect + readonly post: (url: string | URL, options?: ClientRequest.Options.NoUrl) => Effect.Effect + readonly patch: (url: string | URL, options?: ClientRequest.Options.NoUrl) => Effect.Effect + readonly put: (url: string | URL, options?: ClientRequest.Options.NoUrl) => Effect.Effect + readonly del: (url: string | URL, options?: ClientRequest.Options.NoUrl) => Effect.Effect + readonly options: (url: string | URL, options?: ClientRequest.Options.NoUrl) => Effect.Effect } /** @@ -60,7 +66,7 @@ export declare namespace HttpClient { * @since 1.0.0 * @category models */ - export type Execute = ( + export type Postprocess = ( request: Effect.Effect ) => Effect.Effect @@ -74,46 +80,14 @@ export declare namespace HttpClient { * @since 1.0.0 * @category models */ - export type Default = WithResponse + export type Service = WithResponse } -/** - * @since 1.0.0 - * @category models - */ -export interface Fetch { - readonly _: unique symbol -} - -/** - * @since 1.0.0 - * @category tags - */ -export const HttpClient: Context.Tag = internal.tag - /** * @since 1.0.0 * @category tags */ -export const Fetch: Context.Tag = internal.Fetch - -/** - * @since 1.0.0 - * @category layers - */ -export const layer: Layer.Layer = internal.layer - -/** - * @since 1.0.0 - * @category constructors - */ -export const fetch: HttpClient.Default = internal.fetch - -/** - * @since 1.0.0 - * @category constructors - */ -export const fetchOk: HttpClient.Default = internal.fetchOk +export const HttpClient: Context.Tag = internal.tag /** * @since 1.0.0 @@ -262,14 +236,14 @@ export const make: ( * @since 1.0.0 * @category constructors */ -export const makeDefault: ( +export const makeService: ( f: ( request: ClientRequest.HttpClientRequest, url: URL, signal: AbortSignal, fiber: RuntimeFiber ) => Effect.Effect -) => HttpClient.Default = internal.makeDefault +) => HttpClient.Service = internal.makeService /** * @since 1.0.0 @@ -319,20 +293,6 @@ export const mapEffect: { (self: HttpClient, f: (a: A) => Effect.Effect): HttpClient } = internal.mapEffect -/** - * @since 1.0.0 - * @category mapping & sequencing - */ -export const mapEffectScoped: { - ( - f: (a: A) => Effect.Effect - ): (self: HttpClient) => HttpClient | Exclude> - ( - self: HttpClient, - f: (a: A) => Effect.Effect - ): HttpClient | Exclude> -} = internal.mapEffectScoped - /** * @since 1.0.0 * @category mapping & sequencing @@ -365,7 +325,7 @@ export const mapRequestEffect: { * @since 1.0.0 * @category mapping & sequencing */ -export const mapInputRequest: { +export const mapRequestInput: { ( f: (a: ClientRequest.HttpClientRequest) => ClientRequest.HttpClientRequest ): (self: HttpClient) => HttpClient @@ -373,13 +333,13 @@ export const mapInputRequest: { self: HttpClient, f: (a: ClientRequest.HttpClientRequest) => ClientRequest.HttpClientRequest ): HttpClient -} = internal.mapInputRequest +} = internal.mapRequestInput /** * @since 1.0.0 * @category mapping & sequencing */ -export const mapInputRequestEffect: { +export const mapRequestInputEffect: { ( f: (a: ClientRequest.HttpClientRequest) => Effect.Effect ): (self: HttpClient) => HttpClient @@ -387,20 +347,42 @@ export const mapInputRequestEffect: { self: HttpClient, f: (a: ClientRequest.HttpClientRequest) => Effect.Effect ): HttpClient -} = internal.mapInputRequestEffect +} = internal.mapRequestInputEffect + +/** + * @since 1.0.0 + * @category error handling + */ +export declare namespace Retry { + /** + * @since 1.0.0 + * @category error handling + */ + export type Return> = HttpClient< + A, + | (O extends { schedule: Schedule.Schedule } ? E + : O extends { until: Predicate.Refinement } ? E2 + : E) + | (O extends { while: (...args: Array) => Effect.Effect } ? E : never) + | (O extends { until: (...args: Array) => Effect.Effect } ? E : never), + | R + | (O extends { schedule: Schedule.Schedule } ? R : never) + | (O extends { while: (...args: Array) => Effect.Effect } ? R : never) + | (O extends { until: (...args: Array) => Effect.Effect } ? R : never) + > extends infer Z ? Z : never +} /** * @since 1.0.0 * @category error handling */ export const retry: { - ( - policy: Schedule.Schedule + >(options: O): (self: HttpClient) => Retry.Return + ( + policy: Schedule.Schedule, R1> ): (self: HttpClient) => HttpClient - ( - self: HttpClient, - policy: Schedule.Schedule - ): HttpClient + >(self: HttpClient, options: O): Retry.Return + (self: HttpClient, policy: Schedule.Schedule): HttpClient } = internal.retry /** @@ -512,15 +494,7 @@ export const withTracerPropagation: { /** * @since 1.0.0 - * @category fiber refs - */ -export const currentFetchOptions: FiberRef.FiberRef = internal.currentFetchOptions - -/** - * @since 1.0.0 - * @category fiber refs */ -export const withFetchOptions: { - (options: RequestInit): (effect: Effect.Effect) => Effect.Effect - (effect: Effect.Effect, options: RequestInit): Effect.Effect -} = internal.withFetchOptions +export const layerMergedContext: ( + effect: Effect.Effect +) => Layer = internal.layerMergedContext diff --git a/packages/platform/src/HttpClientRequest.ts b/packages/platform/src/HttpClientRequest.ts index e51eddb2c70..39f6485d72f 100644 --- a/packages/platform/src/HttpClientRequest.ts +++ b/packages/platform/src/HttpClientRequest.ts @@ -36,7 +36,7 @@ export type TypeId = typeof TypeId * @category models */ export interface HttpClientRequest - extends Effect.Effect, Inspectable + extends Effect.Effect, Inspectable { readonly [TypeId]: TypeId readonly method: HttpMethod @@ -301,73 +301,73 @@ export const setBody: { * @since 1.0.0 * @category combinators */ -export const uint8ArrayBody: { +export const bodyUint8Array: { (body: Uint8Array, contentType?: string): (self: HttpClientRequest) => HttpClientRequest (self: HttpClientRequest, body: Uint8Array, contentType?: string): HttpClientRequest -} = internal.uint8ArrayBody +} = internal.bodyUint8Array /** * @since 1.0.0 * @category combinators */ -export const textBody: { +export const bodyText: { (body: string, contentType?: string): (self: HttpClientRequest) => HttpClientRequest (self: HttpClientRequest, body: string, contentType?: string): HttpClientRequest -} = internal.textBody +} = internal.bodyText /** * @since 1.0.0 * @category combinators */ -export const jsonBody: { +export const bodyJson: { (body: unknown): (self: HttpClientRequest) => Effect.Effect (self: HttpClientRequest, body: unknown): Effect.Effect -} = internal.jsonBody +} = internal.bodyJson /** * @since 1.0.0 * @category combinators */ -export const unsafeJsonBody: { +export const bodyUnsafeJson: { (body: unknown): (self: HttpClientRequest) => HttpClientRequest (self: HttpClientRequest, body: unknown): HttpClientRequest -} = internal.unsafeJsonBody +} = internal.bodyUnsafeJson /** * @since 1.0.0 * @category combinators */ -export const schemaBody: ( +export const schemaBodyJson: ( schema: Schema.Schema, options?: ParseOptions | undefined ) => { (body: A): (self: HttpClientRequest) => Effect.Effect (self: HttpClientRequest, body: A): Effect.Effect -} = internal.schemaBody +} = internal.schemaBodyJson /** * @since 1.0.0 * @category combinators */ -export const urlParamsBody: { +export const bodyUrlParams: { (input: UrlParams.Input): (self: HttpClientRequest) => HttpClientRequest (self: HttpClientRequest, input: UrlParams.Input): HttpClientRequest -} = internal.urlParamsBody +} = internal.bodyUrlParams /** * @since 1.0.0 * @category combinators */ -export const formDataBody: { +export const bodyFormData: { (body: FormData): (self: HttpClientRequest) => HttpClientRequest (self: HttpClientRequest, body: FormData): HttpClientRequest -} = internal.formDataBody +} = internal.bodyFormData /** * @since 1.0.0 * @category combinators */ -export const streamBody: { +export const bodyStream: { ( body: Stream.Stream, options?: { readonly contentType?: string | undefined; readonly contentLength?: number | undefined } | undefined @@ -377,13 +377,13 @@ export const streamBody: { body: Stream.Stream, options?: { readonly contentType?: string | undefined; readonly contentLength?: number | undefined } | undefined ): HttpClientRequest -} = internal.streamBody +} = internal.bodyStream /** * @since 1.0.0 * @category combinators */ -export const fileBody: { +export const bodyFile: { ( path: string, options?: FileSystem.StreamOptions & { readonly contentType?: string } @@ -393,13 +393,13 @@ export const fileBody: { path: string, options?: FileSystem.StreamOptions & { readonly contentType?: string } ): Effect.Effect -} = internal.fileBody +} = internal.bodyFile /** * @since 1.0.0 * @category combinators */ -export const fileWebBody: { +export const bodyFileWeb: { (file: Body.HttpBody.FileLike): (self: HttpClientRequest) => HttpClientRequest (self: HttpClientRequest, file: Body.HttpBody.FileLike): HttpClientRequest -} = internal.fileWebBody +} = internal.bodyFileWeb diff --git a/packages/platform/src/HttpClientResponse.ts b/packages/platform/src/HttpClientResponse.ts index 9b17daaa2c9..5e44cf975bb 100644 --- a/packages/platform/src/HttpClientResponse.ts +++ b/packages/platform/src/HttpClientResponse.ts @@ -7,12 +7,12 @@ import type * as Schema from "@effect/schema/Schema" import type * as Effect from "effect/Effect" import type * as Scope from "effect/Scope" import type * as Stream from "effect/Stream" +import type { Unify } from "effect/Unify" import type * as Cookies from "./Cookies.js" import type * as Error from "./HttpClientError.js" import type * as ClientRequest from "./HttpClientRequest.js" import type * as IncomingMessage from "./HttpIncomingMessage.js" import * as internal from "./internal/httpClientResponse.js" -import type * as UrlParams from "./UrlParams.js" export { /** @@ -20,11 +20,6 @@ export { * @category schema */ schemaBodyJson, - /** - * @since 1.0.0 - * @category schema - */ - schemaBodyJsonScoped, /** * @since 1.0.0 * @category schema @@ -34,17 +29,7 @@ export { * @since 1.0.0 * @category schema */ - schemaBodyUrlParamsScoped, - /** - * @since 1.0.0 - * @category schema - */ - schemaHeaders, - /** - * @since 1.0.0 - * @category schema - */ - schemaHeadersScoped + schemaHeaders } from "./HttpIncomingMessage.js" /** @@ -111,41 +96,6 @@ export const schemaNoBody: < options?: ParseOptions | undefined ) => (self: HttpClientResponse) => Effect.Effect = internal.schemaNoBody -/** - * @since 1.0.0 - * @category accessors - */ -export const arrayBuffer: ( - effect: Effect.Effect -) => Effect.Effect> = internal.arrayBuffer - -/** - * @since 1.0.0 - * @category accessors - */ -export const formData: ( - effect: Effect.Effect -) => Effect.Effect> = internal.formData - -/** - * @since 1.0.0 - * @category accessors - */ -export const json: ( - effect: Effect.Effect -) => Effect.Effect> = internal.json - -const void_: ( - effect: Effect.Effect -) => Effect.Effect> = internal.void_ -export { - /** - * @since 1.0.0 - * @category accessors - */ - void_ as void -} - /** * @since 1.0.0 * @category accessors @@ -154,64 +104,6 @@ export const stream: ( effect: Effect.Effect ) => Stream.Stream> = internal.stream -/** - * @since 1.0.0 - * @category accessors - */ -export const text: ( - effect: Effect.Effect -) => Effect.Effect> = internal.text - -/** - * @since 1.0.0 - * @category accessors - */ -export const urlParamsBody: ( - effect: Effect.Effect -) => Effect.Effect> = internal.urlParamsBody - -/** - * @since 1.0.0 - * @category schema - */ -export const schemaJsonScoped: < - R, - I extends { - readonly status?: number | undefined - readonly headers?: Readonly> | undefined - readonly body?: unknown - }, - A ->( - schema: Schema.Schema, - options?: ParseOptions | undefined -) => ( - effect: Effect.Effect -) => Effect.Effect< - A, - E | Error.ResponseError | ParseResult.ParseError, - Exclude | Exclude -> = internal.schemaJsonScoped - -/** - * @since 1.0.0 - * @category schema - */ -export const schemaNoBodyScoped: < - R, - I extends { - readonly status?: number | undefined - readonly headers?: Readonly> | undefined - }, - A ->( - schema: Schema.Schema, - options?: ParseOptions | undefined -) => ( - effect: Effect.Effect -) => Effect.Effect | Exclude> = - internal.schemaNoBodyScoped - /** * @since 1.0.0 * @category pattern matching @@ -226,7 +118,7 @@ export const matchStatus: { readonly "5xx"?: (_: HttpClientResponse) => any readonly orElse: (_: HttpClientResponse) => any } - >(cases: Cases): (self: HttpClientResponse) => Cases[keyof Cases] extends (_: any) => infer R ? R : never + >(cases: Cases): (self: HttpClientResponse) => Cases[keyof Cases] extends (_: any) => infer R ? Unify : never < const Cases extends { readonly [status: number]: (_: HttpClientResponse) => any @@ -236,55 +128,5 @@ export const matchStatus: { readonly "5xx"?: (_: HttpClientResponse) => any readonly orElse: (_: HttpClientResponse) => any } - >(self: HttpClientResponse, cases: Cases): Cases[keyof Cases] extends (_: any) => infer R ? R : never + >(self: HttpClientResponse, cases: Cases): Cases[keyof Cases] extends (_: any) => infer R ? Unify : never } = internal.matchStatus - -/** - * @since 1.0.0 - * @category pattern matching - */ -export const matchStatusScoped: { - < - const Cases extends { - readonly [status: number]: (_: HttpClientResponse) => Effect.Effect - readonly "2xx"?: (_: HttpClientResponse) => Effect.Effect - readonly "3xx"?: (_: HttpClientResponse) => Effect.Effect - readonly "4xx"?: (_: HttpClientResponse) => Effect.Effect - readonly "5xx"?: (_: HttpClientResponse) => Effect.Effect - readonly orElse: (_: HttpClientResponse) => Effect.Effect - } - >( - cases: Cases - ): ( - self: Effect.Effect - ) => Effect.Effect< - Cases[keyof Cases] extends (_: any) => Effect.Effect ? _A : never, - E | (Cases[keyof Cases] extends (_: any) => Effect.Effect ? _E : never), - Exclude< - R | (Cases[keyof Cases] extends (_: any) => Effect.Effect ? _R : never), - Scope.Scope - > - > - < - E, - R, - const Cases extends { - readonly [status: number]: (_: HttpClientResponse) => Effect.Effect - readonly "2xx"?: (_: HttpClientResponse) => Effect.Effect - readonly "3xx"?: (_: HttpClientResponse) => Effect.Effect - readonly "4xx"?: (_: HttpClientResponse) => Effect.Effect - readonly "5xx"?: (_: HttpClientResponse) => Effect.Effect - readonly orElse: (_: HttpClientResponse) => Effect.Effect - } - >( - self: Effect.Effect, - cases: Cases - ): Effect.Effect< - Cases[keyof Cases] extends (_: any) => Effect.Effect ? _A : never, - E | (Cases[keyof Cases] extends (_: any) => Effect.Effect ? _E : never), - Exclude< - R | (Cases[keyof Cases] extends (_: any) => Effect.Effect ? _R : never), - Scope.Scope - > - > -} = internal.matchStatusScoped diff --git a/packages/platform/src/HttpIncomingMessage.ts b/packages/platform/src/HttpIncomingMessage.ts index 29f616c5b5c..e217a94ebc0 100644 --- a/packages/platform/src/HttpIncomingMessage.ts +++ b/packages/platform/src/HttpIncomingMessage.ts @@ -10,7 +10,6 @@ import { dual } from "effect/Function" import * as Global from "effect/GlobalValue" import type { Inspectable } from "effect/Inspectable" import * as Option from "effect/Option" -import type * as Scope from "effect/Scope" import type * as Stream from "effect/Stream" import * as FileSystem from "./FileSystem.js" import type * as Headers from "./Headers.js" @@ -53,18 +52,6 @@ export const schemaBodyJson = (schema: Schema.Schema, options? Effect.flatMap(self.json, parse) } -/** - * @since 1.0.0 - * @category schema - */ -export const schemaBodyJsonScoped = (schema: Schema.Schema, options?: ParseOptions | undefined) => { - const decode = schemaBodyJson(schema, options) - return ( - effect: Effect.Effect, E2, R2> - ): Effect.Effect | Exclude> => - Effect.scoped(Effect.flatMap(effect, decode)) -} - /** * @since 1.0.0 * @category schema @@ -78,21 +65,6 @@ export const schemaBodyUrlParams = parse(Object.fromEntries(_))) } -/** - * @since 1.0.0 - * @category schema - */ -export const schemaBodyUrlParamsScoped = >, R>( - schema: Schema.Schema, - options?: ParseOptions | undefined -) => { - const decode = schemaBodyUrlParams(schema, options) - return ( - effect: Effect.Effect, E2, R2> - ): Effect.Effect | Exclude> => - Effect.scoped(Effect.flatMap(effect, decode)) -} - /** * @since 1.0.0 * @category schema @@ -105,21 +77,6 @@ export const schemaHeaders = (self: HttpIncomingMessage): Effect.Effect => parse(self.headers) } -/** - * @since 1.0.0 - * @category schema - */ -export const schemaHeadersScoped = >, R>( - schema: Schema.Schema, - options?: ParseOptions | undefined -) => { - const decode = schemaHeaders(schema, options) - return ( - effect: Effect.Effect, E2, R2> - ): Effect.Effect | Exclude> => - Effect.scoped(Effect.flatMap(effect, decode)) -} - /** * @since 1.0.0 * @category fiber refs diff --git a/packages/platform/src/HttpServer.ts b/packages/platform/src/HttpServer.ts index 9a5e155d5e2..c972e2a5d86 100644 --- a/packages/platform/src/HttpServer.ts +++ b/packages/platform/src/HttpServer.ts @@ -201,5 +201,5 @@ export const withLogAddress: (layer: Layer.Layer) => Layer.Lay * @since 1.0.0 * @category layers */ -export const layerTestClient: Layer.Layer = +export const layerTestClient: Layer.Layer = internal.layerTestClient diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts index 7764a015eda..b85998a25dd 100644 --- a/packages/platform/src/index.ts +++ b/packages/platform/src/index.ts @@ -28,6 +28,11 @@ export * as Error from "./Error.js" */ export * as Etag from "./Etag.js" +/** + * @since 1.0.0 + */ +export * as FetchHttpClient from "./FetchHttpClient.js" + /** * @since 1.0.0 */ diff --git a/packages/platform/src/internal/fetchHttpClient.ts b/packages/platform/src/internal/fetchHttpClient.ts new file mode 100644 index 00000000000..c73ce42ce56 --- /dev/null +++ b/packages/platform/src/internal/fetchHttpClient.ts @@ -0,0 +1,56 @@ +import * as Effect from "effect/Effect" +import * as FiberRef from "effect/FiberRef" +import * as Stream from "effect/Stream" +import type * as Client from "../HttpClient.js" +import * as Error from "../HttpClientError.js" +import * as Method from "../HttpMethod.js" +import * as client from "./httpClient.js" +import * as internalResponse from "./httpClientResponse.js" + +/** @internal */ +export const fetchTagKey = "@effect/platform/FetchHttpClient/Fetch" +/** @internal */ +export const requestInitTagKey = "@effect/platform/FetchHttpClient/FetchOptions" + +const fetch: Client.HttpClient.Service = client.makeService((request, url, signal, fiber) => { + const context = fiber.getFiberRef(FiberRef.currentContext) + const fetch: typeof globalThis.fetch = context.unsafeMap.get(fetchTagKey) ?? globalThis.fetch + const options: RequestInit = context.unsafeMap.get(requestInitTagKey) ?? {} + const headers = new globalThis.Headers(request.headers) + const send = (body: BodyInit | undefined) => + Effect.map( + Effect.tryPromise({ + try: () => + fetch(url, { + ...options, + method: request.method, + headers, + body, + duplex: request.body._tag === "Stream" ? "half" : undefined, + signal + } as any), + catch: (cause) => + new Error.RequestError({ + request, + reason: "Transport", + cause + }) + }), + (response) => internalResponse.fromWeb(request, response) + ) + if (Method.hasBody(request.method)) { + switch (request.body._tag) { + case "Raw": + case "Uint8Array": + return send(request.body.body as any) + case "FormData": + return send(request.body.formData) + case "Stream": + return Effect.flatMap(Stream.toReadableStreamEffect(request.body.stream), send) + } + } + return send(undefined) +}) + +/** @internal */ +export const layer = client.layerMergedContext(Effect.succeed(fetch)) diff --git a/packages/platform/src/internal/httpClient.ts b/packages/platform/src/internal/httpClient.ts index 7cee624e0ef..8fc588308f5 100644 --- a/packages/platform/src/internal/httpClient.ts +++ b/packages/platform/src/internal/httpClient.ts @@ -7,25 +7,23 @@ import type * as Fiber from "effect/Fiber" import * as FiberRef from "effect/FiberRef" import { constFalse, dual } from "effect/Function" import { globalValue } from "effect/GlobalValue" +import * as Inspectable from "effect/Inspectable" import * as Layer from "effect/Layer" import { pipeArguments } from "effect/Pipeable" import * as Predicate from "effect/Predicate" import * as Ref from "effect/Ref" import type * as Schedule from "effect/Schedule" import * as Scope from "effect/Scope" -import * as Stream from "effect/Stream" import * as Cookies from "../Cookies.js" import * as Headers from "../Headers.js" import type * as Client from "../HttpClient.js" import * as Error from "../HttpClientError.js" import type * as ClientRequest from "../HttpClientRequest.js" import type * as ClientResponse from "../HttpClientResponse.js" -import * as Method from "../HttpMethod.js" import * as TraceContext from "../HttpTraceContext.js" import * as UrlParams from "../UrlParams.js" import * as internalBody from "./httpBody.js" import * as internalRequest from "./httpClientRequest.js" -import * as internalResponse from "./httpClientResponse.js" /** @internal */ export const TypeId: Client.TypeId = Symbol.for( @@ -33,7 +31,7 @@ export const TypeId: Client.TypeId = Symbol.for( ) as Client.TypeId /** @internal */ -export const tag = Context.GenericTag("@effect/platform/HttpClient") +export const tag = Context.GenericTag("@effect/platform/HttpClient") /** @internal */ export const currentTracerDisabledWhen = globalValue( @@ -69,57 +67,72 @@ export const withTracerPropagation = dual< ) => Effect.Effect >(2, (self, enabled) => Effect.locally(self, currentTracerPropagation, enabled)) -/** @internal */ -export const currentFetchOptions = globalValue( - Symbol.for("@effect/platform/HttpClient/currentFetchOptions"), - () => FiberRef.unsafeMake({}) -) - -/** @internal */ -export const withFetchOptions = dual< - ( - options: RequestInit - ) => (effect: Effect.Effect) => Effect.Effect, - ( - effect: Effect.Effect, - options: RequestInit - ) => Effect.Effect ->(2, (self, options) => Effect.locally(self, currentFetchOptions, options)) - -const clientProto = { +const ClientProto = { [TypeId]: TypeId, pipe() { return pipeArguments(this, arguments) + }, + ...Inspectable.BaseProto, + toJSON() { + return { + _id: "@effect/platform/HttpClient" + } + }, + get(this: Client.HttpClient.Service, url: string | URL, options?: ClientRequest.Options.NoBody) { + return this.execute(internalRequest.get(url, options)) + }, + head(this: Client.HttpClient.Service, url: string | URL, options?: ClientRequest.Options.NoBody) { + return this.execute(internalRequest.head(url, options)) + }, + post(this: Client.HttpClient.Service, url: string | URL, options: ClientRequest.Options.NoUrl) { + return this.execute(internalRequest.post(url, options)) + }, + put(this: Client.HttpClient.Service, url: string | URL, options: ClientRequest.Options.NoUrl) { + return this.execute(internalRequest.put(url, options)) + }, + patch(this: Client.HttpClient.Service, url: string | URL, options: ClientRequest.Options.NoUrl) { + return this.execute(internalRequest.patch(url, options)) + }, + del(this: Client.HttpClient.Service, url: string | URL, options?: ClientRequest.Options.NoUrl) { + return this.execute(internalRequest.del(url, options)) + }, + options(this: Client.HttpClient.Service, url: string | URL, options?: ClientRequest.Options.NoBody) { + return this.execute(internalRequest.options(url, options)) } } const isClient = (u: unknown): u is Client.HttpClient => Predicate.hasProperty(u, TypeId) +interface HttpClientImpl extends Client.HttpClient { + readonly preprocess: Client.HttpClient.Preprocess + readonly postprocess: Client.HttpClient.Postprocess +} + /** @internal */ export const make = ( - execute: ( + postprocess: ( request: Effect.Effect ) => Effect.Effect, preprocess: Client.HttpClient.Preprocess ): Client.HttpClient => { - function client(request: ClientRequest.HttpClientRequest) { - return execute(preprocess(request)) + const self = Object.create(ClientProto) + self.preprocess = preprocess + self.postprocess = postprocess + self.execute = function(request: ClientRequest.HttpClientRequest) { + return postprocess(preprocess(request)) } - Object.setPrototypeOf(client, clientProto) - ;(client as any).preprocess = preprocess - ;(client as any).execute = execute - return client as any + return self } /** @internal */ -export const makeDefault = ( +export const makeService = ( f: ( request: ClientRequest.HttpClientRequest, url: URL, signal: AbortSignal, fiber: Fiber.RuntimeFiber ) => Effect.Effect -): Client.HttpClient.Default => +): Client.HttpClient.Service => make((effect) => Effect.flatMap(effect, (request) => Effect.withFiberRuntime((fiber) => { @@ -188,52 +201,6 @@ export const makeDefault = ( ) })), Effect.succeed as Client.HttpClient.Preprocess) -/** @internal */ -export const Fetch = Context.GenericTag( - "@effect/platform/HttpClient/Fetch" -) - -/** @internal */ -export const fetch: Client.HttpClient.Default = makeDefault((request, url, signal, fiber) => { - const context = fiber.getFiberRef(FiberRef.currentContext) - const fetch: typeof globalThis.fetch = context.unsafeMap.get(Fetch.key) ?? globalThis.fetch - const options = fiber.getFiberRef(currentFetchOptions) - const headers = new globalThis.Headers(request.headers) - const send = (body: BodyInit | undefined) => - Effect.map( - Effect.tryPromise({ - try: () => - fetch(url, { - ...options, - method: request.method, - headers, - body, - duplex: request.body._tag === "Stream" ? "half" : undefined, - signal - } as any), - catch: (cause) => - new Error.RequestError({ - request, - reason: "Transport", - cause - }) - }), - (response) => internalResponse.fromWeb(request, response) - ) - if (Method.hasBody(request.method)) { - switch (request.body._tag) { - case "Raw": - case "Uint8Array": - return send(request.body.body as any) - case "FormData": - return send(request.body.formData) - case "Stream": - return Effect.flatMap(Stream.toReadableStreamEffect(request.body.stream), send) - } - } - return send(undefined) -}) - /** @internal */ export const transform = dual< ( @@ -249,11 +216,13 @@ export const transform = dual< request: ClientRequest.HttpClientRequest ) => Effect.Effect ) => Client.HttpClient ->(2, (self, f) => - make( - Effect.flatMap((request) => f(self.execute(Effect.succeed(request)), request)), - self.preprocess - )) +>(2, (self, f) => { + const client = self as HttpClientImpl + return make( + Effect.flatMap((request) => f(client.postprocess(Effect.succeed(request)), request)), + client.preprocess + ) +}) /** @internal */ export const filterStatus = dual< @@ -297,12 +266,6 @@ export const filterStatusOk = ( }) )) -/** @internal */ -export const fetchOk: Client.HttpClient.Default = filterStatusOk(fetch) - -/** @internal */ -export const layer = Layer.succeed(tag, fetch) - /** @internal */ export const transformResponse = dual< ( @@ -312,7 +275,10 @@ export const transformResponse = dual< self: Client.HttpClient, f: (effect: Effect.Effect) => Effect.Effect ) => Client.HttpClient ->(2, (self, f) => make((request) => f(self.execute(request)), self.preprocess)) +>(2, (self, f) => { + const client = self as HttpClientImpl + return make((request) => f(client.postprocess(request)), client.preprocess) +}) /** @internal */ export const catchTag: { @@ -584,7 +550,10 @@ export const mapRequest = dual< self: Client.HttpClient, f: (a: ClientRequest.HttpClientRequest) => ClientRequest.HttpClientRequest ) => Client.HttpClient ->(2, (self, f) => make(self.execute, (request) => Effect.map(self.preprocess(request), f))) +>(2, (self, f) => { + const client = self as HttpClientImpl + return make(client.postprocess, (request) => Effect.map(client.preprocess(request), f)) +}) /** @internal */ export const mapRequestEffect = dual< @@ -601,10 +570,13 @@ export const mapRequestEffect = dual< a: ClientRequest.HttpClientRequest ) => Effect.Effect ) => Client.HttpClient ->(2, (self, f) => make(self.execute as any, (request) => Effect.flatMap(self.preprocess(request), f))) +>(2, (self, f) => { + const client = self as HttpClientImpl + return make(client.postprocess as any, (request) => Effect.flatMap(client.preprocess(request), f)) +}) /** @internal */ -export const mapInputRequest = dual< +export const mapRequestInput = dual< ( f: (a: ClientRequest.HttpClientRequest) => ClientRequest.HttpClientRequest ) => (self: Client.HttpClient) => Client.HttpClient, @@ -612,10 +584,13 @@ export const mapInputRequest = dual< self: Client.HttpClient, f: (a: ClientRequest.HttpClientRequest) => ClientRequest.HttpClientRequest ) => Client.HttpClient ->(2, (self, f) => make(self.execute, (request) => self.preprocess(f(request)))) +>(2, (self, f) => { + const client = self as HttpClientImpl + return make(client.postprocess, (request) => client.preprocess(f(request))) +}) /** @internal */ -export const mapInputRequestEffect = dual< +export const mapRequestInputEffect = dual< ( f: ( a: ClientRequest.HttpClientRequest @@ -629,17 +604,29 @@ export const mapInputRequestEffect = dual< a: ClientRequest.HttpClientRequest ) => Effect.Effect ) => Client.HttpClient ->(2, (self, f) => make(self.execute as any, (request) => Effect.flatMap(f(request), self.preprocess))) +>(2, (self, f) => { + const client = self as HttpClientImpl + return make(client.postprocess as any, (request) => Effect.flatMap(f(request), client.preprocess)) +}) /** @internal */ export const retry: { - ( - policy: Schedule.Schedule + >( + options: O + ): ( + self: Client.HttpClient + ) => Client.Retry.Return + ( + policy: Schedule.Schedule, R1> ): (self: Client.HttpClient) => Client.HttpClient - ( + >( self: Client.HttpClient, - policy: Schedule.Schedule - ): Client.HttpClient + options: O + ): Client.Retry.Return + ( + self: Client.HttpClient, + policy: Schedule.Schedule + ): Client.HttpClient } = dual( 2, ( @@ -683,7 +670,7 @@ export const schemaFunction = dual< }) }), (body) => - self( + self.execute( internalRequest.setBody( request, internalBody.uint8Array(body, "application/json") @@ -714,7 +701,10 @@ export const tapRequest = dual< self: Client.HttpClient, f: (a: ClientRequest.HttpClientRequest) => Effect.Effect<_, E2, R2> ) => Client.HttpClient ->(2, (self, f) => make(self.execute as any, (request) => Effect.tap(self.preprocess(request), f))) +>(2, (self, f) => { + const client = self as HttpClientImpl + return make(client.postprocess as any, (request) => Effect.tap(client.preprocess(request), f)) +}) /** @internal */ export const withCookiesRef = dual< @@ -730,15 +720,16 @@ export const withCookiesRef = dual< ( self: Client.HttpClient.WithResponse, ref: Ref.Ref - ): Client.HttpClient.WithResponse => - make( + ): Client.HttpClient.WithResponse => { + const client = self as HttpClientImpl + return make( (request: Effect.Effect) => Effect.tap( - self.execute(request), + client.postprocess(request), (response) => Ref.update(ref, (cookies) => Cookies.merge(cookies, response.cookies)) ), (request) => - Effect.flatMap(self.preprocess(request), (request) => + Effect.flatMap(client.preprocess(request), (request) => Effect.map( Ref.get(ref), (cookies) => @@ -747,6 +738,7 @@ export const withCookiesRef = dual< : internalRequest.setHeader(request, "cookie", Cookies.toCookieHeader(cookies)) )) ) + } ) /** @internal */ @@ -761,15 +753,16 @@ export const followRedirects = dual< >((args) => isClient(args[0]), ( self: Client.HttpClient.WithResponse, maxRedirects?: number | undefined -): Client.HttpClient.WithResponse => - make( +): Client.HttpClient.WithResponse => { + const client = self as HttpClientImpl + return make( (request) => { const loop = ( request: ClientRequest.HttpClientRequest, redirects: number ): Effect.Effect => Effect.flatMap( - self.execute(Effect.succeed(request)), + client.postprocess(Effect.succeed(request)), (response) => response.status >= 300 && response.status < 400 && response.headers.location && redirects < (maxRedirects ?? 10) @@ -784,5 +777,18 @@ export const followRedirects = dual< ) return Effect.flatMap(request, (request) => loop(request, 0)) }, - self.preprocess - )) + client.preprocess + ) +}) + +/** @internal */ +export const layerMergedContext = (effect: Effect.Effect) => + Layer.effect( + tag, + Effect.flatMap(Effect.context(), (context) => + Effect.map(effect, (client) => + transformResponse( + client, + Effect.mapInputContext((input: Context.Context) => Context.merge(context, input)) + ))) + ) diff --git a/packages/platform/src/internal/httpClientRequest.ts b/packages/platform/src/internal/httpClientRequest.ts index 70b2b493d35..9ed4683f558 100644 --- a/packages/platform/src/internal/httpClientRequest.ts +++ b/packages/platform/src/internal/httpClientRequest.ts @@ -21,14 +21,14 @@ import * as internalBody from "./httpBody.js" export const TypeId: ClientRequest.TypeId = Symbol.for("@effect/platform/HttpClientRequest") as ClientRequest.TypeId /** @internal */ -export const clientTag = Context.GenericTag("@effect/platform/HttpClient") +export const clientTag = Context.GenericTag("@effect/platform/HttpClient") const Proto = { [TypeId]: TypeId, ...Effectable.CommitPrototype, ...Inspectable.BaseProto, commit(this: ClientRequest.HttpClientRequest) { - return Effect.flatMap(clientTag, (client) => client(this)) + return Effect.flatMap(clientTag, (client) => client.execute(this)) }, toJSON(this: ClientRequest.HttpClientRequest): unknown { return { @@ -393,7 +393,7 @@ export const setBody = dual< }) /** @internal */ -export const uint8ArrayBody = dual< +export const bodyUint8Array = dual< ( body: Uint8Array, contentType?: string @@ -405,7 +405,7 @@ export const uint8ArrayBody = dual< ) /** @internal */ -export const textBody = dual< +export const bodyText = dual< (body: string, contentType?: string) => (self: ClientRequest.HttpClientRequest) => ClientRequest.HttpClientRequest, (self: ClientRequest.HttpClientRequest, body: string, contentType?: string) => ClientRequest.HttpClientRequest >( @@ -414,7 +414,7 @@ export const textBody = dual< ) /** @internal */ -export const jsonBody = dual< +export const bodyJson = dual< ( body: unknown ) => (self: ClientRequest.HttpClientRequest) => Effect.Effect, @@ -425,13 +425,13 @@ export const jsonBody = dual< >(2, (self, body) => Effect.map(internalBody.json(body), (body) => setBody(self, body))) /** @internal */ -export const unsafeJsonBody = dual< +export const bodyUnsafeJson = dual< (body: unknown) => (self: ClientRequest.HttpClientRequest) => ClientRequest.HttpClientRequest, (self: ClientRequest.HttpClientRequest, body: unknown) => ClientRequest.HttpClientRequest >(2, (self, body) => setBody(self, internalBody.unsafeJson(body))) /** @internal */ -export const fileBody = dual< +export const bodyFile = dual< ( path: string, options?: FileSystem.StreamOptions & { readonly contentType?: string } @@ -449,13 +449,13 @@ export const fileBody = dual< ) /** @internal */ -export const fileWebBody = dual< +export const bodyFileWeb = dual< (file: Body.HttpBody.FileLike) => (self: ClientRequest.HttpClientRequest) => ClientRequest.HttpClientRequest, (self: ClientRequest.HttpClientRequest, file: Body.HttpBody.FileLike) => ClientRequest.HttpClientRequest >(2, (self, file) => setBody(self, internalBody.fileWeb(file))) /** @internal */ -export const schemaBody = (schema: Schema.Schema, options?: ParseOptions | undefined): { +export const schemaBodyJson = (schema: Schema.Schema, options?: ParseOptions | undefined): { ( body: A ): (self: ClientRequest.HttpClientRequest) => Effect.Effect @@ -479,7 +479,7 @@ export const schemaBody = (schema: Schema.Schema, options?: Pa } /** @internal */ -export const urlParamsBody = dual< +export const bodyUrlParams = dual< (input: UrlParams.Input) => (self: ClientRequest.HttpClientRequest) => ClientRequest.HttpClientRequest, (self: ClientRequest.HttpClientRequest, input: UrlParams.Input) => ClientRequest.HttpClientRequest >(2, (self, body) => @@ -492,13 +492,13 @@ export const urlParamsBody = dual< )) /** @internal */ -export const formDataBody = dual< +export const bodyFormData = dual< (body: FormData) => (self: ClientRequest.HttpClientRequest) => ClientRequest.HttpClientRequest, (self: ClientRequest.HttpClientRequest, body: FormData) => ClientRequest.HttpClientRequest >(2, (self, body) => setBody(self, internalBody.formData(body))) /** @internal */ -export const streamBody = dual< +export const bodyStream = dual< ( body: Stream.Stream, options?: { diff --git a/packages/platform/src/internal/httpClientResponse.ts b/packages/platform/src/internal/httpClientResponse.ts index d5ed7f31f8b..e71c39f1596 100644 --- a/packages/platform/src/internal/httpClientResponse.ts +++ b/packages/platform/src/internal/httpClientResponse.ts @@ -5,8 +5,8 @@ import * as Effect from "effect/Effect" import { dual } from "effect/Function" import * as Inspectable from "effect/Inspectable" import * as Option from "effect/Option" -import type { Scope } from "effect/Scope" import * as Stream from "effect/Stream" +import type { Unify } from "effect/Unify" import * as Cookies from "../Cookies.js" import * as Headers from "../Headers.js" import * as Error from "../HttpClientError.js" @@ -194,63 +194,10 @@ export const schemaNoBody = < }) } -/** @internal */ -export const arrayBuffer = (effect: Effect.Effect) => - Effect.scoped(Effect.flatMap(effect, (_) => _.arrayBuffer)) - -/** @internal */ -export const text = (effect: Effect.Effect) => - Effect.scoped(Effect.flatMap(effect, (_) => _.text)) - -/** @internal */ -export const json = (effect: Effect.Effect) => - Effect.scoped(Effect.flatMap(effect, (_) => _.json)) - -/** @internal */ -export const urlParamsBody = (effect: Effect.Effect) => - Effect.scoped(Effect.flatMap(effect, (_) => _.urlParamsBody)) - -/** @internal */ -export const formData = (effect: Effect.Effect) => - Effect.scoped(Effect.flatMap(effect, (_) => _.formData)) - -/** @internal */ -export const void_ = (effect: Effect.Effect) => - Effect.scoped(Effect.asVoid(effect)) - /** @internal */ export const stream = (effect: Effect.Effect) => Stream.unwrapScoped(Effect.map(effect, (_) => _.stream)) -/** @internal */ -export const schemaJsonScoped = < - R, - I extends { - readonly status?: number | undefined - readonly headers?: Readonly> | undefined - readonly body?: unknown | undefined - }, - A ->(schema: Schema.Schema, options?: ParseOptions | undefined) => { - const decode = schemaJson(schema, options) - return (effect: Effect.Effect) => - Effect.scoped(Effect.flatMap(effect, decode)) -} - -/** @internal */ -export const schemaNoBodyScoped = < - R, - I extends { - readonly status?: number | undefined - readonly headers?: Readonly> | undefined - }, - A ->(schema: Schema.Schema, options?: ParseOptions | undefined) => { - const decode = schemaNoBody(schema, options) - return (effect: Effect.Effect) => - Effect.scoped(Effect.flatMap(effect, decode)) -} - /** @internal */ export const matchStatus = dual< < @@ -264,7 +211,7 @@ export const matchStatus = dual< } >( cases: Cases - ) => (self: ClientResponse.HttpClientResponse) => Cases[keyof Cases] extends (_: any) => infer R ? R : never, + ) => (self: ClientResponse.HttpClientResponse) => Cases[keyof Cases] extends (_: any) => infer R ? Unify : never, < const Cases extends { readonly [status: number]: (_: ClientResponse.HttpClientResponse) => any @@ -274,7 +221,10 @@ export const matchStatus = dual< readonly "5xx"?: (_: ClientResponse.HttpClientResponse) => any readonly orElse: (_: ClientResponse.HttpClientResponse) => any } - >(self: ClientResponse.HttpClientResponse, cases: Cases) => Cases[keyof Cases] extends (_: any) => infer R ? R : never + >( + self: ClientResponse.HttpClientResponse, + cases: Cases + ) => Cases[keyof Cases] extends (_: any) => infer R ? Unify : never >(2, (self, cases) => { const status = self.status if (cases[status]) { @@ -290,43 +240,3 @@ export const matchStatus = dual< } return cases.orElse(self) }) - -/** @internal */ -export const matchStatusScoped = dual< - < - const Cases extends { - readonly [status: number]: (_: ClientResponse.HttpClientResponse) => Effect.Effect - readonly "2xx"?: (_: ClientResponse.HttpClientResponse) => Effect.Effect - readonly "3xx"?: (_: ClientResponse.HttpClientResponse) => Effect.Effect - readonly "4xx"?: (_: ClientResponse.HttpClientResponse) => Effect.Effect - readonly "5xx"?: (_: ClientResponse.HttpClientResponse) => Effect.Effect - readonly orElse: (_: ClientResponse.HttpClientResponse) => Effect.Effect - } - >(cases: Cases) => (self: Effect.Effect) => Effect.Effect< - Cases[keyof Cases] extends (_: any) => Effect.Effect ? _A : never, - E | (Cases[keyof Cases] extends (_: any) => Effect.Effect ? _E : never), - Exclude< - R | (Cases[keyof Cases] extends (_: any) => Effect.Effect ? _R : never), - Scope - > - >, - < - E, - R, - const Cases extends { - readonly [status: number]: (_: ClientResponse.HttpClientResponse) => Effect.Effect - readonly "2xx"?: (_: ClientResponse.HttpClientResponse) => Effect.Effect - readonly "3xx"?: (_: ClientResponse.HttpClientResponse) => Effect.Effect - readonly "4xx"?: (_: ClientResponse.HttpClientResponse) => Effect.Effect - readonly "5xx"?: (_: ClientResponse.HttpClientResponse) => Effect.Effect - readonly orElse: (_: ClientResponse.HttpClientResponse) => Effect.Effect - } - >(self: Effect.Effect, cases: Cases) => Effect.Effect< - Cases[keyof Cases] extends (_: any) => Effect.Effect ? _A : never, - E | (Cases[keyof Cases] extends (_: any) => Effect.Effect ? _E : never), - Exclude< - R | (Cases[keyof Cases] extends (_: any) => Effect.Effect ? _R : never), - Scope - > - > ->(2, (self, cases) => Effect.scoped(Effect.flatMap(self, matchStatus(cases) as any))) diff --git a/packages/platform/test/HttpClient.test.ts b/packages/platform/test/HttpClient.test.ts index 8dc0f03a9f8..7ba2484ff2c 100644 --- a/packages/platform/test/HttpClient.test.ts +++ b/packages/platform/test/HttpClient.test.ts @@ -1,4 +1,11 @@ -import { Cookies, HttpClient, HttpClientRequest, HttpClientResponse, UrlParams } from "@effect/platform" +import { + Cookies, + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, + UrlParams +} from "@effect/platform" import * as Schema from "@effect/schema/Schema" import { assert, describe, expect, it } from "@effect/vitest" import { Either, Ref } from "effect" @@ -25,7 +32,8 @@ const makeJsonPlaceholder = Effect.gen(function*(_) { HttpClient.mapRequest(HttpClientRequest.prependUrl("https://jsonplaceholder.typicode.com")) ) const todoClient = client.pipe( - HttpClient.mapEffectScoped(HttpClientResponse.schemaBodyJson(Todo)) + HttpClient.mapEffect(HttpClientResponse.schemaBodyJson(Todo)), + HttpClient.scoped ) const createTodo = HttpClient.schemaFunction( todoClient, @@ -40,27 +48,28 @@ const makeJsonPlaceholder = Effect.gen(function*(_) { interface JsonPlaceholder extends Effect.Effect.Success {} const JsonPlaceholder = Context.GenericTag("test/JsonPlaceholder") const JsonPlaceholderLive = Layer.effect(JsonPlaceholder, makeJsonPlaceholder) - .pipe(Layer.provide(HttpClient.layer)) + .pipe(Layer.provide(FetchHttpClient.layer)) describe("HttpClient", () => { it("google", () => Effect.gen(function*(_) { const response = yield* _( HttpClientRequest.get("https://www.google.com/"), - HttpClient.fetchOk, Effect.flatMap((_) => _.text), Effect.scoped ) expect(response).toContain("Google") - }).pipe(Effect.runPromise)) + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.runPromise)) it("google withCookiesRef", () => Effect.gen(function*(_) { const ref = yield* _(Ref.make(Cookies.empty)) - const client = HttpClient.withCookiesRef(HttpClient.fetchOk, ref) + const client = (yield* HttpClient.HttpClient).pipe( + HttpClient.withCookiesRef(ref) + ) yield* _( HttpClientRequest.get("https://www.google.com/"), - client, + client.execute, Effect.scoped ) const cookieHeader = yield* _(Ref.get(ref), Effect.map(Cookies.toCookieHeader)) @@ -72,27 +81,26 @@ describe("HttpClient", () => { assert.strictEqual(req.headers.cookie, cookieHeader) }) ) - ), + ).execute, Effect.scoped ) - }).pipe(Effect.runPromise)) + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.runPromise)) it("google stream", () => Effect.gen(function*(_) { const response = yield* _( HttpClientRequest.get(new URL("https://www.google.com/")), - HttpClient.fetchOk, Effect.map((_) => _.stream), Stream.unwrapScoped, Stream.runFold("", (a, b) => a + new TextDecoder().decode(b)) ) expect(response).toContain("Google") - }).pipe(Effect.runPromise)) + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.runPromise)) it("jsonplaceholder", () => - Effect.gen(function*(_) { - const jp = yield* _(JsonPlaceholder) - const response = yield* _(HttpClientRequest.get("/todos/1"), jp.todoClient) + Effect.gen(function*() { + const jp = yield* JsonPlaceholder + const response = yield* jp.todoClient.get("/todos/1") expect(response.id).toBe(1) }).pipe(Effect.provide(JsonPlaceholderLive), Effect.runPromise)) @@ -110,30 +118,32 @@ describe("HttpClient", () => { it("jsonplaceholder schemaJson", () => Effect.gen(function*(_) { const jp = yield* _(JsonPlaceholder) - const client = HttpClient.mapEffectScoped(jp.client, HttpClientResponse.schemaJson(OkTodo)).pipe( + const client = HttpClient.mapEffect(jp.client, HttpClientResponse.schemaJson(OkTodo)).pipe( + HttpClient.scoped, HttpClient.map((_) => _.body) ) - const response = yield* _(HttpClientRequest.get("/todos/1"), client) + const response = yield* client.get("/todos/1") expect(response.id).toBe(1) }).pipe(Effect.provide(JsonPlaceholderLive), Effect.runPromise)) it("request processing order", () => - Effect.gen(function*(_) { - const defaultClient = yield* _(HttpClient.HttpClient) + Effect.gen(function*() { + const defaultClient = yield* HttpClient.HttpClient const client = defaultClient.pipe( HttpClient.mapRequest(HttpClientRequest.prependUrl("jsonplaceholder.typicode.com")), HttpClient.mapRequest(HttpClientRequest.prependUrl("https://")) ) const todoClient = client.pipe( - HttpClient.mapEffectScoped(HttpClientResponse.schemaBodyJson(Todo)) + HttpClient.mapEffect(HttpClientResponse.schemaBodyJson(Todo)), + HttpClient.scoped ) - const response = yield* _(HttpClientRequest.get("/todos/1"), todoClient) + const response = yield* todoClient.get("/todos/1") expect(response.id).toBe(1) - }).pipe(Effect.provide(HttpClient.layer), Effect.runPromise)) + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.runPromise)) it("streamBody accesses the current runtime", () => - Effect.gen(function*(_) { - const defaultClient = yield* _(HttpClient.HttpClient) + Effect.gen(function*() { + const defaultClient = yield* HttpClient.HttpClient const requestStream = Stream.fromIterable(["hello", "world"]).pipe( Stream.tap((_) => Effect.log(_)), @@ -144,14 +154,14 @@ describe("HttpClient", () => { const logger = Logger.make(({ message }) => logs.push(message)) yield* HttpClientRequest.post("https://jsonplaceholder.typicode.com").pipe( - HttpClientRequest.streamBody(requestStream), - defaultClient, + HttpClientRequest.bodyStream(requestStream), + defaultClient.execute, Effect.provide(Logger.replace(Logger.defaultLogger, logger)), Effect.scoped ) expect(logs).toEqual([["hello"], ["world"]]) - }).pipe(Effect.provide(HttpClient.layer), Effect.runPromise)) + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.runPromise)) it("ClientRequest parses URL instances", () => { const request = HttpClientRequest.get(new URL("https://example.com/?foo=bar#hash")).pipe( @@ -164,16 +174,18 @@ describe("HttpClient", () => { ) }) - it.effect("matchStatusScoped", () => + it.effect("matchStatus", () => Effect.gen(function*() { const jp = yield* JsonPlaceholder - const response = yield* HttpClientRequest.get("/todos/1").pipe( - jp.client, - HttpClientResponse.matchStatusScoped({ - "2xx": HttpClientResponse.schemaBodyJson(Todo), - 404: () => Effect.fail("not found"), - orElse: () => Effect.fail("boom") - }) + const response = yield* jp.client.get("/todos/1").pipe( + Effect.flatMap( + HttpClientResponse.matchStatus({ + "2xx": HttpClientResponse.schemaBodyJson(Todo), + 404: () => Effect.fail("not found"), + orElse: () => Effect.fail("boom") + }) + ), + Effect.scoped ) assert.deepStrictEqual(response, { id: 1, userId: 1, title: "delectus aut autem", completed: false }) }).pipe(Effect.provide(JsonPlaceholderLive))) diff --git a/packages/rpc-http/examples/client.ts b/packages/rpc-http/examples/client.ts index f54966b862b..f69b8d9901c 100644 --- a/packages/rpc-http/examples/client.ts +++ b/packages/rpc-http/examples/client.ts @@ -1,4 +1,4 @@ -import { HttpClient, HttpClientRequest } from "@effect/platform" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform" import { RpcResolver } from "@effect/rpc" import { HttpRpcResolver } from "@effect/rpc-http" import { Console, Effect, Stream } from "effect" @@ -6,16 +6,24 @@ import type { UserRouter } from "./router.js" import { GetUser, GetUserIds } from "./schema.js" // Create the client -const client = HttpRpcResolver.make( - HttpClient.fetchOk.pipe( - HttpClient.mapRequest(HttpClientRequest.prependUrl("http://localhost:3000/rpc")) - ) -).pipe(RpcResolver.toClient) +const makeClient = Effect.gen(function*() { + const client = yield* HttpClient.HttpClient + return HttpRpcResolver.make( + client.pipe( + HttpClient.mapRequest(HttpClientRequest.prependUrl("http://localhost:3000/rpc")) + ) + ).pipe(RpcResolver.toClient) +}) // Use the client -client(new GetUserIds()).pipe( - Stream.runCollect, - Effect.flatMap(Effect.forEach((id) => client(new GetUser({ id })), { batching: true })), - Effect.tap(Console.log), +Effect.gen(function*() { + const client = yield* makeClient + yield* client(new GetUserIds()).pipe( + Stream.runCollect, + Effect.flatMap(Effect.forEach((id) => client(new GetUser({ id })), { batching: true })), + Effect.tap(Console.log) + ) +}).pipe( + Effect.provide(FetchHttpClient.layer), Effect.runFork ) diff --git a/packages/rpc-http/src/HttpRpcResolver.ts b/packages/rpc-http/src/HttpRpcResolver.ts index 6d00d43b6ba..76caed75b88 100644 --- a/packages/rpc-http/src/HttpRpcResolver.ts +++ b/packages/rpc-http/src/HttpRpcResolver.ts @@ -19,15 +19,15 @@ import * as Stream from "effect/Stream" * @since 1.0.0 */ export const make = >( - client: Client.HttpClient.Default + client: Client.HttpClient.Service ): RequestResolver.RequestResolver< Rpc.Request>, Serializable.SerializableWithResult.Context> > => Resolver.make((requests) => - client(ClientRequest.post("", { + client.post("", { body: Body.unsafeJson(requests) - })).pipe( + }).pipe( Effect.map((_) => _.stream.pipe( Stream.decodeText(), @@ -46,19 +46,22 @@ export const make = >( */ export const makeClient = >( baseUrl: string -): Serializable.SerializableWithResult.Context> extends never ? Resolver.Client< - RequestResolver.RequestResolver< - Rpc.Request> - > - > : - "HttpResolver.makeClient: request context is not `never`" => - Resolver.toClient(make( - Client.fetchOk.pipe( +): Serializable.SerializableWithResult.Context> extends never ? Effect.Effect< + Resolver.Client< + RequestResolver.RequestResolver< + Rpc.Request> + > + >, + never, + Client.HttpClient + > + : "request context is not `never`" => + Effect.map(Client.HttpClient, (client) => + Resolver.toClient(make(client.pipe( Client.mapRequest(ClientRequest.prependUrl(baseUrl)), Client.retry( Schedule.exponential(50).pipe( Schedule.intersect(Schedule.recurs(5)) ) ) - ) - ) as any) as any + )) as any)) as any diff --git a/packages/rpc-http/src/HttpRpcResolverNoStream.ts b/packages/rpc-http/src/HttpRpcResolverNoStream.ts index 4668adce195..746b8a62435 100644 --- a/packages/rpc-http/src/HttpRpcResolverNoStream.ts +++ b/packages/rpc-http/src/HttpRpcResolverNoStream.ts @@ -18,15 +18,15 @@ import * as Schedule from "effect/Schedule" * @since 1.0.0 */ export const make = >( - client: Client.HttpClient.Default + client: Client.HttpClient.Service ): RequestResolver.RequestResolver< Rpc.Request>, Serializable.SerializableWithResult.Context> > => ResolverNoStream.make((requests) => - client(ClientRequest.post("", { + client.post("", { body: Body.unsafeJson(requests) - })).pipe( + }).pipe( Effect.flatMap((_) => _.json), Effect.scoped ) @@ -38,19 +38,22 @@ export const make = >( */ export const makeClient = >( baseUrl: string -): Serializable.SerializableWithResult.Context> extends never ? Resolver.Client< - RequestResolver.RequestResolver< - Rpc.Request> - > +): Serializable.SerializableWithResult.Context> extends never ? Effect.Effect< + Resolver.Client< + RequestResolver.RequestResolver< + Rpc.Request> + > + >, + never, + Client.HttpClient > - : "HttpResolver.makeClientEffect: request context is not `never`" => - Resolver.toClient(make( - Client.fetchOk.pipe( + : "request context is not `never`" => + Effect.map(Client.HttpClient, (client) => + Resolver.toClient(make(client.pipe( Client.mapRequest(ClientRequest.prependUrl(baseUrl)), Client.retry( Schedule.exponential(50).pipe( Schedule.intersect(Schedule.recurs(5)) ) ) - ) - ) as any) as any + )) as any)) as any