File Coverage

blib/lib/Apple/AppStoreConnect.pm
Criterion Covered Total %
statement 165 167 98.8
branch 77 90 85.5
condition 40 60 66.6
subroutine 23 23 100.0
pod 8 8 100.0
total 313 348 89.9


line stmt bran cond sub pod time code
1             package Apple::AppStoreConnect;
2              
3 3     3   487571 use 5.008;
  3         10  
4 3     3   14 use strict;
  3         4  
  3         63  
5 3     3   9 use warnings;
  3         5  
  3         103  
6              
7 3     3   10 use Carp;
  3         6  
  3         211  
8 3     3   1768 use Crypt::JWT qw(encode_jwt);
  3         146909  
  3         190  
9 3     3   24 use JSON;
  3         4  
  3         22  
10              
11             =head1 NAME
12              
13             Apple::AppStoreConnect - Apple App Store Connect API client
14              
15             =head1 VERSION
16              
17             Version 0.13
18              
19             =cut
20              
21             our $VERSION = '0.13';
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             # List App Store versions, optionally with localizations
42             $res = $asc->get_app_store_versions(id => $app_id, platform => 'IOS');
43             $res = $asc->get_app_store_versions(id => $app_id, localizations => 1);
44             $res = $asc->get_app_store_versions(id => $app_id, localizations => 'en-US');
45              
46             # Latest beta feedback for alerting
47             $res = $asc->get_beta_feedback_screenshot_submissions(id => $app_id, platform => 'IOS');
48             $res = $asc->get_beta_feedback_crash_submissions(id => $app_id, crash_log => 1);
49              
50              
51             =head1 DESCRIPTION
52              
53             Apple::AppStoreConnect provides basic access to the Apple App Store Connect API.
54              
55             Please see the L
56             for usage and all possible requests.
57              
58             You can also use it with the L.
59              
60             =head1 CONSTRUCTOR
61              
62             =head2 C
63              
64             my $asc = Apple::AppStoreConnect->new(
65             key_id => $key_id,
66             key => $private_key?,
67             key_file => $private_key_pem?,
68             issuer => "57246542-96fe-1a63-e053-0824d011072a",
69             scope => \@scope?,
70             timeout => $timeout_sec?,
71             expiration => $expire_secs?,
72             ua => $lwp_ua?,
73             curl => $use_curl?,
74             jwt_payload => {%extra_payload}
75             );
76            
77             Required parameters:
78              
79             =over 4
80              
81             =item * C : The encrypted App Store Connect API private key file that you
82             create under B -> B on the App Store Connect portal. On the portal
83             you download a PKCS8 format file (.p8), which you first need to convert to the PEM format.
84             On a Mac you can convert it simply:
85              
86             openssl pkcs8 -nocrypt -in AuthKey_.p8 -out AuthKey_.pem
87              
88             =item * C : Instead of the C<.pem> file, you can pass its contents directly
89             as a string.
90              
91             =item * C : The ID of the App Store Connect API key created on the App Store
92             Connect portal (B section).
93              
94             =item * C : Your API Key B. Can be found at the top of the API keys
95             on the App Store Connect Portal (B section).
96              
97             =back
98              
99             Optional parameters:
100              
101             =over 4
102              
103             =item * C : An arrayref that defines the token scope. Example entry:
104             C<["GET /v1/apps?filter[platform]=IOS"]>.
105              
106             =item * C : Timeout for requests in secs. Default: C<30>.
107              
108             =item * C : Pass your own L to customise the agent string etc.
109              
110             =item * C : If true, fall back to using the C command line program.
111             This is useful if you have issues adding https support to L, which
112             is the default method for the API requests.
113              
114             =item * C : Token expiration time in seconds. Tokens are cached until
115             there are less than 10 minutes left to expiration. Default: C<900> - the API will
116             not accept more than 20 minutes expiration time for most requests.
117              
118             =item * C : Extra items to append to the JWT payload. Allows extending
119             the module to support more/newer versions of Apple APIs. For example, for the Apple
120             Store Server API you'd need to add:
121              
122             jwt_payload => {bid => $bundle_id}
123              
124             =back
125              
126             =head1 METHODS
127              
128             =head2 C
129              
130             my $res = $asc->get(
131             url => $url,
132             raw => $raw?,
133             params => \%query_params?
134             );
135              
136             Fetches the requested API url, by default, it will use L to decode it
137             directly to a Perl hash, unless you request C result as a string.
138              
139             Requires L, unless the C option was set.
140              
141             If the request is not successful, it will C throwing the C<< HTTP::Response->status_line >>.
142              
143             =over 4
144            
145             =item * C : A URL to an API endpoint. Can pass the full URL, e.g. C 'https://api.appstoreconnect.apple.com/v1/apps'>,
146             or you can omit the part up to I (i.e. C 'apps'>).
147              
148             =item * C : Any other query params that you need to pass
149             (see L).
150              
151             =back
152              
153             =head2 C
154              
155             my $res = $asc->get_response(
156             url => $url,
157             raw => $raw?,
158             params => \%query_params?
159             );
160              
161             Same as C except it returns the full L from the API (so you
162             can handle bad requests yourself).
163              
164             =head1 CONVENIENCE METHODS
165              
166             =head2 C
167              
168             my $jwt = $asc->jwt(
169             iat => $iat?,
170             exp => $exp?
171             );
172              
173             Returns the JSON Web Token string in case you need it. Will return a cached one
174             if it has more than 5 minutes until expiration and you don't explicitly pass an
175             C argument.
176              
177             =over 4
178            
179             =item * C : Specify the token creation timestamp. Default is C.
180              
181             =item * C : Specify the token expiration timestamp. Passing this parameter
182             will force the creation of a new token. Default is C (or what you
183             specified in the constructor).
184              
185             =back
186              
187             =head2 C
188              
189             my $res = $asc->get_apps(
190             id => $app_id?,
191             path => $path?,
192             platform => $platform?,
193             params => \%query_params?
194             );
195              
196             Without arguments it is similar to C"apps">, fetching the list of apps,
197             but does some extra processing to return a Perl hash with app IDs as keys and the
198             app attributes as values.
199              
200             There are optional arguments to get details of a specific app or app resource:
201              
202             =over 4
203            
204             =item * C : The app ID. Specifying just the id will return the details for a
205             single app.
206              
207             =item * C : Requires C and is similar to C"apps/$app_id/$path")>,
208             returning a specific resource type for an app, except it does the convenience processing
209             where a hash with the ids of this resource as keys are returned and the attributes
210             as values (unless the specific resource does not follow that pattern).
211             See API documentation for C support (e.g. C, C,
212             C, C etc.).
213              
214             =item * C : Optional shortcut for C, for example
215             C, C, C, or C.
216              
217             =item * C : Any other query params that you need to pass
218             (see L).
219              
220             =back
221              
222             =head2 C
223              
224             my $res = $asc->get_app_store_versions(
225             id => $app_id,
226             platform => $platform?,
227             localizations => $localizations?,
228             localization_fields => $localization_fields?,
229             params => \%query_params?
230             );
231              
232             my $versions = $asc->get_app_store_versions(
233             id => $app_id,
234             platform => 'IOS',
235             localization_fields => 'locale,whatsNew',
236             params => {
237             'fields[appStoreVersions]' => 'platform,versionString,appVersionState'
238             }
239             );
240              
241             Returns an arrayref of App Store versions for the app, automatically fetching
242             all pages. Each entry is a hash of the resource attributes, with C and
243             C added.
244              
245             When C is requested, one additional API call is made per
246             version to fetch its localizations.
247              
248             =over 4
249              
250             =item * C : The app ID.
251              
252             =item * C : Optional shortcut for C, for example
253             C, C, C, or C.
254              
255             =item * C : If true, each version entry will include a
256             C arrayref. Passing C<1> fetches all localizations. Passing a
257             locale string, for example C, fetches only that locale.
258              
259             =item * C : Optional fields to return for each
260             C resource, for example C. If
261             specified, C defaults to C<1>.
262              
263             =item * C : Any other query params to pass to the
264             C request.
265              
266             =back
267              
268             =head2 C
269              
270             my $res = $asc->get_beta_feedback_screenshot_submissions(
271             id => $app_id,
272             platform => $platform?,
273             limit => $limit?,
274             sort => $sort?,
275             params => \%query_params?
276             );
277              
278             Returns an arrayref of beta feedback screenshot submissions for the app. By
279             default, results are sorted newest first using C<-createdDate>, with C
280             set to C<50>. Only up to C results are returned; pagination is not
281             performed.
282              
283             =over 4
284              
285             =item * C : The app ID.
286              
287             =item * C : Optional shortcut for C, for example
288             C, C, C, or C.
289              
290             =item * C : Optional maximum number of results to return. Default C<50>.
291              
292             =item * C : Optional sort order. Default C<-createdDate>.
293              
294             =item * C : Any other query params to pass to the
295             C request, for example
296             C or C.
297              
298             =back
299              
300             =head2 C
301              
302             my $res = $asc->get_beta_feedback_crash_submissions(
303             id => $app_id,
304             platform => $platform?,
305             limit => $limit?,
306             sort => $sort?,
307             crash_log => $crash_log?,
308             crash_log_fields => $crash_log_fields?,
309             params => \%query_params?
310             );
311              
312             Returns an arrayref of beta feedback crash submissions for the app. By default,
313             results are sorted newest first using C<-createdDate>, with C set to
314             C<50>. Only up to C results are returned; pagination is not performed.
315             If C is true, each returned crash submission includes a C
316             hashref with the linked crash log; one additional API call is made per
317             submission to fetch it.
318              
319             =over 4
320              
321             =item * C : The app ID.
322              
323             =item * C : Optional shortcut for C, for example
324             C, C, C, or C.
325              
326             =item * C : Optional maximum number of results to return. Default C<50>.
327              
328             =item * C : Optional sort order. Default C<-createdDate>.
329              
330             =item * C : If true, fetch C
331             for each crash submission and attach it as C.
332              
333             =item * C : Optional fields to return for each C
334             resource, for example C.
335              
336             =item * C : Any other query params to pass to the
337             C request, for example
338             C or C. Passing
339             C here is also supported; it is applied to the crash log
340             request.
341              
342             =back
343              
344             =cut
345              
346             sub new {
347 14     14 1 369341 my $class = shift;
348              
349 14         32 my $self = {};
350 14         22 bless($self, $class);
351              
352 14         50 my %args = @_;
353              
354             ($self->{$_} = $args{$_} || croak("$_ (string) required."))
355 14   66     365 foreach qw/issuer key_id/;
356              
357 12 100       29 unless ($args{key}) {
358 3 100       97 croak("key or key_file required.") unless $args{key_file};
359 2 100       108 open my $fh, '<', $args{key_file} or die "Can't open file $!";
360 1         3 $args{key} = do { local $/; <$fh> };
  1         5  
  1         30  
361             }
362 10         29 $self->{key} = \$args{key};
363 10   100     51 $self->{timeout} = $args{timeout} || 30;
364 10   100     34 $self->{expiration} = $args{expiration} || 900;
365 10         57 $self->{$_} = $args{$_} for qw/ua curl scope jwt_payload/;
366 10         19 $self->{base_url} = "https://api.appstoreconnect.apple.com/v1/";
367              
368 10         32 return $self;
369             }
370              
371             sub get {
372 24     24 1 7557 my $self = shift;
373 24         51 my %args = @_;
374              
375 24         55 my $resp = $self->get_response(%args);
376              
377 23 100       1276 unless ($self->{curl}) {
378 22 100       55 die $resp->status_line unless $resp->is_success;
379 21         154 $resp = $resp->decoded_content;
380             }
381              
382 22 100       2325 return $args{raw} ? $resp : JSON::decode_json($resp);
383             }
384              
385             sub get_response {
386 24     24 1 35 my $self = shift;
387 24         41 my %args = @_;
388              
389 24 100       136 croak("url required") unless $args{url};
390 23 100       94 $args{url} = $self->{base_url}.$args{url} unless $args{url} =~ /^http/;
391              
392 23         42 my $jwt = $self->jwt;
393 23         57 my $url = _build_url(%args);
394              
395 23 100 100     95 unless ($self->{curl} || $self->{ua}) {
396 2         19 require LWP::UserAgent;
397             $self->{ua} = LWP::UserAgent->new(
398             agent => "libwww-perl Apple::AppStoreConnect/$VERSION",
399             timeout => $self->{timeout}
400 2         22 );
401             }
402              
403 23         5290 return _fetch($self->{ua}, $url, $jwt);
404             }
405              
406             sub get_apps {
407 7     7 1 12278 my $self = shift;
408 7         12 my %args = @_;
409 7 50       20 my %params = $args{params} ? %{$args{params}} : ();
  0         0  
410 7 100       16 $params{'filter[platform]'} = $args{platform} if $args{platform};
411 7 100       29 $args{params} = \%params if %params;
412              
413 7         16 $args{url} = $self->{base_url} . 'apps';
414 7 100       16 $args{url} .= "/$args{id}" if $args{id};
415 7 100       19 $args{url} .= "/$args{path}" if $args{path};
416 7         17 my $res = $self->get(%args);
417              
418 7         20 return _process_data($res);
419             }
420              
421             sub get_app_store_versions {
422 4     4 1 382 my $self = shift;
423 4         12 my %args = @_;
424              
425 4 100       114 croak("id required") unless $args{id};
426              
427 3 50       9 my %params = $args{params} ? %{$args{params}} : ();
  0         0  
428 3 100       8 $params{'filter[platform]'} = $args{platform} if $args{platform};
429 3   50     15 $params{limit} ||= 200;
430 3         4 my $localizations = $args{localizations};
431 3 100 50     9 $localizations ||= 1 if $args{localization_fields};
432              
433 3         14 my $versions = _flatten_resources($self->_get_all_pages(
434             url => "apps/$args{id}/appStoreVersions",
435             params => \%params,
436             ));
437              
438 3 50       11 if ($localizations) {
439 3         6 foreach my $version (@$versions) {
440 4         11 my %localization_params = (limit => 200);
441 4 100       9 $localization_params{'filter[locale]'} = $localizations
442             unless $localizations eq '1';
443             $localization_params{'fields[appStoreVersionLocalizations]'} = $args{localization_fields}
444 4 100       28 if $args{localization_fields};
445              
446 4         12 $version->{localizations} = _flatten_resources($self->_get_all_pages(
447             url => "appStoreVersions/$version->{id}/appStoreVersionLocalizations",
448             params => \%localization_params,
449             ));
450             }
451             }
452              
453 3         10 return $versions;
454             }
455              
456             sub get_beta_feedback_screenshot_submissions {
457 2     2 1 351 my $self = shift;
458 2         9 return $self->_get_beta_feedback_submissions(
459             resource => 'betaFeedbackScreenshotSubmissions',
460             @_,
461             );
462             }
463              
464             sub get_beta_feedback_crash_submissions {
465 2     2 1 347 my $self = shift;
466 2         7 my %args = @_;
467              
468 2 100       10 my %params = $args{params} ? %{$args{params}} : ();
  1         3  
469 2         4 my $crash_log_fields = $args{crash_log_fields};
470 2   66     13 $crash_log_fields ||= delete $params{'fields[betaCrashLogs]'};
471 2         4 $args{params} = \%params;
472              
473 2         7 my $crashes = $self->_get_beta_feedback_submissions(
474             resource => 'betaFeedbackCrashSubmissions',
475             %args,
476             );
477              
478 1 50       3 if ($args{crash_log}) {
479 1         3 foreach my $crash (@$crashes) {
480 1         2 my %params;
481 1 50       3 $params{'fields[betaCrashLogs]'} = $crash_log_fields
482             if $crash_log_fields;
483              
484 1         6 my $res = $self->get(
485             url => "betaFeedbackCrashSubmissions/$crash->{id}/crashLog",
486             params => \%params,
487             );
488 1         3 $crash->{crashLog} = _flatten_resource($res->{data});
489             }
490             }
491              
492 1         4 return $crashes;
493             }
494              
495             sub jwt {
496 26     26 1 7621 my $self = shift;
497 26         52 my %args = @_;
498              
499             # Return cached one
500             return $self->{jwt}
501 26 100 100     135 if !$args{exp} && $self->{jwt_exp} && $self->{jwt_exp} >= time() + 300;
      66        
502              
503 10         24 return $self->_new_jwt(%args);
504             }
505              
506             sub _new_jwt {
507 10     10   13 my $self = shift;
508 10         15 my %args = @_;
509              
510 10   66     40 $args{iat} ||= time();
511 10   66     38 $self->{jwt_exp} = $args{exp} || (time() + $self->{expiration});
512              
513             my $data = {
514             iss => $self->{issuer},
515             aud => "appstoreconnect-v1",
516             exp => $self->{jwt_exp},
517             iat => $args{iat},
518 10         36 };
519              
520 10 100       21 $data->{scope} = $self->{scope} if $self->{scope};
521             $data = {
522             %$data,
523 1         5 %{$self->{jwt_payload}}
524 10 100       32 } if $self->{jwt_payload};
525              
526             $self->{jwt} = encode_jwt(
527             payload => $data,
528             alg => 'ES256',
529             key => $self->{key},
530             extra_headers => {
531             kid => $self->{key_id},
532 10         64 typ => "JWT"
533             }
534             );
535              
536 10         86676 return $self->{jwt};
537             }
538              
539             sub _fetch {
540 23     23   38 my ($ua, $url, $jwt) = @_;
541              
542 23 100       44 return _curl($url, $jwt) unless $ua;
543              
544 22         93 return $ua->get($url, Authorization => "Bearer $jwt");
545             }
546              
547             sub _curl {
548 1     1   6 return `curl "$_[0]" -A "Curl Apple::AppStoreConnect/$VERSION" -s -H 'Authorization: Bearer $_[1]'`;
549             }
550              
551             sub _build_url {
552 26     26   7681 my %args = @_;
553 26         37 my $url = $args{url};
554 26 100       67 return $url unless ref($args{params});
555              
556 14         22 my $params = join("&", map {"$_=$args{params}->{$_}"} keys %{$args{params}});
  22         70  
  14         48  
557              
558 14 100       41 $url .= "?$params" if $params;
559              
560 14         85 return $url;
561             }
562              
563             sub _get_beta_feedback_submissions {
564 4     4   38 my $self = shift;
565 4         13 my %args = @_;
566              
567 4 100       195 croak("id required") unless $args{id};
568 2 50       5 croak("resource required") unless $args{resource};
569              
570 2 50       6 my %params = $args{params} ? %{$args{params}} : ();
  2         5  
571 2 100       6 $params{'filter[appPlatform]'} = $args{platform} if $args{platform};
572 2   100     12 $params{limit} ||= $args{limit} || 50;
      33        
573 2   50     12 $params{sort} ||= $args{sort} || '-createdDate';
      33        
574              
575 2         8 my $res = $self->get(
576             url => "apps/$args{id}/$args{resource}",
577             params => \%params,
578             );
579              
580 2         7 return _flatten_resources($res->{data});
581             }
582              
583             sub _get_all_pages {
584 7     7   11 my $self = shift;
585 7         14 my %args = @_;
586              
587 7         8 my @data;
588 7         16 while ($args{url}) {
589 8         18 my $res = $self->get(%args);
590 8         13 push @data, @{$res->{data}}
591 8 50 33     41 if ref($res) && ref($res->{data}) && ref($res->{data}) eq 'ARRAY';
      33        
592              
593             my $next = ref($res) && ref($res->{links}) eq 'HASH'
594             ? $res->{links}->{next}
595 8 100 66     25 : undef;
596 8 100       28 last unless $next;
597              
598 1         6 %args = (url => $next);
599             }
600              
601 7         18 return \@data;
602             }
603              
604             sub _flatten_resources {
605 10     10   24 my $data = shift;
606              
607 10 50 33     34 return $data unless ref($data) && ref($data) eq 'ARRAY';
608              
609             return [
610             map {
611 10         19 my $item = $_;
  11         14  
612             my $res = ref($item->{attributes}) eq 'HASH'
613 11 50       18 ? {%{$item->{attributes}}}
  11         31  
614             : {};
615 11 50       28 $res->{id} = $item->{id} if $item->{id};
616 11 50       24 $res->{type} = $item->{type} if $item->{type};
617 11         50 $res;
618             } @$data
619             ];
620             }
621              
622             sub _flatten_resource {
623 1     1   2 my $data = shift;
624 1         3 my $res = _flatten_resources([$data]);
625 1         6 return $res->[0];
626             }
627              
628             sub _process_data {
629 14     14   4262 my $hash = shift;
630 14 100 100     105 if (ref($hash) && ref($hash->{data}) && ref($hash->{data}) eq 'ARRAY') {
      66        
631 9         17 my $res;
632 9         10 foreach my $item (@{$hash->{data}}) {
  9         20  
633 9 100 100     31 if ($item->{id} && $item->{attributes}) {
634 7         10 $res->{$item->{id}} = {%{$item->{attributes}}};
  7         28  
635 7 50       24 $res->{$item->{id}}->{type} = $item->{type} if $item->{type};
636             }
637             }
638 9 100       40 return $res if $res;
639             }
640 7         17 return $hash;
641             }
642              
643             =head1 NOTES
644              
645             =head2 Apple Store Server API
646              
647             You can use this module with the L
648             by passing your app's bundle ID to the JWT payload. So there is just one addition to the constructor call:
649              
650             my $assa = Apple::AppStoreConnect->new(
651             issuer => $API_key_issuer,
652             key_id => $key_id,
653             key => $private_key,
654             jwt_payload => {bid => $bundle_id}
655             );
656              
657             You can then pass custon Store Server API requests:
658              
659             my $res = $assa->get(url => "https://api.storekit.itunes.apple.com/inApps/v2/history/$transactionId");
660              
661             =head2 POST/PATCH/DELETE requests
662              
663             Note that currently only GET requests are implemented, as that is what I needed.
664             However, POST/PATCH/DELETE can be added upon request.
665              
666             =head2 403 Unauthorized etc errors
667              
668             If you suddenly start getting unauthorized errors with a token that should be valid,
669             log onto App Store Connect and see if you have any documents pending approval (e.g
670             tax documents, new terms etc).
671              
672             =head1 AUTHOR
673              
674             Dimitrios Kechagias, L
675              
676             =head1 BUGS
677              
678             Please report any bugs or feature requests either on L (preferred), or on RT (via the email
679             C or L).
680              
681             I will be notified, and then you'll automatically be notified of progress on your bug as I make changes.
682              
683             =head1 GIT
684              
685             L
686              
687             =head1 LICENSE AND COPYRIGHT
688              
689             This software is copyright (c) 2023 by Dimitrios Kechagias.
690              
691             This is free software; you can redistribute it and/or modify it under
692             the same terms as the Perl 5 programming language system itself.
693              
694             =cut
695              
696             1;