Skip to content

Commit aec7f1f

Browse files
Merge remote-tracking branch 'origin/main'
2 parents 3796769 + f472a55 commit aec7f1f

12 files changed

Lines changed: 309 additions & 21 deletions

File tree

.env.development.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ GITHUB_CLIENT_SECRET=your_github_oauth_client_secret
33
GITHUB_AUTH_ISSUER=https://your_unique_authentication_issuer
44
JWT_PRIVATE_KEY_PATH=./private-key.pem
55
JWT_PUBLIC_KEY_PATH=./public-key.pem
6-
JWT_KEY_ID=your-api-key-1
6+
JWT_KEY_ID=your-api-key-1
7+
CORS_ORIGIN=

.env.production.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ GITHUB_CLIENT_ID=your_github_oauth_client_id
22
GITHUB_CLIENT_SECRET=your_github_oauth_client_secret
33
GITHUB_AUTH_ISSUER=https://your_unique_authentication_issuer
44
COOKIE_DOMAIN=.yourdomain.com
5-
COOKIE_SAME_SITE=lax
5+
COOKIE_SAME_SITE=lax
6+
CORS_ORIGIN=

README.md

Lines changed: 4 additions & 1 deletion
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

@@ -33,9 +35,10 @@ GITHUB_AUTH_ISSUER=https://your-domain.com/auth/github
3335
> [!NOTE]
3436
> The issuer must be unique for the service. The authentication modules use it to distinguish the providers.
3537
36-
3. (Optional) Configure cookie settings for cross-subdomain support in `.env.production`:
38+
3. (Optional) Configure CORS and cookie settings in `.env.production`:
3739

3840
```bash
41+
CORS_ORIGIN=https://yourapp.yourdomain.com
3942
COOKIE_DOMAIN=.yourdomain.com
4043
COOKIE_SAME_SITE=lax
4144
```

bunfig.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
[test]
2-
preload = ["./test-setup.ts"]
2+
preload = ["./test-setup.ts"]

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: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,23 @@ 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

20+
const corsOrigin = process.env.CORS_ORIGIN;
21+
1822
export const app = new Elysia()
1923
.error({
2024
FetchApiError,
@@ -42,22 +46,31 @@ export const app = new Elysia()
4246
}
4347
})
4448
)
45-
.use(cors())
49+
.use(
50+
cors({
51+
...(corsOrigin !== undefined && { origin: corsOrigin })
52+
})
53+
)
4654
.decorate('github', new GitHubDecorator())
4755
.decorate('jwt', new JwtDecorator())
56+
.decorate('exchange', new ExchangeDecorator())
4857
.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-
)
58+
app
59+
.group('/auth', (app) =>
60+
app
61+
.get('/certs', authJwks)
62+
.group('/finalize', (app) =>
63+
app.post('/github', githubAuthFinalize, {
64+
body: GitHubAuthFinalizeSchema
65+
})
66+
)
67+
.group('/init', (app) =>
68+
app.get('/github', githubAuthInit, { query: GitHubAuthInitSchema })
69+
)
70+
)
71+
.group('/exchange', (app) =>
72+
app.get('/price/:ledgerId', exchangePrice, { params: ExchangePriceSchema })
73+
)
6174
)
6275
.listen(3000);
6376

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({

0 commit comments

Comments
 (0)