diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65188623..e0dafe99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,8 @@ jobs: run: npm ci - name: Build SDK run: npm run build + - name: Type-check SDK + run: npm run type-check - name: Generate SDK docs if: matrix.node-version == 24 run: npm run docs @@ -59,6 +61,10 @@ jobs: run: npm run build --workspace @terminal49/sdk - name: Build MCP run: npm run build --workspace @terminal49/mcp + - name: Type-check SDK + MCP + run: | + npm run type-check --workspace @terminal49/sdk + npm run type-check --workspace @terminal49/mcp - name: Test MCP run: npm run test --workspace @terminal49/mcp -- --run --coverage - name: Lint MCP (oxlint + oxfmt) diff --git a/docs/sdk/reference/client/managers/classes/ShipmentManager.mdx b/docs/sdk/reference/client/managers/classes/ShipmentManager.mdx index e299a916..4bdfa953 100644 --- a/docs/sdk/reference/client/managers/classes/ShipmentManager.mdx +++ b/docs/sdk/reference/client/managers/classes/ShipmentManager.mdx @@ -58,7 +58,7 @@ description: "ShipmentManager class in the Terminal49 TypeScript SDK, used to li | Parameter | Type | | ------ | ------ | -| `filters` | \{ `carrier?`: `string`; `include?`: [`IncludeParam`](/sdk/reference/types/options/type-aliases/IncludeParam)\<[`ShipmentInclude`](/sdk/reference/types/options/type-aliases/ShipmentInclude)\>; `includeContainers?`: `boolean`; `port?`: `string`; `status?`: `string`; `updatedAfter?`: `string`; \} \| `undefined` | +| `filters` | \{ `carrier?`: `string`; `include?`: [`IncludeParam`](/sdk/reference/types/options/type-aliases/IncludeParam)\<[`ShipmentInclude`](/sdk/reference/types/options/type-aliases/ShipmentInclude)\>; `includeContainers?`: `boolean`; `number?`: `string`; `port?`: `string`; `status?`: `string`; `trackingStopped?`: `boolean`; `updatedAfter?`: `string`; \} \| `undefined` | | `options?` | `Omit`\<[`ListOptions`](/sdk/reference/types/options/interfaces/ListOptions), `"page"`\> | #### Returns @@ -73,16 +73,18 @@ description: "ShipmentManager class in the Terminal49 TypeScript SDK, used to li #### Parameters -| Parameter | Type | -| ------ | ------ | -| `filters` | \{ `carrier?`: `string`; `include?`: [`IncludeParam`](/sdk/reference/types/options/type-aliases/IncludeParam)\<[`ShipmentInclude`](/sdk/reference/types/options/type-aliases/ShipmentInclude)\>; `includeContainers?`: `boolean`; `port?`: `string`; `status?`: `string`; `updatedAfter?`: `string`; \} | -| `filters.carrier?` | `string` | -| `filters.include?` | [`IncludeParam`](/sdk/reference/types/options/type-aliases/IncludeParam)\<[`ShipmentInclude`](/sdk/reference/types/options/type-aliases/ShipmentInclude)\> | -| `filters.includeContainers?` | `boolean` | -| `filters.port?` | `string` | -| `filters.status?` | `string` | -| `filters.updatedAfter?` | `string` | -| `options?` | [`ListOptions`](/sdk/reference/types/options/interfaces/ListOptions) | +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `filters` | \{ `carrier?`: `string`; `include?`: [`IncludeParam`](/sdk/reference/types/options/type-aliases/IncludeParam)\<[`ShipmentInclude`](/sdk/reference/types/options/type-aliases/ShipmentInclude)\>; `includeContainers?`: `boolean`; `number?`: `string`; `port?`: `string`; `status?`: `string`; `trackingStopped?`: `boolean`; `updatedAfter?`: `string`; \} | - | +| `filters.carrier?` | `string` | - | +| `filters.include?` | [`IncludeParam`](/sdk/reference/types/options/type-aliases/IncludeParam)\<[`ShipmentInclude`](/sdk/reference/types/options/type-aliases/ShipmentInclude)\> | - | +| `filters.includeContainers?` | `boolean` | - | +| `filters.number?` | `string` | Search shipments by the original tracking `request_number`. | +| `filters.port?` | `string` | - | +| `filters.status?` | `string` | - | +| `filters.trackingStopped?` | `boolean` | Filter shipments by whether they are still tracking. Maps to the supported `filter[tracking_stopped]`. | +| `filters.updatedAfter?` | `string` | - | +| `options?` | [`ListOptions`](/sdk/reference/types/options/interfaces/ListOptions) | - | #### Returns diff --git a/docs/sdk/reference/types/options/type-aliases/ContainerInclude.mdx b/docs/sdk/reference/types/options/type-aliases/ContainerInclude.mdx index 0afa07d8..0ba1edb7 100644 --- a/docs/sdk/reference/types/options/type-aliases/ContainerInclude.mdx +++ b/docs/sdk/reference/types/options/type-aliases/ContainerInclude.mdx @@ -5,4 +5,4 @@ description: "ContainerInclude type alias in the Terminal49 TypeScript SDK listi # Type Alias: ContainerInclude -> **ContainerInclude** = `"shipment"` \| `"pod_terminal"` \| `"destination_terminal"` \| `"transport_events"` +> **ContainerInclude** = `"shipment"` \| `"pod_terminal"` \| `"pickup_facility"` \| `"transport_events"` diff --git a/sdks/typescript-sdk/src/client.filters.test.ts b/sdks/typescript-sdk/src/client.filters.test.ts new file mode 100644 index 00000000..c5894fde --- /dev/null +++ b/sdks/typescript-sdk/src/client.filters.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; +import { Terminal49Client } from './client.js'; +import { + buildContainerListQuery, + buildShipmentListQuery, + clampPageSize, + MAX_PAGE_SIZE, +} from './client/query.js'; + +const baseUrl = 'https://api.test/v2'; + +/** + * Permissive fetch that records each call and always returns an empty + * JSON:API document, regardless of query string. Lets us assert on the + * emitted query keys without registering exact URLs. + */ +function createRecordingFetch() { + const calls: URL[] = []; + const fetchImpl = (async (input: Request | URL | string) => { + const urlString = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + calls.push(new URL(urlString)); + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as unknown as typeof fetch; + return { fetchImpl, calls }; +} + +function makeClient(fetchImpl: typeof fetch) { + return new Terminal49Client({ + apiToken: 'token-123', + apiBaseUrl: baseUrl, + fetchImpl, + }); +} + +describe('pure list-query builders', () => { + it('drops every unsupported container filter and reports them', () => { + const { query, unsupportedFilters } = buildContainerListQuery({ + status: 'in_transit', + port: 'USLAX', + carrier: 'MAEU', + updatedAfter: '2024-01-01', + }); + + expect(query['filter[status]' as keyof typeof query]).toBeUndefined(); + expect(query['filter[pod_locode]' as keyof typeof query]).toBeUndefined(); + expect(query['filter[line_scac]' as keyof typeof query]).toBeUndefined(); + expect(query['filter[updated_at]' as keyof typeof query]).toBeUndefined(); + expect(unsupportedFilters.sort()).toEqual( + ['carrier', 'port', 'status', 'updatedAfter'].sort(), + ); + }); + + it('keeps supported container keys (include) and reports nothing unsupported', () => { + const { query, unsupportedFilters } = buildContainerListQuery({ + include: 'shipment,pod_terminal', + }); + expect(query.include).toBe('shipment,pod_terminal'); + expect(unsupportedFilters).toEqual([]); + }); + + it('drops unsupported shipment filters but keeps supported ones', () => { + const { query, unsupportedFilters } = buildShipmentListQuery({ + status: 'in_transit', + port: 'USLAX', + carrier: 'MAEU', + updatedAfter: '2024-01-01', + trackingStopped: true, + number: 'TRK-1', + include: 'containers', + }); + + // unsupported keys are never emitted + expect(query['filter[status]' as keyof typeof query]).toBeUndefined(); + expect(query['filter[pod_locode]' as keyof typeof query]).toBeUndefined(); + expect(query['filter[line_scac]' as keyof typeof query]).toBeUndefined(); + expect(query['filter[updated_at]' as keyof typeof query]).toBeUndefined(); + + // supported keys ARE emitted + expect(query['filter[tracking_stopped]']).toBe(true); + expect(query.number).toBe('TRK-1'); + expect(query.include).toBe('containers'); + + expect(unsupportedFilters.sort()).toEqual( + ['carrier', 'port', 'status', 'updatedAfter'].sort(), + ); + }); + + it('clamps page size to the API maximum and floors at 1', () => { + expect(clampPageSize(9999)).toBe(MAX_PAGE_SIZE); + expect(clampPageSize(MAX_PAGE_SIZE + 1)).toBe(MAX_PAGE_SIZE); + expect(clampPageSize(0)).toBe(1); + expect(clampPageSize(-5)).toBe(1); + expect(clampPageSize(25)).toBe(25); + expect(clampPageSize(undefined)).toBeUndefined(); + }); +}); + +describe('ContainerManager.list filter correctness', () => { + it('does NOT emit filter[*] keys for unsupported status/port/carrier/updatedAfter', async () => { + const { fetchImpl, calls } = createRecordingFetch(); + const client = makeClient(fetchImpl); + + await client.listContainers({ + status: 'in_transit', + port: 'USLAX', + carrier: 'MAEU', + updatedAfter: '2024-01-01', + }); + + const params = calls[0].searchParams; + expect(params.get('filter[status]')).toBeNull(); + expect(params.get('filter[pod_locode]')).toBeNull(); + expect(params.get('filter[line_scac]')).toBeNull(); + expect(params.get('filter[updated_at]')).toBeNull(); + // include (a supported key) is still emitted + expect(params.get('include')).toContain('shipment'); + }); + + it('reports the dropped filters via unsupportedFilters on the mapped result', async () => { + const { fetchImpl } = createRecordingFetch(); + const client = makeClient(fetchImpl); + + const result = (await client.listContainers( + { status: 'in_transit', port: 'USLAX' }, + { format: 'mapped' }, + )) as { unsupportedFilters?: string[] }; + + expect(result.unsupportedFilters?.sort()).toEqual(['port', 'status']); + }); + + it('clamps an over-large page size before sending', async () => { + const { fetchImpl, calls } = createRecordingFetch(); + const client = makeClient(fetchImpl); + + await client.listContainers({}, { pageSize: 9999 }); + + expect(calls[0].searchParams.get('page[size]')).toBe(String(MAX_PAGE_SIZE)); + }); +}); + +describe('ShipmentManager.list filter correctness', () => { + it('does NOT emit filter[*] keys for unsupported status/port/carrier/updatedAfter', async () => { + const { fetchImpl, calls } = createRecordingFetch(); + const client = makeClient(fetchImpl); + + await client.listShipments({ + status: 'in_transit', + port: 'USLAX', + carrier: 'MAEU', + updatedAfter: '2024-01-01', + }); + + const params = calls[0].searchParams; + expect(params.get('filter[status]')).toBeNull(); + expect(params.get('filter[pod_locode]')).toBeNull(); + expect(params.get('filter[line_scac]')).toBeNull(); + expect(params.get('filter[updated_at]')).toBeNull(); + }); + + it('emits the supported filter[tracking_stopped] key when requested', async () => { + const { fetchImpl, calls } = createRecordingFetch(); + const client = makeClient(fetchImpl); + + // trackingStopped is a manager-level filter (the wrapper keeps its + // historical signature), so exercise the manager directly. + await client.shipments.list({ trackingStopped: true }); + + expect(calls[0].searchParams.get('filter[tracking_stopped]')).toBe('true'); + }); + + it('reports the dropped filters via unsupportedFilters on the mapped result', async () => { + const { fetchImpl } = createRecordingFetch(); + const client = makeClient(fetchImpl); + + const result = (await client.listShipments( + { carrier: 'MAEU', updatedAfter: '2024-01-01' }, + { format: 'mapped' }, + )) as { unsupportedFilters?: string[] }; + + expect(result.unsupportedFilters?.sort()).toEqual([ + 'carrier', + 'updatedAfter', + ]); + }); +}); diff --git a/sdks/typescript-sdk/src/client.request.test.ts b/sdks/typescript-sdk/src/client.request.test.ts index 2eb32a12..de4bde49 100644 --- a/sdks/typescript-sdk/src/client.request.test.ts +++ b/sdks/typescript-sdk/src/client.request.test.ts @@ -78,16 +78,12 @@ describe('Terminal49Client request building', () => { expect(result.mapped?.[0]?.scac).toBe('MAEU'); }); - it('builds listShipments filters and pagination', async () => { + it('builds listShipments include + pagination and omits unsupported filters', async () => { const search = buildSearchParams([ [ 'include', 'containers,pod_terminal,port_of_lading,port_of_discharge,destination,destination_terminal', ], - ['filter[status]', 'in_transit'], - ['filter[pod_locode]', 'USLAX'], - ['filter[line_scac]', 'MAEU'], - ['filter[updated_at]', '2024-01-01'], ['page[number]', '2'], ['page[size]', '50'], ]); @@ -102,23 +98,32 @@ describe('Terminal49Client request building', () => { fetchImpl, }); - await client.listShipments( + const result = await client.listShipments( { status: 'in_transit', port: 'USLAX', carrier: 'MAEU', updatedAfter: '2024-01-01', }, - { page: 2, pageSize: 50 }, + { page: 2, pageSize: 50, format: 'mapped' }, ); const params = calls[0].url.searchParams; - expect(params.get('filter[status]')).toBe('in_transit'); - expect(params.get('filter[pod_locode]')).toBe('USLAX'); - expect(params.get('filter[line_scac]')).toBe('MAEU'); - expect(params.get('filter[updated_at]')).toBe('2024-01-01'); + // The v2 API does not support these filter[*] keys, so the SDK omits them + // instead of sending no-op params. + expect(params.get('filter[status]')).toBeNull(); + expect(params.get('filter[pod_locode]')).toBeNull(); + expect(params.get('filter[line_scac]')).toBeNull(); + expect(params.get('filter[updated_at]')).toBeNull(); expect(params.get('page[number]')).toBe('2'); expect(params.get('page[size]')).toBe('50'); + // ...and reports them back so callers know they were dropped. + expect(result.unsupportedFilters).toEqual([ + 'status', + 'port', + 'carrier', + 'updatedAfter', + ]); }); it('removes containers from include when includeContainers=false', async () => { @@ -146,10 +151,7 @@ describe('Terminal49Client request building', () => { }); it('accepts comma-separated listShipments include strings', async () => { - const search = buildSearchParams([ - ['include', 'containers,pod_terminal'], - ['filter[status]', 'in_transit'], - ]); + const search = buildSearchParams([['include', 'containers,pod_terminal']]); const { fetchImpl, calls } = createMockFetch({ [`/shipments?${search}`]: () => jsonResponse({ data: [] }), @@ -166,15 +168,15 @@ describe('Terminal49Client request building', () => { include: 'containers,pod_terminal', }); - expect(calls[0].url.searchParams.get('include')).toBe( - 'containers,pod_terminal', - ); + const params = calls[0].url.searchParams; + expect(params.get('include')).toBe('containers,pod_terminal'); + // Unsupported filter is dropped rather than forwarded as a no-op. + expect(params.get('filter[status]')).toBeNull(); }); - it('builds listContainers filters and pagination with custom include', async () => { + it('builds listContainers include + pagination and omits unsupported filters', async () => { const search = buildSearchParams([ ['include', 'shipment,pod_terminal,transport_events'], - ['filter[status]', 'in_transit'], ['page[number]', '3'], ['page[size]', '10'], ]); @@ -189,28 +191,27 @@ describe('Terminal49Client request building', () => { fetchImpl, }); - await client.listContainers( + const result = await client.listContainers( { status: 'in_transit', include: ['shipment', 'pod_terminal', 'transport_events'], }, - { page: 3, pageSize: 10 }, + { page: 3, pageSize: 10, format: 'mapped' }, ); const params = calls[0].url.searchParams; expect(params.get('include')).toBe( 'shipment,pod_terminal,transport_events', ); - expect(params.get('filter[status]')).toBe('in_transit'); + // `filter[status]` is unsupported on /containers, so it is omitted. + expect(params.get('filter[status]')).toBeNull(); expect(params.get('page[number]')).toBe('3'); expect(params.get('page[size]')).toBe('10'); + expect(result.unsupportedFilters).toEqual(['status']); }); it('accepts comma-separated listContainers include strings', async () => { - const search = buildSearchParams([ - ['include', 'shipment,pod_terminal'], - ['filter[status]', 'available'], - ]); + const search = buildSearchParams([['include', 'shipment,pod_terminal']]); const { fetchImpl, calls } = createMockFetch({ [`/containers?${search}`]: () => jsonResponse({ data: [] }), @@ -227,9 +228,10 @@ describe('Terminal49Client request building', () => { include: 'shipment,pod_terminal', }); - expect(calls[0].url.searchParams.get('include')).toBe( - 'shipment,pod_terminal', - ); + const params = calls[0].url.searchParams; + expect(params.get('include')).toBe('shipment,pod_terminal'); + // Unsupported filter is dropped rather than forwarded as a no-op. + expect(params.get('filter[status]')).toBeNull(); }); it('hits container raw events and refresh endpoints', async () => { diff --git a/sdks/typescript-sdk/src/client/managers/containers.ts b/sdks/typescript-sdk/src/client/managers/containers.ts index ba1f6d6c..b64a5d48 100644 --- a/sdks/typescript-sdk/src/client/managers/containers.ts +++ b/sdks/typescript-sdk/src/client/managers/containers.ts @@ -6,9 +6,18 @@ import type { ListOptions, } from '../../types/options.js'; import { mapContainerList, mapRoute, mapTransportEvents } from '../mappers.js'; -import { normalizeInclude, normalizeIncludeWithDefault } from '../query.js'; +import { + applyTypedPagination, + buildContainerListQuery, + normalizeInclude, +} from '../query.js'; import { BaseManager } from './base.js'; +const DEFAULT_CONTAINER_INCLUDES = [ + 'shipment', + 'pod_terminal', +] as const satisfies readonly ContainerInclude[]; + export class ContainerManager extends BaseManager { async get( id: string, @@ -37,28 +46,21 @@ export class ContainerManager extends BaseManager { } = {}, options?: ListOptions, ): Promise { - const includeParam = normalizeIncludeWithDefault(filters.include, [ - 'shipment', - 'pod_terminal', - ]); - const params: Record = {}; - if (includeParam) params.include = includeParam; - if (filters.status) params['filter[status]'] = filters.status; - if (filters.port) params['filter[pod_locode]'] = filters.port; - if (filters.carrier) params['filter[line_scac]'] = filters.carrier; - if (filters.updatedAfter) - params['filter[updated_at]'] = filters.updatedAfter; - - this.applyPagination(params, options); + const { query, unsupportedFilters } = buildContainerListQuery( + filters, + DEFAULT_CONTAINER_INCLUDES, + ); + applyTypedPagination(query, options); const raw = await this.transport.execute(() => this.transport.client.GET('/containers', { - params: { query: params as any }, + params: { query }, }), ); - return this.formatResult(raw, options?.format, (doc) => - this.mapListResult(doc, mapContainerList), - ); + return this.formatResult(raw, options?.format, (doc) => ({ + ...this.mapListResult(doc, mapContainerList), + unsupportedFilters, + })); } iterate( diff --git a/sdks/typescript-sdk/src/client/managers/shipments.ts b/sdks/typescript-sdk/src/client/managers/shipments.ts index d2e0eb98..d9c80e92 100644 --- a/sdks/typescript-sdk/src/client/managers/shipments.ts +++ b/sdks/typescript-sdk/src/client/managers/shipments.ts @@ -6,7 +6,11 @@ import type { ShipmentInclude, } from '../../types/options.js'; import { mapShipment, mapShipmentList } from '../mappers.js'; -import { normalizeInclude, normalizeIncludeWithDefault } from '../query.js'; +import { + applyTypedPagination, + buildShipmentListQuery, + normalizeIncludeWithDefault, +} from '../query.js'; import { BaseManager } from './base.js'; const DEFAULT_SHIPMENT_INCLUDES = [ @@ -56,36 +60,34 @@ export class ShipmentManager extends BaseManager { port?: string; carrier?: string; updatedAfter?: string; + /** Filter shipments by whether they are still tracking. Maps to the supported `filter[tracking_stopped]`. */ + trackingStopped?: boolean; + /** Search shipments by the original tracking `request_number`. */ + number?: string; includeContainers?: boolean; include?: IncludeParam; } = {}, options?: ListOptions, ): Promise { - const params: Record = {}; - const includesStr = normalizeInclude( - filters.include ?? - (filters.includeContainers === false - ? SHIPMENT_INCLUDES_WITHOUT_CONTAINERS - : DEFAULT_SHIPMENT_INCLUDES), + const defaultInclude = + filters.includeContainers === false + ? SHIPMENT_INCLUDES_WITHOUT_CONTAINERS + : DEFAULT_SHIPMENT_INCLUDES; + const { query, unsupportedFilters } = buildShipmentListQuery( + filters, + defaultInclude, ); - if (includesStr) params.include = includesStr; - - if (filters.status) params['filter[status]'] = filters.status; - if (filters.port) params['filter[pod_locode]'] = filters.port; - if (filters.carrier) params['filter[line_scac]'] = filters.carrier; - if (filters.updatedAfter) - params['filter[updated_at]'] = filters.updatedAfter; - - this.applyPagination(params, options); + applyTypedPagination(query, options); const raw = await this.transport.execute(() => this.transport.client.GET('/shipments', { - params: { query: params as any }, + params: { query }, }), ); - return this.formatResult(raw, options?.format, (doc) => - this.mapListResult(doc, mapShipmentList), - ); + return this.formatResult(raw, options?.format, (doc) => ({ + ...this.mapListResult(doc, mapShipmentList), + unsupportedFilters, + })); } iterate( diff --git a/sdks/typescript-sdk/src/client/query.ts b/sdks/typescript-sdk/src/client/query.ts index 0f150d52..f595564e 100644 --- a/sdks/typescript-sdk/src/client/query.ts +++ b/sdks/typescript-sdk/src/client/query.ts @@ -1,9 +1,78 @@ -import type { ListOptions } from '../types/options.js'; +import type { paths } from '../generated/terminal49.js'; +import type { + ContainerInclude, + IncludeParam as IncludeParamOption, + ListOptions, + ShipmentInclude, +} from '../types/options.js'; export type IncludeParam = | readonly TInclude[] | string; +/** + * Maximum `page[size]` accepted by the Terminal49 v2 list endpoints. Values + * above this are silently truncated by the API, so the SDK clamps them to keep + * pagination cursors honest. + */ +export const MAX_PAGE_SIZE = 100; + +/** Typed query objects for the JSON:API list endpoints, sourced from the generated OpenAPI spec. */ +type ContainerListQuery = NonNullable< + paths['/containers']['get']['parameters']['query'] +>; +type ShipmentListQuery = NonNullable< + paths['/shipments']['get']['parameters']['query'] +>; + +/** Filters accepted by {@link buildContainerListQuery}. Mirrors the public `containers.list` signature. */ +export interface ContainerListFilters { + status?: string; + port?: string; + carrier?: string; + updatedAfter?: string; + include?: IncludeParamOption; +} + +/** Filters accepted by {@link buildShipmentListQuery}. Mirrors the public `shipments.list` signature. */ +export interface ShipmentListFilters { + status?: string; + port?: string; + carrier?: string; + updatedAfter?: string; + trackingStopped?: boolean; + number?: string; + includeContainers?: boolean; + include?: IncludeParamOption; +} + +/** Result of building a list query: the typed query plus any filters the API does not support. */ +export interface ListQueryResult { + query: TQuery; + unsupportedFilters: string[]; +} + +/** + * Filter keys the SDK historically forwarded as `filter[*]` query params but + * which the Terminal49 v2 API does NOT support on `/containers` or `/shipments` + * (verified against docs/openapi.json + the generated OpenAPI types). The API + * silently drops them, so we report them back instead of pretending they worked. + */ +const UNSUPPORTED_FILTER_KEYS = [ + 'status', + 'port', + 'carrier', + 'updatedAfter', +] as const; + +function collectUnsupportedFilters( + filters: Partial>, +): string[] { + return UNSUPPORTED_FILTER_KEYS.filter( + (key) => filters[key] !== undefined && filters[key] !== '', + ); +} + export function normalizeInclude( include?: IncludeParam, ): string | undefined { @@ -25,6 +94,20 @@ export function normalizeIncludeWithDefault( return normalizeInclude(include ?? defaultInclude); } +/** + * Clamp a requested page size into the range the API actually honors: at least + * 1, at most {@link MAX_PAGE_SIZE}. Returns `undefined` when no size was given + * so the API's own default (30) applies. + */ +export function clampPageSize(pageSize?: number): number | undefined { + if (pageSize === undefined) return undefined; + if (!Number.isFinite(pageSize)) return undefined; + const floored = Math.floor(pageSize); + if (floored < 1) return 1; + if (floored > MAX_PAGE_SIZE) return MAX_PAGE_SIZE; + return floored; +} + export function applyPagination( params: Record, options?: Pick, @@ -33,11 +116,83 @@ export function applyPagination( if (options.page !== undefined) { params['page[number]'] = String(options.page); } - if (options.pageSize !== undefined) { - params['page[size]'] = String(options.pageSize); + const pageSize = clampPageSize(options.pageSize); + if (pageSize !== undefined) { + params['page[size]'] = String(pageSize); } } +/** Query objects that carry numeric JSON:API pagination keys. */ +type PaginatedQuery = { + 'page[number]'?: number; + 'page[size]'?: number; +}; + +/** + * Apply pagination to a typed list query as numbers (matching the generated + * OpenAPI types), clamping `page[size]` to {@link MAX_PAGE_SIZE}. + */ +export function applyTypedPagination( + query: TQuery, + options?: Pick, +): TQuery { + if (!options) return query; + if (options.page !== undefined) { + query['page[number]'] = options.page; + } + const pageSize = clampPageSize(options.pageSize); + if (pageSize !== undefined) { + query['page[size]'] = pageSize; + } + return query; +} + +/** + * Build the typed `/containers` list query, mapping only filters the v2 API + * supports (`include`) and reporting the rest via `unsupportedFilters`. + */ +export function buildContainerListQuery( + filters: ContainerListFilters, + defaultInclude?: IncludeParamOption, +): ListQueryResult { + const query: ContainerListQuery = {}; + + const includeStr = normalizeIncludeWithDefault( + filters.include, + defaultInclude ?? [], + ); + if (includeStr) query.include = includeStr; + + return { query, unsupportedFilters: collectUnsupportedFilters(filters) }; +} + +/** + * Build the typed `/shipments` list query. Maps the supported filters + * (`include`, `number`, `filter[tracking_stopped]`) and reports unsupported + * ones via `unsupportedFilters`. + */ +export function buildShipmentListQuery( + filters: ShipmentListFilters, + defaultInclude?: IncludeParamOption, +): ListQueryResult { + const query: ShipmentListQuery = {}; + + const includeStr = normalizeIncludeWithDefault( + filters.include, + defaultInclude ?? [], + ); + if (includeStr) query.include = includeStr; + + if (filters.number !== undefined && filters.number !== '') { + query.number = filters.number; + } + if (filters.trackingStopped !== undefined) { + query['filter[tracking_stopped]'] = filters.trackingStopped; + } + + return { query, unsupportedFilters: collectUnsupportedFilters(filters) }; +} + export function copyStringParams( params: Record, values: Record, diff --git a/sdks/typescript-sdk/src/types/options.ts b/sdks/typescript-sdk/src/types/options.ts index 90e3123b..e112c4d7 100644 --- a/sdks/typescript-sdk/src/types/options.ts +++ b/sdks/typescript-sdk/src/types/options.ts @@ -30,7 +30,7 @@ export type ShipmentInclude = export type ContainerInclude = | 'shipment' | 'pod_terminal' - | 'destination_terminal' + | 'pickup_facility' | 'transport_events'; export type TrackingRequestInclude = 'shipment' | 'container';