Skip to content

Commit e284293

Browse files
authored
chore: base url fallbacks (#3291)
1 parent bfa4000 commit e284293

File tree

9 files changed

+200
-24
lines changed

9 files changed

+200
-24
lines changed
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { sanitizeUrl } from "../sanitizeUrl";
2+
3+
describe("sanitizeUrl", () => {
4+
describe("undefined and empty inputs", () => {
5+
it("returns undefined for undefined input", () => {
6+
expect(sanitizeUrl(undefined)).toBeUndefined();
7+
});
8+
9+
it("returns undefined for empty string", () => {
10+
expect(sanitizeUrl("")).toBeUndefined();
11+
});
12+
13+
it("returns undefined for whitespace string", () => {
14+
expect(sanitizeUrl(" ")).toBeUndefined();
15+
});
16+
});
17+
18+
describe("protocol-relative URLs", () => {
19+
it("handles protocol-relative URLs starting with //", () => {
20+
expect(sanitizeUrl("//example.com")).toBe("https://example.com/");
21+
expect(sanitizeUrl("//api.example.com/path")).toBe(
22+
"https://api.example.com/path"
23+
);
24+
expect(sanitizeUrl("//localhost:8080")).toBe("https://localhost:8080/");
25+
});
26+
27+
it("returns undefined for invalid protocol-relative URLs", () => {
28+
expect(sanitizeUrl("//")).toBeUndefined();
29+
});
30+
});
31+
32+
describe("URLs without protocol", () => {
33+
it("adds https:// to URLs without protocol", () => {
34+
expect(sanitizeUrl("example.com")).toBe("https://example.com/");
35+
expect(sanitizeUrl("api.example.com/path")).toBe(
36+
"https://api.example.com/path"
37+
);
38+
expect(sanitizeUrl("localhost:3000")).toBe("https://localhost:3000/");
39+
});
40+
});
41+
42+
describe("complete URLs with protocol", () => {
43+
it("validates and returns complete HTTPS URLs", () => {
44+
expect(sanitizeUrl("https://example.com")).toBe("https://example.com/");
45+
expect(sanitizeUrl("https://api.example.com/path")).toBe(
46+
"https://api.example.com/path"
47+
);
48+
expect(sanitizeUrl("https://localhost:8080")).toBe(
49+
"https://localhost:8080/"
50+
);
51+
expect(sanitizeUrl("https://example.com?param=value")).toBe(
52+
"https://example.com/?param=value"
53+
);
54+
});
55+
56+
it("validates and returns complete HTTP URLs", () => {
57+
expect(sanitizeUrl("http://example.com")).toBe("http://example.com/");
58+
expect(sanitizeUrl("http://api.example.com/path")).toBe(
59+
"http://api.example.com/path"
60+
);
61+
expect(sanitizeUrl("http://localhost:8080")).toBe(
62+
"http://localhost:8080/"
63+
);
64+
});
65+
66+
it("returns undefined for invalid complete URLs", () => {
67+
expect(sanitizeUrl("https://")).toBeUndefined();
68+
expect(sanitizeUrl("http://")).toBeUndefined();
69+
});
70+
});
71+
72+
describe("edge cases", () => {
73+
it("handles URLs with query parameters and fragments", () => {
74+
expect(sanitizeUrl("https://example.com/path?param=value#fragment")).toBe(
75+
"https://example.com/path?param=value#fragment"
76+
);
77+
expect(sanitizeUrl("example.com/path?param=value#fragment")).toBe(
78+
"https://example.com/path?param=value#fragment"
79+
);
80+
});
81+
82+
it("handles URLs with special characters", () => {
83+
expect(sanitizeUrl("https://example.com/path with spaces")).toBe(
84+
"https://example.com/path%20with%20spaces"
85+
);
86+
expect(sanitizeUrl("example.com/path with spaces")).toBe(
87+
"https://example.com/path%20with%20spaces"
88+
);
89+
});
90+
91+
it("handles URLs with authentication", () => {
92+
expect(sanitizeUrl("https://user:[email protected]")).toBe(
93+
"https://user:[email protected]/"
94+
);
95+
expect(sanitizeUrl("user:[email protected]")).toBe(
96+
"https://user:[email protected]/"
97+
);
98+
});
99+
100+
it("handles IP addresses", () => {
101+
expect(sanitizeUrl("https://192.168.1.1")).toBe("https://192.168.1.1/");
102+
expect(sanitizeUrl("192.168.1.1")).toBe("https://192.168.1.1/");
103+
expect(sanitizeUrl("//192.168.1.1")).toBe("https://192.168.1.1/");
104+
});
105+
106+
it("handles localhost", () => {
107+
expect(sanitizeUrl("https://localhost")).toBe("https://localhost/");
108+
expect(sanitizeUrl("localhost")).toBe("https://localhost/");
109+
expect(sanitizeUrl("//localhost")).toBe("https://localhost/");
110+
});
111+
});
112+
});

packages/commons/core-utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ export type { Digit, Letter, LowercaseLetter, UppercaseLetter } from "./types";
2323
export { unknownToString } from "./unknownToString";
2424
export { visitDiscriminatedUnion } from "./visitDiscriminatedUnion";
2525
export { withDefaultProtocol } from "./withDefaultProtocol";
26+
export { sanitizeUrl } from "./sanitizeUrl";
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* validates and sanitizes a base URL.
3+
* returns null if the URL cannot be coerced into a valid URL.
4+
*/
5+
export function sanitizeUrl(url: string | undefined): string | undefined {
6+
if (!url) {
7+
return undefined;
8+
}
9+
10+
// handle protocol-relative URLs (starting with //)
11+
if (url.startsWith("//")) {
12+
try {
13+
const parsedUrl = new URL(`https:${url}`);
14+
return parsedUrl.toString();
15+
} catch {
16+
return undefined;
17+
}
18+
}
19+
20+
// handle URLs without protocol
21+
if (!url.startsWith("http://") && !url.startsWith("https://")) {
22+
try {
23+
const parsedUrl = new URL(`https://${url}`);
24+
return parsedUrl.toString();
25+
} catch {
26+
return undefined;
27+
}
28+
}
29+
30+
// validate complete URLs
31+
try {
32+
const parsedUrl = new URL(url);
33+
return parsedUrl.toString();
34+
} catch {
35+
return undefined;
36+
}
37+
}

packages/fdr-sdk/src/api-definition/snippets/SnippetHttpRequest.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { compact } from "es-toolkit/array";
22
import { noop } from "ts-essentials";
33
import urljoin from "url-join";
44

5+
import { sanitizeUrl } from "@fern-api/ui-core-utils";
56
import visitDiscriminatedUnion from "@fern-api/ui-core-utils/visitDiscriminatedUnion";
67

78
import type * as Latest from "../latest";
@@ -73,7 +74,9 @@ export function toSnippetHttpRequest(
7374
(env) => env.id === endpoint.defaultEnvironment
7475
) ?? endpoint.environments?.[0]
7576
)?.baseUrl;
76-
const url = urljoin(compact([environmentUrl, example.path]));
77+
const sanitizedEnvironment = sanitizeUrl(environmentUrl);
78+
79+
const url = urljoin(compact([sanitizedEnvironment, example.path]));
7780

