Skip to content

Welcome to Elasticmagic

Elasticmagic implements advanced type awareness DSL for Kotlin to construct Elasticsearch queries.

Warning

The library is in very alpha status. API may change significantly at any time. Use it on your own risk

Getting started

Setup

Add following dependencies in your build.gradle.kts script:

repositories {
    mavenCentral()
}

val elasticmagicVersion = "0.0.29"
val ktorVersion = "2.2.2"

dependencies {
    // Elasticmagic core api
    implementation("dev.evo.elasticmagic:elasticmagic:$elasticmagicVersion")
    // Json serialization using kotlinx.serialization
    implementation("dev.evo.elasticmagic:elasticmagic-serde-kotlinx-json:$elasticmagicVersion")
    // Transport that uses ktor http client
    implementation("dev.evo.elasticmagic:elasticmagic-transport-ktor:$elasticmagicVersion")

    implementation("io.ktor:ktor-client-cio:$ktorVersion")
}

Usage

First you need to describe a document (represents a mapping in terms of Elasticsearch):

package samples.started

import dev.evo.elasticmagic.doc.Document

object UserDoc : Document() {
    val id by int()
    val name by keyword()
    val groups by keyword()
    val about by text()
}

Now create ElasticsearchCluster object. It is an entry point for executing search queries:

package samples.started

import dev.evo.elasticmagic.ElasticsearchCluster
import dev.evo.elasticmagic.serde.kotlinx.JsonSerde
import dev.evo.elasticmagic.transport.ElasticsearchKtorTransport

const val DEFAULT_ELASTIC_URL = "http://localhost:9200"
const val DEFAULT_ELASTIC_USER = "elastic"

expect val esTransport: ElasticsearchKtorTransport
val cluster = ElasticsearchCluster(esTransport, serde = JsonSerde)
val userIndex = cluster["elasticmagic-samples_user"]

Any ElasticsearchCluster needs an ElasticsearchTransport. We will use the ElasticsearchKtorTransport that utilises Ktor http client.

Here are examples of creating transports for the cluster.

JVM:

package samples.started

import dev.evo.elasticmagic.transport.Auth
import dev.evo.elasticmagic.transport.ElasticsearchKtorTransport
import dev.evo.elasticmagic.transport.Request
import dev.evo.elasticmagic.transport.Response
import dev.evo.elasticmagic.transport.PlainRequest
import dev.evo.elasticmagic.transport.PlainResponse
import dev.evo.elasticmagic.transport.Tracker

import io.ktor.client.engine.cio.CIO

import java.security.cert.X509Certificate
import javax.net.ssl.X509TrustManager

import kotlin.time.Duration
import kotlin.getOrThrow

actual val esTransport = ElasticsearchKtorTransport(
    System.getenv("ELASTIC_URL") ?: DEFAULT_ELASTIC_URL,
    engine = CIO.create {
        https {
            trustManager = object: X509TrustManager {
                override fun checkClientTrusted(
                    chain: Array<out X509Certificate>?, authType: String?
                ) {}

                override fun checkServerTrusted(
                    chain: Array<out X509Certificate>?, authType: String?
                ) {}

                override fun getAcceptedIssuers(): Array<X509Certificate>? = null
            }
        }
    }
) {
    val elasticUser = System.getenv("ELASTIC_USER") ?: DEFAULT_ELASTIC_USER
    val elasticPassword = System.getenv("ELASTIC_PASSWORD")
    if (!elasticPassword.isNullOrEmpty()) {
        auth = Auth.Basic(elasticUser, elasticPassword)
    }

    if (System.getenv("ELASTICMAGIC_DEBUG") != null) {
        trackers = listOf {
            object : Tracker {
                override fun requiresTextContent(request: Request<*, *, *>) = true

                override suspend fun onRequest(request: PlainRequest) {
                    println(">>>")
                    println("${request.method} ${request.path.ifEmpty { "/" }}")
                    println(request.textContent)
                }

                override suspend fun onResponse(responseResult: Result<PlainResponse>, duration: Duration) {
                    responseResult
                        .onSuccess { response ->
                            println("<<< ${response.statusCode}: ${duration}")
                            response.headers.forEach { header ->
                                println("< ${header.key}: ${header.value}")
                            }
                            println(response.contentType)
                            println(response.content)
                        }
                        .onFailure { exception ->
                            println("!!! $exception")
                        }
                }
            }
        }
    }
}

Native:

package samples.started

import dev.evo.elasticmagic.serde.kotlinx.JsonSerde
import dev.evo.elasticmagic.transport.Auth
import dev.evo.elasticmagic.transport.ElasticsearchKtorTransport

