feat(javaTest): expose local dispatch visualizer API
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
package com.ft546.wcs.shadow.history.visualizer;
|
||||
|
||||
import com.ft546.wcs.shadow.history.HistoricalQueueDataset;
|
||||
import com.ft546.wcs.shadow.history.HistoricalQueueLoader;
|
||||
import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInput;
|
||||
import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInputFactory;
|
||||
|
||||
import java.nio.file.Paths;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
|
||||
/** 启动只监听本机的调度过程可视化审计台。 */
|
||||
public final class HistoricalDispatchVisualizerApplication {
|
||||
private HistoricalDispatchVisualizerApplication() { }
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
if (args.length > 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"usage: HistoricalDispatchVisualizerApplication [port]");
|
||||
}
|
||||
int port = args.length == 0 ? 18084 : Integer.parseInt(args[0]);
|
||||
ClassLoader classLoader = HistoricalDispatchVisualizerApplication.class.getClassLoader();
|
||||
HistoricalQueueLoader loader = new HistoricalQueueLoader();
|
||||
HistoricalQueueDataset dataset = classLoader.getResource("shadow/history/manifest.json") != null
|
||||
? loader.load(classLoader, "shadow/history")
|
||||
: loader.load(Paths.get("src/test/resources/shadow/history"));
|
||||
HistoricalSimulationInput input = new HistoricalSimulationInputFactory().create(dataset);
|
||||
HistoricalDispatchVisualizerServer server =
|
||||
new HistoricalDispatchVisualizerServer(port, dataset, input);
|
||||
CountDownLatch shutdown = new CountDownLatch(1);
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
server.stop();
|
||||
shutdown.countDown();
|
||||
}, "javaTest-visualizer-shutdown"));
|
||||
server.start();
|
||||
System.out.println("DISPATCH-VISUALIZER http://127.0.0.1:" + server.getPort());
|
||||
shutdown.await();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package com.ft546.wcs.shadow.history.visualizer;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ft546.wcs.shadow.history.HistoricalQueueDataset;
|
||||
import com.ft546.wcs.shadow.history.simulation.HistoricalDispatchSimulationResult;
|
||||
import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInput;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 为离线调度审计页面提供同源静态资源和 JSON API。
|
||||
*
|
||||
* <p>服务只绑定到本机回环地址,不暴露生产系统接口,也不接受文件路径或命令输入。
|
||||
*/
|
||||
public final class HistoricalDispatchVisualizerServer {
|
||||
private static final int MAX_REQUEST_BYTES = 64 * 1024;
|
||||
|
||||
private final HttpServer httpServer;
|
||||
private final ExecutorService executor;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final HistoricalSimulationInput input;
|
||||
private final PointToPointSimulationService pointService;
|
||||
private final HistoricalWorkloadSimulationService historicalService;
|
||||
private final SimulationVisualizationMapper mapper;
|
||||
private final AtomicBoolean simulationBusy = new AtomicBoolean(false);
|
||||
private final AtomicBoolean started = new AtomicBoolean(false);
|
||||
|
||||
public HistoricalDispatchVisualizerServer(
|
||||
int port, HistoricalQueueDataset dataset, HistoricalSimulationInput input)
|
||||
throws IOException {
|
||||
if (port < 0 || port > 65_535) {
|
||||
throw new IllegalArgumentException("port must be between 0 and 65535");
|
||||
}
|
||||
if (dataset == null || input == null) {
|
||||
throw new IllegalArgumentException("dataset and input are required");
|
||||
}
|
||||
this.input = input;
|
||||
this.pointService = new PointToPointSimulationService(input);
|
||||
this.historicalService = new HistoricalWorkloadSimulationService(input);
|
||||
this.mapper = new SimulationVisualizationMapper(dataset);
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.httpServer = HttpServer.create(new InetSocketAddress(
|
||||
InetAddress.getByName("127.0.0.1"), port), 0);
|
||||
this.executor = Executors.newFixedThreadPool(4, new VisualizerThreadFactory());
|
||||
this.httpServer.setExecutor(executor);
|
||||
this.httpServer.createContext("/", new DispatchHandler());
|
||||
}
|
||||
|
||||
/** 启动本地服务。重复启动属于调用错误。 */
|
||||
public void start() {
|
||||
if (!started.compareAndSet(false, true)) {
|
||||
throw new IllegalStateException("visualizer server is already started");
|
||||
}
|
||||
httpServer.start();
|
||||
}
|
||||
|
||||
/** 停止服务并释放测试或命令行进程使用的线程。 */
|
||||
public void stop() {
|
||||
if (started.compareAndSet(true, false)) httpServer.stop(0);
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
return httpServer.getAddress().getPort();
|
||||
}
|
||||
|
||||
private final class DispatchHandler implements HttpHandler {
|
||||
@Override
|
||||
public void handle(HttpExchange exchange) throws IOException {
|
||||
try {
|
||||
route(exchange);
|
||||
} catch (VisualizerHttpException exception) {
|
||||
writeError(exchange, exception.getStatus(), exception.getCode(),
|
||||
exception.getMessage());
|
||||
} catch (JsonProcessingException exception) {
|
||||
writeError(exchange, 400, "BAD_REQUEST", "请求 JSON 无法解析");
|
||||
} catch (IllegalArgumentException exception) {
|
||||
writeError(exchange, 400, businessCode(exception.getMessage()),
|
||||
exception.getMessage());
|
||||
} catch (IllegalStateException exception) {
|
||||
String code = exception.getMessage() != null
|
||||
&& exception.getMessage().contains("tick limit")
|
||||
? "TICK_LIMIT_EXCEEDED" : "WATCHDOG_DEADLOCK";
|
||||
writeError(exchange, 422, code, exception.getMessage());
|
||||
} catch (Exception exception) {
|
||||
writeError(exchange, 500, "INTERNAL_ERROR", "调度审计服务处理失败");
|
||||
} finally {
|
||||
exchange.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void route(HttpExchange exchange) throws IOException {
|
||||
String path = exchange.getRequestURI().getPath();
|
||||
String method = exchange.getRequestMethod();
|
||||
if ("/api/health".equals(path)) {
|
||||
requireMethod(method, "GET");
|
||||
Map<String, Object> health = new LinkedHashMap<String, Object>();
|
||||
health.put("status", "UP");
|
||||
health.put("mode", "OFFLINE_IN_MEMORY_SIMULATION");
|
||||
health.put("mapLoaded", !input.getGridMap().getSites().isEmpty());
|
||||
health.put("simulationBusy", simulationBusy.get());
|
||||
writeJson(exchange, 200, health);
|
||||
return;
|
||||
}
|
||||
if ("/api/bootstrap".equals(path)) {
|
||||
requireMethod(method, "GET");
|
||||
writeJson(exchange, 200, mapper.bootstrap(input));
|
||||
return;
|
||||
}
|
||||
if ("/api/simulations/point-to-point".equals(path)) {
|
||||
requireMethod(method, "POST");
|
||||
byte[] body = readBody(exchange);
|
||||
PointToPointSimulationRequest request = objectMapper.readValue(
|
||||
body, PointToPointSimulationRequest.class);
|
||||
writeJson(exchange, 200, runSimulation(() ->
|
||||
mapper.result(input, pointService.run(request))));
|
||||
return;
|
||||
}
|
||||
if ("/api/simulations/historical".equals(path)) {
|
||||
requireMethod(method, "POST");
|
||||
readBody(exchange);
|
||||
writeJson(exchange, 200, runSimulation(() -> {
|
||||
HistoricalDispatchSimulationResult result = historicalService.run();
|
||||
return mapper.result(input, result);
|
||||
}));
|
||||
return;
|
||||
}
|
||||
throw new VisualizerHttpException(404, "NOT_FOUND", "请求的审计资源不存在");
|
||||
}
|
||||
|
||||
private Map<String, Object> runSimulation(SimulationCall call) {
|
||||
if (!simulationBusy.compareAndSet(false, true)) {
|
||||
throw new VisualizerHttpException(409, "SIMULATION_BUSY", "已有仿真正在运行");
|
||||
}
|
||||
try {
|
||||
return call.run();
|
||||
} finally {
|
||||
simulationBusy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] readBody(HttpExchange exchange) throws IOException {
|
||||
String contentLength = exchange.getRequestHeaders().getFirst("Content-Length");
|
||||
if (contentLength != null) {
|
||||
try {
|
||||
if (Long.parseLong(contentLength) > MAX_REQUEST_BYTES) {
|
||||
throw new VisualizerHttpException(413, "PAYLOAD_TOO_LARGE", "请求体超过 64 KiB");
|
||||
}
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new VisualizerHttpException(400, "BAD_REQUEST", "Content-Length 无效");
|
||||
}
|
||||
}
|
||||
try (InputStream inputStream = exchange.getRequestBody();
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
byte[] buffer = new byte[4096];
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) >= 0) {
|
||||
if (output.size() + read > MAX_REQUEST_BYTES) {
|
||||
throw new VisualizerHttpException(413, "PAYLOAD_TOO_LARGE", "请求体超过 64 KiB");
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
return output.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private void requireMethod(String actual, String expected) {
|
||||
if (!expected.equals(actual)) {
|
||||
throw new VisualizerHttpException(405, "METHOD_NOT_ALLOWED",
|
||||
"该接口仅允许 " + expected);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeError(HttpExchange exchange, int status, String code, String message)
|
||||
throws IOException {
|
||||
Map<String, Object> value = new LinkedHashMap<String, Object>();
|
||||
value.put("code", code);
|
||||
value.put("message", message == null ? "请求处理失败" : message);
|
||||
value.put("details", new LinkedHashMap<String, Object>());
|
||||
writeJson(exchange, status, value);
|
||||
}
|
||||
|
||||
private void writeJson(HttpExchange exchange, int status, Object value) throws IOException {
|
||||
byte[] body = objectMapper.writeValueAsBytes(value);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||
exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff");
|
||||
exchange.sendResponseHeaders(status, body.length);
|
||||
try (OutputStream output = exchange.getResponseBody()) {
|
||||
output.write(body);
|
||||
}
|
||||
}
|
||||
|
||||
private String businessCode(String message) {
|
||||
if (message == null) return "BAD_REQUEST";
|
||||
int separator = message.indexOf(':');
|
||||
if (separator <= 0) return "BAD_REQUEST";
|
||||
String candidate = message.substring(0, separator);
|
||||
return candidate.matches("[A-Z_]+") ? candidate : "BAD_REQUEST";
|
||||
}
|
||||
|
||||
private interface SimulationCall {
|
||||
Map<String, Object> run();
|
||||
}
|
||||
|
||||
private static final class VisualizerThreadFactory implements ThreadFactory {
|
||||
private final AtomicInteger sequence = new AtomicInteger();
|
||||
|
||||
@Override
|
||||
public Thread newThread(Runnable runnable) {
|
||||
Thread thread = new Thread(runnable,
|
||||
"javaTest-visualizer-http-" + sequence.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ft546.wcs.shadow.history.visualizer;
|
||||
|
||||
import com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan;
|
||||
import com.ft546.wcs.shadow.history.simulation.HistoricalDispatchSimulationResult;
|
||||
import com.ft546.wcs.shadow.history.simulation.HistoricalDispatchSimulator;
|
||||
import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInput;
|
||||
import com.ft546.wcs.shadow.history.simulation.SimulatedPlcGateway;
|
||||
import com.ft546.wcs.shadow.history.simulation.SimulationConfig;
|
||||
|
||||
/** 重新运行固化的 240 条历史负载,并保留既定的确定性 PLC 故障注入。 */
|
||||
public final class HistoricalWorkloadSimulationService {
|
||||
private final HistoricalSimulationInput input;
|
||||
|
||||
public HistoricalWorkloadSimulationService(HistoricalSimulationInput input) {
|
||||
if (input == null) throw new IllegalArgumentException("input is required");
|
||||
this.input = input;
|
||||
}
|
||||
|
||||
public HistoricalDispatchSimulationResult run() {
|
||||
DeterministicFaultPlan.Builder faults = DeterministicFaultPlan.builder();
|
||||
for (int i = 0; i < input.getTasks().size(); i += 37) {
|
||||
faults.plcFailure(input.getTasks().get(i).getSimulationTaskId(), 1,
|
||||
SimulatedPlcGateway.PlcReason.SEND_FAILURE);
|
||||
}
|
||||
return new HistoricalDispatchSimulator().simulate(
|
||||
input, SimulationConfig.defaults(), faults.build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ft546.wcs.shadow.history.visualizer;
|
||||
|
||||
/** 表示可稳定映射为前端业务错误码的 HTTP 请求异常。 */
|
||||
final class VisualizerHttpException extends RuntimeException {
|
||||
private final int status;
|
||||
private final String code;
|
||||
|
||||
VisualizerHttpException(int status, String code, String message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
int getStatus() { return status; }
|
||||
String getCode() { return code; }
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.ft546.wcs.shadow.history.visualizer;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ft546.wcs.shadow.history.HistoricalQueueDataset;
|
||||
import com.ft546.wcs.shadow.history.HistoricalQueueLoader;
|
||||
import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInput;
|
||||
import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInputFactory;
|
||||
import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationTask;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class HistoricalDispatchVisualizerServerTest {
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static HistoricalQueueDataset dataset;
|
||||
private static HistoricalSimulationInput input;
|
||||
private static HistoricalSimulationTask sampleTask;
|
||||
|
||||
private HistoricalDispatchVisualizerServer server;
|
||||
|
||||
@BeforeAll
|
||||
static void loadFixture() {
|
||||
dataset = new HistoricalQueueLoader().load(
|
||||
HistoricalDispatchVisualizerServerTest.class.getClassLoader(), "shadow/history");
|
||||
input = new HistoricalSimulationInputFactory().create(dataset);
|
||||
sampleTask = input.getTasks().stream()
|
||||
.filter(task -> !task.getPhysicalSource().equals(task.getPhysicalTarget()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AssertionError("a point-to-point sample is required"));
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = new HistoricalDispatchVisualizerServer(0, dataset, input);
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
if (server != null) server.stop();
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesHealthBootstrapAndPointToPointSimulation() throws Exception {
|
||||
HttpResult health = request("GET", "/api/health", null);
|
||||
HttpResult bootstrap = request("GET", "/api/bootstrap", null);
|
||||
PointToPointSimulationRequest task = PointToPointSimulationRequest.valid(
|
||||
sampleTask.getPhysicalSource(), sampleTask.getPhysicalTarget(),
|
||||
"INBOUND", 1, 100, null, Collections.emptyList(), "NONE");
|
||||
HttpResult simulation = request("POST", "/api/simulations/point-to-point",
|
||||
JSON.writeValueAsString(task));
|
||||
|
||||
assertEquals(200, health.status);
|
||||
assertEquals(Boolean.TRUE, map(health.body).get("mapLoaded"));
|
||||
assertEquals(Boolean.FALSE, map(health.body).get("simulationBusy"));
|
||||
assertEquals(200, bootstrap.status);
|
||||
assertEquals("OFFLINE_IN_MEMORY_SIMULATION", map(bootstrap.body).get("mode"));
|
||||
assertEquals(200, simulation.status);
|
||||
assertEquals(1, map(map(simulation.body).get("summary")).get("completedCount"));
|
||||
assertFalse(list(map(simulation.body).get("events")).isEmpty());
|
||||
assertTrue(simulation.body.contains("plannedPath"));
|
||||
assertTrue(simulation.body.contains("MOVE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void historicalEndpointRunsAllCurrentTasks() throws Exception {
|
||||
HttpResult response = request("POST", "/api/simulations/historical", "{}");
|
||||
Map<String, Object> summary = map(map(response.body).get("summary"));
|
||||
|
||||
assertEquals(200, response.status);
|
||||
assertEquals(240, summary.get("inputCount"));
|
||||
assertEquals(240, summary.get("completedCount"));
|
||||
assertEquals(0, summary.get("waitingCount"));
|
||||
assertEquals(0, summary.get("failedCount"));
|
||||
assertEquals(Boolean.FALSE, map(response.body).get("pathInfoUsedForPlanning"));
|
||||
assertEquals(0, map(map(response.body).get("invariants")).get("totalSafetyViolations"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownRoutesWrongMethodsMalformedAndOversizedBodies() throws Exception {
|
||||
HttpResult unknown = request("GET", "/api/unknown", null);
|
||||
HttpResult wrongMethod = request("POST", "/api/health", "{}");
|
||||
HttpResult malformed = request("POST", "/api/simulations/point-to-point", "{");
|
||||
StringBuilder oversized = new StringBuilder(70_000);
|
||||
for (int i = 0; i < 70_000; i++) oversized.append('x');
|
||||
HttpResult tooLarge = request("POST", "/api/simulations/point-to-point",
|
||||
oversized.toString());
|
||||
|
||||
assertEquals(404, unknown.status);
|
||||
assertEquals("NOT_FOUND", map(unknown.body).get("code"));
|
||||
assertEquals(405, wrongMethod.status);
|
||||
assertEquals("METHOD_NOT_ALLOWED", map(wrongMethod.body).get("code"));
|
||||
assertEquals(400, malformed.status);
|
||||
assertEquals("BAD_REQUEST", map(malformed.body).get("code"));
|
||||
assertEquals(413, tooLarge.status);
|
||||
assertEquals("PAYLOAD_TOO_LARGE", map(tooLarge.body).get("code"));
|
||||
}
|
||||
|
||||
private HttpResult request(String method, String path, String body) throws IOException {
|
||||
URL url = new URL("http://127.0.0.1:" + server.getPort() + path);
|
||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setRequestMethod(method);
|
||||
connection.setConnectTimeout(5_000);
|
||||
connection.setReadTimeout(60_000);
|
||||
connection.setRequestProperty("Accept", "application/json");
|
||||
if (body != null) {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
connection.setDoOutput(true);
|
||||
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
|
||||
connection.setFixedLengthStreamingMode(bytes.length);
|
||||
try (OutputStream output = connection.getOutputStream()) {
|
||||
output.write(bytes);
|
||||
}
|
||||
}
|
||||
int status = connection.getResponseCode();
|
||||
InputStream stream = status >= 400
|
||||
? connection.getErrorStream() : connection.getInputStream();
|
||||
String response = stream == null ? "" : read(stream);
|
||||
connection.disconnect();
|
||||
return new HttpResult(status, response);
|
||||
}
|
||||
|
||||
private String read(InputStream input) throws IOException {
|
||||
try (InputStream source = input; ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
byte[] buffer = new byte[4096];
|
||||
int read;
|
||||
while ((read = source.read(buffer)) >= 0) output.write(buffer, 0, read);
|
||||
return new String(output.toByteArray(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> map(String json) throws IOException {
|
||||
return JSON.readValue(json, new TypeReference<Map<String, Object>>() { });
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> map(Object value) {
|
||||
return (Map<String, Object>) value;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static java.util.List<Object> list(Object value) {
|
||||
return (java.util.List<Object>) value;
|
||||
}
|
||||
|
||||
private static final class HttpResult {
|
||||
private final int status;
|
||||
private final String body;
|
||||
|
||||
private HttpResult(int status, String body) {
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user