diff --git a/pom.xml b/pom.xml
index efd84c91..f23e2aa6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -48,6 +48,7 @@
slf4j
spring4
soap
+ soap-jakarta
reactive
dropwizard-metrics4
dropwizard-metrics5
diff --git a/soap-jakarta/README.md b/soap-jakarta/README.md
new file mode 100644
index 00000000..6bcb5ce2
--- /dev/null
+++ b/soap-jakarta/README.md
@@ -0,0 +1,57 @@
+SOAP Codec
+===================
+
+This module adds support for encoding and decoding SOAP Body objects via JAXB and SOAPMessage. It also provides SOAPFault decoding capabilities by wrapping them into the original `javax.xml.ws.soap.SOAPFaultException`, so that you'll only need to catch `SOAPFaultException` in order to handle SOAPFault.
+
+Add `SOAPEncoder` and/or `SOAPDecoder` to your `Feign.Builder` like so:
+
+```java
+public interface MyApi {
+
+ @RequestLine("POST /getObject")
+ @Headers({
+ "SOAPAction: getObject",
+ "Content-Type: text/xml"
+ })
+ MyJaxbObjectResponse getObject(MyJaxbObjectRequest request);
+
+ }
+
+ ...
+
+ JAXBContextFactory jaxbFactory = new JAXBContextFactory.Builder()
+ .withMarshallerJAXBEncoding("UTF-8")
+ .withMarshallerSchemaLocation("http://apihost http://apihost/schema.xsd")
+ .build();
+
+ api = Feign.builder()
+ .encoder(new SOAPEncoder(jaxbFactory))
+ .decoder(new SOAPDecoder(jaxbFactory))
+ .target(MyApi.class, "http://api");
+
+ ...
+
+ try {
+ api.getObject(new MyJaxbObjectRequest());
+ } catch (SOAPFaultException faultException) {
+ log.info(faultException.getFault().getFaultString());
+ }
+
+```
+
+Because a SOAP Fault can be returned as well with a 200 http code than a 4xx or 5xx HTTP error code (depending on the used API), you may also use `SOAPErrorDecoder` in your API configuration, in order to be able to catch `SOAPFaultException` in case of SOAP Fault. Add it, like below:
+
+```java
+api = Feign.builder()
+ .encoder(new SOAPEncoder(jaxbFactory))
+ .decoder(new SOAPDecoder(jaxbFactory))
+ .errorDecoder(new SOAPErrorDecoder())
+ .target(MyApi.class, "http://api");
+```
+
+In certain situations the declarations on the SOAP envelope are not inherited by JAXB when reading the documents. This is particularly
+troublesome when it is not possible to correct the XML at the source.
+
+To account for this situation, use the `useFirstChild` option on the `SOAPDecoder` builder. This will instruct JAX be to use `SOAPBody#getFirstChild()`
+instead of `SOAPBody#extractContentAsDocument()`. This will allow users to supply a `package-info.java` to manage the element namespaces
+explicitly and define what should occur if the namespace declarations are missing.
\ No newline at end of file
diff --git a/soap-jakarta/pom.xml b/soap-jakarta/pom.xml
new file mode 100644
index 00000000..32534023
--- /dev/null
+++ b/soap-jakarta/pom.xml
@@ -0,0 +1,86 @@
+
+
+
+ 4.0.0
+
+
+ io.github.openfeign
+ parent
+ 12.4-SNAPSHOT
+
+
+ feign-soap-jakarta
+ Feign SOAP Jakarta
+ Feign SOAP CoDec
+
+
+ 11
+ 11
+ 11
+ ${project.basedir}/..
+
+ true
+
+
+
+
+ ${project.groupId}
+ feign-core
+
+
+
+ ${project.groupId}
+ feign-jaxb-jakarta
+ ${project.version}
+
+
+
+ ${project.groupId}
+ feign-core
+ test-jar
+ test
+
+
+
+ jakarta.xml.ws
+ jakarta.xml.ws-api
+ 4.0.0
+
+
+ jakarta.xml.soap
+ jakarta.xml.soap-api
+ 3.0.0
+
+
+ jakarta.xml.bind
+ jakarta.xml.bind-api
+ 4.0.0
+
+
+ com.sun.xml.messaging.saaj
+ saaj-impl
+ 3.0.2
+
+
+ com.sun.xml.bind
+ jaxb-impl
+ 4.0.3
+ runtime
+
+
+
+
diff --git a/soap-jakarta/src/main/java/feign/soap/SOAPDecoder.java b/soap-jakarta/src/main/java/feign/soap/SOAPDecoder.java
new file mode 100644
index 00000000..a56327c1
--- /dev/null
+++ b/soap-jakarta/src/main/java/feign/soap/SOAPDecoder.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright 2012-2023 The Feign Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package feign.soap;
+
+import feign.Response;
+import feign.Util;
+import feign.codec.DecodeException;
+import feign.codec.Decoder;
+import feign.jaxb.JAXBContextFactory;
+import jakarta.xml.bind.JAXBException;
+import jakarta.xml.bind.Unmarshaller;
+import jakarta.xml.soap.*;
+import jakarta.xml.ws.soap.SOAPFaultException;
+import java.io.IOException;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+
+/**
+ * Decodes SOAP responses using SOAPMessage and JAXB for the body part.
+ *
+ *
+ * The JAXBContextFactory should be reused across requests as it caches the created JAXB contexts.
+ *
+ *
+ * A SOAP Fault can be returned with a 200 HTTP code. Hence, faults could be handled with no error
+ * on the HTTP layer. In this case, you'll certainly have to catch {@link SOAPFaultException} to get
+ * fault from your API client service. In the other case (Faults are returned with 4xx or 5xx HTTP
+ * error code), you may use {@link SOAPErrorDecoder} in your API configuration.
+ *
+ *
+ *
+ * public interface MyApi {
+ *
+ * @RequestLine("POST /getObject")
+ * @Headers({
+ * "SOAPAction: getObject",
+ * "Content-Type: text/xml"
+ * })
+ * MyJaxbObjectResponse getObject(MyJaxbObjectRequest request);
+ *
+ * }
+ *
+ * ...
+ *
+ * JAXBContextFactory jaxbFactory = new JAXBContextFactory.Builder()
+ * .withMarshallerJAXBEncoding("UTF-8")
+ * .withMarshallerSchemaLocation("http://apihost http://apihost/schema.xsd")
+ * .build();
+ *
+ * api = Feign.builder()
+ * .decoder(new SOAPDecoder(jaxbFactory))
+ * .target(MyApi.class, "http://api");
+ *
+ * ...
+ *
+ * try {
+ * api.getObject(new MyJaxbObjectRequest());
+ * } catch (SOAPFaultException faultException) {
+ * log.info(faultException.getFault().getFaultString());
+ * }
+ *
+ *
+ * @see SOAPErrorDecoder
+ * @see SOAPFaultException
+ */
+public class SOAPDecoder implements Decoder {
+
+ private final JAXBContextFactory jaxbContextFactory;
+ private final String soapProtocol;
+ private final boolean useFirstChild;
+
+ public SOAPDecoder(JAXBContextFactory jaxbContextFactory) {
+ this.jaxbContextFactory = jaxbContextFactory;
+ this.soapProtocol = SOAPConstants.DEFAULT_SOAP_PROTOCOL;
+ this.useFirstChild = false;
+ }
+
+ private SOAPDecoder(Builder builder) {
+ this.soapProtocol = builder.soapProtocol;
+ this.jaxbContextFactory = builder.jaxbContextFactory;
+ this.useFirstChild = builder.useFirstChild;
+ }
+
+ @Override
+ public Object decode(Response response, Type type) throws IOException {
+ if (response.status() == 404)
+ return Util.emptyValueOf(type);
+ if (response.body() == null)
+ return null;
+ while (type instanceof ParameterizedType) {
+ ParameterizedType ptype = (ParameterizedType) type;
+ type = ptype.getRawType();
+ }
+ if (!(type instanceof Class)) {
+ throw new UnsupportedOperationException(
+ "SOAP only supports decoding raw types. Found " + type);
+ }
+
+ try {
+ SOAPMessage message =
+ MessageFactory.newInstance(soapProtocol)
+ .createMessage(null, response.body().asInputStream());
+ if (message.getSOAPBody() != null) {
+ if (message.getSOAPBody().hasFault()) {
+ throw new SOAPFaultException(message.getSOAPBody().getFault());
+ }
+
+ Unmarshaller unmarshaller = jaxbContextFactory.createUnmarshaller((Class>) type);
+
+ if (this.useFirstChild) {
+ return unmarshaller.unmarshal(message.getSOAPBody().getFirstChild());
+ } else {
+ return unmarshaller.unmarshal(message.getSOAPBody().extractContentAsDocument());
+ }
+ }
+ } catch (SOAPException | JAXBException e) {
+ throw new DecodeException(response.status(), e.toString(), response.request(), e);
+ } finally {
+ if (response.body() != null) {
+ response.body().close();
+ }
+ }
+ return Util.emptyValueOf(type);
+ }
+
+ public static class Builder {
+ String soapProtocol = SOAPConstants.DEFAULT_SOAP_PROTOCOL;
+ JAXBContextFactory jaxbContextFactory;
+ boolean useFirstChild = false;
+
+ public Builder withJAXBContextFactory(JAXBContextFactory jaxbContextFactory) {
+ this.jaxbContextFactory = jaxbContextFactory;
+ return this;
+ }
+
+ /**
+ * The protocol used to create message factory. Default is "SOAP 1.1 Protocol".
+ *
+ * @param soapProtocol a string constant representing the MessageFactory protocol.
+ * @see SOAPConstants#SOAP_1_1_PROTOCOL
+ * @see SOAPConstants#SOAP_1_2_PROTOCOL
+ * @see SOAPConstants#DYNAMIC_SOAP_PROTOCOL
+ * @see MessageFactory#newInstance(String)
+ */
+ public Builder withSOAPProtocol(String soapProtocol) {
+ this.soapProtocol = soapProtocol;
+ return this;
+ }
+
+ /**
+ * Alters the behavior of the code to use the {@link SOAPBody#getFirstChild()} in place of
+ * {@link SOAPBody#extractContentAsDocument()}.
+ *
+ * @return the builder instance.
+ */
+ public Builder useFirstChild() {
+ this.useFirstChild = true;
+ return this;
+ }
+
+ public SOAPDecoder build() {
+ if (jaxbContextFactory == null) {
+ throw new IllegalStateException("JAXBContextFactory must be non-null");
+ }
+ return new SOAPDecoder(this);
+ }
+ }
+}
diff --git a/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java b/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java
new file mode 100644
index 00000000..82498559
--- /dev/null
+++ b/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java
@@ -0,0 +1,224 @@
+/*
+ * Copyright 2012-2023 The Feign Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package feign.soap;
+
+import feign.RequestTemplate;
+import feign.codec.EncodeException;
+import feign.codec.Encoder;
+import feign.jaxb.JAXBContextFactory;
+import jakarta.xml.bind.JAXBException;
+import jakarta.xml.bind.Marshaller;
+import jakarta.xml.soap.MessageFactory;
+import jakarta.xml.soap.SOAPConstants;
+import jakarta.xml.soap.SOAPException;
+import jakarta.xml.soap.SOAPMessage;
+import org.w3c.dom.Document;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.transform.*;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
+
+/**
+ * Encodes requests using SOAPMessage and JAXB for the body part.
+ *
+ *
+ * Basic example with Feign.Builder:
+ *
+ *
+ *
+ * public interface MyApi {
+ *
+ * @RequestLine("POST /getObject")
+ * @Headers({
+ * "SOAPAction: getObject",
+ * "Content-Type: text/xml"
+ * })
+ * MyJaxbObjectResponse getObject(MyJaxbObjectRequest request);
+ *
+ * }
+ *
+ * ...
+ *
+ * JAXBContextFactory jaxbFactory = new JAXBContextFactory.Builder()
+ * .withMarshallerJAXBEncoding("UTF-8")
+ * .withMarshallerSchemaLocation("http://apihost http://apihost/schema.xsd")
+ * .build();
+ *
+ * api = Feign.builder()
+ * .encoder(new SOAPEncoder(jaxbFactory))
+ * .target(MyApi.class, "http://api");
+ *
+ * ...
+ *
+ * try {
+ * api.getObject(new MyJaxbObjectRequest());
+ * } catch (SOAPFaultException faultException) {
+ * log.info(faultException.getFault().getFaultString());
+ * }
+ *
+ *
+ *
+ * The JAXBContextFactory should be reused across requests as it caches the created JAXB contexts.
+ */
+public class SOAPEncoder implements Encoder {
+
+ private static final String DEFAULT_SOAP_PROTOCOL = SOAPConstants.SOAP_1_1_PROTOCOL;
+
+ private final boolean writeXmlDeclaration;
+ private final boolean formattedOutput;
+ private final Charset charsetEncoding;
+ private final JAXBContextFactory jaxbContextFactory;
+ private final String soapProtocol;
+
+ public SOAPEncoder(Builder builder) {
+ this.jaxbContextFactory = builder.jaxbContextFactory;
+ this.writeXmlDeclaration = builder.writeXmlDeclaration;
+ this.charsetEncoding = builder.charsetEncoding;
+ this.soapProtocol = builder.soapProtocol;
+ this.formattedOutput = builder.formattedOutput;
+ }
+
+ public SOAPEncoder(JAXBContextFactory jaxbContextFactory) {
+ this.jaxbContextFactory = jaxbContextFactory;
+ this.writeXmlDeclaration = true;
+ this.formattedOutput = false;
+ this.charsetEncoding = StandardCharsets.UTF_8;
+ this.soapProtocol = DEFAULT_SOAP_PROTOCOL;
+ }
+
+ @Override
+ public void encode(Object object, Type bodyType, RequestTemplate template) {
+ if (!(bodyType instanceof Class)) {
+ throw new UnsupportedOperationException(
+ "SOAP only supports encoding raw types. Found " + bodyType);
+ }
+ try {
+ Document document =
+ DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument();
+ Marshaller marshaller = jaxbContextFactory.createMarshaller((Class>) bodyType);
+ marshaller.marshal(object, document);
+ SOAPMessage soapMessage = MessageFactory.newInstance(soapProtocol).createMessage();
+ soapMessage.setProperty(
+ SOAPMessage.WRITE_XML_DECLARATION, Boolean.toString(writeXmlDeclaration));
+ soapMessage.setProperty(
+ SOAPMessage.CHARACTER_SET_ENCODING, charsetEncoding.displayName());
+ soapMessage.getSOAPBody().addDocument(document);
+
+ soapMessage = modifySOAPMessage(soapMessage);
+
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ if (formattedOutput) {
+ Transformer t = TransformerFactory.newInstance().newTransformer();
+ t.setOutputProperty(OutputKeys.INDENT, "yes");
+ t.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4");
+ t.transform(new DOMSource(soapMessage.getSOAPPart()), new StreamResult(bos));
+ } else {
+ soapMessage.writeTo(bos);
+ }
+ template.body(bos.toString());
+ } catch (SOAPException
+ | JAXBException
+ | ParserConfigurationException
+ | IOException
+ | TransformerFactoryConfigurationError
+ | TransformerException e) {
+ throw new EncodeException(e.toString(), e);
+ }
+ }
+
+ /**
+ * Override this in order to modify the SOAP message object before it's finally encoded.
+ * This might be useful to add SOAP Headers, which are not supported by this SOAPEncoder directly.
+ *
+ * This is an example of how to add a security header:
+ * protected SOAPMessage modifySOAPMessage(SOAPMessage soapMessage) throws SOAPException {
+ * SOAPFactory soapFactory = SOAPFactory.newInstance();
+ * String uri = "http://schemas.xmlsoap.org/ws/2002/12/secext";
+ * String prefix = "wss";
+ * SOAPElement security = soapFactory.createElement("Security", prefix, uri);
+ * SOAPElement usernameToken = soapFactory.createElement("UsernameToken", prefix, uri);
+ * usernameToken.addChildElement("Username", prefix, uri).setValue("test");
+ * usernameToken.addChildElement("Password", prefix, uri).setValue("test");
+ * security.addChildElement(usernameToken);
+ * soapMessage.getSOAPHeader().addChildElement(security);
+ * return soapMessage;
+ * }
+ *
+ */
+ protected SOAPMessage modifySOAPMessage(SOAPMessage soapMessage) throws SOAPException {
+ // Intentionally blank
+ return soapMessage;
+ }
+
+ /** Creates instances of {@link SOAPEncoder}. */
+ public static class Builder {
+
+ public boolean formattedOutput = false;
+ private JAXBContextFactory jaxbContextFactory;
+ private boolean writeXmlDeclaration = true;
+ private Charset charsetEncoding = StandardCharsets.UTF_8;
+ private String soapProtocol = DEFAULT_SOAP_PROTOCOL;
+
+ /** The {@link JAXBContextFactory} for body part. */
+ public Builder withJAXBContextFactory(JAXBContextFactory jaxbContextFactory) {
+ this.jaxbContextFactory = jaxbContextFactory;
+ return this;
+ }
+
+ /** Output format indent if true. Default is false */
+ public Builder withFormattedOutput(boolean formattedOutput) {
+ this.formattedOutput = formattedOutput;
+ return this;
+ }
+
+ /** Write the xml declaration if true. Default is true */
+ public Builder withWriteXmlDeclaration(boolean writeXmlDeclaration) {
+ this.writeXmlDeclaration = writeXmlDeclaration;
+ return this;
+ }
+
+ /** Specify the charset encoding. Default is {@link Charset#defaultCharset()}. */
+ public Builder withCharsetEncoding(Charset charsetEncoding) {
+ this.charsetEncoding = charsetEncoding;
+ return this;
+ }
+
+ /**
+ * The protocol used to create message factory. Default is "SOAP 1.1 Protocol".
+ *
+ * @param soapProtocol a string constant representing the MessageFactory protocol.
+ * @see SOAPConstants#SOAP_1_1_PROTOCOL
+ * @see SOAPConstants#SOAP_1_2_PROTOCOL
+ * @see SOAPConstants#DYNAMIC_SOAP_PROTOCOL
+ * @see MessageFactory#newInstance(String)
+ */
+ public Builder withSOAPProtocol(String soapProtocol) {
+ this.soapProtocol = soapProtocol;
+ return this;
+ }
+
+ public SOAPEncoder build() {
+ if (jaxbContextFactory == null) {
+ throw new IllegalStateException("JAXBContextFactory must be non-null");
+ }
+ return new SOAPEncoder(this);
+ }
+ }
+}
diff --git a/soap-jakarta/src/main/java/feign/soap/SOAPErrorDecoder.java b/soap-jakarta/src/main/java/feign/soap/SOAPErrorDecoder.java
new file mode 100644
index 00000000..ad6d9301
--- /dev/null
+++ b/soap-jakarta/src/main/java/feign/soap/SOAPErrorDecoder.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2012-2023 The Feign Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package feign.soap;
+
+import feign.Response;
+import feign.codec.ErrorDecoder;
+import jakarta.xml.soap.*;
+import jakarta.xml.ws.soap.SOAPFaultException;
+import java.io.IOException;
+
+/**
+ * Wraps the returned {@link SOAPFault} if present into a {@link SOAPFaultException}. So you need to
+ * catch {@link SOAPFaultException} to retrieve the reason of the {@link SOAPFault}.
+ *
+ *
+ * If no faults is returned then the default {@link ErrorDecoder} is used to return exception and
+ * eventually retry the call.
+ */
+public class SOAPErrorDecoder implements ErrorDecoder {
+
+ private final String soapProtocol;
+
+ public SOAPErrorDecoder() {
+ this.soapProtocol = SOAPConstants.DEFAULT_SOAP_PROTOCOL;
+ }
+
+ /**
+ * SOAPErrorDecoder constructor allowing you to specify the SOAP protocol.
+ *
+ * @param soapProtocol a string constant representing the MessageFactory protocol.
+ * @see SOAPConstants#SOAP_1_1_PROTOCOL
+ * @see SOAPConstants#SOAP_1_2_PROTOCOL
+ * @see SOAPConstants#DYNAMIC_SOAP_PROTOCOL
+ * @see MessageFactory#newInstance(String)
+ */
+ public SOAPErrorDecoder(String soapProtocol) {
+ this.soapProtocol = soapProtocol;
+ }
+
+ @Override
+ public Exception decode(String methodKey, Response response) {
+ if (response.body() == null || response.status() == 503)
+ return defaultErrorDecoder(methodKey, response);
+
+ SOAPMessage message;
+ try {
+ message =
+ MessageFactory.newInstance(soapProtocol)
+ .createMessage(null, response.body().asInputStream());
+ if (message.getSOAPBody() != null && message.getSOAPBody().hasFault()) {
+ return new SOAPFaultException(message.getSOAPBody().getFault());
+ }
+ } catch (SOAPException | IOException e) {
+ // ignored
+ }
+ return defaultErrorDecoder(methodKey, response);
+ }
+
+ private Exception defaultErrorDecoder(String methodKey, Response response) {
+ return new ErrorDecoder.Default().decode(methodKey, response);
+ }
+}
diff --git a/soap-jakarta/src/test/java/feign/soap/SOAPCodecTest.java b/soap-jakarta/src/test/java/feign/soap/SOAPCodecTest.java
new file mode 100644
index 00000000..18a84aa4
--- /dev/null
+++ b/soap-jakarta/src/test/java/feign/soap/SOAPCodecTest.java
@@ -0,0 +1,485 @@
+/*
+ * Copyright 2012-2023 The Feign Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package feign.soap;
+
+import static feign.Util.UTF_8;
+import static feign.assertj.FeignAssertions.assertThat;
+import static org.junit.Assert.assertEquals;
+import feign.Request;
+import feign.Request.HttpMethod;
+import feign.RequestTemplate;
+import feign.Response;
+import feign.Util;
+import feign.codec.Encoder;
+import feign.jaxb.JAXBContextFactory;
+import jakarta.xml.bind.annotation.*;
+import jakarta.xml.soap.SOAPElement;
+import jakarta.xml.soap.SOAPException;
+import jakarta.xml.soap.SOAPFactory;
+import jakarta.xml.soap.SOAPMessage;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import java.lang.reflect.Type;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Map;
+
+@SuppressWarnings("deprecation")
+public class SOAPCodecTest {
+
+ @Rule
+ public final ExpectedException thrown = ExpectedException.none();
+
+ @Test
+ public void encodesSoap() {
+ Encoder encoder = new SOAPEncoder.Builder()
+ .withJAXBContextFactory(new JAXBContextFactory.Builder().build())
+ .build();
+
+ GetPrice mock = new GetPrice();
+ mock.item = new Item();
+ mock.item.value = "Apples";
+
+ RequestTemplate template = new RequestTemplate();
+ encoder.encode(mock, GetPrice.class, template);
+
+ String soapEnvelop = "" +
+ "" +
+ "" +
+ "" +
+ "" +
+ "- Apples
" +
+ "" +
+ "" +
+ "";
+ assertThat(template).hasBody(soapEnvelop);
+ }
+
+ @Test
+ public void doesntEncodeParameterizedTypes() throws Exception {
+ thrown.expect(UnsupportedOperationException.class);
+ thrown.expectMessage(
+ "SOAP only supports encoding raw types. Found java.util.Map");
+
+ class ParameterizedHolder {
+
+ @SuppressWarnings("unused")
+ Map field;
+ }
+ Type parameterized = ParameterizedHolder.class.getDeclaredField("field").getGenericType();
+
+ RequestTemplate template = new RequestTemplate();
+ new SOAPEncoder(new JAXBContextFactory.Builder().build())
+ .encode(Collections.emptyMap(), parameterized, template);
+ }
+
+
+ @Test
+ public void encodesSoapWithCustomJAXBMarshallerEncoding() {
+ JAXBContextFactory jaxbContextFactory =
+ new JAXBContextFactory.Builder().withMarshallerJAXBEncoding("UTF-16").build();
+
+ Encoder encoder = new SOAPEncoder.Builder()
+ // .withWriteXmlDeclaration(true)
+ .withJAXBContextFactory(jaxbContextFactory)
+ .withCharsetEncoding(StandardCharsets.UTF_16)
+ .build();
+
+ GetPrice mock = new GetPrice();
+ mock.item = new Item();
+ mock.item.value = "Apples";
+
+ RequestTemplate template = new RequestTemplate();
+ encoder.encode(mock, GetPrice.class, template);
+
+ String soapEnvelop = "" +
+ "" +
+ "" +
+ "" +
+ "" +
+ "- Apples
" +
+ "" +
+ "" +
+ "";
+ byte[] utf16Bytes = soapEnvelop.getBytes(StandardCharsets.UTF_16LE);
+ assertThat(template).hasBody(utf16Bytes);
+ }
+
+
+ @Test
+ public void encodesSoapWithCustomJAXBSchemaLocation() {
+ JAXBContextFactory jaxbContextFactory =
+ new JAXBContextFactory.Builder()
+ .withMarshallerSchemaLocation("http://apihost http://apihost/schema.xsd")
+ .build();
+
+ Encoder encoder = new SOAPEncoder(jaxbContextFactory);
+
+ GetPrice mock = new GetPrice();
+ mock.item = new Item();
+ mock.item.value = "Apples";
+
+ RequestTemplate template = new RequestTemplate();
+ encoder.encode(mock, GetPrice.class, template);
+
+ assertThat(template).hasBody(""
+ + ""
+ + ""
+ + ""
+ + ""
+ + "- Apples
"
+ + ""
+ + ""
+ + "");
+ }
+
+
+ @Test
+ public void encodesSoapWithCustomJAXBNoSchemaLocation() {
+ JAXBContextFactory jaxbContextFactory =
+ new JAXBContextFactory.Builder()
+ .withMarshallerNoNamespaceSchemaLocation("http://apihost/schema.xsd")
+ .build();
+
+ Encoder encoder = new SOAPEncoder(jaxbContextFactory);
+
+ GetPrice mock = new GetPrice();
+ mock.item = new Item();
+ mock.item.value = "Apples";
+
+ RequestTemplate template = new RequestTemplate();
+ encoder.encode(mock, GetPrice.class, template);
+
+ assertThat(template).hasBody(""
+ + ""
+ + ""
+ + ""
+ + ""
+ + "- Apples
"
+ + ""
+ + ""
+ + "");
+ }
+
+ @Test
+ public void encodesSoapWithCustomJAXBFormattedOuput() {
+ Encoder encoder = new SOAPEncoder.Builder().withFormattedOutput(true)
+ .withJAXBContextFactory(new JAXBContextFactory.Builder()
+ .build())
+ .build();
+
+ GetPrice mock = new GetPrice();
+ mock.item = new Item();
+ mock.item.value = "Apples";
+
+ RequestTemplate template = new RequestTemplate();
+ encoder.encode(mock, GetPrice.class, template);
+
+ assertThat(template).hasBody(
+ "" + System.lineSeparator() +
+ ""
+ + System.lineSeparator() +
+ " " + System.lineSeparator() +
+ " " + System.lineSeparator() +
+ " " + System.lineSeparator() +
+ " - Apples
" + System.lineSeparator() +
+ " " + System.lineSeparator() +
+ " " + System.lineSeparator() +
+ "" + System.lineSeparator() +
+ "");
+ }
+
+ @Test
+ public void decodesSoap() throws Exception {
+ GetPrice mock = new GetPrice();
+ mock.item = new Item();
+ mock.item.value = "Apples";
+
+ String mockSoapEnvelop = ""
+ + ""
+ + ""
+ + ""
+ + ""
+ + "- Apples
"
+ + ""
+ + ""
+ + "";
+
+ Response response = Response.builder()
+ .status(200)
+ .reason("OK")
+ .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8))
+ .headers(Collections.emptyMap())
+ .body(mockSoapEnvelop, UTF_8)
+ .build();
+
+ SOAPDecoder decoder = new SOAPDecoder(new JAXBContextFactory.Builder().build());
+
+ assertEquals(mock, decoder.decode(response, GetPrice.class));
+ }
+
+ @Test
+ public void decodesSoapWithSchemaOnEnvelope() throws Exception {
+ GetPrice mock = new GetPrice();
+ mock.item = new Item();
+ mock.item.value = "Apples";
+
+ String mockSoapEnvelop = ""
+ + ""
+ + ""
+ + ""
+ + ""
+ + "- Apples
"
+ + ""
+ + ""
+ + "";
+
+ Response response = Response.builder()
+ .status(200)
+ .reason("OK")
+ .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8))
+ .headers(Collections.emptyMap())
+ .body(mockSoapEnvelop, UTF_8)
+ .build();
+
+ SOAPDecoder decoder = new SOAPDecoder.Builder()
+ .withJAXBContextFactory(new JAXBContextFactory.Builder().build())
+ .useFirstChild()
+ .build();
+
+ assertEquals(mock, decoder.decode(response, GetPrice.class));
+ }
+
+ @Test
+ public void decodesSoap1_2Protocol() throws Exception {
+ GetPrice mock = new GetPrice();
+ mock.item = new Item();
+ mock.item.value = "Apples";
+
+ String mockSoapEnvelop = ""
+ + ""
+ + ""
+ + ""
+ + ""
+ + "- Apples
"
+ + ""
+ + ""
+ + "";
+
+ Response response = Response.builder()
+ .status(200)
+ .reason("OK")
+ .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8))
+ .headers(Collections.emptyMap())
+ .body(mockSoapEnvelop, UTF_8)
+ .build();
+
+ SOAPDecoder decoder = new SOAPDecoder(new JAXBContextFactory.Builder().build());
+
+ assertEquals(mock, decoder.decode(response, GetPrice.class));
+ }
+
+
+ @Test
+ public void doesntDecodeParameterizedTypes() throws Exception {
+ thrown.expect(feign.codec.DecodeException.class);
+ thrown.expectMessage(
+ "java.util.Map is an interface, and JAXB can't handle interfaces.\n"
+ + "\tthis problem is related to the following location:\n"
+ + "\t\tat java.util.Map");
+
+ class ParameterizedHolder {
+
+ @SuppressWarnings("unused")
+ Map field;
+ }
+ Type parameterized = ParameterizedHolder.class.getDeclaredField("field").getGenericType();
+
+ Response response = Response.builder()
+ .status(200)
+ .reason("OK")
+ .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8))
+ .headers(Collections.emptyMap())
+ .body(""
+ + ""
+ + ""
+ + ""
+ + ""
+ + "- Apples
"
+ + ""
+ + ""
+ + "", UTF_8)
+ .build();
+
+ new SOAPDecoder(new JAXBContextFactory.Builder().build()).decode(response, parameterized);
+ }
+
+ @Test
+ public void decodeAnnotatedParameterizedTypes() throws Exception {
+ JAXBContextFactory jaxbContextFactory =
+ new JAXBContextFactory.Builder().withMarshallerFormattedOutput(true).build();
+
+ Encoder encoder = new SOAPEncoder(jaxbContextFactory);
+
+ Box boxStr = new Box<>();
+ boxStr.set("hello");
+ Box> boxBoxStr = new Box<>();
+ boxBoxStr.set(boxStr);
+ RequestTemplate template = new RequestTemplate();
+ encoder.encode(boxBoxStr, Box.class, template);
+
+ Response response = Response.builder()
+ .status(200)
+ .reason("OK")
+ .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8))
+ .headers(Collections.emptyMap())
+ .body(template.body())
+ .build();
+
+ new SOAPDecoder(new JAXBContextFactory.Builder().build()).decode(response, Box.class);
+
+ }
+
+ /**
+ * Enabled via {@link feign.Feign.Builder#dismiss404()}
+ */
+ @Test
+ public void notFoundDecodesToNull() throws Exception {
+ Response response = Response.builder()
+ .status(404)
+ .reason("NOT FOUND")
+ .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8))
+ .headers(Collections.emptyMap())
+ .build();
+ assertThat((byte[]) new SOAPDecoder(new JAXBContextFactory.Builder().build())
+ .decode(response, byte[].class)).isEmpty();
+ }
+
+ @Test
+ public void changeSoapProtocolAndSetHeader() {
+ Encoder encoder =
+ new ChangedProtocolAndHeaderSOAPEncoder(new JAXBContextFactory.Builder().build());
+
+ GetPrice mock = new GetPrice();
+ mock.item = new Item();
+ mock.item.value = "Apples";
+
+ RequestTemplate template = new RequestTemplate();
+ encoder.encode(mock, GetPrice.class, template);
+
+ String soapEnvelop = "" +
+ "" +
+ "" +
+ (System.getProperty("java.version").startsWith("1.8")
+ ? ""
+ : "")
+ +
+ "" +
+ "test" +
+ "test" +
+ "" +
+ "" +
+ "" +
+ "" +
+ "" +
+ "- Apples
" +
+ "" +
+ "" +
+ "";
+ assertThat(template).hasBody(soapEnvelop);
+ }
+
+ @XmlRootElement
+ static class Box {
+
+ @XmlElement
+ private T t;
+
+ public void set(T t) {
+ this.t = t;
+ }
+
+ }
+
+ static class ChangedProtocolAndHeaderSOAPEncoder extends SOAPEncoder {
+
+ public ChangedProtocolAndHeaderSOAPEncoder(JAXBContextFactory jaxbContextFactory) {
+ super(new SOAPEncoder.Builder()
+ .withSOAPProtocol("SOAP 1.2 Protocol")
+ .withJAXBContextFactory(jaxbContextFactory));
+ }
+
+ @Override
+ protected SOAPMessage modifySOAPMessage(SOAPMessage soapMessage) throws SOAPException {
+ SOAPFactory soapFactory = SOAPFactory.newInstance();
+ String uri = "http://schemas.xmlsoap.org/ws/2002/12/secext";
+ String prefix = "wss";
+ SOAPElement security = soapFactory.createElement("Security", prefix, uri);
+ SOAPElement usernameToken = soapFactory.createElement("UsernameToken", prefix, uri);
+ usernameToken.addChildElement("Username", prefix, uri).setValue("test");
+ usernameToken.addChildElement("Password", prefix, uri).setValue("test");
+ security.addChildElement(usernameToken);
+ soapMessage.getSOAPHeader().addChildElement(security);
+ return soapMessage;
+ }
+ }
+
+ @XmlRootElement(name = "GetPrice")
+ @XmlAccessorType(XmlAccessType.FIELD)
+ static class GetPrice {
+
+ @XmlElement(name = "Item")
+ private Item item;
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof GetPrice) {
+ GetPrice getPrice = (GetPrice) obj;
+ return item.value.equals(getPrice.item.value);
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return item.value != null ? item.value.hashCode() : 0;
+ }
+ }
+
+ @XmlRootElement(name = "Item")
+ @XmlAccessorType(XmlAccessType.FIELD)
+ static class Item {
+
+ @XmlValue
+ private String value;
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof Item) {
+ Item item = (Item) obj;
+ return value.equals(item.value);
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return value != null ? value.hashCode() : 0;
+ }
+ }
+
+}
diff --git a/soap-jakarta/src/test/java/feign/soap/SOAPFaultDecoderTest.java b/soap-jakarta/src/test/java/feign/soap/SOAPFaultDecoderTest.java
new file mode 100644
index 00000000..b4b81e32
--- /dev/null
+++ b/soap-jakarta/src/test/java/feign/soap/SOAPFaultDecoderTest.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2012-2023 The Feign Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package feign.soap;
+
+import static feign.Util.UTF_8;
+import feign.FeignException;
+import feign.Request;
+import feign.Request.HttpMethod;
+import feign.Response;
+import feign.Util;
+import feign.jaxb.JAXBContextFactory;
+import jakarta.xml.soap.SOAPConstants;
+import jakarta.xml.ws.soap.SOAPFaultException;
+import org.assertj.core.api.Assertions;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import java.io.DataInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collections;
+
+@SuppressWarnings("deprecation")
+public class SOAPFaultDecoderTest {
+
+ @Rule
+ public final ExpectedException thrown = ExpectedException.none();
+
+ private static byte[] getResourceBytes(String resourcePath) throws IOException {
+ InputStream resourceAsStream = SOAPFaultDecoderTest.class.getResourceAsStream(resourcePath);
+ byte[] bytes = new byte[resourceAsStream.available()];
+ new DataInputStream(resourceAsStream).readFully(bytes);
+ return bytes;
+ }
+
+ @Test
+ public void soapDecoderThrowsSOAPFaultException() throws IOException {
+
+ thrown.expect(SOAPFaultException.class);
+ thrown.expectMessage("Processing error");
+
+ Response response = Response.builder()
+ .status(200)
+ .reason("OK")
+ .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8))
+ .headers(Collections.emptyMap())
+ .body(getResourceBytes("/samples/SOAP_1_2_FAULT.xml"))
+ .build();
+
+ new SOAPDecoder.Builder().withSOAPProtocol(SOAPConstants.SOAP_1_2_PROTOCOL)
+ .withJAXBContextFactory(new JAXBContextFactory.Builder().build()).build()
+ .decode(response, Object.class);
+ }
+
+ @Test
+ public void errorDecoderReturnsSOAPFaultException() throws IOException {
+ Response response = Response.builder()
+ .status(400)
+ .reason("BAD REQUEST")
+ .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8))
+ .headers(Collections.emptyMap())
+ .body(getResourceBytes("/samples/SOAP_1_1_FAULT.xml"))
+ .build();
+
+ Exception error =
+ new SOAPErrorDecoder().decode("Service#foo()", response);
+ Assertions.assertThat(error).isInstanceOf(SOAPFaultException.class)
+ .hasMessage("Message was not SOAP 1.1 compliant");
+ }
+
+ @Test
+ public void errorDecoderReturnsFeignExceptionOn503Status() throws IOException {
+ Response response = Response.builder()
+ .status(503)
+ .reason("Service Unavailable")
+ .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8))
+ .headers(Collections.emptyMap())
+ .body("Service Unavailable", UTF_8)
+ .build();
+
+ Exception error =
+ new SOAPErrorDecoder().decode("Service#foo()", response);
+
+ Assertions.assertThat(error).isInstanceOf(FeignException.class)
+ .hasMessage(
+ "[503 Service Unavailable] during [GET] to [/api] [Service#foo()]: [Service Unavailable]");
+ }
+
+ @Test
+ public void errorDecoderReturnsFeignExceptionOnEmptyFault() throws IOException {
+ String responseBody = "\n" +
+ "\n" +
+ " \n" +
+ " \n" +
+ "";
+ Response response = Response.builder()
+ .status(500)
+ .reason("Internal Server Error")
+ .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8))
+ .headers(Collections.emptyMap())
+ .body(responseBody, UTF_8)
+ .build();
+
+ Exception error =
+ new SOAPErrorDecoder().decode("Service#foo()", response);
+
+ Assertions.assertThat(error).isInstanceOf(FeignException.class)
+ .hasMessage("[500 Internal Server Error] during [GET] to [/api] [Service#foo()]: ["
+ + responseBody + "]");
+ }
+
+}
diff --git a/soap-jakarta/src/test/java/feign/soap/package-info.java b/soap-jakarta/src/test/java/feign/soap/package-info.java
new file mode 100644
index 00000000..d087155b
--- /dev/null
+++ b/soap-jakarta/src/test/java/feign/soap/package-info.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2012-2023 The Feign Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+@XmlSchema(elementFormDefault = XmlNsForm.UNQUALIFIED)
+package feign.soap;
+
+import jakarta.xml.bind.annotation.XmlNsForm;
+import jakarta.xml.bind.annotation.XmlSchema;
diff --git a/soap-jakarta/src/test/resources/samples/SOAP_1_1_FAULT.xml b/soap-jakarta/src/test/resources/samples/SOAP_1_1_FAULT.xml
new file mode 100644
index 00000000..5f7fe979
--- /dev/null
+++ b/soap-jakarta/src/test/resources/samples/SOAP_1_1_FAULT.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+ SOAP-ENV:Client
+ Message was not SOAP 1.1 compliant
+
+
+
\ No newline at end of file
diff --git a/soap-jakarta/src/test/resources/samples/SOAP_1_2_FAULT.xml b/soap-jakarta/src/test/resources/samples/SOAP_1_2_FAULT.xml
new file mode 100644
index 00000000..0b39989e
--- /dev/null
+++ b/soap-jakarta/src/test/resources/samples/SOAP_1_2_FAULT.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+ env:Sender
+
+ rpc:BadArguments
+
+
+
+ Processing error
+
+
+
+ Name does not match card number
+ 999
+
+
+
+
+
diff --git a/src/docs/overview-mindmap.iuml b/src/docs/overview-mindmap.iuml
index 5244bcdf..9ec8e13f 100644
--- a/src/docs/overview-mindmap.iuml
+++ b/src/docs/overview-mindmap.iuml
@@ -19,6 +19,7 @@
*** JAX-RS 3 / Jakarta
*** Spring 4
*** SOAP
+*** SOAP Jakarta
*** Spring boot (3rd party)
left side
@@ -26,6 +27,7 @@ left side
** encoders/decoders
*** GSON
*** JAXB
+*** JAXB Jakarta
*** Jackson 1
*** Jackson 2
*** Jackson JAXB