using Best.HTTP; using Best.HTTP.Response; using Best.HTTP.Shared.PlatformSupport.Memory; using Cysharp.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; using System.IO; using System.Text; using UnityEngine; using UVC.Json; using UVC.Log; namespace UVC.Network { /// /// HTTP 요청을 처리하기 위한 유틸리티 클래스 /// /// /// 이 클래스는 REST API 호출을 위한 메소드들을 제공합니다. /// GET, POST 요청 및 파일 다운로드를 지원하며 요청/응답 로깅 기능도 포함합니다. /// 요청은 비동기(UniTask)로 처리됩니다. /// /// 기본 사용법: /// /// // 도메인 설정 /// HttpRequester.Domain = "https://api.example.com"; /// /// // GET 요청 예제 /// var products = await HttpRequester.RequestGet>("/api/products"); /// /// // POST 요청 예제 /// var loginData = new Dictionary /// { /// { "username", "user@example.com" }, /// { "password", "password123" } /// }; /// var response = await HttpRequester.RequestPost("/api/login", loginData); /// /// public class HttpRequester { /// /// 기본 도메인 주소입니다. /// public static string Domain { get; set; } = ""; /// /// Dictionary 형태의 본문을 가진 사용자 정의 HTTP 메소드로 API 요청을 수행합니다. /// /// 응답 데이터를 변환할 타입 /// 요청할 URL (http가 포함되지 않은 경우 Domain과 결합) /// HTTP 메소드 (get, post, put 등) /// 요청 본문으로 전송할 Dictionary 데이터 (null 가능) /// 추가할 헤더 정보 (null 가능) /// 인증 토큰 사용 여부 /// 지정된 타입으로 변환된 응답 데이터 /// /// /// // PUT 요청 예제 /// public async UniTask UpdateProductAsync(int productId, string name, float price) /// { /// var body = new Dictionary /// { /// { "Id", productId }, /// { "name", name }, /// { "price", price } /// }; /// /// return await HttpRequester.Request("/api/products", "put", body); /// } /// /// public static async UniTask Request(string url, string method, Dictionary body = null, Dictionary header = null, bool useAuth = false) { return await Request_(url, body == null ? null : JsonHelper.ToJson(body), method, header, useAuth); } /// /// 문자열 형태의 본문을 가진 사용자 정의 HTTP 메소드로 API 요청을 수행합니다. /// /// 응답 데이터를 변환할 타입 /// 요청할 URL (http가 포함되지 않은 경우 Domain과 결합) /// HTTP 메소드 (get, post, put 등) /// 요청 본문으로 전송할 JSON 문자열 (null 가능) /// 추가할 헤더 정보 (null 가능) /// 인증 토큰 사용 여부 /// 지정된 타입으로 변환된 응답 데이터 /// /// /// // DELETE 요청 예제 /// public async UniTask DeleteResourceAsync(string resourceId) /// { /// string jsonBody = $"{{\"Id\": \"{resourceId}\"}}"; /// /// return await HttpRequester.Request("/api/resources", "delete", jsonBody); /// } /// /// public static async UniTask Request(string url, string method, string body = null, Dictionary header = null, bool useAuth = false) { return await Request_(url, body, method, header, useAuth); } /// /// Dictionary를 JSON body로 변환하여 POST 요청을 수행합니다. /// /// 응답 데이터를 변환할 타입 /// 요청할 URL (http가 포함되지 않은 경우 Domain과 결합) /// 요청 본문으로 전송할 Dictionary 데이터 (null 가능) /// 추가할 헤더 정보 (null 가능) /// 인증 토큰 사용 여부 /// 지정된 타입으로 변환된 응답 데이터 /// /// /// // 사용자 로그인 요청 예제 /// public async UniTask LoginAsync(string username, string password) /// { /// var body = new Dictionary /// { /// { "username", username }, /// { "password", password } /// }; /// /// return await HttpRequester.RequestPost("/api/login", body); /// } /// /// // LoginResponse 클래스 예제 /// [Serializable] /// public class LoginResponse /// { /// public string token; /// public UserInfo userInfo; /// } /// /// public static async UniTask RequestPost(string url, Dictionary body = null, Dictionary header = null, bool useAuth = false) { return await Request_(url, body == null ? null : JsonHelper.ToJson(body), "post", header, useAuth); } /// /// 문자열 형태의 JSON을 body로 사용하여 POST 요청을 수행합니다. /// /// 응답 데이터를 변환할 타입 /// 요청할 URL (http가 포함되지 않은 경우 Domain과 결합) /// 요청 본문으로 전송할 JSON 문자열 /// 추가할 헤더 정보 (null 가능) /// 인증 토큰 사용 여부 /// 지정된 타입으로 변환된 응답 데이터 /// /// /// // 사용자 정의 JSON 문자열로 요청하는 예제 /// public async UniTask UpdateProductAsync(int productId, string productName) /// { /// string jsonBody = $"{{\"Id\": {productId}, \"name\": \"{productName}\"}}"; /// /// return await HttpRequester.RequestPost("/api/products/update", jsonBody); /// } /// /// public static async UniTask RequestPost(string url, string body, Dictionary header = null, bool useAuth = false) { return await Request_(url, body, "post", header, useAuth); } /// /// GET 방식으로 API를 호출합니다. /// /// 응답 데이터를 변환할 타입 /// 요청할 URL (http가 포함되지 않은 경우 Domain과 결합) /// 요청에 포함할 파라미터 (URL 쿼리스트링으로 변환) /// 추가할 헤더 정보 (null 가능) /// 인증 토큰 사용 여부 /// 지정된 타입으로 변환된 응답 데이터 /// /// /// // 상품 목록을 가져오는 예제 /// public async UniTask> GetProductsAsync(int page = 1, int pageSize = 20) /// { /// var queryParams = new Dictionary /// { /// { "page", page }, /// { "pageSize", pageSize } /// }; /// /// return await HttpRequester.RequestGet>("/api/products", queryParams); /// } /// /// // Product 클래스 예제 /// [Serializable] /// public class Product /// { /// public int Id; /// public string name; /// public float price; /// } /// /// public static async UniTask RequestGet(string url, Dictionary body = null, Dictionary header = null, bool useAuth = false) { return await Request_(url, body == null ? null : JsonHelper.ToJson(body), "get", header, useAuth); } /// /// HTTP 요청을 처리하는 내부 메소드 /// /// /// 이 메소드는 모든 HTTP 요청의 공통 로직을 처리합니다: /// - URL 구성 (Domain과 상대 경로 결합) /// - HTTP 메소드 설정 /// - 헤더 설정 /// - 요청 로깅 /// - 응답 처리 및 역직렬화 /// /// 응답 데이터를 변환할 타입 /// 요청할 URL /// 요청 본문 (JSON 문자열) /// HTTP 메소드 문자열 /// 추가할 헤더 정보 /// 인증 토큰 사용 여부 /// 지정된 타입으로 변환된 응답 데이터 private static async UniTask Request_(string url, string body = null, string methodString = "post", Dictionary header = null, bool useAuth = false) { HTTPMethods method = StringToMethod(methodString); if (!url.Contains("http")) url = $"{Domain}{url}"; var request = SelectHTTPRequest(method, url); request.DownloadSettings = new Best.HTTP.Request.Settings.DownloadSettings() { ContentStreamMaxBuffered = 1024 * 1024 * 200 }; request.MethodType = method; request.SetHeader("Content-Type", "application/json; charset=utf-8"); //if (useAuth) request.SetHeader("access-token", AuthService.Instance.Entiti.accessToken); JObject headerObject = new JObject(); headerObject.Add("Content-Type", "application/json; charset=utf-8"); if (header != null) { foreach (var kvp in header) { request.SetHeader(kvp.Key, kvp.Value); headerObject.Add(kvp.Key, kvp.Value); } } if (body != null) { request.UploadSettings.UploadStream = new MemoryStream(Encoding.UTF8.GetBytes(body)); } #if !UNITY_WEBGL || UNITY_EDITOR HttpLogEntry log = ServerLog.LogHttpRequest(url, methodString, headerObject.ToString(Formatting.None), body, DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")); //Debug.Log($"Request APIToken :{AuthService.Instance.Entiti.accessToken}"); bool isMainThread = PlayerLoopHelper.IsMainThread; #endif //var response = await request.GetFromJsonResultAsync(); var response = await request.GetAsStringAsync(); #if !UNITY_WEBGL || UNITY_EDITOR if (!isMainThread) await UniTask.SwitchToThreadPool(); log.ResponseData = response; log.ResponseDate = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); ServerLog.LogHttpResponse(log); #endif //T가 string이면 if (typeof(T) == typeof(string)) { return (T)(object)response; } else { return JsonHelper.FromJson(response); } } /// /// Dictionary를 JSON body로 변환하여 POST 요청을 수행하고 처리 시간도 함께 반환합니다. /// /// 응답 데이터를 변환할 타입 /// 요청할 URL (http가 포함되지 않은 경우 Domain과 결합) /// 요청 본문으로 전송할 Dictionary 데이터 (null 가능) /// 추가할 헤더 정보 (null 가능) /// 인증 토큰 사용 여부 /// 지정된 타입으로 변환된 응답 데이터와 요청 처리 시간 /// /// /// // 서버 응답 시간을 측정하는 예제 /// public async UniTask<(UserData, TimeSpan)> GetUserDataWithPerformanceCheck(int userId) /// { /// var body = new Dictionary /// { /// { "userId", userId } /// }; /// /// var (userData, duration) = await HttpRequester.RequestPostWithDuration("/api/users/details", body); /// /// Debug.Log($"API 응답 시간: {duration.TotalMilliseconds}ms"); /// return (userData, duration); /// } /// /// public static async UniTask<(T, TimeSpan)> RequestPostWithDuration(string url, Dictionary body = null, Dictionary header = null, bool useAuth = true) { return await RequestWithDuration(url, body == null ? null : JsonHelper.ToJson(body), "post", header, useAuth); } /// /// 문자열 형태의 JSON을 body로 사용하여 POST 요청을 수행하고 처리 시간도 함께 반환합니다. /// /// 응답 데이터를 변환할 타입 /// 요청할 URL (http가 포함되지 않은 경우 Domain과 결합) /// 요청 본문으로 전송할 JSON 문자열 /// 추가할 헤더 정보 (null 가능) /// 인증 토큰 사용 여부 /// 지정된 타입으로 변환된 응답 데이터와 요청 처리 시간 /// /// /// // 커스텀 JSON으로 요청하고 응답 시간을 측정하는 예제 /// public async UniTask<(APIResponse, TimeSpan)> SendComplexDataWithTiming(string jsonData) /// { /// // jsonData는 이미 직렬화된 JSON 문자열 /// /// var (response, duration) = await HttpRequester.RequestPostWithDuration("/api/complex-operation", jsonData); /// /// if (duration.TotalSeconds > 1.0) /// { /// Debug.LogWarning($"API 응답이 느립니다: {duration.TotalSeconds}초"); /// } /// /// return (response, duration); /// } /// /// public static async UniTask<(T, TimeSpan)> RequestPostWithDuration(string url, string body, Dictionary header = null, bool useAuth = true) { return await RequestWithDuration(url, body, "post", header, useAuth); } /// /// GET 방식으로 API를 호출하고 처리 시간도 함께 반환합니다. /// /// 응답 데이터를 변환할 타입 /// 요청할 URL (http가 포함되지 않은 경우 Domain과 결합) /// 요청에 포함할 파라미터 (URL 쿼리스트링으로 변환) /// 추가할 헤더 정보 (null 가능) /// 인증 토큰 사용 여부 /// 지정된 타입으로 변환된 응답 데이터와 요청 처리 시간 /// /// /// // 서버 상태 확인 및 응답 시간 측정 예제 /// public async UniTask<(ServerStatus, TimeSpan)> CheckServerStatus() /// { /// var (status, duration) = await HttpRequester.RequestGetWithDuration("/api/server/status"); /// /// // 응답 시간에 따른 서버 상태 평가 /// if (duration.TotalMilliseconds < 100) /// { /// Debug.Log("서버 응답 상태: 매우 좋음"); /// } /// else if (duration.TotalMilliseconds < 500) /// { /// Debug.Log("서버 응답 상태: 양호"); /// } /// else /// { /// Debug.Log("서버 응답 상태: 지연됨"); /// } /// /// return (status, duration); /// } /// /// public static async UniTask<(T, TimeSpan)> RequestGetWithDuration(string url, Dictionary body = null, Dictionary header = null, bool useAuth = true) { return await RequestWithDuration(url, body == null ? null : JsonHelper.ToJson(body), "get", header, useAuth); } /// /// HTTP 요청을 처리하고 처리 시간을 측정하는 내부 메소드 /// /// /// 이 메소드는 모든 성능 측정이 필요한 HTTP 요청의 공통 로직을 처리합니다: /// - 요청 처리 시간 측정 /// - URL 구성 /// - HTTP 메소드 설정 /// - 헤더 설정 /// - 요청 로깅 /// - 응답 처리 및 역직렬화 /// /// 응답 데이터를 변환할 타입 /// 요청할 URL /// 요청 본문 (JSON 문자열) /// HTTP 메소드 문자열 /// 추가할 헤더 정보 /// 인증 토큰 사용 여부 /// 지정된 타입으로 변환된 응답 데이터와 요청 처리 시간의 튜플 private static async UniTask<(T, TimeSpan)> RequestWithDuration(string url, string body = null, string methodString = "", Dictionary header = null, bool useAuth = true) { HTTPMethods method = StringToMethod(methodString); if (!url.Contains("http")) url = $"{Domain}{url}"; var request = SelectHTTPRequest(method, url); request.DownloadSettings = new Best.HTTP.Request.Settings.DownloadSettings() { ContentStreamMaxBuffered = 1024 * 1024 * 200 }; request.MethodType = method; request.SetHeader("Content-Type", "application/json; charset=utf-8"); JObject headerObject = new JObject(); headerObject.Add("Content-Type", "application/json; charset=utf-8"); if (header != null) { foreach (var kvp in header) { request.SetHeader(kvp.Key, kvp.Value); headerObject.Add(kvp.Key, kvp.Value); } } //ULog.Debug($"RequestWithDuration APIToken :{AuthService.Instance.Entiti.accessToken}"); //if (useAuth) request.SetHeader("access-token", AuthService.Instance.Entiti.accessToken); if (body != null) { request.UploadSettings.UploadStream = new MemoryStream(Encoding.UTF8.GetBytes(body)); } HttpLogEntry log = ServerLog.LogHttpRequest(url, methodString, headerObject.ToString(Formatting.None), body, DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")); //var response = await request.GetFromJsonResultAsync(); var now = DateTime.UtcNow; bool isMainThread = PlayerLoopHelper.IsMainThread; var response = await request.GetAsStringAsync(); #if !UNITY_WEBGL || UNITY_EDITOR if (!isMainThread) await UniTask.SwitchToThreadPool(); #endif var diff = DateTime.UtcNow - now; log.ResponseData = response; log.ResponseDate = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); ServerLog.LogHttpResponse(log); if (typeof(T) == typeof(string)) { return ((T)(object)response, diff); } else { return (JsonHelper.FromJson(response), diff); } } /// /// 원격 서버에서 파일을 다운로드합니다. /// /// 다운로드할 파일의 URL /// 파일을 저장할 로컬 경로 /// 다운로드 완료시 호출될 콜백 /// 다운로드 진행 상태 업데이트 콜백 (progress: 다운로드된 바이트 수, length: 전체 파일 크기) /// 오류 발생시 호출될 콜백 /// 발행된 HTTP 요청 객체 (파일이 이미 존재하는 경우 null) /// /// 지정된 경로에 파일이 이미 존재하는 경우 다운로드를 수행하지 않고 바로 OnComplete 콜백을 호출합니다. /// /// /// /// // 이미지 파일 다운로드 예제 /// public void DownloadImage(string imageUrl, string fileName) /// { /// string savePath = Path.Combine(Application.persistentDataPath, fileName); /// /// HttpRequester.Download( /// imageUrl, /// savePath, /// () => { /// Debug.Log($"이미지가 성공적으로 다운로드되었습니다: {savePath}"); /// // 다운로드된 이미지 사용 코드... /// }, /// (progress, length) => { /// float percentage = (float)progress / length * 100f; /// Debug.Log($"다운로드 진행률: {percentage:F1}%"); /// }, /// (error) => { /// Debug.LogError($"다운로드 실패: {error}"); /// } /// ); /// } /// /// public static HTTPRequest Download(string url, string savePath, Action OnComplete, Action OnProgress = null, Action OnError = null) { ULog.Debug($"Download {url}"); if (File.Exists(savePath)) { OnComplete.Invoke(); return null; } OnRequestFinishedDelegate onRequest = (HTTPRequest req, HTTPResponse resp) => { req.DownloadSettings.OnDownloadStarted = null; req.DownloadSettings.OnDownloadProgress = null; req.DownloadSettings.DownloadStreamFactory = null; switch (req.State) { case HTTPRequestStates.Finished: if (resp.IsSuccess) { #if UNITY_WEBGL && !UNITY_EDITOR // WebGL: 백그라운드 쓰레드 불가 -> 완료 시 메모리 데이터로 파일 저장 try { System.IO.File.WriteAllBytes(savePath, resp.Data); OnComplete?.Invoke(); } catch (Exception ex) { ULog.Error($"Failed to write file on WebGL: {ex.Message}", ex); OnError?.Invoke($"Failed to write file: {ex.Message}"); } #else //스트리밍 파일 기록은 OnDownloadStarted에서 처리됨 OnComplete.Invoke(); #endif } else { ULog.Warning($"Server error({resp.StatusCode} - {resp.Message})! "); OnError?.Invoke($"Server error({resp.StatusCode} - {resp.Message})!"); } break; default: string detailedError = req.Exception != null ? req.Exception.ToString() : (resp != null ? resp.Message : "Unknown error"); string errorMsgDefault = $"Request failed! State: {req.State}, URL: {req.CurrentUri}, Error: {detailedError}"; ULog.Error(errorMsgDefault, req.Exception); //ULog.Error(req.State.ToString(), req.Exception != null ? req.Exception: new Exception(resp?.Message)); OnError?.Invoke(errorMsgDefault); break; } }; var request = SelectHTTPRequest(HTTPMethods.Get, url, onRequest); #if UNITY_WEBGL && !UNITY_EDITOR // WebGL: 쓰레드 기반 스트리밍 미사용. 진행률 콜백만 연결. request.DownloadSettings.OnDownloadProgress += (HTTPRequest req, long progress, long length) => { ULog.Debug($"Download Progress! progress:{progress} length:{length}"); OnProgress?.Invoke(progress, length); }; // DownloadStreamFactory/BlockingDownloadContentStream 사용 금지 #else request.DownloadSettings.OnDownloadStarted += async (HTTPRequest req, HTTPResponse resp, DownloadContentStream stream) => { ULog.Debug("Download Started"); long len = await UniTask.RunOnThreadPool(() => ConsumeDownloadStream(savePath, stream as BlockingDownloadContentStream)); if (len == 0) OnError?.Invoke($"Download Fail!"); ULog.Debug($"Download finished!"); }; request.DownloadSettings.OnDownloadProgress += (HTTPRequest req, long progress, long length) => { ULog.Debug($"Download Progress! progress:{progress} length:{length}"); OnProgress?.Invoke(progress, length); }; request.DownloadSettings.DownloadStreamFactory = (req, resp, bufferAvailableHandler) => new BlockingDownloadContentStream(resp, req.DownloadSettings.ContentStreamMaxBuffered, bufferAvailableHandler); #endif return request.Send(); } /// /// 다운로드 스트림을 처리하여 파일로 저장합니다. /// /// /// 이 메소드는 스트림 데이터를 지정된 파일로 저장하며, 다운로드가 완료되거나 중단될 때까지 블로킹 방식으로 데이터를 처리합니다. /// 다운로드에 실패한 경우 (길이가 0인 경우) 빈 파일은 삭제됩니다. /// /// 다운로드한 데이터를 저장할 파일 경로 /// 다운로드 콘텐츠 스트림 /// 저장된 파일의 크기(바이트) /// /// /// // 내부적으로 Download 메소드에서 사용됩니다 /// long fileSize = await UniTask.RunOnThreadPool(() => /// ConsumeDownloadStream(savePath, downloadStream as BlockingDownloadContentStream)); /// /// private static long ConsumeDownloadStream(string savePath, BlockingDownloadContentStream blockingStream) { long len = 0; using (FileStream fs = new FileStream(savePath, System.IO.FileMode.Create)) { try { //ULog.Debug($"ConsumeDownloadStream blockingStream:{blockingStream == null}"); while (blockingStream != null && !blockingStream.IsCompleted) { if (blockingStream.TryTake(out var buffer)) { try { //ULog.Debug($"ConsumeDownloadStream buffer.Data:{buffer.Data.Length}, Offset:{buffer.Offset}, Count:{buffer.Count}"); fs.Write(buffer.Data, buffer.Offset, buffer.Count); } finally { BufferPool.Release(buffer); } } } } finally { if (blockingStream != null) blockingStream.Dispose(); len = fs.Length; } } if (len == 0) File.Delete(savePath); return len; } /// /// HTTP 메소드와 URL을 기반으로 적절한 HTTPRequest 객체를 생성합니다. /// /// 요청에 사용할 HTTP 메소드 /// 요청할 URL /// 요청 완료 시 호출될 콜백 (선택 사항) /// 생성된 HTTPRequest 객체 /// /// /// // 내부 사용 예제 (직접 사용하지 않음) /// HTTPRequest request = SelectHTTPRequest(HTTPMethods.Get, "https://api.example.com/data"); /// request.SetHeader("Custom-Header", "HeaderValue"); /// request.Send(); /// /// private static HTTPRequest SelectHTTPRequest(HTTPMethods methods, string url, OnRequestFinishedDelegate onRequest = null) { switch (methods) { case HTTPMethods.Get: if (onRequest != null) return HTTPRequest.CreateGet(url, onRequest); else return HTTPRequest.CreateGet(url); case HTTPMethods.Post: return HTTPRequest.CreatePost(url); } return null; } /// /// 문자열 형태의 HTTP 메소드를 Best.HTTP.HTTPMethods 열거형으로 변환합니다. /// /// 변환할 HTTP 메소드 문자열 ("get", "post" 등) /// 해당하는 HTTPMethods 열거형 값 /// 지원하지 않는 HTTP 메소드인 경우 발생 private static HTTPMethods StringToMethod(string method) { return method.ToLower() switch { "get" => HTTPMethods.Get, "post" => HTTPMethods.Post, _ => throw new ArgumentException($"Unsupported HTTP method: {method}"), }; } } }