Quarkus Extension Custom Dev Service - Partie III

In the previous article, we discussed how to automatically launch a container that exposes an API connected to its own database using a Quarkus extension. Now, we will explore how to communicate with this API from our main application.

To Call an API, We Need Its URL!

In the previous article, we saved the access URL for our API in a RunningDevService. Now, we’ll use this URL to inject this value into our main application. To do this, we will create a new class named DemoClientApiProcessor:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@BuildSteps(onlyIf = { GlobalDevServicesConfig.Enabled.class })
public class DemoClientApiProcessor {

    @Record(ExecutionTime.RUNTIME_INIT)
    @BuildStep
    public void createSDK(List<DevServicesResultBuildItem> devServicesResultBuildItem, DemoServiceRecorder recorder, BuildProducer<SyntheticBeanBuildItem> syntheticBeanBuildItemBuildProducer) {
        // Retrieve config set Inside DemoExtensionProcessor
        String apiUrl = devServicesResultBuildItem.stream().filter(devService -> devService.getName().equals(DemoServiceContainer.CONTAINER_NAME))
                .findFirst()
                .orElseThrow(() -> new IllegalStateException("Can't find Demo-service url"))
                .getConfig().get("url");

        syntheticBeanBuildItemBuildProducer.produce(SyntheticBeanBuildItem.configure(DemoConfig.class)
                .unremovable()
                .setRuntimeInit()
                .runtimeValue(recorder.createConfig("http://"+apiUrl))
                .done());
    }


    @BuildStep
    public void addSDKBeans(BuildProducer<AdditionalBeanBuildItem> additionalBeans, BuildProducer<AdditionalIndexedClassesBuildItem> additionalIndexedClasses) {
        additionalBeans.produce(new AdditionalBeanBuildItem(DemoService.class));
        additionalIndexedClasses.produce(new AdditionalIndexedClassesBuildItem(DemoService.class.getName()));
    }
}

The createSDK method, annotated with @BuildStep, takes a list of DevServicesResultBuildItem as a parameter. Quarkus will call this method after instantiating our database and API containers. The other parameters include a DemoServiceRecorder and a BuildProducer of SyntheticBeanBuildItem. Recorders and synthetic beans were already covered in a previous article. In summary, we retrieve the API URL from the devServicesResultBuildItem object and create a DemoConfig containing this URL, which will then be injectable.

The recorder is defined as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package info.techland.demo.extension.runtime;

import io.quarkus.runtime.RuntimeValue;
import io.quarkus.runtime.annotations.Recorder;



@Recorder
public class DemoServiceRecorder {

    public RuntimeValue<DemoConfig> createConfig(String url) {
        return new RuntimeValue<>(new DemoConfig(url));
    }
}

The recorder is defined in the runtime package of our extension. Its role is to instantiate our DemoConfig object with the URL passed as a parameter so that Quarkus can record the resulting bytecode.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package info.techland.demo.extension.runtime;

import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class DemoConfig {

    private String url;

    // Only for CDI injection
    @SuppressWarnings("unused")
    private DemoConfig() {}

    public DemoConfig(String url) {
        this.url = url;
    }

    public String getUrl() {
        return url;
    }

    public void setUrl(String url) {
        this.url = url;
    }
}

Our DemoConfig is just a bean containing the URL of our API once deployed as a devService.

1
2
3
4
5
@BuildStep
public void addSDKBeans(BuildProducer<AdditionalBeanBuildItem> additionalBeans, BuildProducer<AdditionalIndexedClassesBuildItem> additionalIndexedClasses) {
    additionalBeans.produce(new AdditionalBeanBuildItem(DemoService.class));
    additionalIndexedClasses.produce(new AdditionalIndexedClassesBuildItem(DemoService.class.getName()));
}

This step declares our syntheticBean as injectable in our final application.

Building a RestClient in Our Application

Now we have an instance of DemoConfig that is injectable into any class of our main application. Let’s start by creating an interface for our RestClient in our main application to call our Demo API.

1
2
3
4
5
6
7
8
9
package info.techland.demo.app;


@Path("/api")
public interface DemoService {

    @GET
    Set<Demo> fetchAll();
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
 * Somewhere in our principal application...
 */
@Path("")
public class AppResource {

    private DemoConfig demoConfig;
    private final DemoService demoService;

