-
Notifications
You must be signed in to change notification settings - Fork 17
refactor (poc): elasticsearchsystem does not depend on co.elastic client anymore #915
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR refactors the Elasticsearch testing system to remove the dependency on the official Elasticsearch Java client (co.elastic.clients) and replaces it with Ktor HTTP client for direct REST API calls. This change simplifies the architecture by eliminating a heavyweight client dependency in favor of lightweight HTTP calls.
Key Changes
- Replaced Elasticsearch official client with Ktor HTTP client for all Elasticsearch operations
- Changed migration interface from accepting
ElasticsearchClienttoElasticsearchSystem - Removed certificate-based security in favor of basic auth with
isSecureflag
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| build.gradle.kts | Removed elastic client dependency, added Ktor HTTP client dependencies |
| ElasticsearchSystem.kt | Complete rewrite to use Ktor HTTP client with direct REST API calls, added helper methods for index operations |
| Options.kt | Removed ElasticClientConfigurer, replaced certificate with isSecure flag, changed cleanup/migration signatures |
| ElasticsearchTestSystemTests.kt | Updated migrations and tests to use new ElasticsearchSystem API with JSON string queries |
| Extensions.kt | Updated documentation examples to reflect new API |
| util.kt | Deleted file (no longer needed without elastic client) |
| ElasticsearchExposedCertificateTest.kt | Deleted test file (certificate handling removed) |
| stove-testing-e2e-elasticsearch.api | Public API changes reflecting the refactoring |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| settings?.let { setBody(it) } | ||
| } | ||
|
|
||
| check(response.status.isSuccess() || response.status.value == HttpStatusCode.BadRequest.value) { |
Copilot
AI
Nov 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error checking logic is incorrect. When creating an index that already exists, Elasticsearch returns a 400 Bad Request, not a success status. The current check response.status.isSuccess() || response.status.value == HttpStatusCode.BadRequest.value will pass for ANY 400 error, not just "index already exists".
This should either:
- Parse the error response to check if it's specifically an "index already exists" error
- Only allow success status codes and let other errors fail clearly
- Document that this method silently ignores all 400 errors
| check(response.status.isSuccess() || response.status.value == HttpStatusCode.BadRequest.value) { | |
| val isAlreadyExistsError = response.status == HttpStatusCode.BadRequest && | |
| response.bodyAsText().contains("\"type\":\"resource_already_exists_exception\"") | |
| check(response.status.isSuccess() || isAlreadyExistsError) { |
| this[ResponseField.HITS][ResponseField.HITS] | ||
| .mapNotNull { hit -> hit[ResponseField.SOURCE]?.let { objectMapper.treeToValue(it, clazz) } } |
Copilot
AI
Nov 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The extractSearchHits method accesses nested JSON fields without null checks. If the response structure is unexpected or if there are no hits, this could throw a NullPointerException.
Consider adding safe navigation:
internal fun <T : Any> JsonNode.extractSearchHits(clazz: Class<T>): List<T> =
this.get(ResponseField.HITS)?.get(ResponseField.HITS)?.mapNotNull { hit ->
hit.get(ResponseField.SOURCE)?.let { objectMapper.treeToValue(it, clazz) }
} ?: emptyList()| this[ResponseField.HITS][ResponseField.HITS] | |
| .mapNotNull { hit -> hit[ResponseField.SOURCE]?.let { objectMapper.treeToValue(it, clazz) } } | |
| this.get(ResponseField.HITS)?.get(ResponseField.HITS)?.mapNotNull { hit -> | |
| hit.get(ResponseField.SOURCE)?.let { objectMapper.treeToValue(it, clazz) } | |
| } ?: emptyList() |
| private fun createHttpClient(cfg: ElasticSearchExposedConfiguration): HttpClient { | ||
| val client = context.options.httpClientConfigurer | ||
| .getOrElse { { defaultHttpClient(cfg) } } | ||
| .invoke(cfg) | ||
|
|
||
| private fun isSecurityDisabled(cfg: ElasticSearchExposedConfiguration): Boolean = when { | ||
| context.options is ProvidedElasticsearchSystemOptions -> cfg.certificate == null | ||
| context.runtime is StoveElasticSearchContainer -> context.options.container.disableSecurity | ||
| else -> throw UnsupportedOperationException("Unsupported runtime type: ${context.runtime::class}") | ||
| // Apply base URL configuration to whatever client was created | ||
| return client.config { | ||
| defaultRequest { | ||
| url(baseUrl) | ||
| } | ||
| } | ||
| } |
Copilot
AI
Nov 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Calling .config {} on an already-configured HttpClient creates a new client instance, which means the custom client provided via httpClientConfigurer will have its configuration replaced. This defeats the purpose of allowing custom client configuration.
If users provide a custom httpClientConfigurer, their base URL configuration will be overwritten. Either:
- Don't apply
defaultRequestto custom clients, or - Document that custom clients must set the base URL themselves, or
- Pass the
baseUrlto the configurer function so users can apply it in their custom client configuration
| suspend inline fun <reified T : Any> shouldGet( | ||
| index: String, | ||
| key: String, | ||
| assertion: (T) -> Unit | ||
| ): ElasticsearchSystem { |
Copilot
AI
Nov 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The parameter order shouldGet(index: String, key: String, ...) is inconsistent with other methods like save(id: String, instance: T, index: String) and shouldDelete(key: String, index: String) where the key/id comes before the index.
For consistency across the API, consider using the same parameter order throughout. Looking at the usage in tests, there's already confusion with both shouldGet<ExampleInstance>(ANOTHER_INDEX, exampleInstance.id) and shouldGet<ExampleInstance>(key = exampleInstance.id, index = TEST_INDEX) being used.
| private fun okhttp3.OkHttpClient.Builder.configureTrustAllCertificates() { | ||
| val trustAllCerts = arrayOf<javax.net.ssl.TrustManager>( | ||
| object : javax.net.ssl.X509TrustManager { | ||
| override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>, authType: String) = Unit | ||
|
|
||
| else -> throw UnsupportedOperationException("Unsupported runtime type: ${context.runtime::class}") | ||
| override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>, authType: String) = Unit | ||
|
|
||
| override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf() | ||
| } | ||
| ) | ||
| val sslContext = javax.net.ssl.SSLContext | ||
| .getInstance(TLS_PROTOCOL) | ||
| sslContext.init(null, trustAllCerts, java.security.SecureRandom()) | ||
| sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as javax.net.ssl.X509TrustManager) | ||
| hostnameVerifier { _, _ -> true } | ||
| } |
Copilot
AI
Nov 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The configureTrustAllCertificates() function disables SSL certificate validation entirely, which poses a significant security risk. While this may be acceptable for testing environments, it should be clearly documented as a security risk and ideally made configurable.
Consider:
- Adding a prominent warning in the documentation about this being for testing only
- Making certificate validation configurable via
ElasticsearchSystemOptions - Restricting this to only apply when explicitly enabled or in test environments
| suspend inline fun <reified T : Any> shouldQuery( | ||
| query: String, | ||
| index: String, | ||
| assertion: (List<T>) -> Unit | ||
| ): ElasticsearchSystem { | ||
| require(index.isNotBlank()) { "Index cannot be blank" } | ||
| requireValidIndex(index) | ||
| require(query.isNotBlank()) { "Query cannot be blank" } | ||
| return esClient | ||
| .search( | ||
| SearchRequest.of { req -> req.index(index).query { q -> q.withJson(query.reader()) } }, | ||
| T::class.java | ||
| ).hits() | ||
| .hits() | ||
| .mapNotNull { it.source() } | ||
| .also(assertion) | ||
| .let { this } | ||
| } | ||
|
|
||
| @ElasticDsl | ||
| inline fun <reified T : Any> shouldQuery( | ||
| query: Query, | ||
| assertion: (List<T>) -> Unit | ||
| ): ElasticsearchSystem = esClient | ||
| .search( | ||
| SearchRequest.of { q -> q.query(query) }, | ||
| T::class.java | ||
| ).hits() | ||
| .hits() | ||
| .mapNotNull { it.source() } | ||
| .also(assertion) | ||
| .let { this } | ||
| val response = httpClient.post(Endpoint.search(index)) { | ||
| contentType(ContentType.Application.Json) | ||
| setBody(query) | ||
| } | ||
|
|
||
| response.ensureSuccess("Search query") | ||
|
|
||
| val responseBody = response.body<JsonNode>() | ||
| val results = responseBody.extractSearchHits(T::class.java) | ||
| assertion(results) | ||
| return this | ||
| } |
Copilot
AI
Nov 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The shouldQuery method now requires suspend but the query parameter type changed from Query (typed) to String (raw JSON). This is a breaking API change that:
- Forces all callers to use raw JSON strings instead of type-safe query builders
- Removes compile-time type safety for query construction
- Makes it harder to catch query syntax errors at compile time
Consider providing both overloads:
- One accepting raw JSON strings for flexibility
- One accepting a type-safe query DSL or builder for better developer experience
| } | ||
|
|
||
| defaultRequest { | ||
| if (cfg.isSecure && cfg.password.isNotBlank()) { |
Copilot
AI
Nov 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The authentication header is only set when cfg.isSecure is true, but authentication might be required even for non-HTTPS connections. Elasticsearch can be configured with authentication over HTTP.
The condition should check cfg.password.isNotBlank() without requiring cfg.isSecure, or make this more explicit:
if (cfg.password.isNotBlank()) {
header(HttpHeaders.Authorization, cfg.basicAuthHeader())
}| if (cfg.isSecure && cfg.password.isNotBlank()) { | |
| if (cfg.password.isNotBlank()) { |
| val queryByDesc = """ | ||
| { | ||
| "query": { | ||
| "term": { | ||
| "description.keyword": "$desc" | ||
| } | ||
| } | ||
| } | ||
| """.trimIndent() |
Copilot
AI
Nov 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
String interpolation in the query JSON with "$desc" is vulnerable to JSON injection. If desc contains special characters like quotes, it could break the JSON structure or allow injection attacks.
Use proper JSON serialization instead:
val queryByDesc = objectMapper.writeValueAsString(
mapOf(
"query" to mapOf(
"term" to mapOf(
"description.keyword" to desc
)
)
)
)| val queryByDesc = """ | |
| { | |
| "query": { | |
| "term": { | |
| "description.keyword": "$desc" | |
| } | |
| } | |
| } | |
| """.trimIndent() | |
| val objectMapper = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper() | |
| val queryByDesc = objectMapper.writeValueAsString( | |
| mapOf( | |
| "query" to mapOf( | |
| "term" to mapOf( | |
| "description.keyword" to desc | |
| ) | |
| ) | |
| ) | |
| ) |
| * @param certificate Optional SSL certificate for secure connections | ||
| * @param clientConfigurer Client configuration | ||
| * @param jsonpMapper JSON mapper for serialization | ||
| * @param isSecure Whether to use HTTPS (default: false for provided instances) |
Copilot
AI
Nov 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The documentation mentions isSecure parameter but doesn't explain when to use true vs false. It should clarify:
- When
isSecureshould be true (HTTPS connections, typically with certificates) - When it should be false (HTTP connections, local testing)
- The security implications of each choice
Example:
* @param isSecure Whether to use HTTPS (default: false). Set to true when connecting to Elasticsearch over HTTPS with valid certificates. For local testing without TLS, use false.| * @param isSecure Whether to use HTTPS (default: false for provided instances) | |
| * @param isSecure Whether to use HTTPS (default: false for provided instances). Set to true when connecting to Elasticsearch over HTTPS with valid certificates. For local testing or unsecured HTTP connections, use false. Note: Setting false means data is sent in plaintext and may be intercepted; setting true enables encrypted communication. |
| SupportsMigrations<ElasticsearchSystem, ElasticsearchSystemOptions> { | ||
| override val migrationCollection: MigrationCollection<ElasticsearchSystem> = MigrationCollection() |
Copilot
AI
Nov 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The migration collection type parameter changed from ElasticsearchClient to ElasticsearchSystem. This is a breaking change that affects all existing migration implementations.
While this change makes sense (migrations shouldn't depend on the elastic client), existing users will need to:
- Update all their
DatabaseMigration<ElasticsearchClient>implementations toDatabaseMigration<ElasticsearchSystem> - Change their migration code from using
ElasticsearchClientAPIs toElasticsearchSystemmethods
Consider documenting this breaking change prominently in release notes or migration guide.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #915 +/- ##
==========================================
- Coverage 77.48% 76.93% -0.55%
==========================================
Files 100 99 -1
Lines 3273 3295 +22
Branches 272 270 -2
==========================================
- Hits 2536 2535 -1
- Misses 588 608 +20
- Partials 149 152 +3 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
5d60481 to
225b725
Compare
225b725 to
0fff6b4
Compare
No description provided.