File Coverage

blib/lib/Apple/AppStoreConnect.pm
Criterion Covered Total %
statement 95 95 100.0
branch 41 42 97.6
condition 26 31 83.8
subroutine 16 16 100.0
pod 5 5 100.0
total 183 189 96.8


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