248 lines
7.3 KiB
TypeScript
248 lines
7.3 KiB
TypeScript
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<FairwayService, "getGraphsForRoute" | "close">;
|
|
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<RouteRequest>;
|
|
|
|
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<FastifyInstance> {
|
|
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
|
|
};
|
|
}
|