@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.
Files changed (79) hide show
  1. package/dist/DocsLayout-BCVU6TTX.cjs +2027 -0
  2. package/dist/DocsLayout-BCVU6TTX.cjs.map +1 -0
  3. package/dist/DocsLayout-ERETJLLV.mjs +2020 -0
  4. package/dist/DocsLayout-ERETJLLV.mjs.map +1 -0
  5. package/dist/{PlaygroundLayout-O52C6HK5.css → DocsLayout-MBFIB4NO.css} +1 -1
  6. package/dist/{PrettyCode.client-SGDGQTYT.cjs → PrettyCode.client-5GABIN2I.cjs} +57 -35
  7. package/dist/PrettyCode.client-5GABIN2I.cjs.map +1 -0
  8. package/dist/{PrettyCode.client-DW5LTG47.mjs → PrettyCode.client-IZTXXYHG.mjs} +57 -35
  9. package/dist/PrettyCode.client-IZTXXYHG.mjs.map +1 -0
  10. package/dist/chunk-IULI4XII.cjs +1129 -0
  11. package/dist/chunk-IULI4XII.cjs.map +1 -0
  12. package/dist/chunk-VZGQC3NG.mjs +1100 -0
  13. package/dist/chunk-VZGQC3NG.mjs.map +1 -0
  14. package/dist/index.cjs +88 -552
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +18 -6
  17. package/dist/index.d.ts +18 -6
  18. package/dist/index.mjs +25 -496
  19. package/dist/index.mjs.map +1 -1
  20. package/package.json +6 -6
  21. package/src/tools/OpenapiViewer/.claude/.sidecar/activity.jsonl +6 -0
  22. package/src/tools/OpenapiViewer/.claude/.sidecar/history/2026-04-22.md +35 -0
  23. package/src/tools/OpenapiViewer/.claude/.sidecar/map_cache.json +30 -0
  24. package/src/tools/OpenapiViewer/.claude/.sidecar/review.md +35 -0
  25. package/src/tools/OpenapiViewer/.claude/.sidecar/scan.log +3 -0
  26. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-001.md +18 -0
  27. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-002.md +18 -0
  28. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-003.md +18 -0
  29. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-004.md +18 -0
  30. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-005.md +18 -0
  31. package/src/tools/OpenapiViewer/.claude/.sidecar/usage.json +5 -0
  32. package/src/tools/OpenapiViewer/.claude/project-map.md +23 -0
  33. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +28 -2
  34. package/src/tools/OpenapiViewer/README.md +104 -51
  35. package/src/tools/OpenapiViewer/components/DocsLayout/ApiIntroSection.tsx +64 -0
  36. package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +137 -0
  37. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +268 -0
  38. package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +139 -0
  39. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +211 -0
  40. package/src/tools/OpenapiViewer/components/DocsLayout/SlideInPlayground.tsx +101 -0
  41. package/src/tools/OpenapiViewer/components/DocsLayout/TryItSheet.tsx +57 -0
  42. package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +11 -0
  43. package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +71 -0
  44. package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +166 -0
  45. package/src/tools/OpenapiViewer/components/DocsLayout/schemaFields.ts +121 -0
  46. package/src/tools/OpenapiViewer/components/DocsLayout/sidebarLabel.ts +60 -0
  47. package/src/tools/OpenapiViewer/components/index.ts +5 -2
  48. package/src/tools/OpenapiViewer/components/shared/BodyFormEditor.tsx +422 -0
  49. package/src/tools/OpenapiViewer/components/shared/EndpointDraftSync.tsx +108 -0
  50. package/src/tools/OpenapiViewer/components/shared/EndpointResetButton.tsx +50 -0
  51. package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/RequestPanel.tsx +174 -87
  52. package/src/tools/OpenapiViewer/components/shared/SendButton.tsx +91 -0
  53. package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ui.tsx +5 -4
  54. package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +82 -8
  55. package/src/tools/OpenapiViewer/hooks/useEndpointDraft.ts +142 -0
  56. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +126 -13
  57. package/src/tools/OpenapiViewer/index.tsx +3 -7
  58. package/src/tools/OpenapiViewer/lazy.tsx +6 -27
  59. package/src/tools/OpenapiViewer/types.ts +44 -0
  60. package/src/tools/OpenapiViewer/utils/formatters.ts +2 -23
  61. package/src/tools/OpenapiViewer/utils/index.ts +3 -1
  62. package/src/tools/OpenapiViewer/utils/schemaExport.ts +206 -0
  63. package/src/tools/OpenapiViewer/utils/url.ts +202 -0
  64. package/src/tools/PrettyCode/PrettyCode.client.tsx +42 -8
  65. package/src/tools/PrettyCode/index.tsx +6 -0
  66. package/dist/PlaygroundLayout-DHUATCHB.cjs +0 -798
  67. package/dist/PlaygroundLayout-DHUATCHB.cjs.map +0 -1
  68. package/dist/PlaygroundLayout-NONWOVQR.mjs +0 -791
  69. package/dist/PlaygroundLayout-NONWOVQR.mjs.map +0 -1
  70. package/dist/PrettyCode.client-DW5LTG47.mjs.map +0 -1
  71. package/dist/PrettyCode.client-SGDGQTYT.cjs.map +0 -1
  72. package/dist/chunk-5FKE7OME.cjs +0 -369
  73. package/dist/chunk-5FKE7OME.cjs.map +0 -1
  74. package/dist/chunk-BKWDHJKF.mjs +0 -356
  75. package/dist/chunk-BKWDHJKF.mjs.map +0 -1
  76. package/src/tools/OpenapiViewer/components/PlaygroundLayout/EndpointList.tsx +0 -228
  77. package/src/tools/OpenapiViewer/components/PlaygroundLayout/index.tsx +0 -107
  78. /package/dist/{PlaygroundLayout-O52C6HK5.css.map → DocsLayout-MBFIB4NO.css.map} +0 -0
  79. /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, substituteUrlParameters } from '../utils';
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
- // Auto-select first API key
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 (!isLoadingApiKeys && apiKeys.length > 0 && !state.selectedApiKey) {
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 = substituteUrlParameters(state.selectedEndpoint.path, state.parameters);
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
- const extractEndpoints = (schema: OpenApiSchema): ApiEndpoint[] => {
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 description = op.description || op.summary || `${methodUpper} ${path}`;
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
- let requestBody: { type: string; description?: string } | undefined;
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: mediaType?.schema?.type || 'object',
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 + path,
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
- () => (currentOpenApiSchema ? extractEndpoints(currentOpenApiSchema) : []),
141
- [currentOpenApiSchema]
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
- // Lazy load the PlaygroundLayout component
8
- const PlaygroundLayout = lazy(() =>
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
- <PlaygroundLayout />
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
- * import OpenapiViewer from '@djangocfg/ui-tools/openapi'
10
+ * import OpenapiViewer from '@djangocfg/ui-tools/openapi'
11
11
  */
12
12
 
13
13
  import * as React from 'react';
14
- import { createLazyComponent, LoadingFallback } from '../../components';
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
- // Lazy Component (internal)
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: 'LazyPlaygroundLayout',
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
- <LazyPlaygroundLayout />
46
+ <LazyDocsLayout />
68
47
  </PlaygroundProvider>
69
48
  );
70
49
  };