Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/base-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const VERSION_SEGMENT = /\/v\d+\.\d+$/;

/**
* Join a caller-supplied `baseUrl` with the version the SDK resolved.
*
* `baseUrl` carries the host and path prefix and nothing else; the version
* segment is always appended here. A version written into `baseUrl` is
* rejected rather than swapped or appended on top of, because letting two
* options decide the same path segment is what forced the old precedence
* rules — and those rules meant anyone who only wanted to change host was
* made to manage the version by hand.
*/
export const withVersion = (baseUrl: string, version: string, hint = ''): string => {
const trimmed = baseUrl.replace(/\/+$/, '');
const existing = trimmed.match(VERSION_SEGMENT);

if (existing) {
throw new TypeError(
`baseUrl must not include a version segment (found '${existing[0]}'). ` +
`Pass the host and path prefix only: '${trimmed.slice(0, -existing[0].length)}'.` +
(hint ? ` ${hint}` : '')
);
}

return `${trimmed}/${version}`;
};
12 changes: 10 additions & 2 deletions src/rest/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { RestClient } from './client';
import { RestStockClient } from './stock/client';
import { RestFutOptClient } from './futopt/client';
import { ClientFactory } from '../client-factory';
import { withVersion } from '../base-url';
import { FUGLE_MARKETDATA_API_REST_BASE_URL, FUGLE_MARKETDATA_API_VERSION } from '../constants';

