Skip to content

Commit 1e9fbd5

Browse files
committed
feat(hunspell): Add ref_path support and cache invalidation API
- Add ref_path parameter for package-based dictionary loading - Load from config/packages/{packageId}/hunspell/{locale}/ - Add cache info API: GET /_hunspell/cache (cluster:monitor/hunspell/cache) - Add cache invalidation API: POST /_hunspell/cache/_invalidate (cluster:admin/hunspell/cache/invalidate) - Support invalidation by package_id, locale, cache_key, or invalidate_all - Add security validation (path traversal, separator injection, null bytes) - Add updateable flag for hot-reload via _reload_search_analyzers - Use Strings.hasText() and Strings.isNullOrEmpty() for validation consistency - Consistent response schema with all fields always present - Add unit tests, REST handler tests, and integration tests Signed-off-by: shayush622 <ayush5267@gmail.com>
1 parent c1dd00b commit 1e9fbd5

File tree

16 files changed

+390
-199
lines changed

16 files changed

+390
-199
lines changed
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
*
4+
* The OpenSearch Contributors require contributions made to
5+
* this file be licensed under the Apache-2.0 license or a
6+
* compatible open source license.
7+
*/
8+
9+
package org.opensearch.action.admin.indices.cache.hunspell;
10+
11+
import org.opensearch.common.settings.Settings;
12+
import org.opensearch.indices.analysis.HunspellService;
13+
import org.opensearch.test.OpenSearchIntegTestCase;
14+
import org.opensearch.test.OpenSearchIntegTestCase.ClusterScope;
15+
16+
import java.io.IOException;
17+
import java.nio.charset.StandardCharsets;
18+
import java.nio.file.Files;
19+
import java.nio.file.Path;
20+
21+
import static org.hamcrest.Matchers.equalTo;
22+
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
23+
24+
/**
25+
* Integration tests for hunspell cache info and invalidation APIs.
26+
*
27+
* <p>Tests the full REST→Transport→HunspellService flow on a real cluster:
28+
* <ul>
29+
* <li>GET /_hunspell/cache (HunspellCacheInfoAction)</li>
30+
* <li>POST /_hunspell/cache/_invalidate (HunspellCacheInvalidateAction)</li>
31+
* </ul>
32+
*/
33+
@ClusterScope(scope = OpenSearchIntegTestCase.Scope.TEST)
34+
public class HunspellCacheIT extends OpenSearchIntegTestCase {
35+
36+
@Override
37+
protected Settings nodeSettings(int nodeOrdinal) {
38+
// Enable lazy loading so dictionaries are only loaded on-demand
39+
return Settings.builder()
40+
.put(super.nodeSettings(nodeOrdinal))
41+
.put(HunspellService.HUNSPELL_LAZY_LOAD.getKey(), true)
42+
.build();
43+
}
44+
45+
/**
46+
* Sets up hunspell dictionary files in the node's config directory.
47+
* Creates both traditional and package-based dictionaries.
48+
*/
49+
private void setupDictionaries(Path configDir) throws IOException {
50+
// Traditional dictionary: config/hunspell/en_US/
51+
Path traditionalDir = configDir.resolve("hunspell").resolve("en_US");
52+
Files.createDirectories(traditionalDir);
53+
Files.write(traditionalDir.resolve("en_US.aff"), "SET UTF-8\n".getBytes(StandardCharsets.UTF_8));
54+
Files.write(traditionalDir.resolve("en_US.dic"), "1\nhello\n".getBytes(StandardCharsets.UTF_8));
55+
56+
// Package-based dictionary: config/packages/test-pkg/hunspell/en_US/
57+
Path packageDir = configDir.resolve("packages").resolve("test-pkg").resolve("hunspell").resolve("en_US");
58+
Files.createDirectories(packageDir);
59+
Files.write(packageDir.resolve("en_US.aff"), "SET UTF-8\n".getBytes(StandardCharsets.UTF_8));
60+
Files.write(packageDir.resolve("en_US.dic"), "1\nworld\n".getBytes(StandardCharsets.UTF_8));
61+
}
62+
63+
// ==================== Cache Info Tests ====================
64+
65+
public void testCacheInfoReturnsEmptyWhenNoDictionariesLoaded() {
66+
HunspellCacheInfoResponse response = client().execute(
67+
HunspellCacheInfoAction.INSTANCE,
68+
new HunspellCacheInfoRequest()
69+
).actionGet();
70+
71+
assertThat(response.getTotalCachedCount(), equalTo(0));
72+
assertThat(response.getPackageBasedCount(), equalTo(0));
73+
assertThat(response.getTraditionalLocaleCount(), equalTo(0));
74+
assertTrue(response.getPackageBasedKeys().isEmpty());
75+
assertTrue(response.getTraditionalLocaleKeys().isEmpty());
76+
}
77+
78+
// ==================== Cache Invalidation Tests ====================
79+
80+
public void testInvalidateAllOnEmptyCache() {
81+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
82+
request.setInvalidateAll(true);
83+
84+
HunspellCacheInvalidateResponse response = client().execute(
85+
HunspellCacheInvalidateAction.INSTANCE,
86+
request
87+
).actionGet();
88+
89+
assertTrue(response.isAcknowledged());
90+
assertThat(response.getInvalidatedCount(), equalTo(0));
91+
}
92+
93+
public void testInvalidateByPackageIdOnEmptyCache() {
94+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
95+
request.setPackageId("nonexistent-pkg");
96+
97+
HunspellCacheInvalidateResponse response = client().execute(
98+
HunspellCacheInvalidateAction.INSTANCE,
99+
request
100+
).actionGet();
101+
102+
assertTrue(response.isAcknowledged());
103+
assertThat(response.getInvalidatedCount(), equalTo(0));
104+
assertThat(response.getPackageId(), equalTo("nonexistent-pkg"));
105+
}
106+
107+
public void testInvalidateByCacheKeyOnEmptyCache() {
108+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
109+
request.setCacheKey("nonexistent-key");
110+
111+
HunspellCacheInvalidateResponse response = client().execute(
112+
HunspellCacheInvalidateAction.INSTANCE,
113+
request
114+
).actionGet();
115+
116+
assertTrue(response.isAcknowledged());
117+
assertThat(response.getInvalidatedCount(), equalTo(0));
118+
}
119+
120+
// ==================== Request Validation Tests ====================
121+
122+
public void testInvalidateRequestValidationFailsWithNoParameters() {
123+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
124+
// No parameters set — should fail validation
125+
126+
assertNotNull(request.validate());
127+
}
128+
129+
public void testInvalidateRequestValidationFailsWithConflictingParameters() {
130+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
131+
request.setInvalidateAll(true);
132+
request.setPackageId("some-pkg");
133+
134+
assertNotNull(request.validate());
135+
}
136+
137+
public void testInvalidateRequestValidationFailsWithEmptyPackageId() {
138+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
139+
request.setPackageId(" ");
140+
141+
assertNotNull(request.validate());
142+
}
143+
144+
public void testInvalidateRequestValidationPassesWithPackageIdOnly() {
145+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
146+
request.setPackageId("valid-pkg");
147+
148+
assertNull(request.validate());
149+
}
150+
151+
public void testInvalidateRequestValidationPassesWithPackageIdAndLocale() {
152+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
153+
request.setPackageId("valid-pkg");
154+
request.setLocale("en_US");
155+
156+
assertNull(request.validate());
157+
}
158+
159+
public void testInvalidateRequestValidationFailsWithLocaleWithoutPackageId() {
160+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
161+
request.setLocale("en_US");
162+
163+
assertNotNull(request.validate());
164+
}
165+
166+
// ==================== Response Schema Tests ====================
167+
168+
public void testInvalidateResponseAlwaysIncludesAllFields() {
169+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
170+
request.setInvalidateAll(true);
171+
172+
HunspellCacheInvalidateResponse response = client().execute(
173+
HunspellCacheInvalidateAction.INSTANCE,
174+
request
175+
).actionGet();
176+
177+
// Response should always include these fields for consistent schema
178+
assertTrue(response.isAcknowledged());
179+
assertThat(response.getInvalidatedCount(), greaterThanOrEqualTo(0));
180+
// Null fields should still be accessible (consistent schema)
181+
assertNull(response.getPackageId());
182+
assertNull(response.getLocale());
183+
assertNull(response.getCacheKey());
184+
}
185+
186+
public void testCacheInfoResponseSchema() {
187+
HunspellCacheInfoResponse response = client().execute(
188+
HunspellCacheInfoAction.INSTANCE,
189+
new HunspellCacheInfoRequest()
190+
).actionGet();
191+
192+
// Verify response schema has all expected fields
193+
assertThat(response.getTotalCachedCount(), greaterThanOrEqualTo(0));
194+
assertThat(response.getPackageBasedCount(), greaterThanOrEqualTo(0));
195+
assertThat(response.getTraditionalLocaleCount(), greaterThanOrEqualTo(0));
196+
assertNotNull(response.getPackageBasedKeys());
197+
assertNotNull(response.getTraditionalLocaleKeys());
198+
}
199+
}

