test: add architecture and OpenAPI validation tooling
This commit is contained in:
+180
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
Reference in New Issue
Block a user