export class RestClientFactory extends ClientFactory {
Expand All @@ -19,8 +20,15 @@ export class RestClientFactory extends ClientFactory {
let client = this.clients.get(type);

if (!client) {
const baseUrl = this.options.baseUrl || `${FUGLE_MARKETDATA_API_REST_BASE_URL}/${FUGLE_MARKETDATA_API_VERSION}`;
const url = `${baseUrl.replace(/\/+$/, '')}/${type}`;
// Same rule as streaming: baseUrl is host and path prefix, the SDK owns
// the version segment. REST serves one version, so there's no option to
// choose it with — but a version written into baseUrl is still rejected
// rather than silently doubled.
const baseUrl = withVersion(
this.options.baseUrl || FUGLE_MARKETDATA_API_REST_BASE_URL,
FUGLE_MARKETDATA_API_VERSION,
);
const url = `${baseUrl}/${type}`;

/* istanbul ignore else */
if (type === 'stock') {
Expand Down
21 changes: 8 additions & 13 deletions src/websocket/factory.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { WebSocketClient } from './client';
import { WebSocketStockClient } from './stock/client';
import { WebSocketFutOptClient } from './futopt/client';
import { WebSocketProduct, applyVersionToBaseUrl, resolveVersion } from './version';
import { WebSocketProduct, VERSION_OPTION_HINT, resolveVersion } from './version';
import { FUGLE_MARKETDATA_API_WEBSOCKET_BASE_URL } from '../constants';
import { ClientFactory } from '../client-factory';
import { withVersion } from '../base-url';

export class WebSocketClientFactory extends ClientFactory {
private readonly clients = new Map<string, WebSocketClient>();
Expand All @@ -17,21 +18,15 @@ export class WebSocketClientFactory extends ClientFactory {
}

/**
* A `baseUrl` is only re-versioned when `version` was explicitly supplied.
* Left alone otherwise, the version the caller wrote into their URL wins —
* which keeps custom and internal deployments (whose paths need not follow
* the public versioning at all) working exactly as before.
* `baseUrl` picks the host and path prefix; `version` picks the version.
* Nothing else: a custom endpoint is written the same way as the public one,
* and pointing at a different host never forces the caller to track versions
* by hand.
*/
private resolveBaseUrl(type: WebSocketProduct) {
const version = resolveVersion(type, this.options.version);

if (!this.options.baseUrl) {
return `${FUGLE_MARKETDATA_API_WEBSOCKET_BASE_URL}/${version}`;
}

return this.options.version !== undefined
? applyVersionToBaseUrl(this.options.baseUrl, version)
: this.options.baseUrl;
const baseUrl = this.options.baseUrl || FUGLE_MARKETDATA_API_WEBSOCKET_BASE_URL;
return withVersion(baseUrl, version, VERSION_OPTION_HINT);
}

private getClient(type: WebSocketProduct) {
Expand Down
53 changes: 24 additions & 29 deletions src/websocket/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,24 @@ export type WebSocketVersion = typeof FUGLE_MARKETDATA_WS_SUPPORTED_VERSIONS[Web

/**
* Per-product form. Each product is narrowed to the versions it actually
* serves, so `{ stock: 'v1.1' }` is a compile-time error.
* serves, so `{ stock: 'v1.1' }` is a compile-time error. A product left out
* of the map — including the empty map — gets that product's latest.
*/
export type WebSocketVersionMap = {
[P in WebSocketProduct]?: typeof FUGLE_MARKETDATA_WS_SUPPORTED_VERSIONS[P][number];
};

/**
* Either a single version applied to every product, or a per-product map.
* The `version` option: always a per-product map.
*
* The scalar form can't be checked at compile time — which product it ends up
* applying to isn't known until a client is taken off the factory — so an
* unsupported combination throws when that client is created.
* A bare version string used to be accepted as "this version for every
* product", but which product it applied to wasn't known until a client was
* taken off the factory, so an unsupported pairing only surfaced then — and
* with the products serving different version sets, the only scalar that never
* throws is the one every product happens to share. The map form says the same
* thing without the trap.
*/
export type WebSocketVersionOption = WebSocketVersion | WebSocketVersionMap;
export type WebSocketVersionOption = WebSocketVersionMap;

const supportedVersions = (product: WebSocketProduct): readonly string[] =>
FUGLE_MARKETDATA_WS_SUPPORTED_VERSIONS[product];
Expand All @@ -45,19 +49,23 @@ const assertSupported = (product: WebSocketProduct, version: string, hint: strin
/**
* Resolve the streaming version for a product from the `version` option.
*
* Omitted entirely, or omitted for this product in the map form, means the
* product's latest. Nothing is ever silently clamped: asking for a version a
* product doesn't serve throws rather than quietly handing back an older one.
* Omitted entirely, an empty map, or omitted for this product all mean the same
* thing: that product's latest. Nothing is ever silently clamped — asking for a
* version a product doesn't serve throws rather than quietly handing back an
* older one.
*/
export const resolveVersion = (product: WebSocketProduct, version?: WebSocketVersionOption): string => {
if (version === undefined) return latestVersion(product);

// Guard for JavaScript callers, who don't get the compile-time rejection.
if (typeof version === 'string') {
const alternatives = productsSupporting(version);
assertSupported(product, version, alternatives.length
? `Use version: { ${alternatives[0]}: '${version}' } to target a single product.`
: `No product serves ${version}.`);
return version;
throw new TypeError(
`version must be a per-product map, not the bare string '${version}'. ` +
(alternatives.length
? `Use version: { ${alternatives.map(p => `${p}: '${version}'`).join(', ')} }.`
: `No product serves ${version}.`)
);
}

const requested = version[product];
Expand All @@ -67,19 +75,6 @@ export const resolveVersion = (product: WebSocketProduct, version?: WebSocketVer
return requested;
};

const VERSION_SEGMENT = /\/v\d+\.\d+$/;

/**
* Point an explicitly supplied `baseUrl` at `version` by swapping its trailing
* version segment (`.../marketdata/v1.0` → `.../marketdata/v1.1`).
*
* A baseUrl without a recognizable version segment is left alone — it may be a
* proxy or an internal deployment that doesn't encode a version in its path,
* and inventing one would break it.
*/
export const applyVersionToBaseUrl = (baseUrl: string, version: string): string => {
const trimmed = baseUrl.replace(/\/+$/, '');
return VERSION_SEGMENT.test(trimmed)
? trimmed.replace(VERSION_SEGMENT, `/${version}`)
: trimmed;
};
/** Appended to `baseUrl` rejections, pointing at the option that owns the version. */
export const VERSION_OPTION_HINT =
"The version comes from the `version` option, e.g. version: { futopt: 'v1.1' }.";
42 changes: 25 additions & 17 deletions test/rest-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe('RestClient', () => {
});

it('should create a RestClient instance with custom baseUrl', () => {
const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://custom-api.example.com/v2.0' });
const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://custom-api.example.com' });
expect(client).toBeInstanceOf(RestClient);
});
});
Expand All @@ -63,19 +63,19 @@ describe('RestClient', () => {
});

it('should use custom baseUrl for stock client', () => {
const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://custom-api.example.com/v2.0' });
const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://custom-api.example.com' });
const stock = client.stock;
expect(stock).toBeInstanceOf(RestStockClient);
// @ts-ignore - accessing private property for testing
expect(stock.options.baseUrl).toBe('https://custom-api.example.com/v2.0/stock');
expect(stock.options.baseUrl).toBe('https://custom-api.example.com/v1.0/stock');
});

it('should use custom baseUrl for futopt client', () => {
const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://custom-api.example.com/v2.0' });
const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://custom-api.example.com' });
const futopt = client.futopt;
expect(futopt).toBeInstanceOf(RestFutOptClient);
// @ts-ignore - accessing private property for testing
expect(futopt.options.baseUrl).toBe('https://custom-api.example.com/v2.0/futopt');
expect(futopt.options.baseUrl).toBe('https://custom-api.example.com/v1.0/futopt');
});

describe('.intraday', () => {
Expand Down Expand Up @@ -123,11 +123,11 @@ describe('RestClient', () => {
});

it('should request with custom baseUrl', async () => {
const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://custom-api.example.com/v2.0' });
const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://custom-api.example.com' });
const stock = client.stock as RestStockClient;
await stock.intraday.tickers({ type: 'INDEX' });
expect(fetch).toBeCalledWith(
'https://custom-api.example.com/v2.0/stock/intraday/tickers?type=INDEX',
'https://custom-api.example.com/v1.0/stock/intraday/tickers?type=INDEX',
{ headers: { 'X-API-KEY': 'api-key' } },
);
});
Expand Down Expand Up @@ -929,35 +929,43 @@ describe('RestClient', () => {

describe('URL normalization', () => {
it('should handle baseUrl without trailing slash', () => {
const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://api.example.com/v1' });
const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://api.example.com/marketdata' });
const stock = client.stock;
// @ts-ignore - accessing private property for testing
expect(stock.options.baseUrl).toBe('https://api.example.com/v1/stock');
expect(stock.options.baseUrl).toBe('https://api.example.com/marketdata/v1.0/stock');
});

it('should handle baseUrl with single trailing slash', () => {
const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://api.example.com/v1/' });
const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://api.example.com/marketdata/' });
const stock = client.stock;
// @ts-ignore - accessing private property for testing
expect(stock.options.baseUrl).toBe('https://api.example.com/v1/stock');
expect(stock.options.baseUrl).toBe('https://api.example.com/marketdata/v1.0/stock');
});

it('should handle baseUrl with multiple trailing slashes', () => {
const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://api.example.com/v1///' });
const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://api.example.com/marketdata///' });
const stock = client.stock;
// @ts-ignore - accessing private property for testing
expect(stock.options.baseUrl).toBe('https://api.example.com/v1/stock');
expect(stock.options.baseUrl).toBe('https://api.example.com/marketdata/v1.0/stock');
});

it('should handle baseUrl with complex path and trailing slash', () => {
it('should treat a path segment that is not a vX.Y version as part of the prefix', () => {
const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://api.example.com/api/v2/' });
const stock = client.stock;
// @ts-ignore - accessing private property for testing
expect(stock.options.baseUrl).toBe('https://api.example.com/api/v2/stock');
expect(stock.options.baseUrl).toBe('https://api.example.com/api/v2/v1.0/stock');

const futopt = client.futopt;
// @ts-ignore - accessing private property for testing
expect(futopt.options.baseUrl).toBe('https://api.example.com/api/v2/futopt');
expect(futopt.options.baseUrl).toBe('https://api.example.com/api/v2/v1.0/futopt');
});

it('should reject a baseUrl carrying its own version segment', () => {
const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://api.fugle.tw/marketdata/v1.0' });
expect(() => client.stock).toThrowError(
"baseUrl must not include a version segment (found '/v1.0'). " +
"Pass the host and path prefix only: 'https://api.fugle.tw/marketdata'."
);
});
});
});
Loading
Loading