diff --git a/spring-batch-infrastructure/src/main/java/org/springframework/batch/infrastructure/item/json/JacksonJsonObjectReader.java b/spring-batch-infrastructure/src/main/java/org/springframework/batch/infrastructure/item/json/JacksonJsonObjectReader.java index 99bf8d9b97..860e403f71 100644 --- a/spring-batch-infrastructure/src/main/java/org/springframework/batch/infrastructure/item/json/JacksonJsonObjectReader.java +++ b/spring-batch-infrastructure/src/main/java/org/springframework/batch/infrastructure/item/json/JacksonJsonObjectReader.java @@ -97,7 +97,7 @@ public void open(Resource resource) throws Exception { public @Nullable T read() throws Exception { try { if (this.jsonParser.nextToken() == JsonToken.START_OBJECT) { - return this.mapper.readValue(this.jsonParser, this.itemType); + return readObject(); } } catch (JacksonException e) { @@ -106,6 +106,31 @@ public void open(Resource resource) throws Exception { return null; } + @SuppressWarnings("DataFlowIssue") + private T readObject() { + int objectDepth = this.jsonParser.streamReadContext().getNestingDepth(); + try { + return this.mapper.readValue(this.jsonParser, this.itemType); + } + catch (JacksonException e) { + // A failed mapping leaves the cursor inside the malformed object, where the + // next read would find a token other than START_OBJECT and report the end of + // the input. Skipping to the end of the object keeps the remaining items + // readable, so a fault-tolerant step can skip this one and carry on. + skipToEndOfObject(objectDepth); + throw e; + } + } + + @SuppressWarnings("DataFlowIssue") + private void skipToEndOfObject(int objectDepth) { + while (this.jsonParser.streamReadContext().getNestingDepth() >= objectDepth) { + if (this.jsonParser.nextToken() == null) { + return; + } + } + } + @Override public void close() throws Exception { if (inputStream != null) { diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/infrastructure/item/json/JacksonJsonObjectReaderTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/infrastructure/item/json/JacksonJsonObjectReaderTests.java new file mode 100644 index 0000000000..3fb55a578e --- /dev/null +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/infrastructure/item/json/JacksonJsonObjectReaderTests.java @@ -0,0 +1,114 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.batch.infrastructure.item.json; + +import org.junit.jupiter.api.Test; + +import org.springframework.batch.infrastructure.item.ParseException; +import org.springframework.batch.infrastructure.item.json.domain.Trade; +import org.springframework.core.io.ByteArrayResource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * @author Donghwan Kim + */ +class JacksonJsonObjectReaderTests { + + @Test + void testReadObjectAfterParseException() throws Exception { + // given + String json = """ + [ + {"isin":"AAA","quantity":1,"price":1.0,"customer":"foo"}, + {"isin":"BBB","quantity":"not a number","price":2.0,"customer":"bar"}, + {"isin":"CCC","quantity":3,"price":3.0,"customer":"baz"} + ] + """; + JacksonJsonObjectReader objectReader = new JacksonJsonObjectReader<>(Trade.class); + objectReader.open(new ByteArrayResource(json.getBytes())); + + // when + Trade firstItem = objectReader.read(); + assertThrows(ParseException.class, objectReader::read); + Trade thirdItem = objectReader.read(); + + // then + assertNotNull(firstItem); + assertEquals("AAA", firstItem.getIsin()); + assertNotNull(thirdItem); + assertEquals("CCC", thirdItem.getIsin()); + + objectReader.close(); + } + + @Test + void testReadObjectAfterParseExceptionInObjectWithNestedObject() throws Exception { + // given + String json = """ + [ + {"isin":"AAA","quantity":1,"price":1.0,"customer":"foo"}, + {"isin":"BBB","quantity":"not a number","price":2.0,"customer":"bar", + "details":{"origin":{"country":"FR"}}}, + {"isin":"CCC","quantity":3,"price":3.0,"customer":"baz"} + ] + """; + JacksonJsonObjectReader objectReader = new JacksonJsonObjectReader<>(Trade.class); + objectReader.open(new ByteArrayResource(json.getBytes())); + + // when + Trade firstItem = objectReader.read(); + assertThrows(ParseException.class, objectReader::read); + Trade thirdItem = objectReader.read(); + + // then + assertNotNull(firstItem); + assertEquals("AAA", firstItem.getIsin()); + assertNotNull(thirdItem); + assertEquals("CCC", thirdItem.getIsin()); + + objectReader.close(); + } + + @Test + void testReadAfterParseExceptionInLastObject() throws Exception { + // given + String json = """ + [ + {"isin":"AAA","quantity":1,"price":1.0,"customer":"foo"}, + {"isin":"BBB","quantity":"not a number","price":2.0,"customer":"bar"} + ] + """; + JacksonJsonObjectReader objectReader = new JacksonJsonObjectReader<>(Trade.class); + objectReader.open(new ByteArrayResource(json.getBytes())); + + // when + Trade firstItem = objectReader.read(); + assertThrows(ParseException.class, objectReader::read); + + // then + assertNotNull(firstItem); + assertEquals("AAA", firstItem.getIsin()); + assertNull(objectReader.read()); + + objectReader.close(); + } + +}