Skip to content

Commit 91e8357

Browse files
feat: exchange price proxying Binance (#7)
* feat(frontend): exchange price proxying Binance * feat: cache * feat: rename and response * test: decorator * test: error * test: price * test: server * feat: params instead of query * feat: rename * feat: inline price * docs: list exchange feature
1 parent 992f0e8 commit 91e8357

9 files changed

Lines changed: 295 additions & 16 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ This API is designed to be deployed independently, giving you full control over
1111
- **GitHub:**
1212
- Proxy OAuth integration with JWT token generation
1313
- JWKS Endpoint: Public key discovery for the token verification by Juno's authentication module
14+
- **Exchange:**
15+
- ICP/USD price feed proxied from a public market data source, no API key required
1416

1517
## Quick Start
1618

src/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { Context, RouteSchema } from 'elysia';
2+
import type { ExchangeDecorator } from './decorators/exchange';
23
import type { GitHubDecorator } from './decorators/github';
34
import type { JwtDecorator } from './decorators/jwt';
45

56
export type ApiContext<Route extends RouteSchema = RouteSchema> = Context<Route> & {
67
github: GitHubDecorator;
78
jwt: JwtDecorator;
9+
exchange: ExchangeDecorator;
810
};

src/decorators/exchange.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { z } from 'zod';
2+
import { FetchApiError } from '../errors';
3+
4+
const BinanceTickerPriceSchema = z.strictObject({
5+
symbol: z.string(),
6+
price: z.string()
7+
});
8+
9+
type BinanceTickerPrice = z.infer<typeof BinanceTickerPriceSchema>;
10+
11+
const ExchangePriceSchema = z.strictObject({
12+
...BinanceTickerPriceSchema.shape,
13+
fetchedAt: z.iso.datetime()
14+
});
15+
16+
type ExchangePrice = z.infer<typeof ExchangePriceSchema>;
17+
18+
const CACHE_TTL = 60_000;
19+
20+
export class ExchangeDecorator {
21+
#priceCache = new Map<BinanceTickerPrice['symbol'], ExchangePrice>();
22+
23+
fetchPrice = async ({ symbol }: { symbol: string }): Promise<ExchangePrice> => {
24+
const cached = this.#priceCache.get(symbol);
25+
26+
if (cached !== undefined && Date.now() < new Date(cached.fetchedAt).getTime() + CACHE_TTL) {
27+
return cached;
28+
}
29+
30+
const price = await this.#fetchBinanceTickerPrice({ symbol });
31+
32+
const tickerPrice = { ...price, fetchedAt: new Date().toISOString() };
33+
34+
this.#priceCache.set(symbol, tickerPrice);
35+
36+
return tickerPrice;
37+
};
38+
39+
#fetchBinanceTickerPrice = async ({
40+
symbol
41+
}: {
42+
symbol: string;
43+
}): Promise<BinanceTickerPrice> => {
44+
// Market data only URL do not require an API key or attribution.
45+
// Reference: https://developers.binance.com/docs/binance-spot-api-docs/faqs/market_data_only
46+
const response = await fetch(
47+
`https://data-api.binance.vision/api/v3/ticker/price?symbol=${symbol}`
48+
);
49+
50+
if (!response.ok) {
51+
throw new FetchApiError(response.status, `Binance API error: ${response.status}`);
52+
}
53+
54+
const data = await response.json();
55+
56+
return BinanceTickerPriceSchema.parse(data);
57+
};
58+
}

