File Coverage

blib/lib/Net/WURFL/ScientiaMobile.pm
Criterion Covered Total %
statement 105 181 58.0
branch 14 52 26.9
condition 4 24 16.6
subroutine 23 34 67.6
pod 7 7 100.0
total 153 298 51.3


line stmt bran cond sub pod time code
1             #
2             # This software is the Copyright of ScientiaMobile, Inc.
3             #
4             # Please refer to the LICENSE.txt file distributed with the software for licensing information.
5             #
6             # @package NodeWurflCloudClient
7             #
8              
9             package Net::WURFL::ScientiaMobile;
10             our $VERSION = '1.0.3';
11              
12             use Exception::Class (
13 4         79 'Net::WURFL::ScientiaMobile::Exception',
14             'Net::WURFL::ScientiaMobile::Exception::InvalidCapability' => {
15             isa => 'Net::WURFL::ScientiaMobile::Exception',
16             },
17             'Net::WURFL::ScientiaMobile::Exception::HTTP' => {
18             isa => 'Net::WURFL::ScientiaMobile::Exception',
19             fields => ['response', 'code'],
20             },
21             'Net::WURFL::ScientiaMobile::Exception::Auth' => {
22             isa => 'Net::WURFL::ScientiaMobile::Exception::HTTP',
23             },
24             'Net::WURFL::ScientiaMobile::Exception::ApiKeyInvalid' => {
25             isa => 'Net::WURFL::ScientiaMobile::Exception::Auth',
26             description => 'API Authentication error, check your API Key',
27             },
28             'Net::WURFL::ScientiaMobile::Exception::NoAuthProvided' => {
29             isa => 'Net::WURFL::ScientiaMobile::Exception::Auth',
30             description => 'API Authentication error, check your API Key',
31             },
32             'Net::WURFL::ScientiaMobile::Exception::ApiKeyExpired' => {
33             isa => 'Net::WURFL::ScientiaMobile::Exception::Auth',
34             description => 'API Authentication error, your WURFL Cloud subscription is expired',
35             },
36             'Net::WURFL::ScientiaMobile::Exception::ApiKeyRevoked' => {
37             isa => 'Net::WURFL::ScientiaMobile::Exception::Auth',
38             description => 'API Authentication error, your WURFL Cloud subscription is revoked',
39             },
40             'Net::WURFL::ScientiaMobile::Exception::InvalidSignature' => {
41             isa => 'Net::WURFL::ScientiaMobile::Exception::Auth',
42             description => 'API Authentication error, your request signature is invalid',
43             },
44 4     4   103786 );
  4         54569  
45 4     4   28312 use JSON qw(decode_json);
  4         58500  
  4         29  
46 4     4   698 use List::Util qw(first sum);
  4         15  
  4         487  
47 4     4   4411 use LWP::UserAgent;
  4         206421  
  4         151  
48 4     4   4120 use Module::Load qw(load);
  4         4517  
  4         26  
49 4     4   4395 use Moo;
  4         73471  
  4         24  
50 4     4   11319 use Try::Tiny;
  4         6627  
  4         241  
51 4     4   26 use URI::Escape qw(uri_unescape);
  4         8  
  4         217  
52              
53 4     4   17 use constant ERROR_CONFIG => 1; # Configuration error
  4         40  
  4         168  
54 4     4   19 use constant ERROR_NO_SERVER => 2; # Unable to contact server or Invalid server address
  4         6  
  4         157  
55 4     4   35 use constant ERROR_TIMEOUT => 4; # Timed out while contacting server
  4         8  
  4         165  
56 4     4   20 use constant ERROR_BAD_RESPONSE => 8; # Unable to parse response
  4         7  
  4         177  
57 4     4   19 use constant ERROR_AUTH => 16; # API Authentication failed
  4         45  
  4         155  
58 4     4   17 use constant ERROR_KEY_DISABLED => 32; # API Key is disabled or revoked
  4         6  
  4         158  
59 4     4   15 use constant SOURCE_NONE => 'none'; # No detection was performed
  4         7  
  4         165  
60 4     4   17 use constant SOURCE_CLOUD => 'cloud'; # Response was returned from cloud
  4         4  
  4         149  
