From 40cbdf805df8ea144bfe0f7d5f8ab20f2ab741a5 Mon Sep 17 00:00:00 2001 From: Benjamin Beret Date: Fri, 12 Jul 2024 15:50:42 +0200 Subject: [PATCH] Safer B2B response types --- .changeset/cold-schools-grab.md | 5 + package.json | 8 +- pnpm-lock.yaml | 26 +--- .../queryCompleteAIXMDatasets.test.ts | 8 +- src/Airspace/queryCompleteAIXMDatasets.ts | 13 +- src/Airspace/retrieveAUP.test.ts | 32 ++-- src/Airspace/retrieveAUP.ts | 21 +-- src/Airspace/retrieveAUPChain.test.ts | 2 +- src/Airspace/retrieveAUPChain.ts | 21 +-- src/Airspace/retrieveEAUPChain.test.ts | 2 +- src/Airspace/retrieveEAUPChain.ts | 13 +- src/Common/types.ts | 8 +- src/Flight/queryFlightPlans.test.ts | 141 +++++++++--------- src/Flight/queryFlightsByMeasure.test.ts | 36 +++-- .../queryFlightsByTrafficVolume.test.ts | 4 +- src/Flight/retrieveFlight.test.ts | 11 +- src/Flight/types.ts | 76 ++++------ src/Flow/queryRegulations.test.ts | 41 ++++- src/Flow/queryTrafficCountsByAirspace.test.ts | 2 +- .../queryTrafficCountsByTrafficVolume.test.ts | 2 +- src/Flow/retrieveCapacityPlan.test.ts | 8 +- src/Flow/retrieveOTMVPlan.test.ts | 6 +- .../retrieveSectorConfigurationPlan.test.ts | 7 +- src/Flow/retrieveSectorConfigurationPlan.ts | 11 +- src/Flow/types.ts | 49 +++--- src/Flow/updateOTMVPlan.test.ts | 14 +- src/GeneralInformation/types.ts | 10 +- src/index.ts | 4 + src/utils/types.test-d.ts | 90 +++++++++++ src/utils/types.ts | 67 ++++++++- 30 files changed, 437 insertions(+), 301 deletions(-) create mode 100644 .changeset/cold-schools-grab.md create mode 100644 src/utils/types.test-d.ts diff --git a/.changeset/cold-schools-grab.md b/.changeset/cold-schools-grab.md new file mode 100644 index 0000000..04b5a6a --- /dev/null +++ b/.changeset/cold-schools-grab.md @@ -0,0 +1,5 @@ +--- +'@dgac/nmb2b-client': minor +--- + +Response typings should be safer now diff --git a/package.json b/package.json index 76fcc90..b545c12 100644 --- a/package.json +++ b/package.json @@ -83,9 +83,9 @@ "clean": "rimraf dist", "build": "tsup-node", "release": "pnpm build && changeset publish", - "lint": "eslint", + "lint": "eslint --max-warnings=0", "test": "vitest", - "test:ci": "vitest --watch=false --reporter=basic --reporter=junit --outputFile.junit=junit.xml --coverage.enabled", + "test:ci": "vitest --watch=false --reporter=basic --reporter=junit --outputFile.junit=junit.xml --coverage.enabled --typecheck", "typecheck": "tsc --noEmit" }, "files": [ @@ -97,7 +97,6 @@ "@eslint/js": "^9.6.0", "@total-typescript/shoehorn": "^0.1.2", "@types/debug": "^4.1.12", - "@types/eslint__js": "^8.42.3", "@types/invariant": "^2.2.37", "@types/node": "^18.19.39", "@types/proper-lockfile": "^4.1.4", @@ -122,7 +121,8 @@ "proper-lockfile": "^4.1.2", "remeda": "^2.5.0", "soap": "^1.0.4", - "tar": "^7.4.0" + "tar": "^7.4.0", + "type-fest": "^4.21.0" }, "publishConfig": { "provenance": true diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e79f23..0365aff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: tar: specifier: ^7.4.0 version: 7.4.0 + type-fest: + specifier: ^4.21.0 + version: 4.21.0 devDependencies: '@changesets/changelog-github': specifier: ^0.5.0 @@ -51,9 +54,6 @@ importers: '@types/debug': specifier: ^4.1.12 version: 4.1.12 - '@types/eslint__js': - specifier: ^8.42.3 - version: 8.42.3 '@types/invariant': specifier: ^2.2.37 version: 2.2.37 @@ -639,21 +639,12 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} - '@types/eslint@8.56.10': - resolution: {integrity: sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==} - - '@types/eslint__js@8.42.3': - resolution: {integrity: sha512-alfG737uhmPdnvkrLdZLcEKJ/B8s9Y4hrZ+YAdzUeoArBlSUERA2E87ROfOaS4jd/C45fzOoZzidLc1IPwLqOw==} - '@types/estree@1.0.5': resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} '@types/invariant@2.2.37': resolution: {integrity: sha512-IwpIMieE55oGWiXkQPSBY1nw1nFs6bsKXTFskNY8sdS17K24vyEBRQZEwlRS7ZmXCWnJcQtbxWzly+cODWGs2A==} - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/ms@0.7.34': resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} @@ -2555,21 +2546,10 @@ snapshots: dependencies: '@types/ms': 0.7.34 - '@types/eslint@8.56.10': - dependencies: - '@types/estree': 1.0.5 - '@types/json-schema': 7.0.15 - - '@types/eslint__js@8.42.3': - dependencies: - '@types/eslint': 8.56.10 - '@types/estree@1.0.5': {} '@types/invariant@2.2.37': {} - '@types/json-schema@7.0.15': {} - '@types/ms@0.7.34': {} '@types/node@12.20.55': {} diff --git a/src/Airspace/queryCompleteAIXMDatasets.test.ts b/src/Airspace/queryCompleteAIXMDatasets.test.ts index 1f5bce0..277dd22 100644 --- a/src/Airspace/queryCompleteAIXMDatasets.test.ts +++ b/src/Airspace/queryCompleteAIXMDatasets.test.ts @@ -17,12 +17,16 @@ describe('queryCompleteAIXMDatasets', async () => { }, }); - expect(res.data.datasetSummaries).toBeDefined(); + expect(res.data?.datasetSummaries).toBeDefined(); + assert(res.data?.datasetSummaries); expect(res.data.datasetSummaries.length).toBeGreaterThanOrEqual(1); + const dataset = res.data.datasetSummaries[0]; assert(dataset); - expect(Array.isArray(dataset.files)).toBe(true); + assert(Array.isArray(dataset.files)); + expect(dataset.files.length).toBeGreaterThan(0); + dataset.files.forEach((f) => { expect(f).toMatchObject({ id: expect.stringMatching(/BASELINE\.zip$/), diff --git a/src/Airspace/queryCompleteAIXMDatasets.ts b/src/Airspace/queryCompleteAIXMDatasets.ts index 69a4214..e169c5a 100644 --- a/src/Airspace/queryCompleteAIXMDatasets.ts +++ b/src/Airspace/queryCompleteAIXMDatasets.ts @@ -4,25 +4,20 @@ 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, DateYearMonthDayPeriod, - Reply, + ReplyWithData, } from '../Common/types'; export interface CompleteAIXMDatasetRequest { queryCriteria: CompleteDatasetQueryCriteria; } -export type CompleteAIXMDatasetReply = CollapseEmptyObjectsToNull< - Reply & { - data: { - datasetSummaries: CompleteDatasetSummary[]; - }; - } ->; +export type CompleteAIXMDatasetReply = ReplyWithData<{ + datasetSummaries: CompleteDatasetSummary[]; +}>; type Values = CompleteAIXMDatasetRequest; type Result = CompleteAIXMDatasetReply; diff --git a/src/Airspace/retrieveAUP.test.ts b/src/Airspace/retrieveAUP.test.ts index 8aacff6..95bdced 100644 --- a/src/Airspace/retrieveAUP.test.ts +++ b/src/Airspace/retrieveAUP.test.ts @@ -2,12 +2,11 @@ import { assert, beforeAll, describe, expect, test } from 'vitest'; import { makeAirspaceClient } from '..'; import b2bOptions from '../../tests/options'; import { shouldUseRealB2BConnection } from '../../tests/utils'; -import type { AUPSummary } from './types'; describe('retrieveAUP', async () => { const Airspace = await makeAirspaceClient(b2bOptions); - let AUPSummaries: AUPSummary[] = []; + let AUPSummaryIds: Array = []; beforeAll(async () => { // Find some AUP id const res = await Airspace.retrieveAUPChain({ @@ -15,29 +14,28 @@ describe('retrieveAUP', async () => { chainDate: new Date(), }); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- TODO: Check if this condition is necessary ? - if (res.data) { - assert(res.data.chains[0]); - AUPSummaries = res.data.chains[0].aups.filter( - ({ aupState }) => aupState === 'RELEASED', - ); - - AUPSummaries.sort( - (a, b) => - (a.lastUpdate?.timestamp.valueOf() ?? 0) - - (b.lastUpdate?.timestamp.valueOf() ?? 0), - ); - } + assert(res.data?.chains?.[0]?.aups); + + const summaries = res.data.chains[0].aups.filter( + ({ aupState }) => aupState === 'RELEASED', + ); + summaries.sort( + (a, b) => + (a.lastUpdate?.timestamp.valueOf() ?? 0) - + (b.lastUpdate?.timestamp.valueOf() ?? 0), + ); + + AUPSummaryIds = summaries.map(({ id }) => id); }); test.runIf(shouldUseRealB2BConnection)('AUP Retrieval', async () => { - if (!AUPSummaries[0]) { + if (!AUPSummaryIds[0]) { console.warn('AUPChainRetrieval did not yield any AUP id'); return; } const res = await Airspace.retrieveAUP({ - aupId: AUPSummaries[0].id, + aupId: AUPSummaryIds[0], returnComputed: true, }); diff --git a/src/Airspace/retrieveAUP.ts b/src/Airspace/retrieveAUP.ts index 8f22378..d4629d9 100644 --- a/src/Airspace/retrieveAUP.ts +++ b/src/Airspace/retrieveAUP.ts @@ -1,11 +1,10 @@ -import type { AirspaceClient } from './'; -import { injectSendTime, responseStatusHandler } from '../utils/internals'; +import type { ReplyWithData } from '../Common/types'; 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'; +import { injectSendTime, responseStatusHandler } from '../utils/internals'; +import { prepareSerializer } from '../utils/transformers'; +import type { AirspaceClient } from './'; +import type { AUP, AUPId } from './types'; type Values = AUPRetrievalRequest; type Result = AUPRetrievalReply; @@ -44,10 +43,6 @@ export interface AUPRetrievalRequest { returnComputed?: boolean; } -export type AUPRetrievalReply = CollapseEmptyObjectsToNull< - Reply & { - data: { - aup: AUP; - }; - } ->; +export type AUPRetrievalReply = ReplyWithData<{ + aup: AUP; +}>; diff --git a/src/Airspace/retrieveAUPChain.test.ts b/src/Airspace/retrieveAUPChain.test.ts index 6a20466..6f8c686 100644 --- a/src/Airspace/retrieveAUPChain.test.ts +++ b/src/Airspace/retrieveAUPChain.test.ts @@ -12,6 +12,6 @@ describe('retrieveAUPChain', async () => { chainDate: new Date(), }); - expect(Array.isArray(res.data.chains)).toBe(true); + expect(Array.isArray(res.data?.chains)).toBe(true); }); }); diff --git a/src/Airspace/retrieveAUPChain.ts b/src/Airspace/retrieveAUPChain.ts index 16b54de..68d08aa 100644 --- a/src/Airspace/retrieveAUPChain.ts +++ b/src/Airspace/retrieveAUPChain.ts @@ -1,16 +1,15 @@ +import type { + AirNavigationUnitId, + DateYearMonthDay, + ReplyWithData, +} from '../Common/types'; import type { SoapOptions } from '../soap'; 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; @@ -51,10 +50,6 @@ export interface AUPChainRetrievalRequest { amcIds?: AirNavigationUnitId[]; } -export type AUPChainRetrievalReply = CollapseEmptyObjectsToNull< - Reply & { - data: { - chains: AUPChain[]; - }; - } ->; +export type AUPChainRetrievalReply = ReplyWithData<{ + chains: AUPChain[]; +}>; diff --git a/src/Airspace/retrieveEAUPChain.test.ts b/src/Airspace/retrieveEAUPChain.test.ts index e91e6cc..37eca3e 100644 --- a/src/Airspace/retrieveEAUPChain.test.ts +++ b/src/Airspace/retrieveEAUPChain.test.ts @@ -16,7 +16,7 @@ describe('retrieveEAUPChain', async () => { eaups: expect.any(Array), }); - for (const eaup of res.data.chain.eaups) { + for (const eaup of res.data.chain.eaups ?? []) { expect(eaup).toEqual({ releaseTime: expect.any(Date), validityPeriod: { diff --git a/src/Airspace/retrieveEAUPChain.ts b/src/Airspace/retrieveEAUPChain.ts index 7e76661..84d69fd 100644 --- a/src/Airspace/retrieveEAUPChain.ts +++ b/src/Airspace/retrieveEAUPChain.ts @@ -1,11 +1,10 @@ +import type { DateYearMonthDay, ReplyWithData } from '../Common/types'; import type { SoapOptions } from '../soap'; 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; @@ -46,10 +45,6 @@ export interface EAUPChainRetrievalRequest { chainDate: DateYearMonthDay; } -export type EAUPChainRetrievalReply = CollapseEmptyObjectsToNull< - Reply & { - data: { - chain: EAUPChain; - }; - } ->; +export type EAUPChainRetrievalReply = ReplyWithData<{ + chain: EAUPChain; +}>; diff --git a/src/Common/types.ts b/src/Common/types.ts index fec224b..27baaa0 100644 --- a/src/Common/types.ts +++ b/src/Common/types.ts @@ -1,3 +1,5 @@ +import type { SoapDeserializer } from '../utils/types'; + export type DateYearMonthDay = Date; export type DateTimeMinute = Date; export type DateTimeSecond = Date; @@ -128,7 +130,7 @@ export type ReplyStatus = | 'CONFLICTING_UPDATE' | 'INVALID_DATASET'; -export type Reply = { +export interface Reply { requestReceptionTime?: DateTimeSecond; requestId?: string; sendTime?: DateTimeSecond; @@ -140,6 +142,10 @@ export type Reply = { reason?: string; } +export type ReplyWithData = Reply & { + data: SoapDeserializer; +}; + export type Request = { endUserId?: string; onBehalfOfUnit?: AirNavigationUnitId; diff --git a/src/Flight/queryFlightPlans.test.ts b/src/Flight/queryFlightPlans.test.ts index a17c7bf..4cd7b6c 100644 --- a/src/Flight/queryFlightPlans.test.ts +++ b/src/Flight/queryFlightPlans.test.ts @@ -1,5 +1,5 @@ import { inspect } from 'util'; -import { NMB2BError, makeFlightClient } from '..'; +import { type B2BDeserializedResponse, NMB2BError, makeFlightClient } from '..'; import { sub, add } from 'date-fns'; import b2bOptions from '../../tests/options'; import type { FlightOrFlightPlan as B2BFlight } from './types'; @@ -9,7 +9,7 @@ import { describe, beforeAll, expect, test, assert } from 'vitest'; describe('queryFlightPlans', async () => { const Flight = await makeFlightClient(b2bOptions); - let knownFlight: B2BFlight | undefined; + let knownFlight: B2BDeserializedResponse | undefined; beforeAll(async () => { if (!shouldUseRealB2BConnection) { @@ -32,16 +32,14 @@ describe('queryFlightPlans', async () => { return; } - knownFlight = res.data.flights.find((f) => { - if (!('flight' in f)) { + const t = res.data.flights.find((f) => { + if (!('flight' in f) || !f.flight) { return false; } - const { flight } = f; - if ( - flight.flightId.keys?.aircraftId && - /(AFR)|(BAW)|(MON)|(EZY)|(RYR)/i.test(flight.flightId.keys.aircraftId) + f.flight.flightId?.keys?.aircraftId && + /(AFR)|(BAW)|(MON)|(EZY)|(RYR)/i.test(f.flight.flightId.keys.aircraftId) ) { return true; } @@ -49,10 +47,12 @@ describe('queryFlightPlans', async () => { return false; }); + knownFlight = t; + if ( !knownFlight || 'flightPlan' in knownFlight || - !knownFlight.flight.flightId.keys + !knownFlight.flight?.flightId?.keys ) { console.error('Could not find a valid callsign !'); return; @@ -77,76 +77,73 @@ describe('queryFlightPlans', async () => { }, }); - expect(res.data).toBe(null); + expect(!!res.data).toBe(false); }); - test.runIf(shouldUseRealB2BConnection && false)( - 'query known flight', - async () => { - try { - if (!knownFlight || !('flight' in knownFlight)) { - return; - } + test.runIf(shouldUseRealB2BConnection)('query known flight', async () => { + try { + if (!knownFlight || !('flight' in knownFlight) || !knownFlight.flight) { + return; + } - 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.', + 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; + } + + for (const f of data.summaries) { + if (!('lastValidFlightPlan' in f || 'currentInvalid' in f)) { + throw new Error( + 'queryFlightPlans: either lastValidFlightPlan or currentInvalid should exist', ); - return; } - 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), - }, + 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', - ); - } - } - } catch (err) { - if (err instanceof NMB2BError) { - console.log(inspect(err, { depth: 4 })); + }, + status: expect.any(String), + }); + } else if ('currentInvalid' in f) { + console.warn( + 'Query returned a flight with a currentInvalid property', + ); } - - throw err; } - }, - ); + } catch (err) { + if (err instanceof NMB2BError) { + console.log(inspect(err, { depth: 4 })); + } + + throw err; + } + }); }); diff --git a/src/Flight/queryFlightsByMeasure.test.ts b/src/Flight/queryFlightsByMeasure.test.ts index 39f6435..a976f10 100644 --- a/src/Flight/queryFlightsByMeasure.test.ts +++ b/src/Flight/queryFlightsByMeasure.test.ts @@ -1,5 +1,12 @@ import { inspect } from 'util'; -import { NMB2BError, makeFlightClient, makeFlowClient } from '..'; + +import { + type B2BDeserializedResponse, + NMB2BError, + makeFlightClient, + makeFlowClient, +} from '..'; + import b2bOptions from '../../tests/options'; import type { Regulation } from '../Flow/types'; import { beforeAll, describe, expect, test } from 'vitest'; @@ -8,7 +15,7 @@ import { sub, add, startOfHour } from 'date-fns'; import { extractReferenceLocation } from '../utils'; describe('queryFlightsByMeasure', async () => { - let measure: undefined | Regulation; + let measure: undefined | B2BDeserializedResponse; const [Flight, Flow] = await Promise.all([ makeFlightClient(b2bOptions), @@ -29,24 +36,25 @@ describe('queryFlightsByMeasure', async () => { }, }); - const hasAirspaceMatching = (regex: RegExp) => (item: Regulation) => { - const referenceLocation = extractReferenceLocation( - 'referenceLocation', - item.location, - ); + const hasAirspaceMatching = + (regex: RegExp) => (item: B2BDeserializedResponse) => { + const referenceLocation = extractReferenceLocation( + 'referenceLocation', + item.location, + ); - if (!referenceLocation || referenceLocation.type !== 'AIRSPACE') { - return false; - } + if (!referenceLocation || referenceLocation.type !== 'AIRSPACE') { + return false; + } - return regex.test(referenceLocation.id); - }; + return regex.test(referenceLocation.id); + }; - const candidates = res.data.regulations.item.filter( + const candidates = res.data.regulations?.item?.filter( hasAirspaceMatching(/^LF/), ); - if (!candidates.length) { + if (!candidates?.length) { return; } diff --git a/src/Flight/queryFlightsByTrafficVolume.test.ts b/src/Flight/queryFlightsByTrafficVolume.test.ts index 6e1c869..aca71fe 100644 --- a/src/Flight/queryFlightsByTrafficVolume.test.ts +++ b/src/Flight/queryFlightsByTrafficVolume.test.ts @@ -61,12 +61,12 @@ describe('queryFlightsByTrafficVolume', async () => { estimatedOffBlockTime: expect.any(Date), }; - if (!flight.flight.flightId.keys?.nonICAOAerodromeOfDeparture) { + if (!flight.flight?.flightId?.keys?.nonICAOAerodromeOfDeparture) { flightKeysMatcher.aerodromeOfDeparture = expect.stringMatching(/^[A-Z]{4}$/); } - if (!flight.flight.flightId.keys?.nonICAOAerodromeOfDestination) { + if (!flight.flight?.flightId?.keys?.nonICAOAerodromeOfDestination) { flightKeysMatcher.aerodromeOfDestination = expect.stringMatching(/^[A-Z]{4}$/); } diff --git a/src/Flight/retrieveFlight.test.ts b/src/Flight/retrieveFlight.test.ts index a6f8ba8..114358b 100644 --- a/src/Flight/retrieveFlight.test.ts +++ b/src/Flight/retrieveFlight.test.ts @@ -2,6 +2,7 @@ import { inspect } from 'util'; import { NMB2BError, makeFlightClient } from '..'; import b2bOptions from '../../tests/options'; import type { FlightKeys } from './types'; +import type { B2BDeserializedResponse } from '../index'; import { shouldUseRealB2BConnection } from '../../tests/utils'; import { expect, beforeAll, test, describe, assert } from 'vitest'; import { add, sub } from 'date-fns'; @@ -12,7 +13,7 @@ describe('retrieveFlight', async () => { let knownFlight: | { ifplId: string; - keys: FlightKeys; + keys: B2BDeserializedResponse; } | undefined; @@ -36,7 +37,7 @@ describe('retrieveFlight', async () => { const flights = res.data.flights.filter( (f): f is Extract => { - if ('flightPlan' in f) { + if ('flightPlan' in f || !f.flight) { return false; } @@ -46,12 +47,12 @@ describe('retrieveFlight', async () => { const flight = flights[0]; - if (!flight) { + if (!flight?.flight) { console.error('Could not fetch a known flight, test aborted'); return; } - if (!flight.flight.flightId.id) { + if (!flight.flight.flightId?.id) { console.error('Flight has no ifplId, test aborted'); return; } @@ -129,7 +130,7 @@ describe('retrieveFlight', async () => { const flight = res.data?.flight; expect(flight).toBeDefined(); - expect(flight?.flightId.id).toEqual( + 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 8c0d42d..1a6bd09 100644 --- a/src/Flight/types.ts +++ b/src/Flight/types.ts @@ -96,7 +96,7 @@ import type { NMList, NMSet, ReceivedOrSent, - Reply, + ReplyWithData, ShiftHourMinute, SignedDurationHourMinuteSecond, TimeHourMinutePeriod, @@ -120,8 +120,6 @@ import type { TrafficVolumeScenarios, } from '../Flow/types'; -import type { CollapseEmptyObjectsToNull } from '../utils/types'; - export interface FlightKeys { aircraftId: ExtendedAircraftICAOId; aerodromeOfDeparture?: AerodromeICAOId; @@ -1364,11 +1362,8 @@ export interface FlightListByLocationReplyData extends FlightListReplyData { effectiveTrafficWindow: DateTimeMinutePeriod; } -export type FlightListByAirspaceReply = CollapseEmptyObjectsToNull< - Reply & { - data: FlightListByAirspaceReplyData; - } ->; +export type FlightListByAirspaceReply = + ReplyWithData; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface FlightListByAirspaceReplyData @@ -1384,13 +1379,11 @@ export interface FlightPlanListRequest { estimatedOffBlockTime: DateTimeMinutePeriod; } -export type FlightPlanListReply = CollapseEmptyObjectsToNull< - Reply & { - data: { - summaries?: FlightPlanOrInvalidFiling[]; - }; - } ->; +export type FlightPlanListReply = ReplyWithData; + +export type FlightPlanListReplyData = { + summaries: FlightPlanOrInvalidFiling[]; +}; export interface FlightRetrievalRequest { dataset: Dataset; @@ -1400,16 +1393,14 @@ export interface FlightRetrievalRequest { requestedFlightFields?: FlightField[]; } -export type FlightRetrievalReply = CollapseEmptyObjectsToNull< - Reply & { - data: { - latestFlightPlan?: FlightPlanOutput; - flightPlanHistory?: FlightPlanHistory; - flight?: Flight; - structuredFlightPlan?: StructuredFlightPlan; - }; - } ->; +export type FlightRetrievalReply = ReplyWithData; + +export type FlightRetrievalReplyData = { + latestFlightPlan?: FlightPlanOutput; + flightPlanHistory?: FlightPlanHistory; + flight?: Flight; + structuredFlightPlan?: StructuredFlightPlan; +}; export interface FlightListByTrafficVolumeRequest extends FlightListByLocationRequest { @@ -1418,11 +1409,8 @@ export interface FlightListByTrafficVolumeRequest flow?: FlowId; } -export type FlightListByTrafficVolumeReply = CollapseEmptyObjectsToNull< - Reply & { - data: FlightListByTrafficVolumeReplyData; - } ->; +export type FlightListByTrafficVolumeReply = + ReplyWithData; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface FlightListByTrafficVolumeReplyData @@ -1438,11 +1426,8 @@ export type FlightListByMeasureMode = | 'ACTIVATED_BY_MEASURE' | 'CONCERNED_BY_MEASURE'; -export type FlightListByMeasureReply = CollapseEmptyObjectsToNull< - Reply & { - data: FlightListByMeasureReplyData; - } ->; +export type FlightListByMeasureReply = + ReplyWithData; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface FlightListByMeasureReplyData @@ -1472,11 +1457,8 @@ export type AerodromeRole = */ | 'ALTERNATE'; -export type FlightListByAerodromeReply = CollapseEmptyObjectsToNull< - Reply & { - data: FlightListByAerodromeReplyData; - } ->; +export type FlightListByAerodromeReply = + ReplyWithData; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface FlightListByAerodromeReplyData @@ -1488,11 +1470,8 @@ export interface FlightListByAerodromeSetRequest aerodromeRole: AerodromeRole; } -export type FlightListByAerodromeSetReply = CollapseEmptyObjectsToNull< - Reply & { - data: FlightListByAerodromeSetReplyData; - } ->; +export type FlightListByAerodromeSetReply = + ReplyWithData; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface FlightListByAerodromeSetReplyData @@ -1503,11 +1482,8 @@ export interface FlightListByAircraftOperatorRequest calculationType?: CountsCalculationType; } -export type FlightListByAircraftOperatorReply = CollapseEmptyObjectsToNull< - Reply & { - data: FlightListByAircraftOperatorReplyData; - } ->; +export type FlightListByAircraftOperatorReply = + ReplyWithData; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface FlightListByAircraftOperatorReplyData diff --git a/src/Flow/queryRegulations.test.ts b/src/Flow/queryRegulations.test.ts index ddeebc2..0e0ca3b 100644 --- a/src/Flow/queryRegulations.test.ts +++ b/src/Flow/queryRegulations.test.ts @@ -1,5 +1,5 @@ import { inspect } from 'util'; -import { describe, expect, test } from 'vitest'; +import { assert, describe, expect, test } from 'vitest'; import { NMB2BError, makeFlowClient } from '..'; import b2bOptions from '../../tests/options'; import { shouldUseRealB2BConnection } from '../../tests/utils'; @@ -10,6 +10,40 @@ import { add, startOfHour, sub } from 'date-fns'; describe('queryRegulations', async () => { const Flow = await makeFlowClient(b2bOptions); + test.runIf(shouldUseRealB2BConnection)('empty regulation lists', async () => { + try { + const res = await Flow.queryRegulations({ + dataset: { type: 'OPERATIONAL' }, + regulations: { + item: ['LFZZ*'], + }, + queryPeriod: { + wef: sub(new Date(), { minutes: 1 }), + unt: add(new Date(), { minutes: 1 }), + }, + requestedRegulationFields: { + item: [ + 'applicability', + 'location', + 'reason', + 'linkedRegulations', + 'scenarioReference', + ], + }, + }); + + console.log(inspect(res, { depth: 6 })); + + expect(res.data.regulations).toBe(null); + } catch (err) { + if (err instanceof NMB2BError) { + console.log(inspect(err, { depth: 4 })); + } + + throw err; + } + }); + test.runIf(shouldUseRealB2BConnection)('List all regulations', async () => { try { const res: RegulationListReply = await Flow.queryRegulations({ @@ -29,9 +63,10 @@ describe('queryRegulations', async () => { }, }); - const items = res.data.regulations.item; + const items = res.data.regulations?.item; + + assert(items); - expect(items).toBeDefined(); expect(items.length).toBeGreaterThanOrEqual(1); for (const item of items) { diff --git a/src/Flow/queryTrafficCountsByAirspace.test.ts b/src/Flow/queryTrafficCountsByAirspace.test.ts index 5d6d034..ece38f7 100644 --- a/src/Flow/queryTrafficCountsByAirspace.test.ts +++ b/src/Flow/queryTrafficCountsByAirspace.test.ts @@ -31,7 +31,7 @@ describe('queryTrafficCountsByAirspace', async () => { expect(res.data.counts).toBeDefined(); const { counts } = res.data; expect(Array.isArray(counts?.item)).toBe(true); - expect(counts?.item.length).toBe(6); + expect(counts?.item?.length).toBe(6); for (const item of counts?.item ?? []) { expect(item).toMatchObject({ diff --git a/src/Flow/queryTrafficCountsByTrafficVolume.test.ts b/src/Flow/queryTrafficCountsByTrafficVolume.test.ts index a37e43a..3a5cc5d 100644 --- a/src/Flow/queryTrafficCountsByTrafficVolume.test.ts +++ b/src/Flow/queryTrafficCountsByTrafficVolume.test.ts @@ -32,7 +32,7 @@ describe('queryTrafficCountsByTrafficVolume', async () => { const { counts } = res.data; expect(Array.isArray(counts?.item)).toBe(true); - expect(counts?.item.length).toBe(6); + expect(counts?.item?.length).toBe(6); for (const item of counts?.item ?? []) { expect(item).toMatchObject({ diff --git a/src/Flow/retrieveCapacityPlan.test.ts b/src/Flow/retrieveCapacityPlan.test.ts index bad1ac5..efd16da 100644 --- a/src/Flow/retrieveCapacityPlan.test.ts +++ b/src/Flow/retrieveCapacityPlan.test.ts @@ -2,7 +2,7 @@ import { inspect } from 'util'; import { NMB2BError, makeFlowClient } from '..'; import b2bOptions from '../../tests/options'; import type { Result as CapacityPlanRetrievalResult } from './retrieveCapacityPlan'; -import { describe, test, expect } from 'vitest'; +import { describe, test, expect, assert } from 'vitest'; import { shouldUseRealB2BConnection } from '../../tests/utils'; describe('retrieveCapacityPlan', async () => { @@ -19,7 +19,11 @@ describe('retrieveCapacityPlan', async () => { }); expect(res.data.plans).toBeDefined(); - expect(Array.isArray(res.data.plans.tvCapacities.item)).toBe(true); + assert(res.data.plans); + + expect(Array.isArray(res.data.plans.tvCapacities?.item)).toBe(true); + assert(res.data.plans.tvCapacities?.item); + for (const item of res.data.plans.tvCapacities.item) { expect(item).toEqual({ key: expect.stringMatching(/^[A-Z0-9]+$/), diff --git a/src/Flow/retrieveOTMVPlan.test.ts b/src/Flow/retrieveOTMVPlan.test.ts index bf413de..120b173 100644 --- a/src/Flow/retrieveOTMVPlan.test.ts +++ b/src/Flow/retrieveOTMVPlan.test.ts @@ -19,15 +19,15 @@ describe('retrieveOTMVPlan', async () => { expect(res.data).toBeDefined(); expect(res.data.plans.planCutOffReached).toEqual(expect.any(Boolean)); - expect(res.data.plans.tvsOTMVs.item.length).toEqual(1); - expect(res.data.plans.tvsOTMVs.item[0]).toEqual({ + expect(res.data.plans.tvsOTMVs?.item?.length).toEqual(1); + expect(res.data.plans.tvsOTMVs?.item?.[0]).toEqual({ key: 'LFERMS', value: { item: expect.any(Array), }, }); - assert(res.data.plans.tvsOTMVs.item[0]); + assert(res.data.plans.tvsOTMVs?.item?.[0]?.value?.item); for (const otmvPlan of res.data.plans.tvsOTMVs.item[0].value.item) { expect(otmvPlan).toEqual({ diff --git a/src/Flow/retrieveSectorConfigurationPlan.test.ts b/src/Flow/retrieveSectorConfigurationPlan.test.ts index 2af37ff..ea9a15d 100644 --- a/src/Flow/retrieveSectorConfigurationPlan.test.ts +++ b/src/Flow/retrieveSectorConfigurationPlan.test.ts @@ -1,5 +1,5 @@ import { inspect } from 'util'; -import { describe, expect, test } from 'vitest'; +import { assert, describe, expect, test } from 'vitest'; import { NMB2BError, makeFlowClient } from '..'; import b2bOptions from '../../tests/options'; import { shouldUseRealB2BConnection } from '../../tests/utils'; @@ -29,6 +29,7 @@ describe('retrieveSectorConfigurationPlan', async () => { expect(Array.isArray(nmSchedule.item)).toBe(true); expect(Array.isArray(clientSchedule.item)).toBe(true); expect(Array.isArray(knownConfigurations.item)).toBe(true); + assert(knownConfigurations.item); for (const conf of knownConfigurations.item) { expect(conf).toMatchObject({ @@ -40,7 +41,7 @@ describe('retrieveSectorConfigurationPlan', async () => { } // Test that we can generate a valid map - const map = knownConfigurationsToMap(res.data.plan.knownConfigurations); + const map = knownConfigurationsToMap(knownConfigurations); const keys = Array.from(map.keys()); expect(keys.length).toBeGreaterThan(0); @@ -69,10 +70,12 @@ describe('retrieveSectorConfigurationPlan', async () => { } }; + assert(nmSchedule.item); for (const conf of nmSchedule.item) { testSchedule(conf); } + assert(clientSchedule.item); for (const conf of clientSchedule.item) { testSchedule(conf); } diff --git a/src/Flow/retrieveSectorConfigurationPlan.ts b/src/Flow/retrieveSectorConfigurationPlan.ts index a375139..52fe5cb 100644 --- a/src/Flow/retrieveSectorConfigurationPlan.ts +++ b/src/Flow/retrieveSectorConfigurationPlan.ts @@ -17,6 +17,7 @@ export { } from './types'; import type { AirspaceId } from '../Airspace/types'; +import type { B2BDeserializedResponse } from '..'; type Values = SectorConfigurationPlanRetrievalRequest; type Result = SectorConfigurationPlanRetrievalReply; @@ -53,7 +54,7 @@ export default function prepareRetrieveSectorConfigurationPlan( } export function knownConfigurationsToMap( - knownConfigurations: undefined | null | KnownConfigurations, + knownConfigurations: undefined | null | B2BDeserializedResponse, ): Map { if (!knownConfigurations?.item) { return new Map(); @@ -62,8 +63,12 @@ export function knownConfigurationsToMap( const { item } = knownConfigurations; const map: Map = new Map(); - item.forEach(({ key, value: { item: value } }) => { - map.set(key, value); + item.forEach(({ key, value }) => { + if (!value?.item) { + return; + } + + map.set(key, value.item); }); return map; diff --git a/src/Flow/types.ts b/src/Flow/types.ts index 4a5ef6d..65e438a 100644 --- a/src/Flow/types.ts +++ b/src/Flow/types.ts @@ -25,7 +25,6 @@ import type { import type { DateTimeMinutePeriod, DurationHourMinute, - Reply, Dataset, DateYearMonthDay, PlanDataId, @@ -36,6 +35,7 @@ import type { UserId, AirNavigationUnitId, DateTimeMinute, + ReplyWithData, } from '../Common/types'; import type { @@ -204,9 +204,8 @@ export interface TacticalConfigurationRetrievalRequest { day: DateYearMonthDay; } -export interface SectorConfigurationPlanRetrievalReply extends Reply { - data: SectorConfigurationPlanRetrievalReplyData; -} +export type SectorConfigurationPlanRetrievalReply = + ReplyWithData; export interface SectorConfigurationPlanRetrievalReplyData { plan: SectorConfigurationPlan; @@ -281,13 +280,11 @@ export type CountSubTotalComputeMode = */ | 'SUB_TOTALS_BY_REGULATION_DETAILS'; -export interface TrafficCountsByAirspaceReply extends Reply { - data: TrafficCountsReplyData; -} +export type TrafficCountsByAirspaceReply = + ReplyWithData; -export interface TrafficCountsByTrafficVolumeReply extends Reply { - data: TrafficCountsReplyData; -} +export type TrafficCountsByTrafficVolumeReply = + ReplyWithData; export interface TrafficCountsReplyData { effectiveTrafficWindow: DateTimeMinutePeriod; @@ -415,9 +412,7 @@ export type RegulationListRequest = RegulationOrMCDMOnlyListRequest & { regulationStates?: NMSet; }; -export interface RegulationListReply extends Reply { - data: RegulationListReplyData; -} +export type RegulationListReply = ReplyWithData; export interface RegulationListReplyData extends RegulationOrMCDMOnlyListReplyData { @@ -648,10 +643,9 @@ export interface HotspotListRequest { export type HotspotKind = 'LOCATION_OF_INTEREST' | 'PROBLEM'; -export interface HotspotListReply extends Reply { - plans: HotspotPlans; -} +export type HotspotListReply = ReplyWithData; +export type HotspotListReplyData = { plans: HotspotPlans }; export interface HotspotPlans { dataId: PlanDataId; dataset: Dataset; @@ -710,9 +704,7 @@ interface OTMVSustained { type OTMVThreshold = number; -export interface OTMVPlanRetrievalReply extends Reply { - data: OTMVPlanRetrievalReplyData; -} +export type OTMVPlanRetrievalReply = ReplyWithData; export interface OTMVPlanRetrievalReplyData { plans: OTMVPlans; @@ -722,9 +714,7 @@ export interface OTMVPlanUpdateRequest { plans: OTMVPlans; } -export interface OTMVPlanUpdateReply extends Reply { - data: OTMVPlanUpdateReplyData; -} +export type OTMVPlanUpdateReply = ReplyWithData; export interface OTMVPlanUpdateReplyData { plans: OTMVPlans; @@ -735,9 +725,8 @@ export interface CapacityPlanRetrievalRequest trafficVolumes: NMSet; } -export interface CapacityPlanRetrievalReply extends Reply { - data: CapacityPlanRetrievalReplyData; -} +export type CapacityPlanRetrievalReply = + ReplyWithData; export interface CapacityPlanRetrievalReplyData { plans: CapacityPlans; @@ -764,17 +753,15 @@ export interface CapacityPlanUpdateRequest { plans: CapacityPlans; } -export interface CapacityPlanUpdateReply extends Reply { - data: CapacityPlanUpdateReplyData; -} +export type CapacityPlanUpdateReply = + ReplyWithData; export interface CapacityPlanUpdateReplyData { plans: CapacityPlans; } -export interface RunwayConfigurationPlanRetrievalReply extends Reply { - data: RunwayConfigurationPlanRetrievalData; -} +export type RunwayConfigurationPlanRetrievalReply = + ReplyWithData; export interface RunwayConfigurationPlanRetrievalData { plan: RunwayConfigurationPlan; diff --git a/src/Flow/updateOTMVPlan.test.ts b/src/Flow/updateOTMVPlan.test.ts index df4da92..2e1f21d 100644 --- a/src/Flow/updateOTMVPlan.test.ts +++ b/src/Flow/updateOTMVPlan.test.ts @@ -33,15 +33,21 @@ describe('updateOTMVPlan', async () => { return; } - function clearNmSchedules(plan: typeof planBefore): typeof planBefore { + function clearNmSchedules(plan: typeof planBefore) { assert(plan); const plans = plan.plans; + assert(plans.tvsOTMVs?.item); + for (const { value } of plans.tvsOTMVs.item) { - const v = value.item; + const v = value?.item; + if (!v) { + continue; + } + for (const { value } of v) { - if (value.nmSchedule) { + if (value?.nmSchedule) { delete value.nmSchedule; } } @@ -50,7 +56,7 @@ describe('updateOTMVPlan', async () => { return plan; } - await Flow.updateOTMVPlan(clearNmSchedules(planBefore)); + await Flow.updateOTMVPlan(clearNmSchedules(planBefore) as any); } catch (err) { console.warn('Error resetting otmv plan after test'); console.log(JSON.stringify(err, null, 2)); diff --git a/src/GeneralInformation/types.ts b/src/GeneralInformation/types.ts index 725c2f0..1948d3b 100644 --- a/src/GeneralInformation/types.ts +++ b/src/GeneralInformation/types.ts @@ -1,12 +1,10 @@ -import type { Reply, NMB2BVersion, File } from '../Common/types'; +import type { File, NMB2BVersion, ReplyWithData } from '../Common/types'; export interface NMB2BWSDLsRequest { version: NMB2BVersion; } -export interface NMB2BWSDLsReply extends Reply { - data: NMB2BWSDLsReplyData; -} +export type NMB2BWSDLsReply = ReplyWithData; export interface NMB2BWSDLsReplyData { file: B2BInfoFile; @@ -18,9 +16,7 @@ export interface B2BInfoFile extends File { export type UserInformationRequest = Record; -export interface UserInformationReply extends Reply { - data?: UserInformationReplyData; -} +export type UserInformationReply = ReplyWithData; export type UserInformationReplyData = { textReport: TextReport; diff --git a/src/index.ts b/src/index.ts index c8fe623..7a744d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,10 @@ export { FlowService } from './Flow'; export { GeneralInformationService } from './GeneralInformation'; export { NMB2BError } from './utils/NMB2BError'; +import type { SoapDeserializer } from './utils/types'; +export type B2BDeserializedResponse = + SoapDeserializer; + export interface B2BClient { Airspace: AirspaceService; Flight: FlightService; diff --git a/src/utils/types.test-d.ts b/src/utils/types.test-d.ts new file mode 100644 index 0000000..1b76689 --- /dev/null +++ b/src/utils/types.test-d.ts @@ -0,0 +1,90 @@ +import { describe, test, expectTypeOf } from 'vitest'; +import type { SoapDeserializer, AllOptionalObjectsAreUndefined } from './types'; + +describe('AllOptionalObjectsAreUndefined', () => { + test('should not modify an object with a required key', () => { + type Input = { foo: string }; + type T = AllOptionalObjectsAreUndefined; + + expectTypeOf().toEqualTypeOf(); + }); + + test('should not modify an object with a required key and an optional key', () => { + type Input = { foo: string; bar?: string }; + type T = AllOptionalObjectsAreUndefined; + + expectTypeOf().toEqualTypeOf(); + }); + + test('should modify an object with a key which can be undefined', () => { + type Input = { foo: string | undefined }; + type T = AllOptionalObjectsAreUndefined; + + expectTypeOf().toEqualTypeOf(); + }); + + test('should add undefined to an object with all optional keys', () => { + type Input = { + foo?: string; + bar: undefined | string; + baz?: { foo: string }; + }; + type T = AllOptionalObjectsAreUndefined; + + expectTypeOf().toEqualTypeOf(); + }); +}); + +describe('SoapDeserializer', () => { + test('should keep scalars', () => { + type T = SoapDeserializer<{ foo: string }>; + + expectTypeOf<{ foo: string }>().toEqualTypeOf(); + }); + + test('should collapse object with all optional properties to undefined', () => { + type T = SoapDeserializer<{ foo?: string }>; + + expectTypeOf().toEqualTypeOf(); + }); + + test('should preserve Date objects', () => { + type T = SoapDeserializer<{ foo?: Date; bar: Date }>; + + expectTypeOf<{ foo?: Date; bar: Date }>().toEqualTypeOf(); + }); + + test('should collapse multiple level deep', () => { + type T = SoapDeserializer<{ foo: Array }>; + // type T = SoapDeserializer<{ foo: { bar: { baz: Array } } }>; + + expectTypeOf().toMatchTypeOf(); + }); + + test('should collapse nested objects recursively', () => { + type T = SoapDeserializer<{ + foo: { bar?: string }; + second?: { first?: string }; + }>; + + expectTypeOf().toMatchTypeOf(); + }); + + test('should collapse arrays', () => { + type T = SoapDeserializer<{ + foo: Array; + }>; + + expectTypeOf().toMatchTypeOf(); + }); + + test('should keep non optional objects in array', () => { + type T = SoapDeserializer<{ + foo: Array<{ opt?: string; required: string }>; + }>; + + expectTypeOf<{ + foo: Array<{ required: string }> | null; + }>().toMatchTypeOf(); + }); +}); diff --git a/src/utils/types.ts b/src/utils/types.ts index 496c9a4..365d838 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1,13 +1,64 @@ +export type SoapDeserializer = TInput extends Primitive | Date + ? TInput + : TInput extends Array + ? Array, null | undefined>> | null | undefined + : AllOptionalObjectsAreUndefined<{ + [TKey in keyof TInput]: SoapDeserializer; + }>; + +export type AllOptionalObjectsAreUndefined = + Exclude> extends never + ? T | null | undefined + : T; + +type PartialOrUndefinedKeysOf = + | UndefinedKeysOf + | NullKeysOf + | OptionalKeysOf; + +type UndefinedKeysOf = keyof { + [TKey in keyof T as undefined extends T[TKey] ? TKey : never]: T[TKey]; +}; + +type NullKeysOf = keyof { + [TKey in keyof T as null extends T[TKey] ? TKey : never]: T[TKey]; +}; + +// type UndefinedKeysToOptionals = /** * 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; +// export type SoapDeserializerOld = +// /** +// * If TInput is a Date, Set, Map, string or number, do nothing +// */ +// NonNullable extends Date | string | number | boolean +// ? TInput +// : NonNullable extends NMSet +// ? NMSet> | null +// : NonNullable extends NMMap +// ? NMMap, SoapDeserializer> | null +// : NonNullable extends NMList +// ? NMList> | null +// : NonNullable extends Array +// ? Array> | undefined | null +// : /** +// * If an empty object is assignable to TInput, then make it nullable. +// * Recursively map over TInput properties +// */ +// TInput extends object +// ? HasRequiredKeys extends true +// ? { +// [TKey in keyof TInput]: SoapDeserializer; +// } +// : +// | { +// [TKey in keyof TInput]: SoapDeserializer; +// } +// | null +// | undefined +// : never; + +import type { OptionalKeysOf, Primitive } from 'type-fest';