zudoku 0.91.0 → 0.93.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/cli.js +118 -3
- package/dist/cli/worker.js +1 -0
- package/dist/declarations/config/validators/ZudokuConfig.d.ts +8 -0
- package/dist/declarations/lib/plugins/api-keys/fetchSigned.d.ts +2 -0
- package/dist/declarations/lib/plugins/openapi/interfaces.d.ts +1 -0
- package/dist/declarations/lib/plugins/openapi/util/shouldShowRequestBox.d.ts +1 -0
- package/dist/declarations/lib/util/redirectPattern.d.ts +11 -0
- package/dist/flat-config.d.ts +1 -0
- package/docs/configuration/api-reference.md +15 -2
- package/docs/configuration/authentication-auth0.md +34 -19
- package/docs/configuration/overview.md +5 -1
- package/docs/guides/managing-api-keys-and-identities.md +8 -0
- package/docs/guides/redirects.md +37 -0
- package/docs/openapi-extensions/x-zudoku-request-box-enabled.md +47 -0
- package/package.json +1 -1
- package/src/app/utils/createRedirectRoutes.ts +2 -1
- package/src/config/validators/ZudokuConfig.ts +1 -0
- package/src/lib/authentication/providers/openid.tsx +1 -14
- package/src/lib/plugins/api-keys/fetchSigned.ts +35 -0
- package/src/lib/plugins/api-keys/index.tsx +31 -34
- package/src/lib/plugins/openapi/Sidecar.tsx +85 -74
- package/src/lib/plugins/openapi/interfaces.ts +1 -0
- package/src/lib/plugins/openapi/util/shouldShowRequestBox.ts +18 -0
- package/src/lib/util/redirectPattern.ts +172 -0
- package/src/vite/output.ts +20 -1
- package/src/vite/plugin-api.ts +1 -0
- package/src/vite/prerender/prerender.ts +5 -1
- package/src/vite/prerender/utils.ts +18 -1
package/dist/cli/cli.js
CHANGED
|
@@ -3402,6 +3402,7 @@ var ApiOptionsSchema = z7.object({
|
|
|
3402
3402
|
supportedLanguages: z7.array(LanguageOption),
|
|
3403
3403
|
disablePlayground: z7.boolean(),
|
|
3404
3404
|
disableSidecar: z7.boolean(),
|
|
3405
|
+
disableRequestBox: z7.boolean(),
|
|
3405
3406
|
showVersionSelect: z7.enum(["always", "if-available", "hide"]),
|
|
3406
3407
|
expandAllTags: z7.boolean(),
|
|
3407
3408
|
showInfoPage: z7.boolean(),
|
|
@@ -6816,6 +6817,7 @@ var generateDefaultApiOptionsCode = () => [
|
|
|
6816
6817
|
` supportedLanguages: config.defaults?.apis?.supportedLanguages,`,
|
|
6817
6818
|
` disablePlayground: config.defaults?.apis?.disablePlayground,`,
|
|
6818
6819
|
` disableSidecar: config.defaults?.apis?.disableSidecar,`,
|
|
6820
|
+
` disableRequestBox: config.defaults?.apis?.disableRequestBox,`,
|
|
6819
6821
|
` disableSecurity: config.defaults?.apis?.disableSecurity ?? true,`,
|
|
6820
6822
|
` disableMcpAuthInstructions: config.defaults?.apis?.disableMcpAuthInstructions,`,
|
|
6821
6823
|
` showVersionSelect: config.defaults?.apis?.showVersionSelect ?? "if-available",`,
|
|
@@ -9105,6 +9107,98 @@ import { cp, mkdir as mkdir3, readdir, rm, writeFile as writeFile4 } from "node:
|
|
|
9105
9107
|
import path20 from "node:path";
|
|
9106
9108
|
init_joinUrl();
|
|
9107
9109
|
|
|
9110
|
+
// src/lib/util/redirectPattern.ts
|
|
9111
|
+
init_joinUrl();
|
|
9112
|
+
var SPLAT = "*";
|
|
9113
|
+
var ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i;
|
|
9114
|
+
var TOKEN_REGEX = /\/(?::([\w-]+)|\*)(?=[/?#]|$)/g;
|
|
9115
|
+
var parseSegments = (path32) => {
|
|
9116
|
+
const parts = path32.split("/").filter(Boolean);
|
|
9117
|
+
return parts.map((part, index) => {
|
|
9118
|
+
if (part === SPLAT && index === parts.length - 1) return { type: "splat" };
|
|
9119
|
+
const match = part.match(/^:([\w-]+)(\?)?$/);
|
|
9120
|
+
if (!match?.[1]) return { type: "static", value: part };
|
|
9121
|
+
return { type: "param", name: match[1], optional: match[2] === "?" };
|
|
9122
|
+
});
|
|
9123
|
+
};
|
|
9124
|
+
var getParamNames = (from) => parseSegments(from).flatMap((segment) => {
|
|
9125
|
+
if (segment.type === "param") return [segment.name];
|
|
9126
|
+
if (segment.type === "splat") return [SPLAT];
|
|
9127
|
+
return [];
|
|
9128
|
+
});
|
|
9129
|
+
var isDynamicRedirect = (redirect) => getParamNames(redirect.from).length > 0;
|
|
9130
|
+
var replaceTokens = (to, names, replace) => {
|
|
9131
|
+
const target = to.replace(TOKEN_REGEX, (token, name) => {
|
|
9132
|
+
const key = name ?? SPLAT;
|
|
9133
|
+
return names.includes(key) ? replace(key) : token;
|
|
9134
|
+
});
|
|
9135
|
+
const lostPath = target === "" || /^[?#]/.test(target);
|
|
9136
|
+
return target !== to && lostPath ? `/${target}` : target;
|
|
9137
|
+
};
|
|
9138
|
+
var rankPath = (path32) => {
|
|
9139
|
+
const segments = joinUrl(path32).split("/");
|
|
9140
|
+
return segments.reduce(
|
|
9141
|
+
(score, segment) => {
|
|
9142
|
+
if (segment === SPLAT) return score;
|
|
9143
|
+
if (segment.startsWith(":")) return score + 3;
|
|
9144
|
+
return score + (segment === "" ? 1 : 10);
|
|
9145
|
+
},
|
|
9146
|
+
segments.length - (segments.includes(SPLAT) ? 2 : 0)
|
|
9147
|
+
);
|
|
9148
|
+
};
|
|
9149
|
+
var segmentToPath = (segment) => {
|
|
9150
|
+
if (segment.type === "static") return segment.value;
|
|
9151
|
+
if (segment.type === "splat") return SPLAT;
|
|
9152
|
+
return `:${segment.name}`;
|
|
9153
|
+
};
|
|
9154
|
+
var explodeOptional = (segments) => {
|
|
9155
|
+
const [first, ...rest] = segments;
|
|
9156
|
+
if (!first) return [[]];
|
|
9157
|
+
const variants = explodeOptional(rest);
|
|
9158
|
+
const withFirst = variants.map((variant) => [first, ...variant]);
|
|
9159
|
+
return first.type === "param" && first.optional ? [...withFirst, ...variants] : withFirst;
|
|
9160
|
+
};
|
|
9161
|
+
var escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9162
|
+
var toBuildOutputRedirects = (redirects, basePath) => {
|
|
9163
|
+
const base = joinUrl(basePath);
|
|
9164
|
+
const prefix = base === "/" ? "" : escapeRegex(base);
|
|
9165
|
+
return redirects.flatMap((redirect) => {
|
|
9166
|
+
const names = getParamNames(redirect.from);
|
|
9167
|
+
const to = ABSOLUTE_URL_REGEX.test(redirect.to) ? redirect.to : joinUrl(basePath, redirect.to);
|
|
9168
|
+
const toRoute = (segments) => {
|
|
9169
|
+
const captured = segments.flatMap((segment) => {
|
|
9170
|
+
if (segment.type === "param") return [segment.name];
|
|
9171
|
+
if (segment.type === "splat") return [SPLAT];
|
|
9172
|
+
return [];
|
|
9173
|
+
});
|
|
9174
|
+
const pattern = segments.map((segment) => {
|
|
9175
|
+
if (segment.type === "static") {
|
|
9176
|
+
return `/${escapeRegex(segment.value)}`;
|
|
9177
|
+
}
|
|
9178
|
+
return segment.type === "splat" ? "(/.+)" : "(/[^/]+)";
|
|
9179
|
+
}).join("");
|
|
9180
|
+
const endsWithSplat = segments.at(-1)?.type === "splat";
|
|
9181
|
+
return {
|
|
9182
|
+
// React Router accepts any number of trailing slashes
|
|
9183
|
+
src: `^${prefix}${pattern}${endsWithSplat ? "" : "/*"}$`,
|
|
9184
|
+
// Like React Router, a repeated param name takes the last value
|
|
9185
|
+
location: replaceTokens(
|
|
9186
|
+
to,
|
|
9187
|
+
names,
|
|
9188
|
+
(name) => captured.includes(name) ? `$${captured.lastIndexOf(name) + 1}` : ""
|
|
9189
|
+
)
|
|
9190
|
+
};
|
|
9191
|
+
};
|
|
9192
|
+
return explodeOptional(parseSegments(redirect.from)).flatMap(
|
|
9193
|
+
(segments) => {
|
|
9194
|
+
const rank = rankPath(`/${segments.map(segmentToPath).join("/")}`);
|
|
9195
|
+
const variants = segments.at(-1)?.type === "splat" ? [segments, segments.slice(0, -1)] : [segments];
|
|
9196
|
+
return variants.map((variant) => ({ rank, ...toRoute(variant) }));
|
|
9197
|
+
}
|
|
9198
|
+
);
|
|
9199
|
+
}).toSorted((a, b) => b.rank - a.rank).map(({ src, location }) => ({ src, location }));
|
|
9200
|
+
};
|
|
9201
|
+
|
|
9108
9202
|
// src/vite/vercel-markdown-middleware.ts
|
|
9109
9203
|
var normalizePath2 = (value) => {
|
|
9110
9204
|
const pathname = value.split("/").filter(Boolean).map((segment) => {
|
|
@@ -9573,8 +9667,20 @@ function generateOutput({
|
|
|
9573
9667
|
continue: true
|
|
9574
9668
|
});
|
|
9575
9669
|
}
|
|
9576
|
-
|
|
9670
|
+
const dynamicRedirects = toBuildOutputRedirects(
|
|
9671
|
+
(config.redirects ?? []).filter(isDynamicRedirect),
|
|
9672
|
+
config.basePath
|
|
9673
|
+
);
|
|
9674
|
+
if (rewrites.length > 0 || dynamicRedirects.length > 0) {
|
|
9577
9675
|
routes.push({ handle: "filesystem" });
|
|
9676
|
+
for (const { src, location } of dynamicRedirects) {
|
|
9677
|
+
routes.push({
|
|
9678
|
+
src,
|
|
9679
|
+
dest: location,
|
|
9680
|
+
status: 301,
|
|
9681
|
+
headers: { Location: location }
|
|
9682
|
+
});
|
|
9683
|
+
}
|
|
9578
9684
|
for (const rewrite of rewrites) {
|
|
9579
9685
|
routes.push({
|
|
9580
9686
|
src: joinUrl(config.basePath, rewrite.source),
|
|
@@ -9776,10 +9882,16 @@ var collectRewrites = (resolved) => resolved.flatMap((r) => [
|
|
|
9776
9882
|
...collectRewrites(r.children)
|
|
9777
9883
|
]);
|
|
9778
9884
|
var routesToPaths = (routes) => collectPaths(resolveRoutes(routes), "");
|
|
9885
|
+
var withoutDynamicRedirectRoutes = (routes, redirects = []) => {
|
|
9886
|
+
const paths = new Set(
|
|
9887
|
+
redirects.filter(isDynamicRedirect).map(({ from }) => joinUrl(from))
|
|
9888
|
+
);
|
|
9889
|
+
return routes.filter((route) => !route.path || !paths.has(route.path));
|
|
9890
|
+
};
|
|
9779
9891
|
var routesToPrerenderPaths = (routes, redirects = []) => Array.from(
|
|
9780
9892
|
/* @__PURE__ */ new Set([
|
|
9781
9893
|
...routesToPaths(routes),
|
|
9782
|
-
...redirects.map(({ from }) => joinUrl(from))
|
|
9894
|
+
...redirects.filter((redirect) => !isDynamicRedirect(redirect)).map(({ from }) => joinUrl(from))
|
|
9783
9895
|
])
|
|
9784
9896
|
);
|
|
9785
9897
|
var routesToRewrites = (routes) => collectRewrites(resolveRoutes(routes));
|
|
@@ -9824,7 +9936,10 @@ var prerender = async ({
|
|
|
9824
9936
|
const buildConfig = await getBuildConfig();
|
|
9825
9937
|
const module = await import(entryServerPath);
|
|
9826
9938
|
const getRoutes = module.getRoutesByConfig;
|
|
9827
|
-
const routes =
|
|
9939
|
+
const routes = withoutDynamicRedirectRoutes(
|
|
9940
|
+
getRoutes(config),
|
|
9941
|
+
config.redirects
|
|
9942
|
+
);
|
|
9828
9943
|
const paths = routesToPrerenderPaths(routes, config.redirects);
|
|
9829
9944
|
const rewrites = routesToRewrites(routes);
|
|
9830
9945
|
const { maxThreads, maxOldGenerationSizeMb } = getWorkerScaling(
|
package/dist/cli/worker.js
CHANGED
|
@@ -2514,6 +2514,7 @@ var ApiOptionsSchema = z4.object({
|
|
|
2514
2514
|
supportedLanguages: z4.array(LanguageOption),
|
|
2515
2515
|
disablePlayground: z4.boolean(),
|
|
2516
2516
|
disableSidecar: z4.boolean(),
|
|
2517
|
+
disableRequestBox: z4.boolean(),
|
|
2517
2518
|
showVersionSelect: z4.enum(["always", "if-available", "hide"]),
|
|
2518
2519
|
expandAllTags: z4.boolean(),
|
|
2519
2520
|
showInfoPage: z4.boolean(),
|
|
@@ -49,6 +49,7 @@ declare const ApiOptionsSchema: z.ZodObject<{
|
|
|
49
49
|
}, z.core.$strip>>>;
|
|
50
50
|
disablePlayground: z.ZodOptional<z.ZodBoolean>;
|
|
51
51
|
disableSidecar: z.ZodOptional<z.ZodBoolean>;
|
|
52
|
+
disableRequestBox: z.ZodOptional<z.ZodBoolean>;
|
|
52
53
|
showVersionSelect: z.ZodOptional<z.ZodEnum<{
|
|
53
54
|
always: "always";
|
|
54
55
|
hide: "hide";
|
|
@@ -7741,6 +7742,7 @@ export declare const ZudokuConfig: z.ZodObject<{
|
|
|
7741
7742
|
}, z.core.$strip>>>;
|
|
7742
7743
|
disablePlayground: z.ZodOptional<z.ZodBoolean>;
|
|
7743
7744
|
disableSidecar: z.ZodOptional<z.ZodBoolean>;
|
|
7745
|
+
disableRequestBox: z.ZodOptional<z.ZodBoolean>;
|
|
7744
7746
|
showVersionSelect: z.ZodOptional<z.ZodEnum<{
|
|
7745
7747
|
always: "always";
|
|
7746
7748
|
hide: "hide";
|
|
@@ -7778,6 +7780,7 @@ export declare const ZudokuConfig: z.ZodObject<{
|
|
|
7778
7780
|
}, z.core.$strip>>>;
|
|
7779
7781
|
disablePlayground: z.ZodOptional<z.ZodBoolean>;
|
|
7780
7782
|
disableSidecar: z.ZodOptional<z.ZodBoolean>;
|
|
7783
|
+
disableRequestBox: z.ZodOptional<z.ZodBoolean>;
|
|
7781
7784
|
showVersionSelect: z.ZodOptional<z.ZodEnum<{
|
|
7782
7785
|
always: "always";
|
|
7783
7786
|
hide: "hide";
|
|
@@ -7819,6 +7822,7 @@ export declare const ZudokuConfig: z.ZodObject<{
|
|
|
7819
7822
|
}, z.core.$strip>>>;
|
|
7820
7823
|
disablePlayground: z.ZodOptional<z.ZodBoolean>;
|
|
7821
7824
|
disableSidecar: z.ZodOptional<z.ZodBoolean>;
|
|
7825
|
+
disableRequestBox: z.ZodOptional<z.ZodBoolean>;
|
|
7822
7826
|
showVersionSelect: z.ZodOptional<z.ZodEnum<{
|
|
7823
7827
|
always: "always";
|
|
7824
7828
|
hide: "hide";
|
|
@@ -7852,6 +7856,7 @@ export declare const ZudokuConfig: z.ZodObject<{
|
|
|
7852
7856
|
}, z.core.$strip>>>;
|
|
7853
7857
|
disablePlayground: z.ZodOptional<z.ZodBoolean>;
|
|
7854
7858
|
disableSidecar: z.ZodOptional<z.ZodBoolean>;
|
|
7859
|
+
disableRequestBox: z.ZodOptional<z.ZodBoolean>;
|
|
7855
7860
|
showVersionSelect: z.ZodOptional<z.ZodEnum<{
|
|
7856
7861
|
always: "always";
|
|
7857
7862
|
hide: "hide";
|
|
@@ -7889,6 +7894,7 @@ export declare const ZudokuConfig: z.ZodObject<{
|
|
|
7889
7894
|
}, z.core.$strip>>>;
|
|
7890
7895
|
disablePlayground: z.ZodOptional<z.ZodBoolean>;
|
|
7891
7896
|
disableSidecar: z.ZodOptional<z.ZodBoolean>;
|
|
7897
|
+
disableRequestBox: z.ZodOptional<z.ZodBoolean>;
|
|
7892
7898
|
showVersionSelect: z.ZodOptional<z.ZodEnum<{
|
|
7893
7899
|
always: "always";
|
|
7894
7900
|
hide: "hide";
|
|
@@ -7930,6 +7936,7 @@ export declare const ZudokuConfig: z.ZodObject<{
|
|
|
7930
7936
|
}, z.core.$strip>>>;
|
|
7931
7937
|
disablePlayground: z.ZodOptional<z.ZodBoolean>;
|
|
7932
7938
|
disableSidecar: z.ZodOptional<z.ZodBoolean>;
|
|
7939
|
+
disableRequestBox: z.ZodOptional<z.ZodBoolean>;
|
|
7933
7940
|
showVersionSelect: z.ZodOptional<z.ZodEnum<{
|
|
7934
7941
|
always: "always";
|
|
7935
7942
|
hide: "hide";
|
|
@@ -8032,6 +8039,7 @@ export declare const ZudokuConfig: z.ZodObject<{
|
|
|
8032
8039
|
}, z.core.$strip>>>;
|
|
8033
8040
|
disablePlayground: z.ZodOptional<z.ZodBoolean>;
|
|
8034
8041
|
disableSidecar: z.ZodOptional<z.ZodBoolean>;
|
|
8042
|
+
disableRequestBox: z.ZodOptional<z.ZodBoolean>;
|
|
8035
8043
|
showVersionSelect: z.ZodOptional<z.ZodEnum<{
|
|
8036
8044
|
always: "always";
|
|
8037
8045
|
hide: "hide";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const shouldShowRequestBox: (extensions: Record<string, unknown> | null | undefined, disableRequestBox: boolean | undefined) => boolean;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type Redirect = {
|
|
2
|
+
from: string;
|
|
3
|
+
to: string;
|
|
4
|
+
};
|
|
5
|
+
export declare const isDynamicRedirect: (redirect: Pick<Redirect, "from">) => boolean;
|
|
6
|
+
export declare const resolveRedirectTarget: ({ from, to }: Redirect, params: Record<string, string | undefined>) => string;
|
|
7
|
+
export declare const toBuildOutputRedirects: (redirects: readonly Redirect[], basePath?: string) => {
|
|
8
|
+
src: string;
|
|
9
|
+
location: string;
|
|
10
|
+
}[];
|
|
11
|
+
export {};
|
package/dist/flat-config.d.ts
CHANGED
|
@@ -251,6 +251,7 @@ const config = {
|
|
|
251
251
|
],
|
|
252
252
|
disablePlayground: false, // Disable the interactive API playground
|
|
253
253
|
disableSidecar: false, // Disable the sidecar completely
|
|
254
|
+
disableRequestBox: false, // Hide the request box at the top of the sidecar
|
|
254
255
|
disableSecurity: true, // Disable security scheme display and playground auth (default)
|
|
255
256
|
disableMcpAuthInstructions: false, // Hide auth steps in the MCP server card
|
|
256
257
|
showVersionSelect: "if-available", // Control version selector visibility
|
|
@@ -272,6 +273,11 @@ Available options:
|
|
|
272
273
|
identifier) and `label` (display name)
|
|
273
274
|
- `disablePlayground`: Disable the interactive API playground globally
|
|
274
275
|
- `disableSidecar`: Disable the sidecar panel completely
|
|
276
|
+
- `disableRequestBox`: Hide the request box at the top of the sidecar. The box holds the method and
|
|
277
|
+
path, the generated code snippet with its language selector, the auth selector and the button that
|
|
278
|
+
opens the playground, so hiding it also removes the way to open the playground. The request body
|
|
279
|
+
and response examples below it are still shown. Operations can override this with
|
|
280
|
+
[`x-zudoku-request-box-enabled`](../openapi-extensions/x-zudoku-request-box-enabled)
|
|
275
281
|
- `disableSecurity`: Disable OpenAPI security scheme display (auth badges on operations, security
|
|
276
282
|
schemes section on the info page, and the Authorize dialog in the playground). Disabled by default
|
|
277
283
|
(`true`). Set to `false` to enable security scheme support
|
|
@@ -310,6 +316,7 @@ const config = {
|
|
|
310
316
|
examplesLanguage: "shell", // Default language for code examples
|
|
311
317
|
disablePlayground: false, // Disable the interactive API playground
|
|
312
318
|
disableSidecar: false, // Disable the sidecar completely
|
|
319
|
+
disableRequestBox: false, // Hide the request box at the top of the sidecar
|
|
313
320
|
disableSecurity: true, // Disable security scheme display and playground auth (default)
|
|
314
321
|
disableMcpAuthInstructions: false, // Hide auth steps in the MCP server card
|
|
315
322
|
showVersionSelect: "if-available", // Control version selector visibility
|
|
@@ -399,9 +406,14 @@ different levels of your API documentation.
|
|
|
399
406
|
### Operations
|
|
400
407
|
|
|
401
408
|
- `x-zudoku-playground-enabled`: Control playground visibility for an operation (default: `true`)
|
|
409
|
+
- `x-zudoku-request-box-enabled`: Control request box visibility in the sidecar for an operation
|
|
410
|
+
(default: `true`). See
|
|
411
|
+
[`x-zudoku-request-box-enabled`](../openapi-extensions/x-zudoku-request-box-enabled)
|
|
402
412
|
- `x-internal`: Hide an operation from the documentation. Also works on path items and parameters.
|
|
403
413
|
See [`x-internal`](../openapi-extensions/x-internal)
|
|
404
|
-
- `x-explorer-enabled`: Alias for `x-zudoku-playground-enabled` for compatibility
|
|
414
|
+
- `x-explorer-enabled`: Alias for `x-zudoku-playground-enabled` for compatibility
|
|
415
|
+
|
|
416
|
+
Example:
|
|
405
417
|
|
|
406
418
|
```json
|
|
407
419
|
{
|
|
@@ -409,7 +421,8 @@ different levels of your API documentation.
|
|
|
409
421
|
"/users": {
|
|
410
422
|
"get": {
|
|
411
423
|
"summary": "Get users",
|
|
412
|
-
"x-zudoku-playground-enabled": false // Disable playground for this operation
|
|
424
|
+
"x-zudoku-playground-enabled": false, // Disable playground for this operation
|
|
425
|
+
"x-zudoku-request-box-enabled": false // Hide the sidecar request box for this operation
|
|
413
426
|
}
|
|
414
427
|
}
|
|
415
428
|
}
|
|
@@ -50,21 +50,7 @@ If you don't have an Auth0 account, you can sign up for a
|
|
|
50
50
|
|
|
51
51
|
Keep the default **Refresh Token Expiration** settings unless you have specific requirements.
|
|
52
52
|
|
|
53
|
-
3.
|
|
54
|
-
- Navigate to the [APIs section](https://manage.auth0.com/#/apis) in the Auth0 dashboard
|
|
55
|
-
- Click **Create API**
|
|
56
|
-
- Set a name (e.g., "Zudoku API") and an identifier (e.g., `https://your-domain.com/api`)
|
|
57
|
-
- Choose **RS256** as the signing algorithm
|
|
58
|
-
- Save the API
|
|
59
|
-
|
|
60
|
-
:::warning
|
|
61
|
-
|
|
62
|
-
This step is important. If you skip creating an API, Zudoku will not be able to validate the
|
|
63
|
-
tokens issued by Auth0, leading to authentication failures.
|
|
64
|
-
|
|
65
|
-
:::
|
|
66
|
-
|
|
67
|
-
4. **Configure Zudoku**
|
|
53
|
+
3. **Configure Zudoku**
|
|
68
54
|
|
|
69
55
|
Add the Auth0 configuration to your [Zudoku configuration file](./overview.md):
|
|
70
56
|
|
|
@@ -76,7 +62,6 @@ If you don't have an Auth0 account, you can sign up for a
|
|
|
76
62
|
type: "auth0",
|
|
77
63
|
domain: "your-domain.us.auth0.com",
|
|
78
64
|
clientId: "<your-auth0-client-id>",
|
|
79
|
-
audience: "https://your-domain.com/api", // Your Auth0 API identifier
|
|
80
65
|
},
|
|
81
66
|
// ... other configuration
|
|
82
67
|
};
|
|
@@ -85,7 +70,36 @@ If you don't have an Auth0 account, you can sign up for a
|
|
|
85
70
|
Where:
|
|
86
71
|
- **domain**: Your Auth0 domain (found in your application's Basic Information)
|
|
87
72
|
- **clientId**: The Client ID from your Auth0 application settings
|
|
88
|
-
|
|
73
|
+
|
|
74
|
+
4. **(Optional) Configure an audience for API access**
|
|
75
|
+
|
|
76
|
+
Only configure an `audience` if you need the access token to call a specific API (for example,
|
|
77
|
+
when using the API playground to send authenticated requests to your backend). Without an
|
|
78
|
+
audience, Auth0 issues an opaque token that can be used to identify the user via `/userinfo` —
|
|
79
|
+
which is all Zudoku needs for sign-in and protected routes.
|
|
80
|
+
|
|
81
|
+
:::tip
|
|
82
|
+
|
|
83
|
+
Avoid setting an audience when you don't need it. Issuing an access token scoped to an API when
|
|
84
|
+
it isn't going to call that API gives the token broader privileges than necessary.
|
|
85
|
+
|
|
86
|
+
:::
|
|
87
|
+
|
|
88
|
+
If you do need API access:
|
|
89
|
+
- Navigate to the [APIs section](https://manage.auth0.com/#/apis) in the Auth0 dashboard
|
|
90
|
+
- Click **Create API**, set a name (e.g., "Zudoku API") and an identifier (e.g.,
|
|
91
|
+
`https://your-domain.com/api`)
|
|
92
|
+
- Choose **RS256** as the signing algorithm and save the API
|
|
93
|
+
- Add the identifier to your Zudoku configuration:
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
authentication: {
|
|
97
|
+
type: "auth0",
|
|
98
|
+
domain: "your-domain.us.auth0.com",
|
|
99
|
+
clientId: "<your-auth0-client-id>",
|
|
100
|
+
audience: "https://your-domain.com/api", // Your Auth0 API identifier
|
|
101
|
+
}
|
|
102
|
+
```
|
|
89
103
|
|
|
90
104
|
</Stepper>
|
|
91
105
|
|
|
@@ -188,8 +202,9 @@ authentication: {
|
|
|
188
202
|
3. **Authentication Loop**: Check that your Auth0 domain is a plain hostname only (e.g.,
|
|
189
203
|
`your-domain.us.auth0.com`) without a protocol prefix (`https://`) or trailing slash.
|
|
190
204
|
|
|
191
|
-
4. **Token Validation Errors**:
|
|
192
|
-
identifier of the Auth0 API you created.
|
|
205
|
+
4. **Token Validation Errors**: If you've configured an `audience`, make sure it matches the
|
|
206
|
+
identifier of the Auth0 API you created. If you don't need to call an API, remove `audience` from
|
|
207
|
+
your configuration — Auth0 will issue an opaque token suitable for `/userinfo`.
|
|
193
208
|
|
|
194
209
|
## Next Steps
|
|
195
210
|
|
|
@@ -284,12 +284,16 @@ URL.
|
|
|
284
284
|
// ...
|
|
285
285
|
"redirects": [
|
|
286
286
|
{ "from": "/", "to": "/documentation/introduction" },
|
|
287
|
-
{ "from": "/documentation", "to": "/documentation/introduction" }
|
|
287
|
+
{ "from": "/documentation", "to": "/documentation/introduction" },
|
|
288
|
+
{ "from": "/blog/:slug", "to": "/posts/:slug" }
|
|
288
289
|
]
|
|
289
290
|
// ...
|
|
290
291
|
}
|
|
291
292
|
```
|
|
292
293
|
|
|
294
|
+
`from` paths can contain `:param` segments and a trailing `*` wildcard. See
|
|
295
|
+
[Redirects](/docs/guides/redirects#path-parameters-and-wildcards) for details.
|
|
296
|
+
|
|
293
297
|
### `port`
|
|
294
298
|
|
|
295
299
|
The port on which the development server will run. Defaults to `3000`. This option can also be
|
|
@@ -35,6 +35,14 @@ this is already enabled.)
|
|
|
35
35
|
|
|
36
36
|
This will make the API Key UI available at `/settings/api-keys` in your Zudoku.
|
|
37
37
|
|
|
38
|
+
:::note
|
|
39
|
+
|
|
40
|
+
Zuplo's API key service identifies users by their JWT access token. If you use
|
|
41
|
+
[Auth0](../configuration/authentication-auth0.md), configure an `audience` in your authentication
|
|
42
|
+
settings. Without one, Auth0 issues an opaque access token that can't be used to manage API keys.
|
|
43
|
+
|
|
44
|
+
:::
|
|
45
|
+
|
|
38
46
|
### Creating API Keys
|
|
39
47
|
|
|
40
48
|
By default, there is no **Create API Key** Button. To get the button working you have to implement
|
package/docs/guides/redirects.md
CHANGED
|
@@ -52,6 +52,43 @@ redirects: [{ from: "/old-page", to: "/new-page" }];
|
|
|
52
52
|
Trailing slashes in the `from` path are normalized automatically. Both `/old-page` and `/old-page/`
|
|
53
53
|
will match the same redirect rule.
|
|
54
54
|
|
|
55
|
+
## Path parameters and wildcards
|
|
56
|
+
|
|
57
|
+
A `from` path can match more than one URL. Use `:name` for a single path segment and a trailing `*`
|
|
58
|
+
for everything after a prefix. Reference the same tokens in `to` to carry the matched values over:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
redirects: [
|
|
62
|
+
// /blog/hello-world → /posts/hello-world
|
|
63
|
+
{ from: "/blog/:slug", to: "/posts/:slug" },
|
|
64
|
+
// /v1/guides/auth/tokens → /guides/auth/tokens
|
|
65
|
+
{ from: "/v1/*", to: "/*" },
|
|
66
|
+
// /dashboard/apps/42 → https://dashboard.example.com/apps/42
|
|
67
|
+
{ from: "/dashboard/*", to: "https://dashboard.example.com/*" },
|
|
68
|
+
];
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- **`:name`** matches exactly one segment. Add `?` (`:name?`) to make it optional.
|
|
72
|
+
- **`*`** must be the last segment of `from`. It also matches the prefix itself, so `/dashboard/*`
|
|
73
|
+
redirects `/dashboard` too. In that case the `/*` in `to` is dropped, leaving `/` if nothing else
|
|
74
|
+
remains.
|
|
75
|
+
- In `to`, `/:name` and `/*` must each fill a whole path segment, so `/posts/:slug` works and
|
|
76
|
+
`/posts-:slug` doesn't. Tokens that don't appear in `from` are left as they are.
|
|
77
|
+
- Zudoku doesn't copy the incoming query string to the target.
|
|
78
|
+
|
|
79
|
+
Pages in your portal take precedence over a wildcard redirect, so `{ from: "/docs/*", to: "/" }`
|
|
80
|
+
still lets visitors reach `/docs/introduction` if that page exists.
|
|
81
|
+
|
|
82
|
+
Static hosts can't prerender a file for every URL a pattern matches, so pattern redirects behave
|
|
83
|
+
differently from exact ones:
|
|
84
|
+
|
|
85
|
+
- **Zuplo** and **Vercel**: Zudoku turns the pattern into a regular-expression route in the build
|
|
86
|
+
output, and the platform returns an HTTP **301**.
|
|
87
|
+
- **SSR deployments**: the server returns an HTTP 301, the same as exact redirects.
|
|
88
|
+
- **Other static hosts**: no redirect file is written. If your host serves `404.html` (or your app's
|
|
89
|
+
`index.html`) for unknown paths, the page loads, Zudoku matches the pattern, and the browser is
|
|
90
|
+
redirected. Otherwise, set up the redirect in your host's own configuration.
|
|
91
|
+
|
|
55
92
|
## How redirects work
|
|
56
93
|
|
|
57
94
|
Zudoku redirects operate at two levels depending on how the visitor reaches the page:
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: x-zudoku-request-box-enabled
|
|
3
|
+
sidebar_icon: square-code
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Use `x-zudoku-request-box-enabled` to show or hide the request box at the top of an operation's
|
|
7
|
+
sidecar for a specific operation. By default, the request box is shown for all operations unless
|
|
8
|
+
globally disabled via the [`disableRequestBox`](/docs/configuration/api-reference) option.
|
|
9
|
+
|
|
10
|
+
The request box holds the method and path, the generated code snippet (or your
|
|
11
|
+
[`x-code-samples`](./x-code-samples)) with its language selector, the auth selector and the button
|
|
12
|
+
that opens the playground. Hiding it also removes the way to open the playground for that operation.
|
|
13
|
+
The request body and response examples below it are still shown.
|
|
14
|
+
|
|
15
|
+
## Location
|
|
16
|
+
|
|
17
|
+
The extension is added at the **Operation Object** level.
|
|
18
|
+
|
|
19
|
+
| Option | Type | Description |
|
|
20
|
+
| ------------------------------ | --------- | ------------------------------------------------ |
|
|
21
|
+
| `x-zudoku-request-box-enabled` | `boolean` | Show (`true`) or hide (`false`) the request box. |
|
|
22
|
+
|
|
23
|
+
If the extension is not set, the request box visibility falls back to the global `disableRequestBox`
|
|
24
|
+
configuration.
|
|
25
|
+
|
|
26
|
+
## Example
|
|
27
|
+
|
|
28
|
+
```yaml
|
|
29
|
+
paths:
|
|
30
|
+
/users:
|
|
31
|
+
get:
|
|
32
|
+
summary: List users
|
|
33
|
+
x-zudoku-request-box-enabled: true
|
|
34
|
+
responses:
|
|
35
|
+
"200":
|
|
36
|
+
description: Successful response
|
|
37
|
+
/webhooks/trigger:
|
|
38
|
+
post:
|
|
39
|
+
summary: Trigger webhook
|
|
40
|
+
x-zudoku-request-box-enabled: false
|
|
41
|
+
responses:
|
|
42
|
+
"200":
|
|
43
|
+
description: Accepted
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
In this example, `List users` shows the request box while `Trigger webhook` hides it regardless of
|
|
47
|
+
the global setting.
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { redirect, type RouteObject } from "react-router";
|
|
2
2
|
import type { ZudokuRedirect } from "../../config/validators/ZudokuConfig.js";
|
|
3
3
|
import { joinUrl } from "../../lib/util/joinUrl.js";
|
|
4
|
+
import { resolveRedirectTarget } from "../../lib/util/redirectPattern.js";
|
|
4
5
|
|
|
5
6
|
export const createRedirectRoutes = (
|
|
6
7
|
redirects?: ZudokuRedirect[],
|
|
7
8
|
): RouteObject[] =>
|
|
8
9
|
(redirects ?? []).map((r) => ({
|
|
9
10
|
path: joinUrl(r.from),
|
|
10
|
-
loader: () => redirect(r
|
|
11
|
+
loader: ({ params }) => redirect(resolveRedirectTarget(r, params), 301),
|
|
11
12
|
}));
|
|
@@ -93,6 +93,7 @@ const ApiOptionsSchema = z
|
|
|
93
93
|
supportedLanguages: z.array(LanguageOption),
|
|
94
94
|
disablePlayground: z.boolean(),
|
|
95
95
|
disableSidecar: z.boolean(),
|
|
96
|
+
disableRequestBox: z.boolean(),
|
|
96
97
|
showVersionSelect: z.enum(["always", "if-available", "hide"]),
|
|
97
98
|
expandAllTags: z.boolean(),
|
|
98
99
|
showInfoPage: z.boolean(),
|
|
@@ -17,7 +17,7 @@ import { LogoutCallbackHandler } from "../components/LogoutCallbackHandler.js";
|
|
|
17
17
|
import { OAuthErrorPage } from "../components/OAuthErrorPage.js";
|
|
18
18
|
import { fetchServerSession } from "../cookie-sync.js";
|
|
19
19
|
import { DEFAULT_SESSION_MAX_AGE, SESSION_ENDPOINT_PATH } from "../cookies.js";
|
|
20
|
-
import { AuthorizationError
|
|
20
|
+
import { AuthorizationError } from "../errors.js";
|
|
21
21
|
import { type UserProfile, useAuthState } from "../state.js";
|
|
22
22
|
import { redirectToSignUpUrl } from "./util.js";
|
|
23
23
|
|
|
@@ -178,19 +178,6 @@ export class OpenIDAuthenticationProvider
|
|
|
178
178
|
throw new AuthorizationError("No expires_in in response");
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
const accessToken = response.access_token;
|
|
182
|
-
if (accessToken.split(".").length !== 3) {
|
|
183
|
-
throw new OAuthAuthorizationError(
|
|
184
|
-
"The access token received is not a valid JWT.",
|
|
185
|
-
{
|
|
186
|
-
error: "configuration_error",
|
|
187
|
-
error_description:
|
|
188
|
-
"The authentication provider is issuing opaque tokens instead of JWTs. " +
|
|
189
|
-
"Ensure you have configured the correct `audience` in your authentication settings.",
|
|
190
|
-
},
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
181
|
const claims = response.id_token
|
|
195
182
|
? oauth.getValidatedIdTokenClaims(response)
|
|
196
183
|
: undefined;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ZudokuContext } from "../../core/ZudokuContext.js";
|
|
2
|
+
import { ZudokuError } from "../../util/invariant.js";
|
|
3
|
+
|
|
4
|
+
const isJwt = (token: string) => token.split(".").length === 3;
|
|
5
|
+
|
|
6
|
+
const isAuthFailure = (response: Response) =>
|
|
7
|
+
response.status === 401 || response.status === 403;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Signs and sends a request to Zuplo's API key service. The service identifies
|
|
11
|
+
* the user from a JWT access token, so an opaque token (e.g. Auth0 without an
|
|
12
|
+
* `audience`) rejected with 401/403 is reported as a configuration error
|
|
13
|
+
* instead of a generic request failure.
|
|
14
|
+
*/
|
|
15
|
+
export const fetchSigned = async (context: ZudokuContext, request: Request) => {
|
|
16
|
+
const signedRequest = await context.signRequest(request);
|
|
17
|
+
const response = await fetch(signedRequest);
|
|
18
|
+
|
|
19
|
+
const accessToken = signedRequest.headers
|
|
20
|
+
.get("Authorization")
|
|
21
|
+
?.replace(/^Bearer /i, "");
|
|
22
|
+
|
|
23
|
+
if (isAuthFailure(response) && accessToken && !isJwt(accessToken)) {
|
|
24
|
+
throw new ZudokuError(
|
|
25
|
+
"The access token issued by the authentication provider can't be used to manage API keys.",
|
|
26
|
+
{
|
|
27
|
+
title: "Configuration Error",
|
|
28
|
+
developerHint:
|
|
29
|
+
"Zuplo API key management requires a JWT access token, but the authentication provider issued an opaque token. If you use Auth0, set `audience` in your authentication configuration to the identifier of your Auth0 API.",
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return response;
|
|
35
|
+
};
|
|
@@ -11,6 +11,7 @@ import type { ZudokuContext } from "../../core/ZudokuContext.js";
|
|
|
11
11
|
import invariant from "../../util/invariant.js";
|
|
12
12
|
import { joinUrl } from "../../util/joinUrl.js";
|
|
13
13
|
import { throwIfProblemJson } from "../../util/problemJson.js";
|
|
14
|
+
import { fetchSigned } from "./fetchSigned.js";
|
|
14
15
|
import { SettingsApiKeys } from "./SettingsApiKeys.js";
|
|
15
16
|
|
|
16
17
|
const DEFAULT_GATEWAY_URL = "https://api.zuploedge.com";
|
|
@@ -93,7 +94,7 @@ const createZuploService = ({
|
|
|
93
94
|
method: "DELETE",
|
|
94
95
|
},
|
|
95
96
|
);
|
|
96
|
-
const response = await
|
|
97
|
+
const response = await fetchSigned(context, request);
|
|
97
98
|
await throwIfProblemJson(response);
|
|
98
99
|
invariant(response.ok, "Failed to delete API key");
|
|
99
100
|
},
|
|
@@ -103,23 +104,22 @@ const createZuploService = ({
|
|
|
103
104
|
"Cannot update API key description.",
|
|
104
105
|
developerHintOptions,
|
|
105
106
|
);
|
|
106
|
-
const response = await
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
),
|
|
113
|
-
{
|
|
114
|
-
method: "PATCH",
|
|
115
|
-
headers: {
|
|
116
|
-
"Content-Type": "application/json",
|
|
117
|
-
},
|
|
118
|
-
body: JSON.stringify({
|
|
119
|
-
label: consumer.label,
|
|
120
|
-
}),
|
|
121
|
-
},
|
|
107
|
+
const response = await fetchSigned(
|
|
108
|
+
context,
|
|
109
|
+
new Request(
|
|
110
|
+
joinUrl(
|
|
111
|
+
getApiKeyEndpoint(context),
|
|
112
|
+
`${deploymentName}/consumers/${consumer.id}`,
|
|
122
113
|
),
|
|
114
|
+
{
|
|
115
|
+
method: "PATCH",
|
|
116
|
+
headers: {
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
},
|
|
119
|
+
body: JSON.stringify({
|
|
120
|
+
label: consumer.label,
|
|
121
|
+
}),
|
|
122
|
+
},
|
|
123
123
|
),
|
|
124
124
|
);
|
|
125
125
|
await throwIfProblemJson(response);
|
|
@@ -127,21 +127,20 @@ const createZuploService = ({
|
|
|
127
127
|
},
|
|
128
128
|
rollKey: async (consumerId, context) => {
|
|
129
129
|
invariant(deploymentName, "Cannot roll API key.", developerHintOptions);
|
|
130
|
-
const response = await
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
),
|
|
137
|
-
{
|
|
138
|
-
method: "POST",
|
|
139
|
-
headers: {
|
|
140
|
-
"Content-Type": "application/json",
|
|
141
|
-
},
|
|
142
|
-
body: JSON.stringify({}),
|
|
143
|
-
},
|
|
130
|
+
const response = await fetchSigned(
|
|
131
|
+
context,
|
|
132
|
+
new Request(
|
|
133
|
+
joinUrl(
|
|
134
|
+
getApiKeyEndpoint(context),
|
|
135
|
+
`${deploymentName}/consumers/${consumerId}/roll-key`,
|
|
144
136
|
),
|
|
137
|
+
{
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: {
|
|
140
|
+
"Content-Type": "application/json",
|
|
141
|
+
},
|
|
142
|
+
body: JSON.stringify({}),
|
|
143
|
+
},
|
|
145
144
|
),
|
|
146
145
|
);
|
|
147
146
|
await throwIfProblemJson(response);
|
|
@@ -152,9 +151,7 @@ const createZuploService = ({
|
|
|
152
151
|
const request = new Request(
|
|
153
152
|
joinUrl(getApiKeyEndpoint(context), `${deploymentName}/consumers`),
|
|
154
153
|
);
|
|
155
|
-
await context
|
|
156
|
-
|
|
157
|
-
const keys = await fetch(request);
|
|
154
|
+
const keys = await fetchSigned(context, request);
|
|
158
155
|
await throwIfProblemJson(keys);
|
|
159
156
|
invariant(keys.ok, "Failed to fetch API keys");
|
|
160
157
|
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
import { generateSchemaExample } from "./util/generateSchemaExample.js";
|
|
34
34
|
import { getGraphQLEndpoint } from "./util/graphqlEndpoint.js";
|
|
35
35
|
import { methodForColor } from "./util/methodToColor.js";
|
|
36
|
+
import { shouldShowRequestBox } from "./util/shouldShowRequestBox.js";
|
|
36
37
|
import { useResolvedAuth } from "./util/useResolvedAuth.js";
|
|
37
38
|
|
|
38
39
|
export const GetServerQuery = graphql(/* GraphQL */ `
|
|
@@ -221,7 +222,14 @@ export const Sidecar = ({
|
|
|
221
222
|
const graphQLEndpoint = getGraphQLEndpoint(operation);
|
|
222
223
|
const isGraphQLEndpoint = graphQLEndpoint !== undefined;
|
|
223
224
|
|
|
225
|
+
const showRequestBox = shouldShowRequestBox(
|
|
226
|
+
operation.extensions,
|
|
227
|
+
options?.disableRequestBox,
|
|
228
|
+
);
|
|
229
|
+
|
|
224
230
|
const httpSnippetCode = useMemo<string | undefined>(() => {
|
|
231
|
+
if (!showRequestBox) return;
|
|
232
|
+
|
|
225
233
|
if (codeSamples && !hasResolvedAuth) {
|
|
226
234
|
const match = codeSamples.find((s) => s.lang === selectedLang);
|
|
227
235
|
return match?.source;
|
|
@@ -258,6 +266,7 @@ export const Sidecar = ({
|
|
|
258
266
|
|
|
259
267
|
return getConverted(snippet, selectedLang);
|
|
260
268
|
}, [
|
|
269
|
+
showRequestBox,
|
|
261
270
|
codeSamples,
|
|
262
271
|
currentExampleCode,
|
|
263
272
|
operation,
|
|
@@ -331,82 +340,84 @@ export const Sidecar = ({
|
|
|
331
340
|
className="flex flex-col sticky top-(--scroll-padding) gap-4"
|
|
332
341
|
data-pagefind-ignore="all"
|
|
333
342
|
>
|
|
334
|
-
|
|
335
|
-
<SidecarBox.
|
|
336
|
-
<
|
|
337
|
-
<
|
|
338
|
-
<
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
343
|
+
{showRequestBox && (
|
|
344
|
+
<SidecarBox.Root>
|
|
345
|
+
<SidecarBox.Head className="py-1.5">
|
|
346
|
+
<div className="flex items-center flex-wrap gap-2 justify-between w-full">
|
|
347
|
+
<span className="font-mono wrap-break-word leading-6 space-x-1">
|
|
348
|
+
<Badge
|
|
349
|
+
variant="outline"
|
|
350
|
+
className={cn(
|
|
351
|
+
methodTextColor,
|
|
352
|
+
"px-1.5 rounded-md border-none bg-current/7 dark:bg-current/15",
|
|
353
|
+
)}
|
|
354
|
+
>
|
|
355
|
+
{operation.method.toUpperCase()}
|
|
356
|
+
</Badge>
|
|
357
|
+
{path}
|
|
358
|
+
</span>
|
|
359
|
+
{showPlayground &&
|
|
360
|
+
(isGraphQLEndpoint ? (
|
|
361
|
+
<GraphiQLDialog
|
|
362
|
+
endpoint={graphQLEndpoint?.endpoint ?? operationUrl}
|
|
363
|
+
operation={operation}
|
|
364
|
+
securitySchemes={securitySchemes}
|
|
365
|
+
defaultTabs={
|
|
366
|
+
graphQLTabs && graphQLTabs.length > 0
|
|
367
|
+
? graphQLTabs
|
|
368
|
+
: undefined
|
|
369
|
+
}
|
|
370
|
+
/>
|
|
371
|
+
) : (
|
|
372
|
+
<PlaygroundDialogWrapper
|
|
373
|
+
servers={operation.servers}
|
|
374
|
+
operation={operation}
|
|
375
|
+
examples={requestBodyContent ?? undefined}
|
|
376
|
+
/>
|
|
377
|
+
))}
|
|
378
|
+
</div>
|
|
379
|
+
</SidecarBox.Head>
|
|
380
|
+
<SidecarBox.Body>
|
|
381
|
+
{shouldLazyHighlight && !isOnScreen ? (
|
|
382
|
+
<NonHighlightedCode code={httpSnippetCode ?? ""} />
|
|
383
|
+
) : (
|
|
384
|
+
<SyntaxHighlight
|
|
385
|
+
embedded
|
|
386
|
+
language={selectedLang}
|
|
387
|
+
showLanguageIndicator={false}
|
|
388
|
+
className="[--scrollbar-color:gray] rounded-none text-xs max-h-50"
|
|
389
|
+
// biome-ignore lint/style/noNonNullAssertion: code is guaranteed to be defined
|
|
390
|
+
code={httpSnippetCode!}
|
|
391
|
+
/>
|
|
392
|
+
)}
|
|
393
|
+
</SidecarBox.Body>
|
|
394
|
+
<SidecarBox.Footer className="text-xs self-end flex justify-between items-center gap-2">
|
|
395
|
+
<NativeSelect
|
|
396
|
+
className="text-xs h-fit py-1 max-w-32 truncate bg-background"
|
|
397
|
+
value={selectedLang}
|
|
398
|
+
onChange={(e) => {
|
|
399
|
+
startTransition(() => {
|
|
400
|
+
setSearchParams((prev) => {
|
|
401
|
+
prev.set("lang", e.target.value);
|
|
402
|
+
return prev;
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
}}
|
|
406
|
+
>
|
|
407
|
+
{supportedLanguages.map((language) => (
|
|
408
|
+
<NativeSelectOption key={language.value} value={language.value}>
|
|
409
|
+
{language.label}
|
|
410
|
+
</NativeSelectOption>
|
|
367
411
|
))}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
) : (
|
|
374
|
-
<SyntaxHighlight
|
|
375
|
-
embedded
|
|
376
|
-
language={selectedLang}
|
|
377
|
-
showLanguageIndicator={false}
|
|
378
|
-
className="[--scrollbar-color:gray] rounded-none text-xs max-h-50"
|
|
379
|
-
// biome-ignore lint/style/noNonNullAssertion: code is guaranteed to be defined
|
|
380
|
-
code={httpSnippetCode!}
|
|
412
|
+
</NativeSelect>
|
|
413
|
+
<AuthSelectorPopover
|
|
414
|
+
operation={operation}
|
|
415
|
+
url={operationUrl}
|
|
416
|
+
securitySchemes={securitySchemes}
|
|
381
417
|
/>
|
|
382
|
-
|
|
383
|
-
</SidecarBox.
|
|
384
|
-
|
|
385
|
-
<NativeSelect
|
|
386
|
-
className="text-xs h-fit py-1 max-w-32 truncate bg-background"
|
|
387
|
-
value={selectedLang}
|
|
388
|
-
onChange={(e) => {
|
|
389
|
-
startTransition(() => {
|
|
390
|
-
setSearchParams((prev) => {
|
|
391
|
-
prev.set("lang", e.target.value);
|
|
392
|
-
return prev;
|
|
393
|
-
});
|
|
394
|
-
});
|
|
395
|
-
}}
|
|
396
|
-
>
|
|
397
|
-
{supportedLanguages.map((language) => (
|
|
398
|
-
<NativeSelectOption key={language.value} value={language.value}>
|
|
399
|
-
{language.label}
|
|
400
|
-
</NativeSelectOption>
|
|
401
|
-
))}
|
|
402
|
-
</NativeSelect>
|
|
403
|
-
<AuthSelectorPopover
|
|
404
|
-
operation={operation}
|
|
405
|
-
url={operationUrl}
|
|
406
|
-
securitySchemes={securitySchemes}
|
|
407
|
-
/>
|
|
408
|
-
</SidecarBox.Footer>
|
|
409
|
-
</SidecarBox.Root>
|
|
418
|
+
</SidecarBox.Footer>
|
|
419
|
+
</SidecarBox.Root>
|
|
420
|
+
)}
|
|
410
421
|
|
|
411
422
|
{transformedRequestBodyContent && currentExample ? (
|
|
412
423
|
<RequestBodySidecarBox
|
|
@@ -88,6 +88,7 @@ type BaseOasConfig = {
|
|
|
88
88
|
supportedLanguages?: { value: string; label: string }[];
|
|
89
89
|
disablePlayground?: boolean;
|
|
90
90
|
disableSidecar?: boolean;
|
|
91
|
+
disableRequestBox?: boolean;
|
|
91
92
|
disableSecurity?: boolean;
|
|
92
93
|
disableMcpAuthInstructions?: boolean;
|
|
93
94
|
showVersionSelect?: "always" | "if-available" | "hide";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides whether the request box (method and path, code snippet, language
|
|
3
|
+
* selector, playground button and auth selector) is shown at the top of an
|
|
4
|
+
* operation's sidecar.
|
|
5
|
+
*
|
|
6
|
+
* - `x-zudoku-request-box-enabled: true` shows it, even if the API sets
|
|
7
|
+
* `disableRequestBox`.
|
|
8
|
+
* - Any other explicit value hides it, matching `x-zudoku-playground-enabled`.
|
|
9
|
+
* - When unset (`undefined`), it falls back to the API's `disableRequestBox`.
|
|
10
|
+
*/
|
|
11
|
+
export const shouldShowRequestBox = (
|
|
12
|
+
extensions: Record<string, unknown> | null | undefined,
|
|
13
|
+
disableRequestBox: boolean | undefined,
|
|
14
|
+
) => {
|
|
15
|
+
const enabled = extensions?.["x-zudoku-request-box-enabled"];
|
|
16
|
+
if (enabled === undefined) return !disableRequestBox;
|
|
17
|
+
return enabled === true;
|
|
18
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { joinUrl } from "./joinUrl.js";
|
|
2
|
+
|
|
3
|
+
type Redirect = { from: string; to: string };
|
|
4
|
+
|
|
5
|
+
type Segment =
|
|
6
|
+
| { type: "static"; value: string }
|
|
7
|
+
| { type: "param"; name: string; optional: boolean }
|
|
8
|
+
| { type: "splat" };
|
|
9
|
+
|
|
10
|
+
const SPLAT = "*";
|
|
11
|
+
// React Router's own check for URLs that leave the app
|
|
12
|
+
const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i;
|
|
13
|
+
// A `/:param` or `/*` token that spans a whole path segment
|
|
14
|
+
const TOKEN_REGEX = /\/(?::([\w-]+)|\*)(?=[/?#]|$)/g;
|
|
15
|
+
|
|
16
|
+
// Parses a `from` path using React Router's syntax: `:param`, `:param?` and a
|
|
17
|
+
// trailing `*` splat.
|
|
18
|
+
const parseSegments = (path: string): Segment[] => {
|
|
19
|
+
const parts = path.split("/").filter(Boolean);
|
|
20
|
+
return parts.map((part, index): Segment => {
|
|
21
|
+
if (part === SPLAT && index === parts.length - 1) return { type: "splat" };
|
|
22
|
+
const match = part.match(/^:([\w-]+)(\?)?$/);
|
|
23
|
+
if (!match?.[1]) return { type: "static", value: part };
|
|
24
|
+
return { type: "param", name: match[1], optional: match[2] === "?" };
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const getParamNames = (from: string) =>
|
|
29
|
+
parseSegments(from).flatMap((segment) => {
|
|
30
|
+
if (segment.type === "param") return [segment.name];
|
|
31
|
+
if (segment.type === "splat") return [SPLAT];
|
|
32
|
+
return [];
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const isDynamicRedirect = (redirect: Pick<Redirect, "from">) =>
|
|
36
|
+
getParamNames(redirect.from).length > 0;
|
|
37
|
+
|
|
38
|
+
// Replaces the `/:param` and `/*` tokens in `to` that `from` defines. Tokens
|
|
39
|
+
// unknown to `from` are left untouched. Removing empty params can leave no path
|
|
40
|
+
// at all (`/*` for `/v1/*` at `/v1`), which would redirect back to the same
|
|
41
|
+
// URL, so that falls back to the root. A `to` without replaced tokens is kept
|
|
42
|
+
// as written, so relative targets like `?tab=2` still work.
|
|
43
|
+
const replaceTokens = (
|
|
44
|
+
to: string,
|
|
45
|
+
names: string[],
|
|
46
|
+
replace: (name: string) => string,
|
|
47
|
+
) => {
|
|
48
|
+
const target = to.replace(TOKEN_REGEX, (token, name: string | undefined) => {
|
|
49
|
+
const key = name ?? SPLAT;
|
|
50
|
+
return names.includes(key) ? replace(key) : token;
|
|
51
|
+
});
|
|
52
|
+
const lostPath = target === "" || /^[?#]/.test(target);
|
|
53
|
+
return target !== to && lostPath ? `/${target}` : target;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const encodeParam = (name: string, value: string) =>
|
|
57
|
+
name === SPLAT
|
|
58
|
+
? value.split("/").map(encodeURIComponent).join("/")
|
|
59
|
+
: encodeURIComponent(value);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Builds the redirect target for a matched `from` path by substituting the
|
|
63
|
+
* matched params into `to`, e.g. `{ from: "/blog/:slug", to: "/posts/:slug" }`
|
|
64
|
+
* sends `/blog/hello` to `/posts/hello`. Empty params drop their segment.
|
|
65
|
+
*/
|
|
66
|
+
export const resolveRedirectTarget = (
|
|
67
|
+
{ from, to }: Redirect,
|
|
68
|
+
params: Record<string, string | undefined>,
|
|
69
|
+
) =>
|
|
70
|
+
replaceTokens(to, getParamNames(from), (name) => {
|
|
71
|
+
const value = params[name];
|
|
72
|
+
return value ? `/${encodeParam(name, value)}` : "";
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Mirrors React Router's `computeScore` for a concrete (non-optional) path.
|
|
76
|
+
const rankPath = (path: string) => {
|
|
77
|
+
const segments = joinUrl(path).split("/");
|
|
78
|
+
return segments.reduce(
|
|
79
|
+
(score, segment) => {
|
|
80
|
+
if (segment === SPLAT) return score;
|
|
81
|
+
if (segment.startsWith(":")) return score + 3;
|
|
82
|
+
return score + (segment === "" ? 1 : 10);
|
|
83
|
+
},
|
|
84
|
+
segments.length - (segments.includes(SPLAT) ? 2 : 0),
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const segmentToPath = (segment: Segment) => {
|
|
89
|
+
if (segment.type === "static") return segment.value;
|
|
90
|
+
if (segment.type === "splat") return SPLAT;
|
|
91
|
+
return `:${segment.name}`;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// React Router ranks each concrete variant of a path with optional segments,
|
|
95
|
+
// in this order: `/:a?/:b?` becomes `/:a/:b`, `/:a`, `/:b` and `/`.
|
|
96
|
+
const explodeOptional = (segments: Segment[]): Segment[][] => {
|
|
97
|
+
const [first, ...rest] = segments;
|
|
98
|
+
if (!first) return [[]];
|
|
99
|
+
const variants = explodeOptional(rest);
|
|
100
|
+
const withFirst = variants.map((variant) => [first, ...variant]);
|
|
101
|
+
return first.type === "param" && first.optional
|
|
102
|
+
? [...withFirst, ...variants]
|
|
103
|
+
: withFirst;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const escapeRegex = (value: string) =>
|
|
107
|
+
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Converts dynamic redirects into Build Output API routes. Each concrete
|
|
111
|
+
* variant of a `from` path becomes a regex with one capture group per param
|
|
112
|
+
* (including its leading slash), and the tokens in `to` become the matching
|
|
113
|
+
* `$n` references. Build Output routes match top to bottom, so they are sorted
|
|
114
|
+
* the way React Router ranks the same paths, keeping configuration order for
|
|
115
|
+
* ties.
|
|
116
|
+
*/
|
|
117
|
+
export const toBuildOutputRedirects = (
|
|
118
|
+
redirects: readonly Redirect[],
|
|
119
|
+
basePath?: string,
|
|
120
|
+
) => {
|
|
121
|
+
const base = joinUrl(basePath);
|
|
122
|
+
const prefix = base === "/" ? "" : escapeRegex(base);
|
|
123
|
+
|
|
124
|
+
return redirects
|
|
125
|
+
.flatMap((redirect) => {
|
|
126
|
+
const names = getParamNames(redirect.from);
|
|
127
|
+
const to = ABSOLUTE_URL_REGEX.test(redirect.to)
|
|
128
|
+
? redirect.to
|
|
129
|
+
: joinUrl(basePath, redirect.to);
|
|
130
|
+
|
|
131
|
+
const toRoute = (segments: Segment[]) => {
|
|
132
|
+
const captured = segments.flatMap((segment) => {
|
|
133
|
+
if (segment.type === "param") return [segment.name];
|
|
134
|
+
if (segment.type === "splat") return [SPLAT];
|
|
135
|
+
return [];
|
|
136
|
+
});
|
|
137
|
+
const pattern = segments
|
|
138
|
+
.map((segment) => {
|
|
139
|
+
if (segment.type === "static") {
|
|
140
|
+
return `/${escapeRegex(segment.value)}`;
|
|
141
|
+
}
|
|
142
|
+
return segment.type === "splat" ? "(/.+)" : "(/[^/]+)";
|
|
143
|
+
})
|
|
144
|
+
.join("");
|
|
145
|
+
const endsWithSplat = segments.at(-1)?.type === "splat";
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
// React Router accepts any number of trailing slashes
|
|
149
|
+
src: `^${prefix}${pattern}${endsWithSplat ? "" : "/*"}$`,
|
|
150
|
+
// Like React Router, a repeated param name takes the last value
|
|
151
|
+
location: replaceTokens(to, names, (name) =>
|
|
152
|
+
captured.includes(name) ? `$${captured.lastIndexOf(name) + 1}` : "",
|
|
153
|
+
),
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return explodeOptional(parseSegments(redirect.from)).flatMap(
|
|
158
|
+
(segments) => {
|
|
159
|
+
const rank = rankPath(`/${segments.map(segmentToPath).join("/")}`);
|
|
160
|
+
// An empty splat gets its own route so its target is resolved here
|
|
161
|
+
// rather than from an empty `$n` at request time
|
|
162
|
+
const variants =
|
|
163
|
+
segments.at(-1)?.type === "splat"
|
|
164
|
+
? [segments, segments.slice(0, -1)]
|
|
165
|
+
: [segments];
|
|
166
|
+
return variants.map((variant) => ({ rank, ...toRoute(variant) }));
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
})
|
|
170
|
+
.toSorted((a, b) => b.rank - a.rank)
|
|
171
|
+
.map(({ src, location }) => ({ src, location }));
|
|
172
|
+
};
|
package/src/vite/output.ts
CHANGED
|
@@ -6,6 +6,10 @@ import { getZudokuPackageJson } from "../cli/common/package-json.js";
|
|
|
6
6
|
import type { LoadedConfig } from "../config/config.js";
|
|
7
7
|
import invariant from "../lib/util/invariant.js";
|
|
8
8
|
import { joinUrl } from "../lib/util/joinUrl.js";
|
|
9
|
+
import {
|
|
10
|
+
isDynamicRedirect,
|
|
11
|
+
toBuildOutputRedirects,
|
|
12
|
+
} from "../lib/util/redirectPattern.js";
|
|
9
13
|
import type { RouteRewrite } from "./prerender/utils.js";
|
|
10
14
|
import {
|
|
11
15
|
generateVercelMarkdownMiddleware,
|
|
@@ -200,8 +204,23 @@ export function generateOutput({
|
|
|
200
204
|
});
|
|
201
205
|
}
|
|
202
206
|
|
|
203
|
-
|
|
207
|
+
// Dynamic redirects can't be prerendered, so match them with a regex. They
|
|
208
|
+
// run after the filesystem so real pages win, like React Router's ranking.
|
|
209
|
+
const dynamicRedirects = toBuildOutputRedirects(
|
|
210
|
+
(config.redirects ?? []).filter(isDynamicRedirect),
|
|
211
|
+
config.basePath,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
if (rewrites.length > 0 || dynamicRedirects.length > 0) {
|
|
204
215
|
routes.push({ handle: "filesystem" });
|
|
216
|
+
for (const { src, location } of dynamicRedirects) {
|
|
217
|
+
routes.push({
|
|
218
|
+
src,
|
|
219
|
+
dest: location,
|
|
220
|
+
status: 301,
|
|
221
|
+
headers: { Location: location },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
205
224
|
for (const rewrite of rewrites) {
|
|
206
225
|
routes.push({
|
|
207
226
|
src: joinUrl(config.basePath, rewrite.source),
|
package/src/vite/plugin-api.ts
CHANGED
|
@@ -52,6 +52,7 @@ export const generateDefaultApiOptionsCode = () => [
|
|
|
52
52
|
` supportedLanguages: config.defaults?.apis?.supportedLanguages,`,
|
|
53
53
|
` disablePlayground: config.defaults?.apis?.disablePlayground,`,
|
|
54
54
|
` disableSidecar: config.defaults?.apis?.disableSidecar,`,
|
|
55
|
+
` disableRequestBox: config.defaults?.apis?.disableRequestBox,`,
|
|
55
56
|
` disableSecurity: config.defaults?.apis?.disableSecurity ?? true,`,
|
|
56
57
|
` disableMcpAuthInstructions: config.defaults?.apis?.disableMcpAuthInstructions,`,
|
|
57
58
|
` showVersionSelect: config.defaults?.apis?.showVersionSelect ?? "if-available",`,
|
|
@@ -23,6 +23,7 @@ import { generateSitemap } from "../sitemap.js";
|
|
|
23
23
|
import {
|
|
24
24
|
routesToPrerenderPaths,
|
|
25
25
|
routesToRewrites,
|
|
26
|
+
withoutDynamicRedirectRoutes,
|
|
26
27
|
selectPagesToIndex,
|
|
27
28
|
} from "./utils.js";
|
|
28
29
|
import type { StaticWorkerData, WorkerData } from "./worker.js";
|
|
@@ -87,7 +88,10 @@ export const prerender = async ({
|
|
|
87
88
|
const module = await import(entryServerPath);
|
|
88
89
|
const getRoutes = module.getRoutesByConfig as typeof getRoutesByConfig;
|
|
89
90
|
|
|
90
|
-
const routes =
|
|
91
|
+
const routes = withoutDynamicRedirectRoutes(
|
|
92
|
+
getRoutes(config),
|
|
93
|
+
config.redirects,
|
|
94
|
+
);
|
|
91
95
|
const paths = routesToPrerenderPaths(routes, config.redirects);
|
|
92
96
|
const rewrites = routesToRewrites(routes);
|
|
93
97
|
const { maxThreads, maxOldGenerationSizeMb } = getWorkerScaling(
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RouteObject } from "react-router";
|
|
2
2
|
import { joinUrl } from "../../lib/util/joinUrl.js";
|
|
3
|
+
import { isDynamicRedirect } from "../../lib/util/redirectPattern.js";
|
|
3
4
|
|
|
4
5
|
const resolveRoutePath = (path: string): string | undefined => {
|
|
5
6
|
const segments = path.split("/");
|
|
@@ -72,6 +73,20 @@ const collectRewrites = (resolved: ResolvedRoute[]): RouteRewrite[] =>
|
|
|
72
73
|
export const routesToPaths = (routes: RouteObject[]): string[] =>
|
|
73
74
|
collectPaths(resolveRoutes(routes), "");
|
|
74
75
|
|
|
76
|
+
// Dynamic redirects (`/:param`, `/*`) can't be prerendered to a file; they are
|
|
77
|
+
// emitted as Build Output API routes instead (see `generateOutput`). Their
|
|
78
|
+
// top-level route objects are dropped so optional params don't get stripped
|
|
79
|
+
// into a prerendered path and rewrite.
|
|
80
|
+
export const withoutDynamicRedirectRoutes = (
|
|
81
|
+
routes: RouteObject[],
|
|
82
|
+
redirects: readonly { from: string }[] = [],
|
|
83
|
+
): RouteObject[] => {
|
|
84
|
+
const paths = new Set(
|
|
85
|
+
redirects.filter(isDynamicRedirect).map(({ from }) => joinUrl(from)),
|
|
86
|
+
);
|
|
87
|
+
return routes.filter((route) => !route.path || !paths.has(route.path));
|
|
88
|
+
};
|
|
89
|
+
|
|
75
90
|
export const routesToPrerenderPaths = (
|
|
76
91
|
routes: RouteObject[],
|
|
77
92
|
redirects: readonly { from: string }[] = [],
|
|
@@ -79,7 +94,9 @@ export const routesToPrerenderPaths = (
|
|
|
79
94
|
Array.from(
|
|
80
95
|
new Set([
|
|
81
96
|
...routesToPaths(routes),
|
|
82
|
-
...redirects
|
|
97
|
+
...redirects
|
|
98
|
+
.filter((redirect) => !isDynamicRedirect(redirect))
|
|
99
|
+
.map(({ from }) => joinUrl(from)),
|
|
83
100
|
]),
|
|
84
101
|
);
|
|
85
102
|
|