server/src/main/java/org/opensearch/action/ActionModule.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@
337337
import org.opensearch.identity.IdentityService;
338338
import org.opensearch.index.seqno.RetentionLeaseActions;
339339
import org.opensearch.indices.SystemIndices;
340+
import org.opensearch.indices.analysis.HunspellService;
340341
import org.opensearch.persistent.CompletionPersistentTaskAction;
341342
import org.opensearch.persistent.RemovePersistentTaskAction;
342343
import org.opensearch.persistent.StartPersistentTaskAction;
@@ -423,6 +424,7 @@
423424
import org.opensearch.rest.action.admin.indices.RestGetIngestionStateAction;
424425
import org.opensearch.rest.action.admin.indices.RestGetMappingAction;
425426
import org.opensearch.rest.action.admin.indices.RestGetSettingsAction;
427+
import org.opensearch.rest.action.admin.indices.RestHunspellCacheInvalidateAction;
426428
import org.opensearch.rest.action.admin.indices.RestIndexDeleteAliasesAction;
427429
import org.opensearch.rest.action.admin.indices.RestIndexPutAliasAction;
428430
import org.opensearch.rest.action.admin.indices.RestIndicesAliasesAction;
@@ -446,7 +448,6 @@
446448
import org.opensearch.rest.action.admin.indices.RestSimulateTemplateAction;
447449
import org.opensearch.rest.action.admin.indices.RestSyncedFlushAction;
448450
import org.opensearch.rest.action.admin.indices.RestUpdateSettingsAction;
449-
import org.opensearch.rest.action.admin.indices.RestHunspellCacheInvalidateAction;
450451
import org.opensearch.rest.action.admin.indices.RestUpgradeAction;
451452
import org.opensearch.rest.action.admin.indices.RestUpgradeStatusAction;
452453
import org.opensearch.rest.action.admin.indices.RestValidateQueryAction;
@@ -492,7 +493,6 @@
492493
import org.opensearch.rest.action.list.RestIndicesListAction;
493494
import org.opensearch.rest.action.list.RestListAction;
494495
import org.opensearch.rest.action.list.RestShardsListAction;
495-
import org.opensearch.indices.analysis.HunspellService;
496496
import org.opensearch.rest.action.search.RestClearScrollAction;
497497
import org.opensearch.rest.action.search.RestCountAction;
498498
import org.opensearch.rest.action.search.RestCreatePitAction;
@@ -1124,7 +1124,7 @@ protected void configure() {
11241124
bind(DynamicActionRegistry.class).toInstance(dynamicActionRegistry);
11251125

11261126
bind(ResponseLimitSettings.class).toInstance(responseLimitSettings);
1127-
1127+
11281128
// Bind HunspellService for TransportHunspellCacheInvalidateAction injection
11291129
if (hunspellService != null) {
11301130
bind(HunspellService.class).toInstance(hunspellService);

server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoAction.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
/**
1414
* Action for retrieving Hunspell cache information.
15-
*
15+
*
1616
* <p>This action requires "cluster:monitor/hunspell/cache" permission when security is enabled.
1717
*
1818
* @opensearch.internal
@@ -25,4 +25,4 @@ public class HunspellCacheInfoAction extends ActionType<HunspellCacheInfoRespons
2525
private HunspellCacheInfoAction() {
2626
super(NAME, HunspellCacheInfoResponse::new);
2727
}
28-
}
28+
}

server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoRequest.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@
2222
*/
2323
public class HunspellCacheInfoRequest extends ActionRequest {
2424

25-
public HunspellCacheInfoRequest() {
26-
}
25+
public HunspellCacheInfoRequest() {}
2726

2827
public HunspellCacheInfoRequest(StreamInput in) throws IOException {
2928
super(in);
@@ -38,4 +37,4 @@ public void writeTo(StreamOutput out) throws IOException {
3837
public ActionRequestValidationException validate() {
3938
return null;
4039
}
41-
}
40+
}

server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoResponse.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,4 @@ public Set<String> getPackageBasedKeys() {
9494
public Set<String> getTraditionalLocaleKeys() {
9595
return traditionalLocaleKeys;
9696
}
97-
}
97+
}

server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateRequest.java

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
/**
2020
* Request for Hunspell cache invalidation.
21-
*
21+
*
2222
* <p>Supports three modes:
2323
* <ul>
2424
* <li>Invalidate by package_id (clears all locales for a package)</li>
@@ -36,8 +36,7 @@ public class HunspellCacheInvalidateRequest extends ActionRequest {
3636
private String cacheKey;
3737
private boolean invalidateAll;
3838

39-
public HunspellCacheInvalidateRequest() {
40-
}
39+
public HunspellCacheInvalidateRequest() {}
4140

4241
public HunspellCacheInvalidateRequest(StreamInput in) throws IOException {
4342
super(in);
@@ -59,7 +58,7 @@ public void writeTo(StreamOutput out) throws IOException {
5958
@Override
6059
public ActionRequestValidationException validate() {
6160
ActionRequestValidationException e = null;
62-
61+
6362
// Reject empty/blank strings with clear error messages
6463
if (packageId != null && !Strings.hasText(packageId)) {
6564
e = new ActionRequestValidationException();
@@ -73,18 +72,18 @@ public ActionRequestValidationException validate() {
7372
if (e == null) e = new ActionRequestValidationException();
7473
e.addValidationError("'cache_key' cannot be empty or blank");
7574
}
76-
75+
7776
// If any blank validation errors, return early
7877
if (e != null) {
7978
return e;
8079
}
81-
80+
8281
// Count how many modes are specified
8382
int modeCount = 0;
8483
if (invalidateAll) modeCount++;
8584
if (packageId != null) modeCount++;
8685
if (cacheKey != null) modeCount++;
87-
86+
8887
if (modeCount == 0) {
8988
e = new ActionRequestValidationException();
9089
e.addValidationError("Either 'package_id', 'cache_key', or 'invalidate_all' must be specified");
@@ -96,13 +95,13 @@ public ActionRequestValidationException validate() {
9695
e.addValidationError("Only one of 'package_id' or 'cache_key' can be specified, not both");
9796
}
9897
}
99-
98+
10099
// locale is only valid with package_id
101100
if (locale != null && packageId == null) {
102101
if (e == null) e = new ActionRequestValidationException();
103102
e.addValidationError("'locale' can only be specified together with 'package_id'");
104103
}
105-
104+
106105
return e;
107106
}
108107

@@ -141,4 +140,4 @@ public HunspellCacheInvalidateRequest setInvalidateAll(boolean invalidateAll) {
141140
this.invalidateAll = invalidateAll;
142141
return this;
143142
}
144-
}
143+
}

server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateResponse.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,4 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws
8686
builder.endObject();
8787
return builder;
8888
}
89-
}
89+
}

0 commit comments

Comments
 (0)