Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions .github/workflows/api-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
name: API CI

on:
pull_request:
branches:
- master
paths:
- 'app/api/**'
- 'lib/api/**'
- 'lib/auth/**'
- 'lib/rateLimit.ts'
- 'lib/protectedRoute.ts'
- 'server/services/**'
- 'prisma/schema.prisma'
- 'tests/api/**'
- 'components/**/*.ts'
- 'components/**/*.tsx'
- 'hooks/**/*.ts'

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
ci:
name: CI
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
cache: 'yarn'

- name: Install dependencies
run: yarn install --frozen-lockfile

# ── Core checks ──────────────────────────────────────

- name: Type check
run: npx tsc --noEmit

# ── API conventions ──────────────────────────────────

- name: ESLint (API routes)
run: npx eslint app/api/ --max-warnings 0

- name: API standards (security + quality)
run: bash ./scripts/check-api-standards.sh

- name: API tests
run: npx vitest run tests/api/ --reporter=verbose

# ── PR-level enforcement ─────────────────────────────

- name: Require tests for changed API routes
run: |
# Get API route files changed in this PR
CHANGED_ROUTES=$(git diff --name-only origin/master...HEAD -- 'app/api/**/route.ts' 'app/api/**/route.tsx' | \
grep -v 'auth/\[\.\.\.nextauth\]\|/og/\|well-known\|/check/route' || true)

if [ -z "$CHANGED_ROUTES" ]; then
echo "✓ No API routes changed in this PR"
exit 0
fi

MISSING=0
echo "Checking test coverage for changed API routes..."
while IFS= read -r route; do
[ -z "$route" ] && continue
route_dir=$(echo "$route" | sed 's|app/api/||; s|/route\.tsx\?||')
route_name=$(basename "$route_dir")

# Check if ANY test file references this route
if grep -rl "$route_dir\|$route_name" tests/api/ --include="*.test.ts" > /dev/null 2>&1; then
echo " ✓ $route → has tests"
else
echo " ✗ $route → NO TESTS FOUND"
MISSING=$((MISSING + 1))
fi
done <<< "$CHANGED_ROUTES"

if [ $MISSING -gt 0 ]; then
echo ""
echo "✗ $MISSING changed API route(s) have no test coverage."
echo " Add tests to tests/api/ before merging."
exit 1
fi

echo "✓ All changed API routes have test coverage"
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,10 @@ content/docs/rpcs/x-chain/**/meta.json
!content/docs/rpcs/x-chain/txn-format.mdx
!content/docs/rpcs/x-chain/api.mdx
!content/docs/rpcs/x-chain/rpc.mdx

# Component/hook READMEs (auto-generated, not for git)
components/*/README.md
hooks/README.md
lib/README.md
types/README.md
constants/README.md
6 changes: 6 additions & 0 deletions .lintstagedrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ module.exports = {
`./scripts/check-console-design.sh ${files.join(' ')}`,
],

// API routes: ESLint + Prettier
'app/api/**/*.ts': [
'prettier --write',
'eslint --max-warnings 0',
],

// All TypeScript: type check (runs once, not per-file)
'*.{ts,tsx}': () => 'tsc --noEmit',
};
14 changes: 4 additions & 10 deletions app/(home)/build-games/apply/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { countries } from "@/constants/countries";
import { cn } from "@/lib/utils";
import { apiFetch, ApiClientError } from "@/lib/api/client";
import { getReferrer } from "@/lib/referral";

