mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-07-31 13:55:34 +08:00
refactor(tools): forward the airport endpoints to the backend
/api/service/tools/* was a second implementation of the OSM geocoding and taxi routing the Python backend already owns — its own Overpass client, alias matching and scoring. It drifted: it still asked Overpass for `out center tags`, so a runway resolved to the middle of the strip rather than a threshold, which is the runway-endpoint bug the backend fixed. Teaching the copy about runway endpoints would mean maintaining the geometry twice, so the copy goes instead. Forwarding exposes origin_runway_point / dest_runway_point and include_connectors, and inherits the backend's Overpass cache and its radius fallback for aerodromes with no generated OSM area (EDDM), which the copy answered with an empty feature list. Failures now carry an HTTP status as well as the error code in the body; the old copy answered every error with 200. The frequencies call sites needed real types: airportGeocode.ts sat under server/api with no default export, so Nitro registered it as a route whose return type collapsed to any and poisoned the whole API type map. The typecheck was green because of it, not despite it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,19 @@ export type AirportFrequencyEntry = {
|
||||
lastUpdated?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of `GET /api/airports/:icao/frequencies`.
|
||||
*
|
||||
* Stated explicitly because `api.get()` is generic over the response and
|
||||
* cannot infer it from a template-literal path.
|
||||
*/
|
||||
type AirportFrequenciesResponse = {
|
||||
icao: string
|
||||
airportName?: string
|
||||
sources: { vatsim: boolean; openaip: boolean }
|
||||
frequencies: AirportFrequencyEntry[]
|
||||
}
|
||||
|
||||
export type DisplayAirportFrequencyEntry = AirportFrequencyEntry & {
|
||||
displayKey: string
|
||||
sourceList: Array<'vatsim' | 'openaip'>
|
||||
@@ -429,7 +442,7 @@ export function useFrequencyPresets(
|
||||
if (!destinationIcao.value) return
|
||||
if (destinationIcao.value === activeAirportIcao.value?.trim().toUpperCase()) return
|
||||
try {
|
||||
const response = await api.get(`/api/airports/${encodeURIComponent(destinationIcao.value)}/frequencies`)
|
||||
const response = await api.get<AirportFrequenciesResponse>(`/api/airports/${encodeURIComponent(destinationIcao.value)}/frequencies`)
|
||||
destinationFrequencies.value = Array.isArray(response?.frequencies)
|
||||
? response.frequencies as AirportFrequencyEntry[]
|
||||
: []
|
||||
@@ -457,7 +470,7 @@ export function useFrequencyPresets(
|
||||
// broadcast text onto the frequency entries, so a partial load would show
|
||||
// an ATIS station with no information letter.
|
||||
const [response, report] = await Promise.all([
|
||||
api.get(`/api/airports/${encodeURIComponent(icao)}/frequencies`),
|
||||
api.get<AirportFrequenciesResponse>(`/api/airports/${encodeURIComponent(icao)}/frequencies`),
|
||||
api.get(`/api/airports/${encodeURIComponent(icao)}/atis`).catch((err: unknown) => {
|
||||
console.error('Failed to load ATIS report:', err)
|
||||
return null
|
||||
|
||||
@@ -1,100 +1,17 @@
|
||||
import { defineEventHandler, getQuery } from 'h3'
|
||||
|
||||
import {
|
||||
fetchAirportFeatures,
|
||||
resolveFeature,
|
||||
toGeocodePayload,
|
||||
type GeocodeQuery
|
||||
} from './airportGeocode'
|
||||
import { forwardToRadioBackend } from '../../../utils/radioBackend'
|
||||
|
||||
function parseCoordinate(value: unknown) {
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
function hasCoordinatePair(query: GeocodeQuery) {
|
||||
return typeof query.lat === 'number' && typeof query.lon === 'number'
|
||||
}
|
||||
|
||||
function normalizeQuery(query: GeocodeQuery) {
|
||||
return {
|
||||
name: query.name?.trim() || null,
|
||||
lat: hasCoordinatePair(query) ? (query.lat as number) : null,
|
||||
lon: hasCoordinatePair(query) ? (query.lon as number) : null
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const q = getQuery(event)
|
||||
|
||||
const airport = typeof q.airport === 'string' ? q.airport.trim().toUpperCase() : ''
|
||||
|
||||
const originQuery: GeocodeQuery = {
|
||||
name: typeof q.origin_name === 'string' ? q.origin_name : undefined,
|
||||
lat: parseCoordinate(q.origin_lat),
|
||||
lon: parseCoordinate(q.origin_lng ?? q.origin_lon)
|
||||
}
|
||||
|
||||
const destQuery: GeocodeQuery = {
|
||||
name: typeof q.dest_name === 'string' ? q.dest_name : undefined,
|
||||
lat: parseCoordinate(q.dest_lat),
|
||||
lon: parseCoordinate(q.dest_lng ?? q.dest_lon)
|
||||
}
|
||||
|
||||
const normalizedOrigin = normalizeQuery(originQuery)
|
||||
const normalizedDest = normalizeQuery(destQuery)
|
||||
|
||||
const hasOriginQuery = Boolean(normalizedOrigin.name) || hasCoordinatePair(originQuery)
|
||||
const hasDestQuery = Boolean(normalizedDest.name) || hasCoordinatePair(destQuery)
|
||||
|
||||
if (!airport) {
|
||||
return { error: 'missing_airport' }
|
||||
}
|
||||
|
||||
if (!hasOriginQuery && !hasDestQuery) {
|
||||
return { error: 'missing_query', airport }
|
||||
}
|
||||
|
||||
let features
|
||||
try {
|
||||
features = await fetchAirportFeatures(airport)
|
||||
} catch (error) {
|
||||
return { error: 'overpass_error', airport, details: (error as Error).message }
|
||||
}
|
||||
|
||||
if (!features.length) {
|
||||
return {
|
||||
error: 'no_features',
|
||||
airport,
|
||||
origin: hasOriginQuery ? { query: normalizedOrigin, result: null } : undefined,
|
||||
dest: hasDestQuery ? { query: normalizedDest, result: null } : undefined
|
||||
}
|
||||
}
|
||||
|
||||
const resolve = (query: GeocodeQuery) => {
|
||||
const match = resolveFeature(features, query)
|
||||
if (!match) return null
|
||||
return toGeocodePayload(match)
|
||||
}
|
||||
|
||||
const response: Record<string, unknown> = {
|
||||
airport,
|
||||
feature_count: features.length
|
||||
}
|
||||
|
||||
if (hasOriginQuery) {
|
||||
response.origin = {
|
||||
query: normalizedOrigin,
|
||||
result: resolve(originQuery)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDestQuery) {
|
||||
response.dest = {
|
||||
query: normalizedDest,
|
||||
result: resolve(destQuery)
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
})
|
||||
/**
|
||||
* Resolve named aerodrome features to coordinates, or coordinates to the
|
||||
* nearest named feature, for one airport.
|
||||
*
|
||||
* Forwarded to the Python backend, which owns the OSM feature dataset and the
|
||||
* alias matching. Beyond ending the duplication, this inherits two things the
|
||||
* Nuxt copy never had: the Overpass response cache, and the radius fallback
|
||||
* for aerodromes whose OSM relation has no generated area (EDDM), which the
|
||||
* copy answered with an empty feature list.
|
||||
*/
|
||||
export default defineEventHandler(async (event) =>
|
||||
forwardToRadioBackend('/api/service/tools/airport-geocode', getQuery(event))
|
||||
)
|
||||
|
||||
@@ -1,466 +0,0 @@
|
||||
import https from 'node:https'
|
||||
|
||||
export type FeatureType =
|
||||
| 'runway'
|
||||
| 'gate'
|
||||
| 'parking_position'
|
||||
| 'taxiway'
|
||||
| 'holding_position'
|
||||
| 'stand'
|
||||
| 'unknown'
|
||||
|
||||
export type OsmElement =
|
||||
| ({
|
||||
type: 'node'
|
||||
id: number
|
||||
lat: number
|
||||
lon: number
|
||||
tags?: Record<string, string>
|
||||
})
|
||||
| ({
|
||||
type: 'way'
|
||||
id: number
|
||||
nodes: number[]
|
||||
center?: { lat: number; lon: number }
|
||||
tags?: Record<string, string>
|
||||
})
|
||||
|
||||
export type AirportFeature = {
|
||||
osmType: 'node' | 'way'
|
||||
osmId: number
|
||||
type: FeatureType
|
||||
lat: number
|
||||
lon: number
|
||||
tags: Record<string, string>
|
||||
aliases: string[]
|
||||
primaryAlias: string
|
||||
normalizedAliases: Map<string, string>
|
||||
}
|
||||
|
||||
export type GeocodeQuery = {
|
||||
name?: string | null
|
||||
lat?: number | null
|
||||
lon?: number | null
|
||||
}
|
||||
|
||||
export type GeocodeMatch = {
|
||||
feature: AirportFeature
|
||||
matchedAlias: string | null
|
||||
distanceMeters?: number
|
||||
source: 'name' | 'coordinate'
|
||||
}
|
||||
|
||||
const OVERPASS_ENDPOINT = 'https://overpass-api.de/api/interpreter'
|
||||
const EARTH_RADIUS = 6371000
|
||||
|
||||
function toRadians(value: number) {
|
||||
return (value * Math.PI) / 180
|
||||
}
|
||||
|
||||
function haversineDistance(
|
||||
a: { lat: number; lon: number },
|
||||
b: { lat: number; lon: number }
|
||||
) {
|
||||
const dLat = toRadians(b.lat - a.lat)
|
||||
const dLon = toRadians(b.lon - a.lon)
|
||||
const lat1 = toRadians(a.lat)
|
||||
const lat2 = toRadians(b.lat)
|
||||
|
||||
const sinLat = Math.sin(dLat / 2)
|
||||
const sinLon = Math.sin(dLon / 2)
|
||||
|
||||
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLon * sinLon
|
||||
return 2 * EARTH_RADIUS * Math.asin(Math.sqrt(h))
|
||||
}
|
||||
|
||||
function fetchOverpass(query: string) {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const req = https.request(
|
||||
OVERPASS_ENDPOINT,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
},
|
||||
(res) => {
|
||||
let data = ''
|
||||
res.on('data', (d) => (data += d))
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`))
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(data))
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
req.on('error', reject)
|
||||
req.write('data=' + encodeURIComponent(query))
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
function normalize(value: string) {
|
||||
return value.toUpperCase().replace(/[^A-Z0-9]/g, '')
|
||||
}
|
||||
|
||||
function normalizeWithPrefixes(value: string) {
|
||||
const variants = new Set<string>()
|
||||
const trimmed = value.trim().toUpperCase()
|
||||
if (!trimmed) return variants
|
||||
|
||||
const base = normalize(trimmed)
|
||||
if (base) variants.add(base)
|
||||
|
||||
const withoutRunway = trimmed.replace(/^(RUNWAY|RWY)\s+/, '')
|
||||
if (withoutRunway !== trimmed) {
|
||||
const normalized = normalize(withoutRunway)
|
||||
if (normalized) variants.add(normalized)
|
||||
}
|
||||
|
||||
const withoutGate = trimmed.replace(/^(GATE|STAND)\s+/, '')
|
||||
if (withoutGate !== trimmed) {
|
||||
const normalized = normalize(withoutGate)
|
||||
if (normalized) variants.add(normalized)
|
||||
}
|
||||
|
||||
const tokens = trimmed.split(/\s+/)
|
||||
if (tokens.length > 1) {
|
||||
const lastToken = normalize(tokens[tokens.length - 1])
|
||||
if (lastToken) variants.add(lastToken)
|
||||
}
|
||||
|
||||
return variants
|
||||
}
|
||||
|
||||
function deriveFeatureType(tags: Record<string, string>): FeatureType {
|
||||
const aeroway = tags.aeroway as FeatureType | undefined
|
||||
if (!aeroway) return 'unknown'
|
||||
|
||||
if (aeroway === 'parking_position') return 'parking_position'
|
||||
if (aeroway === 'gate') return 'gate'
|
||||
if (aeroway === 'runway') return 'runway'
|
||||
if (aeroway === 'taxiway') return 'taxiway'
|
||||
if (aeroway === 'holding_position') return 'holding_position'
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function buildAliases(tags: Record<string, string>, featureType: FeatureType) {
|
||||
const aliases = new Set<string>()
|
||||
let primaryAlias: string | null = null
|
||||
|
||||
const addAlias = (value: string) => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return
|
||||
if (!aliases.has(trimmed)) {
|
||||
aliases.add(trimmed)
|
||||
if (!primaryAlias) primaryAlias = trimmed
|
||||
}
|
||||
}
|
||||
|
||||
const candidates: string[] = []
|
||||
const ref = tags.ref
|
||||
const name = tags.name
|
||||
const designation = tags.designation
|
||||
const icao = tags['ref:icao']
|
||||
|
||||
if (ref) candidates.push(ref)
|
||||
if (designation && designation !== ref) candidates.push(designation)
|
||||
if (name && name !== ref) candidates.push(name)
|
||||
if (icao && icao !== ref) candidates.push(icao)
|
||||
|
||||
if (featureType === 'runway') {
|
||||
const runway = tags['ref:runway'] || tags['ref:icao:runway']
|
||||
if (runway) candidates.push(runway)
|
||||
}
|
||||
|
||||
const addCandidate = (candidate: string) => {
|
||||
if (!candidate) return
|
||||
if (featureType === 'runway') {
|
||||
const parts = candidate.split(/[\/;,]/)
|
||||
if (parts.length > 1) {
|
||||
for (const part of parts) addAlias(part)
|
||||
}
|
||||
}
|
||||
addAlias(candidate)
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
addCandidate(candidate)
|
||||
}
|
||||
|
||||
if (featureType === 'parking_position') {
|
||||
const refStand = tags['ref:stand']
|
||||
if (refStand) addAlias(refStand)
|
||||
}
|
||||
|
||||
const aliasList = Array.from(aliases)
|
||||
|
||||
return {
|
||||
aliases: aliasList,
|
||||
primaryAlias: primaryAlias ?? aliasList[0] ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function createFeature(element: OsmElement): AirportFeature | null {
|
||||
const tags = element.tags ?? {}
|
||||
const featureType = deriveFeatureType(tags)
|
||||
const lat = element.type === 'node' ? element.lat : element.center?.lat
|
||||
const lon = element.type === 'node' ? element.lon : element.center?.lon
|
||||
|
||||
if (lat === undefined || lon === undefined) return null
|
||||
|
||||
const aliasResult = buildAliases(tags, featureType)
|
||||
if (aliasResult.aliases.length === 0 || !aliasResult.primaryAlias) return null
|
||||
const { aliases, primaryAlias } = aliasResult as { aliases: string[]; primaryAlias: string }
|
||||
|
||||
const normalizedAliases = new Map<string, string>()
|
||||
for (const alias of aliases) {
|
||||
const variants = normalizeWithPrefixes(alias)
|
||||
for (const variant of variants) {
|
||||
if (!normalizedAliases.has(variant)) {
|
||||
normalizedAliases.set(variant, alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedAliases.size === 0) return null
|
||||
|
||||
return {
|
||||
osmType: element.type,
|
||||
osmId: element.id,
|
||||
type: featureType === 'parking_position' ? 'stand' : featureType,
|
||||
lat,
|
||||
lon,
|
||||
tags,
|
||||
aliases,
|
||||
primaryAlias,
|
||||
normalizedAliases
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeQuery(query: string) {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
return {
|
||||
trimmed,
|
||||
sanitizedQuery: '',
|
||||
runwayBias: false
|
||||
}
|
||||
}
|
||||
|
||||
let sanitized = trimmed
|
||||
const patterns: RegExp[] = [
|
||||
/\b(runway|rwy)\b/gi,
|
||||
/\b(gate)\b/gi,
|
||||
/\b(stand|parking|standposition)\b/gi,
|
||||
/\b(taxiway|taxi)\b/gi,
|
||||
/\b(holding|holdshort|holdingpoint)\b/gi
|
||||
]
|
||||
|
||||
for (const pattern of patterns) {
|
||||
sanitized = sanitized.replace(pattern, ' ')
|
||||
}
|
||||
|
||||
sanitized = sanitized.replace(/\s+/g, ' ').trim()
|
||||
|
||||
const runwayBias =
|
||||
/^\s*\d/.test(trimmed) ||
|
||||
/\b(runway|rwy)\b/i.test(query) ||
|
||||
/\d{1,2}[LRC]?\b/i.test(trimmed) ||
|
||||
/\d{2}\s*\/\s*\d{2}/.test(trimmed)
|
||||
|
||||
return {
|
||||
trimmed,
|
||||
sanitizedQuery: sanitized,
|
||||
runwayBias
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMapUrl(feature: AirportFeature) {
|
||||
const zoom = feature.type === 'runway' ? 17 : 19
|
||||
const lat = feature.lat.toFixed(6)
|
||||
const lon = feature.lon.toFixed(6)
|
||||
return `https://www.openstreetmap.org/${feature.osmType}/${feature.osmId}?mlat=${lat}&mlon=${lon}#map=${zoom}/${lat}/${lon}`
|
||||
}
|
||||
|
||||
export async function fetchAirportFeatures(airport: string) {
|
||||
const overpassQuery = `
|
||||
[out:json][timeout:60];
|
||||
(
|
||||
area["aeroway"="aerodrome"]["ref:icao"="${airport}"];
|
||||
area["aeroway"="aerodrome"]["icao"="${airport}"];
|
||||
area["aeroway"="aerodrome"]["ref"="${airport}"];
|
||||
)->.airport;
|
||||
(
|
||||
node(area.airport)["aeroway"="parking_position"];
|
||||
way(area.airport)["aeroway"="parking_position"];
|
||||
node(area.airport)["aeroway"="gate"];
|
||||
way(area.airport)["aeroway"="gate"];
|
||||
node(area.airport)["aeroway"="runway"];
|
||||
node(area.airport)["aeroway"="taxiway"];
|
||||
node(area.airport)["aeroway"="holding_position"];
|
||||
way(area.airport)["aeroway"="runway"];
|
||||
way(area.airport)["aeroway"="taxiway"];
|
||||
way(area.airport)["aeroway"="holding_position"];
|
||||
);
|
||||
out center tags;
|
||||
`
|
||||
|
||||
const osm = await fetchOverpass(overpassQuery)
|
||||
|
||||
const features: AirportFeature[] = []
|
||||
if (Array.isArray(osm?.elements)) {
|
||||
for (const element of osm.elements as OsmElement[]) {
|
||||
if (element.type !== 'node' && element.type !== 'way') continue
|
||||
const feature = createFeature(element)
|
||||
if (feature) features.push(feature)
|
||||
}
|
||||
}
|
||||
|
||||
return features
|
||||
}
|
||||
|
||||
export function matchFeatureByName(features: AirportFeature[], query: string) {
|
||||
if (!query) return null
|
||||
|
||||
const { trimmed, sanitizedQuery, runwayBias } = analyzeQuery(query)
|
||||
const baseQuery = sanitizedQuery || trimmed
|
||||
if (!baseQuery) return null
|
||||
|
||||
const queryVariants = normalizeWithPrefixes(baseQuery)
|
||||
if (queryVariants.size === 0) return null
|
||||
|
||||
const variantList = Array.from(queryVariants)
|
||||
let best:
|
||||
| {
|
||||
feature: AirportFeature
|
||||
matchedAlias: string
|
||||
score: number
|
||||
}
|
||||
| null = null
|
||||
|
||||
for (const feature of features) {
|
||||
for (const [aliasVariant, originalAlias] of feature.normalizedAliases) {
|
||||
for (const variant of variantList) {
|
||||
let score = 0
|
||||
if (aliasVariant === variant) {
|
||||
score = 100
|
||||
} else if (aliasVariant.includes(variant) || variant.includes(aliasVariant)) {
|
||||
score = 70
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
if (runwayBias) {
|
||||
if (feature.type === 'runway') score += 20
|
||||
else score -= 10
|
||||
}
|
||||
|
||||
if (feature.type === 'runway' && /^\d/.test(variant)) {
|
||||
score += 10
|
||||
}
|
||||
|
||||
if (feature.type === 'gate' || feature.type === 'stand') {
|
||||
score += 2
|
||||
}
|
||||
|
||||
if (!best || score > best.score) {
|
||||
best = {
|
||||
feature,
|
||||
matchedAlias: originalAlias,
|
||||
score
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!best) return null
|
||||
|
||||
return {
|
||||
feature: best.feature,
|
||||
matchedAlias: best.matchedAlias
|
||||
}
|
||||
}
|
||||
|
||||
export function matchFeatureByCoordinate(
|
||||
features: AirportFeature[],
|
||||
lat: number,
|
||||
lon: number
|
||||
) {
|
||||
let best:
|
||||
| {
|
||||
feature: AirportFeature
|
||||
distanceMeters: number
|
||||
}
|
||||
| null = null
|
||||
|
||||
for (const feature of features) {
|
||||
const distanceMeters = haversineDistance({ lat, lon }, { lat: feature.lat, lon: feature.lon })
|
||||
if (!best || distanceMeters < best.distanceMeters) {
|
||||
best = { feature, distanceMeters }
|
||||
}
|
||||
}
|
||||
|
||||
if (!best) return null
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
export function resolveFeature(features: AirportFeature[], query: GeocodeQuery): GeocodeMatch | null {
|
||||
const name = query.name?.trim()
|
||||
const lat = typeof query.lat === 'number' ? query.lat : null
|
||||
const lon = typeof query.lon === 'number' ? query.lon : null
|
||||
|
||||
if (name) {
|
||||
const match = matchFeatureByName(features, name)
|
||||
if (match) {
|
||||
return {
|
||||
feature: match.feature,
|
||||
matchedAlias: match.matchedAlias,
|
||||
source: 'name'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lat !== null && lon !== null) {
|
||||
const match = matchFeatureByCoordinate(features, lat, lon)
|
||||
if (match) {
|
||||
return {
|
||||
feature: match.feature,
|
||||
matchedAlias: match.feature.primaryAlias,
|
||||
distanceMeters: match.distanceMeters,
|
||||
source: 'coordinate'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function toGeocodePayload(match: GeocodeMatch) {
|
||||
const feature = match.feature
|
||||
const resolvedName = match.matchedAlias ?? feature.primaryAlias
|
||||
|
||||
return {
|
||||
type: feature.type,
|
||||
name: resolvedName,
|
||||
lat: feature.lat,
|
||||
lon: feature.lon,
|
||||
matched_alias: match.matchedAlias,
|
||||
primary_alias: feature.primaryAlias,
|
||||
map_url: buildMapUrl(feature),
|
||||
osm: {
|
||||
type: feature.osmType,
|
||||
id: feature.osmId,
|
||||
tags: feature.tags
|
||||
},
|
||||
source: match.source,
|
||||
distance_m: match.distanceMeters ?? null
|
||||
}
|
||||
}
|
||||
@@ -1,342 +1,18 @@
|
||||
import { defineEventHandler, getQuery } from 'h3'
|
||||
import https from 'node:https'
|
||||
|
||||
import { fetchAirportFeatures, resolveFeature, toGeocodePayload } from './airportGeocode'
|
||||
import { forwardToRadioBackend } from '../../../utils/radioBackend'
|
||||
|
||||
// TODO @najajan-de: für die taxiroute soll gelten:
|
||||
// wenn es um wegbeschreibungen mit dem name eines runways (also er muss erst über ein query rausfinden dass der source oder dest name vom typ eines runways ist) soll man angeben können ob man den node vom anfang oder ende des runways nutzen möchte weil aktuell nutz osm die kurve des runway das ja aus viele nodes besteht und nimmt davon die mitte was ja quatsch ist. also wenn man ein runway name angibt dann soll man sagen können ob man von diesen nodes aus denen der runway path besteht start (default) oder ende nutzen will). das gitl nur wenn er erkannt hat dass das was man da per name referenziert hat ein runway ist
|
||||
|
||||
function parseCoordinate(value: unknown) {
|
||||
if (value === undefined || value === null) return null
|
||||
if (typeof value === 'string' && value.trim() === '') return null
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const q = getQuery(event)
|
||||
|
||||
const airport = typeof q.airport === 'string' ? q.airport.trim().toUpperCase() : ''
|
||||
|
||||
const originName = typeof q.origin_name === 'string' ? q.origin_name.trim() : ''
|
||||
const destName = typeof q.dest_name === 'string' ? q.dest_name.trim() : ''
|
||||
|
||||
let oLat: number | null = parseCoordinate(q.origin_lat)
|
||||
let oLon: number | null = parseCoordinate(q.origin_lng ?? q.origin_lon)
|
||||
let dLat: number | null = parseCoordinate(q.dest_lat)
|
||||
let dLon: number | null = parseCoordinate(q.dest_lng ?? q.dest_lon)
|
||||
|
||||
const radius = parseCoordinate(q.radius) ?? 5000
|
||||
|
||||
const originCoordsProvided = oLat !== null && oLon !== null
|
||||
const destCoordsProvided = dLat !== null && dLon !== null
|
||||
|
||||
if (!originCoordsProvided && !originName) {
|
||||
return { error: 'missing_origin', details: 'provide origin coordinates or name', airport: airport || null }
|
||||
}
|
||||
|
||||
if (!destCoordsProvided && !destName) {
|
||||
return { error: 'missing_destination', details: 'provide destination coordinates or name', airport: airport || null }
|
||||
}
|
||||
|
||||
const requiresGeocode = (!originCoordsProvided && !!originName) || (!destCoordsProvided && !!destName)
|
||||
|
||||
let features: Awaited<ReturnType<typeof fetchAirportFeatures>> | null = null
|
||||
|
||||
const ensureFeatures = async () => {
|
||||
if (!features) {
|
||||
if (!airport) {
|
||||
throw Object.assign(new Error('missing airport for geocode'), { code: 'missing_airport' })
|
||||
}
|
||||
features = await fetchAirportFeatures(airport)
|
||||
}
|
||||
return features
|
||||
}
|
||||
|
||||
let originMatch: ReturnType<typeof resolveFeature> = null
|
||||
let destMatch: ReturnType<typeof resolveFeature> = null
|
||||
|
||||
try {
|
||||
if (requiresGeocode) {
|
||||
await ensureFeatures()
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error & { code?: string }
|
||||
if (err.code === 'missing_airport') {
|
||||
return { error: 'missing_airport', details: 'airport is required when using feature names' }
|
||||
}
|
||||
return { error: 'overpass_error', airport, details: err.message }
|
||||
}
|
||||
|
||||
if (!originCoordsProvided && originName) {
|
||||
const match = resolveFeature(features!, { name: originName })
|
||||
if (!match) {
|
||||
return { error: 'origin_not_found', airport, origin: { name: originName } }
|
||||
}
|
||||
originMatch = match
|
||||
oLat = match.feature.lat
|
||||
oLon = match.feature.lon
|
||||
}
|
||||
|
||||
if (!destCoordsProvided && destName) {
|
||||
const match = resolveFeature(features!, { name: destName })
|
||||
if (!match) {
|
||||
return { error: 'dest_not_found', airport, dest: { name: destName } }
|
||||
}
|
||||
destMatch = match
|
||||
dLat = match.feature.lat
|
||||
dLon = match.feature.lon
|
||||
}
|
||||
|
||||
if (originCoordsProvided && originName && airport) {
|
||||
try {
|
||||
const match = resolveFeature(features ?? (await ensureFeatures()), { name: originName })
|
||||
if (match) originMatch = match
|
||||
} catch {
|
||||
// ignore lookup failures when coordinates are already provided
|
||||
}
|
||||
}
|
||||
|
||||
if (destCoordsProvided && destName && airport) {
|
||||
try {
|
||||
const match = resolveFeature(features ?? (await ensureFeatures()), { name: destName })
|
||||
if (match) destMatch = match
|
||||
} catch {
|
||||
// ignore lookup failures when coordinates are already provided
|
||||
}
|
||||
}
|
||||
|
||||
if (oLat === null || oLon === null || dLat === null || dLon === null) {
|
||||
return {
|
||||
error: 'missing_coordinates',
|
||||
airport: airport || null,
|
||||
origin: { lat: oLat, lon: oLon, name: originName || null },
|
||||
dest: { lat: dLat, lon: dLon, name: destName || null }
|
||||
}
|
||||
}
|
||||
|
||||
const oLatNum = oLat as number
|
||||
const oLonNum = oLon as number
|
||||
const dLatNum = dLat as number
|
||||
const dLonNum = dLon as number
|
||||
|
||||
const originQuerySummary = {
|
||||
name: originName || null,
|
||||
lat: originCoordsProvided ? oLatNum : null,
|
||||
lon: originCoordsProvided ? oLonNum : null
|
||||
}
|
||||
|
||||
const destQuerySummary = {
|
||||
name: destName || null,
|
||||
lat: destCoordsProvided ? dLatNum : null,
|
||||
lon: destCoordsProvided ? dLonNum : null
|
||||
}
|
||||
|
||||
const originFeaturePayload = originMatch ? toGeocodePayload(originMatch) : null
|
||||
const destFeaturePayload = destMatch ? toGeocodePayload(destMatch) : null
|
||||
|
||||
const endpoint = 'https://overpass-api.de/api/interpreter'
|
||||
const overpassQ = `
|
||||
[out:json][timeout:90];
|
||||
(
|
||||
way["aeroway"="taxiway"](around:${radius},${oLatNum},${oLonNum});
|
||||
way["aeroway"="taxiway"](around:${radius},${dLatNum},${dLonNum});
|
||||
);
|
||||
(._;>;);
|
||||
out body;
|
||||
`
|
||||
|
||||
// --- utils ---
|
||||
const toRad = (d:number)=>d*Math.PI/180
|
||||
function haversine(a:{lat:number,lon:number}, b:{lat:number,lon:number}){
|
||||
const R=6371000
|
||||
const dLat=toRad(b.lat-a.lat), dLon=toRad(b.lon-a.lon)
|
||||
const la1=toRad(a.lat), la2=toRad(b.lat)
|
||||
const s=Math.sin(dLat/2)**2 + Math.cos(la1)*Math.cos(la2)*Math.sin(dLon/2)**2
|
||||
return 2*R*Math.asin(Math.sqrt(s))
|
||||
}
|
||||
function fetchOverpass(query: string) {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const req = https.request(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
}, res => {
|
||||
let data = ''
|
||||
res.on('data', d => (data += d))
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0,200)}`))
|
||||
resolve(JSON.parse(data))
|
||||
})
|
||||
})
|
||||
req.on('error', reject)
|
||||
req.write('data=' + encodeURIComponent(query))
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
// --- build graph from OSM ---
|
||||
const osm = await fetchOverpass(overpassQ)
|
||||
|
||||
const nodes = new Map<number, {id:number,lat:number,lon:number}>()
|
||||
const ways: Array<any> = []
|
||||
for (const el of osm.elements) {
|
||||
if (el.type === 'node') nodes.set(el.id, { id: el.id, lat: el.lat, lon: el.lon })
|
||||
else if (el.type === 'way') ways.push(el)
|
||||
}
|
||||
|
||||
type Edge = { u:number, v:number, w:number, way_id:number, name:string|null }
|
||||
const edges: Edge[] = []
|
||||
for (const w of ways) {
|
||||
const name: string | null = w.tags?.name || w.tags?.ref || null
|
||||
for (let i=0;i<w.nodes.length-1;i++){
|
||||
const a = nodes.get(w.nodes[i])
|
||||
const b = nodes.get(w.nodes[i+1])
|
||||
if (!a || !b) continue
|
||||
const dist = haversine(a,b)
|
||||
edges.push({ u:a.id, v:b.id, w:dist, way_id:w.id, name })
|
||||
edges.push({ u:b.id, v:a.id, w:dist, way_id:w.id, name })
|
||||
}
|
||||
}
|
||||
|
||||
// --- adjacency + quick edge lookup ---
|
||||
const adj = new Map<number, Array<{to:number, w:number}>>()
|
||||
const edgeName = new Map<string, {name:string|null, way_id:number}>()
|
||||
for (const e of edges){
|
||||
if (!adj.has(e.u)) adj.set(e.u, [])
|
||||
adj.get(e.u)!.push({ to:e.v, w:e.w })
|
||||
edgeName.set(`${e.u}->${e.v}`, { name:e.name, way_id:e.way_id })
|
||||
}
|
||||
|
||||
// --- nearest node to a coordinate ---
|
||||
function nearestNode(lat:number, lon:number){
|
||||
let bestId:number|undefined, bestD=Infinity
|
||||
for (const n of nodes.values()){
|
||||
const d = haversine({lat,lon},{lat:n.lat,lon:n.lon})
|
||||
if (d<bestD){ bestD=d; bestId=n.id }
|
||||
}
|
||||
if (bestId===undefined) return null
|
||||
const nn = nodes.get(bestId)!
|
||||
return { node_id: bestId, lat: nn.lat, lon: nn.lon, distance_m: bestD }
|
||||
}
|
||||
|
||||
const startAttach = nearestNode(oLatNum,oLonNum)
|
||||
const endAttach = nearestNode(dLatNum,dLonNum)
|
||||
|
||||
if (!startAttach || !endAttach) {
|
||||
return {
|
||||
error: 'no_nodes_in_area',
|
||||
airport: airport || null,
|
||||
origin: { lat:oLatNum, lon:oLonNum, query: originQuerySummary, feature: originFeaturePayload },
|
||||
dest: { lat:dLatNum, lon:dLonNum, query: destQuerySummary, feature: destFeaturePayload }
|
||||
}
|
||||
}
|
||||
|
||||
// --- dijkstra shortest path (meters) ---
|
||||
function dijkstra(src:number, dst:number){
|
||||
const dist = new Map<number, number>()
|
||||
const prev = new Map<number, number>()
|
||||
const visited = new Set<number>()
|
||||
const pq: Array<{id:number, d:number}> = []
|
||||
|
||||
const push = (id:number, d:number)=>{
|
||||
pq.push({id,d})
|
||||
// simple binary heap-free: insertion sort-ish
|
||||
pq.sort((a,b)=>a.d-b.d)
|
||||
}
|
||||
|
||||
for (const id of nodes.keys()) dist.set(id, Infinity)
|
||||
dist.set(src, 0); push(src,0)
|
||||
|
||||
while (pq.length){
|
||||
const {id:u} = pq.shift()!
|
||||
if (visited.has(u)) continue
|
||||
visited.add(u)
|
||||
if (u===dst) break
|
||||
const lst = adj.get(u); if (!lst) continue
|
||||
for (const {to:v, w} of lst){
|
||||
const alt = dist.get(u)! + w
|
||||
if (alt < dist.get(v)!){
|
||||
dist.set(v, alt)
|
||||
prev.set(v, u)
|
||||
push(v, alt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!prev.has(dst) && src!==dst) return null
|
||||
const path:number[] = []
|
||||
let u = dst
|
||||
path.push(u)
|
||||
while (u !== src){
|
||||
const p = prev.get(u)
|
||||
if (p===undefined){ break }
|
||||
u = p
|
||||
path.push(u)
|
||||
}
|
||||
path.reverse()
|
||||
const total_m = dist.get(dst)!
|
||||
return { path, total_m }
|
||||
}
|
||||
|
||||
const sp = dijkstra(startAttach.node_id, endAttach.node_id)
|
||||
if (!sp) {
|
||||
return {
|
||||
airport: airport || null,
|
||||
origin: { lat:oLatNum, lon:oLonNum, query: originQuerySummary, feature: originFeaturePayload },
|
||||
dest: { lat:dLatNum, lon:dLonNum, query: destQuerySummary, feature: destFeaturePayload },
|
||||
start_attach: startAttach,
|
||||
end_attach: endAttach,
|
||||
route: null,
|
||||
names: [],
|
||||
names_collapsed: []
|
||||
}
|
||||
}
|
||||
|
||||
// names along path (drop null/unnamed); keep both raw sequence and collapsed variant
|
||||
const namesRaw: string[] = []
|
||||
const hasDigits = (value: string) => /\d/.test(value)
|
||||
for (let i=0;i<sp.path.length-1;i++){
|
||||
const u = sp.path[i], v = sp.path[i+1]
|
||||
const meta = edgeName.get(`${u}->${v}`) || edgeName.get(`${v}->${u}`)
|
||||
const nm = meta?.name?.trim()
|
||||
if (!nm) continue
|
||||
|
||||
if (namesRaw.length === 0 || namesRaw[namesRaw.length-1] !== nm) namesRaw.push(nm)
|
||||
}
|
||||
|
||||
const namesCollapsed: string[] = []
|
||||
for (const nm of namesRaw) {
|
||||
const last = namesCollapsed[namesCollapsed.length - 1]
|
||||
if (last === nm) continue
|
||||
|
||||
const lastNormalized = last?.replace(/\s+/g, '').match(/^([A-Za-z]+)/)?.[1]?.toUpperCase()
|
||||
const currentNormalized = nm.replace(/\s+/g, '').match(/^([A-Za-z]+)/)?.[1]?.toUpperCase()
|
||||
const lastHasDigits = last ? hasDigits(last) : false
|
||||
|
||||
if (
|
||||
lastNormalized &&
|
||||
currentNormalized &&
|
||||
lastNormalized === currentNormalized &&
|
||||
lastHasDigits &&
|
||||
hasDigits(nm)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
namesCollapsed.push(nm)
|
||||
}
|
||||
|
||||
return {
|
||||
airport: airport || null,
|
||||
origin: { lat:oLatNum, lon:oLonNum, query: originQuerySummary, feature: originFeaturePayload },
|
||||
dest: { lat:dLatNum, lon:dLonNum, query: destQuerySummary, feature: destFeaturePayload },
|
||||
start_attach: startAttach,
|
||||
end_attach: endAttach,
|
||||
route: {
|
||||
node_ids: sp.path,
|
||||
total_distance_m: sp.total_m
|
||||
},
|
||||
names: namesRaw,
|
||||
names_collapsed: namesCollapsed
|
||||
}
|
||||
})
|
||||
/**
|
||||
* Compute a taxi route between two points or named aerodrome features.
|
||||
*
|
||||
* The routing itself lives in the Python backend, which owns the Overpass
|
||||
* client and its cache, the airport dataset, and the runway-threshold logic.
|
||||
* This route used to be a second implementation of all of it and drifted:
|
||||
* it asked Overpass for `out center tags`, so a runway resolved to the centre
|
||||
* of the strip instead of a threshold — the runway-endpoint bug the backend
|
||||
* fixed with `origin_runway_point` / `dest_runway_point` (start|end|center,
|
||||
* default start), which forwarding now exposes here too.
|
||||
*/
|
||||
export default defineEventHandler(async (event) =>
|
||||
forwardToRadioBackend('/api/service/tools/taxiroute', getQuery(event))
|
||||
)
|
||||
|
||||
59
server/utils/radioBackend.ts
Normal file
59
server/utils/radioBackend.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
|
||||
/**
|
||||
* Forwarding for the public `/api/service/tools/*` endpoints.
|
||||
*
|
||||
* The OSM airport geocoder and the taxi router live in the Python backend,
|
||||
* which owns the Overpass client and its cache, the airport dataset, and the
|
||||
* runway-threshold logic. Nuxt used to carry a second copy of all of it; the
|
||||
* copy drifted and started routing to runway centres. These helpers keep the
|
||||
* documented public paths while leaving exactly one implementation.
|
||||
*/
|
||||
|
||||
/** Build the upstream URL, forwarding every supplied query parameter. */
|
||||
export function backendToolUrl(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
query: Record<string, unknown>
|
||||
): string {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
for (const [key, raw] of Object.entries(query)) {
|
||||
// h3 yields an array when a parameter is repeated; the backend takes one.
|
||||
const value = Array.isArray(raw) ? raw[0] : raw
|
||||
if (value === undefined || value === null) continue
|
||||
const text = String(value).trim()
|
||||
if (!text) continue
|
||||
params.set(key, text)
|
||||
}
|
||||
|
||||
const base = baseUrl.replace(/\/+$/, '')
|
||||
const search = params.toString()
|
||||
return search ? `${base}${path}?${search}` : `${base}${path}`
|
||||
}
|
||||
|
||||
export function radioBackendBaseUrl(): string {
|
||||
const config = useRuntimeConfig() as any
|
||||
return config?.public?.radioBackendUrl || 'http://127.0.0.1:8000'
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward a tools request and hand back what the backend answered.
|
||||
*
|
||||
* The backend reports failures with real status codes and keeps the old error
|
||||
* code in the body (`{"error": "origin_not_found", …}`). Both are passed
|
||||
* through: rewriting a 404 back into the legacy HTTP 200 would hide a failed
|
||||
* lookup from every caller that checks the status.
|
||||
*/
|
||||
export async function forwardToRadioBackend(
|
||||
path: string,
|
||||
query: Record<string, unknown>
|
||||
): Promise<Record<string, any>> {
|
||||
const url = backendToolUrl(radioBackendBaseUrl(), path, query)
|
||||
const fetcher = (globalThis as any).$fetch as (
|
||||
target: string,
|
||||
options?: Record<string, unknown>
|
||||
) => Promise<Record<string, any>>
|
||||
|
||||
return await fetcher(url, { method: 'GET' })
|
||||
}
|
||||
184
tests/server/radioBackendTools.test.ts
Normal file
184
tests/server/radioBackendTools.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* `/api/service/tools/*` forwards to the Python backend.
|
||||
*
|
||||
* These endpoints used to be a second, older implementation of the OSM
|
||||
* geocoding and taxi routing that the backend already owns — with its own
|
||||
* Overpass client, alias matching and scoring. The copy drifted: it still
|
||||
* asked Overpass for `out center tags`, so a runway resolved to the middle of
|
||||
* the strip rather than a threshold, which is the bug the backend fixed with
|
||||
* `origin_runway_point` / `dest_runway_point`.
|
||||
*
|
||||
* Forwarding is what keeps the two from drifting again, so what is pinned here
|
||||
* is that every parameter reaches the backend untouched — including the ones
|
||||
* the Nuxt copy never knew about — and that an upstream failure is reported as
|
||||
* a failure rather than smoothed over.
|
||||
*/
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { backendToolUrl, radioBackendBaseUrl } from '~~/server/utils/radioBackend'
|
||||
|
||||
/** An h3 event carrying just the query string the handler reads. */
|
||||
function createEvent(search: string) {
|
||||
return { path: `/api/service/tools/x?${search}`, node: { req: { url: `/x?${search}` } } } as any
|
||||
}
|
||||
|
||||
/** Capture the URL the handler asks for, and answer with `response`. */
|
||||
async function callHandler(modulePath: string, search: string, response: unknown = { ok: true }) {
|
||||
;(globalThis as any).defineEventHandler = (handler: any) => handler
|
||||
const calls: string[] = []
|
||||
const original = (globalThis as any).$fetch
|
||||
;(globalThis as any).$fetch = async (target: string) => {
|
||||
calls.push(target)
|
||||
if (response instanceof Error) throw response
|
||||
return response
|
||||
}
|
||||
try {
|
||||
const mod = await import(modulePath)
|
||||
const result = await mod.default(createEvent(search))
|
||||
return { calls, result }
|
||||
} finally {
|
||||
;(globalThis as any).$fetch = original
|
||||
}
|
||||
}
|
||||
|
||||
describe('backendToolUrl', () => {
|
||||
const build = (query: Record<string, any>, base = 'http://127.0.0.1:8000') =>
|
||||
new URL(backendToolUrl(base, '/api/service/tools/taxiroute', query))
|
||||
|
||||
it('targets the backend path', () => {
|
||||
const url = build({ airport: 'EDDF' })
|
||||
assert.equal(url.origin, 'http://127.0.0.1:8000')
|
||||
assert.equal(url.pathname, '/api/service/tools/taxiroute')
|
||||
})
|
||||
|
||||
it('forwards the documented parameters', () => {
|
||||
const url = build({
|
||||
airport: 'EDDF',
|
||||
origin_name: 'Gate A5',
|
||||
dest_name: 'RWY 25C',
|
||||
radius: '2500',
|
||||
})
|
||||
assert.equal(url.searchParams.get('airport'), 'EDDF')
|
||||
assert.equal(url.searchParams.get('origin_name'), 'Gate A5')
|
||||
assert.equal(url.searchParams.get('dest_name'), 'RWY 25C')
|
||||
assert.equal(url.searchParams.get('radius'), '2500')
|
||||
})
|
||||
|
||||
it('forwards the runway endpoint parameters', () => {
|
||||
// The whole point of forwarding: the Nuxt copy could not express these.
|
||||
const url = build({
|
||||
airport: 'EDDF',
|
||||
origin_name: 'Gate A5',
|
||||
dest_name: 'RWY 25C',
|
||||
dest_runway_point: 'end',
|
||||
origin_runway_point: 'center',
|
||||
})
|
||||
assert.equal(url.searchParams.get('dest_runway_point'), 'end')
|
||||
assert.equal(url.searchParams.get('origin_runway_point'), 'center')
|
||||
})
|
||||
|
||||
it('forwards parameters the proxy has never heard of', () => {
|
||||
// A whitelist would silently swallow anything the backend gains later.
|
||||
assert.equal(build({ include_connectors: 'true' }).searchParams.get('include_connectors'), 'true')
|
||||
})
|
||||
|
||||
it('drops empty and absent values instead of sending blanks', () => {
|
||||
const url = build({ airport: 'EDDF', origin_name: '', dest_name: undefined, radius: null })
|
||||
assert.equal(url.searchParams.has('origin_name'), false)
|
||||
assert.equal(url.searchParams.has('dest_name'), false)
|
||||
assert.equal(url.searchParams.has('radius'), false)
|
||||
})
|
||||
|
||||
it('takes the first value of a repeated parameter', () => {
|
||||
assert.equal(build({ airport: ['EDDF', 'EDDM'] }).searchParams.get('airport'), 'EDDF')
|
||||
})
|
||||
|
||||
it('accepts a base URL with a trailing slash', () => {
|
||||
const url = build({ airport: 'EDDF' }, 'https://backend.example.com/')
|
||||
assert.equal(url.origin, 'https://backend.example.com')
|
||||
assert.equal(url.pathname, '/api/service/tools/taxiroute')
|
||||
})
|
||||
|
||||
it('omits the query string entirely when there is nothing to send', () => {
|
||||
assert.equal(
|
||||
backendToolUrl('http://127.0.0.1:8000', '/api/service/tools/taxiroute', {}),
|
||||
'http://127.0.0.1:8000/api/service/tools/taxiroute'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('radioBackendBaseUrl', () => {
|
||||
it('reads the configured backend rather than assuming localhost', () => {
|
||||
// Silently falling back to 127.0.0.1 in production would take the public
|
||||
// endpoint down with a connection error and no hint why.
|
||||
const previous = process.env.NUXT_PUBLIC_RADIO_BACKEND_URL
|
||||
process.env.NUXT_PUBLIC_RADIO_BACKEND_URL = 'https://backend.example.com'
|
||||
try {
|
||||
assert.equal(radioBackendBaseUrl(), 'https://backend.example.com')
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.NUXT_PUBLIC_RADIO_BACKEND_URL
|
||||
else process.env.NUXT_PUBLIC_RADIO_BACKEND_URL = previous
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the dev backend when nothing is configured', () => {
|
||||
const previous = process.env.NUXT_PUBLIC_RADIO_BACKEND_URL
|
||||
delete process.env.NUXT_PUBLIC_RADIO_BACKEND_URL
|
||||
try {
|
||||
assert.equal(radioBackendBaseUrl(), 'http://127.0.0.1:8000')
|
||||
} finally {
|
||||
if (previous !== undefined) process.env.NUXT_PUBLIC_RADIO_BACKEND_URL = previous
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('/api/service/tools/taxiroute handler', () => {
|
||||
it('forwards the request to the backend taxiroute endpoint', async () => {
|
||||
const { calls } = await callHandler(
|
||||
'~~/server/api/service/tools/taxiroute.get',
|
||||
'airport=EDDF&origin_name=Gate+A5&dest_name=RWY+25C&dest_runway_point=end'
|
||||
)
|
||||
assert.equal(calls.length, 1)
|
||||
const url = new URL(calls[0]!)
|
||||
assert.equal(url.pathname, '/api/service/tools/taxiroute')
|
||||
assert.equal(url.searchParams.get('airport'), 'EDDF')
|
||||
assert.equal(url.searchParams.get('dest_name'), 'RWY 25C')
|
||||
assert.equal(url.searchParams.get('dest_runway_point'), 'end')
|
||||
})
|
||||
|
||||
it('returns the backend response unchanged', async () => {
|
||||
const payload = { airport: 'EDDF', names_collapsed: ['L7', 'N'] }
|
||||
const { result } = await callHandler(
|
||||
'~~/server/api/service/tools/taxiroute.get',
|
||||
'airport=EDDF',
|
||||
payload
|
||||
)
|
||||
assert.deepEqual(result, payload)
|
||||
})
|
||||
|
||||
it('reports an upstream failure as a failure', async () => {
|
||||
// The old Nuxt copy answered every error with HTTP 200 and an error body.
|
||||
// Swallowing a 404 here would hide a failed lookup from the caller.
|
||||
const upstream = Object.assign(new Error('Not Found'), { statusCode: 404 })
|
||||
await assert.rejects(
|
||||
() =>
|
||||
callHandler('~~/server/api/service/tools/taxiroute.get', 'airport=EDDF', upstream),
|
||||
(error: any) => error?.statusCode === 404
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('/api/service/tools/airport-geocode handler', () => {
|
||||
it('forwards the request to the backend airport-geocode endpoint', async () => {
|
||||
const { calls } = await callHandler(
|
||||
'~~/server/api/service/tools/airport-geocode.get',
|
||||
'airport=EDDF&origin_name=Stand+V155&origin_runway_point=end'
|
||||
)
|
||||
assert.equal(calls.length, 1)
|
||||
const url = new URL(calls[0]!)
|
||||
assert.equal(url.pathname, '/api/service/tools/airport-geocode')
|
||||
assert.equal(url.searchParams.get('origin_name'), 'Stand V155')
|
||||
assert.equal(url.searchParams.get('origin_runway_point'), 'end')
|
||||
})
|
||||
})
|
||||
@@ -15,5 +15,8 @@ export function useRuntimeConfig() {
|
||||
useSpeaches: process.env.USE_SPEACHES ?? false,
|
||||
speachesBaseUrl: process.env.SPEACHES_BASE_URL || '',
|
||||
speechModelId: process.env.SPEECH_MODEL_ID || 'speaches-ai/piper-en_US-ryan-low',
|
||||
public: {
|
||||
radioBackendUrl: process.env.NUXT_PUBLIC_RADIO_BACKEND_URL || 'http://127.0.0.1:8000',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user