test: auth 域 6 操作契约一致性测试全响应矩阵(D3-8,T3-19)
CI / backend-test (push) Failing after 8s

- 复制 pet 模块契约框架(OpenApiContract/ContractValidator + v1.2.0
  字节级快照)入 patbond-auth,沿每模块复制纪律;快照守卫防漏同步
- AuthContractConformanceTest:沿 AuthE2eIntegrationTest 编排同 JVM 真实
  拉起 user 服务,register/login/refresh/logout 打 auth、me/trackEvents
  打 user;6 操作 19 个 (操作,状态码) 单元格全矩阵零豁免(含 409 双业务
  码、423 锁定、40102 重放、me 404 幽灵用户、events 匿名 202/无效 token 401)
- 修契约测试首轮抓到的真实漂移:EventResult.reason 在 accepted/duplicate
  条目序列化出 null,契约声明仅 rejected 时出现——reason 加
  @JsonInclude(NON_NULL),既有埋点测试零回归

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 17:11:58 +08:00
parent 10a43f876c
commit 263cd88451
5 changed files with 3109 additions and 1 deletions
@@ -0,0 +1,343 @@
package com.patbond.patbond.auth.contract;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.auth.support.SpringTestSupport;
import com.patbond.patbond.auth.support.TestJwtKeys;
import com.patbond.patbond.user.UserApplication;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import java.nio.charset.StandardCharsets;
import java.security.PrivateKey;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import static org.assertj.core.api.Assertions.assertThat;
/**
* T3-19D3-8):auth 域 6 个 M1 操作补进契约一致性保障,机制与
* patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.2.0(快照
* {@code src/test/resources/contract/openapi-v1.2.0.yaml},正典在 doc 仓
* {@code docs/api/openapi.yaml})逐操作真实发请求,用 {@link ContractValidator}
* 严格校验响应结构,最后以全响应矩阵门禁兜底。
*
* <p>与 pet 侧的差别只在运行方式:register/login/refresh/logout 走真实 HTTP
* 打到 auth 服务(本测试的 Spring 上下文),me/trackEvents 打到同 JVM 内
* 启动的真实 user 服务(复用 AuthE2eIntegrationTest 的编排先例),
* 因此这 6 个操作是跨服务的真实纵切,不是 MockMvc 短路。
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = SpringTestSupport.EXCLUDE_JDBC_AUTOCONFIG)
class AuthContractConformanceTest {
private static final String INTERNAL_TOKEN = "contract-internal-token";
private static final OpenApiContract CONTRACT = OpenApiContract.load();
private static final ContractValidator VALIDATOR = new ContractValidator(CONTRACT);
/** 已被真实响应校验过的 (操作, 状态码) 单元格。 */
private static final Set<String> COVERED = ConcurrentHashMap.newKeySet();
/** auth 域 6 个操作(= 契约中 tags ∈ {auth, user, analytics})。 */
private static final List<String> AUTH_OPERATIONS = List.of(
"POST /api/v1/auth/register",
"POST /api/v1/auth/login",
"POST /api/v1/auth/refresh",
"POST /api/v1/auth/logout",
"GET /api/v1/me",
"POST /api/v1/events");
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:18");
private static ConfigurableApplicationContext userApp;
private static String userBaseUrl;
@Autowired
private TestRestTemplate restTemplate;
@DynamicPropertySource
static void bootUserServiceAndWireAuth(DynamicPropertyRegistry registry) {
POSTGRES.start();
userApp = new SpringApplicationBuilder(UserApplication.class).run(
"--server.port=0",
"--spring.application.name=patbond-user",
"--spring.datasource.url=" + POSTGRES.getJdbcUrl(),
"--spring.datasource.username=" + POSTGRES.getUsername(),
"--spring.datasource.password=" + POSTGRES.getPassword(),
"--patbond.internal-token=" + INTERNAL_TOKEN,
"--patbond.jwt.public-key=" + TestJwtKeys.publicPem(),
"--patbond.login-lock.max-failures=3");
userBaseUrl = "http://127.0.0.1:" + userApp.getEnvironment().getProperty("local.server.port");
registry.add("patbond.user-service.url", () -> userBaseUrl);
registry.add("patbond.internal-token", () -> INTERNAL_TOKEN);
registry.add("patbond.jwt.private-key", TestJwtKeys::privatePem);
}
@AfterAll
static void shutdown() {
if (userApp != null) {
userApp.close();
}
POSTGRES.stop();
}
// ---- 校验骨架 ------------------------------------------------------
/**
* 真实发请求,断言 HTTP 状态并将响应体对照冻结契约严格校验;通过后把
* (操作, 状态码) 记入覆盖表。url 为相对路径时打 auth 服务,绝对 URL
* userBaseUrl 前缀)打 user 服务。
*/
private String verified(HttpMethod method, String url, String pathTemplate,
String body, String bearerToken, int expectedStatus) {
HttpHeaders headers = new HttpHeaders();
if (body != null) {
headers.setContentType(MediaType.APPLICATION_JSON);
}
if (bearerToken != null) {
headers.setBearerAuth(bearerToken);
}
ResponseEntity<byte[]> response = restTemplate.exchange(
url, method, new HttpEntity<>(body, headers), byte[].class);
String responseBody = response.getBody() == null
? "" : new String(response.getBody(), StandardCharsets.UTF_8);
assertThat(response.getStatusCode().value())
.as("%s %s 的 HTTP 状态(响应体: %s", method, pathTemplate, responseBody)
.isEqualTo(expectedStatus);
List<String> drift = VALIDATOR.validateResponse(
method.name(), pathTemplate, expectedStatus, responseBody);
assertThat(drift)
.as("%s %s %d 响应与冻结契约漂移", method, pathTemplate, expectedStatus)
.isEmpty();
COVERED.add(method.name() + " " + pathTemplate + " " + expectedStatus);
return responseBody;
}
/** 同上,并额外断言信封 code 等于契约错误码表约定的业务码。 */
private String verifiedError(HttpMethod method, String url, String pathTemplate,
String body, String bearerToken, int status, int bizCode) {
String responseBody = verified(method, url, pathTemplate, body, bearerToken, status);
assertThat((Integer) JsonPath.read(responseBody, "$.code"))
.as("%s %s %d 的业务错误码", method, pathTemplate, status)
.isEqualTo(bizCode);
return responseBody;
}
private String register(String username, String phone) {
return verified(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/register",
"{\"username\":\"%s\",\"phone\":\"%s\",\"password\":\"secret123\"}"
.formatted(username, phone),
null, 200);
}
/** 用正确私钥签任意 subject 的 access token404 场景等)。 */
private static String signToken(PrivateKey key, String subject, Duration ttl) {
Instant now = Instant.now();
return Jwts.builder()
.id(UUID.randomUUID().toString())
.subject(subject)
.issuer("patbond-auth")
.claim("sid", UUID.randomUUID().toString())
.issuedAt(Date.from(now))
.expiration(Date.from(now.plus(ttl)))
.signWith(key, Jwts.SIG.RS256)
.compact();
}
// ---- 成功路径 ------------------------------------------------------
@Test
@Order(1)
void registerRefreshLogoutSuccessShapes() {
String registered = register("contract_auth_alice", "+8613800000601");
String refreshToken = JsonPath.read(registered, "$.data.refreshToken");
// refresh 轮换出新令牌对
String rotated = verified(HttpMethod.POST, "/api/v1/auth/refresh", "/api/v1/auth/refresh",
"{\"refreshToken\":\"%s\"}".formatted(refreshToken), null, 200);
String rotatedAccess = JsonPath.read(rotated, "$.data.accessToken");
String rotatedRefresh = JsonPath.read(rotated, "$.data.refreshToken");
// logout 撤销当前会话
verified(HttpMethod.POST, "/api/v1/auth/logout", "/api/v1/auth/logout",
"{\"refreshToken\":\"%s\"}".formatted(rotatedRefresh), rotatedAccess, 200);
}
@Test
@Order(2)
void loginAndMeSuccessShapes() {
register("contract_auth_bob", "+8613800000602");
String loggedIn = verified(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
"{\"username\":\"contract_auth_bob\",\"password\":\"secret123\"}", null, 200);
String accessToken = JsonPath.read(loggedIn, "$.data.accessToken");
String me = verified(HttpMethod.GET, userBaseUrl + "/api/v1/me", "/api/v1/me",
null, accessToken, 200);
assertThat((String) JsonPath.read(me, "$.data.username")).isEqualTo("contract_auth_bob");
}
@Test
@Order(3)
void trackEventsSuccessShape() {
// 匿名合法批次:202 + 与请求等长的逐条结果
String body = verified(HttpMethod.POST, userBaseUrl + "/api/v1/events", "/api/v1/events",
"""
{"events": [{
"eventId": "%s",
"eventName": "auth_register_started",
"eventVersion": 1,
"anonymousId": "%s",
"sessionId": "%s",
"clientTs": "2026-09-08T10:00:00Z",
"appVersion": "1.0.0+1",
"platform": "android",
"osVersion": "android-14",
"props": {"entryPoint": "onboarding"}
}]}
""".formatted(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID()),
null, 202);
assertThat((String) JsonPath.read(body, "$.data.results[0].status")).isEqualTo("accepted");
}
// ---- 错误信封 ------------------------------------------------------
@Test
@Order(4)
void validationErrorsAnswer40000() {
verifiedError(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/register",
"{}", null, 400, 40000);
verifiedError(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
"{}", null, 400, 40000);
verifiedError(HttpMethod.POST, "/api/v1/auth/refresh", "/api/v1/auth/refresh",
"{}", null, 400, 40000);
// logout 400token 合法但 body 缺 refreshToken
String registered = register("contract_auth_carol", "+8613800000603");
String accessToken = JsonPath.read(registered, "$.data.accessToken");
verifiedError(HttpMethod.POST, "/api/v1/auth/logout", "/api/v1/auth/logout",
"{}", accessToken, 400, 40000);
// events 400:空批次整批拒绝
verifiedError(HttpMethod.POST, userBaseUrl + "/api/v1/events", "/api/v1/events",
"{\"events\":[]}", null, 400, 40000);
}
@Test
@Order(5)
void conflictAndCredentialErrorsMatchContract() {
register("contract_auth_dave", "+8613800000604");
// register 409:用户名占用 40900 / 手机号占用 40901(同一单元格的两种业务码)
verifiedError(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/register",
"{\"username\":\"contract_auth_dave\",\"phone\":\"+8613800000605\",\"password\":\"secret123\"}",
null, 409, 40900);
verifiedError(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/register",
"{\"username\":\"contract_auth_dave2\",\"phone\":\"+8613800000604\",\"password\":\"secret123\"}",
null, 409, 40901);
// login 401:密码错误 40100
verifiedError(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
"{\"username\":\"contract_auth_dave\",\"password\":\"wrong-pass\"}",
null, 401, 40100);
// refresh 401:已轮换 token 重放 40102
String registered = register("contract_auth_erin", "+8613800000606");
String refreshToken = JsonPath.read(registered, "$.data.refreshToken");
verified(HttpMethod.POST, "/api/v1/auth/refresh", "/api/v1/auth/refresh",
"{\"refreshToken\":\"%s\"}".formatted(refreshToken), null, 200);
verifiedError(HttpMethod.POST, "/api/v1/auth/refresh", "/api/v1/auth/refresh",
"{\"refreshToken\":\"%s\"}".formatted(refreshToken), null, 401, 40102);
// logout 401:缺 access token
verifiedError(HttpMethod.POST, "/api/v1/auth/logout", "/api/v1/auth/logout",
"{\"refreshToken\":\"whatever\"}", null, 401, 40101);
// me 401:无 token404:合法签名但用户不存在(40400)
verifiedError(HttpMethod.GET, userBaseUrl + "/api/v1/me", "/api/v1/me",
null, null, 401, 40101);
String ghostToken = signToken(TestJwtKeys.KEY_PAIR.getPrivate(),
UUID.randomUUID().toString(), Duration.ofMinutes(15));
verifiedError(HttpMethod.GET, userBaseUrl + "/api/v1/me", "/api/v1/me",
null, ghostToken, 404, 40400);
// events 401:携带了 Authorization 但 token 无效
verifiedError(HttpMethod.POST, userBaseUrl + "/api/v1/events", "/api/v1/events",
"{\"events\":[]}", "not-a-token", 401, 40101);
}
@Test
@Order(6)
void loginLockoutAnswers42300() {
register("contract_auth_locked", "+8613800000607");
for (int i = 0; i < 3; i++) {
verifiedError(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
"{\"username\":\"contract_auth_locked\",\"password\":\"wrong-pass\"}",
null, 401, 40100);
}
// 窗口内失败达到阈值(测试将阈值降为 3):即使密码正确也锁定
verifiedError(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
"{\"username\":\"contract_auth_locked\",\"password\":\"secret123\"}",
null, 423, 42300);
}
// ---- 快照与覆盖门禁 -------------------------------------------------
/**
* 冻结快照守卫:与 pet 侧同一纪律——正典契约升版时必须同步复制新快照
* 并更新期望值,忘记同步在 CI 立即变红。
*/
@Test
@Order(98)
void frozenSnapshotIsTheExpectedContractVersion() {
assertThat(CONTRACT.version()).isEqualTo("1.2.0");
assertThat(CONTRACT.paths()).hasSize(18);
assertThat(CONTRACT.operations()).hasSize(24);
assertThat(CONTRACT.schemas()).hasSize(45);
assertThat(CONTRACT.operationsTagged(Set.of("auth", "user", "analytics")))
.containsExactlyInAnyOrderElementsOf(AUTH_OPERATIONS);
}
/**
* 全矩阵覆盖门禁:auth 域 6 个操作声明的每个 (操作, 状态码) 都必须被
* 前面的测试真实触发并通过契约校验(19 个单元格,无豁免)。
*/
@Test
@Order(99)
void everyDeclaredResponseCellIsExercised() {
List<String> missing = new ArrayList<>();
for (String op : AUTH_OPERATIONS) {
for (int status : CONTRACT.responseStatuses(op)) {
String cell = op + " " + status;
if (!COVERED.contains(cell)) {
missing.add(cell);
}
}
}
assertThat(missing).as("契约声明但未被契约测试触发的响应单元格").isEmpty();
}
}
@@ -0,0 +1,229 @@
package com.patbond.patbond.auth.contract;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import static com.patbond.patbond.auth.contract.OpenApiContract.cast;
import static com.patbond.patbond.auth.contract.OpenApiContract.list;
import static com.patbond.patbond.auth.contract.OpenApiContract.map;
/**
* Validates an actual HTTP response against the frozen contract, strictly:
*
* <ul>
* <li>the operation and the status must be declared;</li>
* <li>required fields must be present; a null value needs {@code nullable};</li>
* <li>fields the schema does not declare are rejected (this is what catches
* a renamed or newly leaked field — plain OpenAPI semantics would allow
* extra properties, but the frozen contract is "exactly these fields");</li>
* <li>types, enum membership, uuid / date-time / date formats and
* min/max(Length) bounds are checked.</li>
* </ul>
*
* Behavioural semantics (state machines, anti-enumeration, permission logic)
* stay with the existing integration tests — this class only pins structure.
*/
final class ContractValidator {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final OpenApiContract contract;
ContractValidator(OpenApiContract contract) {
this.contract = contract;
}
/**
* @return drift findings, empty when the response conforms; each entry is
* a human-readable "where: what" line
*/
List<String> validateResponse(String method, String pathTemplate, int status, String body) {
List<String> errors = new ArrayList<>();
String opKey = method + " " + pathTemplate;
Map<String, Object> op = contract.operation(opKey);
if (op == null) {
errors.add("契约未声明该操作: " + opKey);
return errors;
}
Object respNode = map(op, "responses").get(String.valueOf(status));
if (respNode == null) {
errors.add("契约未为 " + opKey + " 声明状态码 " + status);
return errors;
}
Map<String, Object> content = map(contract.resolve(cast(respNode)), "content");
if (content == null) {
return errors; // response declared without a body
}
Map<String, Object> schema = map(map(content, "application/json"), "schema");
if (schema == null) {
errors.add(opKey + " " + status + ": 契约声明了 content 但无 application/json schema");
return errors;
}
JsonNode node;
try {
node = MAPPER.readTree(body);
} catch (JsonProcessingException e) {
errors.add(opKey + " " + status + ": 响应体不是合法 JSON: " + e.getOriginalMessage());
return errors;
}
validate(schema, node, "$", errors);
return errors;
}
private void validate(Map<String, Object> rawSchema, JsonNode node, String loc, List<String> errors) {
Map<String, Object> schema = contract.resolve(rawSchema);
if (node == null || node.isMissingNode()) {
errors.add(loc + ": 字段缺失");
return;
}
if (node.isNull()) {
if (!Boolean.TRUE.equals(schema.get("nullable"))) {
errors.add(loc + ": 为 null,但契约未声明 nullable");
}
return;
}
List<Object> allowed = list(schema, "enum");
if (allowed != null && !enumMatches(allowed, node)) {
errors.add(loc + ": 值 " + node + " 不在契约枚举 " + allowed + "");
}
String type = (String) schema.get("type");
if (type == null) {
type = schema.containsKey("properties") ? "object" : null;
}
if (type == null) {
return;
}
switch (type) {
case "object" -> validateObject(schema, node, loc, errors);
case "array" -> validateArray(schema, node, loc, errors);
case "string" -> validateString(schema, node, loc, errors);
case "integer" -> {
if (!node.isIntegralNumber()) {
errors.add(loc + ": 应为 integer,实际 " + node.getNodeType() + " " + node);
} else {
checkRange(schema, node.decimalValue(), loc, errors);
}
}
case "number" -> {
if (!node.isNumber()) {
errors.add(loc + ": 应为 number,实际 " + node.getNodeType() + " " + node);
} else {
checkRange(schema, node.decimalValue(), loc, errors);
}
}
case "boolean" -> {
if (!node.isBoolean()) {
errors.add(loc + ": 应为 boolean,实际 " + node.getNodeType() + " " + node);
}
}
default -> errors.add(loc + ": 契约测试不支持的 type " + type);
}
}
private void validateObject(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
if (!node.isObject()) {
errors.add(loc + ": 应为 object,实际 " + node.getNodeType());
return;
}
Map<String, Object> props = map(schema, "properties");
List<Object> required = list(schema, "required");
if (required != null) {
for (Object r : required) {
if (!node.has((String) r)) {
errors.add(loc + "." + r + ": 契约必填字段缺失");
}
}
}
Object additional = schema.get("additionalProperties");
boolean open = Boolean.TRUE.equals(additional) || additional instanceof Map;
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
Map<String, Object> propSchema = props == null ? null : cast(props.get(field.getKey()));
if (propSchema != null) {
validate(propSchema, field.getValue(), loc + "." + field.getKey(), errors);
} else if (!open) {
errors.add(loc + "." + field.getKey() + ": 契约未声明的字段(结构漂移)");
}
}
}
private void validateArray(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
if (!node.isArray()) {
errors.add(loc + ": 应为 array,实际 " + node.getNodeType());
return;
}
Map<String, Object> items = map(schema, "items");
if (items == null) {
return;
}
int i = 0;
for (JsonNode element : node) {
validate(items, element, loc + "[" + i++ + "]", errors);
}
}
private void validateString(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
if (!node.isTextual()) {
errors.add(loc + ": 应为 string,实际 " + node.getNodeType() + " " + node);
return;
}
String value = node.asText();
String format = (String) schema.get("format");
if (format != null) {
try {
switch (format) {
case "uuid" -> {
if (value.length() != 36) {
throw new IllegalArgumentException("非规范 UUID 长度");
}
java.util.UUID.fromString(value);
}
case "date-time" -> OffsetDateTime.parse(value);
case "date" -> LocalDate.parse(value);
default -> { /* password 等纯标注格式不校验 */ }
}
} catch (IllegalArgumentException | DateTimeParseException e) {
errors.add(loc + ": \"" + value + "\" 不符合 format=" + format);
}
}
if (schema.get("minLength") instanceof Number min && value.length() < min.intValue()) {
errors.add(loc + ": 长度 " + value.length() + " 小于契约 minLength " + min);
}
if (schema.get("maxLength") instanceof Number max && value.length() > max.intValue()) {
errors.add(loc + ": 长度 " + value.length() + " 大于契约 maxLength " + max);
}
}
private static void checkRange(Map<String, Object> schema, BigDecimal value, String loc, List<String> errors) {
if (schema.get("minimum") instanceof Number min
&& value.compareTo(new BigDecimal(min.toString())) < 0) {
errors.add(loc + ": 值 " + value + " 小于契约 minimum " + min);
}
if (schema.get("maximum") instanceof Number max
&& value.compareTo(new BigDecimal(max.toString())) > 0) {
errors.add(loc + ": 值 " + value + " 大于契约 maximum " + max);
}
}
private static boolean enumMatches(List<Object> allowed, JsonNode node) {
if (node.isTextual()) {
return allowed.contains(node.asText());
}
if (node.isIntegralNumber()) {
long v = node.longValue();
return allowed.stream().anyMatch(a -> a instanceof Number n && n.longValue() == v);
}
return false;
}
}
@@ -0,0 +1,148 @@
package com.patbond.patbond.auth.contract;
import org.yaml.snakeyaml.Yaml;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* The frozen v1.2.0 OpenAPI contract, loaded from the test-resource snapshot
* {@code /contract/openapi-v1.2.0.yaml}.
*
* <p><b>Sync discipline (T2-09, extended by T3-19)</b>: the canonical
* contract lives in the doc repo at {@code docs/api/openapi.yaml}; this
* snapshot is a byte-identical copy taken at freeze time, and this class is
* the module-local copy of the pet module's contract framework (same
* per-module duplication discipline as BearerAuthFilter). Whenever the
* canonical contract changes, copy it here AND in patbond-pet under the new
* version's file name and update both conformance tests (expected version +
* snapshot counts). The guard test on {@code info.version} makes a forgotten
* sync fail loudly in CI instead of silently testing against a stale
* contract.
*
* <p>Only the subset of OpenAPI 3.0 this contract actually uses is supported:
* local {@code #/} refs, plain types, {@code nullable}, {@code enum},
* {@code required}, {@code properties}, {@code items} — no allOf/oneOf.
*/
final class OpenApiContract {
static final String RESOURCE = "/contract/openapi-v1.2.0.yaml";
private static final Set<String> HTTP_METHODS =
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
private final Map<String, Object> root;
private OpenApiContract(Map<String, Object> root) {
this.root = root;
}
static OpenApiContract load() {
try (InputStream in = Objects.requireNonNull(
OpenApiContract.class.getResourceAsStream(RESOURCE),
"契约快照缺失: " + RESOURCE)) {
return new OpenApiContract(new Yaml().load(in));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
String version() {
return (String) map(root, "info").get("version");
}
Map<String, Object> paths() {
return map(root, "paths");
}
Map<String, Object> schemas() {
return map(map(root, "components"), "schemas");
}
/** All declared operations as "METHOD pathTemplate" (insertion order). */
Set<String> operations() {
Set<String> ops = new LinkedHashSet<>();
paths().forEach((path, item) -> cast(item).forEach((method, op) -> {
if (HTTP_METHODS.contains(method)) {
ops.add(method.toUpperCase(Locale.ROOT) + " " + path);
}
}));
return ops;
}
/** Operations whose first tag is in {@code tags}, as "METHOD pathTemplate". */
Set<String> operationsTagged(Set<String> tags) {
Set<String> ops = new LinkedHashSet<>();
for (String key : operations()) {
List<Object> opTags = list(operation(key), "tags");
if (opTags != null && opTags.stream().anyMatch(tags::contains)) {
ops.add(key);
}
}
return ops;
}
/** Declared response statuses of an operation, as ints. */
Set<Integer> responseStatuses(String operationKey) {
Set<Integer> statuses = new LinkedHashSet<>();
map(operation(operationKey), "responses")
.keySet().forEach(s -> statuses.add(Integer.parseInt(s)));
return statuses;
}
/** The single 2xx status the operation declares. */
int successStatus(String operationKey) {
return responseStatuses(operationKey).stream()
.filter(s -> s >= 200 && s < 300)
.reduce((a, b) -> {
throw new IllegalStateException("多个 2xx 响应: " + operationKey);
})
.orElseThrow(() -> new IllegalStateException("无 2xx 响应: " + operationKey));
}
/** Operation object for "METHOD pathTemplate", or null when undeclared. */
Map<String, Object> operation(String operationKey) {
String[] parts = operationKey.split(" ", 2);
Map<String, Object> pathItem = map(paths(), parts[1]);
return pathItem == null ? null : map(pathItem, parts[0].toLowerCase(Locale.ROOT));
}
/** Follows local $ref chains; non-ref maps come back unchanged. */
Map<String, Object> resolve(Map<String, Object> node) {
while (node != null && node.get("$ref") instanceof String ref) {
if (!ref.startsWith("#/")) {
throw new IllegalStateException("仅支持本地 $ref: " + ref);
}
Map<String, Object> cur = root;
for (String seg : ref.substring(2).split("/")) {
cur = map(cur, seg);
if (cur == null) {
throw new IllegalStateException("$ref 指向不存在的节点: " + ref);
}
}
node = cur;
}
return node;
}
@SuppressWarnings("unchecked")
static Map<String, Object> cast(Object o) {
return (Map<String, Object>) o;
}
static Map<String, Object> map(Map<String, Object> m, String key) {
return m == null ? null : cast(m.get(key));
}
@SuppressWarnings("unchecked")
static List<Object> list(Map<String, Object> m, String key) {
return m == null ? null : (List<Object>) m.get(key);
}
}
File diff suppressed because it is too large Load Diff