Skip to content

feat: add ValidationHandler to JSONSchema for fail-safe error collection, for issue #3912#3968

Open
jujn wants to merge 4 commits intoalibaba:mainfrom
jujn:feat_3912
Open

feat: add ValidationHandler to JSONSchema for fail-safe error collection, for issue #3912#3968
jujn wants to merge 4 commits intoalibaba:mainfrom
jujn:feat_3912

Conversation

@jujn
Copy link
Collaborator

@jujn jujn commented Jan 28, 2026

What this PR does / why we need it?

在 JSONSchema 加入 ValidationHandler 以支持全量错误收集(已支持全量收集自定义错误信息),通过回调机制允许用户控制校验流程(中断/继续)。

代码示例:

使用示例①
    public void test() {
        // 1. 定义 Schema (校验规则)
        String schemaStr = "{\n" +
                "  \"type\": \"object\",\n" +
                "  \"properties\": {\n" +
                "    \"username\": { \"type\": \"string\", \"minLength\": 5 },\n" +
                "    \"age\": { \"type\": \"integer\", \"minimum\": 18 },\n" +
                "    \"email\": { \"type\": \"string\", \"format\": \"email\" }\n" +
                "  },\n" +
                "  \"required\": [\"username\", \"email\"]\n" +
                "}";

        JSONSchema schema = JSONSchema.parseSchema(schemaStr);

        // 2. 构造一个包含 3 个错误的 数据
        JSONObject badData = JSONObject.of(
                "username", "cat",         // 错误1: 长度小于 5
                "age", "too young",        // 错误2: 类型错误 (String 而不是 Integer)
                "email", "not-an-email"    // 错误3: 格式不对
        );

        // 3. 准备一个容器来装错误
        List<String> errorList = new ArrayList<>();

        // 4. 定义 Handler
        JSONSchema.ValidationHandler handler = (parentSchema, value, message, path) -> {
            String log = String.format("字段 [%s] 校验失败: %s (当前值: %s)", path, message, value);
            errorList.add(log);
            return true; // 返回 true 继续校验,false 则停止
        };

        // 5. 执行校验
        schema.validate(badData, handler);

        // 6. 输出结果
        for (String err : errorList) {
            System.out.println(err);
        }
    }
使用示例②(Web请求校验)
@ExtendWith(SpringExtension.class)
@WebAppConfiguration
@ContextConfiguration
public class Issue3912 {
    @Autowired
    private WebApplicationContext wac;

    private MockMvc mockMvc;

