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
+1
View File
@@ -1,4 +1,5 @@
target/
node_modules/
.docker-context/
*.log
.idea/
@@ -4,8 +4,9 @@
本基线只分析 `firstleg` 同一部署单元,不拆微服务,不改变业务流程、数据库结构、OpenAPI 或跨服务契约。分析输入为:
- `src/main/java`:429 个 Java 文件;948 条可解析的模块内类依赖;66 个物理包。
- `src/test/java`:169 个 Java 文件。与主源码合并分析时共 598 个 Java 文件、1,635 条模块内类依赖、69 个物理包。
- 首切片实施前 `src/main/java`:429 个 Java 文件;948 条可解析的模块内类依赖;66 个物理包。
- 首切片实施前 `src/test/java`:169 个 Java 文件。与主源码合并分析时共 598 个 Java 文件、1,635 条模块内类依赖、69 个物理包。
- 首切片实施后 `src/main/java`:430 个 Java 文件;949 条可解析的模块内类依赖;67 个物理包。增加项仅为只读 Out Port。
可重复命令:
@@ -78,7 +79,7 @@ split-loading
| 订舱报关拖车 | `oceanbooking/`、`customs/`,全局 `TruckAppointment*`、`WarehouseSupplier*`、出仓门禁贡献者 | 68 | 25/13 | `CustomsRepository` 直接读取入库与装柜 Mapper;订舱 Repository 直接读取全局货柜 Entity |
| 出仓 | `outbound/dispatch/` 与全局 `OutboundDispatch*`、运输历史、海外卸柜回写 | 17 | 8/10 | 与装柜、订舱门禁双向;海外回写直接使用全局货柜 Mapper/Entity |
| 船节点 | `portschedule/` 中 `CarrierScheduleQuery/Node/Adapter/Attempt/Exception*` | 22 | 8/1 | 查询服务直接读取订舱、船期、货柜 Entity;节点 Outbox 直接使用入库 Outbox Mapper/Entity |
| 客户统计 | `customerstatistics/` | 4 | 1/0 | Java 依赖最低;唯一跨切片依赖为 Controller 调用 `FirstlegScopeGuard`。SQL 只读订舱、货柜和装柜表 |
| 客户统计 | `customerstatistics/adapter`、`customerstatistics/application` | 5 | 1/0 | 已完成首切片;唯一跨切片依赖为 Web Adapter 调用 `FirstlegScopeGuard`。SQL 只读订舱、货柜和装柜表 |
| 财务集成 | 全局 `InternalFinance*`、`FinanceFeeSource*`、`FinanceCostSnapshot*`、付款凭证/快照类 | 20 | 17/6 | 直接读取入库费、柜次费、装柜及共享 Outbox 的 Entity/Mapper;继续受财务总控冻结的业务口径约束 |
共享技术层当前有 36 类,包含应用启动/配置、Scope Guard、单据号、部分跨服务 Client 及无法安全归属的全局模型。跨服务适配器按 `client` 包独立标识,不能因逻辑切片归属而被迁入应用核心。
@@ -104,7 +105,7 @@ split-loading
选择 `customer-statistics`,不是因为预设,而是因为以下实际证据:
1. 仅 4 个生产类,已集中在单一顶层包;没有业务切片对其产生 Java 类型入向依赖。
1. 实施前仅 4 个生产类且已集中在单一顶层包;实施后为原 4 类加 1 个只读 Out Port,没有业务切片对其产生 Java 类型入向依赖。
2. 唯一跨切片 Java 依赖为 Web Controller 对共享 `FirstlegScopeGuard` 的调用,不涉及 Entity 或 Mapper 泄漏。
3. 不属于当前八节点 SCC,也没有跨服务 Feign/HTTP Client。
4. 现有调用面稳定:一个平台 GET 接口、一个 Admin API 消费者、两个后端测试。
@@ -138,7 +139,7 @@ application -> application.model + common-core
## 7. 精确迁移清单、调用方与测试
首切片实施时只迁移/新增以下文件:
首切片已按以下清单迁移/新增:
| 当前文件 | 目标/动作 |
|---|---|
@@ -170,7 +171,7 @@ application -> application.model + common-core
- `scripts/analyze-firstleg-dependencies.mjs`:生成 JSON/Markdown/DOT 和 Tarjan SCC。
- `src/test/resources/architecture/firstleg-slice-dependencies.json`:冻结当前切片边;历史边可减少,不可增加。
- `scripts/analyze-firstleg-dependencies.test.mjs`:验证基线无新增边,并锁定客户统计 4 类、唯一出向边、零入向边和不进入 SCC。
- `scripts/analyze-firstleg-dependencies.test.mjs`:验证基线无新增边,并锁定客户统计目标 5 类/目录、唯一出向边、零入向边、不进入 SCC 和 application 依赖禁令。
与其他架构专项的依赖:
@@ -182,8 +183,18 @@ application -> application.model + common-core
```powershell
node --test firstleg/scripts/analyze-firstleg-dependencies.test.mjs
node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format markdown
mvn -pl firstleg -am test-compile
mvn -pl firstleg -am -Dtest=CustomerTransitStatisticsServiceTest,CustomerTransitStatisticsContractTest -Dsurefire.failIfNoSpecifiedTests=false test
```
当前只新增只读脚本、合同和报告,没有 Java 签名变化;前两条命令是本轮直接门禁。第三条用于首切片实际迁移后的 Maven 行为验证。
首切片已经发生 Java 包与依赖签名变化,三条命令均为直接门禁;另需执行 `mvn -pl firstleg -am test-compile` 覆盖全部测试源码调用点。
## 11. 首切片实施结果
- 四个旧包类已删除,没有转发兼容层;目标五层目录已由 Node 合同锁定。
- `CustomerTransitStatisticsReader.SummaryProjection` 只包含 `customerId`、`containerCount`、`totalTeu`、`unknownContainerCount`、`asOf`,不暴露 MyBatis、Entity 或其他切片类型。
- 依赖方向由 `application Service -> MyBatis Mapper` 改为 `application Service -> application Port <- persistence Mapper`。
- 主源码由 429 类/948 条内部依赖/66 包变为 430 类/949 条内部依赖/67 包;36 条切片间边、88 条模型泄漏、31 条 Mapper 泄漏及全局 `mapper <-> repository` SCC 均未增加。客户统计仍只有 `adapter.in.web -> FirstlegScopeGuard` 一条跨切片边,且没有入向边。
- 根 Mapper 扫描只把旧精确包替换为新精确包,没有扩大扫描范围或增加兼容 Bean。
- 验证结果:Node 合同 3/3;`test-compile` 成功;客户统计加根扫描定向测试 7/7;`mvn -pl firstleg -am test` 共 854 个测试,0 失败、0 错误、3 跳过,构建成功。
- 首次并发执行 `test-compile` 时,其他专项同时写入共享 `target/classes` 导致若干无关 class 文件瞬时缺失;源符号核对正常,独立重跑 `test-compile` 与完整测试均成功。
+1
View File
@@ -23,6 +23,7 @@
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>com.tngtech.archunit</groupId><artifactId>archunit</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
@@ -73,7 +73,7 @@ function classifySlice(fqcn) {
function classifyKind(fqcn) {
const packageName = fqcn.slice(0, fqcn.lastIndexOf('.'));
if (packageName.includes('.client')) return 'cross-service-adapter';
if (/\.(controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter';
if (/\.(adapter|controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter';
if (/\.(entity|dto)(?:\.|$)/.test(packageName)) return 'data-model';
return 'business-application';
}
@@ -218,7 +218,8 @@ function buildReport(sourceSet) {
crossSliceMapperLeaks: mapperLeaks.length,
},
sliceDefinitions: Object.fromEntries(sliceRules.map(([name, expression]) => [name, expression.source]).concat([['shared-technical', 'fallback']])),
classes: sources.map(({ source, imports, ...entry }) => entry),
classes: sources.map(({ source, ...entry }) => entry),
classDependencies: dependencies,
packageEdges,
sliceEdges,
packageSccs,
@@ -265,8 +266,43 @@ function checkContract(report, contractPath) {
const allowed = new Set(contract.allowedSliceEdges ?? []);
const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`);
const additions = actual.filter(edge => !allowed.has(edge));
if (additions.length > 0) {
process.stderr.write(`New slice dependencies are not allowed by ${contractPath}:\n${additions.map(edge => `- ${edge}`).join('\n')}\n`);
const violations = additions.map(edge => `new slice dependency: ${edge}`);
const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry]));
for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) {
const actualClass = classesByName.get(fqcn);
if (!actualClass) violations.push(`required class missing: ${fqcn}`);
else if (actualClass.file !== expectedFile) violations.push(`class location changed: ${fqcn} (${actualClass.file})`);
}
const applicationRule = contract.applicationDependencyRule;
if (applicationRule) {
const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(applicationRule.packagePrefix));
for (const entry of applicationClasses) {
for (const imported of entry.imports) {
if ((applicationRule.forbiddenImportPrefixes ?? []).some(prefix => imported.startsWith(prefix))) {
violations.push(`forbidden application import: ${entry.fqcn} -> ${imported}`);
}
}
}
for (const edge of report.classDependencies) {
if (edge.source.startsWith(applicationRule.packagePrefix)
&& edge.target.startsWith(internalPrefix)
&& !edge.target.startsWith(applicationRule.allowedInternalPrefix)) {
violations.push(`forbidden application dependency: ${edge.source} -> ${edge.target}`);
}
}
}
for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) {
const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort();
const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort();
if (JSON.stringify(outgoing) !== JSON.stringify([...(rule.allowedOutgoing ?? [])].sort())) {
violations.push(`${slice} outgoing dependencies changed: ${outgoing.join(', ')}`);
}
if (JSON.stringify(incoming) !== JSON.stringify([...(rule.allowedIncoming ?? [])].sort())) {
violations.push(`${slice} incoming dependencies changed: ${incoming.join(', ')}`);
}
}
if (violations.length > 0) {
process.stderr.write(`Firstleg dependency contract violations in ${contractPath}:\n${violations.map(item => `- ${item}`).join('\n')}\n`);
process.exitCode = 1;
}
}
@@ -281,4 +317,3 @@ const rendered = options.format === 'json'
: renderDot(report);
if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8');
else process.stdout.write(rendered);
@@ -25,10 +25,11 @@ test('customer statistics remains an independently packaged low-coupling slice',
const report = analyze();
const classes = report.classes.filter(entry => entry.slice === 'customer-statistics');
assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [
'com.baiyue.firstleg.customerstatistics.controller.PlatformCustomerTransitStatisticsController',
'com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos',
'com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper',
'com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService',
'com.baiyue.firstleg.customerstatistics.adapter.in.web.PlatformCustomerTransitStatisticsController',
'com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper',
'com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService',
'com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos',
'com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader',
]);
const outgoing = report.sliceEdges.filter(edge => edge.source === 'customer-statistics');
const incoming = report.sliceEdges.filter(edge => edge.target === 'customer-statistics');
@@ -37,3 +38,17 @@ test('customer statistics remains an independently packaged low-coupling slice',
assert.equal(report.sliceSccs.some(component => component.includes('customer-statistics')), false);
});
test('customer statistics application layer only depends inward', () => {
const report = analyze();
const prefix = 'com.baiyue.firstleg.customerstatistics.application';
const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(prefix));
assert.equal(applicationClasses.length, 3);
for (const entry of applicationClasses) {
assert.equal(entry.imports.some(imported => imported.startsWith('org.springframework.web')), false);
assert.equal(entry.imports.some(imported => imported.startsWith('org.apache.ibatis')), false);
assert.equal(entry.imports.some(imported => imported.startsWith('com.baomidou.mybatisplus')), false);
}
const outwardDependencies = report.classDependencies.filter(edge => edge.source.startsWith(prefix)
&& edge.target.startsWith('com.baiyue.firstleg.') && !edge.target.startsWith(prefix));
assert.deepEqual(outwardDependencies, []);
});
@@ -0,0 +1,80 @@
package com.baiyue.firstleg.customerstatistics;
import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import org.junit.jupiter.api.Test;
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage;
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
class CustomerStatisticsArchitectureTest {
private static final String FIRSTLEG = "com.baiyue.firstleg..";
private static final String CUSTOMER_STATISTICS = "com.baiyue.firstleg.customerstatistics..";
private static final String APPLICATION = "com.baiyue.firstleg.customerstatistics.application..";
private static final String OUTPUT_PORT = "com.baiyue.firstleg.customerstatistics.application.port.out..";
private static final String PERSISTENCE = "com.baiyue.firstleg.customerstatistics.adapter.out.persistence..";
private static final String WEB = "com.baiyue.firstleg.customerstatistics.adapter.in.web..";
private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
.importPackages("com.baiyue.firstleg");
@Test
void applicationModelAndPortsStayFreeOfWebAndPersistenceFrameworks() {
noClasses().that().resideInAPackage(APPLICATION)
.should().dependOnClassesThat().resideInAnyPackage(
"org.springframework.web..",
"org.apache.ibatis..",
"com.baomidou.mybatisplus..")
.check(PRODUCTION_CLASSES);
}
@Test
void applicationOnlyDependsOnItsOwnSliceWithinFirstleg() {
classes().that().resideInAPackage(APPLICATION)
.should().onlyDependOnClassesThat(
resideOutsideOfPackage(FIRSTLEG).or(resideInAPackage(APPLICATION)))
.check(PRODUCTION_CLASSES);
}
@Test
void persistenceAdapterCollaboratesThroughTheApplicationOutputPort() {
classes().that().resideInAPackage(PERSISTENCE)
.should().onlyDependOnClassesThat(
resideOutsideOfPackage(CUSTOMER_STATISTICS).or(resideInAPackage(OUTPUT_PORT)))
.andShould().beAssignableTo(CustomerTransitStatisticsReader.class)
.check(PRODUCTION_CLASSES);
}
@Test
void webAdapterOnlyUsesApplicationAndScopeGuardWithinFirstleg() {
DescribedPredicate<JavaClass> scopeGuard = new DescribedPredicate<>("FirstlegScopeGuard") {
@Override
public boolean test(JavaClass javaClass) {
return javaClass.getName().equals(FirstlegScopeGuard.class.getName());
}
};
classes().that().resideInAPackage(WEB)
.should().onlyDependOnClassesThat(
resideOutsideOfPackage(FIRSTLEG)
.or(resideInAPackage(APPLICATION))
.or(resideInAPackage(WEB))
.or(scopeGuard))
.check(PRODUCTION_CLASSES);
}
@Test
void otherFirstlegProductionPackagesDoNotDependOnCustomerStatistics() {
noClasses().that().resideOutsideOfPackage(CUSTOMER_STATISTICS)
.should().dependOnClassesThat().resideInAPackage(CUSTOMER_STATISTICS)
.check(PRODUCTION_CLASSES);
}
}
@@ -39,6 +39,22 @@
"split-loading->outbound",
"split-loading->shared-technical"
],
"requiredClassLocations": {
"com.baiyue.firstleg.customerstatistics.adapter.in.web.PlatformCustomerTransitStatisticsController": "src/main/java/com/baiyue/firstleg/customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java",
"com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper": "src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java",
"com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService": "src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java",
"com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos": "src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java",
"com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader": "src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java"
},
"applicationDependencyRule": {
"packagePrefix": "com.baiyue.firstleg.customerstatistics.application",
"allowedInternalPrefix": "com.baiyue.firstleg.customerstatistics.application",
"forbiddenImportPrefixes": [
"org.springframework.web",
"org.apache.ibatis",
"com.baomidou.mybatisplus"
]
},
"sliceSpecificRules": {
"customer-statistics": {
"allowedOutgoing": ["shared-technical"],
+6
View File
@@ -66,6 +66,7 @@
<maven.compiler.plugin.version>3.13.0</maven.compiler.plugin.version>
<maven.enforcer.plugin.version>3.4.1</maven.enforcer.plugin.version>
<jacoco-maven-plugin.version>0.8.12</jacoco-maven-plugin.version>
<archunit.version>1.5.0</archunit.version>
<rocketmq.spring.version>2.3.3</rocketmq.spring.version>
</properties>
@@ -115,6 +116,11 @@
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit</artifactId>
<version>${archunit.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
+17 -1
View File
@@ -117,12 +117,28 @@ test('Jenkins cannot invoke the Node contract without declaring a Node runtime',
if (!pipeline.includes('java-topology-contract.test.mjs')) continue
assert.match(
pipeline,
/(?:nodejs\s*\(|NodeJSInstallation|docker(?:\.image\([^)]*node:|\s+run[^\n]*\bnode:))/,
/(?:nodejs\s*\(|NodeJSInstallation|docker(?:\.image\([^)]*node:|\s+run[^\n]*\bnode:))/,
`${path} must declare how Node is provided before invoking the topology contract`
)
}
})
test('Maven verify publishes JaCoCo reports without enforcing a threshold', async () => {
const pom = await read('pom.xml')
const gitlab = await read('.gitlab-ci.yml')
const jacoco = pom.match(/<plugin>\s*<groupId>org\.jacoco<\/groupId>[\s\S]*?<\/plugin>/)?.[0] ?? ''
assert.match(jacoco, /<artifactId>jacoco-maven-plugin<\/artifactId>/)
assert.match(jacoco, /<goal>prepare-agent<\/goal>/)
assert.match(jacoco, /<phase>verify<\/phase>/)
assert.match(jacoco, /<goal>report<\/goal>/)
assert.match(jacoco, /<format>HTML<\/format>/)
assert.match(jacoco, /<format>XML<\/format>/)
assert.doesNotMatch(jacoco, /<goal>check<\/goal>|<rules>/)
assert.match(gitlab, /^ - "\*\*\/target\/site\/jacoco\/"$/m)
assert.match(gitlab, /^ - "\*\*\/target\/jacoco\.exec"$/m)
})
test('regional Compose and Nacos manifests cover the service-map topology', async () => {
const services = parseServiceMap(await read('deploy/service-map.yml'))
const composeByRegion = new Map([
+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/)
})