feat: implement doctor schedule module, add detail fields to doctor profile, and integrate new appointment API client
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
package vn.sis.appointment;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public record ApiConfig(
|
||||
String baseUrl,
|
||||
String tenant,
|
||||
String clientId,
|
||||
String clientSecret,
|
||||
String scope,
|
||||
String cookieHeader) {
|
||||
|
||||
public static ApiConfig fromForm(Map<String, String> form) {
|
||||
return new ApiConfig(
|
||||
value(form, "baseUrl", "https://dhs.sisvietnam.vn"),
|
||||
value(form, "tenant", "3a0f747c-57b6-753b-ba21-3987e8e93b9a"),
|
||||
value(form, "clientId", "DigitalHealthSolutions_LabConn"),
|
||||
value(form, "clientSecret", ""),
|
||||
value(form, "scope", "LabConn"),
|
||||
value(form, "cookie", ""));
|
||||
}
|
||||
|
||||
public ApiConfig {
|
||||
baseUrl = trimTrailingSlash(Objects.requireNonNullElse(baseUrl, ""));
|
||||
tenant = Objects.requireNonNullElse(tenant, "").trim();
|
||||
clientId = Objects.requireNonNullElse(clientId, "").trim();
|
||||
clientSecret = Objects.requireNonNullElse(clientSecret, "");
|
||||
scope = Objects.requireNonNullElse(scope, "").trim();
|
||||
cookieHeader = Objects.requireNonNullElse(cookieHeader, "").trim();
|
||||
}
|
||||
|
||||
public void validate() {
|
||||
URI uri;
|
||||
try {
|
||||
uri = URI.create(baseUrl);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new IllegalArgumentException("Base URL khong hop le.", ex);
|
||||
}
|
||||
|
||||
String scheme = uri.getScheme();
|
||||
if (scheme == null || !(scheme.equalsIgnoreCase("https") || scheme.equalsIgnoreCase("http"))) {
|
||||
throw new IllegalArgumentException("Base URL phai bat dau bang http:// hoac https://.");
|
||||
}
|
||||
if (tenant.isBlank()) {
|
||||
throw new IllegalArgumentException("Tenant khong duoc de trong.");
|
||||
}
|
||||
if (clientId.isBlank()) {
|
||||
throw new IllegalArgumentException("Client ID khong duoc de trong.");
|
||||
}
|
||||
if (clientSecret.isBlank()) {
|
||||
throw new IllegalArgumentException("Client Secret khong duoc de trong.");
|
||||
}
|
||||
if (scope.isBlank()) {
|
||||
throw new IllegalArgumentException("Scope khong duoc de trong.");
|
||||
}
|
||||
}
|
||||
|
||||
public String tokenUrl() {
|
||||
return baseUrl + "/connect/token";
|
||||
}
|
||||
|
||||
public String doctorsUrl() {
|
||||
return baseUrl + "/api/app/his/doctors/work-schedules";
|
||||
}
|
||||
|
||||
public String scheduleUrl(String doctorCode) {
|
||||
return baseUrl + "/api/app/his/doctors/" + Urls.encodePathSegment(doctorCode) + "/work-schedule-days";
|
||||
}
|
||||
|
||||
public String effectiveCookieHeader() {
|
||||
if (!cookieHeader.isBlank()) {
|
||||
return cookieHeader;
|
||||
}
|
||||
return ".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=" + tenant;
|
||||
}
|
||||
|
||||
public String cacheKeyMaterial() {
|
||||
return String.join("|", baseUrl, tenant, clientId, clientSecret, scope);
|
||||
}
|
||||
|
||||
private static String value(Map<String, String> form, String key, String defaultValue) {
|
||||
String value = form.get(key);
|
||||
return value == null ? defaultValue : value;
|
||||
}
|
||||
|
||||
private static String trimTrailingSlash(String value) {
|
||||
String result = value.trim();
|
||||
while (result.endsWith("/")) {
|
||||
result = result.substring(0, result.length() - 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,28 @@
|
||||
package vn.sis.appointment;
|
||||
|
||||
public final class Application {
|
||||
private Application() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
int port = resolvePort();
|
||||
AppointmentWebServer server = new AppointmentWebServer(port);
|
||||
server.start();
|
||||
|
||||
System.out.printf("S.I.S Appointment Java Web dang chay tai http://localhost:%d%n", port);
|
||||
System.out.println("Nhan Ctrl+C de dung ung dung.");
|
||||
}
|
||||
|
||||
private static int resolvePort() {
|
||||
String value = System.getenv().getOrDefault("PORT", "8080").trim();
|
||||
try {
|
||||
int port = Integer.parseInt(value);
|
||||
if (port < 1 || port > 65535) {
|
||||
throw new IllegalArgumentException("PORT phai nam trong khoang 1-65535.");
|
||||
}
|
||||
return port;
|
||||
} catch (NumberFormatException ex) {
|
||||
throw new IllegalArgumentException("Bien moi truong PORT khong hop le: " + value, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,155 @@
|
||||
package vn.sis.appointment;
|
||||
|
||||
import com.sun.net.httpserver.Headers;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
final class AppointmentWebServer {
|
||||
private final HttpServer server;
|
||||
private final SisApiClient apiClient = new SisApiClient();
|
||||
|
||||
AppointmentWebServer(int port) throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress("0.0.0.0", port), 0);
|
||||
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
|
||||
|
||||
server.createContext("/api/health", exchange -> {
|
||||
if (!requireMethod(exchange, "GET")) {
|
||||
return;
|
||||
}
|
||||
sendJson(exchange, 200, Json.stringify(Map.of(
|
||||
"ok", true,
|
||||
"application", "SisAppointmentJavaWeb",
|
||||
"javaVersion", System.getProperty("java.version"))));
|
||||
});
|
||||
|
||||
server.createContext("/api/token", postHandler((form, config) -> apiClient.checkToken(config)));
|
||||
server.createContext("/api/doctors", postHandler((form, config) -> apiClient.getDoctors(config)));
|
||||
server.createContext("/api/schedule", postHandler((form, config) ->
|
||||
apiClient.getSchedule(config, form.getOrDefault("doctorCode", ""))));
|
||||
server.createContext("/api/clear-token", postHandler((form, config) -> {
|
||||
apiClient.clearToken(config);
|
||||
return new ProxyResponse(true, 200, "OK", "-", "-", 0,
|
||||
"", "", "", null, "Da xoa access token trong bo nho may chu.");
|
||||
}));
|
||||
|
||||
server.createContext("/", new StaticHandler());
|
||||
}
|
||||
|
||||
void start() {
|
||||
server.start();
|
||||
}
|
||||
|
||||
private HttpHandler postHandler(ApiOperation operation) {
|
||||
return exchange -> {
|
||||
if (!requireMethod(exchange, "POST")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Map<String, String> form = FormData.read(exchange);
|
||||
ApiConfig config = ApiConfig.fromForm(form);
|
||||
ProxyResponse result = operation.execute(form, config);
|
||||
sendJson(exchange, 200, result.toJson());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
ProxyResponse error = new ProxyResponse(false, 400, "Bad Request", "-", "-", 0,
|
||||
"", "", "", null, ex.getMessage());
|
||||
sendJson(exchange, 400, error.toJson());
|
||||
} catch (Exception ex) {
|
||||
ProxyResponse error = new ProxyResponse(false, 500, "Internal Server Error", "-", "-", 0,
|
||||
"", "", "", null,
|
||||
ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage());
|
||||
sendJson(exchange, 500, error.toJson());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean requireMethod(HttpExchange exchange, String expected) throws IOException {
|
||||
if (exchange.getRequestMethod().equalsIgnoreCase(expected)) {
|
||||
return true;
|
||||
}
|
||||
exchange.getResponseHeaders().set("Allow", expected);
|
||||
sendJson(exchange, 405, Json.stringify(Map.of(
|
||||
"ok", false,
|
||||
"message", "Phuong thuc khong duoc ho tro.")));
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void sendJson(HttpExchange exchange, int status, String json) throws IOException {
|
||||
byte[] data = json.getBytes(StandardCharsets.UTF_8);
|
||||
applySecurityHeaders(exchange.getResponseHeaders());
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||
exchange.sendResponseHeaders(status, data.length);
|
||||
exchange.getResponseBody().write(data);
|
||||
exchange.close();
|
||||
}
|
||||
|
||||
private static void applySecurityHeaders(Headers headers) {
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
headers.set("X-Frame-Options", "DENY");
|
||||
headers.set("Referrer-Policy", "no-referrer");
|
||||
headers.set("Content-Security-Policy",
|
||||
"default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; connect-src 'self'");
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ApiOperation {
|
||||
ProxyResponse execute(Map<String, String> form, ApiConfig config);
|
||||
}
|
||||
|
||||
private static final class StaticHandler implements HttpHandler {
|
||||
@Override
|
||||
public void handle(HttpExchange exchange) throws IOException {
|
||||
if (!exchange.getRequestMethod().equalsIgnoreCase("GET")) {
|
||||
exchange.getResponseHeaders().set("Allow", "GET");
|
||||
exchange.sendResponseHeaders(405, -1);
|
||||
exchange.close();
|
||||
return;
|
||||
}
|
||||
|
||||
String path = exchange.getRequestURI().getPath();
|
||||
if (path.equals("/")) {
|
||||
path = "/index.html";
|
||||
}
|
||||
if (path.contains("..")) {
|
||||
exchange.sendResponseHeaders(400, -1);
|
||||
exchange.close();
|
||||
return;
|
||||
}
|
||||
|
||||
String resource = "web" + path;
|
||||
try (InputStream input = AppointmentWebServer.class.getClassLoader().getResourceAsStream(resource)) {
|
||||
if (input == null) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
exchange.close();
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] data = input.readAllBytes();
|
||||
Headers headers = exchange.getResponseHeaders();
|
||||
applySecurityHeaders(headers);
|
||||
headers.set("Content-Type", contentType(path));
|
||||
headers.set("Cache-Control", path.endsWith("index.html") ? "no-cache" : "public, max-age=3600");
|
||||
exchange.sendResponseHeaders(200, data.length);
|
||||
exchange.getResponseBody().write(data);
|
||||
exchange.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static String contentType(String path) {
|
||||
if (path.endsWith(".html")) return "text/html; charset=utf-8";
|
||||
if (path.endsWith(".css")) return "text/css; charset=utf-8";
|
||||
if (path.endsWith(".js")) return "application/javascript; charset=utf-8";
|
||||
if (path.endsWith(".svg")) return "image/svg+xml";
|
||||
if (path.endsWith(".png")) return "image/png";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,10 @@
|
||||
package vn.sis.appointment;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
record CachedToken(String accessToken, String tokenType, Instant expiresAt) {
|
||||
boolean isValid() {
|
||||
return accessToken != null && !accessToken.isBlank()
|
||||
&& Instant.now().isBefore(expiresAt.minusSeconds(15));
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,41 @@
|
||||
package vn.sis.appointment;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
final class FormData {
|
||||
private static final int MAX_REQUEST_BYTES = 256 * 1024;
|
||||
|
||||
private FormData() {
|
||||
}
|
||||
|
||||
static Map<String, String> read(HttpExchange exchange) throws IOException {
|
||||
byte[] data = exchange.getRequestBody().readNBytes(MAX_REQUEST_BYTES + 1);
|
||||
if (data.length > MAX_REQUEST_BYTES) {
|
||||
throw new IllegalArgumentException("Du lieu gui len vuot qua gioi han 256 KB.");
|
||||
}
|
||||
|
||||
String body = new String(data, StandardCharsets.UTF_8);
|
||||
Map<String, String> result = new LinkedHashMap<>();
|
||||
if (body.isBlank()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (String pair : body.split("&")) {
|
||||
int separator = pair.indexOf('=');
|
||||
String rawKey = separator >= 0 ? pair.substring(0, separator) : pair;
|
||||
String rawValue = separator >= 0 ? pair.substring(separator + 1) : "";
|
||||
result.put(decode(rawKey), decode(rawValue));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String decode(String value) {
|
||||
return URLDecoder.decode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,21 @@
|
||||
package vn.sis.appointment;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
|
||||
final class Hashes {
|
||||
private Hashes() {
|
||||
}
|
||||
|
||||
static String sha256(String value) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(digest);
|
||||
} catch (NoSuchAlgorithmException ex) {
|
||||
throw new IllegalStateException("JDK khong ho tro SHA-256.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,72 @@
|
||||
package vn.sis.appointment;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
|
||||
final class Json {
|
||||
private Json() {
|
||||
}
|
||||
|
||||
static String stringify(Object value) {
|
||||
if (value == null) {
|
||||
return "null";
|
||||
}
|
||||
if (value instanceof String text) {
|
||||
return quote(text);
|
||||
}
|
||||
if (value instanceof Number || value instanceof Boolean) {
|
||||
return value.toString();
|
||||
}
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
StringBuilder builder = new StringBuilder("{");
|
||||
boolean first = true;
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
if (!first) {
|
||||
builder.append(',');
|
||||
}
|
||||
first = false;
|
||||
builder.append(quote(String.valueOf(entry.getKey())))
|
||||
.append(':')
|
||||
.append(stringify(entry.getValue()));
|
||||
}
|
||||
return builder.append('}').toString();
|
||||
}
|
||||
if (value instanceof Collection<?> collection) {
|
||||
StringBuilder builder = new StringBuilder("[");
|
||||
boolean first = true;
|
||||
for (Object item : collection) {
|
||||
if (!first) {
|
||||
builder.append(',');
|
||||
}
|
||||
first = false;
|
||||
builder.append(stringify(item));
|
||||
}
|
||||
return builder.append(']').toString();
|
||||
}
|
||||
return quote(value.toString());
|
||||
}
|
||||
|
||||
static String quote(String value) {
|
||||
StringBuilder builder = new StringBuilder(value.length() + 16).append('"');
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char ch = value.charAt(i);
|
||||
switch (ch) {
|
||||
case '"' -> builder.append("\\\"");
|
||||
case '\\' -> builder.append("\\\\");
|
||||
case '\b' -> builder.append("\\b");
|
||||
case '\f' -> builder.append("\\f");
|
||||
case '\n' -> builder.append("\\n");
|
||||
case '\r' -> builder.append("\\r");
|
||||
case '\t' -> builder.append("\\t");
|
||||
default -> {
|
||||
if (ch < 0x20) {
|
||||
builder.append(String.format("\\u%04x", (int) ch));
|
||||
} else {
|
||||
builder.append(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return builder.append('"').toString();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,35 @@
|
||||
package vn.sis.appointment;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
record ProxyResponse(
|
||||
boolean ok,
|
||||
int httpStatus,
|
||||
String statusText,
|
||||
String method,
|
||||
String url,
|
||||
int attempts,
|
||||
String body,
|
||||
String requestInfo,
|
||||
String responseHeaders,
|
||||
Instant tokenExpiresAt,
|
||||
String message) {
|
||||
|
||||
String toJson() {
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
values.put("ok", ok);
|
||||
values.put("httpStatus", httpStatus);
|
||||
values.put("statusText", statusText);
|
||||
values.put("method", method);
|
||||
values.put("url", url);
|
||||
values.put("attempts", attempts);
|
||||
values.put("body", body == null ? "" : body);
|
||||
values.put("requestInfo", requestInfo == null ? "" : requestInfo);
|
||||
values.put("responseHeaders", responseHeaders == null ? "" : responseHeaders);
|
||||
values.put("tokenExpiresAt", tokenExpiresAt == null ? null : tokenExpiresAt.toString());
|
||||
values.put("message", message == null ? "" : message);
|
||||
return Json.stringify(values);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,347 @@
|
||||
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<String, CachedToken> 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<String> 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<String> 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<String, String> values) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (Map.Entry<String, String> 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<String> 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,35 @@
|
||||
package vn.sis.appointment;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
final class TokenJsonParser {
|
||||
private static final Pattern ACCESS_TOKEN = Pattern.compile(
|
||||
"\\\"access_token\\\"\\s*:\\s*\\\"([^\\\"]+)\\\"");
|
||||
private static final Pattern TOKEN_TYPE = Pattern.compile(
|
||||
"\\\"token_type\\\"\\s*:\\s*\\\"([^\\\"]*)\\\"");
|
||||
private static final Pattern EXPIRES_IN = Pattern.compile(
|
||||
"\\\"expires_in\\\"\\s*:\\s*(\\d+)");
|
||||
|
||||
private TokenJsonParser() {
|
||||
}
|
||||
|
||||
static ParsedToken parse(String json) {
|
||||
String accessToken = group(ACCESS_TOKEN, json);
|
||||
if (accessToken == null || accessToken.isBlank()) {
|
||||
throw new IllegalArgumentException("API token khong tra ve access_token hop le.");
|
||||
}
|
||||
String tokenType = group(TOKEN_TYPE, json);
|
||||
String expiresText = group(EXPIRES_IN, json);
|
||||
long expiresIn = expiresText == null ? 3600 : Long.parseLong(expiresText);
|
||||
return new ParsedToken(accessToken, tokenType == null ? "Bearer" : tokenType, expiresIn);
|
||||
}
|
||||
|
||||
private static String group(Pattern pattern, String text) {
|
||||
Matcher matcher = pattern.matcher(text == null ? "" : text);
|
||||
return matcher.find() ? matcher.group(1) : null;
|
||||
}
|
||||
|
||||
record ParsedToken(String accessToken, String tokenType, long expiresIn) {
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
package vn.sis.appointment;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
final class Urls {
|
||||
private Urls() {
|
||||
}
|
||||
|
||||
static String encodePathSegment(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user