diff --git a/README.md b/README.md index 69d2bd8..10356c5 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ This API is designed to be deployed independently, giving you full control over - **GitHub:** - Proxy OAuth integration with JWT token generation - JWKS Endpoint: Public key discovery for the token verification by Juno's authentication module +- **Exchange:** + - ICP/USD price feed proxied from a public market data source, no API key required ## Quick Start diff --git a/src/context.ts b/src/context.ts index 85fd481..a98bb4f 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,8 +1,10 @@ import type { Context, RouteSchema } from 'elysia'; +import type { ExchangeDecorator } from './decorators/exchange'; import type { GitHubDecorator } from './decorators/github'; import type { JwtDecorator } from './decorators/jwt'; export type ApiContext = Context & { github: GitHubDecorator; jwt: JwtDecorator; + exchange: ExchangeDecorator; }; diff --git a/src/decorators/exchange.ts b/src/decorators/exchange.ts new file mode 100644 index 0000000..91f4c44 --- /dev/null +++ b/src/decorators/exchange.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; +import { FetchApiError } from '../errors'; + +const BinanceTickerPriceSchema = z.strictObject({ + symbol: z.string(), + price: z.string() +}); + +type BinanceTickerPrice = z.infer; + +const ExchangePriceSchema = z.strictObject({ + ...BinanceTickerPriceSchema.shape, + fetchedAt: z.iso.datetime() +}); + +type ExchangePrice = z.infer; + +const CACHE_TTL = 60_000; + +export class ExchangeDecorator { + #priceCache = new Map(); + + fetchPrice = async ({ symbol }: { symbol: string }): Promise => { + const cached = this.#priceCache.get(symbol); + + if (cached !== undefined && Date.now() < new Date(cached.fetchedAt).getTime() + CACHE_TTL) { + return cached; + } + + const price = await this.#fetchBinanceTickerPrice({ symbol }); + + const tickerPrice = { ...price, fetchedAt: new Date().toISOString() }; + + this.#priceCache.set(symbol, tickerPrice); + + return tickerPrice; + }; + + #fetchBinanceTickerPrice = async ({ + symbol + }: { + symbol: string; + }): Promise => { + // Market data only URL do not require an API key or attribution. + // Reference: https://developers.binance.com/docs/binance-spot-api-docs/faqs/market_data_only + const response = await fetch( + `https://data-api.binance.vision/api/v3/ticker/price?symbol=${symbol}` + ); + + if (!response.ok) { + throw new FetchApiError(response.status, `Binance API error: ${response.status}`); + } + + const data = await response.json(); + + return BinanceTickerPriceSchema.parse(data); + }; +} diff --git a/src/handlers/exchange/price.ts b/src/handlers/exchange/price.ts new file mode 100644 index 0000000..2d8d337 --- /dev/null +++ b/src/handlers/exchange/price.ts @@ -0,0 +1,26 @@ +import { t } from 'elysia'; +import type { ApiContext } from '../../context'; +import { assertNonNullish } from '../../utils/assert'; + +export const ExchangePriceSchema = t.Object({ + ledgerId: t.String() +}); + +type ExchangePrice = (typeof ExchangePriceSchema)['static']; + +export const LEDGER_TO_SYMBOL: Record = { + 'ryjl3-tyaaa-aaaaa-aaaba-cai': 'ICPUSDT' +}; + +export const exchangePrice = async ({ + params, + exchange +}: ApiContext<{ params: ExchangePrice }>) => { + const { ledgerId } = params; + + const symbol = LEDGER_TO_SYMBOL[ledgerId]; + assertNonNullish(symbol, 'Ledger ID not supported'); + + const price = await exchange.fetchPrice({ symbol }); + return { price }; +}; diff --git a/src/server.ts b/src/server.ts index 3a4948d..f38bf1a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,16 +2,18 @@ import { cors } from '@elysiajs/cors'; import { openapi } from '@elysiajs/openapi'; import { Elysia } from 'elysia'; import packageJson from '../package.json'; +import { ExchangeDecorator } from './decorators/exchange'; import { GitHubDecorator } from './decorators/github'; import { JwtDecorator } from './decorators/jwt'; import { FetchApiError, GitHubAuthUnauthorizedError, NullishError } from './errors'; import { - GitHubAuthFinalizeSchema, - GitHubAuthInitSchema, githubAuthFinalize, - githubAuthInit + GitHubAuthFinalizeSchema, + githubAuthInit, + GitHubAuthInitSchema } from './handlers/auth/github'; import { authJwks } from './handlers/auth/jwks'; +import { exchangePrice, ExchangePriceSchema } from './handlers/exchange/price'; const { version: appVersion, name: appName, description: appDescription } = packageJson; @@ -45,19 +47,24 @@ export const app = new Elysia() .use(cors()) .decorate('github', new GitHubDecorator()) .decorate('jwt', new JwtDecorator()) + .decorate('exchange', new ExchangeDecorator()) .group('/v1', (app) => - app.group('/auth', (app) => - app - .get('/certs', authJwks) - .group('/finalize', (app) => - app.post('/github', githubAuthFinalize, { - body: GitHubAuthFinalizeSchema - }) - ) - .group('/init', (app) => - app.get('/github', githubAuthInit, { query: GitHubAuthInitSchema }) - ) - ) + app + .group('/auth', (app) => + app + .get('/certs', authJwks) + .group('/finalize', (app) => + app.post('/github', githubAuthFinalize, { + body: GitHubAuthFinalizeSchema + }) + ) + .group('/init', (app) => + app.get('/github', githubAuthInit, { query: GitHubAuthInitSchema }) + ) + ) + .group('/exchange', (app) => + app.get('/price/:ledgerId', exchangePrice, { params: ExchangePriceSchema }) + ) ) .listen(3000); diff --git a/test/decorators/exchange.test.ts b/test/decorators/exchange.test.ts new file mode 100644 index 0000000..a210a72 --- /dev/null +++ b/test/decorators/exchange.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import { ExchangeDecorator } from '../../src/decorators/exchange'; +import { FetchApiError } from '../../src/errors'; + +describe('decorators > exchange', () => { + const mockTickerPrice = { symbol: 'ICPUSDT', price: '2.23800000' }; + + let exchange: ExchangeDecorator; + + beforeEach(() => { + exchange = new ExchangeDecorator(); + }); + + afterEach(() => { + mock.clearAllMocks(); + mock.restore(); + }); + + describe('fetchTickerPrice', () => { + it('should fetch and return ticker price', async () => { + spyOn(global, 'fetch').mockResolvedValueOnce(Response.json(mockTickerPrice)); + + const result = await exchange.fetchPrice({ symbol: 'ICPUSDT' }); + + expect(result.symbol).toBe('ICPUSDT'); + expect(result.price).toBe('2.23800000'); + expect(result.fetchedAt).toBeString(); + expect(new Date(result.fetchedAt).toISOString()).toBe(result.fetchedAt); + }); + + it('should call Binance API with correct URL', async () => { + spyOn(global, 'fetch').mockResolvedValueOnce(Response.json(mockTickerPrice)); + + await exchange.fetchPrice({ symbol: 'ICPUSDT' }); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://data-api.binance.vision/api/v3/ticker/price?symbol=ICPUSDT' + ); + }); + + it('should return cached value within TTL', async () => { + const fetchSpy = spyOn(global, 'fetch').mockResolvedValueOnce(Response.json(mockTickerPrice)); + + await exchange.fetchPrice({ symbol: 'ICPUSDT' }); + await exchange.fetchPrice({ symbol: 'ICPUSDT' }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('should refetch after TTL expires', async () => { + const fetchSpy = spyOn(global, 'fetch') + .mockResolvedValueOnce(Response.json(mockTickerPrice)) + .mockResolvedValueOnce(Response.json(mockTickerPrice)); + + await exchange.fetchPrice({ symbol: 'ICPUSDT' }); + + spyOn(Date, 'now').mockReturnValue(Date.now() + 61_000); + + await exchange.fetchPrice({ symbol: 'ICPUSDT' }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('should cache different symbols independently', async () => { + const fetchSpy = spyOn(global, 'fetch') + .mockResolvedValueOnce(Response.json({ symbol: 'ICPUSDT', price: '2.23800000' })) + .mockResolvedValueOnce(Response.json({ symbol: 'BTCUSDT', price: '50000.00' })); + + await exchange.fetchPrice({ symbol: 'ICPUSDT' }); + await exchange.fetchPrice({ symbol: 'BTCUSDT' }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('should throw on Binance API error', async () => { + spyOn(global, 'fetch').mockResolvedValueOnce(new Response('{}', { status: 500 })); + + expect(exchange.fetchPrice({ symbol: 'ICPUSDT' })).rejects.toThrow(FetchApiError); + }); + + it('should throw on invalid response schema', async () => { + spyOn(global, 'fetch').mockResolvedValueOnce(Response.json({ unexpected: 'data' })); + + expect(exchange.fetchPrice({ symbol: 'ICPUSDT' })).rejects.toThrow(); + }); + }); +}); diff --git a/test/decorators/jwt.test.ts b/test/decorators/jwt.test.ts index c0e6455..6a1bbe5 100644 --- a/test/decorators/jwt.test.ts +++ b/test/decorators/jwt.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from 'bun:test'; +import { afterEach, beforeAll, describe, expect, it, mock } from 'bun:test'; import { JwtDecorator } from '../../src/decorators/jwt'; describe('decorators > jwt', () => { @@ -8,6 +8,11 @@ describe('decorators > jwt', () => { jwt = new JwtDecorator(); }); + afterEach(() => { + mock.clearAllMocks(); + mock.restore(); + }); + describe('signOpenIdJwt', () => { it('should create valid OpenID JWT', async () => { const token = await jwt.signOpenIdJwt({ diff --git a/test/handlers/exchange/price.test.ts b/test/handlers/exchange/price.test.ts new file mode 100644 index 0000000..e8be24e --- /dev/null +++ b/test/handlers/exchange/price.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import type { ApiContext } from '../../../src/context'; +import { ExchangeDecorator } from '../../../src/decorators/exchange'; +import { exchangePrice } from '../../../src/handlers/exchange/price'; + +describe('handlers > exchange > price', () => { + const mockTickerPrice = { symbol: 'ICPUSDT', price: '2.23800000' }; + + const mockExchangeTickerPrice = { + ...mockTickerPrice, + fetchedAt: new Date().toISOString() + }; + + afterEach(() => { + mock.clearAllMocks(); + }); + + it('should return price for supported ledger ID', async () => { + const exchange = new ExchangeDecorator(); + spyOn(exchange, 'fetchPrice').mockResolvedValueOnce(mockExchangeTickerPrice); + + const context = { + exchange, + params: { ledgerId: 'ryjl3-tyaaa-aaaaa-aaaba-cai' } + } as unknown as ApiContext<{ params: { ledgerId: string } }>; + + const result = await exchangePrice(context); + + expect(result.price).toEqual(mockExchangeTickerPrice); + expect(exchange.fetchPrice).toHaveBeenCalledWith({ symbol: 'ICPUSDT' }); + }); + + it('should throw for unsupported ledger ID', async () => { + const exchange = new ExchangeDecorator(); + + const context = { + exchange, + params: { ledgerId: 'unknown-ledger-id' } + } as unknown as ApiContext<{ params: { ledgerId: string } }>; + + expect(exchangePrice(context)).rejects.toThrow('Ledger ID not supported'); + }); +}); diff --git a/test/server.test.ts b/test/server.test.ts index 98be692..b7581d9 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -169,6 +169,55 @@ describe('server', () => { }); }); + describe('GET /v1/exchange/price', () => { + it('should return price for supported ledger ID', async () => { + spyOn(global, 'fetch').mockImplementation((async (url: string) => { + if (url.includes('data-api.binance.vision')) { + return Response.json({ symbol: 'ICPUSDT', price: '2.23800000' }); + } + return new Response('Not found', { status: 404 }); + }) as typeof fetch); + + const { app } = await import('../src/server'); + const response = await app.handle( + new Request('http://localhost/v1/exchange/price/ryjl3-tyaaa-aaaaa-aaaba-cai') + ); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.price.symbol).toBe('ICPUSDT'); + expect(data.price.price).toBe('2.23800000'); + expect(data.price.fetchedAt).toBeString(); + }); + + it('should return 500 for unsupported ledger ID', async () => { + const { app } = await import('../src/server'); + const response = await app.handle( + new Request('http://localhost/v1/exchange/price/unknown-ledger-id') + ); + + expect(response.status).toBe(500); + }); + + it('should return 503 on exchange API error', async () => { + spyOn(Date, 'now').mockReturnValue(Date.now() + 61_000); + + spyOn(global, 'fetch').mockImplementation((async (url: string) => { + if (url.includes('data-api.binance.vision')) { + return new Response('{}', { status: 503 }); + } + return new Response('Not found', { status: 404 }); + }) as typeof fetch); + + const { app } = await import('../src/server'); + const response = await app.handle( + new Request('http://localhost/v1/exchange/price/ryjl3-tyaaa-aaaaa-aaaba-cai') + ); + + expect(response.status).toBe(503); + }); + }); + describe('Error handling', () => { it('should return 404 for unknown routes', async () => { const { app } = await import('../src/server');