@djangocfg/ui-tools 2.1.285 → 2.1.287
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/dist/DocsLayout-BCVU6TTX.cjs +2027 -0
- package/dist/DocsLayout-BCVU6TTX.cjs.map +1 -0
- package/dist/DocsLayout-ERETJLLV.mjs +2020 -0
- package/dist/DocsLayout-ERETJLLV.mjs.map +1 -0
- package/dist/{PlaygroundLayout-O52C6HK5.css → DocsLayout-MBFIB4NO.css} +1 -1
- package/dist/{PrettyCode.client-SGDGQTYT.cjs → PrettyCode.client-5GABIN2I.cjs} +57 -35
- package/dist/PrettyCode.client-5GABIN2I.cjs.map +1 -0
- package/dist/{PrettyCode.client-DW5LTG47.mjs → PrettyCode.client-IZTXXYHG.mjs} +57 -35
- package/dist/PrettyCode.client-IZTXXYHG.mjs.map +1 -0
- package/dist/chunk-IULI4XII.cjs +1129 -0
- package/dist/chunk-IULI4XII.cjs.map +1 -0
- package/dist/chunk-VZGQC3NG.mjs +1100 -0
- package/dist/chunk-VZGQC3NG.mjs.map +1 -0
- package/dist/index.cjs +88 -552
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -6
- package/dist/index.d.ts +18 -6
- package/dist/index.mjs +25 -496
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/tools/OpenapiViewer/.claude/.sidecar/activity.jsonl +6 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/history/2026-04-22.md +35 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/map_cache.json +30 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/review.md +35 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/scan.log +3 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-001.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-002.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-003.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-004.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-005.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/usage.json +5 -0
- package/src/tools/OpenapiViewer/.claude/project-map.md +23 -0
- package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +28 -2
- package/src/tools/OpenapiViewer/README.md +104 -51
- package/src/tools/OpenapiViewer/components/DocsLayout/ApiIntroSection.tsx +64 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +137 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +268 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +139 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +211 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/SlideInPlayground.tsx +101 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/TryItSheet.tsx +57 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +11 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +71 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +166 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/schemaFields.ts +121 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/sidebarLabel.ts +60 -0
- package/src/tools/OpenapiViewer/components/index.ts +5 -2
- package/src/tools/OpenapiViewer/components/shared/BodyFormEditor.tsx +422 -0
- package/src/tools/OpenapiViewer/components/shared/EndpointDraftSync.tsx +108 -0
- package/src/tools/OpenapiViewer/components/shared/EndpointResetButton.tsx +50 -0
- package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/RequestPanel.tsx +174 -87
- package/src/tools/OpenapiViewer/components/shared/SendButton.tsx +91 -0
- package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ui.tsx +5 -4
- package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +82 -8
- package/src/tools/OpenapiViewer/hooks/useEndpointDraft.ts +142 -0
- package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +126 -13
- package/src/tools/OpenapiViewer/index.tsx +3 -7
- package/src/tools/OpenapiViewer/lazy.tsx +6 -27
- package/src/tools/OpenapiViewer/types.ts +44 -0
- package/src/tools/OpenapiViewer/utils/formatters.ts +2 -23
- package/src/tools/OpenapiViewer/utils/index.ts +3 -1
- package/src/tools/OpenapiViewer/utils/schemaExport.ts +206 -0
- package/src/tools/OpenapiViewer/utils/url.ts +202 -0
- package/src/tools/PrettyCode/PrettyCode.client.tsx +42 -8
- package/src/tools/PrettyCode/index.tsx +6 -0
- package/dist/PlaygroundLayout-DHUATCHB.cjs +0 -798
- package/dist/PlaygroundLayout-DHUATCHB.cjs.map +0 -1
- package/dist/PlaygroundLayout-NONWOVQR.mjs +0 -791
- package/dist/PlaygroundLayout-NONWOVQR.mjs.map +0 -1
- package/dist/PrettyCode.client-DW5LTG47.mjs.map +0 -1
- package/dist/PrettyCode.client-SGDGQTYT.cjs.map +0 -1
- package/dist/chunk-5FKE7OME.cjs +0 -369
- package/dist/chunk-5FKE7OME.cjs.map +0 -1
- package/dist/chunk-BKWDHJKF.mjs +0 -356
- package/dist/chunk-BKWDHJKF.mjs.map +0 -1
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/EndpointList.tsx +0 -228
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/index.tsx +0 -107
- /package/dist/{PlaygroundLayout-O52C6HK5.css.map → DocsLayout-MBFIB4NO.css.map} +0 -0
- /package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ResponsePanel.tsx +0 -0
|
@@ -5,13 +5,21 @@ import React, {
|
|
|
5
5
|
createContext, ReactNode, useCallback, useContext, useEffect, useReducer, useRef
|
|
6
6
|
} from 'react';
|
|
7
7
|
|
|
8
|
+
import { useSessionStorage } from '@djangocfg/ui-core/hooks';
|
|
9
|
+
|
|
8
10
|
import type {
|
|
9
11
|
ApiEndpoint, ApiResponse, PlaygroundConfig, PlaygroundContextType, PlaygroundState,
|
|
10
12
|
PlaygroundStep
|
|
11
13
|
} from '../types';
|
|
12
|
-
import { parseRequestHeaders
|
|
14
|
+
import { parseRequestHeaders } from '../utils';
|
|
15
|
+
import { UrlBuilder } from '../utils/url';
|
|
13
16
|
import { getDefaultVersion } from '../utils/versionManager';
|
|
14
17
|
|
|
18
|
+
// Session-scoped auth persistence. sessionStorage (not localStorage) so
|
|
19
|
+
// the token dies when the browser tab closes — safer default for secrets.
|
|
20
|
+
const AUTH_KEY_STORAGE = 'openapi-playground:auth:apiKeyId';
|
|
21
|
+
const AUTH_BEARER_STORAGE = 'openapi-playground:auth:bearer';
|
|
22
|
+
|
|
15
23
|
// ─── Initial state ────────────────────────────────────────────────────────────
|
|
16
24
|
|
|
17
25
|
const createInitialState = (): PlaygroundState => ({
|
|
@@ -31,6 +39,7 @@ const createInitialState = (): PlaygroundState => ({
|
|
|
31
39
|
response: null,
|
|
32
40
|
loading: false,
|
|
33
41
|
sidebarOpen: false,
|
|
42
|
+
activeSchemaId: null,
|
|
34
43
|
});
|
|
35
44
|
|
|
36
45
|
// ─── Actions ──────────────────────────────────────────────────────────────────
|
|
@@ -59,6 +68,7 @@ type Action =
|
|
|
59
68
|
// Batched: set error response + loading=false atomically
|
|
60
69
|
| { type: 'REQUEST_ERROR'; response: ApiResponse }
|
|
61
70
|
| { type: 'SET_SIDEBAR'; open: boolean }
|
|
71
|
+
| { type: 'SET_ACTIVE_SCHEMA_ID'; id: string | null }
|
|
62
72
|
| { type: 'SYNC_API_KEY_HEADER'; headers: string }
|
|
63
73
|
| { type: 'CLEAR_API_KEY_SELECTION' }
|
|
64
74
|
| { type: 'SYNC_URL'; url: string }
|
|
@@ -83,16 +93,34 @@ function reducer(state: PlaygroundState, action: Action): PlaygroundState {
|
|
|
83
93
|
return i > 0 ? { ...state, currentStep: state.steps[i - 1]! } : state;
|
|
84
94
|
}
|
|
85
95
|
|
|
86
|
-
case 'SELECT_ENDPOINT':
|
|
96
|
+
case 'SELECT_ENDPOINT': {
|
|
87
97
|
if (!action.endpoint) return { ...state, selectedEndpoint: null };
|
|
98
|
+
// Guard: selecting the same endpoint is a no-op for response state.
|
|
99
|
+
// Without this, clicking "Try it" on the already-loaded endpoint
|
|
100
|
+
// would wipe its response for no reason.
|
|
101
|
+
const same =
|
|
102
|
+
state.selectedEndpoint?.method === action.endpoint.method &&
|
|
103
|
+
state.selectedEndpoint?.path === action.endpoint.path;
|
|
104
|
+
// Pre-fill request body from the OpenAPI example when available.
|
|
105
|
+
// For a brand-new endpoint selection, a realistic example is more
|
|
106
|
+
// useful than the {"key":"value"} placeholder. If the user already
|
|
107
|
+
// has a saved draft, EndpointDraftSync will overwrite this with the
|
|
108
|
+
// persisted value once it hydrates.
|
|
109
|
+
const exampleBody = action.endpoint.requestBody?.example ?? '';
|
|
88
110
|
return {
|
|
89
111
|
...state,
|
|
90
112
|
selectedEndpoint: action.endpoint,
|
|
91
113
|
requestMethod: action.endpoint.method,
|
|
92
114
|
requestUrl: action.endpoint.path,
|
|
93
|
-
parameters: {},
|
|
115
|
+
parameters: same ? state.parameters : {},
|
|
116
|
+
requestBody: same ? state.requestBody : exampleBody,
|
|
117
|
+
// Switching to a different endpoint: the previous response no
|
|
118
|
+
// longer belongs here. Clear it so the playground panel collapses
|
|
119
|
+
// back to single-column until the user sends a new request.
|
|
120
|
+
response: same ? state.response : null,
|
|
94
121
|
currentStep: 'request',
|
|
95
122
|
};
|
|
123
|
+
}
|
|
96
124
|
|
|
97
125
|
case 'SET_CATEGORY': return { ...state, selectedCategory: action.category };
|
|
98
126
|
case 'SET_SEARCH': return { ...state, searchTerm: action.term };
|
|
@@ -117,6 +145,7 @@ function reducer(state: PlaygroundState, action: Action): PlaygroundState {
|
|
|
117
145
|
return { ...state, loading: false, response: action.response };
|
|
118
146
|
|
|
119
147
|
case 'SET_SIDEBAR': return { ...state, sidebarOpen: action.open };
|
|
148
|
+
case 'SET_ACTIVE_SCHEMA_ID': return { ...state, activeSchemaId: action.id };
|
|
120
149
|
case 'SYNC_API_KEY_HEADER': return { ...state, requestHeaders: action.headers };
|
|
121
150
|
case 'CLEAR_API_KEY_SELECTION': return { ...state, selectedApiKey: null };
|
|
122
151
|
case 'SYNC_URL': return { ...state, requestUrl: action.url };
|
|
@@ -156,12 +185,52 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({ children
|
|
|
156
185
|
);
|
|
157
186
|
const isLoadingApiKeys = config.apiKeysLoading ?? false;
|
|
158
187
|
|
|
159
|
-
//
|
|
188
|
+
// ── Auth persistence (session-scoped) ─────────────────────────────────────
|
|
189
|
+
// Use sessionStorage so the chosen API key / bearer token survive reload
|
|
190
|
+
// within the same tab but die when the tab closes. That matches how
|
|
191
|
+
// users expect auth sessions to work and keeps secrets out of localStorage.
|
|
192
|
+
const [storedApiKeyId, setStoredApiKeyId] = useSessionStorage<string | null>(
|
|
193
|
+
AUTH_KEY_STORAGE,
|
|
194
|
+
null,
|
|
195
|
+
);
|
|
196
|
+
const [storedBearer, setStoredBearer] = useSessionStorage<string>(
|
|
197
|
+
AUTH_BEARER_STORAGE,
|
|
198
|
+
'',
|
|
199
|
+
);
|
|
200
|
+
const hasHydratedAuthRef = useRef(false);
|
|
201
|
+
|
|
202
|
+
// Hydrate auth state from sessionStorage exactly once.
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
if (hasHydratedAuthRef.current) return;
|
|
205
|
+
hasHydratedAuthRef.current = true;
|
|
206
|
+
if (storedApiKeyId) dispatch({ type: 'SET_API_KEY', apiKeyId: storedApiKeyId });
|
|
207
|
+
if (storedBearer) dispatch({ type: 'SET_MANUAL_TOKEN', token: storedBearer });
|
|
208
|
+
// We intentionally don't depend on the stored values — this effect
|
|
209
|
+
// runs once on mount; later changes are written out by the effects
|
|
210
|
+
// below, not re-hydrated.
|
|
211
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
212
|
+
}, []);
|
|
213
|
+
|
|
214
|
+
// Persist selection → sessionStorage as it changes.
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
if (!hasHydratedAuthRef.current) return;
|
|
217
|
+
setStoredApiKeyId(state.selectedApiKey);
|
|
218
|
+
}, [state.selectedApiKey, setStoredApiKeyId]);
|
|
219
|
+
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
if (!hasHydratedAuthRef.current) return;
|
|
222
|
+
setStoredBearer(state.manualApiToken);
|
|
223
|
+
}, [state.manualApiToken, setStoredBearer]);
|
|
224
|
+
|
|
225
|
+
// Auto-select first API key — only when there's no persisted selection
|
|
226
|
+
// to restore. Otherwise the first-render auto-pick would clobber a
|
|
227
|
+
// session that had a non-first key chosen.
|
|
160
228
|
useEffect(() => {
|
|
161
|
-
if (!
|
|
229
|
+
if (!hasHydratedAuthRef.current) return;
|
|
230
|
+
if (!isLoadingApiKeys && apiKeys.length > 0 && !state.selectedApiKey && !storedApiKeyId) {
|
|
162
231
|
dispatch({ type: 'SET_API_KEY', apiKeyId: apiKeys[0]?.id || null });
|
|
163
232
|
}
|
|
164
|
-
}, [apiKeys, isLoadingApiKeys, state.selectedApiKey]);
|
|
233
|
+
}, [apiKeys, isLoadingApiKeys, state.selectedApiKey, storedApiKeyId]);
|
|
165
234
|
|
|
166
235
|
// Sync X-API-Key header when selected key changes
|
|
167
236
|
useEffect(() => {
|
|
@@ -191,10 +260,13 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({ children
|
|
|
191
260
|
}
|
|
192
261
|
}, [state.selectedApiKey, apiKeys]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
193
262
|
|
|
194
|
-
// Sync URL when path parameters change
|
|
263
|
+
// Sync URL when path parameters or query parameters change. UrlBuilder
|
|
264
|
+
// handles BOTH path substitution and query string assembly — the old
|
|
265
|
+
// implementation only did the former, which silently dropped every
|
|
266
|
+
// non-path parameter the user entered.
|
|
195
267
|
useEffect(() => {
|
|
196
268
|
if (!state.selectedEndpoint) return;
|
|
197
|
-
const updated =
|
|
269
|
+
const updated = new UrlBuilder(state.selectedEndpoint, state.parameters).build();
|
|
198
270
|
if (updated !== state.requestUrl) {
|
|
199
271
|
dispatch({ type: 'SYNC_URL', url: updated });
|
|
200
272
|
}
|
|
@@ -226,6 +298,7 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({ children
|
|
|
226
298
|
dispatch({ type: 'SET_RESPONSE', response }), []);
|
|
227
299
|
const setLoading = useCallback((loading: boolean) => dispatch({ type: 'SET_LOADING', loading }), []);
|
|
228
300
|
const setSidebarOpen = useCallback((open: boolean) => dispatch({ type: 'SET_SIDEBAR', open }), []);
|
|
301
|
+
const setActiveSchemaId = useCallback((id: string | null) => dispatch({ type: 'SET_ACTIVE_SCHEMA_ID', id }), []);
|
|
229
302
|
const clearAll = useCallback(() => dispatch({ type: 'RESET' }), []);
|
|
230
303
|
|
|
231
304
|
// ── Send request ──────────────────────────────────────────────────────────
|
|
@@ -327,6 +400,7 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({ children
|
|
|
327
400
|
setResponse,
|
|
328
401
|
setLoading,
|
|
329
402
|
setSidebarOpen,
|
|
403
|
+
setActiveSchemaId,
|
|
330
404
|
clearAll,
|
|
331
405
|
sendRequest,
|
|
332
406
|
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { ApiEndpoint } from '../types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Per-endpoint draft kept in localStorage so user input survives across
|
|
9
|
+
* reloads and across switching back-and-forth between endpoints.
|
|
10
|
+
*
|
|
11
|
+
* Scoped by ``schemaId`` + ``method`` + ``path`` — two endpoints on the
|
|
12
|
+
* same path with different methods don't share drafts. Two different
|
|
13
|
+
* APIs don't collide either.
|
|
14
|
+
*
|
|
15
|
+
* Storage shape:
|
|
16
|
+
* { parameters: { [name]: string }, requestBody: string }
|
|
17
|
+
*
|
|
18
|
+
* Implementation note: we deliberately DO NOT build on top of
|
|
19
|
+
* ``useLocalStorage`` here. That hook's ``setValue`` callback reference
|
|
20
|
+
* changes whenever its internal state changes, and when EndpointDraftSync
|
|
21
|
+
* mirrors context → draft, the chain
|
|
22
|
+
* state change ⇒ persist call ⇒ hook state change ⇒ new callback ⇒
|
|
23
|
+
* effect rerun ⇒ persist call …
|
|
24
|
+
* blew up into a maximum-update-depth loop. Writing straight to
|
|
25
|
+
* ``localStorage`` with a stable ref-based writer breaks the cycle.
|
|
26
|
+
*/
|
|
27
|
+
export interface EndpointDraft {
|
|
28
|
+
parameters: Record<string, string>;
|
|
29
|
+
requestBody: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const EMPTY_DRAFT: EndpointDraft = { parameters: {}, requestBody: '' };
|
|
33
|
+
|
|
34
|
+
function storageKey(schemaId: string | null, ep: ApiEndpoint | null): string | null {
|
|
35
|
+
if (!schemaId || !ep) return null;
|
|
36
|
+
return `openapi-playground:draft:${schemaId}:${ep.method}:${ep.path}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readDraft(key: string | null): EndpointDraft {
|
|
40
|
+
if (!key || typeof window === 'undefined') return EMPTY_DRAFT;
|
|
41
|
+
try {
|
|
42
|
+
const raw = window.localStorage.getItem(key);
|
|
43
|
+
if (!raw) return EMPTY_DRAFT;
|
|
44
|
+
const parsed = JSON.parse(raw) as Partial<EndpointDraft>;
|
|
45
|
+
return {
|
|
46
|
+
parameters: parsed?.parameters ?? {},
|
|
47
|
+
requestBody: typeof parsed?.requestBody === 'string' ? parsed.requestBody : '',
|
|
48
|
+
};
|
|
49
|
+
} catch {
|
|
50
|
+
return EMPTY_DRAFT;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function writeDraft(key: string | null, value: EndpointDraft): void {
|
|
55
|
+
if (!key || typeof window === 'undefined') return;
|
|
56
|
+
try {
|
|
57
|
+
// Skip writes for empty drafts — reduces storage noise and keeps
|
|
58
|
+
// "never edited" endpoints out of storage entirely.
|
|
59
|
+
if (Object.keys(value.parameters).length === 0 && !value.requestBody) {
|
|
60
|
+
window.localStorage.removeItem(key);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
window.localStorage.setItem(key, JSON.stringify(value));
|
|
64
|
+
} catch {
|
|
65
|
+
// Quota / private mode — silently drop; UI state still works.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface UseEndpointDraftResult {
|
|
70
|
+
/** Draft snapshot loaded on mount / endpoint change. Does not update
|
|
71
|
+
* as the user types — the caller owns the "live" state (context). */
|
|
72
|
+
draft: EndpointDraft;
|
|
73
|
+
/** Persist the current parameters. Safe to call on every change;
|
|
74
|
+
* writes skip when the endpoint has no key yet. */
|
|
75
|
+
setParameters: (params: Record<string, string>) => void;
|
|
76
|
+
setRequestBody: (body: string) => void;
|
|
77
|
+
/** Wipe the persisted draft for the current endpoint. */
|
|
78
|
+
reset: () => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function useEndpointDraft(
|
|
82
|
+
schemaId: string | null,
|
|
83
|
+
endpoint: ApiEndpoint | null,
|
|
84
|
+
): UseEndpointDraftResult {
|
|
85
|
+
const key = storageKey(schemaId, endpoint);
|
|
86
|
+
|
|
87
|
+
// ``draft`` is reloaded from storage only when the key changes —
|
|
88
|
+
// not when we write. Writes don't need to come back to us because
|
|
89
|
+
// the caller keeps the live state and re-reads on key change.
|
|
90
|
+
const [draft, setDraftSnapshot] = useState<EndpointDraft>(() => readDraft(key));
|
|
91
|
+
|
|
92
|
+
// Track the last loaded key so we reload exactly once per endpoint.
|
|
93
|
+
const loadedKeyRef = useRef<string | null>(key);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (loadedKeyRef.current === key) return;
|
|
96
|
+
loadedKeyRef.current = key;
|
|
97
|
+
setDraftSnapshot(readDraft(key));
|
|
98
|
+
}, [key]);
|
|
99
|
+
|
|
100
|
+
// Keep a ref of the current key so the writers below stay stable
|
|
101
|
+
// across renders — React callback identity was the root cause of
|
|
102
|
+
// the infinite render loop we had before.
|
|
103
|
+
const keyRef = useRef(key);
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
keyRef.current = key;
|
|
106
|
+
}, [key]);
|
|
107
|
+
|
|
108
|
+
// Same for the latest full draft value — both writers need to merge
|
|
109
|
+
// their field into the last known shape before writing.
|
|
110
|
+
const latestRef = useRef<EndpointDraft>(draft);
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
latestRef.current = draft;
|
|
113
|
+
}, [draft]);
|
|
114
|
+
|
|
115
|
+
const setParameters = useCallback((params: Record<string, string>) => {
|
|
116
|
+
const next: EndpointDraft = {
|
|
117
|
+
parameters: params,
|
|
118
|
+
requestBody: latestRef.current.requestBody,
|
|
119
|
+
};
|
|
120
|
+
latestRef.current = next;
|
|
121
|
+
writeDraft(keyRef.current, next);
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
const setRequestBody = useCallback((body: string) => {
|
|
125
|
+
const next: EndpointDraft = {
|
|
126
|
+
parameters: latestRef.current.parameters,
|
|
127
|
+
requestBody: body,
|
|
128
|
+
};
|
|
129
|
+
latestRef.current = next;
|
|
130
|
+
writeDraft(keyRef.current, next);
|
|
131
|
+
}, []);
|
|
132
|
+
|
|
133
|
+
const reset = useCallback(() => {
|
|
134
|
+
latestRef.current = EMPTY_DRAFT;
|
|
135
|
+
if (keyRef.current && typeof window !== 'undefined') {
|
|
136
|
+
try { window.localStorage.removeItem(keyRef.current); } catch { /* noop */ }
|
|
137
|
+
}
|
|
138
|
+
setDraftSnapshot(EMPTY_DRAFT);
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
return { draft, setParameters, setRequestBody, reset };
|
|
142
|
+
}
|
|
@@ -3,27 +3,90 @@
|
|
|
3
3
|
import consola from 'consola';
|
|
4
4
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
5
5
|
|
|
6
|
-
import { ApiEndpoint, OpenApiSchema, SchemaSource, UseOpenApiSchemaReturn } from '../types';
|
|
6
|
+
import { ApiEndpoint, OpenApiInfo, OpenApiSchema, SchemaSource, UseOpenApiSchemaReturn } from '../types';
|
|
7
|
+
import { dereferenceSchema } from '../utils/schemaExport';
|
|
8
|
+
import { joinUrl, resolveBaseUrl } from '../utils/url';
|
|
9
|
+
|
|
10
|
+
// ─── JSON Schema → example value ──────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
type JsonSchemaNode = Record<string, unknown> & {
|
|
13
|
+
type?: string;
|
|
14
|
+
properties?: Record<string, JsonSchemaNode>;
|
|
15
|
+
required?: string[];
|
|
16
|
+
items?: JsonSchemaNode;
|
|
17
|
+
enum?: unknown[];
|
|
18
|
+
example?: unknown;
|
|
19
|
+
default?: unknown;
|
|
20
|
+
format?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Walk a JSON Schema and build a realistic-looking example value. */
|
|
24
|
+
function exampleFromSchema(schema: JsonSchemaNode | undefined, depth = 0): unknown {
|
|
25
|
+
if (!schema || depth > 8) return null;
|
|
26
|
+
|
|
27
|
+
// Respect schema-provided examples first — no need to invent a value
|
|
28
|
+
// when the spec author already did the work.
|
|
29
|
+
if (schema.example !== undefined) return schema.example;
|
|
30
|
+
if (schema.default !== undefined) return schema.default;
|
|
31
|
+
if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum[0];
|
|
32
|
+
|
|
33
|
+
switch (schema.type) {
|
|
34
|
+
case 'object': {
|
|
35
|
+
const out: Record<string, unknown> = {};
|
|
36
|
+
const props = schema.properties ?? {};
|
|
37
|
+
for (const [k, v] of Object.entries(props)) {
|
|
38
|
+
out[k] = exampleFromSchema(v, depth + 1);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
case 'array':
|
|
43
|
+
return [exampleFromSchema(schema.items, depth + 1)];
|
|
44
|
+
case 'integer':
|
|
45
|
+
case 'number':
|
|
46
|
+
return 0;
|
|
47
|
+
case 'boolean':
|
|
48
|
+
return false;
|
|
49
|
+
case 'string':
|
|
50
|
+
if (schema.format === 'date-time') return new Date().toISOString();
|
|
51
|
+
if (schema.format === 'date') return new Date().toISOString().slice(0, 10);
|
|
52
|
+
if (schema.format === 'email') return 'user@example.com';
|
|
53
|
+
if (schema.format === 'uri' || schema.format === 'url') return 'https://example.com';
|
|
54
|
+
if (schema.format === 'uuid') return '00000000-0000-0000-0000-000000000000';
|
|
55
|
+
return '';
|
|
56
|
+
default:
|
|
57
|
+
// No type (or composed schema like allOf/oneOf we don't unpack) —
|
|
58
|
+
// fall back to an empty object rather than ``null`` so the resulting
|
|
59
|
+
// JSON is still valid-looking.
|
|
60
|
+
if (schema.properties) {
|
|
61
|
+
const out: Record<string, unknown> = {};
|
|
62
|
+
for (const [k, v] of Object.entries(schema.properties)) {
|
|
63
|
+
out[k] = exampleFromSchema(v, depth + 1);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
7
70
|
|
|
8
71
|
// HTTP methods to extract from OpenAPI schema
|
|
9
72
|
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete'] as const;
|
|
10
73
|
|
|
11
|
-
// Extract endpoints from OpenAPI schema (all methods)
|
|
12
|
-
|
|
74
|
+
// Extract endpoints from OpenAPI schema (all methods). ``baseUrl`` is
|
|
75
|
+
// resolved by the caller via ``resolveBaseUrl`` — we just paste it onto
|
|
76
|
+
// the front of each path here.
|
|
77
|
+
const extractEndpoints = (schema: OpenApiSchema, baseUrl: string): ApiEndpoint[] => {
|
|
13
78
|
const endpoints: ApiEndpoint[] = [];
|
|
14
79
|
|
|
15
80
|
if (!schema.paths) return [];
|
|
16
81
|
|
|
17
|
-
// Get base URL from servers
|
|
18
|
-
const baseUrl = schema.servers && schema.servers.length > 0 ? schema.servers[0].url : '';
|
|
19
|
-
|
|
20
82
|
for (const [path, methods] of Object.entries(schema.paths)) {
|
|
21
83
|
for (const method of HTTP_METHODS) {
|
|
22
84
|
const op = (methods as any)[method];
|
|
23
85
|
if (!op) continue;
|
|
24
86
|
|
|
25
87
|
const methodUpper = method.toUpperCase();
|
|
26
|
-
const
|
|
88
|
+
const summary = (op.summary || '').trim();
|
|
89
|
+
const description = op.description || summary || `${methodUpper} ${path}`;
|
|
27
90
|
const category = op.tags?.[0] || 'Other';
|
|
28
91
|
|
|
29
92
|
const parameters: Array<{
|
|
@@ -59,21 +122,29 @@ const extractEndpoints = (schema: OpenApiSchema): ApiEndpoint[] => {
|
|
|
59
122
|
}
|
|
60
123
|
}
|
|
61
124
|
|
|
62
|
-
// Extract request body info
|
|
63
|
-
|
|
125
|
+
// Extract request body info — keep the dereferenced schema so
|
|
126
|
+
// downstream UI can render a fields table and generate a starter
|
|
127
|
+
// example instead of showing an opaque ``object`` / ``array`` tag.
|
|
128
|
+
let requestBody: ApiEndpoint['requestBody'];
|
|
64
129
|
if (op.requestBody) {
|
|
65
130
|
const content = op.requestBody.content;
|
|
66
131
|
const mediaType = content?.['application/json'] || content?.[Object.keys(content || {})[0]];
|
|
132
|
+
const rawSchema = mediaType?.schema as JsonSchemaNode | undefined;
|
|
67
133
|
requestBody = {
|
|
68
|
-
type:
|
|
134
|
+
type: rawSchema?.type || 'object',
|
|
69
135
|
description: op.requestBody.description,
|
|
136
|
+
schema: rawSchema,
|
|
137
|
+
example: rawSchema
|
|
138
|
+
? JSON.stringify(exampleFromSchema(rawSchema), null, 2)
|
|
139
|
+
: undefined,
|
|
70
140
|
};
|
|
71
141
|
}
|
|
72
142
|
|
|
73
143
|
const endpoint: ApiEndpoint = {
|
|
74
144
|
name: path.split('/').pop() || path,
|
|
75
145
|
method: methodUpper,
|
|
76
|
-
path: baseUrl
|
|
146
|
+
path: baseUrl ? joinUrl(baseUrl, path) : path,
|
|
147
|
+
summary,
|
|
77
148
|
description,
|
|
78
149
|
category,
|
|
79
150
|
parameters: parameters.length > 0 ? parameters : undefined,
|
|
@@ -111,11 +182,15 @@ const fetchSchema = async (url: string): Promise<OpenApiSchema> => {
|
|
|
111
182
|
interface UseOpenApiSchemaProps {
|
|
112
183
|
schemas: SchemaSource[];
|
|
113
184
|
defaultSchemaId?: string;
|
|
185
|
+
/** Global base URL override from ``PlaygroundConfig.baseUrl``.
|
|
186
|
+
* Per-schema ``SchemaSource.baseUrl`` takes precedence over this. */
|
|
187
|
+
baseUrl?: string;
|
|
114
188
|
}
|
|
115
189
|
|
|
116
190
|
export default function useOpenApiSchema({
|
|
117
191
|
schemas,
|
|
118
192
|
defaultSchemaId,
|
|
193
|
+
baseUrl: configBaseUrl,
|
|
119
194
|
}: UseOpenApiSchemaProps): UseOpenApiSchemaReturn {
|
|
120
195
|
const [loading, setLoading] = useState(true);
|
|
121
196
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -136,13 +211,45 @@ export default function useOpenApiSchema({
|
|
|
136
211
|
[loadedSchemas, currentSchemaId]
|
|
137
212
|
);
|
|
138
213
|
|
|
214
|
+
// Dereference once per schema load so endpoint extraction sees fully
|
|
215
|
+
// resolved ``$ref`` graphs. The raw schema is still exposed via
|
|
216
|
+
// ``rawSchema`` for Copy-for-AI (juniors may want to hand the raw
|
|
217
|
+
// document to an LLM and have it resolve refs itself).
|
|
218
|
+
const dereferencedSchema = useMemo(
|
|
219
|
+
() => (currentOpenApiSchema ? dereferenceSchema(currentOpenApiSchema) : null),
|
|
220
|
+
[currentOpenApiSchema],
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// Resolve base URL with priority chain. Centralised in ``resolveBaseUrl``
|
|
224
|
+
// so the same logic is reused by the schema-export utilities.
|
|
225
|
+
const resolvedBaseUrl = useMemo(
|
|
226
|
+
() =>
|
|
227
|
+
resolveBaseUrl({
|
|
228
|
+
schemaSource: currentSchema?.baseUrl,
|
|
229
|
+
config: configBaseUrl,
|
|
230
|
+
fromServers: currentOpenApiSchema?.servers?.[0]?.url,
|
|
231
|
+
}),
|
|
232
|
+
[currentSchema?.baseUrl, configBaseUrl, currentOpenApiSchema],
|
|
233
|
+
);
|
|
234
|
+
|
|
139
235
|
const endpoints = useMemo(
|
|
140
|
-
() => (
|
|
141
|
-
[
|
|
236
|
+
() => (dereferencedSchema ? extractEndpoints(dereferencedSchema, resolvedBaseUrl) : []),
|
|
237
|
+
[dereferencedSchema, resolvedBaseUrl]
|
|
142
238
|
);
|
|
143
239
|
|
|
144
240
|
const categories = useMemo(() => getCategories(endpoints), [endpoints]);
|
|
145
241
|
|
|
242
|
+
const schemaInfo = useMemo<OpenApiInfo | null>(() => {
|
|
243
|
+
if (!currentOpenApiSchema?.info) return null;
|
|
244
|
+
const { title, version, description } = currentOpenApiSchema.info;
|
|
245
|
+
return {
|
|
246
|
+
title,
|
|
247
|
+
version,
|
|
248
|
+
description,
|
|
249
|
+
servers: currentOpenApiSchema.servers,
|
|
250
|
+
};
|
|
251
|
+
}, [currentOpenApiSchema]);
|
|
252
|
+
|
|
146
253
|
// Load schema when current schema changes
|
|
147
254
|
useEffect(() => {
|
|
148
255
|
if (!currentSchema) return;
|
|
@@ -206,6 +313,12 @@ export default function useOpenApiSchema({
|
|
|
206
313
|
categories,
|
|
207
314
|
schemas,
|
|
208
315
|
currentSchema,
|
|
316
|
+
schemaInfo,
|
|
317
|
+
rawSchema: currentOpenApiSchema ?? null,
|
|
318
|
+
// Consumers expect ``undefined`` when no base URL was resolved (for
|
|
319
|
+
// conditional ``{ baseUrl?: … }`` plumbing). Turn the empty-string
|
|
320
|
+
// convention from the resolver into undefined at the API boundary.
|
|
321
|
+
resolvedBaseUrl: resolvedBaseUrl || undefined,
|
|
209
322
|
setCurrentSchema,
|
|
210
323
|
refresh,
|
|
211
324
|
};
|
|
@@ -4,12 +4,10 @@ import React, { lazy, Suspense } from 'react';
|
|
|
4
4
|
import { PlaygroundProvider } from './context/PlaygroundContext';
|
|
5
5
|
import type { PlaygroundConfig } from './types';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import('./components/PlaygroundLayout').then((mod) => ({ default: mod.PlaygroundLayout }))
|
|
7
|
+
const DocsLayout = lazy(() =>
|
|
8
|
+
import('./components/DocsLayout').then((mod) => ({ default: mod.DocsLayout }))
|
|
10
9
|
);
|
|
11
10
|
|
|
12
|
-
// Loading fallback component
|
|
13
11
|
const LoadingFallback = () => (
|
|
14
12
|
<div className="flex items-center justify-center min-h-[400px]">
|
|
15
13
|
<div className="text-muted-foreground">Loading API Playground...</div>
|
|
@@ -24,14 +22,12 @@ export const Playground: React.FC<PlaygroundProps> = ({ config }) => {
|
|
|
24
22
|
return (
|
|
25
23
|
<PlaygroundProvider config={config}>
|
|
26
24
|
<Suspense fallback={<LoadingFallback />}>
|
|
27
|
-
<
|
|
25
|
+
<DocsLayout />
|
|
28
26
|
</Suspense>
|
|
29
27
|
</PlaygroundProvider>
|
|
30
28
|
);
|
|
31
29
|
};
|
|
32
30
|
|
|
33
|
-
// Re-export types for convenience
|
|
34
31
|
export type { PlaygroundConfig, SchemaSource } from './types';
|
|
35
32
|
|
|
36
|
-
// Default export for dynamic import
|
|
37
33
|
export default Playground;
|
|
@@ -7,28 +7,20 @@
|
|
|
7
7
|
* Use this for automatic code-splitting with Suspense fallback.
|
|
8
8
|
*
|
|
9
9
|
* For direct imports without lazy loading, use:
|
|
10
|
-
*
|
|
10
|
+
* import OpenapiViewer from '@djangocfg/ui-tools/openapi'
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import * as React from 'react';
|
|
14
|
-
import { createLazyComponent
|
|
14
|
+
import { createLazyComponent } from '../../components';
|
|
15
15
|
import { PlaygroundProvider } from './context/PlaygroundContext';
|
|
16
16
|
import type { ApiKey, PlaygroundConfig, SchemaSource } from './types';
|
|
17
17
|
|
|
18
|
-
// ============================================================================
|
|
19
|
-
// Types
|
|
20
|
-
// ============================================================================
|
|
21
|
-
|
|
22
18
|
export interface PlaygroundProps {
|
|
23
19
|
config: PlaygroundConfig;
|
|
24
20
|
}
|
|
25
21
|
|
|
26
22
|
export type { ApiKey, PlaygroundConfig, SchemaSource };
|
|
27
23
|
|
|
28
|
-
// ============================================================================
|
|
29
|
-
// OpenAPI Loading Fallback
|
|
30
|
-
// ============================================================================
|
|
31
|
-
|
|
32
24
|
function OpenapiLoadingFallback() {
|
|
33
25
|
return (
|
|
34
26
|
<div className="flex items-center justify-center min-h-[400px] bg-muted/30 rounded-lg">
|
|
@@ -40,31 +32,18 @@ function OpenapiLoadingFallback() {
|
|
|
40
32
|
);
|
|
41
33
|
}
|
|
42
34
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// ============================================================================
|
|
46
|
-
|
|
47
|
-
const LazyPlaygroundLayout = createLazyComponent(
|
|
48
|
-
() => import('./components/PlaygroundLayout').then((mod) => ({ default: mod.PlaygroundLayout })),
|
|
35
|
+
const LazyDocsLayout = createLazyComponent(
|
|
36
|
+
() => import('./components/DocsLayout').then((mod) => ({ default: mod.DocsLayout })),
|
|
49
37
|
{
|
|
50
|
-
displayName: '
|
|
38
|
+
displayName: 'LazyDocsLayout',
|
|
51
39
|
fallback: <OpenapiLoadingFallback />,
|
|
52
40
|
}
|
|
53
41
|
);
|
|
54
42
|
|
|
55
|
-
// ============================================================================
|
|
56
|
-
// LazyOpenapiViewer
|
|
57
|
-
// ============================================================================
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* LazyOpenapiViewer - Lazy-loaded OpenAPI schema viewer & playground
|
|
61
|
-
*
|
|
62
|
-
* Automatically shows loading state while OpenAPI components load (~400KB)
|
|
63
|
-
*/
|
|
64
43
|
export const LazyOpenapiViewer: React.FC<PlaygroundProps> = ({ config }) => {
|
|
65
44
|
return (
|
|
66
45
|
<PlaygroundProvider config={config}>
|
|
67
|
-
<
|
|
46
|
+
<LazyDocsLayout />
|
|
68
47
|
</PlaygroundProvider>
|
|
69
48
|
);
|
|
70
49
|
};
|