diff --git a/src/Context.tsx b/src/Context.tsx index f254401f..623922d1 100644 --- a/src/Context.tsx +++ b/src/Context.tsx @@ -5,6 +5,11 @@ import { ResolveFunction } from "./Get"; export interface RestfulReactProviderProps { /** The backend URL where the RESTful resources live. */ base: string; + /** + * The path that gets accumulated from each level of nesting + * taking the absolute and relative nature of each path into consideration + */ + parentPath?: string; /** * A function to resolve data return from the backend, most typically * used when the backend response needs to be adapted in some way. @@ -26,6 +31,7 @@ export interface RestfulReactProviderProps { const { Provider, Consumer: RestfulReactConsumer } = React.createContext>({ base: "", + parentPath: "", resolve: (data: any) => data, requestOptions: {}, onError: noop, @@ -44,6 +50,7 @@ export default class RestfulReactProvider extends React.Component data, requestOptions: {}, + parentPath: "", ...value, }} > diff --git a/src/Get.test.tsx b/src/Get.test.tsx index e07c1180..9a538a9c 100644 --- a/src/Get.test.tsx +++ b/src/Get.test.tsx @@ -48,23 +48,6 @@ describe("Get", () => { await wait(() => expect(children.mock.calls.length).toBe(2)); }); - it("should compose the url with the base", async () => { - nock("https://my-awesome-api.fake") - .get("/plop") - .reply(200); - - const children = jest.fn(); - children.mockReturnValue(
); - - render( - - {children} - , - ); - - await wait(() => expect(children.mock.calls.length).toBe(2)); - }); - it("should set loading to `true` on mount", async () => { nock("https://my-awesome-api.fake") .get("/") @@ -163,9 +146,9 @@ describe("Get", () => { expect(children.mock.calls[1][0]).toEqual(null); expect(children.mock.calls[1][1].error).toEqual({ data: - "invalid json response body at https://my-awesome-api.fake/ reason: Unexpected token < in JSON at position 0", + "invalid json response body at https://my-awesome-api.fake reason: Unexpected token < in JSON at position 0", message: - "Failed to fetch: 200 OK - invalid json response body at https://my-awesome-api.fake/ reason: Unexpected token < in JSON at position 0", + "Failed to fetch: 200 OK - invalid json response body at https://my-awesome-api.fake reason: Unexpected token < in JSON at position 0", }); }); @@ -663,4 +646,211 @@ describe("Get", () => { expect(apiCalls).toEqual(2); }); }); + describe("Compose paths and urls", () => { + it("should compose the url with the base", async () => { + nock("https://my-awesome-api.fake") + .get("/plop") + .reply(200); + const children = jest.fn(); + children.mockReturnValue(
); + render( + + {children} + , + ); + await wait(() => expect(children.mock.calls.length).toBe(2)); + }); + it("should compose absolute urls", async () => { + nock("https://my-awesome-api.fake") + .get("/people") + .reply(200); + nock("https://my-awesome-api.fake") + .get("/absolute") + .reply(200); + const children = jest.fn(); + children.mockReturnValue(
); + render( + + {() => {children}} + , + ); + await wait(() => expect(children.mock.calls.length).toBe(3)); + }); + it("should compose relative urls", async () => { + nock("https://my-awesome-api.fake") + .get("/people") + .reply(200); + nock("https://my-awesome-api.fake") + .get("/people/relative") + .reply(200, { path: "/people/relative" }); + const children = jest.fn(); + children.mockReturnValue(
); + render( + + {() => {children}} + , + ); + await wait(() => expect(children.mock.calls.length).toBe(3)); + expect(children.mock.calls[2][0]).toEqual({ path: "/people/relative" }); + }); + it("should compose absolute urls with base subpath", async () => { + nock("https://my-awesome-api.fake/MY_SUBROUTE") + .get("/people") + .reply(200); + nock("https://my-awesome-api.fake/MY_SUBROUTE") + .get("/absolute") + .reply(200, { path: "/absolute" }); + const children = jest.fn(); + children.mockReturnValue(
); + render( + + {() => {children}} + , + ); + await wait(() => expect(children.mock.calls.length).toBe(3)); + expect(children.mock.calls[2][0]).toEqual({ path: "/absolute" }); + }); + it("should compose relative urls with base subpath", async () => { + nock("https://my-awesome-api.fake/MY_SUBROUTE") + .get("/people") + .reply(200); + nock("https://my-awesome-api.fake/MY_SUBROUTE") + .get("/people/relative") + .reply(200, { path: "/people/relative" }); + const children = jest.fn(); + children.mockReturnValue(
); + render( + + {() => {children}} + , + ); + await wait(() => expect(children.mock.calls.length).toBe(3)); + expect(children.mock.calls[2][0]).toEqual({ path: "/people/relative" }); + }); + it("should compose properly when base contains a trailing slash", async () => { + nock("https://my-awesome-api.fake/MY_SUBROUTE") + .get("/people") + .reply(200); + nock("https://my-awesome-api.fake/MY_SUBROUTE") + .get("/people/relative") + .reply(200, { path: "/people/relative" }); + const children = jest.fn(); + children.mockReturnValue(
); + render( + + {() => {children}} + , + ); + await wait(() => expect(children.mock.calls.length).toBe(3)); + expect(children.mock.calls[2][0]).toEqual({ path: "/people/relative" }); + }); + it("should compose more nested absolute and relative urls", async () => { + nock("https://my-awesome-api.fake/MY_SUBROUTE") + .get("/absolute-1") + .reply(200); + nock("https://my-awesome-api.fake/MY_SUBROUTE") + .get("/absolute-1/relative-1") + .reply(200); + nock("https://my-awesome-api.fake/MY_SUBROUTE") + .get("/absolute-2") + .reply(200); + nock("https://my-awesome-api.fake/MY_SUBROUTE") + .get("/absolute-2/relative-2") + .reply(200); + nock("https://my-awesome-api.fake/MY_SUBROUTE") + .get("/absolute-2/relative-2/relative-3") + .reply(200, { path: "/absolute-2/relative-2/relative-3" }); + const children = jest.fn(); + children.mockReturnValue(
); + render( + + + {() => ( + + {() => ( + + {() => {() => {children}}} + + )} + + )} + + , + ); + await wait(() => expect(children.mock.calls.length).toBe(6)); + expect(children.mock.calls[5][0]).toEqual({ path: "/absolute-2/relative-2/relative-3" }); + }); + it("should compose properly when one of the paths is empty string", async () => { + nock("https://my-awesome-api.fake") + .get("/absolute-1") + .reply(200); + nock("https://my-awesome-api.fake") + .get("/absolute-1/relative-1") + .reply(200); + nock("https://my-awesome-api.fake") + .get("/absolute-1/absolute-2") + .reply(200); + nock("https://my-awesome-api.fake") + .get("/absolute-1/absolute-2/relative-2") + .reply(200); + nock("https://my-awesome-api.fake") + .get("/absolute-1/absolute-2/relative-2/relative-3") + .reply(200, { path: "/absolute-1/absolute-2/relative-2/relative-3" }); + const children = jest.fn(); + children.mockReturnValue(
); + render( + + + {() => ( + + {() => ( + + {() => {() => {children}}} + + )} + + )} + + , + ); + await wait(() => expect(children.mock.calls.length).toBe(6)); + expect(children.mock.calls[5][0]).toEqual({ path: "/absolute-1/absolute-2/relative-2/relative-3" }); + }); + it("should compose properly when one of the paths is lone slash and base has trailing slash", async () => { + nock("https://my-awesome-api.fake") + .get("/absolute-1") + .reply(200); + nock("https://my-awesome-api.fake") + .get("/absolute-1/relative-1") + .reply(200); + nock("https://my-awesome-api.fake") + .get("/absolute-1/absolute-2") + .reply(200); + nock("https://my-awesome-api.fake") + .get("/absolute-1/absolute-2/relative-2") + .reply(200); + nock("https://my-awesome-api.fake") + .get("/absolute-1/absolute-2/relative-2/relative-3") + .reply(200, { path: "/absolute-1/absolute-2/relative-2/relative-3" }); + const children = jest.fn(); + children.mockReturnValue(
); + render( + + + {() => ( + + {() => ( + + {() => {() => {children}}} + + )} + + )} + + , + ); + await wait(() => expect(children.mock.calls.length).toBe(6)); + expect(children.mock.calls[5][0]).toEqual({ path: "/absolute-1/absolute-2/relative-2/relative-3" }); + }); + }); }); diff --git a/src/Get.tsx b/src/Get.tsx index 82b55896..c168f8d4 100644 --- a/src/Get.tsx +++ b/src/Get.tsx @@ -1,8 +1,8 @@ import { DebounceSettings } from "lodash"; import debounce from "lodash/debounce"; import * as React from "react"; -import url from "url"; import RestfulReactProvider, { InjectedProps, RestfulReactConsumer, RestfulReactProviderProps } from "./Context"; +import { composePath, composeUrl } from "./util/composeUrl"; import { processResponse } from "./util/processResponse"; /** @@ -93,6 +93,11 @@ export interface GetProps { * */ base?: string; + /** + * The accumulated path from each level of parent GETs + * taking the absolute and relative nature of each path into consideration + */ + parentPath?: string; /** * How long do we wait between subsequent requests? * Uses [lodash's debounce](https://lodash.com/docs/4.17.10#debounce) under the hood. @@ -148,6 +153,7 @@ class ContextlessGet extends React.Component< public static defaultProps = { base: "", + parentPath: "", resolve: (unresolvedData: any) => unresolvedData, }; @@ -158,8 +164,13 @@ class ContextlessGet extends React.Component< } public componentDidUpdate(prevProps: GetProps) { - const { base, path, resolve } = prevProps; - if (base !== this.props.base || path !== this.props.path || resolve !== this.props.resolve) { + const { base, parentPath, path, resolve } = prevProps; + if ( + base !== this.props.base || + parentPath !== this.props.parentPath || + path !== this.props.path || + resolve !== this.props.resolve + ) { if (!this.props.lazy) { this.fetch(); } @@ -196,13 +207,13 @@ class ContextlessGet extends React.Component< }; public fetch = async (requestPath?: string, thisRequestOptions?: RequestInit) => { - const { base, path, resolve } = this.props; + const { base, parentPath, path, resolve } = this.props; if (this.state.error || !this.state.loading) { this.setState(() => ({ error: null, loading: true })); } const request = new Request( - url.resolve(base!, requestPath || path || ""), + composeUrl(base!, parentPath!, requestPath || path || ""), this.getRequestOptions(thisRequestOptions), ); const response = await fetch(request); @@ -231,7 +242,7 @@ class ContextlessGet extends React.Component< }; public render() { - const { children, wait, path, base } = this.props; + const { children, wait, path, base, parentPath } = this.props; const { data, error, loading, response } = this.state; if (wait && data === null && !error) { @@ -242,7 +253,7 @@ class ContextlessGet extends React.Component< data, { loading, error }, { refetch: this.fetch }, - { response, absolutePath: url.resolve(base!, path) }, + { response, absolutePath: composeUrl(base!, parentPath!, path) }, ); } } @@ -254,14 +265,14 @@ class ContextlessGet extends React.Component< * which all requests will be made. * * We compose Consumers immediately with providers - * in order to provide new `base` props that contain + * in order to provide new `parentPath` props that contain * a segment of the path, creating composable URLs. */ function Get(props: GetProps) { return ( {contextProps => ( - + )} diff --git a/src/Mutate.test.tsx b/src/Mutate.test.tsx index 442367e9..b42a279c 100644 --- a/src/Mutate.test.tsx +++ b/src/Mutate.test.tsx @@ -292,4 +292,245 @@ describe("Mutate", () => { expect(onError.mock.calls.length).toEqual(0); }); }); + describe("Compose paths and urls", () => { + it("should compose absolute urls", async () => { + nock("https://my-awesome-api.fake") + .post("/absolute") + .reply(200, { id: 1 }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + + {() => ( + + {children} + + )} + + , + ); + + await wait(() => expect(children.mock.calls.length).toBe(1)); + expect(children.mock.calls[0][1].loading).toEqual(false); + expect(children.mock.calls[0][0]).toBeDefined(); + + // post action + children.mock.calls[0][0](); + await wait(() => expect(children.mock.calls.length).toBe(3)); + + // transition state + expect(children.mock.calls[1][1].loading).toEqual(true); + + // after post state + expect(children.mock.calls[2][1].loading).toEqual(false); + expect(children.mock.calls[2][1].loading).toEqual(false); + }); + + it("should compose relative urls", async () => { + nock("https://my-awesome-api.fake") + .post("/people/relative") + .reply(200, { id: 1 }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + + {() => ( + + {children} + + )} + + , + ); + + await wait(() => expect(children.mock.calls.length).toBe(1)); + expect(children.mock.calls[0][1].loading).toEqual(false); + expect(children.mock.calls[0][0]).toBeDefined(); + + // post action + children.mock.calls[0][0](); + await wait(() => expect(children.mock.calls.length).toBe(3)); + + // transition state + expect(children.mock.calls[1][1].loading).toEqual(true); + + // after post state + expect(children.mock.calls[2][1].loading).toEqual(false); + expect(children.mock.calls[2][1].loading).toEqual(false); + }); + + it("should compose with base subpath", async () => { + nock("https://my-awesome-api.fake/MY_SUBROUTE") + .post("/people/relative") + .reply(200, { id: 1 }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + + {() => ( + + {children} + + )} + + , + ); + + await wait(() => expect(children.mock.calls.length).toBe(1)); + expect(children.mock.calls[0][1].loading).toEqual(false); + expect(children.mock.calls[0][0]).toBeDefined(); + + // post action + children.mock.calls[0][0](); + await wait(() => expect(children.mock.calls.length).toBe(3)); + + // transition state + expect(children.mock.calls[1][1].loading).toEqual(true); + + // after post state + expect(children.mock.calls[2][1].loading).toEqual(false); + expect(children.mock.calls[2][1].loading).toEqual(false); + }); + + it("should compose base with trailing slash", async () => { + nock("https://my-awesome-api.fake/MY_SUBROUTE") + .post("/people/relative") + .reply(200, { id: 1 }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + + {() => ( + + {children} + + )} + + , + ); + + await wait(() => expect(children.mock.calls.length).toBe(1)); + expect(children.mock.calls[0][1].loading).toEqual(false); + expect(children.mock.calls[0][0]).toBeDefined(); + + // post action + children.mock.calls[0][0](); + await wait(() => expect(children.mock.calls.length).toBe(3)); + + // transition state + expect(children.mock.calls[1][1].loading).toEqual(true); + + // after post state + expect(children.mock.calls[2][1].loading).toEqual(false); + expect(children.mock.calls[2][1].loading).toEqual(false); + }); + + it("should compose properly when one of the nested paths is empty string", async () => { + nock("https://my-awesome-api.fake/absolute-1") + .post("/absolute-2/relative-2/relative-3") + .reply(200, { id: 1 }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + + {() => ( + + {() => ( + + {() => ( + + {() => ( + + {children} + + )} + + )} + + )} + + )} + + , + ); + + await wait(() => expect(children.mock.calls.length).toBe(1)); + expect(children.mock.calls[0][1].loading).toEqual(false); + expect(children.mock.calls[0][0]).toBeDefined(); + + // post action + children.mock.calls[0][0](); + await wait(() => expect(children.mock.calls.length).toBe(3)); + + // transition state + expect(children.mock.calls[1][1].loading).toEqual(true); + + // after post state + expect(children.mock.calls[2][1].loading).toEqual(false); + expect(children.mock.calls[2][1].loading).toEqual(false); + }); + + it("should compose properly when one of the nested paths is lone slash and base has trailing slash", async () => { + nock("https://my-awesome-api.fake/absolute-1") + .post("/absolute-2/relative-2/relative-3") + .reply(200, { id: 1 }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + + {() => ( + + {() => ( + + {() => ( + + {() => ( + + {children} + + )} + + )} + + )} + + )} + + , + ); + + await wait(() => expect(children.mock.calls.length).toBe(1)); + expect(children.mock.calls[0][1].loading).toEqual(false); + expect(children.mock.calls[0][0]).toBeDefined(); + + // post action + children.mock.calls[0][0](); + await wait(() => expect(children.mock.calls.length).toBe(3)); + + // transition state + expect(children.mock.calls[1][1].loading).toEqual(true); + + // after post state + expect(children.mock.calls[2][1].loading).toEqual(false); + expect(children.mock.calls[2][1].loading).toEqual(false); + }); + }); }); diff --git a/src/Mutate.tsx b/src/Mutate.tsx index dea98b14..527d8929 100644 --- a/src/Mutate.tsx +++ b/src/Mutate.tsx @@ -1,7 +1,7 @@ import * as React from "react"; -import url from "url"; import RestfulReactProvider, { InjectedProps, RestfulReactConsumer, RestfulReactProviderProps } from "./Context"; import { GetState } from "./Get"; +import { composePath, composePathWithBody, composeUrl } from "./util/composeUrl"; import { processResponse } from "./util/processResponse"; /** @@ -47,6 +47,11 @@ export interface MutateCommonProps { * */ base?: string; + /** + * The accumulated path from each level of parent GETs + * taking the absolute and relative nature of each path into consideration + */ + parentPath?: string; /** Options passed into the fetch call. */ requestOptions?: RestfulReactProviderProps["requestOptions"]; /** @@ -107,17 +112,18 @@ class ContextlessMutate extends React.Component< public static defaultProps = { base: "", + parentPath: "", path: "", }; public mutate = async (body?: string | {}, mutateRequestOptions?: RequestInit) => { - const { base, path, verb, requestOptions: providerRequestOptions } = this.props; + const { base, parentPath, path, verb, requestOptions: providerRequestOptions } = this.props; this.setState(() => ({ error: null, loading: true })); const requestPath = verb === "DELETE" && typeof body === "string" - ? url.resolve(base!, url.resolve(path, body)) - : url.resolve(base!, path); + ? composeUrl(base!, parentPath!, composePathWithBody(path, body)) + : composeUrl(base!, parentPath!, path); const request = new Request(requestPath, { method: verb, body: typeof body === "object" ? JSON.stringify(body) : body, @@ -154,10 +160,10 @@ class ContextlessMutate extends React.Component< }; public render() { - const { children, path, base } = this.props; + const { children, path, base, parentPath } = this.props; const { error, loading, response } = this.state; - return children(this.mutate, { loading, error }, { response, absolutePath: `${base}${path}` }); + return children(this.mutate, { loading, error }, { response, absolutePath: composeUrl(base!, parentPath!, path) }); } } @@ -168,14 +174,14 @@ class ContextlessMutate extends React.Component< * which all requests will be made. * * We compose Consumers immediately with providers - * in order to provide new `base` props that contain + * in order to provide new `parentPath` props that contain * a segment of the path, creating composable URLs. */ function Mutate(props: MutateProps) { return ( {contextProps => ( - + {...contextProps} {...props} /> )} diff --git a/src/Poll.test.tsx b/src/Poll.test.tsx index 7b40c2ac..245e6c48 100644 --- a/src/Poll.test.tsx +++ b/src/Poll.test.tsx @@ -239,9 +239,9 @@ describe("Poll", () => { expect(children.mock.calls[1][0]).toEqual(null); expect(children.mock.calls[1][1].error).toEqual({ data: - "invalid json response body at https://my-awesome-api.fake/ reason: Unexpected token < in JSON at position 0", + "invalid json response body at https://my-awesome-api.fake reason: Unexpected token < in JSON at position 0", message: - "Failed to poll: 200 OK - invalid json response body at https://my-awesome-api.fake/ reason: Unexpected token < in JSON at position 0", + "Failed to poll: 200 OK - invalid json response body at https://my-awesome-api.fake reason: Unexpected token < in JSON at position 0", }); }); @@ -501,6 +501,44 @@ describe("Poll", () => { expect(children.mock.calls[1][1].loading).toEqual(false); expect(children.mock.calls[1][0]).toEqual({ id: 1 }); }); + + it("should compose urls with base subpath", async () => { + nock("https://my-awesome-api.fake/MY_SUBROUTE") + .get("/absolute") + .reply(200, { id: 1 }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + {children} + , + ); + + await wait(() => expect(children.mock.calls.length).toBe(2)); + expect(children.mock.calls[1][1].loading).toEqual(false); + expect(children.mock.calls[1][0]).toEqual({ id: 1 }); + }); + + it("should compose urls properly when base has a trailing slash", async () => { + nock("https://my-awesome-api.fake/MY_SUBROUTE") + .get("/absolute") + .reply(200, { id: 1 }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + {children} + , + ); + + await wait(() => expect(children.mock.calls.length).toBe(2)); + expect(children.mock.calls[1][1].loading).toEqual(false); + expect(children.mock.calls[1][0]).toEqual({ id: 1 }); + }); }); describe("with custom request options", () => { diff --git a/src/Poll.tsx b/src/Poll.tsx index 1f56532c..f04a000b 100644 --- a/src/Poll.tsx +++ b/src/Poll.tsx @@ -1,9 +1,9 @@ import React from "react"; import equal from "react-fast-compare"; -import url from "url"; import { InjectedProps, RestfulReactConsumer } from "./Context"; import { GetProps, GetState, Meta as GetComponentMeta } from "./Get"; +import { composeUrl } from "./util/composeUrl"; import { processResponse } from "./util/processResponse"; /** @@ -214,7 +214,7 @@ class ContextlessPoll extends React.Component< const { lastPollIndex } = this.state; const requestOptions = this.getRequestOptions(); - const request = new Request(url.resolve(base!, path), { + const request = new Request(composeUrl(base!, "", path), { ...requestOptions, headers: { Prefer: `wait=${wait}s;${lastPollIndex ? `index=${lastPollIndex}` : ""}`, @@ -301,7 +301,7 @@ class ContextlessPoll extends React.Component< const meta: Meta = { response, - absolutePath: url.resolve(base!, path), + absolutePath: composeUrl(base!, "", path), }; const states: States = { diff --git a/src/util/composeUrl.test.ts b/src/util/composeUrl.test.ts new file mode 100644 index 00000000..af190d7a --- /dev/null +++ b/src/util/composeUrl.test.ts @@ -0,0 +1,93 @@ +import { composePath, composePathWithBody, composeUrl } from "./composeUrl"; + +describe("compose paths and urls", () => { + it("should handle empty parentPath with absolute path", () => { + const parentPath = ""; + const path = "/absolute"; + expect(composePath(parentPath, path)).toBe("/absolute"); + + const base = "https://my-awesome-api.fake"; + expect(composeUrl(base, parentPath, path)).toBe("https://my-awesome-api.fake/absolute"); + + const baseWithSubpath = "https://my-awesome-api.fake/MY_SUBROUTE"; + expect(composeUrl(baseWithSubpath, parentPath, path)).toBe("https://my-awesome-api.fake/MY_SUBROUTE/absolute"); + }); + + it("should handle empty parentPath with relative path", () => { + const parentPath = ""; + const path = "relative"; + expect(composePath(parentPath, path)).toBe("/relative"); + + const base = "https://my-awesome-api.fake"; + expect(composeUrl(base, parentPath, path)).toBe("https://my-awesome-api.fake/relative"); + + const baseWithSubpath = "https://my-awesome-api.fake/MY_SUBROUTE"; + expect(composeUrl(baseWithSubpath, parentPath, path)).toBe("https://my-awesome-api.fake/MY_SUBROUTE/relative"); + }); + + it("should ignore empty string from path", () => { + const parentPath = "/someBasePath"; + const path = ""; + expect(composePath(parentPath, path)).toBe("/someBasePath"); + + const base = "https://my-awesome-api.fake"; + expect(composeUrl(base, parentPath, path)).toBe("https://my-awesome-api.fake/someBasePath"); + + const baseWithSubpath = "https://my-awesome-api.fake/MY_SUBROUTE"; + expect(composeUrl(baseWithSubpath, parentPath, path)).toBe("https://my-awesome-api.fake/MY_SUBROUTE/someBasePath"); + }); + + it("should ignore lone forward slash from path", () => { + const parentPath = "/someBasePath"; + const path = "/"; + expect(composePath(parentPath, path)).toBe("/someBasePath"); + + const base = "https://my-awesome-api.fake"; + expect(composeUrl(base, parentPath, path)).toBe("https://my-awesome-api.fake/someBasePath"); + + const baseWithSubpath = "https://my-awesome-api.fake/MY_SUBROUTE"; + expect(composeUrl(baseWithSubpath, parentPath, path)).toBe("https://my-awesome-api.fake/MY_SUBROUTE/someBasePath"); + }); + + it("should not include parentPath value when path is absolute", () => { + const parentPath = "/someBasePath"; + const path = "/absolute"; + expect(composePath(parentPath, path)).toBe("/absolute"); + + const base = "https://my-awesome-api.fake"; + expect(composeUrl(base, parentPath, path)).toBe("https://my-awesome-api.fake/absolute"); + + const baseWithSubpath = "https://my-awesome-api.fake/MY_SUBROUTE"; + expect(composeUrl(baseWithSubpath, parentPath, path)).toBe("https://my-awesome-api.fake/MY_SUBROUTE/absolute"); + }); + + it("should include parentPath value when path is relative", () => { + const parentPath = "/someBasePath"; + const path = "relative"; + expect(composePath(parentPath, path)).toBe("/someBasePath/relative"); + + const base = "https://my-awesome-api.fake"; + expect(composeUrl(base, parentPath, path)).toBe("https://my-awesome-api.fake/someBasePath/relative"); + + const baseWithSubpath = "https://my-awesome-api.fake/MY_SUBROUTE"; + expect(composeUrl(baseWithSubpath, parentPath, path)).toBe( + "https://my-awesome-api.fake/MY_SUBROUTE/someBasePath/relative", + ); + }); +}); + +describe("compose path with body", () => { + it("should compose body with absolute path", () => { + const path = "/absolute"; + const body = "?somebody"; + + expect(composePathWithBody(path, body)).toBe("/absolute?somebody"); + }); + + it("should compose body with relative path", () => { + const path = "relative"; + const body = "?somebody"; + + expect(composePathWithBody(path, body)).toBe("relative?somebody"); + }); +}); diff --git a/src/util/composeUrl.ts b/src/util/composeUrl.ts new file mode 100644 index 00000000..8b5f7f5e --- /dev/null +++ b/src/util/composeUrl.ts @@ -0,0 +1,27 @@ +import url from "url"; + +export const composeUrl = (base: string, parentPath: string, path: string): string => { + const composedPath = composePath(parentPath, path); + /* If the base contains a trailing slash, it will be trimmed during composition */ + return base!.endsWith("/") ? `${base!.slice(0, -1)}${composedPath}` : `${base}${composedPath}`; +}; + +/** + * If the path starts with slash, it is considered as absolute url. + * If not, it is considered as relative url. + * For example, + * parentPath = "/someBasePath" and path = "/absolute" resolves to "/absolute" + * whereas, + * parentPath = "/someBasePath" and path = "relative" resolves to "/someBasePath/relative" + */ +export const composePath = (parentPath: string, path: string): string => { + if (path.startsWith("/") && path.length > 1) { + return url.resolve(parentPath, path); + } else if (path !== "" && path !== "/") { + return `${parentPath}/${path}`; + } else { + return parentPath; + } +}; + +export const composePathWithBody = (path: string, body: string): string => url.resolve(path, body);