SAAJ, CXF, Camel and SOAP client samples

Author
Damian
Terlecki
39 minutes read
Java

When dealing with large legacy systems that typically have monolithic architecture leading to slower delivery speed, it's helpful to have a trusted tool for implementing temporary integration solutions. Camel's support for multiple protocols and data formats enables easy integration with diverse systems. In addition, the standalone mode does not require interference with the existing infrastructure. It makes Camel ideal for building temporary solutions, for which it brings various integration patterns like polling, splitter, throttle, circuit breaker, and many others.

SOAP in the context of older systems is one of the more popular (in terms of frequency of occurrence) data exchange protocols. In combination with XML, the implementation of communication with such a web service is often not as fast as with a typical REST architecture based on the HTTP protocol. Even more so when you need a quick temporary solution prepared from scratch.

SAAJ

While Java offers a set of interfaces called SAAJ (SOAP with Attachments API for Java) for SOAP communication, it is a low-level API under the javax.xml.soap package. To be clear, it's not very handy for complex request and response structures. It also doesn't have a validation feature for XML/XSD documents.

But let's look at some examples. We have a server with a SOAP web service from my samples repository https://github.com/t3rmian/jmeter-samples. The service listens to and responds to sample requests:

<!--getUser_4_smith_request.xml Request-->
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
    <soap:Body>
        <ns2:getUserRequest xmlns:ns2="https://github.com/t3rmian/jmeter-samples">
        <ns2:id>4</ns2:id>
        </ns2:getUserRequest>
    </soap:Body>
</soap:Envelope>

<!--Response-->
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
<SOAP-ENV:Header/>
<SOAP-ENV:Body>
    <ns2:getUserResponse xmlns:ns2="https://github.com/t3rmian/jmeter-samples">
        <ns2:id>4</ns2:id>
        <ns2:name>smith</ns2:name>
        <ns2:email>smith@example.com</ns2:email>
        <ns2:registrationDate>2023-03-31T15:40:09.825+02:00</ns2:registrationDate>
    </ns2:getUserResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>

The classes needed to implement communication from the client level using SAAJ and self-descriptive code might look like this:

import javax.xml.namespace.QName;
import javax.xml.soap.MessageFactory;
import javax.xml.soap.SOAPBody;
import javax.xml.soap.SOAPBodyElement;
import javax.xml.soap.SOAPConnection;
import javax.xml.soap.SOAPConnectionFactory;
import javax.xml.soap.SOAPConstants;
import javax.xml.soap.SOAPElement;
import javax.xml.soap.SOAPEnvelope;
import javax.xml.soap.SOAPException;
import javax.xml.soap.SOAPMessage;
import org.junit.Test;

import java.net.MalformedURLException;
import java.net.URL;

import static org.junit.Assert.assertEquals;

public class CamelSoapClientIT {
    static final String NS = "https://github.com/t3rmian/jmeter-samples";
    
    @Test
    public void given_existingUserSmith_When_getUserBySmithId_usingSAAJ_Then_returnSmithName() throws SOAPException, MalformedURLException {
        SOAPMessage soapMessage = createGetUserSAAJMessage(EXISTING_USER_ID);
        URL endpointUrl = new URL(System.getProperty("wsEndpointAddress"));
        SOAPConnectionFactory soapConnectionFactory = SOAPConnectionFactory.newInstance();
        SOAPConnection soapConnection = soapConnectionFactory.createConnection();

        SOAPMessage response = soapConnection.call(soapMessage, endpointUrl);

        SOAPBody soapBody = response.getSOAPBody();
        assertEquals(soapBody.getElementsByTagNameNS(NS, "id")
                .item(0).getTextContent(), String.valueOf(EXISTING_USER_ID));
        assertEquals(soapBody.getElementsByTagNameNS(NS, "name")
                .item(0).getTextContent(), EXISTING_USER_NAME);
    }

