feat(search): route /api/es/raw & /api/es/search through phase-aware SearchAPI (#36396)#36398
feat(search): route /api/es/raw & /api/es/search through phase-aware SearchAPI (#36396)#36398fabrizzio-dotCMS wants to merge 5 commits into
Conversation
ESContentResourcePortlet.searchRaw() (POST /api/es/raw) called the deprecated ContentletAPI.esSearchRaw(), which is hardcoded to the Elasticsearch client and bypasses the phase-aware router from #35609. It always read from ES regardless of the OpenSearch migration phase and would break entirely once ES is decommissioned in Phase 3. - Route through the phase-aware ContentletAPI.searchRaw() (delegates to SearchAPI.searchRaw()) so the endpoint targets the engine selected by the active migration phase (ES in phases 0-1, OS in phases 2-3). - Serialize the vendor-neutral ContentSearchResponse via the dotCMS default Jackson mapper instead of emitting the ES wire format through SearchResponse.toString(). The response shape changes to the neutral DTO (hits, scrollId, tookMillis, aggregationTree) — documented in the Swagger annotations, the regenerated openapi.yaml, and the es-search help JSP. - Remove the FIXME(OS-cutover) comment now that the migration landed. - Add a unit test pinning the neutral serialization contract using the exact mapper the endpoint uses (guards the SearchHits Iterable risk). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @fabrizzio-dotCMS's task in 7m 33s —— View job Rollback-Safety Analysis Complete
Result: 🟡 MEDIUM — M-3 (REST API Contract Change)
No CRITICAL/HIGH categories apply — no database migrations, ES mapping changes, or structural storage changes are present in this diff. |
🤖 dotBot Review (Bedrock)Reviewed 8 file(s); 5 candidate(s) → 5 confirmed, 0 uncertain (unverified, kept for review). Confirmed findings
us.deepseek.r1-v1:0 · Run: #28637200530 · tokens: in: 62154 · out: 11617 · total: 73771 · calls: 16 · est. ~$0.147 |
…ES-wire back-compat Migrates ESContentResourcePortlet.search() (POST /api/es/search) off the deprecated ContentletAPI.esSearch() (hardcoded to Elasticsearch) onto the phase-aware, vendor-neutral SearchAPI, so the endpoint observes the engine the active OpenSearch migration phase selects (ES in phases 0-1, OS in phases 2-3) and keeps working once ES is decommissioned in Phase 3. Mirrors the /api/es/raw migration already in this PR. Backward compatibility (no client/UI breakage): - Legacy Elasticsearch-wire adapter (toLegacyEsJson) rebuilds the "esresponse" field from the neutral ContentSearchResponse (took, hits.total, hits.hits[]._id/._index/._score/._source, aggregations with ES typed-keys, suggest). Preserves the contract the dot-es-search Angular portlet and external clients depend on; isolated in the resource so the neutral DTO stays clean. - Velocity/back-compat accessors on ContentSearchResponse (getHits/getScrollId/getTookInMillis/ getAggregations/getSuggest) and TotalHits (getValue) so $dotcontent.raw(...) templates using ES-style property syntax keep resolving. All @JsonIgnore or same-value merges, so the neutral JSON shape of /api/es/raw is unchanged (guarded by AggregationDomainTest). suggest support: - New neutral suggest field on ContentSearchResponse, populated from both ES (from(Suggest)) and OS (from the term/phrase/completion union) so the Suggestions tab has parity across engines. Tests: - ESContentResourcePortletTest#test_search_esresponse_preservesLegacyEsWireShape pins the ES-wire esresponse contract. - ContentSearchToolTest adds hit/timing accessor coverage (search + raw) and a legacy property-syntax test proving the compat accessors resolve. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Pull Request Unsafe to Rollback!!!
|
…sertion
test_search_esresponse_preservesLegacyEsWireShape asserted the aggregation under the plain
name "types", but the legacy adapter emits the ES-native typed key ("sterms#types") that the
dot-es-search portlet's splitAggKey() parses. Match either the typed key or the plain name and
still require the legacy "buckets" array. Fixes the lone failure in the local IT run
(ContentSearchToolTest was already 9/9 green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ES suggestFrom() traversal only uses raw types (no unchecked generic operations), so @SuppressWarnings("unchecked") was redundant. Keep only "rawtypes". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| * Velocity/back-compat alias for {@link #value()}. Elasticsearch's {@code TotalHits.value} was a | ||
| * public field, so legacy VTL walked {@code $r.hits.totalHits.value}; that resolves via this | ||
| * getter on the record. Returns the same value as the {@code value} component, so Jackson merges | ||
| * it into the existing {@code value} field and the JSON shape is unchanged. |
There was a problem hiding this comment.
🔴 [Critical] Jackson serialization conflict between value() and getValue() causes duplicate field
The TotalHits record has both a value() method (generated by the record) and a custom getValue() method. Jackson will serialize both as 'value' fields, causing a JsonMappingException due to duplicate property names. This breaks the API response serialization.
There was a problem hiding this comment.
Invalid — false positive.
value() (record component, annotated @JsonProperty("value")) and getValue() both map to the same logical property name value, so Jackson merges them into a single accessor — it does not emit two value fields nor throw a duplicate/conflicting-getter error.
Verified with the exact default ObjectMapper the endpoint uses: AggregationDomainTest#contentSearchResponse_jacksonSerializesNeutralShapeForEsRawEndpoint serializes ContentSearchResponse → SearchHits → TotalHits and asserts totalHits is present — 12/12 green (re-confirmed after getValue() was added). No JsonMappingException.
getValue() is required for Velocity backward compatibility ($dotcontent.raw().hits.totalHits.value, which was a public field on ES TotalHits). Removing it or adding @JsonIgnore would break that (or drop the value field entirely).
| final ContentSearchResults<Contentlet> esresult = esapi | ||
| .search(esQuery.toString(), isAnonymous ? mode.showLive : liveParam, userForSearch, | ||
| mode.respectAnonPerms); | ||
|
|
There was a problem hiding this comment.
🟠 [High] Uncaught JSON serialization exception leaks stack traces
The call to objectMapper.writeValueAsString(contentSearchResponse) at line 137 can throw JsonProcessingException, which is not caught in the try block. This would propagate as an unhandled exception, exposing internal stack traces via API responses. The endpoint needs explicit error handling around the serialization call to return controlled error responses.
There was a problem hiding this comment.
Invalid — false positive.
The writeValueAsString(...) call is inside a try { ... } catch (Exception e) block that returns responseResource.responseError(e.getMessage()). JsonProcessingException extends Exception, so it is caught and turned into a controlled error response carrying only the message — not an unhandled exception and not a stack trace. The same applies to /api/es/search (toLegacyEsJson() throws JSONException, caught by the method-level catch (Exception)).
(The referenced line 137 does not correspond to the writeValueAsString call in searchRaw() — likely a stale diff position.)
|
test2 |
…le field Adds a dedicated unit test pinning that TotalHits serializes 'value' exactly once with the default mapper — the record accessor value() (@JsonProperty("value")) and the Velocity back-compat getter getValue() merge into one property, no duplicate/conflicting-getter error. Directly guards the (invalid) "duplicate field" review finding. AggregationDomainTest 13/13. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| mode.respectAnonPerms); | ||
|
|
||
| final JSONObject json = new JSONObject(); | ||
| final JSONObject json = new JSONObject(); |
There was a problem hiding this comment.
🔴 [Critical] Clearing contentlets list after adding to response map causes empty array in response
The code adds esresult.getContentlets() to the response map, then immediately clears the list. Since Java collections are reference-based, this clears the contentlets array in the response map, resulting in an empty contentlets array being returned to clients. The responseMap.put() stores a reference to the same list object that gets cleared, making API responses lose all contentlet data.
|
Pull Request Unsafe to Rollback!!!
Note: no database migrations, Elasticsearch mapping changes, or structural storage changes are present in this diff (confirmed via |
Proposed Changes
Fixes #36396 · addresses the
/api/es/rawphase-bypass finding in #35752 (and extends the same fix to/api/es/search)Both backend Elasticsearch REST endpoints in
ESContentResourcePortletcalled deprecated, ES-hardcodedContentletAPImethods that bypassed the phase-aware router introduced in #35609. They always read from Elasticsearch regardless of the active OpenSearch migration phase — could not validate Phase 2/3 read routing (caused the false TC-028 failure in QA #35752) and would break entirely once ES is decommissioned in Phase 3.POST /api/es/rawesapi.esSearchRaw()→ hardcoded ESContentletAPI.searchRaw()→SearchAPIPOST /api/es/searchesapi.esSearch()→ hardcoded ESContentletAPI.search()→SearchAPIBoth now observe whichever engine the active phase selects (ES in phases 0–1, OS in phases 2–3) and keep working after ES is decommissioned in Phase 3. The
FIXME(OS-cutover)is gone./api/es/raw— neutral response (documented shape change)Routes through
searchRaw()and serializes the vendor-neutralContentSearchResponsewith the dotCMS defaultObjectMapper(no ES wire format). This endpoint is a low-level query/aggregation API used via curl; the ES Search backend portlet UI uses/api/portlet/es-search/render, not this endpoint, so the shape change is safe.hitshits[](each:id,index,sourceAsMap,score,fields),totalHits,hasErrorscrollIdnulltookMillisaggregationTree/api/es/search— phase-aware with full backward compatibilityUnlike
/api/es/raw,/api/es/searchhas a live consumer: thedot-es-searchAngular portlet (Results/Raw/Aggregations/Suggestions tabs) parses the ES-wire shape of theesresponsefield. To route through the neutralSearchAPIwithout breaking that portlet or external clients, the ES-wire shape is preserved at the edge:ESContentResourcePortlet#toLegacyEsJson) rebuildsesresponsefrom the neutral DTO:took,hits.total.value,hits.hits[]._id/._index/._score/._source,aggregations(ES-native typed keys e.g.sterms#content_types), andsuggest. Isolated in the resource so the shared neutral DTO stays clean; removable at R7. Thecontentletsarray is unchanged (built fromContentletobjects).ContentSearchResponse(getHits/getScrollId/getTookInMillis/getAggregations/getSuggest) andTotalHits(getValue) so$dotcontent.raw(...)/$ESContenttemplates using ES-style property syntax keep resolving. All@JsonIgnoreor same-value merges → the/api/es/rawneutral JSON shape is unchanged (guarded byAggregationDomainTest).suggestis now a neutral field onContentSearchResponse, populated from both ES (from(Suggest)) and OS (term/phrase/completion union) → the Suggestions tab has parity across engines.Testing
ESContentResourcePortletTest— 9/9 (incl.test_search_esresponse_preservesLegacyEsWireShapepinning the ES-wireesresponsecontract)ContentSearchToolTest— 9/9 (Velocitysearch()/raw()hit+timing accessors, and a legacy property-syntax test proving the compat accessors resolve)AggregationDomainTest— 12/12 (pins the neutral/api/es/rawJSON; confirms the compat getters +suggestfield did not change it)../mvnw compile -pl :dotcms-core— BUILD SUCCESS (Java 25);openapi.yamlregenerated for both endpoints.Checklist
/api/es/rawroutes through phase-awaresearchRaw(); neutral response documented/api/es/searchroutes through phase-awaresearch(); ES-wireesresponsepreserved via adapter (no portlet/client breakage)/api/es/rawJSON shape unchanged (guarded by test)suggestcarried on the neutral DTO from both ES and OSFIXME(OS-cutover)removed on both paths; works in Phase 3 with ES decommissionedopenapi.yamlregenerated and committedPart of the OpenSearch migration epic #35476. Builds on #35609.
🤖 Generated with Claude Code