Compare commits

...

2 Commits

Author SHA1 Message Date
Looly
c9c4cf5ae3 fix test 2025-10-23 02:53:41 +08:00
Looly
0b2565f6aa add proxy support 2025-10-23 02:38:54 +08:00
24 changed files with 241 additions and 227 deletions

View File

@@ -18,10 +18,13 @@ package cn.hutool.v7.ai;
import cn.hutool.v7.core.exception.HutoolException; import cn.hutool.v7.core.exception.HutoolException;
import java.io.Serial;
/** /**
* 异常处理类 * 异常处理类
*/ */
public class AIException extends HutoolException { public class AIException extends HutoolException {
@Serial
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** /**

View File

@@ -16,6 +16,8 @@
package cn.hutool.v7.ai.core; package cn.hutool.v7.ai.core;
import cn.hutool.v7.http.proxy.ProxyInfo;
import java.util.Map; import java.util.Map;
/** /**
@@ -142,4 +144,19 @@ public interface AIConfig {
*/ */
int getReadTimeout(); int getReadTimeout();
/**
* 获取http代理
*
* @return http代理
* @since 7.0.0
*/
ProxyInfo getProxy();
/**
* 设置代理配置
*
* @param proxy 连接超时时间
* @since 7.0.0
*/
void setProxy(ProxyInfo proxy);
} }

View File

