File Coverage

blib/lib/Net/IPData.pm
Criterion Covered Total %
statement 94 114 82.4
branch 32 36 88.8
condition 16 26 61.5
subroutine 23 30 76.6
pod 17 17 100.0
total 182 223 81.6


line stmt bran cond sub pod time code
1             package Net::IPData;
2              
3 3     3   917452 use strict;
  3         7  
  3         117  
4 3     3   14 use warnings;
  3         6  
  3         236  
5 3     3   55 use 5.010001;
  3         11  
6              
7             our $VERSION = '1.0.0';
8              
9 3     3   2365 use HTTP::Tiny;
  3         190292  
  3         225  
10 3     3   31 use JSON::PP qw(encode_json decode_json);
  3         6  
  3         295  
11 3     3   23 use Carp qw(croak);
  3         8  
  3         199  
12 3     3   18 use Scalar::Util qw(blessed);
  3         6  
  3         214  
13              
14             # API base URLs
15             use constant {
16 3         7338 BASE_URL => 'https://api.ipdata.co',
17             EU_BASE_URL => 'https://eu-api.ipdata.co',
18             MAX_BULK => 100,
19 3     3   25 };
  3         5  
20              
21             # Valid top-level fields for filtering
22             my %VALID_FIELDS = map { $_ => 1 } qw(
23             ip is_eu city region region_code region_type
24             country_name country_code continent_name continent_code
25             latitude longitude postal calling_code flag
26             emoji_flag emoji_unicode asn company carrier
27             languages currency time_zone threat count status message
28             );
29              
30             # Valid single-field path lookups (fields accessible via /{ip}/{field})
31             my %PATH_FIELDS = map { $_ => 1 } qw(
32             ip is_eu city region region_code region_type
33             country_name country_code continent_name continent_code
34             latitude longitude postal calling_code flag
35             emoji_flag emoji_unicode asn carrier languages
36             currency time_zone threat
37             );
38              
39             sub new {
40 9     9 1 3787 my ($class, %args) = @_;
41              
42             my $api_key = $args{api_key}
43 9 100       417 or croak 'Required parameter "api_key" is missing';
44              
45 7         15 my $base_url = $args{base_url};
46 7 100 100     34 if ($args{eu} && !$base_url) {
47 1         12 $base_url = EU_BASE_URL;
48             }
49 7   100     37 $base_url //= BASE_URL;
50 7         49 $base_url =~ s{/+$}{}; # strip trailing slashes
51              
52 7   100     62 my $timeout = $args{timeout} // 30;
53              
54 7         80 my $ua = HTTP::Tiny->new(
55             agent => "Net-IPData-Perl/$VERSION",
56             timeout => $timeout,
57             default_headers => {
58             'Accept' => 'application/json',
59             'Content-Type' => 'application/json',
60             },
61             );
62              
63 7         760 return bless {
64             api_key => $api_key,
65             base_url => $base_url,
66             ua => $ua,
67             timeout => $timeout,
68             }, $class;
69             }
70              
71             # --- Public Methods ---
72              
73             sub lookup {
74 10     10 1 15182 my ($self, $ip, %opts) = @_;
75              
76 10 100 66     69 my $path = defined $ip && length $ip ? "/$ip" : '';
77 10         18 my @params;
78              
79 10 100       40 if (my $fields = $opts{fields}) {
80 4 100       134 my @field_list = ref $fields eq 'ARRAY' ? @$fields : split(/\s*,\s*/, $fields);
81 4         23 _validate_fields(\@field_list);
82 3         19 push @params, 'fields=' . join(',', @field_list);
83             }
84              
85 9         30 my $url = $self->_build_url($path, @params);
86 9         26 return $self->_get($url);
87             }
88              
89             sub lookup_mine {
90 1     1 1 6642 my ($self, %opts) = @_;
91 1         6 return $self->lookup(undef, %opts);
92             }
93              
94             sub lookup_field {
95 10     10 1 8083 my ($self, $ip, $field) = @_;
96              
97 10 100 66     208 croak 'Required parameter "ip" is missing' unless defined $ip && length $ip;
98 9 100 66     313 croak 'Required parameter "field" is missing' unless defined $field && length $field;
99 8 100       589 croak "Invalid field: $field" unless $PATH_FIELDS{$field};
100              
101 5         26 my $url = $self->_build_url("/$ip/$field");
102 5         16 return $self->_get($url);
103             }
104              
105             sub bulk {
106 5     5 1 3085 my ($self, $ips, %opts) = @_;
107              
108 5 100       192 croak 'Required parameter "ips" must be an array reference'
109             unless ref $ips eq 'ARRAY';
110 4 100       110 croak 'Bulk lookup supports a maximum of ' . MAX_BULK . ' IP addresses'
111             if @$ips > MAX_BULK;
112 3 100       93 croak 'Bulk lookup requires at least one IP address'
113             if @$ips == 0;
114              
115 2         3 my @params;
116 2 100       8 if (my $fields = $opts{fields}) {
117 1 50       3 my @field_list = ref $fields eq 'ARRAY' ? @$fields : split(/\s*,\s*/, $fields);
118 1         3 _validate_fields(\@field_list);
119 0         0 push @params, 'fields=' . join(',', @field_list);
120             }
121              
122 1         4 my $url = $self->_build_url('/bulk', @params);
123 1         5 return $self->_post($url, $ips);
124             }
125              
126             sub asn {
127 0     0 1 0 my ($self, $ip) = @_;
128 0         0 return $self->lookup_field($ip, 'asn');
129             }
130              
131             sub threat {
132 1     1 1 3838 my ($self, $ip) = @_;
133 1         5 return $self->lookup_field($ip, 'threat');
134             }
135              
136             sub carrier {
137 0     0 1 0 my ($self, $ip) = @_;
138 0         0 return $self->lookup_field($ip, 'carrier');
139             }
140              
141             sub currency {
142 1     1 1 642 my ($self, $ip) = @_;
143 1         7 return $self->lookup_field($ip, 'currency');
144             }
145              
146             sub time_zone {
147 0     0 1 0 my ($self, $ip) = @_;
148 0         0 return $self->lookup_field($ip, 'time_zone');
149             }
150              
151             sub languages {
152 0     0 1 0 my ($self, $ip) = @_;
153 0         0 return $self->lookup_field($ip, 'languages');
154             }
155              
156             sub country_name {
157 0     0 1 0 my ($self, $ip) = @_;
158 0         0 return $self->lookup_field($ip, 'country_name');
159             }
160              
161             sub country_code {
162 0     0 1 0 my ($self, $ip) = @_;
163 0         0 return $self->lookup_field($ip, 'country_code');
164             }
165              
166             sub is_eu {
167 0     0 1 0 my ($self, $ip) = @_;
168 0         0 return $self->lookup_field($ip, 'is_eu');
169             }
170              
171             # --- Accessors ---
172              
173 1     1 1 487 sub api_key { return $_[0]->{api_key} }
174 4     4 1 35 sub base_url { return $_[0]->{base_url} }
175 2     2 1 17 sub timeout { return $_[0]->{timeout} }
176              
177             # --- Private Methods ---
178              
179             sub _build_url {
180 15     15   38 my ($self, $path, @params) = @_;
181              
182 15         62 unshift @params, 'api-key=' . $self->{api_key};
183 15         42 my $query = join('&', @params);
184              
185 15   50     74 return $self->{base_url} . ($path // '') . '?' . $query;
186             }
187              
188             sub _get {
189 14     14   35 my ($self, $url) = @_;
190              
191 14         176 my $response = $self->{ua}->get($url);
192 14         271193 return $self->_handle_response($response);
193             }
194              
195             sub _post {
196 1     1   3 my ($self, $url, $data) = @_;
197              
198 1         5 my $body = encode_json($data);
199 1         155 my $response = $self->{ua}->post($url, { content => $body });
200 1         19 return $self->_handle_response($response);
201             }
202              
203             sub _handle_response {
204 15     15   41 my ($self, $response) = @_;
205              
206 15         41 my $status = $response->{status};
207 15   50     45 my $content = $response->{content} // '';
208              
209             # Network-level failures from HTTP::Tiny (status 599)
210 15 100       49 if ($status == 599) {
211 1         151 croak "Network error: $content";
212             }
213              
214             # Try to decode JSON
215 14         21 my $data;
216 14         30 eval { $data = decode_json($content); };
  14         57  
217 14 50       24828 if ($@) {
218             # Non-JSON response (e.g., single field value like country_name)
219             # The API returns bare strings for single-field path lookups
220 0         0 $content =~ s/^\s+|\s+$//g; # trim whitespace
221 0         0 $content =~ s/^"|"$//g; # strip surrounding quotes
222              
223 0 0 0     0 if ($status >= 200 && $status < 300) {
224 0         0 return $content;
225             }
226 0         0 croak "HTTP $status: $content";
227             }
228              
229             # API error responses include a "message" field
230 14 100       40 if ($status >= 400) {
231 6   33     31 my $message = $data->{message} // "HTTP $status error";
232 6         1608 croak "API error ($status): $message";
233             }
234              
235 8         70 return $data;
236             }
237              
238             sub _validate_fields {
239 5     5   10 my ($fields) = @_;
240 5         11 for my $f (@$fields) {
241 10 100       1107 croak "Invalid field: $f" unless $VALID_FIELDS{$f};
242             }
243             }
244              
245             1;
246              
247             __END__