From 1cf88516e4a61e40ff749ef58672c2d77399b2d6 Mon Sep 17 00:00:00 2001 From: Pierre Lakreb Date: Tue, 11 Sep 2018 18:51:28 +0200 Subject: [PATCH] Adding SOAP CoDec (+ JAXB modifications) --- README.md | 22 + .../java/feign/jaxb/JAXBContextFactory.java | 41 +- .../src/main/java/feign/jaxb/JAXBDecoder.java | 17 +- .../src/main/java/feign/jaxb/JAXBEncoder.java | 2 +- pom.xml | 5 + soap/README.md | 50 +++ soap/pom.xml | 89 ++++ .../src/main/java/feign/soap/SOAPDecoder.java | 168 ++++++++ .../src/main/java/feign/soap/SOAPEncoder.java | 199 +++++++++ .../java/feign/soap/SOAPErrorDecoder.java | 80 ++++ .../services/javax.xml.soap.SAAJMetaFactory | 1 + .../test/java/feign/soap/SOAPCodecTest.java | 395 ++++++++++++++++++ .../java/feign/soap/SOAPFaultDecoderTest.java | 122 ++++++ .../test/resources/samples/SOAP_1_1_FAULT.xml | 13 + .../test/resources/samples/SOAP_1_2_FAULT.xml | 23 + 15 files changed, 1200 insertions(+), 27 deletions(-) create mode 100644 soap/README.md create mode 100644 soap/pom.xml create mode 100644 soap/src/main/java/feign/soap/SOAPDecoder.java create mode 100644 soap/src/main/java/feign/soap/SOAPEncoder.java create mode 100644 soap/src/main/java/feign/soap/SOAPErrorDecoder.java create mode 100644 soap/src/main/resources/services/javax.xml.soap.SAAJMetaFactory create mode 100644 soap/src/test/java/feign/soap/SOAPCodecTest.java create mode 100644 soap/src/test/java/feign/soap/SOAPFaultDecoderTest.java create mode 100644 soap/src/test/resources/samples/SOAP_1_1_FAULT.xml create mode 100644 soap/src/test/resources/samples/SOAP_1_2_FAULT.xml diff --git a/README.md b/README.md index 182e5d5781..8d8c994da8 100644 --- a/README.md +++ b/README.md @@ -342,6 +342,28 @@ public class Example { } ``` +### SOAP +[SOAP](./soap) includes an encoder and decoder you can use with an XML API. + + +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 class Example { + public static void main(String[] args) { + Api api = Feign.builder() + .encoder(new SOAPEncoder(jaxbFactory)) + .decoder(new SOAPDecoder(jaxbFactory)) + .errorDecoder(new SOAPErrorDecoder()) + .target(MyApi.class, "http://api"); + } +} +``` + +NB: you may also need to add `SOAPErrorDecoder` if SOAP Faults are returned in response with error http codes (4xx, 5xx, ...) + ### SLF4J [SLF4JModule](./slf4j) allows directing Feign's logging to [SLF4J](http://www.slf4j.org/), allowing you to easily use a logging backend of your choice (Logback, Log4J, etc.) diff --git a/jaxb/src/main/java/feign/jaxb/JAXBContextFactory.java b/jaxb/src/main/java/feign/jaxb/JAXBContextFactory.java index 61254e5812..e1e30db767 100644 --- a/jaxb/src/main/java/feign/jaxb/JAXBContextFactory.java +++ b/jaxb/src/main/java/feign/jaxb/JAXBContextFactory.java @@ -14,9 +14,9 @@ package feign.jaxb; import java.util.HashMap; -import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Map.Entry; import java.util.concurrent.ConcurrentHashMap; import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBException; @@ -31,8 +31,8 @@ */ public final class JAXBContextFactory { - private final ConcurrentHashMap jaxbContexts = - new ConcurrentHashMap(64); + private final ConcurrentHashMap, JAXBContext> jaxbContexts = + new ConcurrentHashMap<>(64); private final Map properties; private JAXBContextFactory(Map properties) { @@ -43,26 +43,21 @@ private JAXBContextFactory(Map properties) { * Creates a new {@link javax.xml.bind.Unmarshaller} that handles the supplied class. */ public Unmarshaller createUnmarshaller(Class clazz) throws JAXBException { - JAXBContext ctx = getContext(clazz); - return ctx.createUnmarshaller(); + return getContext(clazz).createUnmarshaller(); } /** * Creates a new {@link javax.xml.bind.Marshaller} that handles the supplied class. */ public Marshaller createMarshaller(Class clazz) throws JAXBException { - JAXBContext ctx = getContext(clazz); - Marshaller marshaller = ctx.createMarshaller(); + Marshaller marshaller = getContext(clazz).createMarshaller(); setMarshallerProperties(marshaller); return marshaller; } private void setMarshallerProperties(Marshaller marshaller) throws PropertyException { - Iterator keys = properties.keySet().iterator(); - - while (keys.hasNext()) { - String key = keys.next(); - marshaller.setProperty(key, properties.get(key)); + for (Entry en : properties.entrySet()) { + marshaller.setProperty(en.getKey(), en.getValue()); } } @@ -90,11 +85,11 @@ private void preloadContextCache(List> classes) throws JAXBException { } /** - * Creates instances of {@link feign.jaxb.JAXBContextFactory} + * Creates instances of {@link feign.jaxb.JAXBContextFactory}. */ public static class Builder { - private final Map properties = new HashMap(5); + private final Map properties = new HashMap<>(10); /** * Sets the jaxb.encoding property of any Marshaller created by this factory. @@ -136,6 +131,24 @@ public Builder withMarshallerFragment(Boolean value) { return this; } + /** + * Sets the given property of any Marshaller created by this factory. + * + *

