Files
time_to_leave/apps/web/src/app/api/hafas/route.ts
T
fegger 04e793cbb8 Implement journey ranking and improve UI presentation
Add `rankJourneys` utility to score connections based on arrival fit,
directness, and duration. Update `JourneyList` to display the best option
first with a "Top" badge and allow expanding to see more. Fix HAFAS
parser to include train direction in journey details. Limit API result
count to 5 and add TypeScript declaration for PNG assets.
2026-05-14 17:05:56 +02:00

217 lines
7.2 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";
/** HAFAS protocol version identifier sent in every request envelope. */
const HAFAS_VER = process.env.HAFAS_VER || "1.36";
const HAFAS_LANG = process.env.HAFAS_LANG || "eng";
/** Application ID registered with ÖBB for HAFAS access. */
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";
/** Hard limit on the POST body size to prevent abuse. */
const HAFAS_BODY_MAX = 4 * 1024; // 4 KB
/** Only these HAFAS service methods are allowed through the proxy. */
const ALLOWED_METHODS = ["TripSearch", "LocMatch"] as const;
type HafasMethod = (typeof ALLOWED_METHODS)[number];
/** Single service request entry inside a HAFAS envelope. */
interface HafasServiceRequest {
meth: string;
req?: Record<string, unknown>;
}
/** Full HAFAS request body with a list of service requests. */
interface HafasBody extends Record<string, unknown> {
svcReqL: HafasServiceRequest[];
}
/**
* Injects required HAFAS protocol fields (version, language, auth, client)
* into the outgoing request. These fields identify the caller to ÖBB's system.
*
* @param body - Client-provided partial body (typically containing `svcReqL`).
* @returns Fully assembled HAFAS request envelope.
*/
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,
};
}
/**
* GET handler — convenience endpoint for simple trip searches.
*
* Accepts `from`, `to` (numeric ÖBB station IDs) and `date` as query params.
* Constructs a TripSearch request, injects auth, and forwards it to HAFAS.
*
* This is a simplified interface; the POST handler below supports arbitrary
* HAFAS envelopes (TripSearch, LocMatch).
*/
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 });
}
}
/**
* POST handler — generic HAFAS proxy for advanced callers.
*
* Accepts a JSON body containing a HAFAS service request (`svcReqL`).
* Validates the method is allowed, caps TripSearch results at 10,
* injects auth credentials, and forwards to the ÖBB HAFAS endpoint.
*
* Timeout is enforced via AbortController. Errors include a correlation ID
* for server-side log lookup.
*/
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), 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(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 });
}
}