blume 1.5.0 → 1.5.2
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/CHANGELOG.md +32 -0
- package/README.md +16 -12
- package/dist/cli/index.js +449 -135
- package/dist/cli/index.js.map +24 -23
- package/dist/types/ai/ask-context.d.ts +78 -0
- package/dist/types/core/config-input.d.ts +54 -2
- package/dist/types/core/data.d.ts +19 -2
- package/dist/types/core/open-in-chat.d.ts +9 -0
- package/dist/types/core/schema.d.ts +48 -1
- package/dist/types/core/types.d.ts +10 -3
- package/dist/types/openapi/references.d.ts +9 -0
- package/dist/types/search/orama-index.d.ts +70 -0
- package/dist/types/theme/fonts.d.ts +11 -2
- package/docs/advanced/api-reference.mdx +67 -5
- package/docs/advanced/custom-pages.mdx +5 -1
- package/docs/configuration/ai.mdx +35 -0
- package/docs/configuration/index.mdx +14 -2
- package/docs/configuration/search.mdx +4 -4
- package/docs/configuration/theming.mdx +4 -2
- package/docs/reference/cli.mdx +2 -2
- package/package.json +1 -1
- package/skills/blume-migrate/SKILL.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +1 -1
- package/src/ai/ask-context.ts +51 -11
- package/src/ai/mcp/data.ts +3 -2
- package/src/ai/mcp/server.ts +3 -2
- package/src/assets/icon-dark.png +0 -0
- package/src/astro/generate.ts +172 -18
- package/src/astro/templates.ts +89 -15
- package/src/components/content/AccordionItem.astro +4 -0
- package/src/components/content/Update.astro +3 -0
- package/src/components/islands/AskAI.astro +6 -0
- package/src/components/islands/ask-ai.tsx +39 -9
- package/src/components/layout/Analytics.astro +9 -1
- package/src/components/layout/Favicon.astro +29 -8
- package/src/components/layout/Fonts.astro +23 -3
- package/src/components/layout/Header.astro +2 -2
- package/src/components/layout/NavSelector.astro +1 -1
- package/src/components/layout/PageActions.astro +120 -78
- package/src/components/layout/PageFeedback.astro +12 -3
- package/src/components/layout/PageLayout.astro +79 -5
- package/src/components/layout/ReferenceLayout.astro +12 -9
- package/src/components/layout/RootLayout.astro +153 -121
- package/src/components/layout/Search.astro +41 -26
- package/src/components/layout/drawer-inert.ts +10 -5
- package/src/components/layout/head-scripts.ts +34 -16
- package/src/components/layout/nav-utils.ts +34 -15
- package/src/components/layout/search/orama.ts +3 -2
- package/src/components/openapi/AsyncApiOperation.astro +22 -7
- package/src/components/openapi/MessageComposer.astro +238 -0
- package/src/components/openapi/Operation.astro +26 -12
- package/src/components/openapi/PanelTabs.astro +7 -0
- package/src/components/openapi/Playground.astro +320 -0
- package/src/components/openapi/RequestPanel.astro +1 -0
- package/src/components/openapi/async-snippets.ts +20 -7
- package/src/components/openapi/async.ts +13 -2
- package/src/components/openapi/message-composer.ts +242 -0
- package/src/components/openapi/message-model.ts +108 -0
- package/src/components/openapi/message.ts +153 -0
- package/src/components/openapi/operation-model.ts +260 -0
- package/src/components/openapi/playground-client.ts +486 -0
- package/src/components/openapi/playground-schema.ts +109 -0
- package/src/components/openapi/request.ts +287 -0
- package/src/components/openapi/security.ts +0 -56
- package/src/components/openapi/snippets.ts +23 -136
- package/src/components/openapi/validate-json.ts +144 -0
- package/src/components/openapi/ws-client.ts +194 -0
- package/src/core/config-input.ts +67 -1
- package/src/core/content-assets.ts +66 -15
- package/src/core/data.ts +16 -2
- package/src/core/last-modified.ts +76 -2
- package/src/core/links.ts +30 -4
- package/src/core/navigation.ts +26 -1
- package/src/core/open-in-chat.ts +17 -0
- package/src/core/project-graph.ts +11 -0
- package/src/core/schema.ts +60 -1
- package/src/core/server-features.ts +11 -0
- package/src/core/sources/normalize.ts +10 -2
- package/src/core/types.ts +10 -3
- package/src/deploy/vercel-negotiation.ts +34 -14
- package/src/og/card.ts +3 -1
- package/src/openapi/model.ts +7 -0
- package/src/openapi/proxy.ts +217 -0
- package/src/openapi/references.ts +8 -0
- package/src/openapi/source.ts +13 -0
- package/src/registry/eject.ts +4 -5
- package/src/search/orama-index.ts +109 -36
- package/src/theme/entry.ts +15 -2
- package/src/theme/fonts.ts +75 -3
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import type { RequestSample } from "./snippets.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Framework-free request model for the "Try it" playground. This module ships
|
|
5
|
+
* in the client bundle, so it must stay dependency-free: parameter and body
|
|
6
|
+
* examples are precomputed server-side (`operation-model.ts`) and embedded in
|
|
7
|
+
* the model JSON — nothing here touches openapi-sampler or the spec document.
|
|
8
|
+
* `buildRequest` is THE one request builder: the code samples, the copy
|
|
9
|
+
* buttons, and the live fetch all consume its output, so what readers see is
|
|
10
|
+
* byte-for-byte what gets sent.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface PlaygroundParam {
|
|
14
|
+
name: string;
|
|
15
|
+
in: "path" | "query" | "header";
|
|
16
|
+
required: boolean;
|
|
17
|
+
description?: string;
|
|
18
|
+
/** Short type label for the input: "string" | "number" | "integer" | "boolean" | ... */
|
|
19
|
+
type: string;
|
|
20
|
+
/** Enum members stringified, when the schema declares them. */
|
|
21
|
+
enum?: string[];
|
|
22
|
+
/** Precomputed default from spec example/sampler; "" when none (optional params default ""). */
|
|
23
|
+
value: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PlaygroundBodyField {
|
|
27
|
+
name: string;
|
|
28
|
+
required: boolean;
|
|
29
|
+
description?: string;
|
|
30
|
+
/** Primitive type name; the client coerces number/integer/boolean. */
|
|
31
|
+
type: string;
|
|
32
|
+
enum?: string[];
|
|
33
|
+
/** Stringified default, "" when none. */
|
|
34
|
+
value: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A pruned, cycle-free JSON-schema subset `validate-json.ts` understands. */
|
|
38
|
+
export interface ValidationSchema {
|
|
39
|
+
type?: string;
|
|
40
|
+
properties?: Record<string, ValidationSchema>;
|
|
41
|
+
required?: string[];
|
|
42
|
+
items?: ValidationSchema;
|
|
43
|
+
enum?: unknown[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PlaygroundBody {
|
|
47
|
+
contentType: string;
|
|
48
|
+
/** Present when the schema is a flat object of primitives -> typed fields UI. */
|
|
49
|
+
fields?: PlaygroundBodyField[];
|
|
50
|
+
/** Pretty-printed example JSON prefill for the raw editor; "" when none. */
|
|
51
|
+
example: string;
|
|
52
|
+
schema?: ValidationSchema;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type AuthKind = "bearer" | "basic" | "apiKey" | "oauth2";
|
|
56
|
+
|
|
57
|
+
export interface PlaygroundAuthInput {
|
|
58
|
+
/** SecurityScheme component key. */
|
|
59
|
+
id: string;
|
|
60
|
+
kind: AuthKind;
|
|
61
|
+
/** `schemeLabel()` output. */
|
|
62
|
+
label: string;
|
|
63
|
+
carrier: { in: "header" | "query" | "cookie"; name: string };
|
|
64
|
+
/** Placeholder credential used in redacted samples, e.g. "YOUR_TOKEN". */
|
|
65
|
+
placeholder: string;
|
|
66
|
+
/** Header value prefix, e.g. "Bearer " ("" for apiKey). Basic uses "Basic ". */
|
|
67
|
+
prefix: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface PlaygroundModel {
|
|
71
|
+
/** Upper-case HTTP method. */
|
|
72
|
+
method: string;
|
|
73
|
+
/** Templated path, e.g. `/pets/{id}`. */
|
|
74
|
+
path: string;
|
|
75
|
+
/** Spec servers in order; first is the default base. */
|
|
76
|
+
servers: string[];
|
|
77
|
+
params: PlaygroundParam[];
|
|
78
|
+
body?: PlaygroundBody;
|
|
79
|
+
/** First security alternative (AND set). */
|
|
80
|
+
auth: PlaygroundAuthInput[];
|
|
81
|
+
authOptional: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface AuthValue {
|
|
85
|
+
value: string;
|
|
86
|
+
username?: string;
|
|
87
|
+
password?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface RequestValues {
|
|
91
|
+
/** Resolved base URL (custom override already applied). */
|
|
92
|
+
server: string;
|
|
93
|
+
/** Key: `paramKey(param)`. */
|
|
94
|
+
params: Record<string, string>;
|
|
95
|
+
/** Raw JSON text; undefined = no body. */
|
|
96
|
+
body?: string;
|
|
97
|
+
/** Key: `PlaygroundAuthInput.id`. */
|
|
98
|
+
auth: Record<string, AuthValue>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const paramKey = (p: { in: string; name: string }): string =>
|
|
102
|
+
`${p.in}:${p.name}`;
|
|
103
|
+
|
|
104
|
+
/** Values pre-filled from the model's precomputed examples; auth entries empty. */
|
|
105
|
+
export const defaultValues = (model: PlaygroundModel): RequestValues => ({
|
|
106
|
+
auth: Object.fromEntries(
|
|
107
|
+
model.auth.map((input) => [input.id, { value: "" }])
|
|
108
|
+
),
|
|
109
|
+
body: model.body ? model.body.example : undefined,
|
|
110
|
+
params: Object.fromEntries(
|
|
111
|
+
model.params.map((param) => [paramKey(param), param.value])
|
|
112
|
+
),
|
|
113
|
+
server: model.servers[0] ?? "",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/** Copy of `values` with every auth value emptied (redacted) so samples show placeholders. */
|
|
117
|
+
export const redactAuth = (
|
|
118
|
+
model: PlaygroundModel,
|
|
119
|
+
values: RequestValues
|
|
120
|
+
): RequestValues => ({
|
|
121
|
+
...values,
|
|
122
|
+
auth: Object.fromEntries(
|
|
123
|
+
model.auth.map((input) => [input.id, { value: "" }])
|
|
124
|
+
),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const TRAILING_SLASH = /\/+$/u;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Base64 of `text`'s UTF-8 bytes. `btoa` alone throws on any code point above
|
|
131
|
+
* U+00FF, so a credential with non-Latin-1 characters (a Cyrillic username, an
|
|
132
|
+
* emoji in a password) would take down every sample render; RFC 7617 names
|
|
133
|
+
* UTF-8 as the charset to encode a `user:password` pair in.
|
|
134
|
+
*/
|
|
135
|
+
const base64Utf8 = (text: string): string => {
|
|
136
|
+
let binary = "";
|
|
137
|
+
for (const byte of new TextEncoder().encode(text)) {
|
|
138
|
+
binary += String.fromCodePoint(byte);
|
|
139
|
+
}
|
|
140
|
+
return btoa(binary);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The credential a request carries for one auth input: the user's value when
|
|
145
|
+
* present, else the redaction placeholder. Basic auth encodes user:password
|
|
146
|
+
* per RFC 7617 — the placeholder stands in until either field is filled.
|
|
147
|
+
*/
|
|
148
|
+
const credentialFor = (
|
|
149
|
+
input: PlaygroundAuthInput,
|
|
150
|
+
auth: AuthValue | undefined
|
|
151
|
+
): string => {
|
|
152
|
+
if (input.kind === "basic") {
|
|
153
|
+
const username = auth?.username ?? "";
|
|
154
|
+
const password = auth?.password ?? "";
|
|
155
|
+
return username !== "" || password !== ""
|
|
156
|
+
? base64Utf8(`${username}:${password}`)
|
|
157
|
+
: input.placeholder;
|
|
158
|
+
}
|
|
159
|
+
const value = auth?.value ?? "";
|
|
160
|
+
return value === "" ? input.placeholder : value;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Apply the model's auth inputs to the outgoing query/headers. Query-borne
|
|
165
|
+
* credentials skip names an explicit query parameter already contributed (the
|
|
166
|
+
* spec's example wins); cookie-borne ones collapse into a single `Cookie`
|
|
167
|
+
* header, matching how browsers send them.
|
|
168
|
+
*/
|
|
169
|
+
const applyAuth = (
|
|
170
|
+
model: PlaygroundModel,
|
|
171
|
+
values: RequestValues,
|
|
172
|
+
seen: ReadonlySet<string>,
|
|
173
|
+
query: string[],
|
|
174
|
+
headers: Record<string, string>
|
|
175
|
+
): void => {
|
|
176
|
+
const cookies: string[] = [];
|
|
177
|
+
for (const input of model.auth) {
|
|
178
|
+
const credential = credentialFor(input, values.auth[input.id]);
|
|
179
|
+
if (input.carrier.in === "query") {
|
|
180
|
+
if (!seen.has(input.carrier.name)) {
|
|
181
|
+
query.push(
|
|
182
|
+
`${encodeURIComponent(input.carrier.name)}=${encodeURIComponent(
|
|
183
|
+
credential
|
|
184
|
+
)}`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
} else if (input.carrier.in === "cookie") {
|
|
188
|
+
cookies.push(`${input.carrier.name}=${credential}`);
|
|
189
|
+
} else {
|
|
190
|
+
headers[input.carrier.name] = `${input.prefix}${credential}`;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (cookies.length > 0) {
|
|
194
|
+
headers.Cookie = cookies.join("; ");
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/** The raw editor text to send, with a parsed mirror when it is valid JSON. */
|
|
199
|
+
const bodyFor = (
|
|
200
|
+
text: string | undefined
|
|
201
|
+
): Pick<RequestSample, "body" | "bodyValue"> => {
|
|
202
|
+
if (text === undefined || text === "") {
|
|
203
|
+
return {};
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
return { body: text, bodyValue: JSON.parse(text) };
|
|
207
|
+
} catch {
|
|
208
|
+
// Not valid JSON (mid-edit or intentionally raw): still send it — the
|
|
209
|
+
// structured mirror is only a nicety for consumers of `bodyValue`.
|
|
210
|
+
return { body: text };
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/** THE one request builder: samples, copy buttons, and fetch all consume its output. */
|
|
215
|
+
export const buildRequest = (
|
|
216
|
+
model: PlaygroundModel,
|
|
217
|
+
values: RequestValues
|
|
218
|
+
): RequestSample => {
|
|
219
|
+
const base = values.server.replace(TRAILING_SLASH, "");
|
|
220
|
+
|
|
221
|
+
// Path params: an empty value substitutes the raw param name, so a blank
|
|
222
|
+
// form still renders a readable templated URL instead of `//`.
|
|
223
|
+
let resolvedPath = model.path;
|
|
224
|
+
for (const param of model.params) {
|
|
225
|
+
if (param.in !== "path") {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const value = values.params[paramKey(param)] ?? "";
|
|
229
|
+
resolvedPath = resolvedPath.replace(
|
|
230
|
+
`{${param.name}}`,
|
|
231
|
+
value === "" ? param.name : encodeURIComponent(value)
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Query params with a value contribute in model order; a query-borne auth
|
|
236
|
+
// credential appends after — unless the spec also declares that name as an
|
|
237
|
+
// explicit query parameter, whose (better) example wins.
|
|
238
|
+
const query: string[] = [];
|
|
239
|
+
const seen = new Set<string>();
|
|
240
|
+
for (const param of model.params) {
|
|
241
|
+
if (param.in !== "query") {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const value = values.params[paramKey(param)] ?? "";
|
|
245
|
+
// A required param stays visible even when blank (`filter=`) — silently
|
|
246
|
+
// dropping it would make the samples deny the parameter exists. Optional
|
|
247
|
+
// blanks drop out entirely.
|
|
248
|
+
if (value === "" && !param.required) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
seen.add(param.name);
|
|
252
|
+
query.push(
|
|
253
|
+
`${encodeURIComponent(param.name)}=${encodeURIComponent(value)}`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Auth first, so a spec that also declares the credential as an explicit
|
|
258
|
+
// header parameter overrides it below with its own (better) example.
|
|
259
|
+
const headers: Record<string, string> = {};
|
|
260
|
+
applyAuth(model, values, seen, query, headers);
|
|
261
|
+
for (const param of model.params) {
|
|
262
|
+
if (param.in !== "header") {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const value = values.params[paramKey(param)] ?? "";
|
|
266
|
+
// Required headers emit even when blank, mirroring the query rule above —
|
|
267
|
+
// except a blank one never clobbers a credential auth already placed
|
|
268
|
+
// under the same name.
|
|
269
|
+
if (value !== "" || (param.required && headers[param.name] === undefined)) {
|
|
270
|
+
headers[param.name] = value;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const { body, bodyValue } = model.body ? bodyFor(values.body) : {};
|
|
275
|
+
if (body !== undefined && model.body) {
|
|
276
|
+
headers["Content-Type"] = model.body.contentType;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const search = query.length > 0 ? `?${query.join("&")}` : "";
|
|
280
|
+
return {
|
|
281
|
+
body,
|
|
282
|
+
bodyValue,
|
|
283
|
+
headers,
|
|
284
|
+
method: model.method.toUpperCase(),
|
|
285
|
+
url: `${base}${resolvedPath}${search}`,
|
|
286
|
+
};
|
|
287
|
+
};
|
|
@@ -152,9 +152,6 @@ export const resolveAsyncApiSecurity = (
|
|
|
152
152
|
return { alternatives, optional: false };
|
|
153
153
|
};
|
|
154
154
|
|
|
155
|
-
const capitalize = (text: string): string =>
|
|
156
|
-
text.charAt(0).toUpperCase() + text.slice(1);
|
|
157
|
-
|
|
158
155
|
/**
|
|
159
156
|
* Fixed labels for scheme types with no per-scheme variation. Covers both
|
|
160
157
|
* OpenAPI's types and the broker-auth types AsyncAPI adds; `http` is handled
|
|
@@ -219,56 +216,3 @@ export const schemeCarrier = (
|
|
|
219
216
|
}
|
|
220
217
|
}
|
|
221
218
|
};
|
|
222
|
-
|
|
223
|
-
/** Placeholder credentials the request samples send. */
|
|
224
|
-
export interface SampleAuth {
|
|
225
|
-
headers: Record<string, string>;
|
|
226
|
-
query: Record<string, string>;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Placeholder credentials for an operation's request samples, from its first
|
|
231
|
-
* alternative (the spec's preferred way to authorize). Schemes that don't
|
|
232
|
-
* travel in the request (mutual TLS) and unknown refs contribute nothing.
|
|
233
|
-
*/
|
|
234
|
-
export const sampleAuth = (security: OperationSecurity): SampleAuth => {
|
|
235
|
-
const headers: Record<string, string> = {};
|
|
236
|
-
const query: Record<string, string> = {};
|
|
237
|
-
const cookies: string[] = [];
|
|
238
|
-
for (const resolved of security.alternatives[0] ?? []) {
|
|
239
|
-
const { scheme } = resolved;
|
|
240
|
-
switch (scheme?.type) {
|
|
241
|
-
case "http": {
|
|
242
|
-
const kind = (scheme.scheme ?? "bearer").toLowerCase();
|
|
243
|
-
headers.Authorization =
|
|
244
|
-
kind === "bearer"
|
|
245
|
-
? "Bearer YOUR_TOKEN"
|
|
246
|
-
: `${capitalize(kind)} YOUR_CREDENTIALS`;
|
|
247
|
-
break;
|
|
248
|
-
}
|
|
249
|
-
case "oauth2":
|
|
250
|
-
case "openIdConnect": {
|
|
251
|
-
headers.Authorization = "Bearer YOUR_ACCESS_TOKEN";
|
|
252
|
-
break;
|
|
253
|
-
}
|
|
254
|
-
case "apiKey": {
|
|
255
|
-
const name = scheme.name ?? resolved.key;
|
|
256
|
-
if (scheme.in === "query") {
|
|
257
|
-
query[name] = "YOUR_API_KEY";
|
|
258
|
-
} else if (scheme.in === "cookie") {
|
|
259
|
-
cookies.push(`${name}=YOUR_API_KEY`);
|
|
260
|
-
} else {
|
|
261
|
-
headers[name] = "YOUR_API_KEY";
|
|
262
|
-
}
|
|
263
|
-
break;
|
|
264
|
-
}
|
|
265
|
-
default: {
|
|
266
|
-
break;
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
if (cookies.length > 0) {
|
|
271
|
-
headers.Cookie = cookies.join("; ");
|
|
272
|
-
}
|
|
273
|
-
return { headers, query };
|
|
274
|
-
};
|
|
@@ -1,31 +1,12 @@
|
|
|
1
|
-
import { exampleValue, toJson } from "./helpers.ts";
|
|
2
|
-
import type { SchemaLike } from "./helpers.ts";
|
|
3
|
-
import type { SampleAuth } from "./security.ts";
|
|
4
|
-
|
|
5
1
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
2
|
+
* Code-sample generation for an operation. Deliberately dependency-free: the
|
|
3
|
+
* playground client renders samples live in the browser, so this module must
|
|
4
|
+
* stay out of the server-only dependency graph (no helpers.ts, no
|
|
5
|
+
* openapi-sampler). Requests are assembled once by `buildRequest` in
|
|
6
|
+
* `request.ts`; the builders here only render a finished `RequestSample` as
|
|
7
|
+
* simple, copy-pasteable starter code — not an exhaustive SDK.
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
|
-
interface ParamLike {
|
|
12
|
-
name?: string;
|
|
13
|
-
in?: string;
|
|
14
|
-
required?: boolean;
|
|
15
|
-
schema?: SchemaLike;
|
|
16
|
-
example?: unknown;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface MediaTypeLike {
|
|
20
|
-
schema?: SchemaLike;
|
|
21
|
-
example?: unknown;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface OperationLike {
|
|
25
|
-
parameters?: ParamLike[];
|
|
26
|
-
requestBody?: { content?: Record<string, MediaTypeLike> };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
10
|
export interface RequestSample {
|
|
30
11
|
method: string;
|
|
31
12
|
url: string;
|
|
@@ -35,115 +16,6 @@ export interface RequestSample {
|
|
|
35
16
|
bodyValue?: unknown;
|
|
36
17
|
}
|
|
37
18
|
|
|
38
|
-
const TRAILING_SLASH = /\/+$/u;
|
|
39
|
-
|
|
40
|
-
const jsonContentType = (
|
|
41
|
-
content: Record<string, MediaTypeLike> | undefined
|
|
42
|
-
): [string, MediaTypeLike] | undefined => {
|
|
43
|
-
const entries = Object.entries(content ?? {});
|
|
44
|
-
return entries.find(([type]) => type.includes("json")) ?? entries[0];
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* The `?a=1&b=2` query string from an operation's required query params, plus
|
|
49
|
-
* any extra entries (a query-borne API key from the security requirements).
|
|
50
|
-
*/
|
|
51
|
-
const queryString = (
|
|
52
|
-
params: ParamLike[],
|
|
53
|
-
schemas: Record<string, SchemaLike>,
|
|
54
|
-
extra: Record<string, string>
|
|
55
|
-
): string => {
|
|
56
|
-
const query: string[] = [];
|
|
57
|
-
const seen = new Set<string>();
|
|
58
|
-
for (const param of params) {
|
|
59
|
-
if (!(param.in === "query" && param.required && param.name)) {
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
seen.add(param.name);
|
|
63
|
-
const value = param.example ?? exampleValue(param.schema, schemas);
|
|
64
|
-
query.push(
|
|
65
|
-
`${encodeURIComponent(param.name)}=${encodeURIComponent(
|
|
66
|
-
String(value ?? "")
|
|
67
|
-
)}`
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
for (const [name, value] of Object.entries(extra)) {
|
|
71
|
-
// A spec may declare the credential as an explicit query parameter too;
|
|
72
|
-
// its (better) example wins over the auth placeholder, as in headers.
|
|
73
|
-
if (seen.has(name)) {
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
76
|
-
query.push(`${encodeURIComponent(name)}=${encodeURIComponent(value)}`);
|
|
77
|
-
}
|
|
78
|
-
return query.length > 0 ? `?${query.join("&")}` : "";
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* The sample's headers: auth placeholders first, so a spec that also declares
|
|
83
|
-
* the credential as an explicit header parameter overrides them with its own
|
|
84
|
-
* (better) example.
|
|
85
|
-
*/
|
|
86
|
-
const headerValues = (
|
|
87
|
-
params: ParamLike[],
|
|
88
|
-
schemas: Record<string, SchemaLike>,
|
|
89
|
-
auth: SampleAuth | undefined
|
|
90
|
-
) => {
|
|
91
|
-
const headers = { ...auth?.headers };
|
|
92
|
-
for (const param of params) {
|
|
93
|
-
if (param.in === "header" && param.required && param.name) {
|
|
94
|
-
headers[param.name] = String(
|
|
95
|
-
param.example ?? exampleValue(param.schema, schemas) ?? ""
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
return headers;
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
/** Assemble a representative request from an operation and the spec servers. */
|
|
103
|
-
export const buildRequestSample = (
|
|
104
|
-
operation: OperationLike,
|
|
105
|
-
method: string,
|
|
106
|
-
path: string,
|
|
107
|
-
servers: { url?: string }[],
|
|
108
|
-
schemas: Record<string, SchemaLike>,
|
|
109
|
-
auth?: SampleAuth
|
|
110
|
-
): RequestSample => {
|
|
111
|
-
const base = (servers[0]?.url ?? "").replace(TRAILING_SLASH, "");
|
|
112
|
-
const params = operation.parameters ?? [];
|
|
113
|
-
|
|
114
|
-
let resolvedPath = path;
|
|
115
|
-
for (const param of params) {
|
|
116
|
-
if (param.in === "path" && param.name) {
|
|
117
|
-
const value = param.example ?? exampleValue(param.schema, schemas);
|
|
118
|
-
resolvedPath = resolvedPath.replace(
|
|
119
|
-
`{${param.name}}`,
|
|
120
|
-
encodeURIComponent(String(value ?? param.name))
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const search = queryString(params, schemas, auth?.query ?? {});
|
|
126
|
-
const headers = headerValues(params, schemas, auth);
|
|
127
|
-
|
|
128
|
-
const media = jsonContentType(operation.requestBody?.content);
|
|
129
|
-
let body: string | undefined;
|
|
130
|
-
let bodyValue: unknown;
|
|
131
|
-
if (media) {
|
|
132
|
-
const [type, mediaType] = media;
|
|
133
|
-
headers["Content-Type"] = type;
|
|
134
|
-
bodyValue = mediaType.example ?? exampleValue(mediaType.schema, schemas);
|
|
135
|
-
body = toJson(bodyValue);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return {
|
|
139
|
-
body,
|
|
140
|
-
bodyValue,
|
|
141
|
-
headers,
|
|
142
|
-
method: method.toUpperCase(),
|
|
143
|
-
url: `${base}${resolvedPath}${search}`,
|
|
144
|
-
};
|
|
145
|
-
};
|
|
146
|
-
|
|
147
19
|
const headerLines = (
|
|
148
20
|
headers: Record<string, string>,
|
|
149
21
|
format: (key: string, value: string) => string
|
|
@@ -174,7 +46,15 @@ const fetchSnippet = (sample: RequestSample): string => {
|
|
|
174
46
|
options.push(` headers: {\n${headers}\n }`);
|
|
175
47
|
}
|
|
176
48
|
if (sample.body) {
|
|
177
|
-
|
|
49
|
+
// `bodyValue` mirrors the body only when it parses as JSON. Mid-edit text
|
|
50
|
+
// that doesn't can't be inlined as a JS expression — a string literal of
|
|
51
|
+
// the raw text keeps the snippet syntactically valid and byte-identical
|
|
52
|
+
// to what the live send transmits.
|
|
53
|
+
options.push(
|
|
54
|
+
sample.bodyValue === undefined
|
|
55
|
+
? ` body: ${JSON.stringify(sample.body)}`
|
|
56
|
+
: ` body: JSON.stringify(${sample.body})`
|
|
57
|
+
);
|
|
178
58
|
}
|
|
179
59
|
return `const response = await fetch("${sample.url}", {\n${options.join(
|
|
180
60
|
",\n"
|
|
@@ -209,7 +89,14 @@ const pythonSnippet = (sample: RequestSample): string => {
|
|
|
209
89
|
args.push(` headers={\n${headers}\n }`);
|
|
210
90
|
}
|
|
211
91
|
if (sample.body) {
|
|
212
|
-
|
|
92
|
+
// Same rule as the fetch snippet: only valid JSON rewrites into a Python
|
|
93
|
+
// literal for `json=`; anything else travels as a raw string via `data=`
|
|
94
|
+
// (JSON string escapes are a subset of Python's, so the literal is valid).
|
|
95
|
+
args.push(
|
|
96
|
+
sample.bodyValue === undefined
|
|
97
|
+
? ` data=${JSON.stringify(sample.body)}`
|
|
98
|
+
: ` json=${toPython(sample.body)}`
|
|
99
|
+
);
|
|
213
100
|
}
|
|
214
101
|
return `import requests\n\nresponse = requests.${sample.method.toLowerCase()}(\n${args.join(
|
|
215
102
|
",\n"
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { ValidationSchema } from "./request.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal JSON-schema validation for the playground body editor. Client-safe
|
|
5
|
+
* and dependency-free by design: the server prunes the operation's schema into
|
|
6
|
+
* the tiny `ValidationSchema` subset (`operation-model.ts`), so a full
|
|
7
|
+
* draft-2020 validator would be dead weight in the browser bundle. Checks are
|
|
8
|
+
* advisory — they catch the common "typo'd a required field" mistakes, they do
|
|
9
|
+
* not gate the Send button.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** An already-parsed JSON object — string keys, parsed-JSON values. */
|
|
13
|
+
interface JsonObject {
|
|
14
|
+
[key: string]: JsonValue;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Already-parsed JSON — everything `JSON.parse` can produce. */
|
|
18
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
|
|
19
|
+
|
|
20
|
+
// `typeof` checks live in named predicates (the form the oxlint anti-slop
|
|
21
|
+
// config sanctions); each narrows the parsed-JSON union at its call site.
|
|
22
|
+
const isString = (value: JsonValue): value is string =>
|
|
23
|
+
typeof value === "string";
|
|
24
|
+
|
|
25
|
+
const isNumber = (value: JsonValue): value is number =>
|
|
26
|
+
typeof value === "number";
|
|
27
|
+
|
|
28
|
+
const isBoolean = (value: JsonValue): value is boolean =>
|
|
29
|
+
typeof value === "boolean";
|
|
30
|
+
|
|
31
|
+
const isJsonObject = (value: JsonValue): value is JsonObject =>
|
|
32
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
33
|
+
|
|
34
|
+
/** A human-readable name for a value's actual type, for mismatch messages. */
|
|
35
|
+
const describeValue = (value: JsonValue): string => {
|
|
36
|
+
if (value === null) {
|
|
37
|
+
return "null";
|
|
38
|
+
}
|
|
39
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- typeof's result string is itself the human-readable label here, not a narrowing check
|
|
40
|
+
return Array.isArray(value) ? "array" : typeof value;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Whether `value` satisfies a schema `type` keyword; unknown keywords pass. */
|
|
44
|
+
const matchesType = (value: JsonValue, type: string): boolean => {
|
|
45
|
+
switch (type) {
|
|
46
|
+
case "object": {
|
|
47
|
+
return isJsonObject(value);
|
|
48
|
+
}
|
|
49
|
+
case "array": {
|
|
50
|
+
return Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
case "string": {
|
|
53
|
+
return isString(value);
|
|
54
|
+
}
|
|
55
|
+
case "number": {
|
|
56
|
+
return isNumber(value);
|
|
57
|
+
}
|
|
58
|
+
case "integer": {
|
|
59
|
+
return isNumber(value) && Number.isInteger(value);
|
|
60
|
+
}
|
|
61
|
+
case "boolean": {
|
|
62
|
+
return isBoolean(value);
|
|
63
|
+
}
|
|
64
|
+
default: {
|
|
65
|
+
// A keyword the pruned subset doesn't model (e.g. "null"): no opinion.
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const walk = (
|
|
72
|
+
value: JsonValue,
|
|
73
|
+
schema: ValidationSchema | undefined,
|
|
74
|
+
path: string,
|
|
75
|
+
errors: string[]
|
|
76
|
+
): void => {
|
|
77
|
+
if (!schema) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (schema.type !== undefined && !matchesType(value, schema.type)) {
|
|
81
|
+
// Deeper checks against a wrong-shaped value would only produce noise.
|
|
82
|
+
errors.push(
|
|
83
|
+
`${path} should be ${schema.type}, got ${describeValue(value)}`
|
|
84
|
+
);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (
|
|
88
|
+
schema.enum !== undefined &&
|
|
89
|
+
!schema.enum.some(
|
|
90
|
+
(member) => JSON.stringify(member) === JSON.stringify(value)
|
|
91
|
+
)
|
|
92
|
+
) {
|
|
93
|
+
errors.push(
|
|
94
|
+
`${path} must be one of: ${schema.enum
|
|
95
|
+
.map((member) => JSON.stringify(member))
|
|
96
|
+
.join(", ")}`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (isJsonObject(value)) {
|
|
100
|
+
for (const name of schema.required ?? []) {
|
|
101
|
+
if (!(name in value)) {
|
|
102
|
+
errors.push(`${path}.${name} is required`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const [name, property] of Object.entries(schema.properties ?? {})) {
|
|
106
|
+
if (name in value) {
|
|
107
|
+
// SAFETY: `name in value` above proves the key exists, and parsed
|
|
108
|
+
// JSON never holds `undefined` — the lookup is always a `JsonValue`
|
|
109
|
+
// that `noUncheckedIndexedAccess` alone cannot see.
|
|
110
|
+
walk(value[name] as JsonValue, property, `${path}.${name}`, errors);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(value) && schema.items) {
|
|
115
|
+
for (const [index, item] of value.entries()) {
|
|
116
|
+
walk(item, schema.items, `${path}[${index}]`, errors);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Validate raw JSON text against a pruned schema. Returns human-readable
|
|
123
|
+
* error messages; an empty array means valid. With no schema, only syntax is
|
|
124
|
+
* checked. `root` names the value in those messages — the HTTP panel edits a
|
|
125
|
+
* `body`, the event composer a `payload`.
|
|
126
|
+
*/
|
|
127
|
+
export const validateJson = (
|
|
128
|
+
text: string,
|
|
129
|
+
schema?: ValidationSchema,
|
|
130
|
+
root = "body"
|
|
131
|
+
): string[] => {
|
|
132
|
+
// `JSON.parse` returns `any`; `JsonValue` is exactly its possible outputs.
|
|
133
|
+
let value: JsonValue;
|
|
134
|
+
try {
|
|
135
|
+
value = JSON.parse(text);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return [
|
|
138
|
+
`Invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
const errors: string[] = [];
|
|
142
|
+
walk(value, schema, root, errors);
|
|
143
|
+
return errors;
|
|
144
|
+
};
|