사내 API 공용 호출 서비스(ApiService) 추가 + 문서
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
d11f428045
commit
e788684bd9
2
dist/README.txt
vendored
2
dist/README.txt
vendored
@ -1,5 +1,5 @@
|
||||
UbisamBase 플랫폼 배포 폴더 (빌드할 때 자동 생성 - 직접 고치지 마세요)
|
||||
갱신 시각 : 2026-09-21 22:04:42
|
||||
갱신 시각 : 2026-09-21 22:06:55
|
||||
빌드 구성 : Release
|
||||
원본 소스 : D:\업무\03_개발\Source\용접기\08_UbisamPlatform\
|
||||
----
|
||||
|
||||
BIN
dist/UbisamBase.Core.dll
vendored
BIN
dist/UbisamBase.Core.dll
vendored
Binary file not shown.
BIN
dist/UbisamBase.Launcher.exe
vendored
BIN
dist/UbisamBase.Launcher.exe
vendored
Binary file not shown.
BIN
dist/UbisamBase.Shell.exe
vendored
BIN
dist/UbisamBase.Shell.exe
vendored
Binary file not shown.
2
dist/platform-version.json
vendored
2
dist/platform-version.json
vendored
@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2026-09-21 22:04:42",
|
||||
"version": "2026-09-21 22:06:55",
|
||||
"configuration": "Release"
|
||||
}
|
||||
|
||||
@ -13,3 +13,5 @@
|
||||
| 2026-09-21 | `ICloseGuard` 추가 — 저장 안 한 변경 확인을 셸이 단일 통로로 처리(하단 "종료"도 이 경로) (건의 4) |
|
||||
| 2026-09-21 | 플랫폼 스크롤바 색 조정(트랙=테마 판 색, 막대=accent) |
|
||||
| 2026-09-21 | 플랫폼 자동 업데이트 추가 — Launcher가 켤 때 깃 저장소의 `dist`와 비교해 새 버전이면 받아서 실행(UI Setting에서 사용/자동 여부 선택) |
|
||||
| 2026-09-21 | 업데이트 안내창을 플랫폼 디자인으로 교체(Launcher 자체 XAML, 시스템 테마 따름) |
|
||||
| 2026-09-21 | `ApiService` 추가 — 사내 API를 앱이 대신 호출(토큰은 설정 파일에만, 호출 간격 제한·타임아웃 포함) |
|
||||
|
||||
@ -686,3 +686,54 @@ Launcher(`PlatformUpdater.Settings`)가 같은 이름으로 읽고 쓴다. **이
|
||||
- 업데이트는 **덮어쓰기**다. 쓰지 않게 된 옛 파일까지 정리하려면 `publish-platform.ps1`로 `bin`을
|
||||
비우고 다시 채운 뒤 `dist`를 올리면 된다.
|
||||
|
||||
---
|
||||
|
||||
## 13. 사내 API 호출 — `UbisamBase.Core.Net.ApiService`
|
||||
|
||||
토큰이 필요한 사내 API를 **앱이 대신 호출해** 응답 본문(JSON 문자열)만 돌려준다. 화면(WebView2 등)이
|
||||
직접 부르면 토큰이 그 문서에 섞여 들어가므로, 앱이 부르고 값만 넘기는 구조를 공용으로 둔 것이다.
|
||||
브라우저에서 막히는 CORS도 이 경로로는 문제가 되지 않는다.
|
||||
|
||||
```csharp
|
||||
using UbisamBase.Core.Net;
|
||||
|
||||
var json = await ApiService.Current.FetchAsync(url); // 실패하면 예외
|
||||
var (ok, body, error) = await ApiService.Current.TryFetchAsync(url); // 예외 대신 결과로 받기
|
||||
```
|
||||
|
||||
- 인증 헤더는 서비스 안에서 붙는다 — **부르는 쪽은 토큰을 몰라도 된다.**
|
||||
- 같은 주소를 `MinIntervalMs`(기본 1초) 안에 다시 부르면 서버를 때리지 않고 직전 응답을 돌려준다.
|
||||
화면 갱신 주기가 짧아도 호출 제한에 걸리지 않게 하기 위한 것이다.
|
||||
- 로그에는 주소의 `?` 앞부분만 남긴다(쿼리에 토큰이 섞인 경우 대비). 응답 본문도 남기지 않는다.
|
||||
|
||||
### 13-1. 설정 — `D:\UbisamConfig\Settings\ApiSettings.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"AuthMode": "BearerHeader",
|
||||
"HeaderName": "X-Api-Key",
|
||||
"Token": "...",
|
||||
"MinIntervalMs": 1000,
|
||||
"TimeoutSeconds": 10
|
||||
}
|
||||
```
|
||||
|
||||
| AuthMode | 붙는 헤더 |
|
||||
| --- | --- |
|
||||
| `None` | 없음(사내망 IP 제한 등) |
|
||||
| `BearerHeader` | `Authorization: Bearer {Token}` |
|
||||
| `CustomHeader` | `{HeaderName}: {Token}` |
|
||||
| `Cookie` | `Cookie: {Token}` |
|
||||
|
||||
**토큰은 이 파일에만 있다.** 내보낸 HTML·`.ub` 같은 산출물에는 들어가지 않는다.
|
||||
|
||||
### 13-2. 내보낸 파일에서는 인증 API를 못 부른다
|
||||
|
||||
앱 밖으로 내보낸 HTML은 혼자 도는 파일이라 이 서비스가 없다. 그래서:
|
||||
|
||||
- **앱 안에서 볼 때** → `ApiService`로 최신 값
|
||||
- **내보낸 파일을 남에게 줄 때** → 인증이 필요한 API는 호출 불가, 내보낼 때 담긴 마지막 값이 보인다
|
||||
(인증이 없는 공개 API라면 내보낸 파일에서도 실시간으로 돈다)
|
||||
|
||||
이는 제약이라기보다 의도된 동작이다 — 토큰이 파일에 들어가지 않기 때문이다.
|
||||
|
||||
|
||||
229
src/UbisamBase.Core/Net/ApiService.cs
Normal file
229
src/UbisamBase.Core/Net/ApiService.cs
Normal file
@ -0,0 +1,229 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using UbisamBase.Core.Bootstrap;
|
||||
using UbisamBase.Core.Logging;
|
||||
|
||||
namespace UbisamBase.Core.Net;
|
||||
|
||||
/// <summary>인증을 어떻게 붙이는지.</summary>
|
||||
public enum ApiAuthMode
|
||||
{
|
||||
/// <summary>인증 없음(사내망 IP 제한 등). 주소만으로 부른다.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Authorization: Bearer {토큰}</summary>
|
||||
BearerHeader,
|
||||
|
||||
/// <summary>{HeaderName}: {토큰} — 사내 API가 고유 헤더를 쓰는 경우.</summary>
|
||||
CustomHeader,
|
||||
|
||||
/// <summary>Cookie: {토큰} — 세션 쿠키를 그대로 넘기는 경우.</summary>
|
||||
Cookie
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사내 API를 대신 호출해 응답 본문(JSON 문자열)만 돌려주는 공용 서비스.
|
||||
///
|
||||
/// <para><b>왜 플랫폼에 있나</b> — 토큰이 필요한 API는 화면(WebView2 등)에서 직접 부르면 토큰이 그
|
||||
/// 문서에 섞여 들어간다. 앱이 대신 부르고 <b>숫자만</b> 넘기면 토큰은 앱 설정 파일에만 남는다.
|
||||
/// 내보낸 HTML·.ub 같은 파일에는 절대 들어가지 않는다. 브라우저에서 막히는 CORS도 우회된다.</para>
|
||||
///
|
||||
/// <para>토큰은 <c>D:\UbisamConfig\Settings\ApiSettings.json</c>에만 저장되고, 로그에도 남기지 않는다.</para>
|
||||
/// </summary>
|
||||
public sealed class ApiService
|
||||
{
|
||||
private static readonly Logger Log = new("Api");
|
||||
private static readonly HttpClient Http = new();
|
||||
private static ApiService? current;
|
||||
|
||||
private readonly Dictionary<string, CacheEntry> cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static ApiService Current => current ??= new ApiService();
|
||||
|
||||
public ApiSettings Settings { get; private set; } = ApiSettings.Load();
|
||||
|
||||
/// <summary>설정 파일을 다시 읽는다(설정 화면에서 토큰을 바꾼 뒤 등).</summary>
|
||||
public void ReloadSettings() => Settings = ApiSettings.Load();
|
||||
|
||||
/// <summary>
|
||||
/// 주소를 호출해 응답 본문을 그대로 돌려준다. 인증 헤더는 설정에 따라 이 안에서 붙으므로
|
||||
/// 부르는 쪽(모듈·편집기)은 토큰을 몰라도 된다.
|
||||
///
|
||||
/// 같은 주소를 <see cref="ApiSettings.MinIntervalMs"/> 안에 다시 부르면 서버를 때리지 않고
|
||||
/// 직전 응답을 돌려준다 — 화면 갱신 주기가 짧아도 호출 제한에 걸리지 않게 하기 위한 것이다.
|
||||
/// 실패하면 예외를 던진다(메시지는 사용자에게 보여줘도 되는 내용만 담는다).
|
||||
/// </summary>
|
||||
public async Task<string> FetchAsync(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
throw new ArgumentException("주소가 비어 있습니다.", nameof(url));
|
||||
}
|
||||
|
||||
if (TryGetCached(url, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
ApplyAuth(request);
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(Math.Max(1, Settings.TimeoutSeconds)));
|
||||
using var response = await Http.SendAsync(request, cts.Token).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
// 본문에 토큰이 되비칠 수 있으므로 상태 코드만 남긴다.
|
||||
Log.W($"API 호출 실패 ({(int)response.StatusCode}) : {Safe(url)}");
|
||||
throw new InvalidOperationException($"서버가 {(int)response.StatusCode} 응답을 돌려줬습니다.");
|
||||
}
|
||||
|
||||
lock (cache)
|
||||
{
|
||||
cache[url] = new CacheEntry(body, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Log.W($"API 호출 시간 초과 : {Safe(url)}");
|
||||
throw new InvalidOperationException($"{Settings.TimeoutSeconds}초 안에 응답이 오지 않았습니다.");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
Log.W($"API 호출 실패 : {Safe(url)} — {ex.Message}");
|
||||
throw new InvalidOperationException("서버에 연결하지 못했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>예외 대신 성공 여부로 받고 싶을 때. 실패하면 <paramref name="error"/>에 사유가 담긴다.</summary>
|
||||
public async Task<(bool Ok, string Body, string Error)> TryFetchAsync(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (true, await FetchAsync(url).ConfigureAwait(false), string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, string.Empty, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetCached(string url, out string body)
|
||||
{
|
||||
lock (cache)
|
||||
{
|
||||
if (cache.TryGetValue(url, out var entry) &&
|
||||
(DateTime.UtcNow - entry.At).TotalMilliseconds < Math.Max(0, Settings.MinIntervalMs))
|
||||
{
|
||||
body = entry.Body;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
body = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ApplyAuth(HttpRequestMessage request)
|
||||
{
|
||||
var token = Settings.Token;
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (Settings.AuthMode)
|
||||
{
|
||||
case ApiAuthMode.BearerHeader:
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + token);
|
||||
break;
|
||||
|
||||
case ApiAuthMode.CustomHeader:
|
||||
var name = string.IsNullOrWhiteSpace(Settings.HeaderName) ? "X-Api-Key" : Settings.HeaderName;
|
||||
request.Headers.TryAddWithoutValidation(name, token);
|
||||
break;
|
||||
|
||||
case ApiAuthMode.Cookie:
|
||||
request.Headers.TryAddWithoutValidation("Cookie", token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>로그에 남길 주소 — 쿼리에 토큰이 섞여 있을 수 있으므로 물음표 앞까지만 남긴다.</summary>
|
||||
private static string Safe(string url)
|
||||
{
|
||||
var index = url.IndexOf('?');
|
||||
return index < 0 ? url : url.Substring(0, index) + "?...";
|
||||
}
|
||||
|
||||
private readonly struct CacheEntry
|
||||
{
|
||||
public CacheEntry(string body, DateTime at)
|
||||
{
|
||||
Body = body;
|
||||
At = at;
|
||||
}
|
||||
|
||||
public string Body { get; }
|
||||
|
||||
public DateTime At { get; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>ApiService 설정. 토큰이 들어 있으므로 앱 설정 폴더에만 저장한다.</summary>
|
||||
public sealed class ApiSettings
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
public static string FilePath => Path.Combine(UbisamAppBuilder.ConfigRoot, "Settings", "ApiSettings.json");
|
||||
|
||||
public ApiAuthMode AuthMode { get; set; } = ApiAuthMode.None;
|
||||
|
||||
/// <summary>AuthMode가 CustomHeader일 때 쓸 헤더 이름.</summary>
|
||||
public string HeaderName { get; set; } = "X-Api-Key";
|
||||
|
||||
/// <summary>토큰/키/쿠키 값. 이 파일 밖으로 나가지 않는다.</summary>
|
||||
public string Token { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>같은 주소를 이 시간 안에 다시 부르면 서버 대신 직전 응답을 쓴다(호출 제한 대비).</summary>
|
||||
public int MinIntervalMs { get; set; } = 1000;
|
||||
|
||||
public int TimeoutSeconds { get; set; } = 10;
|
||||
|
||||
public static ApiSettings Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(FilePath))
|
||||
{
|
||||
return JsonSerializer.Deserialize<ApiSettings>(File.ReadAllText(FilePath), JsonOptions) ?? new ApiSettings();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 손상된 파일은 무시하고 기본값(인증 없음)으로 간다.
|
||||
}
|
||||
|
||||
return new ApiSettings();
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
var dir = Path.GetDirectoryName(FilePath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, JsonOptions));
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.IO.Compression.FileSystem" />
|
||||
<!-- 사내 API 호출(ApiService)용 — net48에서는 명시적으로 참조해야 HttpClient를 쓸 수 있다. -->
|
||||
<Reference Include="System.Net.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user