미국장 일봉 import가 국내 KIS chart endpoint에 묶여 있으면 NASDAQ/NYSE 요청을 정상화된 USD bar로 저장할 수 없다. provider capability로 venue/timeframe 지원 여부를 먼저 검증하고, KIS overseas dailyprice 응답을 venue timezone과 USD 통화 기준으로 normalize하도록 경로를 분리한다.
649 lines
19 KiB
Go
649 lines
19 KiB
Go
package kis
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.toki-labs.com/toki/alt/packages/domain/market"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer"
|
|
)
|
|
|
|
const (
|
|
RealBaseURL = "https://openapi.koreainvestment.com:9443"
|
|
PaperBaseURL = "https://openapivts.koreainvestment.com:29443"
|
|
|
|
tokenPath = "/oauth2/tokenP"
|
|
|
|
DefaultUserAgent = "ALT worker KIS smoke"
|
|
)
|
|
|
|
type Environment string
|
|
|
|
const (
|
|
EnvironmentReal Environment = "real"
|
|
EnvironmentPaper Environment = "paper"
|
|
)
|
|
|
|
type ErrorKind string
|
|
|
|
const (
|
|
ErrorUnavailable ErrorKind = "unavailable"
|
|
ErrorAuth ErrorKind = "auth"
|
|
ErrorQuota ErrorKind = "quota"
|
|
ErrorMalformed ErrorKind = "malformed"
|
|
ErrorProvider ErrorKind = "provider"
|
|
)
|
|
|
|
type Error struct {
|
|
Kind ErrorKind
|
|
Op string
|
|
StatusCode int
|
|
Code string
|
|
Message string
|
|
Err error
|
|
}
|
|
|
|
func (e *Error) Error() string {
|
|
var b strings.Builder
|
|
if e.Op != "" {
|
|
b.WriteString("kis ")
|
|
b.WriteString(e.Op)
|
|
b.WriteString(": ")
|
|
}
|
|
b.WriteString(string(e.Kind))
|
|
if e.StatusCode != 0 {
|
|
fmt.Fprintf(&b, " status=%d", e.StatusCode)
|
|
}
|
|
if e.Code != "" {
|
|
fmt.Fprintf(&b, " code=%s", e.Code)
|
|
}
|
|
if e.Message != "" {
|
|
fmt.Fprintf(&b, " msg=%s", e.Message)
|
|
}
|
|
if e.Err != nil {
|
|
fmt.Fprintf(&b, ": %v", e.Err)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (e *Error) Unwrap() error {
|
|
return e.Err
|
|
}
|
|
|
|
func IsErrorKind(err error, kind ErrorKind) bool {
|
|
var kisErr *Error
|
|
return errors.As(err, &kisErr) && kisErr.Kind == kind
|
|
}
|
|
|
|
type Config struct {
|
|
Environment Environment
|
|
ConfigError string
|
|
BaseURL string
|
|
AppKey string
|
|
AppSecret string
|
|
AccountNo string
|
|
AccountProductCode string
|
|
UserAgent string
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
func ConfigFromEnv() Config {
|
|
env := Environment(strings.TrimSpace(os.Getenv("KIS_ACTIVE_ENV")))
|
|
switch env {
|
|
case EnvironmentReal:
|
|
return Config{
|
|
Environment: EnvironmentReal,
|
|
BaseURL: RealBaseURL,
|
|
AppKey: os.Getenv("KIS_REAL_APP_KEY"),
|
|
AppSecret: os.Getenv("KIS_REAL_APP_SECRET"),
|
|
AccountNo: os.Getenv("KIS_REAL_CANO"),
|
|
AccountProductCode: os.Getenv("KIS_REAL_ACNT_PRDT_CD"),
|
|
}
|
|
case EnvironmentPaper:
|
|
return Config{
|
|
Environment: EnvironmentPaper,
|
|
BaseURL: PaperBaseURL,
|
|
AppKey: os.Getenv("KIS_PAPER_APP_KEY"),
|
|
AppSecret: os.Getenv("KIS_PAPER_APP_SECRET"),
|
|
AccountNo: os.Getenv("KIS_PAPER_CANO"),
|
|
AccountProductCode: os.Getenv("KIS_PAPER_ACNT_PRDT_CD"),
|
|
}
|
|
default:
|
|
return Config{
|
|
Environment: env,
|
|
ConfigError: `KIS_ACTIVE_ENV must be "paper" or "real"`,
|
|
}
|
|
}
|
|
}
|
|
|
|
type Client struct {
|
|
cfg Config
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewClient(cfg Config) *Client {
|
|
if cfg.UserAgent == "" {
|
|
cfg.UserAgent = DefaultUserAgent
|
|
}
|
|
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
|
|
httpClient := cfg.HTTPClient
|
|
if httpClient == nil {
|
|
httpClient = &http.Client{Timeout: 20 * time.Second}
|
|
}
|
|
return &Client{cfg: cfg, httpClient: httpClient}
|
|
}
|
|
|
|
type Token struct {
|
|
AccessToken string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
func (c *Client) Auth(ctx context.Context) (Token, error) {
|
|
if err := c.validateBase("auth"); err != nil {
|
|
return Token{}, err
|
|
}
|
|
|
|
payload := map[string]string{
|
|
"grant_type": "client_credentials",
|
|
"appkey": c.cfg.AppKey,
|
|
"appsecret": c.cfg.AppSecret,
|
|
}
|
|
body, status, err := c.doJSON(ctx, http.MethodPost, tokenPath, nil, payload, nil)
|
|
if err != nil {
|
|
return Token{}, err
|
|
}
|
|
if status != http.StatusOK {
|
|
return Token{}, c.httpStatusError("auth", status, body)
|
|
}
|
|
|
|
var resp struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresAt string `json:"access_token_token_expired"`
|
|
}
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return Token{}, &Error{Kind: ErrorMalformed, Op: "auth", Err: err}
|
|
}
|
|
if resp.AccessToken == "" {
|
|
return Token{}, &Error{Kind: ErrorAuth, Op: "auth", Message: "missing access_token"}
|
|
}
|
|
expiresAt, err := time.ParseInLocation("2006-01-02 15:04:05", resp.ExpiresAt, time.Local)
|
|
if err != nil {
|
|
return Token{}, &Error{Kind: ErrorMalformed, Op: "auth", Message: "invalid token expiry", Err: err}
|
|
}
|
|
return Token{AccessToken: resp.AccessToken, ExpiresAt: expiresAt}, nil
|
|
}
|
|
|
|
type DailyItemChartPriceQuery struct {
|
|
MarketDivCode string
|
|
Symbol string
|
|
From string
|
|
To string
|
|
PeriodDivCode string
|
|
AdjustedPrice string
|
|
}
|
|
|
|
func (q DailyItemChartPriceQuery) withDefaults() DailyItemChartPriceQuery {
|
|
if q.MarketDivCode == "" {
|
|
q.MarketDivCode = "J"
|
|
}
|
|
if q.PeriodDivCode == "" {
|
|
q.PeriodDivCode = "D"
|
|
}
|
|
if q.AdjustedPrice == "" {
|
|
q.AdjustedPrice = "0"
|
|
}
|
|
return q
|
|
}
|
|
|
|
func (q DailyItemChartPriceQuery) validate() error {
|
|
if q.Symbol == "" {
|
|
return &Error{Kind: ErrorMalformed, Op: "daily", Message: "symbol is required"}
|
|
}
|
|
if !isYYYYMMDD(q.From) {
|
|
return &Error{Kind: ErrorMalformed, Op: "daily", Message: "from must be YYYYMMDD"}
|
|
}
|
|
if !isYYYYMMDD(q.To) {
|
|
return &Error{Kind: ErrorMalformed, Op: "daily", Message: "to must be YYYYMMDD"}
|
|
}
|
|
if q.From > q.To {
|
|
return &Error{Kind: ErrorMalformed, Op: "daily", Message: "from must be before or equal to to"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) FetchDailyItemChartPrice(ctx context.Context, query DailyItemChartPriceQuery) (DailyItemChartPriceResponse, error) {
|
|
token, err := c.Auth(ctx)
|
|
if err != nil {
|
|
return DailyItemChartPriceResponse{}, err
|
|
}
|
|
return c.InquireDailyItemChartPrice(ctx, token.AccessToken, query)
|
|
}
|
|
|
|
func (c *Client) InquireDailyItemChartPrice(ctx context.Context, accessToken string, query DailyItemChartPriceQuery) (DailyItemChartPriceResponse, error) {
|
|
if err := c.validateBase("daily"); err != nil {
|
|
return DailyItemChartPriceResponse{}, err
|
|
}
|
|
if accessToken == "" {
|
|
return DailyItemChartPriceResponse{}, &Error{Kind: ErrorAuth, Op: "daily", Message: "missing access token"}
|
|
}
|
|
query = query.withDefaults()
|
|
if err := query.validate(); err != nil {
|
|
return DailyItemChartPriceResponse{}, err
|
|
}
|
|
|
|
headers := map[string]string{
|
|
"authorization": "Bearer " + accessToken,
|
|
"appkey": c.cfg.AppKey,
|
|
"appsecret": c.cfg.AppSecret,
|
|
"tr_id": DailyItemChartPriceTRID,
|
|
"custtype": "P",
|
|
"tr_cont": "",
|
|
}
|
|
params := url.Values{
|
|
"FID_COND_MRKT_DIV_CODE": []string{query.MarketDivCode},
|
|
"FID_INPUT_ISCD": []string{query.Symbol},
|
|
"FID_INPUT_DATE_1": []string{query.From},
|
|
"FID_INPUT_DATE_2": []string{query.To},
|
|
"FID_PERIOD_DIV_CODE": []string{query.PeriodDivCode},
|
|
"FID_ORG_ADJ_PRC": []string{query.AdjustedPrice},
|
|
}
|
|
body, status, err := c.doJSON(ctx, http.MethodGet, DailyItemChartPricePath, headers, nil, params)
|
|
if err != nil {
|
|
return DailyItemChartPriceResponse{}, err
|
|
}
|
|
if status != http.StatusOK {
|
|
return DailyItemChartPriceResponse{}, c.httpStatusError("daily", status, body)
|
|
}
|
|
|
|
var resp DailyItemChartPriceResponse
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return DailyItemChartPriceResponse{}, &Error{Kind: ErrorMalformed, Op: "daily", Err: err}
|
|
}
|
|
if resp.ReturnCode != "0" {
|
|
return DailyItemChartPriceResponse{}, providerPayloadError("daily", resp.MessageCd, resp.Message)
|
|
}
|
|
if len(resp.Output2) == 0 {
|
|
return DailyItemChartPriceResponse{}, &Error{Kind: ErrorMalformed, Op: "daily", Message: "no output2 rows"}
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// OverseasDailyPriceQuery describes a KIS overseas 기간별시세 (dailyprice) request.
|
|
// ExchangeCode is the KIS EXCD value (e.g. NAS, NYS), BaseDate is the BYMD
|
|
// reference date the endpoint walks back from, PeriodCode is GUBN (0=day), and
|
|
// Adjusted is MODP.
|
|
type OverseasDailyPriceQuery struct {
|
|
ExchangeCode string
|
|
Symbol string
|
|
BaseDate string
|
|
PeriodCode string
|
|
Adjusted string
|
|
}
|
|
|
|
func (q OverseasDailyPriceQuery) withDefaults() OverseasDailyPriceQuery {
|
|
if q.PeriodCode == "" {
|
|
q.PeriodCode = "0"
|
|
}
|
|
if q.Adjusted == "" {
|
|
q.Adjusted = "1"
|
|
}
|
|
return q
|
|
}
|
|
|
|
func (q OverseasDailyPriceQuery) validate() error {
|
|
if q.ExchangeCode == "" {
|
|
return &Error{Kind: ErrorMalformed, Op: "overseas-daily", Message: "exchange code is required"}
|
|
}
|
|
if q.Symbol == "" {
|
|
return &Error{Kind: ErrorMalformed, Op: "overseas-daily", Message: "symbol is required"}
|
|
}
|
|
if !isYYYYMMDD(q.BaseDate) {
|
|
return &Error{Kind: ErrorMalformed, Op: "overseas-daily", Message: "base date must be YYYYMMDD"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) FetchOverseasDailyPrice(ctx context.Context, query OverseasDailyPriceQuery) (OverseasDailyPriceResponse, error) {
|
|
token, err := c.Auth(ctx)
|
|
if err != nil {
|
|
return OverseasDailyPriceResponse{}, err
|
|
}
|
|
return c.InquireOverseasDailyPrice(ctx, token.AccessToken, query)
|
|
}
|
|
|
|
func (c *Client) InquireOverseasDailyPrice(ctx context.Context, accessToken string, query OverseasDailyPriceQuery) (OverseasDailyPriceResponse, error) {
|
|
if err := c.validateBase("overseas-daily"); err != nil {
|
|
return OverseasDailyPriceResponse{}, err
|
|
}
|
|
if accessToken == "" {
|
|
return OverseasDailyPriceResponse{}, &Error{Kind: ErrorAuth, Op: "overseas-daily", Message: "missing access token"}
|
|
}
|
|
query = query.withDefaults()
|
|
if err := query.validate(); err != nil {
|
|
return OverseasDailyPriceResponse{}, err
|
|
}
|
|
|
|
headers := map[string]string{
|
|
"authorization": "Bearer " + accessToken,
|
|
"appkey": c.cfg.AppKey,
|
|
"appsecret": c.cfg.AppSecret,
|
|
"tr_id": OverseasDailyPriceTRID,
|
|
"custtype": "P",
|
|
"tr_cont": "",
|
|
}
|
|
params := url.Values{
|
|
"AUTH": []string{""},
|
|
"EXCD": []string{query.ExchangeCode},
|
|
"SYMB": []string{query.Symbol},
|
|
"GUBN": []string{query.PeriodCode},
|
|
"BYMD": []string{query.BaseDate},
|
|
"MODP": []string{query.Adjusted},
|
|
}
|
|
body, status, err := c.doJSON(ctx, http.MethodGet, OverseasDailyPricePath, headers, nil, params)
|
|
if err != nil {
|
|
return OverseasDailyPriceResponse{}, err
|
|
}
|
|
if status != http.StatusOK {
|
|
return OverseasDailyPriceResponse{}, c.httpStatusError("overseas-daily", status, body)
|
|
}
|
|
|
|
var resp OverseasDailyPriceResponse
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return OverseasDailyPriceResponse{}, &Error{Kind: ErrorMalformed, Op: "overseas-daily", Err: err}
|
|
}
|
|
if resp.ReturnCode != "0" {
|
|
return OverseasDailyPriceResponse{}, providerPayloadError("overseas-daily", resp.MessageCd, resp.Message)
|
|
}
|
|
if len(resp.Output2) == 0 {
|
|
return OverseasDailyPriceResponse{}, &Error{Kind: ErrorMalformed, Op: "overseas-daily", Message: "no output2 rows"}
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
type LiveProvider struct {
|
|
client *Client
|
|
}
|
|
|
|
func NewLiveProvider(client *Client) *LiveProvider {
|
|
return &LiveProvider{client: client}
|
|
}
|
|
|
|
func (p *LiveProvider) FetchDailyBars(ctx context.Context, request importer.DailyBarRequest) ([]importer.InstrumentBars, error) {
|
|
if p == nil || p.client == nil {
|
|
return nil, &Error{Kind: ErrorUnavailable, Op: "provider", Message: "KIS client is not configured"}
|
|
}
|
|
if request.Provider != "" && request.Provider != market.ProviderKIS {
|
|
return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: fmt.Sprintf("unsupported provider %q", request.Provider)}
|
|
}
|
|
if len(request.Selector.Symbols) == 0 {
|
|
return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: "selector symbols are required"}
|
|
}
|
|
if request.From.IsZero() || request.To.IsZero() {
|
|
return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: "from/to date range is required"}
|
|
}
|
|
|
|
from := request.From.Format(kisDateLayout)
|
|
to := request.To.Format(kisDateLayout)
|
|
// Route by venue so a US request reaches the overseas endpoint instead of
|
|
// silently falling back to the KR domestic endpoint/normalizer. The provider
|
|
// rejects unsupported venues itself so a direct provider call cannot bypass
|
|
// the capability gate and store mislabeled bars.
|
|
switch request.Selector.Venue {
|
|
case "", market.VenueKRX:
|
|
return p.fetchDomesticDailyBars(ctx, request.Selector.Symbols, from, to)
|
|
case market.VenueNASDAQ, market.VenueNYSE:
|
|
return p.fetchOverseasDailyBars(ctx, request.Selector.Venue, request.Selector.Symbols, from, to)
|
|
default:
|
|
return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: fmt.Sprintf("unsupported venue %q", request.Selector.Venue)}
|
|
}
|
|
}
|
|
|
|
// fetchDomesticDailyBars fetches KR/KRX daily bars through the domestic chart
|
|
// endpoint and the KST normalizer.
|
|
func (p *LiveProvider) fetchDomesticDailyBars(ctx context.Context, symbols []string, from, to string) ([]importer.InstrumentBars, error) {
|
|
items := make([]importer.InstrumentBars, 0, len(symbols))
|
|
for _, symbol := range symbols {
|
|
resp, err := p.client.FetchDailyItemChartPrice(ctx, DailyItemChartPriceQuery{
|
|
Symbol: symbol,
|
|
From: from,
|
|
To: to,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
inst := instrumentFromDailyResponse(resp, symbol)
|
|
bars, err := NormalizeDailyBars(resp, inst)
|
|
if err != nil {
|
|
return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: "normalize daily bars", Err: err}
|
|
}
|
|
items = append(items, importer.InstrumentBars{Instrument: inst, Bars: bars})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// fetchOverseasDailyBars fetches US daily bars through the overseas dailyprice
|
|
// endpoint and the venue-timezone normalizer. The overseas dailyprice base date
|
|
// (BYMD) is the request's To date; the endpoint walks back from it. The overseas
|
|
// payload carries no name or asset type, so the instrument lands with its
|
|
// venue-derived market/currency and the KIS provider symbol only; richer
|
|
// instrument metadata is a later split's concern.
|
|
func (p *LiveProvider) fetchOverseasDailyBars(ctx context.Context, venue market.Venue, symbols []string, fromDate, toDate string) ([]importer.InstrumentBars, error) {
|
|
exchangeCode, err := overseasExchangeCode(venue)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]importer.InstrumentBars, 0, len(symbols))
|
|
for _, symbol := range symbols {
|
|
resp, err := p.client.FetchOverseasDailyPrice(ctx, OverseasDailyPriceQuery{
|
|
ExchangeCode: exchangeCode,
|
|
Symbol: symbol,
|
|
BaseDate: toDate,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
inst, err := overseasInstrument(venue, symbol)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
bars, err := NormalizeOverseasDailyBars(resp, inst)
|
|
if err != nil {
|
|
return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: "normalize overseas daily bars", Err: err}
|
|
}
|
|
|
|
filteredBars := make([]market.Bar, 0, len(bars))
|
|
for _, bar := range bars {
|
|
barDate := bar.Timestamp.Format(kisDateLayout)
|
|
if barDate >= fromDate && barDate <= toDate {
|
|
filteredBars = append(filteredBars, bar)
|
|
}
|
|
}
|
|
|
|
items = append(items, importer.InstrumentBars{Instrument: inst, Bars: filteredBars})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// overseasExchangeCode maps a US venue to the KIS overseas EXCD code.
|
|
func overseasExchangeCode(v market.Venue) (string, error) {
|
|
switch v {
|
|
case market.VenueNASDAQ:
|
|
return "NAS", nil
|
|
case market.VenueNYSE:
|
|
return "NYS", nil
|
|
default:
|
|
return "", &Error{Kind: ErrorMalformed, Op: "provider", Message: fmt.Sprintf("unsupported overseas venue %q", v)}
|
|
}
|
|
}
|
|
|
|
// overseasInstrument builds the US instrument identity for an overseas symbol.
|
|
// Market and currency come from the venue metadata so the instrument cannot
|
|
// inherit KR/KRW defaults.
|
|
func overseasInstrument(venue market.Venue, symbol string) (market.Instrument, error) {
|
|
meta, ok := market.GetVenueMetadata(venue)
|
|
if !ok {
|
|
return market.Instrument{}, &Error{Kind: ErrorMalformed, Op: "provider", Message: fmt.Sprintf("unknown venue %q", venue)}
|
|
}
|
|
return market.Instrument{
|
|
ID: market.InstrumentID(string(venue) + ":" + symbol),
|
|
Market: meta.Market,
|
|
Venue: venue,
|
|
Symbol: symbol,
|
|
Currency: meta.Currency,
|
|
ProviderSymbols: map[string]string{
|
|
string(market.ProviderKIS): symbol,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func instrumentFromDailyResponse(resp DailyItemChartPriceResponse, requestedSymbol string) market.Instrument {
|
|
symbol := resp.Output1.ShortCode
|
|
if symbol == "" {
|
|
symbol = requestedSymbol
|
|
}
|
|
return market.Instrument{
|
|
ID: market.InstrumentID("KRX:" + symbol),
|
|
Market: market.MarketKR,
|
|
Venue: market.VenueKRX,
|
|
Symbol: symbol,
|
|
Name: resp.Output1.Name,
|
|
Currency: market.CurrencyKRW,
|
|
ProviderSymbols: map[string]string{
|
|
string(market.ProviderKIS): symbol,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *Client) validateBase(op string) error {
|
|
if c.cfg.ConfigError != "" {
|
|
return &Error{Kind: ErrorUnavailable, Op: op, Message: c.cfg.ConfigError}
|
|
}
|
|
if c.cfg.BaseURL == "" {
|
|
return &Error{Kind: ErrorUnavailable, Op: op, Message: "base URL is not configured"}
|
|
}
|
|
if c.cfg.AppKey == "" {
|
|
return &Error{Kind: ErrorUnavailable, Op: op, Message: "app key is not configured"}
|
|
}
|
|
if c.cfg.AppSecret == "" {
|
|
return &Error{Kind: ErrorUnavailable, Op: op, Message: "app secret is not configured"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) doJSON(ctx context.Context, method, path string, headers map[string]string, payload map[string]string, params url.Values) ([]byte, int, error) {
|
|
reqURL := c.cfg.BaseURL + path
|
|
if len(params) > 0 {
|
|
reqURL += "?" + params.Encode()
|
|
}
|
|
var body io.Reader
|
|
if payload != nil {
|
|
encoded, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, 0, &Error{Kind: ErrorMalformed, Op: strings.ToLower(method), Err: err}
|
|
}
|
|
body = bytes.NewReader(encoded)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
|
|
if err != nil {
|
|
return nil, 0, &Error{Kind: ErrorMalformed, Op: strings.ToLower(method), Err: err}
|
|
}
|
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", c.cfg.UserAgent)
|
|
for key, value := range headers {
|
|
req.Header.Set(key, value)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, 0, &Error{Kind: ErrorUnavailable, Op: strings.ToLower(method), Err: err}
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return nil, resp.StatusCode, &Error{Kind: ErrorUnavailable, Op: strings.ToLower(method), Err: err}
|
|
}
|
|
return data, resp.StatusCode, nil
|
|
}
|
|
|
|
func (c *Client) httpStatusError(op string, status int, body []byte) error {
|
|
message, code := responseMessage(body)
|
|
return &Error{
|
|
Kind: classifyFailure(status, code, message),
|
|
Op: op,
|
|
StatusCode: status,
|
|
Code: code,
|
|
Message: message,
|
|
}
|
|
}
|
|
|
|
func providerPayloadError(op, code, message string) error {
|
|
return &Error{
|
|
Kind: classifyFailure(http.StatusOK, code, message),
|
|
Op: op,
|
|
Code: code,
|
|
Message: message,
|
|
}
|
|
}
|
|
|
|
func responseMessage(body []byte) (string, string) {
|
|
var payload struct {
|
|
Code string `json:"msg_cd"`
|
|
Message string `json:"msg1"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err == nil && (payload.Code != "" || payload.Message != "") {
|
|
return payload.Message, payload.Code
|
|
}
|
|
trimmed := strings.TrimSpace(string(body))
|
|
if len(trimmed) > 300 {
|
|
trimmed = trimmed[:300]
|
|
}
|
|
return trimmed, ""
|
|
}
|
|
|
|
func classifyFailure(status int, code, message string) ErrorKind {
|
|
if status == http.StatusTooManyRequests {
|
|
return ErrorQuota
|
|
}
|
|
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
|
return ErrorAuth
|
|
}
|
|
text := strings.ToLower(code + " " + message)
|
|
switch {
|
|
case strings.Contains(text, "token"),
|
|
strings.Contains(text, "auth"),
|
|
strings.Contains(text, "oauth"),
|
|
strings.Contains(text, "인증"):
|
|
return ErrorAuth
|
|
case strings.Contains(text, "quota"),
|
|
strings.Contains(text, "rate"),
|
|
strings.Contains(text, "exceed"),
|
|
strings.Contains(text, "초당"),
|
|
strings.Contains(text, "과다"):
|
|
return ErrorQuota
|
|
default:
|
|
return ErrorProvider
|
|
}
|
|
}
|
|
|
|
func isYYYYMMDD(value string) bool {
|
|
if len(value) != 8 {
|
|
return false
|
|
}
|
|
for _, r := range value {
|
|
if r < '0' || r > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|