    static SOAPMessage createGetUserSAAJMessage(long userId) throws SOAPException {
        MessageFactory messageFactory = MessageFactory.newInstance(SOAPConstants.SOAP_1_1_PROTOCOL);
        SOAPMessage soapMessage = messageFactory.createMessage();
        SOAPEnvelope envelope = soapMessage.getSOAPPart().getEnvelope();
        SOAPBody body = envelope.getBody();
        SOAPBodyElement getUserRequest = body.addBodyElement(new QName(NS, "getUserRequest"));
        SOAPElement id = getUserRequest.addChildElement(new QName(NS, "id"));
        id.setTextContent(String.valueOf(userId));
        return soapMessage;
    }
}

To use this API effectively, remember to utilize namespaces during element creation and when searching for elements in the response. Omitting them will either result in the rejection of the request or in the inability to find the element when reading, which can be disastrous for optional parts.

In the ongoing evolution of the Java platform, SAAJ has been deprecated and then removed from Java SE 11 and no longer ships with the JDK. This package has been extracted into a separate artifact javax.xml.soap:javax.xml.soap-api, which you can find in the Maven Central Repository.

JAX-WS and Apache CXF

CXF (an acronym created from the merge of Celtix and XFire products from 2006) as a framework for building web services and clients enables much faster implementation of communication, especially when dealing with advanced structures and extensions, for example, WSS. In the scope of standard interfaces (up to Java 11), it also serves as a provider for JAX-WS (Java API for XML Web Services).

In terms of tooling, Apache CXF offers a Maven plugin org.apache.cxf:cxf-codegen for generating Java classes representing a web service from WSDL/XSD files.

<plugin>
    <groupId>org.apache.cxf</groupId>
    <artifactId>cxf-codegen-plugin</artifactId>
    <version>3.5.5</version>
    <executions>
        <execution>
            <id>generate-sources</id>
            <phase>generate-sources</phase>
            <configuration>
                <wsdlOptions>
                    <wsdlOption>
                        <wsdl>
                            ${basedir}/src/main/resources/users.wsdl
                        </wsdl>
                    </wsdlOption>
                </wsdlOptions>
            </configuration>
            <goals>
                <goal>wsdl2java</goal>
            </goals>
        </execution>
    </executions>
</plugin>

An IDE (e.g., IntelliJ) typically does not automatically invoke this plugin. You can use Maven directly or manually include this step in your runtime configuration.

To implement the client, you will need artifacts from the org.apache.cxf group:

  • cxf-core – classes responsible for processing, mapping, and protocol handling;
  • cxf-rt-frontend-jaxws – client classes;
  • cxf-rt-databinding-jaxb – XML mapping;
  • cxf-rt-transports-http – HTTP communication;
  • cxf-rt-transports-soap – SOAP support;
  • cxf-rt-features-logging – message logging extension.

If you are facing an error like Caused by: java.lang.NullPointerException: Cannot invoke "java.lang.reflect.Method.invoke(Object, Object[])" because "com.sun.xml.bind.v2 .runtime.reflect.opt.Injector.defineClass" is null, take a look at the solutions from https://github.com/eclipse-ee4j/jaxb-ri/issues/1197.
My sample workaround was to add com.sun.xml.bind:jaxb-xjc artifact related to generating Java code from XML files in addition to the transitive JAXB jaxb-runtime dependency. Although the classes are already generated in the provided examples, running them on a different Java version can cause an internal error due to the JAXB implementation attempting to optimize access using missing classes.

This time the code is much simpler. We use the DTOs generated by the plugin. Additionally, by referring to the CXF implementation, you have the opportunity to configure the client (timeout, logging, SSL):

import https.github_com.t3rmian.jmeter_samples.CommonFault;
import https.github_com.t3rmian.jmeter_samples.ObjectFactory;
import https.github_com.t3rmian.jmeter_samples.User;
import https.github_com.t3rmian.jmeter_samples.UserPayload;
import https.github_com.t3rmian.jmeter_samples.Users;
import https.github_com.t3rmian.jmeter_samples.UsersService;
import org.apache.cxf.ext.logging.LoggingFeature;
import org.apache.cxf.frontend.ClientProxy;
import org.apache.cxf.transport.http.HTTPConduit;

