001/** 002 * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). 003 * <p> 004 * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * <p> 008 * http://www.gnu.org/licenses/lgpl-3.0.txt 009 * <p> 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package dev.tinyflow.core.node; 017 018import com.alibaba.fastjson.JSON; 019import com.alibaba.fastjson.JSONObject; 020import dev.tinyflow.core.chain.Chain; 021import dev.tinyflow.core.chain.DataType; 022import dev.tinyflow.core.chain.Parameter; 023import dev.tinyflow.core.filestoreage.FileStorage; 024import dev.tinyflow.core.filestoreage.FileStorageManager; 025import dev.tinyflow.core.util.OkHttpClientUtil; 026import dev.tinyflow.core.util.StringUtil; 027import dev.tinyflow.core.util.TextTemplate; 028import okhttp3.*; 029 030import java.io.IOException; 031import java.io.InputStream; 032import java.io.UnsupportedEncodingException; 033import java.net.URLEncoder; 034import java.util.HashMap; 035import java.util.List; 036import java.util.Map; 037 038public class HttpNode extends BaseNode { 039 040 private String url; 041 private String method; 042 043 private List<Parameter> headers; 044 045 private String bodyType; 046 private List<Parameter> formData; 047 private List<Parameter> formUrlencoded; 048 private String bodyJson; 049 private String rawBody; 050 051 public static String mapToQueryString(Map<String, Object> map) { 052 if (map == null || map.isEmpty()) { 053 return ""; 054 } 055 056 StringBuilder stringBuilder = new StringBuilder(); 057 058 for (String key : map.keySet()) { 059 if (StringUtil.noText(key)) { 060 continue; 061 } 062 if (stringBuilder.length() > 0) { 063 stringBuilder.append("&"); 064 } 065 stringBuilder.append(key.trim()); 066 stringBuilder.append("="); 067 Object value = map.get(key); 068 stringBuilder.append(value == null ? "" : urlEncode(value.toString().trim())); 069 } 070 return stringBuilder.toString(); 071 } 072 073 public static String urlEncode(String string) { 074 try { 075 return URLEncoder.encode(string, "UTF-8"); 076 } catch (UnsupportedEncodingException e) { 077 throw new RuntimeException(e); 078 } 079 } 080 081 public String getUrl() { 082 return url; 083 } 084 085 public void setUrl(String url) { 086 this.url = url; 087 } 088 089 public String getMethod() { 090 return method; 091 } 092 093 public void setMethod(String method) { 094 this.method = method; 095 } 096 097 public List<Parameter> getHeaders() { 098 return headers; 099 } 100 101 public void setHeaders(List<Parameter> headers) { 102 this.headers = headers; 103 } 104 105 public String getBodyType() { 106 return bodyType; 107 } 108 109 public void setBodyType(String bodyType) { 110 this.bodyType = bodyType; 111 } 112 113 public List<Parameter> getFormData() { 114 return formData; 115 } 116 117 public void setFormData(List<Parameter> formData) { 118 this.formData = formData; 119 } 120 121 public List<Parameter> getFormUrlencoded() { 122 return formUrlencoded; 123 } 124 125 public void setFormUrlencoded(List<Parameter> formUrlencoded) { 126 this.formUrlencoded = formUrlencoded; 127 } 128 129 public String getBodyJson() { 130 return bodyJson; 131 } 132 133 public void setBodyJson(String bodyJson) { 134 this.bodyJson = bodyJson; 135 } 136 137 public String getRawBody() { 138 return rawBody; 139 } 140 141 public void setRawBody(String rawBody) { 142 this.rawBody = rawBody; 143 } 144 145 @Override 146 public Map<String, Object> execute(Chain chain) { 147 int maxRetry = 5; 148 long retryInterval = 2000L; 149 150 int attempt = 0; 151 Throwable lastError = null; 152 153 while (attempt < maxRetry) { 154 attempt++; 155 156 try { 157 return doExecute(chain); 158 } catch (Throwable ex) { 159 160 lastError = ex; 161 162 // 判断是否需要重试 163 if (!shouldRetry(ex)) { 164 throw wrapAsRuntime(ex, attempt); 165 } 166 167 try { 168 long waitMs = Math.min( 169 retryInterval * (1L << (attempt - 1)), 170 10_000L // 最大 10 秒 171 ); 172 Thread.sleep(waitMs); 173 } catch (InterruptedException ie) { 174 Thread.currentThread().interrupt(); 175 throw new RuntimeException("HTTP retry interrupted", ie); 176 } 177 } 178 } 179 180 // 理论上不会走到这里 181 throw wrapAsRuntime(lastError, attempt); 182 } 183 184 185 protected boolean shouldRetry(Throwable ex) { 186 if (ex instanceof HttpServerErrorException) { 187 int code = ((HttpServerErrorException) ex).getStatusCode(); 188 return code == 503 || code == 504; // 只对特定 5xx 重试 189 } 190 191 // 1. IO 异常(超时、连接失败、Socket 问题) 192 if (ex instanceof IOException) { 193 return true; 194 } 195 196 // 2. 包装过的异常 197 Throwable cause = ex.getCause(); 198 return cause instanceof IOException; 199 } 200 201 private RuntimeException wrapAsRuntime(Throwable ex, int attempt) { 202 if (ex instanceof RuntimeException) { 203 return (RuntimeException) ex; 204 } 205 return new RuntimeException( 206 String.format("HttpNode[%s] failed after %d attempt(s)", getName(), attempt), 207 ex 208 ); 209 } 210 211 212 public Map<String, Object> doExecute(Chain chain) throws IOException { 213 214 Map<String, Object> formatParameters = getFormatParameters(chain); 215 String newUrl = TextTemplate.of(url).formatToString(formatParameters); 216 217 Request.Builder reqBuilder = new Request.Builder().url(newUrl); 218 219 Map<String, Object> headersMap = chain.getState().resolveParameters(this, headers, formatParameters); 220 headersMap.forEach((s, o) -> reqBuilder.addHeader(s, String.valueOf(o))); 221 222 if (StringUtil.noText(method) || "GET".equalsIgnoreCase(method)) { 223 reqBuilder.method("GET", null); 224 } else { 225 reqBuilder.method(method.toUpperCase(), getRequestBody(chain, formatParameters)); 226 } 227 228 OkHttpClient okHttpClient = OkHttpClientUtil.buildDefaultClient(); 229 try (Response response = okHttpClient.newCall(reqBuilder.build()).execute()) { 230 231 // 服务器异常 232 if (response.code() >= 500 && response.code() < 600) { 233 throw new HttpServerErrorException(response.code(), response.message()); 234 } 235 236 Map<String, Object> result = new HashMap<>(); 237 result.put("statusCode", response.code()); 238 239 Map<String, String> responseHeaders = new HashMap<>(); 240 Headers headers = response.headers(); 241 for (String name : headers.names()) { 242 responseHeaders.put(name, response.header(name)); 243 } 244 result.put("headers", responseHeaders); 245 246 ResponseBody body = response.body(); 247 if (body == null) { 248 result.put("body", null); 249 return result; 250 } 251 252 DataType bodyDataType = null; 253 List<Parameter> outputDefs = getOutputDefs(); 254 if (outputDefs != null) { 255 for (Parameter outputDef : outputDefs) { 256 if ("body".equalsIgnoreCase(outputDef.getName())) { 257 bodyDataType = outputDef.getDataType(); 258 break; 259 } 260 } 261 } 262 263 if (bodyDataType == null) { 264 result.put("body", body.string()); 265 } else if (bodyDataType == DataType.Object || bodyDataType.getValue().startsWith("Array")) { 266 result.put("body", JSON.parse(body.string())); 267 } else if (bodyDataType == DataType.File) { 268 try (InputStream stream = body.byteStream()) { 269 FileStorage fileStorage = FileStorageManager.getInstance().getFileStorage(); 270 String fileUrl = fileStorage.saveFile(stream, responseHeaders, this, chain); 271 result.put("body", fileUrl); 272 } 273 } else { 274 result.put("body", body.string()); 275 } 276 return result; 277 } 278 } 279 280 private RequestBody getRequestBody(Chain chain, Map<String, Object> formatArgs) { 281 if ("json".equals(bodyType)) { 282 String bodyJsonString = TextTemplate.of(bodyJson).formatToString(formatArgs, true); 283 JSONObject jsonObject = JSON.parseObject(bodyJsonString); 284 return RequestBody.create(jsonObject.toString(), MediaType.parse("application/json")); 285 } 286 287 if ("x-www-form-urlencoded".equals(bodyType)) { 288 Map<String, Object> formUrlencodedMap = chain.getState().resolveParameters(this, formUrlencoded); 289 String bodyString = mapToQueryString(formUrlencodedMap); 290 return RequestBody.create(bodyString, MediaType.parse("application/x-www-form-urlencoded")); 291 } 292 293 if ("form-data".equals(bodyType)) { 294 Map<String, Object> formDataMap = chain.getState().resolveParameters(this, formData, formatArgs); 295 296 MultipartBody.Builder builder = new MultipartBody.Builder() 297 .setType(MultipartBody.FORM); 298 299 formDataMap.forEach((s, o) -> { 300// if (o instanceof File) { 301// File f = (File) o; 302// RequestBody body = RequestBody.create(f, MediaType.parse("application/octet-stream")); 303// builder.addFormDataPart(s, f.getName(), body); 304// } else if (o instanceof InputStream) { 305// RequestBody body = new HttpClient.InputStreamRequestBody(MediaType.parse("application/octet-stream"), (InputStream) o); 306// builder.addFormDataPart(s, s, body); 307// } else if (o instanceof byte[]) { 308// builder.addFormDataPart(s, s, RequestBody.create((byte[]) o)); 309// } else { 310// builder.addFormDataPart(s, String.valueOf(o)); 311// } 312 builder.addFormDataPart(s, String.valueOf(o)); 313 }); 314 315 return builder.build(); 316 } 317 318 if ("raw".equals(bodyType)) { 319 String rawBodyString = TextTemplate.of(rawBody).formatToString(formatArgs); 320 return RequestBody.create(rawBodyString, null); 321 } 322 //none 323 return RequestBody.create("", null); 324 } 325 326 public static class HttpServerErrorException extends IOException { 327 private final int statusCode; 328 329 public HttpServerErrorException(int statusCode, String message) { 330 super("HTTP " + statusCode + ": " + message); 331 this.statusCode = statusCode; 332 } 333 334 public int getStatusCode() { 335 return statusCode; 336 } 337 } 338 339 340 @Override 341 public String toString() { 342 return "HttpNode{" + 343 "url='" + url + '\'' + 344 ", method='" + method + '\'' + 345 ", headers=" + headers + 346 ", bodyType='" + bodyType + '\'' + 347 ", formData=" + formData + 348 ", formUrlencoded=" + formUrlencoded + 349 ", bodyJson='" + bodyJson + '\'' + 350 ", rawBody='" + rawBody + '\'' + 351 ", parameters=" + parameters + 352 ", outputDefs=" + outputDefs + 353 ", id='" + id + '\'' + 354 ", name='" + name + '\'' + 355 ", description='" + description + '\'' + 356 ", condition=" + condition + 357 ", validator=" + validator + 358 ", loopEnable=" + loopEnable + 359 ", loopIntervalMs=" + loopIntervalMs + 360 ", loopBreakCondition=" + loopBreakCondition + 361 ", maxLoopCount=" + maxLoopCount + 362 ", retryEnable=" + retryEnable + 363 ", resetRetryCountAfterNormal=" + resetRetryCountAfterNormal + 364 ", maxRetryCount=" + maxRetryCount + 365 ", retryIntervalMs=" + retryIntervalMs + 366 ", computeCostExpr='" + computeCostExpr + '\'' + 367 '}'; 368 } 369}