package vn.sis.appointment; import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; final class SisApiClient { private final HttpClient httpClient; private final Map tokenCache = new ConcurrentHashMap<>(); SisApiClient() { this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(30)) .followRedirects(HttpClient.Redirect.NORMAL) .version(HttpClient.Version.HTTP_1_1) .build(); } ProxyResponse checkToken(ApiConfig config) { try { config.validate(); TokenResult tokenResult = obtainToken(config, true); if (!tokenResult.success()) { return tokenResult.proxyResponse(); } CachedToken token = tokenResult.token(); String safeBody = Json.stringify(Map.of( "access_token", "[da an]", "token_type", token.tokenType(), "expires_at", token.expiresAt().toString())); ProxyResponse source = tokenResult.proxyResponse(); return new ProxyResponse( true, source.httpStatus(), source.statusText(), source.method(), source.url(), 1, safeBody, source.requestInfo(), source.responseHeaders(), token.expiresAt(), "Lay access token thanh cong."); } catch (IllegalArgumentException ex) { return validationError(ex.getMessage()); } catch (Exception ex) { return errorResponse(ex); } } ProxyResponse getDoctors(ApiConfig config) { return authorizedGet(config, config.doctorsUrl()); } ProxyResponse getSchedule(ApiConfig config, String doctorCode) { if (doctorCode == null || doctorCode.isBlank()) { return validationError("Ma bac si khong duoc de trong."); } return authorizedGet(config, config.scheduleUrl(doctorCode.trim())); } void clearToken(ApiConfig config) { tokenCache.remove(cacheKey(config)); } private ProxyResponse authorizedGet(ApiConfig config, String url) { try { config.validate(); int attempts = 1; TokenResult tokenResult = obtainToken(config, false); if (!tokenResult.success()) { return tokenResult.proxyResponse(); } CachedToken token = tokenResult.token(); HttpCall call = sendGet(config, url, token.accessToken()); if (call.statusCode() == 401) { attempts = 2; clearToken(config); tokenResult = obtainToken(config, true); if (!tokenResult.success()) { return tokenResult.proxyResponse(); } token = tokenResult.token(); call = sendGet(config, url, token.accessToken()); } boolean ok = call.statusCode() >= 200 && call.statusCode() <= 299; return new ProxyResponse( ok, call.statusCode(), call.statusText(), "GET", url, attempts, call.body(), requestInfo(config, "GET", url, true), call.responseHeaders(), token.expiresAt(), ok ? "Goi API thanh cong." : "API tra ve loi HTTP " + call.statusCode() + "."); } catch (IllegalArgumentException ex) { return validationError(ex.getMessage()); } catch (Exception ex) { return errorResponse(ex); } } private TokenResult obtainToken(ApiConfig config, boolean forceRefresh) throws IOException, InterruptedException { String key = cacheKey(config); CachedToken cached = tokenCache.get(key); if (!forceRefresh && cached != null && cached.isValid()) { ProxyResponse cachedResponse = new ProxyResponse( true, 200, "OK", "CACHE", config.tokenUrl(), 0, "", "Su dung access token dang con han trong bo nho may chu.", "", cached.expiresAt(), "Token con hieu luc."); return new TokenResult(true, cached, cachedResponse); } String form = formEncode(Map.of( "grant_type", "client_credentials", "client_id", config.clientId(), "client_secret", config.clientSecret(), "scope", config.scope())); HttpRequest request = HttpRequest.newBuilder(URI.create(config.tokenUrl())) .timeout(Duration.ofSeconds(60)) .version(HttpClient.Version.HTTP_1_1) .header("__tenant", config.tenant()) .header("Accept", "application/json") .header("Content-Type", "application/x-www-form-urlencoded") .header("User-Agent", "SisAppointmentJavaWeb/1.0") .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)) .build(); HttpResponse response = httpClient.send( request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); HttpCall call = HttpCall.from(response); boolean ok = call.statusCode() >= 200 && call.statusCode() <= 299; if (!ok) { ProxyResponse proxyResponse = new ProxyResponse( false, call.statusCode(), call.statusText(), "POST", config.tokenUrl(), 1, call.body(), requestInfo(config, "POST", config.tokenUrl(), false), call.responseHeaders(), null, "Khong lay duoc access token."); return new TokenResult(false, null, proxyResponse); } TokenJsonParser.ParsedToken parsed; try { parsed = TokenJsonParser.parse(call.body()); } catch (RuntimeException ex) { ProxyResponse proxyResponse = new ProxyResponse( false, call.statusCode(), call.statusText(), "POST", config.tokenUrl(), 1, limit(call.body()), requestInfo(config, "POST", config.tokenUrl(), false), call.responseHeaders(), null, ex.getMessage()); return new TokenResult(false, null, proxyResponse); } long safeLifetime = Math.max(parsed.expiresIn() - 60, 60); CachedToken token = new CachedToken( parsed.accessToken(), parsed.tokenType(), Instant.now().plusSeconds(safeLifetime)); tokenCache.put(key, token); ProxyResponse proxyResponse = new ProxyResponse( true, call.statusCode(), call.statusText(), "POST", config.tokenUrl(), 1, "", requestInfo(config, "POST", config.tokenUrl(), false), call.responseHeaders(), token.expiresAt(), "Lay access token thanh cong."); return new TokenResult(true, token, proxyResponse); } private HttpCall sendGet(ApiConfig config, String url, String accessToken) throws IOException, InterruptedException { HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url)) .timeout(Duration.ofSeconds(60)) .version(HttpClient.Version.HTTP_1_1) .header("__tenant", config.tenant()) .header("Authorization", "Bearer " + accessToken) .header("Accept", "application/json") .header("Cache-Control", "no-cache, no-store, max-age=0") .header("Pragma", "no-cache") .header("User-Agent", "SisAppointmentJavaWeb/1.0") .GET(); String cookie = config.effectiveCookieHeader(); if (!cookie.isBlank()) { builder.header("Cookie", cookie); } HttpResponse response = httpClient.send( builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); return HttpCall.from(response); } private static String requestInfo(ApiConfig config, String method, String url, boolean includeCookie) { StringBuilder info = new StringBuilder() .append(method).append(' ').append(url).append('\n') .append("__tenant: ").append(config.tenant()).append('\n') .append("Authorization: Bearer [da an]").append('\n'); if (method.equals("POST")) { info.append("Content-Type: application/x-www-form-urlencoded\n"); } else { info.append("Content-Type: --\n"); } if (includeCookie) { info.append("Cookie: ").append(maskCookie(config.effectiveCookieHeader())); } return info.toString(); } private static String maskCookie(String cookie) { if (cookie == null || cookie.isBlank()) { return "--"; } String[] parts = cookie.split(";"); for (int i = 0; i < parts.length; i++) { String part = parts[i].trim(); int equals = part.indexOf('='); if (equals > 0 && part.substring(0, equals).trim().equalsIgnoreCase("affinity")) { parts[i] = " affinity=[da an]"; } } return String.join(";", parts).trim(); } private static String formEncode(Map values) { StringBuilder result = new StringBuilder(); for (Map.Entry entry : values.entrySet()) { if (!result.isEmpty()) { result.append('&'); } result.append(java.net.URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)) .append('=') .append(java.net.URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)); } return result.toString(); } private static String cacheKey(ApiConfig config) { return Hashes.sha256(config.cacheKeyMaterial()); } private static ProxyResponse validationError(String message) { return new ProxyResponse(false, 400, "Bad Request", "-", "-", 0, "", "", "", null, message); } private static ProxyResponse errorResponse(Exception ex) { String message = ex.getMessage() == null || ex.getMessage().isBlank() ? ex.getClass().getSimpleName() : ex.getMessage(); return new ProxyResponse(false, 500, "Internal Server Error", "-", "-", 0, "", "", "", null, message); } private static String limit(String value) { if (value == null || value.length() <= 4000) { return value == null ? "" : value; } return value.substring(0, 4000) + "..."; } private record TokenResult(boolean success, CachedToken token, ProxyResponse proxyResponse) { } private record HttpCall(int statusCode, String statusText, String body, String responseHeaders) { static HttpCall from(HttpResponse response) { StringBuilder headers = new StringBuilder(); response.headers().map().forEach((name, values) -> headers.append(name).append(": ").append(String.join(", ", values)).append('\n')); return new HttpCall( response.statusCode(), statusText(response.statusCode()), Optional.ofNullable(response.body()).orElse(""), headers.toString().trim()); } private static String statusText(int code) { return switch (code) { case 200 -> "OK"; case 201 -> "Created"; case 204 -> "No Content"; case 400 -> "Bad Request"; case 401 -> "Unauthorized"; case 403 -> "Forbidden"; case 404 -> "Not Found"; case 405 -> "Method Not Allowed"; case 408 -> "Request Timeout"; case 429 -> "Too Many Requests"; case 500 -> "Internal Server Error"; case 502 -> "Bad Gateway"; case 503 -> "Service Unavailable"; case 504 -> "Gateway Timeout"; default -> "HTTP " + code; }; } } }