Dive into Quarkus extensions – ArangoDB integration

Introduction

I was testing Quarkus extensions capabilities, and I wanted to share some of the details in this blog post.  Quarkus has straightforward dependency injection system, lots of extensions that can be added with one line in gradle, quick boot times, these are just few advantages of this tool. During my experimentations with this framework, I had one usecase thats not straightforward to solve – configuring arbitrary number of beans of same class, and allow them for injection. There is a mechanism in Quarkus, which allows synthetic beans creation, and initialize them accordingly either in build time or runtime – extensions. The majority of research readings were for Java and Maven, thus I decided to present a process for creating Quarkus Extensions with Kotlin and Gradle Kotlin.

This extension was inspired by Mongo client extension for Quarkus and my aim was to provide couple of features listed below

  • Injectable Arango Db client initialization based on configuration
  • Named beans for database, with seperate configuration
  • SSL support
  • Native build support
 
 

Project structure

Quarkus extension has conventional project structure

In essence it’s a module containing 2 child modules: deployment and runtime. Deployment module in nutshel, contains processors defining how to bootstrap beans for example. But there are are many more things that can be achieved at both build and run time. 

For more information about Quarkus extensions in general, I encourage you to read this official page

Gradle Kotlin setup

Quarkus cli, unfortunately  doesn’t really have any support for Gradle Kotlin DSL for generating extension projects, unlike for application. It’s not a big problem, we can quickly wire up parent module ourselves.

 

build.gradle.kts
val quarkusPlatformGroupId: String by project;
val quarkusPlatformArtifactId: String by project;
val quarkusPlatformVersion: String by project;

val kotlinVersion: String by project;
val junitVersion: String by project;
val junitLauncherVersion: String by project;

val jvmTarget: String by project;
val projectGroup: String by project;
val projectVersion: String by project;

val arangoDbDriverVersion: String by project;


plugins {

  id("java")
  kotlin("jvm")
  kotlin("kapt")
  kotlin("plugin.allopen")
}


repositories {
  mavenLocal()
  mavenCentral()
  gradlePluginPortal()
}

dependencies {
  implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))

}


subprojects {

  group = projectGroup
  version = projectVersion

  apply {
    plugin("java-library")
    plugin("maven-publish")
    plugin("org.jetbrains.kotlin.jvm")
    plugin("org.jetbrains.kotlin.plugin.allopen")
    plugin("org.jetbrains.kotlin.kapt")


  }

  configurations.all {
    resolutionStrategy {
      force("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.20")
      force("org.jetbrains.kotlin:kotlin-reflect:1.7.20")
    }
  }

  allOpen {
    annotations(
      "javax.ws.rs.Path",
      "javax.enterprise.context.ApplicationScoped",
      "javax.persistence.Entity",
      "io.quarkus.runtime.annotations.ConfigRoot",
      "io.quarkus.runtime.annotations.Recorder"
    )
  }



  repositories {
    mavenLocal()
    mavenCentral()
    gradlePluginPortal()
  }

  java {
    sourceCompatibility = JavaVersion.VERSION_18
    targetCompatibility = JavaVersion.VERSION_18
  }

  dependencies {
    implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))

    implementation("io.quarkus:quarkus-bom:${quarkusPlatformVersion}")
    implementation("io.quarkus:quarkus-core:${quarkusPlatformVersion}")
    implementation("io.quarkus:quarkus-jackson")
    implementation("io.quarkus:quarkus-resteasy-jackson")
    implementation("io.quarkus:quarkus-rest-client-jackson")

    implementation("com.arangodb:arangodb-java-driver:${arangoDbDriverVersion}")

    testImplementation("io.quarkus:quarkus-bom:${quarkusPlatformVersion}")
    testImplementation("io.quarkus:quarkus-core:${quarkusPlatformVersion}")
    testImplementation("io.quarkus:quarkus-arc-deployment:${quarkusPlatformVersion}")
    testImplementation("io.quarkus:quarkus-core-deployment:${quarkusPlatformVersion}")
    testImplementation("io.quarkus:quarkus-jaxb-deployment:${quarkusPlatformVersion}")
    testImplementation("io.quarkus:quarkus-undertow-deployment:${quarkusPlatformVersion}")
    testImplementation("io.quarkus:quarkus-devtools-testing:${junitVersion}")
    testImplementation("io.quarkus:quarkus-junit5:${junitVersion}")
    testImplementation("io.quarkus:quarkus-junit5-internal:${junitVersion}")
    testImplementation("io.quarkus:quarkus-junit5-mockito:${junitVersion}")
    testImplementation("org.assertj:assertj-core:3.23.1")
    testImplementation("io.quarkus:quarkus-junit5")
    testImplementation("io.rest-assured:rest-assured")
    testImplementation("org.mockito:mockito-core:4.10.0")
    testImplementation("org.assertj:assertj-core:3.23.1")
    testImplementation("org.testcontainers:junit-jupiter:1.17.6")

  }

  tasks.test {
    // Use the built-in JUnit support of Gradle.
    systemProperty("java.util.logging.manager", "org.jboss.logmanager.LogManager")
    useJUnitPlatform()
  }
}
settings.gradle.kts
pluginManagement {

  val quarkusPlatformVersion: String by settings
  val quarkusPluginId: String by settings
  val kotlinVersion: String by settings

  repositories {
    mavenLocal()
    mavenCentral()
    gradlePluginPortal()
  }

  plugins {
    id("io.quarkus") version quarkusPlatformVersion
    id("io.quarkus.extension") version quarkusPlatformVersion
    kotlin("jvm") version kotlinVersion
    kotlin("kapt") version kotlinVersion
    kotlin("plugin.allopen") version kotlinVersion
  }
}
val projectArtifactId: String by settings

