feat(atis): serve the resolved ATIS from /api/airports/:icao/atis

Composes the three live sources into one AtisReport: VATSIM ATIS stations,
the METAR, and the OpenAIP runway ends the frequency endpoint was already
fetching and discarding.

The upstream fetches move into server/utils/airportSources.ts with a TTL cache
matched to each source (VATSIM 20s, METAR 5min, OpenAIP 6h). Without it, adding
this endpoint would have meant pulling the multi-megabyte VATSIM datafeed twice
per session start; the frequency endpoint now shares the same cached copy.

Verified against live data: EDDF (no VATSIM ATIS) synthesises information I with
runway 25C from wind 260/14, while EDDC and EDDM take letter and runway from
their live VATSIM broadcasts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-26 19:37:16 +02:00
parent aee42e31df
commit bcf9a9621c
3 changed files with 250 additions and 53 deletions

View File

@@ -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<AtisReport> => {
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),
})
})

View File

@@ -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<FrequencyResponse> => {
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<FrequencyResponse> => {
}
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<FrequencyResponse> => {
}
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=<ICAO>` 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) => {