diff --git a/CHANGELOG.md b/CHANGELOG.md index 26f56fd72..5ee14fef3 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### 🐣新特性 * 【core 】 `ListUtil`增加`zip`方法(pr#4052@Github) +* 【http 】 增加`JakartaSoapClient`(issue#4103@Github) ### 🐞Bug修复 * 【jwt 】 修复verify方法在定义alg为`none`时验证失效问题(issue#4105@Github) diff --git a/hutool-http/pom.xml b/hutool-http/pom.xml index 778648d7d..6406ece8a 100755 --- a/hutool-http/pom.xml +++ b/hutool-http/pom.xml @@ -38,5 +38,11 @@ 1.4.0 provided + + jakarta.xml.soap + jakarta.xml.soap-api + 2.0.1 + provided + diff --git a/hutool-http/src/main/java/cn/hutool/http/webservice/JakartaSoapClient.java b/hutool-http/src/main/java/cn/hutool/http/webservice/JakartaSoapClient.java new file mode 100644 index 000000000..5970307cd --- /dev/null +++ b/hutool-http/src/main/java/cn/hutool/http/webservice/JakartaSoapClient.java @@ -0,0 +1,654 @@ +package cn.hutool.http.webservice; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.core.util.XmlUtil; +import cn.hutool.http.HttpBase; +import cn.hutool.http.HttpGlobalConfig; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import jakarta.xml.soap.*; + +import javax.xml.XMLConstants; +import javax.xml.namespace.QName; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +/** + * SOAP客户端 + * + *