include(":runtime", ":deployment", ":tests")


project(":runtime").name = "arangodb-client"
rootProject.name = "arangodb-client-parent"
gradle.properties
projectGroup = dev.techyon.arangodb
projectArtifactId = arangodb-client
projectVersion = 0.0.1

#Gradle properties
quarkusPlatformArtifactId=quarkus-universe-bom
quarkusPlatformGroupId=io.quarkus
quarkusPlatformVersion=3.0.0.Alpha2


quarkusExtensionArtifactId=extension

jvmTarget=18

junitVersion=3.0.0.Alpha2
junitLauncherVersion=1.7.0

kotlinVersion=1.8.0

arangoDbDriverVersion=6.20.0

Next we need to create deployment and runtime child projects. Deployment part deals with work that can be done at build time, and runtime part with what needs to be done at runtime. More about it here.

Let’s start with deployment module:

build.gradle.kts:

 

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

val quarkusPlatformGroupId: String by project;
val quarkusPlatformArtifactId: String by project;
val quarkusPlatformVersion: String by project;

val kotlinVersion: String by project;
val junitVersion: String by project;
val junitLauncherVersion: String by project;

val projectGroup: String by project;
val projectVersion: String by project;
val projectArtifactId: String by project;

group = projectGroup
version = projectVersion

publishing {
    publications {
        create<MavenPublication>("maven") {
            groupId = projectGroup
            artifactId = "${projectArtifactId}-deployment"
            version = projectVersion

            from(components["java"])
        }
    }
}

dependencies {

    implementation("io.quarkus:quarkus-bom:${quarkusPlatformVersion}")
    implementation("io.quarkus:quarkus-core-deployment:${quarkusPlatformVersion}")
    implementation("io.quarkus:quarkus-arc-deployment:${quarkusPlatformVersion}")
    implementation("io.quarkus:quarkus-undertow-deployment:${quarkusPlatformVersion}")

    implementation("dev.techyon.arangodb:arangodb-client:${projectVersion}")

    implementation("io.quarkus:quarkus-extension-processor:${quarkusPlatformVersion}")
    annotationProcessor("io.quarkus:quarkus-extension-processor:${quarkusPlatformVersion}")
    kapt("io.quarkus:quarkus-extension-processor:${quarkusPlatformVersion}")

}

2 Important things to notice here:

 – artifact id by quarkus convention has -deployment suffix when publishing.

 – runtime module needs to be added to dependencies (    implementation(“dev.techyon.arangodb:arangodb-client:${projectVersion}”)) and package name is set in settings.gradle.kts of parent module

Let’s move to runtime module and it’s build.gradle.kts:

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

val quarkusPlatformGroupId: String by project;
val quarkusExtensionArtifactId: String by project;
val quarkusPlatformArtifactId: String by project;
val quarkusPlatformVersion: String by project;

