From 514f9ad021124e2d34e77615bb782b7751d2ee89 Mon Sep 17 00:00:00 2001 From: Bogyeong Kim Date: Fri, 29 May 2026 20:54:55 +0900 Subject: [PATCH 01/13] Add Rust workspace and Upbit API spec Co-authored-by: multica-agent --- .gitignore | 1 + Cargo.lock | 11 + Cargo.toml | 18 ++ README.md | 15 +- crates/upbit-mock/Cargo.toml | 15 ++ crates/upbit-mock/src/lib.rs | 23 ++ crates/upbit-sdk/Cargo.toml | 15 ++ crates/upbit-sdk/src/lib.rs | 27 ++ spec/README.md | 46 ++++ spec/upbit-rest-api.yaml | 506 +++++++++++++++++++++++++++++++++++ 10 files changed, 676 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/upbit-mock/Cargo.toml create mode 100644 crates/upbit-mock/src/lib.rs create mode 100644 crates/upbit-sdk/Cargo.toml create mode 100644 crates/upbit-sdk/src/lib.rs create mode 100644 spec/README.md create mode 100644 spec/upbit-rest-api.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2c85cad --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,11 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "upbit-mock" +version = "0.1.0" + +[[package]] +name = "upbit-sdk" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2adf9b9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[workspace] +resolver = "2" +members = [ + "crates/upbit-sdk", + "crates/upbit-mock", +] + +[workspace.package] +edition = "2021" +license = "MIT OR Apache-2.0" +repository = "https://github.com/Bogyie/upbit-sdk" +rust-version = "1.78" + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +all = "deny" diff --git a/README.md b/README.md index 7861e4b..7d39bec 100644 --- a/README.md +++ b/README.md @@ -1 +1,14 @@ -# upbit-sdk \ No newline at end of file +# upbit-sdk + +Rust workspace for an Upbit SDK and related test tooling. + +## Workspace Layout + +- `crates/upbit-sdk`: SDK crate foundation. +- `crates/upbit-mock`: mock/test tooling crate foundation. +- `spec/upbit-rest-api.yaml`: machine-readable REST API contract seeded from + official Upbit documentation research. +- `spec/README.md`: spec source, caveats, and regeneration policy. + +The REST spec is the shared contract for SDK request/response types and mock +server route fixtures. diff --git a/crates/upbit-mock/Cargo.toml b/crates/upbit-mock/Cargo.toml new file mode 100644 index 0000000..58f9bff --- /dev/null +++ b/crates/upbit-mock/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "upbit-mock" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Mock server test tooling foundation for the Upbit SDK workspace." + +[lib] +name = "upbit_mock" +path = "src/lib.rs" + +[lints] +workspace = true diff --git a/crates/upbit-mock/src/lib.rs b/crates/upbit-mock/src/lib.rs new file mode 100644 index 0000000..c0a85d5 --- /dev/null +++ b/crates/upbit-mock/src/lib.rs @@ -0,0 +1,23 @@ +//! Mock server test tooling foundation. +//! +//! Route fixtures should be derived from `spec/upbit-rest-api.yaml` so tests and +//! SDK implementation share one endpoint contract. + +/// Relative path to the repo-owned REST API spec. +pub const REST_SPEC_PATH: &str = "spec/upbit-rest-api.yaml"; + +/// Returns the REST spec path used by mock tooling. +#[must_use] +pub const fn rest_spec_path() -> &'static str { + REST_SPEC_PATH +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exposes_rest_spec_path() { + assert_eq!(rest_spec_path(), "spec/upbit-rest-api.yaml"); + } +} diff --git a/crates/upbit-sdk/Cargo.toml b/crates/upbit-sdk/Cargo.toml new file mode 100644 index 0000000..4d04738 --- /dev/null +++ b/crates/upbit-sdk/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "upbit-sdk" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Rust SDK foundation for the Upbit REST API." + +[lib] +name = "upbit_sdk" +path = "src/lib.rs" + +[lints] +workspace = true diff --git a/crates/upbit-sdk/src/lib.rs b/crates/upbit-sdk/src/lib.rs new file mode 100644 index 0000000..ef0ffce --- /dev/null +++ b/crates/upbit-sdk/src/lib.rs @@ -0,0 +1,27 @@ +//! Rust SDK foundation for Upbit. +//! +//! The endpoint contract lives in `spec/upbit-rest-api.yaml` at the repository +//! root. SDK request/response types should be generated or implemented against +//! that file instead of scraping the public docs during builds. + +/// Default Upbit REST API base URL. +pub const REST_BASE_URL: &str = "https://api.upbit.com/v1"; + +/// Relative path to the repo-owned REST API spec. +pub const REST_SPEC_PATH: &str = "spec/upbit-rest-api.yaml"; + +/// Returns the default REST base URL. +#[must_use] +pub const fn rest_base_url() -> &'static str { + REST_BASE_URL +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exposes_default_rest_base_url() { + assert_eq!(rest_base_url(), "https://api.upbit.com/v1"); + } +} diff --git a/spec/README.md b/spec/README.md new file mode 100644 index 0000000..138dd67 --- /dev/null +++ b/spec/README.md @@ -0,0 +1,46 @@ +# Upbit REST API Spec + +`upbit-rest-api.yaml` is the repo-owned contract for SDK and mock-server work. + +## Source + +- Research source: Multica issue BOG-228 attachment `upbit-api-spec-draft.md` +- Official docs checked: 2026-05-29 KST +- API overview: https://docs.upbit.com/kr/reference/api-overview +- Auth guide: https://docs.upbit.com/kr/reference/auth +- Rate limits: https://docs.upbit.com/kr/reference/rate-limits +- REST guide and errors: https://docs.upbit.com/kr/reference/rest-api-guide +- Discovery index: https://docs.upbit.com/kr/llms.txt + +The research found no single official downloadable OpenAPI file. Official +reference pages expose per-endpoint OpenAPI JSON blocks plus prose constraints, +so this file preserves both structured fields and documented caveats. + +## Regeneration Policy + +Regenerate this file from official Upbit documentation before broad SDK type +generation or mock fixture generation. Keep `verified_at` and source URLs +current, and prefer official overview/auth/rate-limit guides over inconsistent +embedded OpenAPI metadata. + +Do not hard-code runtime data such as live market lists, network states, fees, +withdrawal limits, supported orderbook levels, or wallet service status as SDK +constants. Treat those as API responses or refreshable test fixtures. + +## Known Ambiguities + +- Quotation REST endpoints are public even when embedded OpenAPI security says + otherwise. Exchange REST endpoints require JWT auth even when embedded + OpenAPI security is absent. +- Some response schemas have incomplete `required` lists. Generators must read + `response_fields`, examples, and prose, not only official `required` arrays. +- Mutually exclusive request parameters are often documented in prose, including + `uuid` vs `identifier`, `uuids[]` vs `identifiers[]`, and order-type-specific + `price`/`volume` combinations. +- `time_in_force` prose appears to contain a typo. Use the order-type tables: + `post_only` is limit-only, `best` requires `ioc` or `fok`, and `post_only` + cannot be combined with `smp_type`. +- BOG-228 and this child issue refer to 45 REST endpoints, but the BOG-228 + REST table and current official REST references enumerate 44 REST endpoints. + `list_subscriptions` is included as the 45th API inventory item with + `protocol: websocket` because it is an official non-REST operation. diff --git a/spec/upbit-rest-api.yaml b/spec/upbit-rest-api.yaml new file mode 100644 index 0000000..128a000 --- /dev/null +++ b/spec/upbit-rest-api.yaml @@ -0,0 +1,506 @@ +spec_version: 0.1.0 +name: upbit-rest-api +verified_at: "2026-05-29 KST" +base_url: https://api.upbit.com/v1 +source_issue: BOG-228 +source_attachment: upbit-api-spec-draft.md +sources: + api_overview: https://docs.upbit.com/kr/reference/api-overview + rest_guide: https://docs.upbit.com/kr/reference/rest-api-guide + auth: https://docs.upbit.com/kr/reference/auth + rate_limits: https://docs.upbit.com/kr/reference/rate-limits + discovery_index: https://docs.upbit.com/kr/llms.txt +global_rules: + auth: + quotation_rest: public + exchange_rest: jwt_required + jwt_header: "Authorization: Bearer " + query_hash: "Include query_hash and usually query_hash_alg=SHA512 when query parameters or JSON body exist." + rate_limit_header: Remaining-Req + error_envelope: '{ "error": { "name": "...", "message": "..." } }' + decimal_policy: "Preserve money and quantity fields as string-backed decimal types; do not use f64 by default." + caveats: + - "Embedded OpenAPI security metadata conflicts with overview/auth docs; overview/auth docs are authoritative." + - "Some official response required arrays are incomplete; use response_fields, examples, and prose together." + - "Runtime data such as market lists, fees, limits, networks, and service states must not be frozen as SDK constants." +rate_limit_groups: + market: { limit: "10/s", unit: ip } + candle: { limit: "10/s", unit: ip } + trade: { limit: "10/s", unit: ip } + ticker: { limit: "10/s", unit: ip } + orderbook: { limit: "10/s", unit: ip } + default: { limit: "30/s", unit: account } + order: { limit: "8/s", unit: account } + order-test: { limit: "8/s", unit: account } + order-cancel-all: { limit: "1 request / 2s", unit: account } + travel-rule-verify: { limit: "1 request / 10 min per same deposit case", unit: account } + websocket-message: { limit: "5/s and 100/min", unit: connection } +endpoints: + - id: list_trading_pairs + category: quotation.trading_pairs + title: List trading pairs + method: GET + path: /market/all + auth_required: false + rate_limit_group: market + request_fields: [is_details] + response_fields: [market, korean_name, english_name] + source_url: https://docs.upbit.com/kr/reference/list-trading-pairs + - id: list_candles_seconds + category: quotation.ohlcv + title: List second candles + method: GET + path: /candles/seconds + auth_required: false + rate_limit_group: candle + request_fields: [market*, to, count] + response_fields: &second_candle_fields [market, candle_date_time_utc, candle_date_time_kst, opening_price, high_price, low_price, trade_price, timestamp, candle_acc_trade_price, candle_acc_trade_volume] + source_url: https://docs.upbit.com/kr/reference/list-candles-seconds + - id: list_candles_minutes + category: quotation.ohlcv + title: List minute candles + method: GET + path: /candles/minutes/{unit} + auth_required: false + rate_limit_group: candle + path_params: [{ name: unit, required: true, enum: [1, 3, 5, 10, 15, 30, 60, 240] }] + request_fields: [market*, to, count] + response_fields: [market, candle_date_time_utc, candle_date_time_kst, opening_price, high_price, low_price, trade_price, timestamp, candle_acc_trade_price, candle_acc_trade_volume, unit] + source_url: https://docs.upbit.com/kr/reference/list-candles-minutes + - id: list_candles_days + category: quotation.ohlcv + title: List day candles + method: GET + path: /candles/days + auth_required: false + rate_limit_group: candle + request_fields: [market*, to, count, converting_price_unit] + response_fields: [market, candle_date_time_utc, candle_date_time_kst, opening_price, high_price, low_price, trade_price, timestamp, candle_acc_trade_price, candle_acc_trade_volume, prev_closing_price, change_price, change_rate] + source_url: https://docs.upbit.com/kr/reference/list-candles-days + - id: list_candles_weeks + category: quotation.ohlcv + title: List week candles + method: GET + path: /candles/weeks + auth_required: false + rate_limit_group: candle + request_fields: [market*, to, count] + response_fields: [market, candle_date_time_utc, candle_date_time_kst, opening_price, high_price, low_price, trade_price, timestamp, candle_acc_trade_price, candle_acc_trade_volume, first_day_of_period] + source_url: https://docs.upbit.com/kr/reference/list-candles-weeks + - id: list_candles_months + category: quotation.ohlcv + title: List month candles + method: GET + path: /candles/months + auth_required: false + rate_limit_group: candle + request_fields: [market*, to, count] + response_fields: [market, candle_date_time_utc, candle_date_time_kst, opening_price, high_price, low_price, trade_price, timestamp, candle_acc_trade_price, candle_acc_trade_volume, first_day_of_period] + source_url: https://docs.upbit.com/kr/reference/list-candles-months + - id: list_candles_years + category: quotation.ohlcv + title: List year candles + method: GET + path: /candles/years + auth_required: false + rate_limit_group: candle + request_fields: [market*, to, count] + response_fields: [market, candle_date_time_utc, candle_date_time_kst, opening_price, high_price, low_price, trade_price, timestamp, candle_acc_trade_price, candle_acc_trade_volume, first_day_of_period] + source_url: https://docs.upbit.com/kr/reference/list-candles-years + - id: list_pair_trades + category: quotation.trade + title: List pair trades + method: GET + path: /trades/ticks + auth_required: false + rate_limit_group: trade + request_fields: [market*, to, count, cursor, days_ago] + response_fields: [market, trade_date_utc, trade_time_utc, timestamp, trade_price, trade_volume, prev_closing_price, change_price, ask_bid, sequential_id] + source_url: https://docs.upbit.com/kr/reference/list-pair-trades + - id: list_tickers + category: quotation.ticker + title: List pair tickers + method: GET + path: /ticker + auth_required: false + rate_limit_group: ticker + request_fields: [markets*] + response_fields: &ticker_fields [market, trade_date, trade_time, trade_date_kst, trade_time_kst, trade_timestamp, opening_price, high_price, low_price, trade_price, prev_closing_price, change, change_price, change_rate, signed_change_price, signed_change_rate, trade_volume, acc_trade_price, acc_trade_price_24h, acc_trade_volume, acc_trade_volume_24h, highest_52_week_price, highest_52_week_date, lowest_52_week_price, lowest_52_week_date, timestamp] + source_url: https://docs.upbit.com/kr/reference/list-tickers + - id: list_quote_tickers + category: quotation.ticker + title: List quote-currency tickers + method: GET + path: /ticker/all + auth_required: false + rate_limit_group: ticker + request_fields: [quote_currencies*] + response_fields: *ticker_fields + source_url: https://docs.upbit.com/kr/reference/list-quote-tickers + - id: list_orderbooks + category: quotation.orderbook + title: List orderbooks + method: GET + path: /orderbook + auth_required: false + rate_limit_group: orderbook + request_fields: [markets*, level, count] + response_fields: [market, timestamp, total_ask_size, total_bid_size, orderbook_units, level] + source_url: https://docs.upbit.com/kr/reference/list-orderbooks + - id: list_orderbook_instruments + category: quotation.orderbook + title: List orderbook instruments + method: GET + path: /orderbook/instruments + auth_required: false + rate_limit_group: orderbook + request_fields: [markets*] + response_fields: [market, quote_currency, tick_size, supported_levels] + source_url: https://docs.upbit.com/kr/reference/list-orderbook-instruments + - id: list_orderbook_levels + category: quotation.orderbook + title: List supported orderbook levels + method: GET + path: /orderbook/supported_levels + auth_required: false + rate_limit_group: orderbook + request_fields: [markets*] + response_fields: [market, supported_levels] + source_url: https://docs.upbit.com/kr/reference/list-orderbook-levels + - id: get_balance + category: exchange.asset + title: Get account balances + method: GET + path: /accounts + auth_required: true + rate_limit_group: default + request_fields: [] + response_fields: [currency, balance, locked, avg_buy_price, avg_buy_price_modified, unit_currency] + source_url: https://docs.upbit.com/kr/reference/get-balance + - id: available_order_information + category: exchange.order + title: Get available order information + method: GET + path: /orders/chance + auth_required: true + rate_limit_group: default + request_fields: [market*] + response_fields: [bid_fee, ask_fee, maker_bid_fee, maker_ask_fee, market, bid_account, ask_account] + source_url: https://docs.upbit.com/kr/reference/available-order-information + - id: new_order + category: exchange.order + title: Create order + method: POST + path: /orders + auth_required: true + rate_limit_group: order + request_fields: &order_request_fields [market*, side*, volume, price, ord_type, identifier, time_in_force, smp_type] + response_fields: &order_fields [market, uuid, side, ord_type, state, created_at, remaining_volume, executed_volume, reserved_fee, remaining_fee, paid_fee, locked, trades_count, prevented_volume, prevented_locked] + caveats: [price_volume_depend_on_ord_type, post_only_cannot_combine_with_smp_type, best_requires_ioc_or_fok] + source_url: https://docs.upbit.com/kr/reference/new-order + - id: order_test + category: exchange.order + title: Create test order + method: POST + path: /orders/test + auth_required: true + rate_limit_group: order-test + request_fields: *order_request_fields + response_fields: *order_fields + caveats: [same_contract_as_new_order] + source_url: https://docs.upbit.com/kr/reference/order-test + - id: cancel_and_new_order + category: exchange.order + title: Cancel and replace order + method: POST + path: /orders/cancel_and_new + auth_required: true + rate_limit_group: order + request_fields: [prev_order_uuid, prev_order_identifier, new_ord_type*, new_volume, new_price, new_identifier, new_time_in_force, new_smp_type] + response_fields: [market, uuid, side, ord_type, state, created_at, remaining_volume, executed_volume, reserved_fee, remaining_fee, paid_fee, locked, trades_count, prevented_volume, prevented_locked, new_order_uuid] + caveats: [prev_order_uuid_xor_prev_order_identifier] + source_url: https://docs.upbit.com/kr/reference/cancel-and-new-order + - id: cancel_order + category: exchange.order + title: Cancel single order + method: DELETE + path: /order + auth_required: true + rate_limit_group: default + request_fields: [uuid, identifier] + response_fields: *order_fields + caveats: [uuid_xor_identifier] + source_url: https://docs.upbit.com/kr/reference/cancel-order + - id: cancel_orders_by_ids + category: exchange.order + title: Cancel orders by IDs + method: DELETE + path: /orders/uuids + auth_required: true + rate_limit_group: default + request_fields: ["uuids[]", "identifiers[]"] + response_fields: [success, failed] + caveats: [uuids_xor_identifiers] + source_url: https://docs.upbit.com/kr/reference/cancel-orders-by-ids + - id: batch_cancel_orders + category: exchange.order + title: Batch cancel open orders + method: DELETE + path: /orders/open + auth_required: true + rate_limit_group: order-cancel-all + request_fields: [quote_currencies, cancel_side, count, order_by, pairs, exclude_pairs] + response_fields: [success, failed] + source_url: https://docs.upbit.com/kr/reference/batch-cancel-orders + - id: get_order + category: exchange.order + title: Get single order + method: GET + path: /order + auth_required: true + rate_limit_group: default + request_fields: [uuid, identifier] + response_fields: [market, uuid, side, ord_type, state, created_at, remaining_volume, executed_volume, reserved_fee, remaining_fee, paid_fee, locked, trades_count, prevented_volume, prevented_locked, trades] + caveats: [uuid_xor_identifier, official_required_list_lacks_remaining_volume] + source_url: https://docs.upbit.com/kr/reference/get-order + - id: list_orders_by_ids + category: exchange.order + title: List orders by IDs + method: GET + path: /orders/uuids + auth_required: true + rate_limit_group: default + request_fields: [market, "uuids[]", "identifiers[]", order_by] + response_fields: [market, uuid, side, ord_type, state, created_at] + caveats: [uuids_xor_identifiers] + source_url: https://docs.upbit.com/kr/reference/list-orders-by-ids + - id: list_open_orders + category: exchange.order + title: List open orders + method: GET + path: /orders/open + auth_required: true + rate_limit_group: default + request_fields: [market, state, "states[]", page, limit, order_by] + response_fields: [market, uuid, side, ord_type, state, created_at, remaining_volume, executed_funds] + source_url: https://docs.upbit.com/kr/reference/list-open-orders + - id: list_closed_orders + category: exchange.order + title: List closed orders + method: GET + path: /orders/closed + auth_required: true + rate_limit_group: default + request_fields: [market, state, "states[]", start_time, end_time, limit, order_by] + response_fields: [market, uuid, side, ord_type, state, created_at, price, volume, remaining_volume, executed_funds] + source_url: https://docs.upbit.com/kr/reference/list-closed-orders + - id: available_withdrawal_information + category: exchange.withdrawal + title: Get available withdrawal information + method: GET + path: /withdraws/chance + auth_required: true + rate_limit_group: default + request_fields: [currency*, net_type] + response_fields: [member_level, currency, account, withdraw_limit] + source_url: https://docs.upbit.com/kr/reference/available-withdrawal-information + - id: withdraw_coin + category: exchange.withdrawal + title: Request digital asset withdrawal + method: POST + path: /withdraws/coin + auth_required: true + rate_limit_group: default + request_fields: [currency*, net_type*, amount*, address*, secondary_address, transaction_type] + response_fields: &withdrawal_fields [type, uuid, currency, txid, state, created_at, done_at, amount, fee, transaction_type] + enum_fields: { transaction_type: [default, internal] } + source_url: https://docs.upbit.com/kr/reference/withdraw + - id: withdraw_krw + category: exchange.withdrawal + title: Request KRW withdrawal + method: POST + path: /withdraws/krw + auth_required: true + rate_limit_group: default + request_fields: [amount*, two_factor_type*] + response_fields: *withdrawal_fields + enum_fields: { two_factor_type: [kakao, naver, hana] } + source_url: https://docs.upbit.com/kr/reference/withdraw-krw + - id: cancel_withdrawal + category: exchange.withdrawal + title: Cancel digital asset withdrawal + method: DELETE + path: /withdraws/coin + auth_required: true + rate_limit_group: default + request_fields: [uuid*] + response_fields: [type, uuid, currency, txid, state, created_at, done_at, amount, fee, is_cancelable] + source_url: https://docs.upbit.com/kr/reference/cancel-withdrawal + - id: get_withdrawal + category: exchange.withdrawal + title: Get withdrawal + method: GET + path: /withdraw + auth_required: true + rate_limit_group: default + request_fields: [uuid, txid, currency] + response_fields: *withdrawal_fields + source_url: https://docs.upbit.com/kr/reference/get-withdrawal + - id: list_withdrawals + category: exchange.withdrawal + title: List withdrawals + method: GET + path: /withdraws + auth_required: true + rate_limit_group: default + request_fields: [currency, state, "uuids[]", "txids[]", limit, page, order_by, from, to] + response_fields: *withdrawal_fields + source_url: https://docs.upbit.com/kr/reference/list-withdrawals + - id: list_withdrawal_addresses + category: exchange.withdrawal + title: List withdrawal addresses + method: GET + path: /withdraws/coin_addresses + auth_required: true + rate_limit_group: default + request_fields: [] + response_fields: [currency, net_type, network_name, withdraw_address] + source_url: https://docs.upbit.com/kr/reference/list-withdrawal-addresses + - id: available_deposit_information + category: exchange.deposit + title: Get available deposit information + method: GET + path: /deposits/chance/coin + auth_required: true + rate_limit_group: default + request_fields: [currency*, net_type*] + response_fields: [currency, net_type, is_deposit_possible, deposit_impossible_reason, minimum_deposit_amount, minimum_deposit_confirmations, decimal_precision] + source_url: https://docs.upbit.com/kr/reference/available-deposit-information + - id: create_deposit_address + category: exchange.deposit + title: Create deposit address + method: POST + path: /deposits/generate_coin_address + auth_required: true + rate_limit_group: default + request_fields: [currency*, net_type*] + response_fields: [currency, net_type, deposit_address] + source_url: https://docs.upbit.com/kr/reference/create-deposit-address + - id: get_deposit_address + category: exchange.deposit + title: Get deposit address + method: GET + path: /deposits/coin_address + auth_required: true + rate_limit_group: default + request_fields: [currency*, net_type*] + response_fields: [currency, net_type, deposit_address] + source_url: https://docs.upbit.com/kr/reference/get-deposit-address + - id: list_deposit_addresses + category: exchange.deposit + title: List deposit addresses + method: GET + path: /deposits/coin_addresses + auth_required: true + rate_limit_group: default + request_fields: [] + response_fields: [currency, net_type, deposit_address] + source_url: https://docs.upbit.com/kr/reference/list-deposit-addresses + - id: get_deposit + category: exchange.deposit + title: Get deposit + method: GET + path: /deposit + auth_required: true + rate_limit_group: default + request_fields: [currency, uuid, txid] + response_fields: &deposit_fields [type, uuid, currency, txid, state, created_at, done_at, amount, fee, transaction_type] + source_url: https://docs.upbit.com/kr/reference/get-deposit + - id: list_deposits + category: exchange.deposit + title: List deposits + method: GET + path: /deposits + auth_required: true + rate_limit_group: default + request_fields: [currency, state, "uuids[]", "txids[]", limit, page, order_by, from, to] + response_fields: *deposit_fields + caveats: [official_required_list_missing_or_incomplete] + source_url: https://docs.upbit.com/kr/reference/list-deposits + - id: deposit_krw + category: exchange.deposit + title: Request KRW deposit + method: POST + path: /deposits/krw + auth_required: true + rate_limit_group: default + request_fields: [amount*, two_factor_type*] + response_fields: *deposit_fields + enum_fields: { two_factor_type: [kakao, naver, hana] } + source_url: https://docs.upbit.com/kr/reference/deposit-krw + - id: list_travelrule_vasps + category: exchange.travel_rule + title: List Travel Rule VASPs + method: GET + path: /travel_rule/vasps + auth_required: true + rate_limit_group: default + request_fields: [] + response_fields: [depositable, vasp_uuid, vasp_name, withdrawable] + source_url: https://docs.upbit.com/kr/reference/list-travelrule-vasps + - id: verify_travelrule_by_uuid + category: exchange.travel_rule + title: Verify deposit owner by UUID + method: POST + path: /travel_rule/deposit/uuid + auth_required: true + rate_limit_group: default + extra_throttle: travel-rule-verify + request_fields: [deposit_uuid*, vasp_uuid*] + response_fields: [deposit_uuid, deposit_state, verification_result] + source_url: https://docs.upbit.com/kr/reference/verify-travelrule-by-uuid + - id: verify_travelrule_by_txid + category: exchange.travel_rule + title: Verify deposit owner by TxID + method: POST + path: /travel_rule/deposit/txid + auth_required: true + rate_limit_group: default + extra_throttle: travel-rule-verify + request_fields: [vasp_uuid*, txid*, currency*, net_type*] + response_fields: [deposit_uuid, verification_result, deposit_state] + source_url: https://docs.upbit.com/kr/reference/verify-travelrule-by-txid + - id: get_service_status + category: exchange.service + title: Get wallet service status + method: GET + path: /status/wallet + auth_required: true + rate_limit_group: default + request_fields: [] + response_fields: [currency, wallet_state, block_elapsed_minutes, net_type, network_name] + source_url: https://docs.upbit.com/kr/reference/get-service-status + - id: list_api_keys + category: exchange.service + title: List API keys + method: GET + path: /api_keys + auth_required: true + rate_limit_group: default + request_fields: [] + response_fields: [access_key, expire_at] + source_url: https://docs.upbit.com/kr/reference/list-api-keys + - id: list_subscriptions + protocol: websocket + category: websocket.operation + title: List current WebSocket subscriptions + method: LIST_SUBSCRIPTIONS + path: wss://api.upbit.com/websocket/v1 + auth_required: false + rate_limit_group: websocket-message + request_fields: [ticket*, method*] + response_fields: [method, result, result.type, result.codes, result.level, ticket] + caveats: + - "Included as the 45th API inventory item because BOG-228/BOG-235 say 45 REST endpoints, while official REST references and the BOG-228 REST table enumerate 44 REST endpoints." + - "Private stream subscriptions use wss://api.upbit.com/websocket/v1/private and JWT auth." + source_url: https://docs.upbit.com/kr/reference/list-subscriptions From d933ee9aa8c08909051f7761689c8c42a2802aef Mon Sep 17 00:00:00 2001 From: Bogyeong Kim Date: Fri, 29 May 2026 21:08:26 +0900 Subject: [PATCH 02/13] BOG-236 Add spec-driven Upbit mock server Co-authored-by: multica-agent --- Cargo.lock | 148 +++++ README.md | 15 +- crates/upbit-mock/Cargo.toml | 9 + crates/upbit-mock/README.md | 65 ++ crates/upbit-mock/fixtures/README.md | 11 + crates/upbit-mock/fixtures/catalog.json | 43 ++ crates/upbit-mock/src/lib.rs | 802 +++++++++++++++++++++++- crates/upbit-mock/src/main.rs | 20 + 8 files changed, 1108 insertions(+), 5 deletions(-) create mode 100644 crates/upbit-mock/README.md create mode 100644 crates/upbit-mock/fixtures/README.md create mode 100644 crates/upbit-mock/fixtures/catalog.json create mode 100644 crates/upbit-mock/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 2c85cad..a673c80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,10 +2,158 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "upbit-mock" version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "serde_yaml", +] [[package]] name = "upbit-sdk" version = "0.1.0" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/README.md b/README.md index 7d39bec..51df4a2 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,23 @@ Rust workspace for an Upbit SDK and related test tooling. ## Workspace Layout - `crates/upbit-sdk`: SDK crate foundation. -- `crates/upbit-mock`: mock/test tooling crate foundation. +- `crates/upbit-mock`: spec-driven mock server and conformance baseline for + SDK integration tests. - `spec/upbit-rest-api.yaml`: machine-readable REST API contract seeded from official Upbit documentation research. - `spec/README.md`: spec source, caveats, and regeneration policy. The REST spec is the shared contract for SDK request/response types and mock server route fixtures. + +## Local Mock Server + +Run a credential-free mock Upbit REST endpoint for SDK integration tests: + +```sh +cargo run -p upbit-mock -- 127.0.0.1:8001 . +``` + +Use `http://127.0.0.1:8001/v1` as the SDK REST base URL. See +`crates/upbit-mock/README.md` for auth, validation, error, fixture, and +conformance details. diff --git a/crates/upbit-mock/Cargo.toml b/crates/upbit-mock/Cargo.toml index 58f9bff..90e26ad 100644 --- a/crates/upbit-mock/Cargo.toml +++ b/crates/upbit-mock/Cargo.toml @@ -7,9 +7,18 @@ repository.workspace = true rust-version.workspace = true description = "Mock server test tooling foundation for the Upbit SDK workspace." +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" + [lib] name = "upbit_mock" path = "src/lib.rs" +[[bin]] +name = "upbit-mock" +path = "src/main.rs" + [lints] workspace = true diff --git a/crates/upbit-mock/README.md b/crates/upbit-mock/README.md new file mode 100644 index 0000000..e157314 --- /dev/null +++ b/crates/upbit-mock/README.md @@ -0,0 +1,65 @@ +# upbit-mock + +`upbit-mock` provides a local, deterministic HTTP mock for SDK integration +tests. It reads `spec/upbit-rest-api.yaml` at startup and derives route +coverage, auth requirements, request-field validation, rate-limit headers, and +representative response fixtures from that contract. + +## Run + +From the repository root: + +```sh +cargo run -p upbit-mock -- 127.0.0.1:8001 . +``` + +The first argument is the bind address. The second argument is the repository +root containing `spec/upbit-rest-api.yaml`. If omitted, the server binds to +`127.0.0.1:8001` and uses the current directory as the repository root. + +Point SDK tests at `http://127.0.0.1:8001/v1` instead of +`https://api.upbit.com/v1`. Exchange endpoints require any bearer token-like +header, for example: + +```sh +curl -H 'Authorization: Bearer test.jwt' http://127.0.0.1:8001/v1/accounts +``` + +Quotation endpoints are public: + +```sh +curl 'http://127.0.0.1:8001/v1/ticker?markets=KRW-BTC' +``` + +## Fixture And Error Behavior + +- Success fixtures are generated from each endpoint's `response_fields`. +- Required request fields are marked with `*` in the spec and return a 400 + Upbit-style error envelope when omitted. +- Auth-required endpoints return a 401 Upbit-style error envelope when the + `Authorization: Bearer ...` header is missing. +- Add `__mock_error=rate_limit` to return a deterministic 429 rate-limit + fixture. +- Responses include a `remaining-req` header derived from the endpoint + `rate_limit_group`. + +## Conformance Baseline + +Run: + +```sh +cargo test -p upbit-mock +``` + +The conformance tests fail when: + +- a REST endpoint lacks a mock route, +- duplicate `METHOD path` route keys appear, +- an endpoint fixture lacks a documented response field, +- auth-required endpoints stop returning a 401 without a bearer token, or +- the WebSocket-only `list_subscriptions` exception is no longer documented. + +Current route coverage baseline: + +- REST route coverage: 44/44 +- Inventory exception: `list_subscriptions` (`protocol: websocket`) diff --git a/crates/upbit-mock/fixtures/README.md b/crates/upbit-mock/fixtures/README.md new file mode 100644 index 0000000..036e3eb --- /dev/null +++ b/crates/upbit-mock/fixtures/README.md @@ -0,0 +1,11 @@ +# Upbit Mock Fixtures + +Fixtures are generated by `upbit-mock` from `spec/upbit-rest-api.yaml` at +runtime. The checked-in catalog records the supported fixture classes and the +error envelope so SDK tests can depend on stable mock behavior without freezing +live Upbit market, fee, network, or wallet-state data as SDK constants. + +The conformance baseline verifies every REST endpoint has a route and a +representative fixture containing all `response_fields` listed in the spec. +`list_subscriptions` is intentionally excluded from HTTP route coverage because +the spec marks it as `protocol: websocket`. diff --git a/crates/upbit-mock/fixtures/catalog.json b/crates/upbit-mock/fixtures/catalog.json new file mode 100644 index 0000000..2281980 --- /dev/null +++ b/crates/upbit-mock/fixtures/catalog.json @@ -0,0 +1,43 @@ +{ + "success": { + "source": "spec/upbit-rest-api.yaml", + "strategy": "Generate one deterministic representative JSON fixture per REST endpoint from response_fields.", + "runtime_data_policy": "Market, fee, wallet, network, and service-state examples are mock-only responses, not SDK constants." + }, + "errors": { + "auth_failure": { + "status": 401, + "body": { + "error": { + "name": "jwt_verification", + "message": "Authorization bearer token is required." + } + } + }, + "validation_error": { + "status": 400, + "body": { + "error": { + "name": "validation_error", + "message": "Missing required request field(s): " + } + } + }, + "rate_limit": { + "status": 429, + "trigger": "Add __mock_error=rate_limit to the query string.", + "body": { + "error": { + "name": "too_many_requests", + "message": "Rate limit exceeded." + } + } + } + }, + "documented_exceptions": [ + { + "id": "list_subscriptions", + "reason": "WebSocket operation, not an HTTP REST route." + } + ] +} diff --git a/crates/upbit-mock/src/lib.rs b/crates/upbit-mock/src/lib.rs index c0a85d5..807e2f0 100644 --- a/crates/upbit-mock/src/lib.rs +++ b/crates/upbit-mock/src/lib.rs @@ -1,23 +1,817 @@ -//! Mock server test tooling foundation. +//! Spec-driven mock server tooling for Upbit SDK tests. //! -//! Route fixtures should be derived from `spec/upbit-rest-api.yaml` so tests and -//! SDK implementation share one endpoint contract. +//! The dispatcher derives route coverage and fixture shapes from +//! `spec/upbit-rest-api.yaml`. Tests should depend on this crate instead of +//! live Upbit credentials when verifying SDK request construction and response +//! decoding. + +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream, ToSocketAddrs}; +use std::path::{Path, PathBuf}; /// Relative path to the repo-owned REST API spec. pub const REST_SPEC_PATH: &str = "spec/upbit-rest-api.yaml"; +/// Fixture catalog path documented for SDK test authors. +pub const FIXTURE_CATALOG_PATH: &str = "crates/upbit-mock/fixtures/catalog.json"; + +const API_PREFIX: &str = "/v1"; + /// Returns the REST spec path used by mock tooling. #[must_use] pub const fn rest_spec_path() -> &'static str { REST_SPEC_PATH } +/// Returns the deterministic fixture catalog path. +#[must_use] +pub const fn fixture_catalog_path() -> &'static str { + FIXTURE_CATALOG_PATH +} + +/// Repo-owned Upbit API contract. +#[derive(Debug, Deserialize)] +pub struct ApiSpec { + /// Machine-readable spec version. + pub spec_version: String, + /// REST API name. + pub name: String, + /// REST base URL documented by the spec. + pub base_url: String, + /// Endpoint inventory. + pub endpoints: Vec, +} + +/// Endpoint contract used for mock route generation and conformance checks. +#[derive(Debug, Clone, Deserialize)] +pub struct EndpointSpec { + /// Stable endpoint id. + pub id: String, + /// Protocol marker. Omitted endpoints are REST. + #[serde(default)] + pub protocol: Option, + /// Endpoint category. + pub category: String, + /// HTTP method or non-REST operation name. + pub method: String, + /// Path template. + pub path: String, + /// Whether JWT auth is required. + pub auth_required: bool, + /// Rate-limit group name. + #[serde(default)] + pub rate_limit_group: Option, + /// Request field names. Required fields end with `*`. + #[serde(default)] + pub request_fields: Vec, + /// Representative response field names. + #[serde(default)] + pub response_fields: Vec, +} + +impl EndpointSpec { + /// Returns true for REST endpoints. + #[must_use] + pub fn is_rest(&self) -> bool { + self.protocol.as_deref().unwrap_or("rest") == "rest" + } + + /// Required request fields after removing the `*` marker. + #[must_use] + pub fn required_fields(&self) -> Vec { + self.request_fields + .iter() + .filter_map(|field| field.strip_suffix('*').map(ToOwned::to_owned)) + .collect() + } +} + +/// Request input accepted by the mock dispatcher. +#[derive(Debug, Clone)] +pub struct MockRequest { + /// HTTP method. + pub method: String, + /// Request path, with or without the `/v1` API prefix. + pub path: String, + /// Query parameters. + pub query: BTreeMap, + /// Request headers. + pub headers: BTreeMap, + /// JSON request body, if present. + pub json: Option, +} + +impl MockRequest { + /// Creates a request with method and path. + #[must_use] + pub fn new(method: impl Into, path: impl Into) -> Self { + Self { + method: method.into(), + path: path.into(), + query: BTreeMap::new(), + headers: BTreeMap::new(), + json: None, + } + } + + /// Adds a query parameter. + #[must_use] + pub fn with_query(mut self, name: impl Into, value: impl Into) -> Self { + self.query.insert(name.into(), value.into()); + self + } + + /// Adds a header. + #[must_use] + pub fn with_header(mut self, name: impl Into, value: impl Into) -> Self { + self.headers + .insert(name.into().to_ascii_lowercase(), value.into()); + self + } + + /// Adds a JSON body. + #[must_use] + pub fn with_json(mut self, body: Value) -> Self { + self.json = Some(body); + self + } +} + +/// Mock HTTP-style response. +#[derive(Debug, Clone)] +pub struct MockResponse { + /// HTTP status code. + pub status: u16, + /// Response headers. + pub headers: BTreeMap, + /// JSON body. + pub body: Value, +} + +/// Route and fixture coverage summary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoverageSummary { + /// Total endpoints in the inventory, including non-REST entries. + pub total_inventory_endpoints: usize, + /// REST endpoints that the mock dispatcher covers. + pub rest_route_count: usize, + /// Non-REST endpoints excluded from HTTP mock routing. + pub documented_exceptions: Vec, + /// `METHOD path` route keys. + pub route_keys: Vec, +} + +/// Spec-driven mock dispatcher. +#[derive(Debug)] +pub struct MockServerSpec { + rest_endpoints: Vec, + documented_exceptions: Vec, +} + +impl MockServerSpec { + /// Builds the dispatcher from a parsed API spec. + #[must_use] + pub fn from_api_spec(spec: ApiSpec) -> Self { + let (rest_endpoints, documented_exceptions) = spec + .endpoints + .into_iter() + .partition(|endpoint| endpoint.is_rest() && endpoint.method != "LIST_SUBSCRIPTIONS"); + + Self { + rest_endpoints, + documented_exceptions, + } + } + + /// Loads the dispatcher from a spec file. + pub fn from_spec_path(path: impl AsRef) -> Result { + Ok(Self::from_api_spec(load_api_spec(path)?)) + } + + /// Loads the dispatcher from a repository root. + pub fn from_repo_root(root: impl AsRef) -> Result { + Self::from_spec_path(root.as_ref().join(REST_SPEC_PATH)) + } + + /// Dispatches a request against the spec-derived routes. + #[must_use] + pub fn dispatch(&self, request: MockRequest) -> MockResponse { + let path = strip_api_prefix(&request.path); + let Some(endpoint) = self.find_endpoint(&request.method, path) else { + return error_response(404, "not_found", "No mock route matches the request."); + }; + + if endpoint.auth_required && !has_bearer_token(&request.headers) { + return error_response( + 401, + "jwt_verification", + "Authorization bearer token is required.", + ); + } + + if request + .query + .get("__mock_error") + .is_some_and(|value| value == "rate_limit") + { + return error_response(429, "too_many_requests", "Rate limit exceeded."); + } + + let missing = missing_required_fields(endpoint, &request); + if !missing.is_empty() { + return error_response( + 400, + "validation_error", + &format!("Missing required request field(s): {}", missing.join(", ")), + ); + } + + let mut headers = default_headers(endpoint); + headers.insert("content-type".to_owned(), "application/json".to_owned()); + + MockResponse { + status: 200, + headers, + body: fixture_for_endpoint(endpoint), + } + } + + /// Returns route coverage data for implementation and QA handoff. + #[must_use] + pub fn coverage_summary(&self) -> CoverageSummary { + CoverageSummary { + total_inventory_endpoints: self.rest_endpoints.len() + self.documented_exceptions.len(), + rest_route_count: self.rest_endpoints.len(), + documented_exceptions: self + .documented_exceptions + .iter() + .map(|endpoint| { + format!( + "{} ({})", + endpoint.id, + endpoint.protocol.as_deref().unwrap_or("non-rest") + ) + }) + .collect(), + route_keys: self + .rest_endpoints + .iter() + .map(|endpoint| format!("{} {}", endpoint.method, endpoint.path)) + .collect(), + } + } + + /// Asserts route and fixture conformance against the parsed spec. + pub fn assert_conformance(&self) -> Result<(), MockError> { + let mut route_keys = BTreeSet::new(); + + for endpoint in &self.rest_endpoints { + let route_key = format!("{} {}", endpoint.method, endpoint.path); + if !route_keys.insert(route_key.clone()) { + return Err(MockError::Conformance(format!( + "duplicate mock route key: {route_key}" + ))); + } + + if endpoint.response_fields.is_empty() { + return Err(MockError::Conformance(format!( + "{} has no representative response fields", + endpoint.id + ))); + } + + let body = fixture_for_endpoint(endpoint); + assert_fixture_fields(endpoint, &body)?; + + if endpoint.auth_required { + let unauthenticated = + self.dispatch(MockRequest::new(&endpoint.method, example_path(endpoint))); + if unauthenticated.status != 401 { + return Err(MockError::Conformance(format!( + "{} does not enforce auth", + endpoint.id + ))); + } + } + } + + if self + .documented_exceptions + .iter() + .any(|endpoint| endpoint.id == "list_subscriptions") + { + Ok(()) + } else { + Err(MockError::Conformance( + "expected websocket list_subscriptions to be documented as an HTTP mock exception" + .to_owned(), + )) + } + } + + fn find_endpoint(&self, method: &str, path: &str) -> Option<&EndpointSpec> { + self.rest_endpoints.iter().find(|endpoint| { + endpoint.method == method.to_ascii_uppercase() && path_matches(&endpoint.path, path) + }) + } +} + +/// Server bind configuration. +#[derive(Debug, Clone)] +pub struct ServerConfig { + /// Address such as `127.0.0.1:8001`. + pub addr: String, + /// Repository root used to load `spec/upbit-rest-api.yaml`. + pub repo_root: PathBuf, +} + +impl ServerConfig { + /// Creates a server config. + #[must_use] + pub fn new(addr: impl Into, repo_root: impl Into) -> Self { + Self { + addr: addr.into(), + repo_root: repo_root.into(), + } + } +} + +/// Runs the blocking local mock HTTP server. +pub fn run_blocking_server(config: ServerConfig) -> Result<(), MockError> { + let mock = MockServerSpec::from_repo_root(&config.repo_root)?; + mock.assert_conformance()?; + + let listener = TcpListener::bind(resolve_addr(&config.addr)?)?; + eprintln!("upbit-mock listening on http://{}", listener.local_addr()?); + + for stream in listener.incoming() { + let stream = stream?; + handle_stream(stream, &mock)?; + } + + Ok(()) +} + +/// Loads the repo-owned API spec. +pub fn load_api_spec(path: impl AsRef) -> Result { + let contents = fs::read_to_string(path)?; + serde_yaml::from_str(&contents).map_err(MockError::Yaml) +} + +fn resolve_addr(addr: &str) -> Result { + addr.to_socket_addrs()? + .next() + .ok_or_else(|| MockError::Config(format!("no socket address resolved for {addr}"))) +} + +fn handle_stream(mut stream: TcpStream, mock: &MockServerSpec) -> Result<(), MockError> { + let mut reader = BufReader::new(stream.try_clone()?); + let mut first_line = String::new(); + reader.read_line(&mut first_line)?; + if first_line.trim().is_empty() { + return Ok(()); + } + + let request_parts: Vec<&str> = first_line.split_whitespace().collect(); + if request_parts.len() < 2 { + write_http_response( + &mut stream, + &error_response(400, "bad_request", "Malformed request line."), + )?; + return Ok(()); + } + + let method = request_parts[0].to_owned(); + let (path, query) = split_path_and_query(request_parts[1]); + let mut headers = BTreeMap::new(); + let mut content_length = 0usize; + + loop { + let mut line = String::new(); + reader.read_line(&mut line)?; + let line = line.trim_end(); + if line.is_empty() { + break; + } + + if let Some((name, value)) = line.split_once(':') { + let key = name.trim().to_ascii_lowercase(); + let value = value.trim().to_owned(); + if key == "content-length" { + content_length = value.parse().unwrap_or(0); + } + headers.insert(key, value); + } + } + + let mut body = vec![0; content_length]; + if content_length > 0 { + reader.read_exact(&mut body)?; + } + + let json = if body.is_empty() { + None + } else { + serde_json::from_slice(&body).ok() + }; + + let response = mock.dispatch(MockRequest { + method, + path, + query, + headers, + json, + }); + + write_http_response(&mut stream, &response)?; + Ok(()) +} + +fn write_http_response(stream: &mut TcpStream, response: &MockResponse) -> Result<(), MockError> { + let body = serde_json::to_vec_pretty(&response.body)?; + write!( + stream, + "HTTP/1.1 {} {}\r\n", + response.status, + reason_phrase(response.status) + )?; + for (name, value) in &response.headers { + write!(stream, "{name}: {value}\r\n")?; + } + write!(stream, "content-length: {}\r\n\r\n", body.len())?; + stream.write_all(&body)?; + Ok(()) +} + +fn reason_phrase(status: u16) -> &'static str { + match status { + 200 => "OK", + 400 => "Bad Request", + 401 => "Unauthorized", + 404 => "Not Found", + 429 => "Too Many Requests", + _ => "Mock Response", + } +} + +fn strip_api_prefix(path: &str) -> &str { + path.strip_prefix(API_PREFIX).unwrap_or(path) +} + +fn split_path_and_query(target: &str) -> (String, BTreeMap) { + let Some((path, query_string)) = target.split_once('?') else { + return (target.to_owned(), BTreeMap::new()); + }; + + let query = query_string + .split('&') + .filter(|pair| !pair.is_empty()) + .map(|pair| { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + (key.to_owned(), value.to_owned()) + }) + .collect(); + + (path.to_owned(), query) +} + +fn path_matches(template: &str, request_path: &str) -> bool { + let template_parts: Vec<&str> = template.trim_matches('/').split('/').collect(); + let request_parts: Vec<&str> = request_path.trim_matches('/').split('/').collect(); + + template_parts.len() == request_parts.len() + && template_parts + .iter() + .zip(request_parts) + .all(|(template_part, request_part)| { + (template_part.starts_with('{') && template_part.ends_with('}')) + || *template_part == request_part + }) +} + +fn example_path(endpoint: &EndpointSpec) -> String { + let path = endpoint.path.replace("{unit}", "1"); + format!("{API_PREFIX}{path}") +} + +fn has_bearer_token(headers: &BTreeMap) -> bool { + headers + .get("authorization") + .is_some_and(|header| header.starts_with("Bearer ") && header.len() > "Bearer ".len()) +} + +fn missing_required_fields(endpoint: &EndpointSpec, request: &MockRequest) -> Vec { + endpoint + .required_fields() + .into_iter() + .filter(|field| !request_field_exists(field, request)) + .collect() +} + +fn request_field_exists(field: &str, request: &MockRequest) -> bool { + if request.query.contains_key(field) { + return true; + } + + request + .json + .as_ref() + .and_then(Value::as_object) + .is_some_and(|object| object.contains_key(field)) +} + +fn default_headers(endpoint: &EndpointSpec) -> BTreeMap { + let mut headers = BTreeMap::new(); + let group = endpoint.rate_limit_group.as_deref().unwrap_or("default"); + headers.insert( + "remaining-req".to_owned(), + format!("group={group}; min=1800; sec=29"), + ); + headers +} + +fn error_response(status: u16, name: &str, message: &str) -> MockResponse { + MockResponse { + status, + headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]), + body: json!({ + "error": { + "name": name, + "message": message, + } + }), + } +} + +fn fixture_for_endpoint(endpoint: &EndpointSpec) -> Value { + let fixture = object_for_fields(&endpoint.response_fields); + + if returns_collection(endpoint) { + Value::Array(vec![Value::Object(fixture)]) + } else { + Value::Object(fixture) + } +} + +fn returns_collection(endpoint: &EndpointSpec) -> bool { + endpoint.id.starts_with("list_") + || matches!(endpoint.id.as_str(), "get_balance" | "get_service_status") +} + +fn object_for_fields(fields: &[String]) -> Map { + let mut root = Map::new(); + + for field in fields { + insert_fixture_field(&mut root, field); + } + + root +} + +fn insert_fixture_field(root: &mut Map, field: &str) { + if let Some((parent, child)) = field.split_once('.') { + let parent_value = root + .entry(parent.to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + if let Value::Object(parent_object) = parent_value { + insert_fixture_field(parent_object, child); + } + return; + } + + let value = match field { + "success" | "failed" | "orderbook_units" | "trades" | "supported_levels" | "codes" => { + Value::Array(vec![sample_scalar(field)]) + } + _ => sample_scalar(field), + }; + + root.insert(field.to_owned(), value); +} + +fn sample_scalar(field: &str) -> Value { + match field { + "market" => json!("KRW-BTC"), + "markets" => json!("KRW-BTC,KRW-ETH"), + "currency" => json!("BTC"), + "quote_currency" | "unit_currency" => json!("KRW"), + "korean_name" => json!("Mock Bitcoin"), + "english_name" => json!("Bitcoin"), + "side" => json!("bid"), + "ord_type" => json!("limit"), + "state" | "wallet_state" | "deposit_state" => json!("done"), + "ask_bid" => json!("BID"), + "change" => json!("RISE"), + "uuid" | "new_order_uuid" | "deposit_uuid" | "vasp_uuid" => { + json!("00000000-0000-4000-8000-000000000001") + } + "txid" => json!("mock-txid-0001"), + "access_key" => json!("mock-access-key"), + "expire_at" | "created_at" | "done_at" => json!("2026-05-29T00:00:00+09:00"), + "candle_date_time_utc" => json!("2026-05-28T15:00:00"), + "candle_date_time_kst" => json!("2026-05-29T00:00:00"), + "trade_date" + | "trade_date_utc" + | "trade_date_kst" + | "highest_52_week_date" + | "lowest_52_week_date" => { + json!("20260529") + } + "trade_time" | "trade_time_utc" | "trade_time_kst" => json!("000000"), + "timestamp" + | "trade_timestamp" + | "sequential_id" + | "block_elapsed_minutes" + | "trades_count" => { + json!(1) + } + "unit" | "level" | "count" | "decimal_precision" | "minimum_deposit_confirmations" => { + json!(1) + } + "is_deposit_possible" + | "avg_buy_price_modified" + | "depositable" + | "withdrawable" + | "is_cancelable" => { + json!(true) + } + "result" => json!({ "type": "ticker", "codes": ["KRW-BTC"], "level": 0 }), + name if is_decimal_like(name) => json!("1.2345"), + _ => json!(format!("mock_{field}")), + } +} + +fn is_decimal_like(field: &str) -> bool { + field.contains("price") + || field.contains("volume") + || field.contains("amount") + || field.contains("balance") + || field.contains("locked") + || field.contains("fee") + || field.contains("rate") + || field.contains("funds") + || field.contains("size") + || field.contains("limit") +} + +fn assert_fixture_fields(endpoint: &EndpointSpec, body: &Value) -> Result<(), MockError> { + let object = body + .as_array() + .and_then(|items| items.first()) + .or_else(|| body.as_object().map(|_| body)) + .ok_or_else(|| { + MockError::Conformance(format!("{} fixture is not an object or array", endpoint.id)) + })?; + + for field in &endpoint.response_fields { + if !fixture_field_exists(object, field) { + return Err(MockError::Conformance(format!( + "{} fixture is missing response field {field}", + endpoint.id + ))); + } + } + + Ok(()) +} + +fn fixture_field_exists(value: &Value, field: &str) -> bool { + let mut current = value; + for part in field.split('.') { + let Some(next) = current.as_object().and_then(|object| object.get(part)) else { + return false; + }; + current = next; + } + true +} + +/// Mock tooling errors. +#[derive(Debug)] +pub enum MockError { + /// Filesystem or network IO failed. + Io(std::io::Error), + /// YAML spec parsing failed. + Yaml(serde_yaml::Error), + /// JSON serialization failed. + Json(serde_json::Error), + /// Configuration failed before server startup. + Config(String), + /// Conformance baseline failed. + Conformance(String), +} + +impl std::fmt::Display for MockError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(error) => write!(formatter, "io error: {error}"), + Self::Yaml(error) => write!(formatter, "yaml error: {error}"), + Self::Json(error) => write!(formatter, "json error: {error}"), + Self::Config(message) => write!(formatter, "config error: {message}"), + Self::Conformance(message) => write!(formatter, "conformance error: {message}"), + } + } +} + +impl std::error::Error for MockError {} + +impl From for MockError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl From for MockError { + fn from(error: serde_json::Error) -> Self { + Self::Json(error) + } +} + #[cfg(test)] mod tests { use super::*; + fn mock() -> MockServerSpec { + MockServerSpec::from_repo_root(env!("CARGO_MANIFEST_DIR").replace("/crates/upbit-mock", "")) + .expect("repo spec should load") + } + #[test] - fn exposes_rest_spec_path() { + fn exposes_rest_spec_and_fixture_paths() { assert_eq!(rest_spec_path(), "spec/upbit-rest-api.yaml"); + assert_eq!( + fixture_catalog_path(), + "crates/upbit-mock/fixtures/catalog.json" + ); + } + + #[test] + fn route_coverage_matches_rest_spec_baseline() { + let mock = mock(); + let summary = mock.coverage_summary(); + + assert_eq!(summary.total_inventory_endpoints, 45); + assert_eq!(summary.rest_route_count, 44); + assert_eq!( + summary.documented_exceptions, + vec!["list_subscriptions (websocket)"] + ); + assert!(summary.route_keys.contains(&"GET /market/all".to_owned())); + assert!(summary.route_keys.contains(&"POST /orders".to_owned())); + } + + #[test] + fn conformance_baseline_passes_for_all_rest_routes() { + mock() + .assert_conformance() + .expect("mock routes should conform"); + } + + #[test] + fn public_route_returns_representative_fixture() { + let response = mock().dispatch( + MockRequest::new("GET", "/v1/candles/minutes/1").with_query("market", "KRW-BTC"), + ); + + assert_eq!(response.status, 200); + assert_eq!( + response.headers.get("remaining-req").expect("rate header"), + "group=candle; min=1800; sec=29" + ); + assert_eq!(response.body[0]["market"], "KRW-BTC"); + assert_eq!(response.body[0]["unit"], 1); + } + + #[test] + fn exchange_route_requires_bearer_auth() { + let response = mock().dispatch(MockRequest::new("GET", "/v1/accounts")); + + assert_eq!(response.status, 401); + assert_eq!(response.body["error"]["name"], "jwt_verification"); + } + + #[test] + fn required_fields_are_validated() { + let response = + mock().dispatch(MockRequest::new("GET", "/v1/ticker").with_query("ignored", "true")); + + assert_eq!(response.status, 400); + assert_eq!(response.body["error"]["name"], "validation_error"); + } + + #[test] + fn authenticated_request_can_return_rate_limit_error_fixture() { + let response = mock().dispatch( + MockRequest::new("GET", "/v1/accounts") + .with_header("authorization", "Bearer test.jwt") + .with_query("__mock_error", "rate_limit"), + ); + + assert_eq!(response.status, 429); + assert_eq!(response.body["error"]["name"], "too_many_requests"); } } diff --git a/crates/upbit-mock/src/main.rs b/crates/upbit-mock/src/main.rs new file mode 100644 index 0000000..bca3e89 --- /dev/null +++ b/crates/upbit-mock/src/main.rs @@ -0,0 +1,20 @@ +use std::env; +use std::process::ExitCode; +use upbit_mock::{run_blocking_server, ServerConfig}; + +fn main() -> ExitCode { + let mut args = env::args().skip(1); + let addr = args.next().unwrap_or_else(|| "127.0.0.1:8001".to_owned()); + let repo_root = args + .next() + .map(Into::into) + .unwrap_or_else(|| env::current_dir().expect("current directory should be readable")); + + match run_blocking_server(ServerConfig::new(addr, repo_root)) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::FAILURE + } + } +} From d5fa02acf13b36a25c4382129653e1c4b43cdf2a Mon Sep 17 00:00:00 2001 From: Bogyeong Kim Date: Fri, 29 May 2026 21:09:05 +0900 Subject: [PATCH 03/13] Add SDK core auth config and errors Co-authored-by: multica-agent --- Cargo.lock | 1697 ++++++++++++++++++++++++++++++++ crates/upbit-sdk/Cargo.toml | 11 + crates/upbit-sdk/src/auth.rs | 156 +++ crates/upbit-sdk/src/client.rs | 123 +++ crates/upbit-sdk/src/config.rs | 261 +++++ crates/upbit-sdk/src/error.rs | 206 ++++ crates/upbit-sdk/src/lib.rs | 12 + crates/upbit-sdk/src/query.rs | 207 ++++ 8 files changed, 2673 insertions(+) create mode 100644 crates/upbit-sdk/src/auth.rs create mode 100644 crates/upbit-sdk/src/client.rs create mode 100644 crates/upbit-sdk/src/config.rs create mode 100644 crates/upbit-sdk/src/error.rs create mode 100644 crates/upbit-sdk/src/query.rs diff --git a/Cargo.lock b/Cargo.lock index 2c85cad..a11a5a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,1135 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "upbit-mock" version = "0.1.0" @@ -9,3 +1138,571 @@ version = "0.1.0" [[package]] name = "upbit-sdk" version = "0.1.0" +dependencies = [ + "base64", + "hmac", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "url", + "uuid", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/upbit-sdk/Cargo.toml b/crates/upbit-sdk/Cargo.toml index 4d04738..6111ac2 100644 --- a/crates/upbit-sdk/Cargo.toml +++ b/crates/upbit-sdk/Cargo.toml @@ -11,5 +11,16 @@ description = "Rust SDK foundation for the Upbit REST API." name = "upbit_sdk" path = "src/lib.rs" +[dependencies] +base64 = "0.22" +hmac = "0.12" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "1" +url = "2" +uuid = { version = "1", features = ["v4"] } + [lints] workspace = true diff --git a/crates/upbit-sdk/src/auth.rs b/crates/upbit-sdk/src/auth.rs new file mode 100644 index 0000000..5c18d14 --- /dev/null +++ b/crates/upbit-sdk/src/auth.rs @@ -0,0 +1,156 @@ +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha512}; +use uuid::Uuid; + +use crate::config::Credentials; +use crate::error::SdkError; + +type HmacSha512 = Hmac; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct JwtClaims { + pub access_key: String, + pub nonce: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub query_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub query_hash_alg: Option, +} + +#[derive(Clone, Debug)] +pub struct JwtSigner { + credentials: Credentials, +} + +impl JwtSigner { + #[must_use] + pub const fn new(credentials: Credentials) -> Self { + Self { credentials } + } + + pub fn sign(&self, query_string: Option<&str>) -> Result { + let nonce = Uuid::new_v4().to_string(); + self.sign_with_nonce(query_string, nonce) + } + + pub fn sign_with_nonce( + &self, + query_string: Option<&str>, + nonce: impl Into, + ) -> Result { + let claims = JwtClaims::new(self.credentials.access_key(), nonce, query_string); + encode_hs512(&claims, self.credentials.secret_key()) + } +} + +impl JwtClaims { + #[must_use] + pub fn new( + access_key: impl Into, + nonce: impl Into, + query_string: Option<&str>, + ) -> Self { + let query_hash = query_string + .filter(|query| !query.is_empty()) + .map(sha512_hex); + let query_hash_alg = query_hash.as_ref().map(|_| "SHA512".to_string()); + + Self { + access_key: access_key.into(), + nonce: nonce.into(), + query_hash, + query_hash_alg, + } + } +} + +fn encode_hs512(claims: &JwtClaims, secret_key: &str) -> Result { + let header = serde_json::json!({ + "alg": "HS512", + "typ": "JWT", + }); + let encoded_header = encode_json_part(&header)?; + let encoded_claims = encode_json_part(claims)?; + let signing_input = format!("{encoded_header}.{encoded_claims}"); + let mut mac = HmacSha512::new_from_slice(secret_key.as_bytes()) + .map_err(|error| SdkError::Auth(format!("failed to initialize JWT signer: {error}")))?; + mac.update(signing_input.as_bytes()); + let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + + Ok(format!("{signing_input}.{signature}")) +} + +fn encode_json_part(value: &T) -> Result +where + T: Serialize, +{ + let bytes = serde_json::to_vec(value)?; + Ok(URL_SAFE_NO_PAD.encode(bytes)) +} + +fn sha512_hex(value: &str) -> String { + let digest = Sha512::digest(value.as_bytes()); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn decode_claims(token: &str) -> JwtClaims { + let claims = token.split('.').nth(1).expect("JWT must contain claims"); + let decoded = URL_SAFE_NO_PAD.decode(claims).unwrap(); + serde_json::from_slice(&decoded).unwrap() + } + + #[test] + fn jwt_without_query_includes_nonce_and_no_query_hash() { + let credentials = Credentials::new("test-access", "test-secret").unwrap(); + let signer = JwtSigner::new(credentials); + + let token = signer + .sign_with_nonce(None, "00000000-0000-4000-8000-000000000000") + .unwrap(); + let claims = decode_claims(&token); + + assert_eq!(claims.access_key, "test-access"); + assert_eq!(claims.nonce, "00000000-0000-4000-8000-000000000000"); + assert_eq!(claims.query_hash, None); + assert_eq!(claims.query_hash_alg, None); + } + + #[test] + fn jwt_with_query_hashes_exact_query_string_using_sha512() { + let credentials = Credentials::new("test-access", "test-secret").unwrap(); + let signer = JwtSigner::new(credentials); + let query = "market=KRW-BTC&states[]=wait&states[]=watch&limit=10"; + + let token = signer + .sign_with_nonce(Some(query), "00000000-0000-4000-8000-000000000001") + .unwrap(); + let claims = decode_claims(&token); + + assert_eq!( + claims.query_hash.as_deref(), + Some("e3cfc649139c595e1c26a8aa2b3c8504f4b15011fc2b819081451e5e845172bd5dbbb5110ec5d7a3d1d32ff71f46a78323a040e8bedf8672021fd2206190a3a8") + ); + assert_eq!(claims.query_hash_alg.as_deref(), Some("SHA512")); + } + + #[test] + fn jwt_signature_is_hs512_and_not_a_plain_secret_leak() { + let credentials = Credentials::new("access", "super-secret").unwrap(); + let signer = JwtSigner::new(credentials); + + let token = signer.sign_with_nonce(None, "nonce").unwrap(); + let header = token.split('.').next().unwrap(); + let decoded = URL_SAFE_NO_PAD.decode(header).unwrap(); + let header: serde_json::Value = serde_json::from_slice(&decoded).unwrap(); + + assert_eq!(header["alg"], "HS512"); + assert!(!token.contains("super-secret")); + } +} diff --git a/crates/upbit-sdk/src/client.rs b/crates/upbit-sdk/src/client.rs new file mode 100644 index 0000000..9320781 --- /dev/null +++ b/crates/upbit-sdk/src/client.rs @@ -0,0 +1,123 @@ +use reqwest::header::{HeaderValue, AUTHORIZATION, USER_AGENT}; +use reqwest::Method; +use serde::de::DeserializeOwned; +use serde::Serialize; + +use crate::auth::JwtSigner; +use crate::config::UpbitConfig; +use crate::error::{map_response_error, SdkError}; +use crate::query::{json_body_to_query_string, QueryParams}; + +#[derive(Clone, Debug)] +pub struct UpbitClient { + config: UpbitConfig, + http: reqwest::Client, +} + +impl UpbitClient { + pub fn new(config: UpbitConfig) -> Result { + let user_agent = HeaderValue::from_str(config.user_agent()) + .map_err(|error| SdkError::Config(format!("invalid user agent header: {error}")))?; + let http = reqwest::Client::builder() + .timeout(config.timeout()) + .user_agent(user_agent) + .build()?; + + Ok(Self { config, http }) + } + + #[must_use] + pub fn config(&self) -> &UpbitConfig { + &self.config + } + + #[must_use] + pub fn http_client(&self) -> &reqwest::Client { + &self.http + } + + pub async fn request_json( + &self, + method: Method, + path: &str, + query: Option<&QueryParams>, + body: Option<&B>, + auth_required: bool, + ) -> Result + where + T: DeserializeOwned, + B: Serialize + ?Sized, + { + let mut url = self.url_for(path)?; + let query_string = match (query.filter(|query| !query.is_empty()), body) { + (Some(query), _) => Some(query.to_query_string()), + (None, Some(body)) => json_body_to_query_string(body)?, + (None, None) => None, + }; + if let Some(query) = query { + query.append_to_url(&mut url); + } + + let mut request = self.http.request(method, url); + + if let Some(body) = body { + request = request.json(body); + } + + if auth_required { + let credentials = self.config.credentials().ok_or_else(|| { + SdkError::Auth("credentials are required for this endpoint".into()) + })?; + let token = JwtSigner::new(credentials.clone()).sign(query_string.as_deref())?; + request = request.header(AUTHORIZATION, format!("Bearer {token}")); + } + + request = request.header(USER_AGENT, self.config.user_agent()); + + let response = request.send().await?; + let status = response.status(); + if !status.is_success() { + return Err(map_response_error(response).await); + } + + response.json::().await.map_err(SdkError::Transport) + } + + fn url_for(&self, path: &str) -> Result { + let path = path.strip_prefix('/').unwrap_or(path); + let mut url = self.config.base_url().clone(); + let base_path = url.path().trim_end_matches('/'); + url.set_path(&format!("{base_path}/{path}")); + Ok(url) + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::config::{Credentials, UpbitConfig}; + + #[test] + fn builds_client_with_reusable_underlying_reqwest_client() { + let config = UpbitConfig::builder() + .base_url("http://127.0.0.1:8080/v1") + .unwrap() + .credentials(Credentials::new("access", "secret").unwrap()) + .timeout(Duration::from_secs(3)) + .unwrap() + .build() + .unwrap(); + + let client = UpbitClient::new(config).unwrap(); + + assert_eq!( + client.config().base_url().as_str(), + "http://127.0.0.1:8080/v1" + ); + let first = client.http_client() as *const reqwest::Client; + let second = client.http_client() as *const reqwest::Client; + assert_eq!(first, second); + } +} diff --git a/crates/upbit-sdk/src/config.rs b/crates/upbit-sdk/src/config.rs new file mode 100644 index 0000000..abeba64 --- /dev/null +++ b/crates/upbit-sdk/src/config.rs @@ -0,0 +1,261 @@ +use std::fmt; +use std::time::Duration; + +use url::Url; + +use crate::error::SdkError; +use crate::REST_BASE_URL; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +const DEFAULT_USER_AGENT: &str = concat!("upbit-sdk-rust/", env!("CARGO_PKG_VERSION")); + +#[derive(Clone, Eq, PartialEq)] +pub struct SecretValue(String); + +impl SecretValue { + /// Creates a secret string wrapper that redacts formatter output. + /// + /// Empty or whitespace-only values are rejected so callers fail before + /// constructing an authenticated client. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.trim().is_empty() { + return Err(SdkError::Config("secret value cannot be empty".into())); + } + Ok(Self(value)) + } + + pub(crate) fn expose_secret(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SecretValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SecretValue([REDACTED])") + } +} + +impl fmt::Display for SecretValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("[REDACTED]") + } +} + +#[derive(Clone, Eq, PartialEq)] +pub struct Credentials { + access_key: SecretValue, + secret_key: SecretValue, +} + +impl Credentials { + /// Builds Upbit API credentials. + pub fn new( + access_key: impl Into, + secret_key: impl Into, + ) -> Result { + Ok(Self { + access_key: SecretValue::new(access_key)?, + secret_key: SecretValue::new(secret_key)?, + }) + } + + pub(crate) fn access_key(&self) -> &str { + self.access_key.expose_secret() + } + + pub(crate) fn secret_key(&self) -> &str { + self.secret_key.expose_secret() + } +} + +impl fmt::Debug for Credentials { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Credentials") + .field("access_key", &"[REDACTED]") + .field("secret_key", &"[REDACTED]") + .finish() + } +} + +#[derive(Clone, Debug)] +pub struct UpbitConfig { + base_url: Url, + credentials: Option, + timeout: Duration, + user_agent: String, + retry_enabled: bool, + fallback_enabled: bool, +} + +impl UpbitConfig { + /// Starts a config builder with conservative defaults. + #[must_use] + pub fn builder() -> UpbitConfigBuilder { + UpbitConfigBuilder::default() + } + + #[must_use] + pub fn base_url(&self) -> &Url { + &self.base_url + } + + #[must_use] + pub fn credentials(&self) -> Option<&Credentials> { + self.credentials.as_ref() + } + + #[must_use] + pub const fn timeout(&self) -> Duration { + self.timeout + } + + #[must_use] + pub fn user_agent(&self) -> &str { + &self.user_agent + } + + #[must_use] + pub const fn retry_enabled(&self) -> bool { + self.retry_enabled + } + + #[must_use] + pub const fn fallback_enabled(&self) -> bool { + self.fallback_enabled + } +} + +impl Default for UpbitConfig { + fn default() -> Self { + Self::builder() + .build() + .expect("default Upbit config must be valid") + } +} + +#[derive(Clone, Debug)] +pub struct UpbitConfigBuilder { + base_url: Url, + credentials: Option, + timeout: Duration, + user_agent: String, + retry_enabled: bool, + fallback_enabled: bool, +} + +impl UpbitConfigBuilder { + pub fn base_url(mut self, base_url: impl AsRef) -> Result { + let parsed = Url::parse(base_url.as_ref()) + .map_err(|error| SdkError::Config(format!("invalid base URL: {error}")))?; + if parsed.scheme() != "https" && parsed.scheme() != "http" { + return Err(SdkError::Config("base URL must use http or https".into())); + } + self.base_url = parsed; + Ok(self) + } + + #[must_use] + pub fn credentials(mut self, credentials: Credentials) -> Self { + self.credentials = Some(credentials); + self + } + + pub fn timeout(mut self, timeout: Duration) -> Result { + if timeout.is_zero() { + return Err(SdkError::Config("timeout must be greater than zero".into())); + } + self.timeout = timeout; + Ok(self) + } + + pub fn user_agent(mut self, user_agent: impl Into) -> Result { + let user_agent = user_agent.into(); + if user_agent.trim().is_empty() { + return Err(SdkError::Config("user agent cannot be empty".into())); + } + self.user_agent = user_agent; + Ok(self) + } + + #[must_use] + pub const fn retry_enabled(mut self, retry_enabled: bool) -> Self { + self.retry_enabled = retry_enabled; + self + } + + #[must_use] + pub const fn fallback_enabled(mut self, fallback_enabled: bool) -> Self { + self.fallback_enabled = fallback_enabled; + self + } + + pub fn build(self) -> Result { + Ok(UpbitConfig { + base_url: self.base_url, + credentials: self.credentials, + timeout: self.timeout, + user_agent: self.user_agent, + retry_enabled: self.retry_enabled, + fallback_enabled: self.fallback_enabled, + }) + } +} + +impl Default for UpbitConfigBuilder { + fn default() -> Self { + Self { + base_url: Url::parse(REST_BASE_URL).expect("REST_BASE_URL must be valid"), + credentials: None, + timeout: DEFAULT_TIMEOUT, + user_agent: DEFAULT_USER_AGENT.into(), + retry_enabled: false, + fallback_enabled: false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_is_credential_free_and_conservative() { + let config = UpbitConfig::default(); + + assert_eq!(config.base_url().as_str(), "https://api.upbit.com/v1"); + assert!(config.credentials().is_none()); + assert_eq!(config.timeout(), Duration::from_secs(10)); + assert!(!config.retry_enabled()); + assert!(!config.fallback_enabled()); + } + + #[test] + fn rejects_invalid_config_values() { + assert!(UpbitConfig::builder().base_url("not a url").is_err()); + assert!(UpbitConfig::builder().timeout(Duration::ZERO).is_err()); + assert!(UpbitConfig::builder().user_agent(" ").is_err()); + assert!(Credentials::new("", "secret").is_err()); + assert!(Credentials::new("access", "").is_err()); + } + + #[test] + fn redacts_credentials_from_formatters() { + let credentials = Credentials::new("access-key", "secret-key").unwrap(); + let config = UpbitConfig::builder() + .credentials(credentials.clone()) + .build() + .unwrap(); + + let credentials_debug = format!("{credentials:?}"); + let config_debug = format!("{config:?}"); + let secret_display = credentials.secret_key.to_string(); + + assert!(!credentials_debug.contains("access-key")); + assert!(!credentials_debug.contains("secret-key")); + assert!(!config_debug.contains("access-key")); + assert!(!config_debug.contains("secret-key")); + assert_eq!(secret_display, "[REDACTED]"); + } +} diff --git a/crates/upbit-sdk/src/error.rs b/crates/upbit-sdk/src/error.rs new file mode 100644 index 0000000..8075d59 --- /dev/null +++ b/crates/upbit-sdk/src/error.rs @@ -0,0 +1,206 @@ +use std::time::Duration; + +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ErrorResponse { + pub error: UpbitApiError, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct UpbitApiError { + pub name: String, + pub message: String, +} + +#[derive(Debug, Error)] +pub enum SdkError { + #[error("configuration error: {0}")] + Config(String), + + #[error("authentication error: {0}")] + Auth(String), + + #[error("transport error: {0}")] + Transport(#[from] reqwest::Error), + + #[error("serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("rate limited with status {status}")] + RateLimited { + status: StatusCode, + retry_after: Option, + upbit_error: Option, + body: Option, + }, + + #[error("Upbit API error {status}: {upbit_error:?}")] + Upbit { + status: StatusCode, + upbit_error: UpbitApiError, + }, + + #[error("HTTP status error {status}")] + HttpStatus { + status: StatusCode, + body: Option, + }, +} + +impl SdkError { + #[must_use] + pub fn status(&self) -> Option { + match self { + Self::RateLimited { status, .. } + | Self::Upbit { status, .. } + | Self::HttpStatus { status, .. } => Some(*status), + Self::Config(_) | Self::Auth(_) | Self::Transport(_) | Self::Serialization(_) => None, + } + } + + #[must_use] + pub fn is_retryable(&self) -> bool { + match self { + Self::RateLimited { .. } | Self::Transport(_) => true, + Self::HttpStatus { status, .. } => status.is_server_error(), + Self::Upbit { status, .. } => status.is_server_error(), + Self::Config(_) | Self::Auth(_) | Self::Serialization(_) => false, + } + } +} + +pub(crate) async fn map_response_error(response: reqwest::Response) -> SdkError { + let status = response.status(); + let retry_after = parse_retry_after(response.headers().get(reqwest::header::RETRY_AFTER)); + let text = match response.text().await { + Ok(text) if !text.is_empty() => Some(text), + Ok(_) => None, + Err(error) => return SdkError::Transport(error), + }; + map_error_parts(status, retry_after, text) +} + +fn map_error_parts( + status: StatusCode, + retry_after: Option, + text: Option, +) -> SdkError { + let upbit_error = text + .as_deref() + .and_then(|body| serde_json::from_str::(body).ok()) + .map(|response| response.error); + + if status == StatusCode::TOO_MANY_REQUESTS { + return SdkError::RateLimited { + status, + retry_after, + upbit_error, + body: text, + }; + } + + if let Some(upbit_error) = upbit_error { + return SdkError::Upbit { + status, + upbit_error, + }; + } + + SdkError::HttpStatus { status, body: text } +} + +fn parse_retry_after(value: Option<&reqwest::header::HeaderValue>) -> Option { + value + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .map(Duration::from_secs) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn typed_errors_report_status_and_retryability() { + let rate_limited = SdkError::RateLimited { + status: StatusCode::TOO_MANY_REQUESTS, + retry_after: Some(Duration::from_secs(2)), + upbit_error: Some(UpbitApiError { + name: "too_many_requests".into(), + message: "slow down".into(), + }), + body: None, + }; + let server_error = SdkError::HttpStatus { + status: StatusCode::BAD_GATEWAY, + body: None, + }; + let bad_request = SdkError::Upbit { + status: StatusCode::BAD_REQUEST, + upbit_error: UpbitApiError { + name: "invalid_query_payload".into(), + message: "query_hash mismatch".into(), + }, + }; + + assert_eq!(rate_limited.status(), Some(StatusCode::TOO_MANY_REQUESTS)); + assert!(rate_limited.is_retryable()); + assert!(server_error.is_retryable()); + assert!(!bad_request.is_retryable()); + } + + #[test] + fn upbit_error_body_deserializes() { + let body = r#"{"error":{"name":"invalid_access_key","message":"Access key is missing"}}"#; + let parsed: ErrorResponse = serde_json::from_str(body).unwrap(); + + assert_eq!(parsed.error.name, "invalid_access_key"); + assert_eq!(parsed.error.message, "Access key is missing"); + } + + #[test] + fn maps_upbit_error_envelope_to_typed_error() { + let body = r#"{"error":{"name":"invalid_query_payload","message":"query_hash mismatch"}}"#; + + let error = map_error_parts(StatusCode::BAD_REQUEST, None, Some(body.into())); + + match error { + SdkError::Upbit { + status, + upbit_error, + } => { + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(upbit_error.name, "invalid_query_payload"); + } + other => panic!("expected typed Upbit error, got {other:?}"), + } + } + + #[test] + fn maps_429_to_rate_limited_with_retry_after() { + let body = r#"{"error":{"name":"too_many_requests","message":"request limit exceeded"}}"#; + + let error = map_error_parts( + StatusCode::TOO_MANY_REQUESTS, + Some(Duration::from_secs(7)), + Some(body.into()), + ); + + match error { + SdkError::RateLimited { + status, + retry_after, + upbit_error, + .. + } => { + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(retry_after, Some(Duration::from_secs(7))); + assert_eq!(upbit_error.unwrap().name, "too_many_requests"); + } + other => panic!("expected typed rate limit error, got {other:?}"), + } + } +} diff --git a/crates/upbit-sdk/src/lib.rs b/crates/upbit-sdk/src/lib.rs index ef0ffce..239eda9 100644 --- a/crates/upbit-sdk/src/lib.rs +++ b/crates/upbit-sdk/src/lib.rs @@ -4,6 +4,18 @@ //! root. SDK request/response types should be generated or implemented against //! that file instead of scraping the public docs during builds. +pub mod auth; +pub mod client; +pub mod config; +pub mod error; +pub mod query; + +pub use auth::{JwtClaims, JwtSigner}; +pub use client::UpbitClient; +pub use config::{Credentials, SecretValue, UpbitConfig, UpbitConfigBuilder}; +pub use error::{ErrorResponse, SdkError, UpbitApiError}; +pub use query::{QueryParams, QueryValue}; + /// Default Upbit REST API base URL. pub const REST_BASE_URL: &str = "https://api.upbit.com/v1"; diff --git a/crates/upbit-sdk/src/query.rs b/crates/upbit-sdk/src/query.rs new file mode 100644 index 0000000..6702ac1 --- /dev/null +++ b/crates/upbit-sdk/src/query.rs @@ -0,0 +1,207 @@ +use std::fmt; + +use url::form_urlencoded::Serializer; + +use crate::error::SdkError; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum QueryValue { + Single(String), + Multiple(Vec), +} + +impl QueryValue { + #[must_use] + pub fn single(value: impl Into) -> Self { + Self::Single(value.into()) + } + + #[must_use] + pub fn multiple(values: impl IntoIterator>) -> Self { + Self::Multiple(values.into_iter().map(Into::into).collect()) + } +} + +impl From<&str> for QueryValue { + fn from(value: &str) -> Self { + Self::single(value) + } +} + +impl From for QueryValue { + fn from(value: String) -> Self { + Self::single(value) + } +} + +impl From for QueryValue { + fn from(value: u32) -> Self { + Self::single(value.to_string()) + } +} + +impl From for QueryValue { + fn from(value: u64) -> Self { + Self::single(value.to_string()) + } +} + +impl From for QueryValue { + fn from(value: i64) -> Self { + Self::single(value.to_string()) + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct QueryParams { + pairs: Vec<(String, QueryValue)>, +} + +impl QueryParams { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn push(mut self, key: impl Into, value: impl Into) -> Self { + self.pairs.push((key.into(), value.into())); + self + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.pairs.is_empty() + } + + /// Serializes query parameters in insertion order. + /// + /// Upbit examples hash the exact query string sent on the wire. Repeated + /// array values therefore remain repeated keys, preserving `[]` suffixes in + /// keys such as `states[]` after percent encoding. + #[must_use] + pub fn to_query_string(&self) -> String { + let mut serializer = Serializer::new(String::new()); + for (key, value) in &self.pairs { + match value { + QueryValue::Single(value) => { + serializer.append_pair(key, value); + } + QueryValue::Multiple(values) => { + for value in values { + serializer.append_pair(key, value); + } + } + } + } + serializer.finish().replace("%5B%5D", "[]") + } + + pub(crate) fn append_to_url(&self, url: &mut url::Url) { + let mut pairs = url.query_pairs_mut(); + for (key, value) in &self.pairs { + match value { + QueryValue::Single(value) => { + pairs.append_pair(key, value); + } + QueryValue::Multiple(values) => { + for value in values { + pairs.append_pair(key, value); + } + } + } + } + } +} + +impl fmt::Display for QueryParams { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.to_query_string()) + } +} + +pub(crate) fn json_body_to_query_string(body: &T) -> Result, SdkError> +where + T: serde::Serialize + ?Sized, +{ + let value = serde_json::to_value(body)?; + let Some(object) = value.as_object() else { + return Ok(None); + }; + if object.is_empty() { + return Ok(None); + } + + let mut serializer = Serializer::new(String::new()); + for (key, value) in object { + match value { + serde_json::Value::Null => {} + serde_json::Value::Array(values) => { + for value in values { + serializer.append_pair(key, &json_scalar_to_string(value)); + } + } + value => { + serializer.append_pair(key, &json_scalar_to_string(value)); + } + } + } + + let query_string = serializer.finish().replace("%5B%5D", "[]"); + Ok((!query_string.is_empty()).then_some(query_string)) +} + +fn json_scalar_to_string(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(value) => value.clone(), + serde_json::Value::Number(value) => value.to_string(), + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Null | serde_json::Value::Array(_) | serde_json::Value::Object(_) => { + value.to_string() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serializes_queries_in_stable_insertion_order() { + let query = QueryParams::new() + .push("market", "KRW-BTC") + .push("states[]", QueryValue::multiple(["wait", "watch"])) + .push("limit", 10_u32); + + assert_eq!( + query.to_query_string(), + "market=KRW-BTC&states[]=wait&states[]=watch&limit=10" + ); + } + + #[test] + fn percent_encodes_values_but_preserves_array_key_suffix() { + let query = + QueryParams::new().push("identifier[]", QueryValue::multiple(["one two", "a+b"])); + + assert_eq!( + query.to_query_string(), + "identifier[]=one+two&identifier[]=a%2Bb" + ); + } + + #[test] + fn serializes_flat_json_body_for_auth_hashing() { + let body = serde_json::json!({ + "market": "KRW-BTC", + "side": "bid", + "price": "1000", + "ord_type": "price" + }); + + assert_eq!( + json_body_to_query_string(&body).unwrap().as_deref(), + Some("market=KRW-BTC&ord_type=price&price=1000&side=bid") + ); + } +} From bd8ee9f02296aad9724ed34a7090ca86268c10d6 Mon Sep 17 00:00:00 2001 From: Bogyeong Kim Date: Fri, 29 May 2026 21:15:36 +0900 Subject: [PATCH 04/13] Restrict HTTP base URLs to loopback Co-authored-by: multica-agent --- crates/upbit-sdk/src/client.rs | 1 + crates/upbit-sdk/src/config.rs | 67 ++++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/crates/upbit-sdk/src/client.rs b/crates/upbit-sdk/src/client.rs index 9320781..e52ed30 100644 --- a/crates/upbit-sdk/src/client.rs +++ b/crates/upbit-sdk/src/client.rs @@ -65,6 +65,7 @@ impl UpbitClient { } if auth_required { + self.config.validate_authenticated_transport()?; let credentials = self.config.credentials().ok_or_else(|| { SdkError::Auth("credentials are required for this endpoint".into()) })?; diff --git a/crates/upbit-sdk/src/config.rs b/crates/upbit-sdk/src/config.rs index abeba64..61af90c 100644 --- a/crates/upbit-sdk/src/config.rs +++ b/crates/upbit-sdk/src/config.rs @@ -1,4 +1,5 @@ use std::fmt; +use std::net::IpAddr; use std::time::Duration; use url::Url; @@ -125,6 +126,10 @@ impl UpbitConfig { pub const fn fallback_enabled(&self) -> bool { self.fallback_enabled } + + pub(crate) fn validate_authenticated_transport(&self) -> Result<(), SdkError> { + validate_authenticated_base_url(&self.base_url) + } } impl Default for UpbitConfig { @@ -149,9 +154,7 @@ impl UpbitConfigBuilder { pub fn base_url(mut self, base_url: impl AsRef) -> Result { let parsed = Url::parse(base_url.as_ref()) .map_err(|error| SdkError::Config(format!("invalid base URL: {error}")))?; - if parsed.scheme() != "https" && parsed.scheme() != "http" { - return Err(SdkError::Config("base URL must use http or https".into())); - } + validate_base_url(&parsed)?; self.base_url = parsed; Ok(self) } @@ -203,6 +206,36 @@ impl UpbitConfigBuilder { } } +fn validate_base_url(base_url: &Url) -> Result<(), SdkError> { + match base_url.scheme() { + "https" => Ok(()), + "http" if is_loopback_host(base_url) => Ok(()), + "http" => Err(SdkError::Config( + "HTTP base URL is only allowed for loopback mock/test endpoints".into(), + )), + _ => Err(SdkError::Config( + "base URL must use https, or http for loopback mock/test endpoints".into(), + )), + } +} + +pub(crate) fn validate_authenticated_base_url(base_url: &Url) -> Result<(), SdkError> { + validate_base_url(base_url) +} + +fn is_loopback_host(base_url: &Url) -> bool { + let Some(host) = base_url.host_str() else { + return false; + }; + + let host_without_ipv6_brackets = host.trim_start_matches('[').trim_end_matches(']'); + + host.eq_ignore_ascii_case("localhost") + || host_without_ipv6_brackets + .parse::() + .is_ok_and(|address| address.is_loopback()) +} + impl Default for UpbitConfigBuilder { fn default() -> Self { Self { @@ -234,12 +267,40 @@ mod tests { #[test] fn rejects_invalid_config_values() { assert!(UpbitConfig::builder().base_url("not a url").is_err()); + assert!(UpbitConfig::builder() + .base_url("http://api.upbit.com/v1") + .is_err()); assert!(UpbitConfig::builder().timeout(Duration::ZERO).is_err()); assert!(UpbitConfig::builder().user_agent(" ").is_err()); assert!(Credentials::new("", "secret").is_err()); assert!(Credentials::new("access", "").is_err()); } + #[test] + fn allows_http_only_for_loopback_mock_endpoints() { + for url in [ + "http://localhost:8080/v1", + "http://127.0.0.1:8080/v1", + "http://[::1]:8080/v1", + ] { + let config = UpbitConfig::builder() + .base_url(url) + .unwrap() + .build() + .unwrap(); + assert_eq!(config.base_url().as_str(), url); + } + } + + #[test] + fn authenticated_transport_rejects_remote_http_base_url() { + let remote_http = Url::parse("http://api.upbit.com/v1").unwrap(); + + let error = validate_authenticated_base_url(&remote_http).unwrap_err(); + + assert!(matches!(error, SdkError::Config(message) if message.contains("loopback"))); + } + #[test] fn redacts_credentials_from_formatters() { let credentials = Credentials::new("access-key", "secret-key").unwrap(); From 9791454f0c9946c7492720a144200f45f67de06b Mon Sep 17 00:00:00 2001 From: Bogyeong Kim Date: Fri, 29 May 2026 21:17:21 +0900 Subject: [PATCH 05/13] BOG-236 Enforce mock path parameter enums Co-authored-by: multica-agent --- crates/upbit-mock/README.md | 11 ++ crates/upbit-mock/src/lib.rs | 235 +++++++++++++++++++++++++++++++++-- 2 files changed, 237 insertions(+), 9 deletions(-) diff --git a/crates/upbit-mock/README.md b/crates/upbit-mock/README.md index e157314..c4fbf71 100644 --- a/crates/upbit-mock/README.md +++ b/crates/upbit-mock/README.md @@ -36,6 +36,9 @@ curl 'http://127.0.0.1:8001/v1/ticker?markets=KRW-BTC' - Success fixtures are generated from each endpoint's `response_fields`. - Required request fields are marked with `*` in the spec and return a 400 Upbit-style error envelope when omitted. +- Path parameters declared in the spec are validated for presence in the route + template and enum membership. For example, unsupported minute candle units + return a 400 validation error instead of a success fixture. - Auth-required endpoints return a 401 Upbit-style error envelope when the `Authorization: Bearer ...` header is missing. - Add `__mock_error=rate_limit` to return a deterministic 429 rate-limit @@ -43,6 +46,11 @@ curl 'http://127.0.0.1:8001/v1/ticker?markets=KRW-BTC' - Responses include a `remaining-req` header derived from the endpoint `rate_limit_group`. +Current validation scope is limited to required request fields, declared path +parameter presence, path parameter enum values, bearer auth boundaries, and the +deterministic `__mock_error=rate_limit` branch. Other enum-like request/query +values are not validated unless they are modeled as `path_params` in the spec. + ## Conformance Baseline Run: @@ -55,6 +63,9 @@ The conformance tests fail when: - a REST endpoint lacks a mock route, - duplicate `METHOD path` route keys appear, +- a path template parameter is missing from `path_params`, +- a required `path_params` entry is missing from the route template, +- a path parameter enum accepts a value outside the spec contract, - an endpoint fixture lacks a documented response field, - auth-required endpoints stop returning a 401 without a bearer token, or - the WebSocket-only `list_subscriptions` exception is no longer documented. diff --git a/crates/upbit-mock/src/lib.rs b/crates/upbit-mock/src/lib.rs index 807e2f0..3788c4b 100644 --- a/crates/upbit-mock/src/lib.rs +++ b/crates/upbit-mock/src/lib.rs @@ -60,6 +60,9 @@ pub struct EndpointSpec { pub method: String, /// Path template. pub path: String, + /// Path parameter contracts. + #[serde(default)] + pub path_params: Vec, /// Whether JWT auth is required. pub auth_required: bool, /// Rate-limit group name. @@ -73,6 +76,19 @@ pub struct EndpointSpec { pub response_fields: Vec, } +/// Path parameter contract used by mock route validation. +#[derive(Debug, Clone, Deserialize)] +pub struct PathParamSpec { + /// Parameter name in the endpoint path template. + pub name: String, + /// Whether the parameter is required. + #[serde(default)] + pub required: bool, + /// Allowed values for enum-constrained parameters. + #[serde(default, rename = "enum")] + pub enum_values: Vec, +} + impl EndpointSpec { /// Returns true for REST endpoints. #[must_use] @@ -221,6 +237,10 @@ impl MockServerSpec { return error_response(429, "too_many_requests", "Rate limit exceeded."); } + if let Err(message) = validate_path_params(endpoint, path) { + return error_response(400, "validation_error", &message); + } + let missing = missing_required_fields(endpoint, &request); if !missing.is_empty() { return error_response( @@ -277,6 +297,8 @@ impl MockServerSpec { ))); } + assert_path_param_contract(endpoint)?; + if endpoint.response_fields.is_empty() { return Err(MockError::Conformance(format!( "{} has no representative response fields", @@ -297,6 +319,28 @@ impl MockServerSpec { ))); } } + + for param in &endpoint.path_params { + if param.enum_values.is_empty() { + continue; + } + + let invalid_path = example_path_with_override( + endpoint, + ¶m.name, + &invalid_path_enum_candidate(param), + ); + let invalid_response = + self.dispatch(request_with_required_fields(endpoint, invalid_path)); + if invalid_response.status != 400 + || invalid_response.body["error"]["name"] != "validation_error" + { + return Err(MockError::Conformance(format!( + "{} path parameter {} enum is not enforced", + endpoint.id, param.name + ))); + } + } } if self @@ -480,24 +524,183 @@ fn split_path_and_query(target: &str) -> (String, BTreeMap) { } fn path_matches(template: &str, request_path: &str) -> bool { + extract_path_params(template, request_path).is_some() +} + +fn extract_path_params(template: &str, request_path: &str) -> Option> { let template_parts: Vec<&str> = template.trim_matches('/').split('/').collect(); let request_parts: Vec<&str> = request_path.trim_matches('/').split('/').collect(); - template_parts.len() == request_parts.len() - && template_parts - .iter() - .zip(request_parts) - .all(|(template_part, request_part)| { - (template_part.starts_with('{') && template_part.ends_with('}')) - || *template_part == request_part - }) + if template_parts.len() != request_parts.len() { + return None; + } + + let mut params = BTreeMap::new(); + + for (template_part, request_part) in template_parts.iter().zip(request_parts) { + if let Some(name) = path_param_name(template_part) { + params.insert(name.to_owned(), request_part.to_owned()); + } else if *template_part != request_part { + return None; + } + } + + Some(params) +} + +fn path_param_name(template_part: &str) -> Option<&str> { + template_part + .strip_prefix('{') + .and_then(|name| name.strip_suffix('}')) + .filter(|name| !name.is_empty()) +} + +fn validate_path_params(endpoint: &EndpointSpec, request_path: &str) -> Result<(), String> { + let params = extract_path_params(&endpoint.path, request_path) + .ok_or_else(|| "Request path does not match endpoint template.".to_owned())?; + + for param in &endpoint.path_params { + let value = params.get(¶m.name); + if param.required && value.is_none() { + return Err(format!("Missing required path parameter: {}", param.name)); + } + + if let Some(value) = value { + if !param.enum_values.is_empty() + && !param + .enum_values + .iter() + .any(|allowed| path_enum_value_matches(allowed, value)) + { + return Err(format!( + "Invalid path parameter {}: {} is not one of [{}]", + param.name, + value, + param + .enum_values + .iter() + .map(path_enum_value_as_string) + .collect::>() + .join(", ") + )); + } + } + } + + Ok(()) +} + +fn path_enum_value_matches(allowed: &Value, actual: &str) -> bool { + path_enum_value_as_string(allowed) == actual +} + +fn path_enum_value_as_string(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + Value::Number(value) => value.to_string(), + Value::Bool(value) => value.to_string(), + _ => value.to_string(), + } } fn example_path(endpoint: &EndpointSpec) -> String { - let path = endpoint.path.replace("{unit}", "1"); + example_path_with_override(endpoint, "", "") +} + +fn example_path_with_override( + endpoint: &EndpointSpec, + override_name: &str, + override_value: &str, +) -> String { + let mut path = endpoint.path.clone(); + for param in &endpoint.path_params { + let replacement = if param.name == override_name { + override_value.to_owned() + } else { + param + .enum_values + .first() + .map(path_enum_value_as_string) + .unwrap_or_else(|| "mock".to_owned()) + }; + path = path.replace(&format!("{{{}}}", param.name), &replacement); + } format!("{API_PREFIX}{path}") } +fn invalid_path_enum_candidate(param: &PathParamSpec) -> String { + let mut candidate = "__invalid__".to_owned(); + while param + .enum_values + .iter() + .any(|allowed| path_enum_value_matches(allowed, &candidate)) + { + candidate.push('_'); + } + candidate +} + +fn request_with_required_fields(endpoint: &EndpointSpec, path: String) -> MockRequest { + let mut request = MockRequest::new(&endpoint.method, path); + if endpoint.auth_required { + request = request.with_header("authorization", "Bearer test.jwt"); + } + + for field in endpoint.required_fields() { + request = request.with_query(field, "mock"); + } + + request +} + +fn assert_path_param_contract(endpoint: &EndpointSpec) -> Result<(), MockError> { + let template_params: BTreeSet = endpoint + .path + .trim_matches('/') + .split('/') + .filter_map(path_param_name) + .map(ToOwned::to_owned) + .collect(); + + for name in &template_params { + if !endpoint + .path_params + .iter() + .any(|param| param.name.as_str() == name) + { + return Err(MockError::Conformance(format!( + "{} template path parameter {name} is missing from path_params", + endpoint.id + ))); + } + } + + for param in &endpoint.path_params { + if param.required && !template_params.contains(¶m.name) { + return Err(MockError::Conformance(format!( + "{} required path parameter {} is not present in path template", + endpoint.id, param.name + ))); + } + + let example = example_path(endpoint); + let Some(example_without_prefix) = example.strip_prefix(API_PREFIX) else { + return Err(MockError::Conformance(format!( + "{} example path is missing API prefix", + endpoint.id + ))); + }; + validate_path_params(endpoint, example_without_prefix).map_err(|message| { + MockError::Conformance(format!( + "{} path parameter contract failed: {message}", + endpoint.id + )) + })?; + } + + Ok(()) +} + fn has_bearer_token(headers: &BTreeMap) -> bool { headers .get("authorization") @@ -803,6 +1006,20 @@ mod tests { assert_eq!(response.body["error"]["name"], "validation_error"); } + #[test] + fn path_param_enums_are_validated() { + let response = mock().dispatch( + MockRequest::new("GET", "/v1/candles/minutes/999").with_query("market", "KRW-BTC"), + ); + + assert_eq!(response.status, 400); + assert_eq!(response.body["error"]["name"], "validation_error"); + assert!(response.body["error"]["message"] + .as_str() + .expect("error message") + .contains("Invalid path parameter unit")); + } + #[test] fn authenticated_request_can_return_rate_limit_error_fixture() { let response = mock().dispatch( From b1e9ac2fb9a82520a79192c44489f7b1df37c07e Mon Sep 17 00:00:00 2001 From: Bogyeong Kim Date: Fri, 29 May 2026 21:30:17 +0900 Subject: [PATCH 06/13] Add retry fallback and redacted logging controls Co-authored-by: multica-agent --- Cargo.lock | 26 ++ README.md | 20 ++ crates/upbit-sdk/Cargo.toml | 5 + crates/upbit-sdk/src/client.rs | 556 +++++++++++++++++++++++++++++++- crates/upbit-sdk/src/config.rs | 314 +++++++++++++++++- crates/upbit-sdk/src/error.rs | 13 + crates/upbit-sdk/src/lib.rs | 6 +- crates/upbit-sdk/src/logging.rs | 145 +++++++++ 8 files changed, 1058 insertions(+), 27 deletions(-) create mode 100644 crates/upbit-sdk/src/logging.rs diff --git a/Cargo.lock b/Cargo.lock index a11a5a8..b16e8d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1024,9 +1024,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -1089,9 +1101,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1146,6 +1170,8 @@ dependencies = [ "serde_json", "sha2", "thiserror 1.0.69", + "tokio", + "tracing", "url", "uuid", ] diff --git a/README.md b/README.md index 7d39bec..6817f6b 100644 --- a/README.md +++ b/README.md @@ -12,3 +12,23 @@ Rust workspace for an Upbit SDK and related test tooling. The REST spec is the shared contract for SDK request/response types and mock server route fixtures. + +## Safety Defaults + +`UpbitConfig::default()` is credential-free and conservative: + +- automatic retry is disabled unless `retry_enabled(true)` or `RetryConfig` is + supplied; +- fallback routing is disabled unless `fallback_enabled(true)` and an explicit + fallback base URL are supplied; +- unsafe order-mutating requests are not retried or sent to fallback endpoints + unless the caller explicitly allows that behavior; +- authenticated requests are not sent to fallback endpoints unless explicitly + allowed; +- SDK trace fields redact URL query values that look like credentials, JWTs, + account identifiers, order identifiers, price, or volume, and request bodies + are not emitted by the client. + +The client owns one reusable `reqwest::Client` per `UpbitClient` instance, so +retry and fallback attempts rebuild request objects while preserving the +underlying HTTP client's connection pooling. diff --git a/crates/upbit-sdk/Cargo.toml b/crates/upbit-sdk/Cargo.toml index 6111ac2..617eeb1 100644 --- a/crates/upbit-sdk/Cargo.toml +++ b/crates/upbit-sdk/Cargo.toml @@ -19,8 +19,13 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" thiserror = "1" +tokio = { version = "1", features = ["time"] } +tracing = "0.1" url = "2" uuid = { version = "1", features = ["v4"] } +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } + [lints] workspace = true diff --git a/crates/upbit-sdk/src/client.rs b/crates/upbit-sdk/src/client.rs index e52ed30..368ab36 100644 --- a/crates/upbit-sdk/src/client.rs +++ b/crates/upbit-sdk/src/client.rs @@ -2,10 +2,12 @@ use reqwest::header::{HeaderValue, AUTHORIZATION, USER_AGENT}; use reqwest::Method; use serde::de::DeserializeOwned; use serde::Serialize; +use std::time::Duration; use crate::auth::JwtSigner; use crate::config::UpbitConfig; use crate::error::{map_response_error, SdkError}; +use crate::logging::redact_url; use crate::query::{json_body_to_query_string, QueryParams}; #[derive(Clone, Debug)] @@ -14,6 +16,16 @@ pub struct UpbitClient { http: reqwest::Client, } +struct JsonRequest<'a, B: ?Sized> { + method: Method, + url: url::Url, + query: Option<&'a QueryParams>, + body: Option<&'a B>, + query_string: Option<&'a str>, + auth_required: bool, + is_fallback: bool, +} + impl UpbitClient { pub fn new(config: UpbitConfig) -> Result { let user_agent = HeaderValue::from_str(config.user_agent()) @@ -48,34 +60,115 @@ impl UpbitClient { T: DeserializeOwned, B: Serialize + ?Sized, { - let mut url = self.url_for(path)?; let query_string = match (query.filter(|query| !query.is_empty()), body) { (Some(query), _) => Some(query.to_query_string()), (None, Some(body)) => json_body_to_query_string(body)?, (None, None) => None, }; - if let Some(query) = query { + + let mut last_error = None; + for (url, is_fallback) in self.request_urls(path, &method, auth_required)? { + let request = JsonRequest { + method: method.clone(), + url, + query, + body, + query_string: query_string.as_deref(), + auth_required, + is_fallback, + }; + let result = self.request_json_with_retries(request).await; + + match result { + Ok(response) => return Ok(response), + Err(error) if self.should_try_fallback(&method, auth_required, &error) => { + last_error = Some(error); + } + Err(error) => return Err(error), + } + } + + Err(last_error.expect("request_urls always includes the primary URL")) + } + + async fn request_json_with_retries( + &self, + request: JsonRequest<'_, B>, + ) -> Result + where + T: DeserializeOwned, + B: Serialize + ?Sized, + { + let max_attempts = self.max_attempts_for(&request.method); + + for attempt in 1..=max_attempts { + let result = self.send_json_request(&request, attempt).await; + + match result { + Ok(response) => return Ok(response), + Err(error) if attempt < max_attempts && error.is_retryable() => { + let delay = self.retry_delay(&error, attempt - 1); + tracing::warn!( + method = %request.method, + url = %redact_url(&request.url), + attempt, + max_attempts, + fallback = request.is_fallback, + delay_ms = delay.as_millis(), + "retrying Upbit SDK request after retryable failure" + ); + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + } + Err(error) => return Err(error), + } + } + + unreachable!("retry loop always returns") + } + + async fn send_json_request( + &self, + request: &JsonRequest<'_, B>, + attempt: usize, + ) -> Result + where + T: DeserializeOwned, + B: Serialize + ?Sized, + { + let mut url = request.url.clone(); + if let Some(query) = request.query { query.append_to_url(&mut url); } - let mut request = self.http.request(method, url); + tracing::debug!( + method = %request.method, + url = %redact_url(&url), + attempt, + fallback = request.is_fallback, + auth_required = request.auth_required, + "sending Upbit SDK request" + ); + + let mut reqwest_request = self.http.request(request.method.clone(), url); - if let Some(body) = body { - request = request.json(body); + if let Some(body) = request.body { + reqwest_request = reqwest_request.json(body); } - if auth_required { + if request.auth_required { self.config.validate_authenticated_transport()?; let credentials = self.config.credentials().ok_or_else(|| { SdkError::Auth("credentials are required for this endpoint".into()) })?; - let token = JwtSigner::new(credentials.clone()).sign(query_string.as_deref())?; - request = request.header(AUTHORIZATION, format!("Bearer {token}")); + let token = JwtSigner::new(credentials.clone()).sign(request.query_string)?; + reqwest_request = reqwest_request.header(AUTHORIZATION, format!("Bearer {token}")); } - request = request.header(USER_AGENT, self.config.user_agent()); + reqwest_request = reqwest_request.header(USER_AGENT, self.config.user_agent()); - let response = request.send().await?; + let response = reqwest_request.send().await?; let status = response.status(); if !status.is_success() { return Err(map_response_error(response).await); @@ -84,21 +177,165 @@ impl UpbitClient { response.json::().await.map_err(SdkError::Transport) } - fn url_for(&self, path: &str) -> Result { + fn request_urls( + &self, + path: &str, + method: &Method, + auth_required: bool, + ) -> Result, SdkError> { + let mut urls = vec![(self.url_for(self.config.base_url(), path)?, false)]; + let fallback = self.config.fallback(); + + if fallback.is_enabled() + && fallback.base_url().is_some() + && method_allowed_for_fallback(method, fallback.allow_unsafe_requests()) + && (!auth_required || fallback.allow_authenticated_requests()) + { + let fallback_base_url = fallback + .base_url() + .expect("checked fallback base URL presence"); + urls.push((self.url_for(fallback_base_url, path)?, true)); + } + + Ok(urls) + } + + fn should_try_fallback(&self, method: &Method, auth_required: bool, error: &SdkError) -> bool { + let fallback = self.config.fallback(); + fallback.is_enabled() + && fallback.base_url().is_some() + && error.is_retryable() + && method_allowed_for_fallback(method, fallback.allow_unsafe_requests()) + && (!auth_required || fallback.allow_authenticated_requests()) + } + + fn max_attempts_for(&self, method: &Method) -> usize { + let retry = self.config.retry(); + if retry.is_enabled() && method_allowed_for_retry(method, retry.retry_unsafe_requests()) { + retry.max_attempts() + } else { + 1 + } + } + + fn retry_delay(&self, error: &SdkError, completed_retries: usize) -> Duration { + let retry = self.config.retry(); + let exponential = retry + .initial_backoff() + .checked_mul(2_u32.saturating_pow(completed_retries as u32)) + .unwrap_or_else(|| retry.max_backoff()); + let delay = error.retry_after().unwrap_or(exponential); + delay.min(retry.max_backoff()) + } + + fn url_for(&self, base_url: &url::Url, path: &str) -> Result { let path = path.strip_prefix('/').unwrap_or(path); - let mut url = self.config.base_url().clone(); + let mut url = base_url.clone(); let base_path = url.path().trim_end_matches('/'); url.set_path(&format!("{base_path}/{path}")); Ok(url) } } +fn method_allowed_for_retry(method: &Method, allow_unsafe_requests: bool) -> bool { + allow_unsafe_requests + || method == Method::GET + || method == Method::HEAD + || method == Method::OPTIONS + || method == Method::TRACE +} + +fn method_allowed_for_fallback(method: &Method, allow_unsafe_requests: bool) -> bool { + method_allowed_for_retry(method, allow_unsafe_requests) +} + #[cfg(test)] mod tests { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; use std::time::Duration; + use std::{str, thread}; use super::*; use crate::config::{Credentials, UpbitConfig}; + use serde_json::json; + + struct TestServer { + base_url: String, + expected_count: usize, + request_count: Arc, + requests: Arc>>, + handle: Option>, + } + + impl TestServer { + fn spawn(responses: Vec<&'static str>) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let expected_count = responses.len(); + let request_count = Arc::new(AtomicUsize::new(0)); + let requests = Arc::new(std::sync::Mutex::new(Vec::new())); + let count_for_thread = Arc::clone(&request_count); + let requests_for_thread = Arc::clone(&requests); + + let handle = thread::spawn(move || { + for response in responses { + let (mut stream, _) = listener.accept().unwrap(); + let mut buffer = [0_u8; 2048]; + let bytes_read = stream.read(&mut buffer).unwrap(); + let request = str::from_utf8(&buffer[..bytes_read]).unwrap().to_string(); + requests_for_thread.lock().unwrap().push(request); + count_for_thread.fetch_add(1, Ordering::SeqCst); + stream.write_all(response.as_bytes()).unwrap(); + } + }); + + Self { + base_url: format!("http://{address}/v1"), + expected_count, + request_count, + requests, + handle: Some(handle), + } + } + + fn count(&self) -> usize { + self.request_count.load(Ordering::SeqCst) + } + + fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } + } + + impl Drop for TestServer { + fn drop(&mut self) { + while self.count() < self.expected_count { + let _ = std::net::TcpStream::connect( + self.base_url + .strip_prefix("http://") + .and_then(|value| value.strip_suffix("/v1")) + .unwrap(), + ); + } + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } + } + + const SERVER_ERROR: &str = + "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + const OK_JSON: &str = concat!( + "HTTP/1.1 200 OK\r\n", + "Content-Type: application/json\r\n", + "Content-Length: 11\r\n", + "Connection: close\r\n", + "\r\n", + "{\"ok\":true}" + ); #[test] fn builds_client_with_reusable_underlying_reqwest_client() { @@ -121,4 +358,299 @@ mod tests { let second = client.http_client() as *const reqwest::Client; assert_eq!(first, second); } + + #[tokio::test] + async fn retry_disabled_does_not_retry_retryable_status() { + let server = TestServer::spawn(vec![SERVER_ERROR]); + let config = UpbitConfig::builder() + .base_url(&server.base_url) + .unwrap() + .build() + .unwrap(); + let client = UpbitClient::new(config).unwrap(); + + let error = client + .request_json::( + Method::GET, + "/accounts", + None, + None, + false, + ) + .await + .unwrap_err(); + + assert_eq!( + error.status(), + Some(reqwest::StatusCode::SERVICE_UNAVAILABLE) + ); + assert_eq!(server.count(), 1); + } + + #[tokio::test] + async fn retry_enabled_retries_safe_get_until_success() { + let server = TestServer::spawn(vec![SERVER_ERROR, OK_JSON]); + let config = UpbitConfig::builder() + .base_url(&server.base_url) + .unwrap() + .retry_enabled(true) + .retry_max_attempts(2) + .unwrap() + .retry_backoff(Duration::ZERO, Duration::ZERO) + .unwrap() + .build() + .unwrap(); + let client = UpbitClient::new(config).unwrap(); + + let response = client + .request_json::( + Method::GET, + "/accounts", + None, + None, + false, + ) + .await + .unwrap(); + + assert_eq!(response, json!({"ok": true})); + assert_eq!(server.count(), 2); + } + + #[tokio::test] + async fn unsafe_post_is_not_retried_by_default() { + let server = TestServer::spawn(vec![SERVER_ERROR]); + let config = UpbitConfig::builder() + .base_url(&server.base_url) + .unwrap() + .retry_enabled(true) + .retry_max_attempts(2) + .unwrap() + .retry_backoff(Duration::ZERO, Duration::ZERO) + .unwrap() + .build() + .unwrap(); + let client = UpbitClient::new(config).unwrap(); + let body = json!({"market": "KRW-BTC", "side": "bid", "price": "1000"}); + + let error = client + .request_json::( + Method::POST, + "/orders", + None, + Some(&body), + false, + ) + .await + .unwrap_err(); + + assert_eq!( + error.status(), + Some(reqwest::StatusCode::SERVICE_UNAVAILABLE) + ); + assert_eq!(server.count(), 1); + } + + #[tokio::test] + async fn unsafe_post_retries_only_when_explicitly_allowed() { + let server = TestServer::spawn(vec![SERVER_ERROR, OK_JSON]); + let config = UpbitConfig::builder() + .base_url(&server.base_url) + .unwrap() + .retry_enabled(true) + .retry_unsafe_requests(true) + .retry_max_attempts(2) + .unwrap() + .retry_backoff(Duration::ZERO, Duration::ZERO) + .unwrap() + .build() + .unwrap(); + let client = UpbitClient::new(config).unwrap(); + let body = json!({"market": "KRW-BTC", "side": "bid", "price": "1000"}); + + let response = client + .request_json::( + Method::POST, + "/orders", + None, + Some(&body), + false, + ) + .await + .unwrap(); + + assert_eq!(response, json!({"ok": true})); + assert_eq!(server.count(), 2); + } + + #[tokio::test] + async fn fallback_is_disabled_by_default() { + let primary = TestServer::spawn(vec![SERVER_ERROR]); + let config = UpbitConfig::builder() + .base_url(&primary.base_url) + .unwrap() + .build() + .unwrap(); + let client = UpbitClient::new(config).unwrap(); + + let error = client + .request_json::( + Method::GET, + "/ticker", + None, + None, + false, + ) + .await + .unwrap_err(); + + assert_eq!( + error.status(), + Some(reqwest::StatusCode::SERVICE_UNAVAILABLE) + ); + assert_eq!(primary.count(), 1); + } + + #[tokio::test] + async fn fallback_enabled_uses_explicit_fallback_base_url_for_safe_requests() { + let primary = TestServer::spawn(vec![SERVER_ERROR]); + let fallback = TestServer::spawn(vec![OK_JSON]); + let config = UpbitConfig::builder() + .base_url(&primary.base_url) + .unwrap() + .fallback_enabled(true) + .fallback_base_url(&fallback.base_url) + .unwrap() + .build() + .unwrap(); + let client = UpbitClient::new(config).unwrap(); + + let response = client + .request_json::( + Method::GET, + "/ticker", + None, + None, + false, + ) + .await + .unwrap(); + + assert_eq!(response, json!({"ok": true})); + assert_eq!(primary.count(), 1); + assert_eq!(fallback.count(), 1); + } + + #[tokio::test] + async fn fallback_does_not_send_unsafe_requests_by_default() { + let primary = TestServer::spawn(vec![SERVER_ERROR]); + let config = UpbitConfig::builder() + .base_url(&primary.base_url) + .unwrap() + .fallback_enabled(true) + .fallback_base_url("http://127.0.0.1:1/v1") + .unwrap() + .build() + .unwrap(); + let client = UpbitClient::new(config).unwrap(); + let body = json!({"market": "KRW-BTC", "side": "bid", "price": "1000"}); + + let error = client + .request_json::( + Method::POST, + "/orders", + None, + Some(&body), + false, + ) + .await + .unwrap_err(); + + assert_eq!( + error.status(), + Some(reqwest::StatusCode::SERVICE_UNAVAILABLE) + ); + assert_eq!(primary.count(), 1); + } + + #[tokio::test] + async fn fallback_does_not_send_authenticated_requests_by_default() { + let primary = TestServer::spawn(vec![SERVER_ERROR]); + let config = UpbitConfig::builder() + .base_url(&primary.base_url) + .unwrap() + .credentials(Credentials::new("access", "secret").unwrap()) + .fallback_enabled(true) + .fallback_base_url("http://127.0.0.1:1/v1") + .unwrap() + .build() + .unwrap(); + let client = UpbitClient::new(config).unwrap(); + + let error = client + .request_json::( + Method::GET, + "/accounts", + None, + None, + true, + ) + .await + .unwrap_err(); + + assert_eq!( + error.status(), + Some(reqwest::StatusCode::SERVICE_UNAVAILABLE) + ); + assert_eq!(primary.count(), 1); + } + + #[tokio::test] + async fn authenticated_request_logs_never_need_raw_query_or_credentials() { + let server = TestServer::spawn(vec![OK_JSON]); + let config = UpbitConfig::builder() + .base_url(&server.base_url) + .unwrap() + .credentials(Credentials::new("test-access-value", "super-secret-value").unwrap()) + .build() + .unwrap(); + let client = UpbitClient::new(config).unwrap(); + + let response = client + .request_json::( + Method::GET, + "/accounts", + None, + None, + true, + ) + .await + .unwrap(); + + assert_eq!(response, json!({"ok": true})); + let requests = server.requests(); + assert!(requests[0].to_ascii_lowercase().contains("authorization:")); + assert!(requests[0].contains("Bearer ")); + assert!(!format!("{:?}", client.config()).contains("test-access-value")); + assert!(!format!("{:?}", client.config()).contains("super-secret-value")); + } + + #[test] + fn retry_delay_respects_retry_after_and_max_backoff() { + let config = UpbitConfig::builder() + .retry_enabled(true) + .retry_backoff(Duration::from_millis(10), Duration::from_millis(50)) + .unwrap() + .build() + .unwrap(); + let client = UpbitClient::new(config).unwrap(); + let error = SdkError::RateLimited { + status: reqwest::StatusCode::TOO_MANY_REQUESTS, + retry_after: Some(Duration::from_secs(5)), + upbit_error: None, + body: None, + }; + + assert_eq!(client.retry_delay(&error, 0), Duration::from_millis(50)); + } } diff --git a/crates/upbit-sdk/src/config.rs b/crates/upbit-sdk/src/config.rs index 61af90c..806de59 100644 --- a/crates/upbit-sdk/src/config.rs +++ b/crates/upbit-sdk/src/config.rs @@ -9,6 +9,9 @@ use crate::REST_BASE_URL; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); const DEFAULT_USER_AGENT: &str = concat!("upbit-sdk-rust/", env!("CARGO_PKG_VERSION")); +const DEFAULT_RETRY_MAX_ATTEMPTS: usize = 3; +const DEFAULT_RETRY_INITIAL_BACKOFF: Duration = Duration::from_millis(100); +const DEFAULT_RETRY_MAX_BACKOFF: Duration = Duration::from_secs(2); #[derive(Clone, Eq, PartialEq)] pub struct SecretValue(String); @@ -86,8 +89,8 @@ pub struct UpbitConfig { credentials: Option, timeout: Duration, user_agent: String, - retry_enabled: bool, - fallback_enabled: bool, + retry: RetryConfig, + fallback: FallbackConfig, } impl UpbitConfig { @@ -119,12 +122,22 @@ impl UpbitConfig { #[must_use] pub const fn retry_enabled(&self) -> bool { - self.retry_enabled + self.retry.is_enabled() } #[must_use] pub const fn fallback_enabled(&self) -> bool { - self.fallback_enabled + self.fallback.is_enabled() + } + + #[must_use] + pub const fn retry(&self) -> &RetryConfig { + &self.retry + } + + #[must_use] + pub const fn fallback(&self) -> &FallbackConfig { + &self.fallback } pub(crate) fn validate_authenticated_transport(&self) -> Result<(), SdkError> { @@ -146,8 +159,183 @@ pub struct UpbitConfigBuilder { credentials: Option, timeout: Duration, user_agent: String, - retry_enabled: bool, - fallback_enabled: bool, + retry: RetryConfig, + fallback: FallbackConfig, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RetryConfig { + enabled: bool, + max_attempts: usize, + initial_backoff: Duration, + max_backoff: Duration, + retry_unsafe_requests: bool, +} + +impl RetryConfig { + #[must_use] + pub const fn disabled() -> Self { + Self { + enabled: false, + max_attempts: DEFAULT_RETRY_MAX_ATTEMPTS, + initial_backoff: DEFAULT_RETRY_INITIAL_BACKOFF, + max_backoff: DEFAULT_RETRY_MAX_BACKOFF, + retry_unsafe_requests: false, + } + } + + #[must_use] + pub const fn enabled_default() -> Self { + Self { + enabled: true, + ..Self::disabled() + } + } + + #[must_use] + pub const fn is_enabled(&self) -> bool { + self.enabled + } + + #[must_use] + pub const fn max_attempts(&self) -> usize { + self.max_attempts + } + + #[must_use] + pub const fn initial_backoff(&self) -> Duration { + self.initial_backoff + } + + #[must_use] + pub const fn max_backoff(&self) -> Duration { + self.max_backoff + } + + #[must_use] + pub const fn retry_unsafe_requests(&self) -> bool { + self.retry_unsafe_requests + } + + pub fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + pub fn with_max_attempts(mut self, max_attempts: usize) -> Result { + if max_attempts == 0 { + return Err(SdkError::Config( + "retry max attempts must be greater than zero".into(), + )); + } + self.max_attempts = max_attempts; + Ok(self) + } + + pub fn with_backoff( + mut self, + initial_backoff: Duration, + max_backoff: Duration, + ) -> Result { + if initial_backoff > max_backoff { + return Err(SdkError::Config( + "retry initial backoff cannot exceed max backoff".into(), + )); + } + self.initial_backoff = initial_backoff; + self.max_backoff = max_backoff; + Ok(self) + } + + pub fn with_retry_unsafe_requests(mut self, retry_unsafe_requests: bool) -> Self { + self.retry_unsafe_requests = retry_unsafe_requests; + self + } +} + +impl Default for RetryConfig { + fn default() -> Self { + Self::disabled() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FallbackConfig { + enabled: bool, + base_url: Option, + allow_authenticated_requests: bool, + allow_unsafe_requests: bool, +} + +impl FallbackConfig { + #[must_use] + pub const fn disabled() -> Self { + Self { + enabled: false, + base_url: None, + allow_authenticated_requests: false, + allow_unsafe_requests: false, + } + } + + #[must_use] + pub fn enabled_with_base_url(base_url: Url) -> Self { + Self { + enabled: true, + base_url: Some(base_url), + allow_authenticated_requests: false, + allow_unsafe_requests: false, + } + } + + #[must_use] + pub const fn is_enabled(&self) -> bool { + self.enabled + } + + #[must_use] + pub const fn base_url(&self) -> Option<&Url> { + self.base_url.as_ref() + } + + #[must_use] + pub const fn allow_authenticated_requests(&self) -> bool { + self.allow_authenticated_requests + } + + #[must_use] + pub const fn allow_unsafe_requests(&self) -> bool { + self.allow_unsafe_requests + } + + pub fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + pub fn with_base_url(mut self, base_url: impl AsRef) -> Result { + let parsed = Url::parse(base_url.as_ref()) + .map_err(|error| SdkError::Config(format!("invalid fallback base URL: {error}")))?; + validate_base_url(&parsed)?; + self.base_url = Some(parsed); + Ok(self) + } + + pub fn with_allow_authenticated_requests(mut self, allowed: bool) -> Self { + self.allow_authenticated_requests = allowed; + self + } + + pub fn with_allow_unsafe_requests(mut self, allowed: bool) -> Self { + self.allow_unsafe_requests = allowed; + self + } +} + +impl Default for FallbackConfig { + fn default() -> Self { + Self::disabled() + } } impl UpbitConfigBuilder { @@ -183,25 +371,80 @@ impl UpbitConfigBuilder { } #[must_use] - pub const fn retry_enabled(mut self, retry_enabled: bool) -> Self { - self.retry_enabled = retry_enabled; + pub fn retry_enabled(mut self, retry_enabled: bool) -> Self { + self.retry = self.retry.with_enabled(retry_enabled); + self + } + + #[must_use] + pub fn fallback_enabled(mut self, fallback_enabled: bool) -> Self { + self.fallback = self.fallback.with_enabled(fallback_enabled); + self + } + + #[must_use] + pub fn retry_config(mut self, retry: RetryConfig) -> Self { + self.retry = retry; + self + } + + #[must_use] + pub fn fallback_config(mut self, fallback: FallbackConfig) -> Self { + self.fallback = fallback; + self + } + + pub fn retry_max_attempts(mut self, max_attempts: usize) -> Result { + self.retry = self.retry.with_max_attempts(max_attempts)?; + Ok(self) + } + + pub fn retry_backoff( + mut self, + initial_backoff: Duration, + max_backoff: Duration, + ) -> Result { + self.retry = self.retry.with_backoff(initial_backoff, max_backoff)?; + Ok(self) + } + + #[must_use] + pub fn retry_unsafe_requests(mut self, retry_unsafe_requests: bool) -> Self { + self.retry = self.retry.with_retry_unsafe_requests(retry_unsafe_requests); self } + pub fn fallback_base_url(mut self, base_url: impl AsRef) -> Result { + self.fallback = self.fallback.with_base_url(base_url)?; + Ok(self) + } + #[must_use] - pub const fn fallback_enabled(mut self, fallback_enabled: bool) -> Self { - self.fallback_enabled = fallback_enabled; + pub fn fallback_allow_authenticated_requests(mut self, allowed: bool) -> Self { + self.fallback = self.fallback.with_allow_authenticated_requests(allowed); + self + } + + #[must_use] + pub fn fallback_allow_unsafe_requests(mut self, allowed: bool) -> Self { + self.fallback = self.fallback.with_allow_unsafe_requests(allowed); self } pub fn build(self) -> Result { + if self.fallback.is_enabled() && self.fallback.base_url().is_none() { + return Err(SdkError::Config( + "fallback base URL is required when fallback is enabled".into(), + )); + } + Ok(UpbitConfig { base_url: self.base_url, credentials: self.credentials, timeout: self.timeout, user_agent: self.user_agent, - retry_enabled: self.retry_enabled, - fallback_enabled: self.fallback_enabled, + retry: self.retry, + fallback: self.fallback, }) } } @@ -243,8 +486,8 @@ impl Default for UpbitConfigBuilder { credentials: None, timeout: DEFAULT_TIMEOUT, user_agent: DEFAULT_USER_AGENT.into(), - retry_enabled: false, - fallback_enabled: false, + retry: RetryConfig::default(), + fallback: FallbackConfig::default(), } } } @@ -262,6 +505,11 @@ mod tests { assert_eq!(config.timeout(), Duration::from_secs(10)); assert!(!config.retry_enabled()); assert!(!config.fallback_enabled()); + assert_eq!(config.retry().max_attempts(), 3); + assert!(!config.retry().retry_unsafe_requests()); + assert!(config.fallback().base_url().is_none()); + assert!(!config.fallback().allow_authenticated_requests()); + assert!(!config.fallback().allow_unsafe_requests()); } #[test] @@ -272,10 +520,48 @@ mod tests { .is_err()); assert!(UpbitConfig::builder().timeout(Duration::ZERO).is_err()); assert!(UpbitConfig::builder().user_agent(" ").is_err()); + assert!(UpbitConfig::builder().retry_max_attempts(0).is_err()); + assert!(UpbitConfig::builder() + .retry_backoff(Duration::from_secs(2), Duration::from_secs(1)) + .is_err()); + assert!(UpbitConfig::builder() + .fallback_enabled(true) + .build() + .is_err()); assert!(Credentials::new("", "secret").is_err()); assert!(Credentials::new("access", "").is_err()); } + #[test] + fn enables_retry_and_fallback_only_when_explicitly_configured() { + let config = UpbitConfig::builder() + .retry_enabled(true) + .retry_max_attempts(2) + .unwrap() + .retry_backoff(Duration::ZERO, Duration::from_millis(5)) + .unwrap() + .retry_unsafe_requests(true) + .fallback_enabled(true) + .fallback_base_url("http://127.0.0.1:8081/v1") + .unwrap() + .fallback_allow_authenticated_requests(true) + .fallback_allow_unsafe_requests(true) + .build() + .unwrap(); + + assert!(config.retry_enabled()); + assert_eq!(config.retry().max_attempts(), 2); + assert_eq!(config.retry().initial_backoff(), Duration::ZERO); + assert!(config.retry().retry_unsafe_requests()); + assert!(config.fallback_enabled()); + assert_eq!( + config.fallback().base_url().unwrap().as_str(), + "http://127.0.0.1:8081/v1" + ); + assert!(config.fallback().allow_authenticated_requests()); + assert!(config.fallback().allow_unsafe_requests()); + } + #[test] fn allows_http_only_for_loopback_mock_endpoints() { for url in [ diff --git a/crates/upbit-sdk/src/error.rs b/crates/upbit-sdk/src/error.rs index 8075d59..d13eb56 100644 --- a/crates/upbit-sdk/src/error.rs +++ b/crates/upbit-sdk/src/error.rs @@ -70,6 +70,19 @@ impl SdkError { Self::Config(_) | Self::Auth(_) | Self::Serialization(_) => false, } } + + #[must_use] + pub const fn retry_after(&self) -> Option { + match self { + Self::RateLimited { retry_after, .. } => *retry_after, + Self::Config(_) + | Self::Auth(_) + | Self::Transport(_) + | Self::Serialization(_) + | Self::Upbit { .. } + | Self::HttpStatus { .. } => None, + } + } } pub(crate) async fn map_response_error(response: reqwest::Response) -> SdkError { diff --git a/crates/upbit-sdk/src/lib.rs b/crates/upbit-sdk/src/lib.rs index 239eda9..c3d4c53 100644 --- a/crates/upbit-sdk/src/lib.rs +++ b/crates/upbit-sdk/src/lib.rs @@ -8,12 +8,16 @@ pub mod auth; pub mod client; pub mod config; pub mod error; +pub mod logging; pub mod query; pub use auth::{JwtClaims, JwtSigner}; pub use client::UpbitClient; -pub use config::{Credentials, SecretValue, UpbitConfig, UpbitConfigBuilder}; +pub use config::{ + Credentials, FallbackConfig, RetryConfig, SecretValue, UpbitConfig, UpbitConfigBuilder, +}; pub use error::{ErrorResponse, SdkError, UpbitApiError}; +pub use logging::{redact_sensitive_text, redact_url, REDACTED}; pub use query::{QueryParams, QueryValue}; /// Default Upbit REST API base URL. diff --git a/crates/upbit-sdk/src/logging.rs b/crates/upbit-sdk/src/logging.rs new file mode 100644 index 0000000..5fff08e --- /dev/null +++ b/crates/upbit-sdk/src/logging.rs @@ -0,0 +1,145 @@ +use url::Url; + +pub const REDACTED: &str = "[REDACTED]"; + +const SENSITIVE_KEYS: &[&str] = &[ + "access_key", + "account", + "authorization", + "identifier", + "jwt", + "nonce", + "order", + "price", + "query_hash", + "secret", + "secret_key", + "token", + "uuid", + "volume", +]; + +#[must_use] +pub fn redact_url(url: &Url) -> String { + let mut redacted = url.clone(); + if url.query().is_some() { + redacted + .query_pairs_mut() + .clear() + .extend_pairs(url.query_pairs().map(|(key, value)| { + let value = if is_sensitive_key(&key) { + REDACTED.into() + } else { + value + }; + (key, value) + })); + } + redacted.to_string() +} + +#[must_use] +pub fn redact_sensitive_text(value: &str) -> String { + let mut output = redact_bearer_tokens(value); + for key in SENSITIVE_KEYS { + output = redact_key_value(&output, key); + } + output +} + +pub(crate) fn is_sensitive_key(key: &str) -> bool { + let normalized = key.to_ascii_lowercase(); + SENSITIVE_KEYS + .iter() + .any(|sensitive| normalized.contains(sensitive)) +} + +fn redact_bearer_tokens(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + let mut rest = value; + + while let Some(index) = rest.find("Bearer ") { + output.push_str(&rest[..index + "Bearer ".len()]); + let token_start = index + "Bearer ".len(); + let token_rest = &rest[token_start..]; + let token_end = token_rest + .find(char::is_whitespace) + .unwrap_or(token_rest.len()); + output.push_str(REDACTED); + rest = &token_rest[token_end..]; + } + + output.push_str(rest); + output +} + +fn redact_key_value(value: &str, key: &str) -> String { + let mut output = String::with_capacity(value.len()); + let mut rest = value; + + while let Some(index) = find_key(rest, key) { + output.push_str(&rest[..index]); + let after_key = &rest[index + key.len()..]; + let Some(separator) = after_key + .chars() + .next() + .filter(|char| matches!(char, '=' | ':')) + else { + output.push_str(&rest[index..index + key.len()]); + rest = after_key; + continue; + }; + output.push_str(&rest[index..index + key.len()]); + output.push(separator); + output.push_str(REDACTED); + + let value_start = separator.len_utf8(); + let after_separator = &after_key[value_start..]; + let value_end = after_separator + .find(['&', ',', ' ', '\n', '\r', '\t']) + .unwrap_or(after_separator.len()); + rest = &after_separator[value_end..]; + } + + output.push_str(rest); + output +} + +fn find_key(value: &str, key: &str) -> Option { + value.to_ascii_lowercase().find(key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacts_sensitive_url_query_values() { + let url = Url::parse( + "https://api.upbit.com/v1/orders?market=KRW-BTC&access_key=raw-access-value&uuid=raw-order-id", + ) + .unwrap(); + + let redacted = redact_url(&url); + + assert!(redacted.contains("market=KRW-BTC")); + assert!(!redacted.contains("raw-access-value")); + assert!(!redacted.contains("raw-order-id")); + assert!(redacted.contains("access_key=%5BREDACTED%5D")); + assert!(redacted.contains("uuid=%5BREDACTED%5D")); + } + + #[test] + fn redacts_tokens_and_private_order_text() { + let text = "Authorization: Bearer header.claims.signature access_key=raw-access-value secret_key=raw-secret-value uuid=raw-order-id price=1000"; + + let redacted = redact_sensitive_text(text); + + assert!(!redacted.contains("header.claims.signature")); + assert!(!redacted.contains("raw-access-value")); + assert!(!redacted.contains("raw-secret-value")); + assert!(!redacted.contains("raw-order-id")); + assert!(!redacted.contains("1000")); + assert!(redacted.contains(REDACTED)); + } +} From d1e47f3425c64bdd1047f67fd3e3dcfbd92fb6c8 Mon Sep 17 00:00:00 2001 From: Bogyeong Kim Date: Fri, 29 May 2026 21:38:15 +0900 Subject: [PATCH 07/13] BOG-238 Add typed REST endpoint coverage Co-authored-by: multica-agent --- Cargo.lock | 1 + README.md | 45 + crates/upbit-sdk/Cargo.toml | 3 + crates/upbit-sdk/src/endpoints.rs | 1510 +++++++++++++++++++++++++++++ crates/upbit-sdk/src/error.rs | 11 +- crates/upbit-sdk/src/lib.rs | 2 + crates/upbit-sdk/src/query.rs | 11 + 7 files changed, 1581 insertions(+), 2 deletions(-) create mode 100644 crates/upbit-sdk/src/endpoints.rs diff --git a/Cargo.lock b/Cargo.lock index 7625902..c476687 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1170,6 +1170,7 @@ dependencies = [ "serde_json", "sha2", "thiserror 1.0.69", + "upbit-mock", "url", "uuid", ] diff --git a/README.md b/README.md index 51df4a2..59e05a1 100644 --- a/README.md +++ b/README.md @@ -25,3 +25,48 @@ cargo run -p upbit-mock -- 127.0.0.1:8001 . Use `http://127.0.0.1:8001/v1` as the SDK REST base URL. See `crates/upbit-mock/README.md` for auth, validation, error, fixture, and conformance details. + +## SDK Endpoint Usage + +Use the local mock for credential-free endpoint development and tests: + +```rust,no_run +use upbit_sdk::{CandleRequest, MinuteCandleUnit, UpbitClient, UpbitConfig}; + +# async fn example() -> Result<(), upbit_sdk::SdkError> { +let config = UpbitConfig::builder() + .base_url("http://127.0.0.1:8001/v1")? + .build()?; +let client = UpbitClient::new(config)?; + +let candles = client + .list_candles_minutes( + MinuteCandleUnit::One, + CandleRequest { + market: "KRW-BTC".into(), + count: Some(1), + ..Default::default() + }, + ) + .await?; +# Ok(()) +# } +``` + +Exchange endpoints require credentials and JWT signing. Keep live credentials in +the caller's secret store or environment, never in source code or fixtures: + +```rust,no_run +use upbit_sdk::{Credentials, UpbitClient, UpbitConfig}; + +# fn example(access_key: String, secret_key: String) -> Result { +let config = UpbitConfig::builder() + .credentials(Credentials::new(access_key, secret_key)?) + .build()?; +UpbitClient::new(config) +# } +``` + +The SDK covers the 44 REST endpoints in `spec/upbit-rest-api.yaml`. The spec's +`list_subscriptions` inventory item is a WebSocket operation and is documented +outside the REST client surface. diff --git a/crates/upbit-sdk/Cargo.toml b/crates/upbit-sdk/Cargo.toml index 6111ac2..612ec20 100644 --- a/crates/upbit-sdk/Cargo.toml +++ b/crates/upbit-sdk/Cargo.toml @@ -22,5 +22,8 @@ thiserror = "1" url = "2" uuid = { version = "1", features = ["v4"] } +[dev-dependencies] +upbit-mock = { path = "../upbit-mock" } + [lints] workspace = true diff --git a/crates/upbit-sdk/src/endpoints.rs b/crates/upbit-sdk/src/endpoints.rs new file mode 100644 index 0000000..ad9a8cd --- /dev/null +++ b/crates/upbit-sdk/src/endpoints.rs @@ -0,0 +1,1510 @@ +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::client::UpbitClient; +use crate::error::SdkError; +use crate::query::{QueryParams, QueryValue}; + +/// REST endpoint ids covered by the SDK surface. `list_subscriptions` is a +/// WebSocket operation and is intentionally excluded from REST coverage. +pub const SUPPORTED_REST_ENDPOINT_IDS: &[&str] = &[ + "list_trading_pairs", + "list_candles_seconds", + "list_candles_minutes", + "list_candles_days", + "list_candles_weeks", + "list_candles_months", + "list_candles_years", + "list_pair_trades", + "list_tickers", + "list_quote_tickers", + "list_orderbooks", + "list_orderbook_instruments", + "list_orderbook_levels", + "get_balance", + "available_order_information", + "new_order", + "order_test", + "cancel_and_new_order", + "cancel_order", + "cancel_orders_by_ids", + "batch_cancel_orders", + "get_order", + "list_orders_by_ids", + "list_open_orders", + "list_closed_orders", + "available_withdrawal_information", + "withdraw_coin", + "withdraw_krw", + "cancel_withdrawal", + "get_withdrawal", + "list_withdrawals", + "list_withdrawal_addresses", + "available_deposit_information", + "create_deposit_address", + "get_deposit_address", + "list_deposit_addresses", + "get_deposit", + "list_deposits", + "deposit_krw", + "list_travelrule_vasps", + "verify_travelrule_by_uuid", + "verify_travelrule_by_txid", + "get_service_status", + "list_api_keys", +]; + +pub type DecimalString = String; + +trait ToQueryParams { + fn to_query_params(&self) -> QueryParams; +} + +fn empty_query() -> QueryParams { + QueryParams::new() +} + +fn push_opt(query: QueryParams, key: &str, value: &Option) -> QueryParams +where + T: Clone + Into, +{ + match value { + Some(value) => query.push(key, value.clone()), + None => query, + } +} + +fn comma(values: &[String]) -> String { + values.join(",") +} + +fn validate_non_empty(values: &[String], field: &str) -> Result<(), SdkError> { + if values.is_empty() { + return Err(SdkError::Request(format!("{field} must not be empty"))); + } + Ok(()) +} + +impl UpbitClient { + async fn get_endpoint( + &self, + path: &str, + query: QueryParams, + auth_required: bool, + ) -> Result + where + T: serde::de::DeserializeOwned, + { + let query = (!query.is_empty()).then_some(query); + self.request_json::( + Method::GET, + path, + query.as_ref(), + Option::<&Value>::None, + auth_required, + ) + .await + } + + async fn delete_endpoint( + &self, + path: &str, + query: QueryParams, + auth_required: bool, + ) -> Result + where + T: serde::de::DeserializeOwned, + { + let query = (!query.is_empty()).then_some(query); + self.request_json::( + Method::DELETE, + path, + query.as_ref(), + Option::<&Value>::None, + auth_required, + ) + .await + } + + async fn post_endpoint( + &self, + path: &str, + body: &B, + auth_required: bool, + ) -> Result + where + T: serde::de::DeserializeOwned, + B: Serialize + ?Sized, + { + self.request_json(Method::POST, path, None, Some(body), auth_required) + .await + } + + pub async fn list_trading_pairs( + &self, + request: ListTradingPairsRequest, + ) -> Result, SdkError> { + self.get_endpoint("/market/all", request.to_query_params(), false) + .await + } + + pub async fn list_candles_seconds( + &self, + request: CandleRequest, + ) -> Result, SdkError> { + self.get_endpoint("/candles/seconds", request.to_query_params(), false) + .await + } + + pub async fn list_candles_minutes( + &self, + unit: MinuteCandleUnit, + request: CandleRequest, + ) -> Result, SdkError> { + self.get_endpoint( + &format!("/candles/minutes/{}", unit.as_u16()), + request.to_query_params(), + false, + ) + .await + } + + pub async fn list_candles_days(&self, request: CandleRequest) -> Result, SdkError> { + self.get_endpoint("/candles/days", request.to_query_params(), false) + .await + } + + pub async fn list_candles_weeks( + &self, + request: CandleRequest, + ) -> Result, SdkError> { + self.get_endpoint("/candles/weeks", request.to_query_params(), false) + .await + } + + pub async fn list_candles_months( + &self, + request: CandleRequest, + ) -> Result, SdkError> { + self.get_endpoint("/candles/months", request.to_query_params(), false) + .await + } + + pub async fn list_candles_years( + &self, + request: CandleRequest, + ) -> Result, SdkError> { + self.get_endpoint("/candles/years", request.to_query_params(), false) + .await + } + + pub async fn list_pair_trades( + &self, + request: PairTradesRequest, + ) -> Result, SdkError> { + self.get_endpoint("/trades/ticks", request.to_query_params(), false) + .await + } + + pub async fn list_tickers(&self, markets: Vec) -> Result, SdkError> { + validate_non_empty(&markets, "markets")?; + self.get_endpoint( + "/ticker", + QueryParams::new().push("markets", comma(&markets)), + false, + ) + .await + } + + pub async fn list_quote_tickers( + &self, + quote_currencies: Vec, + ) -> Result, SdkError> { + validate_non_empty("e_currencies, "quote_currencies")?; + self.get_endpoint( + "/ticker/all", + QueryParams::new().push("quote_currencies", comma("e_currencies)), + false, + ) + .await + } + + pub async fn list_orderbooks( + &self, + request: OrderbookRequest, + ) -> Result, SdkError> { + request.validate()?; + self.get_endpoint("/orderbook", request.to_query_params(), false) + .await + } + + pub async fn list_orderbook_instruments( + &self, + markets: Vec, + ) -> Result, SdkError> { + validate_non_empty(&markets, "markets")?; + self.get_endpoint( + "/orderbook/instruments", + QueryParams::new().push("markets", comma(&markets)), + false, + ) + .await + } + + pub async fn list_orderbook_levels( + &self, + markets: Vec, + ) -> Result, SdkError> { + validate_non_empty(&markets, "markets")?; + self.get_endpoint( + "/orderbook/supported_levels", + QueryParams::new().push("markets", comma(&markets)), + false, + ) + .await + } + + pub async fn get_balance(&self) -> Result, SdkError> { + self.get_endpoint("/accounts", empty_query(), true).await + } + + pub async fn available_order_information( + &self, + market: impl Into, + ) -> Result { + self.get_endpoint( + "/orders/chance", + QueryParams::new().push("market", market.into()), + true, + ) + .await + } + + pub async fn new_order(&self, request: CreateOrderRequest) -> Result { + request.validate()?; + self.post_endpoint("/orders", &request, true).await + } + + pub async fn order_test(&self, request: CreateOrderRequest) -> Result { + request.validate()?; + self.post_endpoint("/orders/test", &request, true).await + } + + pub async fn cancel_and_new_order( + &self, + request: CancelAndNewOrderRequest, + ) -> Result { + request.validate()?; + self.post_endpoint("/orders/cancel_and_new", &request.to_body(), true) + .await + } + + pub async fn cancel_order(&self, order: OrderId) -> Result { + self.delete_endpoint("/order", order.to_query_params(), true) + .await + } + + pub async fn cancel_orders_by_ids( + &self, + orders: OrderIds, + ) -> Result { + orders.validate()?; + self.delete_endpoint("/orders/uuids", orders.to_query_params(), true) + .await + } + + pub async fn batch_cancel_orders( + &self, + request: BatchCancelOrdersRequest, + ) -> Result { + self.delete_endpoint("/orders/open", request.to_query_params(), true) + .await + } + + pub async fn get_order(&self, order: OrderId) -> Result { + self.get_endpoint("/order", order.to_query_params(), true) + .await + } + + pub async fn list_orders_by_ids( + &self, + request: ListOrdersByIdsRequest, + ) -> Result, SdkError> { + request.orders.validate()?; + self.get_endpoint("/orders/uuids", request.to_query_params(), true) + .await + } + + pub async fn list_open_orders( + &self, + request: ListOpenOrdersRequest, + ) -> Result, SdkError> { + self.get_endpoint("/orders/open", request.to_query_params(), true) + .await + } + + pub async fn list_closed_orders( + &self, + request: ListClosedOrdersRequest, + ) -> Result, SdkError> { + self.get_endpoint("/orders/closed", request.to_query_params(), true) + .await + } + + pub async fn available_withdrawal_information( + &self, + request: CurrencyNetworkRequest, + ) -> Result { + self.get_endpoint("/withdraws/chance", request.to_query_params(), true) + .await + } + + pub async fn withdraw_coin( + &self, + request: WithdrawCoinRequest, + ) -> Result { + self.post_endpoint("/withdraws/coin", &request, true).await + } + + pub async fn withdraw_krw(&self, request: FiatTransferRequest) -> Result { + self.post_endpoint("/withdraws/krw", &request, true).await + } + + pub async fn cancel_withdrawal(&self, uuid: impl Into) -> Result { + self.delete_endpoint( + "/withdraws/coin", + QueryParams::new().push("uuid", uuid.into()), + true, + ) + .await + } + + pub async fn get_withdrawal( + &self, + request: AssetTransferLookup, + ) -> Result { + self.get_endpoint("/withdraw", request.to_query_params(), true) + .await + } + + pub async fn list_withdrawals( + &self, + request: AssetTransferListRequest, + ) -> Result, SdkError> { + self.get_endpoint("/withdraws", request.to_query_params(), true) + .await + } + + pub async fn list_withdrawal_addresses(&self) -> Result, SdkError> { + self.get_endpoint("/withdraws/coin_addresses", empty_query(), true) + .await + } + + pub async fn available_deposit_information( + &self, + request: CurrencyNetworkRequest, + ) -> Result { + self.get_endpoint("/deposits/chance/coin", request.to_query_params(), true) + .await + } + + pub async fn create_deposit_address( + &self, + request: CurrencyNetworkRequest, + ) -> Result { + self.post_endpoint("/deposits/generate_coin_address", &request, true) + .await + } + + pub async fn get_deposit_address( + &self, + request: CurrencyNetworkRequest, + ) -> Result { + self.get_endpoint("/deposits/coin_address", request.to_query_params(), true) + .await + } + + pub async fn list_deposit_addresses(&self) -> Result, SdkError> { + self.get_endpoint("/deposits/coin_addresses", empty_query(), true) + .await + } + + pub async fn get_deposit(&self, request: AssetTransferLookup) -> Result { + self.get_endpoint("/deposit", request.to_query_params(), true) + .await + } + + pub async fn list_deposits( + &self, + request: AssetTransferListRequest, + ) -> Result, SdkError> { + self.get_endpoint("/deposits", request.to_query_params(), true) + .await + } + + pub async fn deposit_krw(&self, request: FiatTransferRequest) -> Result { + self.post_endpoint("/deposits/krw", &request, true).await + } + + pub async fn list_travelrule_vasps(&self) -> Result, SdkError> { + self.get_endpoint("/travel_rule/vasps", empty_query(), true) + .await + } + + pub async fn verify_travelrule_by_uuid( + &self, + request: TravelRuleUuidVerificationRequest, + ) -> Result { + self.post_endpoint("/travel_rule/deposit/uuid", &request, true) + .await + } + + pub async fn verify_travelrule_by_txid( + &self, + request: TravelRuleTxidVerificationRequest, + ) -> Result { + self.post_endpoint("/travel_rule/deposit/txid", &request, true) + .await + } + + pub async fn get_service_status(&self) -> Result, SdkError> { + self.get_endpoint("/status/wallet", empty_query(), true) + .await + } + + pub async fn list_api_keys(&self) -> Result, SdkError> { + self.get_endpoint("/api_keys", empty_query(), true).await + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ListTradingPairsRequest { + pub is_details: Option, +} + +impl ToQueryParams for ListTradingPairsRequest { + fn to_query_params(&self) -> QueryParams { + push_opt(empty_query(), "is_details", &self.is_details) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MinuteCandleUnit { + One, + Three, + Five, + Ten, + Fifteen, + Thirty, + Sixty, + TwoForty, +} + +impl MinuteCandleUnit { + #[must_use] + pub const fn as_u16(&self) -> u16 { + match self { + Self::One => 1, + Self::Three => 3, + Self::Five => 5, + Self::Ten => 10, + Self::Fifteen => 15, + Self::Thirty => 30, + Self::Sixty => 60, + Self::TwoForty => 240, + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CandleRequest { + pub market: String, + pub to: Option, + pub count: Option, + pub converting_price_unit: Option, +} + +impl ToQueryParams for CandleRequest { + fn to_query_params(&self) -> QueryParams { + let query = QueryParams::new().push("market", self.market.clone()); + let query = push_opt(query, "to", &self.to); + let query = push_opt(query, "count", &self.count); + push_opt(query, "converting_price_unit", &self.converting_price_unit) + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PairTradesRequest { + pub market: String, + pub to: Option, + pub count: Option, + pub cursor: Option, + pub days_ago: Option, +} + +impl ToQueryParams for PairTradesRequest { + fn to_query_params(&self) -> QueryParams { + let query = QueryParams::new().push("market", self.market.clone()); + let query = push_opt(query, "to", &self.to); + let query = push_opt(query, "count", &self.count); + let query = push_opt(query, "cursor", &self.cursor); + push_opt(query, "days_ago", &self.days_ago) + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct OrderbookRequest { + pub markets: Vec, + pub level: Option, + pub count: Option, +} + +impl OrderbookRequest { + pub fn validate(&self) -> Result<(), SdkError> { + validate_non_empty(&self.markets, "markets") + } +} + +impl ToQueryParams for OrderbookRequest { + fn to_query_params(&self) -> QueryParams { + let query = QueryParams::new().push("markets", comma(&self.markets)); + let query = push_opt(query, "level", &self.level); + push_opt(query, "count", &self.count) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum OrderId { + Uuid(String), + Identifier(String), +} + +impl ToQueryParams for OrderId { + fn to_query_params(&self) -> QueryParams { + match self { + Self::Uuid(uuid) => QueryParams::new().push("uuid", uuid.clone()), + Self::Identifier(identifier) => { + QueryParams::new().push("identifier", identifier.clone()) + } + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum OrderIds { + Uuids(Vec), + Identifiers(Vec), +} + +impl OrderIds { + pub fn validate(&self) -> Result<(), SdkError> { + match self { + Self::Uuids(values) => validate_non_empty(values, "uuids"), + Self::Identifiers(values) => validate_non_empty(values, "identifiers"), + } + } +} + +impl ToQueryParams for OrderIds { + fn to_query_params(&self) -> QueryParams { + match self { + Self::Uuids(values) => { + QueryParams::new().push("uuids[]", QueryValue::multiple(values.clone())) + } + Self::Identifiers(values) => { + QueryParams::new().push("identifiers[]", QueryValue::multiple(values.clone())) + } + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum OrderSide { + Bid, + Ask, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OrderType { + Limit, + Price, + Market, + Best, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TimeInForce { + Ioc, + Fok, + PostOnly, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SmpType { + CancelMaker, + CancelTaker, + Reduce, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CreateOrderRequest { + pub market: String, + pub side: OrderSide, + #[serde(skip_serializing_if = "Option::is_none")] + pub volume: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub price: Option, + pub ord_type: OrderType, + #[serde(skip_serializing_if = "Option::is_none")] + pub identifier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub time_in_force: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub smp_type: Option, +} + +impl CreateOrderRequest { + pub fn validate(&self) -> Result<(), SdkError> { + validate_order_constraints( + &self.side, + &self.ord_type, + self.volume.as_deref(), + self.price.as_deref(), + self.time_in_force.as_ref(), + self.smp_type.as_ref(), + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CancelAndNewOrderRequest { + pub previous_order: OrderId, + pub new_ord_type: OrderType, + pub new_volume: Option, + pub new_price: Option, + pub new_identifier: Option, + pub new_time_in_force: Option, + pub new_smp_type: Option, +} + +impl CancelAndNewOrderRequest { + pub fn validate(&self) -> Result<(), SdkError> { + validate_cancel_and_new_order_constraints( + &self.new_ord_type, + self.new_volume.as_deref(), + self.new_price.as_deref(), + self.new_time_in_force.as_ref(), + self.new_smp_type.as_ref(), + ) + } + + fn to_body(&self) -> Value { + let mut body = serde_json::Map::new(); + match &self.previous_order { + OrderId::Uuid(uuid) => { + body.insert("prev_order_uuid".to_owned(), json!(uuid)); + } + OrderId::Identifier(identifier) => { + body.insert("prev_order_identifier".to_owned(), json!(identifier)); + } + } + body.insert("new_ord_type".to_owned(), json!(self.new_ord_type)); + if let Some(value) = &self.new_volume { + body.insert("new_volume".to_owned(), json!(value)); + } + if let Some(value) = &self.new_price { + body.insert("new_price".to_owned(), json!(value)); + } + if let Some(value) = &self.new_identifier { + body.insert("new_identifier".to_owned(), json!(value)); + } + if let Some(value) = &self.new_time_in_force { + body.insert("new_time_in_force".to_owned(), json!(value)); + } + if let Some(value) = &self.new_smp_type { + body.insert("new_smp_type".to_owned(), json!(value)); + } + Value::Object(body) + } +} + +fn validate_cancel_and_new_order_constraints( + ord_type: &OrderType, + new_volume: Option<&str>, + new_price: Option<&str>, + new_time_in_force: Option<&TimeInForce>, + new_smp_type: Option<&SmpType>, +) -> Result<(), SdkError> { + if matches!(new_time_in_force, Some(TimeInForce::PostOnly)) && new_smp_type.is_some() { + return Err(SdkError::Request( + "post_only new_time_in_force cannot be combined with new_smp_type".into(), + )); + } + + match ord_type { + OrderType::Limit => require_price_and_volume(new_price, new_volume, "cancel-and-new limit"), + OrderType::Price => { + if new_price.is_none() || new_volume.is_some() { + return Err(SdkError::Request( + "cancel-and-new price orders require new_price and no new_volume".into(), + )); + } + Ok(()) + } + OrderType::Market => { + if new_volume.is_none() || new_price.is_some() { + return Err(SdkError::Request( + "cancel-and-new market orders require new_volume and no new_price".into(), + )); + } + Ok(()) + } + OrderType::Best => { + if new_time_in_force.is_none() { + return Err(SdkError::Request( + "cancel-and-new best orders require new_time_in_force".into(), + )); + } + if new_price.is_some() ^ new_volume.is_some() { + Ok(()) + } else { + Err(SdkError::Request( + "cancel-and-new best orders require exactly one of new_price or new_volume" + .into(), + )) + } + } + } +} + +fn validate_order_constraints( + side: &OrderSide, + ord_type: &OrderType, + volume: Option<&str>, + price: Option<&str>, + time_in_force: Option<&TimeInForce>, + smp_type: Option<&SmpType>, +) -> Result<(), SdkError> { + if matches!(time_in_force, Some(TimeInForce::PostOnly)) && smp_type.is_some() { + return Err(SdkError::Request( + "post_only time_in_force cannot be combined with smp_type".into(), + )); + } + + match ord_type { + OrderType::Limit => require_price_and_volume(price, volume, "limit"), + OrderType::Price => { + if !matches!(side, OrderSide::Bid) || price.is_none() || volume.is_some() { + return Err(SdkError::Request( + "price orders must be bid orders with price and without volume".into(), + )); + } + Ok(()) + } + OrderType::Market => { + if !matches!(side, OrderSide::Ask) || volume.is_none() || price.is_some() { + return Err(SdkError::Request( + "market orders must be ask orders with volume and without price".into(), + )); + } + Ok(()) + } + OrderType::Best => { + if !matches!(time_in_force, Some(TimeInForce::Ioc | TimeInForce::Fok)) { + return Err(SdkError::Request( + "best orders require ioc or fok time_in_force".into(), + )); + } + match side { + OrderSide::Bid if price.is_some() && volume.is_none() => Ok(()), + OrderSide::Ask if volume.is_some() && price.is_none() => Ok(()), + _ => Err(SdkError::Request( + "best bid requires price only; best ask requires volume only".into(), + )), + } + } + } +} + +fn require_price_and_volume( + price: Option<&str>, + volume: Option<&str>, + ord_type: &str, +) -> Result<(), SdkError> { + if price.is_some() && volume.is_some() { + Ok(()) + } else { + Err(SdkError::Request(format!( + "{ord_type} orders require both price and volume" + ))) + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BatchCancelOrdersRequest { + pub quote_currencies: Option>, + pub cancel_side: Option, + pub count: Option, + pub order_by: Option, + pub pairs: Option>, + pub exclude_pairs: Option>, +} + +impl ToQueryParams for BatchCancelOrdersRequest { + fn to_query_params(&self) -> QueryParams { + let mut query = empty_query(); + if let Some(values) = &self.quote_currencies { + query = query.push("quote_currencies", comma(values)); + } + query = push_opt(query, "cancel_side", &self.cancel_side); + query = push_opt(query, "count", &self.count); + query = push_opt(query, "order_by", &self.order_by); + if let Some(values) = &self.pairs { + query = query.push("pairs", comma(values)); + } + if let Some(values) = &self.exclude_pairs { + query = query.push("exclude_pairs", comma(values)); + } + query + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ListOrdersByIdsRequest { + pub market: Option, + pub orders: OrderIds, + pub order_by: Option, +} + +impl ToQueryParams for ListOrdersByIdsRequest { + fn to_query_params(&self) -> QueryParams { + let query = push_opt(empty_query(), "market", &self.market); + let query = query.extend(self.orders.to_query_params()); + push_opt(query, "order_by", &self.order_by) + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ListOpenOrdersRequest { + pub market: Option, + pub state: Option, + pub states: Option>, + pub page: Option, + pub limit: Option, + pub order_by: Option, +} + +impl ToQueryParams for ListOpenOrdersRequest { + fn to_query_params(&self) -> QueryParams { + let mut query = push_opt(empty_query(), "market", &self.market); + query = push_opt(query, "state", &self.state); + if let Some(states) = &self.states { + query = query.push("states[]", QueryValue::multiple(states.clone())); + } + query = push_opt(query, "page", &self.page); + query = push_opt(query, "limit", &self.limit); + push_opt(query, "order_by", &self.order_by) + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ListClosedOrdersRequest { + pub market: Option, + pub state: Option, + pub states: Option>, + pub start_time: Option, + pub end_time: Option, + pub limit: Option, + pub order_by: Option, +} + +impl ToQueryParams for ListClosedOrdersRequest { + fn to_query_params(&self) -> QueryParams { + let mut query = push_opt(empty_query(), "market", &self.market); + query = push_opt(query, "state", &self.state); + if let Some(states) = &self.states { + query = query.push("states[]", QueryValue::multiple(states.clone())); + } + query = push_opt(query, "start_time", &self.start_time); + query = push_opt(query, "end_time", &self.end_time); + query = push_opt(query, "limit", &self.limit); + push_opt(query, "order_by", &self.order_by) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CurrencyNetworkRequest { + pub currency: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub net_type: Option, +} + +impl ToQueryParams for CurrencyNetworkRequest { + fn to_query_params(&self) -> QueryParams { + push_opt( + QueryParams::new().push("currency", self.currency.clone()), + "net_type", + &self.net_type, + ) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WithdrawCoinRequest { + pub currency: String, + pub net_type: String, + pub amount: DecimalString, + pub address: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub secondary_address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub transaction_type: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum TransactionType { + Default, + Internal, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct FiatTransferRequest { + pub amount: DecimalString, + pub two_factor_type: TwoFactorType, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum TwoFactorType { + Kakao, + Naver, + Hana, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct AssetTransferLookup { + pub currency: Option, + pub uuid: Option, + pub txid: Option, +} + +impl ToQueryParams for AssetTransferLookup { + fn to_query_params(&self) -> QueryParams { + let query = push_opt(empty_query(), "currency", &self.currency); + let query = push_opt(query, "uuid", &self.uuid); + push_opt(query, "txid", &self.txid) + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct AssetTransferListRequest { + pub currency: Option, + pub state: Option, + pub uuids: Option>, + pub txids: Option>, + pub limit: Option, + pub page: Option, + pub order_by: Option, + pub from: Option, + pub to: Option, +} + +impl ToQueryParams for AssetTransferListRequest { + fn to_query_params(&self) -> QueryParams { + let mut query = push_opt(empty_query(), "currency", &self.currency); + query = push_opt(query, "state", &self.state); + if let Some(values) = &self.uuids { + query = query.push("uuids[]", QueryValue::multiple(values.clone())); + } + if let Some(values) = &self.txids { + query = query.push("txids[]", QueryValue::multiple(values.clone())); + } + query = push_opt(query, "limit", &self.limit); + query = push_opt(query, "page", &self.page); + query = push_opt(query, "order_by", &self.order_by); + query = push_opt(query, "from", &self.from); + push_opt(query, "to", &self.to) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct TravelRuleUuidVerificationRequest { + pub deposit_uuid: String, + pub vasp_uuid: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct TravelRuleTxidVerificationRequest { + pub vasp_uuid: String, + pub txid: String, + pub currency: String, + pub net_type: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct TradingPair { + pub market: String, + pub korean_name: String, + pub english_name: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct Candle { + pub market: String, + pub candle_date_time_utc: String, + pub candle_date_time_kst: String, + pub opening_price: DecimalString, + pub high_price: DecimalString, + pub low_price: DecimalString, + pub trade_price: DecimalString, + pub timestamp: u64, + pub candle_acc_trade_price: DecimalString, + pub candle_acc_trade_volume: DecimalString, + pub unit: Option, + pub prev_closing_price: Option, + pub change_price: Option, + pub change_rate: Option, + pub first_day_of_period: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct PairTrade { + pub market: String, + pub trade_date_utc: String, + pub trade_time_utc: String, + pub timestamp: u64, + pub trade_price: DecimalString, + pub trade_volume: DecimalString, + pub prev_closing_price: DecimalString, + pub change_price: DecimalString, + pub ask_bid: String, + pub sequential_id: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct Ticker { + pub market: String, + pub trade_date: String, + pub trade_time: String, + pub trade_date_kst: String, + pub trade_time_kst: String, + pub trade_timestamp: u64, + pub opening_price: DecimalString, + pub high_price: DecimalString, + pub low_price: DecimalString, + pub trade_price: DecimalString, + pub prev_closing_price: DecimalString, + pub change: String, + pub change_price: DecimalString, + pub change_rate: DecimalString, + pub signed_change_price: DecimalString, + pub signed_change_rate: DecimalString, + pub trade_volume: DecimalString, + pub acc_trade_price: DecimalString, + pub acc_trade_price_24h: DecimalString, + pub acc_trade_volume: DecimalString, + pub acc_trade_volume_24h: DecimalString, + pub highest_52_week_price: DecimalString, + pub highest_52_week_date: String, + pub lowest_52_week_price: DecimalString, + pub lowest_52_week_date: String, + pub timestamp: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct Orderbook { + pub market: String, + pub timestamp: u64, + pub total_ask_size: DecimalString, + pub total_bid_size: DecimalString, + pub orderbook_units: Vec, + pub level: u32, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct OrderbookInstrument { + pub market: String, + pub quote_currency: String, + pub tick_size: DecimalString, + pub supported_levels: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct OrderbookLevels { + pub market: String, + pub supported_levels: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct Balance { + pub currency: String, + pub balance: DecimalString, + pub locked: DecimalString, + pub avg_buy_price: DecimalString, + pub avg_buy_price_modified: bool, + pub unit_currency: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct OrderChance { + pub bid_fee: DecimalString, + pub ask_fee: DecimalString, + pub maker_bid_fee: DecimalString, + pub maker_ask_fee: DecimalString, + pub market: Value, + pub bid_account: Value, + pub ask_account: Value, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct Order { + pub market: Option, + pub uuid: String, + pub side: Option, + pub ord_type: Option, + pub state: Option, + pub created_at: Option, + pub remaining_volume: Option, + pub executed_volume: Option, + pub reserved_fee: Option, + pub remaining_fee: Option, + pub paid_fee: Option, + pub locked: Option, + pub trades_count: Option, + pub prevented_volume: Option, + pub prevented_locked: Option, + pub new_order_uuid: Option, + pub trades: Option>, + pub executed_funds: Option, + pub price: Option, + pub volume: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct BatchOrderResult { + pub success: Vec, + pub failed: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct WithdrawalChance { + pub member_level: Value, + pub currency: Value, + pub account: Value, + pub withdraw_limit: Value, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct Withdrawal { + #[serde(rename = "type")] + pub transfer_type: String, + pub uuid: String, + pub currency: String, + pub txid: String, + pub state: String, + pub created_at: String, + pub done_at: String, + pub amount: DecimalString, + pub fee: DecimalString, + pub transaction_type: String, + pub is_cancelable: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct WithdrawalAddress { + pub currency: String, + pub net_type: String, + pub network_name: String, + pub withdraw_address: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct DepositChance { + pub currency: String, + pub net_type: String, + pub is_deposit_possible: bool, + pub deposit_impossible_reason: String, + pub minimum_deposit_amount: DecimalString, + pub minimum_deposit_confirmations: u64, + pub decimal_precision: u32, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct DepositAddress { + pub currency: String, + pub net_type: String, + pub deposit_address: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct Deposit { + #[serde(rename = "type")] + pub transfer_type: String, + pub uuid: String, + pub currency: String, + pub txid: String, + pub state: String, + pub created_at: String, + pub done_at: String, + pub amount: DecimalString, + pub fee: DecimalString, + pub transaction_type: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct TravelRuleVasp { + pub depositable: bool, + pub vasp_uuid: String, + pub vasp_name: String, + pub withdrawable: bool, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct TravelRuleVerification { + pub deposit_uuid: String, + pub deposit_state: String, + pub verification_result: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct WalletStatus { + pub currency: String, + pub wallet_state: String, + pub block_elapsed_minutes: u64, + pub net_type: String, + pub network_name: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct ApiKey { + pub access_key: String, + pub expire_at: String, +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::*; + use upbit_mock::{load_api_spec, MockRequest, MockServerSpec}; + + fn repo_root() -> String { + env!("CARGO_MANIFEST_DIR").replace("/crates/upbit-sdk", "") + } + + fn mock() -> MockServerSpec { + MockServerSpec::from_repo_root(repo_root()).expect("repo spec should load") + } + + #[test] + fn sdk_endpoint_coverage_matches_rest_spec() { + let spec = load_api_spec(format!("{}/spec/upbit-rest-api.yaml", repo_root())) + .expect("repo spec should load"); + let expected: BTreeSet<_> = spec + .endpoints + .iter() + .filter(|endpoint| endpoint.is_rest() && endpoint.method != "LIST_SUBSCRIPTIONS") + .map(|endpoint| endpoint.id.as_str()) + .collect(); + let actual: BTreeSet<_> = SUPPORTED_REST_ENDPOINT_IDS.iter().copied().collect(); + + assert_eq!(actual.len(), 44); + assert_eq!(actual, expected); + } + + #[test] + fn order_id_types_encode_uuid_identifier_xor_queries() { + assert_eq!( + OrderId::Uuid("u1".into()) + .to_query_params() + .to_query_string(), + "uuid=u1" + ); + assert_eq!( + OrderId::Identifier("i1".into()) + .to_query_params() + .to_query_string(), + "identifier=i1" + ); + assert_eq!( + OrderIds::Uuids(vec!["u1".into(), "u2".into()]) + .to_query_params() + .to_query_string(), + "uuids[]=u1&uuids[]=u2" + ); + } + + #[test] + fn order_constraints_validate_conditional_parameters() { + let post_only_with_smp = CreateOrderRequest { + market: "KRW-BTC".into(), + side: OrderSide::Bid, + volume: Some("0.1".into()), + price: Some("1000".into()), + ord_type: OrderType::Limit, + identifier: None, + time_in_force: Some(TimeInForce::PostOnly), + smp_type: Some(SmpType::CancelMaker), + }; + assert!(matches!( + post_only_with_smp.validate(), + Err(SdkError::Request(message)) if message.contains("post_only") + )); + + let best_without_tif = CreateOrderRequest { + market: "KRW-BTC".into(), + side: OrderSide::Bid, + volume: None, + price: Some("1000".into()), + ord_type: OrderType::Best, + identifier: None, + time_in_force: None, + smp_type: None, + }; + assert!(matches!( + best_without_tif.validate(), + Err(SdkError::Request(message)) if message.contains("best orders require") + )); + } + + #[test] + fn cancel_and_new_validation_does_not_assume_order_side() { + let limit = cancel_and_new(OrderType::Limit, Some("0.1"), Some("1000"), None, None); + assert!(limit.validate().is_ok()); + + let market_sell = cancel_and_new(OrderType::Market, Some("0.1"), None, None, None); + assert!(market_sell.validate().is_ok()); + + let price_buy = cancel_and_new(OrderType::Price, None, Some("1000"), None, None); + assert!(price_buy.validate().is_ok()); + + let best_buy = cancel_and_new( + OrderType::Best, + None, + Some("1000"), + Some(TimeInForce::Ioc), + None, + ); + assert!(best_buy.validate().is_ok()); + + let best_sell = cancel_and_new( + OrderType::Best, + Some("0.1"), + None, + Some(TimeInForce::Fok), + None, + ); + assert!(best_sell.validate().is_ok()); + } + + #[test] + fn cancel_and_new_validation_rejects_invalid_new_order_shapes() { + let market_with_price = + cancel_and_new(OrderType::Market, Some("0.1"), Some("1000"), None, None); + assert!(matches!( + market_with_price.validate(), + Err(SdkError::Request(message)) if message.contains("market orders require") + )); + + let price_with_volume = + cancel_and_new(OrderType::Price, Some("0.1"), Some("1000"), None, None); + assert!(matches!( + price_with_volume.validate(), + Err(SdkError::Request(message)) if message.contains("price orders require") + )); + + let best_without_tif = cancel_and_new(OrderType::Best, Some("0.1"), None, None, None); + assert!(matches!( + best_without_tif.validate(), + Err(SdkError::Request(message)) if message.contains("best orders require") + )); + + let best_with_both_sides = cancel_and_new( + OrderType::Best, + Some("0.1"), + Some("1000"), + Some(TimeInForce::Ioc), + None, + ); + assert!(matches!( + best_with_both_sides.validate(), + Err(SdkError::Request(message)) if message.contains("exactly one") + )); + + let post_only_with_smp = cancel_and_new( + OrderType::Limit, + Some("0.1"), + Some("1000"), + Some(TimeInForce::PostOnly), + Some(SmpType::CancelTaker), + ); + assert!(matches!( + post_only_with_smp.validate(), + Err(SdkError::Request(message)) if message.contains("post_only") + )); + } + + fn cancel_and_new( + new_ord_type: OrderType, + new_volume: Option<&str>, + new_price: Option<&str>, + new_time_in_force: Option, + new_smp_type: Option, + ) -> CancelAndNewOrderRequest { + CancelAndNewOrderRequest { + previous_order: OrderId::Uuid("00000000-0000-4000-8000-000000000001".into()), + new_ord_type, + new_volume: new_volume.map(str::to_owned), + new_price: new_price.map(str::to_owned), + new_identifier: None, + new_time_in_force, + new_smp_type, + } + } + + #[test] + fn mock_fixtures_deserialize_for_representative_sdk_categories() { + let mock = mock(); + + let market = mock.dispatch(MockRequest::new("GET", "/v1/market/all")); + serde_json::from_value::>(market.body).unwrap(); + + let candle = mock.dispatch( + MockRequest::new("GET", "/v1/candles/minutes/1").with_query("market", "KRW-BTC"), + ); + serde_json::from_value::>(candle.body).unwrap(); + + let ticker = + mock.dispatch(MockRequest::new("GET", "/v1/ticker").with_query("markets", "KRW-BTC")); + serde_json::from_value::>(ticker.body).unwrap(); + + let order = mock.dispatch( + MockRequest::new("GET", "/v1/order") + .with_header("authorization", "Bearer test.jwt") + .with_query("uuid", "00000000-0000-4000-8000-000000000001"), + ); + serde_json::from_value::(order.body).unwrap(); + + let withdrawal = mock.dispatch( + MockRequest::new("GET", "/v1/withdraw").with_header("authorization", "Bearer test.jwt"), + ); + serde_json::from_value::(withdrawal.body).unwrap(); + + let deposit = mock.dispatch( + MockRequest::new("GET", "/v1/deposit").with_header("authorization", "Bearer test.jwt"), + ); + serde_json::from_value::(deposit.body).unwrap(); + + let service = mock.dispatch( + MockRequest::new("GET", "/v1/status/wallet") + .with_header("authorization", "Bearer test.jwt"), + ); + serde_json::from_value::>(service.body).unwrap(); + } +} diff --git a/crates/upbit-sdk/src/error.rs b/crates/upbit-sdk/src/error.rs index 8075d59..3f2a52d 100644 --- a/crates/upbit-sdk/src/error.rs +++ b/crates/upbit-sdk/src/error.rs @@ -23,6 +23,9 @@ pub enum SdkError { #[error("authentication error: {0}")] Auth(String), + #[error("request error: {0}")] + Request(String), + #[error("transport error: {0}")] Transport(#[from] reqwest::Error), @@ -57,7 +60,11 @@ impl SdkError { Self::RateLimited { status, .. } | Self::Upbit { status, .. } | Self::HttpStatus { status, .. } => Some(*status), - Self::Config(_) | Self::Auth(_) | Self::Transport(_) | Self::Serialization(_) => None, + Self::Config(_) + | Self::Auth(_) + | Self::Request(_) + | Self::Transport(_) + | Self::Serialization(_) => None, } } @@ -67,7 +74,7 @@ impl SdkError { Self::RateLimited { .. } | Self::Transport(_) => true, Self::HttpStatus { status, .. } => status.is_server_error(), Self::Upbit { status, .. } => status.is_server_error(), - Self::Config(_) | Self::Auth(_) | Self::Serialization(_) => false, + Self::Config(_) | Self::Auth(_) | Self::Request(_) | Self::Serialization(_) => false, } } } diff --git a/crates/upbit-sdk/src/lib.rs b/crates/upbit-sdk/src/lib.rs index 239eda9..28711ac 100644 --- a/crates/upbit-sdk/src/lib.rs +++ b/crates/upbit-sdk/src/lib.rs @@ -7,12 +7,14 @@ pub mod auth; pub mod client; pub mod config; +pub mod endpoints; pub mod error; pub mod query; pub use auth::{JwtClaims, JwtSigner}; pub use client::UpbitClient; pub use config::{Credentials, SecretValue, UpbitConfig, UpbitConfigBuilder}; +pub use endpoints::*; pub use error::{ErrorResponse, SdkError, UpbitApiError}; pub use query::{QueryParams, QueryValue}; diff --git a/crates/upbit-sdk/src/query.rs b/crates/upbit-sdk/src/query.rs index 6702ac1..dbe06db 100644 --- a/crates/upbit-sdk/src/query.rs +++ b/crates/upbit-sdk/src/query.rs @@ -52,6 +52,12 @@ impl From for QueryValue { } } +impl From for QueryValue { + fn from(value: bool) -> Self { + Self::single(value.to_string()) + } +} + #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct QueryParams { pairs: Vec<(String, QueryValue)>, @@ -74,6 +80,11 @@ impl QueryParams { self.pairs.is_empty() } + pub(crate) fn extend(mut self, other: Self) -> Self { + self.pairs.extend(other.pairs); + self + } + /// Serializes query parameters in insertion order. /// /// Upbit examples hash the exact query string sent on the wire. Repeated From cf46257c43b12493aa826859998a43bde4976272 Mon Sep 17 00:00:00 2001 From: Bogyeong Kim Date: Fri, 29 May 2026 21:42:23 +0900 Subject: [PATCH 08/13] BOG-238 Clarify mock credential usage Co-authored-by: multica-agent --- README.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 59e05a1..c09c097 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ conformance details. ## SDK Endpoint Usage -Use the local mock for credential-free endpoint development and tests: +Use the local mock for endpoint development and tests. Quotation mock examples +are credential-free: ```rust,no_run use upbit_sdk::{CandleRequest, MinuteCandleUnit, UpbitClient, UpbitConfig}; @@ -53,8 +54,24 @@ let candles = client # } ``` -Exchange endpoints require credentials and JWT signing. Keep live credentials in -the caller's secret store or environment, never in source code or fixtures: +Auth-required exchange methods still need SDK credentials so the client can +build a bearer JWT, but local mock tests should use dummy, non-live values only: + +```rust,no_run +use upbit_sdk::{Credentials, UpbitClient, UpbitConfig}; + +# fn example() -> Result { +let config = UpbitConfig::builder() + .base_url("http://127.0.0.1:8001/v1")? + .credentials(Credentials::new("test-access", "test-secret")?) + .build()?; +UpbitClient::new(config) +# } +``` + +Live exchange endpoints require credentials and JWT signing. Keep live +credentials in the caller's secret store or environment, never in source code, +mock tests, or fixtures: ```rust,no_run use upbit_sdk::{Credentials, UpbitClient, UpbitConfig}; From 3c1f94e90d3d760740550c8a257af131b57d5dff Mon Sep 17 00:00:00 2001 From: Bogyeong Kim Date: Fri, 29 May 2026 22:07:20 +0900 Subject: [PATCH 09/13] BOG-242 Prepare SDK docs and package metadata Co-authored-by: multica-agent --- README.md | 155 ++++++++----- crates/upbit-sdk/Cargo.toml | 9 + crates/upbit-sdk/README.md | 17 ++ .../examples/authenticated_client.rs | 28 +++ crates/upbit-sdk/examples/mock_server.rs | 23 ++ crates/upbit-sdk/examples/public_ticker.rs | 13 ++ docs/usage.md | 204 ++++++++++++++++++ 7 files changed, 399 insertions(+), 50 deletions(-) create mode 100644 crates/upbit-sdk/README.md create mode 100644 crates/upbit-sdk/examples/authenticated_client.rs create mode 100644 crates/upbit-sdk/examples/mock_server.rs create mode 100644 crates/upbit-sdk/examples/public_ticker.rs create mode 100644 docs/usage.md diff --git a/README.md b/README.md index 64fc63e..cbcb338 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,61 @@ # upbit-sdk -Rust workspace for an Upbit SDK and related test tooling. +Rust SDK workspace for the Upbit REST API and local mock testing tools. + +The SDK is designed around a repo-owned REST contract in +`spec/upbit-rest-api.yaml`, typed request/response models, conservative client +defaults, and a credential-free mock server so development can happen without +live trading keys. ## Workspace Layout -- `crates/upbit-sdk`: SDK crate foundation. -- `crates/upbit-mock`: spec-driven mock server and conformance baseline for - SDK integration tests. +- `crates/upbit-sdk`: SDK crate with REST endpoint methods, typed models, + configuration, JWT signing, retry/fallback controls, and redaction helpers. +- `crates/upbit-mock`: spec-driven local mock server for SDK integration tests. +- `crates/upbit-sdk/examples`: runnable SDK examples for public, authenticated, + and mock-server flows. +- `docs/usage.md`: detailed usage guide with safety and testing notes. - `spec/upbit-rest-api.yaml`: machine-readable REST API contract seeded from official Upbit documentation research. - `spec/README.md`: spec source, caveats, and regeneration policy. -The REST spec is the shared contract for SDK request/response types and mock -server route fixtures. +## Install + +This repository is not published to crates.io yet. Use the git dependency form +or a local path while the crate is prepared for publishing: + +```toml +[dependencies] +upbit-sdk = { git = "https://github.com/Bogyie/upbit-sdk", package = "upbit-sdk" } +``` + +For local workspace development: + +```toml +[dependencies] +upbit-sdk = { path = "crates/upbit-sdk" } +``` + +Future crates.io publishing must be done separately and deliberately. Do not +run `cargo publish` without explicit release authorization and a reviewed +release checklist. + +## Feature Scope + +The SDK covers the 44 REST endpoints tracked in `spec/upbit-rest-api.yaml`. +The spec inventory item `list_subscriptions` is a WebSocket operation and is +not part of the REST client surface. + +Current SDK capabilities include: + +- public Quotation API calls without credentials; +- authenticated Exchange API calls with JWT signing; +- typed endpoint request and response models; +- configurable base URL for live or loopback mock endpoints; +- optional bounded retries for retryable failures; +- optional fallback routing with unsafe/authenticated requests disabled by + default; +- sanitized tracing fields and helper redaction utilities. ## Safety Defaults @@ -30,9 +73,10 @@ server route fixtures. account identifiers, order identifiers, price, or volume, and request bodies are not emitted by the client. -The client owns one reusable `reqwest::Client` per `UpbitClient` instance, so -retry and fallback attempts rebuild request objects while preserving the -underlying HTTP client's connection pooling. +Keep live Upbit access keys, secret keys, JWTs, account identifiers, order +identifiers, and private trading data out of source code, fixtures, issue +comments, logs, and screenshots. Load credentials from the caller's secret +store or environment at runtime. ## Local Mock Server @@ -42,68 +86,79 @@ Run a credential-free mock Upbit REST endpoint for SDK integration tests: cargo run -p upbit-mock -- 127.0.0.1:8001 . ``` -Use `http://127.0.0.1:8001/v1` as the SDK REST base URL. See -`crates/upbit-mock/README.md` for auth, validation, error, fixture, and -conformance details. +Use `http://127.0.0.1:8001/v1` as the SDK REST base URL. Quotation endpoints +are public. Exchange endpoints require any bearer-token-like header in the mock +server and dummy SDK credentials when using the SDK client. Never use live keys +for mock tests. -## SDK Endpoint Usage +See `crates/upbit-mock/README.md` for auth, validation, error, fixture, and +conformance details. -Use the local mock for endpoint development and tests. Quotation mock examples -are credential-free: +## Basic Public Usage ```rust,no_run -use upbit_sdk::{CandleRequest, MinuteCandleUnit, UpbitClient, UpbitConfig}; +use upbit_sdk::{UpbitClient, UpbitConfig}; # async fn example() -> Result<(), upbit_sdk::SdkError> { -let config = UpbitConfig::builder() - .base_url("http://127.0.0.1:8001/v1")? - .build()?; -let client = UpbitClient::new(config)?; +let client = UpbitClient::new(UpbitConfig::default())?; +let tickers = client.list_tickers(vec!["KRW-BTC".to_owned()]).await?; -let candles = client - .list_candles_minutes( - MinuteCandleUnit::One, - CandleRequest { - market: "KRW-BTC".into(), - count: Some(1), - ..Default::default() - }, - ) - .await?; +for ticker in tickers { + println!("{} last trade price: {}", ticker.market, ticker.trade_price); +} # Ok(()) # } ``` -Auth-required exchange methods still need SDK credentials so the client can -build a bearer JWT, but local mock tests should use dummy, non-live values only: +## Authenticated Setup + +Authenticated Exchange API calls require credentials. Read them from the +environment or another secret store owned by the caller: ```rust,no_run use upbit_sdk::{Credentials, UpbitClient, UpbitConfig}; -# fn example() -> Result { +# fn example() -> Result> { +let access_key = std::env::var("UPBIT_ACCESS_KEY")?; +let secret_key = std::env::var("UPBIT_SECRET_KEY")?; + let config = UpbitConfig::builder() - .base_url("http://127.0.0.1:8001/v1")? - .credentials(Credentials::new("test-access", "test-secret")?) + .credentials(Credentials::new(access_key, secret_key)?) .build()?; -UpbitClient::new(config) + +let client = UpbitClient::new(config)?; +# Ok(client) # } ``` -Live exchange endpoints require credentials and JWT signing. Keep live -credentials in the caller's secret store or environment, never in source code, -mock tests, or fixtures: +Avoid copy-pasteable live order examples. Prefer read-only account endpoints +and the mock server while validating integration code. -```rust,no_run -use upbit_sdk::{Credentials, UpbitClient, UpbitConfig}; +## Examples And Checks -# fn example(access_key: String, secret_key: String) -> Result { -let config = UpbitConfig::builder() - .credentials(Credentials::new(access_key, secret_key)?) - .build()?; -UpbitClient::new(config) -# } +Run examples against the live public API or a local mock as appropriate: + +```sh +cargo run -p upbit-sdk --example public_ticker +cargo run -p upbit-sdk --example mock_server +UPBIT_ACCESS_KEY=placeholder UPBIT_SECRET_KEY=placeholder \ + cargo run -p upbit-sdk --example authenticated_client ``` -The SDK covers the 44 REST endpoints in `spec/upbit-rest-api.yaml`. The spec's -`list_subscriptions` inventory item is a WebSocket operation and is documented -outside the REST client surface. +Recommended local checks before opening a change: + +```sh +cargo fmt --check +cargo test +cargo check -p upbit-sdk --examples +cargo package -p upbit-sdk --allow-dirty --list +``` + +`cargo package --list` is a non-publishing readiness check. It must not be +replaced with `cargo publish` unless a release is explicitly authorized. + +## More Usage + +See `docs/usage.md` for public quotation calls, authenticated client setup, +mock-server configuration, retry/fallback examples, error handling, logging and +redaction guidance, and current crates.io readiness notes. diff --git a/crates/upbit-sdk/Cargo.toml b/crates/upbit-sdk/Cargo.toml index ae2cb78..a04af27 100644 --- a/crates/upbit-sdk/Cargo.toml +++ b/crates/upbit-sdk/Cargo.toml @@ -6,6 +6,15 @@ license.workspace = true repository.workspace = true rust-version.workspace = true description = "Rust SDK foundation for the Upbit REST API." +readme = "README.md" +keywords = ["upbit", "crypto", "sdk", "rest", "trading"] +categories = ["api-bindings", "asynchronous", "web-programming::http-client"] +include = [ + "Cargo.toml", + "README.md", + "src/**/*.rs", + "examples/**/*.rs", +] [lib] name = "upbit_sdk" diff --git a/crates/upbit-sdk/README.md b/crates/upbit-sdk/README.md new file mode 100644 index 0000000..81baffc --- /dev/null +++ b/crates/upbit-sdk/README.md @@ -0,0 +1,17 @@ +# upbit-sdk crate + +Rust SDK foundation for the Upbit REST API. + +This crate exposes typed request and response models for the REST endpoint +inventory tracked in this repository, a reusable `reqwest`-backed client, +JWT signing for authenticated Exchange API calls, conservative retry and +fallback controls, and log redaction helpers. + +Start with the repository README and usage guide: + +- Repository README: +- Usage guide: + +Do not commit real Upbit access keys, secret keys, JWTs, account identifiers, +order identifiers, or private trading data. Use the local mock server for +development and examples whenever possible. diff --git a/crates/upbit-sdk/examples/authenticated_client.rs b/crates/upbit-sdk/examples/authenticated_client.rs new file mode 100644 index 0000000..d43cd0a --- /dev/null +++ b/crates/upbit-sdk/examples/authenticated_client.rs @@ -0,0 +1,28 @@ +use std::env; + +use upbit_sdk::{Credentials, SdkError, UpbitClient, UpbitConfig}; + +#[tokio::main] +async fn main() -> Result<(), SdkError> { + let access_key = env::var("UPBIT_ACCESS_KEY") + .map_err(|_| SdkError::Config("UPBIT_ACCESS_KEY must be set by the caller".into()))?; + let secret_key = env::var("UPBIT_SECRET_KEY") + .map_err(|_| SdkError::Config("UPBIT_SECRET_KEY must be set by the caller".into()))?; + + let config = UpbitConfig::builder() + .credentials(Credentials::new(access_key, secret_key)?) + .build()?; + let client = UpbitClient::new(config)?; + + match client.get_balance().await { + Ok(balances) => { + println!("received {} balance rows", balances.len()); + Ok(()) + } + Err(SdkError::RateLimited { retry_after, .. }) => { + eprintln!("rate limited; retry_after={retry_after:?}"); + Ok(()) + } + Err(error) => Err(error), + } +} diff --git a/crates/upbit-sdk/examples/mock_server.rs b/crates/upbit-sdk/examples/mock_server.rs new file mode 100644 index 0000000..5b439e5 --- /dev/null +++ b/crates/upbit-sdk/examples/mock_server.rs @@ -0,0 +1,23 @@ +use upbit_sdk::{Credentials, SdkError, UpbitClient, UpbitConfig}; + +const MOCK_BASE_URL: &str = "http://127.0.0.1:8001/v1"; + +#[tokio::main] +async fn main() -> Result<(), SdkError> { + let public_config = UpbitConfig::builder().base_url(MOCK_BASE_URL)?.build()?; + let public_client = UpbitClient::new(public_config)?; + let tickers = public_client + .list_tickers(vec!["KRW-BTC".to_owned()]) + .await?; + println!("mock returned {} ticker rows", tickers.len()); + + let auth_config = UpbitConfig::builder() + .base_url(MOCK_BASE_URL)? + .credentials(Credentials::new("test-access-key", "test-secret-key")?) + .build()?; + let auth_client = UpbitClient::new(auth_config)?; + let balances = auth_client.get_balance().await?; + println!("mock returned {} balance rows", balances.len()); + + Ok(()) +} diff --git a/crates/upbit-sdk/examples/public_ticker.rs b/crates/upbit-sdk/examples/public_ticker.rs new file mode 100644 index 0000000..1918011 --- /dev/null +++ b/crates/upbit-sdk/examples/public_ticker.rs @@ -0,0 +1,13 @@ +use upbit_sdk::{SdkError, UpbitClient, UpbitConfig}; + +#[tokio::main] +async fn main() -> Result<(), SdkError> { + let client = UpbitClient::new(UpbitConfig::default())?; + let tickers = client.list_tickers(vec!["KRW-BTC".to_owned()]).await?; + + for ticker in tickers { + println!("{} last trade price: {}", ticker.market, ticker.trade_price); + } + + Ok(()) +} diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..8522c17 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,204 @@ +# upbit-sdk Usage Guide + +This guide shows safe SDK setup patterns for public quotation data, +authenticated Exchange API calls, local mock-server tests, retry/fallback +configuration, error handling, and logging redaction. + +Do not commit real Upbit access keys, secret keys, JWTs, account identifiers, +order identifiers, or private trading data. Examples use placeholders and +read-only flows unless they target the local mock server. + +## Public Quotation API + +Quotation endpoints do not require credentials. The default client uses +`https://api.upbit.com/v1`. + +```rust,no_run +use upbit_sdk::{UpbitClient, UpbitConfig}; + +# async fn example() -> Result<(), upbit_sdk::SdkError> { +let client = UpbitClient::new(UpbitConfig::default())?; +let tickers = client.list_tickers(vec!["KRW-BTC".to_owned()]).await?; + +for ticker in tickers { + println!("{} trade price: {}", ticker.market, ticker.trade_price); +} +# Ok(()) +# } +``` + +For examples that should never leave the local machine, set the base URL to the +mock server instead. + +## Authenticated Exchange API Setup + +Authenticated endpoints need Upbit API credentials so the SDK can sign a bearer +JWT. Load credentials at runtime from environment variables or another caller +owned secret store. + +```rust,no_run +use upbit_sdk::{Credentials, UpbitClient, UpbitConfig}; + +# fn example() -> Result> { +let access_key = std::env::var("UPBIT_ACCESS_KEY")?; +let secret_key = std::env::var("UPBIT_SECRET_KEY")?; + +let config = UpbitConfig::builder() + .credentials(Credentials::new(access_key, secret_key)?) + .build()?; + +let client = UpbitClient::new(config)?; +# Ok(client) +# } +``` + +Use read-only account methods first, such as `get_balance` or `list_api_keys`. +Do not put live trading examples in docs or tests. Validate order-related code +against the mock server or a separately approved sandbox process. + +## Local Mock Server + +Start the mock server from the repository root: + +```sh +cargo run -p upbit-mock -- 127.0.0.1:8001 . +``` + +Configure the SDK to use the loopback base URL: + +```rust,no_run +use upbit_sdk::{UpbitClient, UpbitConfig}; + +# async fn example() -> Result<(), upbit_sdk::SdkError> { +let config = UpbitConfig::builder() + .base_url("http://127.0.0.1:8001/v1")? + .build()?; +let client = UpbitClient::new(config)?; + +let tickers = client.list_tickers(vec!["KRW-BTC".to_owned()]).await?; +assert!(!tickers.is_empty()); +# Ok(()) +# } +``` + +For authenticated mock calls, provide dummy credentials. The SDK still signs a +request, while the mock server only checks that a bearer-token-like +authorization header is present. + +```rust,no_run +use upbit_sdk::{Credentials, UpbitClient, UpbitConfig}; + +# async fn example() -> Result<(), upbit_sdk::SdkError> { +let config = UpbitConfig::builder() + .base_url("http://127.0.0.1:8001/v1")? + .credentials(Credentials::new("test-access-key", "test-secret-key")?) + .build()?; +let client = UpbitClient::new(config)?; + +let balances = client.get_balance().await?; +assert!(!balances.is_empty()); +# Ok(()) +# } +``` + +## Retry And Fallback + +Retries and fallback routing are disabled by default. Enable them explicitly +for idempotent or otherwise safe flows. + +```rust,no_run +use std::time::Duration; + +use upbit_sdk::{UpbitClient, UpbitConfig}; + +# fn example() -> Result { +let config = UpbitConfig::builder() + .retry_enabled(true) + .retry_max_attempts(3)? + .retry_backoff(Duration::from_millis(100), Duration::from_secs(2))? + .fallback_enabled(true) + .fallback_base_url("http://127.0.0.1:8001/v1")? + .build()?; + +let client = UpbitClient::new(config)?; +# Ok(client) +# } +``` + +Conservative defaults matter: + +- retryable failures include transport errors, rate limits, and server errors; +- unsafe requests such as order mutation are not retried unless + `retry_unsafe_requests(true)` is set; +- fallback requests are not sent for unsafe methods unless + `fallback_allow_unsafe_requests(true)` is set; +- authenticated requests are not sent to fallback endpoints unless + `fallback_allow_authenticated_requests(true)` is set. + +Only enable unsafe or authenticated fallback when the alternate endpoint is +trusted and the operation has been reviewed for duplicate side effects. + +## Error Handling + +SDK methods return `Result`. Match typed variants when behavior +depends on the failure class. + +```rust,no_run +use upbit_sdk::{SdkError, UpbitClient, UpbitConfig}; + +# async fn example() -> Result<(), upbit_sdk::SdkError> { +let client = UpbitClient::new(UpbitConfig::default())?; + +match client.list_tickers(vec!["KRW-BTC".to_owned()]).await { + Ok(tickers) => println!("received {} ticker rows", tickers.len()), + Err(SdkError::RateLimited { retry_after, .. }) => { + eprintln!("rate limited; retry_after={retry_after:?}"); + } + Err(error) if error.is_retryable() => { + eprintln!("retryable Upbit SDK error: {error}"); + } + Err(error) => return Err(error), +} +# Ok(()) +# } +``` + +Upbit error envelopes are exposed as typed `SdkError::Upbit` values when the +server returns a documented error body. + +## Logging And Redaction + +The client emits sanitized `tracing` fields for request attempts and retries. +It does not log request bodies. URL query values are redacted when their key +looks credential-, account-, order-, price-, or volume-related. + +Use the public helpers before writing raw provider text to local diagnostics: + +```rust +use upbit_sdk::redact_sensitive_text; + +let raw = "Authorization: Bearer header.claims.signature access_key=raw"; +let safe = redact_sensitive_text(raw); +assert!(!safe.contains("header.claims.signature")); +assert!(!safe.contains("raw")); +``` + +Never log authorization headers, JWTs, access keys, secret keys, raw order +payloads, account identifiers, or private balances. + +## Crates.io Readiness + +The crate metadata currently includes a description, repository, license, +readme, keywords, categories, Rust edition, Rust version, and an explicit +package include list. Packaging readiness should be checked without publishing: + +```sh +cargo package -p upbit-sdk --allow-dirty --list +``` + +Known future publish blockers: + +- final crate version policy and changelog/release notes must be approved; +- crates.io ownership/token setup must be handled outside this repository; +- the root README and crate README should be reviewed for public-facing wording; +- `cargo publish` must not be run until the release is explicitly authorized. From 77de52a0057fc72eb6b79eaa2f72eebf1ff0b578 Mon Sep 17 00:00:00 2001 From: Bogyeong Kim Date: Fri, 29 May 2026 22:21:45 +0900 Subject: [PATCH 10/13] BOG-244 Add crates publish workflow Co-authored-by: multica-agent --- .github/workflows/publish-crates.yml | 109 +++++++++++++++++++++++++++ README.md | 11 ++- docs/publishing.md | 78 +++++++++++++++++++ docs/usage.md | 10 +++ 4 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish-crates.yml create mode 100644 docs/publishing.md diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml new file mode 100644 index 0000000..7bedee5 --- /dev/null +++ b/.github/workflows/publish-crates.yml @@ -0,0 +1,109 @@ +name: Publish crates.io package + +on: + pull_request: + paths: + - ".github/workflows/publish-crates.yml" + - "Cargo.toml" + - "Cargo.lock" + - "crates/upbit-sdk/**" + push: + branches: + - integration/BOG-223-upbit-rust-sdk + - main + paths: + - ".github/workflows/publish-crates.yml" + - "Cargo.toml" + - "Cargo.lock" + - "crates/upbit-sdk/**" + workflow_dispatch: + inputs: + mode: + description: "Run a dry run or publish the upbit-sdk crate." + required: true + type: choice + default: dry-run + options: + - dry-run + - publish + +permissions: + contents: read + +concurrency: + group: publish-crates-${{ github.ref }} + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + CRATE_PATH: crates/upbit-sdk + +jobs: + dry-run: + name: Dry-run upbit-sdk publish + if: ${{ github.event_name != 'workflow_dispatch' || inputs.mode == 'dry-run' }} + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Select stable Rust toolchain + run: | + rustup update stable + rustup default stable + + - name: Confirm dry-run behavior + run: echo "Dry-run mode selected; no crates.io upload will be attempted." + + - name: Check package metadata + run: cargo package -p upbit-sdk --allow-dirty --list + + - name: Dry-run publish crate + uses: katyo/publish-crates@v2 + with: + registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }} + path: ${{ env.CRATE_PATH }} + dry-run: true + check-repo: false + + publish: + name: Publish upbit-sdk + if: ${{ github.event_name == 'workflow_dispatch' && inputs.mode == 'publish' }} + runs-on: ubuntu-latest + environment: crates-io + env: + HAS_CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN != '' }} + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Select stable Rust toolchain + run: | + rustup update stable + rustup default stable + + - name: Validate publish preconditions + run: | + set -euo pipefail + if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then + echo "Real publish must be run from a release tag ref." >&2 + exit 1 + fi + if [[ "${HAS_CARGO_REGISTRY_TOKEN}" != "true" ]]; then + echo "CARGO_REGISTRY_TOKEN secret is required for real publish." >&2 + exit 1 + fi + echo "Manual publish preconditions satisfied for ${GITHUB_REF_NAME}." + + - name: Check package metadata + run: cargo package -p upbit-sdk --allow-dirty --list + + - name: Publish crate + uses: katyo/publish-crates@v2 + with: + registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }} + path: ${{ env.CRATE_PATH }} + dry-run: false + check-repo: true diff --git a/README.md b/README.md index cbcb338..fa18df1 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ live trading keys. - `crates/upbit-sdk/examples`: runnable SDK examples for public, authenticated, and mock-server flows. - `docs/usage.md`: detailed usage guide with safety and testing notes. +- `docs/publishing.md`: crates.io dry-run and controlled publish workflow notes. - `spec/upbit-rest-api.yaml`: machine-readable REST API contract seeded from official Upbit documentation research. - `spec/README.md`: spec source, caveats, and regeneration policy. @@ -38,7 +39,12 @@ upbit-sdk = { path = "crates/upbit-sdk" } Future crates.io publishing must be done separately and deliberately. Do not run `cargo publish` without explicit release authorization and a reviewed -release checklist. +release checklist. The repository workflow `.github/workflows/publish-crates.yml` +uses `katyo/publish-crates@v2`; pull request, integration-branch, main-branch, +and manual dry-run paths cannot publish because they run with `dry-run: true`. +Real publishing requires a manual workflow dispatch with `mode=publish`, a +release tag ref, the `CARGO_REGISTRY_TOKEN` GitHub secret, and the protected +`crates-io` environment. See `docs/publishing.md` for the full procedure. ## Feature Scope @@ -162,3 +168,6 @@ replaced with `cargo publish` unless a release is explicitly authorized. See `docs/usage.md` for public quotation calls, authenticated client setup, mock-server configuration, retry/fallback examples, error handling, logging and redaction guidance, and current crates.io readiness notes. + +See `docs/publishing.md` for crates.io dry-run checks, manual publish +preconditions, secret handling, and failed publish or rollback caveats. diff --git a/docs/publishing.md b/docs/publishing.md new file mode 100644 index 0000000..791698b --- /dev/null +++ b/docs/publishing.md @@ -0,0 +1,78 @@ +# crates.io Publishing + +This repository includes a GitHub Actions workflow for crates.io publish +readiness and controlled manual publishing of the `upbit-sdk` crate: + +- workflow: `.github/workflows/publish-crates.yml` +- package path: `crates/upbit-sdk` +- requested publish action: `katyo/publish-crates@v2` +- crates.io token secret: `CARGO_REGISTRY_TOKEN` +- protected GitHub Environment for real publish: `crates-io` + +Do not run `cargo publish`, create a release tag, create a GitHub Release, or +merge release branches unless the release is explicitly authorized. + +## Safe Dry Runs + +The workflow runs in dry-run mode for pull requests, pushes to +`integration/BOG-223-upbit-rust-sdk`, pushes to `main`, and manual +`workflow_dispatch` runs where `mode` is `dry-run`. + +Dry-run jobs: + +- run `cargo package -p upbit-sdk --allow-dirty --list`; +- call `katyo/publish-crates@v2` with `dry-run: true`; +- set `check-repo: false` so pull request or detached checkout contexts do not + fail before the publish dry run; +- must not upload a package to crates.io. + +The workflow prints an explicit dry-run confirmation before the action step. + +## Real Publish Path + +Real crates.io publishing is intentionally narrow. It can only happen when all +of these conditions are true: + +- the workflow is started manually with `workflow_dispatch`; +- the `mode` input is `publish`; +- the selected workflow ref is a release tag; +- the repository has a `CARGO_REGISTRY_TOKEN` secret with crates.io publish + permissions for `upbit-sdk`; +- the `crates-io` GitHub Environment approvals and protections, if configured, + allow the job to continue. + +The publish job validates the tag ref and token presence before invoking +`katyo/publish-crates@v2` with `dry-run: false` and `check-repo: true`. + +Recommended release sequence: + +1. Confirm the crate version, changelog, README, examples, and package include + list are approved. +2. Run the workflow manually with `mode=dry-run` on the release candidate ref. +3. Create the release tag only after release approval. +4. Run the workflow manually from that tag with `mode=publish`. +5. Verify the crates.io package page and published metadata after the job + completes. + +## Secret Handling + +Store the crates.io token only as a GitHub Actions secret named +`CARGO_REGISTRY_TOKEN`. Do not put the token in repository files, issue +comments, workflow logs, screenshots, or local command output. + +Dry-run jobs pass the same input shape to the action, but the action documents +that the registry token is not used when dry-run mode is enabled. + +## Failure And Rollback Notes + +crates.io publishes are not mutable. A bad publish usually cannot be replaced +with the same version. If a publish fails or a bad package is released: + +- inspect the GitHub Actions logs without exposing secrets; +- fix the repository state and rerun dry-run before any publish retry; +- publish a new semver version when a published artifact must be corrected; +- yank a version only when the release owner decides the published version + should no longer be selected by new dependency resolution. + +Tag deletion, release deletion, base-branch merges, and crates.io yanks all +require separate explicit authorization. diff --git a/docs/usage.md b/docs/usage.md index 8522c17..c1f5018 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -202,3 +202,13 @@ Known future publish blockers: - crates.io ownership/token setup must be handled outside this repository; - the root README and crate README should be reviewed for public-facing wording; - `cargo publish` must not be run until the release is explicitly authorized. + +The repository also includes `.github/workflows/publish-crates.yml` for +automated package dry runs and a protected manual publish path. Pull requests, +integration-branch pushes, main-branch pushes, and manual `mode=dry-run` +dispatches run the requested `katyo/publish-crates@v2` action with +`dry-run: true` and cannot publish to crates.io. Real publishing requires a +manual `mode=publish` dispatch from a release tag, a `CARGO_REGISTRY_TOKEN` +GitHub secret, and the protected `crates-io` environment. + +See `docs/publishing.md` before preparing any crates.io release. From 13dfe17362761a625dca44d228a244b365abe0f2 Mon Sep 17 00:00:00 2001 From: Bogyeong Kim Date: Fri, 29 May 2026 22:33:16 +0900 Subject: [PATCH 11/13] BOG-244 Harden crates publish workflow Co-authored-by: multica-agent --- .github/workflows/publish-crates.yml | 5 ++--- README.md | 12 +++++++----- docs/publishing.md | 20 ++++++++++++++------ docs/usage.md | 9 +++++---- 4 files changed, 28 insertions(+), 18 deletions(-) diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index 7bedee5..e2e4d16 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -60,9 +60,8 @@ jobs: run: cargo package -p upbit-sdk --allow-dirty --list - name: Dry-run publish crate - uses: katyo/publish-crates@v2 + uses: katyo/publish-crates@02cc2f1ad653fb25c7d1ff9eb590a8a50d06186b with: - registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }} path: ${{ env.CRATE_PATH }} dry-run: true check-repo: false @@ -101,7 +100,7 @@ jobs: run: cargo package -p upbit-sdk --allow-dirty --list - name: Publish crate - uses: katyo/publish-crates@v2 + uses: katyo/publish-crates@02cc2f1ad653fb25c7d1ff9eb590a8a50d06186b with: registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }} path: ${{ env.CRATE_PATH }} diff --git a/README.md b/README.md index fa18df1..f31bacb 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,13 @@ upbit-sdk = { path = "crates/upbit-sdk" } Future crates.io publishing must be done separately and deliberately. Do not run `cargo publish` without explicit release authorization and a reviewed release checklist. The repository workflow `.github/workflows/publish-crates.yml` -uses `katyo/publish-crates@v2`; pull request, integration-branch, main-branch, -and manual dry-run paths cannot publish because they run with `dry-run: true`. -Real publishing requires a manual workflow dispatch with `mode=publish`, a -release tag ref, the `CARGO_REGISTRY_TOKEN` GitHub secret, and the protected -`crates-io` environment. See `docs/publishing.md` for the full procedure. +uses a reviewed pinned equivalent of `katyo/publish-crates@v2`; pull request, +integration-branch, main-branch, and manual dry-run paths cannot publish +because they run with `dry-run: true` and do not pass a registry token to the +third-party action. Real publishing requires a manual workflow dispatch with +`mode=publish`, a release tag ref, the `CARGO_REGISTRY_TOKEN` GitHub secret, +and the protected `crates-io` environment. See `docs/publishing.md` for the +full procedure. ## Feature Scope diff --git a/docs/publishing.md b/docs/publishing.md index 791698b..4fc1a6d 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -5,7 +5,9 @@ readiness and controlled manual publishing of the `upbit-sdk` crate: - workflow: `.github/workflows/publish-crates.yml` - package path: `crates/upbit-sdk` -- requested publish action: `katyo/publish-crates@v2` +- requested publish action: reviewed pinned equivalent of + `katyo/publish-crates@v2` +- reviewed action revision: `02cc2f1ad653fb25c7d1ff9eb590a8a50d06186b` - crates.io token secret: `CARGO_REGISTRY_TOKEN` - protected GitHub Environment for real publish: `crates-io` @@ -16,14 +18,18 @@ merge release branches unless the release is explicitly authorized. The workflow runs in dry-run mode for pull requests, pushes to `integration/BOG-223-upbit-rust-sdk`, pushes to `main`, and manual -`workflow_dispatch` runs where `mode` is `dry-run`. +`workflow_dispatch` runs where `mode` is `dry-run`. Pull request and push +dry-runs only trigger when the workflow or package paths listed in the workflow +change. Dry-run jobs: - run `cargo package -p upbit-sdk --allow-dirty --list`; -- call `katyo/publish-crates@v2` with `dry-run: true`; +- call the reviewed pinned `katyo/publish-crates@v2` equivalent with + `dry-run: true`; - set `check-repo: false` so pull request or detached checkout contexts do not fail before the publish dry run; +- do not pass `registry-token` to the third-party action; - must not upload a package to crates.io. The workflow prints an explicit dry-run confirmation before the action step. @@ -42,7 +48,8 @@ of these conditions are true: allow the job to continue. The publish job validates the tag ref and token presence before invoking -`katyo/publish-crates@v2` with `dry-run: false` and `check-repo: true`. +the reviewed pinned `katyo/publish-crates@v2` equivalent with `dry-run: false` +and `check-repo: true`. Recommended release sequence: @@ -60,8 +67,9 @@ Store the crates.io token only as a GitHub Actions secret named `CARGO_REGISTRY_TOKEN`. Do not put the token in repository files, issue comments, workflow logs, screenshots, or local command output. -Dry-run jobs pass the same input shape to the action, but the action documents -that the registry token is not used when dry-run mode is enabled. +Dry-run jobs do not pass the registry token to the third-party action. The +token is only provided to the protected manual publish job after its tag and +secret preconditions pass. ## Failure And Rollback Notes diff --git a/docs/usage.md b/docs/usage.md index c1f5018..7bc3817 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -206,9 +206,10 @@ Known future publish blockers: The repository also includes `.github/workflows/publish-crates.yml` for automated package dry runs and a protected manual publish path. Pull requests, integration-branch pushes, main-branch pushes, and manual `mode=dry-run` -dispatches run the requested `katyo/publish-crates@v2` action with -`dry-run: true` and cannot publish to crates.io. Real publishing requires a -manual `mode=publish` dispatch from a release tag, a `CARGO_REGISTRY_TOKEN` -GitHub secret, and the protected `crates-io` environment. +dispatches run a reviewed pinned equivalent of the requested +`katyo/publish-crates@v2` action with `dry-run: true` and cannot publish to +crates.io. Dry-run jobs do not pass a registry token to the third-party action. +Real publishing requires a manual `mode=publish` dispatch from a release tag, a +`CARGO_REGISTRY_TOKEN` GitHub secret, and the protected `crates-io` environment. See `docs/publishing.md` before preparing any crates.io release. From a0b10e078804dc3f57cf4d8607981a638aefdf34 Mon Sep 17 00:00:00 2001 From: Bogyeong Kim Date: Fri, 29 May 2026 22:50:50 +0900 Subject: [PATCH 12/13] BOG-246 Trigger publish on GitHub release Co-authored-by: multica-agent --- .github/workflows/publish-crates.yml | 43 +++++++++++++++++++++++++--- README.md | 8 +++--- docs/publishing.md | 43 +++++++++++++++++----------- 3 files changed, 70 insertions(+), 24 deletions(-) diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index e2e4d16..30f3f69 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -1,6 +1,9 @@ name: Publish crates.io package on: + release: + types: + - published pull_request: paths: - ".github/workflows/publish-crates.yml" @@ -41,7 +44,7 @@ env: jobs: dry-run: name: Dry-run upbit-sdk publish - if: ${{ github.event_name != 'workflow_dispatch' || inputs.mode == 'dry-run' }} + if: ${{ github.event_name != 'release' && (github.event_name != 'workflow_dispatch' || inputs.mode == 'dry-run') }} runs-on: ubuntu-latest steps: @@ -68,7 +71,7 @@ jobs: publish: name: Publish upbit-sdk - if: ${{ github.event_name == 'workflow_dispatch' && inputs.mode == 'publish' }} + if: ${{ github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.mode == 'publish') }} runs-on: ubuntu-latest environment: crates-io env: @@ -77,6 +80,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Select stable Rust toolchain run: | @@ -87,14 +92,44 @@ jobs: run: | set -euo pipefail if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then - echo "Real publish must be run from a release tag ref." >&2 + echo "Real publish must run from a release tag ref." >&2 exit 1 fi if [[ "${HAS_CARGO_REGISTRY_TOKEN}" != "true" ]]; then echo "CARGO_REGISTRY_TOKEN secret is required for real publish." >&2 exit 1 fi - echo "Manual publish preconditions satisfied for ${GITHUB_REF_NAME}." + + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + tag_commit="$(git rev-parse "${GITHUB_REF_NAME}^{commit}")" + main_commit="$(git rev-parse "origin/main^{commit}")" + + if ! git merge-base --is-ancestor "${tag_commit}" "${main_commit}"; then + echo "Release tag ${GITHUB_REF_NAME} must point to a commit already reachable from origin/main." >&2 + exit 1 + fi + + if [[ "${GITHUB_EVENT_NAME}" == "release" ]]; then + event_action="$(jq -r '.action // ""' "${GITHUB_EVENT_PATH}")" + if [[ "${event_action}" != "published" ]]; then + echo "Release-triggered publishing only accepts the published action." >&2 + exit 1 + fi + + release_target="$(jq -r '.release.target_commitish // ""' "${GITHUB_EVENT_PATH}")" + case "${release_target}" in + main|refs/heads/main|"${main_commit}"|"${tag_commit}") + ;; + *) + echo "Release target_commitish must be main or the verified main/tag commit; got '${release_target}'." >&2 + exit 1 + ;; + esac + + echo "Release publish preconditions satisfied for ${GITHUB_REF_NAME} on main." + else + echo "Manual publish preconditions satisfied for ${GITHUB_REF_NAME} on main." + fi - name: Check package metadata run: cargo package -p upbit-sdk --allow-dirty --list diff --git a/README.md b/README.md index f31bacb..235d632 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,10 @@ release checklist. The repository workflow `.github/workflows/publish-crates.yml uses a reviewed pinned equivalent of `katyo/publish-crates@v2`; pull request, integration-branch, main-branch, and manual dry-run paths cannot publish because they run with `dry-run: true` and do not pass a registry token to the -third-party action. Real publishing requires a manual workflow dispatch with -`mode=publish`, a release tag ref, the `CARGO_REGISTRY_TOKEN` GitHub secret, -and the protected `crates-io` environment. See `docs/publishing.md` for the -full procedure. +third-party action. Real publishing is triggered when a GitHub Release is +published for a tag that is already reachable from `main`, and it requires the +`CARGO_REGISTRY_TOKEN` GitHub secret plus the protected `crates-io` +environment. See `docs/publishing.md` for the full procedure. ## Feature Scope diff --git a/docs/publishing.md b/docs/publishing.md index 4fc1a6d..215fdf8 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -1,7 +1,8 @@ # crates.io Publishing This repository includes a GitHub Actions workflow for crates.io publish -readiness and controlled manual publishing of the `upbit-sdk` crate: +readiness and controlled publishing of the `upbit-sdk` crate after a GitHub +Release is published from `main`: - workflow: `.github/workflows/publish-crates.yml` - package path: `crates/upbit-sdk` @@ -18,9 +19,10 @@ merge release branches unless the release is explicitly authorized. The workflow runs in dry-run mode for pull requests, pushes to `integration/BOG-223-upbit-rust-sdk`, pushes to `main`, and manual -`workflow_dispatch` runs where `mode` is `dry-run`. Pull request and push -dry-runs only trigger when the workflow or package paths listed in the workflow -change. +`workflow_dispatch` runs where `mode` is `dry-run`. GitHub Release events do +not run the dry-run job because they are reserved for the real publish path. +Pull request and push dry-runs only trigger when the workflow or package paths +listed in the workflow change. Dry-run jobs: @@ -36,29 +38,38 @@ The workflow prints an explicit dry-run confirmation before the action step. ## Real Publish Path -Real crates.io publishing is intentionally narrow. It can only happen when all -of these conditions are true: +Real crates.io publishing is intentionally narrow. The expected path is: +merge the release commit to `main`, create a release tag from `main`, then +publish a GitHub Release for that tag. The publish job can only proceed when +all of these conditions are true: -- the workflow is started manually with `workflow_dispatch`; -- the `mode` input is `publish`; +- the workflow is triggered by a GitHub Release `published` event, or a + separately authorized manual `workflow_dispatch` run with `mode=publish`; - the selected workflow ref is a release tag; +- the release tag commit is already reachable from `origin/main`; +- for GitHub Release events, the event action is `published` and + `release.target_commitish` resolves to `main`, `refs/heads/main`, the + verified `origin/main` commit, or the verified tag commit; - the repository has a `CARGO_REGISTRY_TOKEN` secret with crates.io publish permissions for `upbit-sdk`; - the `crates-io` GitHub Environment approvals and protections, if configured, allow the job to continue. -The publish job validates the tag ref and token presence before invoking -the reviewed pinned `katyo/publish-crates@v2` equivalent with `dry-run: false` -and `check-repo: true`. +The publish job validates the tag ref, main-branch ancestry, release event +shape, and token presence before invoking the reviewed pinned +`katyo/publish-crates@v2` equivalent with `dry-run: false` and +`check-repo: true`. Recommended release sequence: 1. Confirm the crate version, changelog, README, examples, and package include list are approved. 2. Run the workflow manually with `mode=dry-run` on the release candidate ref. -3. Create the release tag only after release approval. -4. Run the workflow manually from that tag with `mode=publish`. -5. Verify the crates.io package page and published metadata after the job +3. Merge the approved release commit to `main`. +4. Create the release tag from `main`. +5. Publish the GitHub Release for that tag. The release `published` event starts + the real publish job. +6. Verify the crates.io package page and published metadata after the job completes. ## Secret Handling @@ -68,8 +79,8 @@ Store the crates.io token only as a GitHub Actions secret named comments, workflow logs, screenshots, or local command output. Dry-run jobs do not pass the registry token to the third-party action. The -token is only provided to the protected manual publish job after its tag and -secret preconditions pass. +token is only provided to the protected real publish job after its tag, +main-branch, release-event, and secret preconditions pass. ## Failure And Rollback Notes From c0f34ab0ff6303927187b5cfab475eb62e3b9808 Mon Sep 17 00:00:00 2001 From: Bogyeong Kim Date: Fri, 29 May 2026 23:14:19 +0900 Subject: [PATCH 13/13] BOG-240 Add final reconciliation evidence Co-authored-by: multica-agent --- docs/final-reconciliation.md | 148 +++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/final-reconciliation.md diff --git a/docs/final-reconciliation.md b/docs/final-reconciliation.md new file mode 100644 index 0000000..b799425 --- /dev/null +++ b/docs/final-reconciliation.md @@ -0,0 +1,148 @@ +# Final Reconciliation Evidence + +Status: implementation evidence for BOG-240 +Branch: `issue/BOG-240-final-spec-qa-security-pr-readiness` +Base: `origin/integration/BOG-223-upbit-rust-sdk` +Integration HEAD reviewed: `91d7ca62b9f1e5bbf6b7d8bd2694a9fda6f8a85b` +Date: 2026-05-29 + +## Source Alignment + +The repo-owned contract is `spec/upbit-rest-api.yaml`. It records BOG-228 as +the research source, `upbit-api-spec-draft.md` as the source attachment, and +official Upbit documentation verification at 2026-05-29 KST. + +Endpoint inventory: + +- total API inventory entries: 45 +- REST endpoints: 44 +- documented non-REST exception: `list_subscriptions` with `protocol: + websocket` +- public REST endpoints: 13 +- authenticated REST endpoints: 31 + +The BOG-228 and BOG-240 issue text refers to 45 REST endpoints, but +`spec/README.md` records the reconciliation decision: the current official REST +references enumerate 44 REST endpoints, while `list_subscriptions` is an +official non-REST WebSocket operation and is intentionally excluded from REST +SDK and HTTP mock route coverage. + +## REST Coverage + +SDK coverage is represented by +`crates/upbit-sdk/src/endpoints.rs::SUPPORTED_REST_ENDPOINT_IDS`. The endpoint +coverage test loads `spec/upbit-rest-api.yaml`, filters REST endpoints, and +asserts that the SDK's supported endpoint IDs exactly match the 44 REST +endpoint IDs from the spec. + +Mock coverage is represented by `upbit_mock::MockServerSpec`, which derives +routes from the same spec. The mock coverage test asserts: + +- total inventory endpoints: 45 +- REST route count: 44 +- documented exception: `list_subscriptions (websocket)` +- representative routes include `GET /market/all` and `POST /orders` + +The mock conformance test also exercises every REST route generated from the +spec and verifies each route can produce a representative fixture response. + +## Documentation And Packaging Evidence + +BOG-242 is included in the reviewed integration branch through PR #6. The +following user-facing and package-readiness artifacts are present: + +- `README.md`: install/setup, credential safety, mock-first flow, + retry/fallback defaults, logging redaction, examples, and publishing notes +- `docs/usage.md`: public quotation API, authenticated API setup, mock server, + retry/fallback, error handling, logging/redaction, and package-readiness + examples +- `docs/publishing.md`: dry-run and real publish operating procedure, secret + handling, release sequence, and failure/rollback notes +- `crates/upbit-sdk/examples/public_ticker.rs` +- `crates/upbit-sdk/examples/authenticated_client.rs` +- `crates/upbit-sdk/examples/mock_server.rs` +- `crates/upbit-sdk/Cargo.toml`: package metadata including description, + repository, license, README, keywords, categories, and include list + +No crates.io publish was performed as part of this reconciliation. + +## Release Workflow Evidence + +BOG-244 is included in the reviewed integration branch through PR #7, and +BOG-246 is included through PR #8. The reviewed integration HEAD is merge commit +`91d7ca62b9f1e5bbf6b7d8bd2694a9fda6f8a85b`. + +`.github/workflows/publish-crates.yml` includes these safety controls: + +- `pull_request`, integration-branch `push`, main-branch `push`, and + `workflow_dispatch mode=dry-run` paths run the dry-run job only +- dry-run paths do not pass `registry-token` to the third-party action +- `katyo/publish-crates` is pinned to + `02cc2f1ad653fb25c7d1ff9eb590a8a50d06186b` +- real publish can run on GitHub Release `published` events or separately + authorized manual `workflow_dispatch mode=publish` +- real publish requires tag refs, `CARGO_REGISTRY_TOKEN`, and the protected + `crates-io` environment +- release-triggered publishing validates `action == published`, + `release.target_commitish`, and that the tag commit is reachable from + `origin/main` +- checkout uses `fetch-depth: 0` for publish precondition validation + +The workflow documentation in `docs/publishing.md` describes the main merge, +tag, GitHub Release, secret, environment, and failed-publish handling +requirements. + +## Targeted Security And Code-Quality Review + +Reviewed surfaces: + +- JWT signing: `crates/upbit-sdk/src/auth.rs` +- credential and transport configuration: `crates/upbit-sdk/src/config.rs` +- request execution, retry, fallback, and logging: `crates/upbit-sdk/src/client.rs` +- error mapping: `crates/upbit-sdk/src/error.rs` +- redaction helpers: `crates/upbit-sdk/src/logging.rs` +- crates.io publish workflow: `.github/workflows/publish-crates.yml` + +Security evidence: + +- JWTs use HS512 and include a SHA512 `query_hash` when query parameters or + JSON body data are signed. +- credential formatter output is redacted by wrapper types. +- authenticated requests validate authenticated transport before signing. +- retry and fallback are disabled by default. +- unsafe methods are not retried or sent to fallback endpoints unless explicitly + enabled. +- authenticated requests are not sent to fallback endpoints unless explicitly + enabled. +- SDK tracing uses redacted URLs and does not log request bodies. +- text redaction covers bearer tokens, access keys, secret keys, query hashes, + UUID/order identifiers, price, and volume-like sensitive fields. +- publish dry-run paths avoid registry tokens, and real publish paths require + GitHub secret/environment gates. + +No remaining actionable security findings were identified from static review. +Residual operational risk remains around repository settings that cannot be +verified from code: `CARGO_REGISTRY_TOKEN`, `crates-io` environment protection, +protected `main` branch policy, and release approval process must be configured +in GitHub before any real publish. + +## Local Verification + +Commands run from the repository root: + +| Command | Result | +| --- | --- | +| `ruby -ryaml -e '...'` endpoint inventory and SDK coverage checks | pass | +| `cargo fmt --check` | pass | +| `cargo clippy --workspace --all-targets -- -D warnings` | pass | +| `cargo test --workspace` | pass; `upbit-mock` 8 unit tests, `upbit-sdk` 37 unit tests, doc-tests 0/0 | +| `cargo test --workspace --doc` | pass; doc-tests 0/0 | +| `cargo package -p upbit-sdk --allow-dirty --list` | pass; listed package contents without publishing | + +## Parent PR Readiness + +Final PR to `main` is ready to be prepared after BOG-240 QA and required +review pass, subject to Repository Contribution Steward handling public PR +creation/update and repository contribution details. The final base-branch merge +and any crates.io publish or GitHub Release remain unauthorized without explicit +approval.