SOAP client
SOAP is a protocol for exchanging XML messages, often used for integration with external systems over HTTP and a WSDL contract.
The soap-client module creates client implementations for interfaces annotated with jakarta.jws.WebService and registers them in the application graph.
Usually, such interfaces and related JAXB classes are generated from WSDL, for example with wsdl2java.
After generation, Kora creates the client implementation and connects it to an HTTP client, XML mapping, and telemetry.
Dependency¶
Dependency build.gradle:
Module:
Dependency build.gradle.kts:
Module:
Requires an HTTP client implementation (http-client-jdk, http-client-ok or http-client-apache)
and a configuration module (HOCON or YAML) to be present in the application.
The soap-client artifact already brings the jakarta / JAXB runtime the client needs, so no extra declarations are required:
org.glassfish.jaxb:jaxb-runtime—4.0.9jakarta.xml.ws:jakarta.xml.ws-api—4.0.3jakarta.xml.bind:jakarta.xml.bind-api—4.0.5commons-codec:commons-codec—1.22.1
If another plugin or dependency in the build pins different versions of these artifacts, align them so that exactly one JAXB runtime ends up on the classpath.
Description¶
The application is expected to already have interfaces annotated with jakarta.jws.WebService.
They can be written manually, but are usually created from WSDL by a separate tool, for example a Gradle plugin.
Based on such interfaces, the annotation processor (bundled in the annotation-processors / symbol-processors artifact) creates in the same package:
- A client implementation named
$<Interface>_SoapClientImplimplementing the@WebServiceinterface. Its constructor is(HttpClient, SoapClientTelemetryFactory, SoapServiceConfig, Function<SoapEnvelope, SoapEnvelope>), where the last argument is the optional request envelope processor and may benull. InJavathe processor additionally emits a three-argument convenience constructor without the processor; both constructors declareJAXBException. - A module named
$<Interface>_SoapClientModuleannotated with@Module, which registers two@DefaultComponentfactories:SoapServiceConfigtagged with@Tag(<Interface>.class), read from thesoapClient.<service name>configuration path.- The client itself, injecting
HttpClient,SoapClientTelemetryFactory, the taggedSoapServiceConfigand an optionalFunction<SoapEnvelope, SoapEnvelope>tagged with@Tag(<Interface>.class).
The generated module is registered in the application graph automatically — it does not have to be added to @KoraApp by hand.
After that, the configuration and the SOAP client become available for dependency injection.
How it works¶
At runtime the generated client uses the connected HttpClient and behaves as follows:
- Sends an
HTTP POSTrequest withContent-Type: text/xmlto the address from theurlconfiguration parameter. - Adds the
SOAPActionHTTPheader only whenactionis set on the method's@WebMethodannotation. - Applies the
timeoutconfiguration value as the request timeout. - Treats
HTTP 200as a successful response and unmarshals the body into the method's return type. - Treats
HTTP 500as aSOAP Faultand converts it either to a typed WSDL fault exception or toSoapFaultException. - Raises
SoapInvalidHttpResponseExceptionfor any otherHTTPstatus code. - Parses
multipartresponses (XOP/MTOMattachments) automatically.
All generated methods are synchronous — the call blocks until the response is read and mapped.
Configuration¶
All configurations for SOAP clients are created with the soapClient prefix.
The main part of the client configuration is placed under the service name from the @WebService annotation.
The section name is selected in this order:
namefrom@WebServiceserviceNamefrom@WebServiceportNamefrom@WebService- interface name
A SOAP client named SimpleService will have the soapClient.SimpleService configuration path.
Basic configuration parameters:
- Service
URLwhere requests will be sent (required, no default). - Maximum request execution time (default:
60s).
Full Configuration
Example of the complete configuration described by the SoapServiceConfig class:
soapClient {
SimpleService {
url = "https://localhost:8090" //(1)!
timeout = "60s" //(2)!
telemetry {
logging {
enabled = false //(3)!
}
metrics {
enabled = false //(4)!
slo = [ 1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 90000 ] //(5)!
tags = { // (6)!
"key1" = "value1"
"key2" = "value2"
}
}
tracing {
enabled = true //(7)!
attributes = { // (8)!
"key1" = "value1"
"key2" = "value2"
}
}
}
}
}
- Service
URLwhere requests will be sent (required, no default). - Maximum request execution time (default:
60s). - Enables module logging (default:
false). - Enables module metrics (default:
false). - Configures SLO for the DistributionSummary metric (default:
TelemetryConfig.MetricsConfig.DEFAULT_SLO). - Additional tags for metrics (default:
{}). - Enables module tracing (default:
true). - Additional attributes for tracing (default:
{}).
soapClient:
SimpleService:
url: "https://localhost:8090" #(1)!
timeout: "60s" #(2)!
telemetry:
logging:
enabled: false #(3)!
metrics:
enabled: false #(4)!
slo: [ 1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 90000 ] #(5)!
tags: #(6)!
key1: value1
key2: value2
tracing:
enabled: true #(7)!
attributes: #(8)!
key1: value1
key2: value2
- Service
URLwhere requests will be sent (required, no default). - Maximum request execution time (default:
60s). - Enables module logging (default:
false). - Enables module metrics (default:
false). - Configures SLO for the DistributionSummary metric (default:
TelemetryConfig.MetricsConfig.DEFAULT_SLO). - Additional tags for metrics (default:
{}). - Enables module tracing (default:
true). - Additional attributes for tracing (default:
{}).
Module metrics are described in the Metrics Reference section.
The configuration is described by the SoapServiceConfig interface. The url parameter is required:
if the whole section or the url value is missing, the application graph fails to build with a ConfigValueException.
The configuration is registered in the graph under @Tag(<Interface>.class), so when a client is
constructed manually the SoapServiceConfig dependency must be resolved with that same tag.
Usage¶
After all components are created, the SOAP client becomes available for injection.
Below is an example for the SimpleService client:
Invocation¶
A generated method accepts the request type and returns the typed response.
For the SimpleService client with a test operation:
@Component
public final class SomeService {
private final SimpleService service;
public SomeService(SimpleService service) {
this.service = service;
}
public String call() throws Exception {
var request = new TestRequest();
request.setVal1("1");
request.setVal2("2");
TestResponse response = service.test(request);
return response.getVal1();
}
}
Multipart responses¶
When the server answers with a multipart/related body (MTOM / XOP attachments), the client parses it without any extra configuration:
the XML part named by the start parameter of Content-Type is unmarshalled as the SOAP envelope, and <xop:Include href="cid:…"/>
references are resolved against the remaining parts. The bytes of a referenced part are taken as they arrived — the part's
Content-Transfer-Encoding header is not applied, so the attachment has to be sent in binary form.
The Content-Type header of such a response must carry both the boundary and the start parameters, otherwise the response is rejected
with an IllegalArgumentException.
A WSDL element of type xsd:base64Binary is generated as a byte[] field, and the attachment content is placed into it:
- Content of the attachment referenced by
<xop:Include/>in the response envelope.
RPC style¶
Operations of a service bound with <soap:binding style="rpc"/> are supported as well.
For such operations wsdl2java generates a void method whose output parts are jakarta.xml.ws.Holder arguments,
and the client fills those holders from the response:
Request customization¶
SOAP clients do not use the @InterceptWith mechanism of declarative HTTP clients.
Instead, the generated client accepts a Function<SoapEnvelope, SoapEnvelope> envelope processor.
The processor is applied to the request SOAP envelope before it is marshalled and sent — this is the extension point for adding
SOAP headers (authorization, tracing, custom elements) or otherwise transforming the outgoing envelope.
The generated module declares that processor as an optional dependency tagged with @Tag(<Interface>.class).
Registering a component with that type and tag is enough — no other wiring is needed.
In Kotlin the type must be imported as java.util.function.Function, otherwise it resolves to the single-argument kotlin.Function:
@Module
public interface SoapModule {
@Tag(SimpleService.class)
default Function<SoapEnvelope, SoapEnvelope> simpleServiceEnvelopeProcessor() {
return SoapEnvelopeProcessorsUtils.wssAuth("username", "password"); //(1)!
}
}
- Any
Function<SoapEnvelope, SoapEnvelope>can be used here;SoapEnvelopeProcessorsUtils.wssAuthis a built-in one.
@Module
interface SoapModule {
@Tag(SimpleService::class)
fun simpleServiceEnvelopeProcessor(): Function<SoapEnvelope, SoapEnvelope> {
return SoapEnvelopeProcessorsUtils.wssAuth("username", "password") //(1)!
}
}
- Any
Function<SoapEnvelope, SoapEnvelope>can be used here;SoapEnvelopeProcessorsUtils.wssAuthis a built-in one.
A custom processor can add arbitrary SOAP headers by appending to envelope.getHeader().getAny():
@Module
public interface SoapModule {
@Tag(SimpleService.class)
default Function<SoapEnvelope, SoapEnvelope> simpleServiceEnvelopeProcessor() {
return envelope -> {
envelope.getHeader().getAny().add(myHeaderElement); //(1)!
return envelope;
};
}
}
- An
org.w3c.dom.Elementor aJAXBobject known to the client'sJAXBContext.
@Module
interface SoapModule {
@Tag(SimpleService::class)
fun simpleServiceEnvelopeProcessor(): Function<SoapEnvelope, SoapEnvelope> {
return Function { envelope ->
envelope.header.any.add(myHeaderElement) //(1)!
envelope
}
}
}
- An
org.w3c.dom.Elementor aJAXBobject known to the client'sJAXBContext.
Because the generated client factory is a @DefaultComponent, a factory of your own that returns the client interface type
overrides it entirely. That is only needed when the client must be built by hand — the SoapServiceConfig dependency then has to be
resolved with @Tag(<Interface>.class), the tag under which the generated module registers it:
@Module
public interface SoapModule {
default SimpleService simpleService(HttpClient httpClient,
SoapClientTelemetryFactory telemetryFactory,
@Tag(SimpleService.class) SoapServiceConfig config) {
var processor = SoapEnvelopeProcessorsUtils.wssAuth("username", "password");
try {
return new $SimpleService_SoapClientImpl(httpClient, telemetryFactory, config, processor);
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
}
@Module
interface SoapModule {
fun simpleService(httpClient: HttpClient,
telemetryFactory: SoapClientTelemetryFactory,
@Tag(SimpleService::class) config: SoapServiceConfig): SimpleService {
val processor = SoapEnvelopeProcessorsUtils.wssAuth("username", "password")
return `$SimpleService_SoapClientImpl`(httpClient, telemetryFactory, config, processor)
}
}
Authorization¶
SoapEnvelopeProcessorsUtils.wssAuth(username, password) is a built-in processor that adds a
WS-Security UsernameToken header (Username plus a plaintext Password)
to every request envelope. Wire it exactly as shown above by registering it as the tagged envelope processor.
Logging¶
Client logging is off by default and is enabled with telemetry.logging.enabled.
Each client writes into two SLF4J loggers named after the canonical name of the @WebService interface:
<package>.<Interface>.request<package>.<Interface>.response
What is written where:
SoapService requesting— every request, with theclientConfigPath,soapServiceandsoapMethodkey-values. The requestXMLis added assoapRequestBodyonly when the request logger is atTRACE.SoapService received response— a successful response, additionally withsoapStatus=success. The responseXMLis added assoapResponseBodyonly when the response logger is atTRACE.SoapService received 'failure'— aSOAP Fault, withsoapStatus=failure,soapFaultCodeandsoapFaultActor, or a transport/mapping error, withsoapStatus=failureandexceptionType. Both are written atINFO.
Nothing is written when the corresponding logger is below INFO, even if telemetry.logging.enabled is true.
To mask or transform the logged envelopes (for example, to hide sensitive data), register a @Component that extends
DefaultSoapClientLoggerFactory and returns a logger overriding prepareRequestBodyForLog / prepareResponseBodyForLog.
SoapClientModule takes such a component as an optional dependency of the telemetry factory and uses it for every client:
@Component
public final class MaskingSoapClientLoggerFactory extends DefaultSoapClientLoggerFactory {
@Override
public DefaultSoapClientLogger create(DefaultSoapClientTelemetry.TelemetryContext context) {
var requestLog = LoggerFactory.getLogger(context.clientCanonicalName() + ".request");
var responseLog = LoggerFactory.getLogger(context.clientCanonicalName() + ".response");
return new MaskingLogger(requestLog, responseLog, context);
}
private static final class MaskingLogger extends DefaultSoapClientLoggerFactory.DefaultSoapClientLogger {
private MaskingLogger(Logger requestLog,
Logger responseLog,
DefaultSoapClientTelemetry.TelemetryContext context) {
super(requestLog, responseLog, context);
}
@Override
protected String prepareRequestBodyForLog(byte[] requestXml) {
return "<masked/>";
}
@Override
protected String prepareResponseBodyForLog(byte[] xml) {
return new String(xml, StandardCharsets.UTF_8);
}
}
}
@Component
class MaskingSoapClientLoggerFactory : DefaultSoapClientLoggerFactory() {
override fun create(context: DefaultSoapClientTelemetry.TelemetryContext): DefaultSoapClientLoggerFactory.DefaultSoapClientLogger {
val requestLog = LoggerFactory.getLogger(context.clientCanonicalName() + ".request")
val responseLog = LoggerFactory.getLogger(context.clientCanonicalName() + ".response")
return MaskingLogger(requestLog, responseLog, context)
}
private class MaskingLogger(
requestLog: Logger,
responseLog: Logger,
context: DefaultSoapClientTelemetry.TelemetryContext
) : DefaultSoapClientLoggerFactory.DefaultSoapClientLogger(requestLog, responseLog, context) {
override fun prepareRequestBodyForLog(requestXml: ByteArray): String {
return "<masked/>"
}
override fun prepareResponseBodyForLog(xml: ByteArray): String {
return String(xml, StandardCharsets.UTF_8)
}
}
}
Exception handling¶
All SOAP client failures are unchecked and extend the base SoapException (a RuntimeException),
so a single catch (SoapException e) handles all of them, or a specific subtype can be caught.
Main exception types:
SoapException— base unchecked exception forSOAPclient failures; also thrown directly for transport andI/Oerrors of the underlyingHTTP client.SoapFaultException— the server returned aSOAP Faultthat does not match a typedWSDLfault.getFault()returns aSoapFaultexposinggetFaultcode()(QName),getFaultstring(),getFaultactor(), andgetDetail().SoapInvalidHttpResponseException— the server returned an unexpectedHTTPstatus code (anything other than200or500). The message contains the code and up to the first 500 bytes of the response body.SoapRequestMarshallingException— the request envelope could not be marshalled toXML.SoapResponseUnmarshallingException— the responseXMLcould not be unmarshalled.
When a WSDL operation declares faults (<wsdl:fault>), the generator emits typed checked exceptions annotated with @WebFault,
and the method throws them directly when the returned fault detail matches one of them. If the fault does not match any
declared type, SoapFaultException is thrown instead.
try {
var response = service.test(request);
// ... use the response
} catch (TestError1Msg e) { //(1)!
// handle a specific declared WSDL fault
} catch (SoapFaultException e) { //(2)!
SoapFault fault = e.getFault();
var code = fault.getFaultcode();
var message = fault.getFaultstring();
} catch (SoapInvalidHttpResponseException e) {
// unexpected HTTP status code
} catch (SoapRequestMarshallingException | SoapResponseUnmarshallingException e) {
// XML (un)marshalling failure
} catch (SoapException e) {
// any other transport/HTTP SOAP failure
}
- Typed
@WebFaultexception generated from a<wsdl:fault>; the concrete class name comes from theWSDL. - Any
SOAP Faultthat does not match a declared typed fault.
try {
val response = service.test(request)
// ... use the response
} catch (e: TestError1Msg) { //(1)!
// handle a specific declared WSDL fault
} catch (e: SoapFaultException) { //(2)!
val fault = e.fault
val code = fault.faultcode
val message = fault.faultstring
} catch (e: SoapInvalidHttpResponseException) {
// unexpected HTTP status code
} catch (e: SoapRequestMarshallingException) {
// request XML marshalling failure
} catch (e: SoapResponseUnmarshallingException) {
// response XML unmarshalling failure
} catch (e: SoapException) {
// any other transport/HTTP SOAP failure
}
- Typed
@WebFaultexception generated from a<wsdl:fault>; the concrete class name comes from theWSDL. - Any
SOAP Faultthat does not match a declared typed fault.
Low-level result model¶
Internally the request engine SoapRequestExecutor returns a SoapResult, a sealed interface with two records:
SoapResult.Success(Object body) and SoapResult.Failure(SoapFault fault, String faultMessage).
The generated client maps Success to the typed response and Failure to a typed fault exception or SoapFaultException,
so you normally do not work with SoapResult directly.
Testing¶
The client can be tested with @KoraAppTest by injecting it as a @TestComponent and pointing url at a mock server.
The example below overrides the SOAP_CLIENT_URL environment substitution used by soapClient.SimpleService.url,
stubs the response envelope and invokes service.test(request):
@TestcontainersMockServer(mode = ContainerMode.PER_CLASS)
@KoraAppTest(Application.class)
class SimpleServiceTests implements KoraAppTestConfigModifier {
@ConnectionMockServer
private MockServerConnection mockserverConnection;
@TestComponent
private SimpleService service;
@Override
public KoraConfigModification config() {
return KoraConfigModification.ofSystemProperty("SOAP_CLIENT_URL", mockserverConnection.params().uri().toString());
}
@Test
void testCall() throws Exception {
mockserverConnection.client()
.when(HttpRequest.request().withMethod("POST").withPath("/"))
.respond(HttpResponse.response().withBody(new XmlBody("""
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<ns2:Envelope xmlns:ns2="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns3="http://kora.tinkoff.ru/simple/service">
<ns2:Header/>
<ns2:Body>
<ns3:TestResponse>
<val1>1</val1>
</ns3:TestResponse>
</ns2:Body>
</ns2:Envelope>
""")));
var request = new TestRequest();
request.setVal1("1");
request.setVal2("2");
var response = service.test(request);
assertEquals("1", response.getVal1());
}
}
@TestcontainersMockServer(mode = ContainerMode.PER_CLASS)
@KoraAppTest(Application::class)
class SimpleServiceTests : KoraAppTestConfigModifier {
@ConnectionMockServer
lateinit var mockserverConnection: MockServerConnection
@TestComponent
lateinit var service: SimpleService
override fun config(): KoraConfigModification =
KoraConfigModification.ofSystemProperty("SOAP_CLIENT_URL", mockserverConnection.params().uri().toString())
@Test
fun testCall() {
mockserverConnection.client()
.`when`(request().withMethod("POST").withPath("/"))
.respond(response().withBody(XmlBody("""
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<ns2:Envelope xmlns:ns2="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns3="http://kora.tinkoff.ru/simple/service">
<ns2:Header/>
<ns2:Body>
<ns3:TestResponse>
<val1>1</val1>
</ns3:TestResponse>
</ns2:Body>
</ns2:Envelope>
""".trimIndent())))
val request = TestRequest().apply {
val1 = "1"
val2 = "2"
}
val response = service.test(request)
assertEquals("1", response.val1)
}
}
The request envelope sent to the server and the response envelope it returns look like this on the wire:
<!-- Request -->
<ns2:Envelope xmlns:ns2="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns3="http://kora.tinkoff.ru/simple/service">
<ns2:Header/>
<ns2:Body>
<ns3:TestRequest>
<val1>1</val1>
<val2>2</val2>
</ns3:TestRequest>
</ns2:Body>
</ns2:Envelope>
<!-- Response -->
<ns2:Envelope xmlns:ns2="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns3="http://kora.tinkoff.ru/simple/service">
<ns2:Header/>
<ns2:Body>
<ns3:TestResponse>
<val1>1</val1>
</ns3:TestResponse>
</ns2:Body>
</ns2:Envelope>
wsdl2java Plugin¶
A Gradle plugin can be used as one option for creating interfaces annotated with jakarta.jws.WebService,
as well as JAXB classes based on WSDL.
Dependency¶
Usage¶
Suppose there is a WSDL where the SimpleService service is declared.
Then the plugin configuration for generation with jakarta annotations will look like this:
Plugin setup build.gradle:
wsdl2java {
cxfVersion = "4.0.2"
wsdlDir = layout.projectDirectory.dir("src/main/resources/wsdl")
useJakarta = true
markGenerated = true
verbose = false
packageName = "io.koraframework.example.generated.soap"
generatedSourceDir.set(layout.buildDirectory.dir("generated/sources/wsdl2java/java"))
includesWithOptions = [
"**/simple-service.wsdl": ["-wsdlLocation", "https://kora.tinkoff.ru/simple/service?wsdl"],
]
}
Plugin setup build.gradle.kts:
wsdl2java {
cxfVersion.set("4.0.2")
wsdlDir.set(layout.projectDirectory.dir("src/main/resources/wsdl"))
useJakarta.set(true)
markGenerated.set(true)
verbose.set(false)
packageName.set("io.koraframework.example.generated.soap")
generatedSourceDir.set(layout.buildDirectory.dir("generated/sources/wsdl2java/java"))
includesWithOptions.set(
mapOf(
"**/simple-service.wsdl" to listOf(
"-wsdlLocation",
"https://kora.tinkoff.ru/simple/service?wsdl"
)
)
)
}
sourceSets.main {
java.srcDir(layout.buildDirectory.dir("generated/sources/wsdl2java/java")) //(1)!
}
- The plugin generates
Javasources; the directory has to be added to theJavasource set so thatKSPsees the@WebServiceinterfaces.
The useJakarta = true option is required — the annotation processor only recognises jakarta.jws.WebService.
Kora itself is built and tested against CXF 4.2.3, so cxfVersion can be raised to that version if the generated code has to match.