File Coverage

lib/WebService/GeoPost/Shipping/API.pm
Criterion Covered Total %
statement 104 150 69.3
branch 18 54 33.3
condition 10 57 17.5
subroutine 22 34 64.7
pod 19 21 90.4
total 173 316 54.7


line stmt bran cond sub pod time code
1             package WebService::GeoPost::Shipping::API;
2 1     1   33444 use strict;
  1         2  
  1         30  
3 1     1   4 use warnings;
  1         2  
  1         26  
4 1     1   3 use Carp;
  1         5  
  1         67  
5 1     1   1463 use Moo;
  1         16041  
  1         7  
6 1     1   4513 use LWP::UserAgent;
  1         145990  
  1         45  
7 1     1   6835 use HTTP::Request::Common;
  1         2974  
  1         198  
8 1     1   8 use URI::Escape;
  1         1  
  1         82  
9 1     1   6501 use Data::Dumper;
  1         10902  
  1         144  
10 1     1   1131 use JSON;
  1         20193  
  1         10  
11 1     1   1142 use MIME::Base64;
  1         867  
  1         109  
12 1     1   629 use namespace::clean;
  1         17845  
  1         13  
13              
14             # ABSTRACT: communicates with GeoPost API
15              
16             our $VERSION = 'v0.0004';
17              
18            
19             =head1 NAME
20              
21             WebService::GeoPost::Shipping::API
22              
23             =head1 SYNOPSIS
24              
25              
26             $geopost = WebService::GeoPost::Shipping::API->new(
27             username => $username,
28             password => $password,
29             geoClient => "account/$customer_id",
30             );
31             =cut
32              
33             =head1 DESCRIPTION
34              
35             This module provides a simple wrapper around GeoPost delivery service API. This is a work in progress and contains incomplete test code, methods are likely to be refactored, you have been warned.
36              
37              
38             =head1 METHODS
39              
40             =cut
41              
42              
43             has username => (
44             is => 'ro',
45             required => 1,
46             );
47              
48             has password => (
49             is => 'ro',
50             required => 1,
51             );
52              
53             has url => ( is => 'ro',
54             default => 'https://api.dpd.co.uk'
55             );
56              
57             has host => ( is => 'ro',
58             lazy => 1,
59             default => sub {
60             my $self=shift;
61             my $url = $self->url;
62             $url =~ s/^https{0,1}.:\/\///;
63             return $url; },
64             );
65              
66             has ua => (
67             is => 'rw',
68             );
69              
70             has geoSession => (
71             is => 'rw',
72             );
73              
74             has geoClient => (
75             is => 'ro',
76             default => 'thirdparty/pryanet',
77             );
78              
79             has debug => (
80             is => 'rw',
81             default => 0,
82             );
83              
84             has errstr => (
85             is => 'rw',
86             default => '',
87             );
88              
89             sub BUILD
90             {
91 1     1 0 3624 my $self = shift;
92 1         13 $self->ua( LWP::UserAgent->new );
93 1         10011 $self->ua->agent("Perl_WebService::GeoPost::Shipping::API/$VERSION");
94 1         101 $self->ua->cookie_jar({});
95             }
96              
97              
98              
99              
100             =head2 login
101              
102             Authenticates and establishes api session used by following methods
103              
104             $geopost->login;
105              
106             =cut
107             sub login
108             {
109 1     1 1 2 my $self = shift;
110 1         29 my $result = $self->send_request( {
111             path => '/user/?action=login',
112             type => 'POST',
113             header => {
114             Authorization => 'Basic ' . encode_base64($self->username . ':' . $self->password, ''),
115             },
116             } );
117 1         11 $self->geoSession( $result->{geoSession} );
118 1         706 return $result;
119             }
120              
121             =head2 get_country( $code )
122              
123             Retrieves the country details for a provided country code and can be used to determine if a country requires a postcode or if liability is allowed etc.
124              
125             $country = $geopost->get_country( 'GB' );
126            
127             =cut
128             sub get_country
129             {
130 1     1 1 3 my ( $self, $code ) = @_;
131 1 50 0     5 $self->errstr( "No country code" ) and return unless $code;
132 1         9 return $self->send_request ( {
133             path => '/shipping/country/' . $code,
134             } );
135             }
136              
137             =head2 get_services( \%shipping_information )
138              
139             Retrieves list of services available for provided shipping information.
140              
141             my $address = {
142             countryCode => 'GB',
143             county => 'West Midlands',
144             locality => 'Birmingham',
145             organisation => 'GeoPost',
146             postcode => 'B661BY',
147             property => 'GeoPost UK',
148             street => 'Roebuck Ln',
149             town => 'Smethwick',
150             };
151              
152             my $shipping = {
153             collectionDetails => {
154             address => $address,
155             },
156             deliveryDetails => {
157             address => $address,
158             },
159             deliveryDirection => 1, # 1 for outbound 2 for inbound
160             numberOfParcels => 1,
161             totalWeight => 5,
162             shipmentType => 0, # 1 or 2 if a collection on delivery or swap it service is required
163             };
164              
165             my $services = $geopost->get_services( $shipping );
166              
167              
168             =cut
169             sub get_services
170             {
171 1     1 1 439 my ( $self, $shipping ) = @_;
172 1 50 0     5 $self->errstr( "No shipping information" ) and return unless $shipping;
173 1         6 return $self->send_request ( {
174             path => '/shipping/network/?' . $self->_to_query_params($shipping),
175             } );
176             }
177              
178             =head2 get_service( geoServiceCode )
179              
180             Retrieves the supported countries for a geoServiceCode
181              
182             $service = $geopost->get_service(812);
183              
184             =cut
185             sub get_service
186             {
187 1     1 1 357 my ( $self, $geoServiceCode ) = @_;
188 1 50 0     3 $self->errstr( "No geoServiceCode" ) and return unless $geoServiceCode;
189 1         7 return $self->send_request ( {
190             path => "/shipping/network/$geoServiceCode/",
191             } );
192             }
193              
194             =head2 create_shipment( \%data )
195              
196             Creates a shipment object
197              
198             my $shipment_data = {
199             jobId => 'null',
200             collectionOnDelivery => "false",
201             invoice => "null",
202             collectionDate => $date,
203             consolidate => "false",
204             consignment => [
205             {
206             collectionDetails => {
207             contactDetails => {
208             contactName => "Mr David Smith",
209             telephone => "0121 500 2500"
210             },
211             address => $address,
212             },
213             deliveryDetails => {
214             contactDetails => {
215             contactName => "Mr David Smith",
216             telephone => "0121 500 2500"
217             },
218             notificationDetails => {
219             mobile => "07921 123456",
220             email => 'david.smith@acme.com',
221             },
222             address => {
223             organisation => "ACME Ltd",
224             property => "Miles Industrial Estate",
225             street => "42 Bridge Road",
226             locality => "",
227             town => "Birmingham",
228             county => "West Midlands",
229             postcode => "B1 1AA",
230             countryCode => "GB",
231             }
232             },
233             networkCode => "1^12",
234             numberOfParcels => '1',
235             totalWeight => '5',
236             shippingRef1 => "Catalogue Batch 1",
237             shippingRef2 => "Invoice 231",
238             shippingRef3 => "",
239             customsValue => '0',
240             deliveryInstructions => "Please deliver to industrial gate A",
241             parcelDescription => "",
242             liabilityValue => '0',
243             liability => "false",
244             parcels => [],
245             consignmentNumber => "null",
246             consignmentRef => "null",
247             }
248             ]
249             };
250              
251              
252             $shipment = $geopost->create_shipment( $shipment_data_example );
253              
254             =cut
255             sub create_shipment
256             {
257 1     1 1 708 my ( $self, $data ) = @_;
258 1 50 0     5 $self->errstr( "No data" ) and return unless $data;
259 1         6 return $self->send_request ( {
260             type => 'POST',
261             path => "/shipping/shipment",
262             data => $data,
263             } );
264             }
265              
266             =head2 list_countries
267              
268             Provides a full list of available shipping countries
269              
270             $countries = $geopost->list_countries;
271              
272             =cut
273              
274             sub list_countries
275             {
276 1     1 1 549 my $self = shift;
277 1         9 return $self->send_request ( {
278             path => '/shipping/country',
279             } );
280             }
281              
282             =head2 get_labels( $shipment_id, $format )
283              
284             Get label for given shipment id, available in multiple formats
285              
286             $label = $geopost->get_labels( $shipment_id, 'application/pdf' );
287              
288             =cut
289             sub get_labels
290             {
291 1     1 1 582 my ( $self, $id, $format ) = @_;
292 1 50 0     13 $self->errstr( "No shipment ID/format provided" ) and return unless ( $id and $format );
      33        
293 1         13 return $self->send_request ( {
294             path => "/shipping/shipment/$id/label/",
295             header => {
296             Accept => $format,
297             },
298             raw_result => 1,
299             } );
300              
301             }
302              
303              
304             =head1 FUTURE METHODS
305              
306             These methods are implemented as documented in the GeoPost API specification. Although at the time of writing their functionality has not been publicly implemented within the API.
307              
308             =cut
309              
310              
311             =head2 request_job_id
312              
313             Get a job id to group shipments
314              
315             $job_id = $geopost->request_jobid;
316              
317             =cut
318             sub request_jobid
319             {
320 0     0 0 0 my ( $self ) = @_;
321 0         0 return $self->send_request( {
322             type => 'GET',
323             path => '/shipping/job/',
324             header => {
325             Accept => 'application/json',
326             }
327             } );
328             }
329              
330             =head2 get_labels_for_job( $id, $format )
331              
332             Retrieves all labels for a given job id
333              
334             $labels = $geopost->get_labels_for_job( $id, $format );
335              
336             =cut
337             sub get_labels_for_job
338             {
339 0     0 1 0 my ( $self, $id, $format ) = @_;
340 0 0 0     0 $self->errstr( "No id provided" ) and return unless $id;
341 0 0 0     0 $self->errstr( "No format provided" ) and return unless $format;
342 0         0 return $self->send_request( {
343             path => "/shipping/job/$id/label",
344             header => {
345             Accept => $format,
346             }
347             } );
348             }
349              
350              
351             =head2 get_shipments( \%search_params )
352              
353             Retrieves a full list of shipments meeting the search criteria and/or collection date. If no URL parameters are set the default settings brings back the first 100 shipments found.
354              
355             $shipments = $self->get_shipments( {
356             collectionDate => $date,
357             searchCriterea => 'foo',
358             searchPage => 1,
359             searchPageSize => 20,
360             useTemplate => false,
361             });
362             =cut
363             sub get_shipments
364             {
365 0     0 1 0 my ( $self, $params ) = @_;
366 0         0 my $path = '/shipping/shipment/';
367 0 0       0 $path .= '?' . $self->_to_query_params($params) if $params;
368 0         0 return $self->send_request( {
369             path => $path,
370             } );
371              
372             }
373              
374             =head2 get_shipment( $id )
375              
376             Retrieves all shipment information associated with a shipment id
377              
378             $shipment = $geopost->get_shipment( $id );
379              
380             =cut
381             sub get_shipment
382             {
383 0     0 1 0 my ( $self, $id ) = @_;
384 0 0 0     0 $self->errstr( "No id provided" ) and return unless $id;
385 0         0 return $self->send_request( {
386             path => "/shipping/shipment/$id/",
387             } );
388             }
389              
390             =head2 get_international_invoice( $shipment_id )
391              
392             Creates and returns an international invoice associated with the given shipment id.
393              
394             $invoice = $geopost->get_international_invoice( $shipment_id );
395              
396             =cut
397             sub get_international_invoice
398             {
399 0     0 1 0 my ( $self, $id ) = @_;
400 0 0 0     0 $self->errstr( "No shipment ID provided" ) and return unless $id;
401 0         0 return $self->send_request( {
402             path => "/shipping/shipment/$id/invoice/",
403             header => {
404             Accept => 'text/html',
405             },
406             raw_result => 1,
407             } );
408             }
409              
410             =head2 get_unprinted_labels( $date, $format )
411              
412             Retrieves all labels that have not already been printed for a particular collection date.
413              
414             $labels = $geopost->get_unprinted_labels( $date, $format );
415              
416             =cut
417             sub get_unprinted_labels
418             {
419 0     0 1 0 my ( $self, $date, $format ) = @_;
420 0 0 0     0 $self->errstr( "No date" ) and return unless $date;
421 0         0 return $self->send_request( {
422             path => "/shipping/shipment/_/label/?collectionDate=$date",
423             header => {
424             Accept => $format,
425             }
426             } );
427             }
428              
429             =head2 delete_shipment( $id )
430              
431             Delete a shipment
432              
433             $geopost->delete_shipment( $id );
434              
435             =cut
436             sub delete_shipment
437             {
438 0     0 1 0 my ( $self, $id ) = @_;
439 0 0 0     0 $self->errstr( "No id provided" ) and return unless $id;
440 0         0 return $self->send_request( {
441             type => 'DELETE',
442             path => "/shipping/shipment/$id/",
443             } );
444             }
445              
446             =head2 change_collection_date( $id, $date )
447              
448             Update collection date for a shipment
449              
450             $geopost->change_collection_date( $id, $date );
451              
452             =cut
453             sub change_collection_date
454             {
455 0     0 1 0 my ( $self, $id, $date ) = @_;
456 0 0 0     0 $self->errstr( "No id provided" ) and return unless $id;
457 0 0 0     0 $self->errstr( "No date provided" ) and return unless $date;
458 0         0 return $self->send_request( {
459             type => 'PUT',
460             path => "/shipping/shipment/$id/?action=ChangeCollectionDate",
461             data => {
462             collectionDate => $date,
463             }
464             } );
465             }
466              
467             =head2 void_shipment
468              
469             Update status of shipment to void.
470              
471             $geopost->void_shipment( $id );
472              
473             =cut
474             sub void_shipment
475             {
476 0     0 1 0 my ( $self, $id ) = @_;
477 0 0 0     0 $self->errstr( "No id provided" ) and return unless $id;
478 0         0 return $self->send_request( {
479             type => 'PUT',
480             path => "/shipping/shipment/$id/?action=Void",
481             data => {
482             isVoided => 'true',
483             },
484             } );
485             }
486              
487             =head2 create_manifest
488              
489             Tag all non manifested shipments for a collection date with a new generated manifest id.
490              
491             $manifest = $geopost->create_manifest( $date );
492              
493             =cut
494             sub create_manifest
495             {
496 0     0 1 0 my ( $self, $date ) = @_;
497 0 0 0     0 $self->errstr( "No date provided" ) and return unless $date;
498 0         0 return $self->send_request( {
499             type => 'POST',
500             path => '/shipping/manifest/',
501             data => {
502             collectionDate => $date,
503             },
504             } );
505             }
506              
507             =head2 get_manifest_by_date( $date )
508              
509             Retrieves all the manifests and the core manifest information for a particular collection date.
510            
511             $manifests = $geopost->get_manifest_by_date( $date );
512              
513             =cut
514             sub get_manifest_by_date
515             {
516 0     0 1 0 my ( $self, $date ) = @_;
517 0         0 return $self->send_request( {
518             path => "/shipping/manifest/?collectionDate=$date",
519             } );
520             }
521              
522             =head2 get_manifest_by_id( $id )
523              
524             Get printable manifest by its associated manifest id
525              
526             $manifest = get_manifest_by_id( $id );
527             =cut
528             sub get_manifest_by_id
529             {
530 0     0 1 0 my ( $self, $id ) = @_;
531 0 0 0     0 $self->errstr( "No id provided" ) and return unless $id;
532 0         0 return $self->send_request( {
533             path => "/shipping/manifest/$id",
534             header => {
535             Accept => 'text/html',
536             },
537             } );
538             }
539              
540              
541             =head1 INTERNAL METHODS
542              
543             =cut
544              
545             =head2 _to_query_params
546              
547             Recursively converts hash of hashes into query string for http request
548              
549             =cut
550             sub _to_query_params
551             {
552 1     1   1 my ( $self, $data ) = @_;
553 1         2 my @params;
554             my $sub;
555             $sub = sub {
556 5     5   5 my ( $name, $data ) = @_;
557 5         14 for ( keys %$data )
558             {
559 24 100       33 if ( ref $data->{$_} eq 'HASH' )
560             {
561 4         12 $sub->( "$name.$_", $data->{$_} );
562             }
563             else
564             {
565 20         55 push @params, { key => "$name.$_", value => $data->{$_} };
566             }
567             }
568 1         6 };
569 1         3 $sub->( '', $data);
570 1         2 my $query;
571 1         3 for ( @params )
572             {
573 20         197 $_->{key} =~ s/^\.//;
574 20         34 $query .= $_->{key} . '='. uri_escape( $_->{value} ) . '&';
575             }
576 1         14 $query =~ s/&$//;
577 1         15 return $query;
578             }
579              
580             =head2 send_request( \%args )
581              
582             Constructs and sends authenticated HTTP API request
583              
584             $result = $geopost->send_request( {
585             type => 'POST', # HTTP request type defaults to GET
586             path => "/path/to/service", # Path to service
587             data => { # hashref of data for POST/PUT requests, converted to JSON for sending
588             key1 => 'value1',
589             key2 => 'value2',
590             },
591             content_type => 'appilcation/json', # defaults to application/json
592             header => { # hashref of additional headers
593             Accept => $format,
594             }
595              
596             } );
597              
598             =cut
599             sub send_request
600             {
601 7     7 1 20 my ( $self, $args ) = @_;
602 7   100     48 my $type = $args->{type} || 'GET';
603 7         101 my $req = HTTP::Request->new($type => $self->url . $args->{path} );
604             #Required headers
605 7         13577 $req->header( Host => $self->host );
606 7         591 $req->protocol('HTTP/1.1');
607 7         95 $req->header( GEOClient => $self->geoClient );
608 7 100       376 $req->header( GEOSession => $self->geoSession ) if $self->geoSession;
609            
610             #Per request overridable
611 7   50     442 $req->content_type( $args->{content_type} || 'application/json' );
612 7   100     201 $req->header( Accept => $args->{header}->{Accept} || 'application/json' );
613              
614             #Custom headers
615 7         267 for ( keys %{ $args->{header} } )
  7         41  
616             {
617 2         10 $req->header( $_ => $args->{header}->{$_} );
618             }
619              
620 7 100 66     126 if ( $args->{data} and $type =~ /^(POST|PUT)$/ )
621             {
622 1         6 my $content = to_json( $args->{data} );
623             #hacky translation to correct representation of null and boolean values
624 1         134 $content =~ s/"null"/null/gi;
625 1         46 $content =~ s/"false"/false/gi;
626 1         40 $content =~ s/"true"/true/gi;
627 1         10 $req->content( $content );
628             }
629              
630             #Send request
631 7 50       88 warn $req->as_string if $self->debug;
632 7         49 my $response = $self->ua->request($req);
633 7 50       3325205 warn $response->as_string if $self->debug;
634 7 50       26 if ( $response->code == 200 )
635             {
636 7         68 my $result;
637             #FIXME assumes JSON
638 7         16 eval{ $result = JSON->new->utf8->decode($response->content) };
  7         160  
639 7 50 0     2962 $self->errstr("Server response was invalid\n") and return if $@ and ! $args->{raw_result};
      66        
640 7 50       35 if ( $result->{error} )
641             {
642 0 0       0 my $error = ref $result->{error} eq 'ARRAY' ? $result->{error}->[0] : $result->{error};
643 0   0     0 my $error_type = $error->{errorType} || '';
644 0   0     0 my $error_obj = $error->{obj} || '';
645 0   0     0 my $error_code = $error->{errorCode} || '';
646 0   0     0 my $error_message = $error->{errorMessage} || '';
647 0         0 $self->errstr( "$error_type error : $error_obj : $error_code : $error_message\n" );
648 0         0 return;
649             }
650 7         26 $result->{response} = $response;
651 7 100       28 if ( $args->{raw_result} )
652             {
653 1         4 $result->{data} = $response->content;
654             }
655 7         257 return $result->{data};
656             }
657             else
658             {
659 0           $self->errstr('API communication error: ' . $args->{path} . ': ' . $response->status_line . "\n\n\n\n");
660 0           return;
661             }
662             }
663              
664             1;
665              
666             =head1 SOURCE CODE
667              
668             The source code for this module is held in a public git repository on Github : https://github.com/pryanet/WebService-GeoPost-Shipping-API
669              
670             =head1 LICENSE AND COPYRIGHT
671            
672             Copyright (c) 2014 Richard Newsham, Pryanet Ltd
673            
674             This library is free software; you can redistribute it and/or
675             modify it under the same terms as Perl itself.
676            
677             =head1 BUGS AND LIMITATIONS
678            
679             See rt.cpan.org for current bugs, if any.
680            
681             =head1 INCOMPATIBILITIES
682            
683             None known.
684            
685             =head1 DEPENDENCIES
686              
687             Carp
688             Moo
689             LWP::UserAgent
690             LWP::Protocol::https
691             HTTP::Request::Common
692             URI::Escape
693             Data::Dumper
694             JSON
695             MIME::Base64
696             namespace::clean
697              
698             =cut