const EMPLOYMENT_ROLES = ["Accounting", "Administrative", "Development", "Communications", "Consulting", "Customer", "Design", "Education", "Engineering", "Entrepreneurship", "Finance", "Health", "Human Resources", "Information Technology", "Legal", "Marketing", "Operations", "Product", "Project Management", "Public Relations", "Quality Assurance", "Real Estate", "Recruiting", "Research", "Sales", "Support", "Retired", "Other"];
Expand Down Expand Up @@ -290,21 +291,14 @@ export default function BuildGamesApplyForm() {
try {
const referrer = getReferrer();

const response = await fetch("/api/build-games/apply", {
await apiFetch("/api/build-games/apply", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
body: {
...values,
referrer: referrer,
}),
},
});

const result = await response.json();

if (!response.ok || !result.success) {
throw new Error(result.message || "Failed to submit application");
}

setSubmissionStatus("success");
form.reset();
} catch (error) {
Expand Down
9 changes: 3 additions & 6 deletions app/(home)/grants/retro9000returning/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { countries } from "@/constants/countries";
import { cn } from "@/lib/utils";
import { apiFetch, ApiClientError } from "@/lib/api/client";
import { formSchema, jobRoles, projectTypes, projectVerticals, continents, fundingRanges, type Retro9000ReturningFormData } from "@/types/retro9000ReturningForm";

const STEPS = [
Expand Down Expand Up @@ -195,14 +196,10 @@ export default function Retro9000ReturningForm() {
async function onSubmit(values: Retro9000ReturningFormData) {
setIsSubmitting(true);
try {
const response = await fetch("/api/retro9000-returning", {
await apiFetch("/api/retro9000-returning", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
body: values,
});

const result = await response.json();
if (!response.ok || !result.success) { throw new Error(result.message || "Failed to submit application") }
setSubmissionStatus("success");
form.reset();
if (session?.user?.email) { form.setValue("email", session.user.email) }
Expand Down
37 changes: 14 additions & 23 deletions app/(home)/stats/avax-token/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Tooltip as UITooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { CircleDotDashed, CircleFadingPlus, Lock, BadgeDollarSign, RefreshCw, Flame, Award, MessageSquareIcon, Server, Unlock, HandCoins, Info, ArrowUpRight } from "lucide-react";
import { useEffect, useState, useMemo } from "react";
import { apiFetch } from "@/lib/api/client";
import Image from "next/image";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Tooltip, ResponsiveContainer, Brush, LineChart, Line } from "recharts";
import { L1BubbleNav } from "@/components/stats/l1-bubble.config";
Expand Down Expand Up @@ -72,19 +73,12 @@ export default function AvaxTokenPage() {
setLoading(true);
setError(null);

const [supplyRes, cChainRes, icmRes] = await Promise.all([
fetch("/api/avax-supply"),
fetch("/api/chain-stats/43114?timeRange=1y"),
fetch("/api/icm-contract-fees?timeRange=1y"),
const [supplyData, cChainData, icmRes] = await Promise.all([
apiFetch<AvaxSupplyData>("/api/avax-supply"),
apiFetch<CChainFeesResponse>("/api/chain-stats/43114?timeRange=1y"),
apiFetch<ICMFeesResponse>("/api/icm-contract-fees?timeRange=1y").catch(() => null),
]);

if (!supplyRes.ok || !cChainRes.ok) {
throw new Error("Failed to fetch required data");
}

const supplyData = await supplyRes.json();
const cChainData: CChainFeesResponse = await cChainRes.json();

setData(supplyData);

const cChainFeesData: FeeDataPoint[] = cChainData.feesPaid.data
Expand All @@ -97,18 +91,15 @@ export default function AvaxTokenPage() {

setCChainFees(cChainFeesData);

if (icmRes.ok) {
const icmData: ICMFeesResponse = await icmRes.json();
if (icmData.data && Array.isArray(icmData.data)) {
const icmFeesData: FeeDataPoint[] = icmData.data
.map((item) => ({
date: item.date,
timestamp: item.timestamp,
value: item.feesPaid / 1e18,
}))
.reverse();
setICMFees(icmFeesData);
}
if (icmRes && icmRes.data && Array.isArray(icmRes.data)) {
const icmFeesData: FeeDataPoint[] = icmRes.data
.map((item) => ({
date: item.date,
timestamp: item.timestamp,
value: item.feesPaid / 1e18,
}))
.reverse();
setICMFees(icmFeesData);
}
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
Expand Down
10 changes: 3 additions & 7 deletions app/(home)/stats/chain-list/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useState, useEffect, useMemo, useRef } from "react";
import { apiFetch } from "@/lib/api/client";
import Image from "next/image";
import { useTheme } from "next-themes";
import { Card } from "@/components/ui/card";
Expand Down Expand Up @@ -140,13 +141,8 @@ export default function ChainListPage() {
await Promise.all(
batch.map(async (chain) => {
try {
const response = await fetch(`/api/explorer/${chain.chainId}?priceOnly=true`);
if (response.ok) {
const data = await response.json();
supportMap.set(chain.chainId, data.glacierSupported === true);
} else {
supportMap.set(chain.chainId, false);
}
const data = await apiFetch<any>(`/api/explorer/${chain.chainId}?priceOnly=true`);
supportMap.set(chain.chainId, data.glacierSupported === true);
} catch (error) {
console.warn(`Failed to check Glacier support for chain ${chain.chainId}:`, error);
supportMap.set(chain.chainId, false);
Expand Down
27 changes: 5 additions & 22 deletions app/(home)/stats/interchain-messaging/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";
import { useState, useEffect, useMemo, useRef } from "react";
import { apiFetch } from "@/lib/api/client";
import { Bar, BarChart, CartesianGrid, Line, LineChart, XAxis, YAxis, Tooltip, Brush, ResponsiveContainer } from "recharts";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -106,13 +107,7 @@ export default function ICMStatsPage() {
setError(null);

// Always fetch 1 year of data
const response = await fetch(`/api/icm-stats?timeRange=1y`);

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const data = await response.json();
const data = await apiFetch<any>(`/api/icm-stats?timeRange=1y`);
setMetrics(data);
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
Expand All @@ -132,16 +127,7 @@ export default function ICMStatsPage() {
}

const limit = offset === 0 ? 20 : 25;
const response = await fetch(
`/api/ictt-stats?limit=${limit}&offset=${offset}`
);

if (!response.ok) {
console.error("Failed to fetch ICTT stats:", response.status);
return;
}

const data = await response.json();
const data = await apiFetch<any>(`/api/ictt-stats?limit=${limit}&offset=${offset}`);

if (append && icttData) {
setIcttData({
Expand All @@ -168,11 +154,8 @@ export default function ICMStatsPage() {
const fetchIcmFlowData = async () => {
try {
setIcmFlowLoading(true);
const response = await fetch("/api/icm-flow?days=30");
if (response.ok) {
const data = await response.json();
setIcmFlowData(data);
}
const data = await apiFetch<any>("/api/icm-flow?days=30");
setIcmFlowData(data);
} catch (err) {
console.error("Error fetching ICM flow data:", err);
} finally {
Expand Down
24 changes: 7 additions & 17 deletions app/(home)/stats/overview/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
VersionLabels,
} from "@/components/stats/VersionBreakdown";
import { formatMarketCap } from "@/lib/utils/format-market-cap";
import { apiFetch } from "@/lib/api/client";

type TableView = "summary" | "validators";

Expand Down Expand Up @@ -303,11 +304,8 @@ export default function AvalancheMetrics() {
useEffect(() => {
const fetchAvaxSupply = async () => {
try {
const response = await fetch("/api/avax-supply");
if (response.ok) {
const data = await response.json();
setAvaxSupplyData(data);
}
const data = await apiFetch<any>("/api/avax-supply");
setAvaxSupplyData(data);
} catch (err) {
console.error("Error fetching AVAX supply data:", err);
}
Expand All @@ -322,11 +320,7 @@ export default function AvalancheMetrics() {
const fetchValidatorStats = async () => {
setValidatorStatsLoading(true);
try {
const response = await fetch("/api/validator-stats?network=mainnet");
if (!response.ok) {
throw new Error(`Failed to fetch validator stats: ${response.status}`);
}
const stats: SubnetStats[] = await response.json();
const stats = await apiFetch<SubnetStats[]>("/api/validator-stats?network=mainnet");
setValidatorStats(stats);

// Extract available versions
Expand Down Expand Up @@ -417,13 +411,12 @@ export default function AvalancheMetrics() {
const fetchIcmFlows = useCallback(async () => {
try {
setIcmLoading(true);
const icmResponse = await fetch("/api/icm-flow?days=30").catch(
const icmData = await apiFetch<any>("/api/icm-flow?days=30").catch(
() => null
);

if (icmResponse && icmResponse.ok) {
if (icmData) {
try {
const icmData = await icmResponse.json();
if (icmData.flows && Array.isArray(icmData.flows)) {
setIcmFlows(
icmData.flows.map((f: any) => ({
Expand Down Expand Up @@ -544,12 +537,9 @@ export default function AvalancheMetrics() {
}
setError(null);
try {
const response = await fetch(
const metrics = await apiFetch<any>(
`/api/overview-stats?timeRange=${timeRange}`
);
if (!response.ok)
throw new Error(`Failed to fetch metrics: ${response.status}`);
const metrics = await response.json();
setOverviewMetrics(metrics);
} catch (err: any) {
console.error("Error fetching metrics data:", err);
Expand Down
Loading
Loading