-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(api): add exchange rate api (#146)
- Loading branch information
Showing
11 changed files
with
768 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,6 +14,7 @@ | |
}, | ||
"editor.formatOnSave": true, | ||
"cSpell.words": [ | ||
"EXCHANGERATES", | ||
"hono", | ||
"openai" | ||
] | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
15 changes: 15 additions & 0 deletions
15
apps/api/prisma/migrations/20240718080526_add_currency_exchange_rate/migration.sql
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
-- CreateTable | ||
CREATE TABLE "CurrencyExchangeRate" ( | ||
"id" TEXT NOT NULL, | ||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
"updatedAt" TIMESTAMP(3) NOT NULL, | ||
"fromCurrency" TEXT NOT NULL, | ||
"toCurrency" TEXT NOT NULL, | ||
"rate" DECIMAL(65,30) NOT NULL, | ||
"date" TIMESTAMP(3) NOT NULL, | ||
|
||
CONSTRAINT "CurrencyExchangeRate_pkey" PRIMARY KEY ("id") | ||
); | ||
|
||
-- CreateIndex | ||
CREATE UNIQUE INDEX "CurrencyExchangeRate_fromCurrency_toCurrency_date_key" ON "CurrencyExchangeRate"("fromCurrency", "toCurrency", "date"); |
2 changes: 2 additions & 0 deletions
2
apps/api/prisma/migrations/20240718080935_set_exchange_rate_date_to_string/migration.sql
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
-- AlterTable | ||
ALTER TABLE "CurrencyExchangeRate" ALTER COLUMN "date" SET DATA TYPE TEXT; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
declare module 'got' { | ||
import * as got from 'got/dist/source' | ||
export = got | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
import { zValidator } from '@hono/zod-validator' | ||
import { Hono } from 'hono' | ||
import { z } from 'zod' | ||
import { | ||
getExchangeRate, | ||
getExchangeRates, | ||
} from '../services/exchange-rates.service' | ||
|
||
const router = new Hono() | ||
.use(async (c, next) => { | ||
const apiKey = c.req.header('x-api-key') | ||
|
||
if (!apiKey || apiKey !== process.env.API_SECRET_KEY) { | ||
return c.json({ message: 'Unauthorized' }, 401) | ||
} | ||
|
||
await next() | ||
}) | ||
|
||
.get( | ||
'/', | ||
zValidator( | ||
'query', | ||
z.object({ | ||
date: z.string().default('latest'), | ||
}), | ||
), | ||
async (c) => { | ||
const { date } = c.req.valid('query') | ||
|
||
const exchangeRates = await getExchangeRates({ date }) | ||
|
||
return c.json(exchangeRates) | ||
}, | ||
) | ||
|
||
.get( | ||
'/:fromCurrency/:toCurrency', | ||
zValidator( | ||
'query', | ||
z.object({ | ||
date: z.string().default('latest'), | ||
}), | ||
), | ||
zValidator( | ||
'param', | ||
z.object({ | ||
fromCurrency: z.string(), | ||
toCurrency: z.string(), | ||
}), | ||
), | ||
async (c) => { | ||
const { fromCurrency, toCurrency } = c.req.valid('param') | ||
const { date } = c.req.valid('query') | ||
|
||
const exchangeRate = await getExchangeRate({ | ||
date, | ||
fromCurrency: fromCurrency, | ||
toCurrency: toCurrency, | ||
}) | ||
|
||
if (!exchangeRate) { | ||
return c.json({ message: 'Exchange rate not found' }, 404) | ||
} | ||
|
||
return c.json(exchangeRate) | ||
}, | ||
) | ||
|
||
export default router |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,138 @@ | ||
import got from 'got' | ||
import { getLogger } from '../../lib/log' | ||
import prisma from '../../lib/prisma' | ||
|
||
const API_KEY = process.env.EXCHANGERATES_API_KEY | ||
const BASE_URL = 'http://api.exchangeratesapi.io/v1/' | ||
const BASE_CURRENCY = 'EUR' | ||
const DEFAULT_SYMBOLS = ['USD', 'JPY', 'AUD', 'VND', 'SGD', 'CNY'] | ||
const client = got.extend({ | ||
prefixUrl: BASE_URL, | ||
}) | ||
|
||
export async function getExchangeRates({ | ||
date = 'latest', | ||
base = BASE_CURRENCY, | ||
symbols = DEFAULT_SYMBOLS, | ||
}: { | ||
date?: string | 'latest' | ||
base?: string | ||
symbols?: string[] | ||
}) { | ||
const logger = getLogger(`services.exchange-rates:${getExchangeRates.name}`) | ||
logger.debug( | ||
'Getting exchange rates. Date: %s, Base: %s, Symbols: %o', | ||
date, | ||
base, | ||
symbols, | ||
) | ||
|
||
// YYYY-MM-DD | ||
const dateStr = (date === 'latest' ? new Date() : new Date(date)) | ||
.toISOString() | ||
.split('T')[0] | ||
|
||
// Find records in database | ||
const rates = await prisma.currencyExchangeRate.findMany({ | ||
where: { | ||
date: dateStr, | ||
fromCurrency: base, | ||
toCurrency: { | ||
in: symbols, | ||
}, | ||
}, | ||
}) | ||
|
||
// If some rates are missing, call the API | ||
const missingSymbols = symbols.filter( | ||
(symbol) => | ||
!rates.find( | ||
(rate) => rate.toCurrency === symbol && rate.fromCurrency === base, | ||
), | ||
) | ||
|
||
if (missingSymbols.length === 0) { | ||
logger.debug('All rates are found in the database') | ||
return rates | ||
} | ||
|
||
const missingSymbolsStr = missingSymbols.join(',') | ||
|
||
logger.debug('Some rates are missing. Calling the API: %s', missingSymbolsStr) | ||
|
||
const missingRates = await client | ||
.get(`${date === 'latest' ? date : dateStr}`, { | ||
searchParams: { | ||
access_key: API_KEY, | ||
base, | ||
symbols: missingSymbolsStr, | ||
}, | ||
}) | ||
.json<{ | ||
success: boolean | ||
timestamp: number | ||
base: string | ||
date: string | ||
rates: Record<string, number> | ||
}>() | ||
// Save the result to database | ||
logger.debug('Saving the missing rates to the database') | ||
await prisma.currencyExchangeRate.createMany({ | ||
data: Object.entries(missingRates.rates).map(([toCurrency, rate]) => ({ | ||
date: missingRates.date, | ||
fromCurrency: base, | ||
toCurrency, | ||
rate, | ||
})), | ||
}) | ||
// Return all the rates | ||
const allRates = await prisma.currencyExchangeRate.findMany({ | ||
where: { | ||
date: dateStr, | ||
fromCurrency: base, | ||
toCurrency: { | ||
in: symbols, | ||
}, | ||
}, | ||
}) | ||
|
||
logger.debug('All rates are found') | ||
|
||
return allRates | ||
} | ||
|
||
export async function getExchangeRate({ | ||
date = 'latest', | ||
fromCurrency = BASE_CURRENCY, | ||
toCurrency, | ||
}: { | ||
date?: string | 'latest' | ||
fromCurrency?: string | ||
toCurrency: string | ||
}) { | ||
const baseRates = await getExchangeRates({ | ||
date, | ||
base: BASE_CURRENCY, | ||
symbols: [fromCurrency, toCurrency], | ||
}) | ||
const fromRate = baseRates.find((rate) => rate.toCurrency === fromCurrency) | ||
const toRate = baseRates.find((rate) => rate.toCurrency === toCurrency) | ||
|
||
if (!(fromRate && toRate)) { | ||
return null | ||
} | ||
|
||
const rateDecimal = toRate.rate.div(fromRate.rate) | ||
const rate = rateDecimal.toNumber() | ||
|
||
return { | ||
rate, | ||
rateDecimal, | ||
fromCurrency, | ||
toCurrency, | ||
// YYYY-MM-DD | ||
date: date === 'latest' ? new Date().toISOString().split('T')[0] : date, | ||
[fromCurrency]: 1, | ||
[toCurrency]: rate, | ||
} | ||
} |
Oops, something went wrong.