import cors from "@fastify/cors"; import Fastify, { type FastifyInstance } from "fastify"; import { z } from "zod"; import { buildFairwayRoutes, EMDEN_HAMM_GRAPH, type FairwayGraph, type RouteOption, type RouteRequest, type RouteResult } from "@watermaps/shared"; import { loadEnv, type ApiEnv } from "./env.js"; import { createCache, type Cache } from "./services/cache.js"; import { appConfig } from "./services/config.js"; import { FairwayService } from "./services/fairways.js"; import { FeatureService } from "./services/features.js"; import { getNearestTideSummary } from "./services/tides.js"; import { getMarineForecast } from "./services/weather.js"; import type { FetchLike } from "./services/http.js"; import { getNavigationData, type NavigationDataAdapters } from "./services/navigation-data.js"; export type AppDeps = { env?: ApiEnv; cache?: Cache; fetcher?: FetchLike; featureService?: FeatureService; fairwayService?: Pick; navigationAdapters?: NavigationDataAdapters; }; const coordinateSchema = z.object({ lat: z.number().min(-90).max(90), lon: z.number().min(-180).max(180) }); const routeRequestSchema = z.object({ start: coordinateSchema, destination: coordinateSchema, waypoints: z.array(coordinateSchema).max(25).optional(), departureTime: z .string() .refine((value) => Number.isFinite(Date.parse(value)), "departureTime must be an ISO timestamp") .optional(), vesselProfile: z.object({ draughtM: z.number().positive().max(15), safetyReserveM: z.number().min(0).max(10), airDraftM: z.number().positive().max(80).optional(), beamM: z.number().positive().max(80).optional(), cruiseSpeedKn: z.number().positive().max(80).optional() }), depthSamples: z .array( z.object({ coordinate: coordinateSchema, depthM: z.number().nullable() }) ) .max(500) .optional() }) satisfies z.ZodType; const coordinateQuerySchema = z.object({ lat: z.coerce.number().min(-90).max(90), lon: z.coerce.number().min(-180).max(180), at: z .string() .refine((value) => Number.isFinite(Date.parse(value)), "at must be an ISO timestamp") .optional() }); const featuresQuerySchema = z.object({ bbox: z .string() .transform((value, ctx) => { const parts = value.split(",").map(Number); if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "bbox must be minLon,minLat,maxLon,maxLat" }); return z.NEVER; } return parts as [number, number, number, number]; }), layers: z .string() .default("seamarks,bridges,locks,harbours") .transform((value) => value .split(",") .map((layer) => layer.trim()) .filter(Boolean) ) }); const navigationQuerySchema = z.object({ waterways: commaSeparatedQuery(12).optional(), stationIds: commaSeparatedQuery(30).optional(), lockIds: commaSeparatedQuery(30).optional() }); export async function buildServer(deps: AppDeps = {}): Promise { const env = deps.env ?? loadEnv(); const cache = deps.cache ?? createCache(env.redisUrl); const fetcher = deps.fetcher ?? fetch; const featureService = deps.featureService ?? new FeatureService(env); const fairwayService = deps.fairwayService ?? new FairwayService({ cache, fetcher, liveEnabled: env.liveOsmFairways, databaseUrl: env.databaseUrl }); const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? "info" } }); await app.register(cors, { origin: true }); app.addHook("onClose", async () => { await cache.close(); await featureService.close(); await fairwayService.close?.(); }); app.get("/health", async () => ({ ok: true, service: "watermaps-api" })); app.get("/api/config", async () => appConfig); app.get("/api/weather/marine", async (request, reply) => { const parsed = coordinateQuerySchema.safeParse(request.query); if (!parsed.success) { return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() }); } return getMarineForecast(parsed.data, { cache, fetcher }); }); app.get("/api/tides/nearest", async (request, reply) => { const parsed = coordinateQuerySchema.safeParse(request.query); if (!parsed.success) { return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() }); } const summary = await getNearestTideSummary(parsed.data, { cache, fetcher }); if (!summary) { return reply.code(404).send({ error: "no_tide_station_found" }); } return summary; }); app.get("/api/navigation/live", async (request, reply) => { const parsed = navigationQuerySchema.safeParse(request.query); if (!parsed.success) { return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() }); } return getNavigationData(parsed.data, { cache, fetcher, adapters: deps.navigationAdapters }); }); app.get("/api/features", async (request, reply) => { const parsed = featuresQuerySchema.safeParse(request.query); if (!parsed.success) { return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() }); } return featureService.getFeatures(parsed.data); }); app.post("/api/routes", async (request, reply) => { const parsed = routeRequestSchema.safeParse(request.body); if (!parsed.success) { return reply.code(400).send({ error: "invalid_route", details: parsed.error.flatten() }); } const dynamicGraphs = await fairwayService.getGraphsForRoute(parsed.data).catch((error) => { app.log.warn({ error }, "fairway extraction failed"); return []; }); const route = buildRouteFromGraphs(parsed.data, dynamicGraphs); if (!route) { return reply.code(422).send({ error: "no_fairway_route", message: "Keine Fahrwasserroute für Start und Ziel gefunden. Setze Punkte näher an ein bekanntes Fahrwasser oder importiere weitere Fahrwasserdaten." }); } return route; }); return app; } function commaSeparatedQuery(maxItems: number) { return z.string().transform((value, ctx) => { const items = [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))]; if (items.length > maxItems) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `too many values (maximum ${maxItems})` }); return z.NEVER; } return items; }); } function buildRouteFromGraphs(request: RouteRequest, graphs: FairwayGraph[]) { for (const graph of graphs) { const routes = buildFairwayRoutes(request, graph); if (routes.length > 0) { return routeResultWithAlternatives(routes); } } for (const graph of [undefined, EMDEN_HAMM_GRAPH] as const) { const routes = graph ? buildFairwayRoutes(request, graph) : buildFairwayRoutes(request); if (routes.length > 0) { return routeResultWithAlternatives(routes); } } return null; } function routeResultWithAlternatives(routes: RouteOption[]): RouteResult { const [primary, ...alternatives] = routes; if (!primary) { throw new Error("routeResultWithAlternatives requires at least one route"); } return { ...primary, alternatives }; }