File Coverage

blib/lib/WWW/Shopify.pm
Criterion Covered Total %
statement 16 18 88.8
branch n/a
condition n/a
subroutine 6 6 100.0
pod n/a
total 22 24 91.6


line stmt bran cond sub pod time code
1             #!/usr/bin/perl
2              
3             =head1 NAME
4              
5             WWW::Shopify - Main object representing acess to a particular Shopify store.
6              
7             =cut
8              
9             =head1 DISCLAIMER
10              
11             WWW::Shopify is my first official CPAN module, so please bear with me as I try to sort out all the bugs, and deal with the unfamiliar CPAN infrastructure. Don't expect this to work out of the box as of yet, I'm still learning exactly how things are working. Hence some version problems I've been having.
12              
13             Thanks for your understanding.
14              
15             =cut
16              
17             =head1 DESCRIPTION
18              
19             WWW::Shopify represents a way to grab and upload data to a particular shopify store.
20             All that's required is the access token for a particular app, its url, and the API key, or altenratively, if you have a private app, you can substitue the app password for the api key.
21             If you want to use make a private app, use WWW::Shopify::Private. If you want to make a public app, use WWW::Shopify::Public.
22              
23             =cut
24              
25             =head1 EXAMPLES
26              
27             In order to get a list of all products, we can do the following:
28              
29             # Here we instantiate a copy of the public API object, with all the necessary fields.
30             my $sa = new WWW::Shopify::Public($shop_url, $api_key, $access_token);
31              
32             # Here we call get_all, OO style, and specify the entity we want to get.
33             my @products = $sa->get_all('Product');
34              
35             In this way, we can get and modify all the different types of shopify stuffs.
36              
37             If you don't want to be using a public app, and just want to make a private app, it's just as easy:
38              
39             # Here we instantiate a copy of the private API object this time, which means we don't need an access token, we just need a password.
40             my $sa = new WWW::Shopify::Private($shop_url, $api_key, $password);
41             my @products = $sa->get_all('Product');
42              
43             Easy enough.
44              
45             To insert a Webhook, we'd do the following.
46              
47             my $webhook = new WWW::Shopify::Model::Webhook({topic => "orders/create", address => $URL, format => "json"});
48             $sa->create($Webhook);
49              
50             And that's all there is to it. To delete all the webhooks in a store, we'd do:
51              
52             $sa->delete($_) for ($sa->get_all('Webhook'));
53              
54             Very easy.
55              
56             If we want to do something like update an existing product, without getting it, you can simply create a wrapper object to pass to the sub. Let's update a product's title, if all we have
57             is the product ID.
58              
59             $sa->update(WWW::Shopify::Model::Product->new({ id => $product_id, title => "My New Title!" }));
60              
61             That'll update the product title.
62              
63             Now, for another example. Let's say we want to get all products that have the letter "A" in their title, and double the weight of all their variants (randomly). This is also very easy.
64              
65             my @products = $sa->get_all("Product");
66             for my $variant (map { $_->variants } grep { $_->title =~ m/A/ } @products) {
67             $variant->weight($variant->weight*2);
68             $sa->update($variant);
69             }
70              
71             =cut
72              
73 1     1   12404 use strict;
  1         2  
  1         21  
74 1     1   3 use warnings;
  1         1  
  1         19  
75 1     1   521 use LWP::UserAgent;
  1         30024  
  1         37  
76              
77             package WWW::Shopify;
78              
79             our $VERSION = '1.02';
80              
81 1     1   321 use WWW::Shopify::Exception;
  1         2  
  1         16  
82 1     1   349 use WWW::Shopify::Field;
  1         3  
  1         48  
83 1     1   232 use Module::Find;
  0            
  0            
