@kingsleyweb/bun-ui 0.0.0-stage → 3.0.0-rc.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/LICENSE +21 -0
- package/README.md +80 -2
- package/app/docs/App.tsx +72 -0
- package/app/docs/api/client.ts +303 -0
- package/app/docs/api/contract.ts +24 -0
- package/app/docs/api/docs.ts +38 -0
- package/app/docs/api/errors.ts +144 -0
- package/app/docs/boot.tsx +58 -0
- package/app/docs/components/Badge.tsx +65 -0
- package/app/docs/components/Button.tsx +37 -0
- package/app/docs/components/Card.tsx +41 -0
- package/app/docs/components/ConfirmDialog.tsx +173 -0
- package/app/docs/components/CopyButton.tsx +68 -0
- package/app/docs/components/Dialog.tsx +230 -0
- package/app/docs/components/EmptyState.tsx +34 -0
- package/app/docs/components/ErrorView.tsx +70 -0
- package/app/docs/components/Field.tsx +93 -0
- package/app/docs/components/JsonEditor.tsx +151 -0
- package/app/docs/components/JsonView.tsx +338 -0
- package/app/docs/components/KeyValue.tsx +63 -0
- package/app/docs/components/ProblemBanner.tsx +116 -0
- package/app/docs/components/ScreenErrorBoundary.tsx +141 -0
- package/app/docs/components/Spinner.tsx +33 -0
- package/app/docs/components/Table.tsx +33 -0
- package/app/docs/components/Tabs.tsx +226 -0
- package/app/docs/components/classNames.ts +4 -0
- package/app/docs/components/components.css +691 -0
- package/app/docs/components/copy.ts +89 -0
- package/app/docs/components/fieldContext.ts +29 -0
- package/app/docs/components/inputValues.ts +142 -0
- package/app/docs/components/inputs.tsx +387 -0
- package/app/docs/components/jsonParse.ts +338 -0
- package/app/docs/components/listKeys.ts +30 -0
- package/app/docs/config.ts +86 -0
- package/app/docs/context.ts +33 -0
- package/app/docs/embed.ts +74 -0
- package/app/docs/env.d.ts +2 -0
- package/app/docs/format.ts +9 -0
- package/app/docs/hooks/useUrlTab.ts +16 -0
- package/app/docs/main.tsx +11 -0
- package/app/docs/queryClient.ts +54 -0
- package/app/docs/router.tsx +194 -0
- package/app/docs/routing.ts +194 -0
- package/app/docs/screens/docs/document.ts +27 -0
- package/app/docs/screens/docs/http/OperationView.tsx +407 -0
- package/app/docs/screens/docs/http/Sidebar.tsx +190 -0
- package/app/docs/screens/docs/http/TryItPanel.tsx +603 -0
- package/app/docs/screens/docs/http/http.css +405 -0
- package/app/docs/screens/docs/http/index.tsx +395 -0
- package/app/docs/screens/docs/http/model.ts +604 -0
- package/app/docs/screens/docs/http/tryIt.ts +455 -0
- package/app/docs/screens/docs/permissions.ts +6 -0
- package/app/docs/screens/docs/schema/Prose.tsx +43 -0
- package/app/docs/screens/docs/schema/SchemaTree.tsx +319 -0
- package/app/docs/screens/docs/schema/index.ts +30 -0
- package/app/docs/screens/docs/schema/resolve.ts +466 -0
- package/app/docs/screens/docs/schema/schema.css +137 -0
- package/app/docs/screens/docs/schema/skeleton.ts +92 -0
- package/app/docs/screens/docs/viewer.tsx +154 -0
- package/app/docs/screens/docs/ws/InlineText.tsx +25 -0
- package/app/docs/screens/docs/ws/PayloadSchema.tsx +39 -0
- package/app/docs/screens/docs/ws/format.ts +82 -0
- package/app/docs/screens/docs/ws/index.tsx +286 -0
- package/app/docs/screens/docs/ws/items.tsx +786 -0
- package/app/docs/screens/docs/ws/model.ts +1178 -0
- package/app/docs/screens/docs/ws/panels.tsx +440 -0
- package/app/docs/screens/docs/ws/ws.css +231 -0
- package/app/docs/shortcuts.ts +94 -0
- package/app/docs/styles/base.css +847 -0
- package/app/docs/styles/tokens.css +143 -0
- package/app/docs/theme.ts +56 -0
- package/app/kit/unauthorized.ts +222 -0
- package/dist/docs/assets/main-0rchxtv5.js +18 -0
- package/dist/docs/assets/main-0rchxtv5.js.map +99 -0
- package/dist/docs/assets/main-cqgyvs86.css +1 -0
- package/dist/docs/manifest.json +26 -0
- package/dist/jobs/assets/AddFlowDialog-h6xkgx9b.js +6 -0
- package/dist/jobs/assets/AddFlowDialog-h6xkgx9b.js.map +11 -0
- package/dist/jobs/assets/AddFlowDialog-n7gtc84v.css +1 -0
- package/dist/jobs/assets/added-cbwsxv3e.js +4 -0
- package/dist/jobs/assets/added-cbwsxv3e.js.map +11 -0
- package/dist/jobs/assets/bodies-yyw5hmrp.js +6 -0
- package/dist/jobs/assets/bodies-yyw5hmrp.js.map +10 -0
- package/dist/jobs/assets/channel-tgkvqm8y.js +4 -0
- package/dist/jobs/assets/channel-tgkvqm8y.js.map +10 -0
- package/dist/jobs/assets/channelChecks-s4yz4yhw.js +4 -0
- package/dist/jobs/assets/channelChecks-s4yz4yhw.js.map +10 -0
- package/dist/jobs/assets/index-02427smp.css +1 -0
- package/dist/jobs/assets/index-09z2cjft.js +4 -0
- package/dist/jobs/assets/index-09z2cjft.js.map +16 -0
- package/dist/jobs/assets/index-18bn0gvy.css +1 -0
- package/dist/jobs/assets/index-47pxmyxn.js +4 -0
- package/dist/jobs/assets/index-47pxmyxn.js.map +10 -0
- package/dist/jobs/assets/index-4e55z986.js +4 -0
- package/dist/jobs/assets/index-4e55z986.js.map +11 -0
- package/dist/jobs/assets/index-5c933424.js +4 -0
- package/dist/jobs/assets/index-5c933424.js.map +18 -0
- package/dist/jobs/assets/index-5kbyqspm.css +1 -0
- package/dist/jobs/assets/index-5ng37hk6.css +1 -0
- package/dist/jobs/assets/index-5w8w7fvc.css +1 -0
- package/dist/jobs/assets/index-71qs5qyx.css +1 -0
- package/dist/jobs/assets/index-btpg4r8b.css +1 -0
- package/dist/jobs/assets/index-d5r5n0mm.js +4 -0
- package/dist/jobs/assets/index-d5r5n0mm.js.map +23 -0
- package/dist/jobs/assets/index-e0yy2skq.css +1 -0
- package/dist/jobs/assets/index-hs60b25v.js +10 -0
- package/dist/jobs/assets/index-hs60b25v.js.map +62 -0
- package/dist/jobs/assets/index-k2smrr70.js +4 -0
- package/dist/jobs/assets/index-k2smrr70.js.map +9 -0
- package/dist/jobs/assets/index-q24jw3w9.js +4 -0
- package/dist/jobs/assets/index-q24jw3w9.js.map +32 -0
- package/dist/jobs/assets/index-qfb80xen.css +1 -0
- package/dist/jobs/assets/index-rtynx6wj.js +4 -0
- package/dist/jobs/assets/index-rtynx6wj.js.map +10 -0
- package/dist/jobs/assets/live-6k7s6358.js +4 -0
- package/dist/jobs/assets/live-6k7s6358.js.map +10 -0
- package/dist/jobs/assets/main-0jmeh6mq.js +6 -0
- package/dist/jobs/assets/main-0jmeh6mq.js.map +12 -0
- package/dist/jobs/assets/main-3yj6bv90.js +5 -0
- package/dist/jobs/assets/main-3yj6bv90.js.map +10 -0
- package/dist/jobs/assets/main-4g69fn3x.js +5 -0
- package/dist/jobs/assets/main-4g69fn3x.js.map +10 -0
- package/dist/jobs/assets/main-4gte3eqq.js +5 -0
- package/dist/jobs/assets/main-4gte3eqq.js.map +10 -0
- package/dist/jobs/assets/main-4j1fcevh.js +5 -0
- package/dist/jobs/assets/main-4j1fcevh.js.map +10 -0
- package/dist/jobs/assets/main-54qwkz28.css +1 -0
- package/dist/jobs/assets/main-5grjvsy7.js +5 -0
- package/dist/jobs/assets/main-5grjvsy7.js.map +11 -0
- package/dist/jobs/assets/main-5zbx60gq.js +5 -0
- package/dist/jobs/assets/main-5zbx60gq.js.map +11 -0
- package/dist/jobs/assets/main-9prgjhg4.js +5 -0
- package/dist/jobs/assets/main-9prgjhg4.js.map +10 -0
- package/dist/jobs/assets/main-a250ffk7.js +17 -0
- package/dist/jobs/assets/main-a250ffk7.js.map +115 -0
- package/dist/jobs/assets/main-d6ge7w08.js +5 -0
- package/dist/jobs/assets/main-d6ge7w08.js.map +10 -0
- package/dist/jobs/assets/main-gngzht43.js +5 -0
- package/dist/jobs/assets/main-gngzht43.js.map +10 -0
- package/dist/jobs/assets/main-hg0hrg8j.js +5 -0
- package/dist/jobs/assets/main-hg0hrg8j.js.map +10 -0
- package/dist/jobs/assets/main-hm1ng3yg.js +5 -0
- package/dist/jobs/assets/main-hm1ng3yg.js.map +12 -0
- package/dist/jobs/assets/main-jkvd92z9.js +5 -0
- package/dist/jobs/assets/main-jkvd92z9.js.map +10 -0
- package/dist/jobs/assets/main-jv5xqd13.js +5 -0
- package/dist/jobs/assets/main-jv5xqd13.js.map +21 -0
- package/dist/jobs/assets/main-jxre7w93.js +5 -0
- package/dist/jobs/assets/main-jxre7w93.js.map +23 -0
- package/dist/jobs/assets/main-kwmqkjfx.js +5 -0
- package/dist/jobs/assets/main-kwmqkjfx.js.map +11 -0
- package/dist/jobs/assets/main-n9cf6bxm.js +5 -0
- package/dist/jobs/assets/main-n9cf6bxm.js.map +11 -0
- package/dist/jobs/assets/main-pegp0ygb.js +5 -0
- package/dist/jobs/assets/main-pegp0ygb.js.map +10 -0
- package/dist/jobs/assets/main-qr573853.js +5 -0
- package/dist/jobs/assets/main-qr573853.js.map +12 -0
- package/dist/jobs/assets/main-rq97rdr4.js +4 -0
- package/dist/jobs/assets/main-rq97rdr4.js.map +22 -0
- package/dist/jobs/assets/main-szjdmydc.js +5 -0
- package/dist/jobs/assets/main-szjdmydc.js.map +13 -0
- package/dist/jobs/assets/main-tnbhrm9n.js +7 -0
- package/dist/jobs/assets/main-tnbhrm9n.js.map +11 -0
- package/dist/jobs/assets/main-ts19j4n0.js +5 -0
- package/dist/jobs/assets/main-ts19j4n0.js.map +25 -0
- package/dist/jobs/assets/main-w93x1dzs.js +5 -0
- package/dist/jobs/assets/main-w93x1dzs.js.map +10 -0
- package/dist/jobs/assets/sections-0ksdxs8m.css +1 -0
- package/dist/jobs/assets/sections-0y7w4fb4.js +4 -0
- package/dist/jobs/assets/sections-0y7w4fb4.js.map +10 -0
- package/dist/jobs/assets/sse-mn0t0fh7.js +5 -0
- package/dist/jobs/assets/sse-mn0t0fh7.js.map +11 -0
- package/dist/jobs/assets/sseChecks-t7tcm7ra.js +4 -0
- package/dist/jobs/assets/sseChecks-t7tcm7ra.js.map +10 -0
- package/dist/jobs/assets/validation-ysjfy072.js +4 -0
- package/dist/jobs/assets/validation-ysjfy072.js.map +9 -0
- package/dist/jobs/manifest.json +511 -0
- package/dts/docs/assets.d.ts +144 -0
- package/dts/docs/assets.d.ts.map +1 -0
- package/dts/docs/browser/config.d.ts +61 -0
- package/dts/docs/browser/config.d.ts.map +1 -0
- package/dts/docs/browser/index.d.ts +12 -0
- package/dts/docs/browser/index.d.ts.map +1 -0
- package/dts/docs/docsUi.d.ts +42 -0
- package/dts/docs/docsUi.d.ts.map +1 -0
- package/dts/docs/hosted.d.ts +22 -0
- package/dts/docs/hosted.d.ts.map +1 -0
- package/dts/docs/index.d.ts +18 -0
- package/dts/docs/index.d.ts.map +1 -0
- package/dts/docs/shell.d.ts +57 -0
- package/dts/docs/shell.d.ts.map +1 -0
- package/dts/docs/types.d.ts +137 -0
- package/dts/docs/types.d.ts.map +1 -0
- package/dts/index.d.ts +21 -0
- package/dts/index.d.ts.map +1 -0
- package/dts/jobs/assets.d.ts +157 -0
- package/dts/jobs/assets.d.ts.map +1 -0
- package/dts/jobs/browser/config.d.ts +67 -0
- package/dts/jobs/browser/config.d.ts.map +1 -0
- package/dts/jobs/browser/index.d.ts +10 -0
- package/dts/jobs/browser/index.d.ts.map +1 -0
- package/dts/jobs/browser/unauthorized.d.ts +9 -0
- package/dts/jobs/browser/unauthorized.d.ts.map +1 -0
- package/dts/jobs/index.d.ts +24 -0
- package/dts/jobs/index.d.ts.map +1 -0
- package/dts/jobs/jobsUi.d.ts +63 -0
- package/dts/jobs/jobsUi.d.ts.map +1 -0
- package/dts/jobs/shell.d.ts +77 -0
- package/dts/jobs/shell.d.ts.map +1 -0
- package/dts/jobs/types.d.ts +146 -0
- package/dts/jobs/types.d.ts.map +1 -0
- package/dts/kit/browser/shared.d.ts +10 -0
- package/dts/kit/browser/shared.d.ts.map +1 -0
- package/dts/kit/browser/unauthorized.d.ts +74 -0
- package/dts/kit/browser/unauthorized.d.ts.map +1 -0
- package/dts/kit/unauthorized.d.ts +47 -0
- package/dts/kit/unauthorized.d.ts.map +1 -0
- package/guides/docs.md +170 -0
- package/guides/jobs.md +1120 -0
- package/lib/docs/assets.ts +427 -0
- package/lib/docs/browser/config.ts +66 -0
- package/lib/docs/browser/index.ts +24 -0
- package/lib/docs/docsUi.ts +623 -0
- package/lib/docs/hosted.ts +31 -0
- package/lib/docs/index.ts +37 -0
- package/lib/docs/shell.ts +156 -0
- package/lib/docs/types.ts +149 -0
- package/lib/index.ts +27 -0
- package/lib/jobs/assets.ts +448 -0
- package/lib/jobs/browser/config.ts +65 -0
- package/lib/jobs/browser/index.ts +9 -0
- package/lib/jobs/browser/unauthorized.ts +11 -0
- package/lib/jobs/index.ts +41 -0
- package/lib/jobs/jobsUi.ts +709 -0
- package/lib/jobs/shell.ts +205 -0
- package/lib/jobs/types.ts +162 -0
- package/lib/kit/browser/shared.ts +11 -0
- package/lib/kit/browser/unauthorized.ts +114 -0
- package/lib/kit/unauthorized.ts +222 -0
- package/package.json +198 -4
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import type { HttpMethod, QueryScalar, QueryValue } from "../../../api/client";
|
|
2
|
+
import type { SchemaRoot } from "../schema/resolve";
|
|
3
|
+
import type { DocOperation, DocParameter } from "./model";
|
|
4
|
+
import {
|
|
5
|
+
CSRF_HEADER_VALUE,
|
|
6
|
+
segment,
|
|
7
|
+
serializeQuery,
|
|
8
|
+
} from "../../../api/client";
|
|
9
|
+
import { JSON_METHODS, MUTATING_METHODS } from "../../../api/contract";
|
|
10
|
+
import { declaredTypes, deref, isSchemaObject } from "../schema/resolve";
|
|
11
|
+
import { schemaSkeleton } from "../schema/skeleton";
|
|
12
|
+
import { successStatuses } from "./model";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The try-it panel's logic: which input each parameter gets, the request a
|
|
16
|
+
* filled form makes, whether the panel may send it at all, and the curl and
|
|
17
|
+
* fetch() snippets of that exact request. The request goes through the
|
|
18
|
+
* viewer's own client, so the headers here restate its rules (for the
|
|
19
|
+
* snippets) rather than decide them.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Methods the viewer's client can send. */
|
|
23
|
+
const CLIENT_METHODS: ReadonlySet<string> = new Set([
|
|
24
|
+
"GET",
|
|
25
|
+
"POST",
|
|
26
|
+
"PUT",
|
|
27
|
+
"PATCH",
|
|
28
|
+
"DELETE",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
/** Action verbs whose try-it needs a typed confirmation: they delete, discard or irreversibly end work. */
|
|
32
|
+
const DESTRUCTIVE_VERBS: ReadonlySet<string> = new Set([
|
|
33
|
+
"remove",
|
|
34
|
+
"drain",
|
|
35
|
+
"clean",
|
|
36
|
+
"kill",
|
|
37
|
+
"fail",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/** The input a parameter is edited with. */
|
|
41
|
+
export type InputKind =
|
|
42
|
+
| { kind: "text" }
|
|
43
|
+
| { kind: "number"; integer: boolean; min?: number; max?: number }
|
|
44
|
+
| { kind: "boolean" }
|
|
45
|
+
| { kind: "enum"; options: string[] }
|
|
46
|
+
| { kind: "list"; item: "text" | "integer" | "number" }
|
|
47
|
+
| { kind: "enum-list"; options: string[] };
|
|
48
|
+
|
|
49
|
+
/** A form value: text for scalars and comma lists, the ticked options for an enum list. */
|
|
50
|
+
export type FieldValue = string | readonly string[];
|
|
51
|
+
|
|
52
|
+
/** What the form holds, keyed by {@link paramKey}. */
|
|
53
|
+
export type TryItValues = Readonly<Record<string, FieldValue>>;
|
|
54
|
+
|
|
55
|
+
/** A parameter's form key: `path:queue`, `query:state`. */
|
|
56
|
+
export function paramKey(parameter: Pick<DocParameter, "in" | "name">): string {
|
|
57
|
+
return `${parameter.in}:${parameter.name}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The scalar item type of a schema (`$ref`s followed). */
|
|
61
|
+
function scalarType(schema: unknown, root: SchemaRoot): string | undefined {
|
|
62
|
+
const node = deref(schema, root).schema;
|
|
63
|
+
if (!isSchemaObject(node)) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
return declaredTypes(node).find((type) => type !== "null");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The string values of an enum schema, when it is one. */
|
|
70
|
+
function enumOptions(schema: unknown, root: SchemaRoot): string[] | undefined {
|
|
71
|
+
const node = deref(schema, root).schema;
|
|
72
|
+
if (!isSchemaObject(node) || !Array.isArray(node.enum)) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
return node.enum
|
|
76
|
+
.filter((value) => value !== null)
|
|
77
|
+
.map((value) => String(value));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** How a parameter is edited, from its schema. */
|
|
81
|
+
export function inputKind(
|
|
82
|
+
parameter: DocParameter,
|
|
83
|
+
root: SchemaRoot,
|
|
84
|
+
): InputKind {
|
|
85
|
+
const node = deref(parameter.schema, root).schema;
|
|
86
|
+
const schema = isSchemaObject(node) ? node : {};
|
|
87
|
+
const type = scalarType(schema, root);
|
|
88
|
+
if (type === "array") {
|
|
89
|
+
const options = enumOptions(schema.items, root);
|
|
90
|
+
if (options) {
|
|
91
|
+
return { kind: "enum-list", options };
|
|
92
|
+
}
|
|
93
|
+
const item = scalarType(schema.items, root);
|
|
94
|
+
return {
|
|
95
|
+
kind: "list",
|
|
96
|
+
item: item === "integer" || item === "number" ? item : "text",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const options = enumOptions(schema, root);
|
|
100
|
+
if (options) {
|
|
101
|
+
return { kind: "enum", options };
|
|
102
|
+
}
|
|
103
|
+
if (type === "boolean") {
|
|
104
|
+
return { kind: "boolean" };
|
|
105
|
+
}
|
|
106
|
+
if (type === "integer" || type === "number") {
|
|
107
|
+
return {
|
|
108
|
+
kind: "number",
|
|
109
|
+
integer: type === "integer",
|
|
110
|
+
min: typeof schema.minimum === "number" ? schema.minimum : undefined,
|
|
111
|
+
max: typeof schema.maximum === "number" ? schema.maximum : undefined,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { kind: "text" };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The parameters the form edits: path and query. Header parameters are the client's to send. */
|
|
118
|
+
export function formParameters(operation: DocOperation): DocParameter[] {
|
|
119
|
+
return operation.parameters.filter(
|
|
120
|
+
(parameter) => parameter.in === "path" || parameter.in === "query",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The schema of the JSON body, when the operation takes one worth editing. */
|
|
125
|
+
export function bodySchema(operation: DocOperation): unknown {
|
|
126
|
+
const json = operation.requestBody?.content.find(
|
|
127
|
+
(media) => media.contentType === "application/json",
|
|
128
|
+
);
|
|
129
|
+
const schema = json?.schema;
|
|
130
|
+
// The API's bodiless POSTs declare a body only to require the media type;
|
|
131
|
+
// its schema is a bare description ("Ignored. Send no body, or `{}`.").
|
|
132
|
+
if (
|
|
133
|
+
!isSchemaObject(schema) ||
|
|
134
|
+
Object.keys(schema).every(
|
|
135
|
+
(key) => key === "description" || key.startsWith("x-"),
|
|
136
|
+
)
|
|
137
|
+
) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
return schema;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The editor's starting text: the body schema's required skeleton, or `""`. */
|
|
144
|
+
export function initialBodyText(
|
|
145
|
+
operation: DocOperation,
|
|
146
|
+
root: SchemaRoot,
|
|
147
|
+
): string {
|
|
148
|
+
const schema = bodySchema(operation);
|
|
149
|
+
return schema === undefined
|
|
150
|
+
? ""
|
|
151
|
+
: JSON.stringify(schemaSkeleton(schema, root), null, 2);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A parameter's form value as the query takes it, or an error message. */
|
|
155
|
+
function coerce(
|
|
156
|
+
parameter: DocParameter,
|
|
157
|
+
kind: InputKind,
|
|
158
|
+
value: FieldValue | undefined,
|
|
159
|
+
): { value: QueryValue } | { error: string } {
|
|
160
|
+
if (kind.kind === "enum-list") {
|
|
161
|
+
const picked = Array.isArray(value) ? value : [];
|
|
162
|
+
return { value: picked.length > 0 ? [...picked] : undefined };
|
|
163
|
+
}
|
|
164
|
+
const raw = typeof value === "string" ? value.trim() : "";
|
|
165
|
+
if (raw === "") {
|
|
166
|
+
return parameter.required
|
|
167
|
+
? { error: `${parameter.name} is required` }
|
|
168
|
+
: { value: undefined };
|
|
169
|
+
}
|
|
170
|
+
switch (kind.kind) {
|
|
171
|
+
case "number": {
|
|
172
|
+
const number = Number(raw);
|
|
173
|
+
if (
|
|
174
|
+
!Number.isFinite(number) ||
|
|
175
|
+
(kind.integer && !Number.isInteger(number))
|
|
176
|
+
) {
|
|
177
|
+
return {
|
|
178
|
+
error: `${parameter.name} must be ${kind.integer ? "an integer" : "a number"}`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return { value: number };
|
|
182
|
+
}
|
|
183
|
+
case "boolean":
|
|
184
|
+
return raw === "true" || raw === "false"
|
|
185
|
+
? { value: raw === "true" }
|
|
186
|
+
: { error: `${parameter.name} must be true or false` };
|
|
187
|
+
case "list": {
|
|
188
|
+
const items = raw
|
|
189
|
+
.split(",")
|
|
190
|
+
.map((item) => item.trim())
|
|
191
|
+
.filter(Boolean);
|
|
192
|
+
const values: QueryScalar[] = [];
|
|
193
|
+
for (const item of items) {
|
|
194
|
+
if (kind.item === "text") {
|
|
195
|
+
values.push(item);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const number = Number(item);
|
|
199
|
+
if (
|
|
200
|
+
!Number.isFinite(number) ||
|
|
201
|
+
(kind.item === "integer" && !Number.isInteger(number))
|
|
202
|
+
) {
|
|
203
|
+
return {
|
|
204
|
+
error: `${parameter.name}: "${item}" is not ${kind.item === "integer" ? "an integer" : "a number"}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
values.push(number);
|
|
208
|
+
}
|
|
209
|
+
return { value: values.length > 0 ? values : undefined };
|
|
210
|
+
}
|
|
211
|
+
default:
|
|
212
|
+
return { value: raw };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** A request the panel sends, in the terms of `ApiClient.request`. */
|
|
217
|
+
export interface TryItRequest {
|
|
218
|
+
/** The method. */
|
|
219
|
+
method: HttpMethod;
|
|
220
|
+
/** The path under the API base, path parameters percent-encoded. */
|
|
221
|
+
path: string;
|
|
222
|
+
/** The query; arrays go as repeated keys. */
|
|
223
|
+
query: Record<string, QueryValue>;
|
|
224
|
+
/** The JSON body, or `undefined` for none. */
|
|
225
|
+
body: unknown;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The outcome of {@link buildRequest}. */
|
|
229
|
+
export type BuildResult =
|
|
230
|
+
| { ok: true; request: TryItRequest }
|
|
231
|
+
| { ok: false; errors: Record<string, string> };
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The request a filled form makes: path parameters substituted (each
|
|
235
|
+
* percent-encoded as one segment, so `a/b` is `a%2Fb`), query values typed
|
|
236
|
+
* from their schemas, and the body as parsed. `body` is the editor's parsed
|
|
237
|
+
* value, `undefined` for an empty editor.
|
|
238
|
+
*/
|
|
239
|
+
export function buildRequest(
|
|
240
|
+
operation: DocOperation,
|
|
241
|
+
root: SchemaRoot,
|
|
242
|
+
values: TryItValues,
|
|
243
|
+
body: unknown,
|
|
244
|
+
): BuildResult {
|
|
245
|
+
const errors: Record<string, string> = {};
|
|
246
|
+
if (!CLIENT_METHODS.has(operation.method)) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
errors: { method: `${operation.method} cannot be sent from here` },
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
let path = operation.path;
|
|
253
|
+
const query: Record<string, QueryValue> = {};
|
|
254
|
+
for (const parameter of formParameters(operation)) {
|
|
255
|
+
const key = paramKey(parameter);
|
|
256
|
+
const result = coerce(parameter, inputKind(parameter, root), values[key]);
|
|
257
|
+
if ("error" in result) {
|
|
258
|
+
errors[key] = result.error;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (parameter.in === "path") {
|
|
262
|
+
path = path
|
|
263
|
+
.split(`{${parameter.name}}`)
|
|
264
|
+
.join(segment(String(result.value)));
|
|
265
|
+
} else if (result.value !== undefined) {
|
|
266
|
+
query[parameter.name] = result.value;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (
|
|
270
|
+
body === undefined &&
|
|
271
|
+
operation.requestBody?.required &&
|
|
272
|
+
bodySchema(operation) !== undefined
|
|
273
|
+
) {
|
|
274
|
+
errors.body = "A body is required";
|
|
275
|
+
}
|
|
276
|
+
if (Object.keys(errors).length > 0) {
|
|
277
|
+
return { ok: false, errors };
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
ok: true,
|
|
281
|
+
request: { method: operation.method as HttpMethod, path, query, body },
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The headers the viewer's client sends for a method — restated from
|
|
287
|
+
* `createDocsClient` for the snippets: `Accept` always, `Content-Type` on
|
|
288
|
+
* POST/PUT/PATCH (body or not), and the CSRF header on every mutation when
|
|
289
|
+
* the viewer is configured with one.
|
|
290
|
+
*/
|
|
291
|
+
export function clientHeaders(
|
|
292
|
+
method: HttpMethod,
|
|
293
|
+
csrfHeader: string | null,
|
|
294
|
+
): Record<string, string> {
|
|
295
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
296
|
+
if (JSON_METHODS.has(method)) {
|
|
297
|
+
headers["Content-Type"] = "application/json";
|
|
298
|
+
}
|
|
299
|
+
if (csrfHeader && MUTATING_METHODS.has(method)) {
|
|
300
|
+
headers[csrfHeader] = CSRF_HEADER_VALUE;
|
|
301
|
+
}
|
|
302
|
+
return headers;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Where the snippets point. */
|
|
306
|
+
export interface SnippetContext {
|
|
307
|
+
/** The app's API base (`/jobs-api`, or an absolute URL). */
|
|
308
|
+
apiBase: string;
|
|
309
|
+
/** The page's origin, for an absolute curl URL. */
|
|
310
|
+
origin: string;
|
|
311
|
+
/** The UI's CSRF header, or `null`. */
|
|
312
|
+
csrfHeader: string | null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The URL the client requests: base + path + query. */
|
|
316
|
+
export function requestUrl(request: TryItRequest, apiBase: string): string {
|
|
317
|
+
return `${apiBase.replace(/\/+$/, "")}${request.path}${serializeQuery(request.query)}`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Quotes a word for a POSIX shell. */
|
|
321
|
+
function shellQuote(value: string): string {
|
|
322
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** A `curl` command sending exactly what the panel sends. */
|
|
326
|
+
export function curlSnippet(
|
|
327
|
+
request: TryItRequest,
|
|
328
|
+
context: SnippetContext,
|
|
329
|
+
): string {
|
|
330
|
+
const relative = requestUrl(request, context.apiBase);
|
|
331
|
+
const url = /^https?:\/\//.test(relative)
|
|
332
|
+
? relative
|
|
333
|
+
: `${context.origin}${relative}`;
|
|
334
|
+
const lines = [`curl -X ${request.method} ${shellQuote(url)}`];
|
|
335
|
+
for (const [name, value] of Object.entries(
|
|
336
|
+
clientHeaders(request.method, context.csrfHeader),
|
|
337
|
+
)) {
|
|
338
|
+
lines.push(` -H ${shellQuote(`${name}: ${value}`)}`);
|
|
339
|
+
}
|
|
340
|
+
if (request.body !== undefined) {
|
|
341
|
+
lines.push(` --data ${shellQuote(JSON.stringify(request.body))}`);
|
|
342
|
+
}
|
|
343
|
+
return [
|
|
344
|
+
"# The browser also sends this site's cookies (credentials: same-origin).",
|
|
345
|
+
"# If the API's authorize reads a session, add it: -b 'name=value'",
|
|
346
|
+
lines.join(" \\\n"),
|
|
347
|
+
].join("\n");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** A `fetch()` call sending exactly what the panel sends, from a page on this origin. */
|
|
351
|
+
export function fetchSnippet(
|
|
352
|
+
request: TryItRequest,
|
|
353
|
+
context: SnippetContext,
|
|
354
|
+
): string {
|
|
355
|
+
const headers = clientHeaders(request.method, context.csrfHeader);
|
|
356
|
+
const headerLines = Object.entries(headers)
|
|
357
|
+
.map(
|
|
358
|
+
([name, value]) =>
|
|
359
|
+
` ${JSON.stringify(name)}: ${JSON.stringify(value)},`,
|
|
360
|
+
)
|
|
361
|
+
.join("\n");
|
|
362
|
+
const lines = [
|
|
363
|
+
`const response = await fetch(${JSON.stringify(requestUrl(request, context.apiBase))}, {`,
|
|
364
|
+
` method: ${JSON.stringify(request.method)},`,
|
|
365
|
+
" headers: {",
|
|
366
|
+
headerLines,
|
|
367
|
+
" },",
|
|
368
|
+
` credentials: "same-origin",`,
|
|
369
|
+
];
|
|
370
|
+
if (request.body !== undefined) {
|
|
371
|
+
lines.push(
|
|
372
|
+
` body: JSON.stringify(${JSON.stringify(request.body, null, 2).replace(/\n/g, "\n ")}),`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
lines.push("});", "const data = await response.json();");
|
|
376
|
+
return lines.join("\n");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Whether sending an operation needs a TYPED confirmation: a DELETE, or an
|
|
381
|
+
* action whose verb removes, discards or irreversibly ends work
|
|
382
|
+
* (`jobs.remove`, `queues.drain`, `queues.clean`, `runners.kill`,
|
|
383
|
+
* `repeatables.remove`, `jobs.fail`: a failed job is dead for good).
|
|
384
|
+
*/
|
|
385
|
+
export function isDestructive(operation: DocOperation): boolean {
|
|
386
|
+
if (operation.method === "DELETE") {
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
const verb = operation.action?.split(".").pop() ?? "";
|
|
390
|
+
return DESTRUCTIVE_VERBS.has(verb);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Whether the panel may send, and why not. */
|
|
394
|
+
export type TryItGate = { enabled: true } | { enabled: false; reason: string };
|
|
395
|
+
|
|
396
|
+
/** What {@link tryItGate} decides from. */
|
|
397
|
+
export interface GateInputs {
|
|
398
|
+
/** `meta.readOnly`. */
|
|
399
|
+
readOnly: boolean;
|
|
400
|
+
/** Whether the caller holds the operation's action (untargeted permissions). */
|
|
401
|
+
permitted: boolean;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The panel is off for a mutation on a read-only API, and for an action the
|
|
406
|
+
* caller does not hold. Advisory, like every permission marker here:
|
|
407
|
+
* `authorize` may decide per target, and the API's answer stays final.
|
|
408
|
+
*/
|
|
409
|
+
export function tryItGate(
|
|
410
|
+
operation: DocOperation,
|
|
411
|
+
inputs: GateInputs,
|
|
412
|
+
): TryItGate {
|
|
413
|
+
if (!CLIENT_METHODS.has(operation.method)) {
|
|
414
|
+
return {
|
|
415
|
+
enabled: false,
|
|
416
|
+
reason: `The viewer's client does not send ${operation.method}.`,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
// Kept although a read-only `createJobsApi` neither routes nor documents
|
|
420
|
+
// its mutations: this reason shows only for a document that lists one
|
|
421
|
+
// anyway, a stale or foreign OpenAPI document (a cached copy from before
|
|
422
|
+
// the API went read-only, or another API's). The API would refuse the
|
|
423
|
+
// send, so the panel says why instead of offering it.
|
|
424
|
+
if (operation.mutation && inputs.readOnly) {
|
|
425
|
+
return {
|
|
426
|
+
enabled: false,
|
|
427
|
+
reason: "This API is read-only: it refuses every change.",
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
if (operation.action !== undefined && !inputs.permitted) {
|
|
431
|
+
return {
|
|
432
|
+
enabled: false,
|
|
433
|
+
reason: `You do not have the ${operation.action} permission.`,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
return { enabled: true };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The note try-it shows when the status received is not one the operation
|
|
441
|
+
* documents, naming the documented success statuses (`2xx` when it documents
|
|
442
|
+
* none); `undefined` when the status is documented.
|
|
443
|
+
*/
|
|
444
|
+
export function documentedNote(
|
|
445
|
+
operation: DocOperation,
|
|
446
|
+
status: number,
|
|
447
|
+
): string | undefined {
|
|
448
|
+
if (
|
|
449
|
+
operation.responses.some((response) => response.status === String(status))
|
|
450
|
+
) {
|
|
451
|
+
return undefined;
|
|
452
|
+
}
|
|
453
|
+
const success = successStatuses(operation);
|
|
454
|
+
return `Not a documented status. Documented success: ${success.length > 0 ? success.join(" / ") : "2xx"}.`;
|
|
455
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How an embedding app answers "does the caller hold this permission": `true`
|
|
3
|
+
* held, `false` lacked, `undefined` not a permission it knows. A standalone
|
|
4
|
+
* viewer passes no probe and every permission renders plainly.
|
|
5
|
+
*/
|
|
6
|
+
export type PermissionProbe = (action: string) => boolean | undefined;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** Props of {@link Prose}. */
|
|
4
|
+
export interface ProseProps {
|
|
5
|
+
/** The text: a spec's `description`, which may carry `` `code` `` spans and blank-line paragraphs. */
|
|
6
|
+
text: string;
|
|
7
|
+
/** Extra class names on each paragraph. */
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Splits one paragraph into text and `<code>` spans at backticks. */
|
|
12
|
+
function inlineCode(text: string): ReactNode[] {
|
|
13
|
+
return text.split(/(`[^`]+`)/).map((part, index) =>
|
|
14
|
+
part.length > 2 && part.startsWith("`") && part.endsWith("`") ? (
|
|
15
|
+
// eslint-disable-next-line react/no-array-index-key -- the split is fixed for a given text
|
|
16
|
+
<code key={index}>{part.slice(1, -1)}</code>
|
|
17
|
+
) : (
|
|
18
|
+
part
|
|
19
|
+
),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A spec description as paragraphs, with backtick spans as code. Nothing
|
|
25
|
+
* else of Markdown is interpreted, and no HTML is ever injected: a spec is
|
|
26
|
+
* data from the API, rendered as text.
|
|
27
|
+
*/
|
|
28
|
+
export function Prose({ text, className }: ProseProps) {
|
|
29
|
+
const paragraphs = text.split(/\n\s*\n/).filter((part) => part.trim() !== "");
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
{paragraphs.map((paragraph, index) => (
|
|
33
|
+
<p
|
|
34
|
+
// eslint-disable-next-line react/no-array-index-key -- paragraphs of a fixed text
|
|
35
|
+
key={index}
|
|
36
|
+
className={className}
|
|
37
|
+
>
|
|
38
|
+
{inlineCode(paragraph)}
|
|
39
|
+
</p>
|
|
40
|
+
))}
|
|
41
|
+
</>
|
|
42
|
+
);
|
|
43
|
+
}
|