Skip to content

Commit dc254ea

Browse files
authored
Merge pull request #237 from blessme247/feat/request-validation
Feat/request validation
2 parents b7f1c56 + d0c026f commit dc254ea

File tree

18 files changed

+1339
-70
lines changed

18 files changed

+1339
-70
lines changed

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Anchor API variables
22
ANCHOR_API_BASE_URL=https://api.example.com/anchor
3+
STELLAR_NETWORK="testnet"
4+
STELLAR_RPC_URL="https://soroban-testnet.stellar.org"
5+
INSURANCE_CONTRACT_ID="CACDYF3CYMJEJTIVFESQYZTN67GO2R5D5IUABTCUG3HXQSRXCSOROBAN"
6+
REMITTANCE_CONTRACT_ID="CACDYF3CYMJEJTIVFESQYZTN67GO2R5D5IUABTCUG3HXQSRXCSOROBAN"
7+
SAVINGS_CONTRACT_ID="CACDYF3CYMJEJTIVFESQYZTN67GO2R5D5IUABTCUG3HXQSRXCSOROBAN"
8+
BILLS_CONTRACT_ID="CACDYF3CYMJEJTIVFESQYZTN67GO2R5D5IUABTCUG3HXQSRXCSOROBAN"
39

410
# Session encryption for wallet-based auth (required for /api/auth/*).
511
# Generate with: openssl rand -base64 32

app/api/bills/route.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,44 @@
1+
2+
import { NextRequest, NextResponse } from "next/server";
3+
import { z } from "zod";
4+
import { compose, validatedRoute, withAuth } from "@/lib/auth/middleware";
5+
6+
const billSchema = z.object({
7+
name: z.string().min(4, "Name is too short"),
8+
amount: z.coerce.number().positive().gt(0),
9+
dueDate: z.coerce.date(),
10+
recurring: z.preprocess((val) => val === "on" || val === true, z.boolean()),
11+
});
12+
13+
const addBillHandler = validatedRoute(billSchema, "body", async (req, data) => {
14+
// data is fully typed as { name: string, amount: number, dueDate: Date, recurring: boolean }
15+
// console.log(data, 'data in handler');
16+
17+
// your DB logic here
18+
19+
return NextResponse.json({
20+
success: "Bill added successfully",
21+
name: data.name,
22+
amount: data.amount,
23+
});
24+
});
25+
26+
// if auth is needed on a route
27+
// compose auth + validation — order matters: auth runs first
28+
// export const POST = compose(withAuth)(addBillHandler);
29+
30+
// if you don't need auth on a route, just export directly:
31+
// export const POST = addBillHandler;
32+
// import { withAuth } from '@/lib/auth';
33+
34+
async function getHandler(request: NextRequest) {
35+
// TODO: Fetch bills from Soroban bill_payments contract
36+
return NextResponse.json({ bills: [] });
37+
}
38+
39+
40+
export const GET = compose(withAuth)(getHandler);
41+
export const POST = compose(withAuth)(addBillHandler);
142
import { NextRequest, NextResponse } from 'next/server';
243
import { withAuth } from '@/lib/auth';
344
import { getAllBills, getBill } from '@/lib/contracts/bill-payments';
@@ -61,4 +102,4 @@ async function postHandler(request: NextRequest, session: string) {
61102
}
62103

63104
export const GET = withAuth(getHandler);
64-
export const POST = withAuth(postHandler);
105+
export const POST = withAuth(postHandler);