84             use WWW::Shopify::URLHandler;
85             use WWW::Shopify::Query;
86             use WWW::Shopify::Login;
87              
88              
89             # Make sure we include all our models so that when people call the model, we actually know what they're talking about.
90             BEGIN { eval(join("\n", map { "require $_;" } findallmod WWW::Shopify::Model)); }
91              
92             package WWW::Shopify;
93              
94             use Date::Parse;
95              
96             =head1 METHODS
97              
98             =head2 new($shop_url, [$email, $pass])
99              
100             Creates a new shop, without using the actual API, uses automated form submission to log in.
101              
102             =cut
103              
104             sub new {
105             my ($package, $shop_url, $email, $password) = @_;
106             die new WWW::Shopify::Exception("Can't create a shop without a shop url.") unless $shop_url;
107             my $ua = LWP::UserAgent->new( ($^O eq' linux' ? (ssl_opts => {'SSL_version' => 'TLSv12' }) : ()) );
108             $ua->cookie_jar({ });
109             $ua->timeout(30);
110             $ua->agent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/48.0.2564.116 Safari/537.36");
111             $package = "WWW::Shopify::Login" if $package eq "WWW::Shopify";
112             my $self = bless { _shop_url => $shop_url, _ua => $ua, _url_handler => undef, _api_calls => 0, _sleep_for_limit => 0, _last_timestamp => undef }, $package;
113             $self->url_handler(new WWW::Shopify::URLHandler($self));
114             return $self;
115             }
116              
117              
118             sub api_calls { $_[0]->{_api_calls} = $_[1] if defined $_[1]; return $_[0]->{_api_calls}; }
119             sub url_handler { $_[0]->{_url_handler} = $_[1] if defined $_[1]; return $_[0]->{_url_handler}; }
120             sub sleep_for_limit { $_[0]->{_sleep_for_limit} = $_[1] if defined $_[1]; return $_[0]->{_sleep_for_limit}; }
121             sub last_timestamp { $_[0]->{_last_timestamp} = $_[1] if defined $_[1]; return $_[0]->{_last_timestamp}; }
122              
123             =head2 encode_url($url)
124              
125             Basic url encoding, works the same for public apps or logged-in apps.
126              
127             =cut
128              
129             sub encode_url { return "https://" . $_[0]->shop_url . $_[1]; }
130              
131              
132             =head2 ua([$new_ua])
133              
134             Gets/sets the user agent we're using to access shopify's api. By default we use LWP::UserAgent, with a timeout of 5 seconds.
135              
136             PLEASE NOTE: At the very least, with LWP::UserAgent, at least, on my system, I had to force the SSL layer of the agent to use TLSv12, using the line
137              
138             LWP::UserAgent->new( ssl_opts => { SSL_version => 'TLSv12' } );
139              
140             Otherwise, Shopify does some very weird stuff, and some very weird errors are spit out. Just FYI.
141              
142             =cut
143              
144             sub ua { $_[0]->{_ua} = $_[1] if defined $_[1]; return $_[0]->{_ua}; }
145              
146              
147             =head2 shop_url([$shop_url])
148              
149             Gets/sets the shop url that we're going to be making calls to.
150              
151             =cut
152              
153             # Modifiable Attributes
154             sub shop_url { $_[0]->{_shop_url} = $_[1] if defined $_[1]; return $_[0]->{_shop_url}; }
155              
156             sub translate_model($) {
157             return $_[1] if $_[1] =~ m/WWW::Shopify::Model/;
158             return "WWW::Shopify::Model::" . $_[1];
159             }
160              
161             sub PULLING_ITEM_LIMIT { return 250; }
162             #sub CALL_LIMIT_REFRESH { return 60*5; }
163             #sub CALL_LIMIT_MAX { return 500; }
164             sub CALL_LIMIT_MAX { return 40; }
165             sub CALL_LIMIT_LEAK_TIME { return 1; }
166             sub CALL_LIMIT_LEAK_RATE { return 2; }
167              
168             sub get_url { return $_[0]->url_handler->get_url($_[1], $_[2], $_[3], $_[4], $_[5]); }
169             sub post_url { return $_[0]->url_handler->post_url($_[1], $_[2], $_[3], $_[4], $_[5]); }
170             sub put_url { return $_[0]->url_handler->put_url($_[1], $_[2], $_[3], $_[4], $_[5]); }
171             sub delete_url { return $_[0]->url_handler->delete_url($_[1], $_[2], $_[3], $_[4], $_[5]); }
172              
173             use Data::Dumper;
174             sub use_url {
175             my ($self, $type, $url, @args) = @_;
176             my $method = lc($type) . "_url";
177             my ($decoded, $response);
178             $url = $self->encode_url($url);
179             eval {
180             if ($self->sleep_for_limit) {
181             do {
182             eval { ($decoded, $response) = $self->$method($url, @args); };
183             if (my $exp = $@) {
184             die $exp if !ref($exp) || ref($exp) ne 'WWW::Shopify::Exception::CallLimit';
185             sleep(1);
186             }
187             } while (!$response);
188             } else {
189             ($decoded, $response) = $self->$method($url, @args);
190             }
191             };
192             if (my $exp = $@) {
193             print STDERR Dumper($exp->error) if $ENV{'SHOPIFY_LOG'} && $ENV{'SHOPIFY_LOG'} > 1;
194             die $exp;
195             }
196             print STDERR uc($type) . " " . $response->request->uri . "\n" if $ENV{'SHOPIFY_LOG'} && $ENV{'SHOPIFY_LOG'} == 1;
197             print STDERR Dumper($response) if $ENV{'SHOPIFY_LOG'} && $ENV{'SHOPIFY_LOG'} > 1;
198             $self->last_timestamp(DateTime->from_epoch( epoch => str2time($response->header('Date'))) ) if $response && $response->header('Date');
199             return ($decoded, $response);
200             }
201              
202             use Devel::StackTrace;
203             sub resolve_trailing_url {
204             my ($self, $package, $action, $parent, $specs) = @_;
205             $package = ref($package) if ref($package);
206             my $method = lc($action) . "_through_parent";
207             if ($package->$method && (!$parent || !$parent->is_shop || $package ne "WWW::Shopify::Model::Metafield")) {
208             die new WWW::Shopify::Exception("Cannot get, no parent specified.") unless $parent;
209             if ($package eq "WWW::Shopify::Model::Metafield" && ref($parent) eq 'WWW::Shopify::Model::Product::Image' && $specs) {
210             $specs->{"metafield[owner_id]"} = $parent->id;
211             $specs->{"metafield[owner_resource]"} = "product_image";
212             return "/admin/" . $package->url_plural;
213             }
214             # Should be made more generic when I'm sure this won't mess up any other of Shopfy's crazy API.
215             if ($package eq 'WWW::Shopify::Model::Order::Fulfillment::FulfillmentEvent') {
216             return '/admin/' . $parent->associated_parent->url_plural . '/' . $parent->associated_parent->id . '/' . $parent->url_plural . '/' . $parent->id . '/' . $package->url_plural;
217             }
218             return "/admin/" . $parent->url_plural . "/" . $parent->id . "/" . $package->url_plural;
219             }
220             return "/admin/" . $package->url_plural;
221             }
222              
223             sub get_all_limit {
224             my ($self, $package, $specs) = @_;
225             $package = $self->translate_model($package);
226             $specs->{"limit"} = $package->max_per_page unless exists $specs->{"limit"};
227             return () if ($specs->{limit} == 0);
228             return $self->get_shop if $package->is_shop;
229             my $url = $self->resolve_trailing_url($package, "get", $specs->{parent}, $specs) . ".json";
230             my ($decoded, $response) = $self->use_url('get', $url, $specs);
231             my @return = map { my $object = $package->from_json($_, $self); $object->associated_parent($specs->{parent}); $object; } @{$decoded->{$package->plural}};
232             return @return;
233             }
234              
235             =head2 get_all($self, $package, $filters)
236              
237             Gets up to 249 * CALL_LIMIT objects (currently 124750) from Shopify at once. Goes in a loop until it's got everything. Performs a count first to see where it's at.
238              
239             @products = $sa->get_all("Product")
240              
241             If you don't want this behaviour, use the limit filter.
242              
243             =cut
244              
245             use POSIX qw/ceil/;
246             use List::Util qw(min);
247             sub get_all {
248             my ($self, $package, $specs) = @_;
249             # We copy our specs so that we don't modify the original hash. Doesn't have to be a deep copy.
250             $specs = {%$specs} if $specs;
251             $package = $self->translate_model($package);
252             $self->validate_item($package);
253             return $self->get_shop if $package->is_shop;
254            
255             my $limit = $specs->{limit};
256             $specs->{limit} = defined $limit && $limit < $package->max_per_page ? $limit : $package->max_per_page;
257            
258             my @return;
259             my $page = $specs->{page};
260             eval {
261             $specs->{page} = $specs->{page} ? $specs->{page} : 1;
262             my @chunk;
263             do {
264             @chunk = $self->get_all_limit($package, $specs);
265             if (!defined $limit || (int(@chunk) + int(@return) < $limit)) {
266             push(@return, @chunk);
267             } else {
268             push(@return, grep { defined $_ } @chunk[0..($limit - int(@return) - 1)]);
269             }
270             $specs->{page}++;
271             } while (!defined $page && int(@chunk) == $specs->{limit} && (!defined $limit || int(@return) < $limit));
272             };
273             if (my $exception = $@) {
274             $exception->extra(\@return) if ref($exception) && $exception->isa('WWW::Shopify::Exception::CallLimit');
275             die $exception;
276             }
277             return @return if wantarray;
278             return $return[0];
279             }
280              
281             =head2 get_shop($self)
282              
283             Returns the actual shop object.
284              
285             my $shop = $sa->get_shop;
286              
287             =cut
288              
289             sub get_shop {
290             my ($self) = @_;
291             my $package = 'WWW::Shopify::Model::Shop';
292             my ($decoded, $response) = $self->use_url('get', "/admin/" . $package->singular() . ".json");
293             my $object = $package->from_json($decoded->{$package->singular()}, $self);
294             return $object;
295             }
296              
297             =head2 get_timestamp($self)
298              
299             Uses a call to Shopify to determine the DateTime on the shopify server. This can be used to synchronize things without worrying about the
300             local clock being out of sync with Shopify.
301              
302             =cut
303              
304             sub get_timestamp {
305             my ($self) = @_;
306             my $ua = $self->ua;
307             my ($decoded, $response) = $self->use_url('get', "/admin/shop.json");
308             my $date = $response->header('Date');
309             my $time = str2time($date);
310             return DateTime->from_epoch( epoch => $time );
311             }
312              
313             =head2 get_count($self, $package, $filters)
314              
315             Gets the item count from the shopify store. So if we wanted to count all our orders, we'd do:
316              
317             my $order = $sa->get_count('Order', { status => "any" });
318              
319             It's as easy as that. Keep in mind not all items are countable (who the hell knows why); a glaring exception is assets. Either check the shopify docs, or grep for the sub "countable".
320              
321             =cut
322              
323             sub get_count {
324             my ($self, $package, $specs) = @_;
325             $package = $self->translate_model($package);
326             $self->validate_item($package);
327             # If it's not countable (sigh), do a binary search to figure out what the count is. Should find it in ln(n), as opposed to n/250
328             # This is generally better for stores where this could become an issue.
329             if (!$package->countable) {
330             my $limit = $specs->{limit} || $package->max_per_page;
331             my ($lowest_no_items, $highest_items);
332             my $page = 1;
333             my @items;
334             while (int(@items) == $limit || int(@items) == 0) {
335             @items = $self->get_all_limit($package, { %$specs, limit => $limit, page => $page });
336             return 0 if int(@items) == 0 && $page == 1;
337             if (int(@items) == 0) {
338             $lowest_no_items = $page if !defined $lowest_no_items || $page < $lowest_no_items;
339             # We need to go down.
340             my $differential = int(($highest_items - $page)/2);
341             return ($page-1)*$limit if $differential == 0;
342             $page = $differential + $page;
343             } elsif (int(@items) == $limit) {
344             $highest_items = $page if !defined $highest_items || $page > $highest_items;
345             # We need to go up.
346             if (!defined $lowest_no_items) {
347             $page *= 2;
348             } else {
349             my $differential = int(($lowest_no_items - $page)/2);
350             return $page*$limit if $differential == 0;
351             $page = $differential + $page;
352             }
353             }
354             }
355             return ($page-1)*$limit + int(@items);
356             }
357             my ($decoded, $response) = $self->use_url('get', $self->resolve_trailing_url($package, "get", $specs->{parent}, $specs) . "/count.json", $specs);
358             return $decoded->{'count'};
359             }
360              
361             =head2 get($self, $package, $id)
362              
363             Gets the item from the shopify store. Returns it in local (classed up) form. In order to get an order for example:
364              
365             my $order = $sa->get('Order', 142345);
366              
367             It's as easy as that. If we don't retrieve anything, we return undef.
368              
369             =cut
370              
371             sub get {
372             my ($self, $package, $id, $specs) = @_;
373             $package = $self->translate_model($package);
374             $self->validate_item($package);
375             # We have a special case for asssets, for some arbitrary reason.
376             my ($decoded, $response);
377             eval {
378             if ($package !~ m/Asset/) {
379             ($decoded, $response) = $self->use_url('get', $self->resolve_trailing_url($package, "get", $specs->{parent}) . "/$id.json");
380             } else {
381             die new WWW::Shopify::Exception("MUST have a parent with assets.") unless $specs->{parent};
382             ($decoded, $response) = $self->use_url('get', "/admin/themes/" . $specs->{parent}->id . "/assets.json", {'asset[key]' => $id, theme_id => $specs->{parent}->id});
383             }
384             };
385             if (my $exp = $@) {
386             return undef if ref($exp) && $exp->isa("WWW::Shopify::Exception::NotFound");
387             die $exp;
388             }
389             my $class = $package->from_json($decoded->{$package->singular()}, $self);
390             # Wow, this is straight up stupid that sometimes we don't get a 404.
391             return undef unless $class;
392             $class->associated_parent($specs->{parent});
393             return $class;
394             }
395              
396             =head2 search($self, $package, $item, { query => $query })
397              
398             Searches for the item from the shopify store. Not all items are searchable, check the API docs, or grep this module's source code and look for the "searchable" sub.
399              
400             A popular thing to search for is customers by email, you can do so like the following:
401              
402             my $customer = $sa->search("Customer", { query => "email:me@example.com" });
403              
404             =cut
405              
406             sub search {
407             my ($self, $package, $specs) = @_;
408             $package = $self->translate_model($package);
409             die new WWW::Shopify::Exception("Unable to search $package; it is not marked as searchable in Shopify's API.") unless $package->searchable;
410             die new WWW::Shopify::Exception("Must have a query to search.") unless $specs && $specs->{query};
411             $self->validate_item($package);
412              
413             my ($decoded, $response) = $self->use_url('get', $self->resolve_trailing_url($package, "get", $specs->{parent}) . "/search.json", $specs);
414              
415             my @return = ();
416             foreach my $element (@{$decoded->{$package->plural()}}) {
417             my $class = $package->from_json($element, $self);
418             $class->associated_parent($specs->{parent}) if $specs->{parent};
419             push(@return, $class);
420             }
421             return @return if wantarray;
422             return $return[0] if int(@return) > 0;
423             return undef;
424             }
425              
426             =head2 create($self, $item)
427              
428             Creates the item on the shopify store. Not all items are creatable, check the API docs, or grep this module's source code and look for the "creatable" sub.
429              
430             =cut
431              
432             use List::Util qw(first);
433             use HTTP::Request::Common;
434             sub create {
435             my ($self, $item, $options) = @_;
436            
437             $self->validate_item(ref($item));
438             my $specs = {};
439             my $missing = first { !exists $item->{$_} } $item->creation_minimal;
440             die new WWW::Shopify::Exception("Missing minimal creation member: $missing in " . ref($item)) if $missing;
441             die new WWW::Shopify::Exception(ref($item) . " requires you to login with an admin account.") if ($item->needs_login && !$item->needs_plus) && !$self->logged_in_admin;
442             $specs = $item->to_json();
443             my ($decoded, $response) = $self->use_url($item->create_method, $self->resolve_trailing_url(ref($item), "create", $item->associated_parent) . ".json", {$item->singular() => $specs}, $item->needs_login);
444             my $element = $decoded->{$item->singular};
445             my $object = ref($item)->from_json($element, $self);
446             $object->associated_parent($item->associated_parent);
447             return $object;
448             }
449              
450             =head2 update($self, $item)
451              
452             Updates the item from the shopify store. Not all items are updatable, check the API docs, or grep this module's source code and look for the "updatable" sub.
453              
454             =cut
455              
456             sub update {
457             my ($self, $class) = @_;
458             $self->validate_item(ref($class));
459             my %mods = map { $_ => 1 } $class->update_fields;
460             my $vars = $class->to_json();
461             $vars = { $class->singular => {map { $_ => $vars->{$_} } grep { exists $mods{$_} } keys(%$vars)} };
462              
463             my ($decoded, $response);
464             if (ref($class) =~ m/Asset/) {
465             my $url = $self->resolve_trailing_url(ref($class), "update", $class->associated_parent) . ".json";
466             ($decoded, $response) = $self->use_url($class->update_method, $url, $vars);
467             }
468             else {
469             ($decoded, $response) = $self->use_url($class->update_method, $self->resolve_trailing_url($class, "update", $class->associated_parent) . "/" . $class->id . ".json", $vars);
470             }
471              
472             my $element = $decoded->{$class->singular()};
473             my $object = ref($class)->from_json($element, $self);
474             $object->associated_parent($class->associated_parent);
475             return $object;
476             }
477              
478             =head2 delete($self, $item)
479              
480             Deletes the item from the shopify store. Not all items are deletable, check the API docs, or grep this module's source code and look for the "deletable" sub.
481              
482             =cut
483              
484             sub delete {
485             my ($self, $class) = @_;
486             $self->validate_item(ref($class));
487             if (ref($class) =~ m/Asset/) {
488             my $url = $self->resolve_trailing_url(ref($class), "delete", $class->associated_parent) . ".json?asset[key]=" . $class->key;
489             $self->use_url($class->delete_method, $url);
490             }
491             else {
492             $self->use_url($class->delete_method, $self->resolve_trailing_url($class, "delete", $class->associated_parent) . "/" . $class->id . ".json");
493             }
494             return 1;
495             }
496              
497             # For simple things like activating, enabling, disabling, that are a simple post to a custom URL.
498             # Sometimes returns an object, sometimes returns a 1.
499             use List::Util qw(first);
500             sub custom_action {
501             my ($self, $object, $action) = @_;
502             die new WWW::Shopify::Exception("You can't $action " . $object->plural . ".") unless defined $object && first { $_ eq $action } $object->actions;
503             my $id = $object->id;
504             my $url = $self->resolve_trailing_url($object, $action, $object->associated_parent) . "/$id/$action.json";
505             my ($decoded, $response) = $self->use_url('post', $url, {$object->singular() => $object->to_json});
506             return 1 if !$decoded;
507             my $element = $decoded->{$object->singular()};
508             if ($element) {
509             $object = ref($object)->from_json($element, $self);
510             return $object;
511             } else {
512             return $decoded;
513             }
514             }
515              
516             =head2 activate($self, $charge), disable($self, $discount), enable($self, $discount), open($self, $order), close($self, $order), cancel($self, $order)
517              
518             Special actions that do what they say.
519              
520             =cut
521              
522             sub activate { return $_[0]->custom_action($_[1], "activate"); }
523             sub disable { return $_[0]->custom_action($_[1], "disable"); }
524             sub enable { return $_[0]->custom_action($_[1], "enable"); }
525             sub open { return $_[0]->custom_action($_[1], "open"); }
526             sub close { return $_[0]->custom_action($_[1], "close"); }
527             sub cancel { return $_[0]->custom_action($_[1], "cancel"); }
528             sub approve { return $_[0]->custom_action($_[1], "approve"); }
529             sub remove { return $_[0]->custom_action($_[1], "remove"); }
530             sub spam { return $_[0]->custom_action($_[1], "spam"); }
531             sub not_spam { return $_[0]->custom_action($_[1], "not_spam"); }
532             sub account_activation_url { return $_[0]->custom_action($_[1], "account_activation_url"); }
533              
534              
535             sub is_valid { eval { $_[0]->get_shop; }; return undef if ($@); return 1; }
536             sub handleize {
537             my ($self, $handle) = @_;
538             $handle = $self if !ref($self);
539             $handle = lc($handle);
540             $handle =~ s/\s/-/g;
541             $handle =~ s/[^a-z0-9\-]//g;
542             $handle =~ s/\-+/-/g;
543             return $handle;
544             }
545              
546              
547             =head2 create_private_app()
548              
549             Automates a form submission to generate a private app. Returns a WWW::Shopify::Private with the appropriate credentials. Must be logged in.
550              
551             =cut
552              
553             use WWW::Shopify::Private;
554             use List::Util qw(first);
555             sub create_private_app {
556             my ($self) = @_;
557             my $app = $self->create(new WWW::Shopify::Model::APIClient({}));
558             my @permissions = $self->get_all("APIPermission");
559             my $permission = first { $_->api_client->api_key eq $app->api_key } @permissions;
560             return new WWW::Shopify::Private($self->shop_url, $app->api_key, $permission->access_token);
561             }
562              
563              
564             =head2 delete_private_app($private_api)
565              
566             Removes a private app. Must be logged in.
567              
568             =cut
569              
570             sub delete_private_app {
571             my ($self, $api) = @_;
572             my @apps = $self->get_all("APIPermission");
573             my $app = first { $_->api_client && $_->api_client->api_key eq $api->api_key } @apps;
574             die new WWW::Shopify::Exception("Can't find app with api key " . $api->api_key) unless $app;
575             return $self->delete(new WWW::Shopify::Model::APIClient({ id => $app->api_client->id }));
576             }
577              
578              
579             # Internal methods.
580             sub validate_item {
581             eval { die unless $_[1]; $_[1]->is_item; };
582             die new WWW::Shopify::Exception($_[1] . " is not an item.") if ($@);
583             die new WWW::Shopify::Exception($_[1] . " requires you to login with an admin account.") if ($_[1]->needs_login && !$_[1]->needs_plus) && !$_[0]->logged_in_admin;
584             }
585              
586              
587             =head2 upload_files($self, @image_paths)
588              
589             Requires log in. Uploads an array of files/images into the shop's non-theme file/image management system by automating a form submission.
590              
591             $sa->login_admin("email", "password");
592             $sa->upload_files("image1.jpg", "image2.jpg");
593              
594             Gets around the issue that this is not actually exposed to the API.
595              
596             =cut
597              
598             use JSON qw(decode_json);
599              
600             sub upload_files {
601             my ($self, @images) = @_;
602             die new WWW::Shopify::Exception("Uploading files/images requires you to login with an admin account.") unless $self->logged_in_admin;
603             my @returns;
604             foreach my $path (@images) {
605             die new WWW::Shopify::Exception("Unable to determine extension type.") unless $path =~ m/\.(\w{2,4})$/;
606             my $req = POST "https://" . $self->shop_url . "/admin/settings/files.json",
607             Content_Type => "form-data",
608             Accept => "*/*",
609             Content => [authenticity_token => $self->{authenticity_token}, "file[file]" => [$path]];
610             my $res = $self->ua->request($req);
611             print STDERR Dumper($res) if $ENV{'SHOPIFY_LOG'} && $ENV{'SHOPIFY_LOG'} == 2;
612             die new WWW::Shopify::Exception("Error uploading $path.") unless $res->is_success;
613             push(@returns, WWW::Shopify::Model::File->from_json(decode_json($res->decoded_content)->{file}));
614             }
615             return @returns;
616             }
617              
618             =cut
619              
620             =head1 EXPORTED FUNCTIONS
621              
622             The functions below are exported as part of the package.
623              
624             =cut
625              
626             =head2 calc_webhook_signature($shared_secret, $request_body)
627              
628             Calculates the webhook_signature based off the shared secret and request body passed in.
629              
630             =cut
631              
632             =head2 verify_webhook($shared_secret, $request_body)
633              
634             Shopify webhook authentication. ALMOST the same as login authentication, but, of course, because this is shopify they've got a different system. 'Cause you know, one's not good enough.
635              
636             Follows this: http://wiki.shopify.com/Verifying_Webhooks.
637              
638             =cut
639              
640             use Exporter 'import';
641             our @EXPORT_OK = qw(verify_webhook verify_login verify_proxy calc_webhook_signature calc_login_signature calc_hmac_login_signature calc_proxy_signature handleize);
642             use Digest::MD5 'md5_hex';
643             use Digest::SHA qw(hmac_sha256_hex hmac_sha256_base64);
644             use MIME::Base64;
645              
646             sub calc_webhook_signature {
647             my ($shared_secret, $request_body) = @_;
648             my $calc_signature = hmac_sha256_base64((defined $request_body) ? $request_body : "", $shared_secret);
649             while (length($calc_signature) % 4) { $calc_signature .= '='; }
650             return $calc_signature;
651             }
652              
653             sub verify_webhook {
654             my ($x_shopify_hmac_sha256, $request_body, $shared_secret) = @_;
655             return undef unless $x_shopify_hmac_sha256;
656             return $x_shopify_hmac_sha256 eq calc_webhook_signature($shared_secret, $request_body);
657             }
658              
659             =head2 calc_login_signature($shared_secret, $%params)
660              
661             Calculates the MD5 login signature based on the shared secret and parmaeter hash passed in. This is deprecated.
662              
663             =cut
664              
665             =head2 calc_hmac_login_signature($shared_secret, $%params)
666              
667             Calculates the SHA256 login signature based on the shared secret and parmaeter hash passed in.
668              
669             =cut
670              
671             =head2 verify_login($shared_secret, $%params)
672              
673             Shopify app dashboard verification (when someone clicks Login on the app dashboard).
674              
675             This one was kinda random, 'cause they say it's like a webhook, but it's actually like legacy auth.
676              
677             Also, they don't have a code parameter. For whatever reason.
678              
679             =cut
680              
681             sub calc_login_signature {
682             my ($shared_secret, $params) = @_;
683             return md5_hex($shared_secret . join("", map { "$_=" . $params->{$_} } (sort(grep { $_ ne "signature" } keys(%$params)))));
684             }
685              
686             sub calc_hmac_login_signature {
687             my ($shared_secret, $params) = @_;
688             return hmac_sha256_hex(join("&", map { "$_=" . $params->{$_} } (sort(grep { $_ ne "hmac" && $_ ne "signature" } keys(%$params)))), $shared_secret);
689             }
690              
691             sub verify_login {
692             my ($shared_secret, $params) = @_;
693             return undef unless $params->{hmac};
694             return calc_hmac_login_signature($shared_secret, $params) eq $params->{hmac};
695             }
696              
697             =head2 calc_proxy_signature($shared_secret, $%params)
698              
699             Based on shared secret/hash of parameters passed in, calculates the proxy signature.
700              
701             =cut
702              
703             =head2 verify_proxy($shared_secret, %$params)
704              
705             This is SLIGHTLY different from the above two. For, as far as I can tell, no reason.
706              
707             =cut
708              
709             sub calc_proxy_signature {
710             my ($shared_secret, $params) = @_;
711             return hmac_sha256_hex(join("", sort(map {
712             my $p = $params->{$_};
713             "$_=" . (ref($p) eq "ARRAY" ? join("$_=", @$p) : $p);
714             } (grep { $_ ne "signature" } keys(%$params)))), $shared_secret);
715             }
716              
717             sub verify_proxy {
718             my ($shared_secret, $params) = @_;
719             return undef unless $params->{signature};
720             return calc_proxy_signature($shared_secret, $params) eq $params->{signature};
721             }
722              
723             =head1 SEE ALSO
724              
725             L, L, L, L, L
726              
727             =head1 AUTHOR
728              
729             Adam Harrison (adamdharrison@gmail.com)
730              
731             =head1 LICENSE
732              
733             Copyright (C) 2016 Adam Harrison
734              
735             Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
736              
737             The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
738              
739             THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
740              
741             =cut
742              
743             1;