@@ -16,6 +16,8 @@
package cn.hutool.v7.ai.core; package cn.hutool.v7.ai.core;
import cn.hutool.v7.http.proxy.ProxyInfo;
import java.lang.reflect.Constructor; import java.lang.reflect.Constructor;
/** /**
@@ -134,6 +136,18 @@ public class AIConfigBuilder {
return this; return this;
} }
/**
* 设置代理
*
* @param proxy 代理
* @return config
* @since 7.0.0
*/
public AIConfigBuilder setProxy(final ProxyInfo proxy) {
config.setProxy(proxy);
return this;
}
/** /**
* 返回config实例 * 返回config实例
* *

View File

@@ -16,6 +16,8 @@
package cn.hutool.v7.ai.core; package cn.hutool.v7.ai.core;
import cn.hutool.v7.http.proxy.ProxyInfo;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@@ -25,7 +27,7 @@ import java.util.concurrent.ConcurrentHashMap;
* @author elichow * @author elichow
* @since 6.0.0 * @since 6.0.0
*/ */
public class BaseConfig implements AIConfig { public class BaseAIConfig implements AIConfig {
/** /**
* API Key * API Key
@@ -51,6 +53,10 @@ public class BaseConfig implements AIConfig {
* 读取超时 * 读取超时
*/ */
protected volatile int readTimeout = 300000; protected volatile int readTimeout = 300000;
/**
* 代理
*/
private volatile ProxyInfo proxy;
@Override @Override
public void setApiKey(final String apiKey) { public void setApiKey(final String apiKey) {
@@ -116,4 +122,14 @@ public class BaseConfig implements AIConfig {
public void setReadTimeout(final int readTimeout) { public void setReadTimeout(final int readTimeout) {
this.readTimeout = readTimeout; this.readTimeout = readTimeout;
} }
@Override
public ProxyInfo getProxy() {
return proxy;
}
@Override
public void setProxy(final ProxyInfo proxy) {
this.proxy = proxy;
}
} }

View File

@@ -16,19 +16,18 @@
package cn.hutool.v7.ai.core; package cn.hutool.v7.ai.core;
import cn.hutool.v7.ai.AIException; import cn.hutool.v7.core.io.IoUtil;
import cn.hutool.v7.http.HttpGlobalConfig;
import cn.hutool.v7.http.HttpUtil; import cn.hutool.v7.http.HttpUtil;
import cn.hutool.v7.http.client.ClientConfig;
import cn.hutool.v7.http.client.Response; import cn.hutool.v7.http.client.Response;
import cn.hutool.v7.http.client.engine.ClientEngine;
import cn.hutool.v7.http.client.engine.ClientEngineFactory;
import cn.hutool.v7.http.meta.ContentType;
import cn.hutool.v7.http.meta.HeaderName; import cn.hutool.v7.http.meta.HeaderName;
import cn.hutool.v7.http.meta.Method; import cn.hutool.v7.http.meta.Method;
import cn.hutool.v7.json.JSONUtil;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.InputStreamReader; import java.io.IOException;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Map; import java.util.Map;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -44,6 +43,7 @@ public class BaseAIService {
* AI配置 * AI配置
*/ */
protected final AIConfig config; protected final AIConfig config;
private final ClientEngine clientEngine;
/** /**
* 构造方法 * 构造方法
@@ -52,6 +52,8 @@ public class BaseAIService {
*/ */
public BaseAIService(final AIConfig config) { public BaseAIService(final AIConfig config) {
this.config = config; this.config = config;
this.clientEngine = ClientEngineFactory.createEngine(
ClientConfig.of().setTimeout(config.getTimeout()).setProxy(config.getProxy()));
} }
/** /**
@@ -60,17 +62,10 @@ public class BaseAIService {
* @return 请求响应 * @return 请求响应
*/ */
protected Response sendGet(final String endpoint) { protected Response sendGet(final String endpoint) {
//链式构建请求 return HttpUtil.createRequest(config.getApiUrl() + endpoint, Method.GET)
try { .header(HeaderName.ACCEPT, ContentType.JSON.getValue())
//设置超时 .bearerAuth(config.getApiKey())
HttpGlobalConfig.setTimeout(config.getTimeout()); .send(this.clientEngine);
return HttpUtil.createRequest(config.getApiUrl() + endpoint, Method.GET)
.header(HeaderName.ACCEPT, "application/json")
.header(HeaderName.AUTHORIZATION, "Bearer " + config.getApiKey())
.send();
} catch (final AIException e) {
throw new AIException("Failed to send GET request: " + e.getMessage(), e);
}
} }
/** /**
@@ -80,20 +75,12 @@ public class BaseAIService {
* @return 请求响应 * @return 请求响应
*/ */
protected Response sendPost(final String endpoint, final String paramJson) { protected Response sendPost(final String endpoint, final String paramJson) {
//链式构建请求 return HttpUtil.createRequest(config.getApiUrl() + endpoint, Method.POST)
try { .header(HeaderName.CONTENT_TYPE, ContentType.JSON.getValue())
//设置超时3分钟 .header(HeaderName.ACCEPT, ContentType.JSON.getValue())
HttpGlobalConfig.setTimeout(config.getTimeout()); .bearerAuth(config.getApiKey())
return HttpUtil.createRequest(config.getApiUrl() + endpoint, Method.POST) .body(paramJson)
.header(HeaderName.CONTENT_TYPE, "application/json") .send(this.clientEngine);
.header(HeaderName.ACCEPT, "application/json")
.header(HeaderName.AUTHORIZATION, "Bearer " + config.getApiKey())
.body(paramJson)
.send();
} catch (final AIException e) {
throw new AIException("Failed to send POST request" + e.getMessage(), e);
}
} }
/** /**
@@ -103,19 +90,12 @@ public class BaseAIService {
* @return 请求响应 * @return 请求响应
*/ */
protected Response sendFormData(final String endpoint, final Map<String, Object> paramMap) { protected Response sendFormData(final String endpoint, final Map<String, Object> paramMap) {
//链式构建请求 return HttpUtil.createPost(config.getApiUrl() + endpoint)
try { //form表单中有file对象会自动将文件编码为 multipart/form-data 格式。不需要设置
//设置超时3分钟
HttpGlobalConfig.setTimeout(config.getTimeout());
return HttpUtil.createPost(config.getApiUrl() + endpoint)
//form表单中有file对象会自动将文件编码为 multipart/form-data 格式。不需要设置
// .header(HeaderName.CONTENT_TYPE, "multipart/form-data") // .header(HeaderName.CONTENT_TYPE, "multipart/form-data")
.header(HeaderName.AUTHORIZATION, "Bearer " + config.getApiKey()) .bearerAuth(config.getApiKey())
.form(paramMap) .form(paramMap)
.send(); .send(this.clientEngine);
} catch (final AIException e) {
throw new AIException("Failed to send POST request" + e.getMessage(), e);
}
} }
/** /**
@@ -126,41 +106,22 @@ public class BaseAIService {
* @param callback 流式数据回调函数 * @param callback 流式数据回调函数
*/ */
protected void sendPostStream(final String endpoint, final Map<String, Object> paramMap, final Consumer<String> callback) { protected void sendPostStream(final String endpoint, final Map<String, Object> paramMap, final Consumer<String> callback) {
HttpURLConnection connection = null; final Response response = HttpUtil.createPost(config.getApiUrl() + endpoint)
try { //form表单中有file对象会自动将文件编码为 multipart/form-data 格式。不需要设置
// 创建连接 // .header(HeaderName.CONTENT_TYPE, "multipart/form-data")
final URL apiUrl = new URL(config.getApiUrl() + endpoint); .bearerAuth(config.getApiKey())
connection = (HttpURLConnection) apiUrl.openConnection(); .form(paramMap)
connection.setRequestMethod(Method.POST.name()); .send(this.clientEngine);
connection.setRequestProperty(HeaderName.CONTENT_TYPE.getValue(), "application/json");
connection.setRequestProperty(HeaderName.AUTHORIZATION.getValue(), "Bearer " + config.getApiKey());
connection.setDoOutput(true);
//设置读取超时
connection.setReadTimeout(config.getReadTimeout());
//设置连接超时
connection.setConnectTimeout(config.getTimeout());
// 发送请求体
try (final OutputStream os = connection.getOutputStream()) {
final String jsonInputString = JSONUtil.toJsonStr(paramMap);
os.write(jsonInputString.getBytes());
os.flush();
}
// 读取流式响应 // 读取流式响应
try (final BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) { try (final BufferedReader reader = IoUtil.toUtf8Reader(response.bodyStream())) {
String line; String line;
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
// 调用回调函数处理每一行数据 // 调用回调函数处理每一行数据
callback.accept(line); callback.accept(line);
}
} }
} catch (final Exception e) { } catch (final IOException e){
callback.accept("{\"error\": \"" + e.getMessage() + "\"}"); callback.accept("{\"error\": \"" + e.getMessage() + "\"}");
} finally {
// 关闭连接
if (connection != null) {
connection.disconnect();
}
} }
} }
} }

