test: add architecture and OpenAPI validation tooling
This commit is contained in:
@@ -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` 与完整测试均成功。
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
|
||||
+80
@@ -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"],
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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