+ * Example :
+ *
+ * + * new JAXBContextFactory.Builder() + * .withProperty("com.sun.xml.internal.bind.xmlHeaders", "<!DOCTYPE Example SYSTEM \"example.dtd\">") + * .build(); + * + *

+ */ + public Builder withProperty(String key, Object value) { + properties.put(key, value); + return this; + } + /** * Creates a new {@link feign.jaxb.JAXBContextFactory} instance with a lazy loading cached * context diff --git a/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java b/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java index aa0a6a66fb..4485abcb6c 100644 --- a/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java +++ b/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java @@ -14,13 +14,11 @@ package feign.jaxb; import java.io.IOException; -import java.lang.reflect.Type; import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; import javax.xml.bind.JAXBException; -import javax.xml.bind.Unmarshaller; import javax.xml.parsers.ParserConfigurationException; import javax.xml.parsers.SAXParserFactory; -import javax.xml.transform.Source; import javax.xml.transform.sax.SAXSource; import feign.Response; import feign.Util; @@ -90,15 +88,10 @@ public Object decode(Response response, Type type) throws IOException { false); saxParserFactory.setNamespaceAware(namespaceAware); - Source source = new SAXSource(saxParserFactory.newSAXParser().getXMLReader(), - new InputSource(response.body().asInputStream())); - Unmarshaller unmarshaller = jaxbContextFactory.createUnmarshaller((Class) type); - return unmarshaller.unmarshal(source); - } catch (JAXBException e) { - throw new DecodeException(e.toString(), e); - } catch (ParserConfigurationException e) { - throw new DecodeException(e.toString(), e); - } catch (SAXException e) { + return jaxbContextFactory.createUnmarshaller((Class) type).unmarshal(new SAXSource( + saxParserFactory.newSAXParser().getXMLReader(), + new InputSource(response.body().asInputStream()))); + } catch (JAXBException | ParserConfigurationException | SAXException e) { throw new DecodeException(e.toString(), e); } finally { if (response.body() != null) { diff --git a/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java b/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java index 5bb30eac19..c4d8f79047 100644 --- a/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java +++ b/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java @@ -56,7 +56,7 @@ public void encode(Object object, Type bodyType, RequestTemplate template) { "JAXB only supports encoding raw types. Found " + bodyType); } try { - Marshaller marshaller = jaxbContextFactory.createMarshaller((Class) bodyType); + Marshaller marshaller = jaxbContextFactory.createMarshaller((Class) bodyType); StringWriter stringWriter = new StringWriter(); marshaller.marshal(object, stringWriter); template.body(stringWriter.toString()); diff --git a/pom.xml b/pom.xml index 1e00e02021..ad557a398a 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,7 @@ ribbon sax slf4j + soap reactive example-github example-wikipedia @@ -52,6 +53,9 @@ UTF-8 UTF-8 + + -Duser.language=en + 1.8 java18 @@ -317,6 +321,7 @@ true false + ${jvm.options} diff --git a/soap/README.md b/soap/README.md new file mode 100644 index 0000000000..c6e804b96c --- /dev/null +++ b/soap/README.md @@ -0,0 +1,50 @@ +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"); +``` \ No newline at end of file diff --git a/soap/pom.xml b/soap/pom.xml new file mode 100644 index 0000000000..b534b2634d --- /dev/null +++ b/soap/pom.xml @@ -0,0 +1,89 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 10.0.2-SNAPSHOT + + + feign-soap + Feign SOAP + Feign SOAP CoDec + + + ${project.basedir}/.. + + + + + ${project.groupId} + feign-core + + + + ${project.groupId} + feign-jaxb + + + + ${project.groupId} + feign-core + test-jar + test + + + + + + + 11 + + + + + + javax.xml.bind + jaxb-api + 2.3.1 + + + org.glassfish.jaxb + jaxb-runtime + 2.4.0-b180830.0438 + test + + + javax.xml.ws + jaxws-api + 2.3.1 + + + com.sun.xml.messaging.saaj + saaj-impl + 1.5.0 + + + + + + diff --git a/soap/src/main/java/feign/soap/SOAPDecoder.java b/soap/src/main/java/feign/soap/SOAPDecoder.java new file mode 100644 index 0000000000..bfb19237e0 --- /dev/null +++ b/soap/src/main/java/feign/soap/SOAPDecoder.java @@ -0,0 +1,168 @@ +/** + * Copyright 2012-2018 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 java.io.IOException; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import javax.xml.bind.JAXBException; +import javax.xml.soap.MessageFactory; +import javax.xml.soap.SOAPConstants; +import javax.xml.soap.SOAPException; +import javax.xml.soap.SOAPMessage; +import javax.xml.ws.soap.SOAPFaultException; +import feign.Response; +import feign.Util; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import feign.jaxb.JAXBContextFactory; + +/** + * 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; + + public SOAPDecoder(JAXBContextFactory jaxbContextFactory) { + this.jaxbContextFactory = jaxbContextFactory; + this.soapProtocol = SOAPConstants.DEFAULT_SOAP_PROTOCOL; + } + + private SOAPDecoder(Builder builder) { + this.soapProtocol = builder.soapProtocol; + this.jaxbContextFactory = builder.jaxbContextFactory; + } + + @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()); + } + + return jaxbContextFactory.createUnmarshaller((Class) type) + .unmarshal(message.getSOAPBody().extractContentAsDocument()); + } + } catch (SOAPException | JAXBException e) { + throw new DecodeException(e.toString(), 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; + + 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; + } + + public SOAPDecoder build() { + if (jaxbContextFactory == null) { + throw new IllegalStateException("JAXBContextFactory must be non-null"); + } + return new SOAPDecoder(this); + } + } + +} diff --git a/soap/src/main/java/feign/soap/SOAPEncoder.java b/soap/src/main/java/feign/soap/SOAPEncoder.java new file mode 100644 index 0000000000..c67fce2a1f --- /dev/null +++ b/soap/src/main/java/feign/soap/SOAPEncoder.java @@ -0,0 +1,199 @@ +/** + * Copyright 2012-2018 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 java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.charset.Charset; +import javax.xml.bind.JAXBException; +import javax.xml.bind.Marshaller; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.soap.MessageFactory; +import javax.xml.soap.SOAPConstants; +import javax.xml.soap.SOAPException; +import javax.xml.soap.SOAPMessage; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.TransformerFactoryConfigurationError; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import org.w3c.dom.Document; +import feign.RequestTemplate; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import feign.jaxb.JAXBContextFactory; + + +/** + * Encodes requests using SOAPMessage and JAXB for the body part.
+ *

+ * Basic example with 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; + + private 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 = Charset.defaultCharset(); + 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); + 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(new String(bos.toByteArray())); + } catch (SOAPException | JAXBException | ParserConfigurationException | IOException + | TransformerFactoryConfigurationError | TransformerException e) { + throw new EncodeException(e.toString(), e); + } + } + + /** + * Creates instances of {@link SOAPEncoder}. + */ + public static class Builder { + + private JAXBContextFactory jaxbContextFactory; + public boolean formattedOutput = false; + private boolean writeXmlDeclaration = true; + private Charset charsetEncoding = Charset.defaultCharset(); + 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/src/main/java/feign/soap/SOAPErrorDecoder.java b/soap/src/main/java/feign/soap/SOAPErrorDecoder.java new file mode 100644 index 0000000000..af400042f3 --- /dev/null +++ b/soap/src/main/java/feign/soap/SOAPErrorDecoder.java @@ -0,0 +1,80 @@ +/** + * Copyright 2012-2018 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 java.io.IOException; +import javax.xml.soap.MessageFactory; +import javax.xml.soap.SOAPConstants; +import javax.xml.soap.SOAPException; +import javax.xml.soap.SOAPFault; +import javax.xml.soap.SOAPMessage; +import javax.xml.ws.soap.SOAPFaultException; +import feign.Response; +import feign.codec.ErrorDecoder; + +/** + * 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/src/main/resources/services/javax.xml.soap.SAAJMetaFactory b/soap/src/main/resources/services/javax.xml.soap.SAAJMetaFactory new file mode 100644 index 0000000000..a09cd2b139 --- /dev/null +++ b/soap/src/main/resources/services/javax.xml.soap.SAAJMetaFactory @@ -0,0 +1 @@ +com.sun.xml.messaging.saaj.soap.SAAJMetaFactoryImpl diff --git a/soap/src/test/java/feign/soap/SOAPCodecTest.java b/soap/src/test/java/feign/soap/SOAPCodecTest.java new file mode 100644 index 0000000000..0cca8cddea --- /dev/null +++ b/soap/src/test/java/feign/soap/SOAPCodecTest.java @@ -0,0 +1,395 @@ +/** + * Copyright 2012-2018 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.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; +import java.lang.reflect.Type; +import java.nio.charset.Charset; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; +import javax.xml.bind.annotation.XmlValue; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +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 feign.jaxb.JAXBDecoder; + +public class SOAPCodecTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + @Test + public void encodesSoap() throws Exception { + 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() throws Exception { + JAXBContextFactory jaxbContextFactory = + new JAXBContextFactory.Builder().withMarshallerJAXBEncoding("UTF-16").build(); + + Encoder encoder = new SOAPEncoder.Builder() + // .withWriteXmlDeclaration(true) + .withJAXBContextFactory(jaxbContextFactory) + .withCharsetEncoding(Charset.forName("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("UTF-16LE"); + assertThat(template).hasBody(utf16Bytes); + } + + + @Test + public void encodesSoapWithCustomJAXBSchemaLocation() throws Exception { + 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() throws Exception { + 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() throws Exception { + 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("\n" + + "\n" + + " \n" + + " \n" + + " \n" + + " Apples\n" + + " \n" + + " \n" + + "\n" + + ""); + } + + @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 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); + } + + @XmlRootElement + static class Box { + + @XmlElement + private T t; + + public void set(T t) { + this.t = t; + } + + } + + @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#decode404()} + */ + @Test + public void notFoundDecodesToEmpty() 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 JAXBDecoder(new JAXBContextFactory.Builder().build()) + .decode(response, byte[].class)).isEmpty(); + } + + + @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/src/test/java/feign/soap/SOAPFaultDecoderTest.java b/soap/src/test/java/feign/soap/SOAPFaultDecoderTest.java new file mode 100644 index 0000000000..04c57ec20e --- /dev/null +++ b/soap/src/test/java/feign/soap/SOAPFaultDecoderTest.java @@ -0,0 +1,122 @@ +/** + * Copyright 2012-2018 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 java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import javax.xml.soap.SOAPConstants; +import javax.xml.ws.soap.SOAPFaultException; +import org.assertj.core.api.Assertions; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import feign.FeignException; +import feign.Request; +import feign.Request.HttpMethod; +import feign.Response; +import feign.Util; +import feign.jaxb.JAXBContextFactory; + +public class SOAPFaultDecoderTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + @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("status 503 reading Service#foo()"); + } + + @Test + public void errorDecoderReturnsFeignExceptionOnEmptyFault() throws IOException { + 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("\n" + + "\n" + + " \n" + + " \n" + + "", UTF_8) + .build(); + + Exception error = + new SOAPErrorDecoder().decode("Service#foo()", response); + + Assertions.assertThat(error).isInstanceOf(FeignException.class) + .hasMessage("status 500 reading Service#foo()"); + } + + 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; + } + +} diff --git a/soap/src/test/resources/samples/SOAP_1_1_FAULT.xml b/soap/src/test/resources/samples/SOAP_1_1_FAULT.xml new file mode 100644 index 0000000000..5f7fe979fa --- /dev/null +++ b/soap/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/src/test/resources/samples/SOAP_1_2_FAULT.xml b/soap/src/test/resources/samples/SOAP_1_2_FAULT.xml new file mode 100644 index 0000000000..0b39989e4a --- /dev/null +++ b/soap/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 + + + + +