Skip to content

Commit a8ce11c

Browse files
fmontesclaude
andauthored
fix(block-editor): guard StoryBlock JSON parsing against non-object scalars (#35469)
Fixes regression from #35412: `/api/content/_search` throws `Cannot deserialize value of type LinkedHashMap from Integer` when a Story Block references a contentlet with scalar field values. ## Fix **1. JSON guard.** `JsonUtil.isValidJSON()` returns true for scalars (`42`, `"foo"`, `[1,2]`). Replaced with `isJsonObject()` (valid JSON + starts with `{`) at the 4 call sites feeding `toMap()`. HTML strings correctly skip refresh (no `{`). **2. Failure isolation.** One bad contentlet was killing the whole search response. Now: - `ContentletTransformer.refreshStoryBlockReferences` catches/logs — bad contentlet returns un-refreshed. - `StoryBlockAPIImpl.isRefreshed` isolates each child block. - `StoryBlockAPIImpl.refreshContentlet` isolates each field (falls back to raw value). ## QA Re-run QA from #35408 (PR #35412) and #35403 (PR #35413). Plus: via Content REST API, create a contentlet with **raw HTML** as the Block Editor field value (e.g. `<p>hello</p>`). Confirm create + update + `_search` + edit-UI all work without `MismatchedInputException` in logs. ## Tests - `test_refreshStoryBlockValueReferences_with_non_object_json_scalars` — numeric/string/boolean/array/HTML inputs - `test_refreshStoryBlockValueReferences_isolates_bad_child_block` — inner isolation - `test_refreshReferences_does_not_throw_on_malformed_nested_block` — API-level resilience --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 61d6d1a commit a8ce11c

3 files changed

Lines changed: 204 additions & 31 deletions

File tree

dotCMS/src/main/java/com/dotcms/contenttype/business/StoryBlockAPIImpl.java

Lines changed: 62 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ private int getCurrentDepthValue(final HttpServletRequest request) {
199199
@SuppressWarnings("unchecked")
200200
public StoryBlockReferenceResult refreshStoryBlockValueReferences(final Object storyBlockValue, final String parentContentletIdentifier) {
201201
boolean refreshed;
202-
if (null != storyBlockValue && JsonUtil.isValidJSON(storyBlockValue.toString())) {
202+
if (null != storyBlockValue && isJsonObject(storyBlockValue.toString())) {
203203
try {
204204
final LinkedHashMap<String, Object> blockEditorMap = this.toMap(storyBlockValue);
205205
final Object contentsMap = blockEditorMap.get(CONTENT_KEY);
@@ -227,15 +227,23 @@ private boolean isRefreshed(final String parentContentletIdentifier,
227227
boolean refreshed = false;
228228
for (final Map<String, Object> contentMap : contentsMap) {
229229
if (UtilMethods.isSet(contentMap)) {
230-
final String type = contentMap.get(TYPE_KEY).toString();
231-
if (allowedTypes.contains(type)) { // if somebody adds a story block to itself, we don't want to refresh it
230+
// Isolate per-block failures so that one bad nested reference
231+
// does not prevent the rest of the Story Block from refreshing.
232+
try {
233+
final String type = contentMap.get(TYPE_KEY).toString();
234+
if (allowedTypes.contains(type)) { // if somebody adds a story block to itself, we don't want to refresh it
232235

233-
refreshed |= this.refreshStoryBlockMap(contentMap, parentContentletIdentifier);
234-
} else {
235-
final Object nestedContent = contentMap.get(CONTENT_KEY);
236-
if (nestedContent instanceof List) {
237-
refreshed |= this.isRefreshed(parentContentletIdentifier, (List<Map<String, Object>>) nestedContent);
236+
refreshed |= this.refreshStoryBlockMap(contentMap, parentContentletIdentifier);
237+
} else {
238+
final Object nestedContent = contentMap.get(CONTENT_KEY);
239+
if (nestedContent instanceof List) {
240+
refreshed |= this.isRefreshed(parentContentletIdentifier, (List<Map<String, Object>>) nestedContent);
241+
}
238242
}
243+
} catch (final Exception e) {
244+
Logger.warnAndDebug(StoryBlockAPIImpl.class, String.format(
245+
"Skipping Story Block child while refreshing parent '%s': %s",
246+
parentContentletIdentifier, ExceptionUtil.getErrorMessage(e)), e);
239247
}
240248
}
241249
}
@@ -338,7 +346,7 @@ public List<StoryBlockDependency> getDependencies(final Object storyBlockValue)
338346

339347
try {
340348

341-
if (null != storyBlockValue && JsonUtil.isValidJSON(storyBlockValue.toString())) {
349+
if (null != storyBlockValue && isJsonObject(storyBlockValue.toString())) {
342350
final Map<String, Object> blockEditorMap = this.toMap(storyBlockValue);
343351
Object contentsMap = blockEditorMap.getOrDefault(CONTENT_KEY, List.of());
344352
if(!(contentsMap instanceof List)) {
@@ -570,6 +578,22 @@ private static void addDependencies(final ImmutableList.Builder<StoryBlockDepend
570578
}
571579
}
572580

581+
/**
582+
* Returns {@code true} when the supplied String is valid JSON whose root
583+
* token is an object. Story Block documents are always JSON objects, so
584+
* scalar JSON tokens (numbers, strings, booleans) and arrays must be
585+
* rejected here — otherwise {@link #toMap(Object)} fails to deserialize
586+
* them into a {@link LinkedHashMap} and the entire transformer pipeline
587+
* aborts (see issue surfaced via /api/content/_search).
588+
*/
589+
private static boolean isJsonObject(final String value) {
590+
if (value == null) {
591+
return false;
592+
}
593+
final String trimmed = value.trim();
594+
return trimmed.startsWith("{") && JsonUtil.isValidJSON(trimmed);
595+
}
596+
573597
@Override
574598
@SuppressWarnings("unchecked")
575599
public LinkedHashMap<String, Object> toMap(final Object blockEditorValue) throws JsonProcessingException {
@@ -746,25 +770,36 @@ private Map<String, Object> refreshContentlet(final Contentlet contentlet)
746770
for (final Field field : fields) {
747771
final Object value = contentlet.get(field.variable());
748772
if (null != value) {
749-
if (field instanceof StoryBlockField) {
750-
// At this depth, if the Contentlet inside the Block Editor also has a Block
751-
// Editor field, we'll return the raw JSON data of any potential Contentlets it
752-
// is referencing. This will prevent infinite recursion problems.
753-
// Prefer the _raw companion field when it contains valid JSON; otherwise fall
754-
// back to the story block value itself. If neither is valid JSON (e.g. a test
755-
// or misconfigured field whose default value is plain text), skip the field
756-
// entirely so the rest of the data map is still populated correctly.
757-
final Object rawValue = contentlet.get(field.variable() + "_raw");
758-
final String rawStr = rawValue != null ? rawValue.toString() : null;
759-
if (rawStr != null && JsonUtil.isValidJSON(rawStr)) {
760-
dataMap.put(field.variable(), this.toMap(rawValue));
761-
} else if (JsonUtil.isValidJSON(value.toString())) {
762-
dataMap.put(field.variable(), this.toMap(value));
773+
// Isolate per-field failures so that one malformed field on a
774+
// nested contentlet does not abort hydration of the rest.
775+
try {
776+
if (field instanceof StoryBlockField) {
777+
// At this depth, if the Contentlet inside the Block Editor also has a Block
778+
// Editor field, we'll return the raw JSON data of any potential Contentlets it
779+
// is referencing. This will prevent infinite recursion problems.
780+
// Prefer the _raw companion field when it contains valid JSON; otherwise fall
781+
// back to the story block value itself. If neither is valid JSON (e.g. a test
782+
// or misconfigured field whose default value is plain text), skip the field
783+
// entirely so the rest of the data map is still populated correctly.
784+
final Object rawValue = contentlet.get(field.variable() + "_raw");
785+
final String rawStr = rawValue != null ? rawValue.toString() : null;
786+
if (rawStr != null && isJsonObject(rawStr)) {
787+
dataMap.put(field.variable(), this.toMap(rawValue));
788+
} else if (isJsonObject(value.toString())) {
789+
dataMap.put(field.variable(), this.toMap(value));
790+
}
791+
} else {
792+
dataMap.putIfAbsent(field.variable(),
793+
this.refreshNestedStoryBlockValues(value, contentlet.getIdentifier(),
794+
MAX_NESTED_STORY_BLOCK_REFRESH_DEPTH));
763795
}
764-
} else {
765-
dataMap.putIfAbsent(field.variable(),
766-
this.refreshNestedStoryBlockValues(value, contentlet.getIdentifier(),
767-
MAX_NESTED_STORY_BLOCK_REFRESH_DEPTH));
796+
} catch (final Exception e) {
797+
Logger.warnAndDebug(StoryBlockAPIImpl.class, String.format(
798+
"Skipping field '%s' while hydrating contentlet '%s': %s",
799+
field.variable(), contentlet.getIdentifier(),
800+
ExceptionUtil.getErrorMessage(e)), e);
801+
// Fall back to the raw value so the field still appears in the response.
802+
dataMap.putIfAbsent(field.variable(), value);
768803
}
769804
}
770805
}

dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/ContentletTransformer.java

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.dotcms.contenttype.model.type.FileAssetContentType;
88
import com.dotcms.contenttype.transform.field.LegacyFieldTransformer;
99
import com.dotcms.util.ConversionUtils;
10+
import com.dotcms.exception.ExceptionUtil;
1011
import com.dotcms.util.transform.DBTransformer;
1112
import com.dotmarketing.beans.Host;
1213
import com.dotmarketing.beans.Identifier;
@@ -178,10 +179,22 @@ private static String replaceBadContentTypes(String jsonStringIn, String content
178179
* @param contentlet The {@link Contentlet} whose Story Block fields will be inspected.
179180
*/
180181
private static void refreshStoryBlockReferences(final Contentlet contentlet) {
181-
final StoryBlockReferenceResult result = APILocator.getStoryBlockAPI().refreshReferences(contentlet);
182-
if (result.isRefreshed()) {
183-
Logger.debug(ContentletTransformer.class,
184-
()-> "Refreshed story block dependencies for the contentlet: " + contentlet.getIdentifier());
182+
try {
183+
final StoryBlockReferenceResult result = APILocator.getStoryBlockAPI().refreshReferences(contentlet);
184+
if (result.isRefreshed()) {
185+
Logger.debug(ContentletTransformer.class,
186+
() -> "Refreshed story block dependencies for the contentlet: " + contentlet.getIdentifier());
187+
}
188+
} catch (final Exception e) {
189+
// Story Block hydration is a best-effort enrichment. A single bad
190+
// contentlet (e.g. malformed JSON, scalar-only field values, broken
191+
// reference) must not abort the entire transform and take down the
192+
// surrounding /api/content/_search response.
193+
Logger.warnAndDebug(ContentletTransformer.class, String.format(
194+
"Failed to refresh Story Block references for contentlet '%s' (inode '%s'); "
195+
+ "returning the contentlet with un-refreshed Story Block data: %s",
196+
contentlet.getIdentifier(), contentlet.getInode(),
197+
ExceptionUtil.getErrorMessage(e)), e);
185198
}
186199
}
187200

dotcms-integration/src/test/java/com/dotcms/contenttype/business/StoryBlockAPITest.java

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,131 @@ public void test_refreshStoryBlockValueReferences_with_json_value() {
601601

602602
}
603603

604+
/**
605+
* Method to test: {@link StoryBlockAPI#refreshStoryBlockValueReferences(Object, String)}
606+
* Given Scenario: A non-object JSON scalar (number, string, boolean, array) is passed in —
607+
* these are valid JSON tokens but are not Story Block documents. They can reach this method
608+
* via {@code refreshNestedStoryBlockValues} when iterating over scalar field values on
609+
* related contentlets.
610+
* ExpectedResult: No exception must be thrown and the original value must be returned
611+
* unchanged. Regression test for "/api/content/_search failing with
612+
* MismatchedInputException: Cannot deserialize value of type LinkedHashMap from Integer".
613+
*/
614+
@Test
615+
public void test_refreshStoryBlockValueReferences_with_non_object_json_scalars() {
616+
final StoryBlockAPI storyBlockAPI = APILocator.getStoryBlockAPI();
617+
618+
// Bare integer — valid JSON, but not an object. Was the trigger of the original bug.
619+
StoryBlockReferenceResult result = storyBlockAPI.refreshStoryBlockValueReferences("42", "parent-id");
620+
assertNotNull(result);
621+
assertFalse(result.isRefreshed());
622+
assertEquals("42", result.getValue());
623+
624+
// Bare quoted string — also valid JSON.
625+
result = storyBlockAPI.refreshStoryBlockValueReferences("\"hello\"", "parent-id");
626+
assertNotNull(result);
627+
assertFalse(result.isRefreshed());
628+
629+
// Bare boolean.
630+
result = storyBlockAPI.refreshStoryBlockValueReferences("true", "parent-id");
631+
assertNotNull(result);
632+
assertFalse(result.isRefreshed());
633+
634+
// JSON array — valid JSON, but not a Story Block document object.
635+
result = storyBlockAPI.refreshStoryBlockValueReferences("[1,2,3]", "parent-id");
636+
assertNotNull(result);
637+
assertFalse(result.isRefreshed());
638+
639+
// Untransformed HTML body content — not JSON at all, must be returned untouched.
640+
final String html = "<p>Hello <strong>world</strong></p>";
641+
result = storyBlockAPI.refreshStoryBlockValueReferences(html, "parent-id");
642+
assertNotNull(result);
643+
assertFalse(result.isRefreshed());
644+
assertEquals(html, result.getValue());
645+
}
646+
647+
/**
648+
* Method to test: {@link StoryBlockAPI#refreshStoryBlockValueReferences(Object, String)}
649+
* Given Scenario: A Story Block document that contains two children, where the first
650+
* child is malformed (missing the {@code type} key, which would have caused a
651+
* NullPointerException inside {@code isRefreshed}). The second child is well-formed.
652+
* ExpectedResult: No exception is propagated. The bad child is skipped and the call
653+
* still returns a non-null result so the surrounding contentlet (and the rest of the
654+
* search response) is not aborted by a single bad nested reference.
655+
*/
656+
/**
657+
* Method to test: {@link StoryBlockAPI#refreshReferences(Contentlet)}
658+
* Given Scenario: A contentlet has a Story Block field whose value contains a malformed
659+
* nested child (missing the {@code type} key).
660+
* ExpectedResult: refreshReferences must complete normally (no thrown exception). This is
661+
* the resilience boundary that prevents one bad contentlet from aborting an entire
662+
* /api/content/_search response when ContentletTransformer iterates over the result set.
663+
*/
664+
@Test
665+
public void test_refreshReferences_does_not_throw_on_malformed_nested_block()
666+
throws DotDataException, DotSecurityException {
667+
ContentType storyBlockType = null;
668+
try {
669+
// Reuse an existing helper pattern: any content type with a Story Block field.
670+
final long timestamp = System.currentTimeMillis();
671+
storyBlockType = new ContentTypeDataGen()
672+
.name("storyBlockResilience" + timestamp)
673+
.velocityVarName("storyBlockResilience" + timestamp)
674+
.nextPersisted();
675+
final Field storyBlockField = new FieldDataGen()
676+
.type(StoryBlockField.class)
677+
.contentTypeId(storyBlockType.id())
678+
.nextPersisted();
679+
680+
final String malformedStoryBlock =
681+
"{"
682+
+ "\"type\":\"doc\","
683+
+ "\"attrs\":{},"
684+
+ "\"content\":["
685+
+ " {\"attrs\":{\"data\":{\"identifier\":\"missing-type-key\"}}}"
686+
+ "]"
687+
+ "}";
688+
689+
final Contentlet contentlet = new ContentletDataGen(storyBlockType.id())
690+
.languageId(APILocator.getLanguageAPI().getDefaultLanguage().getId())
691+
.setProperty(storyBlockField.variable(), malformedStoryBlock)
692+
.nextPersisted();
693+
694+
try {
695+
APILocator.getStoryBlockAPI().refreshReferences(contentlet);
696+
} catch (final Throwable t) {
697+
Assert.fail("refreshReferences must not propagate exceptions for a single "
698+
+ "malformed Story Block: " + t.getMessage());
699+
}
700+
} finally {
701+
if (storyBlockType != null) {
702+
ContentTypeDataGen.remove(storyBlockType);
703+
}
704+
}
705+
}
706+
707+
@Test
708+
public void test_refreshStoryBlockValueReferences_isolates_bad_child_block() {
709+
final String storyBlockWithBadChild =
710+
"{"
711+
+ "\"type\":\"doc\","
712+
+ "\"attrs\":{},"
713+
+ "\"content\":["
714+
+ " {\"attrs\":{\"data\":{\"identifier\":\"missing-type-key\"}}},"
715+
+ " {\"type\":\"paragraph\",\"content\":[]}"
716+
+ "]"
717+
+ "}";
718+
719+
StoryBlockReferenceResult result = null;
720+
try {
721+
result = APILocator.getStoryBlockAPI()
722+
.refreshStoryBlockValueReferences(storyBlockWithBadChild, "parent-resilience");
723+
} catch (final Throwable t) {
724+
Assert.fail("A malformed nested block must not abort the parent refresh: " + t.getMessage());
725+
}
726+
assertNotNull(result);
727+
}
728+
604729
/**
605730
* Method to test: {@link StoryBlockAPI#refreshReferences(Contentlet)}
606731
* Given Scenario: This will create 2 block contents, adds a rich content to each block content and retrieve the json.

0 commit comments

Comments
 (0)