Initial Watermaps import
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user