61 4     4   16 use constant SOURCE_CACHE => 'cache'; # Response was returned from cache
  4         13  
  4         12006  
62              
63             has 'cache' => (
64             is => 'rw',
65             default => sub { q{Net::WURFL::ScientiaMobile::Cache::Null} },
66             coerce => sub { (ref $_[0]) ? $_[0] : do { load($_[0]); $_[0]->new } },
67             isa => sub { Role::Tiny::does_role($_[0], 'Net::WURFL::ScientiaMobile::Cache') },
68             );
69              
70             has 'api_key' => (
71             is => 'rw',
72             required => 1,
73             isa => sub {
74             die "The API Key provided is invalid"
75             unless length($_[0]) == 39 && index($_[0], ':') == 6;
76             },
77             );
78              
79             has 'http_timeout' => (is => 'rw', default => sub { 1000 });
80             has 'compression' => (is => 'rw', default => sub { 1 });
81             has 'auto_purge' => (is => 'rw', default => sub { 0 });
82             has 'report_interval' => (is => 'rw', default => sub { 60 });
83             has 'wcloud_servers' => (
84             is => 'rw',
85             default => sub { { 'wurfl_cloud' => [ 'api.wurflcloud.com' => 80 ] } },
86             isa => sub { die "wcloud_servers must be a hashref\n" unless ref $_[0] eq 'HASH' },
87             );
88              
89             has 'wcloud_host' => (is => 'lazy', default => sub { $_[0]->getWeightedServer->[0] }, reader => 'getCloudServer');
90             has '_current_server' => (is => 'ro', default => sub { [] });
91             has 'capabilities' => (is => 'rw', default => sub { {} });
92             has '_errors' => (is => 'rw', default => sub { [] });
93             has '_search_capabilities' => (is => 'ro', default => sub { [] });
94             has '_user_agent' => (is => 'rw'); # The HTTP User-Agent that is being evaluated
95             has '_http_request' => (is => 'rw'); # The HTTP Request (PSGI env) that is being evaluated
96             has '_json' => (is => 'rw'); # The raw json response from the server
97             has '_report_data' => (is => 'rw', default => sub { {} }); # Storage for report data (cache hits, misses, errors)
98             has '_api_version' => (is => 'rw', reader => 'getAPIVersion'); # The version of the WURFL Cloud Server
99             has '_api_username' => (is => 'lazy', default => sub { substr $_[0]->api_key, 0, 6 }); # The 6-digit API Username
100             has '_api_password' => (is => 'lazy', default => sub { substr $_[0]->api_key, 7 }); # The 32-character API Password
101             has '_loaded_date' => (is => 'rw'); # The date that the WURFL Cloud Server's data was updated
102             has '_source' => (is => 'rw', default => sub { SOURCE_NONE }, reader => 'getSource'); # The source of the last detection
103             has '_http_client' => (is => 'rw', default => sub { LWP::UserAgent->new }); # The HTTP Client that will be used to call WURFL Cloud
104             has '_http_headers' => (is => 'ro', default => sub { {} });
105             has '_http_success' => (is => 'rw');
106              
107             # The HTTP Headers that will be examined to find the best User Agent, if one is not specified
108             my @user_agent_headers = qw(
109             HTTP_X_DEVICE_USER_AGENT X-Device-User-Agent
110             HTTP_X_ORIGINAL_USER_AGENT X-Original-User-Agent
111             HTTP_X_OPERAMINI_PHONE_UA X-OperaMini-Phone-UA
112             HTTP_X_SKYFIRE_PHONE X-Skyfire-Phone
113             HTTP_X_BOLT_PHONE_UA X-Bolt-Phone-UA
114             HTTP_USER_AGENT User-Agent
115             );
116              
117             sub getWeightedServer {
118 1     1 1 4 my $self = shift;
119            
120 1 50       3 return $self->_current_server if @{$self->_current_server} == 1;
  1         12  
121 1 50       3 return [ map @$_, values %{$self->wcloud_servers} ] if keys %{$self->wcloud_servers} == 1;
  1         561  
  1         7  
122            
123 0         0 my $max = sum(map $_->[1], values %{$self->wcloud_servers});
  0         0  
124 0         0 my $wrand = int rand $max;
125 0         0 my $rcount = 0;
126 0     0   0 my $k = first { $wrand <= ($rcount += $self->wcloud_servers->{$_}[1] ) }
127 0         0 keys %{$self->wcloud_servers};
  0         0  
128 0   0     0 $k ||= +(keys %{$self->wcloud_servers})[0];
  0         0  
129 0         0 $self->_current_server($self->_wcloud_servers->{$k});
130 0         0 return $self->_current_server;
131             }
132              
133             sub clearServers {
134 0     0 1 0 my $self = shift;
135 0         0 $self->wcloud_servers({});
136             }
137              
138             sub addCloudServer {
139 0     0 1 0 my $self = shift;
140 0         0 my ($nickname, $host, $weight) = @_;
141 0   0     0 $self->wcloud_servers->{$nickname} = [ $host => $weight || 100 ];
142             }
143              
144             sub detectDevice {
145 1     1 1 425 my $self = shift;
146 1         3 my ($env, $search_capabilities) = @_;
147            
148 1         15 $self->_source(SOURCE_NONE);
149 1         6 $self->_http_request($env);
150 1 50       4 $self->_search_capabilities($search_capabilities) if ref $search_capabilities eq 'ARRAY';
151 1         5 $self->_user_agent($self->getUserAgent($env));
152 1         5 my $result = $self->cache->getDevice($self->_user_agent);
153 1 50       5 unless (ref $result eq 'HASH') {
154 1         4 $self->_source(SOURCE_CLOUD);
155 1         6 $self->_callWurflCloud;
156 0         0 $self->_validateCache;
157 0 0       0 if ($self->getSource eq SOURCE_CLOUD) {
158 0         0 $self->cache->setDevice($self->_user_agent, $self->capabilities);
159             }
160             } else {
161 0         0 $self->_source(SOURCE_CACHE);
162 0         0 $self->capabilities($result);
163             # The user requested capabilities that don't exist in the cached copy.
164             # Retrieve and cache the missing capabilities
165 0 0       0 if (!$self->_allCapabilitiesPresent) {
166 0         0 $self->_source(SOURCE_CLOUD);
167 0         0 my $initial_capabilities = $self->capabilities;
168 0         0 $self->_callWurflCloud;
169 0         0 $self->capabilities({ %$initial_capabilities, @{$self->capabilities} });
  0         0  
170 0 0       0 if ($self->getSource eq SOURCE_CLOUD) {
171 0         0 $self->cache->setDevice($self->_user_agent, $self->capabilities);
172             }
173             }
174             }
175             }
176              
177             sub _allCapabilitiesPresent {
178 0     0   0 my $self = shift;
179 0 0   0   0 return (first { !exists $self->capabilities->{$_} } @{$self->_search_capabilities}) ? 0 : 1;
  0         0  
  0         0  
180             }
181              
182             sub getDeviceCapability {
183 0     0 1 0 my $self = shift;
184 0         0 my ($capability) = @_;
185            
186 0         0 $capability = lc $capability;
187 0 0       0 return $self->capabilities->{$capability} if exists $self->capabilities->{$capability};
188            
189 0 0       0 if (!$self->_http_success) {
190             # The capability is not in the cache (http_client was not called) - query the Cloud
191             # to see if we even have the capability
192 0         0 $self->_source(SOURCE_CLOUD);
193 0         0 $self->callWurflCloud;
194 0         0 $self->validateCache;
195 0 0       0 if ($self->_source eq SOURCE_CLOUD) {
196 0         0 $self->cache->setDevice($self->_user_agent, $self->capabilities);
197 0 0       0 return $self->capabilities->{$capability} if exists $self->capabilities->{$capability};
198             }
199             }
200             Net::WURFL::ScientiaMobile::Exception::InvalidCapability->throw
201 0         0 ("The requested capability ($capability) is invalid or you are not subscribed to it.");
202             }
203              
204             sub getUserAgent {
205 1     1 1 2 my $self = shift;
206 1         2 my ($env) = @_;
207            
208 1   50     3 $env ||= \%ENV;
209 1 50       4 $env = $env->to_hash if ref $env eq 'Mojo::Headers';
210 1 50       4 if (ref $env eq 'HTTP::Headers') {
211 0         0 my $headers = {};
212 0     0   0 $env->scan(sub { $headers->{$_[0]} = $_[1] });
  0         0  
213 0         0 $env = $headers;
214             }
215            
216 1         1 my $user_agent;
217 1 50 33     5 if (defined $env->{QUERY_STRING} && $env->{QUERY_STRING} =~ /\bUA=([^&]+)/) {
218 0         0 $user_agent = uri_unescape($1);
219             } else {
220 1     11   19 $user_agent = first { $_ } @$env{@user_agent_headers};
  11         10  
221             }
222 1   50     9 return substr $user_agent || '', 0, 255;
223             }
224              
225             sub _callWurflCloud {
226 1     1   1 my $self = shift;
227            
228 1         3 my %headers = ();
229            
230             # If the reportInterval is enabled and past the report age, include the report data
231             # in the next request
232 1 50 33     31 if ($self->report_interval > 0 && $self->cache->getReportAge >= $self->report_interval) {
233 0         0 $self->addReportDataToRequest;
234            
235 0         0 $self->_report_data($self->cache->getCounters);
236 0         0 $headers{'X-Cloud-Counters'} = join ',',
237             map "$_:" . $self->_report_data->{$_},
238 0         0 keys %{$self->report_data};
239            
240 0         0 $self->cache->resetReportAge;
241 0         0 $self->cache->resetCounters;
242             }
243            
244             # Add HTTP Headers to pending request
245 1         6 $headers{'User-Agent'} = $self->_user_agent;
246 1         5 $headers{'X-Cloud-Client'} = __PACKAGE__ . " $VERSION";
247            
248             # Add X-Forwarded-For
249             {
250 1         2 my $ip = $self->_http_request->{REMOTE_ADDR};
  1         4  
251 1         4 my $fwd = $self->_http_request->{HTTP_X_FORWARDED_FOR};
252 1 50       5 if ($ip) {
253 0 0       0 $headers{'X-Forwarded-For'} = "$ip" . ($fwd ? ", $fwd" : "");
254             }
255             }
256            
257             # We use 'X-Accept' so it doesn't stomp on our deflate/gzip header
258 1 50       6 $headers{'X-Accept'} = $self->_http_request->{HTTP_ACCEPT} if $self->_http_request->{HTTP_ACCEPT};
259             {
260 1     2   2 my $wap_profile = first { $_ } @{$self->_http_request}{qw(HTTP_X_WAP_PROFILE HTTP_PROFILE)};
  1         5  
  2         3  
  1         5  
261 1 50       5 $headers{'X-Wap-Profile'} = $wap_profile if $wap_profile;
262             }
263            
264 1         7 my $request_path = @{$self->_search_capabilities} == 0
  0         0  
265             ? '/v1/json/'
266 1 50       2 : '/v1/json/search:(' . join(',', @{$self->_search_capabilities}) . ')';
267            
268             # Prepare request
269 1         14 my $url = sprintf 'http://%s%s', $self->getCloudServer, $request_path;
270 1         24 my $request = HTTP::Request->new(GET => $url);
271 1         9147 $request->header($_ => $headers{$_}) for keys %headers;
272 1         166 $request->authorization_basic($self->_api_username, $self->_api_password);
273            
274             # Execute call
275 1         1619 $self->_http_client->timeout($self->http_timeout / 1000);
276 1         29 my $response = $self->_http_client->request($request);
277 1         182544 $self->_http_success($response->is_success);
278 1 50       20 if (!$response->is_success) {
279 1         16 my %exceptions_by_status = qw(
280             API_KEY_INVALID ApiKeyInvalid
281             AUTHENTICATION_REQUIRED NoAuthProvided
282             API_KEY_EXPIRED ApiKeyExpired
283             API_KEY_REVOKED ApiKeyRevoked
284             INVALID_SIGNATURE InvalidSignature
285             );
286 1 50       6 if (exists $exceptions_by_status{$response->message}) {
287 1         15 ("Net::WURFL::ScientiaMobile::Exception::" . $exceptions_by_status{$response->message})->throw
288             (error => $response->status_line, response => $response);
289             } else {
290 0           Net::WURFL::ScientiaMobile::Exception::HTTP->throw(
291             error => "Unable to contact server: " . $response->status_line,
292             response => $response,
293             );
294             }
295             }
296             try {
297 0     0     $self->_json(decode_json($response->content));
298             } catch {
299 0     0     Net::WURFL::ScientiaMobile::Exception::HTTP->throw(
300             error => "Unable to parse JSON response from server: $_",
301             response => $response,
302             code => ERROR_BAD_RESPONSE,
303             );
304 0           };
305            
306 0           $self->_errors($self->_json->{errors});
307 0   0       $self->_api_version($self->_json->{apiVersion} || '');
308 0   0       $self->_loaded_date($self->_json->{mtime} || '');
309 0   0       $self->capabilities->{id} = $self->_json->{id} || '';
310 0           $self->capabilities->{$_} = $self->_json->{capabilities}{$_}
311 0           for keys %{$self->_json->{capabilities}};
312             }
313              
314             sub getLoadedDate {
315 0     0 1   my $self = shift;
316 0 0         $self->_loaded_date($self->cache->getMtime) unless $self->_loaded_date;
317 0           return $self->_loaded_date;
318             }
319              
320             sub _validateCache {
321 0     0     my $self = shift;
322            
323 0           my $cache_mtime = $self->cache->getMtime;
324 0 0 0       if (!$cache_mtime || $cache_mtime != $self->_loaded_date) {
325 0           $self->cache->setMtime($self->_loaded_date);
326 0 0         $self->cache->purge if $self->auto_purge;
327             }
328             }
329              
330             =head1 NAME
331              
332             Net::WURFL::ScientiaMobile - Client for the ScientiaMobile cloud webservice
333              
334             =head1 SYNOPSIS
335              
336             use Net::WURFL::ScientiaMobile;
337            
338             my $scientiamobile = Net::WURFL::ScientiaMobile->new(
339             api_key => '...',
340             );
341            
342             # process this HTTP request
343             $scientiamobile->detectDevice($env);
344            
345             # check if the device is mobile
346             if ($scientiamobile->getDeviceCapability('ux_full_desktop')) {
347             print "This is a desktop browser.";
348             }
349              
350             =head1 DESCRIPTION
351              
352             The WURFL Cloud Service by ScientiaMobile, Inc. is a cloud-based
353             mobile device detection service that can quickly and accurately
354             detect over 500 capabilities of visiting devices. It can differentiate
355             between portable mobile devices, desktop devices, SmartTVs and any
356             other types of devices that have a web browser.
357              
358             This is the Perl Client for accessing the WURFL Cloud Service, and
359             it requires a free or paid WURFL Cloud account from ScientiaMobile:
360             L
361              
362             This module analyzes the C<$env> data structure of your incoming HTTP request and extracts
363             the device identifier string(s). It then queries the WURFL Cloud Service or the local cache
364             (if any is configured) to get the device capabilities.
365              
366             If you use a PSGI-compatible web framework (such as L, L, L and others),
367             the easiest way to use this client is to apply the L
368             module to your application. It will provide the device capabilities to your request handlers
369             automatically with minimal programming effort.
370              
371             =head1 CONSTRUCTOR
372              
373             The C constructor accepts the following named arguments.
374              
375             =head2 api_key
376              
377             Required. The full API key provided by the WURFL Cloud Service.
378              
379             =head2 cache
380              
381             A L object (or class name as string). If none is provided,
382             no caching will happen.
383              
384             =head2 http_timeout
385              
386             The timeout in milliseconds to wait for the WURFL Cloud request to complete. Defaults to 1000.
387              
388             =head2 compression
389              
390             Boolean flag to enable/disable compression for querying the WURFL Cloud Service.
391             Using compression can increase CPU usage in very high traffic environments, but will decrease
392             network traffic and latency. Defaults to true.
393              
394             =head2 auto_purge
395              
396             If true, the entire cache (e.g. memcache, etc.) will be cleared if the WURFL Cloud Service has
397             been updated. This option should not be enabled for production use since it will result in a
398             massive cache purge, which will result in higher latency lookups. Defaults to false.
399              
400             =head2 report_interval
401              
402             The interval in seconds that after which API will report its performance.
403              
404             =head2 wcloud_servers
405              
406             WURFL Cloud servers to use for uncached requests. The "weight" field can contain any positive
407             number, the weights are relative to each other. Use this if you want to override the built-in
408             server list. For example:
409              
410             my $scientiamobile = Net::WURFL::ScientiaMobile->new(
411             api_key => '...',
412             wcloud_servers => {
413             # nickname => [ host => weight ],
414             'wurfl_cloud' => [ 'api.wurflcloud.com' => 80 ],
415             },
416             );
417              
418             =head1 METHODS FOR CAPABILITY DETECTION
419              
420             =head2 detectDevice
421              
422             $scientiamobile->detectDevice($env);
423             $scientiamobile->detectDevice($env, ['ux_full_desktop', 'brand_name']);
424              
425             Get the requested capabilities from the WURFL Cloud for the given HTTP Request. If the second
426             argument is not provided, all available capabilities will be fetched.
427              
428             Refer to the documentation of your web framework to learn how to access C<$env>. For example,
429             L provides it in C<$ctx-Erequest-Eenv>, L provides it in
430             Cenv>, L provides it in C<$self-Etx-Ereq-Eenv>.
431              
432             Instead of the C<$env> hashref you can also supply a L or a L object.
433             This is handy when you're not running in a PSGI environment and your web server doesn't supply
434             a PSGI-compatible C<$env> hashref (for example, when running C<./myapp.pl daemon> in a
435             L application. Note that the L built-in web server still provides
436             a PSGI-compatible C<$env>).
437              
438             =head2 getDeviceCapability
439              
440             my $is_wireless = $scientiamobile->getDeviceCapability('is_wireless_device');
441              
442             Returns the value of the requested capability. If the capability does not exist, returns undef.
443              
444             =head2 capabilities
445              
446             Flat capabilities hashref, thus containing I<'key' => 'value'> pairs.
447             Since it is 'flattened', there are no groups in this array, just individual capabilities.
448              
449             =head1 METHODS FOR SERVERS POOL MANAGEMENT
450              
451             =head2 addCloudServer
452              
453             $scientiamobile->addCloudServer('wurfl_cloud', 'api.wurflcloud.com', 80);
454              
455             Adds the specified WURFL Cloud Server. The last argument is the server's weight. It specifies
456             the chances that this server will be chosen over the other servers in the pool. This number is
457             relative to the other servers' weights.
458              
459             =head2 clearServers
460              
461             $scientiamobile->clearServers;
462              
463             Removes the WURFL Cloud Servers.
464              
465             =head2 getWeightedServer
466              
467             my $server = $scientiamobile->getWeightedServer;
468              
469             Uses a weighted-random algorithm to chose a server from the pool. It returns an arrayref whose
470             first argument is the host and the second argument is the weight.
471             You don't need to call this method usually. It is called internally when the client prepares the
472             request to the WURFL Cloud Service.
473              
474             =head1 UTILITY METHODS
475              
476             =head2 getUserAgent
477              
478             my $user_agent = $scientiamobile->getUserAgent($env);
479              
480             Reads the user agent string from C<$env>.
481              
482             =head2 getLoadedDate
483              
484             my $date = $scientiamobile->getLoadedDate;
485              
486             Get the date that the WURFL Cloud Server was last updated as a UNIX timestamp (seconds since Epoch).
487             This will be undef if there has not been a recent query to the server, or if the cached value was
488             pushed out of memory.
489              
490             =head1 SEE ALSO
491              
492             L, L
493              
494             =head1 AUTHOR
495              
496             Alessandro Ranellucci C<< >>
497              
498             =head1 COPYRIGHT & LICENSE
499              
500             Copyright 2012, ScientiaMobile, Inc.
501              
502             This program is free software; you can redistribute it and/or modify
503             it under the same terms as Perl itself.
504              
505             =cut
506              
507             1;