test: add architecture and OpenAPI validation tooling

This commit is contained in:
2026-08-25 12:22:14 +08:00
parent 24df851acf
commit 0e0b6f8264
13 changed files with 544 additions and 17 deletions
+180
View File
@@ -0,0 +1,180 @@
{
"name": "baiyue-openapi-validation",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "baiyue-openapi-validation",
"version": "1.0.0",
"dependencies": {
"@apidevtools/swagger-parser": "12.1.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz",
"integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==",
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.15",
"js-yaml": "^4.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/philsturgeon"
}
},
"node_modules/@apidevtools/openapi-schemas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@apidevtools/swagger-methods": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
"license": "MIT"
},
"node_modules/@apidevtools/swagger-parser": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz",
"integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==",
"license": "MIT",
"dependencies": {
"@apidevtools/json-schema-ref-parser": "14.0.1",
"@apidevtools/openapi-schemas": "^2.1.0",
"@apidevtools/swagger-methods": "^3.0.2",
"ajv": "^8.17.1",
"ajv-draft-04": "^1.0.0",
"call-me-maybe": "^1.0.2"
},
"peerDependencies": {
"openapi-types": ">=7"
}
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-draft-04": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
"integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
"license": "MIT",
"peerDependencies": {
"ajv": "^8.5.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/call-me-maybe": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz",
"integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"license": "MIT",
"peer": true
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "baiyue-openapi-validation",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"test": "node --test validate-openapi.test.mjs",
"validate": "node validate-openapi.mjs"
},
"dependencies": {
"@apidevtools/swagger-parser": "12.1.0"
}
}
@@ -0,0 +1,83 @@
import SwaggerParser from '@apidevtools/swagger-parser'
import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const OPENAPI_DIRECTORY = ['src', 'main', 'resources', 'openapi'].join('/')
const OPENAPI_EXTENSIONS = new Set(['.json', '.yaml', '.yml'])
const EXCLUDED_DIRECTORIES = new Set([
'.git', '.logs', 'dist', 'node_modules', 'output', 'outputs', 'reports', 'target'
])
export const repositoryRoot = path.resolve(fileURLToPath(new URL('../..', import.meta.url)))
export async function discoverOpenApiFiles(root) {
const files = []
async function walk(directory) {
const entries = await readdir(directory, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory() && EXCLUDED_DIRECTORIES.has(entry.name)) continue
const absolute = path.join(directory, entry.name)
if (entry.isDirectory()) {
await walk(absolute)
continue
}
const relative = path.relative(root, absolute).replaceAll(path.sep, '/')
if (relative.includes(`/${OPENAPI_DIRECTORY}/`)
&& OPENAPI_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(relative)
}
}
await walk(root)
return files.sort()
}
export function parseServiceModules(serviceMap) {
return [...serviceMap.matchAll(/^ ([a-z][a-z0-9-]+):$/gm)].map((match) => match[1]).sort()
}
export function summarizeCoverage(files, serviceModules) {
const coveredServices = [...new Set(files.map((file) => file.split('/')[0]))].sort()
const codeFirstServices = serviceModules.filter((service) => !coveredServices.includes(service))
return { coveredServices, codeFirstServices }
}
export async function validateRepositoryOpenApi(root = repositoryRoot) {
const files = await discoverOpenApiFiles(root)
if (files.length === 0) throw new Error('No static OpenAPI documents were discovered')
const serviceMap = await readFile(path.join(root, 'deploy', 'service-map.yml'), 'utf8')
const serviceModules = parseServiceModules(serviceMap.replaceAll('\r\n', '\n'))
const coverage = summarizeCoverage(files, serviceModules)
const failures = []
for (const relative of files) {
try {
await SwaggerParser.validate(path.join(root, relative))
} catch (error) {
failures.push(`${relative}: ${error instanceof Error ? error.message : String(error)}`)
}
}
if (failures.length > 0) {
throw new Error(
`Discovered ${files.length} documents across ${coverage.coveredServices.length}/${serviceModules.length} services; `
+ `code-first gaps: ${coverage.codeFirstServices.join(', ')}.\n`
+ `OpenAPI validation failed for ${failures.length}/${files.length} documents:\n`
+ failures.map((failure) => `- ${failure}`).join('\n')
)
}
return { files, serviceModules, ...coverage }
}
async function main() {
const result = await validateRepositoryOpenApi()
process.stdout.write(`OpenAPI validation passed: ${result.files.length} documents across ${result.coveredServices.length}/${result.serviceModules.length} services.\n`)
process.stdout.write(`Static OpenAPI services: ${result.coveredServices.join(', ')}\n`)
process.stdout.write(`Code-first services without static documents: ${result.codeFirstServices.join(', ')}\n`)
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
process.exitCode = 1
})
}
@@ -0,0 +1,67 @@
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 {
discoverOpenApiFiles,
parseServiceModules,
summarizeCoverage,
validateRepositoryOpenApi
} from './validate-openapi.mjs'
test('discovers new static OpenAPI documents without a handwritten file list', async (t) => {
const root = await mkdtemp(path.join(tmpdir(), 'baiyue-openapi-discovery-'))
t.after(() => rm(root, { recursive: true, force: true }))
const openapi = path.join(root, 'new-service', 'src', 'main', 'resources', 'openapi')
const ignored = path.join(root, 'new-service', 'target', 'src', 'main', 'resources', 'openapi')
await mkdir(openapi, { recursive: true })
await mkdir(ignored, { recursive: true })
await writeFile(path.join(openapi, 'new-api.yaml'), 'openapi: 3.0.3\n', 'utf8')
await writeFile(path.join(openapi, 'new-api.json'), '{"openapi":"3.0.3"}\n', 'utf8')
await writeFile(path.join(openapi, 'notes.md'), 'not an OpenAPI document\n', 'utf8')
await writeFile(path.join(ignored, 'ignored.yaml'), 'openapi: 3.0.3\n', 'utf8')
assert.deepEqual(await discoverOpenApiFiles(root), [
'new-service/src/main/resources/openapi/new-api.json',
'new-service/src/main/resources/openapi/new-api.yaml'
])
})
test('derives static and code-first service coverage from service-map', () => {
const serviceModules = parseServiceModules('services:\n alpha:\n port: 1\n beta:\n port: 2\n')
assert.deepEqual(serviceModules, ['alpha', 'beta'])
assert.deepEqual(summarizeCoverage([
'alpha/src/main/resources/openapi/alpha.yaml'
], serviceModules), {
coveredServices: ['alpha'],
codeFirstServices: ['beta']
})
})
test('rejects an unresolved OpenAPI reference', async (t) => {
const root = await mkdtemp(path.join(tmpdir(), 'baiyue-openapi-ref-'))
t.after(() => rm(root, { recursive: true, force: true }))
const openapi = path.join(root, 'alpha', 'src', 'main', 'resources', 'openapi')
await mkdir(openapi, { recursive: true })
await mkdir(path.join(root, 'deploy'), { recursive: true })
await writeFile(path.join(root, 'deploy', 'service-map.yml'), 'services:\n alpha:\n port: 1\n', 'utf8')
await writeFile(path.join(openapi, 'broken.yaml'), `openapi: 3.0.3
info:
title: Broken API
version: 1.0.0
paths:
/broken:
get:
responses:
'200':
description: broken
content:
application/json:
schema:
$ref: '#/components/schemas/Missing'
`, 'utf8')
await assert.rejects(validateRepositoryOpenApi(root), /Missing \$ref pointer/)
})