    @BeforeEach
    public void setup() {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac)
                .addFilter(new CharacterEncodingFilter("UTF-8", true))
                .build();
    }

    /**
     * 测试场景 : 数据包含多个错误,应该返回 400 Bad Request,并包含所有错误信息
     * 错误 1: username 长度不够 (min 5)
     * 错误 2: age 未满 18 (min 18)
     */
    @Test
    public void testValidationFail() throws Exception {
        String requestJson = "{\"username\":\"a\",\"age\":10}";
        mockMvc.perform(
                        (post("/issue3912")
                                .characterEncoding("UTF-8")
                                .contentType(MediaType.APPLICATION_JSON_VALUE)
                                .content(requestJson)
                        ))
                .andExpect(status().isBadRequest())
                .andExpect(content().string(containsString("用户名长度不能小于5")))
                .andExpect(content().string(containsString("必须年满18岁")));
    }

    @RestController
    public static class TestController {
        @PostMapping(value = "/issue3912", produces = "application/json")
        public String issue3912(@RequestBody UserBean user) {
            return "success";
        }
    }

    @Data
    public static class UserBean {
        private String username;
        private Integer age;
    }

    /**
     * 自定义异常,用于携带校验错误信息
     */
    public static class SchemaValidationException extends RuntimeException {
        private final List<String> errors;

        public SchemaValidationException(List<String> errors) {
            this.errors = errors;
        }

        public List<String> getErrors() {
            return errors;
        }
    }

    /**
     * 在 JSON 反序列化成对象后,Controller 执行前,进行 Schema 校验
     */
    @RestControllerAdvice
    public static class SchemaValidationAdvice extends RequestBodyAdviceAdapter {
        private static final JSONSchema USER_SCHEMA = JSONSchema.parseSchema("{\n" +
                "  \"type\": \"object\",\n" +
                "  \"properties\": {\n" +
                "    \"username\": {\n" +
                "      \"type\": \"string\",\n" +
                "      \"minLength\": 5,\n" +
                "      \"error\": \"用户名长度不能小于5\"\n" + // 自定义错误消息
                "    },\n" +
                "    \"age\": {\n" +
                "      \"type\": \"integer\",\n" +
                "      \"minimum\": 18,\n" +
                "      \"error\": \"必须年满18岁\"\n" + // 自定义错误消息
                "    }\n" +
                "  }\n" +
                "}");

        @Override
        public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
            return targetType == UserBean.class; // 仅拦截 UserBean
        }

        @Override
        public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
            if (body instanceof UserBean) {
                List<String> errors = new ArrayList<>();

                USER_SCHEMA.validate(body, (schema, value, message, path) -> {
                    errors.add(path + ": " + message);
                    return true;
                });

                if (!errors.isEmpty()) {
                    throw new SchemaValidationException(errors);
                }
            }
            return body;
        }
    }

    @RestControllerAdvice
    public static class GlobalExceptionHandler {
        @ExceptionHandler(SchemaValidationException.class)
        public ResponseEntity<Map<String, Object>> handleException(SchemaValidationException ex) {
            Map<String, Object> body = new HashMap<>();
            body.put("errors", ex.getErrors());
            return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
        }
    }

    @ComponentScan(basePackages = "com.alibaba.fastjson2.spring.issues.issue3912")
    @Configuration
    @Order(Ordered.LOWEST_PRECEDENCE + 1)
    @EnableWebMvc
    public static class WebMvcConfig implements WebMvcConfigurer {
        @Override
        public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
            FastJsonHttpMessageConverter fastConverter = new FastJsonHttpMessageConverter();
            FastJsonConfig fastJsonConfig = new FastJsonConfig();
            fastConverter.setFastJsonConfig(fastJsonConfig);
            fastConverter.setDefaultCharset(StandardCharsets.UTF_8);
            List<MediaType> supportedMediaTypes = new ArrayList<>();
            supportedMediaTypes.add(MediaType.APPLICATION_JSON);
            fastConverter.setSupportedMediaTypes(supportedMediaTypes);
            converters.add(0, fastConverter);
        }
    }
}

Summary of your change

  1. 接口定义 (ValidationHandler):
  • 定义 handle(schema, value, message, path) 方法。
  • 返回 true 表示“已处理,可继续校验后续节点”,用户可以在回调中收集 message 错误信息;返回 false 表示“立即中断”(同 schema 校验默认逻辑)。
  1. 基类改动 (JSONSchema):
  • 新增 validate(value, handler) 入口方法。
  • 新增 handleError 辅助方法,封装“应用 customErrorMessage -> 调用 handler -> 处理中断”通用逻辑。
  1. 叶节点改动 (StringSchema、IntegerSchema……):
  • 当发现错误时,不再直接 return,而是调用 handleError。
  1. 容器节点改动 (ObjectSchema,、ArraySchema):
  • 当收到子节点返回的失败结果时,检查 handler 和 result.isAbort() 。
  • Fail-Fast:如果 isAbort() 为 true,立即向上层 return,中断整个校验树的遍历。
  • Fail-Safe:如果 isAbort() 为 false,说明用户希望继续。容器节点会记录当前失败状态(totalSuccess = false),然后继续循环去校验下一个节点,从而收集所有错误。
  1. 组合逻辑改动 (AnyOf、OneOf、AllOf 等):
  • 逻辑同容器、叶子节点。
  1. 具体改动逻辑**(供 review 参考)**:
  • 改动针对所有返回 ValidateResult 的地方
  • 如果原代码返回 new ValidateResult 或静态常量 ValidateResult,则 return handleError(…) (容器节点中需要判断 handler==null 和 result.isAbort,决定中断/继续)
  • 如果原代码返回 子scheme.validateInternal()得到的result,只需判断 handler==null 和 result.isAbort,决定中断/继续