app/api/dashboard/route.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { compose, withAuth } from "@/lib/auth/middleware";
2+
import { NextRequest, NextResponse } from "next/server";
3+
4+
/**
5+
* GET /api/dashboard
6+
* ─────────────────────────────────────────────────────────────────────────────
7+
* Returns all data needed to render the dashboard in a single round-trip.
8+
*
9+
* Authentication:
10+
* Uses validatedRoute middleware with a Zod schema that reads the user's
11+
* wallet address from the Authorization header (Bearer <wallet-address>)
12+
* or from a `address` query param for local dev.
13+
*
14+
* Caching:
15+
* Results are cached in-process for 30 s (DASHBOARD_TTL_SECONDS env var).
16+
* The `meta.fromCache` field in the response tells the client whether it
17+
* received a cached or freshly-fetched result.
18+
* Cache-Control is set to `private, max-age=30` so browsers/CDNs won't
19+
* cache across users, but the client can use the response for 30 s.
20+
*
21+
* Partial failures:
22+
* If any contract dependency (RPC call) fails, the affected section returns
23+
* `{ status: "error", error: "<message>" }` while all other sections return
24+
* normally. The HTTP status is always 200 — check each section's `status`
25+
* field to detect partial failures.
26+
*
27+
* Response shape: see DashboardResponse in dashboardAggregator.ts
28+
*/
29+
30+
import { getDashboardData } from "@/lib/dashboard";
31+
import { getSession } from "@/lib/session";
32+
33+
const DASHBOARD_TTL_S = Number(process.env.DASHBOARD_TTL_SECONDS) || 30;
34+
35+
36+
37+
export const getHandler = async (req: NextRequest) => {
38+
const session = await getSession();
39+
40+
const dashboard = await getDashboardData(session?.address || "");
41+
42+
return NextResponse.json(dashboard, {
43+
status: 200,
44+
headers: {
45+
// private = per-user, not cacheable by shared CDN/proxy
46+
"Cache-Control": `private, max-age=${DASHBOARD_TTL_S}, stale-while-revalidate=${DASHBOARD_TTL_S * 2}`,
47+
"X-Cache": dashboard.meta.fromCache ? "HIT" : "MISS",
48+
},
49+
});
50+
}
51+
52+
53+
54+
export const GET = compose(withAuth)(getHandler);

app/api/goals/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
import { getAllGoals } from "@/lib/contracts/savings-goal";
33
import { NextRequest, NextResponse } from 'next/server';
4-
import { withAuth } from '@/lib/auth';
4+
import { withAuth } from '@/lib/auth';
55

66
import { validatePaginationParams, paginateData, PaginatedResult } from '../../../lib/utils/pagination';
77
import { buildCreateGoalTx } from '@/lib/contracts/savings-goals';

app/api/insurance/route.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,44 @@
1+
12
import { NextRequest, NextResponse } from "next/server";
3+
import { z } from "zod";
4+
import { compose, validatedRoute, withAuth } from "@/lib/auth/middleware";
5+
// import { getActivePolicies } from "@/lib/contracts/insurance";
26
import { getActivePolicies } from "@/lib/contracts/insurance-cached";
37
import { validateAuth, unauthorizedResponse } from "@/lib/auth";
48

9+
const billSchema = z.object({
10+
policyName: z.string().min(4, "Name is too short"),
11+
coverageType: z.enum(["Health", "Emergency", "Life"] as const, "Please select a coverage type"),
12+
monthlyPremium: z.coerce.number().positive().gt(0),
13+
coverageAmount: z.coerce.number().positive().gt(0)
14+
});
15+
16+
const addInsuranceHandler = validatedRoute(billSchema, "body", async (req, data) => {
17+
18+
// DB logic here
19+
20+
return NextResponse.json({
21+
success: "Insurance added successfully",
22+
policyName: data.policyName,
23+
coverageType: data.coverageType,
24+
monthlyPremium: data.monthlyPremium,
25+
coverageAmount: data.coverageAmount,
26+
});
27+
});
28+
29+
// if auth is needed on a route
30+
// compose auth + validation — order matters: auth runs first
31+
// export const POST = compose(withAuth)(addInsuranceHandler);
32+
33+
// if you don't need auth on a route, just export directly:
34+
35+
36+
537
// GET /api/insurance
638
// Returns active policies for the authenticated owner.
739
// Query param: ?owner=G... (Stellar account address)
8-
export async function GET(request: NextRequest) {
9-
if (!validateAuth(request)) {
10-
return unauthorizedResponse();
11-
}
40+
const getInsuranceHandler = async (request: NextRequest)=> {
41+
1242

1343
const { searchParams } = new URL(request.url);
1444
const owner = searchParams.get("owner");
@@ -36,4 +66,9 @@ export async function GET(request: NextRequest) {
3666
{ status: 502 }
3767
);
3868
}
69+
3970
}
71+
72+
73+
export const POST = compose(withAuth)(addInsuranceHandler);
74+
export const GET = compose(withAuth)(getInsuranceHandler);