val kotlinVersion: String by project;
val junitVersion: String by project;
val junitLauncherVersion: String by project;
val http4kVersion: String by project;
val striktVersion: String by project;
val pesticideVersion: String by project;


val projectGroup: String by project;
val projectVersion: String by project;
val projectArtifactId: String by project;
group = projectGroup
version = projectVersion

plugins {

    id("io.quarkus.extension")
    kotlin("jvm")
    kotlin("kapt")
}

publishing {
    publications {
        create<MavenPublication>("maven") {
            groupId = projectGroup
            artifactId = projectArtifactId
            version = projectVersion

            from(components["java"])
        }
    }
}

dependencies {
    implementation(platform("io.quarkus:quarkus-bom:${quarkusPlatformVersion}"))
    implementation("io.quarkus:quarkus-arc")
    implementation("io.quarkus:quarkus-core")
    implementation("io.quarkus:quarkus-kotlin")
    implementation("io.quarkus:quarkus-undertow")

    implementation("io.quarkus:quarkus-extension-processor:${quarkusPlatformVersion}")
    annotationProcessor("io.quarkus:quarkus-extension-processor:${quarkusPlatformVersion}")
    kapt("io.quarkus:quarkus-extension-processor:${quarkusPlatformVersion}")

}

Now we can get to integrating ArangoDB with quarkus. But first what would be our requirements? 

1. I would like to be able to define default ArangoDB connection, as well as multiple named connections
2. I would like those connections to have seperate injectable beans. 
3. I would like all of these features to work with native build.
4. I would like SSL to be supported

 

 

Quarkus extensions, have an amazing way of wiring up things toghether in series of multiple build steps, exactly like in pipelines. 
Each step needs to be annotated with @BuildStep annotation, and if build step is emitting any items required for further work we, BuildProducer class can be used to pass results from one build step to another. 

For example, here is how we emit build item, with name of an extension, so it will be printed upon Quarkus startup, and prepares additional beans:

class ArangoClientProcessor {

  val ARANGO_CLIENT = DotName.createSimple(ArangoDB::class.java.name);
  val ARANGO_CLIENT_ANNOTATION = DotName.createSimple(ArangoClientName::class.java.name);

  @BuildStep
  fun feature(): FeatureBuildItem {

    return FeatureBuildItem(FEATURE)
  }

  @BuildStep
  fun additionalBeans(additionalBeans: BuildProducer<AdditionalBeanBuildItem>) {
    // add the @ArangoClientName class otherwise it won't registered as a qualifier
    additionalBeans.produce(AdditionalBeanBuildItem.builder().addBeanClass(ArangoClientName::class.java).build())
    // make ArangoClients an unremoveable bean
    additionalBeans.produce(
      AdditionalBeanBuildItem.builder().addBeanClasses(ArangoClients::class.java).setUnremovable().build()
    )
  }
}

Now, we to meet our requirements, we need to find out what beans we are suposed to create for ArangoDB connection. For that purpose, in runtime module I defined ArangoClientName annotation, in case we wanted to use a named connection:

@Target(
  AnnotationTarget.TYPE,
  AnnotationTarget.FUNCTION,
  AnnotationTarget.FIELD,
  AnnotationTarget.PROPERTY
)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@Qualifier
annotation class ArangoClientName(
  /**
   * Specify the name of the connection.
   *
   * @return the value
   */
  val value: String = ""
)

And we need to discover all places in whole index where our annotation with custom name was used, and with what value:

class ArangoClientProcessor {

  ...
  
  @BuildStep
  fun arangoClientNames(
    indexBuildItem: CombinedIndexBuildItem,
    arangoClientName: BuildProducer<ArangoClientNameBuildItem>
  ) {
    val values: MutableSet<String> = HashSet()
    val indexView: IndexView = indexBuildItem.getIndex()
    addArangoClientNameValues(ARANGO_CLIENT_ANNOTATION, indexView, values)
    for (value in values) {
      arangoClientName.produce(ArangoClientNameBuildItem(value))
    }
  }
  
  private fun addArangoClientNameValues(annotationName: DotName, indexView: IndexView, values: MutableSet<String>) {
    val arangoClientAnnotations: Collection<AnnotationInstance> = indexView.getAnnotations(annotationName)
    for (annotation in arangoClientAnnotations) {
      values.add(annotation.value().asString())
    }
  }
  
