mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 06:31:49 +00:00
OpenAPI: improve Auth UI
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': patch
|
||||
---
|
||||
|
||||
Improve auth UI for playground
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@fumari/stf': patch
|
||||
---
|
||||
|
||||
Expose more utils
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute, notFound } from '@tanstack/react-router';
|
||||
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
||||
import { DocsLayout } from 'fumadocs-ui/layouts/notebook';
|
||||
import { createServerFn } from '@tanstack/react-start';
|
||||
import { getPageMarkdownUrl, source } from '@/lib/source';
|
||||
import browserCollections from 'collections/browser';
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
DocsTitle,
|
||||
MarkdownCopyButton,
|
||||
ViewOptionsPopover,
|
||||
} from 'fumadocs-ui/layouts/docs/page';
|
||||
} from 'fumadocs-ui/layouts/notebook/page';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
import { gitConfig } from '@/lib/shared';
|
||||
import { useFumadocsLoader } from 'fumadocs-core/source/client';
|
||||
@@ -71,7 +71,7 @@ const clientLoader = browserCollections.docs.createClientLoader({
|
||||
},
|
||||
) {
|
||||
return (
|
||||
<DocsPage toc={toc}>
|
||||
<DocsPage toc={toc} tableOfContent={{ style: 'clerk' }}>
|
||||
<DocsTitle>{frontmatter.title}</DocsTitle>
|
||||
<DocsDescription>{frontmatter.description}</DocsDescription>
|
||||
<div className="flex flex-row gap-2 items-center border-b -mt-4 pb-6">
|
||||
@@ -107,8 +107,9 @@ function Page() {
|
||||
content = <Suspense>{clientLoader.useContent(page.path, page)}</Suspense>;
|
||||
}
|
||||
|
||||
const base = baseOptions();
|
||||
return (
|
||||
<DocsLayout {...baseOptions()} tree={page.pageTree}>
|
||||
<DocsLayout {...base} nav={{ ...base.nav, mode: 'top' }} tree={page.pageTree}>
|
||||
{content}
|
||||
</DocsLayout>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:[email protected]&family=Geist:[email protected]&display=swap');
|
||||
@import 'tailwindcss';
|
||||
@import 'fumadocs-ui/css/neutral.css';
|
||||
@import 'fumadocs-ui/css/preset.css';
|
||||
@import 'fumadocs-openapi/css/preset.css';
|
||||
|
||||
@theme {
|
||||
--default-font-family: 'Geist';
|
||||
--default-mono-font-family: 'Geist Mono';
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createGetUrl, getSlugs, PathUtils } from 'fumadocs-core/source';
|
||||
import { createAutoPreset, type SchemaToPagesOptions } from '@/utils/pages/preset-auto';
|
||||
import { fromSchema, type OutputGroup, type OutputEntry } from '@/utils/pages/builder';
|
||||
import type { DistributiveOmit } from './types';
|
||||
import { doubleQuote } from './requests/string-utils';
|
||||
|
||||
export interface OutputFile {
|
||||
path: string;
|
||||
@@ -275,10 +276,10 @@ function writeIndexFiles(
|
||||
// cannot link to groups
|
||||
if (entry.type === 'group') continue;
|
||||
const descriptionAttr = entry.info.description
|
||||
? `description=${JSON.stringify(entry.info.description)} `
|
||||
? `description=${doubleQuote(entry.info.description)} `
|
||||
: '';
|
||||
content.push(
|
||||
`<Card href="${urlFn(entry.path)}" title=${JSON.stringify(entry.info.title)} ${descriptionAttr}/>`,
|
||||
`<Card href="${urlFn(entry.path)}" title=${doubleQuote(entry.info.title)} ${descriptionAttr}/>`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ export const defaultTranslations = {
|
||||
usernameField: 'Username',
|
||||
passwordField: 'Password',
|
||||
fetchingToken: 'Fetching token...',
|
||||
fetchTokenError: 'Failed to fetch token',
|
||||
|
||||
// Server select
|
||||
serverUrl: 'Server URL',
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
import { useApiContext } from '@/ui/contexts/api';
|
||||
import { useQuery } from '@/utils/use-query';
|
||||
import { createContext, type ReactNode, use, useEffect, useMemo, useState } from 'react';
|
||||
import type { AuthCodeState, ImplicitState } from './components/oauth-dialog';
|
||||
|
||||
/** scheme name -> token info */
|
||||
type TokenStore = Record<string, TokenInfo | undefined>;
|
||||
type TokenInfo =
|
||||
| {
|
||||
type: 'authorization_code';
|
||||
redirect_uri: string;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
type: 'implicit';
|
||||
client_id: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
interface AuthContextType {
|
||||
store: TokenStore;
|
||||
updatedSchemeId: string | null;
|
||||
isLoading: boolean;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = use(AuthContext);
|
||||
if (!ctx) throw new Error('must use this component under <AuthProvider />');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const { schemes } = useApiContext();
|
||||
const [store, setStore] = useState<TokenStore>({});
|
||||
|
||||
const authCodeQuery = useQuery(async (code: string, state: AuthCodeState) => {
|
||||
const scheme = schemes[state.scheme];
|
||||
if (!scheme || scheme.type !== 'oauth2') return;
|
||||
const value = scheme.flows?.authorizationCode;
|
||||
if (!value) return;
|
||||
|
||||
const res = await fetch(value.tokenUrl!, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
// note: `state` could be invalid, but server will check it
|
||||
redirect_uri: state.redirect_uri,
|
||||
client_id: state.client_id,
|
||||
client_secret: state.client_secret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const { access_token, token_type = 'Bearer' } = (await res.json()) as {
|
||||
access_token: string;
|
||||
token_type?: string;
|
||||
};
|
||||
|
||||
const info: TokenInfo = {
|
||||
type: 'authorization_code',
|
||||
...state,
|
||||
token: `${token_type} ${access_token}`,
|
||||
};
|
||||
setStore((s) => ({
|
||||
...s,
|
||||
[state.scheme]: info,
|
||||
}));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function onQuery() {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const state = query.get('state');
|
||||
const code = query.get('code');
|
||||
if (!state || !code) return;
|
||||
|
||||
const parsedState = JSON.parse(state) as AuthCodeState;
|
||||
|
||||
authCodeQuery.start(code, parsedState);
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
}
|
||||
|
||||
function onHash() {
|
||||
const hash = new URLSearchParams(window.location.hash.slice(1));
|
||||
const state = hash.get('state');
|
||||
const token = hash.get('access_token');
|
||||
const type = hash.get('token_type') ?? 'Bearer';
|
||||
if (!state || !token) return;
|
||||
|
||||
const parsedState = JSON.parse(state) as ImplicitState;
|
||||
const scheme = schemes[parsedState.scheme];
|
||||
if (!scheme || scheme.type !== 'oauth2' || !scheme.flows?.implicit) return;
|
||||
|
||||
const info: TokenInfo = {
|
||||
type: 'implicit',
|
||||
client_id: parsedState.client_id,
|
||||
token: `${type} ${token}`,
|
||||
};
|
||||
setStore((s) => ({
|
||||
...s,
|
||||
[parsedState.scheme]: info,
|
||||
}));
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
}
|
||||
|
||||
if (window.location.search.length > 0) onQuery();
|
||||
if (window.location.hash.length > 1) onHash();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- first page load only
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext
|
||||
value={useMemo(
|
||||
() => ({
|
||||
store,
|
||||
updatedSchemeId: Object.keys(store)[0] ?? null,
|
||||
isLoading: authCodeQuery.isLoading,
|
||||
error: authCodeQuery.error,
|
||||
}),
|
||||
[store, authCodeQuery.isLoading, authCodeQuery.error],
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</AuthContext>
|
||||
);
|
||||
}
|
||||
@@ -37,22 +37,25 @@ import { getPreferredType, type SecurityEntry, type ParsedSchema } from '@/utils
|
||||
import ServerSelect from './components/server-select';
|
||||
import { useStorageKey } from '@/ui/client/storage-key';
|
||||
import {
|
||||
type DataEngine,
|
||||
FieldKey,
|
||||
Stf,
|
||||
StfProvider,
|
||||
useDataEngine,
|
||||
useFieldValue,
|
||||
useListener,
|
||||
useStf,
|
||||
} from '@fumari/stf';
|
||||
import { objectGet, objectSet, stringifyFieldKey } from '@fumari/stf/lib/utils';
|
||||
import { arrayStartsWith, objectGet, objectSet, stringifyFieldKey } from '@fumari/stf/lib/utils';
|
||||
import { FieldInput, FieldSet, JsonInput, ObjectInput } from './components/inputs';
|
||||
import type { Document, HttpMethods, ParameterObject } from '@/types';
|
||||
import { ClientCodeBlock } from '@/ui/components/codeblock';
|
||||
import { useTranslations } from '@/ui/client/i18n';
|
||||
import { useOperationContext } from '@/ui/operation/client';
|
||||
import { OauthDialog, OauthDialogTrigger } from './components/oauth-dialog';
|
||||
import { OAuthDialog, OAuthDialogContent, OAuthDialogTrigger } from './components/oauth-dialog';
|
||||
import { dereferenceSwallow } from '@/utils/schema/dereference';
|
||||
import { useAuth } from './auth';
|
||||
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
|
||||
import { Spinner } from './components/spinner';
|
||||
|
||||
export interface FormValues extends Record<string, unknown> {
|
||||
path: Record<string, unknown>;
|
||||
@@ -160,7 +163,6 @@ export default function PlaygroundClient({
|
||||
|
||||
const { example: exampleId, examples, setExampleData } = useOperationContext();
|
||||
const { server } = useServerContext();
|
||||
const storageKeys = useStorageKey();
|
||||
const {
|
||||
mediaAdapters,
|
||||
client: {
|
||||
@@ -171,19 +173,10 @@ export default function PlaygroundClient({
|
||||
} = {},
|
||||
requestTimeout,
|
||||
fetchOptions = { requestTimeout },
|
||||
transformAuthInputs,
|
||||
renderBodyField,
|
||||
} = {},
|
||||
},
|
||||
} = useApiContext();
|
||||
const [securityId, setSecurityId] = useState(() => {
|
||||
const idx = securities.findIndex((s) => s.every((entry) => !entry.deprecated));
|
||||
return idx === -1 ? 0 : idx;
|
||||
});
|
||||
const { inputs, mapInputs, initAuthValues } = useAuthInputs(
|
||||
securities[securityId],
|
||||
transformAuthInputs,
|
||||
);
|
||||
|
||||
const defaultValues: FormValues = useMemo(() => {
|
||||
const requestData = examples.find((example) => example.id === exampleId)?.data;
|
||||
@@ -203,6 +196,11 @@ export default function PlaygroundClient({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const { inputs, requirementId, setRequirementId, mapInputs, initAuthInputs } = useAuthInputs(
|
||||
stf.dataEngine,
|
||||
securities,
|
||||
);
|
||||
|
||||
const testQuery = useQuery(async (input: FormValues) => {
|
||||
const fetcher = await import('./fetcher').then((mod) =>
|
||||
mod.createBrowserFetcher(mediaAdapters, { proxyUrl, ...fetchOptions }),
|
||||
@@ -226,30 +224,25 @@ export default function PlaygroundClient({
|
||||
});
|
||||
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const stfSync = useRef(false);
|
||||
function triggerExampleUpdate() {
|
||||
const data = {
|
||||
...mapInputs(stf.dataEngine.getData() as FormValues),
|
||||
method,
|
||||
bodyMediaType: body?.mediaType,
|
||||
};
|
||||
setExampleData(data, encodeRequestData(data, mediaAdapters, parameters));
|
||||
}
|
||||
|
||||
useListener({
|
||||
stf,
|
||||
onUpdate() {
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||
timerRef.current = window.setTimeout(
|
||||
() => {
|
||||
const values = stf.dataEngine.getData() as FormValues;
|
||||
for (const item of inputs) {
|
||||
const value = stf.dataEngine.get(item.fieldName);
|
||||
if (!stfSync.current) return;
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
if (value) {
|
||||
localStorage.setItem(storageKeys.AuthField(item), JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
const data = {
|
||||
...mapInputs(values),
|
||||
method,
|
||||
bodyMediaType: body?.mediaType,
|
||||
};
|
||||
setExampleData(data, encodeRequestData(data, mediaAdapters, parameters));
|
||||
},
|
||||
timerRef.current ? 400 : 0,
|
||||
);
|
||||
timerRef.current = window.setTimeout(triggerExampleUpdate, 400);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -262,7 +255,13 @@ export default function PlaygroundClient({
|
||||
}, [defaultValues]);
|
||||
|
||||
useEffect(() => {
|
||||
return initAuthValues(stf);
|
||||
const reset = initAuthInputs();
|
||||
triggerExampleUpdate();
|
||||
stfSync.current = true;
|
||||
return () => {
|
||||
stfSync.current = false;
|
||||
reset();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- ignore other parts
|
||||
}, [defaultValues, inputs]);
|
||||
|
||||
@@ -276,7 +275,7 @@ export default function PlaygroundClient({
|
||||
rest.className,
|
||||
)}
|
||||
onSubmit={(e) => {
|
||||
testQuery.start(mapInputs(stf.dataEngine.getData() as FormValues));
|
||||
testQuery.start(stf.dataEngine.getData() as FormValues);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
@@ -295,15 +294,15 @@ export default function PlaygroundClient({
|
||||
{testQuery.data ? <ResultDisplay data={testQuery.data} reset={testQuery.reset} /> : null}
|
||||
|
||||
{securities.length > 0 && (
|
||||
<SecurityTabs
|
||||
<SecurityRequirements
|
||||
securities={securities}
|
||||
securityId={securityId}
|
||||
setSecurityId={setSecurityId}
|
||||
securityId={requirementId}
|
||||
setSecurityId={setRequirementId}
|
||||
>
|
||||
{inputs.map((input) => (
|
||||
<Fragment key={stringifyFieldKey(input.fieldName)}>{input.children}</Fragment>
|
||||
))}
|
||||
</SecurityTabs>
|
||||
</SecurityRequirements>
|
||||
)}
|
||||
<ParametersForm parameters={parameters} />
|
||||
{body && (
|
||||
@@ -317,27 +316,7 @@ export default function PlaygroundClient({
|
||||
);
|
||||
}
|
||||
|
||||
function SecurityTabsSelectItem({ security }: { security: SecurityEntry[] }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 max-w-[600px]">
|
||||
{security.map((item) => (
|
||||
<div key={item.id}>
|
||||
<p
|
||||
className={cn(
|
||||
'font-mono font-medium',
|
||||
item.deprecated && 'text-fd-muted-foreground line-through',
|
||||
)}
|
||||
>
|
||||
{item.id}
|
||||
</p>
|
||||
<p className="text-fd-muted-foreground whitespace-pre-wrap">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SecurityTabs({
|
||||
function SecurityRequirements({
|
||||
securities,
|
||||
setSecurityId,
|
||||
securityId,
|
||||
@@ -348,24 +327,49 @@ function SecurityTabs({
|
||||
setSecurityId: (value: number) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const engine = useDataEngine();
|
||||
const t = useTranslations();
|
||||
const { isLoading, error } = useAuth();
|
||||
const defaultOpen = isLoading || error != null;
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const { CollapsiblePanel = DefaultCollapsiblePanel } =
|
||||
useApiContext().client.playground?.components ?? {};
|
||||
|
||||
const result = (
|
||||
<CollapsiblePanel title={t.authorization} data-type="authorization">
|
||||
useOnChange(defaultOpen, () => {
|
||||
if (defaultOpen) setOpen(true);
|
||||
});
|
||||
|
||||
return (
|
||||
<CollapsiblePanel
|
||||
title={
|
||||
<>
|
||||
{t.authorization}
|
||||
{isLoading && (
|
||||
<span className="border-s ps-2 inline-flex items-center gap-1.5 text-fd-muted-foreground text-xs font-mono">
|
||||
<Spinner /> {t.fetchingToken}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
data-type="authorization"
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
{error != null && (
|
||||
<div className="p-2 border rounded-lg bg-fd-secondary">
|
||||
<p className="text-fd-muted-foreground font-medium mb-1">{t.fetchTokenError}</p>
|
||||
<p>{String(error)}</p>
|
||||
</div>
|
||||
)}
|
||||
<Select value={securityId.toString()} onValueChange={(v) => setSecurityId(Number(v))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
<SecurityTabsSelectItem security={securities[securityId]} />
|
||||
<SecurityRequirement requirement={securities[securityId]} />
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{securities.map((security, i) => (
|
||||
<SelectItem key={i} value={i.toString()}>
|
||||
<SecurityTabsSelectItem security={security} />
|
||||
<SecurityRequirement requirement={security} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -373,33 +377,32 @@ function SecurityTabs({
|
||||
{children}
|
||||
</CollapsiblePanel>
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < securities.length; i++) {
|
||||
const security = securities[i];
|
||||
function SecurityRequirement({ requirement }: { requirement: SecurityEntry[] }) {
|
||||
const { schemes } = useApiContext();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 max-w-[600px]">
|
||||
{requirement.map((item) => {
|
||||
const scheme = schemes[item.id];
|
||||
|
||||
for (const item of security) {
|
||||
if (item.type === 'oauth2') {
|
||||
return (
|
||||
<OauthDialog
|
||||
scheme={item}
|
||||
scopes={item.scopes}
|
||||
open={open}
|
||||
setOpen={(v) => {
|
||||
setOpen(v);
|
||||
if (v) {
|
||||
setSecurityId(i);
|
||||
}
|
||||
}}
|
||||
setToken={(token) => engine.update(['header', 'Authorization'], token)}
|
||||
>
|
||||
{result}
|
||||
</OauthDialog>
|
||||
<div key={item.id}>
|
||||
<p
|
||||
className={cn(
|
||||
'font-mono font-medium',
|
||||
scheme.deprecated && 'text-fd-muted-foreground line-through',
|
||||
)}
|
||||
>
|
||||
{item.id}
|
||||
</p>
|
||||
<p className="text-fd-muted-foreground whitespace-pre-wrap">{scheme.description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ParamTypes = ['path', 'header', 'cookie', 'query'] as const;
|
||||
@@ -509,41 +512,51 @@ function BodyInput({ field: _field }: { field: ParsedSchema }) {
|
||||
|
||||
export interface AuthField {
|
||||
fieldName: FieldKey;
|
||||
schemeId: string;
|
||||
storageKey: string;
|
||||
defaultValue: unknown;
|
||||
|
||||
original?: SecurityEntry;
|
||||
children: ReactNode;
|
||||
|
||||
mapOutput?: (values: unknown) => unknown;
|
||||
}
|
||||
|
||||
function useAuthInputs(
|
||||
securities?: SecurityEntry[],
|
||||
transform?: (fields: AuthField[]) => AuthField[],
|
||||
) {
|
||||
function useAuthInputs(engine: DataEngine, requirements: SecurityEntry[][]) {
|
||||
const authCtx = useAuth();
|
||||
const storageKeys = useStorageKey();
|
||||
const t = useTranslations();
|
||||
const inputs = useMemo(() => {
|
||||
const result: AuthField[] = [];
|
||||
if (!securities) return result;
|
||||
const {
|
||||
schemes,
|
||||
client: { playground: { transformAuthInputs } = {} },
|
||||
} = useApiContext();
|
||||
const [requirementId, setRequirementId] = useState(() => {
|
||||
if (requirements.length === 0) return -1;
|
||||
|
||||
for (const security of securities) {
|
||||
if (security.type === 'http' && security.scheme === 'basic') {
|
||||
const idx = requirements.findIndex((s) => s.every((item) => !schemes[item.id].deprecated));
|
||||
return idx !== -1 ? idx : 0;
|
||||
});
|
||||
const requirement = requirementId === -1 ? null : requirements[requirementId];
|
||||
|
||||
let inputs = useMemo<AuthField[]>(() => {
|
||||
if (!requirement) return [];
|
||||
|
||||
return requirement.map((item) => {
|
||||
const scheme = schemes[item.id];
|
||||
|
||||
if (scheme.type === 'http' && scheme.scheme === 'basic') {
|
||||
const fieldName: FieldKey = ['header', 'Authorization'];
|
||||
|
||||
result.push({
|
||||
return {
|
||||
fieldName,
|
||||
original: security,
|
||||
schemeId: item.id,
|
||||
storageKey: storageKeys.AuthField(item.id),
|
||||
defaultValue: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
mapOutput(out) {
|
||||
mapOutput(out: unknown) {
|
||||
if (out && typeof out === 'object') {
|
||||
const obj = out as Record<string, unknown>;
|
||||
return `Basic ${btoa(`${obj.username ?? ''}:${obj.password ?? ''}`)}`;
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
children: (
|
||||
@@ -562,49 +575,24 @@ function useAuthInputs(
|
||||
fieldName={fieldName}
|
||||
/>
|
||||
),
|
||||
});
|
||||
} else if (security.type === 'oauth2') {
|
||||
};
|
||||
}
|
||||
if (scheme.type === 'oauth2') {
|
||||
const fieldName: FieldKey = ['header', 'Authorization'];
|
||||
|
||||
result.push({
|
||||
return {
|
||||
fieldName,
|
||||
original: security,
|
||||
schemeId: item.id,
|
||||
storageKey: storageKeys.AuthField(item.id),
|
||||
defaultValue: 'Bearer ',
|
||||
children: (
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<label htmlFor={stringifyFieldKey(fieldName)} className={cn(labelVariants())}>
|
||||
{t.accessToken}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<FieldInput
|
||||
fieldName={fieldName}
|
||||
field={{
|
||||
type: 'string',
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
<OauthDialogTrigger
|
||||
type="button"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: 'sm',
|
||||
color: 'secondary',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
{t.authorize}
|
||||
</OauthDialogTrigger>
|
||||
</div>
|
||||
</fieldset>
|
||||
),
|
||||
});
|
||||
} else if (security.type === 'http') {
|
||||
children: <OAuth2Input fieldName={fieldName} security={item} />,
|
||||
};
|
||||
}
|
||||
if (scheme.type === 'http') {
|
||||
const fieldName: FieldKey = ['header', 'Authorization'];
|
||||
|
||||
result.push({
|
||||
return {
|
||||
fieldName,
|
||||
original: security,
|
||||
schemeId: item.id,
|
||||
storageKey: storageKeys.AuthField(item.id),
|
||||
defaultValue: 'Bearer ',
|
||||
children: (
|
||||
<FieldSet
|
||||
@@ -615,86 +603,166 @@ function useAuthInputs(
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
} else if (security.type === 'apiKey') {
|
||||
const fieldName: FieldKey = [security.in!, security.name!];
|
||||
|
||||
result.push({
|
||||
};
|
||||
}
|
||||
if (scheme.type === 'apiKey') {
|
||||
const fieldName: FieldKey = [scheme.in!, scheme.name!];
|
||||
return {
|
||||
fieldName,
|
||||
schemeId: item.id,
|
||||
defaultValue: '',
|
||||
original: security,
|
||||
storageKey: storageKeys.AuthField(item.id),
|
||||
children: (
|
||||
<FieldSet
|
||||
fieldName={fieldName}
|
||||
name={`${security.name} (${security.in})`}
|
||||
name={`${scheme.name} (${scheme.in})`}
|
||||
field={{
|
||||
type: 'string',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
} else {
|
||||
const fieldName: FieldKey = ['header', 'Authorization'];
|
||||
|
||||
result.push({
|
||||
fieldName,
|
||||
defaultValue: '',
|
||||
original: security,
|
||||
children: (
|
||||
<>
|
||||
<FieldSet
|
||||
name={`${t.authorization} (${t.header})`}
|
||||
fieldName={fieldName}
|
||||
field={{
|
||||
type: 'string',
|
||||
}}
|
||||
/>
|
||||
<p className="text-fd-muted-foreground text-xs">{t.openIdUnsupported}</p>
|
||||
</>
|
||||
),
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
// fallback: openid or unknown
|
||||
const fieldName: FieldKey = ['header', 'Authorization'];
|
||||
return {
|
||||
fieldName,
|
||||
schemeId: item.id,
|
||||
defaultValue: '',
|
||||
storageKey: storageKeys.AuthField(item.id),
|
||||
children: (
|
||||
<>
|
||||
<FieldSet
|
||||
name={`${t.authorization} (${t.header})`}
|
||||
fieldName={fieldName}
|
||||
field={{
|
||||
type: 'string',
|
||||
}}
|
||||
/>
|
||||
<p className="text-fd-muted-foreground text-xs">{t.openIdUnsupported}</p>
|
||||
</>
|
||||
),
|
||||
};
|
||||
});
|
||||
}, [requirement, storageKeys, schemes, t]);
|
||||
if (transformAuthInputs) inputs = transformAuthInputs(inputs);
|
||||
|
||||
return transform ? transform(result) : result;
|
||||
}, [securities, transform, t]);
|
||||
useListener({
|
||||
stf: engine,
|
||||
onUpdate(key) {
|
||||
for (const item of inputs) {
|
||||
if (!arrayStartsWith(item.fieldName, key)) continue;
|
||||
const value = engine.get(item.fieldName);
|
||||
|
||||
const mapInputs = (values: FormValues) => {
|
||||
const cloned = structuredClone(values);
|
||||
|
||||
for (const item of inputs) {
|
||||
if (!item.mapOutput) continue;
|
||||
objectSet(cloned, item.fieldName, item.mapOutput(objectGet(cloned, item.fieldName)));
|
||||
}
|
||||
|
||||
return cloned;
|
||||
};
|
||||
|
||||
const initAuthValues = (stf: Stf) => {
|
||||
const { dataEngine } = stf;
|
||||
for (const item of inputs) {
|
||||
const stored = localStorage.getItem(storageKeys.AuthField(item));
|
||||
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (typeof parsed === typeof item.defaultValue) {
|
||||
dataEngine.init(item.fieldName, parsed);
|
||||
continue;
|
||||
if (value != null) {
|
||||
localStorage.setItem(item.storageKey, JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
dataEngine.init(item.fieldName, item.defaultValue);
|
||||
useOnChange(authCtx.updatedSchemeId, () => {
|
||||
const { updatedSchemeId } = authCtx;
|
||||
if (!updatedSchemeId) return;
|
||||
const { token } = authCtx.store[updatedSchemeId]!;
|
||||
|
||||
const input = inputs.find((input) => input.schemeId === updatedSchemeId);
|
||||
if (input) {
|
||||
// update current value
|
||||
engine.update(input.fieldName, token);
|
||||
return;
|
||||
}
|
||||
|
||||
// reset
|
||||
return () => {
|
||||
for (const item of inputs) {
|
||||
stf.dataEngine.delete(item.fieldName);
|
||||
}
|
||||
};
|
||||
};
|
||||
const idx = requirements.findIndex((requirement) =>
|
||||
requirement.some((item) => item.id === updatedSchemeId),
|
||||
);
|
||||
if (idx !== -1) {
|
||||
// persisted value
|
||||
localStorage.setItem(storageKeys.AuthField(updatedSchemeId), JSON.stringify(token));
|
||||
setRequirementId(idx);
|
||||
}
|
||||
});
|
||||
|
||||
return { inputs, mapInputs, initAuthValues };
|
||||
return {
|
||||
inputs,
|
||||
requirementId,
|
||||
setRequirementId,
|
||||
mapInputs(values: FormValues) {
|
||||
const cloned = structuredClone(values);
|
||||
|
||||
for (const item of inputs) {
|
||||
if (!item.mapOutput) continue;
|
||||
objectSet(cloned, item.fieldName, item.mapOutput(objectGet(cloned, item.fieldName)));
|
||||
}
|
||||
|
||||
return cloned;
|
||||
},
|
||||
initAuthInputs() {
|
||||
for (const item of inputs) {
|
||||
const stored = localStorage.getItem(item.storageKey);
|
||||
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (typeof parsed === typeof item.defaultValue) {
|
||||
engine.init(item.fieldName, parsed);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
engine.init(item.fieldName, item.defaultValue);
|
||||
}
|
||||
|
||||
// reset
|
||||
return () => {
|
||||
for (const item of inputs) {
|
||||
engine.delete(item.fieldName);
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function OAuth2Input({ fieldName, security }: { fieldName: FieldKey; security: SecurityEntry }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const engine = useDataEngine();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<label htmlFor={stringifyFieldKey(fieldName)} className={cn(labelVariants())}>
|
||||
{t.accessToken}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<FieldInput
|
||||
fieldName={fieldName}
|
||||
field={{
|
||||
type: 'string',
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
<OAuthDialog open={open} onOpenChange={setOpen}>
|
||||
<OAuthDialogTrigger
|
||||
type="button"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: 'sm',
|
||||
color: 'secondary',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
{t.authorize}
|
||||
</OAuthDialogTrigger>
|
||||
<OAuthDialogContent
|
||||
setOpen={setOpen}
|
||||
schemeId={security.id}
|
||||
scopes={security.scopes}
|
||||
setToken={(token) => engine.update(['header', 'Authorization'], token)}
|
||||
></OAuthDialogContent>
|
||||
</OAuthDialog>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function Route({ route, ...props }: ComponentProps<'div'> & { route: string }) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Input, labelVariants } from '@/ui/components/input';
|
||||
import { useQuery } from '@/utils/use-query';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
|
||||
import {
|
||||
@@ -21,17 +21,17 @@ import {
|
||||
} from '@/ui/components/select';
|
||||
import type { OAuth2SecurityScheme } from '@/types';
|
||||
import { useTranslations } from '@/ui/client/i18n';
|
||||
import { useAuth } from '../auth';
|
||||
import { useApiContext } from '@/ui/contexts/api';
|
||||
|
||||
type FlowType = keyof NonNullable<OAuth2SecurityScheme['flows']>;
|
||||
|
||||
export interface AuthDialogProps {
|
||||
scheme: OAuth2SecurityScheme;
|
||||
export interface AuthDialogContentProps {
|
||||
schemeId: string;
|
||||
scopes: string[];
|
||||
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
setToken: (token: string) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
@@ -41,15 +41,19 @@ interface FormValues {
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface AuthCodeState {
|
||||
export interface AuthCodeState {
|
||||
redirect_uri: string;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
/** name of the source security scheme */
|
||||
scheme: string;
|
||||
}
|
||||
|
||||
interface ImplicitState {
|
||||
export interface ImplicitState {
|
||||
redirect_uri: string;
|
||||
client_id: string;
|
||||
/** name of the source security scheme */
|
||||
scheme: string;
|
||||
}
|
||||
|
||||
interface FlowInfo {
|
||||
@@ -58,17 +62,33 @@ interface FlowInfo {
|
||||
supported: boolean;
|
||||
}
|
||||
|
||||
export function OauthDialog({
|
||||
scheme,
|
||||
scopes,
|
||||
setToken,
|
||||
children,
|
||||
open,
|
||||
setOpen,
|
||||
}: AuthDialogProps) {
|
||||
const [type, setType] = useState(() => {
|
||||
export const OAuthDialog = Dialog;
|
||||
|
||||
export function OAuthDialogContent(props: AuthDialogContentProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.authorization}</DialogTitle>
|
||||
<DialogDescription>{t.obtainAccessToken}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Content {...props} />
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function Content({ schemeId, scopes, setToken, setOpen }: AuthDialogContentProps) {
|
||||
const { schemes } = useApiContext();
|
||||
const tokenInfo = useAuth().store[schemeId];
|
||||
const scheme = schemes[schemeId];
|
||||
if (!scheme || scheme.type !== 'oauth2')
|
||||
throw new Error('unexpected schemaId: must be type oauth2');
|
||||
|
||||
const [type, setType] = useState<FlowType | null>(() => {
|
||||
return Object.keys(scheme.flows!)[0] as FlowType;
|
||||
});
|
||||
|
||||
const t = useTranslations();
|
||||
const allFlows: Record<FlowType, FlowInfo> = useMemo(
|
||||
() => ({
|
||||
@@ -101,78 +121,19 @@ export function OauthDialog({
|
||||
[t],
|
||||
);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
const defaultValues = useMemo<FormValues>(() => {
|
||||
return {
|
||||
clientId: tokenInfo?.client_id ?? '',
|
||||
clientSecret: tokenInfo?.type === 'authorization_code' ? tokenInfo.client_secret : '',
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const authCodeCallback = useQuery(async (code: string, state: AuthCodeState) => {
|
||||
const value = scheme.flows!.authorizationCode!;
|
||||
|
||||
const res = await fetch(value.tokenUrl!, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
// note: `state` could be invalid, but server will check it
|
||||
redirect_uri: state.redirect_uri,
|
||||
client_id: state.client_id,
|
||||
client_secret: state.client_secret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const { access_token, token_type = 'Bearer' } = (await res.json()) as {
|
||||
access_token: string;
|
||||
token_type?: string;
|
||||
};
|
||||
}, [tokenInfo]);
|
||||
|
||||
setToken(`${token_type} ${access_token}`);
|
||||
setOpen(false);
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (scheme.flows!.authorizationCode) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const state = params.get('state');
|
||||
const code = params.get('code');
|
||||
|
||||
if (state && code) {
|
||||
const parsedState = JSON.parse(state) as AuthCodeState;
|
||||
setOpen(true);
|
||||
|
||||
form.setValue('clientId', parsedState.client_id);
|
||||
form.setValue('clientSecret', parsedState.client_secret);
|
||||
authCodeCallback.start(code, parsedState);
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (scheme.flows!.implicit && window.location.hash.length > 1) {
|
||||
const params = new URLSearchParams(window.location.hash.slice(1));
|
||||
const state = params.get('state');
|
||||
const token = params.get('access_token');
|
||||
const type = params.get('token_type') ?? 'Bearer';
|
||||
|
||||
if (state && token) {
|
||||
const parsedState = JSON.parse(state) as ImplicitState;
|
||||
|
||||
form.setValue('clientId', parsedState.client_id);
|
||||
setToken(`${type} ${token}`);
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- first page load only
|
||||
}, []);
|
||||
|
||||
const authorize = useQuery(async (values: FormValues) => {
|
||||
if (type === 'implicit') {
|
||||
const value = scheme.flows![type]!;
|
||||
@@ -185,6 +146,7 @@ export function OauthDialog({
|
||||
params.set(
|
||||
'state',
|
||||
JSON.stringify({
|
||||
scheme: schemeId,
|
||||
client_id: values.clientId,
|
||||
redirect_uri: window.location.href,
|
||||
} satisfies ImplicitState),
|
||||
@@ -207,6 +169,7 @@ export function OauthDialog({
|
||||
client_id: values.clientId,
|
||||
client_secret: values.clientSecret,
|
||||
redirect_uri: window.location.href,
|
||||
scheme: schemeId,
|
||||
} satisfies AuthCodeState),
|
||||
);
|
||||
|
||||
@@ -262,134 +225,122 @@ export function OauthDialog({
|
||||
}
|
||||
});
|
||||
|
||||
const isLoading = authorize.isLoading;
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
return authorize.start(values);
|
||||
});
|
||||
|
||||
const isLoading = authorize.isLoading || authCodeCallback.isLoading;
|
||||
const error = authCodeCallback.error ?? authorize.error;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{children}
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.authorization}</DialogTitle>
|
||||
<DialogDescription>{t.obtainAccessToken}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-6"
|
||||
onSubmit={(e) => {
|
||||
void onSubmit(e);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Select value={type} onValueChange={setType as (s: string) => void}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.keys(scheme.flows!).map((key) => {
|
||||
const { name, description } = allFlows[key as FlowType];
|
||||
<form
|
||||
className="flex flex-col gap-6"
|
||||
onSubmit={(e) => {
|
||||
void onSubmit(e);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Select value={type ?? ''} onValueChange={setType as (s: string) => void}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a flow" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.keys(scheme.flows!).map((key) => {
|
||||
const { name, description } = allFlows[key as FlowType];
|
||||
|
||||
return (
|
||||
<SelectItem key={key} value={key}>
|
||||
<p className="font-medium">{name}</p>
|
||||
<p className="text-fd-muted-foreground">{description}</p>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
return (
|
||||
<SelectItem key={key} value={key}>
|
||||
<p className="font-medium">{name}</p>
|
||||
<p className="text-fd-muted-foreground">{description}</p>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{(type === 'authorizationCode' ||
|
||||
type === 'clientCredentials' ||
|
||||
type === 'implicit') && (
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<label htmlFor="client_id" className={cn(labelVariants())}>
|
||||
{t.clientId}
|
||||
</label>
|
||||
<p className="text-fd-muted-foreground text-sm">{t.clientIdHint}</p>
|
||||
<Input
|
||||
id="client_id"
|
||||
placeholder={t.inputPlaceholder}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
disabled={isLoading}
|
||||
{...form.register('clientId', { required: true })}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
{(type === 'authorizationCode' || type === 'clientCredentials') && (
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<label htmlFor="client_secret" className={cn(labelVariants())}>
|
||||
{t.clientSecret}
|
||||
</label>
|
||||
<p className="text-fd-muted-foreground text-sm">{t.clientSecretHint}</p>
|
||||
<Input
|
||||
id="client_secret"
|
||||
placeholder={t.inputPlaceholder}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
disabled={isLoading}
|
||||
{...form.register('clientSecret', { required: true })}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
{type === 'password' && (
|
||||
<>
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<label htmlFor="username" className={cn(labelVariants())}>
|
||||
{t.usernameField}
|
||||
</label>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder={t.inputPlaceholder}
|
||||
type="text"
|
||||
disabled={isLoading}
|
||||
autoComplete="off"
|
||||
{...form.register('username', { required: true })}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<label htmlFor="password" className={cn(labelVariants())}>
|
||||
{t.clientSecret}
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
placeholder={t.inputPlaceholder}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
disabled={isLoading}
|
||||
{...form.register('password', { required: true })}
|
||||
/>
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
{allFlows[type].supported ? (
|
||||
<>
|
||||
{error ? <p className="text-red-400 font-medium text-sm">{String(error)}</p> : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'primary',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
{authCodeCallback.isLoading ? t.fetchingToken : t.submit}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-fd-muted-foreground bg-fd-muted p-2 rounded-lg border">
|
||||
{t.unsupported}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{(type === 'authorizationCode' || type === 'clientCredentials' || type === 'implicit') && (
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<label htmlFor="client_id" className={cn(labelVariants())}>
|
||||
{t.clientId}
|
||||
</label>
|
||||
<p className="text-fd-muted-foreground text-sm">{t.clientIdHint}</p>
|
||||
<Input
|
||||
id="client_id"
|
||||
placeholder={t.inputPlaceholder}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
disabled={isLoading}
|
||||
{...form.register('clientId', { required: true })}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
{(type === 'authorizationCode' || type === 'clientCredentials') && (
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<label htmlFor="client_secret" className={cn(labelVariants())}>
|
||||
{t.clientSecret}
|
||||
</label>
|
||||
<p className="text-fd-muted-foreground text-sm">{t.clientSecretHint}</p>
|
||||
<Input
|
||||
id="client_secret"
|
||||
placeholder={t.inputPlaceholder}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
disabled={isLoading}
|
||||
{...form.register('clientSecret', { required: true })}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
{type === 'password' && (
|
||||
<>
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<label htmlFor="username" className={cn(labelVariants())}>
|
||||
{t.usernameField}
|
||||
</label>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder={t.inputPlaceholder}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
disabled={isLoading}
|
||||
{...form.register('username', { required: true })}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<label htmlFor="password" className={cn(labelVariants())}>
|
||||
{t.clientSecret}
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
placeholder={t.inputPlaceholder}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
disabled={isLoading}
|
||||
{...form.register('password', { required: true })}
|
||||
/>
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
{type && allFlows[type].supported ? (
|
||||
<>
|
||||
{authorize.error ? (
|
||||
<p className="text-red-400 font-medium text-sm">{String(authorize.error)}</p>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'primary',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
{t.submit}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-fd-muted-foreground bg-fd-muted p-2 rounded-lg border">
|
||||
{t.unsupported}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export const OauthDialogTrigger = DialogTrigger;
|
||||
export const OAuthDialogTrigger = DialogTrigger;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
|
||||
export function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<Loader2Icon
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn('size-4 animate-spin', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CodeUsageGenerator } from '@/requests/generators';
|
||||
import { resolveMediaAdapter } from '@/requests/media/adapter';
|
||||
import { doubleQuote } from '../string-utils';
|
||||
|
||||
export const csharp: CodeUsageGenerator = {
|
||||
label: 'C#',
|
||||
@@ -35,7 +36,7 @@ export const csharp: CodeUsageGenerator = {
|
||||
const headerLines: string[] = [];
|
||||
|
||||
function addHeader(key: string, value: string) {
|
||||
headerLines.push(`client.DefaultRequestHeaders.Add("${key}", ${JSON.stringify(value)});`);
|
||||
headerLines.push(`client.DefaultRequestHeaders.Add("${key}", ${doubleQuote(value)});`);
|
||||
}
|
||||
for (const k in headers) {
|
||||
addHeader(k, headers[k].value);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { escapeString, indent, inputToString } from '@/requests/string-utils';
|
||||
import { doubleQuote, indent, inputToString, singleQuote } from '@/requests/string-utils';
|
||||
import type { CodeUsageGenerator } from '@/requests/generators';
|
||||
|
||||
export const curl: CodeUsageGenerator = {
|
||||
@@ -6,7 +6,7 @@ export const curl: CodeUsageGenerator = {
|
||||
lang: 'bash',
|
||||
generate(url, data) {
|
||||
const s: string[] = [];
|
||||
s.push(`curl -X ${data.method} "${url}"`);
|
||||
s.push(`curl -X ${data.method.toUpperCase()} "${url}"`);
|
||||
|
||||
for (const header in data.header) {
|
||||
const value = `${header}: ${data.header[header].value}`;
|
||||
@@ -17,23 +17,22 @@ export const curl: CodeUsageGenerator = {
|
||||
for (const k in data.cookie) {
|
||||
const param = data.cookie[k];
|
||||
|
||||
s.push(`--cookie ${JSON.stringify(`${k}=${param.value}`)}`);
|
||||
s.push(`--cookie ${doubleQuote(`${k}=${param.value}`)}`);
|
||||
}
|
||||
|
||||
if (data.body && data.bodyMediaType === 'multipart/form-data') {
|
||||
if (typeof data.body !== 'object') throw new Error('[CURL] request body must be an object.');
|
||||
|
||||
for (const [key, value] of Object.entries(data.body)) {
|
||||
s.push(`-F ${key}=${JSON.stringify(inputToString(value))}`);
|
||||
s.push(`-F ${key}=${doubleQuote(inputToString(value))}`);
|
||||
}
|
||||
} else if (data.body && data.bodyMediaType) {
|
||||
const escaped = escapeString(
|
||||
const escaped = singleQuote(
|
||||
inputToString(
|
||||
data.body,
|
||||
// @ts-expect-error -- assume the body media type is supported
|
||||
data.bodyMediaType,
|
||||
),
|
||||
"'",
|
||||
);
|
||||
|
||||
s.push(`-H "Content-Type: ${data.bodyMediaType}"`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { indent } from '@/requests/string-utils';
|
||||
import { doubleQuote, indent } from '@/requests/string-utils';
|
||||
import type { CodeUsageGenerator } from '@/requests/generators';
|
||||
import { resolveMediaAdapter } from '@/requests/media/adapter';
|
||||
|
||||
@@ -9,17 +9,17 @@ export const go: CodeUsageGenerator = {
|
||||
const imports = ['fmt', 'net/http', 'io/ioutil'];
|
||||
const headers = new Map<string, string>();
|
||||
const variables = new Map<string, string>();
|
||||
variables.set('url', JSON.stringify(url));
|
||||
variables.set('url', doubleQuote(url));
|
||||
|
||||
for (const header in data.header) {
|
||||
headers.set(header, JSON.stringify(data.header[header].value));
|
||||
headers.set(header, doubleQuote(data.header[header].value));
|
||||
}
|
||||
|
||||
const cookies = Object.entries(data.cookie);
|
||||
if (cookies.length > 0) {
|
||||
headers.set(
|
||||
'Cookie',
|
||||
JSON.stringify(cookies.map(([k, param]) => `${k}=${param.value}`).join('; ')),
|
||||
doubleQuote(cookies.map(([k, param]) => `${k}=${param.value}`).join('; ')),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ ${Array.from(variables.entries())
|
||||
.map(([k, v]) => indent(`${k} := ${v}`))
|
||||
.join('\n')}
|
||||
${body ? indent(body) : ''}
|
||||
req, _ := http.NewRequest("${data.method}", url, ${body ? 'body' : 'nil'})
|
||||
req, _ := http.NewRequest("${data.method.toUpperCase()}", url, ${body ? 'body' : 'nil'})
|
||||
${indent(
|
||||
Array.from(headers.entries())
|
||||
.map(([key, value]) => `req.Header.Add("${key}", ${value})`)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { indent } from '@/requests/string-utils';
|
||||
import { doubleQuote, indent } from '@/requests/string-utils';
|
||||
import type { CodeUsageGenerator } from '@/requests/generators';
|
||||
import { resolveMediaAdapter } from '@/requests/media/adapter';
|
||||
|
||||
@@ -46,11 +46,11 @@ export const java: CodeUsageGenerator = {
|
||||
|
||||
// Build request
|
||||
s.push('HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()');
|
||||
s.push(indent(`.uri(URI.create(${JSON.stringify(url)}))`));
|
||||
s.push(indent(`.uri(URI.create(${doubleQuote(url)}))`));
|
||||
|
||||
// Add headers
|
||||
for (const [key, param] of Object.entries(headers)) {
|
||||
s.push(indent(`.header(${JSON.stringify(key)}, ${JSON.stringify(param.value)})`));
|
||||
s.push(indent(`.header(${doubleQuote(key)}, ${doubleQuote(param.value)})`));
|
||||
}
|
||||
|
||||
if (data.bodyMediaType) {
|
||||
@@ -63,7 +63,7 @@ export const java: CodeUsageGenerator = {
|
||||
if (cookies.length > 0) {
|
||||
const cookieString = cookies.map(([key, param]) => `${key}=${param.value}`).join('; ');
|
||||
|
||||
s.push(indent(`.header("Cookie", ${JSON.stringify(cookieString)})`));
|
||||
s.push(indent(`.header("Cookie", ${doubleQuote(cookieString)})`));
|
||||
}
|
||||
|
||||
const arg = body ? 'body' : '';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { indent } from '@/requests/string-utils';
|
||||
import { doubleQuote, indent } from '@/requests/string-utils';
|
||||
import type { CodeUsageGenerator } from '@/requests/generators';
|
||||
import { resolveMediaAdapter } from '@/requests/media/adapter';
|
||||
|
||||
@@ -10,7 +10,7 @@ export const javascript: CodeUsageGenerator = {
|
||||
const options = new Map<string, string>();
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
options.set('method', JSON.stringify(data.method));
|
||||
options.set('method', `"${data.method.toUpperCase()}"`);
|
||||
if (data.bodyMediaType) {
|
||||
headers['Content-Type'] = data.bodyMediaType;
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export const javascript: CodeUsageGenerator = {
|
||||
options.set('body', 'body');
|
||||
}
|
||||
|
||||
const params = [JSON.stringify(url)];
|
||||
const params = [doubleQuote(url)];
|
||||
if (options.size > 0) {
|
||||
const str = Array.from(options.entries())
|
||||
.map(([k, v]) => indent(k === v ? k : `${k}: ${v}`))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CodeUsageGenerator } from '@/requests/generators';
|
||||
import { resolveMediaAdapter } from '@/requests/media/adapter';
|
||||
import { doubleQuote } from '../string-utils';
|
||||
|
||||
export const python: CodeUsageGenerator = {
|
||||
label: 'Python',
|
||||
@@ -7,7 +8,7 @@ export const python: CodeUsageGenerator = {
|
||||
generate(url, data, { mediaAdapters }) {
|
||||
const headers: Record<string, string> = {};
|
||||
const imports = new Set<string>();
|
||||
const params = [`"${data.method}"`, 'url'];
|
||||
const params = [`"${data.method.toUpperCase()}"`, 'url'];
|
||||
let body: string | undefined;
|
||||
|
||||
imports.add('requests');
|
||||
@@ -48,7 +49,7 @@ export const python: CodeUsageGenerator = {
|
||||
.map((name) => 'import ' + name)
|
||||
.join('\n')}
|
||||
|
||||
url = ${JSON.stringify(url)}
|
||||
url = ${doubleQuote(url)}
|
||||
${body ?? ''}
|
||||
response = requests.request(${params.join(', ')})
|
||||
|
||||
@@ -62,7 +63,7 @@ export function generatePythonObject(v: unknown, imports: Set<string>): string {
|
||||
} else if (typeof v === 'boolean') {
|
||||
return v ? 'True' : 'False';
|
||||
} else if (typeof v === 'string') {
|
||||
return JSON.stringify(v);
|
||||
return doubleQuote(v);
|
||||
} else if (typeof v === 'number') {
|
||||
return v.toString();
|
||||
} else if (Array.isArray(v)) {
|
||||
@@ -73,7 +74,7 @@ export function generatePythonObject(v: unknown, imports: Set<string>): string {
|
||||
return `datetime.datetime(${v.getFullYear()}, ${v.getMonth() + 1}, ${v.getDate()}, ${v.getHours()}, ${v.getMinutes()}, ${v.getSeconds()}, ${v.getMilliseconds()})`;
|
||||
} else if (typeof v === 'object') {
|
||||
const entries = Object.entries(v).map(
|
||||
([key, value]) => ` ${JSON.stringify(key)}: ${generatePythonObject(value, imports)}`,
|
||||
([key, value]) => ` ${doubleQuote(key)}: ${generatePythonObject(value, imports)}`,
|
||||
);
|
||||
return `{\n${entries.join(', \n')}\n}`;
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { escapeString, inputToString } from '@/requests/string-utils';
|
||||
import { backtickQuote, inputToString, tripleDoubleQuote } from '@/requests/string-utils';
|
||||
export { resolveMediaAdapter, isMediaTypeSupported } from './resolve-adapter';
|
||||
// @ts-expect-error -- untyped
|
||||
import js2xml from 'xml-js/lib/js2xml';
|
||||
@@ -187,7 +187,7 @@ export const defaultAdapters = {
|
||||
for (const [key, value] of Object.entries(data.body as object)) {
|
||||
if (!value) continue;
|
||||
|
||||
const escaped = escapeString(inputToString(value, 'application/json'), '`');
|
||||
const escaped = backtickQuote(inputToString(value, 'application/json'));
|
||||
|
||||
s.push(`mp.WriteField("${key}", ${escaped})`);
|
||||
}
|
||||
@@ -231,28 +231,28 @@ function str(
|
||||
if (mediaType === 'application/json') {
|
||||
return `const body = JSON.stringify(${JSON.stringify(init, null, 2)})`;
|
||||
}
|
||||
return `const body = ${escapeString(inputToString(init, mediaType), '`')}`;
|
||||
return `const body = ${backtickQuote(inputToString(init, mediaType))}`;
|
||||
}
|
||||
|
||||
if (ctx.lang === 'python') {
|
||||
return `body = ${escapeString(inputToString(init, mediaType), '"""')}`;
|
||||
return `body = ${tripleDoubleQuote(inputToString(init, mediaType))}`;
|
||||
}
|
||||
|
||||
if (ctx.lang === 'go') {
|
||||
const { addImport } = ctx as GoContext;
|
||||
addImport('strings');
|
||||
return `body := strings.NewReader(${escapeString(inputToString(init, mediaType), '`')})`;
|
||||
return `body := strings.NewReader(${backtickQuote(inputToString(init, mediaType))})`;
|
||||
}
|
||||
|
||||
if (ctx.lang === 'java') {
|
||||
const { addImport } = ctx as JavaContext;
|
||||
addImport('java.net.http.HttpRequest.BodyPublishers');
|
||||
return `var body = BodyPublishers.ofString(${escapeString(inputToString(init, mediaType), '"""')});`;
|
||||
return `var body = BodyPublishers.ofString(${tripleDoubleQuote(inputToString(init, mediaType))});`;
|
||||
}
|
||||
|
||||
if (ctx.lang === 'csharp') {
|
||||
const input = `\n${inputToString(init, mediaType)}\n`;
|
||||
|
||||
return `var body = new StringContent(${escapeString(input, '"""')}, Encoding.UTF8, "${mediaType}");`;
|
||||
return `var body = new StringContent(${tripleDoubleQuote(input)}, Encoding.UTF8, "${mediaType}");`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,10 +41,34 @@ export function inputToString(
|
||||
return js2xml(value, { compact: true, spaces: 2 });
|
||||
}
|
||||
|
||||
export function escapeString(str: string, delimit?: string): string {
|
||||
if (!delimit) return JSON.stringify(str);
|
||||
/**
|
||||
* Returns the input string wrapped in single quotes, escaping internal single quotes and backslashes.
|
||||
*/
|
||||
export function singleQuote(str: string): string {
|
||||
return `'${str.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
||||
}
|
||||
|
||||
return `${delimit}${str.replaceAll(delimit, `\\${delimit}`)}${delimit}`;
|
||||
/**
|
||||
* Returns the input string wrapped in double quotes, escaping internal double quotes and backslashes.
|
||||
*/
|
||||
export function doubleQuote(str: string): string {
|
||||
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the input string wrapped in Python triple double-quotes,
|
||||
* escaping embedded triple quotes and backslashes.
|
||||
*/
|
||||
export function tripleDoubleQuote(str: string): string {
|
||||
// Escape \ first, then triple double quotes
|
||||
return `"""${str.replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"')}"""`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the input string wrapped in backticks, escaping internal backticks and backslashes.
|
||||
*/
|
||||
export function backtickQuote(str: string): string {
|
||||
return `\`${str.replace(/\\/g, '\\\\').replace(/`/g, '\\`')}\``;
|
||||
}
|
||||
|
||||
export function indent(code: string, tab: number = 1) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { EncodedParameter, EncodedParameterMultiple } from '@/requests/media/encode';
|
||||
import type { HttpMethods } from '@/types';
|
||||
|
||||
export interface RawRequestData {
|
||||
method: string;
|
||||
method: HttpMethods;
|
||||
|
||||
path: Record<string, unknown>;
|
||||
query: Record<string, unknown>;
|
||||
@@ -13,7 +14,7 @@ export interface RawRequestData {
|
||||
}
|
||||
|
||||
export interface RequestData {
|
||||
method: string;
|
||||
method: HttpMethods;
|
||||
|
||||
path: Record<string, EncodedParameter>;
|
||||
query: Record<string, EncodedParameterMultiple>;
|
||||
|
||||
@@ -52,7 +52,7 @@ export function APIPage({
|
||||
</div>
|
||||
);
|
||||
|
||||
const content = renderPageLayout(
|
||||
let content = renderPageLayout(
|
||||
{
|
||||
operations: operations?.map((item) => {
|
||||
const pathItem = dereferenced.paths?.[item.path];
|
||||
@@ -111,8 +111,16 @@ export function APIPage({
|
||||
ctx,
|
||||
);
|
||||
|
||||
if (ctx.playground?.enabled !== false && ctx.playground?.provider) {
|
||||
content = ctx.playground.provider({ children: content });
|
||||
}
|
||||
|
||||
return (
|
||||
<ctx.clientBoundary.ApiProvider shikiOptions={ctx.shikiOptions} client={ctx.client ?? {}}>
|
||||
<ctx.clientBoundary.ApiProvider
|
||||
schemes={dereferenced.components?.securitySchemes ?? {}}
|
||||
shikiOptions={ctx.shikiOptions}
|
||||
client={ctx.client ?? {}}
|
||||
>
|
||||
<ctx.clientBoundary.ServerProvider servers={dereferenced.servers as ServerObject[]}>
|
||||
{content}
|
||||
</ctx.clientBoundary.ServerProvider>
|
||||
|
||||
@@ -27,6 +27,8 @@ import type { ExampleRequestItem } from './operation/get-example-requests';
|
||||
import { compile } from '@fumari/json-schema-ts';
|
||||
import { pickSchema } from '@/utils/schema/pick';
|
||||
import { encodeInternalRef } from '@/utils/schema/ref';
|
||||
import type { RequestTabsRenderContext } from './operation/request-tabs';
|
||||
import { PlaygroundAuthProvider } from '@/ui/client/boundary.lazy';
|
||||
|
||||
export interface GenerateTypeScriptDefinitionsContext extends RenderContext {
|
||||
operation: NoReference<MethodInformation>;
|
||||
@@ -108,13 +110,7 @@ export interface CreateAPIPageOptions {
|
||||
content?: {
|
||||
renderResponseTabs?: (tabs: ResponseTab[], ctx: RenderContext) => ReactNode;
|
||||
|
||||
renderRequestTabs?: (
|
||||
items: ExampleRequestItem[],
|
||||
ctx: RenderContext & {
|
||||
route: string;
|
||||
operation: NoReference<MethodInformation>;
|
||||
},
|
||||
) => ReactNode;
|
||||
renderRequestTabs?: (items: ExampleRequestItem[], ctx: RequestTabsRenderContext) => ReactNode;
|
||||
|
||||
renderAPIExampleLayout?: (
|
||||
slots: {
|
||||
@@ -201,6 +197,11 @@ export interface CreateAPIPageOptions {
|
||||
* @defaultValue true
|
||||
*/
|
||||
enabled?: boolean;
|
||||
|
||||
/**
|
||||
* render a page-level provider (useful for handling auth)
|
||||
*/
|
||||
provider?: (props: { children: ReactNode }) => ReactNode;
|
||||
/**
|
||||
* replace the server-side renderer
|
||||
*/
|
||||
@@ -247,6 +248,10 @@ export function createAPIPage(
|
||||
.use(rehypeReact);
|
||||
}
|
||||
|
||||
function renderPlaygroundProviderDefault({ children }: { children: ReactNode }) {
|
||||
return <PlaygroundAuthProvider>{children}</PlaygroundAuthProvider>;
|
||||
}
|
||||
|
||||
function renderPlaygroundDefault({ path, method, ctx }: APIPlaygroundProps) {
|
||||
return (
|
||||
<ctx.clientBoundary.PlaygroundClient
|
||||
@@ -291,6 +296,7 @@ export function createAPIPage(
|
||||
},
|
||||
playground: {
|
||||
...options.playground,
|
||||
provider: options.playground?.provider ?? renderPlaygroundProviderDefault,
|
||||
render: options.playground?.render ?? renderPlaygroundDefault,
|
||||
},
|
||||
renderHeading(depth, text, props) {
|
||||
|
||||
@@ -26,6 +26,10 @@ export const SchemaUI = wrapLazy(() =>
|
||||
|
||||
export const PlaygroundClient = wrapLazy(() => import('@/playground/client'));
|
||||
|
||||
export const PlaygroundAuthProvider = wrapLazy(() =>
|
||||
import('@/playground/auth').then((mod) => ({ default: mod.AuthProvider })),
|
||||
);
|
||||
|
||||
// Waku wraps all export functions of a "use client" file in `React.lazy`, but `lazy(lazy(() => ...))` causes error.
|
||||
// we wrap another layer of component such that it is valid
|
||||
// TODO: perhaps we can remove it once Waku migrated to vite-rsc
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { AuthField } from '@/playground/client';
|
||||
import { useApiContext } from '../contexts/api';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
@@ -10,8 +9,7 @@ export function useStorageKey() {
|
||||
return useMemo(
|
||||
() => ({
|
||||
of: (name: KeyName) => getStorageKey(storageKeyPrefix, name),
|
||||
AuthField: (field: AuthField) =>
|
||||
getStorageKey(storageKeyPrefix, `auth-${field.original?.id ?? field.fieldName}`),
|
||||
AuthField: (schemeId: string) => getStorageKey(storageKeyPrefix, `auth-${schemeId}`),
|
||||
}),
|
||||
[storageKeyPrefix],
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
import { createContext, type ReactNode, use, useEffect, useMemo, useState } from 'react';
|
||||
import type { RenderContext, ServerObject } from '@/types';
|
||||
import type { RenderContext, SecuritySchemeObject, ServerObject } from '@/types';
|
||||
import { defaultAdapters, type MediaAdapter } from '@/requests/media/adapter';
|
||||
import { useStorageKey } from '../client/storage-key';
|
||||
import type { APIPageClientOptions } from '../client';
|
||||
@@ -21,7 +21,9 @@ interface ServerContextType {
|
||||
setServerVariables: (value: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
export type ApiProviderProps = InheritFromContext;
|
||||
export interface ApiProviderProps extends InheritFromContext {
|
||||
schemes: Record<string, SecuritySchemeObject>;
|
||||
}
|
||||
|
||||
export interface SelectedServer {
|
||||
url: string;
|
||||
@@ -32,6 +34,7 @@ export interface SelectedServer {
|
||||
interface ApiContextType extends InheritFromContext {
|
||||
mediaAdapters: Record<string, MediaAdapter>;
|
||||
codeUsages: CodeUsageGeneratorRegistry;
|
||||
schemes: Record<string, SecuritySchemeObject>;
|
||||
}
|
||||
|
||||
const ApiContext = createContext<ApiContextType | null>(null);
|
||||
@@ -55,6 +58,7 @@ export function ApiProvider({
|
||||
children,
|
||||
shikiOptions,
|
||||
client,
|
||||
schemes,
|
||||
}: ApiProviderProps & { children: ReactNode }) {
|
||||
return (
|
||||
<ApiContext
|
||||
@@ -71,12 +75,13 @@ export function ApiProvider({
|
||||
shikiOptions,
|
||||
client,
|
||||
codeUsages,
|
||||
schemes,
|
||||
mediaAdapters: {
|
||||
...defaultAdapters,
|
||||
...client.mediaAdapters,
|
||||
},
|
||||
};
|
||||
}, [client, shikiOptions])}
|
||||
}, [client, schemes, shikiOptions])}
|
||||
>
|
||||
{children}
|
||||
</ApiContext>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { slug } from 'github-slugger';
|
||||
import * as ClientBoundary from '@/ui/client/boundary';
|
||||
import { dereferenceDocument } from '@/utils/document/dereference';
|
||||
import { parseSecurities } from '@/utils/schema';
|
||||
import { AuthProvider } from '@/playground/auth';
|
||||
|
||||
export interface ClientApiPageProps extends Omit<ApiPageProps, 'document'> {
|
||||
payload: ClientApiPagePayload;
|
||||
@@ -98,6 +99,10 @@ export function createClientAPIPage({
|
||||
);
|
||||
}
|
||||
|
||||
function renderPlaygroundProviderDefault({ children }: { children: ReactNode }) {
|
||||
return <AuthProvider>{children}</AuthProvider>;
|
||||
}
|
||||
|
||||
return function ClientAPIPage({ payload, ...props }) {
|
||||
const processed = useMemo(() => dereferenceDocument(payload.bundled), [payload.bundled]);
|
||||
|
||||
@@ -116,6 +121,7 @@ export function createClientAPIPage({
|
||||
},
|
||||
playground: {
|
||||
...options.playground,
|
||||
provider: options.playground?.provider ?? renderPlaygroundProviderDefault,
|
||||
render: options.playground?.render ?? renderPlaygroundDefault,
|
||||
},
|
||||
renderHeading(depth, text, props) {
|
||||
|
||||
@@ -15,6 +15,11 @@ import { resolveRequestData } from '@/utils/url';
|
||||
import { MethodLabel } from '../components/method-label';
|
||||
import type { ExampleRequestItem } from './get-example-requests';
|
||||
|
||||
export interface RequestTabsRenderContext extends RenderContext {
|
||||
route: string;
|
||||
operation: NoReference<MethodInformation>;
|
||||
}
|
||||
|
||||
export function RequestTabs({
|
||||
path,
|
||||
operation,
|
||||
@@ -36,59 +41,7 @@ export function RequestTabs({
|
||||
});
|
||||
}
|
||||
|
||||
function renderRequestTabsDefault(
|
||||
items: ExampleRequestItem[],
|
||||
ctx: RenderContext & {
|
||||
route: string;
|
||||
operation: NoReference<MethodInformation>;
|
||||
},
|
||||
) {
|
||||
function renderItem(item: ExampleRequestItem) {
|
||||
const requestData = item.data;
|
||||
const displayNames: Partial<Record<keyof RawRequestData, ReactNode>> = {
|
||||
body: (
|
||||
<>
|
||||
<I18nLabel label="titleRequestBody" />
|
||||
<code className="text-xs text-fd-muted-foreground ms-auto">
|
||||
{requestData.bodyMediaType}
|
||||
</code>
|
||||
</>
|
||||
),
|
||||
cookie: <I18nLabel label="cookieParameters" />,
|
||||
header: <I18nLabel label="headerParameters" />,
|
||||
query: <I18nLabel label="queryParameters" />,
|
||||
path: <I18nLabel label="pathParameters" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{item.description && ctx.renderMarkdown(item.description)}
|
||||
<div className="flex flex-row gap-2 items-center justify-between">
|
||||
<MethodLabel>{requestData.method}</MethodLabel>
|
||||
<code>{resolveRequestData(ctx.route, item.encoded)}</code>
|
||||
</div>
|
||||
|
||||
<Accordions type="multiple" className="mt-2">
|
||||
{Object.entries(displayNames).map(([k, v]) => {
|
||||
const data = requestData[k as keyof RawRequestData];
|
||||
if (!data || Object.keys(data).length === 0) return;
|
||||
|
||||
return (
|
||||
<AccordionItem key={k} value={k}>
|
||||
<AccordionHeader>
|
||||
<AccordionTrigger>{v}</AccordionTrigger>
|
||||
</AccordionHeader>
|
||||
<AccordionContent className="prose-no-margin">
|
||||
{ctx.renderCodeBlock('json', JSON.stringify(data, null, 2))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRequestTabsDefault(items: ExampleRequestItem[], ctx: RequestTabsRenderContext) {
|
||||
let children: ReactNode;
|
||||
if (items.length > 1) {
|
||||
children = (
|
||||
@@ -102,13 +55,13 @@ function renderRequestTabsDefault(
|
||||
</TabsList>
|
||||
{items.map((item) => (
|
||||
<TabsContent key={item.id} value={item.id}>
|
||||
{renderItem(item)}
|
||||
<RequestTabsItem item={item} ctx={ctx} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
} else if (items.length === 1) {
|
||||
children = renderItem(items[0]);
|
||||
children = <RequestTabsItem item={items[0]} ctx={ctx} />;
|
||||
} else {
|
||||
children = (
|
||||
<p className="text-fd-muted-foreground text-xs">
|
||||
@@ -126,3 +79,55 @@ function renderRequestTabsDefault(
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestTabsItem({
|
||||
item,
|
||||
ctx,
|
||||
}: {
|
||||
item: ExampleRequestItem;
|
||||
ctx: RequestTabsRenderContext;
|
||||
}) {
|
||||
const requestData = item.data;
|
||||
const displayNames: Partial<Record<keyof RawRequestData, ReactNode>> = {
|
||||
body: (
|
||||
<>
|
||||
<I18nLabel label="titleRequestBody" />
|
||||
<code className="text-xs text-fd-muted-foreground ms-auto">
|
||||
{requestData.bodyMediaType}
|
||||
</code>
|
||||
</>
|
||||
),
|
||||
cookie: <I18nLabel label="cookieParameters" />,
|
||||
header: <I18nLabel label="headerParameters" />,
|
||||
query: <I18nLabel label="queryParameters" />,
|
||||
path: <I18nLabel label="pathParameters" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{item.description && ctx.renderMarkdown(item.description)}
|
||||
<div className="flex flex-row gap-2 items-center justify-between">
|
||||
<MethodLabel>{requestData.method}</MethodLabel>
|
||||
<code>{resolveRequestData(ctx.route, item.encoded)}</code>
|
||||
</div>
|
||||
|
||||
<Accordions type="multiple" className="mt-2">
|
||||
{Object.entries(displayNames).map(([k, v]) => {
|
||||
const data = requestData[k as keyof RawRequestData];
|
||||
if (!data || Object.keys(data).length === 0) return;
|
||||
|
||||
return (
|
||||
<AccordionItem key={k} value={k}>
|
||||
<AccordionHeader>
|
||||
<AccordionTrigger>{v}</AccordionTrigger>
|
||||
</AccordionHeader>
|
||||
<AccordionContent className="prose-no-margin">
|
||||
{ctx.renderCodeBlock('json', JSON.stringify(data, null, 2))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { removeUndefined } from '@/utils/remove-undefined';
|
||||
import type { OperationOutput, PageOutput, WebhookOutput } from '@/utils/pages/builder';
|
||||
import type { InternalOpenAPIMeta } from '@/server/source-api';
|
||||
import { toStaticData } from '@/utils/pages/to-static-data';
|
||||
import { doubleQuote } from '@/requests/string-utils';
|
||||
|
||||
export interface PagesToTextOptions {
|
||||
/**
|
||||
@@ -133,7 +134,7 @@ export function generateDocument(
|
||||
if (imports) {
|
||||
out.push(
|
||||
...imports
|
||||
.map((item) => `import { ${item.names.join(', ')} } from ${JSON.stringify(item.from)};`)
|
||||
.map((item) => `import { ${item.names.join(', ')} } from ${doubleQuote(item.from)};`)
|
||||
.join('\n'),
|
||||
);
|
||||
}
|
||||
@@ -217,7 +218,7 @@ function pageContent({
|
||||
webhooks,
|
||||
operations,
|
||||
}: ApiPageProps): string {
|
||||
const propStrs: string[] = [`document={${JSON.stringify(document)}}`];
|
||||
const propStrs: string[] = [`document={${doubleQuote(document)}}`];
|
||||
|
||||
// filter extra properties in props
|
||||
if (webhooks) {
|
||||
@@ -247,10 +248,10 @@ function pageContent({
|
||||
);
|
||||
}
|
||||
if (showTitle) {
|
||||
propStrs.push(`showTitle={${JSON.stringify(showTitle)}}`);
|
||||
propStrs.push(`showTitle`);
|
||||
}
|
||||
if (showDescription) {
|
||||
propStrs.push(`showDescription={${JSON.stringify(showDescription)}}`);
|
||||
propStrs.push(`showDescription`);
|
||||
}
|
||||
|
||||
return `<APIPage ${propStrs.join(' ')} />`;
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
OperationObject,
|
||||
PathItemObject,
|
||||
ReferenceObject,
|
||||
SecuritySchemeObject,
|
||||
TagObject,
|
||||
} from '@/types';
|
||||
import { idToTitle } from '@/utils/id-to-title';
|
||||
@@ -96,11 +95,10 @@ export function pickExample(value: ExampleLike): unknown | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/** parsed security scheme objects */
|
||||
export type SecurityEntry = SecuritySchemeObject & {
|
||||
export interface SecurityEntry {
|
||||
scopes: string[];
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSecurities(
|
||||
method: MethodInformation,
|
||||
@@ -114,13 +112,9 @@ export function parseSecurities(
|
||||
const list: SecurityEntry[] = [];
|
||||
|
||||
for (const [key, scopes] of Object.entries(map)) {
|
||||
const scheme = dereferenced.components?.securitySchemes?.[key];
|
||||
if (!scheme) continue;
|
||||
|
||||
list.push({
|
||||
...scheme,
|
||||
scopes,
|
||||
id: key,
|
||||
scopes,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMemo, useRef, useState } from 'react';
|
||||
export function useQuery<I extends unknown[], T>(
|
||||
fn: (...input: I) => Promise<T>,
|
||||
): {
|
||||
start: (...input: I) => void;
|
||||
start: (...input: I) => Promise<T | void>;
|
||||
reset: () => void;
|
||||
data?: T;
|
||||
error?: unknown;
|
||||
@@ -23,11 +23,12 @@ export function useQuery<I extends unknown[], T>(
|
||||
start(...input) {
|
||||
setLoading(true);
|
||||
|
||||
void fnRef
|
||||
return fnRef
|
||||
.current(...input)
|
||||
.then((res) => {
|
||||
setData(res);
|
||||
setError(undefined);
|
||||
return res;
|
||||
})
|
||||
.catch((err) => {
|
||||
setData(undefined);
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('Code Sample Generators', () => {
|
||||
id: 'id',
|
||||
},
|
||||
bodyMediaType: 'application/json',
|
||||
method: 'GET',
|
||||
method: 'get',
|
||||
cookie: {
|
||||
mode: { value: 'light' },
|
||||
},
|
||||
|
||||
@@ -74,7 +74,7 @@ _openapi:
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
<APIPage document={"museum"} webhooks={[]} operations={[{"path":"/museum-hours","method":"get"},{"path":"/special-events","method":"get"},{"path":"/special-events","method":"post"},{"path":"/special-events/{eventId}","method":"get"},{"path":"/special-events/{eventId}","method":"patch"},{"path":"/special-events/{eventId}","method":"delete"},{"path":"/tickets","method":"post"},{"path":"/tickets/{ticketId}/qr","method":"get"}]} showTitle={true} />
|
||||
<APIPage document={"museum"} webhooks={[]} operations={[{"path":"/museum-hours","method":"get"},{"path":"/special-events","method":"get"},{"path":"/special-events","method":"post"},{"path":"/special-events/{eventId}","method":"get"},{"path":"/special-events/{eventId}","method":"patch"},{"path":"/special-events/{eventId}","method":"delete"},{"path":"/tickets","method":"post"},{"path":"/tickets/{ticketId}/qr","method":"get"}]} showTitle />
|
||||
```
|
||||
|
||||
```mdx title="petstore.mdx"
|
||||
@@ -105,5 +105,5 @@ _openapi:
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
<APIPage document={"petstore"} webhooks={[]} operations={[{"path":"/pets","method":"get"},{"path":"/pets","method":"post"},{"path":"/pets/{petId}","method":"get"}]} showTitle={true} />
|
||||
<APIPage document={"petstore"} webhooks={[]} operations={[{"path":"/pets","method":"get"},{"path":"/pets","method":"post"},{"path":"/pets/{petId}","method":"get"}]} showTitle />
|
||||
```
|
||||
@@ -49,7 +49,7 @@ _openapi:
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
<APIPage document={"museum"} webhooks={[]} operations={[{"path":"/special-events","method":"get"},{"path":"/special-events","method":"post"},{"path":"/special-events/{eventId}","method":"get"},{"path":"/special-events/{eventId}","method":"patch"},{"path":"/special-events/{eventId}","method":"delete"}]} showTitle={true} />
|
||||
<APIPage document={"museum"} webhooks={[]} operations={[{"path":"/special-events","method":"get"},{"path":"/special-events","method":"post"},{"path":"/special-events/{eventId}","method":"get"},{"path":"/special-events/{eventId}","method":"patch"},{"path":"/special-events/{eventId}","method":"delete"}]} showTitle />
|
||||
```
|
||||
|
||||
```mdx title="operations.mdx"
|
||||
@@ -74,7 +74,7 @@ _openapi:
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
<APIPage document={"museum"} webhooks={[]} operations={[{"path":"/museum-hours","method":"get"}]} showTitle={true} />
|
||||
<APIPage document={"museum"} webhooks={[]} operations={[{"path":"/museum-hours","method":"get"}]} showTitle />
|
||||
```
|
||||
|
||||
```mdx title="tickets.mdx"
|
||||
@@ -107,5 +107,5 @@ _openapi:
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
<APIPage document={"museum"} webhooks={[]} operations={[{"path":"/tickets","method":"post"},{"path":"/tickets/{ticketId}/qr","method":"get"}]} showTitle={true} />
|
||||
<APIPage document={"museum"} webhooks={[]} operations={[{"path":"/tickets","method":"post"},{"path":"/tickets/{ticketId}/qr","method":"get"}]} showTitle />
|
||||
```
|
||||
@@ -71,5 +71,5 @@ _openapi:
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
<APIPage document={"unkey"} webhooks={[]} operations={[{"path":"/v1/liveness","method":"get"},{"path":"/v1/keys.getKey","method":"get"},{"path":"/v1/keys.deleteKey","method":"post"},{"path":"/v1/keys.createKey","method":"post"},{"path":"/v1/keys.verifyKey","method":"post"},{"path":"/v1/keys.updateKey","method":"post"},{"path":"/v1/keys.updateRemaining","method":"post"},{"path":"/v1/keys.getVerifications","method":"get"},{"path":"/v1/apis.getApi","method":"get"},{"path":"/v1/apis.createApi","method":"post"},{"path":"/v1/apis.listKeys","method":"get"},{"path":"/v1/apis.deleteApi","method":"post"}]} showTitle={true} />
|
||||
<APIPage document={"unkey"} webhooks={[]} operations={[{"path":"/v1/liveness","method":"get"},{"path":"/v1/keys.getKey","method":"get"},{"path":"/v1/keys.deleteKey","method":"post"},{"path":"/v1/keys.createKey","method":"post"},{"path":"/v1/keys.verifyKey","method":"post"},{"path":"/v1/keys.updateKey","method":"post"},{"path":"/v1/keys.updateRemaining","method":"post"},{"path":"/v1/keys.getVerifications","method":"get"},{"path":"/v1/apis.getApi","method":"get"},{"path":"/v1/apis.createApi","method":"post"},{"path":"/v1/apis.listKeys","method":"get"},{"path":"/v1/apis.deleteApi","method":"post"}]} showTitle />
|
||||
```
|
||||
@@ -419,7 +419,7 @@ describe('URL utilities', () => {
|
||||
describe('resolveRequestData', () => {
|
||||
test('basic path parameter substitution', () => {
|
||||
const requestData: RequestData = {
|
||||
method: 'GET',
|
||||
method: 'get',
|
||||
path: {
|
||||
id: { value: '123' },
|
||||
},
|
||||
@@ -433,7 +433,7 @@ describe('URL utilities', () => {
|
||||
|
||||
test('multiple path parameters', () => {
|
||||
const requestData: RequestData = {
|
||||
method: 'GET',
|
||||
method: 'get',
|
||||
path: {
|
||||
userId: { value: '123' },
|
||||
postId: { value: '456' },
|
||||
@@ -450,7 +450,7 @@ describe('URL utilities', () => {
|
||||
|
||||
test('array path parameter', () => {
|
||||
const requestData: RequestData = {
|
||||
method: 'GET',
|
||||
method: 'get',
|
||||
path: {
|
||||
segments: { value: 'api/v1/users' },
|
||||
},
|
||||
@@ -464,7 +464,7 @@ describe('URL utilities', () => {
|
||||
|
||||
test('adds query parameters to clean path', () => {
|
||||
const requestData: RequestData = {
|
||||
method: 'GET',
|
||||
method: 'get',
|
||||
path: {},
|
||||
query: {
|
||||
limit: { values: ['10'] },
|
||||
@@ -479,7 +479,7 @@ describe('URL utilities', () => {
|
||||
|
||||
test('handles array query parameters', () => {
|
||||
const requestData: RequestData = {
|
||||
method: 'GET',
|
||||
method: 'get',
|
||||
path: {},
|
||||
query: {
|
||||
tags: { values: ['javascript', 'typescript'] },
|
||||
@@ -496,7 +496,7 @@ describe('URL utilities', () => {
|
||||
// Core test cases for paths with existing query parameters (legacy API support)
|
||||
test('path with existing query parameter - verbose example', () => {
|
||||
const requestData: RequestData = {
|
||||
method: 'GET',
|
||||
method: 'get',
|
||||
path: {},
|
||||
query: {
|
||||
limit: { values: ['5'] },
|
||||
@@ -512,7 +512,7 @@ describe('URL utilities', () => {
|
||||
|
||||
test('path with existing query parameter - search example', () => {
|
||||
const requestData: RequestData = {
|
||||
method: 'GET',
|
||||
method: 'get',
|
||||
path: {},
|
||||
query: {
|
||||
limit: { values: ['10'] },
|
||||
@@ -529,7 +529,7 @@ describe('URL utilities', () => {
|
||||
|
||||
test('path with existing query parameters and path parameters', () => {
|
||||
const requestData: RequestData = {
|
||||
method: 'GET',
|
||||
method: 'get',
|
||||
path: {
|
||||
userId: { value: '123' },
|
||||
},
|
||||
@@ -547,7 +547,7 @@ describe('URL utilities', () => {
|
||||
|
||||
test('overrides existing query parameter with new value', () => {
|
||||
const requestData: RequestData = {
|
||||
method: 'GET',
|
||||
method: 'get',
|
||||
path: {},
|
||||
query: {
|
||||
verbose: { values: ['false'] },
|
||||
@@ -563,7 +563,7 @@ describe('URL utilities', () => {
|
||||
|
||||
test('handles multiple existing query parameters', () => {
|
||||
const requestData: RequestData = {
|
||||
method: 'GET',
|
||||
method: 'get',
|
||||
path: {},
|
||||
query: {
|
||||
newParam: { values: ['value'] },
|
||||
@@ -579,7 +579,7 @@ describe('URL utilities', () => {
|
||||
|
||||
test('handles array parameters with existing query string', () => {
|
||||
const requestData: RequestData = {
|
||||
method: 'GET',
|
||||
method: 'get',
|
||||
path: {},
|
||||
query: {
|
||||
categories: { values: ['tech', 'science'] },
|
||||
@@ -595,7 +595,7 @@ describe('URL utilities', () => {
|
||||
|
||||
test('replaces existing array parameter', () => {
|
||||
const requestData: RequestData = {
|
||||
method: 'GET',
|
||||
method: 'get',
|
||||
path: {},
|
||||
query: {
|
||||
tags: { values: ['new', 'updated'] },
|
||||
@@ -611,7 +611,7 @@ describe('URL utilities', () => {
|
||||
|
||||
test('handles empty query parameters object', () => {
|
||||
const requestData: RequestData = {
|
||||
method: 'GET',
|
||||
method: 'get',
|
||||
path: {},
|
||||
query: {},
|
||||
header: {},
|
||||
@@ -625,7 +625,7 @@ describe('URL utilities', () => {
|
||||
|
||||
test('complex scenario with path params, existing query, and new query params', () => {
|
||||
const requestData: RequestData = {
|
||||
method: 'GET',
|
||||
method: 'get',
|
||||
path: {
|
||||
orgId: { value: 'acme' },
|
||||
projectId: { value: 'web-app' },
|
||||
|
||||
@@ -89,6 +89,18 @@ export function fieldKeyStartsWith(a: string, b: string): boolean {
|
||||
return b.length === 0 || a === b || a.startsWith(b + '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns if `a` starts with `b`.
|
||||
*/
|
||||
export function arrayStartsWith(a: FieldKey, b: FieldKey): boolean {
|
||||
if (b.length === 0) return true;
|
||||
if (a.length < b.length) return false;
|
||||
for (let i = 0; i < b.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user