File Coverage

blib/lib/Net/Joker/DMAPI.pm
Criterion Covered Total %
statement 38 142 26.7
branch 0 36 0.0
condition 0 8 0.0
subroutine 13 25 52.0
pod 6 6 100.0
total 57 217 26.2


line stmt bran cond sub pod time code
1             package Net::Joker::DMAPI;
2              
3             our $VERSION = '0.09';
4 1     1   64280 use strict;
  1         3  
  1         37  
5 1     1   6 use warnings;
  1         1  
  1         37  
6 1     1   28 use 5.010;
  1         9  
7 1     1   6 use Carp;
  1         1  
  1         82  
8 1     1   708 use Data::Censor;
  1         905  
  1         34  
9 1     1   653 use Data::Dump;
  1         7337  
  1         53  
10 1     1   887 use DateTime;
  1         310022  
  1         39  
11 1     1   696 use Hash::Merge;
  1         1786  
  1         39  
12 1     1   569 use LWP::UserAgent;
  1         26521  
  1         26  
13 1     1   676 use Moo;
  1         5319  
  1         5  
14 1     1   1487 use MooX::Types::MooseLike::Base qw(:all);
  1         4504  
  1         292  
15 1     1   5 use URI;
  1         1  
  1         1074  
16              
17             =head1 NAME
18              
19             Net::Joker::DMAPI - interface to Joker's Domain Management API
20              
21             =head1 DESCRIPTION
22              
23             An attempt at a sane wrapper around Joker's DMAPI (domain management API).
24              
25             Automatically logs in, and parses responses into something a bit more usable as
26             much as possible.
27              
28             =head1 SYNOPSIS
29              
30             my $dmapi = Net::Joker::DMAPI->new(
31             username => 'bob@example.com',
32             password => 'hunter2',
33             );
34              
35             # Get whois details for a domain - returns parsed data structure
36             my $whois_details = $dmapi->query_whois({ domain => $domain });
37             my @nameservers = @{ $whos_details->{domain}{nameservers} };
38            
39             # can also use query_whois on contact handles
40             my $admin_handle_details = $dmapi->query_whois(
41             { contact => $whois_details->{domain}{admin_c} }
42             );
43            
44             my $current_balance = $dmapi->current_balance;
45              
46             my $tlds = $dmapi->available_tlds;
47              
48              
49             =head1 ATTRIBUTES
50              
51             =over
52              
53             =item username
54              
55             Your Joker account username.
56              
57             =cut
58              
59             has username => (
60             is => 'rw',
61             isa => Str,
62             );
63              
64             =item password
65              
66             Your Joker account password
67              
68             =cut
69              
70             has password => (
71             is => 'rw',
72             isa => Str,
73             );
74              
75             =item debug
76              
77             Whether to omit debug messages; disabled by default, set to a true value to
78             enable. See also the C<logger> attribute to which you can provide a coderef
79             which will be called with messages. If C<debug> is true, all messages will be
80             output to STDOUT as well as passed to the C<logger> coderef (if provided).
81              
82             =cut
83              
84             has debug => (
85             is => 'rw',
86             isa => Int,
87             default => 0,
88             );
89              
90             =item logout_on_destroy
91              
92             End the Joker session upon object destruction, 1 by default.
93              
94             =cut
95              
96             has logout_on_destroy => (
97             is => 'rw',
98             isa => Int,
99             default => 1,
100             );
101              
102             =item ua
103              
104             An LWP::UserAgent object to use. One is constructed by default, so you don't
105             need to supply this unless you have a specific need to do so.
106              
107             =cut
108              
109             has ua => (
110             is => 'rw',
111             isa => InstanceOf['LWP::UserAgent'],
112             builder => 1,
113             );
114             sub _build_ua {
115 0     0     my $ua = LWP::UserAgent->new;
116 0           $ua->agent(__PACKAGE__ . "/$VERSION");
117 0           return $ua;
118             }
119              
120             =item dmapi_url
121              
122             The URL to Joker's DMAPI. You won't need to provide this unless you for some
123             reason need to have requests go elsewhere; it defaults to Joker's live DMAPI
124             URL.
125              
126             =cut
127              
128             has dmapi_url => (
129             is => 'rw',
130             isa => Str,
131             default => 'https://dmapi.joker.com/request',
132             );
133              
134             =item balance
135              
136             The current balance of your Joker account; automatically updated each time a
137             response from the Joker API is received.
138              
139             =cut
140              
141             has balance => (
142             is => 'rw',
143             isa => Str,
144             );
145              
146             =item available_tlds_list
147              
148             An arrayref of TLDs which are available to the reseller. Joker return this in
149             response to the login call, so this is populated after login; it's recommended
150             you access it via the C<available_tlds> method (see below) though, which will
151             call C<login> for you first then return the list.
152              
153             =cut
154              
155             has available_tlds_list => (
156             is => 'rw',
157             isa => ArrayRef,
158             );
159              
160              
161             =item logger
162              
163             A coderef to be used to log interactions with Joker; if this is defined, the
164             coderef provided is called with the log messages, so you can log them however
165             your application usually does.
166              
167             The coderef will be called with two parameters - the log level (C<debug>,
168             C<info>, C<error>), and the message.
169              
170             =cut
171              
172             has logger => (
173             is => 'rw',
174             isa => CodeRef,
175             predicate => 'has_logger',
176             );
177              
178             has auth_sid => (
179             is => 'rw',
180             isa => Str,
181             default => '',
182             predicate => 'has_auth_sid',
183             );
184              
185             =back
186              
187             =head1 METHODS
188              
189             =over
190              
191             =item login
192              
193             Logs in to the Joker DMAPI, retrieves the C<Auth-Sid> from the response, and
194             stores it in the C<auth_sid> attribute for future requests. You won't usually
195             need to call this, as it will happen automatically if you use the convenience
196             methods, but if you want to poke at C<do_request> yourself, you'll need it.
197              
198             =cut
199              
200             has _joker_session_expiry_time => (
201             is => 'rw',
202             isa => Int,
203             );
204              
205             sub _joker_session_still_valid {
206 0     0     my $self = shift;
207              
208 0 0         return 1 if $self->_joker_session_expiry_time > time();
209             }
210              
211             sub login {
212 0     0 1   my $self = shift;
213            
214             # If we've already logged in, we're fine
215 0 0 0       if ($self->auth_sid && $self->_joker_session_still_valid()) {
216 0           $self->_log(debug => "Already have auth_sid, no need to log in");
217 0           return 1;
218             }
219              
220 0           $self->_log(info => "Logging in as " . $self->username);
221 0           my ($login_body, %headers) = $self->do_request(
222             'login',
223             { username => $self->username, password => $self->password }
224             );
225              
226             my $session_timeout
227             = exists $headers{'Session-Timeout'}
228 0 0         ? $headers{'Session-Timeout'}
229             : 3600;
230              
231 0           $self->_joker_session_expiry_time(time() + $session_timeout);
232             # If we got back an Auth-Sid: header, do_request will have
233             # $self->auth_sid with it, so check that happened - if not, login failed
234 0 0         if (!$self->has_auth_sid) {
235 0           $self->_log(error => "Login request failed!");
236 0           die "Login request did not return an Auth-Sid";
237             }
238              
239             # OK, the response body to the login call, strangely, is a list of TLDs
240             # we can sell. Parse it and store it for reference.
241 0           my @tlds = split /\n/, $login_body;
242 0           $self->available_tlds_list([sort @tlds]);
243              
244 0           $self->_log(debug => "Login was successful");
245             }
246              
247              
248             =item do_request
249              
250             Takes the method name you want to call, and a hashref of arguments, calls the
251             method, and returns the response.
252              
253             For instance:
254              
255             my $response = $dmapi->do_request('query-whois', { domain => $domain });
256              
257             The response returned is as given by Joker's (inconsistent) API, though; so
258             you'll probably want to look for a suitable method in this class which takes
259             care of parsing the response and returning something useful. If a method for
260             the DMAPI method you wish to use doesn't yet exist, contact me or submit a patch
261             :) In particular, some requests don't return the result, just an ID which
262             you'll then need to use to poll for the result.
263              
264             =cut
265              
266             # Given a method name and some params, perform the request, check for success,
267             # and return the result
268             sub do_request {
269 0     0 1   my ($self, $method, $params) = @_;
270              
271 0   0       $params ||= {};
272 0           my $url = $self->_form_request_url($method, $params);
273 0           $self->_log(
274             info => "Calling $method with params: "
275             . Data::Dump::dump(Data::Censor->clone_and_censor($params))
276             );
277 0           my $response = $self->ua->get($url);
278              
279 0           $self->_log(
280             info => "$method response status " . $response->status_line
281             . " - body: " . $response->decoded_content
282             );
283              
284 0 0         if (!$response->is_success) {
285 0           my $error = "$method request failed: " . $response->status_line;
286 0           $self->_log( error => $error );
287 0           die $error;
288             } else {
289 0           my $content = $response->decoded_content;
290              
291             # Response will consist of some headers (e.g. Version, Status-Text,
292             # Status-Code) then some body lines
293 0           my ($headers_blob, $body) = split /(?:\r?\n){2,}/, $content, 2;
294 0           my %headers;
295 0           for my $header (split /\r?\n/, $headers_blob) {
296 0           my ($k,$v) = split /:\s/, $header, 2;
297 0           $headers{$k} = $v;
298             }
299              
300 0           my ($dmapi_major_version) = $headers{Version} =~ /^(\d+\.\d+)\.?/;
301 0 0         if ($dmapi_major_version ne '1.2') {
302 0           warn __PACKAGE__ . " $VERSION has not been tested with Joker"
303             . " DMAPI version $headers{Version}";
304             }
305 0 0         if ($headers{'Status-Code'} != 0) {
306             my $error = "Joker request failed with status "
307 0           . $headers{'Status-Text'};
308 0           $self->_log(error => $error);
309 0           die $error;
310             }
311              
312 0 0         $self->balance($headers{'Account-Balance'}) if defined $headers{'Account-Balance'};
313 0 0         $self->auth_sid($headers{'Auth-Sid'}) if $headers{'Auth-Sid'};
314              
315 0 0         return wantarray ? ($body, %headers) : $body;
316             };
317             }
318              
319             =item available_tlds
320              
321             Returns the list of TLDs which are available to the reseller to sell.
322              
323             =cut
324              
325             sub available_tlds {
326 0     0 1   my $self = shift;
327 0           $self->login;
328 0           return $self->available_tlds_list;
329             }
330              
331             =item account_balance
332              
333             Returns the current balance of your Joker account. The C<balance> attribute is
334             automatically updated after every API call; this method is simply provided to
335             ensure you're logged in and return the balance - useful if you want to monitor
336             the balance from a Nagios plugin, say, rather than just seeing what the balance
337             was after making another API call.
338              
339             =cut
340              
341             sub account_balance {
342 0     0 1   my $self = shift;
343 0           $self->login;
344 0           return $self->balance;
345             }
346              
347             =item query_whois
348              
349             A convenient method to call the DMAPI C<query_whois> method, and return the
350             response after parsing it into something useful.
351              
352             my $whois = $dmapi->query_whois({ domain => $domain });
353              
354             The DMAPI accepts C<domain>, C<contact> or C<host>, to look up domains, contact
355             handles or nameservers respectively.
356              
357             The response is parsed into a data structure - for instance, the domain's
358             status, which is returned by Joker as C<domain.status>, will be found at
359             C<$whois->{domain}{status}>. Nameservers are collated into a hashref.
360             Datetimes returned by Joker are automatically inflated to DateTime objects.
361              
362             =cut
363              
364             sub query_whois {
365 0     0 1   my ($self, $params) = @_;
366 0           my @acceptable_params = qw(domain contact host);
367 0           my @specs = grep { defined $_ } map { $params->{$_} } @acceptable_params;
  0            
  0            
368 0 0         if (scalar @specs != 1) {
369 0           Carp::croak(
370             "query_whois must be called with exactly one of the params: "
371             . join ',', @acceptable_params
372             );
373             }
374 0           $self->login;
375 0           my $result = $self->do_request('query-whois', $params);
376              
377 0           return $self->_parse_whois_response($result);
378             }
379              
380             =item expiry_date
381              
382             Returns the expiry date for the given domain.
383              
384             my $expires_datetime = $dmapi->expiry_date($domain);
385              
386             =cut
387              
388             sub expiry_date {
389 0     0 1   my ($self, $domain) = @_;
390 0           return $self->query_whois({ domain => $domain })->{domain}{expires};
391             }
392              
393             # Given a method name and parameters, return the appropriate URL for the request
394             sub _form_request_url {
395 0     0     my ($self, $method, $args) = @_;
396 0           my $uri = URI->new($self->dmapi_url . "/$method");
397 0 0         $uri->query_form({ 'auth-sid' => $self->auth_sid, %{ $args || {} } });
  0            
398 0           return $uri->canonical;
399             }
400              
401              
402             # Log stuff by calling the logger coderef if provided.
403             # If debug is true, also output it to STDERR.
404             sub _log {
405 0     0     my ($self, $level, $message) = @_;
406 0 0         if ($self->has_logger) {
407 0           $self->logger->($level, $message);
408             }
409 0 0         if ($self->debug) {
410 0           print STDERR "($level) $message\n";
411             }
412             }
413            
414              
415              
416             # Parse the format we get back from query-whois into a sensible data strucuture
417             # The format looks like lines in the format:
418             # domain.status: lock,transfer-autoack
419             # domain.name: J Example
420             # domain.created.date: 20000914175917
421             # ...etc - and we want to parse that into a data structure, e.g.:
422             # { domain => { status => '...', name => '...', created => { date => '...' } } }
423             # TODO: may need a more generic name if this format is used for other API
424             # responses
425             sub _parse_whois_response {
426 0     0     my ($self, $response) = @_;
427              
428 0           my $results = {};
429 0           my @nameservers;
430             my %key_value_pairs = (
431             map {
432 0           my ($key, $value) = $_ =~ /(\S+): (.+)/;
  0            
433             # BODGE: don't like doing this in the map, but the data will be
434             # lost if we do it later, as Joker return multiple nameservers
435             # as pairs of lines like:
436             # domain.nservers.nserver.no: 1
437             # domain.nservers.nserver.handle: ns.example.com
438 0 0         if ($key eq 'domain.nservers.nserver.handle') {
439 0           push @nameservers, $value;
440             }
441             # For easier use as hashref keys, swap hyphens for underscores
442 0           $key =~ s/-/_/g;
443 0           $key => $value
444             } split /\n/, $response
445             );
446              
447             # First pass: match dates and inflate them into DateTime objects:
448 0           for my $date_key (grep {
449 0 0         $_ =~ /\.date$/ || $_ eq 'domain.expires'
450             } keys %key_value_pairs) {
451 0           $key_value_pairs{$date_key} =~ m{
452             (?<year> \d{4} )
453             (?<month> \d{2} )
454             (?<day> \d{2} )
455             (?<hour> \d{2} )
456             (?<minute> \d{2} )
457             (?<second> \d{2} )
458             }x;
459 1     1   448 my $dt = DateTime->new(%+);
  1         378  
  1         206  
  0            
460 0           $key_value_pairs{$date_key} = $dt;
461             }
462              
463             # This parsing code was based on a solution kindly supplied by atta on
464             # Freenode/#perl late one night when my brain couldn't quite attack this
465             # problem. Thanks, atta!
466 0           while (my($key, $value) = each %key_value_pairs) {
467 0           my @parts = split qr(\.), $key;
468 0           my $r->{ pop @parts } = $value;
469 0           my $aux;
470              
471 0           for my $part (reverse @parts) {
472 0           $aux = {};
473 0           $aux->{$part} = $r;
474 0           $r = $aux;
475             }
476 0           $results = Hash::Merge::merge($results, $r);
477             }
478            
479 0 0         if (@nameservers) {
480 0           $results->{domain}{nameservers} = \@nameservers;
481 0           delete $results->{domain}{nservers};
482             }
483 0           return $results;
484             }
485              
486              
487             # Destructor, to end session
488             sub DESTROY {
489 0     0     my ($self) = @_;
490 0 0 0       if ( $self->has_auth_sid && $self->logout_on_destroy ) {
491 0           $self->_joker_session_expiry_time(time());
492 0           $self->do_request('logout');
493             }
494             }
495              
496             =back
497              
498             =head1 AUTHOR
499              
500             David Precious C<< <davidp@preshweb.co.uk> >>
501              
502             =head1 ACKNOWLEDGEMENTS
503              
504             Tomasz Czepiel (@tczepiel)
505              
506              
507             =head1 BUGS / FEATURE REQUESTS
508              
509             If you've found a bug, or have a feature request or wish to contribute a patch,
510             this module is developed on GitHub - please feel free to raise issues or pull
511             requests against the repo at:
512             L<https://github.com/bigpresh/Net-Joker-DMAPI>
513              
514              
515             =head1 LICENSE AND COPYRIGHT
516              
517             Copyright 2014-17 David Precious.
518              
519             This program is free software; you can redistribute it and/or modify it
520             under the terms of either: the GNU General Public License as published
521             by the Free Software Foundation; or the Artistic License.
522              
523             See http://dev.perl.org/licenses/ for more information.
524              
525             =head1 SEE ALSO
526              
527             Joker's DMAPI documentation is at:
528             L<https://joker.com/faq/category/39/22-dmapi.html>
529              
530             L<WWW::Domain::Registry::Joker> is another module for talking to Joker's DMAPI,
531             but hasn't been updated for some time and doesn't provide any convenient methods
532             or parsing of responses, just the basics.
533              
534             =cut
535              
536              
537             1;
538