@opendocsdev/cli 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +300 -0
- package/dist/bin/opendocs.js +712 -0
- package/dist/bin/opendocs.js.map +1 -0
- package/dist/templates/api-reference.mdx +308 -0
- package/dist/templates/components.mdx +286 -0
- package/dist/templates/configuration.mdx +190 -0
- package/dist/templates/docs.json +27 -0
- package/dist/templates/introduction.mdx +25 -0
- package/dist/templates/logo.svg +4 -0
- package/dist/templates/quickstart.mdx +59 -0
- package/dist/templates/writing-content.mdx +236 -0
- package/package.json +92 -0
- package/src/engine/astro.config.ts +75 -0
- package/src/engine/src/components/Analytics.astro +57 -0
- package/src/engine/src/components/ApiPlayground.astro +24 -0
- package/src/engine/src/components/Callout.astro +66 -0
- package/src/engine/src/components/Card.astro +75 -0
- package/src/engine/src/components/CardGroup.astro +29 -0
- package/src/engine/src/components/CodeGroup.astro +231 -0
- package/src/engine/src/components/CopyButton.astro +179 -0
- package/src/engine/src/components/Steps.astro +27 -0
- package/src/engine/src/components/Tab.astro +21 -0
- package/src/engine/src/components/TableOfContents.astro +119 -0
- package/src/engine/src/components/Tabs.astro +135 -0
- package/src/engine/src/components/index.ts +107 -0
- package/src/engine/src/components/react/ApiPlayground/AuthSection.tsx +91 -0
- package/src/engine/src/components/react/ApiPlayground/CodeBlock.tsx +66 -0
- package/src/engine/src/components/react/ApiPlayground/CodeSnippets.tsx +66 -0
- package/src/engine/src/components/react/ApiPlayground/CollapsibleSection.tsx +26 -0
- package/src/engine/src/components/react/ApiPlayground/KeyValueEditor.tsx +58 -0
- package/src/engine/src/components/react/ApiPlayground/ResponseDisplay.tsx +109 -0
- package/src/engine/src/components/react/ApiPlayground/Spinner.tsx +6 -0
- package/src/engine/src/components/react/ApiPlayground/constants.ts +16 -0
- package/src/engine/src/components/react/ApiPlayground/generators.test.ts +130 -0
- package/src/engine/src/components/react/ApiPlayground/generators.ts +75 -0
- package/src/engine/src/components/react/ApiPlayground/index.tsx +490 -0
- package/src/engine/src/components/react/ApiPlayground/types.ts +35 -0
- package/src/engine/src/components/react/Callout.tsx +54 -0
- package/src/engine/src/components/react/Card.tsx +48 -0
- package/src/engine/src/components/react/CardGroup.tsx +24 -0
- package/src/engine/src/components/react/FeedbackWidget.tsx +166 -0
- package/src/engine/src/components/react/GitHubLink.tsx +28 -0
- package/src/engine/src/components/react/NavigationCard.tsx +53 -0
- package/src/engine/src/components/react/PageActions.tsx +124 -0
- package/src/engine/src/components/react/PageFooter.tsx +91 -0
- package/src/engine/src/components/react/SearchModal.tsx +358 -0
- package/src/engine/src/components/react/SearchProvider.tsx +37 -0
- package/src/engine/src/components/react/Sidebar.tsx +369 -0
- package/src/engine/src/components/react/SidebarSearchTrigger.tsx +57 -0
- package/src/engine/src/components/react/Steps.tsx +25 -0
- package/src/engine/src/components/react/ThemeToggle.tsx +72 -0
- package/src/engine/src/components/react/index.ts +14 -0
- package/src/engine/src/env.d.ts +10 -0
- package/src/engine/src/layouts/DocsLayout.astro +357 -0
- package/src/engine/src/lib/__tests__/markdown.test.ts +124 -0
- package/src/engine/src/lib/__tests__/mdx-loader.test.ts +205 -0
- package/src/engine/src/lib/config.ts +79 -0
- package/src/engine/src/lib/markdown.ts +54 -0
- package/src/engine/src/lib/mdx-loader.ts +143 -0
- package/src/engine/src/lib/mdx-utils.ts +72 -0
- package/src/engine/src/lib/remark-opendocs.ts +195 -0
- package/src/engine/src/lib/utils.ts +221 -0
- package/src/engine/src/pages/[...slug].astro +115 -0
- package/src/engine/src/pages/index.astro +71 -0
- package/src/engine/src/scripts/mobile-sidebar.ts +56 -0
- package/src/engine/src/scripts/theme-init.ts +25 -0
- package/src/engine/src/styles/global.css +703 -0
- package/src/engine/tailwind.config.mjs +60 -0
- package/src/engine/tsconfig.json +15 -0
- package/src/templates/api-reference.mdx +308 -0
- package/src/templates/components.mdx +286 -0
- package/src/templates/configuration.mdx +190 -0
- package/src/templates/docs.json +27 -0
- package/src/templates/introduction.mdx +25 -0
- package/src/templates/logo.svg +4 -0
- package/src/templates/quickstart.mdx +59 -0
- package/src/templates/writing-content.mdx +236 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { generators, getLanguageForHighlighter } from "./generators";
|
|
3
|
+
|
|
4
|
+
describe("generators", () => {
|
|
5
|
+
const url = "https://api.example.com/users/123";
|
|
6
|
+
const headers = {
|
|
7
|
+
"Content-Type": "application/json",
|
|
8
|
+
Authorization: "Bearer token123",
|
|
9
|
+
};
|
|
10
|
+
const body = '{"name": "John", "email": "john@example.com"}';
|
|
11
|
+
|
|
12
|
+
describe("curl", () => {
|
|
13
|
+
it("generates POST request with headers and body", () => {
|
|
14
|
+
const result = generators.curl("POST", url, headers, body);
|
|
15
|
+
expect(result).toContain("curl");
|
|
16
|
+
expect(result).toContain("-X POST");
|
|
17
|
+
expect(result).toContain(`'${url}'`);
|
|
18
|
+
expect(result).toContain("-H 'Content-Type: application/json'");
|
|
19
|
+
expect(result).toContain("-H 'Authorization: Bearer token123'");
|
|
20
|
+
expect(result).toContain("-d '{\"name\": \"John\", \"email\": \"john@example.com\"}'");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("generates GET request without -X flag", () => {
|
|
24
|
+
const result = generators.curl("GET", url, headers);
|
|
25
|
+
expect(result).toContain("curl");
|
|
26
|
+
expect(result).not.toContain("-X GET");
|
|
27
|
+
expect(result).toContain(`'${url}'`);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("generates simple GET as single line", () => {
|
|
31
|
+
const result = generators.curl("GET", url, {});
|
|
32
|
+
expect(result).toBe(`curl '${url}'`);
|
|
33
|
+
expect(result).not.toContain("\\");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("omits body when not provided", () => {
|
|
37
|
+
const result = generators.curl("POST", url, headers);
|
|
38
|
+
expect(result).not.toContain("-d");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("escapes single quotes in URL", () => {
|
|
42
|
+
const urlWithQuote = "https://api.example.com/users?name=O'Brien";
|
|
43
|
+
const result = generators.curl("GET", urlWithQuote, {});
|
|
44
|
+
expect(result).toContain("O'\\''Brien");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("handles empty headers", () => {
|
|
48
|
+
const result = generators.curl("GET", url, {});
|
|
49
|
+
expect(result).not.toContain("-H");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("generates multi-line for POST with body", () => {
|
|
53
|
+
const result = generators.curl("POST", url, {}, body);
|
|
54
|
+
expect(result).toContain("curl \\\n");
|
|
55
|
+
expect(result).toContain("-X POST");
|
|
56
|
+
expect(result).toContain("-d");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("javascript", () => {
|
|
61
|
+
it("generates fetch with headers and body", () => {
|
|
62
|
+
const result = generators.javascript("POST", url, headers, body);
|
|
63
|
+
expect(result).toContain("await fetch");
|
|
64
|
+
expect(result).toContain(`'${url}'`);
|
|
65
|
+
expect(result).toContain("method: 'POST'");
|
|
66
|
+
expect(result).toContain("headers:");
|
|
67
|
+
expect(result).toContain("'Content-Type': 'application/json'");
|
|
68
|
+
expect(result).toContain("body: JSON.stringify");
|
|
69
|
+
expect(result).toContain("await response.json()");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("generates fetch without body for GET", () => {
|
|
73
|
+
const result = generators.javascript("GET", url, headers);
|
|
74
|
+
expect(result).toContain("method: 'GET'");
|
|
75
|
+
expect(result).not.toContain("body:");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("omits headers when empty", () => {
|
|
79
|
+
const result = generators.javascript("GET", url, {});
|
|
80
|
+
expect(result).not.toContain("headers:");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("python", () => {
|
|
85
|
+
it("generates requests call with headers and body", () => {
|
|
86
|
+
const result = generators.python("POST", url, headers, body);
|
|
87
|
+
expect(result).toContain("import requests");
|
|
88
|
+
expect(result).toContain("headers = {");
|
|
89
|
+
expect(result).toContain("'Content-Type': 'application/json'");
|
|
90
|
+
expect(result).toContain("json_data =");
|
|
91
|
+
expect(result).toContain("requests.post(");
|
|
92
|
+
expect(result).toContain("headers=headers");
|
|
93
|
+
expect(result).toContain("json=json_data");
|
|
94
|
+
expect(result).toContain("print(response.json())");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("uses correct method name", () => {
|
|
98
|
+
expect(generators.python("GET", url, {})).toContain("requests.get(");
|
|
99
|
+
expect(generators.python("PUT", url, {})).toContain("requests.put(");
|
|
100
|
+
expect(generators.python("DELETE", url, {})).toContain("requests.delete(");
|
|
101
|
+
expect(generators.python("PATCH", url, {})).toContain("requests.patch(");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("omits headers when empty", () => {
|
|
105
|
+
const result = generators.python("GET", url, {});
|
|
106
|
+
expect(result).not.toContain("headers = {");
|
|
107
|
+
expect(result).not.toContain("headers=headers");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("omits body when not provided", () => {
|
|
111
|
+
const result = generators.python("POST", url, headers);
|
|
112
|
+
expect(result).not.toContain("json_data");
|
|
113
|
+
expect(result).not.toContain("json=json_data");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("getLanguageForHighlighter", () => {
|
|
119
|
+
it("maps curl to bash", () => {
|
|
120
|
+
expect(getLanguageForHighlighter("curl")).toBe("bash");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("maps javascript to javascript", () => {
|
|
124
|
+
expect(getLanguageForHighlighter("javascript")).toBe("javascript");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("maps python to python", () => {
|
|
128
|
+
expect(getLanguageForHighlighter("python")).toBe("python");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
type GeneratorFn = (
|
|
2
|
+
method: string,
|
|
3
|
+
url: string,
|
|
4
|
+
headers: Record<string, string>,
|
|
5
|
+
body?: string
|
|
6
|
+
) => string;
|
|
7
|
+
|
|
8
|
+
const escapeShell = (s: string) => s.replace(/'/g, "'\\''");
|
|
9
|
+
|
|
10
|
+
export const generators: Record<"curl" | "javascript" | "python", GeneratorFn> = {
|
|
11
|
+
curl: (method, url, headers, body) => {
|
|
12
|
+
const parts: string[] = [];
|
|
13
|
+
if (method !== "GET") parts.push(`-X ${method}`);
|
|
14
|
+
parts.push(`'${escapeShell(url)}'`);
|
|
15
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
16
|
+
parts.push(`-H '${escapeShell(k)}: ${escapeShell(v)}'`);
|
|
17
|
+
}
|
|
18
|
+
if (body?.trim()) {
|
|
19
|
+
parts.push(`-d '${escapeShell(body)}'`);
|
|
20
|
+
}
|
|
21
|
+
// Single line for simple requests, multi-line for complex ones
|
|
22
|
+
if (parts.length === 1) {
|
|
23
|
+
return `curl ${parts[0]}`;
|
|
24
|
+
}
|
|
25
|
+
return `curl \\\n ${parts.join(" \\\n ")}`;
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
javascript: (method, url, headers, body) => {
|
|
29
|
+
const hasHeaders = Object.keys(headers).length > 0;
|
|
30
|
+
const hasBody = body?.trim();
|
|
31
|
+
let code = `const response = await fetch('${url}', {\n`;
|
|
32
|
+
code += ` method: '${method}',\n`;
|
|
33
|
+
if (hasHeaders) {
|
|
34
|
+
code += ` headers: {\n`;
|
|
35
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
36
|
+
code += ` '${k}': '${v}',\n`;
|
|
37
|
+
}
|
|
38
|
+
code += ` },\n`;
|
|
39
|
+
}
|
|
40
|
+
if (hasBody) {
|
|
41
|
+
code += ` body: JSON.stringify(${body}),\n`;
|
|
42
|
+
}
|
|
43
|
+
code += `});\n\n`;
|
|
44
|
+
code += `const data = await response.json();\n`;
|
|
45
|
+
code += `console.log(data);`;
|
|
46
|
+
return code;
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
python: (method, url, headers, body) => {
|
|
50
|
+
const lines = ["import requests", ""];
|
|
51
|
+
if (Object.keys(headers).length > 0) {
|
|
52
|
+
lines.push("headers = {");
|
|
53
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
54
|
+
lines.push(` '${k}': '${v}',`);
|
|
55
|
+
}
|
|
56
|
+
lines.push("}", "");
|
|
57
|
+
}
|
|
58
|
+
if (body?.trim()) {
|
|
59
|
+
lines.push(`json_data = ${body}`, "");
|
|
60
|
+
}
|
|
61
|
+
const args = [`'${url}'`];
|
|
62
|
+
if (Object.keys(headers).length > 0) args.push("headers=headers");
|
|
63
|
+
if (body?.trim()) args.push("json=json_data");
|
|
64
|
+
lines.push(`response = requests.${method.toLowerCase()}(`);
|
|
65
|
+
lines.push(` ${args.join(",\n ")}`);
|
|
66
|
+
lines.push(`)`, "");
|
|
67
|
+
lines.push(`print(response.json())`);
|
|
68
|
+
return lines.join("\n");
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const getLanguageForHighlighter = (lang: "curl" | "javascript" | "python"): string => {
|
|
73
|
+
const map = { curl: "bash", javascript: "javascript", python: "python" };
|
|
74
|
+
return map[lang];
|
|
75
|
+
};
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
ApiPlaygroundProps,
|
|
4
|
+
AuthState,
|
|
5
|
+
KeyValue,
|
|
6
|
+
ResponseState,
|
|
7
|
+
ErrorState,
|
|
8
|
+
CodeLanguage,
|
|
9
|
+
} from "./types";
|
|
10
|
+
import {
|
|
11
|
+
METHOD_COLORS,
|
|
12
|
+
AUTH_STORAGE_PREFIX,
|
|
13
|
+
DEFAULT_AUTH_STATE,
|
|
14
|
+
} from "./constants";
|
|
15
|
+
import { generators } from "./generators";
|
|
16
|
+
import { Spinner } from "./Spinner";
|
|
17
|
+
import { CollapsibleSection } from "./CollapsibleSection";
|
|
18
|
+
import { KeyValueEditor } from "./KeyValueEditor";
|
|
19
|
+
import { AuthSection } from "./AuthSection";
|
|
20
|
+
import { CodeSnippets } from "./CodeSnippets";
|
|
21
|
+
import { ResponseDisplay } from "./ResponseDisplay";
|
|
22
|
+
|
|
23
|
+
const EMPTY_HEADERS: Record<string, string> = {};
|
|
24
|
+
const EMPTY_PARAMS: Record<string, string> = {};
|
|
25
|
+
|
|
26
|
+
export default function ApiPlayground({
|
|
27
|
+
endpoint,
|
|
28
|
+
method,
|
|
29
|
+
baseUrl,
|
|
30
|
+
defaultHeaders = EMPTY_HEADERS,
|
|
31
|
+
defaultBody,
|
|
32
|
+
defaultParams = EMPTY_PARAMS,
|
|
33
|
+
}: ApiPlaygroundProps) {
|
|
34
|
+
const pathParams = useMemo(
|
|
35
|
+
() => (endpoint.match(/\{(\w+)\}/g) || []).map((p) => p.slice(1, -1)),
|
|
36
|
+
[endpoint],
|
|
37
|
+
);
|
|
38
|
+
const methodUpper = method.toUpperCase();
|
|
39
|
+
const supportsBody = ["POST", "PUT", "PATCH", "DELETE"].includes(methodUpper);
|
|
40
|
+
|
|
41
|
+
const [currentBaseUrl, setCurrentBaseUrl] = useState(baseUrl);
|
|
42
|
+
const [pathParamValues, setPathParamValues] = useState<
|
|
43
|
+
Record<string, string>
|
|
44
|
+
>(() => {
|
|
45
|
+
const initial: Record<string, string> = {};
|
|
46
|
+
pathParams.forEach((p) => (initial[p] = defaultParams[p] || ""));
|
|
47
|
+
return initial;
|
|
48
|
+
});
|
|
49
|
+
const [queryParams, setQueryParams] = useState<KeyValue[]>(() =>
|
|
50
|
+
Object.entries(defaultParams)
|
|
51
|
+
.filter(([key]) => !pathParams.includes(key))
|
|
52
|
+
.map(([key, value]) => ({ key, value })),
|
|
53
|
+
);
|
|
54
|
+
const [headers, setHeaders] = useState<KeyValue[]>(() => {
|
|
55
|
+
const initial = Object.entries(defaultHeaders).map(([key, value]) => ({
|
|
56
|
+
key,
|
|
57
|
+
value,
|
|
58
|
+
}));
|
|
59
|
+
if (supportsBody && !defaultHeaders["Content-Type"]) {
|
|
60
|
+
initial.unshift({ key: "Content-Type", value: "application/json" });
|
|
61
|
+
}
|
|
62
|
+
return initial;
|
|
63
|
+
});
|
|
64
|
+
const [requestBody, setRequestBody] = useState(() => {
|
|
65
|
+
if (!defaultBody) return "";
|
|
66
|
+
return typeof defaultBody === "string"
|
|
67
|
+
? defaultBody
|
|
68
|
+
: JSON.stringify(defaultBody, null, 2);
|
|
69
|
+
});
|
|
70
|
+
const [isBodyValid, setIsBodyValid] = useState(true);
|
|
71
|
+
const [authState, setAuthState] = useState<AuthState>(() => ({
|
|
72
|
+
...DEFAULT_AUTH_STATE,
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
76
|
+
const [response, setResponse] = useState<ResponseState | null>(null);
|
|
77
|
+
const [error, setError] = useState<ErrorState | null>(null);
|
|
78
|
+
const [currentLang, setCurrentLang] = useState<CodeLanguage>("curl");
|
|
79
|
+
|
|
80
|
+
const [expandedSections, setExpandedSections] = useState({
|
|
81
|
+
pathParams: pathParams.length > 0,
|
|
82
|
+
queryParams: false,
|
|
83
|
+
headers: false,
|
|
84
|
+
body: true,
|
|
85
|
+
auth: false,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
try {
|
|
90
|
+
const storage = localStorage;
|
|
91
|
+
const savedType = storage.getItem(AUTH_STORAGE_PREFIX + "type") as
|
|
92
|
+
| AuthState["type"]
|
|
93
|
+
| null;
|
|
94
|
+
if (
|
|
95
|
+
savedType &&
|
|
96
|
+
["none", "bearer", "apikey", "basic"].includes(savedType)
|
|
97
|
+
) {
|
|
98
|
+
setAuthState({
|
|
99
|
+
type: savedType,
|
|
100
|
+
bearer: {
|
|
101
|
+
token: storage.getItem(AUTH_STORAGE_PREFIX + "bearer_token") || "",
|
|
102
|
+
},
|
|
103
|
+
apikey: {
|
|
104
|
+
name: storage.getItem(AUTH_STORAGE_PREFIX + "apikey_name") || "",
|
|
105
|
+
value: storage.getItem(AUTH_STORAGE_PREFIX + "apikey_value") || "",
|
|
106
|
+
location:
|
|
107
|
+
(storage.getItem(AUTH_STORAGE_PREFIX + "apikey_location") as
|
|
108
|
+
| "header"
|
|
109
|
+
| "query") || "header",
|
|
110
|
+
},
|
|
111
|
+
basic: {
|
|
112
|
+
username:
|
|
113
|
+
storage.getItem(AUTH_STORAGE_PREFIX + "basic_username") || "",
|
|
114
|
+
password:
|
|
115
|
+
storage.getItem(AUTH_STORAGE_PREFIX + "basic_password") || "",
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// localStorage may not be available (SSR, private browsing, etc.)
|
|
121
|
+
}
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
const saveAuth = useCallback((newAuth: AuthState) => {
|
|
125
|
+
setAuthState(newAuth);
|
|
126
|
+
try {
|
|
127
|
+
const storage = localStorage;
|
|
128
|
+
storage.setItem(AUTH_STORAGE_PREFIX + "type", newAuth.type);
|
|
129
|
+
storage.setItem(
|
|
130
|
+
AUTH_STORAGE_PREFIX + "bearer_token",
|
|
131
|
+
newAuth.bearer.token,
|
|
132
|
+
);
|
|
133
|
+
storage.setItem(AUTH_STORAGE_PREFIX + "apikey_name", newAuth.apikey.name);
|
|
134
|
+
storage.setItem(
|
|
135
|
+
AUTH_STORAGE_PREFIX + "apikey_value",
|
|
136
|
+
newAuth.apikey.value,
|
|
137
|
+
);
|
|
138
|
+
storage.setItem(
|
|
139
|
+
AUTH_STORAGE_PREFIX + "apikey_location",
|
|
140
|
+
newAuth.apikey.location,
|
|
141
|
+
);
|
|
142
|
+
storage.setItem(
|
|
143
|
+
AUTH_STORAGE_PREFIX + "basic_username",
|
|
144
|
+
newAuth.basic.username,
|
|
145
|
+
);
|
|
146
|
+
storage.setItem(
|
|
147
|
+
AUTH_STORAGE_PREFIX + "basic_password",
|
|
148
|
+
newAuth.basic.password,
|
|
149
|
+
);
|
|
150
|
+
} catch {
|
|
151
|
+
// localStorage not available
|
|
152
|
+
}
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
const processedEndpoint = useMemo(() => {
|
|
156
|
+
let processed = endpoint;
|
|
157
|
+
Object.entries(pathParamValues).forEach(([param, value]) => {
|
|
158
|
+
if (value)
|
|
159
|
+
processed = processed.replace(`{${param}}`, encodeURIComponent(value));
|
|
160
|
+
});
|
|
161
|
+
return processed;
|
|
162
|
+
}, [endpoint, pathParamValues]);
|
|
163
|
+
|
|
164
|
+
const queryString = useMemo(() => {
|
|
165
|
+
const params = new URLSearchParams();
|
|
166
|
+
queryParams.forEach((p) => {
|
|
167
|
+
if (p.key && p.value) params.append(p.key, p.value);
|
|
168
|
+
});
|
|
169
|
+
if (
|
|
170
|
+
authState.type === "apikey" &&
|
|
171
|
+
authState.apikey.location === "query" &&
|
|
172
|
+
authState.apikey.name &&
|
|
173
|
+
authState.apikey.value
|
|
174
|
+
) {
|
|
175
|
+
params.append(authState.apikey.name, authState.apikey.value);
|
|
176
|
+
}
|
|
177
|
+
const str = params.toString();
|
|
178
|
+
return str ? `?${str}` : "";
|
|
179
|
+
}, [queryParams, authState]);
|
|
180
|
+
|
|
181
|
+
const requestHeaders = useMemo(() => {
|
|
182
|
+
const reqHeaders: Record<string, string> = {};
|
|
183
|
+
headers.forEach((h) => {
|
|
184
|
+
if (h.key && h.value) reqHeaders[h.key] = h.value;
|
|
185
|
+
});
|
|
186
|
+
if (authState.type === "bearer" && authState.bearer.token) {
|
|
187
|
+
reqHeaders["Authorization"] = `Bearer ${authState.bearer.token}`;
|
|
188
|
+
} else if (
|
|
189
|
+
authState.type === "apikey" &&
|
|
190
|
+
authState.apikey.location === "header" &&
|
|
191
|
+
authState.apikey.name &&
|
|
192
|
+
authState.apikey.value
|
|
193
|
+
) {
|
|
194
|
+
reqHeaders[authState.apikey.name] = authState.apikey.value;
|
|
195
|
+
} else if (authState.type === "basic" && authState.basic.username) {
|
|
196
|
+
reqHeaders["Authorization"] = `Basic ${btoa(
|
|
197
|
+
`${authState.basic.username}:${authState.basic.password}`,
|
|
198
|
+
)}`;
|
|
199
|
+
}
|
|
200
|
+
return reqHeaders;
|
|
201
|
+
}, [headers, authState]);
|
|
202
|
+
|
|
203
|
+
const fullUrl = currentBaseUrl + processedEndpoint + queryString;
|
|
204
|
+
|
|
205
|
+
const codeSnippet = useMemo(() => {
|
|
206
|
+
const body = supportsBody ? requestBody : undefined;
|
|
207
|
+
return generators[currentLang](methodUpper, fullUrl, requestHeaders, body);
|
|
208
|
+
}, [
|
|
209
|
+
currentLang,
|
|
210
|
+
methodUpper,
|
|
211
|
+
fullUrl,
|
|
212
|
+
requestHeaders,
|
|
213
|
+
supportsBody,
|
|
214
|
+
requestBody,
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
const sendRequest = async () => {
|
|
218
|
+
setIsLoading(true);
|
|
219
|
+
setError(null);
|
|
220
|
+
setResponse(null);
|
|
221
|
+
const startTime = performance.now();
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const fetchOptions: RequestInit = {
|
|
225
|
+
method: methodUpper,
|
|
226
|
+
headers: requestHeaders,
|
|
227
|
+
};
|
|
228
|
+
if (supportsBody && requestBody.trim()) fetchOptions.body = requestBody;
|
|
229
|
+
|
|
230
|
+
const res = await fetch(fullUrl, fetchOptions);
|
|
231
|
+
const endTime = performance.now();
|
|
232
|
+
const text = await res.text();
|
|
233
|
+
const resHeaders: Record<string, string> = {};
|
|
234
|
+
res.headers.forEach((value, key) => (resHeaders[key] = value));
|
|
235
|
+
|
|
236
|
+
setResponse({
|
|
237
|
+
status: res.status,
|
|
238
|
+
statusText: res.statusText,
|
|
239
|
+
body: text,
|
|
240
|
+
headers: resHeaders,
|
|
241
|
+
time: Math.round(endTime - startTime),
|
|
242
|
+
});
|
|
243
|
+
} catch (err) {
|
|
244
|
+
if (err instanceof TypeError && err.message.includes("Failed to fetch")) {
|
|
245
|
+
setError({
|
|
246
|
+
title: "Network Error",
|
|
247
|
+
message:
|
|
248
|
+
"Failed to fetch. This could be a CORS issue. Make sure the API server has proper CORS headers configured.",
|
|
249
|
+
});
|
|
250
|
+
} else {
|
|
251
|
+
setError({
|
|
252
|
+
title: "Error",
|
|
253
|
+
message:
|
|
254
|
+
err instanceof Error ? err.message : "An unknown error occurred",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
} finally {
|
|
258
|
+
setIsLoading(false);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const validateBody = (value: string) => {
|
|
263
|
+
if (!value.trim()) {
|
|
264
|
+
setIsBodyValid(true);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
JSON.parse(value);
|
|
269
|
+
setIsBodyValid(true);
|
|
270
|
+
} catch {
|
|
271
|
+
setIsBodyValid(false);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const formatBody = () => {
|
|
276
|
+
try {
|
|
277
|
+
const parsed = JSON.parse(requestBody);
|
|
278
|
+
setRequestBody(JSON.stringify(parsed, null, 2));
|
|
279
|
+
setIsBodyValid(true);
|
|
280
|
+
} catch {
|
|
281
|
+
setIsBodyValid(false);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const resetDefaults = () => {
|
|
286
|
+
setCurrentBaseUrl(baseUrl);
|
|
287
|
+
const initialPathParams: Record<string, string> = {};
|
|
288
|
+
pathParams.forEach((p) => (initialPathParams[p] = defaultParams[p] || ""));
|
|
289
|
+
setPathParamValues(initialPathParams);
|
|
290
|
+
setQueryParams(
|
|
291
|
+
Object.entries(defaultParams)
|
|
292
|
+
.filter(([key]) => !pathParams.includes(key))
|
|
293
|
+
.map(([key, value]) => ({ key, value })),
|
|
294
|
+
);
|
|
295
|
+
const initialHeaders = Object.entries(defaultHeaders).map(
|
|
296
|
+
([key, value]) => ({ key, value }),
|
|
297
|
+
);
|
|
298
|
+
if (supportsBody && !defaultHeaders["Content-Type"]) {
|
|
299
|
+
initialHeaders.unshift({
|
|
300
|
+
key: "Content-Type",
|
|
301
|
+
value: "application/json",
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
setHeaders(initialHeaders);
|
|
305
|
+
setRequestBody(
|
|
306
|
+
defaultBody
|
|
307
|
+
? typeof defaultBody === "string"
|
|
308
|
+
? defaultBody
|
|
309
|
+
: JSON.stringify(defaultBody, null, 2)
|
|
310
|
+
: "",
|
|
311
|
+
);
|
|
312
|
+
setIsBodyValid(true);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const toggleSection = (section: keyof typeof expandedSections) => {
|
|
316
|
+
setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] }));
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<div className="api-playground my-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden max-w-full">
|
|
321
|
+
<div className="px-3 sm:px-4 py-3 bg-[var(--color-surface-raised)] border-b border-[var(--color-border)]">
|
|
322
|
+
<h3 className="text-sm font-semibold text-[var(--color-foreground)] m-0">
|
|
323
|
+
API Playground
|
|
324
|
+
</h3>
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
<div className="p-3 sm:p-4 border-b border-[var(--color-border)] max-w-full overflow-hidden">
|
|
328
|
+
<div className="flex flex-wrap items-center gap-2 mb-4">
|
|
329
|
+
<span
|
|
330
|
+
className={`px-2 py-1 text-xs font-mono font-bold rounded uppercase flex-shrink-0 ${
|
|
331
|
+
METHOD_COLORS[methodUpper] ||
|
|
332
|
+
"bg-[var(--color-surface-raised)] text-[var(--color-muted)]"
|
|
333
|
+
}`}
|
|
334
|
+
>
|
|
335
|
+
{method}
|
|
336
|
+
</span>
|
|
337
|
+
<code className="text-xs sm:text-sm font-mono text-[var(--color-muted)] break-all">
|
|
338
|
+
{endpoint}
|
|
339
|
+
</code>
|
|
340
|
+
</div>
|
|
341
|
+
|
|
342
|
+
<div className="mb-3">
|
|
343
|
+
<label className="block text-xs font-medium text-[var(--color-muted)] mb-1">
|
|
344
|
+
Base URL
|
|
345
|
+
</label>
|
|
346
|
+
<input
|
|
347
|
+
type="text"
|
|
348
|
+
value={currentBaseUrl}
|
|
349
|
+
onChange={(e) => setCurrentBaseUrl(e.target.value)}
|
|
350
|
+
className="w-full px-3 py-2 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-surface)] text-[var(--color-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] font-mono"
|
|
351
|
+
/>
|
|
352
|
+
</div>
|
|
353
|
+
|
|
354
|
+
<div className="mb-4">
|
|
355
|
+
<label className="block text-xs font-medium text-[var(--color-muted)] mb-1">
|
|
356
|
+
Full URL Preview
|
|
357
|
+
</label>
|
|
358
|
+
<div className="px-3 py-2 text-xs sm:text-sm font-mono bg-[var(--color-surface-raised)] border border-[var(--color-border)] rounded-md text-[var(--color-muted)] overflow-x-auto">
|
|
359
|
+
<span className="break-all">{fullUrl}</span>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
{pathParams.length > 0 && (
|
|
364
|
+
<CollapsibleSection
|
|
365
|
+
title="Path Parameters"
|
|
366
|
+
expanded={expandedSections.pathParams}
|
|
367
|
+
onToggle={() => toggleSection("pathParams")}
|
|
368
|
+
>
|
|
369
|
+
<div className="space-y-3">
|
|
370
|
+
{pathParams.map((param) => (
|
|
371
|
+
<div
|
|
372
|
+
key={param}
|
|
373
|
+
className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2"
|
|
374
|
+
>
|
|
375
|
+
<label className="text-xs font-mono text-[var(--color-muted)] sm:w-24 flex-shrink-0">{`{${param}}`}</label>
|
|
376
|
+
<input
|
|
377
|
+
type="text"
|
|
378
|
+
value={pathParamValues[param] || ""}
|
|
379
|
+
onChange={(e) =>
|
|
380
|
+
setPathParamValues((prev) => ({
|
|
381
|
+
...prev,
|
|
382
|
+
[param]: e.target.value,
|
|
383
|
+
}))
|
|
384
|
+
}
|
|
385
|
+
placeholder={`Enter ${param}`}
|
|
386
|
+
className="flex-1 px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-surface)] text-[var(--color-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] font-mono"
|
|
387
|
+
/>
|
|
388
|
+
</div>
|
|
389
|
+
))}
|
|
390
|
+
</div>
|
|
391
|
+
</CollapsibleSection>
|
|
392
|
+
)}
|
|
393
|
+
|
|
394
|
+
<CollapsibleSection
|
|
395
|
+
title="Query Parameters"
|
|
396
|
+
expanded={expandedSections.queryParams}
|
|
397
|
+
onToggle={() => toggleSection("queryParams")}
|
|
398
|
+
>
|
|
399
|
+
<KeyValueEditor
|
|
400
|
+
items={queryParams}
|
|
401
|
+
onChange={setQueryParams}
|
|
402
|
+
keyPlaceholder="Parameter name"
|
|
403
|
+
valuePlaceholder="Value"
|
|
404
|
+
/>
|
|
405
|
+
</CollapsibleSection>
|
|
406
|
+
|
|
407
|
+
<CollapsibleSection
|
|
408
|
+
title="Headers"
|
|
409
|
+
expanded={expandedSections.headers}
|
|
410
|
+
onToggle={() => toggleSection("headers")}
|
|
411
|
+
>
|
|
412
|
+
<KeyValueEditor
|
|
413
|
+
items={headers}
|
|
414
|
+
onChange={setHeaders}
|
|
415
|
+
keyPlaceholder="Header name"
|
|
416
|
+
valuePlaceholder="Value"
|
|
417
|
+
/>
|
|
418
|
+
</CollapsibleSection>
|
|
419
|
+
|
|
420
|
+
{supportsBody && (
|
|
421
|
+
<CollapsibleSection
|
|
422
|
+
title="Request Body"
|
|
423
|
+
expanded={expandedSections.body}
|
|
424
|
+
onToggle={() => toggleSection("body")}
|
|
425
|
+
>
|
|
426
|
+
<div className="space-y-2">
|
|
427
|
+
<textarea
|
|
428
|
+
value={requestBody}
|
|
429
|
+
onChange={(e) => setRequestBody(e.target.value)}
|
|
430
|
+
onBlur={() => validateBody(requestBody)}
|
|
431
|
+
placeholder='{"key": "value"}'
|
|
432
|
+
className="w-full h-32 px-3 py-2 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-surface)] text-[var(--color-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] font-mono resize-y"
|
|
433
|
+
/>
|
|
434
|
+
{!isBodyValid && (
|
|
435
|
+
<p className="text-xs text-[var(--color-error)]">
|
|
436
|
+
Invalid JSON
|
|
437
|
+
</p>
|
|
438
|
+
)}
|
|
439
|
+
<button
|
|
440
|
+
type="button"
|
|
441
|
+
onClick={formatBody}
|
|
442
|
+
className="text-sm text-[var(--color-primary)] hover:underline"
|
|
443
|
+
>
|
|
444
|
+
Format JSON
|
|
445
|
+
</button>
|
|
446
|
+
</div>
|
|
447
|
+
</CollapsibleSection>
|
|
448
|
+
)}
|
|
449
|
+
|
|
450
|
+
<CollapsibleSection
|
|
451
|
+
title="Authentication"
|
|
452
|
+
expanded={expandedSections.auth}
|
|
453
|
+
onToggle={() => toggleSection("auth")}
|
|
454
|
+
>
|
|
455
|
+
<AuthSection authState={authState} onAuthChange={saveAuth} />
|
|
456
|
+
</CollapsibleSection>
|
|
457
|
+
|
|
458
|
+
<div className="mt-4 flex flex-col sm:flex-row gap-2 sm:gap-3">
|
|
459
|
+
<button
|
|
460
|
+
type="button"
|
|
461
|
+
onClick={sendRequest}
|
|
462
|
+
disabled={isLoading}
|
|
463
|
+
className="px-4 py-2 text-sm font-medium text-[var(--color-primary-foreground)] bg-[var(--color-primary)] hover:opacity-90 rounded-md disabled:opacity-50 flex items-center justify-center gap-2"
|
|
464
|
+
>
|
|
465
|
+
{isLoading && <Spinner />}
|
|
466
|
+
{isLoading ? "Sending..." : "Send Request"}
|
|
467
|
+
</button>
|
|
468
|
+
<button
|
|
469
|
+
type="button"
|
|
470
|
+
onClick={resetDefaults}
|
|
471
|
+
className="px-4 py-2 text-sm font-medium text-[var(--color-muted)] bg-[var(--color-surface-raised)] hover:bg-[var(--color-surface-sunken)] rounded-md border border-[var(--color-border)]"
|
|
472
|
+
>
|
|
473
|
+
Reset to Defaults
|
|
474
|
+
</button>
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
|
|
478
|
+
<CodeSnippets
|
|
479
|
+
codeSnippet={codeSnippet}
|
|
480
|
+
currentLang={currentLang}
|
|
481
|
+
onLangChange={setCurrentLang}
|
|
482
|
+
/>
|
|
483
|
+
<ResponseDisplay
|
|
484
|
+
response={response}
|
|
485
|
+
error={error}
|
|
486
|
+
isLoading={isLoading}
|
|
487
|
+
/>
|
|
488
|
+
</div>
|
|
489
|
+
);
|
|
490
|
+
}
|