This is an automated email from the ASF dual-hosted git repository.
kayx23 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-website.git
The following commit(s) were added to refs/heads/master by this push:
new a6f5807aa30 feat(docs): render Control API references with Scalar
(#2148)
a6f5807aa30 is described below
commit a6f5807aa30c67a880e2496eac91ee9802cfb179
Author: Traky Deng <[email protected]>
AuthorDate: Mon Nov 2 18:16:53 2026 +0800
feat(docs): render Control API references with Scalar (#2148)
* feat(docs): render Control API references with Scalar
* test(docs): pin Control API source fixtures
---
doc/src/components/AdminApiReference/index.tsx | 41 ++---
doc/src/components/AdminApiReference/styles.css | 4 +-
doc/src/theme/DocPage/index.tsx | 6 +-
next/scripts/check-admin-api-fixtures.mjs | 20 ++-
next/scripts/check-admin-api-sources.mjs | 24 +--
next/scripts/sync-content.mjs | 6 +-
next/src/layouts/AdminApiPage.astro | 41 ++---
next/src/layouts/DocPage.astro | 2 +-
next/src/pages/docs/apisix/[...id].astro | 2 +-
next/src/pages/docs/apisix/admin-api.astro | 2 +-
.../apisix/{admin-api.astro => control-api.astro} | 2 +-
next/src/pages/zh/docs/apisix/[...id].astro | 2 +-
next/src/pages/zh/docs/apisix/admin-api.astro | 2 +-
.../apisix/{admin-api.astro => control-api.astro} | 2 +-
next/tests/admin-api/README.md | 30 ++--
next/tests/admin-api/admin-api.spec.mjs | 178 +++++++++++----------
next/tests/admin-api/source-fixtures.json | 31 ++++
scripts/lib/admin-api.js | 26 ++-
scripts/lib/admin-api.test.js | 23 ++-
scripts/sync-docs.js | 6 +-
shared/admin-api-scalar.css | 14 +-
shared/admin-api-spec.test.mjs | 18 ++-
shared/admin-api-spec.ts | 8 +-
shared/admin-api-toolbar.css | 8 +-
shared/admin-api.ts | 29 ++--
25 files changed, 313 insertions(+), 214 deletions(-)
diff --git a/doc/src/components/AdminApiReference/index.tsx
b/doc/src/components/AdminApiReference/index.tsx
index 5240145fb65..1024a0c9c61 100644
--- a/doc/src/components/AdminApiReference/index.tsx
+++ b/doc/src/components/AdminApiReference/index.tsx
@@ -2,7 +2,7 @@ import React, { useEffect, useRef } from 'react';
// eslint-disable-next-line import/no-webpack-loader-syntax,
import/no-extraneous-dependencies
import scalarTheme from '!!raw-loader!../../../../shared/admin-api-scalar.css';
import { versions as releaseVersions } from
'../../../../config/apisix-versions';
-import { mountAdminApi } from '../../../../shared/admin-api';
+import { mountApiReference } from '../../../../shared/admin-api';
import '../../../../shared/admin-api-toolbar.css';
import './styles.css';
@@ -12,25 +12,28 @@ interface Props {
}
// The route owns the version, including during client-side version switching.
-const AdminApiReference = ({ path, children }: Props): JSX.Element => {
+const ApiReference = ({ path, children }: Props): JSX.Element => {
const root = useRef<HTMLDivElement>(null);
- const match = path.match(/^(\/zh)?\/docs\/apisix\/([^/]+)\/admin-api\/?$/);
+ const match =
path.match(/^(\/zh)?\/docs\/apisix\/([^/]+)\/(admin-api|control-api)\/?$/);
const prefix = match?.[1] ?? '';
- // Enable only versions with a merged, branch-specific Admin API contract.
+ // Enable only versions with merged, branch-specific API contracts.
// Other archives keep their existing Markdown reference.
const supported = ['3.16', '3.17', '3.18', 'next'];
const version = supported.includes(match?.[2]) ? match?.[2] : undefined;
+ const apiId = match?.[3];
const zh = prefix === '/zh';
- const specUrl = `/assets/openapi/apisix/${version}/admin-api.json`;
+ const pageName = apiId === 'admin-api' ? 'Admin API' : 'Control API';
+ const specUrl = `/assets/openapi/apisix/${version}/${apiId}.json`;
useEffect(() => {
- if (!version || !root.current) return undefined;
- // SSR leaves Markdown readable without JavaScript. Hide it only while the
- // reference is loading; every failure restores it through the shared code.
+ if (!version || !apiId || !root.current) return undefined;
+ // SSR leaves Markdown readable without JavaScript. Hide it immediately
+ // when the client starts so the full reference does not flash before the
+ // loading shell. Every failure restores it through the shared renderer.
const markdown =
root.current.querySelector<HTMLElement>('[data-docs-content]');
if (markdown) markdown.hidden = true;
- return mountAdminApi(root.current, scalarTheme, version);
+ return mountApiReference(root.current, scalarTheme, version);
}, [path]);
- if (!version) return children as JSX.Element;
+ if (!version || !apiId) return children as JSX.Element;
const versions = [...releaseVersions].reverse().concat('next');
const latest = releaseVersions[releaseVersions.length - 1];
const versionLabel = (v: string) => {
@@ -38,34 +41,34 @@ const AdminApiReference = ({ path, children }: Props):
JSX.Element => {
return zh ? '开发版 (next)' : 'Next (unreleased)';
};
return (
- <div ref={root} className="archived-admin-api">
- <section id="admin-api-openapi">
+ <div ref={root} className="archived-api-reference">
+ <section id="api-reference-openapi">
<nav className="api-toolbar" aria-label={zh ? 'API 文档导航' : 'API
reference navigation'}>
<div className="api-location">
<a className="api-back"
href={`${prefix}/docs/apisix/${version}/getting-started/README/`}>{zh ? '文档' :
'Docs'}</a>
<span className="api-separator" aria-hidden="true">/</span>
- <span className="api-page-name" aria-current="page">Admin
API</span>
+ <span className="api-page-name"
aria-current="page">{pageName}</span>
<details className="api-version">
<summary aria-label={zh ? `切换版本,当前 ${version}` : `Switch
version, current ${version}`}>{version}</summary>
<ul>
{versions.map((v) => (
- <li key={v}><a href={`${prefix}/docs/apisix/${v === latest ?
'' : `${v}/`}admin-api/`} aria-current={v === version ? 'page' :
undefined}>{versionLabel(v)}</a></li>
+ <li key={v}><a href={`${prefix}/docs/apisix/${v === latest ?
'' : `${v}/`}${apiId}/`} aria-current={v === version ? 'page' :
undefined}>{versionLabel(v)}</a></li>
))}
</ul>
</details>
</div>
- <a className="api-action api-download" href={specUrl}
download="apisix-admin-api.json" aria-label={zh ? '下载 OpenAPI' : 'Download
OpenAPI'}>
+ <a className="api-action api-download" href={specUrl}
download={`apisix-${apiId}.json`} aria-label={zh ? '下载 OpenAPI' : 'Download
OpenAPI'}>
<span aria-hidden="true">↓</span>
<span className="api-action-label">{zh ? '下载 OpenAPI' : 'Download
OpenAPI'}</span>
</a>
</nav>
<p id="api-reference-loading" className="container" role="status">{zh
? '正在加载 API 参考…' : 'Loading API reference…'}</p>
- <div id="admin-api-reference" data-spec-url={specUrl} hidden />
+ <div id="api-reference-container" data-api-id={apiId}
data-spec-url={specUrl} hidden />
</section>
- <noscript><style>{'#admin-api-openapi { display: none;
}'}</style></noscript>
- <div id="admin-api-markdown" data-docs-content>{children}</div>
+ <noscript><style>{'#api-reference-openapi { display: none;
}'}</style></noscript>
+ <div id="api-reference-markdown" data-docs-content>{children}</div>
</div>
);
};
-export default AdminApiReference;
+export default ApiReference;
diff --git a/doc/src/components/AdminApiReference/styles.css
b/doc/src/components/AdminApiReference/styles.css
index 91876b2172b..fa2b27c147b 100644
--- a/doc/src/components/AdminApiReference/styles.css
+++ b/doc/src/components/AdminApiReference/styles.css
@@ -1,4 +1,4 @@
-.archived-admin-api {
+.archived-api-reference {
--header-height: var(--ifm-navbar-height, 60px);
--font-sans: var(--ifm-font-family-base);
--font-mono: var(--ifm-font-family-monospace);
@@ -13,6 +13,6 @@
min-width: 0;
}
-.archived-admin-api #admin-api-markdown:not([hidden]) {
+.archived-api-reference #api-reference-markdown:not([hidden]) {
display: block;
}
diff --git a/doc/src/theme/DocPage/index.tsx b/doc/src/theme/DocPage/index.tsx
index d4f0b46b5e1..19410ff21be 100644
--- a/doc/src/theme/DocPage/index.tsx
+++ b/doc/src/theme/DocPage/index.tsx
@@ -34,7 +34,7 @@ import type { ImageProps } from 'rc-image';
import { LazyLoadImage } from 'react-lazy-load-image-component';
import Link from '@docusaurus/Link';
import NotFound from '../NotFound';
-import AdminApiReference from '../../components/AdminApiReference';
+import ApiReference from '../../components/AdminApiReference';
import styles from './styles.module.css';
import 'react-lazy-load-image-component/src/effects/blur.css';
@@ -409,7 +409,7 @@ const DocPageContent = ({
tag: docVersionSearchTag(pluginId, version),
}}
>
- <AdminApiReference key={currentDocRoute.path}
path={currentDocRoute.path}>
+ <ApiReference key={currentDocRoute.path} path={currentDocRoute.path}>
<div className={styles.docPage}>
<BackToTopButton />
@@ -484,7 +484,7 @@ const DocPageContent = ({
</div>
</main>
</div>
- </AdminApiReference>
+ </ApiReference>
</Layout>
);
};
diff --git a/next/scripts/check-admin-api-fixtures.mjs
b/next/scripts/check-admin-api-fixtures.mjs
index c1bb2281fa1..840286d2f7c 100644
--- a/next/scripts/check-admin-api-fixtures.mjs
+++ b/next/scripts/check-admin-api-fixtures.mjs
@@ -4,7 +4,8 @@ import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
-import { isAdminApiSpec } from '../../shared/admin-api-spec.ts';
+import { fileURLToPath } from 'node:url';
+import { isApiReferenceSpec } from '../../shared/admin-api-spec.ts';
// Read-only, explicit validation inputs; never called by sync or deployment.
const [fixtureRepository, generatedAssetRoot] = process.argv.slice(2);
@@ -13,13 +14,18 @@ assert.ok(
'Usage: node next/scripts/check-admin-api-fixtures.mjs <fixture-git-repo>
<generated-assets/openapi/apisix>',
);
const fixtures = JSON.parse(fs.readFileSync(new
URL('../tests/admin-api/source-fixtures.json', import.meta.url)));
+const siteRoot = fileURLToPath(new URL('../..', import.meta.url));
console.log('Canonical apache/apisix fixture parity.');
-fixtures.forEach(({ version, commit, canonicalRef }) => {
- const source = execFileSync('git', ['-C', fixtureRepository, 'show',
`${commit}:docs/en/latest/admin-api.json`]);
- const generated = fs.readFileSync(path.join(generatedAssetRoot, version,
'admin-api.json'));
+fixtures.forEach(({
+ version, commit, canonicalRef, apiId = 'admin-api', generatedPath,
+}) => {
+ const source = execFileSync('git', ['-C', fixtureRepository, 'show',
`${commit}:docs/en/latest/${apiId}.json`]);
+ const generated = fs.readFileSync(generatedPath
+ ? path.join(siteRoot, generatedPath)
+ : path.join(generatedAssetRoot, version, `${apiId}.json`));
const spec = JSON.parse(source);
- assert.ok(isAdminApiSpec(spec, version), `Invalid ${version} fixture`);
- assert.deepEqual(generated, source, `${version} generated asset differs from
immutable fixture`);
+ assert.ok(isApiReferenceSpec(spec, version, apiId), `Invalid ${version}
${apiId} fixture`);
+ assert.deepEqual(generated, source, `${version} ${apiId} generated asset
differs from immutable fixture`);
const digest = createHash('sha256').update(source).digest('hex');
- console.log(`PASS ${version}: apache/apisix@${commit}; canonical ref
${canonicalRef}; ${Object.keys(spec.paths).length} paths; sha256=${digest}`);
+ console.log(`PASS ${version} ${apiId}: apache/apisix@${commit}; canonical
ref ${canonicalRef}; ${Object.keys(spec.paths).length} paths;
sha256=${digest}`);
});
diff --git a/next/scripts/check-admin-api-sources.mjs
b/next/scripts/check-admin-api-sources.mjs
index abc2ce58c65..9c93edb0a13 100644
--- a/next/scripts/check-admin-api-sources.mjs
+++ b/next/scripts/check-admin-api-sources.mjs
@@ -3,22 +3,24 @@ import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
-import { isAdminApiSpec } from '../../shared/admin-api-spec.ts';
+import { isApiReferenceSpec } from '../../shared/admin-api-spec.ts';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)),
'../..');
const assets = process.argv[2] ?? path.join(root,
'doc/static/assets/openapi/apisix');
const required = ['3.16', '3.17', '3.18', 'next'];
let failed = false;
required.forEach((version) => {
- const ref = version === 'next' ? 'master' : `release/${version}`;
- const source = `apache/apisix@${ref}/docs/en/latest/admin-api.json`;
- try {
- const bytes = fs.readFileSync(path.join(assets, version,
'admin-api.json'));
- if (!isAdminApiSpec(JSON.parse(bytes), version)) throw new Error('invalid
or mismatched OpenAPI');
- console.log(`OK ${source}
sha256=${createHash('sha256').update(bytes).digest('hex')}`);
- } catch (error) {
- failed = true;
- console.error(`BLOCKED ${source}: ${error.code === 'ENOENT' ? 'missing
synced source' : error.message}`);
- }
+ ['admin-api', 'control-api'].forEach((apiId) => {
+ const ref = version === 'next' ? 'master' : `release/${version}`;
+ const source = `apache/apisix@${ref}/docs/en/latest/${apiId}.json`;
+ try {
+ const bytes = fs.readFileSync(path.join(assets, version,
`${apiId}.json`));
+ if (!isApiReferenceSpec(JSON.parse(bytes), version, apiId)) throw new
Error('invalid or mismatched OpenAPI');
+ console.log(`OK ${source}
sha256=${createHash('sha256').update(bytes).digest('hex')}`);
+ } catch (error) {
+ failed = true;
+ console.error(`BLOCKED ${source}: ${error.code === 'ENOENT' ? 'missing
synced source' : error.message}`);
+ }
+ });
});
if (failed) process.exitCode = 1;
diff --git a/next/scripts/sync-content.mjs b/next/scripts/sync-content.mjs
index 762c9a919af..6623bf8a5cf 100644
--- a/next/scripts/sync-content.mjs
+++ b/next/scripts/sync-content.mjs
@@ -195,8 +195,10 @@ for (const { key, repo } of PROJECTS) {
const cfg = path.join(src, 'config.json');
if (fs.existsSync(cfg)) fs.copyFileSync(cfg, path.join(outDir,
'config.json'));
if (key === 'apisix' && loc === 'en') {
- const spec = path.join(src, 'admin-api.json');
- if (fs.existsSync(spec)) fs.copyFileSync(spec, path.join(outDir,
'admin-api.json'));
+ ['admin-api', 'control-api'].forEach((apiId) => {
+ const spec = path.join(src, `${apiId}.json`);
+ if (fs.existsSync(spec)) fs.copyFileSync(spec, path.join(outDir,
`${apiId}.json`));
+ });
}
}
}
diff --git a/next/src/layouts/AdminApiPage.astro
b/next/src/layouts/AdminApiPage.astro
index bb4c293840c..549678be797 100644
--- a/next/src/layouts/AdminApiPage.astro
+++ b/next/src/layouts/AdminApiPage.astro
@@ -1,18 +1,19 @@
---
import DocPage from './DocPage.astro';
import '../../../shared/admin-api-toolbar.css';
-import { isAdminApiSpec } from '../../../shared/admin-api-spec';
+import { isApiReferenceSpec } from '../../../shared/admin-api-spec';
import { getApisixDocs, getApisixSidebar, sidebarLandingId, docEditUrl,
APISIX_DOCS_VERSION, APISIX_ARCHIVED_VERSIONS, versionedLandingHref, type
VersionEntry } from '../lib/content';
import type { Locale } from '../lib/site';
interface Props {
locale: Locale;
+ apiId: 'admin-api' | 'control-api';
}
-const { locale } = Astro.props;
+const { locale, apiId } = Astro.props;
const zh = locale === 'zh';
const docs = getApisixDocs(locale);
-const entry = docs.find((doc) => doc.pathId === 'admin-api');
-if (!entry) throw new Error('Sync APISIX documentation before building the
Admin API reference');
+const entry = docs.find((doc) => doc.pathId === apiId);
+if (!entry) throw new Error(`Sync APISIX documentation before building the
${apiId} reference`);
const prefix = zh ? '/zh' : '';
const sidebar = getApisixSidebar();
const titleById = new Map(docs.map((doc) => [doc.pathId, doc.title]));
@@ -20,7 +21,7 @@ const urlByPathId = new Map(docs.map((doc) => [doc.pathId,
doc.url]));
const urlById = (id: string) => urlByPathId.get(id) ??
`${prefix}/docs/apisix/${id}/`;
const docsHref = docs.find((doc) => doc.pathId ===
sidebarLandingId(sidebar))?.url ?? `${prefix}/docs/`;
const editUrl = docEditUrl('apisix', 'apisix', entry);
-const currentHref = `${prefix}/docs/apisix/admin-api/`;
+const currentHref = `${prefix}/docs/apisix/${apiId}/`;
const versionsFor = (href: string): VersionEntry[] => [
{ label: `APISIX ${APISIX_DOCS_VERSION}`, href, current: true },
...[...APISIX_ARCHIVED_VERSIONS, 'next'].map((version) => ({
@@ -31,19 +32,21 @@ const versionsFor = (href: string): VersionEntry[] => [
const versions = versionsFor(currentHref);
// The upstream JSON is optional until it is available on the synced release.
// Vite emits it under /_astro/, which the existing deployment already ships.
-const sources = import.meta.glob('/content/docs-apisix-en/admin-api.json', {
eager: true, query: '?raw', import: 'default' }) as Record<string, string>;
-const assets = import.meta.glob('/content/docs-apisix-en/admin-api.json', {
eager: true, query: '?url', import: 'default' }) as Record<string, string>;
+const allSources = import.meta.glob('/content/docs-apisix-en/*.json', { eager:
true, query: '?raw', import: 'default' }) as Record<string, string>;
+const allAssets = import.meta.glob('/content/docs-apisix-en/*.json', { eager:
true, query: '?url', import: 'default' }) as Record<string, string>;
+const sourceKey = Object.keys(allSources).find((key) =>
key.endsWith(`/${apiId}.json`));
let specUrl: string | undefined;
try {
- const spec = JSON.parse(Object.values(sources)[0]);
- if (isAdminApiSpec(spec, APISIX_DOCS_VERSION)) {
- specUrl = Object.values(assets)[0];
+ const spec = JSON.parse(sourceKey ? allSources[sourceKey] : '');
+ if (isApiReferenceSpec(spec, APISIX_DOCS_VERSION, apiId)) {
+ specUrl = sourceKey ? allAssets[sourceKey] : undefined;
}
} catch { /* Missing or invalid JSON keeps the existing Markdown renderer. */ }
+const pageName = apiId === 'admin-api' ? 'Admin API' : 'Control API';
---
-<DocPage entry={entry} activeId={entry.pathId} locale={locale}
path="/docs/apisix/admin-api/" sidebar={sidebar} titleById={titleById}
urlById={urlById} editUrl={editUrl} versionLabel={`APISIX
${APISIX_DOCS_VERSION}`} versions={versionsFor(docsHref)}
hideContent={Boolean(specUrl)}>
+<DocPage entry={entry} activeId={entry.pathId} locale={locale}
path={`/docs/apisix/${apiId}/`} sidebar={sidebar} titleById={titleById}
urlById={urlById} editUrl={editUrl} versionLabel={`APISIX
${APISIX_DOCS_VERSION}`} versions={versionsFor(docsHref)}
hideContent={Boolean(specUrl)}>
<Fragment slot="reference">
- {specUrl && <section id="admin-api-openapi">
+ {specUrl && <section id="api-reference-openapi">
<nav class="api-toolbar" aria-label={zh ? 'API 文档导航' : 'API reference
navigation'}>
<div class="api-location">
<a class="api-back" href={docsHref}>
@@ -51,7 +54,7 @@ try {
{zh ? '文档' : 'Docs'}
</a>
<span class="api-separator" aria-hidden="true">/</span>
- <span class="api-page-name" aria-current="page">Admin API</span>
+ <span class="api-page-name" aria-current="page">{pageName}</span>
<details class="api-version">
<summary aria-label={zh ? `切换版本,当前 ${APISIX_DOCS_VERSION}` :
`Switch version, current ${APISIX_DOCS_VERSION}`}>
<span>{APISIX_DOCS_VERSION}</span>
@@ -60,25 +63,25 @@ try {
<ul>{versions.map((version) => <li><a href={version.href}
aria-current={version.current ? 'page' :
undefined}>{version.label}</a></li>)}</ul>
</details>
</div>
- <a class="api-action api-download" href={specUrl}
download="apisix-admin-api.json" aria-label={zh ? '下载 OpenAPI' : 'Download
OpenAPI'} title={zh ? '下载 OpenAPI' : 'Download OpenAPI'}>
+ <a class="api-action api-download" href={specUrl}
download={`apisix-${apiId}.json`} aria-label={zh ? '下载 OpenAPI' : 'Download
OpenAPI'} title={zh ? '下载 OpenAPI' : 'Download OpenAPI'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M12
3v12m-5-5 5 5 5-5M4 16v5h16v-5" /></svg>
<span class="api-action-label">{zh ? '下载 OpenAPI' : 'Download
OpenAPI'}</span>
</a>
</nav>
<p id="api-reference-loading" class="container" role="status">{zh ?
'正在加载 API 参考…' : 'Loading API reference…'}</p>
- <div id="admin-api-reference" data-spec-url={specUrl}
data-spec-version={APISIX_DOCS_VERSION} hidden></div>
+ <div id="api-reference-container" data-api-id={apiId}
data-spec-url={specUrl} data-spec-version={APISIX_DOCS_VERSION} hidden></div>
</section>}
{specUrl && <noscript>
<style is:inline>
- #admin-api-openapi { display: none; }
- #admin-api-markdown[hidden] { display: grid; }
+ #api-reference-openapi { display: none; }
+ #api-reference-markdown[hidden] { display: grid; }
</style>
</noscript>}
<script>
import scalarTheme from '../../../shared/admin-api-scalar.css?inline';
- import { mountAdminApi } from '../../../shared/admin-api';
- mountAdminApi(document, scalarTheme);
+ import { mountApiReference } from '../../../shared/admin-api';
+ mountApiReference(document, scalarTheme);
</script>
</Fragment>
</DocPage>
diff --git a/next/src/layouts/DocPage.astro b/next/src/layouts/DocPage.astro
index 6aa5f84dac9..a5c9506622d 100644
--- a/next/src/layouts/DocPage.astro
+++ b/next/src/layouts/DocPage.astro
@@ -63,7 +63,7 @@ const hasAlternate = entry.hasTranslation &&
!canonicalOverride;
search
>
<slot name="reference" />
- <div class="container docs-layout" id={Astro.slots.has('reference') ?
'admin-api-markdown' : undefined} hidden={Astro.props.hideContent}
data-docs-content>
+ <div class="container docs-layout" id={Astro.slots.has('reference') ?
'api-reference-markdown' : undefined} hidden={Astro.props.hideContent}
data-docs-content>
{sidebar.length > 0 && (
<nav class="docs-sidebar" aria-label="Docs sidebar">
{versionLabel && (
diff --git a/next/src/pages/docs/apisix/[...id].astro
b/next/src/pages/docs/apisix/[...id].astro
index c701ec57c4e..bf8bbda2093 100644
--- a/next/src/pages/docs/apisix/[...id].astro
+++ b/next/src/pages/docs/apisix/[...id].astro
@@ -7,7 +7,7 @@ export function getStaticPaths() {
// Sidebar config ids are path-derived; map them to post-override
titles/urls.
const titleById = new Map(docs.map((d) => [d.pathId, d.title]));
const urlByPathId = new Map(docs.map((d) => [d.pathId, d.url]));
- return docs.filter((entry) => entry.pathId !== 'admin-api').map((entry) => ({
+ return docs.filter((entry) => !['admin-api',
'control-api'].includes(entry.pathId)).map((entry) => ({
params: { id: entry.id },
props: { entry, titleById, urlByPathId },
}));
diff --git a/next/src/pages/docs/apisix/admin-api.astro
b/next/src/pages/docs/apisix/admin-api.astro
index db5c4140e2f..68e23b1eb5b 100644
--- a/next/src/pages/docs/apisix/admin-api.astro
+++ b/next/src/pages/docs/apisix/admin-api.astro
@@ -1,4 +1,4 @@
---
import AdminApiPage from '../../../layouts/AdminApiPage.astro';
---
-<AdminApiPage locale="en" />
+<AdminApiPage locale="en" apiId="admin-api" />
diff --git a/next/src/pages/docs/apisix/admin-api.astro
b/next/src/pages/docs/apisix/control-api.astro
similarity index 59%
copy from next/src/pages/docs/apisix/admin-api.astro
copy to next/src/pages/docs/apisix/control-api.astro
index db5c4140e2f..88393f0d296 100644
--- a/next/src/pages/docs/apisix/admin-api.astro
+++ b/next/src/pages/docs/apisix/control-api.astro
@@ -1,4 +1,4 @@
---
import AdminApiPage from '../../../layouts/AdminApiPage.astro';
---
-<AdminApiPage locale="en" />
+<AdminApiPage locale="en" apiId="control-api" />
diff --git a/next/src/pages/zh/docs/apisix/[...id].astro
b/next/src/pages/zh/docs/apisix/[...id].astro
index f91d148789e..06b35fece41 100644
--- a/next/src/pages/zh/docs/apisix/[...id].astro
+++ b/next/src/pages/zh/docs/apisix/[...id].astro
@@ -7,7 +7,7 @@ export function getStaticPaths() {
// Sidebar config ids are path-derived; map them to post-override
titles/urls.
const titleById = new Map(docs.map((d) => [d.pathId, d.title]));
const urlByPathId = new Map(docs.map((d) => [d.pathId, d.url]));
- return docs.filter((entry) => entry.pathId !== 'admin-api').map((entry) => ({
+ return docs.filter((entry) => !['admin-api',
'control-api'].includes(entry.pathId)).map((entry) => ({
params: { id: entry.id },
props: { entry, titleById, urlByPathId },
}));
diff --git a/next/src/pages/zh/docs/apisix/admin-api.astro
b/next/src/pages/zh/docs/apisix/admin-api.astro
index 8e10a07387b..9480c917750 100644
--- a/next/src/pages/zh/docs/apisix/admin-api.astro
+++ b/next/src/pages/zh/docs/apisix/admin-api.astro
@@ -1,4 +1,4 @@
---
import AdminApiPage from '../../../../layouts/AdminApiPage.astro';
---
-<AdminApiPage locale="zh" />
+<AdminApiPage locale="zh" apiId="admin-api" />
diff --git a/next/src/pages/zh/docs/apisix/admin-api.astro
b/next/src/pages/zh/docs/apisix/control-api.astro
similarity index 60%
copy from next/src/pages/zh/docs/apisix/admin-api.astro
copy to next/src/pages/zh/docs/apisix/control-api.astro
index 8e10a07387b..aadacdcdb62 100644
--- a/next/src/pages/zh/docs/apisix/admin-api.astro
+++ b/next/src/pages/zh/docs/apisix/control-api.astro
@@ -1,4 +1,4 @@
---
import AdminApiPage from '../../../../layouts/AdminApiPage.astro';
---
-<AdminApiPage locale="zh" />
+<AdminApiPage locale="zh" apiId="control-api" />
diff --git a/next/tests/admin-api/README.md b/next/tests/admin-api/README.md
index e8a7e6421ee..3ac68cf91b7 100644
--- a/next/tests/admin-api/README.md
+++ b/next/tests/admin-api/README.md
@@ -1,4 +1,4 @@
-# Admin API production checks
+# API reference production checks
This suite needs the assembled production site: the current page is Astro,
while the release archives and Next are Docusaurus. Serve that output, then
run:
@@ -9,9 +9,9 @@ ADMIN_API_BASE_URL=http://127.0.0.1:4322 npm run test:admin-api
```
The existing Playwright configuration runs desktop and mobile Chromium. The
-suite checks both locales for the current 3.19 page, 3.16–3.18 archives, and
-Next. Success tests
-require their actual synced canonical English specifications; they must not be
+suite checks both locales for the Admin API and Control API on the current 3.19
+page, 3.16–3.18 archives, and Next. Success tests require their actual synced
+canonical English specifications; they must not be
reported as passed by substituting another release's JSON. Failure tests
intercept requests to exercise missing, malformed, failed and timed-out JSON,
and a failed Scalar script. A separate context checks disabled JavaScript.
@@ -25,9 +25,10 @@ source is absent or invalid. Sync and build from these
apache/apisix refs:
- archives: `release/3.16`, `release/3.17`, and `release/3.18`
- Next: `master`
-The archive and Next syncs copy `docs/en/latest/admin-api.json` byte-for-byte
to
-`doc/static/assets/openapi/apisix/<version>/admin-api.json`. Both locales use
-the same asset for each version. Missing sources remove stale assets and retain
+The archive and Next syncs copy `docs/en/latest/admin-api.json` and
+`docs/en/latest/control-api.json` byte-for-byte to
+`doc/static/assets/openapi/apisix/<version>/<api-id>.json`. Both locales use
+the same assets for each version. Missing sources remove stale assets and
retain
Markdown. Other release archives continue using Markdown.
The assembled deployment already ships `doc/build/assets`, so no external
specification request or additional deployment copy step is needed.
@@ -40,9 +41,13 @@ node --test shared/admin-api-spec.test.mjs
scripts/lib/admin-api.test.js
## Immutable canonical validation fixtures
-`source-fixtures.json` pins the merged release and master specifications in
-`apache/apisix` for reproducible tests. Production synchronization continues
-to use the corresponding Apache branches.
+`source-fixtures.json` pins the merged Admin and Control API specifications in
+`apache/apisix` for reproducible tests. Each entry may identify an `apiId`;
+entries without one retain the Admin API default. Archive and Next fixtures
+validate the published same-origin assets; the current 3.19 Control API fixture
+validates the English source consumed by the Astro build. Do not pin
contributor
+branches or forks. Production synchronization continues to use the
corresponding
+Apache branches.
After preparing generated static assets from those exact commits, check them
without fetching or changing any content:
@@ -57,6 +62,11 @@ node next/scripts/check-admin-api-fixtures.mjs
/path/to/apisix-fixture-checkout
| `release/3.17` |
https://github.com/apache/apisix/commit/9fae9b644357281976463626c624064c0464b5a1
|
| `release/3.18` |
https://github.com/apache/apisix/commit/8d7e45597a065472ba33672581fb7fbafff523ca
|
| `master` |
https://github.com/apache/apisix/commit/88a41b7f30da10072294ae48b1d1d7369452b1ac
|
+| `release/3.16` Control API |
https://github.com/apache/apisix/commit/0f0fef3304579dc495c1131f09c98d947aea9abc
|
+| `release/3.17` Control API |
https://github.com/apache/apisix/commit/36079fe60f55d88ffb27f513ac912f5b1729ff20
|
+| `release/3.18` Control API |
https://github.com/apache/apisix/commit/687d89c68f754d7192761ea55b38d276d1b85e82
|
+| `release/3.19` Control API |
https://github.com/apache/apisix/commit/8e4a79346658d93de8e5bed7f4fcbb6cf8fd4979
|
+| `master` Control API |
https://github.com/apache/apisix/commit/6205e4a568106eea8277f61ae6ef942bbff62d97
|
Run the fixture check against an `apache/apisix` checkout containing the pinned
commit. Report fixture, canonical-source, build, and browser results
separately.
diff --git a/next/tests/admin-api/admin-api.spec.mjs
b/next/tests/admin-api/admin-api.spec.mjs
index f7c24ca6f39..a98024b8d0b 100644
--- a/next/tests/admin-api/admin-api.spec.mjs
+++ b/next/tests/admin-api/admin-api.spec.mjs
@@ -20,99 +20,109 @@ test.beforeEach(async ({ page }, testInfo) => {
});
const versions = ['', '3.16/', '3.17/', '3.18/', 'next/'];
+const apiIds = ['admin-api', 'control-api'];
['', '/zh'].forEach((locale) => {
- versions.forEach((version) => {
- const route = `${locale}/docs/apisix/${version}admin-api/`;
- test(`${route} renders, downloads, switches versions and owns the search
shortcut`, async ({ page }) => {
- test.setTimeout(60000);
- await page.goto(route, { waitUntil: 'domcontentloaded' });
- const reference = page.locator('#admin-api-reference');
- await expect(reference).toBeVisible({ timeout: 16000 });
- await expect(page.locator('#api-reference-loading')).toHaveCount(0);
- await expect(page.locator('#admin-api-markdown')).toBeHidden();
- const download = page.locator('.api-download');
- const href = await download.getAttribute('href');
- expect(new URL(href, page.url()).origin).toBe(new
URL(page.url()).origin);
- const response = await page.request.get(href);
- expect(response.ok()).toBeTruthy();
- const spec = await response.json();
- expect(spec.openapi).toMatch(/^3\./);
- if (version === 'next/') {
- await expect(page.locator('.scalar-api-reference .badge', { hasText:
'Next' })).toBeVisible();
- expect(spec.info.version).not.toBe('Next');
- } else if (version) expect(spec.info.version).toMatch(new
RegExp(`^${version.slice(0, -1).replace('.', '\\.')}([.]|$)`));
- const downloaded = page.waitForEvent('download');
- await download.click();
- expect((await
downloaded).suggestedFilename()).toBe('apisix-admin-api.json');
- await page.locator('.api-version summary').click();
- await expect(page.locator('.api-version
a[aria-current="page"]')).toHaveCount(1);
- await expect(page.locator('.api-version a', { hasText: '3.18'
})).toHaveAttribute('href', `${locale}/docs/apisix/3.18/admin-api/`);
- await page.keyboard.press('Escape');
- await expect(page.locator('.api-version')).not.toHaveAttribute('open',
'');
- // Search dialogs must be opened and closed sequentially.
- // eslint-disable-next-line no-restricted-syntax
- for (const shortcut of ['Control+k', 'Meta+k']) {
- await page.keyboard.press(shortcut);
- await expect(page.locator('.scalar-modal-layout')).toHaveCount(1);
- await expect(page.locator('.DocSearch-Container')).toHaveCount(0);
- await page.keyboard.press(shortcut);
- await expect(page.locator('.scalar-modal-layout')).toHaveCount(1);
- await page.keyboard.press('Escape');
- }
- const hasNoOverflow = await page.evaluate(
- () => document.documentElement.scrollWidth <= window.innerWidth + 1,
- );
- expect(hasNoOverflow).toBeTruthy();
- const destination = version === 'next/' ? '' : 'next';
- const destinationHref = `${locale}/docs/apisix/${destination ?
`${destination}/` : ''}admin-api/`;
- await page.locator('.api-version summary').click();
- await page.locator(`.api-version a[href="${destinationHref}"]`).click();
- await expect(page).toHaveURL(new RegExp(`/apisix/${destination ?
`${destination}/` : ''}admin-api/$`));
- await expect(page.locator('#admin-api-reference')).toBeVisible({
timeout: 16000 });
- });
-
- test(`${route} is readable without JavaScript`, async ({ browser, baseURL
}) => {
- const context = await browser.newContext({ javaScriptEnabled: false });
- const page = await context.newPage();
- await page.goto(`${baseURL}${route}`, { waitUntil: 'domcontentloaded' });
- await expect(page.locator('#admin-api-markdown')).toBeVisible();
- await expect(page.locator('#admin-api-openapi')).toBeHidden();
- await context.close();
- });
-
- ['missing', 'invalid-json', 'invalid-spec', 'network', 'timeout',
'scalar-script'].forEach((mode) => {
- test(`${route} restores Markdown on ${mode}`, async ({ page }) => {
- if (mode === 'timeout') await page.clock.install();
- if (mode === 'scalar-script') {
- await page.route('**/standalone.js', (request) => request.abort());
- } else {
- await page.route('**/*admin-api*.json', (request) => {
- if (mode === 'missing') return request.fulfill({ status: 404,
body: '' });
- if (mode === 'network') return request.abort();
- if (mode === 'timeout') return new Promise(() => {});
- return request.fulfill({ contentType: 'application/json', body:
mode === 'invalid-json' ? '{' : '{"openapi":3,"paths":null}' });
- });
- }
- const requested = mode === 'timeout' ?
page.waitForRequest('**/*admin-api*.json') : undefined;
+ apiIds.forEach((apiId) => {
+ versions.forEach((version) => {
+ const route = `${locale}/docs/apisix/${version}${apiId}/`;
+ test(`${route} renders, downloads, switches versions and owns the search
shortcut`, async ({ page }) => {
+ test.setTimeout(60000);
await page.goto(route, { waitUntil: 'domcontentloaded' });
- if (mode === 'timeout') {
- await requested;
- await expect(page.locator('#api-reference-loading')).toBeVisible();
- await page.clock.fastForward(16000);
+ const reference = page.locator('#api-reference-container');
+ await expect(reference).toBeVisible({ timeout: 16000 });
+ await expect(page.locator('#api-reference-loading')).toHaveCount(0);
+ await expect(page.locator('#api-reference-markdown')).toBeHidden();
+ const download = page.locator('.api-download');
+ const href = await download.getAttribute('href');
+ expect(new URL(href, page.url()).origin).toBe(new
URL(page.url()).origin);
+ const response = await page.request.get(href);
+ expect(response.ok()).toBeTruthy();
+ const spec = await response.json();
+ expect(spec.openapi).toMatch(/^3\./);
+ if (version === 'next/') {
+ await expect(page.locator('.scalar-api-reference .badge', { hasText:
'Next' })).toBeVisible();
+ expect(spec.info.version).not.toBe('Next');
+ } else if (version) expect(spec.info.version).toMatch(new
RegExp(`^${version.slice(0, -1).replace('.', '\\.')}([.]|$)`));
+ const downloaded = page.waitForEvent('download');
+ await download.click();
+ expect((await
downloaded).suggestedFilename()).toBe(`apisix-${apiId}.json`);
+ await page.locator('.api-version summary').click();
+ await expect(page.locator('.api-version
a[aria-current="page"]')).toHaveCount(1);
+ await expect(page.locator('.api-version a', { hasText: '3.18'
})).toHaveAttribute('href', `${locale}/docs/apisix/3.18/${apiId}/`);
+ await page.keyboard.press('Escape');
+ await expect(page.locator('.api-version')).not.toHaveAttribute('open',
'');
+ // eslint-disable-next-line no-restricted-syntax
+ for (const shortcut of ['Control+k', 'Meta+k']) {
+ await page.keyboard.press(shortcut);
+ await expect(page.locator('.scalar-modal-layout')).toHaveCount(1);
+ await expect(page.locator('.DocSearch-Container')).toHaveCount(0);
+ await page.keyboard.press(shortcut);
+ await expect(page.locator('.scalar-modal-layout')).toHaveCount(1);
+ await page.keyboard.press('Escape');
}
- await expect(page.locator('#admin-api-markdown')).toBeVisible({
timeout: 16000 });
- await expect(page.locator('#admin-api-openapi')).toBeHidden();
const hasNoOverflow = await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth + 1,
);
expect(hasNoOverflow).toBeTruthy();
+ const destination = version === 'next/' ? '' : 'next';
+ const destinationHref = `${locale}/docs/apisix/${destination ?
`${destination}/` : ''}${apiId}/`;
+ await page.locator('.api-version summary').click();
+ await page.locator(`.api-version
a[href="${destinationHref}"]`).click();
+ await expect(page).toHaveURL(new RegExp(`/apisix/${destination ?
`${destination}/` : ''}${apiId}/$`));
+ await expect(page.locator('#api-reference-container')).toBeVisible({
timeout: 16000 });
+ });
+
+ test(`${route} is readable without JavaScript`, async ({ browser,
baseURL }) => {
+ const context = await browser.newContext({ javaScriptEnabled: false });
+ const page = await context.newPage();
+ await page.goto(`${baseURL}${route}`, { waitUntil: 'domcontentloaded'
});
+ await expect(page.locator('#api-reference-markdown')).toBeVisible();
+ await expect(page.locator('#api-reference-openapi')).toBeHidden();
+ await context.close();
+ });
+
+ ['missing', 'invalid-json', 'invalid-spec', 'network', 'timeout',
'scalar-script'].forEach((mode) => {
+ test(`${route} restores Markdown on ${mode}`, async ({ page }) => {
+ if (mode === 'timeout') await page.clock.install();
+ if (mode === 'scalar-script') {
+ await page.route('**/standalone.js', (request) => request.abort());
+ } else {
+ await page.route(`**/*${apiId}*.json`, (request) => {
+ if (mode === 'missing') return request.fulfill({ status: 404,
body: '' });
+ if (mode === 'network') return request.abort();
+ if (mode === 'timeout') return new Promise(() => {});
+ return request.fulfill({ contentType: 'application/json', body:
mode === 'invalid-json' ? '{' : '{"openapi":3,"paths":null}' });
+ });
+ }
+ const requested = mode === 'timeout' ?
page.waitForRequest(`**/*${apiId}*.json`) : undefined;
+ await page.goto(route, { waitUntil: 'domcontentloaded' });
+ if (mode === 'timeout') {
+ await requested;
+ await expect(page.locator('#api-reference-loading')).toBeVisible();
+ await expect(page.locator('#api-reference-markdown')).toBeHidden();
+ await page.clock.fastForward(16000);
+ }
+ await expect(page.locator('#api-reference-markdown')).toBeVisible({
timeout: 16000 });
+ await expect(page.locator('#api-reference-openapi')).toBeHidden();
+ const hasNoOverflow = await page.evaluate(
+ () => document.documentElement.scrollWidth <= window.innerWidth +
1,
+ );
+ expect(hasNoOverflow).toBeTruthy();
+ });
});
});
- });
- test(`${locale}/docs/apisix/3.15/admin-api/ remains Markdown-only`, async ({
page }) => {
- await page.goto(`${locale}/docs/apisix/3.15/admin-api/`, { waitUntil:
'domcontentloaded' });
- await expect(page.locator('#admin-api-openapi')).toHaveCount(0);
- await expect(page.locator('article')).toBeVisible();
+ test(`${locale}/docs/apisix/3.18/${apiId}/ shows loading without flashing
Markdown`, async ({ page }) => {
+ await page.route(`**/*${apiId}*.json`, () => new Promise(() => {}));
+ await page.goto(`${locale}/docs/apisix/3.18/${apiId}/`, { waitUntil:
'domcontentloaded' });
+ await expect(page.locator('#api-reference-loading')).toBeVisible();
+ await expect(page.locator('#api-reference-markdown')).toBeHidden();
+ });
+
+ test(`${locale}/docs/apisix/3.15/${apiId}/ remains Markdown-only`, async
({ page }) => {
+ await page.goto(`${locale}/docs/apisix/3.15/${apiId}/`, { waitUntil:
'domcontentloaded' });
+ await expect(page.locator('#api-reference-openapi')).toHaveCount(0);
+ await expect(page.locator('article')).toBeVisible();
+ });
});
});
diff --git a/next/tests/admin-api/source-fixtures.json
b/next/tests/admin-api/source-fixtures.json
index 92a8143b1d0..4cdf775eb59 100644
--- a/next/tests/admin-api/source-fixtures.json
+++ b/next/tests/admin-api/source-fixtures.json
@@ -18,5 +18,36 @@
"version": "next",
"canonicalRef": "master",
"commit": "88a41b7f30da10072294ae48b1d1d7369452b1ac"
+ },
+ {
+ "version": "3.16",
+ "apiId": "control-api",
+ "canonicalRef": "release/3.16",
+ "commit": "0f0fef3304579dc495c1131f09c98d947aea9abc"
+ },
+ {
+ "version": "3.17",
+ "apiId": "control-api",
+ "canonicalRef": "release/3.17",
+ "commit": "36079fe60f55d88ffb27f513ac912f5b1729ff20"
+ },
+ {
+ "version": "3.18",
+ "apiId": "control-api",
+ "canonicalRef": "release/3.18",
+ "commit": "687d89c68f754d7192761ea55b38d276d1b85e82"
+ },
+ {
+ "version": "3.19.0",
+ "apiId": "control-api",
+ "canonicalRef": "release/3.19",
+ "commit": "8e4a79346658d93de8e5bed7f4fcbb6cf8fd4979",
+ "generatedPath": "next/content/docs-apisix-en/control-api.json"
+ },
+ {
+ "version": "next",
+ "apiId": "control-api",
+ "canonicalRef": "master",
+ "commit": "6205e4a568106eea8277f61ae6ef942bbff62d97"
}
]
diff --git a/scripts/lib/admin-api.js b/scripts/lib/admin-api.js
index 44b30e26538..97c2abab3ad 100644
--- a/scripts/lib/admin-api.js
+++ b/scripts/lib/admin-api.js
@@ -2,15 +2,27 @@ const fs = require('fs/promises');
// Specs always come from the English tree of the checked-out apache/apisix
// ref. Both locales request this same-origin asset, never a docs-site copy.
-async function publishAdminApiSpec(source, version, staticRoot) {
+const API_REFERENCES = ['admin-api', 'control-api'];
+
+async function publishApiReferenceSpecs(source, version, staticRoot) {
const target = `${staticRoot}/assets/openapi/apisix/${version}`;
await fs.rm(target, { recursive: true, force: true });
- try { await fs.access(`${source}/admin-api.json`); } catch (error) {
- if (error.code === 'ENOENT') return;
- throw error;
+ const available = (await Promise.all(API_REFERENCES.map(async (apiId) => {
+ try {
+ await fs.access(`${source}/${apiId}.json`);
+ return apiId;
+ } catch (error) {
+ if (error.code === 'ENOENT') return undefined;
+ throw error;
+ }
+ }))).filter(Boolean);
+ if (available.length > 0) {
+ await fs.mkdir(target, { recursive: true });
+ await Promise.all(available.map((apiId) => (
+ fs.copyFile(`${source}/${apiId}.json`, `${target}/${apiId}.json`)
+ )));
}
- await fs.mkdir(target, { recursive: true });
- await fs.copyFile(`${source}/admin-api.json`, `${target}/admin-api.json`);
+ return available;
}
-module.exports = { publishAdminApiSpec };
+module.exports = { API_REFERENCES, publishApiReferenceSpecs };
diff --git a/scripts/lib/admin-api.test.js b/scripts/lib/admin-api.test.js
index a2f066ca4cd..433e53cf14d 100644
--- a/scripts/lib/admin-api.test.js
+++ b/scripts/lib/admin-api.test.js
@@ -3,7 +3,7 @@ const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
-const { publishAdminApiSpec } = require('./admin-api');
+const { publishApiReferenceSpecs } = require('./admin-api');
test('publishes the selected English spec byte-for-byte and removes stale
assets', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'apisix-openapi-'));
@@ -12,13 +12,22 @@ test('publishes the selected English spec byte-for-byte and
removes stale assets
const output = path.join(root, 'static');
await fs.mkdir(source);
const spec =
'{"openapi":"3.1.0","info":{"version":"3.19.0"},"paths":{}}\n';
- await fs.writeFile(path.join(source, 'admin-api.json'), spec);
- await publishAdminApiSpec(source, 'next', output);
- const asset = path.join(output,
'assets/openapi/apisix/next/admin-api.json');
- assert.equal(await fs.readFile(asset, 'utf8'), spec);
+ const controlSpec = spec.replace('3.19.0', '3.19.1');
+ await Promise.all([
+ fs.writeFile(path.join(source, 'admin-api.json'), spec),
+ fs.writeFile(path.join(source, 'control-api.json'), controlSpec),
+ ]);
+ assert.deepEqual(
+ await publishApiReferenceSpecs(source, 'next', output),
+ ['admin-api', 'control-api'],
+ );
+ const target = path.join(output, 'assets/openapi/apisix/next');
+ assert.equal(await fs.readFile(path.join(target, 'admin-api.json'),
'utf8'), spec);
+ assert.equal(await fs.readFile(path.join(target, 'control-api.json'),
'utf8'), controlSpec);
await fs.rm(path.join(source, 'admin-api.json'));
- await publishAdminApiSpec(source, 'next', output);
- await assert.rejects(fs.access(asset), { code: 'ENOENT' });
+ assert.deepEqual(await publishApiReferenceSpecs(source, 'next', output),
['control-api']);
+ await assert.rejects(fs.access(path.join(target, 'admin-api.json')), {
code: 'ENOENT' });
+ assert.equal(await fs.readFile(path.join(target, 'control-api.json'),
'utf8'), controlSpec);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
diff --git a/scripts/sync-docs.js b/scripts/sync-docs.js
index a80afcb757c..386d115fcb4 100644
--- a/scripts/sync-docs.js
+++ b/scripts/sync-docs.js
@@ -10,7 +10,7 @@ const semver = require('semver');
const replace = require('replace-in-file');
const common = require('./common.js');
-const { publishAdminApiSpec } = require('./lib/admin-api.js');
+const { publishApiReferenceSpecs } = require('./lib/admin-api.js');
const { versions, versionMap } = require('../config/apisix-versions.js');
const { projects, languages, projectPaths } = common;
@@ -399,7 +399,7 @@ function extractDocsVersionTasks(project, version) {
const zhTargetDocs =
`${websitePath}/i18n/zh/docusaurus-plugin-content-docs-docs-${projectName}/version-${displayVersionName}`;
if (projectName === 'apisix' && APISIX_SCALAR_ARCHIVES.has(version)) {
- await publishAdminApiSpec(enSrcDocs, version,
`${websitePath}/static`);
+ await publishApiReferenceSpecs(enSrcDocs, version,
`${websitePath}/static`);
}
await Promise.allSettled([
@@ -447,7 +447,7 @@ function extractDocsNextVersionTasks(project, version) {
const enTargetDocs = `${websitePath}/docs/${projectName}`;
const zhTargetDocs =
`${websitePath}/i18n/zh/docusaurus-plugin-content-docs-docs-${projectName}/current`;
- if (projectName === 'apisix') await publishAdminApiSpec(enSrcDocs,
'next', `${websitePath}/static`);
+ if (projectName === 'apisix') await
publishApiReferenceSpecs(enSrcDocs, 'next', `${websitePath}/static`);
await Promise.all([
copyDocs(enSrcDocs, enTargetDocs)
diff --git a/shared/admin-api-scalar.css b/shared/admin-api-scalar.css
index 182b8c3bf5f..0b8726d3a78 100644
--- a/shared/admin-api-scalar.css
+++ b/shared/admin-api-scalar.css
@@ -20,34 +20,34 @@
--scalar-border-color: #e8ecf1;
}
-#admin-api-reference .introduction-section {
+#api-reference-container .introduction-section {
padding-top: 32px;
padding-bottom: 36px;
}
-#admin-api-reference .introduction-card {
+#api-reference-container .introduction-card {
border-radius: 10px;
}
-#admin-api-reference .explore-scalar,
-#admin-api-reference a[href*="utm_source=powered-by"] {
+#api-reference-container .explore-scalar,
+#api-reference-container a[href*="utm_source=powered-by"] {
display: none;
}
-#admin-api-reference h1 {
+#api-reference-container h1 {
font-size: clamp(24px, 2.5vw, 32px);
letter-spacing: -0.035em;
line-height: 1.25;
}
-#admin-api-reference h2 {
+#api-reference-container h2 {
font-size: 28px;
letter-spacing: -0.025em;
line-height: 1.3;
}
@media (max-width: 640px) {
- #admin-api-reference .introduction-section {
+ #api-reference-container .introduction-section {
padding-top: 24px;
padding-bottom: 24px;
}
diff --git a/shared/admin-api-spec.test.mjs b/shared/admin-api-spec.test.mjs
index 2ce1b23dabe..acc62c01ec8 100644
--- a/shared/admin-api-spec.test.mjs
+++ b/shared/admin-api-spec.test.mjs
@@ -1,27 +1,29 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
-import { adminApiDisplaySpec, isAdminApiSpec } from './admin-api-spec.ts';
+import { apiReferenceDisplaySpec, isApiReferenceSpec } from
'./admin-api-spec.ts';
test('accepts patch releases and development versions without crossing release
series', () => {
const spec = { openapi: '3.1.0', info: { title: 'Admin API', version:
'3.18.0' }, paths: {} };
- assert.equal(isAdminApiSpec(spec, '3.18'), true);
- assert.equal(isAdminApiSpec(spec, '3.19'), false);
- assert.equal(isAdminApiSpec(spec, 'next'), true);
- assert.equal(isAdminApiSpec({ ...spec, info: { title: 'Admin API', version:
'3.19.0' } }, '3.19.0'), true);
+ assert.equal(isApiReferenceSpec(spec, '3.18', 'admin-api'), true);
+ assert.equal(isApiReferenceSpec(spec, '3.19', 'admin-api'), false);
+ assert.equal(isApiReferenceSpec(spec, 'next', 'admin-api'), true);
+ assert.equal(isApiReferenceSpec({ ...spec, info: { title: 'Admin API',
version: '3.19.0' } }, '3.19.0', 'admin-api'), true);
+ assert.equal(isApiReferenceSpec({ ...spec, info: { ...spec.info, title:
'Control API' } }, '3.18', 'control-api'), true);
+ assert.equal(isApiReferenceSpec(spec, '3.18', 'control-api'), false);
});
test('labels only the rendered Next copy without mutating the canonical spec',
() => {
const spec = { openapi: '3.1.0', info: { title: 'Admin API', version:
'3.19.0' }, paths: {} };
- const display = adminApiDisplaySpec(spec, 'next');
+ const display = apiReferenceDisplaySpec(spec, 'next');
assert.equal(display.info.version, 'Next');
assert.equal(spec.info.version, '3.19.0');
- assert.equal(adminApiDisplaySpec(spec, '3.19'), spec);
+ assert.equal(apiReferenceDisplaySpec(spec, '3.19'), spec);
});
test('rejects malformed documents without throwing', () => {
[null, [], {}, { openapi: 3 },
{ openapi: '3.1.0', info: { title: 'Admin API', version: '3.18.0' },
paths: null },
{ openapi: '3.1.0', info: { title: 'Admin API', version: '3.18.0' },
paths: [] }].forEach((spec) => {
- assert.equal(isAdminApiSpec(spec, '3.18'), false);
+ assert.equal(isApiReferenceSpec(spec, '3.18'), false);
});
});
diff --git a/shared/admin-api-spec.ts b/shared/admin-api-spec.ts
index 92b7e926a6a..3c734f044f0 100644
--- a/shared/admin-api-spec.ts
+++ b/shared/admin-api-spec.ts
@@ -4,17 +4,21 @@ const isObject = (value: unknown): value is { [key: string]:
unknown } => (
/** Accept release patch versions; Next is deliberately not tied to a release.
*/
// eslint-disable-next-line import/prefer-default-export
-export function isAdminApiSpec(spec: unknown, version?: string): boolean {
+export function isApiReferenceSpec(spec: unknown, version?: string, apiId?:
string): boolean {
if (!isObject(spec) || !isObject(spec.info)) return false;
+ let expectedTitle: RegExp | undefined;
+ if (apiId === 'admin-api') expectedTitle = /\bAdmin API\b/i;
+ if (apiId === 'control-api') expectedTitle = /\bControl API\b/i;
return typeof spec.openapi === 'string' && spec.openapi.startsWith('3.')
&& typeof spec.info.title === 'string'
+ && (!expectedTitle || expectedTitle.test(spec.info.title))
&& typeof spec.info.version === 'string'
&& (!version || version === 'next' || spec.info.version === version ||
spec.info.version.startsWith(`${version}.`))
&& isObject(spec.paths);
}
/** Keep the canonical Next contract intact while labeling its rendered
preview. */
-export function adminApiDisplaySpec(spec: unknown, version?: string): unknown {
+export function apiReferenceDisplaySpec(spec: unknown, version?: string):
unknown {
if (version !== 'next' || !isObject(spec) || !isObject(spec.info)) return
spec;
return { ...spec, info: { ...spec.info, version: 'Next' } };
}
diff --git a/shared/admin-api-toolbar.css b/shared/admin-api-toolbar.css
index 5ea025aebd2..6ebf6cace53 100644
--- a/shared/admin-api-toolbar.css
+++ b/shared/admin-api-toolbar.css
@@ -1,6 +1,6 @@
-#admin-api-openapi[hidden],
-#admin-api-reference[hidden],
-#admin-api-markdown[hidden] {
+#api-reference-openapi[hidden],
+#api-reference-container[hidden],
+#api-reference-markdown[hidden] {
display: none;
}
@@ -147,7 +147,7 @@
outline-offset: 3px;
}
-#admin-api-reference {
+#api-reference-container {
min-height: 60vh;
}
diff --git a/shared/admin-api.ts b/shared/admin-api.ts
index e523fdbe7c8..bd3e6ca5223 100644
--- a/shared/admin-api.ts
+++ b/shared/admin-api.ts
@@ -1,4 +1,4 @@
-import { adminApiDisplaySpec, isAdminApiSpec } from './admin-api-spec';
+import { apiReferenceDisplaySpec, isApiReferenceSpec } from './admin-api-spec';
declare const Scalar: {
createApiReference: (
@@ -9,7 +9,7 @@ declare const Scalar: {
/** Shared by Astro and the Docusaurus archives. Dispose on SPA navigation. */
// eslint-disable-next-line import/prefer-default-export
-export function mountAdminApi(
+export function mountApiReference(
root: ParentNode,
scalarTheme: string,
version?: string,
@@ -25,9 +25,9 @@ export function mountAdminApi(
target.addEventListener(type, handler as EventListener, capture);
cleanups.push(() => target.removeEventListener(type, handler as
EventListener, capture));
};
- const reference = root.querySelector<HTMLElement>('#admin-api-openapi');
+ const reference = root.querySelector<HTMLElement>('#api-reference-openapi');
const markdown = root.querySelector<HTMLElement>('[data-docs-content]');
- const container = root.querySelector<HTMLElement>('#admin-api-reference');
+ const container =
root.querySelector<HTMLElement>('#api-reference-container');
if (reference && markdown && container) {
const toolbar = root.querySelector<HTMLElement>('.api-toolbar');
if (toolbar) {
@@ -65,11 +65,9 @@ export function mountAdminApi(
|| !(event.ctrlKey || event.metaKey)
|| event.key.toLowerCase() !== 'k'
) return;
- const searches = [
- ...container.querySelectorAll<HTMLButtonElement>(
- 'button:has(.sidebar-search-placeholder)',
- ),
- ];
+ const searches =
Array.from(container.querySelectorAll<HTMLButtonElement>(
+ 'button:has(.sidebar-search-placeholder)',
+ ));
const search = searches.find((button) =>
button.getClientRects().length > 0)
?? searches[0];
if (!search) return;
@@ -105,7 +103,14 @@ export function mountAdminApi(
if (!response.ok) throw new Error('Unable to load local OpenAPI');
const spec = await response.json();
const specVersion = version ?? container.dataset.specVersion;
- if (!isAdminApiSpec(spec, specVersion)) throw new Error('Invalid
OpenAPI document');
+ if (!isApiReferenceSpec(spec, specVersion, container.dataset.apiId)) {
+ throw new Error('Invalid OpenAPI document');
+ }
+ // Astro hides Markdown at build time and the archives hide it when
+ // their client mounts. Keep this assignment here so any future host
+ // enters the same loading state only after validation.
+ reference.hidden = false;
+ markdown.hidden = true;
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src =
'https://cdn.jsdelivr.net/npm/@scalar/[email protected]/dist/browser/standalone.js';
@@ -115,7 +120,7 @@ export function mountAdminApi(
});
if (disposed || reference.hidden) return;
const instance = Scalar.createApiReference(container, {
- content: adminApiDisplaySpec(spec, specVersion),
+ content: apiReferenceDisplaySpec(spec, specVersion),
onLoaded: () => {
if (disposed || reference.hidden) return;
window.clearTimeout(timeout);
@@ -134,7 +139,7 @@ export function mountAdminApi(
withDefaultFonts: false,
persistAuth: false,
operationTitleSource: 'path',
- // Admin credentials and requests stay in the browser; no hosted
proxy.
+ // API credentials and requests stay in the browser; no hosted proxy.
proxyUrl: '',
telemetry: false,
agent: { disabled: true },