diff --git a/server/api/airports/[icao]/atis.get.ts b/server/api/airports/[icao]/atis.get.ts new file mode 100644 index 0000000..459db1c --- /dev/null +++ b/server/api/airports/[icao]/atis.get.ts @@ -0,0 +1,45 @@ +import { createError, defineEventHandler, getRouterParam } from 'h3' +import { getServerRuntimeConfig } from '../../../utils/runtimeConfig' +import { + fetchMetar, + fetchOpenAipAirport, + fetchVatsimData, + openAipAtisFrequency, + parseOpenAipRunways, + vatsimAtisStations, +} from '../../../utils/airportSources' +import { resolveAtisReport, type AtisReport } from '../../../../shared/utils/atisReport' + +/** + * The airport's current ATIS, from VATSIM when a station is broadcasting and + * synthesised from the METAR when not. + * + * The response always carries an information letter, so the caller never has to + * invent one — see `shared/utils/atisReport.ts` for the source cascade. + */ +export default defineEventHandler(async (event): Promise => { + const icaoParam = getRouterParam(event, 'icao') + if (!icaoParam) { + throw createError({ statusCode: 400, statusMessage: 'icao required' }) + } + const icao = icaoParam.toUpperCase() + + const { openaipApiKey } = getServerRuntimeConfig() + const [vatsimData, airport, metar] = await Promise.all([ + fetchVatsimData(), + fetchOpenAipAirport(icao, openaipApiKey), + fetchMetar(icao), + ]) + + const airportName = typeof airport?.name === 'string' ? airport.name.trim() : undefined + const municipality = typeof airport?.municipality === 'string' ? airport.municipality.trim() : undefined + + return resolveAtisReport({ + icao, + airportName: airportName || municipality || undefined, + vatsimStations: vatsimAtisStations(vatsimData, icao), + metar, + runways: parseOpenAipRunways(airport), + atisFrequency: openAipAtisFrequency(airport), + }) +}) diff --git a/server/api/airports/[icao]/frequencies.get.ts b/server/api/airports/[icao]/frequencies.get.ts index f0b3844..a8bcc8a 100644 --- a/server/api/airports/[icao]/frequencies.get.ts +++ b/server/api/airports/[icao]/frequencies.get.ts @@ -1,5 +1,6 @@ import { createError, defineEventHandler, getRouterParam } from 'h3' import { getServerRuntimeConfig } from '../../../utils/runtimeConfig' +import { fetchOpenAipAirport, fetchVatsimData } from '../../../utils/airportSources' interface FrequencyEntry { type: string @@ -129,8 +130,10 @@ export default defineEventHandler(async (event): Promise => { let vatsimSuccess = false let openaipSuccess = false - try { - const vatsimData: any = await $fetch('https://data.vatsim.net/v3/vatsim-data.json') + // Shared with the ATIS endpoint so one request never pulls the multi-megabyte + // VATSIM datafeed twice. + const vatsimData: any = await fetchVatsimData() + if (vatsimData) { const prefix = `${icao}_` const atisEntries = Array.isArray(vatsimData?.atis) ? vatsimData.atis : [] @@ -172,8 +175,6 @@ export default defineEventHandler(async (event): Promise => { } vatsimSuccess = true - } catch (err) { - console.warn('[OpenSquawk] Failed to fetch VATSIM frequencies:', err) } // OpenAIP v2 numeric frequency type → our internal type code. @@ -190,56 +191,37 @@ export default defineEventHandler(async (event): Promise => { } const { openaipApiKey } = getServerRuntimeConfig() - if (openaipApiKey) { - try { - // Must use `search` (not `icao`) — the `icao` param does a full-text search - // across all fields and returns the entire 46k-airport dataset unpaged. - // `search=` returns exactly the matching airport. - const openaipData: any = await $fetch('https://api.core.openaip.net/api/airports', { - query: { search: icao }, - headers: { - Accept: 'application/json', - 'x-openaip-api-key': openaipApiKey - } - }) - - const items = Array.isArray(openaipData?.items) ? openaipData.items : [] - for (const airport of items) { - // Real field is `icaoCode`, not `icao` - if ((airport?.icaoCode || '').toUpperCase() !== icao) continue - // Airport-level metadata: `name` (e.g. "Frankfurt am Main"), `municipality` (city only) - if (!airportName) { - const name = typeof airport?.name === 'string' ? airport.name.trim() : '' - const muni = typeof airport?.municipality === 'string' ? airport.municipality.trim() : '' - airportName = name || muni || undefined - } - // Frequencies live under `airport.frequencies[]`; each item has: - // value (MHz string, e.g. "122.035") - // type (numeric code, e.g. 5 for Delivery) - // name (human label, e.g. "FRANKFURT DELIVERY") - const freqItems: any[] = Array.isArray(airport?.frequencies) ? airport.frequencies : [] - - for (const freqItem of freqItems) { - // Real field is `value`, not `frequency` / `frequencyMHz` - const frequency = normalizeFrequency(freqItem?.value ?? freqItem?.frequency) - if (!frequency) continue - - const numericType: number | undefined = typeof freqItem?.type === 'number' ? freqItem.type : undefined - const typeCode = numericType !== undefined ? (OPENAIP_TYPE_MAP[numericType] ?? 'UNK') : 'UNK' - const { type, label } = toTypeLabel(typeCode, freqItem?.name || freqItem?.description) - addFrequencyEntry(frequencyMap, { - type, - label, - frequency, - source: 'openaip' - }) - } - } - - openaipSuccess = true - } catch (err) { - console.warn('[OpenSquawk] Failed to fetch OpenAIP airport data:', err) + const airport = await fetchOpenAipAirport(icao, openaipApiKey) + if (airport) { + // Airport-level metadata: `name` (e.g. "Frankfurt am Main"), `municipality` (city only) + if (!airportName) { + const name = typeof airport?.name === 'string' ? airport.name.trim() : '' + const muni = typeof airport?.municipality === 'string' ? airport.municipality.trim() : '' + airportName = name || muni || undefined } + // Frequencies live under `airport.frequencies[]`; each item has: + // value (MHz string, e.g. "122.035") + // type (numeric code, e.g. 5 for Delivery) + // name (human label, e.g. "FRANKFURT DELIVERY") + const freqItems: any[] = Array.isArray(airport?.frequencies) ? airport.frequencies : [] + + for (const freqItem of freqItems) { + // Real field is `value`, not `frequency` / `frequencyMHz` + const frequency = normalizeFrequency(freqItem?.value ?? freqItem?.frequency) + if (!frequency) continue + + const numericType: number | undefined = typeof freqItem?.type === 'number' ? freqItem.type : undefined + const typeCode = numericType !== undefined ? (OPENAIP_TYPE_MAP[numericType] ?? 'UNK') : 'UNK' + const { type, label } = toTypeLabel(typeCode, freqItem?.name || freqItem?.description) + addFrequencyEntry(frequencyMap, { + type, + label, + frequency, + source: 'openaip' + }) + } + + openaipSuccess = true } const frequencies = Array.from(frequencyMap.values()).sort((a, b) => { diff --git a/server/utils/airportSources.ts b/server/utils/airportSources.ts new file mode 100644 index 0000000..16b976e --- /dev/null +++ b/server/utils/airportSources.ts @@ -0,0 +1,170 @@ +/** + * Shared upstream fetches for airport data (VATSIM, OpenAIP, METAR). + * + * The VATSIM datafeed is a single multi-megabyte document covering the whole + * network, so the frequency and ATIS endpoints must not each pull their own + * copy per request. Everything here is cached in memory for a interval matched + * to how fast the source actually changes. + */ + +import type { RunwayEnd } from '../../shared/utils/atisReport' + +const VATSIM_DATA_URL = 'https://data.vatsim.net/v3/vatsim-data.json' +const OPENAIP_AIRPORTS_URL = 'https://api.core.openaip.net/api/airports' +const METAR_URL = 'https://metar.vatsim.net/metar.php' + +// The datafeed regenerates every ~15 s; airport reference data is effectively +// static; METARs publish twice an hour. +const VATSIM_TTL_MS = 20 * 1000 +const OPENAIP_TTL_MS = 6 * 60 * 60 * 1000 +const METAR_TTL_MS = 5 * 60 * 1000 + +interface CacheEntry { + value: T + expiresAt: number +} + +const cache = new Map>() + +/** + * Run `load` at most once per TTL per key. A failed load is not cached, so a + * transient upstream error does not blank the data for the whole interval. + */ +async function cached(key: string, ttlMs: number, load: () => Promise): Promise { + const hit = cache.get(key) + if (hit && hit.expiresAt > Date.now()) { + return hit.value as T + } + const value = await load() + cache.set(key, { value, expiresAt: Date.now() + ttlMs }) + return value +} + +/** Drop every cached upstream response. Tests only. */ +export function clearAirportSourceCache(): void { + cache.clear() +} + +export interface VatsimAtisStation { + callsign: string + frequency?: string + atisCode?: string + text?: string + lastUpdated?: string +} + +/** The whole VATSIM datafeed, or null when it could not be fetched. */ +export async function fetchVatsimData(): Promise { + try { + return await cached('vatsim', VATSIM_TTL_MS, () => $fetch(VATSIM_DATA_URL)) + } catch (err) { + console.warn('[OpenSquawk] Failed to fetch VATSIM datafeed:', err) + return null + } +} + +/** The raw OpenAIP airport record, or null when unavailable. */ +export async function fetchOpenAipAirport(icao: string, apiKey: string | undefined): Promise { + if (!apiKey) return null + const normalized = icao.toUpperCase() + try { + return await cached(`openaip:${normalized}`, OPENAIP_TTL_MS, async () => { + // Must use `search` (not `icao`) — the `icao` param does a full-text search + // across all fields and returns the entire 46k-airport dataset unpaged. + const data: any = await $fetch(OPENAIP_AIRPORTS_URL, { + query: { search: normalized }, + headers: { Accept: 'application/json', 'x-openaip-api-key': apiKey }, + }) + const items = Array.isArray(data?.items) ? data.items : [] + // Real field is `icaoCode`, not `icao`. + return items.find((item: any) => (item?.icaoCode || '').toUpperCase() === normalized) ?? null + }) + } catch (err) { + console.warn('[OpenSquawk] Failed to fetch OpenAIP airport data:', err) + return null + } +} + +/** The current METAR for an airport, or null when unavailable. */ +export async function fetchMetar(icao: string): Promise { + const normalized = icao.toUpperCase() + try { + return await cached(`metar:${normalized}`, METAR_TTL_MS, async () => { + const text = await $fetch(METAR_URL, { + query: { id: normalized }, + responseType: 'text', + }) + const trimmed = String(text || '').trim() + // The service answers with an empty body for an unknown station. + return trimmed && trimmed.toUpperCase().startsWith(normalized) ? trimmed : null + }) + } catch (err) { + console.warn('[OpenSquawk] Failed to fetch METAR:', err) + return null + } +} + +/** ATIS stations the datafeed lists for one airport. */ +export function vatsimAtisStations(data: any, icao: string): VatsimAtisStation[] { + const prefix = `${icao.toUpperCase()}_` + const entries = Array.isArray(data?.atis) ? data.atis : [] + return entries + .filter((atis: any) => String(atis?.callsign || '').toUpperCase().startsWith(prefix)) + .map((atis: any) => ({ + callsign: String(atis.callsign), + frequency: normalizeFrequency(atis?.frequency) ?? undefined, + atisCode: atis?.atis_code || atis?.code || undefined, + text: joinAtisText(atis?.text_atis ?? atis?.atis_text), + lastUpdated: atis?.last_updated || atis?.logon_time || undefined, + })) +} + +/** Runway ends from an OpenAIP airport record, in the shape the resolver wants. */ +export function parseOpenAipRunways(airport: any): RunwayEnd[] { + const runways = Array.isArray(airport?.runways) ? airport.runways : [] + return runways + .filter((runway: any) => typeof runway?.designator === 'string' && runway.designator.trim()) + .map((runway: any) => ({ + designator: String(runway.designator).trim().toUpperCase(), + trueHeading: typeof runway?.trueHeading === 'number' ? runway.trueHeading : null, + mainRunway: Boolean(runway?.mainRunway), + takeOffOnly: Boolean(runway?.takeOffOnly), + landingOnly: Boolean(runway?.landingOnly), + lengthM: typeof runway?.dimension?.length?.value === 'number' + ? runway.dimension.length.value + : null, + })) +} + +/** Published ATIS frequency from an OpenAIP airport record (numeric type 15). */ +export function openAipAtisFrequency(airport: any): string | undefined { + const frequencies = Array.isArray(airport?.frequencies) ? airport.frequencies : [] + for (const item of frequencies) { + if (item?.type !== 15) continue + const value = normalizeFrequency(item?.value ?? item?.frequency) + if (value) return value + } + return undefined +} + +/** VATSIM sends `text_atis` as an array of broadcast lines. */ +function joinAtisText(value: unknown): string | undefined { + if (!value) return undefined + if (Array.isArray(value)) { + const joined = value.map(line => String(line).trim()).filter(Boolean).join(' ') + return joined || undefined + } + if (typeof value === 'string') return value.trim() || undefined + return undefined +} + +function normalizeFrequency(value: unknown): string | null { + if (typeof value === 'number' && Number.isFinite(value)) return value.toFixed(3) + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return null + const numeric = Number.parseFloat(trimmed) + return Number.isNaN(numeric) ? trimmed : numeric.toFixed(3) + } + return null +}