mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
refactor(schema): simplify validation and OpenAPI generation (#2709)
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdir, mkdtemp, rm, writeFile} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {test} from 'node:test';
|
||||
import {listMarkdownFiles, slugifyHeading} from './DocsSource.ts';
|
||||
|
||||
test('Markdown discovery includes nested pages and excludes assets and dependencies', async (context) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'fluxer-docs-source-'));
|
||||
context.after(() => rm(directory, {recursive: true, force: true}));
|
||||
await mkdir(path.join(directory, 'nested'));
|
||||
await mkdir(path.join(directory, 'node_modules'));
|
||||
await Promise.all(
|
||||
['index.md', 'nested/page.mdx', 'nested/image.svg', 'draft.mdx.bak', 'node_modules/README.md'].map((file) =>
|
||||
writeFile(path.join(directory, file), ''),
|
||||
),
|
||||
);
|
||||
assert.deepEqual((await listMarkdownFiles(directory)).sort(), [
|
||||
path.join(directory, 'index.md'),
|
||||
path.join(directory, 'nested/page.mdx'),
|
||||
]);
|
||||
await assert.rejects(listMarkdownFiles(path.join(directory, 'missing')), {code: 'ENOENT'});
|
||||
});
|
||||
|
||||
for (const [heading, expected] of [
|
||||
[' JSON Body ', 'json-body'],
|
||||
['`user_id` and [display name](/users)', 'userid-and-display-name'],
|
||||
['<span id="user">User</span> object', 'user-object'],
|
||||
['OAuth2: tokens (read-only)', 'oauth2-tokens-read-only'],
|
||||
['', ''],
|
||||
]) {
|
||||
test(`heading ${JSON.stringify(heading)} becomes ${JSON.stringify(expected)}`, () => {
|
||||
assert.equal(slugifyHeading(heading), expected);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {readdir} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
export const DOCS_ROOT = fileURLToPath(new URL('../src/content/docs/', import.meta.url));
|
||||
|
||||
export async function listMarkdownFiles(directory: string): Promise<Array<string>> {
|
||||
const files: Array<string> = [];
|
||||
for (const entry of await readdir(directory, {withFileTypes: true})) {
|
||||
if (entry.name === 'node_modules') {
|
||||
continue;
|
||||
}
|
||||
const resolved = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await listMarkdownFiles(resolved)));
|
||||
} else if (/\.mdx?$/u.test(entry.name)) {
|
||||
files.push(resolved);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function slugifyHeading(heading: string): string {
|
||||
return heading
|
||||
.replace(/`/gu, '')
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/gu, '$1')
|
||||
.replace(/<[^>]*>/gu, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/gu, '')
|
||||
.trim()
|
||||
.replace(/\s+/gu, '-');
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import {fileURLToPath} from 'node:url';
|
||||
import {extractRoutesFromControllers} from '@fluxer/openapi/src/extractors/RouteExtractor';
|
||||
import {installerChecksumLine} from '../src/installer/InstallerDigest.ts';
|
||||
|
||||
const DOCS_ROOT = fileURLToPath(new URL('../src/content/docs/', import.meta.url));
|
||||
import {DOCS_ROOT, listMarkdownFiles} from './DocsSource.ts';
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const MAIN_SPEC = path.join(REPO_ROOT, 'fluxer_api/src/api/openapi/openapi.json');
|
||||
const ADMIN_SPEC = path.join(REPO_ROOT, 'fluxer_admin/openapi-admin.json');
|
||||
@@ -237,22 +238,6 @@ interface SpecOperation {
|
||||
readonly security: ReadonlyArray<Record<string, Array<string>>> | null;
|
||||
}
|
||||
|
||||
async function walk(directory: string): Promise<Array<string>> {
|
||||
const entries = await readdir(directory, {withFileTypes: true});
|
||||
const files: Array<string> = [];
|
||||
for (const entry of entries) {
|
||||
const resolved = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(resolved)));
|
||||
continue;
|
||||
}
|
||||
if (entry.name.endsWith('.mdx') || entry.name.endsWith('.md')) {
|
||||
files.push(resolved);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function stripVersionPrefix(routePath: string): string {
|
||||
if (routePath === '/v1') {
|
||||
return '/';
|
||||
@@ -269,7 +254,7 @@ function shapeOf(method: string, routePath: string): string {
|
||||
}
|
||||
|
||||
async function documentedRoutes(): Promise<Array<DocumentedRoute>> {
|
||||
const files = await walk(DOCS_ROOT);
|
||||
const files = await listMarkdownFiles(DOCS_ROOT);
|
||||
const routes: Array<DocumentedRoute> = [];
|
||||
for (const file of files) {
|
||||
const source = await readFile(file, 'utf8');
|
||||
@@ -313,7 +298,7 @@ const ALIAS_ROW_PATTERN =
|
||||
/^\|\s*(GET|HEAD|POST|PATCH|PUT|DELETE|OPTIONS)\s*\|\s*`(\/[^`]+)`\s*\|\s*\[([^\]]+)\]\([^)]+\)\s*\|\s*$/u;
|
||||
|
||||
async function aliasDocumentedRoutes(): Promise<Array<AliasRoute>> {
|
||||
const files = await walk(DOCS_ROOT);
|
||||
const files = await listMarkdownFiles(DOCS_ROOT);
|
||||
const aliases: Array<AliasRoute> = [];
|
||||
for (const file of files) {
|
||||
const source = await readFile(file, 'utf8');
|
||||
@@ -796,7 +781,7 @@ console.log('enum names and error codes');
|
||||
|
||||
const enumRows: Array<{file: string; line: number; name: string}> = [];
|
||||
const codeRows: Array<{file: string; line: number; code: string}> = [];
|
||||
for (const file of await walk(DOCS_ROOT)) {
|
||||
for (const file of await listMarkdownFiles(DOCS_ROOT)) {
|
||||
const relative = path.relative(DOCS_ROOT, file);
|
||||
const lines = (await readFile(file, 'utf8')).split('\n');
|
||||
let inEnumTable = false;
|
||||
@@ -978,7 +963,7 @@ console.log('rate limit buckets, limits and windows');
|
||||
/([\d,]+) requests? per ([a-z0-9 ]+?)(?:,| for [^.]*?,) on the (?:shared )?`([a-z0-9_:@{}]+)` bucket/gu;
|
||||
const problems: Array<string> = [];
|
||||
let claims = 0;
|
||||
for (const file of await walk(DOCS_ROOT)) {
|
||||
for (const file of await listMarkdownFiles(DOCS_ROOT)) {
|
||||
const relative = path.relative(DOCS_ROOT, file);
|
||||
const lines = (await readFile(file, 'utf8')).split('\n');
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
@@ -1343,7 +1328,7 @@ console.log('self-hosting guide against deploy/self-hosting');
|
||||
|
||||
const PIPE_TO_SHELL =
|
||||
/(?:curl|wget|iwr|Invoke-WebRequest)[^\n|]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|iex|Invoke-Expression)\b/iu;
|
||||
const docsPages = await walk(DOCS_ROOT);
|
||||
const docsPages = await listMarkdownFiles(DOCS_ROOT);
|
||||
for (const [name, source] of installers) {
|
||||
if (PIPE_TO_SHELL.test(source)) {
|
||||
problems.push(`${name} pipes a download into a shell`);
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readdir, readFile} from 'node:fs/promises';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import type {
|
||||
OpenAPIOperation as Operation,
|
||||
OpenAPISchema as SchemaNode,
|
||||
OpenAPIDocument as Spec,
|
||||
} from '@fluxer/openapi/src/OpenAPITypes';
|
||||
|
||||
import {DOCS_ROOT, listMarkdownFiles, slugifyHeading} from './DocsSource.ts';
|
||||
|
||||
const DOCS_ROOT = fileURLToPath(new URL('../src/content/docs/', import.meta.url));
|
||||
const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const MAIN_SPEC = path.join(REPO_ROOT, 'fluxer_api/src/api/openapi/openapi.json');
|
||||
const ADMIN_SPEC = path.join(REPO_ROOT, 'fluxer_admin/openapi-admin.json');
|
||||
@@ -13,42 +19,6 @@ const ROUTE_HEADER = /<RouteHeader\s+method="([A-Z]+)"\s+path="([^"]+)"/u;
|
||||
const TABLE_ROW = /^\|\s*([^|]+?)\s*\|/u;
|
||||
const OBJECT_REFERENCE = /\]\([^)]*#[a-z0-9-]*object\)/u;
|
||||
|
||||
interface Spec {
|
||||
readonly paths: Record<string, Record<string, Operation>>;
|
||||
readonly components?: {schemas?: Record<string, SchemaNode>};
|
||||
}
|
||||
|
||||
interface Operation {
|
||||
readonly parameters?: Array<{name: string; in: string; schema?: SchemaNode}>;
|
||||
readonly requestBody?: {content?: Record<string, {schema?: SchemaNode}>};
|
||||
readonly responses?: Record<string, {content?: Record<string, {schema?: SchemaNode}>}>;
|
||||
}
|
||||
|
||||
interface SchemaNode {
|
||||
$ref?: string;
|
||||
required?: Array<string>;
|
||||
properties?: Record<string, SchemaNode>;
|
||||
allOf?: Array<SchemaNode>;
|
||||
oneOf?: Array<SchemaNode>;
|
||||
anyOf?: Array<SchemaNode>;
|
||||
items?: SchemaNode;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const SUDO_MERGE_FIELDS = ['mfa_method', 'mfa_code', 'webauthn_response', 'webauthn_challenge'];
|
||||
|
||||
const STREAMED_RESPONSE_ROUTES = new Map([
|
||||
['POST /admin/system/heap-snapshots', "'Content-Type': 'application/octet-stream'"],
|
||||
]);
|
||||
|
||||
const MERGED_SCHEMA_ROUTES = new Map([
|
||||
['POST /auth/sessions/logout', "Validator('json', LogoutAuthSessionsRequest.merge(SudoVerificationSchema))"],
|
||||
[
|
||||
'POST /guilds/{}/transfer-ownership',
|
||||
"Validator('json', GuildTransferOwnershipRequest.merge(SudoVerificationSchema))",
|
||||
],
|
||||
]);
|
||||
|
||||
interface Mismatch {
|
||||
readonly page: string;
|
||||
readonly operation: string;
|
||||
@@ -56,25 +26,6 @@ interface Mismatch {
|
||||
readonly detail: string;
|
||||
}
|
||||
|
||||
async function walk(directory: string): Promise<Array<string>> {
|
||||
const entries = await readdir(directory, {withFileTypes: true});
|
||||
const files: Array<string> = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.name === 'node_modules') {
|
||||
continue;
|
||||
}
|
||||
const resolved = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(resolved)));
|
||||
continue;
|
||||
}
|
||||
if (/\.mdx?$/u.test(entry.name)) {
|
||||
files.push(resolved);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function stripVersion(routePath: string): string {
|
||||
if (routePath.startsWith('/v1/')) {
|
||||
return routePath.slice(3);
|
||||
@@ -86,13 +37,29 @@ function shape(method: string, routePath: string): string {
|
||||
return `${method} ${routePath.split('?')[0].replace(/\{[^}]*\}/gu, '{}')}`;
|
||||
}
|
||||
|
||||
function resolveRef(spec: Spec, node: SchemaNode | undefined, depth = 0): SchemaNode | undefined {
|
||||
if (node == null || depth > 12) {
|
||||
return node;
|
||||
function resolveRef(spec: Spec, node: SchemaNode | boolean | undefined, depth = 0): SchemaNode | undefined {
|
||||
if (node == null || typeof node === 'boolean') {
|
||||
return undefined;
|
||||
}
|
||||
if (depth > 64) {
|
||||
throw new Error('OpenAPI reference chain exceeds the supported depth');
|
||||
}
|
||||
if (node.$ref != null) {
|
||||
const name = node.$ref.replace('#/components/schemas/', '');
|
||||
return resolveRef(spec, spec.components?.schemas?.[name], depth + 1);
|
||||
const prefix = '#/components/schemas/';
|
||||
if (!node.$ref.startsWith(prefix)) {
|
||||
throw new Error(`Unsupported schema reference: ${node.$ref}`);
|
||||
}
|
||||
const name = decodeURIComponent(node.$ref.slice(prefix.length)).replace(/~1/gu, '/').replace(/~0/gu, '~');
|
||||
const target = spec.components.schemas[name];
|
||||
if (target == null) {
|
||||
throw new Error(`Missing schema reference: ${node.$ref}`);
|
||||
}
|
||||
const resolved = resolveRef(spec, target, depth + 1);
|
||||
const {$ref, ...siblings} = node;
|
||||
if (resolved == null || Object.keys(siblings).length === 0) {
|
||||
return resolved;
|
||||
}
|
||||
return {...resolved, allOf: [...(resolved.allOf ?? []), siblings]};
|
||||
}
|
||||
return node;
|
||||
}
|
||||
@@ -100,7 +67,7 @@ function resolveRef(spec: Spec, node: SchemaNode | undefined, depth = 0): Schema
|
||||
function collectRequired(spec: Spec, node: SchemaNode | undefined, depth = 0): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const resolved = resolveRef(spec, node, depth);
|
||||
if (resolved == null || depth > 12) {
|
||||
if (resolved == null) {
|
||||
return out;
|
||||
}
|
||||
for (const name of resolved.required ?? []) {
|
||||
@@ -111,21 +78,10 @@ function collectRequired(spec: Spec, node: SchemaNode | undefined, depth = 0): S
|
||||
out.add(name);
|
||||
}
|
||||
}
|
||||
const union = [...(resolved.oneOf ?? []), ...(resolved.anyOf ?? [])];
|
||||
if (union.length > 0) {
|
||||
const declaringBranches = new Map<string, number>();
|
||||
const requiringBranches = new Map<string, number>();
|
||||
for (const branch of union) {
|
||||
const branchRequired = collectRequired(spec, branch, depth + 1);
|
||||
for (const name of collectProperties(spec, branch, depth + 1)) {
|
||||
declaringBranches.set(name, (declaringBranches.get(name) ?? 0) + 1);
|
||||
if (branchRequired.has(name)) {
|
||||
requiringBranches.set(name, (requiringBranches.get(name) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [name, declared] of declaringBranches) {
|
||||
if ((requiringBranches.get(name) ?? 0) === declared) {
|
||||
for (const union of [resolved.oneOf ?? [], resolved.anyOf ?? []]) {
|
||||
const branchRequirements = union.map((branch) => collectRequired(spec, branch, depth + 1));
|
||||
for (const name of branchRequirements[0] ?? []) {
|
||||
if (branchRequirements.every((required) => required.has(name))) {
|
||||
out.add(name);
|
||||
}
|
||||
}
|
||||
@@ -133,17 +89,37 @@ function collectRequired(spec: Spec, node: SchemaNode | undefined, depth = 0): S
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectTypes(spec: Spec, node: SchemaNode | boolean | undefined, depth = 0): Set<string> {
|
||||
const resolved = resolveRef(spec, node, depth);
|
||||
const out = new Set<string>();
|
||||
if (resolved == null) {
|
||||
return out;
|
||||
}
|
||||
for (const type of Array.isArray(resolved.type) ? resolved.type : [resolved.type]) {
|
||||
if (type != null && type !== 'null') {
|
||||
out.add(type);
|
||||
}
|
||||
}
|
||||
for (const branch of [...(resolved.allOf ?? []), ...(resolved.oneOf ?? []), ...(resolved.anyOf ?? [])]) {
|
||||
for (const type of collectTypes(spec, branch, depth + 1)) {
|
||||
out.add(type);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectPropertyTypes(spec: Spec, node: SchemaNode | undefined, depth = 0): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
const resolved = resolveRef(spec, node, depth);
|
||||
if (resolved == null || depth > 12) {
|
||||
if (resolved == null) {
|
||||
return out;
|
||||
}
|
||||
for (const [key, value] of Object.entries(resolved.properties ?? {})) {
|
||||
const property = resolveRef(spec, value, depth + 1);
|
||||
const type = property?.type;
|
||||
if (typeof type === 'string') {
|
||||
out.set(key, type);
|
||||
const types = collectTypes(spec, value, depth + 1);
|
||||
if (types.size === 1) {
|
||||
for (const type of types) {
|
||||
out.set(key, type);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const branch of [...(resolved.allOf ?? [])]) {
|
||||
@@ -198,7 +174,7 @@ function isDeprecatedProperty(property: unknown): boolean {
|
||||
function collectProperties(spec: Spec, node: SchemaNode | undefined, depth = 0): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const resolved = resolveRef(spec, node, depth);
|
||||
if (resolved == null || depth > 12) {
|
||||
if (resolved == null) {
|
||||
return out;
|
||||
}
|
||||
for (const [key, property] of Object.entries(resolved.properties ?? {})) {
|
||||
@@ -355,20 +331,32 @@ const adminSpec: Spec = JSON.parse(await readFile(ADMIN_SPEC, 'utf8'));
|
||||
const mainIndex = operationIndex(mainSpec);
|
||||
const adminIndex = operationIndex(adminSpec);
|
||||
|
||||
function slugifyHeading(heading: string): string {
|
||||
return heading
|
||||
.replace(/`/gu, '')
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/gu, '$1')
|
||||
.replace(/<[^>]*>/gu, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/gu, '')
|
||||
.trim()
|
||||
.replace(/\s+/gu, '-');
|
||||
function documentReferences(page: string, line: string): Set<string> {
|
||||
const references = new Set<string>();
|
||||
for (const link of line.matchAll(/\]\(([^)\s]*)#([a-z0-9-]+)\)/gu)) {
|
||||
if (/^[a-z][a-z0-9+.-]*:/iu.test(link[1])) {
|
||||
continue;
|
||||
}
|
||||
const target =
|
||||
link[1].length === 0
|
||||
? page
|
||||
: link[1].startsWith('/')
|
||||
? link[1].slice(1)
|
||||
: path.posix.join(path.posix.dirname(page), link[1]);
|
||||
const slug = target
|
||||
.replace(/\.(mdx|md)$/u, '')
|
||||
.replace(/\/$/u, '')
|
||||
.replace(/\/index$/u, '');
|
||||
references.add(`${slug}#${link[2]}`);
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
const allFiles = await walk(DOCS_ROOT);
|
||||
const allFiles = await listMarkdownFiles(DOCS_ROOT);
|
||||
const anchorFields = new Map<string, Set<string>>();
|
||||
const anchorTypes = new Map<string, Map<string, string>>();
|
||||
const anchorReferences = new Map<string, Set<string>>();
|
||||
const objectAnchors = new Set<string>();
|
||||
for (const file of allFiles) {
|
||||
const slug = path
|
||||
.relative(DOCS_ROOT, file)
|
||||
@@ -376,19 +364,38 @@ for (const file of allFiles) {
|
||||
.replace(/\/index$/u, '')
|
||||
.replace(/^index$/u, '');
|
||||
const lines = (await readFile(file, 'utf8')).split('\n');
|
||||
let currentAnchor: string | null = null;
|
||||
let currentAnchors: Array<string> = [];
|
||||
const pendingAnchors: Array<string> = [];
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
for (const explicit of line.matchAll(/<a\s+id=["']([^"']+)["']/gu)) {
|
||||
const nextContent = lines.slice(i + 1).find((nextLine) => nextLine.trim().length > 0);
|
||||
if (nextContent?.startsWith('## ')) {
|
||||
pendingAnchors.push(explicit[1]);
|
||||
} else {
|
||||
currentAnchors.push(explicit[1]);
|
||||
}
|
||||
}
|
||||
const heading = line.match(/^##\s+(.+?)\s*$/u);
|
||||
if (heading != null && !line.startsWith('###')) {
|
||||
currentAnchor = slugifyHeading(heading[1]);
|
||||
const explicit = line.match(/id=["']([^"']+)["']/u);
|
||||
if (explicit != null) {
|
||||
currentAnchor = explicit[1];
|
||||
currentAnchors = [...new Set([slugifyHeading(heading[1]), ...pendingAnchors])];
|
||||
if (/\bobject\b/iu.test(heading[1])) {
|
||||
for (const anchor of currentAnchors) {
|
||||
objectAnchors.add(`${slug}#${anchor}`);
|
||||
}
|
||||
}
|
||||
pendingAnchors.length = 0;
|
||||
continue;
|
||||
}
|
||||
if (currentAnchor == null || !line.startsWith('|')) {
|
||||
for (const anchor of currentAnchors) {
|
||||
const key = `${slug}#${anchor}`;
|
||||
const references = anchorReferences.get(key) ?? new Set<string>();
|
||||
for (const reference of documentReferences(slug, line)) {
|
||||
references.add(reference);
|
||||
}
|
||||
anchorReferences.set(key, references);
|
||||
}
|
||||
if (currentAnchors.length === 0 || !line.startsWith('|')) {
|
||||
continue;
|
||||
}
|
||||
const row = line.match(TABLE_ROW);
|
||||
@@ -401,7 +408,8 @@ for (const file of allFiles) {
|
||||
}
|
||||
const cells = line.split('|').slice(1, -1);
|
||||
const declaredType = cells.length >= 2 ? normaliseDocType(cells[1]) : null;
|
||||
for (const anchorKey of [`${slug}#${currentAnchor}`]) {
|
||||
for (const anchor of currentAnchors) {
|
||||
const anchorKey = `${slug}#${anchor}`;
|
||||
const set = anchorFields.get(anchorKey) ?? new Set<string>();
|
||||
set.add(name);
|
||||
anchorFields.set(anchorKey, set);
|
||||
@@ -428,7 +436,7 @@ let typesCompared = 0;
|
||||
let optionalityCompared = 0;
|
||||
const optionalityAdvisories: Array<string> = [];
|
||||
|
||||
for (const file of await walk(DOCS_ROOT)) {
|
||||
for (const file of allFiles) {
|
||||
const relative = path.relative(DOCS_ROOT, file);
|
||||
if (relative.startsWith('media-proxy/')) {
|
||||
continue;
|
||||
@@ -486,13 +494,16 @@ for (const file of await walk(DOCS_ROOT)) {
|
||||
|
||||
const referenced = new Set<string>();
|
||||
for (let i = section.start; i < section.end; i += 1) {
|
||||
for (const link of lines[i].matchAll(/\]\(([^)\s]*)#([a-z0-9-]+)\)/gu)) {
|
||||
const targetPage = link[1].replace(/^\//u, '').replace(/\/$/u, '');
|
||||
if (targetPage.length === 0) {
|
||||
referenced.add(`${relative.replace(/\.(mdx|md)$/u, '').replace(/\/index$/u, '')}#${link[2]}`);
|
||||
continue;
|
||||
}
|
||||
referenced.add(`${targetPage}#${link[2]}`);
|
||||
for (const reference of documentReferences(relative, lines[i])) {
|
||||
referenced.add(reference);
|
||||
}
|
||||
}
|
||||
for (const anchor of referenced) {
|
||||
if (!objectAnchors.has(anchor) || (anchorFields.get(anchor)?.size ?? 0) > 0) {
|
||||
continue;
|
||||
}
|
||||
for (const reference of anchorReferences.get(anchor) ?? []) {
|
||||
referenced.add(reference);
|
||||
}
|
||||
}
|
||||
const referencedFields = new Set<string>();
|
||||
@@ -506,41 +517,40 @@ for (const file of await walk(DOCS_ROOT)) {
|
||||
if (successResponse != null) {
|
||||
const responseSchema = successResponse[1].content?.['application/json']?.schema;
|
||||
const resolvedResponse = resolveRef(spec, responseSchema);
|
||||
const target = resolvedResponse?.type === 'array' ? resolvedResponse.items : resolvedResponse;
|
||||
const itemSchema = resolvedResponse?.type === 'array' ? resolvedResponse.items : resolvedResponse;
|
||||
const target = typeof itemSchema === 'boolean' || Array.isArray(itemSchema) ? undefined : itemSchema;
|
||||
const responseProperties = collectProperties(spec, target);
|
||||
const resolvedTarget = resolveRef(spec, target);
|
||||
const responseIsUnion =
|
||||
resolvedTarget != null && ((resolvedTarget.oneOf ?? []).length > 0 || (resolvedTarget.anyOf ?? []).length > 0);
|
||||
if (!STREAMED_RESPONSE_ROUTES.has(key)) {
|
||||
const referencedTypes = new Map<string, string>();
|
||||
for (const anchor of referenced) {
|
||||
for (const [field, type] of anchorTypes.get(anchor) ?? []) {
|
||||
if (!referencedTypes.has(field)) {
|
||||
referencedTypes.set(field, type);
|
||||
}
|
||||
const referencedTypes = new Map<string, string>();
|
||||
for (const anchor of referenced) {
|
||||
for (const [field, type] of anchorTypes.get(anchor) ?? []) {
|
||||
if (!referencedTypes.has(field)) {
|
||||
referencedTypes.set(field, type);
|
||||
}
|
||||
}
|
||||
for (const [field, specType] of collectPropertyTypes(spec, target)) {
|
||||
const docType = referencedTypes.get(field);
|
||||
if (docType == null) {
|
||||
continue;
|
||||
}
|
||||
typesCompared += 1;
|
||||
if (specType === docType) {
|
||||
continue;
|
||||
}
|
||||
if (specType === 'number' && docType === 'integer') {
|
||||
continue;
|
||||
}
|
||||
mismatches.push({
|
||||
page: relative,
|
||||
operation: key,
|
||||
kind: 'type-mismatch',
|
||||
detail: `${field}: documented ${docType}, response schema ${specType}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (responseProperties.size > 0 && !responseIsUnion && !STREAMED_RESPONSE_ROUTES.has(key)) {
|
||||
for (const [field, specType] of collectPropertyTypes(spec, target)) {
|
||||
const docType = referencedTypes.get(field);
|
||||
if (docType == null) {
|
||||
continue;
|
||||
}
|
||||
typesCompared += 1;
|
||||
if (specType === docType) {
|
||||
continue;
|
||||
}
|
||||
if (specType === 'number' && docType === 'integer') {
|
||||
continue;
|
||||
}
|
||||
mismatches.push({
|
||||
page: relative,
|
||||
operation: key,
|
||||
kind: 'type-mismatch',
|
||||
detail: `${field}: documented ${docType}, response schema ${specType}`,
|
||||
});
|
||||
}
|
||||
if (responseProperties.size > 0 && !responseIsUnion) {
|
||||
responsesChecked += 1;
|
||||
for (const field of responseProperties) {
|
||||
if (pageFields.has(field) || referencedFields.has(field)) {
|
||||
@@ -609,14 +619,10 @@ for (const file of await walk(DOCS_ROOT)) {
|
||||
: `${relative} ${key} ${field}: documented required, schema marks it optional`,
|
||||
);
|
||||
}
|
||||
const merged = MERGED_SCHEMA_ROUTES.has(key);
|
||||
for (const field of documented) {
|
||||
if (actual.has(field)) {
|
||||
continue;
|
||||
}
|
||||
if (merged && SUDO_MERGE_FIELDS.includes(field)) {
|
||||
continue;
|
||||
}
|
||||
mismatches.push({page: relative, operation: key, kind: 'body-extra', detail: field});
|
||||
}
|
||||
const resolvedBody = resolveRef(spec, jsonSchema);
|
||||
@@ -685,63 +691,19 @@ for (const m of mismatches) {
|
||||
byKind.set(m.kind, (byKind.get(m.kind) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const authSource = await readFile(path.join(REPO_ROOT, 'fluxer_api/src/api/auth/AuthController.ts'), 'utf8');
|
||||
const guildSource = await readFile(
|
||||
path.join(REPO_ROOT, 'fluxer_api/src/api/guild/controllers/GuildMemberController.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const adminSystemSource = await readFile(
|
||||
path.join(REPO_ROOT, 'fluxer_api/src/api/admin/controllers/SystemAdminController.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const staleExemptions: Array<string> = [];
|
||||
for (const [route, anchor] of STREAMED_RESPONSE_ROUTES) {
|
||||
if (!adminSystemSource.includes(anchor)) {
|
||||
staleExemptions.push(`${route}: no longer streams a file, drop this exemption`);
|
||||
}
|
||||
}
|
||||
for (const [route, anchor] of MERGED_SCHEMA_ROUTES) {
|
||||
const present = authSource.includes(anchor) || guildSource.includes(anchor);
|
||||
if (!present) {
|
||||
staleExemptions.push(`${route}: the .merge() call is gone, drop this exemption`);
|
||||
}
|
||||
}
|
||||
if (staleExemptions.length > 0) {
|
||||
console.log('stale exemptions:');
|
||||
for (const entry of staleExemptions) {
|
||||
console.log(` ${entry}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`request body tables checked: ${checkedBodies.toString()}`);
|
||||
console.log(`union bodies skipped for the missing check: ${unionBodiesSkipped.toString()}`);
|
||||
console.log(
|
||||
`fields documented in a shared object section rather than the route table: ${referencedElsewhere.toString()}`,
|
||||
);
|
||||
console.log(`bodies documented by reference to an object section: ${documentedByReference.toString()}`);
|
||||
console.log(
|
||||
`merged-schema exemptions active: ${MERGED_SCHEMA_ROUTES.size.toString()} (the OpenAPI generator drops .merge() operands)`,
|
||||
);
|
||||
console.log(`query parameter tables checked: ${checkedQueries.toString()}`);
|
||||
console.log(`success response schemas checked: ${responsesChecked.toString()}`);
|
||||
console.log(
|
||||
`streamed-response exemptions active: ${STREAMED_RESPONSE_ROUTES.size.toString()} (the spec declares JSON, the implementation streams a file)`,
|
||||
);
|
||||
console.log(`response fields found documented on the page: ${responseFieldsFound.toString()}`);
|
||||
console.log(`request body field types compared: ${typesCompared.toString()}`);
|
||||
console.log(`request body optionality compared: ${optionalityCompared.toString()}`);
|
||||
console.log(`optionality advisories: ${optionalityAdvisories.length.toString()}`);
|
||||
if (optionalityAdvisories.length > 0) {
|
||||
console.log(' ADVISORY ONLY, this does not fail the run.');
|
||||
console.log(' Both current lines were adjudicated by hand. ZERO documentation bugs: in each');
|
||||
console.log(' case the page is MORE precise than a bare ? marker can be.');
|
||||
console.log(' POST /stripe/checkout/subscription/preapproval requires country_code in the');
|
||||
console.log(' handler (StripeCheckoutService.ts:310 throws when no country resolves) while the');
|
||||
console.log(' shared CreateCheckoutSessionRequest requires only price_id.');
|
||||
console.log(' PATCH /admin/discovery/applications/{guild_id} takes a discriminated union.');
|
||||
console.log(' reason is required when status is rejected and optional when it is approved,');
|
||||
console.log(' which the page states in a footnote instead of flattening it to ?.');
|
||||
console.log(' Re-adjudicate only if this list changes.');
|
||||
for (const entry of optionalityAdvisories) {
|
||||
console.log(` ${entry}`);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readdir, readFile} from 'node:fs/promises';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const DOCS_ROOT = fileURLToPath(new URL('../src/content/docs/', import.meta.url));
|
||||
import {DOCS_ROOT, listMarkdownFiles, slugifyHeading} from './DocsSource.ts';
|
||||
|
||||
const ASTRO_CONFIG = fileURLToPath(new URL('../astro.config.ts', import.meta.url));
|
||||
|
||||
const SIDEBAR_START = 'sidebar: [';
|
||||
const QUOTED_ENTRY = /'([^']+)'/gu;
|
||||
const LINK_ENTRY = /link:\s*'([^']+)'/gu;
|
||||
|
||||
async function walk(directory: string): Promise<Array<string>> {
|
||||
const entries = await readdir(directory, {withFileTypes: true});
|
||||
const files: Array<string> = [];
|
||||
for (const entry of entries) {
|
||||
const resolved = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(resolved)));
|
||||
continue;
|
||||
}
|
||||
if (entry.name.endsWith('.mdx') || entry.name.endsWith('.md')) {
|
||||
files.push(resolved);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function slugOf(file: string): string {
|
||||
const relative = path.relative(DOCS_ROOT, file);
|
||||
const withoutExtension = relative.replace(/\.(mdx|md)$/u, '');
|
||||
@@ -85,7 +70,7 @@ for (const match of block.matchAll(QUOTED_ENTRY)) {
|
||||
referenced.add(value);
|
||||
}
|
||||
|
||||
const files = await walk(DOCS_ROOT);
|
||||
const files = await listMarkdownFiles(DOCS_ROOT);
|
||||
const actual = new Set(files.map(slugOf));
|
||||
|
||||
const missingPages = [...referenced].filter((slug) => !actual.has(slug)).sort();
|
||||
@@ -119,17 +104,6 @@ const EXPLICIT_ANCHOR = /<[a-z]+\s+id=["']([^"']+)["']/gu;
|
||||
const dangling: Array<string> = [];
|
||||
const anchorable = new Set(actual);
|
||||
|
||||
function slugify(heading: string): string {
|
||||
return heading
|
||||
.replace(/`/gu, '')
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/gu, '$1')
|
||||
.replace(/<[^>]*>/gu, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/gu, '')
|
||||
.trim()
|
||||
.replace(/\s+/gu, '-');
|
||||
}
|
||||
|
||||
const anchorsByPage = new Map<string, Set<string>>();
|
||||
const ambiguousByPage = new Map<string, Set<string>>();
|
||||
for (const file of files) {
|
||||
@@ -140,7 +114,7 @@ for (const file of files) {
|
||||
for (const line of source.split('\n')) {
|
||||
const heading = line.match(HEADING);
|
||||
if (heading != null) {
|
||||
const generated = slugify(heading[1]);
|
||||
const generated = slugifyHeading(heading[1]);
|
||||
anchors.add(generated);
|
||||
counts.set(generated, (counts.get(generated) ?? 0) + 1);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {readdir, readFile} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {DOCS_ROOT, listMarkdownFiles} from './DocsSource.ts';
|
||||
import {
|
||||
columnWidthPercents,
|
||||
extractTables,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
TABLE_WIDE_TIER_PX,
|
||||
} from './DocsTableWidth.ts';
|
||||
|
||||
const DOCS_ROOT = fileURLToPath(new URL('../src/content/docs/', import.meta.url));
|
||||
const STYLES_ROOT = fileURLToPath(new URL('../src/styles/', import.meta.url));
|
||||
const STARLIGHT_STYLES = fileURLToPath(new URL('../node_modules/@astrojs/starlight/style/', import.meta.url));
|
||||
|
||||
@@ -56,22 +56,6 @@ interface Finding {
|
||||
readonly detail: string;
|
||||
}
|
||||
|
||||
async function walk(directory: string): Promise<Array<string>> {
|
||||
const entries = await readdir(directory, {withFileTypes: true});
|
||||
const files: Array<string> = [];
|
||||
for (const entry of entries) {
|
||||
const resolved = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(resolved)));
|
||||
continue;
|
||||
}
|
||||
if (entry.name.endsWith('.mdx') || entry.name.endsWith('.md')) {
|
||||
files.push(resolved);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function frontmatterOf(source: string): string | null {
|
||||
if (!source.startsWith('---\n')) {
|
||||
return null;
|
||||
@@ -93,7 +77,7 @@ function insideFence(lines: ReadonlyArray<string>, index: number): boolean {
|
||||
return fenced;
|
||||
}
|
||||
|
||||
const files = await walk(DOCS_ROOT);
|
||||
const files = await listMarkdownFiles(DOCS_ROOT);
|
||||
const findings: Array<Finding> = [];
|
||||
|
||||
for (const file of files) {
|
||||
|
||||
Reference in New Issue
Block a user