+ * 此对象用于构建一个SOAP消息,并通过HTTP接口发出消息内容。 + * SOAP消息本质上是一个XML文本,可以通过调用{@link #getMsgStr(boolean)} 方法获取消息体 + *

+ * 使用方法: + * + *

+ * SoapClient client = SoapClient.create(url)
+ * .setMethod(methodName, namespaceURI)
+ * .setCharset(CharsetUtil.CHARSET_GBK)
+ * .setParam(param1, "XXX");
+ *
+ * String response = client.send(true);
+ *
+ * 
+ * + * @author looly + * @since 5.8.42 + */ +public class JakartaSoapClient extends HttpBase { + + /** + * XML消息体的Content-Type + * soap1.1 : text/xml + * soap1.2 : application/soap+xml + * soap1.1与soap1.2区别: https://www.cnblogs.com/qlqwjy/p/7577147.html + */ + private static final String CONTENT_TYPE_SOAP11_TEXT_XML = "text/xml;charset="; + private static final String CONTENT_TYPE_SOAP12_SOAP_XML = "application/soap+xml;charset="; + + /** + * 请求的URL地址 + */ + private String url; + + /** + * 默认连接超时 + */ + private int connectionTimeout = HttpGlobalConfig.getTimeout(); + /** + * 默认读取超时 + */ + private int readTimeout = HttpGlobalConfig.getTimeout(); + + /** + * 消息工厂,用于创建消息 + */ + private MessageFactory factory; + /** + * SOAP消息 + */ + private SOAPMessage message; + /** + * 消息方法节点 + */ + private SOAPBodyElement methodEle; + /** + * 应用于方法上的命名空间URI + */ + private final String namespaceURI; + /** + * Soap协议 + * soap1.1 : text/xml + * soap1.2 : application/soap+xml + */ + private final SoapProtocol protocol; + + /** + * 创建SOAP客户端,默认使用soap1.1版本协议 + * + * @param url WS的URL地址 + * @return this + */ + public static JakartaSoapClient create(String url) { + return new JakartaSoapClient(url); + } + + /** + * 创建SOAP客户端 + * + * @param url WS的URL地址 + * @param protocol 协议,见{@link SoapProtocol} + * @return this + */ + public static JakartaSoapClient create(String url, SoapProtocol protocol) { + return new JakartaSoapClient(url, protocol); + } + + /** + * 创建SOAP客户端 + * + * @param url WS的URL地址 + * @param protocol 协议,见{@link SoapProtocol} + * @param namespaceURI 方法上的命名空间URI + * @return this + * @since 4.5.6 + */ + public static JakartaSoapClient create(String url, SoapProtocol protocol, String namespaceURI) { + return new JakartaSoapClient(url, protocol, namespaceURI); + } + + /** + * 构造,默认使用soap1.1版本协议 + * + * @param url WS的URL地址 + */ + public JakartaSoapClient(String url) { + this(url, SoapProtocol.SOAP_1_1); + } + + /** + * 构造 + * + * @param url WS的URL地址 + * @param protocol 协议版本,见{@link SoapProtocol} + */ + public JakartaSoapClient(String url, SoapProtocol protocol) { + this(url, protocol, null); + } + + /** + * 构造 + * + * @param url WS的URL地址 + * @param protocol 协议版本,见{@link SoapProtocol} + * @param namespaceURI 方法上的命名空间URI + * @since 4.5.6 + */ + public JakartaSoapClient(String url, SoapProtocol protocol, String namespaceURI) { + this.url = url; + this.namespaceURI = namespaceURI; + this.protocol = protocol; + init(protocol); + } + + /** + * 初始化 + * + * @param protocol 协议版本枚举,见{@link SoapProtocol} + * @return this + */ + public JakartaSoapClient init(SoapProtocol protocol) { + // 创建消息工厂 + try { + this.factory = MessageFactory.newInstance(protocol.getValue()); + // 根据消息工厂创建SoapMessage + this.message = factory.createMessage(); + } catch (SOAPException e) { + throw new SoapRuntimeException(e); + } + + return this; + } + + /** + * 重置SOAP客户端,用于客户端复用 + * + *

+ * 重置后需调用serMethod方法重新指定请求方法,并调用setParam方法重新定义参数 + * + * @return this + * @since 4.6.7 + */ + public JakartaSoapClient reset() { + try { + this.message = factory.createMessage(); + } catch (SOAPException e) { + throw new SoapRuntimeException(e); + } + this.methodEle = null; + + return this; + } + + /** + * 设置编码 + * + * @param charset 编码 + * @return this + * @see #charset(Charset) + */ + public JakartaSoapClient setCharset(Charset charset) { + return this.charset(charset); + } + + @Override + public JakartaSoapClient charset(Charset charset) { + super.charset(charset); + try { + this.message.setProperty(SOAPMessage.CHARACTER_SET_ENCODING, this.charset()); + this.message.setProperty(SOAPMessage.WRITE_XML_DECLARATION, "true"); + } catch (SOAPException e) { + // ignore + } + + return this; + } + + /** + * 设置Webservice请求地址 + * + * @param url Webservice请求地址 + * @return this + */ + public JakartaSoapClient setUrl(String url) { + this.url = url; + return this; + } + + /** + * 增加SOAP头信息,方法返回{@link SOAPHeaderElement}可以设置具体属性和子节点 + * + * @param name 头信息标签名 + * @param actorURI 中间的消息接收者 + * @param roleUri Role的URI + * @param mustUnderstand 标题项对于要对其进行处理的接收者来说是强制的还是可选的 + * @param relay relay属性 + * @return {@link SOAPHeaderElement} + * @since 5.4.4 + */ + public SOAPHeaderElement addSOAPHeader(QName name, String actorURI, String roleUri, Boolean mustUnderstand, Boolean relay) { + final SOAPHeaderElement ele = addSOAPHeader(name); + try { + if (StrUtil.isNotBlank(roleUri)) { + ele.setRole(roleUri); + } + if (null != relay) { + ele.setRelay(relay); + } + } catch (SOAPException e) { + throw new SoapRuntimeException(e); + } + + if (StrUtil.isNotBlank(actorURI)) { + ele.setActor(actorURI); + } + if (null != mustUnderstand) { + ele.setMustUnderstand(mustUnderstand); + } + + return ele; + } + + /** + * 增加SOAP头信息,方法返回{@link SOAPHeaderElement}可以设置具体属性和子节点 + * + * @param localName 头节点名称 + * @return {@link SOAPHeaderElement} + * @since 5.4.7 + */ + public SOAPHeaderElement addSOAPHeader(String localName) { + return addSOAPHeader(new QName(localName)); + } + + /** + * 增加SOAP头信息,方法返回{@link SOAPHeaderElement}可以设置具体属性和子节点 + * + * @param localName 头节点名称 + * @param value 头节点的值 + * @return {@link SOAPHeaderElement} + * @since 5.4.7 + */ + public SOAPHeaderElement addSOAPHeader(String localName, String value) { + final SOAPHeaderElement soapHeaderElement = addSOAPHeader(localName); + soapHeaderElement.setTextContent(value); + return soapHeaderElement; + } + + /** + * 增加SOAP头信息,方法返回{@link SOAPHeaderElement}可以设置具体属性和子节点 + * + * @param name 头节点名称 + * @return {@link SOAPHeaderElement} + * @since 5.4.4 + */ + public SOAPHeaderElement addSOAPHeader(QName name) { + SOAPHeaderElement ele; + try { + ele = this.message.getSOAPHeader().addHeaderElement(name); + } catch (SOAPException e) { + throw new SoapRuntimeException(e); + } + return ele; + } + + /** + * 设置请求方法 + * + * @param name 方法名及其命名空间 + * @param params 参数 + * @param useMethodPrefix 是否使用方法的命名空间前缀 + * @return this + */ + public JakartaSoapClient setMethod(Name name, Map params, boolean useMethodPrefix) { + return setMethod(new QName(name.getURI(), name.getLocalName(), name.getPrefix()), params, useMethodPrefix); + } + + /** + * 设置请求方法 + * + * @param name 方法名及其命名空间 + * @param params 参数 + * @param useMethodPrefix 是否使用方法的命名空间前缀 + * @return this + */ + public JakartaSoapClient setMethod(QName name, Map params, boolean useMethodPrefix) { + setMethod(name); + final String prefix = useMethodPrefix ? name.getPrefix() : null; + final SOAPBodyElement methodEle = this.methodEle; + for (Entry entry : MapUtil.wrap(params)) { + setParam(methodEle, entry.getKey(), entry.getValue(), prefix); + } + + return this; + } + + /** + * 设置请求方法
+ * 方法名自动识别前缀,前缀和方法名使用“:”分隔
+ * 当识别到前缀后,自动添加xmlns属性,关联到默认的namespaceURI + * + * @param methodName 方法名 + * @return this + */ + public JakartaSoapClient setMethod(String methodName) { + return setMethod(methodName, ObjectUtil.defaultIfNull(this.namespaceURI, XMLConstants.NULL_NS_URI)); + } + + /** + * 设置请求方法
+ * 方法名自动识别前缀,前缀和方法名使用“:”分隔
+ * 当识别到前缀后,自动添加xmlns属性,关联到传入的namespaceURI + * + * @param methodName 方法名(可有前缀也可无) + * @param namespaceURI 命名空间URI + * @return this + */ + public JakartaSoapClient setMethod(String methodName, String namespaceURI) { + final List methodNameList = StrUtil.split(methodName, ':'); + final QName qName; + if (2 == methodNameList.size()) { + qName = new QName(namespaceURI, methodNameList.get(1), methodNameList.get(0)); + } else { + qName = new QName(namespaceURI, methodName); + } + return setMethod(qName); + } + + /** + * 设置请求方法 + * + * @param name 方法名及其命名空间 + * @return this + */ + public JakartaSoapClient setMethod(QName name) { + try { + this.methodEle = this.message.getSOAPBody().addBodyElement(name); + } catch (SOAPException e) { + throw new SoapRuntimeException(e); + } + + return this; + } + + /** + * 设置方法参数,使用方法的前缀 + * + * @param name 参数名 + * @param value 参数值,可以是字符串或Map或{@link SOAPElement} + * @return this + */ + public JakartaSoapClient setParam(String name, Object value) { + return setParam(name, value, true); + } + + /** + * 设置方法参数 + * + * @param name 参数名 + * @param value 参数值,可以是字符串或Map或{@link SOAPElement} + * @param useMethodPrefix 是否使用方法的命名空间前缀 + * @return this + */ + public JakartaSoapClient setParam(String name, Object value, boolean useMethodPrefix) { + setParam(this.methodEle, name, value, useMethodPrefix ? this.methodEle.getPrefix() : null); + return this; + } + + /** + * 批量设置参数,使用方法的前缀 + * + * @param params 参数列表 + * @return this + * @since 4.5.6 + */ + public JakartaSoapClient setParams(Map params) { + return setParams(params, true); + } + + /** + * 批量设置参数 + * + * @param params 参数列表 + * @param useMethodPrefix 是否使用方法的命名空间前缀 + * @return this + * @since 4.5.6 + */ + public JakartaSoapClient setParams(Map params, boolean useMethodPrefix) { + for (Entry entry : MapUtil.wrap(params)) { + setParam(entry.getKey(), entry.getValue(), useMethodPrefix); + } + return this; + } + + /** + * 获取方法节点
+ * 用于创建子节点等操作 + * + * @return {@link SOAPBodyElement} + * @since 4.5.6 + */ + public SOAPBodyElement getMethodEle() { + return this.methodEle; + } + + /** + * 获取SOAP消息对象 {@link SOAPMessage} + * + * @return {@link SOAPMessage} + * @since 4.5.6 + */ + public SOAPMessage getMessage() { + return this.message; + } + + /** + * 获取SOAP请求消息 + * + * @param pretty 是否格式化 + * @return 消息字符串 + */ + public String getMsgStr(boolean pretty) { + return JakartaSoapUtil.toString(this.message, pretty, this.charset); + } + + /** + * 将SOAP消息的XML内容输出到流 + * + * @param out 输出流 + * @return this + * @since 4.5.6 + */ + public JakartaSoapClient write(OutputStream out) { + try { + this.message.writeTo(out); + } catch (SOAPException | IOException e) { + throw new SoapRuntimeException(e); + } + return this; + } + + /** + * 设置超时,单位:毫秒
+ * 超时包括: + * + *

+	 * 1. 连接超时
+	 * 2. 读取响应超时
+	 * 
+ * + * @param milliseconds 超时毫秒数 + * @return this + * @see #setConnectionTimeout(int) + * @see #setReadTimeout(int) + */ + public JakartaSoapClient timeout(int milliseconds) { + setConnectionTimeout(milliseconds); + setReadTimeout(milliseconds); + return this; + } + + /** + * 设置连接超时,单位:毫秒 + * + * @param milliseconds 超时毫秒数 + * @return this + * @since 4.5.6 + */ + public JakartaSoapClient setConnectionTimeout(int milliseconds) { + this.connectionTimeout = milliseconds; + return this; + } + + /** + * 设置连接超时,单位:毫秒 + * + * @param milliseconds 超时毫秒数 + * @return this + * @since 4.5.6 + */ + public JakartaSoapClient setReadTimeout(int milliseconds) { + this.readTimeout = milliseconds; + return this; + } + + /** + * 执行Webservice请求,即发送SOAP内容 + * + * @return 返回结果 + */ + public SOAPMessage sendForMessage() { + final HttpResponse res = sendForResponse(); + final MimeHeaders headers = new MimeHeaders(); + for (Entry> entry : res.headers().entrySet()) { + if (StrUtil.isNotEmpty(entry.getKey())) { + headers.setHeader(entry.getKey(), CollUtil.get(entry.getValue(), 0)); + } + } + try { + return this.factory.createMessage(headers, res.bodyStream()); + } catch (IOException | SOAPException e) { + throw new SoapRuntimeException(e); + } finally { + IoUtil.close(res); + } + } + + /** + * 执行Webservice请求,即发送SOAP内容 + * + * @return 返回结果 + */ + public String send() { + return send(false); + } + + /** + * 执行Webservice请求,即发送SOAP内容 + * + * @param pretty 是否格式化 + * @return 返回结果 + */ + public String send(boolean pretty) { + final String body = sendForResponse().body(); + return pretty ? XmlUtil.format(body) : body; + } + + // -------------------------------------------------------------------------------------------------------- Private method start + + /** + * 发送请求,获取异步响应 + * + * @return 响应对象 + */ + public HttpResponse sendForResponse() { + return HttpRequest.post(this.url)// + .setFollowRedirects(true)// + .setConnectionTimeout(this.connectionTimeout) + .setReadTimeout(this.readTimeout) + .contentType(getXmlContentType())// + .header(this.headers()) + .body(getMsgStr(false))// + .executeAsync(); + } + + /** + * 获取请求的Content-Type,附加编码信息 + * + * @return 请求的Content-Type + */ + private String getXmlContentType() { + switch (this.protocol){ + case SOAP_1_1: + return CONTENT_TYPE_SOAP11_TEXT_XML.concat(this.charset.toString()); + case SOAP_1_2: + return CONTENT_TYPE_SOAP12_SOAP_XML.concat(this.charset.toString()); + default: + throw new SoapRuntimeException("Unsupported protocol: {}", this.protocol); + } + } + + /** + * 设置方法参数 + * + * @param ele 方法节点 + * @param name 参数名 + * @param value 参数值 + * @param prefix 命名空间前缀, {@code null}表示不使用前缀 + * @return {@link SOAPElement}子节点 + */ + @SuppressWarnings("rawtypes") + private static SOAPElement setParam(SOAPElement ele, String name, Object value, String prefix) { + final SOAPElement childEle; + try { + if (StrUtil.isNotBlank(prefix)) { + childEle = ele.addChildElement(name, prefix); + } else { + childEle = ele.addChildElement(name); + } + } catch (SOAPException e) { + throw new SoapRuntimeException(e); + } + + if (null != value) { + if (value instanceof SOAPElement) { + // 单个子节点 + try { + ele.addChildElement((SOAPElement) value); + } catch (SOAPException e) { + throw new SoapRuntimeException(e); + } + } else if (value instanceof Map) { + // 多个字节点 + Entry entry; + for (Object obj : ((Map) value).entrySet()) { + entry = (Entry) obj; + setParam(childEle, entry.getKey().toString(), entry.getValue(), prefix); + } + } else { + // 单个值 + childEle.setValue(value.toString()); + } + } + + return childEle; + } + // -------------------------------------------------------------------------------------------------------- Private method end +} diff --git a/hutool-http/src/main/java/cn/hutool/http/webservice/JakartaSoapProtocol.java b/hutool-http/src/main/java/cn/hutool/http/webservice/JakartaSoapProtocol.java new file mode 100644 index 000000000..08f202fc6 --- /dev/null +++ b/hutool-http/src/main/java/cn/hutool/http/webservice/JakartaSoapProtocol.java @@ -0,0 +1,36 @@ +package cn.hutool.http.webservice; + +import jakarta.xml.soap.SOAPConstants; + +/** + * SOAP协议版本枚举 + * + * @author looly + * + */ +public enum JakartaSoapProtocol { + /** SOAP 1.1协议 */ + SOAP_1_1(SOAPConstants.SOAP_1_1_PROTOCOL), + /** SOAP 1.2协议 */ + SOAP_1_2(SOAPConstants.SOAP_1_2_PROTOCOL); + + /** + * 构造 + * + * @param value {@link SOAPConstants} 中的协议版本值 + */ + JakartaSoapProtocol(String value) { + this.value = value; + } + + private final String value; + + /** + * 获取版本值信息 + * + * @return 版本值信息 + */ + public String getValue() { + return this.value; + } +} diff --git a/hutool-http/src/main/java/cn/hutool/http/webservice/JakartaSoapUtil.java b/hutool-http/src/main/java/cn/hutool/http/webservice/JakartaSoapUtil.java new file mode 100644 index 000000000..f8bf9faae --- /dev/null +++ b/hutool-http/src/main/java/cn/hutool/http/webservice/JakartaSoapUtil.java @@ -0,0 +1,91 @@ +package cn.hutool.http.webservice; + +import cn.hutool.core.exceptions.UtilException; +import cn.hutool.core.util.CharsetUtil; +import cn.hutool.core.util.XmlUtil; + +import jakarta.xml.soap.SOAPException; +import jakarta.xml.soap.SOAPMessage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; + +/** + * SOAP相关工具类 + * + * @author looly + * @since 4.5.7 + */ +public class JakartaSoapUtil { + + /** + * 创建SOAP客户端,默认使用soap1.1版本协议 + * + * @param url WS的URL地址 + * @return {@link SoapClient} + */ + public static SoapClient createClient(String url) { + return SoapClient.create(url); + } + + /** + * 创建SOAP客户端 + * + * @param url WS的URL地址 + * @param protocol 协议,见{@link SoapProtocol} + * @return {@link SoapClient} + */ + public static SoapClient createClient(String url, SoapProtocol protocol) { + return SoapClient.create(url, protocol); + } + + /** + * 创建SOAP客户端 + * + * @param url WS的URL地址 + * @param protocol 协议,见{@link SoapProtocol} + * @param namespaceURI 方法上的命名空间URI + * @return {@link SoapClient} + * @since 4.5.6 + */ + public static SoapClient createClient(String url, SoapProtocol protocol, String namespaceURI) { + return SoapClient.create(url, protocol, namespaceURI); + } + + /** + * {@link SOAPMessage} 转为字符串 + * + * @param message SOAP消息对象 + * @param pretty 是否格式化 + * @return SOAP XML字符串 + */ + public static String toString(SOAPMessage message, boolean pretty) { + return toString(message, pretty, CharsetUtil.CHARSET_UTF_8); + } + + /** + * {@link SOAPMessage} 转为字符串 + * + * @param message SOAP消息对象 + * @param pretty 是否格式化 + * @param charset 编码 + * @return SOAP XML字符串 + * @since 4.5.7 + */ + public static String toString(SOAPMessage message, boolean pretty, Charset charset) { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try { + message.writeTo(out); + } catch (SOAPException | IOException e) { + throw new SoapRuntimeException(e); + } + String messageToString; + try { + messageToString = out.toString(charset.toString()); + } catch (UnsupportedEncodingException e) { + throw new UtilException(e); + } + return pretty ? XmlUtil.format(messageToString) : messageToString; + } +} diff --git a/hutool-http/src/test/java/cn/hutool/http/webservice/JakartaSoapClientTest.java b/hutool-http/src/test/java/cn/hutool/http/webservice/JakartaSoapClientTest.java new file mode 100644 index 000000000..8069b2f51 --- /dev/null +++ b/hutool-http/src/test/java/cn/hutool/http/webservice/JakartaSoapClientTest.java @@ -0,0 +1,41 @@ +package cn.hutool.http.webservice; + +import cn.hutool.core.lang.Console; +import cn.hutool.core.util.CharsetUtil; +import jakarta.xml.soap.SOAPException; +import jakarta.xml.soap.SOAPMessage; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +/** + * SOAP相关单元测试 + * + * @author looly + * + */ +public class JakartaSoapClientTest { + + @Test + @Disabled + public void requestTest() { + JakartaSoapClient client = JakartaSoapClient.create("http://www.webxml.com.cn/WebServices/IpAddressSearchWebService.asmx") + .setMethod("web:getCountryCityByIp", "http://WebXml.com.cn/") + .setCharset(CharsetUtil.CHARSET_GBK) + .setParam("theIpAddress", "218.21.240.106"); + + Console.log(client.getMsgStr(true)); + + Console.log(client.send(true)); + } + + @Test + @Disabled + public void requestForMessageTest() throws SOAPException { + JakartaSoapClient client = JakartaSoapClient.create("http://www.webxml.com.cn/WebServices/IpAddressSearchWebService.asmx") + .setMethod("web:getCountryCityByIp", "http://WebXml.com.cn/") + .setParam("theIpAddress", "218.21.240.106"); + + SOAPMessage message = client.sendForMessage(); + Console.log(message.getSOAPBody().getTextContent()); + } +}