src/handlers/exchange/price.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { t } from 'elysia';
2+
import type { ApiContext } from '../../context';
3+
import { assertNonNullish } from '../../utils/assert';
4+
5+
export const ExchangePriceSchema = t.Object({
6+
ledgerId: t.String()
7+
});
8+
9+
type ExchangePrice = (typeof ExchangePriceSchema)['static'];
10+
11+
export const LEDGER_TO_SYMBOL: Record<string, string> = {
12+
'ryjl3-tyaaa-aaaaa-aaaba-cai': 'ICPUSDT'
13+
};
14+
15+
export const exchangePrice = async ({
16+
params,
17+
exchange
18+
}: ApiContext<{ params: ExchangePrice }>) => {
19+
const { ledgerId } = params;
20+
21+
const symbol = LEDGER_TO_SYMBOL[ledgerId];
22+
assertNonNullish(symbol, 'Ledger ID not supported');
23+
24+
const price = await exchange.fetchPrice({ symbol });
25+
return { price };
26+
};

src/server.ts

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@ import { cors } from '@elysiajs/cors';
22
import { openapi } from '@elysiajs/openapi';
33
import { Elysia } from 'elysia';
44
import packageJson from '../package.json';
5+
import { ExchangeDecorator } from './decorators/exchange';
56
import { GitHubDecorator } from './decorators/github';
67
import { JwtDecorator } from './decorators/jwt';
78
import { FetchApiError, GitHubAuthUnauthorizedError, NullishError } from './errors';
89
import {
9-
GitHubAuthFinalizeSchema,
10-
GitHubAuthInitSchema,
1110
githubAuthFinalize,
12-
githubAuthInit
11+
GitHubAuthFinalizeSchema,
12+
githubAuthInit,
13+
GitHubAuthInitSchema
1314
} from './handlers/auth/github';
1415
import { authJwks } from './handlers/auth/jwks';
16+
import { exchangePrice, ExchangePriceSchema } from './handlers/exchange/price';
1517

1618
const { version: appVersion, name: appName, description: appDescription } = packageJson;
1719

@@ -45,19 +47,24 @@ export const app = new Elysia()
4547
.use(cors())
4648
.decorate('github', new GitHubDecorator())
4749
.decorate('jwt', new JwtDecorator())
50+
.decorate('exchange', new ExchangeDecorator())
4851
.group('/v1', (app) =>
49-
app.group('/auth', (app) =>
50-
app
51-
.get('/certs', authJwks)
52-
.group('/finalize', (app) =>
53-
app.post('/github', githubAuthFinalize, {
54-
body: GitHubAuthFinalizeSchema
55-
})
56-
)
57-
.group('/init', (app) =>
58-
app.get('/github', githubAuthInit, { query: GitHubAuthInitSchema })
59-
)
60-
)
52+
app
53+
.group('/auth', (app) =>
54+
app
55+
.get('/certs', authJwks)
56+
.group('/finalize', (app) =>
57+
app.post('/github', githubAuthFinalize, {
58+
body: GitHubAuthFinalizeSchema
59+
})
60+
)
61+
.group('/init', (app) =>
62+
app.get('/github', githubAuthInit, { query: GitHubAuthInitSchema })
63+
)
64+
)
65+
.group('/exchange', (app) =>
66+
app.get('/price/:ledgerId', exchangePrice, { params: ExchangePriceSchema })
67+
)
6168
)
6269
.listen(3000);
6370