import javax.xml.ws.BindingProvider;
import javax.xml.ws.WebServiceFeature;

import static org.junit.Assert.assertEquals;

public class CamelSoapClientIT {
    @Test
    public void given_existingUserSmith_When_getUserBySmithId_usingCxf_Then_returnSmithName() throws CommonFault {
        UserPayload userPayload = new UserPayload();
        userPayload.setId(EXISTING_USER_ID);

        Users users = new UsersService().getUsersSoap11(getCxfLoggingFeature());
        ((HTTPConduit) ClientProxy.getClient(users).getConduit())
                .getClient().setReceiveTimeout(1000L);
        BindingProvider bindingProvider = (BindingProvider) users;
        bindingProvider.getRequestContext()
                .put(BindingProvider.ENDPOINT_ADDRESS_PROPERTY,
                        System.getProperty("wsEndpointAddress"));
        User user = users.getUser(userPayload);

        assertEquals(EXISTING_USER_NAME, user.getName());
        assertEquals(EXISTING_USER_ID, user.getId());
    }

    static WebServiceFeature getCxfLoggingFeature() {
        LoggingFeature loggingFeature = new LoggingFeature();
        loggingFeature.setPrettyLogging(true);
        loggingFeature.setVerbose(true);
        loggingFeature.setLogMultipart(true);
        return loggingFeature;
    }
}

Camel and SOAP

Camel complements the communication with SOAP web services through its integration patterns. Thanks to them, you can pool the data from the database or implement a bridge between two different endpoints. As of version 3.18, the dependencies required for integration with CXF have been split (CAMEL-9627) into several smaller packages. Earlier versions, also 2.x (which supports Java 8), only required a single artifact, i.e., org.apache.camel:camel-cxf. Any artifact with soap (3.18+) in its name will work for our examples.

The concept of URI paths for the flow of messages between endpoints allows for many configuration options. Let's consider the data formats that define how to construct the message. You can choose from one of the four formats.

  • RAW/MESSAGE - allows for sending and receiving messages in raw form, e.g., String;
  • POJO - POJO as in the CXF example;
  • PAYLOAD - org.w3c.dom XML documents representing SOAP body that you can load into camel CxfPayload;
  • CXF_MESSAGE – SOAP envelope from the javax.xml.soap package.

Each data format has its own set of rules, such as the application of CXF interceptors. You can read about them in the documentation. However, in order to start, it is crucial to examine some examples of client implementations.

Yet another way is to wrap CXF direct communication in a bean and bind it to a camel track.

RAW/MESSAGE

The RAW format works well for sending messages in the form of text, loaded, for example, from a file. You will receive a text in response which, while quick to implement, may not be suitable for accessing it for other purposes.

However, before you send the data, you need to create a URI using which Camel will send the data to the right place.

import org.apache.camel.CamelContext;
import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.component.cxf.common.DataFormat;
import org.apache.camel.impl.DefaultCamelContext;

import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.MatcherAssert.assertThat;

public class CamelSoapClientIT {

    @Test
    public void given_existingUserSmith_When_getUserBySmithId_usingCamelCxfRawFormat_Then_returnSmithName() throws Exception {
        String message = getTestResourceContent("getUser_4_smith_request.xml");

        try (CamelContext camelContext = new DefaultCamelContext()) {
            camelContext.addRoutes(new RouteBuilder() {
                @Override
                public void configure() {
                    from("direct:getUser")
                            .log("Body before cxf route: ${body}")
                            .setHeader(CxfConstants.OPERATION_NAME, constant("getUser"))
                            .to(getCxfUriWithVerboseLoggingOfDataFormat(DataFormat.RAW))
                            .log("Body after cxf route: ${body}");
                }
            });
            camelContext.start();
            String response = camelContext.createProducerTemplate()
                    .requestBody("direct:getUser", message, String.class);

            assertThat(response, containsString(String.valueOf(EXISTING_USER_ID)));
            assertThat(response, containsString(EXISTING_USER_NAME));
        }
    }

