Skip to content

Commit

Permalink
feat(api): add exchange rate api (#146)
Browse files Browse the repository at this point in the history
  • Loading branch information
duongdev authored Jul 18, 2024
1 parent 9981094 commit f11d98d
Show file tree
Hide file tree
Showing 11 changed files with 768 additions and 7 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"editor.formatOnSave": true,
"cSpell.words": [
"EXCHANGERATES",
"hono",
"openai"
]
Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@prisma/client": "^5.16.0",
"@vercel/blob": "^0.23.4",
"dayjs": "^1.11.11",
"got": "^14.4.1",
"hono": "^4.4.8",
"next": "^14.2.4",
"openai": "^4.52.7",
Expand Down
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");
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;
13 changes: 13 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,16 @@ model CachedGptResponse {
query String
response String
}

model CurrencyExchangeRate {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
fromCurrency String
toCurrency String
rate Decimal
date String // YYYY-MM-DD
@@unique([fromCurrency, toCurrency, date])
}
4 changes: 4 additions & 0 deletions apps/api/types/got-fix.d.ts
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
}
2 changes: 2 additions & 0 deletions apps/api/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { authMiddleware } from './middlewares/auth'
import authApp from './routes/auth'
import budgetsApp from './routes/budgets'
import categoriesApp from './routes/categories'
import exchangeRatesApp from './routes/exchange-rates'
import transactionsApp from './routes/transactions'
import usersApp from './routes/users'
import walletsApp from './routes/wallets'

export const hono = new Hono()
.route('/exchange-rates', exchangeRatesApp)

.use('*', authMiddleware)

Expand Down
70 changes: 70 additions & 0 deletions apps/api/v1/routes/exchange-rates.ts
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
138 changes: 138 additions & 0 deletions apps/api/v1/services/exchange-rates.service.ts
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,
}
}
Loading

0 comments on commit f11d98d

Please sign in to comment.