rapidoc 12.0.0 → 12.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +127 -25
  2. package/dist/rapidoc-flow-min.js +580 -0
  3. package/dist/rapidoc-flow-min.js.LICENSE.txt +171 -0
  4. package/dist/rapidoc-min.js +541 -1309
  5. package/dist/rapidoc-min.js.LICENSE.txt +381 -0
  6. package/package.json +21 -18
  7. package/src/README.md +150 -0
  8. package/src/components/README.md +91 -0
  9. package/src/components/api-request.ts +43 -0
  10. package/src/components/api-response.ts +9 -4
  11. package/src/components/schema-base-element.ts +18 -4
  12. package/src/components/schema-table.ts +58 -13
  13. package/src/components/schema-tree.ts +7 -0
  14. package/src/components/tag-input.ts +5 -9
  15. package/src/flow.js +3 -0
  16. package/src/rapi-flow.ts +1178 -0
  17. package/src/rapidoc-mini.ts +27 -6
  18. package/src/rapidoc.ts +108 -62
  19. package/src/styles/README.md +96 -0
  20. package/src/styles/css-bundle.js +2 -2
  21. package/src/styles/endpoint-styles.js +6 -1
  22. package/src/styles/flow-styles.js +248 -0
  23. package/src/styles/nav-styles.js +39 -15
  24. package/src/styles/schema-styles.js +13 -5
  25. package/src/styles/theme-tokens.js +2 -2
  26. package/src/templates/README.md +89 -0
  27. package/src/templates/api-response-template.js +14 -10
  28. package/src/templates/callback-template.js +1 -1
  29. package/src/templates/downloads-template.js +12 -27
  30. package/src/templates/endpoint-template.js +15 -24
  31. package/src/templates/expanded-endpoint-template.js +8 -4
  32. package/src/templates/focused-endpoint-template.js +4 -3
  33. package/src/templates/navbar-template.js +20 -11
  34. package/src/templates/request-body-template.js +29 -7
  35. package/src/templates/request-params-template.js +15 -4
  36. package/src/templates/security-scheme-template.js +11 -2
  37. package/src/templates/server-template.js +3 -2
  38. package/src/utils/README.md +146 -0
  39. package/src/utils/arazzo/README.md +119 -0
  40. package/src/utils/arazzo/components.js +15 -0
  41. package/src/utils/arazzo/graph.js +269 -0
  42. package/src/utils/arazzo/index.js +52 -0
  43. package/src/utils/arazzo/operations.js +106 -0
  44. package/src/utils/arazzo/parse.js +196 -0
  45. package/src/utils/arazzo/refs.js +40 -0
  46. package/src/utils/arazzo/svg.js +707 -0
  47. package/src/utils/curl-utils.js +5 -2
  48. package/src/utils/highlighter.js +3 -0
  49. package/src/utils/mcp/README.md +155 -0
  50. package/src/utils/mcp/common.js +45 -346
  51. package/src/utils/mcp/manifest.js +382 -0
  52. package/src/utils/mcp/project/server.ts +53 -0
  53. package/src/utils/mcp/project.js +88 -0
  54. package/src/utils/mcp/runtime.js +289 -0
  55. package/src/utils/mcp/schema.js +98 -0
  56. package/src/utils/mcp/workflow-runtime.js +374 -0
  57. package/src/utils/mcp/workflows.js +240 -0
  58. package/src/utils/mcp/zip.js +102 -0
  59. package/src/utils/mcp-generator.js +29 -125
  60. package/src/utils/mock-interceptor.js +134 -85
  61. package/src/utils/request-utils.js +61 -0
  62. package/src/utils/schema-utils.js +33 -6
  63. package/src/utils/slugger.js +5 -7
  64. package/src/utils/spec-parser.js +190 -164
  65. package/src/utils/url-utils.js +36 -1
  66. package/dist/rapidoc-static-min.js +0 -13542
  67. package/src/static/index.d.ts +0 -149
  68. package/src/static/index.js +0 -62
  69. package/src/static/navigation.js +0 -497
  70. package/src/static/render.js +0 -490
  71. package/src/static/serializer.js +0 -216
  72. package/src/utils/mcp/generators/csharp.js +0 -242
  73. package/src/utils/mcp/generators/go.js +0 -298
  74. package/src/utils/mcp/generators/java.js +0 -330
  75. package/src/utils/mcp/generators/python.js +0 -248
  76. package/src/utils/mcp/generators/rust.js +0 -281
  77. package/src/utils/mcp/generators/typescript.js +0 -290
