task-96: Java/Python/Vue 三端统一 API 字段兼容检查器
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* API 字段兼容检查器(Task 96)。
|
||||
*
|
||||
* 为 Java/Python/Vue 三端统一响应字段契约:声明 schema(字段名、类型、
|
||||
* 是否必填),对每个响应对象做兼容检查,产出缺失/类型错误清单。
|
||||
* 各端接入同一 schema 描述即可在联调前发现字段漂移。
|
||||
*
|
||||
* 语义:
|
||||
* - 必填字段缺失或类型不符产生 violation(kind: missing | type),
|
||||
* 其余字段安全跳过;
|
||||
* - checkBatch 遍历数组检查;空数组返回 passed,不创建无效检查记录;
|
||||
* - maxChecked 限制累计检查对象数(超出跳过计数),内存有界;
|
||||
* - 同一输入重复检查幂等,输入对象永不修改;
|
||||
* - schema 非法(非对象、未知类型)、maxChecked 非正数、检查对象非普通
|
||||
* 对象均 fail-fast 抛错;schema 读取抛错时调用失败且零状态变更,
|
||||
* 依赖恢复后同一检查器继续可用。
|
||||
*/
|
||||
export type ApiFieldType = 'number' | 'string' | 'boolean' | 'object' | 'array'
|
||||
|
||||
export interface ApiFieldSpec {
|
||||
type: ApiFieldType
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
export interface ApiFieldViolation {
|
||||
field: string
|
||||
kind: 'missing' | 'type'
|
||||
expected: string
|
||||
actual?: string
|
||||
}
|
||||
|
||||
export interface ApiFieldCompatOptions {
|
||||
/** 字段 schema:字段名 → 规格;空对象表示纯遍历(不校验) */
|
||||
schema: Record<string, ApiFieldSpec>
|
||||
/** 累计检查对象数上限,必须为正数,默认 1000 */
|
||||
maxChecked?: number
|
||||
}
|
||||
|
||||
export interface ApiFieldCompatResult {
|
||||
violations: ApiFieldViolation[]
|
||||
passed: boolean
|
||||
checkedCount: number
|
||||
violationCount: number
|
||||
}
|
||||
|
||||
export interface ApiFieldCompatStats {
|
||||
checkedCount: number
|
||||
violationCount: number
|
||||
skippedCount: number
|
||||
/** 按检查顺序排列的对象序号(从 1 开始) */
|
||||
checkedIds: number[]
|
||||
}
|
||||
|
||||
export interface ApiFieldCompatChecker {
|
||||
/** 检查单个响应对象,返回违规清单(空数组表示通过) */
|
||||
check: (obj: unknown) => ApiFieldViolation[]
|
||||
/** 检查响应数组,逐条走 check */
|
||||
checkBatch: (objs: unknown[]) => void
|
||||
/** 最近一次 check/checkBatch 结果 */
|
||||
lastResult: () => ApiFieldCompatResult
|
||||
/** 全部已检查过的字段名(按 schema 声明顺序) */
|
||||
checkedFields: () => string[]
|
||||
stats: () => ApiFieldCompatStats
|
||||
}
|
||||
|
||||
const SUPPORTED_TYPES: ApiFieldType[] = ['number', 'string', 'boolean', 'object', 'array']
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value != null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.getPrototypeOf(value) === Object.prototype
|
||||
)
|
||||
}
|
||||
|
||||
export function createApiFieldCompatChecker(options: ApiFieldCompatOptions): ApiFieldCompatChecker {
|
||||
if (!isPlainObject(options.schema)) {
|
||||
throw new Error('schema 必须是对象')
|
||||
}
|
||||
const maxChecked = options.maxChecked ?? 1000
|
||||
if (!(maxChecked > 0)) {
|
||||
throw new Error('maxChecked 必须为正数: ' + maxChecked)
|
||||
}
|
||||
|
||||
const fieldOrder: string[] = []
|
||||
for (const field of Object.keys(options.schema)) {
|
||||
const spec = options.schema[field]
|
||||
if (!SUPPORTED_TYPES.includes(spec.type)) {
|
||||
throw new Error('不支持的字段类型: ' + spec.type)
|
||||
}
|
||||
fieldOrder.push(field)
|
||||
}
|
||||
|
||||
let checkedCount = 0
|
||||
let violationCount = 0
|
||||
let skippedCount = 0
|
||||
const checkedIds: number[] = []
|
||||
let lastViolations: ApiFieldViolation[] = []
|
||||
|
||||
function typeOf(value: unknown): ApiFieldType {
|
||||
if (value == null) return 'object'
|
||||
if (Array.isArray(value)) return 'array'
|
||||
if (typeof value === 'number') return 'number'
|
||||
if (typeof value === 'string') return 'string'
|
||||
if (typeof value === 'boolean') return 'boolean'
|
||||
if (typeof value === 'object') return 'object'
|
||||
return 'object'
|
||||
}
|
||||
|
||||
function checkOne(obj: Record<string, unknown>): ApiFieldViolation[] {
|
||||
const violations: ApiFieldViolation[] = []
|
||||
for (const field of fieldOrder) {
|
||||
const spec = options.schema[field]
|
||||
const value = obj[field]
|
||||
if (value === undefined) {
|
||||
if (spec.required) {
|
||||
violations.push({ field, kind: 'missing', expected: spec.type })
|
||||
}
|
||||
continue
|
||||
}
|
||||
const actual = typeOf(value)
|
||||
if (actual !== spec.type) {
|
||||
violations.push({ field, kind: 'type', expected: spec.type, actual })
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
function record(violations: ApiFieldViolation[]) {
|
||||
checkedCount += 1
|
||||
checkedIds.push(checkedCount)
|
||||
if (violations.length > 0) {
|
||||
violationCount += violations.length
|
||||
}
|
||||
lastViolations = violations
|
||||
}
|
||||
|
||||
function check(obj: unknown): ApiFieldViolation[] {
|
||||
if (!isPlainObject(obj)) {
|
||||
throw new Error('对象必须是普通对象')
|
||||
}
|
||||
if (checkedCount >= maxChecked) {
|
||||
skippedCount += 1
|
||||
return []
|
||||
}
|
||||
const violations = checkOne(obj)
|
||||
record(violations)
|
||||
return violations
|
||||
}
|
||||
|
||||
function checkBatch(objs: unknown[]) {
|
||||
if (!Array.isArray(objs)) {
|
||||
throw new Error('对象数组必须是数组')
|
||||
}
|
||||
for (const obj of objs) {
|
||||
check(obj)
|
||||
}
|
||||
}
|
||||
|
||||
function lastResult(): ApiFieldCompatResult {
|
||||
return {
|
||||
violations: lastViolations,
|
||||
passed: lastViolations.length === 0,
|
||||
checkedCount,
|
||||
violationCount,
|
||||
}
|
||||
}
|
||||
|
||||
function checkedFields(): string[] {
|
||||
return [...fieldOrder]
|
||||
}
|
||||
|
||||
function stats(): ApiFieldCompatStats {
|
||||
return { checkedCount, violationCount, skippedCount, checkedIds: [...checkedIds] }
|
||||
}
|
||||
|
||||
return { check, checkBatch, lastResult, checkedFields, stats }
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createApiFieldCompatChecker } from '../src/shared/api-field-compat.ts'
|
||||
|
||||
const batchSchema = {
|
||||
taskId: { type: 'number', required: true },
|
||||
status: { type: 'string', required: true },
|
||||
progress: { type: 'number' },
|
||||
message: { type: 'string' },
|
||||
items: { type: 'array' },
|
||||
}
|
||||
|
||||
test('test_task_096_api_compat_normal_default_path', () => {
|
||||
const checker = createApiFieldCompatChecker({ schema: batchSchema })
|
||||
const violations = checker.check({ taskId: 1, status: 'SUCCESS', progress: 50 })
|
||||
assert.deepEqual(violations, [], '全部必填字段存在且类型正确')
|
||||
assert.equal(checker.lastResult().violations.length, 0)
|
||||
assert.equal(checker.lastResult().checkedCount, 1)
|
||||
assert.equal(checker.lastResult().passed, true)
|
||||
assert.deepEqual(checker.checkedFields(), ['taskId', 'status', 'progress', 'message', 'items'])
|
||||
})
|
||||
|
||||
test('test_task_096_api_compat_normal_multiple_items', () => {
|
||||
const checker = createApiFieldCompatChecker({ schema: batchSchema })
|
||||
const rows = [
|
||||
{ taskId: 1, status: 'RUNNING' },
|
||||
{ taskId: 2, status: 'SUCCESS', progress: 100 },
|
||||
{ taskId: 3, status: 'FAILED', message: 'err' },
|
||||
]
|
||||
for (const row of rows) {
|
||||
assert.deepEqual(checker.check(row), [])
|
||||
}
|
||||
assert.equal(checker.stats().checkedCount, 3)
|
||||
assert.equal(checker.stats().violationCount, 0)
|
||||
// 顺序稳定:检查记录按输入顺序
|
||||
assert.deepEqual(checker.stats().checkedIds, [1, 2, 3])
|
||||
})
|
||||
|
||||
test('test_task_096_api_compat_normal_repeated_operation_is_idempotent', () => {
|
||||
const checker = createApiFieldCompatChecker({ schema: batchSchema })
|
||||
const row = { taskId: 1, status: 'SUCCESS' }
|
||||
assert.deepEqual(checker.check(row), [])
|
||||
assert.deepEqual(checker.check(row), [], '同一输入重复检查结果一致')
|
||||
assert.equal(checker.stats().checkedCount, 2)
|
||||
assert.equal(checker.stats().violationCount, 0)
|
||||
// 输入对象不被修改
|
||||
assert.equal('items' in row, false)
|
||||
// 无 schema 的纯遍历模式
|
||||
const free = createApiFieldCompatChecker({ schema: {} })
|
||||
assert.deepEqual(free.check({ anything: 1, other: 'x' }), [])
|
||||
})
|
||||
|
||||
test('test_task_096_api_compat_boundary_empty_input', () => {
|
||||
const checker = createApiFieldCompatChecker({ schema: batchSchema })
|
||||
// 空对象:必填字段缺失
|
||||
const violations = checker.check({})
|
||||
assert.equal(violations.length, 2)
|
||||
assert.ok(violations.some((v) => v.field === 'taskId' && v.kind === 'missing'))
|
||||
assert.ok(violations.some((v) => v.field === 'status' && v.kind === 'missing'))
|
||||
// 空数组响应:无字段可查,跳过
|
||||
const checker2 = createApiFieldCompatChecker({ schema: batchSchema })
|
||||
checker2.checkBatch([])
|
||||
assert.equal(checker2.stats().checkedCount, 0)
|
||||
assert.equal(checker2.stats().violationCount, 0)
|
||||
assert.equal(checker2.lastResult().passed, true)
|
||||
})
|
||||
|
||||
test('test_task_096_api_compat_boundary_single_item', () => {
|
||||
const checker = createApiFieldCompatChecker({ schema: batchSchema })
|
||||
const violations = checker.check({ taskId: 7, status: 'SUCCESS' })
|
||||
assert.deepEqual(violations, [], '单条最小合法记录通过')
|
||||
assert.equal(checker.checkedFields().includes('progress'), true, '可选字段也纳入检查清单')
|
||||
assert.equal(checker.lastResult().checkedCount, 1)
|
||||
})
|
||||
|
||||
test('test_task_096_api_compat_boundary_limit_and_overflow', () => {
|
||||
const checker = createApiFieldCompatChecker({ schema: batchSchema, maxChecked: 2 })
|
||||
checker.check({ taskId: 1, status: 'a' })
|
||||
checker.check({ taskId: 2, status: 'b' })
|
||||
checker.check({ taskId: 3, status: 'c' })
|
||||
assert.equal(checker.stats().checkedCount, 2, '超过 maxChecked 后不再检查')
|
||||
assert.equal(checker.stats().skippedCount, 1)
|
||||
assert.deepEqual(checker.stats().checkedIds, [1, 2])
|
||||
// 类型错误累计
|
||||
const checker2 = createApiFieldCompatChecker({ schema: batchSchema })
|
||||
checker2.check({ taskId: 'x' as never, status: 5 as never })
|
||||
assert.equal(checker2.stats().violationCount, 2)
|
||||
})
|
||||
|
||||
test('test_task_096_api_compat_invalid_input_rejected', () => {
|
||||
assert.throws(() => createApiFieldCompatChecker({} as never), /schema 必须是对象/)
|
||||
assert.throws(() => createApiFieldCompatChecker({ schema: null as never }), /schema 必须是对象/)
|
||||
assert.throws(() => createApiFieldCompatChecker({ schema: [] as never }), /schema 必须是对象/)
|
||||
assert.throws(
|
||||
() => createApiFieldCompatChecker({ schema: { a: { type: 'unknown' } } }),
|
||||
/不支持的字段类型: unknown/,
|
||||
)
|
||||
assert.throws(
|
||||
() => createApiFieldCompatChecker({ schema: batchSchema, maxChecked: 0 }),
|
||||
/maxChecked 必须为正数/,
|
||||
)
|
||||
const checker = createApiFieldCompatChecker({ schema: batchSchema })
|
||||
assert.throws(() => checker.check(null as never), /对象必须是普通对象/)
|
||||
assert.throws(() => checker.check([1, 2] as never), /对象必须是普通对象/)
|
||||
assert.throws(() => checker.checkBatch('x' as never), /对象数组必须是数组/)
|
||||
})
|
||||
|
||||
test('test_task_096_api_compat_dependency_failure_releases_resources', () => {
|
||||
// 检查对象读取失败:check 抛错但状态不被污染,恢复后可用
|
||||
let broken = false
|
||||
const checker = createApiFieldCompatChecker({ schema: batchSchema })
|
||||
const poisoned = new Proxy({ taskId: 1, status: 'SUCCESS' }, {
|
||||
get(target, prop, receiver) {
|
||||
if (broken && prop === 'taskId') throw new Error('row getter down')
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
})
|
||||
broken = true
|
||||
assert.throws(() => checker.check(poisoned), /row getter down/)
|
||||
assert.equal(checker.stats().checkedCount, 0, '失败不产生检查记录')
|
||||
broken = false
|
||||
assert.deepEqual(checker.check(poisoned), [], '依赖恢复后同一检查器继续可用')
|
||||
assert.equal(checker.stats().checkedCount, 1)
|
||||
})
|
||||
Reference in New Issue
Block a user