Files
time_to_leave/apps/web/src/app/api/hafas/route.ts
T
fegger bf252a9e9b Refactor mobile UI and centralize HAFAS parsing
Introduce useColors hook to replace direct theme usage in mobile
screens. Extract EventHeader, JourneyList, BikeSection, and
NearbyStops components to reduce complexity in EventDetailScreen.

Move parseHafasJourneys to packages/core for shared usage between
web and mobile clients. Update web API route with stricter HAFAS
validation and consistent client instantiation.

Add comprehensive codebase function guide documenting data flow,
shared packages, and service integrations.
2026-05-13 19:13:27 +02:00

185 lines
5.8 KiB
TypeScript

import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server";
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "@/lib/constants";
import { hafasDateTime } from "@timetoleave/core";
import { readBodyWithLimit } from "@/lib/api-guards";
const HAFAS_VER = process.env.HAFAS_VER || "1.36";
const HAFAS_LANG = process.env.HAFAS_LANG || "eng";
const HAFAS_AID = process.env.HAFAS_AID || "hf7mcf9bv3nv8g5f";
const HAFAS_CLIENT_ID = process.env.HAFAS_CLIENT_ID || "OEBB";
const HAFAS_CLIENT_VER = process.env.HAFAS_CLIENT_VER || "6020700";
const HAFAS_CLIENT_NAME = process.env.HAFAS_CLIENT_NAME || "oebbApp";
const HAFAS_BODY_MAX = 4 * 1024; // 4 KB
const ALLOWED_METHODS = ["TripSearch", "LocMatch"] as const;
type HafasMethod = (typeof ALLOWED_METHODS)[number];
interface HafasServiceRequest {
meth: string;
req?: Record<string, unknown>;
}
interface HafasBody extends Record<string, unknown> {
svcReqL: HafasServiceRequest[];
}
function injectHafasAuth(
body: Record<string, unknown> | null | undefined,
): Record<string, unknown> {
if (!body || typeof body !== "object") {
return {};
}
return {
ver: HAFAS_VER,
lang: HAFAS_LANG,
auth: { type: "AID", aid: HAFAS_AID },
client: { id: HAFAS_CLIENT_ID, v: HAFAS_CLIENT_VER, type: "IPH", name: HAFAS_CLIENT_NAME },
...body,
};
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const from = searchParams.get("from");
const to = searchParams.get("to");
const date = searchParams.get("date");
if (!from || !to || !date) {
return NextResponse.json({ error: "Missing required parameters: from, to, date" }, { status: 400 });
}
// Validate extId shape — ÖBB station IDs are purely numeric
if (!/^\d+$/.test(from) || !/^\d+$/.test(to)) {
return NextResponse.json({ error: "Invalid station ID format (must be numeric)" }, { status: 400 });
}
const dateObj = new Date(date);
if (isNaN(dateObj.getTime())) {
return NextResponse.json({ error: "Invalid date format" }, { status: 400 });
}
const { date: hafasDate, time: hafasTime } = hafasDateTime(dateObj);
const body = injectHafasAuth({
svcReqL: [
{
meth: "TripSearch",
req: {
depLocL: [{ type: "S", extId: from }],
arrLocL: [{ type: "S", extId: to }],
outDate: hafasDate,
outTime: hafasTime,
numF: 5,
},
},
],
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS);
const response = await fetch(HAFAS_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return NextResponse.json({ error: "HAFAS request failed" }, { status: response.status });
}
const data = await response.json();
return NextResponse.json(data);
} catch (error: unknown) {
if (error instanceof DOMException && error.name === "AbortError") {
return NextResponse.json({ error: "HAFAS request timeout" }, { status: 408 });
}
const corrId = randomUUID().slice(0, 8);
console.error(`[${corrId}] HAFAS API error:`, error);
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
// Body size guard before parsing
const rawBody = await readBodyWithLimit(request, HAFAS_BODY_MAX);
if (!rawBody || rawBody.trim().length === 0) {
return NextResponse.json({ error: "Missing request body" }, { status: 400 });
}
let body: unknown;
try {
body = JSON.parse(rawBody);
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
// Inject required HAFAS protocol fields
const hafasBody = injectHafasAuth(body as Record<string, unknown>) as HafasBody;
// Validate enriched body
if (!hafasBody || !Array.isArray(hafasBody.svcReqL) || hafasBody.svcReqL.length === 0) {
return NextResponse.json({ error: "Invalid HAFAS request body" }, { status: 400 });
}
const svcReq = hafasBody.svcReqL[0] as HafasServiceRequest;
if (
!svcReq ||
typeof svcReq.meth !== "string" ||
!(ALLOWED_METHODS as readonly string[]).includes(svcReq.meth)
) {
return NextResponse.json(
{ error: `Invalid HAFAS method. Allowed: ${ALLOWED_METHODS.join(", ")}` },
{ status: 400 },
);
}
// Cap TripSearch results at 10
if (svcReq.meth === "TripSearch" && svcReq.req && typeof svcReq.req.numF !== "undefined") {
svcReq.req.numF = Math.min(Number(svcReq.req.numF), 10);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS);
const response = await fetch(HAFAS_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(hafasBody),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return NextResponse.json({ error: "HAFAS request failed" }, { status: response.status });
}
const data = await response.json();
return NextResponse.json(data);
} catch (error: unknown) {
if (error instanceof DOMException && error.name === "AbortError") {
return NextResponse.json({ error: "HAFAS request timeout" }, { status: 408 });
}
const corrId = randomUUID().slice(0, 8);
console.error(`[${corrId}] HAFAS API error:`, error);
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
}
}