diff --git a/README.md b/README.md index 44ffabc..373f11a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ The app normalizes both feeds into a shared internal model (`usdPrice` per asset ```text . ├── app +│ ├── api/convert/route.ts │ ├── api/rates/route.ts │ ├── globals.css │ ├── layout.tsx @@ -145,10 +146,20 @@ NEXT_PUBLIC_API_BASE_URL= If empty, the app uses the local default (`/api/rates`). +## API Endpoints + +- `GET /api/rates` + - Returns normalized market data used by the app. +- `GET /api/convert?from=USD&to=BTC&amount=100` + - Converts between any supported fiat/crypto pair using current normalized rates. + - Example response fields: `amount`, `convertedAmount`, `rate`, `inverseRate`, `updatedAt`, `sources`. + ## Architecture Notes - `app/api/rates/route.ts` is the single internal market endpoint for the frontend. +- `app/api/convert/route.ts` provides direct server-side conversion for external/API consumers. - Provider modules are isolated in `lib/api/` so they can be swapped independently. +- Shared in-memory caching is centralized in `lib/server/rates-cache.ts`. - `lib/api/normalize.ts` unifies fiat and crypto responses into one shape used by UI. - Conversion formula is provider-agnostic: - `result = amount * (from.usdPrice / to.usdPrice)` diff --git a/app/api/convert/route.ts b/app/api/convert/route.ts new file mode 100644 index 0000000..51dcbb5 --- /dev/null +++ b/app/api/convert/route.ts @@ -0,0 +1,123 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +import { convertAmount } from "@/lib/rates"; +import { + getLastCachedRates, + getRatesWithCache, + RATES_CACHE_CONTROL_VALUE, +} from "@/lib/server/rates-cache"; +import { amountSchema } from "@/lib/validation"; + +const querySchema = z.object({ + from: z.string().trim().toUpperCase().min(1), + to: z.string().trim().toUpperCase().min(1), + amount: amountSchema, +}); + +export const revalidate = 300; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + + const queryResult = querySchema.safeParse({ + from: searchParams.get("from") ?? "", + to: searchParams.get("to") ?? "", + amount: searchParams.get("amount") ?? "", + }); + + if (!queryResult.success) { + return NextResponse.json( + { + message: + queryResult.error.issues[0]?.message ?? "Invalid query parameters", + }, + { status: 400 }, + ); + } + + const { amount, from, to } = queryResult.data; + + try { + const rates = await getRatesWithCache(); + const rateMap = new Map(rates.assets.map((asset) => [asset.code, asset])); + + const fromAsset = rateMap.get(from); + const toAsset = rateMap.get(to); + + if (!fromAsset || !toAsset) { + return NextResponse.json( + { message: "Unsupported currency or asset code" }, + { status: 404 }, + ); + } + + const converted = convertAmount(amount, fromAsset, toAsset); + const rate = fromAsset.usdPrice / toAsset.usdPrice; + const inverseRate = toAsset.usdPrice / fromAsset.usdPrice; + + return NextResponse.json( + { + from, + to, + amount, + convertedAmount: converted, + rate, + inverseRate, + quoteCurrency: rates.quoteCurrency, + updatedAt: rates.updatedAt, + sources: rates.sources, + }, + { + status: 200, + headers: { + "Cache-Control": RATES_CACHE_CONTROL_VALUE, + }, + }, + ); + } catch (error) { + const cachedRates = getLastCachedRates(); + + if (cachedRates) { + const rateMap = new Map( + cachedRates.assets.map((asset) => [asset.code, asset]), + ); + const fromAsset = rateMap.get(from); + const toAsset = rateMap.get(to); + + if (fromAsset && toAsset) { + const converted = convertAmount(amount, fromAsset, toAsset); + const rate = fromAsset.usdPrice / toAsset.usdPrice; + const inverseRate = toAsset.usdPrice / fromAsset.usdPrice; + + return NextResponse.json( + { + from, + to, + amount, + convertedAmount: converted, + rate, + inverseRate, + quoteCurrency: cachedRates.quoteCurrency, + updatedAt: cachedRates.updatedAt, + sources: cachedRates.sources, + }, + { + status: 200, + headers: { + "Cache-Control": RATES_CACHE_CONTROL_VALUE, + "X-Cache-Fallback": "stale-on-error", + }, + }, + ); + } + } + + const message = + error instanceof Error + ? error.message + : "Unexpected error while converting amount"; + + return NextResponse.json({ message }, { status: 500 }); + } +} diff --git a/app/api/rates/route.ts b/app/api/rates/route.ts index b02487e..6f9e1cc 100644 --- a/app/api/rates/route.ts +++ b/app/api/rates/route.ts @@ -1,42 +1,12 @@ import { NextResponse } from "next/server"; -import { fetchUnifiedRates } from "@/lib/api/normalize"; -import type { RatesResponse } from "@/lib/rates"; - -const CACHE_TTL_MS = 300_000; -const CACHE_CONTROL_VALUE = "s-maxage=300, stale-while-revalidate=1800"; - -let cachedRates: RatesResponse | null = null; -let cacheTimestamp = 0; -let inFlightRequest: Promise | null = null; +import { + getLastCachedRates, + getRatesWithCache, + RATES_CACHE_CONTROL_VALUE, +} from "@/lib/server/rates-cache"; export const revalidate = 300; -async function getRatesWithCache(): Promise { - const now = Date.now(); - const hasFreshCache = cachedRates && now - cacheTimestamp < CACHE_TTL_MS; - - if (hasFreshCache && cachedRates) { - return cachedRates; - } - - if (inFlightRequest) { - return inFlightRequest; - } - - inFlightRequest = (async () => { - const freshRates = await fetchUnifiedRates(); - cachedRates = freshRates; - cacheTimestamp = Date.now(); - return freshRates; - })(); - - try { - return await inFlightRequest; - } finally { - inFlightRequest = null; - } -} - export async function GET() { try { const data = await getRatesWithCache(); @@ -44,15 +14,17 @@ export async function GET() { return NextResponse.json(data, { status: 200, headers: { - "Cache-Control": CACHE_CONTROL_VALUE + "Cache-Control": RATES_CACHE_CONTROL_VALUE } }); } catch (error) { + const cachedRates = getLastCachedRates(); + if (cachedRates) { return NextResponse.json(cachedRates, { status: 200, headers: { - "Cache-Control": CACHE_CONTROL_VALUE, + "Cache-Control": RATES_CACHE_CONTROL_VALUE, "X-Cache-Fallback": "stale-on-error" } }); diff --git a/lib/server/rates-cache.ts b/lib/server/rates-cache.ts new file mode 100644 index 0000000..9747cd0 --- /dev/null +++ b/lib/server/rates-cache.ts @@ -0,0 +1,40 @@ +import { fetchUnifiedRates } from "@/lib/api/normalize"; +import type { RatesResponse } from "@/lib/rates"; + +export const RATES_CACHE_TTL_MS = 300_000; +export const RATES_CACHE_CONTROL_VALUE = + "s-maxage=300, stale-while-revalidate=1800"; + +let cachedRates: RatesResponse | null = null; +let cacheTimestamp = 0; +let inFlightRequest: Promise | null = null; + +export async function getRatesWithCache(): Promise { + const now = Date.now(); + const hasFreshCache = cachedRates && now - cacheTimestamp < RATES_CACHE_TTL_MS; + + if (hasFreshCache && cachedRates) { + return cachedRates; + } + + if (inFlightRequest) { + return inFlightRequest; + } + + inFlightRequest = (async () => { + const freshRates = await fetchUnifiedRates(); + cachedRates = freshRates; + cacheTimestamp = Date.now(); + return freshRates; + })(); + + try { + return await inFlightRequest; + } finally { + inFlightRequest = null; + } +} + +export function getLastCachedRates(): RatesResponse | null { + return cachedRates; +}