diff --git a/apps/engine/src/common/schema.ts b/apps/engine/src/common/schema.ts index 950ec4de1b..a859ae572f 100644 --- a/apps/engine/src/common/schema.ts +++ b/apps/engine/src/common/schema.ts @@ -13,6 +13,18 @@ export const ErrorSchema = z.object({ }), }); +export const GeneralErrorSchema = z.object({ + code: z.string().openapi({ + example: "internal_server_error", + }), + message: z.string().openapi({ + example: "Internal server error", + }), + requestId: z.string().openapi({ + example: "123e4567-e89b-12d3-a456-426655440000", + }), +}); + export const Providers = z.enum(["teller", "plaid", "gocardless"]); export const HeadersSchema = z.object({ diff --git a/apps/engine/src/index.ts b/apps/engine/src/index.ts index 2eb7d3d202..7e6ac4b168 100644 --- a/apps/engine/src/index.ts +++ b/apps/engine/src/index.ts @@ -12,6 +12,7 @@ import accountRoutes from "./routes/accounts"; import authRoutes from "./routes/auth"; import healthRoutes from "./routes/health"; import institutionRoutes from "./routes/institutions"; +import ratesRoutes from "./routes/rates"; import transactionsRoutes from "./routes/transactions"; const app = new OpenAPIHono<{ Bindings: Bindings }>({ @@ -33,11 +34,13 @@ app.get("/institutions", cacheMiddleware); app.get("/accounts", cacheMiddleware); app.get("/accounts/balance", cacheMiddleware); app.get("/transactions", cacheMiddleware); +app.get("/rates", cacheMiddleware); app .route("/transactions", transactionsRoutes) .route("/accounts", accountRoutes) .route("/institutions", institutionRoutes) + .route("/rates", ratesRoutes) .route("/auth", authRoutes); app.openAPIRegistry.registerComponent("securitySchemes", "Bearer", { diff --git a/apps/engine/src/routes/rates/index.ts b/apps/engine/src/routes/rates/index.ts new file mode 100644 index 0000000000..8fc7b123ca --- /dev/null +++ b/apps/engine/src/routes/rates/index.ts @@ -0,0 +1,56 @@ +import { GeneralErrorSchema } from "@/common/schema"; +import { getRates } from "@/utils/rates"; +import { OpenAPIHono, createRoute } from "@hono/zod-openapi"; +import type { Bindings } from "hono/types"; +import { RatesSchema } from "./schema"; + +const app = new OpenAPIHono<{ Bindings: Bindings }>(); + +const indexRoute = createRoute({ + method: "get", + path: "/", + summary: "Get rates", + responses: { + 200: { + content: { + "application/json": { + schema: RatesSchema, + }, + }, + description: "Retrieve rates", + }, + 400: { + content: { + "application/json": { + schema: GeneralErrorSchema, + }, + }, + description: "Returns an error", + }, + }, +}); + +app.openapi(indexRoute, async (c) => { + try { + const data = await getRates(); + + return c.json( + { + data, + }, + 200, + ); + } catch (error) { + return c.json( + { + error: "Internal server error", + message: "Internal server error", + requestId: c.get("requestId"), + code: "400", + }, + 400, + ); + } +}); + +export default app; diff --git a/apps/engine/src/routes/rates/schema.ts b/apps/engine/src/routes/rates/schema.ts new file mode 100644 index 0000000000..03c6076497 --- /dev/null +++ b/apps/engine/src/routes/rates/schema.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export const RatesSchema = z + .object({ + data: z.array( + z.object({ + source: z.string().openapi({ + example: "USD", + }), + rates: z.record(z.string(), z.number()).openapi({ + example: { + EUR: 0.925393, + GBP: 0.792256, + SEK: 10.0, + BDT: 200.0, + }, + }), + }), + ), + }) + + .openapi("RatesSchema"); diff --git a/apps/engine/src/utils/rates.ts b/apps/engine/src/utils/rates.ts new file mode 100644 index 0000000000..3655cfcbf7 --- /dev/null +++ b/apps/engine/src/utils/rates.ts @@ -0,0 +1,57 @@ +import { uniqueCurrencies } from "@midday/location/src/currencies"; + +const ENDPOINT = + "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@2024.8.2/v1"; + +async function getCurrency(currency: string) { + const response = await fetch(`${ENDPOINT}/currencies/${currency}.json`); + + return response.json(); +} + +function transformKeysToUppercase(obj: Record) { + const entries = Object.entries(obj); + + // Transform each entry's key to uppercase + const upperCaseEntries = entries + .map(([key, value]) => { + return [key.toUpperCase(), value]; + }) + .filter(([key]) => uniqueCurrencies.includes(key as string)); + + // Convert the transformed entries back into an object + const transformedObject = Object.fromEntries(upperCaseEntries); + + return transformedObject; +} + +export async function getRates() { + const rates = await Promise.allSettled( + uniqueCurrencies.map((currency) => getCurrency(currency.toLowerCase())), + ); + + return rates + .filter( + (rate): rate is PromiseFulfilledResult> => + rate.status === "fulfilled", + ) + .map((rate) => rate.value) + .map((value) => { + const currency = Object.keys(value).at(1); + + if (!currency) { + return null; + } + + const currencyData = value[currency]; + if (typeof currencyData !== "object" || currencyData === null) { + return null; + } + + return { + source: currency.toUpperCase(), + rates: transformKeysToUppercase(currencyData as Record), + }; + }) + .filter((item) => item !== null); +}