    static String getCxfUriWithVerboseLoggingOfDataFormat(DataFormat dataFormat) {
        return getCxfUri(dataFormat) +
                "&cxfConfigurer=#class:io.github.t3rmian.jmetersamples.CxfTimeoutConfigurer";
    }

    static String getCxfUri(DataFormat dataFormat) {
        return "cxf://{{wsEndpointAddress}}/UsersSoap11?dataFormat=" + dataFormat;
    }
}

The URI starts with the cxf component auto-registered with the included dependency. The expression {{wsEndpointAddress}} allows you to load the environment variable or a Java Property, e.g., http://localhost:8080/ws/users. The cxfConfigurer will be described later. For now, just note that this is a configuration for a generic CXF dispatch mode, useful for sending arbitrary structures not bound to a specific schema.

POJO

The POJO format, in its simplicity, allows the use of classes generated by the cxf-codegen-plugin plugin. However, you need to adjust the URI configuration so that the proper binding can be made.

import https.github_com.t3rmian.jmeter_samples.UserPayload;
import https.github_com.t3rmian.jmeter_samples.User;

import static org.junit.Assert.assertEquals;

public class CamelSoapClientIT {
    @Test
    public void given_existingUserSmith_When_getUserBySmithId_usingCamelCxfPOJOFormat_Then_returnSmithName() throws Exception {
        UserPayload userPayload = new ObjectFactory().createUserPayload();
        userPayload.setId(EXISTING_USER_ID);

        //... .setHeader(CxfConstants.OPERATION_NAME, constant("getUser"))
        User user = camelContext.createProducerTemplate()
                .requestBody("direct:getUser", userPayload, User.class);

        assertEquals(EXISTING_USER_NAME, user.getName());
        assertEquals(EXISTING_USER_ID, user.getId());
    }

    static String getCxfUri(DataFormat dataFormat) {
        return "cxf://{{wsEndpointAddress}}"
                + "?wsdlURL=classpath:users.wsdl"
                + "&serviceClass=https.github_com.t3rmian.jmeter_samples.Users"
                + "&dataFormat=" + dataFormat;
    }
}

Under wsdlURL path, there is a classpath file describing the SOAP service. Next, the serviceClass parameter determines the client implementation, and it is obligatory only for the POJO format.

Based on the CxfConstants.OPERATION_NAME and CxfConstants.OPERATION_NAMESPACE headers, Camel/CXF will associate the message with the appropriate operation. Otherwise, it will make an educated guess. Similarly, you can set the SOAPAction HTTP header with SoapBindingConstants.SOAP_ACTION (SOAP 1.1) in case its required by the server (e.g., multiple operations under one service). The equivalent parameter for SOAP 1.2 is SoapBindingConstants.PARAMETER_ACTION.

PAYLOAD

Preparing a message in the PAYLOAD format is more challenging. Start by building an XML document, and load it into a CxfPayload. Likewise, pass the headers as the first argument to the constructor. Read the response in a typical XML way, and look for elements in the response by a given name and a namespace.

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.apache.camel.component.cxf.common.CxfPayload;
import org.apache.cxf.binding.soap.SoapHeader;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.dom.DOMSource;
import java.util.ArrayList;
import java.util.List;

import static org.junit.Assert.assertEquals;

public class CamelSoapClientIT {
    @Test
    public void given_existingUserSmith_When_getUserBySmithId_usingCamelCxfPayloadFormat_Then_returnSmithName() throws Exception {
        List<Source> outElements = new ArrayList<>();
        Document outDocument = createGetUserXmlDocument(EXISTING_USER_ID);
        outElements.add(new DOMSource(outDocument.getDocumentElement()));
        CxfPayload<SoapHeader> payload = new CxfPayload<>(null, outElements, null);
        
        //...
        CxfPayload<Element> response = camelContext.createProducerTemplate()
                .requestBody("direct:getUser", payload, CxfPayload.class);
        Element getUserResponse = response.getBody().get(0);

        assertEquals(getUserResponse.getElementsByTagNameNS(NS, "id")
                .item(0).getTextContent(), String.valueOf(EXISTING_USER_ID));
        assertEquals(getUserResponse.getElementsByTagNameNS(NS, "name")
                .item(0).getTextContent(), EXISTING_USER_NAME);
    }

