diff --git a/CHANGELOG.md b/CHANGELOG.md index b8d8cf2..b8413d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.3.0 + +- Add `createShipment(shipRequest, options)` — calls the FedEx Ship API to create a shipment (`POST /ship/v1/shipments`). Same passthrough pattern as the other methods: caller supplies the full request body, the package forwards it verbatim. Supports `options.customer_transaction_id` and `options.timeout`. Non-2xx responses and 200-with-`errors[]` envelopes both reject with `HttpError`. +- Add `cancelShipment(cancelRequest, options)` — calls the FedEx Ship API to cancel a shipment (`PUT /ship/v1/shipments/cancel`). Same passthrough pattern. Supports `options.customer_transaction_id` and `options.timeout`. Non-2xx responses and 200-with-`errors[]` envelopes both reject with `HttpError`. + ## 0.2.0 - Add `validateAddress(addressValidationRequest, options)` — calls the FedEx Address Validation API (`POST /address/v1/addresses/resolve`). Same passthrough pattern as `rateAndTransitTimes`: caller supplies the full request body, the package forwards it verbatim. Supports `options.customer_transaction_id` and `options.timeout` like the other methods. Non-2xx responses and 200-with-`errors[]` envelopes both reject with `HttpError`. diff --git a/README.md b/README.md index 791d0ff..47d5930 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![npm version](https://img.shields.io/npm/v/@stores.com/fedex)](https://www.npmjs.com/package/@stores.com/fedex) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -FedEx REST API client for OAuth tokens, Rates and Transit Times, and Address Validation. +FedEx REST API client for Address Validation, OAuth tokens, Rates and Transit Times, Shipment Cancellation, and Shipment Creation. ## Installation @@ -40,6 +40,76 @@ const fedex = new FedEx({ ## Methods +### cancelShipment(cancelRequest, options) + +Cancel a FedEx shipment via the Ship API. The caller supplies the full request body — `accountNumber`, `trackingNumber`, `senderCountryCode`, `deletionControl` — and the package forwards it verbatim. + +See: https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html + +```javascript +const json = await fedex.cancelShipment({ + accountNumber: { value: 'your_account_number' }, + deletionControl: 'DELETE_ALL_PACKAGES', + senderCountryCode: 'US', + trackingNumber: '794644790138' +}); +``` + +Non-2xx responses reject with `HttpError`. If FedEx returns a 200 response carrying a non-empty `errors[]` envelope, the call rejects with an `HttpError` whose message is every `message` joined by `; ` and whose `.json` is the full response body (with the `errors[]` array, codes, and any other fields). + +### createShipment(shipRequest, options) + +Create a FedEx shipment via the Ship API. The caller supplies the full request body — `accountNumber`, `labelResponseOptions`, `requestedShipment` — and the package forwards it verbatim. + +See: https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html + +```javascript +const json = await fedex.createShipment({ + accountNumber: { value: 'your_account_number' }, + labelResponseOptions: 'URL_ONLY', + requestedShipment: { + packagingType: 'YOUR_PACKAGING', + pickupType: 'USE_SCHEDULED_PICKUP', + recipients: [{ + address: { + city: 'New York', + countryCode: 'US', + postalCode: '10001', + stateOrProvinceCode: 'NY', + streetLines: ['10 FedEx Pkwy'] + }, + contact: { + personName: 'Test Recipient', + phoneNumber: '0000000000' + } + }], + requestedPackageLineItems: [{ weight: { units: 'LB', value: 5 } }], + serviceType: 'FEDEX_GROUND', + shipper: { + address: { + city: 'Memphis', + countryCode: 'US', + postalCode: '38116', + stateOrProvinceCode: 'TN', + streetLines: ['10 FedEx Pkwy'] + }, + contact: { + companyName: 'Test Shipper', + phoneNumber: '0000000000' + } + }, + shippingChargesPayment: { paymentType: 'SENDER' } + } +}); + +const trackingNumber = json.output.transactionShipments[0].masterTrackingNumber; + +console.log(trackingNumber); +// '794644790138' +``` + +Non-2xx responses reject with `HttpError`. If FedEx returns a 200 response carrying a non-empty `errors[]` envelope, the call rejects with an `HttpError` whose message is every `message` joined by `; ` and whose `.json` is the full response body (with the `errors[]` array, codes, and any other fields). + ### getAccessToken() FedEx APIs use the OAuth 2.0 protocol for authentication and authorization using the `client_credentials` grant type. Tokens are cached in-process per API key until shortly before they expire. diff --git a/index.js b/index.js index 72b33e9..194500e 100644 --- a/index.js +++ b/index.js @@ -7,6 +7,96 @@ function FedEx(args) { ...args }; + /** + * Cancel a FedEx shipment via the Ship API. The caller supplies the full + * request body — `accountNumber`, `trackingNumber`, `senderCountryCode`, + * `deletionControl` — and the package forwards it verbatim. + * + * @param {object} cancelRequest - Full Cancel Shipment request body. + * @param {object} [options] + * @param {string} [options.customer_transaction_id] - Sent as the `x-customer-transaction-id` + * request header. FedEx echoes this back so callers can correlate requests with responses. + * @param {number} [options.timeout=30000] - Request timeout in milliseconds. + * @returns {Promise} The parsed response body. + * @see https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html + */ + this.cancelShipment = async (cancelRequest, options = {}) => { + const accessToken = await this.getAccessToken(); + + const headers = { + Authorization: `Bearer ${accessToken.access_token}`, + 'Content-Type': 'application/json' + }; + + if (options.customer_transaction_id) { + headers['x-customer-transaction-id'] = options.customer_transaction_id; + } + + const response = await fetch(`${_options.url}/ship/v1/shipments/cancel`, { + body: JSON.stringify(cancelRequest), + headers, + method: 'PUT', + signal: AbortSignal.timeout(options.timeout || 30000) + }); + + if (!response.ok) { + throw await HttpError.from(response); + } + + const json = await response.json(); + + if (json.errors?.length) { + throw await HttpError.from(response); + } + + return json; + }; + + /** + * Create a FedEx shipment via the Ship API. The caller supplies the full + * request body — `accountNumber`, `labelResponseOptions`, `requestedShipment` + * — and the package forwards it verbatim. + * + * @param {object} shipRequest - Full Create Shipment request body. + * @param {object} [options] + * @param {string} [options.customer_transaction_id] - Sent as the `x-customer-transaction-id` + * request header. FedEx echoes this back so callers can correlate requests with responses. + * @param {number} [options.timeout=30000] - Request timeout in milliseconds. + * @returns {Promise} The parsed response body, including `output.transactionShipments[]`. + * @see https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html + */ + this.createShipment = async (shipRequest, options = {}) => { + const accessToken = await this.getAccessToken(); + + const headers = { + Authorization: `Bearer ${accessToken.access_token}`, + 'Content-Type': 'application/json' + }; + + if (options.customer_transaction_id) { + headers['x-customer-transaction-id'] = options.customer_transaction_id; + } + + const response = await fetch(`${_options.url}/ship/v1/shipments`, { + body: JSON.stringify(shipRequest), + headers, + method: 'POST', + signal: AbortSignal.timeout(options.timeout || 30000) + }); + + if (!response.ok) { + throw await HttpError.from(response); + } + + const json = await response.json(); + + if (json.errors?.length) { + throw await HttpError.from(response); + } + + return json; + }; + /** * Request an OAuth access token from FedEx using the `client_credentials` grant. Tokens * are cached in memory per API key for half their lifetime, so repeat calls return the diff --git a/package.json b/package.json index 328c684..7aac9a6 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "@stores.com/http-error": "~1.1.0", "memory-cache": "~0.2.0" }, - "description": "FedEx REST API client for OAuth tokens, rate quotes, and address validation.", + "description": "FedEx REST API client for address validation, OAuth tokens, rate quotes, shipment cancellation, and shipment creation.", "devDependencies": { "@eslint/js": "*", "async": "~3.2.0", @@ -11,9 +11,12 @@ }, "keywords": [ "address-validation", + "cancel", + "create", "fedex", "logistics", "rates", + "ship", "shipping" ], "license": "MIT", @@ -30,5 +33,5 @@ "test": "node --test --test-force-exit --test-reporter=spec", "test:only": "node --test --test-force-exit --test-only --test-reporter=spec" }, - "version": "0.2.0" + "version": "0.3.0" } diff --git a/test/index.js b/test/index.js index dd72ccf..b8f2abb 100644 --- a/test/index.js +++ b/test/index.js @@ -5,6 +5,355 @@ const async = require('async'); const FedEx = require('../index'); +test('cancelShipment', { concurrency: true }, async (t) => { + t.test('should cancel a previously created shipment', async () => { + const fedex = new FedEx({ + api_key: process.env.FEDEX_API_KEY, + secret_key: process.env.FEDEX_SECRET_KEY, + url: process.env.FEDEX_URL + }); + + const shipment = await async.retry(async () => fedex.createShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + labelResponseOptions: 'URL_ONLY', + requestedShipment: { + packagingType: 'YOUR_PACKAGING', + pickupType: 'USE_SCHEDULED_PICKUP', + recipients: [{ + address: { + city: 'New York', + countryCode: 'US', + postalCode: '10001', + stateOrProvinceCode: 'NY', + streetLines: ['10 FedEx Pkwy'] + }, + contact: { + personName: 'Test Recipient', + phoneNumber: '0000000000' + } + }], + requestedPackageLineItems: [{ + weight: { units: 'LB', value: 5 } + }], + serviceType: 'FEDEX_GROUND', + shipper: { + address: { + city: 'Memphis', + countryCode: 'US', + postalCode: '38116', + stateOrProvinceCode: 'TN', + streetLines: ['10 FedEx Pkwy'] + }, + contact: { + companyName: 'Test Shipper', + phoneNumber: '0000000000' + } + }, + labelSpecification: { + imageType: 'PNG', + labelStockType: 'PAPER_4X6' + }, + shippingChargesPayment: { paymentType: 'SENDER' } + } + })); + + const trackingNumber = shipment.output.transactionShipments[0].masterTrackingNumber; + + assert(trackingNumber); + + const body = await async.retry(async () => fedex.cancelShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + deletionControl: 'DELETE_ALL_PACKAGES', + senderCountryCode: 'US', + trackingNumber + })); + + assert(body); + assert(body.transactionId); + }); +}); + +test('cancelShipment (mocked)', async (t) => { + t.test('should send options.customer_transaction_id as x-customer-transaction-id header and use PUT method', async (t) => { + let sentHeader; + let sentMethod; + + t.mock.method(globalThis, 'fetch', async (url, init) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/ship/v1/shipments/cancel')) { + sentHeader = init.headers['x-customer-transaction-id']; + sentMethod = init.method; + return new Response(JSON.stringify({ output: { cancelledShipment: true }, transactionId: 'mock' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await fedex.cancelShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + deletionControl: 'DELETE_ALL_PACKAGES', + senderCountryCode: 'US', + trackingNumber: '794644790138' + }, { customer_transaction_id: 'abc-123' }); + + assert.strictEqual(sentHeader, 'abc-123'); + assert.strictEqual(sentMethod, 'PUT'); + }); + + t.test('should throw HttpError for 200 response with errors envelope', async (t) => { + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/ship/v1/shipments/cancel')) { + return new Response(JSON.stringify({ + errors: [ + { code: 'SHIPMENT.CANCEL.FAILURE', message: 'Shipment already tendered' } + ], + transactionId: 'mock' + }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await assert.rejects(fedex.cancelShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + deletionControl: 'DELETE_ALL_PACKAGES', + senderCountryCode: 'US', + trackingNumber: '794644790138' + }), (err) => { + assert.strictEqual(err.name, 'HttpError'); + return true; + }); + }); + + t.test('should throw HttpError for non 2xx response', async (t) => { + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/ship/v1/shipments/cancel')) { + return new Response('', { status: 500, statusText: 'Internal Server Error' }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await assert.rejects(fedex.cancelShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + deletionControl: 'DELETE_ALL_PACKAGES', + senderCountryCode: 'US', + trackingNumber: '794644790138' + }), (err) => { + assert.strictEqual(err.name, 'HttpError'); + assert.match(err.message, /^500/); + return true; + }); + }); +}); + +test('createShipment', { concurrency: true }, async (t) => { + t.test('should create a FedEx Ground shipment', async () => { + const fedex = new FedEx({ + api_key: process.env.FEDEX_API_KEY, + secret_key: process.env.FEDEX_SECRET_KEY, + url: process.env.FEDEX_URL + }); + + const body = await async.retry(async () => fedex.createShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + labelResponseOptions: 'URL_ONLY', + requestedShipment: { + packagingType: 'YOUR_PACKAGING', + pickupType: 'USE_SCHEDULED_PICKUP', + recipients: [{ + address: { + city: 'New York', + countryCode: 'US', + postalCode: '10001', + stateOrProvinceCode: 'NY', + streetLines: ['10 FedEx Pkwy'] + }, + contact: { + personName: 'Test Recipient', + phoneNumber: '0000000000' + } + }], + requestedPackageLineItems: [{ + weight: { units: 'LB', value: 5 } + }], + serviceType: 'FEDEX_GROUND', + shipper: { + address: { + city: 'Memphis', + countryCode: 'US', + postalCode: '38116', + stateOrProvinceCode: 'TN', + streetLines: ['10 FedEx Pkwy'] + }, + contact: { + companyName: 'Test Shipper', + phoneNumber: '0000000000' + } + }, + labelSpecification: { + imageType: 'PNG', + labelStockType: 'PAPER_4X6' + }, + shippingChargesPayment: { paymentType: 'SENDER' } + } + })); + + assert(body); + assert(body.transactionId); + assert(body.output); + }); +}); + +test('createShipment (mocked)', async (t) => { + t.test('should send options.customer_transaction_id as x-customer-transaction-id header and use POST method', async (t) => { + let sentHeader; + let sentMethod; + + t.mock.method(globalThis, 'fetch', async (url, init) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/ship/v1/shipments')) { + sentHeader = init.headers['x-customer-transaction-id']; + sentMethod = init.method; + return new Response(JSON.stringify({ output: { transactionShipments: [{ masterTrackingNumber: '794644790138' }] }, transactionId: 'mock' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await fedex.createShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + labelResponseOptions: 'URL_ONLY', + requestedShipment: { + packagingType: 'YOUR_PACKAGING', + pickupType: 'USE_SCHEDULED_PICKUP', + recipients: [{ + address: { countryCode: 'US', postalCode: '10001' }, + contact: { personName: 'Test', phoneNumber: '0000000000' } + }], + requestedPackageLineItems: [{ weight: { units: 'LB', value: 5 } }], + serviceType: 'FEDEX_GROUND', + shipper: { + address: { countryCode: 'US', postalCode: '38116' }, + contact: { companyName: 'Test', phoneNumber: '0000000000' } + }, + shippingChargesPayment: { paymentType: 'SENDER' } + } + }, { customer_transaction_id: 'abc-123' }); + + assert.strictEqual(sentHeader, 'abc-123'); + assert.strictEqual(sentMethod, 'POST'); + }); + + t.test('should throw HttpError for 200 response with errors envelope', async (t) => { + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/ship/v1/shipments')) { + return new Response(JSON.stringify({ + errors: [ + { code: 'SHIPMENT.CREATE.FAILURE', message: 'Invalid request' } + ], + transactionId: 'mock' + }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await assert.rejects(fedex.createShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + labelResponseOptions: 'URL_ONLY', + requestedShipment: {} + }), (err) => { + assert.strictEqual(err.name, 'HttpError'); + return true; + }); + }); + + t.test('should throw HttpError for non 2xx response', async (t) => { + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/ship/v1/shipments')) { + return new Response('', { status: 500, statusText: 'Internal Server Error' }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await assert.rejects(fedex.createShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + labelResponseOptions: 'URL_ONLY', + requestedShipment: {} + }), (err) => { + assert.strictEqual(err.name, 'HttpError'); + assert.match(err.message, /^500/); + return true; + }); + }); +}); + test('getAccessToken', { concurrency: true }, async (t) => { t.test('should return a valid access token', async () => { const fedex = new FedEx({