-
Notifications
You must be signed in to change notification settings - Fork 0
finish API design for historical data #20 #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| // app/api/rates/history/route.ts | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { getHistoryByDateAUD, toByCurrency } from "@/lib/server/oxr_history"; | ||
| import { DEFAULTS_CURRENCY } from "@/lib/server/oxr_convert"; | ||
|
|
||
|
|
||
| export async function GET(req: NextRequest) { | ||
| try { | ||
| const q = req.nextUrl.searchParams; | ||
| const days = Math.max(1, Math.min(60, Number(q.get("days")) || 14)); | ||
| const orient = (q.get("orient") || "byCurrency").toLowerCase(); | ||
|
|
||
| const data = await getHistoryByDateAUD(days, DEFAULTS_CURRENCY); | ||
|
|
||
| if (orient === "bycurrency") { | ||
| return NextResponse.json({ | ||
| base: data.base, | ||
| startDate: data.startDate, | ||
| endDate: data.endDate, | ||
| series: toByCurrency(data.points, DEFAULTS_CURRENCY), | ||
| }, { headers: { "Cache-Control": "public, max-age=300" } }); | ||
| } | ||
| return NextResponse.json(data, { headers: { "Cache-Control": "public, max-age=300" } }); | ||
| } catch (e: unknown) { | ||
| const message = e instanceof Error ? e.message : "history fetch failed"; | ||
| return NextResponse.json({ error: message }, { status: 502 }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,138 @@ | ||||||||
| // @vitest-environment node | ||||||||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||||||||
|
|
||||||||
| vi.mock("server-only", () => ({})); // strip Next.js server-only guard in tests | ||||||||
|
|
||||||||
| const rebaseToAUDMock = vi.fn(); | ||||||||
| const getHistoricalMock = vi.fn(); | ||||||||
| const buildPastDatesUTCMock = vi.fn(); | ||||||||
|
|
||||||||
| vi.mock("../oxr_convert", () => ({ | ||||||||
| rebaseToAUD: (...args: unknown[]) => rebaseToAUDMock(...args), | ||||||||
| DEFAULTS_CURRENCY: ["USD", "EUR", "JPY", "GBP", "CNY"] as const, | ||||||||
| })); | ||||||||
|
|
||||||||
| vi.mock("../oxr", () => ({ | ||||||||
| getHistorical: (...args: unknown[]) => getHistoricalMock(...args), | ||||||||
| })); | ||||||||
|
|
||||||||
| vi.mock("../../time_utils", () => ({ | ||||||||
| buildPastDatesUTC: (...args: unknown[]) => buildPastDatesUTCMock(...args), | ||||||||
| })); | ||||||||
|
|
||||||||
| vi.mock("../../time_utils.ts", () => ({ | ||||||||
| buildPastDatesUTC: (...args: unknown[]) => buildPastDatesUTCMock(...args), | ||||||||
| })); | ||||||||
|
Comment on lines
+23
to
+25
|
||||||||
| vi.mock("../../time_utils.ts", () => ({ | |
| buildPastDatesUTC: (...args: unknown[]) => buildPastDatesUTCMock(...args), | |
| })); |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -72,4 +72,24 @@ export async function getLatest(): Promise<RatesResponse> { | |||||||
|
|
||||||||
|
|
||||||||
|
|
||||||||
| /** * Shape of Open Exchange Rates historical payload. | ||||||||
|
||||||||
| /** * Shape of Open Exchange Rates historical payload. | |
| /** Shape of Open Exchange Rates historical payload. |
Copilot
AI
Oct 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The JSDoc comment has an extra space after the opening /**. It should be /** followed by a single space.
| /** * Fetches historical exchange rates for a specific date from Open Exchange Rates. | |
| /** | |
| * Fetches historical exchange rates for a specific date from Open Exchange Rates. |
Copilot
AI
Oct 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment contains Chinese/full-width parentheses '(' and ')' which should be replaced with standard ASCII parentheses '(' and ')'.
| // dateISO = YYYY-MM-DD(UTC) | |
| // dateISO = YYYY-MM-DD (UTC) |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -24,7 +24,7 @@ export type ConvertResult = { | |||||
| /** | ||||||
| * Default currency codes returned when no targets are provided. | ||||||
| */ | ||||||
| export const DEFAULTS_CURRENCY = ["USD", "EUR", "JPY", "GBP", "CNY"] as const; | ||||||
| export const DEFAULTS_CURRENCY = ["USD", "EUR", "JPY", "GBP", "CNY"]; | ||||||
|
||||||
| export const DEFAULTS_CURRENCY = ["USD", "EUR", "JPY", "GBP", "CNY"]; | |
| export const DEFAULTS_CURRENCY = ["USD", "EUR", "JPY", "GBP", "CNY"] as const; |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,128 @@ | ||||||
| import "server-only"; | ||||||
| import { getHistorical, type HistoricalResponse } from "./oxr"; | ||||||
| import { rebaseToAUD } from "./oxr_convert"; | ||||||
| import { buildPastDatesUTC } from "../time_utils"; | ||||||
| import { DEFAULTS_CURRENCY } from "./oxr_convert"; | ||||||
|
|
||||||
| export type SeriesPoint = { date: string; value: number }; | ||||||
| export type SeriesMap = Record<string, SeriesPoint[]>; | ||||||
| export type HistoryByDate = { | ||||||
| base: "AUD"; | ||||||
| startDate: string; | ||||||
| endDate: string; | ||||||
| points: Array<{ date: string; [code: string]: number | string }>; | ||||||
| }; | ||||||
|
|
||||||
| // Controls how many historical requests we fire in parallel. | ||||||
| const WORKER_CONCURRENCY = 4; | ||||||
| // How many times we retry a failing historical request before giving up. | ||||||
| const RETRY_ATTEMPTS = 3; | ||||||
| // Delay between retries to give the upstream API a moment to recover. | ||||||
| const RETRY_DELAY_MS = 100; | ||||||
|
|
||||||
| // Simple promise-based sleep helper used for retry backoff. | ||||||
| function sleep(ms: number) { | ||||||
| return new Promise<void>((resolve) => setTimeout(resolve, ms)); | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Fetches a historical snapshot with limited retries to cope with transient OXR issues. | ||||||
| * | ||||||
| * @returns Historical payload on success, or an object describing the last error. | ||||||
| */ | ||||||
| async function fetchHistoricalWithRetry(date: string): Promise<HistoricalResponse | { error: unknown }> { | ||||||
| let lastError: unknown; | ||||||
|
|
||||||
| for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) { | ||||||
| try { | ||||||
| return await getHistorical(date); | ||||||
| } catch (err) { | ||||||
| lastError = err; | ||||||
| if (attempt === RETRY_ATTEMPTS) break; | ||||||
| await sleep(RETRY_DELAY_MS); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| return { error: lastError }; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Converts a USD-based rate map into a single AUD-based row for the requested targets. | ||||||
| * | ||||||
| * @param usdRates - Snapshot returned by OXR for a particular date. | ||||||
| * @param targets - Currency codes to include in the output row. | ||||||
| * @param date - ISO date string used as the row identifier. | ||||||
| */ | ||||||
| export function toAudRow( | ||||||
| usdRates: Record<string, number>, | ||||||
| targets: string[], | ||||||
| date: string | ||||||
| ): { date: string; [code: string]: number | string } { | ||||||
| const audRates = rebaseToAUD(usdRates); | ||||||
| const row: Record<string, number | string> = { date }; | ||||||
| for (const t of targets) if (audRates[t] != null) row[t] = audRates[t]; | ||||||
| return row as { date: string; [code: string]: number | string }; | ||||||
|
||||||
| return row as { date: string; [code: string]: number | string }; | |
| return row; |
Copilot
AI
Oct 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment is in Chinese ('已是 string' means 'already is string'). Comments should be in English for consistency with the rest of the codebase.
| const d = row.date; // 已是 string | |
| const d = row.date; // already a string |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
|
|
||
|
|
||
| export function toISODateUTC(d: Date) { | ||
| return d.toISOString().slice(0, 10); | ||
| } | ||
|
|
||
|
|
||
| export function buildPastDatesUTC(days: number): string[] { | ||
| const now = new Date(); | ||
| const todayUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); | ||
| const out: string[] = []; | ||
| for (let i = days - 1; i >= 0; i--) { | ||
| const d = new Date(todayUTC); | ||
| d.setUTCDate(d.getUTCDate() - i); | ||
| out.push(toISODateUTC(d)); | ||
| } | ||
| return out; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The mocked
DEFAULTS_CURRENCYusesas constwhile the actual implementation was changed to remove it (line 27 in oxr_convert.ts). This inconsistency could mask issues in tests. Update the mock to match the actual implementation.