diff --git a/CHANGELOG.md b/CHANGELOG.md index a00f6bdf7..6ecc5bfb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ ### 🐞Bug修复 * 【core 】 修复UserAgentUtil识别Linux出错(issue#I50YGY@Gitee) * 【poi 】 修复ExcelWriter.getDisposition方法生成错误(issue#2239@Github) +* 【core 】 修复UrlBuilder重复编码的问题(issue#2243@Github) ------------------------------------------------------------------------------------------------------------- diff --git a/hutool-core/src/main/java/cn/hutool/core/codec/PercentCodec.java b/hutool-core/src/main/java/cn/hutool/core/codec/PercentCodec.java index 71ae0ec8b..88fd210a0 100644 --- a/hutool-core/src/main/java/cn/hutool/core/codec/PercentCodec.java +++ b/hutool-core/src/main/java/cn/hutool/core/codec/PercentCodec.java @@ -1,5 +1,6 @@ package cn.hutool.core.codec; +import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.util.CharUtil; import cn.hutool.core.util.HexUtil; import cn.hutool.core.util.StrUtil; @@ -150,9 +151,10 @@ public class PercentCodec implements Serializable { * * @param path 需要编码的字符串 * @param charset 编码, {@code null}返回原字符串,表示不编码 + * @param customSafeChar 自定义安全字符 * @return 编码后的字符串 */ - public String encode(CharSequence path, Charset charset) { + public String encode(CharSequence path, Charset charset, char... customSafeChar) { if (null == charset || StrUtil.isEmpty(path)) { return StrUtil.str(path); } @@ -161,18 +163,18 @@ public class PercentCodec implements Serializable { final ByteArrayOutputStream buf = new ByteArrayOutputStream(); final OutputStreamWriter writer = new OutputStreamWriter(buf, charset); - int c; + char c; for (int i = 0; i < path.length(); i++) { c = path.charAt(i); - if (safeCharacters.get(c)) { - rewrittenPath.append((char) c); + if (safeCharacters.get(c) || ArrayUtil.contains(customSafeChar, c)) { + rewrittenPath.append(c); } else if (encodeSpaceAsPlus && c == CharUtil.SPACE) { // 对于空格单独处理 rewrittenPath.append('+'); } else { // convert to external encoding before hex conversion try { - writer.write((char) c); + writer.write(c); writer.flush(); } catch (IOException e) { buf.reset(); diff --git a/hutool-core/src/main/java/cn/hutool/core/net/url/UrlBuilder.java b/hutool-core/src/main/java/cn/hutool/core/net/url/UrlBuilder.java index 2332e0f3d..33ada0585 100644 --- a/hutool-core/src/main/java/cn/hutool/core/net/url/UrlBuilder.java +++ b/hutool-core/src/main/java/cn/hutool/core/net/url/UrlBuilder.java @@ -59,6 +59,11 @@ public final class UrlBuilder implements Builder { * 编码,用于URLEncode和URLDecode */ private Charset charset; + /** + * 是否需要编码`%`
+ * 区别对待,如果是,则生成URL时需要重新全部编码,否则跳过所有`%` + */ + private boolean needEncodePercent; /** * 使用URI构建UrlBuilder @@ -203,7 +208,7 @@ public final class UrlBuilder implements Builder { * @param path 路径,例如/aa/bb/cc * @param query 查询,例如a=1&b=2 * @param fragment 标识符例如#后边的部分 - * @param charset 编码,用于URLEncode和URLDecode + * @param charset 编码,用于URLEncode和URLDecode,{@code null}表示不编码 */ public UrlBuilder(String scheme, String host, int port, UrlPath path, UrlQuery query, String fragment, Charset charset) { this.charset = charset; @@ -213,6 +218,8 @@ public final class UrlBuilder implements Builder { this.path = path; this.query = query; this.setFragment(fragment); + // 编码非空情况下做解码 + this.needEncodePercent = null != charset; } /** @@ -308,7 +315,7 @@ public final class UrlBuilder implements Builder { * @return 路径,例如/aa/bb/cc */ public String getPathStr() { - return null == this.path ? StrUtil.SLASH : this.path.build(charset); + return null == this.path ? StrUtil.SLASH : this.path.build(charset, this.needEncodePercent); } /** @@ -378,7 +385,7 @@ public final class UrlBuilder implements Builder { * @return 查询语句,例如a=1&b=2 */ public String getQueryStr() { - return null == this.query ? null : this.query.build(this.charset); + return null == this.query ? null : this.query.build(this.charset, this.needEncodePercent); } /** @@ -426,7 +433,8 @@ public final class UrlBuilder implements Builder { * @return 标识符,例如#后边的部分 */ public String getFragmentEncoded() { - return RFC3986.FRAGMENT.encode(this.fragment, this.charset); + final char[] safeChars = this.needEncodePercent ? null : new char[]{'%'}; + return RFC3986.FRAGMENT.encode(this.fragment, this.charset, safeChars); } /** diff --git a/hutool-core/src/main/java/cn/hutool/core/net/url/UrlPath.java b/hutool-core/src/main/java/cn/hutool/core/net/url/UrlPath.java index 8a968f1a7..39a04ba71 100644 --- a/hutool-core/src/main/java/cn/hutool/core/net/url/UrlPath.java +++ b/hutool-core/src/main/java/cn/hutool/core/net/url/UrlPath.java @@ -126,19 +126,35 @@ public class UrlPath { * @return 如果没有任何内容,则返回空字符串"" */ public String build(Charset charset) { + return build(charset, true); + } + + /** + * 构建path,前面带'/'
+ *
+	 *     path = path-abempty / path-absolute / path-noscheme / path-rootless / path-empty
+	 * 
+ * + * @param charset encode编码,null表示不做encode + * @param encodePercent 是否编码`%` + * @return 如果没有任何内容,则返回空字符串"" + * @since 5.8.0 + */ + public String build(Charset charset, boolean encodePercent) { if (CollUtil.isEmpty(this.segments)) { return StrUtil.EMPTY; } + final char[] safeChars = encodePercent ? null : new char[]{'%'}; final StringBuilder builder = new StringBuilder(); for (String segment : segments) { if(builder.length() == 0){ // 根据https://www.ietf.org/rfc/rfc3986.html#section-3.3定义 // path的第一部分不允许有":",其余部分允许 // 在此处的Path部分特指host之后的部分,即不包含第一部分 - builder.append(CharUtil.SLASH).append(RFC3986.SEGMENT_NZ_NC.encode(segment, charset)); + builder.append(CharUtil.SLASH).append(RFC3986.SEGMENT_NZ_NC.encode(segment, charset, safeChars)); } else { - builder.append(CharUtil.SLASH).append(RFC3986.SEGMENT.encode(segment, charset)); + builder.append(CharUtil.SLASH).append(RFC3986.SEGMENT.encode(segment, charset, safeChars)); } } if (StrUtil.isEmpty(builder)) { diff --git a/hutool-core/src/main/java/cn/hutool/core/net/url/UrlQuery.java b/hutool-core/src/main/java/cn/hutool/core/net/url/UrlQuery.java index 46c6de15f..6a59c64f6 100644 --- a/hutool-core/src/main/java/cn/hutool/core/net/url/UrlQuery.java +++ b/hutool-core/src/main/java/cn/hutool/core/net/url/UrlQuery.java @@ -113,7 +113,7 @@ public class UrlQuery { /** * 构造 * - * @param queryMap 初始化的查询键值对 + * @param queryMap 初始化的查询键值对 */ public UrlQuery(Map queryMap) { this(queryMap, false); @@ -234,11 +234,7 @@ public class UrlQuery { * @return URL查询字符串 */ public String build(Charset charset) { - if (isFormUrlEncoded) { - return build(FormUrlencoded.ALL, FormUrlencoded.ALL, charset); - } - - return build(RFC3986.QUERY_PARAM_NAME, RFC3986.QUERY_PARAM_VALUE, charset); + return build(charset, true); } /** @@ -249,17 +245,57 @@ public class UrlQuery { *
  • 如果value为{@code null},只保留key,如key1对应value为{@code null}生成类似于{@code key1&key2=v2}形式
  • * * - * @param keyCoder 键值对中键的编码器 - * @param valueCoder 键值对中值的编码器 - * @param charset encode编码,null表示不做encode编码 + * @param charset encode编码,null表示不做encode编码 + * @param encodePercent 是否编码`%` + * @return URL查询字符串 + */ + public String build(Charset charset, boolean encodePercent) { + if (isFormUrlEncoded) { + return build(FormUrlencoded.ALL, FormUrlencoded.ALL, charset, encodePercent); + } + + return build(RFC3986.QUERY_PARAM_NAME, RFC3986.QUERY_PARAM_VALUE, charset, encodePercent); + } + + /** + * 构建URL查询字符串,即将key-value键值对转换为{@code key1=v1&key2=v2&key3=v3}形式。
    + * 对于{@code null}处理规则如下: + *
      + *
    • 如果key为{@code null},则这个键值对忽略
    • + *
    • 如果value为{@code null},只保留key,如key1对应value为{@code null}生成类似于{@code key1&key2=v2}形式
    • + *
    + * + * @param keyCoder 键值对中键的编码器 + * @param valueCoder 键值对中值的编码器 + * @param charset encode编码,null表示不做encode编码 * @return URL查询字符串 * @since 5.7.16 */ public String build(PercentCodec keyCoder, PercentCodec valueCoder, Charset charset) { + return build(keyCoder, valueCoder, charset, true); + } + + /** + * 构建URL查询字符串,即将key-value键值对转换为{@code key1=v1&key2=v2&key3=v3}形式。
    + * 对于{@code null}处理规则如下: + *
      + *
    • 如果key为{@code null},则这个键值对忽略
    • + *
    • 如果value为{@code null},只保留key,如key1对应value为{@code null}生成类似于{@code key1&key2=v2}形式
    • + *
    + * + * @param keyCoder 键值对中键的编码器 + * @param valueCoder 键值对中值的编码器 + * @param charset encode编码,null表示不做encode编码 + * @param encodePercent 是否编码`%` + * @return URL查询字符串 + * @since 5.8.0 + */ + public String build(PercentCodec keyCoder, PercentCodec valueCoder, Charset charset, boolean encodePercent) { if (MapUtil.isEmpty(this.query)) { return StrUtil.EMPTY; } + final char[] safeChars = encodePercent ? null : new char[]{'%'}; final StringBuilder sb = new StringBuilder(); CharSequence name; CharSequence value; @@ -269,10 +305,10 @@ public class UrlQuery { if (sb.length() > 0) { sb.append("&"); } - sb.append(keyCoder.encode(name, charset)); + sb.append(keyCoder.encode(name, charset, safeChars)); value = entry.getValue(); if (null != value) { - sb.append("=").append(valueCoder.encode(value, charset)); + sb.append("=").append(valueCoder.encode(value, charset, safeChars)); } } } diff --git a/hutool-core/src/test/java/cn/hutool/core/net/RFC3986Test.java b/hutool-core/src/test/java/cn/hutool/core/net/RFC3986Test.java index 67d02bb20..781b78001 100644 --- a/hutool-core/src/test/java/cn/hutool/core/net/RFC3986Test.java +++ b/hutool-core/src/test/java/cn/hutool/core/net/RFC3986Test.java @@ -14,4 +14,16 @@ public class RFC3986Test { encode = RFC3986.QUERY_PARAM_VALUE.encode("a+1=b", CharsetUtil.CHARSET_UTF_8); Assert.assertEquals("a+1=b", encode); } + + @Test + public void encodeQueryPercentTest(){ + String encode = RFC3986.QUERY_PARAM_VALUE.encode("a=%b", CharsetUtil.CHARSET_UTF_8); + Assert.assertEquals("a=%25b", encode); + } + + @Test + public void encodeQueryWithSafeTest(){ + String encode = RFC3986.QUERY_PARAM_VALUE.encode("a=%25", CharsetUtil.CHARSET_UTF_8, '%'); + Assert.assertEquals("a=%25", encode); + } } diff --git a/hutool-core/src/test/java/cn/hutool/core/net/UrlBuilderTest.java b/hutool-core/src/test/java/cn/hutool/core/net/UrlBuilderTest.java index 16ac46ed7..e73ca1b2c 100644 --- a/hutool-core/src/test/java/cn/hutool/core/net/UrlBuilderTest.java +++ b/hutool-core/src/test/java/cn/hutool/core/net/UrlBuilderTest.java @@ -405,4 +405,18 @@ public class UrlBuilderTest { params.forEach(builder::addQuery); Assert.assertEquals("http://127.0.0.1/devicerecord/list?start=2022-03-31%2000:00:00&end=2022-03-31%2023:59:59&page=1&limit=10", builder.toString()); } + + @Test + public void issue2242Test(){ + + } + + @Test + public void issue2243Test(){ + // https://github.com/dromara/hutool/issues/2243 + // 如果用户已经做了%编码,不应该重复编码 + String url = "https://hutool.cn/v1.0?privateNum=%2B8616512884988"; + final String s = UrlBuilder.of(url, null).setCharset(CharsetUtil.CHARSET_UTF_8).toString(); + Assert.assertEquals(url, s); + } } diff --git a/hutool-http/src/test/java/cn/hutool/http/HttpRequestTest.java b/hutool-http/src/test/java/cn/hutool/http/HttpRequestTest.java index d54290894..4483e1e28 100644 --- a/hutool-http/src/test/java/cn/hutool/http/HttpRequestTest.java +++ b/hutool-http/src/test/java/cn/hutool/http/HttpRequestTest.java @@ -193,19 +193,4 @@ public class HttpRequestTest { HttpRequest request =HttpUtil.createGet(url).form(map); Console.log(request.execute().body()); } - - @Test - public void issueI50NHQTest(){ - String url = "http://127.0.0.1/devicerecord/list"; - HashMap params = new HashMap<>(); - params.put("start", "2022-03-31 00:00:00"); - params.put("end", "2022-03-31 23:59:59"); - params.put("page", 1); - params.put("limit", 10); - - String result = HttpRequest.get(url) - .header("token", "123") - .form(params).toString(); - Console.log(result); - } }