@@ -157,6 +157,67 @@ export function buildUrlEncodedBody(fields = [], dynamicJson = "") {
157
157
  return params;
158
158
  }
159
159
 
160
+ const isComplexSchema = (schema) =>
161
+ !!schema &&
162
+ (schema.type === "object" ||
163
+ (Array.isArray(schema.type) && schema.type.includes("object")) ||
164
+ (!schema.type && !!schema.properties));
165
+
166
+ /**
167
+ * Content-Type of a multipart/form-data part, following OpenAPI's "Special Considerations for multipart Content":
168
+ * the encoding object's contentType when the spec gives one (the first of a comma-separated list), application/json
169
+ * for an object or an array of objects, and "" otherwise (text/plain, or a file's own type).
170
+ *
171
+ * @param {object} schema - Schema of the form field
172
+ * @param {object} [encoding] - The media type's encoding entry for that field
173
+ * @returns {string}
174
+ */
175
+ export function multipartPartContentType(schema, encoding) {
176
+ const declared =
177
+ typeof encoding?.contentType === "string" ? encoding.contentType.split(",")[0].trim() : "";
178
+ if (declared) {
179
+ return declared;
180
+ }
181
+ if (isComplexSchema(schema) || (schema?.type === "array" && isComplexSchema(schema.items))) {
182
+ return "application/json";
183
+ }
184
+ return "";
185
+ }
186
+
187
+ // Quotes and line breaks in part names and file names are percent-encoded, as browsers do for FormData
188
+ const escapePartName = (name) =>
189
+ String(name).replace(/"/g, "%22").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
190
+
191
+ /**
192
+ * Serializes FormData into a multipart/form-data body in which parts carry a Content-Type.
193
+ * A browser only sends a Content-Type for a part that is a file, and a Blob part always gets filename="blob",
194
+ * which servers read as an upload; so parts with a type in partContentTypes are written by hand.
195
+ *
196
+ * @param {FormData} formData
197
+ * @param {Object<string, string>} partContentTypes - Content-Type per field name, for fields that are not files
198
+ * @param {string} [boundary]
199
+ * @returns {{ body: Blob, contentType: string }}
200
+ */
201
+ export function serializeMultipartBody(
202
+ formData,
203
+ partContentTypes = {},
204
+ boundary = `----RapiDocFormBoundary${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`,
205
+ ) {
206
+ const chunks = [];
207
+ for (const [name, value] of formData.entries()) {
208
+ const isFile = typeof value !== "string";
209
+ let head = `--${boundary}\r\nContent-Disposition: form-data; name="${escapePartName(name)}"`;
210
+ if (isFile) {
211
+ head += `; filename="${escapePartName(value.name ?? "blob")}"\r\nContent-Type: ${value.type || partContentTypes[name] || "application/octet-stream"}`;
212
+ } else if (partContentTypes[name]) {
213
+ head += `\r\nContent-Type: ${partContentTypes[name]}`;
214
+ }
215
+ chunks.push(`${head}\r\n\r\n`, isFile ? value : value.replace(/\r?\n/g, "\r\n"), "\r\n");
216
+ }
217
+ chunks.push(`--${boundary}--\r\n`);
218
+ return { body: new Blob(chunks), contentType: `multipart/form-data; boundary=${boundary}` };
219
+ }
220
+
160
221
  /**
161
222
  * Builds a FormData instance from structured field entries.
162
223
  *
@@ -129,6 +129,23 @@ export function isBinaryFileField(schema) {
129
129
  );
130
130
  }
131
131
 
132
+ // Keywords that only annotate a schema, without telling anything about the type of the value
133
+ const ANNOTATION_KEYWORDS = new Set([
134
+ "title",
135
+ "description",
136
+ "example",
137
+ "examples",
138
+ "default",
139
+ "deprecated",
140
+ "readOnly",
141
+ "writeOnly",
142
+ "externalDocs",
143
+ ]);
144
+
145
+ function isAnnotationOnly(schema) {
146
+ return Object.keys(schema).every((key) => ANNOTATION_KEYWORDS.has(key) || key.startsWith("x-"));
147
+ }
148
+
132
149
  /* Generates an schema object containing type and constraint info */
133
150
  export function getTypeInfo(schema) {
134
151
  if (!schema) {
@@ -170,7 +187,7 @@ export function getTypeInfo(schema) {
170
187
  }
171
188
  });
172
189
  dataType = subTypes.length > 0 ? subTypes.join("┃") : "{missing-type-info}";
173
- } else if (Object.keys(schema).length === 0) {
190
+ } else if (isAnnotationOnly(schema)) {
174
191
  dataType = "any";
175
192
  } else {
176
193
  dataType = "{missing-type-info}";
@@ -217,9 +234,7 @@ export function getTypeInfo(schema) {
217
234
  html: "",
218
235
  };
219
236
 
220
- if (info.type === "{recursive}") {
221
- info.description = schema.$ref.substring(schema.$ref.lastIndexOf("/") + 1);
222
- } else if (info.type === "{missing-type-info}" || info.type === "any") {
237
+ if (info.type === "{missing-type-info}" || info.type === "any") {
223
238
  info.description = info.description || "";
224
239
  }
225
240
  // Set Allowed Values
@@ -1316,8 +1331,8 @@ function createPrimitiveAST(schema, name, isRequired) {
1316
1331
  title: (schema.title || "").trim(),
1317
1332
  required: isRequired,
1318
1333
  deprecated: schema.deprecated || !!typeInfo.deprecated,
1319
- readOnly: schema.readOnly || typeInfo.readOrWriteOnly === "readonly",
1320
- writeOnly: schema.writeOnly || typeInfo.readOrWriteOnly === "writeonly",
1334
+ readOnly: schema.readOnly || typeInfo.readOrWriteOnly === "🆁",
1335
+ writeOnly: schema.writeOnly || typeInfo.readOrWriteOnly === "🆆",
1321
1336
  html: typeInfo.html || "",
1322
1337
  };
1323
1338
  }
@@ -1502,6 +1517,10 @@ function composeSchemaAST(schema, level, name, isRequired, suffix) {
1502
1517
  optAst.writeOnly = false;
1503
1518
  optAst.optionIndex = idx + 1;
1504
1519
  optAst.optionTitle = optTitle;
1520
+ if (optAst.kind === "primitive" && !optTitle) {
1521
+ // The OPT n badge already names it, a row labelled "Option n" would only repeat it
1522
+ optAst.name = "";
1523
+ }
1505
1524
  }
1506
1525
  return optAst;
1507
1526
  })
