Skip to content

Commit eda5e7e

Browse files
authored
Merge pull request #1013 from tradingstrategy-ai/fetch-schema-validate
Add optional schema validation to fetchPublicApi
2 parents a1e8645 + fb159fd commit eda5e7e

26 files changed

Lines changed: 139 additions & 47 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"@eslint/compat": "^1.3.1",
2727
"@eslint/js": "^9.31.0",
2828
"@playwright/test": "^1.54.1",
29+
"@standard-schema/spec": "^1.0.0",
2930
"@sveltejs/adapter-node": "^5.2.13",
3031
"@sveltejs/enhanced-img": "^0.7.0",
3132
"@sveltejs/kit": "^2.26.1",

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'unplugin-icons/types/svelte';
2+
import type { StandardSchemaV1 } from '@standard-schema/spec';
23
import type { CountryCode } from '$lib/helpers/geo';
34
import type { TimeBucket } from '$lib/schemas/utility';
45

@@ -10,6 +11,7 @@ declare global {
1011
message: string;
1112
chainName?: string;
1213
stack?: string[];
14+
issues?: readonly StandardSchemaV1.Issue[] | undefined;
1315
eventId?: string;
1416
}
1517

src/lib/charts/BenchmarkSeries.svelte

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import Series from './Series.svelte';
77
import { tsToUnixTimestamp } from './candle-data-feed.svelte';
88
import { fetchPublicApi } from '$lib/helpers/public-api';
9-
import { dateToTs, tsToDate } from './helpers';
9+
import { dateToTs } from './helpers';
1010
1111
type Props = {
1212
token: BenchmarkToken;
@@ -39,7 +39,7 @@
3939
token.loading = true;
4040
benchmarkData = [];
4141
42-
const pairCandles = await fetchPublicApi(fetch, 'candles', {
42+
const pairCandles = await fetchPublicApi<Record<string, ApiCandle[]>>(fetch, 'candles', {
4343
pair_id: token.pairId,
4444
exchange_type: token.exchangeType,
4545
candle_type: 'price',
@@ -48,7 +48,7 @@
4848
end: range[1].toISOString().slice(0, 19)
4949
});
5050
51-
const candles = (pairCandles[token.pairId] ?? []) as ApiCandle[];
51+
const candles = pairCandles[token.pairId] ?? [];
5252
5353
const initialBenchmarkValue = candles[0]?.c ?? 0;
5454

src/lib/charts/candle-data-feed.svelte.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import type { TimeInterval } from 'd3-time';
2-
import type { UTCTimestamp } from 'lightweight-charts';
32
import type { TimeBucket } from '$lib/schemas/utility';
43
import type { ApiCandle, CandleDataItem, DataFeed } from './types';
54
import { timeBucketToInterval } from './helpers';
65
import { isHttpError } from '@sveltejs/kit';
76
import { fetchPublicApi } from '$lib/helpers/public-api';
7+
import { parseDate } from '$lib/helpers/date';
8+
import { dateToTs } from './helpers';
89

910
export function tsToUnixTimestamp(ts: string) {
10-
return (new Date(`${ts}Z`).valueOf() / 1000) as UTCTimestamp;
11+
return dateToTs(parseDate(ts)!);
1112
}
1213

1314
export function apiCandleToDataItem(c: ApiCandle): CandleDataItem {
@@ -20,7 +21,7 @@ export function apiCandleToDataItem(c: ApiCandle): CandleDataItem {
2021
};
2122
}
2223

23-
export type ApiDataTransformer = (data: any) => CandleDataItem[];
24+
export type ApiDataTransformer = (data: unknown) => CandleDataItem[];
2425

2526
export class CandleDataFeed implements DataFeed<CandleDataItem> {
2627
interval: TimeInterval;
@@ -37,6 +38,7 @@ export class CandleDataFeed implements DataFeed<CandleDataItem> {
3738
readonly transformApiData: ApiDataTransformer
3839
) {
3940
this.interval = timeBucketToInterval(timeBucket);
41+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
4042
this.endDate = this.interval.floor(new Date());
4143
}
4244

src/lib/explorer/lending-reserve-client.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ export type LendingReserveIndexParams = Partial<{
3939

4040
type LendingReserveSearchKey = keyof LendingReserveIndexParams;
4141

42+
type LendingReserveApiResponse = {
43+
results: LendingReserve[];
44+
total: number;
45+
};
46+
4247
export type LendingReserveIndexResponse = {
4348
rows: LendingReserve[];
4449
totalRowCount: number;
@@ -53,18 +58,25 @@ const defaultParams: LendingReserveIndexParams = {
5358

5459
const allKeys: LendingReserveSearchKey[] = ['page_size', 'page', 'sort', 'direction', 'protocol_slug', 'chain_slug'];
5560

56-
export async function fetchLendingReserves(fetch: Fetch, params: LendingReserveIndexParams) {
61+
export async function fetchLendingReserves(
62+
fetch: Fetch,
63+
params: LendingReserveIndexParams
64+
): Promise<LendingReserveIndexResponse | undefined> {
5765
const apiParams: Record<string, string> = {};
5866

5967
for (const key of allKeys) {
6068
const value = params[key] || defaultParams[key];
6169
if (value) apiParams[key] = String(value);
6270
}
6371

64-
const data = await fetchPublicApi(fetch, 'lending-reserves', apiParams, true);
72+
const data = await fetchPublicApi<LendingReserveApiResponse>(fetch, 'lending-reserves', apiParams, {
73+
abortPrevious: true
74+
});
75+
76+
if (!data) return;
6577

6678
return {
6779
rows: data.results,
6880
totalRowCount: data.total
69-
} as LendingReserveIndexResponse;
81+
};
7082
}

src/lib/explorer/pair-client.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,21 @@ export type PairIndexParams = Partial<{
1414

1515
type PairSearchKey = keyof PairIndexParams;
1616

17+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
18+
type PairInfo = Record<string, any>;
19+
20+
export type PairDetails = {
21+
summary: PairInfo;
22+
additional_details: PairInfo;
23+
};
24+
25+
type PairApiResponse = {
26+
results: PairInfo[];
27+
total: number;
28+
};
29+
1730
export type PairIndexResponse = {
18-
rows: Record<string, any>[];
31+
rows: PairInfo[];
1932
totalRowCount: number;
2033
};
2134

@@ -36,22 +49,22 @@ const allKeys: PairSearchKey[] = [
3649
'token_addresses'
3750
];
3851

39-
export async function fetchPairs(fetch: Fetch, params: PairIndexParams) {
52+
export async function fetchPairs(fetch: Fetch, params: PairIndexParams): Promise<PairIndexResponse | undefined> {
4053
const apiParams: Record<string, string> = {};
4154

4255
for (const key of allKeys) {
4356
const value = params[key] || defaultParams[key];
4457
if (value) apiParams[key] = String(value);
4558
}
4659

47-
const data = await fetchPublicApi(fetch, 'pairs', apiParams, true);
60+
const data = await fetchPublicApi<PairApiResponse>(fetch, 'pairs', apiParams, { abortPrevious: true });
4861

4962
if (!data) return;
5063

5164
return {
5265
rows: data.results,
5366
totalRowCount: data.total
54-
} as PairIndexResponse;
67+
};
5568
}
5669

5770
export type PairIndexData = PairIndexResponse & {

src/lib/explorer/token-client.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,16 @@ export type TokenIndexParams = Partial<{
1010

1111
type TokenSearchKey = keyof TokenIndexParams;
1212

13+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
14+
export type TokenDetails = Record<string, any>;
15+
16+
type TokenApiResponse = {
17+
results: TokenDetails[];
18+
total: number;
19+
};
20+
1321
export type TokenIndexResponse = {
14-
rows: Record<string, any>[];
22+
rows: TokenDetails[];
1523
totalRowCount: number;
1624
};
1725

@@ -24,20 +32,20 @@ const defaultParams: TokenIndexParams = {
2432

2533
const allKeys: TokenSearchKey[] = ['page_size', 'page', 'sort', 'direction', 'chain_slug'];
2634

27-
export async function fetchTokens(fetch: Fetch, params: TokenIndexParams) {
35+
export async function fetchTokens(fetch: Fetch, params: TokenIndexParams): Promise<TokenIndexResponse | undefined> {
2836
const apiParams: Record<string, string> = {};
2937

3038
for (const key of allKeys) {
3139
const value = params[key] || defaultParams[key];
3240
if (value) apiParams[key] = String(value);
3341
}
3442

35-
const data = await fetchPublicApi(fetch, 'tokens', apiParams, true);
43+
const data = await fetchPublicApi<TokenApiResponse>(fetch, 'tokens', apiParams, { abortPrevious: true });
3644

3745
if (!data) return;
3846

3947
return {
4048
rows: data.results,
4149
totalRowCount: data.total
42-
} as TokenIndexResponse;
50+
};
4351
}

src/lib/helpers/exchange.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
* Misc exchange data helpers.
33
*/
44

5+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6+
export type ExchangeDetails = Record<string, any>;
7+
export type ExchangeIndexResponse = { exchanges: ExchangeDetails[] };
8+
59
export type ExchangeNameInfo = {
610
name: string;
711
version: number;

src/lib/helpers/public-api.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Helpers for working with Trading Strategy public APIs
22
import { type NumericRange, error } from '@sveltejs/kit';
3+
import type { StandardSchemaV1 } from '@standard-schema/spec';
34
import { backendUrl } from '$lib/config';
45

56
type Params = Record<string, string>;
@@ -12,10 +13,22 @@ const controllers: Record<string, AbortController> = {};
1213
* @param fetch SvelteKit's fetch function
1314
* @param endpoint final path segment of endpoint, e.g.: 'chains', 'pairs'
1415
* @param params URL paramaters as a record of string values
15-
* @param abortPrevious set to true to abort pending requets to the same endpoint
16-
* @returns the deserialised JSON payload
16+
* @param options optional configuration
17+
* @param options.abortPrevious abort any pending requests to the same endpoint
18+
* @param options.schema Standard Schema for response validation (e.g., `z.object({ ... })`)
19+
* @returns the deserialized and optionally validated JSON payload
1720
*/
18-
export async function fetchPublicApi(fetch: Fetch, endpoint: string, params: Params = {}, abortPrevious = false) {
21+
export async function fetchPublicApi<T = unknown>(
22+
fetch: Fetch,
23+
endpoint: string,
24+
params: Params = {},
25+
options?: {
26+
abortPrevious?: boolean;
27+
schema?: StandardSchemaV1<unknown, T>;
28+
}
29+
): Promise<T> {
30+
const { abortPrevious = false, schema } = options ?? {};
31+
1932
let signal: AbortSignal | undefined = undefined;
2033

2134
if (abortPrevious) {
@@ -26,16 +39,28 @@ export async function fetchPublicApi(fetch: Fetch, endpoint: string, params: Par
2639

2740
const searchParams = new URLSearchParams(params);
2841

42+
let data: unknown;
43+
2944
try {
3045
const resp = await fetch(`${backendUrl}/${endpoint}?${searchParams}`, { signal });
3146
if (!resp.ok) throw await publicApiError(resp);
32-
return resp.json();
47+
data = await resp.json();
3348
} catch (e) {
34-
if (e.name === 'AbortError') return;
49+
if ((e as Error).name === 'AbortError') return undefined as T;
3550
throw e;
3651
} finally {
3752
if (!signal?.aborted) delete controllers[endpoint];
3853
}
54+
55+
// return raw response payload if no validation schema was provided
56+
if (!schema) return data as T;
57+
58+
// validate response and return validated/typed payload if successful
59+
const result = await schema['~standard'].validate(data);
60+
if (result.issues) {
61+
error(500, { message: 'API response validation failed', ...result });
62+
}
63+
return result.value;
3964
}
4065

4166
/**
@@ -82,5 +107,6 @@ export function optionalDataError(dataType?: string) {
82107
return (err: Error) => {
83108
console.error(`${errorIntro}; rendering page without data.`);
84109
console.error(err);
110+
return undefined;
85111
};
86112
}

0 commit comments

Comments
 (0)