File Coverage

blib/lib/Apple/AppStoreConnect.pm
Criterion Covered Total %
statement 102 102 100.0
branch 41 42 97.6
condition 30 35 85.7
subroutine 16 16 100.0
pod 5 5 100.0
total 194 200 97.0


line stmt bran cond sub pod time code
1             package Apple::AppStoreConnect;
2              
3 3     3   718316 use 5.008;
  3         30  
4 3     3   22 use strict;
  3         6  
  3         78  
5 3     3   16 use warnings;
  3         6  
  3         72  
6              
7 3     3   15 use Carp;
  3         7  
  3         245  
8 3     3   2184 use Crypt::JWT qw(encode_jwt);
  3         168747  
  3         200  
9 3     3   29 use JSON;
  3         7  
  3         22  
10              
11             =head1 NAME
12              
13             Apple::AppStoreConnect - Apple App Store Connect API client
14              
15             =head1 VERSION
16              
17             Version 0.1
18              
19             =cut
20              
21             our $VERSION = '0.1_2';
22              
23             =head1 SYNOPSIS
24              
25             use Apple::AppStoreConnect;
26              
27             my $asc = Apple::AppStoreConnect->new(
28             issuer => $API_key_issuer, # API key issuer ID
29             key_id => $key_id, # App Store Connect API key ID
30             key => $private_key # Encrypted private key (PEM)
31             );
32            
33             # Custom API request
34             my $res = $asc->get(url => $url);
35              
36             # List apps / details convenience function
37             $res = $asc->get_apps(); # List of apps
38             $res = $asc->get_apps(id => $app_id); # App details
39             $res = $asc->get_apps(id => $app_id, path => 'customerReviews'); # App reviews
40              
41              
42             =head1 DESCRIPTION
43              
44             Apple::AppStoreConnect provides basic access to the Apple App Store Connect API.
45              
46             Please see the L
47             for usage and all possible requests.
48              
49             =head1 CONSTRUCTOR
50              
51             =head2 C
52              
53             my $asc = Apple::AppStoreConnect->new(
54             key_id => $key_id,
55             key => $private_key?,
56             key_file => $private_key_pem?,
57             issuer => "57246542-96fe-1a63-e053-0824d011072a",
58             scope => \@scope?,
59             timeout => $timeout_sec?,
60             expiration => $expire_secs?,
61             ua => $lwp_ua?,
62             curl => $use_curl?
63             );
64            
65             Required parameters:
66              
67             =over 4
68              
69             =item * C : The encrypted App Store Connect API private key file that you
70             create under B -> B on the App Store Connect portal. On the portal
71             you download a PKCS8 format file (.p8), which you first need to convert to the PEM format.
72             On a Mac you can convert it simply:
73              
74             openssl pkcs8 -nocrypt -in AuthKey_.p8 -out AuthKey_.pem
75              
76             =item * C : Instead of the C<.pem> file, you can pass its contents directly
77             as a string.
78              
79             =item * C : The ID of the App Store Connect API key created on the App Store
80             Connect portal (B section).
81              
82             =item * C : Your API Key B. Can be found at the top of the API keys
83             on the App Store Connect Portal (B section).
84              
85             =back
86              
87             Optional parameters:
88              
89             =over 4
90              
91             =item * C : An arrayref that defines the token scope. Example entry:
92             C<["GET /v1/apps?filter[platform]=IOS"]>.
93              
94             =item * C : Timeout for requests in secs. Default: C<30>.
95              
96             =item * C : Pass your own L to customise the agent string etc.
97              
98             =item * C : If true, fall back to using the C command line program.
99             This is useful if you have issues adding https support to L, which
100             is the default method for the API requests.
101              
102             =item * C : Token expiration time in seconds. Tokens are cached until
103             there are less than 10 minutes left to expiration. Default: C<900> - the API will
104             not accept more than 20 minutes expiration time for most requests.
105              
106             =back
107              
108             =head1 METHODS
109              
110             =head2 C
111              
112             my $res = $asc->get(
113             url => $url,
114             raw => $raw?,
115             params => \%query_params?
116             );
117              
118             Fetches the requested API url, by default, it will use L to decode it
119             directly to a Perl hash, unless you request C result as a string.
120              
121             Requires L, unless the C option was set.
122              
123             If the request is not successful, it will C throwing the C<< HTTP::Response->status_line >>.
124              
125             =over 4
126            
127             =item * C : A URL to an API endpoint. Can pass the full URL, e.g. C 'https://api.appstoreconnect.apple.com/v1/apps'>,
128             or you can omit the part up to I (i.e. C 'apps'>).
129              
130             =item * C : Any other query params that you need to pass
131             (see L).
132              
133             =back
134              
135             =head2 C
136              
137             my $res = $asc->get_response(
138             url => $url,
139             raw => $raw?,
140             params => \%query_params?
141             );
142              
143             Same as C except it returns the full L from the API (so you
144             can handle bad requests yourself).
145              
146             =head1 CONVENIENCE METHODS
147              
148             =head2 C
149              
150             my $jwt = $asc->jwt(
151             iat => $iat?,
152             exp => $exp?
153             );
154              
155             Returns the JSON Web Token string in case you need it. Will return a cached one
156             if it has more than 5 minutes until expiration and you don't explicitly pass an
157             C argument.
158              
159             =over 4
160            
161             =item * C : Specify the token creation timestamp. Default is C.
162              
163             =item * C : Specify the token expiration timestamp. Passing this parameter
164             will force the creation of a new token. Default is C (or what you
165             specified in the constructor).
166              
167             =back
168              
169             =head2 C
170              
171             my $res = $asc->get_apps(
172             id => $app_id?,
173             path => $path?,
174             params => \%query_params?
175             );
176              
177             Without arguments it is similar to C"apps">, fetching the list of apps,
178             but does some extra processing to return a Perl hash with app IDs as keys and the
179             app attributes as values.
180              
181             There are optional arguments to get details of a specific app or app resource:
182              
183             =over 4
184            
185             =item * C : The app ID. Specifying just the id will return the details for a
186             single app.
187              
188             =item * C : Requires C and is similar to C"apps/$app_id/$path")>,
189             returning a specific resource type for an app, except it does the convenience processing
190             where a hash with the ids of this resource as keys are returned and the attributes
191             as values (unless the specific resource does not follow that pattern).
192             See API documentation for C support (e.g. C, C,
193             C, C etc.).
194              
195             =item * C : Any other query params that you need to pass
196             (see L).
197              
198             =back
199              
200             =cut
201              
202             sub new {
203 9     9 1 17703 my $class = shift;
204              
205 9         21 my $self = {};
206 9         21 bless($self, $class);
207              
208 9         30 my %args = @_;
209              
210             ($self->{$_} = $args{$_} || croak("$_ (string) required."))
211 9   66     392 foreach qw/issuer key_id/;
212              
213 7 100       26 unless ($args{key}) {
214 3 100       115 croak("key or key_file required.") unless $args{key_file};
215 2 100       142 open my $fh, '<', $args{key_file} or die "Can't open file $!";
216 1         5 $args{key} = do { local $/; <$fh> };
  1         5  
  1         39  
217             }
218 5         30 $self->{key} = \$args{key};
219 5   100     27 $self->{timeout} = $args{timeout} || 30;
220 5   100     62 $self->{expiration} = $args{expiration} || 900;
221 5         13 $self->{ua} = $args{ua};
222 5         21 $self->{curl} = $args{curl};
223 5         15 $self->{scope} = $args{scope};
224 5         27 $self->{base_url} = "https://api.appstoreconnect.apple.com/v1/";
225              
226 5         34 return $self;
227             }
228              
229             sub get {
230 12     12 1 7024 my $self = shift;
231 12         36 my %args = @_;
232              
233 12         44 my $resp = $self->get_response(%args);
234              
235 11 100       772 unless ($self->{curl}) {
236 10 100       31 die $resp->status_line unless $resp->is_success;
237 9         86 $resp = $resp->decoded_content;
238             }
239              
240 10 100       1396 return $args{raw} ? $resp : JSON::decode_json($resp);
241             }
242              
243             sub get_response {
244 12     12 1 19 my $self = shift;
245 12         28 my %args = @_;
246              
247 12 100       155 croak("url required") unless $args{url};
248 11 100       67 $args{url} = $self->{base_url}.$args{url} unless $args{url} =~ /^http/;
249              
250 11         29 my $jwt = $self->jwt;
251 11         37 my $url = _build_url(%args);
252              
253 11 100 100     64 unless ($self->{curl} || $self->{ua}) {
254 2         17 require LWP::UserAgent;
255             $self->{ua} = LWP::UserAgent->new(
256             agent => "libwww-perl Apple::AppStoreConnect/$VERSION",
257             timeout => $self->{timeout}
258 2         25 );
259             }
260              
261 11         6836 return _fetch($self->{ua}, $url, $jwt);
262             }
263              
264             sub get_apps {
265 6     6 1 13374 my $self = shift;
266 6         15 my %args = @_;
267 6         20 $args{url} = $self->{base_url} . 'apps';
268 6 100       25 $args{url} .= "/$args{id}" if $args{id};
269 6 100       17 $args{url} .= "/$args{path}" if $args{path};
270 6         20 my $res = $self->get(%args);
271              
272 6         22 return _process_data($res);
273             }
274              
275             sub jwt {
276 16     16 1 12417 my $self = shift;
277 16         37 my %args = @_;
278              
279             # Return cached one
280             return $self->{jwt}
281 16 100 100     116 if !$args{exp} && $self->{jwt_exp} && $self->{jwt_exp} >= time() + 300;
      66        
282              
283 5   66     30 $args{iat} ||= time();
284 5   100     23 my $old = $self->{jwt_exp} || '-';
285 5   66     21 $self->{jwt_exp} = $args{exp} || (time() + $self->{expiration});
286              
287 5   100     20 my $ex = $args{exp} || '-';
288 3     3   2438 use Carp;
  3         17  
  3         1962  
289 5         480 my $c = Carp::longmess();
290 5         1579 my $ln = "";
291 5 100       42 if ($c =~ /simple.t line (\d+)/) {
292 4         12 $ln = $1;
293             }
294 5         231 warn "$ex $old $self->{jwt_exp} $ln";
295              
296             my $data = {
297             iss => $self->{issuer},
298             aud => "appstoreconnect-v1",
299             exp => $self->{jwt_exp},
300             iat => $args{iat},
301 5         56 };
302              
303 5 100       18 $data->{scope} = $self->{scope} if $self->{scope};
304              
305             $self->{jwt} = encode_jwt(
306             payload => $data,
307             alg => 'ES256',
308             key => $self->{key},
309             extra_headers => {
310             kid => $self->{key_id},
311 5         50 typ => "JWT"
312             }
313             );
314              
315 5         46146 return $self->{jwt};
316             }
317              
318             sub _fetch {
319 11     11   27 my ($ua, $url, $jwt) = @_;
320              
321 11 100       38 return _curl($url, $jwt) unless $ua;
322              
323 10         56 return $ua->get($url, Authorization => "Bearer $jwt");
324             }
325              
326             sub _curl {
327 1     1   17 return `curl "$_[0]" -A "Curl Apple::AppStoreConnect/$VERSION" -s -H 'Authorization: Bearer $_[1]'`;
328             }
329              
330             sub _build_url {
331 14     14   9575 my %args = @_;
332 14         28 my $url = $args{url};
333 14 100       55 return $url unless ref($args{params});
334              
335 3         6 my $params = join("&", map {"$_=$args{params}->{$_}"} keys %{$args{params}});
  3         12  
  3         12  
336              
337 3 100       13 $url .= "?$params" if $params;
338              
339 3         50 return $url;
340             }
341              
342             sub _process_data {
343 13     13   5499 my $hash = shift;
344 13 100 100     80 if (ref($hash) && ref($hash->{data}) && ref($hash->{data}) eq 'ARRAY') {
      66        
345 8         15 my $res;
346 8         12 foreach my $item (@{$hash->{data}}) {
  8         21  
347 8 100 100     32 if ($item->{id} && $item->{attributes}) {
348 6         10 $res->{$item->{id}} = {%{$item->{attributes}}};
  6         26  
349 6 50       27 $res->{$item->{id}}->{type} = $item->{type} if $item->{type};
350             }
351             }
352 8 100       47 return $res if $res;
353             }
354 7         23 return $hash;
355             }
356              
357             =head1 AUTHOR
358              
359             Dimitrios Kechagias, C<< >>
360              
361             =head1 BUGS
362              
363             Please report any bugs or feature requests either on L (preferred), or on RT (via the email
364             C or L).
365              
366             I will be notified, and then you'll automatically be notified of progress on your bug as I make changes.
367              
368             =head1 GIT
369              
370             L
371              
372             =head1 LICENSE AND COPYRIGHT
373              
374             This software is copyright (c) 2023 by Dimitrios Kechagias.
375              
376             This is free software; you can redistribute it and/or modify it under
377             the same terms as the Perl 5 programming language system itself.
378              
379             =cut
380              
381             1;