    public AppResource(DemoConfig demoConfig) {
        this.demoConfig = demoConfig;

        demoService = QuarkusRestClientBuilder.newBuilder()
            .baseUri(URI.create(demoConfig.getUrl()))
            .build(ExtensionsService.class);
    }

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public Set<Demo> fetchDemo() {
        return this.demoService.fetch();
    }
}

In our main application, we inject our DemoConfig object, which contains the URL of our API, and use it to configure a RestClient programmatically. Our fetchDemo method returns a Set of Demo objects, which is a simple POJO. Our main application is now automatically linked to our API. But this can be improved further. What if our RestClient wasn’t created by our main application, but by our extension and made injectable, like our DemoConfig?

Migrating the RestClient to Our Extension

Do this, it’s quite simple. First, we will move our DemoService class to our extension, within the runtime package. Next, we will create a DemoResource class responsible for creating our RestClient.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package info.techland.demo.extension.runtime;

import io.quarkus.rest.client.reactive.QuarkusRestClientBuilder;
import io.smallrye.mutiny.Uni;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;

import java.net.URI;
import java.util.List;

@Path("/")
public class DemoResource {

    private DemoService demoService;

    // Only for CDI injection
    @SuppressWarnings("unused")
    private DemoResource() {
    }

    public DemoResource(String url) {
        this.demoService = QuarkusRestClientBuilder.newBuilder()
                .baseUri(URI.create(url))
                .build(DemoService.class);
    }

    @GET
    public Set<Demo> fetchAsync() {
        return demoService.fetchAsync();
    }
}

The Demo POJO is also moved to our extension so that it’s available in any application using our extension.

Next, in our DemoServiceRecorder, we will add the instantiation of our DemoResource:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Recorder
public class DemoServiceRecorder {

    public RuntimeValue<DemoResource> createSDK(String url) {
        return new RuntimeValue<>(new DemoResource(url));
    }

    public RuntimeValue<DemoConfig> createConfig(String url) {
        return new RuntimeValue<>(new DemoConfig(url));
    }
}

The final step is to configure Quarkus to inject our DemoResource via a SyntheticBean in our DemoClientApiProcessor:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@BuildSteps(onlyIf = { GlobalDevServicesConfig.Enabled.class })
public class DemoClientApiProcessor {

    @Record(ExecutionTime.RUNTIME_INIT)
    @BuildStep
    public void createSDK(List<DevServicesResultBuildItem> devServicesResultBuildItem, DemoServiceRecorder recorder, BuildProducer<SyntheticBeanBuildItem> syntheticBeanBuildItemBuildProducer) {
        // Retrieve config set Inside DemoExtensionProcessor
        String apiUrl = devServicesResultBuildItem.stream().filter(devService -> devService.getName().equals(DemoServiceContainer.CONTAINER_NAME))
                .findFirst()
                .orElseThrow(() -> new IllegalStateException("Can't find Demo-service url"))
                .getConfig().get("url");

        syntheticBeanBuildItemBuildProducer.produce(SyntheticBeanBuildItem.configure(DemoConfig.class)
                .unremovable()
                .setRuntimeInit()
                .runtimeValue(recorder.createConfig("http://"+apiUrl))
                .done());

        syntheticBeanBuildItemBuildProducer.produce(SyntheticBeanBuildItem.configure(DemoResource.class)
                .unremovable()
                .setRuntimeInit()
                .runtimeValue(recorder.createSDK("http://"+apiUrl))
                .done());
    }


    @BuildStep
    public void addSDKBeans(BuildProducer<AdditionalBeanBuildItem> additionalBeans, BuildProducer<AdditionalIndexedClassesBuildItem> additionalIndexedClasses) {
        additionalBeans.produce(new AdditionalBeanBuildItem(DemoService.class));
        additionalIndexedClasses.produce(new AdditionalIndexedClassesBuildItem(DemoService.class.getName()));
    }

    /**
     * A jandex file is mandatory by the maven-quarkus-plugin to resolve CDI dependency in the package phase.
     * At runtime, we need to remove this beans because we provide our own via syntheticBean
     * @param buildExclusionsBuildItemBuildProducer Producer for beans that will be excluded
     */
    @BuildStep
    public void removeJandexDemoBean(BuildProducer<ExcludedTypeBuildItem> buildExclusionsBuildItemBuildProducer) {
        buildExclusionsBuildItemBuildProducer.produce(new ExcludedTypeBuildItem(DemoConfig.class.getName()));
        buildExclusionsBuildItemBuildProducer.produce(new ExcludedTypeBuildItem(DemoResource.class.getName()));
    }
}

Our RestClient is now usable via injection of a DemoResource in our application. Let’s update the code in our AppResource, which is now much simpler:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Path("")
public class AppResource {

    private final DemoResource demoResource;

    public AppResource(DemoResource demoResource) {
        this.demoResource = demoResource;
    }

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public Set<Demo> fetchDemo() {
        return this.demoResource.fetch();
    }
}

Conclusion

This concludes our series on using Quarkus extensions to develop custom dev services. For API consumers, usage is greatly simplified. A simple Maven import is all that’s required, providing a readily available SDK that simplifies calling our API. Future updates to our service are now as easy as updating the Maven dependency in the project. When there is a change in our API, it may be handled transparently by the SDK; otherwise, it will be detectable at compile time through a change in the Java method signature.

0%