SOAP клиент
SOAP — это протокол обмена сообщениями в формате XML, который часто используется для интеграции с внешними системами по HTTP и контракту WSDL.
Модуль soap-client создаёт реализации клиентов для интерфейсов, помеченных аннотацией jakarta.jws.WebService, и регистрирует их в графе приложения.
Обычно такие интерфейсы и связанные с ними классы JAXB генерируются из WSDL, например с помощью wsdl2java.
После генерации Kora создаёт реализацию клиента и связывает её с HTTP-клиентом, отображением XML и телеметрией.
Подключение¶
Зависимость build.gradle:
Модуль:
Зависимость build.gradle.kts:
Модуль:
Требуется наличие в приложении реализации HTTP-клиента (http-client-jdk, http-client-ok или http-client-apache)
и модуля конфигурации (HOCON либо YAML).
Артефакт soap-client уже приносит нужный клиенту рантайм jakarta / JAXB, поэтому объявлять его отдельно не требуется:
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
Если другой плагин или зависимость в сборке фиксируют иные версии этих артефактов, версии стоит выровнять, чтобы на classpath оказался ровно один рантайм JAXB.
Описание¶
Предполагается, что в приложении уже есть интерфейсы, помеченные аннотацией jakarta.jws.WebService.
Их можно написать вручную, но обычно они создаются из WSDL отдельным инструментом, например Gradle-плагином.
На основе таких интерфейсов процессор аннотаций (входящий в артефакт annotation-processors / symbol-processors) создаёт в том же пакете:
- Реализацию клиента с именем
$<Interface>_SoapClientImpl, реализующую интерфейс@WebService. Её конструктор —(HttpClient, SoapClientTelemetryFactory, SoapServiceConfig, Function<SoapEnvelope, SoapEnvelope>), где последний аргумент — необязательный обработчик конверта запроса, он может бытьnull. ВJavaпроцессор дополнительно создаёт конструктор из трёх аргументов без обработчика; оба конструктора объявляютJAXBException. - Модуль с именем
$<Interface>_SoapClientModule, помеченный аннотацией@Module, который регистрирует две фабрики@DefaultComponent:SoapServiceConfigс тегом@Tag(<Interface>.class), читаемый по пути конфигурацииsoapClient.<имя сервиса>.- Сам клиент, которому внедряются
HttpClient,SoapClientTelemetryFactory, конфигурация с тегом и необязательныйFunction<SoapEnvelope, SoapEnvelope>с тегом@Tag(<Interface>.class).
Сгенерированный модуль регистрируется в графе приложения автоматически — добавлять его в @KoraApp вручную не нужно.
После этого конфигурация и SOAP-клиент становятся доступны для внедрения зависимостей.
Как это работает¶
Во время работы сгенерированный клиент использует подключённый HttpClient и ведёт себя следующим образом:
- Отправляет запрос
HTTP POSTсContent-Type: text/xmlна адрес из параметра конфигурацииurl. - Добавляет
HTTP-заголовокSOAPActionтолько когда в аннотации@WebMethodметода заданaction. - Применяет значение конфигурации
timeoutкак предельное время выполнения запроса. - Трактует
HTTP 200как успешный ответ и десериализует тело в тип возвращаемого значения метода. - Трактует
HTTP 500какSOAP Faultи преобразует его либо в типизированное исключение ошибки WSDL, либо вSoapFaultException. - Выбрасывает
SoapInvalidHttpResponseExceptionдля любого другого кода состоянияHTTP. - Автоматически разбирает ответы
multipart(вложенияXOP/MTOM).
Все сгенерированные методы синхронные — вызов блокируется до тех пор, пока ответ не будет прочитан и отображён.
Конфигурация¶
Все конфигурации SOAP-клиентов создаются с префиксом soapClient.
Основная часть конфигурации клиента размещается под именем службы из аннотации @WebService.
Имя секции выбирается в следующем порядке:
nameиз@WebServiceserviceNameиз@WebServiceportNameиз@WebService- имя интерфейса
У SOAP-клиента с именем SimpleService путь конфигурации будет soapClient.SimpleService.
Основные параметры конфигурации:
URLслужбы, куда будут отправляться запросы (обязательный, без значения по умолчанию).- Максимальное время выполнения запроса (по умолчанию:
60s).
Полная конфигурация
Пример полной конфигурации, описанной классом SoapServiceConfig:
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"
}
}
}
}
}
URLслужбы, куда будут отправляться запросы (обязательный, без значения по умолчанию).- Максимальное время выполнения запроса (по умолчанию:
60s). - Включает логгирование модуля (по умолчанию:
false). - Включает метрики модуля (по умолчанию:
false). - Настройка SLO для метрики DistributionSummary (по умолчанию:
TelemetryConfig.MetricsConfig.DEFAULT_SLO). - Дополнительные теги для метрик (по умолчанию:
{}). - Включает трассировку модуля (по умолчанию:
true). - Дополнительные атрибуты для трассировки (по умолчанию:
{}).
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
URLслужбы, куда будут отправляться запросы (обязательный, без значения по умолчанию).- Максимальное время выполнения запроса (по умолчанию:
60s). - Включает логгирование модуля (по умолчанию:
false). - Включает метрики модуля (по умолчанию:
false). - Настройка SLO для метрики DistributionSummary (по умолчанию:
TelemetryConfig.MetricsConfig.DEFAULT_SLO). - Дополнительные теги для метрик (по умолчанию:
{}). - Включает трассировку модуля (по умолчанию:
true). - Дополнительные атрибуты для трассировки (по умолчанию:
{}).
Метрики модуля описаны в разделе Справочник метрик.
Конфигурация описывается интерфейсом SoapServiceConfig. Параметр url обязателен:
если отсутствует вся секция или само значение url, граф приложения не соберётся и упадёт с ConfigValueException.
Конфигурация регистрируется в графе под тегом @Tag(<Interface>.class), поэтому при ручном создании клиента
зависимость SoapServiceConfig нужно запрашивать с тем же тегом.
Использование¶
После создания всех компонентов SOAP-клиент становится доступен для внедрения.
Ниже пример для клиента SimpleService:
Вызов¶
Сгенерированный метод принимает тип запроса и возвращает типизированный ответ.
Для клиента SimpleService с операцией test:
@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¶
Если сервер отвечает телом multipart/related (вложения MTOM / XOP), клиент разбирает его без дополнительной настройки:
XML-часть, указанная параметром start заголовка Content-Type, десериализуется как конверт SOAP, а ссылки
<xop:Include href="cid:…"/> разрешаются по остальным частям. Байты вложения берутся ровно в том виде, в каком пришли —
заголовок Content-Transfer-Encoding части не учитывается, поэтому вложение должно передаваться в бинарном виде.
Заголовок Content-Type такого ответа обязан содержать одновременно параметры boundary и start, иначе ответ будет отклонён
с IllegalArgumentException.
Элемент WSDL типа xsd:base64Binary генерируется как поле byte[], куда и помещается содержимое вложения:
- Содержимое вложения, на которое ссылается
<xop:Include/>в конверте ответа.
Стиль RPC¶
Операции сервиса с привязкой <soap:binding style="rpc"/> также поддерживаются.
Для таких операций wsdl2java генерирует метод с типом void, у которого выходные части передаются аргументами
jakarta.xml.ws.Holder, а клиент заполняет эти holder-ы из ответа:
Настройка запроса¶
SOAP-клиенты не используют механизм @InterceptWith из декларативных HTTP-клиентов.
Вместо этого сгенерированный клиент принимает обработчик конверта Function<SoapEnvelope, SoapEnvelope>.
Обработчик применяется к конверту SOAP запроса перед сериализацией и отправкой — это точка расширения для добавления
заголовков SOAP (авторизация, трассировка, произвольные элементы) или иного преобразования исходящего конверта.
Сгенерированный модуль объявляет такой обработчик необязательной зависимостью с тегом @Tag(<Interface>.class).
Достаточно зарегистрировать компонент этого типа с этим тегом — больше ничего связывать не нужно.
В Kotlin тип нужно импортировать как java.util.function.Function, иначе он разрешится в kotlin.Function с одним параметром типа:
@Module
public interface SoapModule {
@Tag(SimpleService.class)
default Function<SoapEnvelope, SoapEnvelope> simpleServiceEnvelopeProcessor() {
return SoapEnvelopeProcessorsUtils.wssAuth("username", "password"); //(1)!
}
}
- Здесь может использоваться любая
Function<SoapEnvelope, SoapEnvelope>;SoapEnvelopeProcessorsUtils.wssAuth— встроенная реализация.
@Module
interface SoapModule {
@Tag(SimpleService::class)
fun simpleServiceEnvelopeProcessor(): Function<SoapEnvelope, SoapEnvelope> {
return SoapEnvelopeProcessorsUtils.wssAuth("username", "password") //(1)!
}
}
- Здесь может использоваться любая
Function<SoapEnvelope, SoapEnvelope>;SoapEnvelopeProcessorsUtils.wssAuth— встроенная реализация.
Собственный обработчик может добавлять произвольные заголовки SOAP, дописывая их в 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;
};
}
}
org.w3c.dom.Elementлибо объектJAXB, известныйJAXBContextклиента.
Поскольку сгенерированная фабрика клиента помечена @DefaultComponent, собственная фабрика, возвращающая тип интерфейса клиента,
полностью её переопределяет. Это нужно только когда клиент требуется собрать вручную — тогда зависимость SoapServiceConfig
следует запрашивать с тегом @Tag(<Interface>.class), под которым её регистрирует сгенерированный модуль:
@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)
}
}
Авторизация¶
SoapEnvelopeProcessorsUtils.wssAuth(username, password) — встроенный обработчик, добавляющий в каждый конверт запроса заголовок
WS-Security UsernameToken (Username и Password открытым текстом).
Подключается ровно так, как показано выше — регистрацией обработчика конверта с тегом.
Логирование¶
Логирование клиента выключено по умолчанию и включается параметром telemetry.logging.enabled.
Каждый клиент пишет в два логгера SLF4J, названных по каноническому имени интерфейса @WebService:
<package>.<Interface>.request<package>.<Interface>.response
Что и куда пишется:
SoapService requesting— каждый запрос, с ключамиclientConfigPath,soapServiceиsoapMethod.XMLзапроса добавляется какsoapRequestBodyтолько когда логгер запроса выставлен вTRACE.SoapService received response— успешный ответ, дополнительно сsoapStatus=success.XMLответа добавляется какsoapResponseBodyтолько когда логгер ответа выставлен вTRACE.SoapService received 'failure'—SOAP Fault, сsoapStatus=failure,soapFaultCodeиsoapFaultActor, либо ошибка транспорта или отображения, сsoapStatus=failureиexceptionType. Обе записи пишутся на уровнеINFO.
Если соответствующий логгер ниже уровня INFO, не пишется ничего, даже когда telemetry.logging.enabled равно true.
Чтобы маскировать или преобразовывать логируемые конверты (например, скрыть чувствительные данные), нужно зарегистрировать @Component,
наследующий DefaultSoapClientLoggerFactory и возвращающий логгер с переопределёнными prepareRequestBodyForLog / prepareResponseBodyForLog.
SoapClientModule принимает такой компонент как необязательную зависимость фабрики телеметрии и использует его для всех клиентов:
@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)
}
}
}
Обработка исключений¶
Все ошибки SOAP-клиента являются непроверяемыми и наследуют базовое SoapException (RuntimeException),
поэтому один catch (SoapException e) покрывает их все, либо можно перехватывать конкретный подтип.
Основные типы исключений:
SoapException— базовое непроверяемое исключение ошибокSOAP-клиента; также выбрасывается напрямую при ошибках транспорта иI/OнижележащегоHTTP-клиента.SoapFaultException— сервер вернулSOAP Fault, который не соответствует ни одной типизированной ошибкеWSDL.getFault()возвращаетSoapFaultс методамиgetFaultcode()(QName),getFaultstring(),getFaultactor()иgetDetail().SoapInvalidHttpResponseException— сервер вернул неожиданный код состоянияHTTP(любой, кроме200и500). В сообщении содержатся код и первые 500 байт тела ответа.SoapRequestMarshallingException— конверт запроса не удалось сериализовать вXML.SoapResponseUnmarshallingException—XMLответа не удалось десериализовать.
Когда операция WSDL объявляет ошибки (<wsdl:fault>), генератор создаёт типизированные проверяемые исключения с аннотацией @WebFault,
и метод выбрасывает их напрямую, если detail полученной ошибки соответствует одному из них. Если ошибка не соответствует ни одному
объявленному типу, выбрасывается SoapFaultException.
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
}
- Типизированное исключение
@WebFault, сгенерированное из<wsdl:fault>; конкретное имя класса берётся изWSDL. - Любой
SOAP 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
}
- Типизированное исключение
@WebFault, сгенерированное из<wsdl:fault>; конкретное имя класса берётся изWSDL. - Любой
SOAP Fault, не соответствующий объявленной типизированной ошибке.
Низкоуровневая модель результата¶
Внутри движок выполнения запроса SoapRequestExecutor возвращает SoapResult — запечатанный интерфейс с двумя записями:
SoapResult.Success(Object body) и SoapResult.Failure(SoapFault fault, String faultMessage).
Сгенерированный клиент отображает Success в типизированный ответ, а Failure — в типизированное исключение ошибки или SoapFaultException,
поэтому напрямую с SoapResult обычно работать не приходится.
Тестирование¶
Клиент можно тестировать с помощью @KoraAppTest, внедрив его как @TestComponent и направив url на mock-сервер.
В примере ниже переопределяется подстановка переменной окружения SOAP_CLIENT_URL, используемая в soapClient.SimpleService.url,
задаётся заглушка конверта ответа и вызывается 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)
}
}
Конверт запроса, отправляемый на сервер, и конверт ответа, который он возвращает, в сыром виде выглядят так:
<!-- 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¶
Одним из вариантов создания интерфейсов с аннотацией jakarta.jws.WebService, а также классов JAXB на основе WSDL,
является Gradle-плагин.
Подключение¶
Использование¶
Предположим, есть WSDL, в котором объявлена служба SimpleService.
Тогда конфигурация плагина для генерации с аннотациями jakarta будет выглядеть так:
Настройка плагина 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"],
]
}
Настройка плагина 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)!
}
- Плагин генерирует исходники на
Java, поэтому каталог нужно добавить вJava-source set, чтобыKSPувидел интерфейсы@WebService.
Опция useJakarta = true обязательна — процессор аннотаций распознаёт только jakarta.jws.WebService.
Сама Kora собирается и тестируется на CXF 4.2.3, поэтому cxfVersion можно поднять до этой версии, если сгенерированный код должен ей соответствовать.