View File

@@ -23,6 +23,7 @@ package cn.hutool.v7.ai.core;
* @since 6.0.0 * @since 6.0.0
*/ */
public class Message { public class Message {
//角色 注意如果设置系统消息请放在messages列表的第一位 //角色 注意如果设置系统消息请放在messages列表的第一位
private String role; private String role;
//内容 //内容
@@ -49,9 +50,11 @@ public class Message {
* 设置角色 * 设置角色
* *
* @param role 角色 * @param role 角色
* @return this
*/ */
public void setRole(final String role) { public Message setRole(final String role) {
this.role = role; this.role = role;
return this;
} }
/** /**
@@ -76,8 +79,10 @@ public class Message {
* 设置内容 * 设置内容
* *
* @param content 内容 * @param content 内容
* @return this
*/ */
public void setContent(final Object content) { public Message setContent(final Object content) {
this.content = content; this.content = content;
return this;
} }
} }

View File

@@ -17,7 +17,7 @@
package cn.hutool.v7.ai.model.deepseek; package cn.hutool.v7.ai.model.deepseek;
import cn.hutool.v7.ai.Models; import cn.hutool.v7.ai.Models;
import cn.hutool.v7.ai.core.BaseConfig; import cn.hutool.v7.ai.core.BaseAIConfig;
/** /**
* DeepSeek配置类初始化API接口地址设置默认的模型 * DeepSeek配置类初始化API接口地址设置默认的模型
@@ -25,7 +25,7 @@ import cn.hutool.v7.ai.core.BaseConfig;
* @author elichow * @author elichow
* @since 6.0.0 * @since 6.0.0
*/ */
public class DeepSeekConfig extends BaseConfig { public class DeepSeekConfig extends BaseAIConfig {
/** /**
* 定义API的基础URL用于后续的所有API请求 * 定义API的基础URL用于后续的所有API请求

View File

@@ -17,7 +17,7 @@
package cn.hutool.v7.ai.model.doubao; package cn.hutool.v7.ai.model.doubao;
import cn.hutool.v7.ai.Models; import cn.hutool.v7.ai.Models;
import cn.hutool.v7.ai.core.BaseConfig; import cn.hutool.v7.ai.core.BaseAIConfig;
/** /**
* Doubao配置类初始化API接口地址设置默认的模型 * Doubao配置类初始化API接口地址设置默认的模型
@@ -25,7 +25,7 @@ import cn.hutool.v7.ai.core.BaseConfig;
* @author elichow * @author elichow
* @since 6.0.0 * @since 6.0.0
*/ */
public class DoubaoConfig extends BaseConfig { public class DoubaoConfig extends BaseAIConfig {
// 定义API的基础URL用于和服务器通信 // 定义API的基础URL用于和服务器通信
private static final String API_URL = "https://ark.cn-beijing.volces.com/api/v3"; private static final String API_URL = "https://ark.cn-beijing.volces.com/api/v3";

View File

@@ -17,7 +17,7 @@
package cn.hutool.v7.ai.model.grok; package cn.hutool.v7.ai.model.grok;
import cn.hutool.v7.ai.Models; import cn.hutool.v7.ai.Models;
import cn.hutool.v7.ai.core.BaseConfig; import cn.hutool.v7.ai.core.BaseAIConfig;
/** /**
* Grok配置类初始化API接口地址设置默认的模型 * Grok配置类初始化API接口地址设置默认的模型
@@ -25,7 +25,7 @@ import cn.hutool.v7.ai.core.BaseConfig;
* @author elichow * @author elichow
* @since 6.0.0 * @since 6.0.0
*/ */
public class GrokConfig extends BaseConfig { public class GrokConfig extends BaseAIConfig {
private final String API_URL = "https://api.x.ai/v1"; private final String API_URL = "https://api.x.ai/v1";

View File

@@ -17,7 +17,7 @@
package cn.hutool.v7.ai.model.hutool; package cn.hutool.v7.ai.model.hutool;
import cn.hutool.v7.ai.Models; import cn.hutool.v7.ai.Models;
import cn.hutool.v7.ai.core.BaseConfig; import cn.hutool.v7.ai.core.BaseAIConfig;
/** /**
* Hutool配置类初始化API接口地址设置默认的模型 * Hutool配置类初始化API接口地址设置默认的模型
@@ -25,7 +25,7 @@ import cn.hutool.v7.ai.core.BaseConfig;
* @author elichow * @author elichow
* @since 6.0.0 * @since 6.0.0
*/ */
public class HutoolConfig extends BaseConfig { public class HutoolConfig extends BaseAIConfig {
private final String API_URL = "https://api.hutool.cn/ai/api"; private final String API_URL = "https://api.hutool.cn/ai/api";

View File

@@ -17,7 +17,7 @@
package cn.hutool.v7.ai.model.ollama; package cn.hutool.v7.ai.model.ollama;
import cn.hutool.v7.ai.Models; import cn.hutool.v7.ai.Models;
import cn.hutool.v7.ai.core.BaseConfig; import cn.hutool.v7.ai.core.BaseAIConfig;
/** /**
* Ollama配置类初始化API接口地址设置默认的模型 * Ollama配置类初始化API接口地址设置默认的模型
@@ -25,7 +25,7 @@ import cn.hutool.v7.ai.core.BaseConfig;
* @author yangruoyu-yumeisoft * @author yangruoyu-yumeisoft
* @since 5.8.40 * @since 5.8.40
*/ */
public class OllamaConfig extends BaseConfig { public class OllamaConfig extends BaseAIConfig {
private final String API_URL = "http://localhost:11434"; private final String API_URL = "http://localhost:11434";

View File

@@ -17,7 +17,7 @@
package cn.hutool.v7.ai.model.openai; package cn.hutool.v7.ai.model.openai;
import cn.hutool.v7.ai.Models; import cn.hutool.v7.ai.Models;
import cn.hutool.v7.ai.core.BaseConfig; import cn.hutool.v7.ai.core.BaseAIConfig;
/** /**
* openai配置类初始化API接口地址设置默认的模型 * openai配置类初始化API接口地址设置默认的模型
@@ -25,7 +25,7 @@ import cn.hutool.v7.ai.core.BaseConfig;
* @author elichow * @author elichow
* @since 6.0.0 * @since 6.0.0
*/ */
public class OpenaiConfig extends BaseConfig { public class OpenaiConfig extends BaseAIConfig {
private final String API_URL = "https://api.openai.com/v1"; private final String API_URL = "https://api.openai.com/v1";

View File

@@ -92,7 +92,7 @@ class OpenaiServiceTest {
final OpenaiService openaiService = AIServiceFactory.getAIService(new AIConfigBuilder(ModelName.OPENAI.getValue()) final OpenaiService openaiService = AIServiceFactory.getAIService(new AIConfigBuilder(ModelName.OPENAI.getValue())
.setApiKey(key).setModel(Models.Openai.GPT_4O_MINI.getModel()).build(), OpenaiService.class); .setApiKey(key).setModel(Models.Openai.GPT_4O_MINI.getModel()).build(), OpenaiService.class);
String prompt = "图片上有些什么?"; String prompt = "图片上有些什么?";
List<String> images = Arrays.asList("https://img2.baidu.com/it/u=862000265,4064861820&fm=253&fmt=auto&app=138&f=JPEG?w=800&h=1544\",\"https://img2.baidu.com/it/u=1682510685,1244554634&fm=253&fmt=auto&app=138&f=JPEG?w=803&h=800"); List<String> images = List.of("https://img2.baidu.com/it/u=862000265,4064861820&fm=253&fmt=auto&app=138&f=JPEG?w=800&h=1544\",\"https://img2.baidu.com/it/u=1682510685,1244554634&fm=253&fmt=auto&app=138&f=JPEG?w=803&h=800");
// 使用AtomicBoolean作为结束标志 // 使用AtomicBoolean作为结束标志
AtomicBoolean isDone = new AtomicBoolean(false); AtomicBoolean isDone = new AtomicBoolean(false);
@@ -155,10 +155,11 @@ class OpenaiServiceTest {
void textToSpeech() { void textToSpeech() {
final OpenaiService openaiService = AIServiceFactory.getAIService(new AIConfigBuilder(ModelName.OPENAI.getValue()) final OpenaiService openaiService = AIServiceFactory.getAIService(new AIConfigBuilder(ModelName.OPENAI.getValue())
.setApiKey(key).setModel(Models.Openai.TTS_1_HD.getModel()).build(), OpenaiService.class); .setApiKey(key).setModel(Models.Openai.TTS_1_HD.getModel()).build(), OpenaiService.class);
final InputStream inputStream = openaiService.textToSpeech("万里山河一夜白,\n" + final InputStream inputStream = openaiService.textToSpeech("""
"千峰尽染玉龙哀。\n" + 万里山河一夜白,
"长风卷起琼花碎,\n" + 千峰尽染玉龙哀。
"直上九霄揽月来。", OpenaiCommon.OpenaiSpeech.NOVA); 长风卷起琼花碎,
直上九霄揽月来。""", OpenaiCommon.OpenaiSpeech.NOVA);
final String filePath = "your filePath"; final String filePath = "your filePath";
final Path path = Paths.get(filePath); final Path path = Paths.get(filePath);
@@ -199,7 +200,7 @@ class OpenaiServiceTest {
void moderations() { void moderations() {
final OpenaiService openaiService = AIServiceFactory.getAIService(new AIConfigBuilder(ModelName.OPENAI.getValue()) final OpenaiService openaiService = AIServiceFactory.getAIService(new AIConfigBuilder(ModelName.OPENAI.getValue())
.setApiKey(key).setModel(Models.Openai.OMNI_MODERATION_LATEST.getModel()).build(), OpenaiService.class); .setApiKey(key).setModel(Models.Openai.OMNI_MODERATION_LATEST.getModel()).build(), OpenaiService.class);
final String moderations = openaiService.moderations("你要杀人", "https://img2.baidu.com/it/u=862000265,4064861820&fm=253&fmt=auto&app=138&f=JPEG?w=800&h=1544"); final String moderations = openaiService.moderations("你要玩游戏", "https://img2.baidu.com/it/u=862000265,4064861820&fm=253&fmt=auto&app=138&f=JPEG?w=800&h=1544");
assertNotNull(moderations); assertNotNull(moderations);
} }

View File

@@ -662,8 +662,7 @@ public class UrlUtil {
try { try {
conn = url.openConnection(); conn = url.openConnection();
useCachesIfNecessary(conn); useCachesIfNecessary(conn);
if (conn instanceof HttpURLConnection) { if (conn instanceof HttpURLConnection httpCon) {
final HttpURLConnection httpCon = (HttpURLConnection) conn;
httpCon.setRequestMethod("HEAD"); httpCon.setRequestMethod("HEAD");
} }
return conn.getContentLengthLong(); return conn.getContentLengthLong();
@@ -686,4 +685,73 @@ public class UrlUtil {
public static void useCachesIfNecessary(final URLConnection con) { public static void useCachesIfNecessary(final URLConnection con) {
con.setUseCaches(con.getClass().getSimpleName().startsWith("JNLP")); con.setUseCaches(con.getClass().getSimpleName().startsWith("JNLP"));
} }
/**
* 将表单数据加到URL中用于GET表单提交
* 表单的键值对会被url编码但是url中原参数不会被编码
* 且对form参数进行 FormUrlEncoded x-www-form-urlencoded模式此模式下空格会编码为'+'
*
* @param url URL
* @param form 表单数据
* @param charset 编码 null表示不encode键值对
* @return 合成后的URL
*/
public static String urlWithFormUrlEncoded(final String url, final Map<String, Object> form, final Charset charset) {
return urlWithForm(url, form, charset, true);
}
/**
* 将表单数据加到URL中用于GET表单提交<br>
* 表单的键值对会被url编码但是url中原参数不会被编码
*
* @param url URL
* @param form 表单数据
* @param charset 编码
* @param isEncodeParams 是否对键和值做转义处理
* @return 合成后的URL
*/
public static String urlWithForm(final String url, final Map<String, Object> form, final Charset charset, final boolean isEncodeParams) {
return urlWithForm(url, UrlQueryUtil.toQuery(form, null), charset, isEncodeParams);
}
/**
* 将表单数据字符串加到URL中用于GET表单提交
*
* @param url URL
* @param queryString 表单数据字符串
* @param charset 编码
* @param isEncode 是否对键和值做转义处理
* @return 拼接后的字符串
*/
public static String urlWithForm(final String url, final String queryString, final Charset charset, final boolean isEncode) {
if (StrUtil.isBlank(queryString)) {
// 无额外参数
if (StrUtil.contains(url, '?')) {
// url中包含参数
return isEncode ? UrlQueryUtil.encodeQuery(url, charset) : url;
}
return url;
}
// 始终有参数
final StringBuilder urlBuilder = new StringBuilder(url.length() + queryString.length() + 16);
final int qmIndex = url.indexOf('?');
if (qmIndex > 0) {
// 原URL带参数则对这部分参数单独编码如果选项为进行编码
urlBuilder.append(isEncode ? UrlQueryUtil.encodeQuery(url, charset) : url);
if (!StrUtil.endWith(url, '&')) {
// 已经带参数的情况下追加参数
urlBuilder.append('&');
}
} else {
// 原url无参数则不做编码
urlBuilder.append(url);
if (qmIndex < 0) {
// 无 '?' 追加之
urlBuilder.append('?');
}
}
urlBuilder.append(isEncode ? UrlQueryUtil.encodeQuery(queryString, charset) : queryString);
return urlBuilder.toString();
}
} }

View File

@@ -3943,9 +3943,21 @@ public class CharSequenceUtil extends StrValidator {
* @return StringBuilder对象 * @return StringBuilder对象
*/ */
public static StringBuilder builder(final CharSequence... strs) { public static StringBuilder builder(final CharSequence... strs) {
return builder(Function.identity(), strs);
}
/**
* 创建StringBuilder对象
*
* @param strEditor 编辑器,用于对每个字符串进行编辑
* @param strs 待处理的字符串列表
* @return StringBuilder对象
* @since 5.8.42
*/
public static StringBuilder builder(Function<CharSequence, CharSequence> strEditor, final CharSequence... strs){
final StringBuilder sb = new StringBuilder(); final StringBuilder sb = new StringBuilder();
for (final CharSequence str : strs) { for (final CharSequence str : strs) {
sb.append(str); sb.append(strEditor.apply( str));
} }
return sb; return sb;
} }

View File

@@ -17,11 +17,15 @@
package cn.hutool.v7.core.net; package cn.hutool.v7.core.net;
import cn.hutool.v7.core.net.url.UrlUtil; import cn.hutool.v7.core.net.url.UrlUtil;
import cn.hutool.v7.core.util.CharsetUtil;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.net.MalformedURLException; import java.net.MalformedURLException;
import java.net.URI; import java.net.URI;
import java.net.URL; import java.net.URL;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -107,4 +111,28 @@ public class URLUtilTest {
final URI resolve = uri.resolve("."); final URI resolve = uri.resolve(".");
assertEquals("/Uploads/20240601/", resolve.toString()); assertEquals("/Uploads/20240601/", resolve.toString());
} }
@Test
public void urlWithFormTest() {
final Map<String, Object> param = new LinkedHashMap<>();
param.put("AccessKeyId", "123");
param.put("Action", "DescribeDomainRecords");
param.put("Format", "date");
param.put("DomainName", "lesper.cn"); // 域名地址
param.put("SignatureMethod", "POST");
param.put("SignatureNonce", "123");
param.put("SignatureVersion", "4.3.1");
param.put("Timestamp", 123432453);
param.put("Version", "1.0");
String urlWithForm = UrlUtil.urlWithForm("http://api.hutool.cn/login?type=aaa", param, CharsetUtil.UTF_8, false);
Assertions.assertEquals(
"http://api.hutool.cn/login?type=aaa&AccessKeyId=123&Action=DescribeDomainRecords&Format=date&DomainName=lesper.cn&SignatureMethod=POST&SignatureNonce=123&SignatureVersion=4.3.1&Timestamp=123432453&Version=1.0",
urlWithForm);
urlWithForm = UrlUtil.urlWithForm("http://api.hutool.cn/login?type=aaa", param, CharsetUtil.UTF_8, false);
Assertions.assertEquals(
"http://api.hutool.cn/login?type=aaa&AccessKeyId=123&Action=DescribeDomainRecords&Format=date&DomainName=lesper.cn&SignatureMethod=POST&SignatureNonce=123&SignatureVersion=4.3.1&Timestamp=123432453&Version=1.0",
urlWithForm);
}
} }

View File

@@ -18,6 +18,7 @@ package cn.hutool.v7.http;
import cn.hutool.v7.core.util.RandomUtil; import cn.hutool.v7.core.util.RandomUtil;
import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
@@ -28,6 +29,7 @@ import java.net.HttpURLConnection;
* @since 4.6.2 * @since 4.6.2
*/ */
public class HttpGlobalConfig implements Serializable { public class HttpGlobalConfig implements Serializable {
@Serial
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** /**
@@ -168,7 +170,7 @@ public class HttpGlobalConfig implements Serializable {
/** /**
* 是否信任所有Host<br> * 是否信任所有Host<br>
* 见https://github.com/chinabugotech/hutool/issues/2042<br> * 见:<a href="https://github.com/chinabugotech/hutool/issues/2042">issue#2042</a><br>
* *
* @param customTrustAnyHost 如果设置为{@code false}则按照JDK默认验证机制验证目标服务器的证书host和请求host是否一致{@code true}表示不验证。 * @param customTrustAnyHost 如果设置为{@code false}则按照JDK默认验证机制验证目标服务器的证书host和请求host是否一致{@code true}表示不验证。
* @since 5.8.27 * @since 5.8.27

View File

@@ -19,7 +19,6 @@ package cn.hutool.v7.http;
import cn.hutool.v7.core.collection.CollUtil; import cn.hutool.v7.core.collection.CollUtil;
import cn.hutool.v7.core.map.CaseInsensitiveMap; import cn.hutool.v7.core.map.CaseInsensitiveMap;
import cn.hutool.v7.core.map.MapUtil; import cn.hutool.v7.core.map.MapUtil;
import cn.hutool.v7.core.net.url.UrlQueryUtil;
import cn.hutool.v7.core.text.StrUtil; import cn.hutool.v7.core.text.StrUtil;
import cn.hutool.v7.http.client.ClientConfig; import cn.hutool.v7.http.client.ClientConfig;
import cn.hutool.v7.http.client.Request; import cn.hutool.v7.http.client.Request;
@@ -208,75 +207,6 @@ public class HttpUtil {
return ClientEngineFactory.getEngine().send(request); return ClientEngineFactory.getEngine().send(request);
} }
/**
* 将表单数据加到URL中用于GET表单提交
* 表单的键值对会被url编码但是url中原参数不会被编码
* 且对form参数进行 FormUrlEncoded x-www-form-urlencoded模式此模式下空格会编码为'+'
*
* @param url URL
* @param form 表单数据
* @param charset 编码 null表示不encode键值对
* @return 合成后的URL
*/
public static String urlWithFormUrlEncoded(final String url, final Map<String, Object> form, final Charset charset) {
return urlWithForm(url, form, charset, true);
}
/**
* 将表单数据加到URL中用于GET表单提交<br>
* 表单的键值对会被url编码但是url中原参数不会被编码
*
* @param url URL
* @param form 表单数据
* @param charset 编码
* @param isEncodeParams 是否对键和值做转义处理
* @return 合成后的URL
*/
public static String urlWithForm(final String url, final Map<String, Object> form, final Charset charset, final boolean isEncodeParams) {
return urlWithForm(url, UrlQueryUtil.toQuery(form, null), charset, isEncodeParams);
}
/**
* 将表单数据字符串加到URL中用于GET表单提交
*
* @param url URL
* @param queryString 表单数据字符串
* @param charset 编码
* @param isEncode 是否对键和值做转义处理
* @return 拼接后的字符串
*/
public static String urlWithForm(final String url, final String queryString, final Charset charset, final boolean isEncode) {
if (StrUtil.isBlank(queryString)) {
// 无额外参数
if (StrUtil.contains(url, '?')) {
// url中包含参数
return isEncode ? UrlQueryUtil.encodeQuery(url, charset) : url;
}
return url;
}
// 始终有参数
final StringBuilder urlBuilder = new StringBuilder(url.length() + queryString.length() + 16);
final int qmIndex = url.indexOf('?');
if (qmIndex > 0) {
// 原URL带参数则对这部分参数单独编码如果选项为进行编码
urlBuilder.append(isEncode ? UrlQueryUtil.encodeQuery(url, charset) : url);
if (!StrUtil.endWith(url, '&')) {
// 已经带参数的情况下追加参数
urlBuilder.append('&');
}
} else {
// 原url无参数则不做编码
urlBuilder.append(url);
if (qmIndex < 0) {
// 无 '?' 追加之
urlBuilder.append('?');
}
}
urlBuilder.append(isEncode ? UrlQueryUtil.encodeQuery(queryString, charset) : queryString);
return urlBuilder.toString();
}
/** /**
* 创建客户端引擎 * 创建客户端引擎
* *

View File

@@ -28,7 +28,6 @@ import cn.hutool.v7.http.ssl.SSLInfo;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.ProxySelector;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;

View File

@@ -30,19 +30,9 @@ import java.net.PasswordAuthentication;
* *
* @author Looly * @author Looly
* @since 6.0.0 * @since 6.0.0
* @param auth 账号密码
*/ */
public class BasicProxyAuthenticator implements Authenticator { public record BasicProxyAuthenticator(PasswordAuthentication auth) implements Authenticator {
private final PasswordAuthentication auth;
/**
* 构造
*
* @param passwordAuthentication 账号密码对
*/
public BasicProxyAuthenticator(final PasswordAuthentication passwordAuthentication) {
auth = passwordAuthentication;
}
@Override @Override
public Request authenticate(final Route route, final Response response) { public Request authenticate(final Route route, final Response response) {

View File

@@ -31,26 +31,17 @@ import java.util.List;
* *
* @author Looly * @author Looly
* @since 6.0.0 * @since 6.0.0
* @param cookieStore Cookie存储器用于自定义Cookie存储实现
*/ */
public class CookieJarImpl implements CookieJar { public record CookieJarImpl(CookieStoreSpi cookieStore) implements CookieJar {
private final CookieStoreSpi cookieStore;
/**
* 构造
*
* @param cookieStore Cookie存储器用于自定义Cookie存储实现
*/
public CookieJarImpl(final CookieStoreSpi cookieStore) {
this.cookieStore = cookieStore;
}
/** /**
* 获取Cookie存储器 * 获取Cookie存储器
* *
* @return Cookie存储器 * @return Cookie存储器
*/ */
public CookieStoreSpi getCookieStore() { @Override
public CookieStoreSpi cookieStore() {
return this.cookieStore; return this.cookieStore;
} }
@@ -59,14 +50,14 @@ public class CookieJarImpl implements CookieJar {
final List<CookieSpi> cookieSpis = this.cookieStore.get(httpUrl.uri()); final List<CookieSpi> cookieSpis = this.cookieStore.get(httpUrl.uri());
final List<Cookie> cookies = new ArrayList<>(cookieSpis.size()); final List<Cookie> cookies = new ArrayList<>(cookieSpis.size());
for (final CookieSpi cookieSpi : cookieSpis) { for (final CookieSpi cookieSpi : cookieSpis) {
cookies.add(((OkCookie)cookieSpi).getRaw()); cookies.add(((OkCookie) cookieSpi).getRaw());
} }
return cookies; return cookies;
} }
@Override @Override
public void saveFromResponse(final HttpUrl httpUrl, final List<Cookie> list) { public void saveFromResponse(final HttpUrl httpUrl, final List<Cookie> list) {
if(CollUtil.isEmpty(list)){ if (CollUtil.isEmpty(list)) {
return; return;
} }

View File

@@ -28,7 +28,6 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -131,30 +130,6 @@ public class HttpUtilTest {
Console.log(body); Console.log(body);
} }
@Test
public void urlWithFormTest() {
final Map<String, Object> param = new LinkedHashMap<>();
param.put("AccessKeyId", "123");
param.put("Action", "DescribeDomainRecords");
param.put("Format", "date");
param.put("DomainName", "lesper.cn"); // 域名地址
param.put("SignatureMethod", "POST");
param.put("SignatureNonce", "123");
param.put("SignatureVersion", "4.3.1");
param.put("Timestamp", 123432453);
param.put("Version", "1.0");
String urlWithForm = HttpUtil.urlWithForm("http://api.hutool.cn/login?type=aaa", param, CharsetUtil.UTF_8, false);
Assertions.assertEquals(
"http://api.hutool.cn/login?type=aaa&AccessKeyId=123&Action=DescribeDomainRecords&Format=date&DomainName=lesper.cn&SignatureMethod=POST&SignatureNonce=123&SignatureVersion=4.3.1&Timestamp=123432453&Version=1.0",
urlWithForm);
urlWithForm = HttpUtil.urlWithForm("http://api.hutool.cn/login?type=aaa", param, CharsetUtil.UTF_8, false);
Assertions.assertEquals(
"http://api.hutool.cn/login?type=aaa&AccessKeyId=123&Action=DescribeDomainRecords&Format=date&DomainName=lesper.cn&SignatureMethod=POST&SignatureNonce=123&SignatureVersion=4.3.1&Timestamp=123432453&Version=1.0",
urlWithForm);
}
@Test @Test
@Disabled @Disabled
public void getWeixinTest(){ public void getWeixinTest(){

View File

@@ -18,6 +18,7 @@ package cn.hutool.v7.http;
import cn.hutool.v7.core.net.url.UrlQuery; import cn.hutool.v7.core.net.url.UrlQuery;
import cn.hutool.v7.core.net.url.UrlQueryUtil; import cn.hutool.v7.core.net.url.UrlQueryUtil;
import cn.hutool.v7.core.net.url.UrlUtil;
import cn.hutool.v7.core.util.CharsetUtil; import cn.hutool.v7.core.util.CharsetUtil;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -35,7 +36,7 @@ public class Issue3536Test {
paramMap.put("redirect_uri", "https://api.hutool.cn/v1/test"); paramMap.put("redirect_uri", "https://api.hutool.cn/v1/test");
paramMap.put("scope", "a,b,c你"); paramMap.put("scope", "a,b,c你");
final String s = HttpUtil.urlWithFormUrlEncoded(url, paramMap, CharsetUtil.UTF_8); final String s = UrlUtil.urlWithFormUrlEncoded(url, paramMap, CharsetUtil.UTF_8);
assertEquals("https://hutool.cn/test?scope=a,b,c%E4%BD%A0&redirect_uri=https://api.hutool.cn/v1/test", s); assertEquals("https://hutool.cn/test?scope=a,b,c%E4%BD%A0&redirect_uri=https://api.hutool.cn/v1/test", s);
} }

View File

@@ -18,6 +18,7 @@ package cn.hutool.v7.http;
import cn.hutool.v7.core.collection.ListUtil; import cn.hutool.v7.core.collection.ListUtil;
import cn.hutool.v7.core.net.url.UrlQuery; import cn.hutool.v7.core.net.url.UrlQuery;
import cn.hutool.v7.core.net.url.UrlUtil;
import cn.hutool.v7.core.util.CharsetUtil; import cn.hutool.v7.core.util.CharsetUtil;
import cn.hutool.v7.json.JSONObject; import cn.hutool.v7.json.JSONObject;
import cn.hutool.v7.json.JSONUtil; import cn.hutool.v7.json.JSONUtil;
@@ -39,12 +40,12 @@ public class IssueIAFKWPTest {
// form-url-encoded模式下所有字符转义 // form-url-encoded模式下所有字符转义
String build = UrlQuery.of(params, UrlQuery.EncodeMode.FORM_URL_ENCODED).build(CharsetUtil.UTF_8); String build = UrlQuery.of(params, UrlQuery.EncodeMode.FORM_URL_ENCODED).build(CharsetUtil.UTF_8);
String s = HttpUtil.urlWithForm("https://hutool.cn", build, CharsetUtil.UTF_8, false); String s = UrlUtil.urlWithForm("https://hutool.cn", build, CharsetUtil.UTF_8, false);
Assertions.assertEquals("https://hutool.cn?query=%7B%22fields%22%3A%5B%221%22%2C%222%22%2C%22good%22%5D%7D", s); Assertions.assertEquals("https://hutool.cn?query=%7B%22fields%22%3A%5B%221%22%2C%222%22%2C%22good%22%5D%7D", s);
// 标准模式下只转义特定字符 // 标准模式下只转义特定字符
build = UrlQuery.of(params, UrlQuery.EncodeMode.NORMAL).build(CharsetUtil.UTF_8); build = UrlQuery.of(params, UrlQuery.EncodeMode.NORMAL).build(CharsetUtil.UTF_8);
s = HttpUtil.urlWithForm("https://hutool.cn", build, CharsetUtil.UTF_8, false); s = UrlUtil.urlWithForm("https://hutool.cn", build, CharsetUtil.UTF_8, false);
Assertions.assertEquals("https://hutool.cn?query=%7B%22fields%22:%5B%221%22,%222%22,%22good%22%5D%7D", s); Assertions.assertEquals("https://hutool.cn?query=%7B%22fields%22:%5B%221%22,%222%22,%22good%22%5D%7D", s);
} }
} }