    static Document createGetUserXmlDocument(long existingUserId)
            throws ParserConfigurationException {
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        DocumentBuilder builder = factory.newDocumentBuilder();
        Document document = builder.newDocument();
        document.setXmlVersion("1.1");

        Element rootElement = document.createElementNS(NS, "getUserRequest");
        document.appendChild(rootElement);

        Element childElement = document.createElementNS(NS, "id");
        childElement.appendChild(document.createTextNode(String.valueOf(existingUserId)));
        rootElement.appendChild(childElement);

        return document;
    }
}

CXF_MESSAGE

The CXF_MESSAGE format looks quite similar to PAYLOAD, but this time the classes used are from the SOAP package (SAAJ):

import https.github_com.t3rmian.jmeter_samples.ObjectFactory;
import https.github_com.t3rmian.jmeter_samples.UserPayload;
import javax.xml.bind.JAXBContext;
import javax.xml.soap.MessageFactory;
import javax.xml.soap.SOAPEnvelope;
import javax.xml.soap.SOAPException;
import javax.xml.soap.SOAPMessage;

import static org.junit.Assert.assertEquals;

public class CamelSoapClientIT {
    @Test
    public void given_existingUserSmith_When_getUserBySmithId_usingCamelCxfCxfMessageFormat_Then_returnSmithName() throws Exception {
        SOAPMessage soapMessage = createGetUserSOAPMessage(EXISTING_USER_ID);

        //...
        SOAPMessage response = camelContext.createProducerTemplate()
                .requestBody("direct:getUser", soapMessage, SOAPMessage.class);

        SOAPBody soapBody = response.getSOAPBody();
        assertEquals(soapBody.getElementsByTagNameNS(NS, "id")
                .item(0).getTextContent(), String.valueOf(EXISTING_USER_ID));
        assertEquals(soapBody.getElementsByTagNameNS(NS, "name")
                .item(0).getTextContent(), EXISTING_USER_NAME);
    }

    static SOAPMessage createGetUserSOAPMessage(long userId) 
            throws SOAPException, JAXBException {
        MessageFactory messageFactory = MessageFactory
                .newInstance(SOAPConstants.SOAP_1_1_PROTOCOL);
        SOAPMessage soapMessage = messageFactory.createMessage();
        SOAPEnvelope envelope = soapMessage.getSOAPPart().getEnvelope();
        ObjectFactory objectFactory = new ObjectFactory();
        UserPayload userPayload = objectFactory.createUserPayload();
        userPayload.setId(userId);
        JAXBContext.newInstance(UserPayload.class).createMarshaller()
                .marshal(objectFactory.createGetUserRequest(userPayload),
                        envelope.getBody());
        // or handcraft: envelope.getBody().addBodyElement().addChildElement()...
        return soapMessage;
    }
}

Instead of manually creating elements as in the SAAJ example, this time, let's use the JAXB interface to convert the POJO to a SOAP body.

CXF configuration in Camel

The Camel cxfConfigurer URI configuration option allows you to plug into the CXF configuration. With it, you can set connection options, additionally secure communication (e.g., using mTLS, whereas Basic Auth can be configured via URI), or set interceptors.

package io.github.t3rmian.jmetersamples;

import org.apache.camel.component.cxf.jaxws.CxfConfigurer;
import org.apache.cxf.endpoint.Client;
import org.apache.cxf.endpoint.Server;
import org.apache.cxf.ext.logging.LoggingInInterceptor;
import org.apache.cxf.ext.logging.LoggingOutInterceptor;
import org.apache.cxf.ext.logging.event.PrettyLoggingFilter;
import org.apache.cxf.ext.logging.slf4j.Slf4jVerboseEventSender;
import org.apache.cxf.frontend.AbstractWSDLBasedEndpointFactory;
import org.apache.cxf.transport.http.HTTPConduit;
import org.apache.cxf.transports.http.configuration.HTTPClientPolicy;