7881
const headers: Record<string, unknown> = { ...example.headers };
7982

packages/fdr-sdk/src/api-definition/snippets/get-har-request.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export function getHarRequest(
7070
if (
7171
Array.isArray(valueObj) ||
7272
typeof valueObj !== "object" ||
73-
valueObj === null
73+
valueObj == null
7474
) {
7575
return true;
7676
}

packages/fdr-sdk/src/api-definition/url.ts

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import qs from "qs";
22

3-
import { unknownToString } from "@fern-api/ui-core-utils";
3+
import { sanitizeUrl, unknownToString } from "@fern-api/ui-core-utils";
44

55
import type { EndpointDefinition, PathPart } from "./latest";
66

@@ -11,15 +11,14 @@ function buildQueryParams(
1111
return "";
1212
}
1313

14-
const filteredParams = Object.entries(queryParameters).reduce(
15-
(acc, [key, value]) => {
16-
if (value != null) {
17-
acc[key] = value;
18-
}
19-
return acc;
20-
},
21-
{} as Record<string, unknown>
22-
);
14+
const filteredParams = Object.entries(queryParameters).reduce<
15+
Record<string, unknown>
16+
>((acc, [key, value]) => {
17+
if (value != null) {
18+
acc[key] = value;
19+
}
20+
return acc;
21+
}, {});
2322

2423
if (Object.keys(filteredParams).length === 0) {
2524
return "";
@@ -65,8 +64,17 @@ export function buildRequestUrl({
6564
pathParameters,
6665
queryParameters,
6766
}: BuildRequestUrlOptions): string {
67+
const sanitizedBaseUrl = sanitizeUrl(baseUrl) || "";
68+
69+
if (sanitizedBaseUrl.endsWith("/")) {
70+
return (
71+
sanitizedBaseUrl.slice(0, -1) +
72+
buildPath(path, pathParameters) +
73+
buildQueryParams(queryParameters)
74+
);
75+
}
6876
return (
69-
baseUrl +
77+
sanitizedBaseUrl +
7078
buildPath(path, pathParameters) +
7179
buildQueryParams(queryParameters)
7280
);
@@ -84,14 +92,19 @@ export function buildEndpointUrl({
8492
queryParameters,
8593
baseUrl,
8694
}: BuildEndpointUrlOptions): string {
95+
const environmentBaseUrl =
96+
baseUrl ??
97+
(
98+
endpoint?.environments?.find(
99+
(env) => env.id === endpoint.defaultEnvironment
100+
) ?? endpoint?.environments?.[0]
101+
)?.baseUrl;
102+
103+
// sanitize the base URL - if invalid, it will be null
104+
const sanitizedBaseUrl = sanitizeUrl(environmentBaseUrl);
105+
87106
return buildRequestUrl({
88-
baseUrl:
89-
baseUrl ??
90-
(
91-
endpoint?.environments?.find(
92-
(env) => env.id === endpoint.defaultEnvironment
93-
) ?? endpoint?.environments?.[0]
94-
)?.baseUrl,
107+
baseUrl: sanitizedBaseUrl || "",
95108
path: endpoint?.path,
96109
pathParameters,
97110
queryParameters,

packages/fern-docs/bundle/src/components/MaybeEnvironmentDropdown.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useAtom } from "jotai";
44
import { parse } from "url";
55

66
import type { APIV1Read } from "@fern-api/fdr-sdk/client/types";
7+
import { sanitizeUrl } from "@fern-api/ui-core-utils";
78
import {
89
FernButton,
910
FernDropdown,
@@ -67,7 +68,7 @@ export function MaybeEnvironmentDropdown({
6768
// TODO: revisit the order of precedence for the baseUrl... this is a temporary fix
6869
const preParsedUrl =
6970
playgroundEnvironment ?? selectedEnvironment?.baseUrl ?? baseUrl;
70-
const url = preParsedUrl && parse(preParsedUrl);
71+
const url = preParsedUrl && parse(sanitizeUrl(preParsedUrl) ?? "");
7172

7273
// TODO: clean up this component
7374
useEffect(() => {

packages/fern-docs/bundle/src/components/api-reference/endpoints/EndpointUrl.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { noop } from "ts-essentials";
1414
import { HttpOrWssOrGrpc } from "@fern-api/docs-utils";
1515
import { APIV1Read } from "@fern-api/fdr-sdk";
1616
import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition";
17-
import visitDiscriminatedUnion from "@fern-api/ui-core-utils/visitDiscriminatedUnion";
17+
import { sanitizeUrl, visitDiscriminatedUnion } from "@fern-api/ui-core-utils";
1818
import { FernTooltip, FernTooltipProvider, cn } from "@fern-docs/components";
1919
import { CopyToClipboardButton } from "@fern-docs/components";
2020
import { HttpMethodBadge } from "@fern-docs/components/badges";
@@ -117,8 +117,15 @@ export const EndpointUrl = React.forwardRef<
117117
if (url == null) {
118118
return undefined;
119119
}
120+
121+
const sanitizedUrl = sanitizeUrl(url);
122+
if (!sanitizedUrl) {
123+
return undefined;
124+
}
125+
120126
try {
121-
return new URL(url, "http://n").pathname;
127+
const parsedUrl = new URL(sanitizedUrl);
128+
return parsedUrl.pathname;
122129
} catch {
123130
return undefined;
124131
}

packages/fern-docs/bundle/src/components/playground/utils/select-environment.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
EnvironmentId,
77
WebSocketChannel,
88
} from "@fern-api/fdr-sdk/api-definition";
9+
import { sanitizeUrl } from "@fern-api/ui-core-utils";
910

1011
import { SELECTED_ENVIRONMENT_ATOM } from "@/state/environment";
1112
import { usePlaygroundEnvironment } from "@/state/playground";
@@ -37,5 +38,6 @@ export function usePlaygroundBaseUrl(
3738
): [baseUrl: string | undefined, environmentId: EnvironmentId | undefined] {
3839
const environment = useSelectedEnvironment(endpoint);
3940
const playgroundBaseUrl = usePlaygroundEnvironment();
40-
return [playgroundBaseUrl ?? environment?.baseUrl, environment?.id];
41+
const sanitizedUrl = sanitizeUrl(playgroundBaseUrl ?? environment?.baseUrl);
42+
return [sanitizedUrl, environment?.id];
4143
}

0 commit comments

Comments
 (0)