diff --git a/README.md b/README.md index 10cc5fc..752225f 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ _*Note*: `shipit-api` Heroku app is not meant for production use, and there are * Amazon * A1 International * Prestige +* Purolator ## Usage @@ -56,7 +57,8 @@ Use it to initialize the shipper clients with your account credentials. DhlGmClient, CanadaPostClient, AmazonClient, - PrestigeClient + PrestigeClient, + PurolatorClient } = require 'shipit' ups = new UpsClient @@ -93,6 +95,10 @@ upsmi = new UpsMiClient() amazonClient = new AmazonClient() prestige = new PrestigeClient() + +purolator = new PurolatorClient + key: 'my-production-key' + password: 'my-purolator-password' ``` Use an initialized client to request tracking data. diff --git a/src/main.coffee b/src/main.coffee index f1b8a24..1fdba2c 100644 --- a/src/main.coffee +++ b/src/main.coffee @@ -10,6 +10,7 @@ {CanadaPostClient} = require './canada_post' {DhlGmClient} = require './dhlgm' {PrestigeClient} = require './prestige' +{PurolatorClient} = require './purolator' guessCarrier = require './guessCarrier' module.exports = { diff --git a/src/purolator.coffee b/src/purolator.coffee new file mode 100644 index 0000000..7a41c79 --- /dev/null +++ b/src/purolator.coffee @@ -0,0 +1,121 @@ +{Builder, Parser} = require 'xml2js' +{find} = require 'underscore' +moment = require 'moment-timezone' +{titleCase, upperCase} = require 'change-case' +{ShipperClient} = require './shipper' + +class PurolatorClient extends ShipperClient + DEV_URI_BASE = 'https://devwebservices.purolator.com/EWS/V1' + URI_BASE = 'https://webservices.purolator.com/EWS/V1' + + PROVINCES = [ + 'NL', 'PE', 'NS', 'NB', 'QC', 'ON', + 'MB', 'SK', 'AB', 'BC', 'YT', 'NT', 'NU' + ] + + STATUS_MAP = + 'Delivery': ShipperClient.STATUS_TYPES.DELIVERED + 'Undeliverable': ShipperClient.STATUS_TYPES.DELAYED + 'OnDelivery': ShipperClient.STATUS_TYPES.OUT_FOR_DELIVERY + + DESCRIPTION_MAP = + 'Arrived': ShipperClient.STATUS_TYPES.EN_ROUTE + 'Departed': ShipperClient.STATUS_TYPES.EN_ROUTE + 'Picked up': ShipperClient.STATUS_TYPES.EN_ROUTE + 'Shipment created': ShipperClient.STATUS_TYPES.SHIPPING + + constructor: ({@key, @password}, @options) -> + super() + @parser = new Parser() + @builder = new Builder(renderOpts: pretty: true) + + generateRequest: (trk) -> + req = + 'SOAP-ENV:Envelope': + '$': + 'xmlns:ns0': 'http://purolator.com/pws/datatypes/v1' + 'xmlns:ns1': 'http://schemas.xmlsoap.org/soap/envelope/' + 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance' + 'xmlns:tns': 'http://purolator.com/pws/datatypes/v1' + 'xmlns:SOAP-ENV': 'http://schemas.xmlsoap.org/soap/envelope/' + 'SOAP-ENV:Header': + 'tns:RequestContext': + 'tns:Version': '1.2' + 'tns:Language': 'en' + 'tns:GroupID': 'xxx' + 'tns:RequestReference': 'Shiprack Package Tracker' + 'ns1:Body': + 'ns0:TrackPackagesByPinRequest': + 'ns0:PINs': + 'ns0:PIN': + 'ns0:Value': trk + + if @options?.dev + req['SOAP-ENV:Envelope']['SOAP-ENV:Header']['tns:RequestContext']['tns:UserToken'] = + @options.token + + @builder.buildObject req + + validateResponse: (response, cb) -> + handleResponse = (xmlErr, trackResult) -> + return cb(xmlErr) if xmlErr? + body = trackResult?['s:Envelope']?['s:Body']?[0] + trackInfo = body?.TrackPackagesByPinResponse?[0]?.TrackingInformationList?[0] + scans = trackInfo?.TrackingInformation?[0]?.Scans?[0]?.Scan + return cb('Unrecognized response format') unless scans?.length + cb null, scans + @parser.reset() + @parser.parseString response, handleResponse + + getStatus: (data) -> + status = STATUS_MAP[data?.ScanType[0]] + return status if status? + for text, statusCode of DESCRIPTION_MAP + regex = new RegExp text, 'i' + if regex.test data?.ScanType[0] + status = statusCode + break + status + + presentLocation: (depot) -> + return null if upperCase(depot) is 'PUROLATOR' + words = depot?.split(' ') + lastWord = words?.pop() + if lastWord?.length is 2 and upperCase(lastWord) in PROVINCES + return "#{titleCase(words.join(' '))}, #{lastWord}" + else + return titleCase depot + + presentTimestamp: (datestr, timestr) -> + moment("#{datestr} #{timestr} +0000", 'YYYY-MM-DD HHmmss ZZ').toDate() + + getActivitiesAndStatus: (data) -> + activities = data?.map (scan) => + details: scan?.Description?[0] + location: @presentLocation scan?.Depot?[0]?.Name?[0] + timestamp: @presentTimestamp scan?.ScanDate?[0], scan?.ScanTime?[0] + activities: activities, status: @getStatus data?[0] + + getEta: (data) -> + + getService: (data) -> + + getWeight: (data) -> + + getDestination: (data) -> + + requestOptions: ({trackingNumber}) -> + method: 'POST' + uri: "#{if @options?.dev then DEV_URI_BASE else URI_BASE}/Tracking/TrackingService.asmx" + headers: + 'SOAPAction': '"http://purolator.com/pws/service/v1/TrackPackagesByPin"' + 'Content-Type': 'text/xml; charset=utf-8' + 'Content-type': 'text/xml; charset=utf-8' + 'Soapaction': '"http://purolator.com/pws/service/v1/TrackPackagesByPin"' + auth: + user: @key + pass: @password + body: @generateRequest trackingNumber + + +module.exports = {PurolatorClient} diff --git a/src/ups.coffee b/src/ups.coffee index fd3c38d..7586acb 100644 --- a/src/ups.coffee +++ b/src/ups.coffee @@ -61,7 +61,7 @@ class UpsClient extends ShipperClient presentTimestamp: (dateString, timeString) -> return unless dateString? - timeString ?= '00:00:00' + timeString ?= '000000' formatSpec = 'YYYYMMDD HHmmss ZZ' moment("#{dateString} #{timeString} +0000", formatSpec).toDate() diff --git a/test/purolator.coffee b/test/purolator.coffee new file mode 100644 index 0000000..34a2294 --- /dev/null +++ b/test/purolator.coffee @@ -0,0 +1,79 @@ +fs = require 'fs' +assert = require 'assert' +should = require('chai').should() +expect = require('chai').expect +{PurolatorClient} = require '../lib/purolator' +{ShipperClient} = require '../lib/shipper' +{Builder, Parser} = require 'xml2js' + +describe "purolator client", -> + _purolatorClient = null + _xmlParser = new Parser() + + before -> + _purolatorClient = new PurolatorClient( + {key: 'purolator-key', password: 'my-password'}, + {dev: 'true', token: 'user-token'} + ) + + describe "generateRequest", -> + _trackRequest = null + + before (done) -> + trackXml = _purolatorClient.generateRequest '330362235641' + _xmlParser.parseString trackXml, (err, data) -> + _trackRequest = data?['SOAP-ENV:Envelope'] + assert _trackRequest? + done() + + it 'contains the correct xml namespace and soap envelope', -> + _trackRequest.should.have.property '$' + _trackRequest['$']['xmlns:ns0'].should.equal 'http://purolator.com/pws/datatypes/v1' + _trackRequest['$']['xmlns:ns1'].should.equal 'http://schemas.xmlsoap.org/soap/envelope/' + _trackRequest['$']['xmlns:xsi'].should.equal 'http://www.w3.org/2001/XMLSchema-instance' + _trackRequest['$']['xmlns:tns'].should.equal 'http://purolator.com/pws/datatypes/v1' + _trackRequest['$']['xmlns:SOAP-ENV'].should.equal 'http://schemas.xmlsoap.org/soap/envelope/' + + it 'contains a valid request context', -> + _trackRequest.should.have.property 'SOAP-ENV:Header' + _context = _trackRequest['SOAP-ENV:Header'][0]['tns:RequestContext'][0] + _context.should.have.property 'tns:GroupID' + _context.should.have.property 'tns:RequestReference' + _context.should.have.property 'tns:UserToken' + _context['tns:Version'][0].should.equal '1.2' + _context['tns:Language'][0].should.equal 'en' + + it 'contains a valid tracking pin', -> + _trackRequest.should.have.property 'ns1:Body' + _pins = _trackRequest['ns1:Body'][0]['ns0:TrackPackagesByPinRequest'][0]['ns0:PINs'][0] + _pins['ns0:PIN'][0]['ns0:Value'][0].should.equal '330362235641' + + describe "integration tests", -> + _package = null + + describe "delivered package", -> + before (done) -> + fs.readFile 'test/stub_data/purolator_delivered.xml', 'utf8', (err, xmlDoc) -> + _purolatorClient.presentResponse xmlDoc, 'trk', (err, resp) -> + should.not.exist(err) + _package = resp + done() + + it "has a status of delivered", -> + expect(_package.status).to.equal ShipperClient.STATUS_TYPES.DELIVERED + + it "has 11 activities", -> + expect(_package.activities).to.have.length 11 + + it "has first activity with timestamp, location and details", -> + act = _package.activities[0] + expect(act.timestamp).to.deep.equal new Date '2015-10-01T16:43:00.000Z' + expect(act.details).to.equal 'Shipment delivered to' + expect(act.location).to.equal 'Burnaby, BC' + + it "has last activity with timestamp, location and details", -> + act = _package.activities[10] + expect(act.timestamp).to.deep.equal new Date '2015-10-01T16:42:00.000Z' + expect(act.details).to.equal 'Shipment created' + expect(act.location).to.equal null + diff --git a/test/stub_data/purolator_delivered.xml b/test/stub_data/purolator_delivered.xml new file mode 100644 index 0000000..f08ac41 --- /dev/null +++ b/test/stub_data/purolator_delivered.xml @@ -0,0 +1 @@ +Shiprack Package Tracker330362235641Delivery330362235641BURNABY, BC2015-10-01164300Shipment delivered tofalseK BONCI0GIFNot known or specifiedNot known or specifiedUndeliverable330362235641BURNABY, BC2015-10-01164300Available for pickup for 5 business days from arrival date at the counter.falseUndeliverable330362235641BURNABY, BC2015-10-01135900Attempted Delivery - Customer ClosedfalseOnDelivery330362235641BURNABY, BC2015-10-01135500On vehicle for deliveryfalseOther330362235641BURNABY, BC2015-09-29065100Arrived at sort facilityfalseOther330362235641VANCOUVER, BC2015-09-29055200Departed sort facilityfalseOther330362235641VANCOUVER, BC2015-09-29051400Arrived at sort facilityfalseOther330362235641WINNIPEG AIRPORT/AEROPORT, MB2015-09-28234900Departed sort facilityfalseOther330362235641REGINA, SK2015-09-28153100Arrived at sort facilityfalseOther330362235641REGINA, SK2015-09-28081900Picked up by Purolator atfalseUndeliverable330362235641Purolator2015-10-01164200Shipment createdfalse