public class CxfTimeoutConfigurer implements CxfConfigurer {

    @Override
    public void configure(AbstractWSDLBasedEndpointFactory factoryBean) {}

    @Override
    public void configureClient(Client client) {
        HTTPConduit httpConduit = (HTTPConduit) client.getConduit();
        httpConduit.setClient(getHttpClientPolicy());
        LoggingInInterceptor loggingInInterceptor = new LoggingInInterceptor(
                new PrettyLoggingFilter(new Slf4jVerboseEventSender())
        );
        client.getOutInterceptors().add(loggingInInterceptor);
        LoggingOutInterceptor loggingOutInterceptor = new LoggingOutInterceptor(
                new PrettyLoggingFilter(new Slf4jVerboseEventSender())
        );
        client.getOutInterceptors().add(loggingOutInterceptor);
    }

    static HTTPClientPolicy getHttpClientPolicy() {
        HTTPClientPolicy httpClientPolicy = new HTTPClientPolicy();
        httpClientPolicy.setConnectionTimeout(1000);
        httpClientPolicy.setConnectionRequestTimeout(1000);
        httpClientPolicy.setReceiveTimeout(1000);
        return httpClientPolicy;
    }

    @Override
    public void configureServer(Server server) {}
}

In the above configuration, I have enabled logging that is typical for verbose settings. Through the Camel URI, you have access to the loggingFeatureEnabled=true option, which unfortunately does not log the content of the message. Additional logging proves valuable when implementing from scratch. Relying on Camel's log() method does not show the final message, only the object before (sending) / after mapping.

An alternative configuration option is the cxf.xml classpath file. However, this option requires Spring dependencies. In this respect, you only need the org.springframework:spring-context.

Standalone Camel

The above samples lack something in terms of the final solution. In its simplicity, the dependency org.apache.camel:camel-main allows all routes to start while waiting for the program termination. In addition, it offers automatic configuration scanning. Below is a short example:

package io.github.t3rmian.jmetersamples;

import https.github_com.t3rmian.jmeter_samples.ObjectFactory;
import https.github_com.t3rmian.jmeter_samples.User;
import https.github_com.t3rmian.jmeter_samples.UserPayload;
import org.apache.camel.ProducerTemplate;
import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.component.cxf.common.DataFormat;
import org.apache.camel.component.cxf.common.message.CxfConstants;
import org.apache.camel.main.BaseMainSupport;
import org.apache.camel.main.Main;
import org.apache.camel.main.MainListenerSupport;

