Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
966fca0
feat(opensearch): extract vendor-neutral SearchAPI and phase-aware ro…
fabrizzio-dotCMS May 7, 2026
b7737a3
Merge branch 'main' into issue-34609/search-layer-neutral-api
fabrizzio-dotCMS May 11, 2026
cc06480
test(os-search): add OSSearchAPIImplIntegrationTest to OpenSearchUpgr…
fabrizzio-dotCMS May 11, 2026
2a936e9
refactor(search): replace ContentletSearchAPIES reimplementation with…
fabrizzio-dotCMS May 11, 2026
beeb262
refactor(search): deprecate esSearch/esSearchRaw in ContentletAPI and…
fabrizzio-dotCMS May 11, 2026
473e790
Merge branch 'main' into issue-34609/search-layer-neutral-api
fabrizzio-dotCMS May 11, 2026
922f38b
test(search): migrate ContentTypeDestroyAPIImplTest to neutral SearchAPI
fabrizzio-dotCMS May 11, 2026
d102e6d
fix(search): wire search/searchRaw into ContentletAPIInterceptor and …
fabrizzio-dotCMS May 12, 2026
24d8fe1
fix(search): upgrade hook esSearch/esSearchRaw to @Deprecated(forRemo…
fabrizzio-dotCMS May 12, 2026
2f7c4a0
test(search): add _source rewrite tests and clean up unused imports i…
fabrizzio-dotCMS May 12, 2026
4bdfbd1
fix(search): add null guard for ES getHits() in ContentSearchResponse…
fabrizzio-dotCMS May 12, 2026
3188eb5
refactor(search): rename search/searchRaw → searchJson/searchRawJson …
fabrizzio-dotCMS May 12, 2026
b9ab862
refactor(search): make ContentSearchResults<T> generic to eliminate u…
fabrizzio-dotCMS May 13, 2026
019a128
test(search): migrate test call-sites to searchJson/searchRawJson and…
fabrizzio-dotCMS May 13, 2026
edf9888
Merge branch 'main' into issue-34609/search-layer-neutral-api
fabrizzio-dotCMS May 13, 2026
320ccd8
refactor(search): rename searchJson/searchRawJson → search/searchRaw …
fabrizzio-dotCMS May 13, 2026
8de3202
Merge branch 'main' into issue-34609/search-layer-neutral-api
fabrizzio-dotCMS May 13, 2026
c95c962
fix(search): re-introduce deprecated esSearch/esRaw bridges in ESCont…
fabrizzio-dotCMS May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 215 additions & 0 deletions docs/backend/SEARCH_API_MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
# Search API Migration Guide

This guide is intended for **dotCMS plugin and integration developers** who use the
`ContentletAPI`, `ESSeachAPI`, or the `$ESContent` Velocity tool in their extensions.

The changes described here are part of the ongoing ES → OpenSearch migration. The
deprecated methods listed below **will be removed** when dotCMS completes the cutover
to OpenSearch. Migrate before that happens to avoid compilation failures in your plugins.

---

## 1. `ContentletAPI` — deprecated search methods

### What was deprecated

| Method | Return type | Status |
|--------|-------------|--------|
| `esSearch(String esQuery, boolean live, User user, boolean respectFrontendRoles)` | `ESSearchResults` | `@Deprecated(forRemoval = true)` |
| `esSearchRaw(String esQuery, boolean live, User user, boolean respectFrontendRoles)` | `SearchResponse` | `@Deprecated(forRemoval = true)` |

Both methods delegate to `ESSeachAPI` directly and return Elasticsearch-specific types
(`ESSearchResults`, `org.elasticsearch.action.search.SearchResponse`). They will be
removed at OS cutover.

### Replacements

| Old method | New method | New return type |
|------------|------------|-----------------|
| `esSearch(...)` | `search(String query, boolean live, User user, boolean respectFrontendRoles)` | `ContentSearchResults<Contentlet>` |
| `esSearchRaw(...)` | `searchRaw(String query, boolean live, User user, boolean respectFrontendRoles)` | `ContentSearchResponse` |

The new methods route through the phase-aware `SearchAPI` router (ES in phases 0–1,
OpenSearch in phases 2–3) and return vendor-neutral DTOs.

### Migration example

```java
// Before
ContentletAPI contentletAPI = APILocator.getContentletAPI();

ESSearchResults results = contentletAPI.esSearch(query, false, user, false);
for (Object obj : results) {
Contentlet c = (Contentlet) obj;
// ...
}

SearchResponse raw = contentletAPI.esSearchRaw(query, false, user, false);
SearchHit[] hits = raw.getHits().getHits();

// After
ContentSearchResults<Contentlet> results = contentletAPI.search(query, false, user, false);
for (Contentlet c : results) {
// no cast needed
}

ContentSearchResponse raw = contentletAPI.searchRaw(query, false, user, false);
List<SearchHit> hits = raw.hits().hits(); // neutral SearchHit DTO
```

---

## 2. `ContentletAPIPreHook` / `ContentletAPIPostHook` — deprecated hook methods

If your OSGi plugin implements either hook interface to intercept search calls, the
following methods are deprecated and will be removed:

### `ContentletAPIPreHook`

| Deprecated | Replacement |
|-----------|-------------|
| `boolean esSearch(String esQuery, boolean live, User user, boolean respectFrontendRoles)` | `boolean search(String query, boolean live, User user, boolean respectFrontendRoles)` |
| `boolean esSearchRaw(String esQuery, boolean live, User user, boolean respectFrontendRoles)` | `boolean searchRaw(String query, boolean live, User user, boolean respectFrontendRoles)` |

### `ContentletAPIPostHook`

| Deprecated | Replacement |
|-----------|-------------|
| `void esSearch(String esQuery, boolean live, User user, boolean respectFrontendRoles)` | `void search(String query, boolean live, User user, boolean respectFrontendRoles)` |
| `void esSearchRaw(String esQuery, boolean live, User user, boolean respectFrontendRoles)` | `void searchRaw(String query, boolean live, User user, boolean respectFrontendRoles)` |

Both replacement methods have default no-op implementations in the interface, so you
only need to override them if you need to intercept those calls.

### Migration example

```java
// Before
public class MyPreHook implements ContentletAPIPreHook {
@Override
public boolean esSearch(String esQuery, boolean live, User user, boolean respectFrontendRoles) {
Logger.info(this, "intercepted esSearch: " + esQuery);
return true;
}
}

// After
public class MyPreHook implements ContentletAPIPreHook {
@Override
public boolean search(String query, boolean live, User user, boolean respectFrontendRoles) {
Logger.info(this, "intercepted search: " + query);
return true;
}
}
```

---

## 3. Velocity / VTL — `$ESContent` viewtool

The `$ESContent` viewtool (`ESContentTool`) is available in Velocity templates. Two of
its methods changed return types in this release.

### `$ESContent.search(query)`

| | Before | After |
|--|--------|-------|
| Return type | `ESSearchResults` (extends raw `List`) | `ContentSearchResults<ContentMap>` (implements `List<ContentMap>`) |
| Element type | `ContentMap` | `ContentMap` (unchanged) |

**Impact:** Templates that iterate over the result with `#foreach` are unaffected —
the elements are still `ContentMap` objects with the same properties.

```velocity
## This continues to work unchanged
#foreach($content in $ESContent.search($query))
$content.title
#end
```

Templates that access the result as `ESSearchResults` through a Java helper or cast
will fail at runtime. Replace with `ContentSearchResults`.

### `$ESContent.raw(query)`

| | Before | After |
|--|--------|-------|
| Return type | `org.elasticsearch.action.search.SearchResponse` | `ContentSearchResponse` |

**Impact:** Templates that call `.toString()` on the raw response to obtain ES
wire-format JSON (e.g. to parse it manually) will receive a different string. The new
`ContentSearchResponse.toString()` is a Java object representation, not JSON.

```velocity
## HIGH RISK — if your template does this, it will stop receiving valid JSON
#set($json = $ESContent.raw($query).toString())

## Use the structured accessors instead
#set($raw = $ESContent.raw($query))
#set($hits = $raw.hits().hits())
#foreach($hit in $hits)
$hit.id()
#end
```

Useful accessors on `ContentSearchResponse`:

| Method | Description |
|--------|-------------|
| `hits()` | Returns `SearchHits` — iterable collection of `SearchHit` |
| `hits().hits()` | `List<SearchHit>` |
| `hits().totalHits().value()` | Total number of matching documents |
| `scrollId()` | Scroll ID for paginated requests, or `null` |
| `tookMillis()` | Query execution time in milliseconds |
| `aggregations()` | `Map<String, List<AggregationBucket>>` — terms aggregations |

---

## 4. Return type change: `ESSearchResults` → `ContentSearchResults<T>`

`ESSearchResults` (package `com.dotcms.content.elasticsearch.business`) is not yet
removed but is no longer returned by the new API methods. If your plugin declares
variables of type `ESSearchResults`, update them to `ContentSearchResults<Contentlet>`.

```java
// Before
ESSearchResults results = (ESSearchResults) contentletAPI.esSearch(query, live, user, roles);

// After
ContentSearchResults<Contentlet> results = contentletAPI.search(query, live, user, roles);
```

The key structural difference:

| | `ESSearchResults` | `ContentSearchResults<T>` |
|--|-------------------|-----------------------------|
| Implements | `List` (raw) | `List<T>` (typed) |
| Element access | Requires `(Contentlet)` cast | Type-safe, no cast needed |
| Response metadata | `getResponse()` → `SearchResponse` (ES) | `getResponse()` → `ContentSearchResponse` (neutral) |
| Total results | `getResponse().getHits().getTotalHits().value` | `getTotalResults()` |
| Scroll ID | `getResponse().getScrollId()` | `getScrollId()` |

---

## 5. Summary of new neutral DTOs

These classes replace the Elasticsearch-specific types in the public API:

| Old type (ES-specific) | New type (neutral) | Package |
|------------------------|--------------------|---------|
| `org.elasticsearch.action.search.SearchResponse` | `ContentSearchResponse` | `com.dotcms.content.index.domain` |
| `com.dotcms.content.elasticsearch.business.ESSearchResults` | `ContentSearchResults<T>` | `com.dotcms.content.index.domain` |
| `org.elasticsearch.search.SearchHits` | `SearchHits` | `com.dotcms.content.index.domain` |
| `org.elasticsearch.search.SearchHit` | `SearchHit` | `com.dotcms.content.index.domain` |
| `org.elasticsearch.search.TotalHits` | `TotalHits` | `com.dotcms.content.index.domain` |

---

## 6. Timeline

| Phase | Action |
|-------|--------|
| Now (this release) | `esSearch` / `esSearchRaw` marked `@Deprecated(forRemoval = true)`. New `search` / `searchRaw` methods available. |
| OpenSearch cutover | `esSearch`, `esSearchRaw`, and ES-specific return types removed. Plugins that have not migrated will **fail to compile**. |

Migrate as soon as possible to get the full migration window.
6 changes: 3 additions & 3 deletions dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import com.dotcms.content.business.json.ContentletJsonAPI;
import com.dotcms.contenttype.model.type.BaseContentType;
import com.dotcms.contenttype.model.type.ContentType;
import com.dotcms.enterprise.ESSeachAPI;
import com.dotcms.content.index.SearchAPI;
import com.dotcms.uuid.shorty.ShortyIdAPI;
import com.dotmarketing.beans.Host;
import com.dotmarketing.business.APILocator;
Expand Down Expand Up @@ -803,7 +803,7 @@ private int countApproximateClausesInQuery(String query) {
*/
private Set<String> processSingleESQuery(final BrowserQuery browserQuery, final Set<String> inodes, final long startTime) {
final boolean live = !browserQuery.showWorking;
final ESSeachAPI esSearchAPI = APILocator.getEsSearchAPI();
final SearchAPI searchAPI = APILocator.getSearchAPI();
final List<String> collectedInodes = new ArrayList<>();

try {
Expand All @@ -815,7 +815,7 @@ private Set<String> processSingleESQuery(final BrowserQuery browserQuery, final

Logger.debug(this, String.format("Single ES query: %d inodes", inodes.size()));

esSearchAPI.esSearch(esQuery, live, browserQuery.user, false).forEach(result -> {
searchAPI.search(esQuery, live, browserQuery.user, false).forEach(result -> {
final Contentlet contentlet = (Contentlet) result;
collectedInodes.add(contentlet.getInode());
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,8 @@
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang.StringUtils;
import org.elasticsearch.action.search.SearchPhaseExecutionException;
import com.dotcms.content.index.domain.ContentSearchResponse;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.search.SearchHit;


/**
Expand Down Expand Up @@ -348,12 +348,14 @@ private static UniqueFieldValidationStrategyResolver getUniqueFieldValidationStr
return CDIUtils.getBeanThrows(UniqueFieldValidationStrategyResolver.class);
}

@Deprecated(forRemoval = true)
@Override
public SearchResponse esSearchRaw(String esQuery, boolean live, User user,
boolean respectFrontendRoles) throws DotSecurityException, DotDataException {
return APILocator.getEsSearchAPI().esSearchRaw(esQuery, live, user, respectFrontendRoles);
}

@Deprecated(forRemoval = true)
@Override
public ESSearchResults esSearch(String esQuery, boolean live, User user,
boolean respectFrontendRoles) throws DotSecurityException, DotDataException {
Expand Down Expand Up @@ -2381,29 +2383,29 @@ private List<Contentlet> getRelatedChildren(final Contentlet contentlet, final R
final String relationshipName = rel.getRelationTypeValue().toLowerCase();
final int limit = limitParam <= 0 ? MAX_LIMIT : limitParam;

SearchResponse response;
ContentSearchResponse response;
final boolean DONT_PULL_PARENTS = Boolean.FALSE;

//Search for related content in existing contentlet
if (UtilMethods.isSet(contentlet.getInode())) {
response = APILocator.getEsSearchAPI()
.esSearchRelated(contentlet, relationshipName, DONT_PULL_PARENTS,
response = APILocator.getSearchAPI()
.searchRelated(contentlet, relationshipName, DONT_PULL_PARENTS,
WORKING_VERSION, user,
respectFrontendRoles, limit, offset, null);
} else {
//Search for related content in other versions of the same contentlet
response = APILocator.getEsSearchAPI()
.esSearchRelated(contentlet.getIdentifier(), relationshipName,
response = APILocator.getSearchAPI()
.searchRelated(contentlet.getIdentifier(), relationshipName,
DONT_PULL_PARENTS, WORKING_VERSION, user,
respectFrontendRoles, limit, offset, null);
}

if (response.getHits() == null) {
if (response.hits().hits().isEmpty()) {
return result;
}

for (final SearchHit sh : response.getHits()) {
final Map<String, Object> sourceMap = sh.getSourceAsMap();
for (final com.dotcms.content.index.domain.SearchHit sh : response.hits()) {
final Map<String, Object> sourceMap = sh.sourceAsMap();
if (sourceMap.get(relationshipName) != null) {
List<String> relatedIdentifiers = ((ArrayList<String>) sourceMap.get(
relationshipName));
Expand Down Expand Up @@ -2475,25 +2477,25 @@ private List<Contentlet> getRelatedParents(final Contentlet contentlet, final Re
final String relationshipName = rel.getRelationTypeValue().toLowerCase();
final int limit = limitParam <= 0 ? MAX_LIMIT : limitParam;

SearchResponse response;
ContentSearchResponse response;
final boolean PULL_PARENTS = Boolean.TRUE;

//Search for related content in existing contentlet
if (UtilMethods.isSet(contentlet.getInode())) {
response = APILocator.getEsSearchAPI()
.esSearchRelated(contentlet, relationshipName, PULL_PARENTS,
response = APILocator.getSearchAPI()
.searchRelated(contentlet, relationshipName, PULL_PARENTS,
WORKING_VERSION, user,
respectFrontendRoles, limit, offset, null);
} else {
response = APILocator.getEsSearchAPI()
.esSearchRelated(contentlet.getIdentifier(), relationshipName, PULL_PARENTS,
response = APILocator.getSearchAPI()
.searchRelated(contentlet.getIdentifier(), relationshipName, PULL_PARENTS,
WORKING_VERSION, user,
respectFrontendRoles, limit, offset, null);
}

if (response.getHits() != null) {
for (final SearchHit sh : response.getHits()) {
final Map<String, Object> sourceMap = sh.getSourceAsMap();
if (!response.hits().hits().isEmpty()) {
for (final com.dotcms.content.index.domain.SearchHit sh : response.hits()) {
final Map<String, Object> sourceMap = sh.sourceAsMap();
final String identifier = (String) sourceMap.get("identifier");
if (identifier != null && !relatedMap.containsKey(identifier)) {
final Contentlet mappedContentlet = findContentletByIdentifierAnyLanguage(
Expand Down
Loading
Loading