mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-01 06:06:05 +08:00
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:
45
server/api/airports/[icao]/atis.get.ts
Normal file
45
server/api/airports/[icao]/atis.get.ts
Normal 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),
|
||||
})
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
170
server/utils/airportSources.ts
Normal file
170
server/utils/airportSources.ts
Normal file
@@ -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<T> {
|
||||
value: T
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry<unknown>>()
|
||||
|
||||
/**
|
||||
* 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<T>(key: string, ttlMs: number, load: () => Promise<T>): Promise<T> {
|
||||
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<any | null> {
|
||||
try {
|
||||
return await cached('vatsim', VATSIM_TTL_MS, () => $fetch<any>(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<any | null> {
|
||||
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<string | null> {
|
||||
const normalized = icao.toUpperCase()
|
||||
try {
|
||||
return await cached(`metar:${normalized}`, METAR_TTL_MS, async () => {
|
||||
const text = await $fetch<string>(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
|
||||
}
|
||||
Reference in New Issue
Block a user