@@ -1877,6 +1896,14 @@ function getSerializeStyleForContentType(contentType) {
1877
1896
  return null;
1878
1897
  }
1879
1898
 
1899
+ /**
1900
+ * Text that names an example in an examples dropdown: its summary, or its key when it has none.
1901
+ * A long summary is shown whole; the dropdown wraps it, or cuts it where the browser's select can't wrap.
1902
+ */
1903
+ export function exampleLabel(example) {
1904
+ return example?.exampleSummary || example?.exampleId || "";
1905
+ }
1906
+
1880
1907
  export function getSchemaFromParam(param) {
1881
1908
  if (param.schema) {
1882
1909
  return [param.schema, null, null];
@@ -129,23 +129,21 @@ export class OperationSlugger {
129
129
  return this.opMap.get(opKey);
130
130
  }
131
131
 
132
- let candidate = this.slugOp(op);
132
+ // Hierarchical style prefixes with tag if provided; `used` holds the prefixed slugs
133
+ const prefix = tagSlug && tagSlug !== "general" ? `${tagSlug}--` : "";
134
+ const base = this.slugOp(op);
135
+ let candidate = `${prefix}${base}`;
133
136
 
134
137
  // Smart semantic collision differentiator:
135
138
  // If the base candidate has already been seen in this spec, attempt method-prefix first (e.g. post-webhooks)
136
139
  if (this.used.has(candidate)) {
137
140
  const method = (op.method || "get").toLowerCase();
138
- const methodCandidate = `${method}-${candidate}`;
141
+ const methodCandidate = `${prefix}${method}-${base}`;
139
142
  if (!this.used.has(methodCandidate)) {
140
143
  candidate = methodCandidate;
141
144
  }
142
145
  }
143
146
 
144
- // Hierarchical style prefixes with tag if provided
145
- if (tagSlug && tagSlug !== "general") {
146
- candidate = `${tagSlug}--${candidate}`;
147
- }
148
-
149
147
  // Guaranteed uniqueness via GitHub slugger registry
150
148
  const finalSlug = this.slugger.slug(candidate);
151
149
  this.used.add(finalSlug);
@@ -169,6 +169,7 @@ export default async function ProcessSpec(
169
169
  matchPaths = "",
170
170
  matchType = "",
171
171
  removeEndpointsWithBadgeLabelAs = "",
172
+ showUnusedTags = false,
172
173
  ) {
173
174
  let jsonParsedSpec;
174
175
  try {
@@ -274,139 +275,163 @@ export default async function ProcessSpec(
274
275
  };
275
276
  }
276
277
 
277
- const hasTagGroups = Boolean(
278
- (jsonParsedSpec?.["x-tagGroups"] && jsonParsedSpec["x-tagGroups"].length > 0) ||
279
- (jsonParsedSpec?.["x-tag-groups"] && jsonParsedSpec["x-tag-groups"].length > 0),
280
- );
278
+ // Everything after loading is also guarded: a spec that parses but trips the grouping code
279
+ // (a server without `url`, a tag without `name`, ...) still yields a `specLoadError` result
280
+ try {
281
+ const hasTagGroups = Boolean(
282
+ (jsonParsedSpec?.["x-tagGroups"] && jsonParsedSpec["x-tagGroups"].length > 0) ||
283
+ (jsonParsedSpec?.["x-tag-groups"] && jsonParsedSpec["x-tag-groups"].length > 0),
284
+ );
281
285
 
282
- // Tags with Paths and WebHooks
283
- const tags = groupByTags(
284
- jsonParsedSpec,
285
- sortEndpointsBy,
286
- generateMissingTags,
287
- sortTags,
288
- hasTagGroups,
289
- );
286
+ // Tags with Paths and WebHooks
287
+ const tags = groupByTags(
288
+ jsonParsedSpec,
289
+ sortEndpointsBy,
290
+ generateMissingTags,
291
+ sortTags,
292
+ hasTagGroups,
293
+ showUnusedTags,
294
+ );
290
295
 
291
- // 3-Level Tag Groups (x-tagGroups / x-tag-groups)
292
- const tagGroups = groupByTagGroups(jsonParsedSpec, tags);
293
-
294
- // Components
295
- const components = getComponents(jsonParsedSpec, sortSchemas);
296
-
297
- // Info Description Headers
298
- const infoDescriptionHeaders = jsonParsedSpec.info?.description
299
- ? getHeadersFromMarkdown(jsonParsedSpec.info.description)
300
- : [];
301
-
302
- // Security Scheme
303
- const securitySchemes = [];
304
- if (jsonParsedSpec.components?.securitySchemes) {
305
- const securitySchemeSet = new Set();
306
- Object.entries(jsonParsedSpec.components.securitySchemes).forEach((kv) => {
307
- if (!securitySchemeSet.has(kv[0])) {
308
- securitySchemeSet.add(kv[0]);
309
- const securityObj = { securitySchemeId: kv[0], ...kv[1] };
310
- securityObj.in = "header";
311
- securityObj.name = "Authorization"; // Name of the header/cookie/api-key
312
- securityObj.nameId = kv[1].name || kv[0]; // Name of the security-scheme
313
- securityObj.user = "";
314
- securityObj.password = "";
315
- securityObj.clientId = "";
316
- securityObj.clientSecret = "";
317
- securityObj.value = "";
318
- securityObj.finalKeyValue = "";
319
- if (kv[1].type === "apiKey") {
320
- securityObj.in = kv[1].in || "header";
321
- securityObj.name = kv[1].name || "Authorization";
322
- }
323
- securitySchemes.push(securityObj);
324
- }
325
- });
326
- }
296
+ // 3-Level Tag Groups (x-tagGroups / x-tag-groups)
297
+ const tagGroups = groupByTagGroups(jsonParsedSpec, tags);
327
298
 
328
- if (attrApiKey && attrApiKeyLocation && attrApiKeyValue) {
329
- securitySchemes.push({
330
- securitySchemeId: rapidocApiKey,
331
- description: "api-key provided in rapidoc element attributes",
332
- type: "apiKey",
333
- oAuthFlow: "",
334
- name: attrApiKey, // Name of the header/cookie/api-key
335
- nameId: attrApiKey, // Name of the security-scheme
336
- in: attrApiKeyLocation,
337
- value: attrApiKeyValue,
338
- finalKeyValue: attrApiKeyValue,
339
- });
340
- }
299
+ // Components
300
+ const components = getComponents(jsonParsedSpec, sortSchemas);
341
301
 
342
- // Updated Security Type Display Text based on Type
343
- securitySchemes.forEach((v) => {
344
- if (v.type === "http") {
345
- v.typeDisplay = v.scheme === "basic" ? "HTTP Basic" : `HTTP Bearer ${v.nameId}`;
346
- } else if (v.type === "apiKey") {
347
- v.typeDisplay = `API Key (${v.name})`;
348
- } else if (v.type === "oauth2") {
349
- v.typeDisplay = `OAuth (${v.securitySchemeId})`;
350
- } else {
351
- v.typeDisplay = v.type || "None";
352
- }
353
- });
302
+ // Info Description Headers
303
+ const infoDescriptionHeaders = jsonParsedSpec.info?.description
304
+ ? getHeadersFromMarkdown(jsonParsedSpec.info.description)
305
+ : [];
354
306
 
355
- // Servers
356
- let servers = [];
357
- if (
358
- jsonParsedSpec.servers &&
359
- Array.isArray(jsonParsedSpec.servers) &&
360
- jsonParsedSpec.servers.length > 0
361
- ) {
362
- jsonParsedSpec.servers.forEach((v) => {
363
- let computedUrl = v.url.trim();
364
- if (
365
- !(
366
- computedUrl.startsWith("http") ||
367
- computedUrl.startsWith("//") ||
368
- computedUrl.startsWith("{")
369
- )
370
- ) {
371
- if (typeof window !== "undefined" && window.location?.origin?.startsWith("http")) {
372
- v.url = window.location.origin + v.url;
373
- computedUrl = v.url;
307
+ // Security Scheme
308
+ const securitySchemes = [];
309
+ if (jsonParsedSpec.components?.securitySchemes) {
310
+ const securitySchemeSet = new Set();
311
+ Object.entries(jsonParsedSpec.components.securitySchemes).forEach((kv) => {
312
+ if (!securitySchemeSet.has(kv[0])) {
313
+ securitySchemeSet.add(kv[0]);
314
+ const securityObj = { securitySchemeId: kv[0], ...kv[1] };
315
+ securityObj.in = "header";
316
+ securityObj.name = "Authorization"; // Name of the header/cookie/api-key
317
+ securityObj.nameId = kv[1].name || kv[0]; // Name of the security-scheme
318
+ securityObj.user = "";
319
+ securityObj.password = "";
320
+ securityObj.clientId = "";
321
+ securityObj.clientSecret = "";
322
+ securityObj.value = "";
323
+ securityObj.finalKeyValue = "";
324
+ if (kv[1].type === "apiKey") {
325
+ securityObj.in = kv[1].in || "header";
326
+ securityObj.name = kv[1].name || "Authorization";
327
+ }
328
+ securitySchemes.push(securityObj);
374
329
  }
330
+ });
331
+ }
332
+
333
+ if (attrApiKey && attrApiKeyLocation && attrApiKeyValue) {
334
+ securitySchemes.push({
335
+ securitySchemeId: rapidocApiKey,
336
+ description: "api-key provided in rapidoc element attributes",
337
+ type: "apiKey",
338
+ oAuthFlow: "",
339
+ name: attrApiKey, // Name of the header/cookie/api-key
340
+ nameId: attrApiKey, // Name of the security-scheme
341
+ in: attrApiKeyLocation,
342
+ value: attrApiKeyValue,
343
+ finalKeyValue: attrApiKeyValue,
344
+ });
345
+ }
346
+
347
+ // Updated Security Type Display Text based on Type
348
+ securitySchemes.forEach((v) => {
349
+ if (v.type === "http") {
350
+ v.typeDisplay = v.scheme === "basic" ? "HTTP Basic" : `HTTP Bearer ${v.nameId}`;
351
+ } else if (v.type === "apiKey") {
352
+ v.typeDisplay = `API Key (${v.name})`;
353
+ } else if (v.type === "oauth2") {
354
+ v.typeDisplay = `OAuth (${v.securitySchemeId})`;
355
+ } else {
356
+ v.typeDisplay = v.type || "None";
375
357
  }
376
- // Apply server-variables to generate final computed-url
377
- if (v.variables) {
378
- Object.entries(v.variables).forEach((kv) => {
379
- const regex = new RegExp(`{${kv[0]}}`, "g");
380
- computedUrl = computedUrl.replace(regex, kv[1].default || "");
381
- kv[1].value = kv[1].default || "";
382
- });
383
- }
384
- v.computedUrl = computedUrl;
385
358
  });
386
- if (serverUrl) {
387
- jsonParsedSpec.servers.push({ url: serverUrl, computedUrl: serverUrl });
359
+
360
+ // Servers
361
+ let servers = [];
362
+ if (
363
+ jsonParsedSpec.servers &&
364
+ Array.isArray(jsonParsedSpec.servers) &&
365
+ jsonParsedSpec.servers.length > 0
366
+ ) {
367
+ jsonParsedSpec.servers.forEach((v) => {
368
+ let computedUrl = String(v.url ?? "").trim();
369
+ if (
370
+ !(
371
+ computedUrl.startsWith("http") ||
372
+ computedUrl.startsWith("//") ||
373
+ computedUrl.startsWith("{")
374
+ )
375
+ ) {
376
+ if (typeof window !== "undefined" && window.location?.origin?.startsWith("http")) {
377
+ v.url = window.location.origin + v.url;
378
+ computedUrl = v.url;
379
+ }
380
+ }
381
+ // Apply server-variables to generate final computed-url
382
+ if (v.variables) {
383
+ Object.entries(v.variables).forEach((kv) => {
384
+ const regex = new RegExp(`{${kv[0]}}`, "g");
385
+ computedUrl = computedUrl.replace(regex, kv[1].default || "");
386
+ kv[1].value = kv[1].default || "";
387
+ });
388
+ }
389
+ v.computedUrl = computedUrl;
390
+ });
391
+ if (serverUrl) {
392
+ jsonParsedSpec.servers.push({ url: serverUrl, computedUrl: serverUrl });
393
+ }
394
+ } else if (serverUrl) {
395
+ jsonParsedSpec.servers = [{ url: serverUrl, computedUrl: serverUrl }];
396
+ } else if (typeof window !== "undefined" && window.location?.origin?.startsWith("http")) {
397
+ jsonParsedSpec.servers = [
398
+ { url: window.location.origin, computedUrl: window.location.origin },
399
+ ];
400
+ } else {
401
+ jsonParsedSpec.servers = [{ url: "http://localhost", computedUrl: "http://localhost" }];
388
402
  }
389
- } else if (serverUrl) {
390
- jsonParsedSpec.servers = [{ url: serverUrl, computedUrl: serverUrl }];
391
- } else if (typeof window !== "undefined" && window.location?.origin?.startsWith("http")) {
392
- jsonParsedSpec.servers = [{ url: window.location.origin, computedUrl: window.location.origin }];
393
- } else {
394
- jsonParsedSpec.servers = [{ url: "http://localhost", computedUrl: "http://localhost" }];
403
+ servers = jsonParsedSpec.servers;
404
+ const parsedSpec = {
405
+ specLoadError: false,
406
+ isSpecLoading: false,
407
+ info: jsonParsedSpec.info,
408
+ infoDescriptionHeaders,
409
+ tags,
410
+ tagGroups,
411
+ components,
412
+ externalDocs: jsonParsedSpec.externalDocs,
413
+ securitySchemes,
414
+ servers,
415
+ };
416
+ return parsedSpec;
417
+ } catch (err) {
418
+ console.info(
419
+ "RapiDoc: %c There was an issue while processing the spec %o ",
420
+ "color:orangered",
421
+ err,
422
+ );
423
+ return {
424
+ specLoadError: true,
425
+ isSpecLoading: false,
426
+ info: {
427
+ title: "Error processing the spec",
428
+ description: `Unable to process the spec ┃ ${err?.message || err}`,
429
+ version: "",
430
+ },
431
+ tags: [],
432
+ tagGroups: [],
433
+ };
395
434
  }
396
- servers = jsonParsedSpec.servers;
397
- const parsedSpec = {
398
- specLoadError: false,
399
- isSpecLoading: false,
400
- info: jsonParsedSpec.info,
401
- infoDescriptionHeaders,
402
- tags,
403
- tagGroups,
404
- components,
405
- externalDocs: jsonParsedSpec.externalDocs,
406
- securitySchemes,
407
- servers,
408
- };
409
- return parsedSpec;
410
435
  }
411
436
 
412
437
  function filterPaths(
@@ -615,6 +640,7 @@ function groupByTags(
615
640
  generateMissingTags = false,
616
641
  sortTags = false,
617
642
  hasTagGroups = false,
643
+ showUnusedTags = false,
618
644
  ) {
619
645
  const supportedMethods = [
620
646
  "get",
@@ -645,45 +671,37 @@ function groupByTags(
645
671
  }))
646
672
  : [];
647
673
 
648
- const pathsAndWebhooks = openApiSpec.paths || {};
649
- if (openApiSpec.webhooks) {
650
- for (const [key, value] of Object.entries(openApiSpec.webhooks)) {
651
- value._type = "webhook";
652
- pathsAndWebhooks[key] = value;
653
- }
654
- }
674
+ // Paths first, then webhooks; a webhook named like a path does not replace it and the spec is not mutated
675
+ const pathsAndWebhooks = [
676
+ ...Object.entries(openApiSpec.paths || {}).map(([name, item]) => [name, item, false]),
677
+ ...Object.entries(openApiSpec.webhooks || {}).map(([name, item]) => [name, item, true]),
678
+ ];
655
679
  // For each path find the tag and push it into the corresponding tag
656
- for (const pathOrHookName in pathsAndWebhooks) {
657
- const commonParams = pathsAndWebhooks[pathOrHookName].parameters;
680
+ for (const [pathOrHookName, pathItem, isWebhook] of pathsAndWebhooks) {
681
+ const commonParams = pathItem.parameters;
658
682
  const commonPathProp = {
659
- servers: pathsAndWebhooks[pathOrHookName].servers || [],
660
- parameters: pathsAndWebhooks[pathOrHookName].parameters || [],
683
+ servers: pathItem.servers || [],
684
+ parameters: pathItem.parameters || [],
661
685
  };
662
- const isWebhook = pathsAndWebhooks[pathOrHookName]._type === "webhook";
663
686
  const operationsToProcess = [];
664
687
  supportedMethods.forEach((methodName) => {
665
- if (pathsAndWebhooks[pathOrHookName][methodName]) {
688
+ if (pathItem[methodName]) {
666
689
  operationsToProcess.push({
667
690
  methodName,
668
- pathOrHookObj: pathsAndWebhooks[pathOrHookName][methodName],
691
+ pathOrHookObj: pathItem[methodName],
669
692
  });
670
693
  }
671
694
  });
672
695
 
673
- if (
674
- pathsAndWebhooks[pathOrHookName].additionalOperations &&
675
- typeof pathsAndWebhooks[pathOrHookName].additionalOperations === "object"
676
- ) {
677
- Object.entries(pathsAndWebhooks[pathOrHookName].additionalOperations).forEach(
678
- ([customMethod, customObj]) => {
679
- if (customObj && typeof customObj === "object") {
680
- operationsToProcess.push({
681
- methodName: customMethod.toLowerCase(),
682
- pathOrHookObj: customObj,
683
- });
684
- }
685
- },
686
- );
696
+ if (pathItem.additionalOperations && typeof pathItem.additionalOperations === "object") {
697
+ Object.entries(pathItem.additionalOperations).forEach(([customMethod, customObj]) => {
698
+ if (customObj && typeof customObj === "object") {
699
+ operationsToProcess.push({
700
+ methodName: customMethod.toLowerCase(),
701
+ pathOrHookObj: customObj,
702
+ });
703
+ }
704
+ });
687
705
  }
688
706
 
689
707
  operationsToProcess.forEach(({ methodName, pathOrHookObj }) => {
@@ -709,7 +727,7 @@ function groupByTags(
709
727
  let specTagsItem;
710
728
 
711
729
  if (openApiSpec.tags) {
712
- specTagsItem = openApiSpec.tags.find((v) => v.name.toLowerCase() === tag.toLowerCase());
730
+ specTagsItem = openApiSpec.tags.find((v) => v.name?.toLowerCase() === tag.toLowerCase());
713
731
  }
714
732
 
715
733
  tagObj = tags.find((v) => v.name === tag);
@@ -816,23 +834,31 @@ function groupByTags(
816
834
  xBadges: pathOrHookObj["x-badges"] || undefined,
817
835
  xCodeSamples: pathOrHookObj["x-codeSamples"] || pathOrHookObj["x-code-samples"] || "",
818
836
  rawOperation: pathOrHookObj,
819
- xMcp: pathOrHookObj["x-mcp"] || pathOrHookObj["x-mcp-tool"] || undefined,
820
- xInternal:
821
- pathOrHookObj["x-internal"] ??
822
- pathsAndWebhooks[pathOrHookName]?.["x-internal"] ??
823
- undefined,
824
- xHidden:
825
- pathOrHookObj["x-hidden"] ??
826
- pathsAndWebhooks[pathOrHookName]?.["x-hidden"] ??
827
- undefined,
828
- xOpenAiIsConsequential: pathOrHookObj["x-openai-isConsequential"],
829
- xSpeakeasyNameOverride: pathOrHookObj["x-speakeasy-name-override"] || undefined,
837
+ xInternal: pathOrHookObj["x-internal"] ?? pathItem?.["x-internal"] ?? undefined,
838
+ xHidden: pathOrHookObj["x-hidden"] ?? pathItem?.["x-hidden"] ?? undefined,
830
839
  });
831
840
  }); // End of tag path create
832
841
  }); // End of operationsToProcess
833
842
  }
834
843
 
835
- const tagsWithSortedPaths = tags.filter((tag) => tag.paths && tag.paths.length > 0);
844
+ // A tag no operation uses is dropped, unless it has a description to show (a guide or concepts section) and
845
+ // either show-unused-tags is on or the spec lists the tag in x-tagGroups. Such a tag is marked descriptionOnly.
846
+ const tagNamesInGroups = new Set(
847
+ (openApiSpec["x-tagGroups"] || openApiSpec["x-tag-groups"] || [])
848
+ .flatMap((g) => (Array.isArray(g?.tags) ? g.tags : []))
849
+ .filter((name) => typeof name === "string")
850
+ .map((name) => name.toLowerCase()),
851
+ );
852
+ const tagsWithSortedPaths = tags.filter((tag) => {
853
+ if (tag.paths && tag.paths.length > 0) {
854
+ return true;
855
+ }
856
+ if (tag.description && (showUnusedTags || tagNamesInGroups.has(tag.name?.toLowerCase()))) {
857
+ tag.descriptionOnly = true;
858
+ return true;
859
+ }
860
+ return false;
861
+ });
836
862
  tagsWithSortedPaths.forEach((tag) => {
837
863
  if (sortEndpointsBy !== "none") {
838
864
  if (sortEndpointsBy === "method") {
@@ -20,6 +20,29 @@ export function replacePathParams(pathTemplate = "", pathParams = {}) {
20
20
  return resolvedPath;
21
21
  }
22
22
 
23
+ /**
24
+ * A value typed into an array input is a string; in a JSON array, numbers and booleans keep their JSON type.
25
+ */
26
+ function typedArrayItem(value, itemType) {
27
+ if (typeof value !== "string") {
28
+ return value;
29
+ }
30
+ // an OpenAPI 3.1 type list such as ["integer", "null"] arrives as "integer,null"
31
+ const types = String(itemType).split(",");
32
+ const text = value.trim();
33
+ if (
34
+ (types.includes("integer") || types.includes("number")) &&
35
+ text !== "" &&
36
+ Number.isFinite(Number(text))
37
+ ) {
38
+ return Number(text);
39
+ }
40
+ if (types.includes("boolean") && (text === "true" || text === "false")) {
41
+ return text === "true";
42
+ }
43
+ return value;
44
+ }
45
+
23
46
  /**
24
47
  * Serializes a single query parameter into an array of [key, value] tuples based on OpenAPI style and explode rules.
25
48
  */
@@ -29,6 +52,7 @@ export function serializeQueryParam(name, rawValue, options = {}) {
29
52
  explode = style === "form",
30
53
  isArray = Array.isArray(rawValue),
31
54
  isObject = false,
55
+ itemType = "",
32
56
  } = options;
33
57
 
34
58
  if (rawValue === undefined || rawValue === null || rawValue === "") {
@@ -95,6 +119,10 @@ export function serializeQueryParam(name, rawValue, options = {}) {
95
119
  return [];
96
120
  }
97
121
 
122
+ // A parameter described by content: application/json is one JSON array, not one parameter per value
123
+ if (style === "json") {
124
+ return [[name, JSON.stringify(vals.map((v) => typedArrayItem(v, itemType)))]];
125
+ }
98
126
  if (style === "spaceDelimited") {
99
127
  return [[name, vals.join(" ")]];
100
128
  }
@@ -140,9 +168,16 @@ export function buildQueryString(queryParams = []) {
140
168
  allowReserved = false,
141
169
  isArray = false,
142
170
  isObject = false,
171
+ itemType = "",
143
172
  } = param;
144
173
 
145
- const entries = serializeQueryParam(name, value, { style, explode, isArray, isObject });
174
+ const entries = serializeQueryParam(name, value, {
175
+ style,
176
+ explode,
177
+ isArray,
178
+ isObject,
179
+ itemType,
180
+ });
146
181
 
147
182
  for (const [k, v] of entries) {
148
183
  const encodedKey = encodeURIComponent(k);