app/api/remittance/qoute/route.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/**
2+
* GET /api/remittance/quote
3+
*
4+
* Query Parameters:
5+
* ┌─────────────┬──────────┬──────────────────────────────────────────────────────┐
6+
* │ Param │ Required │ Description │
7+
* ├─────────────┼──────────┼──────────────────────────────────────────────────────┤
8+
* │ amount │ Yes │ Amount to send (positive number, up to 2 decimals) │
9+
* │ currency │ Yes │ Source currency ISO code (e.g. "USD") │
10+
* │ toCurrency │ Yes │ Destination currency ISO code (e.g. "PHP") │
11+
* └─────────────┴──────────┴──────────────────────────────────────────────────────┘
12+
*
13+
* Success Response Shape { sendAmount, receiveAmount, fee, rate, expiry }:
14+
* ┌───────────────┬─────────┬───────────────────────────────────────────────────────┐
15+
* │ Field │ Type │ Description │
16+
* ├───────────────┼─────────┼───────────────────────────────────────────────────────┤
17+
* │ sendAmount │ number │ Amount the sender pays (equals `amount` param) │
18+
* │ receiveAmount │ number │ Amount recipient gets after fee + exchange conversion │
19+
* │ fee │ number │ Fee charged in source currency │
20+
* │ rate │ number │ Exchange rate: 1 unit of `currency` → `toCurrency` │
21+
* │ expiry │ string │ ISO-8601 datetime — quote valid until this timestamp │
22+
* └───────────────┴─────────┴───────────────────────────────────────────────────────┘
23+
*
24+
* Integration Strategy:
25+
* If ANCHOR_API_BASE_URL is set → call anchor's GET /quote (SEP-38 compatible)
26+
*
27+
* Caching: quotes are cached in-memory for QUOTE_TTL_SECONDS (default 60 s).
28+
* Cache key = `${amount}:${currency}:${toCurrency}` (lower-cased).
29+
*/
30+
31+
import { NextResponse } from "next/server";
32+
import { z } from "zod";
33+
import { validatedRoute } from "@/lib/auth/middleware";
34+
35+
36+
/**
37+
* Coerce query-string strings into the right types.
38+
* `amount` arrives as a string from the URL — coerce to number first.
39+
*/
40+
const quoteSchema = z.object({
41+
amount: z.coerce
42+
.number()
43+
.gt(0, "amount must be greater than 0"),
44+
currency: z
45+
.string()
46+
.length(3, "currency must be a 3-letter ISO code")
47+
.toUpperCase(),
48+
49+
toCurrency: z
50+
.string()
51+
.length(3, "toCurrency must be a 3-letter ISO code")
52+
.toUpperCase(),
53+
});
54+
55+
type QuoteInput = z.infer<typeof quoteSchema>;
56+
type Response = {
57+
sendAmount: number;
58+
receiveAmount: number;
59+
fee: number;
60+
rate: number;
61+
expiry: string;
62+
source: "anchor" | "stellar" ;
63+
}
64+
65+
type QuoteResponse = Response | {message: string}
66+
67+
// ---------------------------------------------------------------------------
68+
// Simple in-memory cache (survives across requests in the same server process)
69+
// ---------------------------------------------------------------------------
70+
71+
const QUOTE_TTL_MS = (Number(process.env.QUOTE_TTL_SECONDS) || 60) * 1_000;
72+
73+
interface CacheEntry {
74+
data: QuoteResponse;
75+
expiresAt: number; // epoch ms
76+
}
77+
78+
const quoteCache = new Map<string, CacheEntry>();
79+
80+
function getCached(key: string): QuoteResponse | null {
81+
const entry = quoteCache.get(key);
82+
if (!entry) return null;
83+
if (Date.now() > entry.expiresAt) {
84+
quoteCache.delete(key);
85+
return null;
86+
}
87+
return entry.data;
88+
}
89+
90+
function setCache(key: string, data: QuoteResponse): void {
91+
quoteCache.set(key, { data, expiresAt: Date.now() + QUOTE_TTL_MS });
92+
}
93+
94+
// ---------------------------------------------------------------------------
95+
// Integration helpers
96+
// ---------------------------------------------------------------------------
97+
98+
/** Call an SEP-38-compatible anchor /quote endpoint. */
99+
async function fetchAnchorQuote(input: QuoteInput): Promise<QuoteResponse> {
100+
const base = process.env.ANCHOR_API_BASE_URL!.replace(/\/$/, "");
101+
const url = new URL(`${base}/quote`);
102+
url.searchParams.set("sell_asset", `iso4217:${input.currency}`);
103+
url.searchParams.set("sell_amount", String(input.amount));
104+
url.searchParams.set("buy_asset", `iso4217:${input.toCurrency}`);
105+
url.searchParams.set("type", `firm`); // type can be indicative or firm, see https://developers.stellar.org/docs/platforms/anchor-platform/api-reference/callbacks/get-rates
106+
107+
const res = await fetch(url.toString(), {
108+
headers: { "Content-Type": "application/json" },
109+
// next.js fetch — don't cache at the fetch layer; we cache ourselves
110+
cache: "no-store",
111+
});
112+
113+
if (!res.ok) {
114+
const body = await res.text().catch(() => "");
115+
throw new Error(`Anchor quote failed (${res.status}): ${body}`);
116+
}
117+
118+
/**
119+
* SEP-38 response shape (simplified):
120+
* { id, expires_at, price, sell_asset, sell_amount, buy_asset, buy_amount, fee: { total, asset } }
121+
*/
122+
const json = await res.json();
123+
124+
const rate = Number(json.price); // buy units per 1 sell unit
125+
const fee = Number(json.fee?.total ?? 0);
126+
const sendAmount = Number(json.sell_amount ?? input.amount);
127+
const receiveAmount = Number(json.buy_amount ?? (sendAmount - fee) * rate);
128+
129+
return {
130+
sendAmount,
131+
receiveAmount: round2(receiveAmount),
132+
fee: round2(fee),
133+
rate,
134+
expiry: json.expires_at ?? ttlIso(),
135+
source: "anchor",
136+
};
137+
}
138+
139+
140+
141+
142+
async function resolveQuote(input: QuoteInput): Promise<QuoteResponse> {
143+
if (process.env.ANCHOR_API_BASE_URL) {
144+
return fetchAnchorQuote(input);
145+
}
146+
147+
return {message: "Unable to resolve quote"}
148+
}
149+
150+
151+
152+
export const GET = validatedRoute(
153+
quoteSchema,
154+
"query",
155+
async (req, data: QuoteInput) => {
156+
const cacheKey = `${data.amount}:${data.currency}:${data.toCurrency}`.toLowerCase();
157+
158+
// Return cached quote if still fresh
159+
const cached = getCached(cacheKey);
160+
if (cached) {
161+
return NextResponse.json(cached, {
162+
headers: { "X-Cache": "HIT", "Cache-Control": `max-age=${QUOTE_TTL_MS / 1000}` },
163+
});
164+
}
165+
166+
try {
167+
const quote = await resolveQuote(data);
168+
setCache(cacheKey, quote);
169+
170+
return NextResponse.json(quote, {
171+
headers: { "X-Cache": "MISS", "Cache-Control": `max-age=${QUOTE_TTL_MS / 1000}` },
172+
});
173+
} catch (err) {
174+
const message = err instanceof Error ? err.message : "Failed to fetch quote";
175+
console.error("[quote] upstream error:", err);
176+
return NextResponse.json({ error: message }, { status: 502 });
177+
}
178+
}
179+
);
180+
181+
182+
function round2(n: number): number {
183+
return Math.round(n * 100) / 100;
184+
}
185+
186+
/** Returns an ISO-8601 string QUOTE_TTL_MS from now. */
187+
function ttlIso(): string {
188+
return new Date(Date.now() + QUOTE_TTL_MS).toISOString();
189+
}

0 commit comments

Comments
 (0)