feat(api): add resolveApiExampleUrl function and related tests for dynamic API URL resolution
This commit is contained in:
1 parent
c51c17e590
commit
14411e0cf8
3 files changed
+131
-1
No files matched your search
@@ -7,10 +7,13 @@ import SearchField from "@/components/SearchField";
|
|||||||
import TablePagination from "@/components/TablePagination";
|
import TablePagination from "@/components/TablePagination";
|
||||||
import {
|
import {
|
||||||
apiKeys as apiKeysApi,
|
apiKeys as apiKeysApi,
|
||||||
|
getApiBaseUrl,
|
||||||
|
settingsApi,
|
||||||
type ApiKeyExpiryOption,
|
type ApiKeyExpiryOption,
|
||||||
type ApiKeyRecord,
|
type ApiKeyRecord,
|
||||||
type CreateApiKeyBody,
|
type CreateApiKeyBody,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import { resolveApiExampleUrl } from "@/lib/api-example-url";
|
||||||
import { useTablePagination } from "@/lib/use-table-pagination";
|
import { useTablePagination } from "@/lib/use-table-pagination";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
@@ -172,6 +175,7 @@ export default function ApiKeysPage() {
|
|||||||
const [generatedKey, setGeneratedKey] = useState<GeneratedKeyState | null>(
|
const [generatedKey, setGeneratedKey] = useState<GeneratedKeyState | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [panelUrl, setPanelUrl] = useState<string | null>(null);
|
||||||
const [sessionRawKeys, setSessionRawKeys] = useState<Record<string, string>>(
|
const [sessionRawKeys, setSessionRawKeys] = useState<Record<string, string>>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
@@ -212,8 +216,16 @@ export default function ApiKeysPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadKeys();
|
void loadKeys();
|
||||||
|
void loadPanelUrl();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const apiExampleUrl = resolveApiExampleUrl({
|
||||||
|
panelUrl,
|
||||||
|
browserOrigin:
|
||||||
|
typeof window === "undefined" ? null : window.location.origin,
|
||||||
|
apiBaseUrl: getApiBaseUrl(),
|
||||||
|
});
|
||||||
|
|
||||||
async function loadKeys() {
|
async function loadKeys() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -347,6 +359,15 @@ export default function ApiKeysPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPanelUrl() {
|
||||||
|
try {
|
||||||
|
const response = await settingsApi.get();
|
||||||
|
setPanelUrl(response.data.general.panelUrl);
|
||||||
|
} catch {
|
||||||
|
// The active browser origin remains a valid same-origin API fallback.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeKey(id: string) {
|
async function removeKey(id: string) {
|
||||||
setRemovingId(id);
|
setRemovingId(id);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -1136,7 +1157,7 @@ export default function ApiKeysPage() {
|
|||||||
fontFamily: "JetBrains Mono, monospace",
|
fontFamily: "JetBrains Mono, monospace",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{`curl -H "x-api-key: ${generatedKey.rawKey}" http://localhost:4000/api/v1/servers`}
|
{`curl -H "x-api-key: ${generatedKey.rawKey}" ${apiExampleUrl}`}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
type ApiExampleUrlOptions = {
|
||||||
|
panelUrl?: string | null;
|
||||||
|
browserOrigin?: string | null;
|
||||||
|
apiBaseUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeHttpUrl(value: string | null | undefined) {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed);
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoopbackHost(hostname: string) {
|
||||||
|
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||||
|
return (
|
||||||
|
normalized === "localhost" ||
|
||||||
|
normalized === "127.0.0.1" ||
|
||||||
|
normalized === "::1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveApiExampleUrl({
|
||||||
|
panelUrl,
|
||||||
|
browserOrigin,
|
||||||
|
apiBaseUrl,
|
||||||
|
}: ApiExampleUrlOptions) {
|
||||||
|
const storedPanelUrl = normalizeHttpUrl(panelUrl);
|
||||||
|
const runtimeOrigin = normalizeHttpUrl(browserOrigin);
|
||||||
|
const storedPanelIsStaleLocalDefault =
|
||||||
|
storedPanelUrl &&
|
||||||
|
runtimeOrigin &&
|
||||||
|
isLoopbackHost(storedPanelUrl.hostname) &&
|
||||||
|
!isLoopbackHost(runtimeOrigin.hostname);
|
||||||
|
|
||||||
|
const publicOrigin = storedPanelIsStaleLocalDefault
|
||||||
|
? runtimeOrigin
|
||||||
|
: storedPanelUrl || runtimeOrigin;
|
||||||
|
|
||||||
|
if (publicOrigin) {
|
||||||
|
return new URL("/api/v1/servers", publicOrigin.origin).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredApiBase = normalizeHttpUrl(apiBaseUrl);
|
||||||
|
if (configuredApiBase) {
|
||||||
|
return new URL(
|
||||||
|
`${configuredApiBase.pathname.replace(/\/+$/, "")}/servers`,
|
||||||
|
configuredApiBase.origin,
|
||||||
|
).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return "http://localhost:3000/api/v1/servers";
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { resolveApiExampleUrl } from "../src/lib/api-example-url";
|
||||||
|
|
||||||
|
test("uses the Panel Access URL stored in settings", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveApiExampleUrl({
|
||||||
|
panelUrl: "https://panel.example.com/",
|
||||||
|
browserOrigin: "http://192.168.1.20:3000",
|
||||||
|
apiBaseUrl: "http://localhost:4000/api/v1",
|
||||||
|
}),
|
||||||
|
"https://panel.example.com/api/v1/servers",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the active browser origin when the stored panel URL is a stale loopback default", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveApiExampleUrl({
|
||||||
|
panelUrl: "http://localhost:3000",
|
||||||
|
browserOrigin: "http://192.168.1.20:3000",
|
||||||
|
apiBaseUrl: "http://localhost:4000/api/v1",
|
||||||
|
}),
|
||||||
|
"http://192.168.1.20:3000/api/v1/servers",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the stored loopback URL during local access", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveApiExampleUrl({
|
||||||
|
panelUrl: "http://localhost:3000",
|
||||||
|
browserOrigin: "http://127.0.0.1:3000",
|
||||||
|
apiBaseUrl: "http://localhost:4000/api/v1",
|
||||||
|
}),
|
||||||
|
"http://localhost:3000/api/v1/servers",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to the configured API base when no public panel origin is available", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveApiExampleUrl({
|
||||||
|
panelUrl: "not-a-url",
|
||||||
|
browserOrigin: null,
|
||||||
|
apiBaseUrl: "https://api.example.com/api/v1/",
|
||||||
|
}),
|
||||||
|
"https://api.example.com/api/v1/servers",
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user