import io.ktor.client.engine.curl.Curl

import kotlinx.cinterop.toKString

import platform.posix.getenv

actual val esTransport = ElasticsearchKtorTransport(
    getenv("ELASTIC_URL")?.toKString() ?: DEFAULT_ELASTIC_URL,
    engine = Curl.create {
        sslVerify = false
    }
) {
    val elasticUser = getenv("ELASTIC_USER")?.toKString() ?: DEFAULT_ELASTIC_USER
    val elasticPassword = getenv("ELASTIC_PASSWORD")?.toKString()
    if (!elasticPassword.isNullOrEmpty()) {
        auth = Auth.Basic(elasticUser, elasticPassword)
    }
}

Create our index if it does not exist or update the mapping otherwise:

package samples.started

import dev.evo.elasticmagic.Params

suspend fun ensureIndexExists() {
    if (!cluster.indexExists(userIndex.name)) {
        cluster.createIndex(
            userIndex.name,
            mapping = UserDoc,
            settings = Params(
                "index.number_of_replicas" to 0,
            ),
        )
    } else {
        cluster.updateMapping(userIndex.name, mapping = UserDoc)
    }
}

Describe document sources and index them:

package samples.started

import dev.evo.elasticmagic.Refresh
import dev.evo.elasticmagic.doc.DynDocSource
import dev.evo.elasticmagic.bulk.IndexAction
import dev.evo.elasticmagic.bulk.IdActionMeta
import dev.evo.elasticmagic.doc.list

suspend fun indexDocs() {
    val docs = listOf(
        DynDocSource {
            // Note that you can't write like following (it just won't compile):
            // it[UserDoc.id] = "0"
            // it[UserDoc.name] = 123
            // it[UserDoc.groups.list()] = "root"
            it[UserDoc.id] = 0
            it[UserDoc.name] = "root"
            it[UserDoc.groups.list()] = mutableListOf("root", "wheel")
            it[UserDoc.about] = "Super user"
        },
        DynDocSource {
            it[UserDoc.id] = 1
            it[UserDoc.name] = "daemon"
            it[UserDoc.groups.list()] = mutableListOf("daemon")
            it[UserDoc.about] = "Daemon user"
        },
        DynDocSource {
            it[UserDoc.id] = 65535
            it[UserDoc.name] = "nobody"
            it[UserDoc.groups.list()] = mutableListOf("nobody")
            it[UserDoc.about] = "Just nobody"
        },
        DynDocSource {
            it[UserDoc.id] = 65534
            it[UserDoc.name] = "noone"
            it[UserDoc.groups.list()] = mutableListOf("nobody")
            it[UserDoc.about] = "Another nobody"
        },
    )
    // Create index actions, make bulk request and refresh the index
    userIndex.bulk(
        docs.map { doc ->
            IndexAction(
                meta = IdActionMeta(id = doc[UserDoc.id].toString()),
                source = doc,
            )
        },
        refresh = Refresh.TRUE,
    )
}

And finally we can search our data:

package samples.started

import dev.evo.elasticmagic.SearchQuery
import dev.evo.elasticmagic.SearchQueryResult
import dev.evo.elasticmagic.aggs.TermsAgg
import dev.evo.elasticmagic.aggs.TermsAggResult
import dev.evo.elasticmagic.doc.DynDocSource
import dev.evo.elasticmagic.query.match

import kotlinx.coroutines.runBlocking

fun printUsers(result: SearchQueryResult<DynDocSource>) {
    println("Found: ${result.totalHits} users")
    for (hit in result.hits) {
        val user = hit.source!!
        println("  ${user[UserDoc.id]}: ${user[UserDoc.name]}")
    }
    println()
}

fun printGroupsAgg(aggResult: TermsAggResult<String>) {
    println("Groups aggregation")
    for (bucket in aggResult.buckets) {
        println("  ${bucket.key}: ${bucket.docCount}")
    }
}

fun main() = runBlocking {
    ensureIndexExists()
    indexDocs()

    // Find all users
    val sq = SearchQuery()
    printUsers(sq.search(userIndex))

    // Find nobody users
    sq.query(UserDoc.about.match("nobody"))
    printUsers(sq.search(userIndex))

    // Build an aggregation that counts users inside a group
    printGroupsAgg(
        SearchQuery()
            .aggs("groups" to TermsAgg(UserDoc.groups))
            .search(userIndex)
            .agg("groups")
    )
}

Run the sample

You can find fully working example inside samples

And run it with as JVM application (of cause you need Elasticsearch available at localhost:9200):

./gradlew :samples:run

or native:

./gradlew :samples:runDebugExecutableNative