V13-FE-011: finalize search list layout slice
This commit is contained in:
+8
@@ -0,0 +1,8 @@
|
||||
// generated from contracts/providers/kbx.providers.json; do not edit.
|
||||
namespace Kbx.Shared.Providers.Generated;
|
||||
public sealed record KbxExternalProviderDefinition(string Id,string Title,string OwnerModule,string Purpose,bool MutationAllowed,IReadOnlyList<string> OfficialSources);
|
||||
public static class KbxExternalProviderCatalog { public const string SourceSha256="5515e5e002767ed895ea0442003829b0bad8fa9c7a0693ce6cdcbd65969b830f"; public static readonly IReadOnlyDictionary<string,KbxExternalProviderDefinition> All=new Dictionary<string,KbxExternalProviderDefinition>(StringComparer.Ordinal) {
|
||||
["provider.krx.openapi"] = new("provider.krx.openapi", "KRX Data Marketplace OPEN API", "COMMON", "market-data-readonly", false, new[] { "https://openapi.krx.co.kr/contents/OPP/INFO/OPPINFO003.jsp", "https://openapi.krx.co.kr/contents/OPP/INFO/service/OPPINFO004.cmd" }),
|
||||
["provider.opendart"] = new("provider.opendart", "금융감독원 OPENDART OpenAPI", "COMMON", "disclosure-data-readonly", false, new[] { "https://opendart.fss.or.kr/guide/detail.do?apiGrpCd=DS001&apiId=2019001", "https://opendart.fss.or.kr/guide/detail.do?apiGrpCd=DS001&apiId=2019002", "https://opendart.fss.or.kr/guide/detail.do?apiGrpCd=DS001&apiId=2019018" }),
|
||||
["provider.kis.market-data"] = new("provider.kis.market-data", "한국투자증권 KIS Open API — 국내주식 시세", "COMMON", "market-data-readonly", false, new[] { "https://apiportal.koreainvestment.com/apiservice-apiservice", "https://apiportal.koreainvestment.com/community/10000000-0000-0011-0000-000000000001/post/d0d1a83f-6f8d-4437-9700-6d26702fd989", "https://github.com/koreainvestment/open-trading-api/blob/main/examples_llm/domestic_stock/inquire_price/inquire_price.py", "https://github.com/koreainvestment/open-trading-api/blob/main/examples_llm/kis_auth.py" })
|
||||
}; }
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
namespace Kbx.Shared.Providers;
|
||||
|
||||
public enum KbxProviderResultKind { Success, NoData, TransientFailure, PermanentFailure }
|
||||
|
||||
public sealed record KbxProviderResult<T>(KbxProviderResultKind Kind,T? Value=null,string? Code=null,string? Message=null,int? HttpStatus=null)
|
||||
{
|
||||
public bool IsSuccess => Kind is KbxProviderResultKind.Success or KbxProviderResultKind.NoData;
|
||||
}
|
||||
|
||||
public interface IKbxProviderSecretStore
|
||||
{
|
||||
ValueTask<string> GetRequiredAsync(string configurationKey, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IKbxExternalProviderAdapter
|
||||
{
|
||||
string ProviderId { get; }
|
||||
}
|
||||
|
||||
public sealed record KrxApprovedServiceRequest(HttpMethod Method, Uri ServiceUri, IReadOnlyDictionary<string,string?> Query, string? Body = null);
|
||||
public sealed record KisCurrentPriceRequest(string MarketDivisionCode,string StockCode,bool Sandbox=false);
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Kbx.Shared.Providers;
|
||||
|
||||
public static class KbxProviderRegistration
|
||||
{
|
||||
public static IServiceCollection AddKbxExternalProviders(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<KisRequestPacer>();
|
||||
services.AddSingleton<IKisAccessTokenProvider, KisAccessTokenProvider>();
|
||||
services.AddHttpClient<KrxOpenApiAdapter>();
|
||||
services.AddHttpClient<OpenDartAdapter>();
|
||||
services.AddHttpClient<KisAccessTokenProvider>();
|
||||
services.AddHttpClient<KisMarketDataAdapter>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Kbx.Shared.Providers;
|
||||
|
||||
public interface IKisAccessTokenProvider { ValueTask<string> GetAsync(bool sandbox, CancellationToken cancellationToken); }
|
||||
|
||||
public sealed class KisAccessTokenProvider(HttpClient http, IKbxProviderSecretStore secrets) : IKisAccessTokenProvider
|
||||
{
|
||||
private readonly SemaphoreSlim _gate = new(1,1);
|
||||
private string? _token;
|
||||
private DateTimeOffset _expiresAt;
|
||||
private bool _sandbox;
|
||||
|
||||
public async ValueTask<string> GetAsync(bool sandbox, CancellationToken ct)
|
||||
{
|
||||
if (_token is not null && _sandbox == sandbox && DateTimeOffset.UtcNow < _expiresAt.AddMinutes(-5)) return _token;
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_token is not null && _sandbox == sandbox && DateTimeOffset.UtcNow < _expiresAt.AddMinutes(-5)) return _token;
|
||||
var appKey = await secrets.GetRequiredAsync("ExternalProviders:Kis:AppKey", ct);
|
||||
var appSecret = await secrets.GetRequiredAsync("ExternalProviders:Kis:AppSecret", ct);
|
||||
var baseUri = sandbox ? "https://openapivts.koreainvestment.com:29443" : "https://openapi.koreainvestment.com:9443";
|
||||
var payload = JsonSerializer.Serialize(new { grant_type="client_credentials", appkey=appKey, appsecret=appSecret });
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, baseUri + "/oauth2/tokenP") { Content = new StringContent(payload, Encoding.UTF8, "application/json") };
|
||||
using var res = await http.SendAsync(req, ct);
|
||||
res.EnsureSuccessStatusCode();
|
||||
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct));
|
||||
_token = doc.RootElement.GetProperty("access_token").GetString() ?? throw new InvalidOperationException("KIS access_token missing");
|
||||
_sandbox = sandbox;
|
||||
if (doc.RootElement.TryGetProperty("access_token_token_expired", out var exp) && DateTimeOffset.TryParse(exp.GetString(), out var parsed)) _expiresAt = parsed;
|
||||
else if (doc.RootElement.TryGetProperty("expires_in", out var sec) && sec.TryGetInt32(out var seconds)) _expiresAt = DateTimeOffset.UtcNow.AddSeconds(seconds);
|
||||
else _expiresAt = DateTimeOffset.UtcNow.AddHours(24);
|
||||
return _token;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Kbx.Shared.Providers;
|
||||
|
||||
public sealed class KisMarketDataAdapter(HttpClient http, IKbxProviderSecretStore secrets, IKisAccessTokenProvider tokens, KisRequestPacer pacer) : IKbxExternalProviderAdapter
|
||||
{
|
||||
public string ProviderId => "provider.kis.market-data";
|
||||
|
||||
public async Task<KbxProviderResult<JsonDocument>> GetDomesticStockCurrentPriceAsync(KisCurrentPriceRequest input, CancellationToken ct)
|
||||
{
|
||||
await pacer.WaitAsync(input.Sandbox, ct);
|
||||
var appKey = await secrets.GetRequiredAsync("ExternalProviders:Kis:AppKey", ct);
|
||||
var appSecret = await secrets.GetRequiredAsync("ExternalProviders:Kis:AppSecret", ct);
|
||||
var token = await tokens.GetAsync(input.Sandbox, ct);
|
||||
var baseUri = input.Sandbox ? "https://openapivts.koreainvestment.com:29443" : "https://openapi.koreainvestment.com:9443";
|
||||
var uri = baseUri + "/uapi/domestic-stock/v1/quotations/inquire-price" +
|
||||
$"?FID_COND_MRKT_DIV_CODE={Uri.EscapeDataString(input.MarketDivisionCode)}&FID_INPUT_ISCD={Uri.EscapeDataString(input.StockCode)}";
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
req.Headers.TryAddWithoutValidation("authorization", "Bearer " + token);
|
||||
req.Headers.TryAddWithoutValidation("appkey", appKey);
|
||||
req.Headers.TryAddWithoutValidation("appsecret", appSecret);
|
||||
req.Headers.TryAddWithoutValidation("tr_id", "FHKST01010100");
|
||||
req.Headers.TryAddWithoutValidation("custtype", "P");
|
||||
req.Headers.TryAddWithoutValidation("tr_cont", "");
|
||||
using var res = await http.SendAsync(req, ct);
|
||||
var text = await res.Content.ReadAsStringAsync(ct);
|
||||
if (!res.IsSuccessStatusCode)
|
||||
return new((int)res.StatusCode >= 500 || (int)res.StatusCode is 408 or 429 ? KbxProviderResultKind.TransientFailure : KbxProviderResultKind.PermanentFailure, Code:$"HTTP_{(int)res.StatusCode}", Message:"KIS 시세 HTTP 호출 실패", HttpStatus:(int)res.StatusCode);
|
||||
JsonDocument doc;
|
||||
try { doc = JsonDocument.Parse(text); } catch { return new(KbxProviderResultKind.PermanentFailure, Code:"KIS_INVALID_JSON", Message:"KIS 응답 JSON을 해석할 수 없습니다."); }
|
||||
var root=doc.RootElement;
|
||||
var rt=root.TryGetProperty("rt_cd",out var r)?r.GetString():null;
|
||||
if(rt=="0") return new(KbxProviderResultKind.Success,doc);
|
||||
var code=root.TryGetProperty("msg_cd",out var c)?c.GetString():"KIS_PROVIDER_ERROR";
|
||||
var msg=root.TryGetProperty("msg1",out var m)?m.GetString():"KIS 시세 조회 실패";
|
||||
return new(KbxProviderResultKind.PermanentFailure,doc,code,msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Kbx.Shared.Providers;
|
||||
|
||||
public sealed class KisRequestPacer
|
||||
{
|
||||
private readonly SemaphoreSlim _gate = new(1,1);
|
||||
private DateTimeOffset _last = DateTimeOffset.MinValue;
|
||||
public async ValueTask WaitAsync(bool sandbox, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var spacing = TimeSpan.FromMilliseconds(sandbox ? 1000 : 100); // KBX conservative default; official production max is higher.
|
||||
var remaining = spacing - (DateTimeOffset.UtcNow - _last);
|
||||
if (remaining > TimeSpan.Zero) await Task.Delay(remaining, ct);
|
||||
_last = DateTimeOffset.UtcNow;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace Kbx.Shared.Providers;
|
||||
|
||||
public sealed class KrxOpenApiAdapter(HttpClient http, IKbxProviderSecretStore secrets) : IKbxExternalProviderAdapter
|
||||
{
|
||||
public string ProviderId => "provider.krx.openapi";
|
||||
|
||||
public async Task<KbxProviderResult<string>> InvokeApprovedServiceAsync(KrxApprovedServiceRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!request.ServiceUri.IsAbsoluteUri || request.ServiceUri.Scheme != Uri.UriSchemeHttps || !request.ServiceUri.Host.EndsWith(".krx.co.kr", StringComparison.OrdinalIgnoreCase))
|
||||
return new(KbxProviderResultKind.PermanentFailure, Code:"KRX_SERVICE_URL_NOT_APPROVED", Message:"KRX 승인 서비스의 HTTPS URL만 사용할 수 있습니다.");
|
||||
if (request.Method != HttpMethod.Get && request.Method != HttpMethod.Post)
|
||||
return new(KbxProviderResultKind.PermanentFailure, Code:"KRX_METHOD_NOT_ALLOWED", Message:"조회성 GET/POST만 허용됩니다.");
|
||||
|
||||
var key = await secrets.GetRequiredAsync("ExternalProviders:Krx:AuthKey", ct);
|
||||
var uri = AppendQuery(request.ServiceUri, request.Query);
|
||||
using var message = new HttpRequestMessage(request.Method, uri);
|
||||
message.Headers.TryAddWithoutValidation("AUTH_KEY", key);
|
||||
if (request.Method == HttpMethod.Post && request.Body is not null)
|
||||
message.Content = new StringContent(request.Body, System.Text.Encoding.UTF8, "application/json");
|
||||
using var response = await http.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
if (response.IsSuccessStatusCode) return new(KbxProviderResultKind.Success, body, HttpStatus:(int)response.StatusCode);
|
||||
var transient = response.StatusCode is HttpStatusCode.RequestTimeout or (HttpStatusCode)429 || (int)response.StatusCode >= 500;
|
||||
return new(transient ? KbxProviderResultKind.TransientFailure : KbxProviderResultKind.PermanentFailure, Code:$"HTTP_{(int)response.StatusCode}", Message:"KRX OPEN API 호출이 실패했습니다.", HttpStatus:(int)response.StatusCode);
|
||||
}
|
||||
|
||||
private static Uri AppendQuery(Uri uri, IReadOnlyDictionary<string,string?> query)
|
||||
{
|
||||
if (query.Count == 0) return uri;
|
||||
var parts = query.Where(x => x.Value is not null).Select(x => $"{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value!)}");
|
||||
var builder = new UriBuilder(uri) { Query = string.Join("&", parts) };
|
||||
return builder.Uri;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Kbx.Shared.Providers;
|
||||
|
||||
public sealed class OpenDartAdapter(HttpClient http, IKbxProviderSecretStore secrets) : IKbxExternalProviderAdapter
|
||||
{
|
||||
private static readonly Uri BaseUri = new("https://opendart.fss.or.kr/api/");
|
||||
public string ProviderId => "provider.opendart";
|
||||
|
||||
public Task<KbxProviderResult<JsonDocument>> GetDisclosuresAsync(IReadOnlyDictionary<string,string?> query, CancellationToken ct)
|
||||
=> GetJsonAsync("list.json", query, ct);
|
||||
public Task<KbxProviderResult<JsonDocument>> GetCompanyAsync(string corpCode, CancellationToken ct)
|
||||
=> GetJsonAsync("company.json", new Dictionary<string,string?> { ["corp_code"] = corpCode }, ct);
|
||||
|
||||
public async Task<KbxProviderResult<byte[]>> DownloadCorpCodeAsync(CancellationToken ct)
|
||||
{
|
||||
var key = await secrets.GetRequiredAsync("ExternalProviders:OpenDart:ApiKey", ct);
|
||||
var uri = new Uri(BaseUri, $"corpCode.xml?crtfc_key={Uri.EscapeDataString(key)}");
|
||||
using var response = await http.GetAsync(uri, ct);
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync(ct);
|
||||
if (response.IsSuccessStatusCode) return new(KbxProviderResultKind.Success, bytes, HttpStatus:(int)response.StatusCode);
|
||||
return new((int)response.StatusCode >= 500 ? KbxProviderResultKind.TransientFailure : KbxProviderResultKind.PermanentFailure, Code:$"HTTP_{(int)response.StatusCode}", Message:"OPENDART 고유번호 파일 호출 실패", HttpStatus:(int)response.StatusCode);
|
||||
}
|
||||
|
||||
private async Task<KbxProviderResult<JsonDocument>> GetJsonAsync(string path, IReadOnlyDictionary<string,string?> query, CancellationToken ct)
|
||||
{
|
||||
var key = await secrets.GetRequiredAsync("ExternalProviders:OpenDart:ApiKey", ct);
|
||||
var pairs = new List<string> { $"crtfc_key={Uri.EscapeDataString(key)}" };
|
||||
pairs.AddRange(query.Where(x=>x.Value is not null).Select(x=>$"{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value!)}"));
|
||||
var uri = new Uri(BaseUri, path + "?" + string.Join("&", pairs));
|
||||
using var response = await http.GetAsync(uri, ct);
|
||||
var text = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return new((int)response.StatusCode >= 500 || (int)response.StatusCode == 429 ? KbxProviderResultKind.TransientFailure : KbxProviderResultKind.PermanentFailure, Code:$"HTTP_{(int)response.StatusCode}", Message:"OPENDART HTTP 호출 실패", HttpStatus:(int)response.StatusCode);
|
||||
JsonDocument doc;
|
||||
try { doc = JsonDocument.Parse(text); } catch { return new(KbxProviderResultKind.PermanentFailure, Code:"DART_INVALID_JSON", Message:"OPENDART 응답 JSON을 해석할 수 없습니다."); }
|
||||
var root = doc.RootElement;
|
||||
var status = root.TryGetProperty("status", out var s) ? s.GetString() : null;
|
||||
var message = root.TryGetProperty("message", out var m) ? m.GetString() : null;
|
||||
return status switch
|
||||
{
|
||||
"000" or null => new(KbxProviderResultKind.Success, doc),
|
||||
"013" => new(KbxProviderResultKind.NoData, doc, status, message),
|
||||
"020" or "800" or "900" => new(KbxProviderResultKind.TransientFailure, doc, status, message),
|
||||
_ => new(KbxProviderResultKind.PermanentFailure, doc, status, message)
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user