  ...
  
 }
class ArangoClientProcessor {

  ...
  
  @BuildStep
  fun arangoClientNames(
    indexBuildItem: CombinedIndexBuildItem,
    arangoClientName: BuildProducer<ArangoClientNameBuildItem>
  ) {
    val values: MutableSet<String> = HashSet()
    val indexView: IndexView = indexBuildItem.getIndex()
    addArangoClientNameValues(ARANGO_CLIENT_ANNOTATION, indexView, values)
    for (value in values) {
      arangoClientName.produce(ArangoClientNameBuildItem(value))
    }
  }
  
  private fun addArangoClientNameValues(annotationName: DotName, indexView: IndexView, values: MutableSet<String>) {
    val arangoClientAnnotations: Collection<AnnotationInstance> = indexView.getAnnotations(annotationName)
    for (annotation in arangoClientAnnotations) {
      values.add(annotation.value().asString())
    }
  }
  
  ...
  
 }

And finally we can generate syntetic beans. Based on client names, we can fetch apropriate configs for corresponding clients.  For this purpose we use:

    syntheticBeanBuildItemBuildProducer: BuildProducer<SyntheticBeanBuildItem?>,

And emit as built items as follows – full implementation will be available at later point on github. Althought Quarkus repository has a good amount of examples how to produce syntetic beans.

class ArangoClientProcessor {

  ...
  
  @Record(ExecutionTime.RUNTIME_INIT)
  @BuildStep
  fun generateClientBeans(
    recorder: ArangoClientRecorder,
    registrationPhase: BeanRegistrationPhaseBuildItem,
    arangoClientNames: List<ArangoClientNameBuildItem>,
    arangoClientBuildTimeConfig: ArangoClientBuildTimeConfig,
    arangodbConfig: ArangoConfig,
    syntheticBeanBuildItemBuildProducer: BuildProducer<SyntheticBeanBuildItem?>,
    vertxBuildItem: VertxBuildItem
  ) {

      ...
      
      
      syntheticBeanBuildItemBuildProducer.produce(
        createBlockingSyntheticBean(
          recorder,
          arangodbConfig,
          arangoClientBuildTimeConfig,
          false,
          ArangoClientBeanUtil.DEFAULT_ARANGOCLIENT_NAME, false
        )
      )
      
      ...
  }
  ...
 }

And at the end I created a simple application, which has extension in dependencies for testing injection of beans:

    implementation("dev.techyon.arangodb:arangodb-client:0.0.1")
@ApplicationScoped
@Path("/hello")
class TestsResource {

  @field:Inject
  lateinit var testClient: ArangoDB;

  @field:Inject
  @field:ArangoClientName("named")
  lateinit var testClient2: ArangoDB;

  @GET
  @Produces(MediaType.APPLICATION_JSON)
  fun hello2(): String {
    testClient.db(DbName.of("SomeDb")).create();
    testClient2.db(DbName.of("SomeDb2")).create();

    return "hello"
  }
}

And configuration in application.properties looks as follows:

quarkus.arangodb.host=localhost
quarkus.arangodb.port=8530
quarkus.arangodb.user=root
quarkus.arangodb.password=rootpassword
quarkus.arangodb.use-ssl=true

quarkus.arangodb.named.host=127.0.0.1
quarkus.arangodb.named.port=8530
quarkus.arangodb.named.user=root
quarkus.arangodb.named.password=rootpassword
quarkus.arangodb.named.use-ssl=true

And end results are pretty astonishing.

gradle testNative -Dquarkus.package.type=native

One slight disadvantage is that compilation time to native takes some time, but if you are aiming for minimal start time, it is worth it. Also to my surprise it wasn’t even that hard to enable SSL in native mode for Arango. 

 

ArangoDB

ArangoDB is a multi-model database. It supports both documents (like MongoDB) and graphs which is pretty interesting offering. It has easy to learn query language called AQL (Arango Query Language). More about AQL can be found here, or in official Arango team Youtube chanel. From my perspective, the major disadvantage of it, is that there is not that many frameworks supporting it. One worth mentioning is Spring Data, however queries still needs to be written using AQL, unlike for Neo4j where data can be modeled with OGM available.

Adrian Jutrowski

Software Developer