public class CamelSoapClient {
    public static void main(String[] args) throws Exception {
        Main main = new Main(CamelSoapClient.class);
        main.addMainListener(new MainListenerSupport() {
            @Override
            public void afterStart(BaseMainSupport main) {
                UserPayload userPayload = new ObjectFactory().createUserPayload();
                userPayload.setId(EXISTING_USER_ID);
                System.out.println("Requesting user " + userPayload.getId());
                try (ProducerTemplate producerTemplate = main
                        .getCamelContext().createProducerTemplate()) {
                    User user = producerTemplate.requestBody("direct:getUser",
                            userPayload, User.class);
                    System.out.println("Response contains " + user.getName());
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("Camel is running and waiting for SIGINT...");
            }
        });
        System.exit(main.run(args));
    }

    @SuppressWarnings("unused") // auto discovered by org.apache.camel.main.Main
    public static class MyRouteBuilder extends RouteBuilder {
        @Override
        public void configure() {
            from("direct:getUser")
                    .setHeader(CxfConstants.OPERATION_NAME, constant("getUser"))
// An alternative to cxfConfigurer URI parameter:
// .setHeader(Client.REQUEST_CONTEXT, () -> new HashMap<String, Object>() {{
//      this.put(HTTPClientPolicy.class.getName(),
//          CxfTimeoutConfigurer.getHttpClientPolicy());
// }})
                    .to(getCxfUri(DataFormat.POJO) + "&loggingFeatureEnabled=true")
                    .process(exchange -> exchange.getIn().setBody(exchange.getIn().getBody()));
        }
    }
}

Camel auto-detects MyRouteBuilder class during the scanning process, and after startup, you can use the path without explicitly registering it.

At first, I attempted to implement the CamelConfiguration interface and add the configuration using the new Main().configure().addConfiguration(CamelSoapClient.class) method. However, it was ignored because the class contained a main method. Even when I used the new Main(CamelSoapClient.class), it still failed if my class implemented the CamelConfiguration interface:

public class CamelSoapClient {
    @Override
    public void configure(CamelContext camelContext) throws Exception {
        camelContext.addRoutes(new RouteBuilder() {
            // ...
        });
    }
}

Camel detected a non-static inner class and tried to initialize it for configuration. Then it threw a rather enigmatic java.lang.NoSuchMethodException: io.github.t3rmian.jmetersamples.CamelSoapClient$2.<init>() error.

So we're back to the original example that works. Here I use MainListenerSupport, which allows you to call additional code after the startup of the context - e.g., a test call. Then with the SIGINT signal, you can terminate such an application.

Fat JAR

The final step in preparing a standalone application is to build an artifact containing all dependencies, the so-called fat JAR. For such a build, you need the org.apache.camel:camel-maven-plugin plugin, which in the prepare-fatjar goal, prepares Camel dependencies for such a package. For instance, it creates an UberTypeConverterLoader file by combining multiple files and storing it in the META-INF folder to ensure the converters are loaded correctly.

However, this plugin does not generate the resulting artifact. To create an artifact, you need another plugin, e.g., maven-assembly-plugin.

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-assembly-plugin</artifactId>
    <configuration>
        <descriptorRefs>
            <descriptorRef>jar-with-dependencies</descriptorRef>
        </descriptorRefs>
        <archive>
            <manifest>
                <mainClass>io.github.t3rmian.jmetersamples.CamelSoapClient</mainClass>
            </manifest>
        </archive>
    </configuration>
    <executions>
        <execution>
            <id>make-assembly</id>
            <phase>package</phase>
            <goals>
                <goal>single</goal>
            </goals>
        </execution>
    </executions>
</plugin>

Now, if you're using CXF dependencies, they need to be prepared in the same way as Camel dependencies. E.g., among other things, you need an appended bus-extensions.txt file in the META-INF folder. The maven-shade-plugin plugin works great for this, and a proper configuration for CXF can be found in the documentation. All you have to do is provide your own starting class in the place of the mainClass.

Summary

Now that you know how to build SOAP queries, read responses, and configure interceptors, it's up to you to implement the business logic. When adding dependencies, remember the evolution of the Java platform. In subsequent versions, some packages have been excluded from the JDK to separate artifacts. In addition, the newest versions and plugins move from the JEE javax naming to Jakarta package names (Camel 4.x, CXF 4.x, JAXB 4.x). Mixing both packages often leads to problems, especially when we do not verify the generated code.

For example, if you use Jakarta annotations in a JEE runtime, there's no guarantee that they will be parsed accurately. Nested query elements can end up stripped of their namespaces even with the elementFormDefault = XmlNsForm.QUALIFIED package configuration. If you suspect this, print the schema generated from JAXB annotated classes to verify this issue:

import https.github_com.t3rmian.jmeter_samples.UserPayload;
import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBException;
import javax.xml.bind.SchemaOutputResolver;
import javax.xml.transform.Result;
import javax.xml.transform.stream.StreamResult;

import java.io.IOException;
import java.io.PrintWriter;

public class CamelSoapClientIT {
    static {
        printSchema();
    }

    private static void printSchema() throws JAXBException, IOException {
        JAXBContext jaxbContext = JAXBContext.newInstance(UserPayload.class);
        jaxbContext.generateSchema(new SchemaOutputResolver() {
            @Override
            public Result createOutput(String namespaceUri, String suggestedFileName) {
                StreamResult streamResult = new StreamResult(new PrintWriter(System.out) {
                    @Override
                    public void close() {
                    }
                });
                streamResult.setSystemId(suggestedFileName);
                return streamResult;
            }
        });
    }
}