diff --git a/.changeset/old-doors-flash.md b/.changeset/old-doors-flash.md new file mode 100644 index 00000000000..f8302740041 --- /dev/null +++ b/.changeset/old-doors-flash.md @@ -0,0 +1,5 @@ +--- +"@effect/platform": minor +--- + +parse URL instances when creating client requests diff --git a/packages/platform-node/src/internal/http/clientUndici.ts b/packages/platform-node/src/internal/http/clientUndici.ts index 10ed66a78c3..e4c5bd56f2b 100644 --- a/packages/platform-node/src/internal/http/clientUndici.ts +++ b/packages/platform-node/src/internal/http/clientUndici.ts @@ -68,7 +68,7 @@ export const make = (dispatcher: Undici.Dispatcher): Client.Client.Default => method: request.method, headers: request.headers, origin: url.origin, - path: url.pathname + url.search, + path: url.pathname + url.search + url.hash, body, // leave timeouts to Effect.timeout etc headersTimeout: 60 * 60 * 1000, diff --git a/packages/platform/src/Http/ClientRequest.ts b/packages/platform/src/Http/ClientRequest.ts index b447e8dd30d..dd1e4022bbe 100644 --- a/packages/platform/src/Http/ClientRequest.ts +++ b/packages/platform/src/Http/ClientRequest.ts @@ -5,6 +5,7 @@ import type { ParseOptions } from "@effect/schema/AST" import type * as Schema from "@effect/schema/Schema" import type * as Effect from "effect/Effect" import type { Inspectable } from "effect/Inspectable" +import type * as Option from "effect/Option" import type { Scope } from "effect/Scope" import type * as Stream from "effect/Stream" import type * as PlatformError from "../Error.js" @@ -41,6 +42,7 @@ export interface ClientRequest readonly method: Method readonly url: string readonly urlParams: UrlParams.UrlParams + readonly hash: Option.Option readonly headers: Headers.Headers readonly body: Body.Body } @@ -51,8 +53,9 @@ export interface ClientRequest */ export interface Options { readonly method?: Method | undefined - readonly url?: string | undefined + readonly url?: string | URL | undefined readonly urlParams?: UrlParams.Input | undefined + readonly hash?: string | undefined readonly headers?: Headers.Input | undefined readonly body?: Body.Body | undefined readonly accept?: string | undefined @@ -204,7 +207,7 @@ export const acceptJson: (self: ClientRequest) => ClientRequest = internal.accep */ export const setUrl: { (url: string | URL): (self: ClientRequest) => ClientRequest - (self: ClientRequest, url: string): ClientRequest + (self: ClientRequest, url: string | URL): ClientRequest } = internal.setUrl /** @@ -212,7 +215,7 @@ export const setUrl: { * @category combinators */ export const prependUrl: { - (path: string | URL): (self: ClientRequest) => ClientRequest + (path: string): (self: ClientRequest) => ClientRequest (self: ClientRequest, path: string): ClientRequest } = internal.prependUrl @@ -238,25 +241,52 @@ export const updateUrl: { * @since 1.0.0 * @category combinators */ -export const setUrlParam = internal.setUrlParam +export const setUrlParam: { + (key: string, value: string): (self: ClientRequest) => ClientRequest + (self: ClientRequest, key: string, value: string): ClientRequest +} = internal.setUrlParam + +/** + * @since 1.0.0 + * @category combinators + */ +export const setUrlParams: { + (input: UrlParams.Input): (self: ClientRequest) => ClientRequest + (self: ClientRequest, input: UrlParams.Input): ClientRequest +} = internal.setUrlParams /** * @since 1.0.0 * @category combinators */ -export const setUrlParams = internal.setUrlParams +export const appendUrlParam: { + (key: string, value: string): (self: ClientRequest) => ClientRequest + (self: ClientRequest, key: string, value: string): ClientRequest +} = internal.appendUrlParam + +/** + * @since 1.0.0 + * @category combinators + */ +export const appendUrlParams: { + (input: UrlParams.Input): (self: ClientRequest) => ClientRequest + (self: ClientRequest, input: UrlParams.Input): ClientRequest +} = internal.appendUrlParams /** * @since 1.0.0 * @category combinators */ -export const appendUrlParam = internal.appendUrlParam +export const setHash: { + (hash: string): (self: ClientRequest) => ClientRequest + (self: ClientRequest, hash: string): ClientRequest +} = internal.setHash /** * @since 1.0.0 * @category combinators */ -export const appendUrlParams = internal.appendUrlParams +export const removeHash: (self: ClientRequest) => ClientRequest = internal.removeHash /** * @since 1.0.0 diff --git a/packages/platform/src/Http/UrlParams.ts b/packages/platform/src/Http/UrlParams.ts index 46d25a2f49f..fedc57d5c92 100644 --- a/packages/platform/src/Http/UrlParams.ts +++ b/packages/platform/src/Http/UrlParams.ts @@ -207,7 +207,7 @@ export const toString = (self: UrlParams): string => new URLSearchParams(self as * @since 1.0.0 * @category constructors */ -export const makeUrl = (url: string, params: UrlParams): Either.Either => { +export const makeUrl = (url: string, params: UrlParams, hash: Option.Option): Either.Either => { try { const urlInstance = new URL(url, baseUrl()) for (let i = 0; i < params.length; i++) { @@ -216,7 +216,9 @@ export const makeUrl = (url: string, params: UrlParams): Either.Either controller.abort())) - const urlResult = UrlParams.makeUrl(request.url, request.urlParams) + const urlResult = UrlParams.makeUrl(request.url, request.urlParams, request.hash) if (urlResult._tag === "Left") { return Effect.fail(new Error.RequestError({ request, reason: "InvalidUrl", error: urlResult.left })) } diff --git a/packages/platform/src/internal/http/clientRequest.ts b/packages/platform/src/internal/http/clientRequest.ts index 501561160f3..f9ff4529d45 100644 --- a/packages/platform/src/internal/http/clientRequest.ts +++ b/packages/platform/src/internal/http/clientRequest.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect" import * as Effectable from "effect/Effectable" import { dual } from "effect/Function" import * as Inspectable from "effect/Inspectable" +import * as Option from "effect/Option" import type * as Stream from "effect/Stream" import type * as PlatformError from "../../Error.js" import type * as FileSystem from "../../FileSystem.js" @@ -35,6 +36,7 @@ const Proto = { method: this.method, url: this.url, urlParams: this.urlParams, + hash: this.hash, headers: this.headers, body: this.body.toJSON() } @@ -45,6 +47,7 @@ function makeInternal( method: Method, url: string, urlParams: UrlParams.UrlParams, + hash: Option.Option, headers: Headers.Headers, body: Body.Body ): ClientRequest.ClientRequest { @@ -52,6 +55,7 @@ function makeInternal( self.method = method self.url = url self.urlParams = urlParams + self.hash = hash self.headers = headers self.body = body return self @@ -66,6 +70,7 @@ export const empty: ClientRequest.ClientRequest = makeInternal( "GET", "", UrlParams.empty, + Option.none(), Headers.empty, internalBody.empty ) @@ -78,7 +83,7 @@ export const make = (method: M) => ) => modify(empty, { method, - url: url.toString(), + url, ...(options ?? undefined) }) @@ -122,6 +127,9 @@ export const modify = dual< if (options.urlParams) { result = setUrlParams(result, options.urlParams) } + if (options.hash) { + result = setHash(result, options.hash) + } if (options.body) { result = setBody(result, options.body) } @@ -144,6 +152,7 @@ export const setHeader = dual< self.method, self.url, self.urlParams, + self.hash, Headers.set(self.headers, key, value), self.body )) @@ -157,6 +166,7 @@ export const setHeaders = dual< self.method, self.url, self.urlParams, + self.hash, Headers.setAll(self.headers, input), self.body )) @@ -191,6 +201,7 @@ export const setMethod = dual< method, self.url, self.urlParams, + self.hash, self.headers, self.body )) @@ -198,15 +209,32 @@ export const setMethod = dual< /** @internal */ export const setUrl = dual< (url: string | URL) => (self: ClientRequest.ClientRequest) => ClientRequest.ClientRequest, - (self: ClientRequest.ClientRequest, url: string) => ClientRequest.ClientRequest ->(2, (self, url) => - makeInternal( + (self: ClientRequest.ClientRequest, url: string | URL) => ClientRequest.ClientRequest +>(2, (self, url) => { + if (typeof url === "string") { + return makeInternal( + self.method, + url, + self.urlParams, + self.hash, + self.headers, + self.body + ) + } + const clone = new URL(url.toString()) + const urlParams = UrlParams.fromInput(clone.searchParams) + const hash = clone.hash ? Option.some(clone.hash.slice(1)) : Option.none() + clone.search = "" + clone.hash = "" + return makeInternal( self.method, - url.toString(), - self.urlParams, + clone.toString(), + urlParams, + hash, self.headers, self.body - )) + ) +}) /** @internal */ export const appendUrl = dual< @@ -215,21 +243,27 @@ export const appendUrl = dual< >(2, (self, url) => makeInternal( self.method, - self.url + url, + self.url.endsWith("/") && url.startsWith("/") ? + self.url + url.slice(1) : + self.url + url, self.urlParams, + self.hash, self.headers, self.body )) /** @internal */ export const prependUrl = dual< - (path: string | URL) => (self: ClientRequest.ClientRequest) => ClientRequest.ClientRequest, + (path: string) => (self: ClientRequest.ClientRequest) => ClientRequest.ClientRequest, (self: ClientRequest.ClientRequest, path: string) => ClientRequest.ClientRequest >(2, (self, url) => makeInternal( self.method, - url.toString() + self.url, + url.endsWith("/") && self.url.startsWith("/") ? + url + self.url.slice(1) : + url + self.url, self.urlParams, + self.hash, self.headers, self.body )) @@ -243,6 +277,7 @@ export const updateUrl = dual< self.method, f(self.url), self.urlParams, + self.hash, self.headers, self.body )) @@ -256,6 +291,7 @@ export const appendUrlParam = dual< self.method, self.url, UrlParams.append(self.urlParams, key, value), + self.hash, self.headers, self.body )) @@ -269,6 +305,7 @@ export const appendUrlParams = dual< self.method, self.url, UrlParams.appendAll(self.urlParams, input), + self.hash, self.headers, self.body )) @@ -282,6 +319,7 @@ export const setUrlParam = dual< self.method, self.url, UrlParams.set(self.urlParams, key, value), + self.hash, self.headers, self.body )) @@ -295,10 +333,36 @@ export const setUrlParams = dual< self.method, self.url, UrlParams.setAll(self.urlParams, input), + self.hash, self.headers, self.body )) +/** @internal */ +export const setHash = dual< + (hash: string) => (self: ClientRequest.ClientRequest) => ClientRequest.ClientRequest, + (self: ClientRequest.ClientRequest, hash: string) => ClientRequest.ClientRequest +>(2, (self, hash) => + makeInternal( + self.method, + self.url, + self.urlParams, + Option.some(hash), + self.headers, + self.body + )) + +/** @internal */ +export const removeHash = (self: ClientRequest.ClientRequest): ClientRequest.ClientRequest => + makeInternal( + self.method, + self.url, + self.urlParams, + Option.none(), + self.headers, + self.body + ) + /** @internal */ export const setBody = dual< (body: Body.Body) => (self: ClientRequest.ClientRequest) => ClientRequest.ClientRequest, @@ -322,6 +386,7 @@ export const setBody = dual< self.method, self.url, self.urlParams, + self.hash, headers, body ) diff --git a/packages/platform/test/Http/UrlParams.test.ts b/packages/platform/test/Http/UrlParams.test.ts index 2504498d019..a1b9b18c02a 100644 --- a/packages/platform/test/Http/UrlParams.test.ts +++ b/packages/platform/test/Http/UrlParams.test.ts @@ -1,12 +1,12 @@ import * as UrlParams from "@effect/platform/Http/UrlParams" import { assert, describe, it } from "@effect/vitest" -import { Effect } from "effect" +import { Effect, Option } from "effect" describe("UrlParams", () => { describe("makeUrl", () => { it.effect("makes a URL", () => Effect.gen(function*(_) { - const url = yield* _(UrlParams.makeUrl("https://example.com/test", [])) + const url = yield* _(UrlParams.makeUrl("https://example.com/test", [], Option.none())) assert.strictEqual(url.toString(), "https://example.com/test") })) @@ -18,7 +18,7 @@ describe("UrlParams", () => { origin: "https://example.com", pathname: "/path/" } as Location - const url = yield* _(UrlParams.makeUrl("test", [])) + const url = yield* _(UrlParams.makeUrl("test", [], Option.none())) assert.strictEqual(url.toString(), "https://example.com/path/test") globalThis.location = originalLocation @@ -31,13 +31,13 @@ describe("UrlParams", () => { // `globalThis.location` is undefined // @ts-expect-error globalThis.location = undefined - let url = yield* _(UrlParams.makeUrl("https://example.com", [])) + let url = yield* _(UrlParams.makeUrl("https://example.com", [], Option.none())) assert.strictEqual(url.toString(), "https://example.com/") // `location` is not in globalThis // @ts-expect-error delete globalThis.location - url = yield* _(UrlParams.makeUrl("http://example.com", [])) + url = yield* _(UrlParams.makeUrl("http://example.com", [], Option.none())) assert.strictEqual(url.toString(), "http://example.com/") globalThis.location = originalLocation @@ -48,21 +48,21 @@ describe("UrlParams", () => { const originalLocation = globalThis.location globalThis.location = { href: "" } as Location - const url1 = yield* _(UrlParams.makeUrl("https://example.com", [])) + const url1 = yield* _(UrlParams.makeUrl("https://example.com", [], Option.none())) assert.strictEqual(url1.toString(), "https://example.com/") globalThis.location = { href: "", origin: "https://example.com" } as unknown as Location - const url2 = yield* _(UrlParams.makeUrl("https://example.com", [])) + const url2 = yield* _(UrlParams.makeUrl("https://example.com", [], Option.none())) assert.strictEqual(url2.toString(), "https://example.com/") globalThis.location = { href: "", pathname: "example_path" } as unknown as Location - const url3 = yield* _(UrlParams.makeUrl("https://example.com", [])) + const url3 = yield* _(UrlParams.makeUrl("https://example.com", [], Option.none())) assert.strictEqual(url3.toString(), "https://example.com/") globalThis.location = originalLocation diff --git a/packages/platform/test/HttpClient.test.ts b/packages/platform/test/HttpClient.test.ts index 15d02327d2a..489d87abf68 100644 --- a/packages/platform/test/HttpClient.test.ts +++ b/packages/platform/test/HttpClient.test.ts @@ -1,6 +1,6 @@ import * as Http from "@effect/platform/HttpClient" import * as Schema from "@effect/schema/Schema" -import { Ref } from "effect" +import { Either, Ref } from "effect" import * as Context from "effect/Context" import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" @@ -22,7 +22,7 @@ const OkTodo = Schema.Struct({ const makeJsonPlaceholder = Effect.gen(function*(_) { const defaultClient = yield* _(Http.client.Client) const client = defaultClient.pipe( - Http.client.mapRequest(Http.request.prependUrl(new URL("https://jsonplaceholder.typicode.com"))) + Http.client.mapRequest(Http.request.prependUrl("https://jsonplaceholder.typicode.com")) ) const todoClient = client.pipe( Http.client.mapEffectScoped(Http.response.schemaBodyJson(Todo)) @@ -152,4 +152,15 @@ describe("HttpClient", () => { expect(logs).toEqual(["hello", "world"]) }).pipe(Effect.provide(Http.client.layer), Effect.runPromise)) + + it("ClientRequest parses URL instances", () => { + const request = Http.request.get(new URL("https://example.com/?foo=bar#hash")).pipe( + Http.request.appendUrl("/foo"), + Http.request.setUrlParam("baz", "qux") + ) + assert.deepStrictEqual( + Http.urlParams.makeUrl(request.url, request.urlParams, request.hash), + Either.right(new URL("https://example.com/foo?foo=bar&baz=qux#hash")) + ) + }) })