canadapost-api
Version:
A more complete Canada Post API library
386 lines (321 loc) • 13.2 kB
JavaScript
const kebabCase = require('lodash.kebabcase');
const camelCase = require('lodash.camelcase');
const xml2js = require('xml2js');
const parser = new xml2js.Parser({ explicitArray: false });
const get = (obj, path, def) => {
try {
const val = path.replace(/(^[.[\]\s]+|[.[\]\s]+$)/g, '').split(/[.[\]]/).reduce((a, p) => a[p], obj);
return val === undefined ? def : val;
} catch (err) {
return def;
}
};
const normalizeArray = (val) => {
if (Array.isArray(val)) {
return val;
} else if (val) {
return [val];
} else {
return [];
}
};
class CanadaPostError extends Error {
constructor (message, code) {
let finalMessage = message;
let finalCode = code;
let originalMessages = message;
if (Array.isArray(message)) {
// Handle case where we passed an array of Canada Post messages
finalMessage = message.map(m => `${m.description} - (code ${m.code})`).join('\n');
finalCode = message.map(m => `${m.code}`).join(',');
} else if (message.description && message.code) {
// Handle case where we passed a single Canada Post messaeg
finalMessage = message.description;
finalCode = message.code;
originalMessages = [message];
} else {
// Handle case where we passed a string (and code?)
originalMessages = [];
}
super(finalMessage);
this.code = finalCode;
this.originalMessages = originalMessages;
Error.captureStackTrace(this, CanadaPostError);
}
}
class CanadaPostClient {
constructor (userId, password, customer, lang, useTestEndpoint) {
if (useTestEndpoint === true) {
this.endpoint = CanadaPostClient.ENDPOINT_DEV;
} else if (useTestEndpoint === false) {
this.endpoint = CanadaPostClient.ENDPOINT;
} else {
this.endpoint = process.env.NODE_ENV === 'production' ? CanadaPostClient.ENDPOINT : CanadaPostClient.ENDPOINT_DEV;
}
this.auth = Buffer.from(`${userId}:${password}`, 'utf8').toString('base64');
this.customer = customer;
this.lang = lang || 'en-CA';
}
async _request (call, params, contentType, path = null, method = 'GET') {
// Set-up the URL & Parameters
const reqUrl = new URL(`https://${this.endpoint}`);
if (path) {
reqUrl.pathname = `/${path}/${call}`;
} else if (this.customer) {
reqUrl.pathname = `/rs/${this.customer}/${call}`;
} else {
reqUrl.pathname = `/${call}`;
}
let body;
if (params && method === 'GET') {
reqUrl.search = new URLSearchParams(params).toString();
} else if (params) {
const builder = new xml2js.Builder();
body = builder.buildObject(CanadaPostClient.normalizeObject(params, true));
}
return this._rawRequest(method, reqUrl.toString(), contentType, body);
}
async _rawRequest (method, url, contentType, body) {
// Set-up the request
const reqParams = {
method,
body,
headers: {
Accept: contentType,
'Content-Type': contentType,
Authorization: `Basic ${this.auth}`,
'Accept-language': this.lang
}
};
// Perform the request
const rawResult = await fetch(url, reqParams);
const result = await parser.parseStringPromise(await rawResult.text());
// We got a response, but Canada Post indicates an error
if (result && result.messages && result.messages.message) {
// Parsing errors handled in CanadaPostError constructor
throw new CanadaPostError(result.messages.message);
}
// No error? Let's return that.
return result;
}
async discoverServices (originPostalCode, destinationCountry, destinationPostalCode) {
const request = {
origpc: originPostalCode,
country: destinationCountry
};
if (destinationPostalCode) {
request.destpc = destinationPostalCode;
}
const result = await this._request('service', request, 'application/vnd.cpc.ship.rate-v3+xml', 'rs/ship');
CanadaPostClient.checkResultFormat(result, 'services.service', Array.isArray(result.services.service));
return get(result, 'services.service', []).map(r => ({
serviceCode: r['service-code'],
serviceName: r['service-name']
}));
}
async getRates (scenario) {
const mailingScenario = CanadaPostClient.setNamespace(scenario, 'http://www.canadapost.ca/ws/ship/rate-v3');
if (this.customer) {
mailingScenario.customerNumber = this.customer;
}
let result = await this._request('price', { mailingScenario }, 'application/vnd.cpc.ship.rate-v3+xml', 'rs/ship', 'POST');
result = CanadaPostClient.normalizeObject(result, false, true);
CanadaPostClient.checkResultFormat(result, 'priceQuotes.priceQuote', Array.isArray(result.priceQuotes.priceQuote));
result = result.priceQuotes.priceQuote;
return result.map(r => {
delete r.serviceLink;
r.priceDetails.adjustments = normalizeArray(r.priceDetails.adjustments && r.priceDetails.adjustments.adjustment);
r.priceDetails.options = normalizeArray(r.priceDetails.options && r.priceDetails.options.option);
return r;
});
}
async createNonContractShipment (shipment) {
const nonContractShipment = CanadaPostClient.setNamespace(shipment, 'http://www.canadapost.ca/ws/ncshipment-v4');
let result = await this._request('ncshipment', { nonContractShipment }, 'application/vnd.cpc.ncshipment-v4+xml', null, 'POST');
result = CanadaPostClient.normalizeObject(result, false, false);
CanadaPostClient.checkResultFormat(result, 'nonContractShipmentInfo');
result = result.nonContractShipmentInfo;
const normalizedResult = {
shipmentId: result.shipmentId,
trackingPin: result.trackingPin,
links: {}
};
if (result && result.links && result.links.link && result.links.link.length) {
const hasMultipleLabels = result.links.link.filter(l => l.$.rel === 'label').length > 1;
result.links.link.forEach(l => {
if (l.$.rel === 'label' && hasMultipleLabels) {
normalizedResult.links.label = normalizedResult.links.label || [];
normalizedResult.links.label[+l.$.index] = l.$.href;
} else {
normalizedResult.links[l.$.rel] = l.$.href;
}
});
}
return normalizedResult;
}
async refundNonContractShipment (id, email) {
const shipment = await this.getShipment(id);
if (!shipment || !shipment.links.refund) {
throw new Error('That shipment was not found, or had no refund link.');
}
const nonContractShipmentRefundRequest = CanadaPostClient.setNamespace({ email }, 'http://www.canadapost.ca/ws/ncshipment-v4');
const builder = new xml2js.Builder();
const body = builder.buildObject(CanadaPostClient.normalizeObject({ nonContractShipmentRefundRequest }, true));
let result = await this._rawRequest('POST', shipment.links.refund, 'application/vnd.cpc.ncshipment-v4+xml', body);
result = CanadaPostClient.normalizeObject(result, false, false);
CanadaPostClient.checkResultFormat(result, 'nonContractShipmentRefundRequestInfo');
result = result.nonContractShipmentRefundRequestInfo;
return {
serviceTicketId: result.serviceTicketId,
serviceTicketDate: result.serviceTicketDate
};
}
async getTrackingSummary (pin, type) {
type = type || 'pin';
if (['pin', 'ref', 'dnc'].indexOf(type) < 0) {
throw new Error('Unknown tracking format. Should be one of pin, ref, dnc');
}
let request = null;
if (type === 'ref') {
request = pin;
pin = 'summary';
} else {
pin = `${pin}/summary`;
}
let result = await this._request(`${type}/${pin}`, request, 'application/vnd.cpc.track+xml', 'vis/track');
result = CanadaPostClient.normalizeObject(result, false, true);
CanadaPostClient.checkResultFormat(result, 'trackingSummary.pinSummary');
result = result.trackingSummary.pinSummary;
return result;
}
async getTrackingDetail (pin, type) {
type = type || 'pin';
if (['pin', 'dnc'].indexOf(type) < 0) {
throw new Error('Unknown tracking format. Should be one of pin, dnc');
}
let result = await this._request(`${type}/${pin}/detail`, null, 'application/vnd.cpc.track+xml', 'vis/track');
result = CanadaPostClient.normalizeObject(result, false, true);
CanadaPostClient.checkResultFormat(result, 'trackingDetail');
result = result.trackingDetail;
if (result.deliveryOptions && result.deliveryOptions.item && result.deliveryOptions.item.length) {
result.deliveryOptions = result.deliveryOptions.item.reduce((a, i) => {
if (i.deliveryOption && i.deliveryOptionDescription) {
a.push({
option: i.deliveryOption,
description: i.deliveryOptionDescription
});
}
return a;
}, []);
}
if (result.significantEvents && result.significantEvents.occurrence && result.significantEvents.occurrence.length) {
result.significantEvents = result.significantEvents.occurrence.reduce((a, i) => {
a.push(i);
return a;
}, []);
}
return result;
}
async getShipments (from, to) {
const params = { from: CanadaPostClient.formatDate(new Date(from)) };
if (to) {
params.to = CanadaPostClient.formatDate(new Date(to));
}
let result = await this._request('ncshipment', params, 'application/vnd.cpc.ncshipment-v4+xml');
result = CanadaPostClient.normalizeObject(result, false, false);
CanadaPostClient.checkResultFormat(result, 'nonContractShipments');
if (Array.isArray(result.nonContractShipments.link)) {
return result.nonContractShipments.link.map((link) => {
const id = /ncshipment\/([0-9]+)/.exec(link.$.href);
if (!id[1]) {
return null;
}
return {
shipmentId: id[1],
href: link.$.href,
mediaType: link.$['media-type'],
rel: link.$.rel
};
}).filter((i) => i !== null);
} else if (result.nonContractShipments.link && result.nonContractShipments.link.$) {
// Only one, so it becomes an object
const link = result.nonContractShipments.link;
const id = /ncshipment\/([0-9]+)/.exec(link.$.href);
if (!id[1]) {
return [];
}
return [{
shipmentId: id[1],
href: link.$.href,
mediaType: link.$['media-type'],
rel: link.$.rel
}];
}
return [];
}
async getShipment (id) {
let result = await this._request(`ncshipment/${id}`, null, 'application/vnd.cpc.ncshipment-v4+xml');
result = CanadaPostClient.normalizeObject(result, false, false);
CanadaPostClient.checkResultFormat(result, 'nonContractShipmentInfo');
result = result.nonContractShipmentInfo;
const normalizedResult = {
shipmentId: result.shipmentId,
trackingPin: result.trackingPin,
links: {}
};
if (result && result.links && result.links.link && result.links.link.length) {
const hasMultipleLabels = result.links.link.filter(l => l.$.rel === 'label').length > 1;
result.links.link.forEach(l => {
if (l.$.rel === 'label' && hasMultipleLabels) {
normalizedResult.links.label = normalizedResult.links.label || [];
normalizedResult.links.label[+l.$.index] = l.$.href;
} else {
normalizedResult.links[l.$.rel] = l.$.href;
}
});
}
return normalizedResult;
}
async getShipmentDetails (id) {
const result = await this._request(`ncshipment/${id}/details`, null, 'application/vnd.cpc.ncshipment-v4+xml');
return CanadaPostClient.normalizeObject(result, false, true);
}
static normalizeObject (obj, kebab, ignoreAttrs) {
if ((!Array.isArray(obj) && typeof obj !== 'object') || obj === null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(o => CanadaPostClient.normalizeObject(o, kebab, ignoreAttrs));
} else {
let out = {};
const keys = Object.keys(obj);
keys.forEach(key => {
if (key === '_' && (keys.length === 1 || (keys.length === 2 && obj.$ && ignoreAttrs))) {
out = obj._;
} else if (key !== '$') {
const newKey = kebab ? kebabCase(key) : camelCase(key);
out[newKey] = CanadaPostClient.normalizeObject(obj[key], kebab, ignoreAttrs);
} else if (!ignoreAttrs) {
out[key] = obj[key];
}
});
return out;
}
}
static formatDate (date) {
const pad = (num) => num >= 10 ? `${num}` : `0${num}`;
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(date.getMinutes())}`;
}
static setNamespace (obj, xmlns) {
return Object.assign({}, obj, { $: { xmlns } });
}
static checkResultFormat (result, path, and) {
if (get(result, path, undefined) === undefined || and === false) {
throw new Error(`Response was in an unknown format. Expected: ${path}, found ${JSON.stringify(result, null, 4)}`);
}
}
}
CanadaPostClient.ENDPOINT = 'soa-gw.canadapost.ca';
CanadaPostClient.ENDPOINT_DEV = 'ct.soa-gw.canadapost.ca';
CanadaPostClient.CanadaPostError = CanadaPostError;
module.exports = CanadaPostClient;