Skip to content

fix(client)!: separate baseUrl from the API version - #11

Merged
kevinypfan merged 1 commit into
release/v1.5.0from
fix/base-url-version-separation
Aug 4, 2026
Merged

fix(client)!: separate baseUrl from the API version#11
kevinypfan merged 1 commit into
release/v1.5.0from
fix/base-url-version-separation

Conversation

@kevinypfan

Copy link
Copy Markdown
Collaborator

問題根源:baseUrl 同時承載 host 和 version

baseUrl 這個字串混了兩個語意:換 host(富邦專屬 endpoint、內部部署、proxy)跟選版本。結果是任何只想換 host 的人,都被迫連 version 一起自己管——這正是我們寫死 /v1.0 的原因,不是因為想 pin,而是因為沒有別的方式表達「我只是換 host」。

順著同一條規則,還有三個 RC 階段該修掉的陷阱:

  1. version: undefinedversion: {} 行為不同。 前者保留 URL 裡的版本,後者 per-product resolve 到 latest。同樣是「我沒指定任何產品的版本」,結果不一樣。
  2. applyVersionToBaseUrl 對沒有版本段的 URL 是「留著不動」。 所以就算傳乾淨的 wss://fubon-api.fugle.tw/marketdata,version 選項會被完全忽略——binding 單方面改根本改不動。
  3. scalar 形式 version: 'v1.1' 實質不可用。 只要取到 stock 就必然 throw,而且是 lazy 的、延到取 client 才爆。它只在「這個 factory 我只拿 futopt」時才安全,等於一個看起來通用、實際只在特例成立的 API 形狀。

改法:同一個 URL 片段只能有一個 owner

baseUrl 只決定 host / path prefix,版本永遠由 version 決定並由 SDK 接上去。SDK 不再 parse baseUrl

這也是主流 SDK 的分法:Stripe 的 api_base 是純 host、版本走 Stripe-Version;Anthropic、GitHub 同樣。版本放 path 的(Twilio 的 /2010-04-01)也是由 SDK 自己組,使用者永遠不寫那一段。

有個證據說明這本來就是原設計的意圖:FUGLE_MARKETDATA_API_WEBSOCKET_BASE_URL 常數本身就是乾淨的 wss://api.fugle.tw/marketdata,預設路徑做的正是 ${BASE}/${version}。偏掉的只有「使用者自己傳 baseUrl」那條分支——這個 PR 讓兩條分支變成同一條。

具體變更

新增 src/base-url.tswithVersion(baseUrl, version, hint?),REST / WS 共用:trim 尾端斜線 → 尾端是 /vX.Y 就 throw → 否則 baseUrl + '/' + version。沒有 swap、沒有分支。

websocket/factory.tsresolveBaseUrl 從兩條路收斂成三行,一律 resolveVersion(product, version) 再串接。

rest/factory.ts — 同一條規則,用它唯一服務的版本。REST 沒有 version 選項,但 baseUrl 帶版本段一樣 throw。

websocket/version.ts — 移除 scalar 形式,WebSocketVersionOption 現在是 WebSocketVersionMap 的 alias(version: 'v1.1' 變 compile error TS2559);JS 端另有 runtime 擋,訊息會列出所有服務該版本的產品。{}undefined 收斂成同一條路徑。

為什麼是 throw 而不是 swap

「尾端有版本段就換掉」看起來相容比較好,但那會默默改掉使用者指定的 endpoint——baseUrl: '.../marketdata/v2.0' 會被換成 /v1.1。純串接則會產生 .../v2.0/v1.1/... 這種難以理解的連線失敗。throw 是唯一會明講「你把值放錯旋鈕了」的選項,訊息直接給出該用的 prefix:

baseUrl must not include a version segment (found '/v1.0').
Pass the host and path prefix only: 'wss://api-dev.fugle.tw/marketdata'.
The version comes from the `version` option, e.g. version: { futopt: 'v1.1' }.

version 選項是 rc.3(兩週前)才進來的,所以「baseUrl + version 同時給」實質沒有使用者;真正的相容面是舊有「baseUrl 自帶 /v1.0」的寫法,那些人會拿到上面這則訊息。

結果

富邦 binding 完全照 official 規則,零特例:

new WebSocketClient({ apiKey, baseUrl: 'wss://fubon-api.fugle.tw/marketdata' })
// futopt → wss://fubon-api.fugle.tw/marketdata/v1.1/futopt/streaming
// stock  → wss://fubon-api.fugle.tw/marketdata/v1.0/stock/streaming

new RestClient({ apiKey, baseUrl: 'https://fubon-api.fugle.tw/marketdata' })
// stock  → https://fubon-api.fugle.tw/marketdata/v1.0/stock

Breaking changes

  • baseUrl 尾端帶 /vX.Y 會 throw TypeError。改成只傳 host + path prefix。
  • scalar version: 'v1.1' 移除,改用 per-product map version: { futopt: 'v1.1' }

⚠️ commit 帶了 BREAKING CHANGE: footer,release-it 的 angular preset 會據此推 major bump。若希望留在 1.5 RC 線上,release 時要明確指定版號。

測試

167 passed(既有測試全綠)。凡是「baseUrl 自帶版本段」的期望值都改成乾淨 baseUrl + 新預期輸出;URL normalization 那組保留 trailing-slash 的原意、prefix 換成 /marketdata,另外把 /api/v2 那個 case 改名為 should treat a path segment that is not a vX.Y version as part of the prefix,明確釘住 lint 邊界(/api/v2 不算版本段,會原樣留在 prefix)。新增涵蓋 {} 等同未指定、scalar 被拒、baseUrl 帶版本段被拒的測試。

未追蹤的 dev harness test_futopt.ts 也已在本機同步更新(它不在版控裡,不含在這個 PR)。

🤖 Generated with Claude Code

https://claude.ai/code/session_01VGBMNGKfgHPyNGMkHMcaJH

baseUrl carried two meanings at once: which host to talk to, and which
version to use. Anyone who only wanted to change host was therefore made
to manage the version by hand, and the SDK needed a precedence rule to
decide which of the two options won.

Every URL segment now has exactly one owner. baseUrl is the host and path
prefix; the version always comes from the resolved `version` option and is
appended by the SDK. The default and custom-baseUrl paths run the same
code, so a custom endpoint is written exactly like the public one.

Three traps went with it:

- `version: undefined` kept whatever version was in the URL while
  `version: {}` resolved per-product. Both now mean per-product latest.
- applyVersionToBaseUrl left a version-less URL alone, so a clean baseUrl
  made the version option a no-op. The new withVersion always appends.
- The scalar form (`version: 'v1.1'`) threw as soon as a client that
  doesn't serve that version was taken off the factory, and only ever
  worked when a caller happened to touch one product. Removed in favour
  of the per-product map, which is checked at compile time.

REST follows the same rule, using the single version it serves.

BREAKING CHANGE: a baseUrl ending in a version segment is now rejected
with a TypeError naming the prefix to use instead — pass the host and
path prefix only. The scalar `version` form is removed; use the
per-product map, e.g. version: { futopt: 'v1.1' }.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGBMNGKfgHPyNGMkHMcaJH
@kevinypfan
kevinypfan merged commit 820cc0a into release/v1.5.0 Aug 4, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant