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}