diff --git a/.changeset/fix-append-url-path-joining.md b/.changeset/fix-append-url-path-joining.md new file mode 100644 index 00000000000..aaa66948cff --- /dev/null +++ b/.changeset/fix-append-url-path-joining.md @@ -0,0 +1,19 @@ +--- +"@effect/platform": patch +--- + +Fix `HttpClientRequest.appendUrl` to properly join URL paths. + +Previously, `appendUrl` used simple string concatenation which could produce invalid URLs: +```typescript +// Before (broken): +appendUrl("https://api.example.com/v1", "users") +// Result: "https://api.example.com/v1users" (missing slash!) +``` + +Now it ensures proper path joining: +```typescript +// After (fixed): +appendUrl("https://api.example.com/v1", "users") +// Result: "https://api.example.com/v1/users" +``` diff --git a/packages/platform/src/internal/httpClientRequest.ts b/packages/platform/src/internal/httpClientRequest.ts index 73d0132ce2a..14fb718c526 100644 --- a/packages/platform/src/internal/httpClientRequest.ts +++ b/packages/platform/src/internal/httpClientRequest.ts @@ -250,17 +250,21 @@ export const setUrl = dual< export const appendUrl = dual< (path: string) => (self: ClientRequest.HttpClientRequest) => ClientRequest.HttpClientRequest, (self: ClientRequest.HttpClientRequest, path: string) => ClientRequest.HttpClientRequest ->(2, (self, url) => - makeInternal( +>(2, (self, path) => { + if (path === "") { + return self + } + const baseUrl = self.url.endsWith("/") ? self.url : self.url + "/" + const pathSegment = path.startsWith("/") ? path.slice(1) : path + return makeInternal( self.method, - self.url.endsWith("/") && url.startsWith("/") ? - self.url + url.slice(1) : - self.url + url, + baseUrl + pathSegment, self.urlParams, self.hash, self.headers, self.body - )) + ) +}) /** @internal */ export const prependUrl = dual< diff --git a/packages/platform/test/HttpClient.test.ts b/packages/platform/test/HttpClient.test.ts index 711bd46782f..f46433ddf56 100644 --- a/packages/platform/test/HttpClient.test.ts +++ b/packages/platform/test/HttpClient.test.ts @@ -202,6 +202,50 @@ describe("HttpClient", () => { ) }) + describe("appendUrl", () => { + it("joins path without trailing slash on base", () => { + const request = HttpClientRequest.get("https://api.example.com/v1").pipe( + HttpClientRequest.appendUrl("users") + ) + strictEqual(request.url, "https://api.example.com/v1/users") + }) + + it("joins path with trailing slash on base", () => { + const request = HttpClientRequest.get("https://api.example.com/v1/").pipe( + HttpClientRequest.appendUrl("users") + ) + strictEqual(request.url, "https://api.example.com/v1/users") + }) + + it("joins path with leading slash", () => { + const request = HttpClientRequest.get("https://api.example.com/v1").pipe( + HttpClientRequest.appendUrl("/users") + ) + strictEqual(request.url, "https://api.example.com/v1/users") + }) + + it("joins path with both trailing and leading slashes", () => { + const request = HttpClientRequest.get("https://api.example.com/v1/").pipe( + HttpClientRequest.appendUrl("/users") + ) + strictEqual(request.url, "https://api.example.com/v1/users") + }) + + it("joins nested paths", () => { + const request = HttpClientRequest.get("https://api.example.com/v1").pipe( + HttpClientRequest.appendUrl("users/123/posts") + ) + strictEqual(request.url, "https://api.example.com/v1/users/123/posts") + }) + + it("handles empty path", () => { + const request = HttpClientRequest.get("https://api.example.com/v1").pipe( + HttpClientRequest.appendUrl("") + ) + strictEqual(request.url, "https://api.example.com/v1") + }) + }) + it.effect("matchStatus", () => Effect.gen(function*() { const jp = yield* JsonPlaceholder