@safe1ine
Copy link

safe1ine commented Feb 6, 2026

@MonkeyCode-AI review 一下

@MonkeyCode-AI
Copy link

MonkeyCode-AI 正在分析任务...

Copy link

@MonkeyCode-AI MonkeyCode-AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

我是 MonkeyCode AI 编程助手,你可以在 GitHub 仓库的 PR 中 at @MonkeyCode-AI 来呼唤我。

任务执行细节请参考: https://monkeycode-ai.com/tasks/public?id=51e2b2b6-be8e-42e2-82bd-298457dcc212

代码审查结果

本 PR 通过引入 ValidationHandler 让 Fail-Safe/Fail-Fast 并存且测试较完善,但仍存在组合校验绕过 handler、错误封装不一致与可诊断性不足等问题,建议修复后再合入。

✨ 代码亮点

  • 通过 ValidationHandler + abort 标志实现 Fail-Safe / Fail-Fast 双模式,API 简洁直观
  • JSONSchema.handleError 将 custom error message 解析与 handler 回调集中封装,降低各 Schema 重复逻辑
  • 新增 Issue3912 单元测试覆盖了收集全部错误、提前中断、嵌套对象/数组、dependentRequired、additionalProperties、uniqueItems 等关键场景
🚨 Critical ⚠️ Warning 💡 Suggestion
1 0 0

Comment on lines 180 to 191
if (anyOf != null) {
ValidateResult result = anyOf.validate(str);
ValidateResult result = anyOf.validateInternal(str, handler, path);
if (!result.isSuccess()) {
return result;
}
}

if (oneOf != null) {
ValidateResult result = oneOf.validate(str);
ValidateResult result = oneOf.validateInternal(str, handler, path);
if (!result.isSuccess()) {
return result;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

🚨 StringSchema 对 anyOf/oneOf 子校验失败时直接 return,可能绕过 handler(无法收集错误)

StringSchema 在处理 anyOf/oneOf 时调用 validateInternal(str, handler, path),但失败时直接 return result,没有确保 handler 一定被触发。

另外 anyOf/oneOf 内部常用 validateInternal(value, null, path) 做探测(PR 中也如此),因此即便 StringSchema 传入 handler,失败结果也可能来自“handler 被刻意屏蔽的探测分支”,导致 validate(...) 返回失败但 handler 未收到回调,违背全量错误收集目标。类似风险可能也存在于其他叶子节点对组合 schema 的调用路径。

建议: 对 anyOf/oneOf 这类组合节点,建议统一由组合节点自身在失败时调用 handleError(handler, ...) 触发回调;或在 StringSchema 这里对失败 result 做一次 handleError 包装,确保 handler 一定会收到错误事件(并尊重 result.isAbort())。

Suggested change
if (anyOf != null) {
ValidateResult result = anyOf.validate(str);
ValidateResult result = anyOf.validateInternal(str, handler, path);
if (!result.isSuccess()) {
return result;
}
}
if (oneOf != null) {
ValidateResult result = oneOf.validate(str);
ValidateResult result = oneOf.validateInternal(str, handler, path);
if (!result.isSuccess()) {
return result;
}
if (anyOf != null) {
ValidateResult result = anyOf.validateInternal(str, handler, path);
if (!result.isSuccess()) {
return handler == null ? result : handleError(handler, value, path, result);
}
}
if (oneOf != null) {
ValidateResult result = oneOf.validateInternal(str, handler, path);
if (!result.isSuccess()) {
return handler == null ? result : handleError(handler, value, path, result);
}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants