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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/context.ts
Original file line number Diff line number Diff line change
@@ -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<Route extends RouteSchema = RouteSchema> = Context<Route> & {
github: GitHubDecorator;
jwt: JwtDecorator;
exchange: ExchangeDecorator;
};
58 changes: 58 additions & 0 deletions src/decorators/exchange.ts
Original file line number Diff line number Diff line change
@@ -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<typeof BinanceTickerPriceSchema>;

const ExchangePriceSchema = z.strictObject({
...BinanceTickerPriceSchema.shape,
fetchedAt: z.iso.datetime()
});

type ExchangePrice = z.infer<typeof ExchangePriceSchema>;

const CACHE_TTL = 60_000;

export class ExchangeDecorator {
#priceCache = new Map<BinanceTickerPrice['symbol'], ExchangePrice>();

fetchPrice = async ({ symbol }: { symbol: string }): Promise<ExchangePrice> => {
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<BinanceTickerPrice> => {
// 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);
};
}
26 changes: 26 additions & 0 deletions src/handlers/exchange/price.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
'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 };
};
37 changes: 22 additions & 15 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);

Expand Down
87 changes: 87 additions & 0 deletions test/decorators/exchange.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
7 changes: 6 additions & 1 deletion test/decorators/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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({
Expand Down
43 changes: 43 additions & 0 deletions test/handlers/exchange/price.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
49 changes: 49 additions & 0 deletions test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading