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