test/decorators/exchange.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
2+
import { ExchangeDecorator } from '../../src/decorators/exchange';
3+
import { FetchApiError } from '../../src/errors';
4+
5+
describe('decorators > exchange', () => {
6+
const mockTickerPrice = { symbol: 'ICPUSDT', price: '2.23800000' };
7+
8+
let exchange: ExchangeDecorator;
9+
10+
beforeEach(() => {
11+
exchange = new ExchangeDecorator();
12+
});
13+
14+
afterEach(() => {
15+
mock.clearAllMocks();
16+
mock.restore();
17+
});
18+
19+
describe('fetchTickerPrice', () => {
20+
it('should fetch and return ticker price', async () => {
21+
spyOn(global, 'fetch').mockResolvedValueOnce(Response.json(mockTickerPrice));
22+
23+
const result = await exchange.fetchPrice({ symbol: 'ICPUSDT' });
24+
25+
expect(result.symbol).toBe('ICPUSDT');
26+
expect(result.price).toBe('2.23800000');
27+
expect(result.fetchedAt).toBeString();
28+
expect(new Date(result.fetchedAt).toISOString()).toBe(result.fetchedAt);
29+
});
30+
31+
it('should call Binance API with correct URL', async () => {
32+
spyOn(global, 'fetch').mockResolvedValueOnce(Response.json(mockTickerPrice));
33+
34+
await exchange.fetchPrice({ symbol: 'ICPUSDT' });
35+
36+
expect(global.fetch).toHaveBeenCalledWith(
37+
'https://data-api.binance.vision/api/v3/ticker/price?symbol=ICPUSDT'
38+
);
39+
});
40+
41+
it('should return cached value within TTL', async () => {
42+
const fetchSpy = spyOn(global, 'fetch').mockResolvedValueOnce(Response.json(mockTickerPrice));
43+
44+
await exchange.fetchPrice({ symbol: 'ICPUSDT' });
45+
await exchange.fetchPrice({ symbol: 'ICPUSDT' });
46+
47+
expect(fetchSpy).toHaveBeenCalledTimes(1);
48+
});
49+
50+
it('should refetch after TTL expires', async () => {
51+
const fetchSpy = spyOn(global, 'fetch')
52+
.mockResolvedValueOnce(Response.json(mockTickerPrice))
53+
.mockResolvedValueOnce(Response.json(mockTickerPrice));
54+
55+
await exchange.fetchPrice({ symbol: 'ICPUSDT' });
56+
57+
spyOn(Date, 'now').mockReturnValue(Date.now() + 61_000);
58+
59+
await exchange.fetchPrice({ symbol: 'ICPUSDT' });
60+
61+
expect(fetchSpy).toHaveBeenCalledTimes(2);
62+
});
63+
64+
it('should cache different symbols independently', async () => {
65+
const fetchSpy = spyOn(global, 'fetch')
66+
.mockResolvedValueOnce(Response.json({ symbol: 'ICPUSDT', price: '2.23800000' }))
67+
.mockResolvedValueOnce(Response.json({ symbol: 'BTCUSDT', price: '50000.00' }));
68+
69+
await exchange.fetchPrice({ symbol: 'ICPUSDT' });
70+
await exchange.fetchPrice({ symbol: 'BTCUSDT' });
71+
72+
expect(fetchSpy).toHaveBeenCalledTimes(2);
73+
});
74+
75+
it('should throw on Binance API error', async () => {
76+
spyOn(global, 'fetch').mockResolvedValueOnce(new Response('{}', { status: 500 }));
77+
78+
expect(exchange.fetchPrice({ symbol: 'ICPUSDT' })).rejects.toThrow(FetchApiError);
79+
});
80+
81+
it('should throw on invalid response schema', async () => {
82+
spyOn(global, 'fetch').mockResolvedValueOnce(Response.json({ unexpected: 'data' }));
83+
84+
expect(exchange.fetchPrice({ symbol: 'ICPUSDT' })).rejects.toThrow();
85+
});
86+
});
87+
});

test/decorators/jwt.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeAll, describe, expect, it } from 'bun:test';
1+
import { afterEach, beforeAll, describe, expect, it, mock } from 'bun:test';
22
import { JwtDecorator } from '../../src/decorators/jwt';
33

