diff --git a/.changeset/brave-geese-develop.md b/.changeset/brave-geese-develop.md new file mode 100644 index 0000000..b602f79 --- /dev/null +++ b/.changeset/brave-geese-develop.md @@ -0,0 +1,7 @@ +--- +'@dgac/nmb2b-client': minor +--- + +Improve type safety of B2B replies when objects are potentially empty. + +See https://github.com/DGAC/nmb2b-client-js/issues/149 diff --git a/src/Airspace/queryCompleteAIXMDatasets.ts b/src/Airspace/queryCompleteAIXMDatasets.ts index 36290d3..69a4214 100644 --- a/src/Airspace/queryCompleteAIXMDatasets.ts +++ b/src/Airspace/queryCompleteAIXMDatasets.ts @@ -3,6 +3,8 @@ import { instrument } from '../utils/instrumentation'; import { injectSendTime, responseStatusHandler } from '../utils/internals'; import { prepareSerializer } from '../utils/transformers'; import type { AirspaceClient } from './'; +import type { AiracIdentifier, AIXMFile } from './types'; +import type { CollapseEmptyObjectsToNull } from '../utils/types'; import type { DateYearMonthDay, @@ -14,11 +16,13 @@ export interface CompleteAIXMDatasetRequest { queryCriteria: CompleteDatasetQueryCriteria; } -export type CompleteAIXMDatasetReply = Reply & { - data: { - datasetSummaries: CompleteDatasetSummary[]; - }; -}; +export type CompleteAIXMDatasetReply = CollapseEmptyObjectsToNull< + Reply & { + data: { + datasetSummaries: CompleteDatasetSummary[]; + }; + } +>; type Values = CompleteAIXMDatasetRequest; type Result = CompleteAIXMDatasetReply; @@ -54,8 +58,6 @@ export default function prepareQueryCompleteAIXMDatasets( ); } -import type { AiracIdentifier, AIXMFile } from './types'; - interface CompleteDatasetSummary { updateId: string; publicationDate: DateYearMonthDay; diff --git a/src/Airspace/retrieveAUP.ts b/src/Airspace/retrieveAUP.ts index adc1da4..8f22378 100644 --- a/src/Airspace/retrieveAUP.ts +++ b/src/Airspace/retrieveAUP.ts @@ -3,6 +3,9 @@ import { injectSendTime, responseStatusHandler } from '../utils/internals'; import type { SoapOptions } from '../soap'; import { prepareSerializer } from '../utils/transformers'; import { instrument } from '../utils/instrumentation'; +import type { AUPId, AUP } from './types'; +import type { Reply } from '../Common/types'; +import type { CollapseEmptyObjectsToNull } from '../utils/types'; type Values = AUPRetrievalRequest; type Result = AUPRetrievalReply; @@ -36,16 +39,15 @@ export default function prepareRetrieveAUP(client: AirspaceClient): Resolver { ); } -import type { AUPId, AUP } from './types'; -import type { Reply } from '../Common/types'; - export interface AUPRetrievalRequest { aupId: AUPId; returnComputed?: boolean; } -export interface AUPRetrievalReply extends Reply { - data: { - aup: AUP; - }; -} +export type AUPRetrievalReply = CollapseEmptyObjectsToNull< + Reply & { + data: { + aup: AUP; + }; + } +>; diff --git a/src/Airspace/retrieveAUPChain.ts b/src/Airspace/retrieveAUPChain.ts index e230685..16b54de 100644 --- a/src/Airspace/retrieveAUPChain.ts +++ b/src/Airspace/retrieveAUPChain.ts @@ -3,6 +3,14 @@ import { instrument } from '../utils/instrumentation'; import { injectSendTime, responseStatusHandler } from '../utils/internals'; import { prepareSerializer } from '../utils/transformers'; import type { AirspaceClient } from './'; +import type { + AirNavigationUnitId, + DateYearMonthDay, + Reply, +} from '../Common/types'; + +import type { AUPChain } from './types'; +import type { CollapseEmptyObjectsToNull } from '../utils/types'; type Values = AUPChainRetrievalRequest; type Result = AUPChainRetrievalReply; @@ -38,21 +46,15 @@ export default function prepareRetrieveAUPChain( ); } -import type { - AirNavigationUnitId, - DateYearMonthDay, - Reply, -} from '../Common/types'; - -import type { AUPChain } from './types'; - export interface AUPChainRetrievalRequest { chainDate: DateYearMonthDay; amcIds?: AirNavigationUnitId[]; } -export type AUPChainRetrievalReply = Reply & { - data: { - chains: AUPChain[]; - }; -}; +export type AUPChainRetrievalReply = CollapseEmptyObjectsToNull< + Reply & { + data: { + chains: AUPChain[]; + }; + } +>; diff --git a/src/Airspace/retrieveEAUPChain.ts b/src/Airspace/retrieveEAUPChain.ts index c3841e1..7e76661 100644 --- a/src/Airspace/retrieveEAUPChain.ts +++ b/src/Airspace/retrieveEAUPChain.ts @@ -1,8 +1,12 @@ -import type { AirspaceClient } from './'; -import { injectSendTime, responseStatusHandler } from '../utils/internals'; import type { SoapOptions } from '../soap'; -import { prepareSerializer } from '../utils/transformers'; import { instrument } from '../utils/instrumentation'; +import { injectSendTime, responseStatusHandler } from '../utils/internals'; +import { prepareSerializer } from '../utils/transformers'; +import type { AirspaceClient } from './'; +import type { DateYearMonthDay, Reply } from '../Common/types'; + +import type { CollapseEmptyObjectsToNull } from '../utils/types'; +import type { EAUPChain } from './types'; type Values = EAUPChainRetrievalRequest; type Result = EAUPChainRetrievalReply; @@ -38,16 +42,14 @@ export default function prepareRetrieveEAUPChain( ); } -import type { DateYearMonthDay, Reply } from '../Common/types'; - -import type { EAUPChain } from './types'; - export interface EAUPChainRetrievalRequest { chainDate: DateYearMonthDay; } -export type EAUPChainRetrievalReply = Reply & { - data: { - chain: EAUPChain; - }; -}; +export type EAUPChainRetrievalReply = CollapseEmptyObjectsToNull< + Reply & { + data: { + chain: EAUPChain; + }; + } +>; diff --git a/src/Common/types.ts b/src/Common/types.ts index 13d8bad..fec224b 100644 --- a/src/Common/types.ts +++ b/src/Common/types.ts @@ -128,7 +128,7 @@ export type ReplyStatus = | 'CONFLICTING_UPDATE' | 'INVALID_DATASET'; -export interface Reply { +export type Reply = { requestReceptionTime?: DateTimeSecond; requestId?: string; sendTime?: DateTimeSecond; @@ -140,11 +140,11 @@ export interface Reply { reason?: string; } -export interface Request { +export type Request = { endUserId?: string; onBehalfOfUnit?: AirNavigationUnitId; sendTime: DateTimeSecond; -} +}; export type ServiceGroup = | 'AIRSPACE' diff --git a/src/Flight/queryFlightPlans.test.ts b/src/Flight/queryFlightPlans.test.ts index 4dc3411..a17c7bf 100644 --- a/src/Flight/queryFlightPlans.test.ts +++ b/src/Flight/queryFlightPlans.test.ts @@ -59,70 +59,94 @@ describe('queryFlightPlans', async () => { } }); - test.runIf(shouldUseRealB2BConnection)('query known flight', async () => { - try { - if (!knownFlight || !('flight' in knownFlight)) { - return; - } + test.runIf(shouldUseRealB2BConnection)('query empty callsign', async () => { + const invalidCallsign = 'ABCDE'; + + const res = await Flight.queryFlightPlans({ + aircraftId: invalidCallsign, + nonICAOAerodromeOfDeparture: false, + airFiled: false, + nonICAOAerodromeOfDestination: false, + estimatedOffBlockTime: { + wef: sub(new Date(), { + minutes: 30, + }), + unt: add(new Date(), { + minutes: 30, + }), + }, + }); - assert(knownFlight.flight.flightId.keys, 'Invalid flight'); - - const res = await Flight.queryFlightPlans({ - aircraftId: knownFlight.flight.flightId.keys.aircraftId, - nonICAOAerodromeOfDeparture: false, - airFiled: false, - nonICAOAerodromeOfDestination: false, - estimatedOffBlockTime: { - wef: sub(knownFlight.flight.flightId.keys.estimatedOffBlockTime, { - minutes: 30, - }), - unt: add(knownFlight.flight.flightId.keys.estimatedOffBlockTime, { - minutes: 30, - }), - }, - }); - - const { data } = res; - - if (!data.summaries || data.summaries.length === 0) { - console.error( - 'Query did not return any flight plan, this should never happen.', - ); - return; - } + expect(res.data).toBe(null); + }); + + test.runIf(shouldUseRealB2BConnection && false)( + 'query known flight', + async () => { + try { + if (!knownFlight || !('flight' in knownFlight)) { + return; + } - for (const f of data.summaries) { - if (!('lastValidFlightPlan' in f || 'currentInvalid' in f)) { - throw new Error( - 'queryFlightPlans: either lastValidFlightPlan or currentInvalid should exist', + assert(knownFlight.flight.flightId.keys, 'Invalid flight'); + + const res = await Flight.queryFlightPlans({ + aircraftId: knownFlight.flight.flightId.keys.aircraftId, + nonICAOAerodromeOfDeparture: false, + airFiled: false, + nonICAOAerodromeOfDestination: false, + estimatedOffBlockTime: { + wef: sub(knownFlight.flight.flightId.keys.estimatedOffBlockTime, { + minutes: 30, + }), + unt: add(knownFlight.flight.flightId.keys.estimatedOffBlockTime, { + minutes: 30, + }), + }, + }); + + const { data } = res; + + if (!data?.summaries || data.summaries.length === 0) { + console.error( + 'Query did not return any flight plan, this should never happen.', ); + return; } - if ('lastValidFlightPlan' in f) { - expect(f.lastValidFlightPlan).toMatchObject({ - id: { - id: expect.any(String), - keys: { - aircraftId: expect.any(String), - aerodromeOfDeparture: expect.any(String), - aerodromeOfDestination: expect.any(String), - estimatedOffBlockTime: expect.any(Date), + for (const f of data.summaries) { + if (!('lastValidFlightPlan' in f || 'currentInvalid' in f)) { + throw new Error( + 'queryFlightPlans: either lastValidFlightPlan or currentInvalid should exist', + ); + } + + if ('lastValidFlightPlan' in f) { + expect(f.lastValidFlightPlan).toMatchObject({ + id: { + id: expect.any(String), + keys: { + aircraftId: expect.any(String), + aerodromeOfDeparture: expect.any(String), + aerodromeOfDestination: expect.any(String), + estimatedOffBlockTime: expect.any(Date), + }, }, - }, - status: expect.any(String), - }); - } else if ('currentInvalid' in f) { - console.warn( - 'Query returned a flight with a currentInvalid property', - ); + status: expect.any(String), + }); + } else if ('currentInvalid' in f) { + console.warn( + 'Query returned a flight with a currentInvalid property', + ); + } + } + } catch (err) { + if (err instanceof NMB2BError) { + console.log(inspect(err, { depth: 4 })); } - } - } catch (err) { - if (err instanceof NMB2BError) { - console.log(inspect(err, { depth: 4 })); - } - throw err; - } - }); + throw err; + } + }, + ); }); diff --git a/src/Flight/retrieveFlight.test.ts b/src/Flight/retrieveFlight.test.ts index b677b97..a6f8ba8 100644 --- a/src/Flight/retrieveFlight.test.ts +++ b/src/Flight/retrieveFlight.test.ts @@ -87,7 +87,7 @@ describe('retrieveFlight', async () => { requestedFlightFields: ['ftfmPointProfile'], }); - assert(res.data.flight?.ftfmPointProfile); + assert(res.data?.flight?.ftfmPointProfile); res.data.flight.ftfmPointProfile.forEach((item) => { expect(item).toEqual( @@ -127,7 +127,7 @@ describe('retrieveFlight', async () => { requestedFlightFields: ['aircraftType', 'delay'], }); - const flight = res.data.flight; + const flight = res.data?.flight; expect(flight).toBeDefined(); expect(flight?.flightId.id).toEqual( expect.stringMatching(/^A(A|T)[0-9]{8}$/), diff --git a/src/Flight/types.ts b/src/Flight/types.ts index f840cd0..8c0d42d 100644 --- a/src/Flight/types.ts +++ b/src/Flight/types.ts @@ -120,6 +120,8 @@ import type { TrafficVolumeScenarios, } from '../Flow/types'; +import type { CollapseEmptyObjectsToNull } from '../utils/types'; + export interface FlightKeys { aircraftId: ExtendedAircraftICAOId; aerodromeOfDeparture?: AerodromeICAOId; @@ -1362,9 +1364,11 @@ export interface FlightListByLocationReplyData extends FlightListReplyData { effectiveTrafficWindow: DateTimeMinutePeriod; } -export interface FlightListByAirspaceReply extends Reply { - data: FlightListByAirspaceReplyData; -} +export type FlightListByAirspaceReply = CollapseEmptyObjectsToNull< + Reply & { + data: FlightListByAirspaceReplyData; + } +>; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface FlightListByAirspaceReplyData @@ -1380,11 +1384,13 @@ export interface FlightPlanListRequest { estimatedOffBlockTime: DateTimeMinutePeriod; } -export interface FlightPlanListReply extends Reply { - data: { - summaries?: FlightPlanOrInvalidFiling[]; - }; -} +export type FlightPlanListReply = CollapseEmptyObjectsToNull< + Reply & { + data: { + summaries?: FlightPlanOrInvalidFiling[]; + }; + } +>; export interface FlightRetrievalRequest { dataset: Dataset; @@ -1394,14 +1400,16 @@ export interface FlightRetrievalRequest { requestedFlightFields?: FlightField[]; } -export interface FlightRetrievalReply extends Reply { - data: { - latestFlightPlan?: FlightPlanOutput; - flightPlanHistory?: FlightPlanHistory; - flight?: Flight; - structuredFlightPlan?: StructuredFlightPlan; - }; -} +export type FlightRetrievalReply = CollapseEmptyObjectsToNull< + Reply & { + data: { + latestFlightPlan?: FlightPlanOutput; + flightPlanHistory?: FlightPlanHistory; + flight?: Flight; + structuredFlightPlan?: StructuredFlightPlan; + }; + } +>; export interface FlightListByTrafficVolumeRequest extends FlightListByLocationRequest { @@ -1410,9 +1418,11 @@ export interface FlightListByTrafficVolumeRequest flow?: FlowId; } -export interface FlightListByTrafficVolumeReply extends Reply { - data: FlightListByTrafficVolumeReplyData; -} +export type FlightListByTrafficVolumeReply = CollapseEmptyObjectsToNull< + Reply & { + data: FlightListByTrafficVolumeReplyData; + } +>; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface FlightListByTrafficVolumeReplyData @@ -1428,9 +1438,11 @@ export type FlightListByMeasureMode = | 'ACTIVATED_BY_MEASURE' | 'CONCERNED_BY_MEASURE'; -export interface FlightListByMeasureReply extends Reply { - data: FlightListByMeasureReplyData; -} +export type FlightListByMeasureReply = CollapseEmptyObjectsToNull< + Reply & { + data: FlightListByMeasureReplyData; + } +>; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface FlightListByMeasureReplyData @@ -1460,9 +1472,11 @@ export type AerodromeRole = */ | 'ALTERNATE'; -export interface FlightListByAerodromeReply extends Reply { - data: FlightListByAerodromeReplyData; -} +export type FlightListByAerodromeReply = CollapseEmptyObjectsToNull< + Reply & { + data: FlightListByAerodromeReplyData; + } +>; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface FlightListByAerodromeReplyData @@ -1474,9 +1488,11 @@ export interface FlightListByAerodromeSetRequest aerodromeRole: AerodromeRole; } -export interface FlightListByAerodromeSetReply extends Reply { - data: FlightListByAerodromeSetReplyData; -} +export type FlightListByAerodromeSetReply = CollapseEmptyObjectsToNull< + Reply & { + data: FlightListByAerodromeSetReplyData; + } +>; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface FlightListByAerodromeSetReplyData @@ -1487,9 +1503,11 @@ export interface FlightListByAircraftOperatorRequest calculationType?: CountsCalculationType; } -export interface FlightListByAircraftOperatorReply extends Reply { - data: FlightListByAircraftOperatorReplyData; -} +export type FlightListByAircraftOperatorReply = CollapseEmptyObjectsToNull< + Reply & { + data: FlightListByAircraftOperatorReplyData; + } +>; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface FlightListByAircraftOperatorReplyData diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 0000000..496c9a4 --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,13 @@ +/** + * Type helper to recursively make potentially empty objects nullable. + * + * {@see https://github.com/DGAC/nmb2b-client-js/issues/149} + */ +export type CollapseEmptyObjectsToNull = + Record extends TInput + ? + | { [TKey in keyof TInput]: CollapseEmptyObjectsToNull } + | null + : TInput extends Record + ? { [TKey in keyof TInput]: CollapseEmptyObjectsToNull } + : TInput;