44
describe('decorators > jwt', () => {
@@ -8,6 +8,11 @@ describe('decorators > jwt', () => {
88
jwt = new JwtDecorator();
99
});
1010

11+
afterEach(() => {
12+
mock.clearAllMocks();
13+
mock.restore();
14+
});
15+
1116
describe('signOpenIdJwt', () => {
1217
it('should create valid OpenID JWT', async () => {
1318
const token = await jwt.signOpenIdJwt({
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
2+
import type { ApiContext } from '../../../src/context';
3+
import { ExchangeDecorator } from '../../../src/decorators/exchange';
4+
import { exchangePrice } from '../../../src/handlers/exchange/price';
5+
6+
describe('handlers > exchange > price', () => {
7+
const mockTickerPrice = { symbol: 'ICPUSDT', price: '2.23800000' };
8+
9+
const mockExchangeTickerPrice = {
10+
...mockTickerPrice,
11+
fetchedAt: new Date().toISOString()
12+
};
13+
14+
afterEach(() => {
15+
mock.clearAllMocks();
16+
});
17+
18+
it('should return price for supported ledger ID', async () => {
19+
const exchange = new ExchangeDecorator();
20+
spyOn(exchange, 'fetchPrice').mockResolvedValueOnce(mockExchangeTickerPrice);
21+
22+
const context = {
23+
exchange,
24+
params: { ledgerId: 'ryjl3-tyaaa-aaaaa-aaaba-cai' }
25+
} as unknown as ApiContext<{ params: { ledgerId: string } }>;
26+
27+
const result = await exchangePrice(context);
28+
29+
expect(result.price).toEqual(mockExchangeTickerPrice);
30+
expect(exchange.fetchPrice).toHaveBeenCalledWith({ symbol: 'ICPUSDT' });
31+
});
32+
33+
it('should throw for unsupported ledger ID', async () => {
34+
const exchange = new ExchangeDecorator();
35+
36+
const context = {
37+
exchange,
38+
params: { ledgerId: 'unknown-ledger-id' }
39+
} as unknown as ApiContext<{ params: { ledgerId: string } }>;
40+
41+
expect(exchangePrice(context)).rejects.toThrow('Ledger ID not supported');
42+
});
43+
});

test/server.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,55 @@ describe('server', () => {
169169
});
170170
});
171171

172+
describe('GET /v1/exchange/price', () => {
173+
it('should return price for supported ledger ID', async () => {
174+
spyOn(global, 'fetch').mockImplementation((async (url: string) => {
175+
if (url.includes('data-api.binance.vision')) {
176+
return Response.json({ symbol: 'ICPUSDT', price: '2.23800000' });
177+
}
178+
return new Response('Not found', { status: 404 });
179+
}) as typeof fetch);
180+
181+
const { app } = await import('../src/server');
182+
const response = await app.handle(
183+
new Request('http://localhost/v1/exchange/price/ryjl3-tyaaa-aaaaa-aaaba-cai')
184+
);
185+
const data = await response.json();
186+
187+
expect(response.status).toBe(200);
188+
expect(data.price.symbol).toBe('ICPUSDT');
189+
expect(data.price.price).toBe('2.23800000');
190+
expect(data.price.fetchedAt).toBeString();
191+
});
192+
193+
it('should return 500 for unsupported ledger ID', async () => {
194+
const { app } = await import('../src/server');
195+
const response = await app.handle(
196+
new Request('http://localhost/v1/exchange/price/unknown-ledger-id')
197+
);
198+
199+
expect(response.status).toBe(500);
200+
});
201+
202+
it('should return 503 on exchange API error', async () => {
203+
spyOn(Date, 'now').mockReturnValue(Date.now() + 61_000);
204+
205+
spyOn(global, 'fetch').mockImplementation((async (url: string) => {
206+
if (url.includes('data-api.binance.vision')) {
207+
return new Response('{}', { status: 503 });
208+
}
209+
return new Response('Not found', { status: 404 });
210+
}) as typeof fetch);
211+
212+
const { app } = await import('../src/server');
213+
const response = await app.handle(
214+
new Request('http://localhost/v1/exchange/price/ryjl3-tyaaa-aaaaa-aaaba-cai')
215+
);
216+
217+
expect(response.status).toBe(503);
218+
});
219+
});
220+
172221
describe('Error handling', () => {
173222
it('should return 404 for unknown routes', async () => {
174223
const { app } = await import('../src/server');

0 commit comments

Comments
 (0)